API 응답 불일치를 undefined로 다루는 예외 처리 설계

2026-07-17

API 응답 불일치를 undefined로 다루는 예외 처리 설계

2025년 5월, API 명세와 실제 응답의 차이로 런타임 오류가 발생했고
팀에서 적용한 예외 처리 구조를 다시 복기하며 정리한 글입니다.

TypeScript로 API 응답 타입을 정의하면 개발 단계에서는 해당 데이터가 타입에 맞게 들어온다고 생각하기 쉽다.

하지만 TypeScript의 타입은 컴파일 과정에서 제거된다. API 함수의 반환 타입을 정확하게 작성했더라도, 실제 서버 응답이 그 타입과 일치하는지까지 보장해주지는 않는다.

실제 운영에서는 다음과 같은 상황이 발생할 수 있다.

  • 명세에는 존재하는 key가 응답에서 누락 혹은 key명이 변경됨
  • 명세에서 정의한 값과 다른 타입이 반환됨(e.g. string -> null or undefined)
  • 일부 필드의 오류로 인해 화면 전체에서 런타임 오류가 발생함

우리 팀에서는 이 문제를 줄이기 위해 다음과 같은 구조를 적용했다.

  1. API 응답을 런타임에서 검증한다.
  2. 명세와 다른 값은 undefined로 변경한다.
  3. 클라이언트에서 사용하는 타입에도 undefined 가능성을 반영한다.
  4. 서비스와 UI에서 값이 없을 때의 동작을 명시적으로 처리한다.
  5. 검증 오류를 빠르게 확인할 수 있도록 디버깅 정보를 남긴다.

이 구조는 팀에서 공유된 설계를 구성원들이 함께 적용한 결과물이다. 내가 처음부터 로직을 제안하거나 주도한 것은 아니다.

처음에는 모든 값에 undefined 가능성을 포함하는 방식이 지나치게 방어적이라고 생각해 반대했다. 하지만 실제로 적용하면서 예외 처리를 개발자의 주의에만 맡기지 않고, 타입 오류를 통해 누락을 확인할 수 있다는 점에서 적용할 가치가 있었다고 생각하게 됐다.

이 글에서는 구현 코드 자체를 세세하게 분석하기보다, 어떤 문제를 해결하기 위해 이 구조를 선택했고 각 레이어의 책임을 어떻게 나눴는지를 중심으로 정리한다.


문제 상황

다음과 같은 서버 응답 타입이 있다고 가정해보자.

type InfoServerModel = {
  infos: {
    id: string;
  }[];
  account: {
    name: string;
  };
};

TypeScript 기준으로 infos는 배열이고 account는 항상 존재한다.

따라서 다음 코드는 타입 오류 없이 작성할 수 있다.

useQuery({
  queryFn: getInfoAPI,
  select: (data) => ({
    selectedInfo: data.infos.find(({ id }) => id === infoId),
    filteredInfos: data.infos.filter(({ id }) => id === infoId),
    infoIds: data.infos.map(({ id }) => id),
    accountName: data.account.name,
  }),
});

하지만 실제 응답이 명세와 다르면 문제가 발생한다.

const data = {
  infos: undefined,
  account: null,
};

이때 다음 코드는 모두 런타임 오류가 발생할 수 있다.

data.infos.find(...);
data.infos.filter(...);
data.infos.map(...);
data.account.name;

API 함수에 제네릭을 지정하더라도 상황은 달라지지 않는다.

const { data } = await ax.get<InfoServerModel>("/infos");
 
return data;

InfoServerModel은 실제 응답을 검사한 결과가 아니다.

개발자가 해당 응답을 InfoServerModel로 사용하겠다고 TypeScript에 알려준 것에 가깝다. 서버가 다른 값을 반환하더라도 컴파일 단계에서는 이를 알 수 없다.

결과적으로 명세와 실제 응답의 차이가 운영 환경의 런타임 오류로 이어지고, 사용자가 정상적으로 화면을 이용하지 못하는 문제가 발생했다.

이 글에서 다루는 API 예외 처리는 네트워크 오류나 HTTP 상태 코드 오류가 아니라,
요청은 성공했지만 응답 데이터의 구조나 값이 명세와 다른 상황을 대상으로 한다.


기존 방식의 한계

1. TypeScript 타입만 신뢰하는 방식

const { data } = await ax.get<InfoServerModel>("/infos");
 
return data;

타입이 선언돼 있다는 이유만으로 실제 응답까지 안전해지는 것은 아니다.

TypeScript는 정적 타입 검사 도구이므로 외부에서 들어오는 값을 런타임에 직접 검증하지 않는다.

따라서 API, 로컬 스토리지, 사용자 입력처럼 애플리케이션 외부에서 들어오는 데이터는 별도의 검증 없이 신뢰하기 어렵다.


2. 필요한 위치마다 optional chaining을 사용하는 방식

가장 간단한 방어 방법은 데이터를 사용하는 위치마다 optional chaining과 기본값을 추가하는 것이다.

const accountName = data?.account?.name ?? "-";
const infoIds = data?.infos?.map(({ id }) => id) ?? [];

이 방식은 해당 코드의 런타임 오류를 막는 데는 도움이 된다.

하지만 모든 개발자가 모든 사용처에서 예외 처리를 빠짐없이 작성해야 한다.

const accountName = data.account.name;

한 곳에서라도 방어 코드를 누락하면 동일한 문제가 다시 발생한다.

결국 예외 처리의 일관성이 개발자의 기억과 코드 리뷰에 의존하게 된다.


3. 모든 값에 임의의 기본값을 넣는 방식

API 응답을 받은 직후 빈 문자열이나 빈 배열을 넣는 방법도 생각할 수 있다.

const infos = data.infos ?? [];
const accountName = data.account?.name ?? "";

텍스트나 목록을 보여주는 화면에서는 적절할 수 있다.

하지만 모든 필드에 안전한 기본값이 존재하는 것은 아니다.

const id = data.id ?? "";

id가 없을 때 빈 문자열을 넣으면 프로퍼티 접근 오류는 막을 수 있다. 그러나 이 값을 API 요청에 사용하면 잘못된 요청이 발생할 수 있다.

updateInfo({
  id: "",
});

값이 없다는 사실을 임의의 기본값으로 숨기면 오류가 사라지는 것이 아니라 다른 위치로 이동할 수 있다.


4. ServerModel을 UI에서 그대로 사용하는 방식

서버가 반환하는 구조와 화면에서 사용하기 좋은 구조는 항상 같지 않다.

type UserServerModel = {
  first_name: string;
  last_name: string;
};

UI에서는 다음과 같은 모델이 더 적합할 수 있다.

type UserClientModel = {
  fullName: string;
};

서버 응답 모델을 UI 전반에서 직접 사용하면 다음 문제가 생길 수 있다.

  • 서버 key가 바뀔 때 화면 코드까지 함께 수정해야 함
  • 프론트엔드가 API 연동보다 먼저 작업할 때 임시 데이터와 실제 응답의 차이를 조정하기 어려움
  • API 응답 구조와 UI 요구사항이 서로 강하게 결합됨
  • API 변경 때마다 프론트엔드 수정 및 재배포 범위가 커짐

따라서 서버 명세를 표현하는 모델과 프론트엔드에서 사용하는 모델을 분리할 필요가 있었다.


요구사항 정리

팀에서 정리한 요구사항은 크게 네 가지였다.

1. 일부 데이터 오류가 화면 전체 장애로 이어지지 않아야 한다

응답의 일부 필드가 잘못됐다는 이유로 사용자가 서비스 전체를 이용하지 못하는 상황을 줄여야 했다.

검증에 실패한 응답을 무조건 버리기보다, 사용할 수 있는 데이터는 유지하고 잘못된 값만 별도의 상태로 전환하는 방향이 필요했다.

2. 예외 처리 누락을 TypeScript 오류로 확인할 수 있어야 한다

예외 처리를 권장 사항으로만 두면 개발자가 누락할 수 있다.

따라서 값이 실제로 undefined가 될 수 있다는 사실을 타입에도 반영해, 예외 처리를 하지 않은 코드에서 TypeScript 오류가 발생하도록 해야 했다.

type Menu = {
  id: string | undefined;
};
 
updateMenu({
  id: menu.id,
});

API가 string만 받는다면 위 코드에서는 다음과 같은 타입 오류가 발생한다.

string | undefined 형식은 string 형식에 할당할 수 없습니다.

런타임에서 발생할 수 있는 문제를 개발 단계로 앞당겨 확인하는 것이 목적이었다.

3. ServerModel과 ClientModel을 분리해야 한다

ServerModel은 API 명세를 표현하고, ClientModel은 프론트엔드가 화면에서 사용하기 좋은 구조를 표현하도록 책임을 나눴다.

이를 통해 API 응답 구조가 UI 전체에 직접 전파되는 것을 줄이고, useQueryselect 단계에서 화면에 필요한 형태로 변환할 수 있도록 했다.

4. 운영 중 발생한 응답 불일치를 빠르게 확인할 수 있어야 한다

잘못된 값을 undefined로 바꾸면 화면의 런타임 오류는 줄어들 수 있다.

반대로 서버 응답 문제가 조용히 묻힐 위험도 있다.

따라서 다음 정보를 확인할 수 있는 디버깅 수단이 필요했다.

  • HTTP method
  • API path
  • 오류가 발생한 필드 경로
  • 기대한 타입
  • 실제 반환된 값

프로젝트에서는 개발 환경과 운영 환경에 맞춰 오류 정보를 콘솔, 컴포넌트 또는 알림으로 확인하는 방식을 적용했다.


해결 전략

전체 데이터 흐름은 다음과 같이 구성했다.

API 응답

ServerModel 기준 런타임 검증

명세와 다른 필드를 undefined로 변경

ClientModel로 포매팅

각 UI에서 기능에 맞게 예외 처리

각 레이어의 책임은 다음과 같다.

API 레이어

  • 실제 서버 응답을 받는다.
  • ServerModel과 일치하는지 런타임에서 검사한다.
  • 잘못된 값의 경로를 추출한다.
  • 문제가 발생한 값을 undefined로 변경한다.
  • 검증 오류 정보를 디버깅 도구에 전달한다.

서비스 레이어

  • useQueryselect에서 서버 응답을 ClientModel로 변환한다.
  • 화면에서 공통으로 사용할 기본 형태를 결정한다.
  • 서버의 key 구조와 UI 모델 사이의 차이를 정리한다.

UI 레이어

  • 값이 없을 때 대체 문구를 표시할지 결정한다.
  • 버튼을 비활성화할지 결정한다.
  • 컴포넌트를 숨길지 결정한다.
  • API 요청 자체를 막을지 결정한다.
  • 핵심 데이터가 없으면 화면 전체를 오류 상태로 전환할지 결정한다.

핵심은 잘못된 값을 무조건 정상적인 기본값으로 복구하는 것이 아니다.

명세와 다른 값을 undefined라는 명시적인 상태로 바꾸고, 각 기능이 그 상태에 맞는 동작을 선택하도록 만드는 것이다.


구현 방법

1. ServerModel과 ClientModel 분리

ServerModel은 API 명세와 동일하게 작성한다.

type GetMenusServerModel = {
  menus: {
    id: string;
    name: string;
    price: number;
  }[];
};

서버 명세상 필수 값이라면 ServerModel에 임의로 undefined를 넣지 않는다.

type MenuServerModel = {
  id: string;
  name: string;
  price: number;
};

반면 프론트엔드에서 사용하는 데이터에는 실제 응답이 잘못될 가능성을 반영한다.

프로젝트에서는 재귀적으로 각 필드에 undefined를 포함하는 유틸리티 타입을 사용했다.

type UtilityUpdateUndefinedType<T> =
  T extends readonly (infer U)[]
    ? Array<UtilityUpdateUndefinedType<U> | undefined> | undefined
    : T extends object
      ? {
          [K in keyof T]:
            | UtilityUpdateUndefinedType<T[K]>
            | undefined;
        }
      : T | undefined;
type GetMenusClientModel =
  UtilityUpdateUndefinedType<GetMenusServerModel>;

실제 유틸리티 타입은 프로젝트의 객체와 배열 구조에 맞게 정의해야 한다.

중요한 점은 런타임에서 잘못된 값을 undefined로 변경했다면, TypeScript 타입도 해당 가능성을 동일하게 표현해야 한다는 것이다.

2. Typia로 API 응답 검증

API 응답의 런타임 검증에는 Typia를 사용했다.

Typia는 TypeScript 타입을 기준으로 런타임 검증 코드를 생성하는 라이브러리다.

이번 글에서는 API 예외 처리 구조에 사용한 두 메서드만 간단히 다룬다.

is<T>()

입력값이 타입과 일치하는지 boolean으로 반환한다.

const isMatched = is<GetMenusServerModel>(data);
 
if (isMatched) {
  return data;
}

정상 응답이면 상세 오류를 생성하거나 데이터를 복사하지 않고 바로 반환할 수 있다.

validate<T>()

검증 결과와 함께 상세한 오류 정보를 반환한다.

const response = validate<GetMenusServerModel>(data);
 
if (!response.success) {
  console.table(response.errors);
}

검증에 실패하면 다음 정보를 얻을 수 있다.

  • 오류가 발생한 경로
  • 기대한 타입
  • 실제 값

Typia가 잘못된 값을 자동으로 undefined로 변경해주는 것은 아니다.

Typia는 검증과 오류 경로 추출을 담당하고, 실제 값의 보정은 별도로 작성한 sanitizeTypiaErrors가 담당한다.

3. 잘못된 값을 undefined로 보정

validate에서 반환한 오류 경로를 따라가며 잘못된 값을 undefined로 변경한다.

interface SanitizeTypiaErrorsProps<T> {
  data: T;
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
  path: string;
  errors: IValidation.IError[];
}

원본 응답을 직접 변경하지 않도록 먼저 깊은 복사를 수행한다.

const cloned = cloneDeep(data);

Typia의 오류 경로는 다음과 같은 형태로 전달될 수 있다.

$input.account.name
$input.menus[0].price

문자열 경로를 객체와 배열의 key로 변환한 뒤, 마지막 값을 undefined로 바꾼다.

const isNumericKey = (key: string): boolean => /^\d+$/.test(key);
 
interface SetDeepValueProps {
  object: Record<string, any>;
  path: string;
  value: any;
}
 
const setDeepValue = ({
  object,
  path,
  value,
}: SetDeepValueProps): void => {
  const keys = path.replace(/\[(\d+)\]/g, ".$1").split(".");
  let current = object;
 
  keys.forEach((key, index) => {
    const isLast = index === keys.length - 1;
    const keyAsIndex = isNumericKey(key) ? Number(key) : key;
 
    if (isLast) {
      current[keyAsIndex] = value;
      return;
    }
 
    const nextKey = keys[index + 1];
    const shouldCreateArray = isNumericKey(nextKey);
 
    if (
      current[keyAsIndex] === undefined ||
      current[keyAsIndex] === null ||
      typeof current[keyAsIndex] !== "object"
    ) {
      current[keyAsIndex] = shouldCreateArray ? [] : {};
    }
 
    current = current[keyAsIndex];
  });
};

검증 오류를 순회하며 해당 값을 변경한다.

export const sanitizeTypiaErrors = <T>({
  data,
  errors,
  method,
  path,
}: SanitizeTypiaErrorsProps<T>): UtilityUpdateUndefinedType<T> => {
  const cloned = cloneDeep(data) as Record<string, any>;
 
  const errorTable = errors.map((error) => ({
    path: error.path.replace("$input.", ""),
    expected: error.expected,
    value: error.value,
  }));
 
  if (process.env.NODE_ENV === "production") {
    alert(
      `WHERE: ${method} ${path}\n\n${errorTable
        .map(
          ({ path, expected, value }, index) =>
            `${index + 1}. path: ${path} | expected: ${expected} | value: ${value}`,
        )
        .join("\n")}`,
    );
  } else {
    console.table(errorTable);
  }
 
  for (const error of errors) {
    const errorPath = error.path.replace(/^\$input\.?/, "");
 
    setDeepValue({
      object: cloned,
      path: errorPath,
      value: undefined,
    });
  }
 
  return cloned as UtilityUpdateUndefinedType<T>;
};

이 함수의 역할은 다음과 같다.

  1. API 응답을 깊은 복사한다.
  2. Typia 오류를 디버깅하기 좋은 형태로 정리한다.
  3. 오류 경로를 따라 잘못된 필드에 접근한다.
  4. 해당 값을 undefined로 변경한다.
  5. 보정된 응답 객체를 반환한다.

예를 들어 price가 숫자여야 하지만 문자열로 내려왔다고 가정해보자.

const data = {
  menus: [
    {
      id: "menu-1",
      name: "김치찌개",
      price: "8000",
    },
  ],
};

검증과 보정 후에는 다음과 같은 형태가 된다.

const sanitizedData = {
  menus: [
    {
      id: "menu-1",
      name: "김치찌개",
      price: undefined,
    },
  ],
};

응답 전체를 버리지 않고 문제가 발생한 필드만 사용할 수 없는 상태로 전환한다.

4. API 함수에 검증 흐름 연결

API 함수에서는 먼저 is로 정상 응답인지 확인한다.

export const getOdooRequestMenusAPI = async (
  req: GetOdooRequestMenusQueryModel,
) => {
  const path = "/odoo/fnb/requests";
 
  const { data } = await ax.get(path, {
    params: req.query,
  });
 
  const isMatched = is<GetOdooRequestMenusServerModel>(data);
 
  if (isMatched) {
    return data;
  }
 
  const response =
    validate<GetOdooRequestMenusServerModel>(data);
 
  if (!response.success) {
    return sanitizeTypiaErrors<
      GetOdooRequestMenusServerModel
    >({
      method: "GET",
      path,
      data,
      errors: response.errors,
    });
  }
};

전체 흐름은 다음과 같다.

  1. API 응답을 받는다.
  2. isServerModel과 일치하는지 확인한다.
  3. 일치하면 원본 데이터를 반환한다.
  4. 일치하지 않으면 validate로 상세 오류를 추출한다.
  5. sanitizeTypiaErrors로 잘못된 필드를 undefined로 변경한다.
  6. 보정된 응답을 반환한다.

validate의 결과는 성공과 실패가 구분된 유니온 타입이다.

따라서 response.errors를 사용하려면 먼저 success 값으로 실패 타입을 좁혀야 한다.

const response = validate<ServerModel>(data);
 
if (!response.success) {
  return sanitizeTypiaErrors({
    data,
    errors: response.errors,
  });
}

response.success를 확인하지 않고 errors에 접근하면 TypeScript 오류가 발생한다.

5. useQuery의 select에서 ClientModel로 변환

API 레이어에서는 서버 응답의 검증과 보정을 담당한다.

서비스 레이어에서는 useQueryselect를 통해 화면에서 사용할 형태로 변환한다.

type MenuClientModel = {
  id: string | undefined;
  label: string;
  price: number | undefined;
};
useQuery({
  queryKey: ["menus", requestId],
  queryFn: () =>
    getOdooRequestMenusAPI({
      query: {
        requestId,
      },
    }),
  select: (data): MenuClientModel[] => {
    return (
      data.menus
        ?.filter(
          (
            menu,
          ): menu is NonNullable<typeof menu> =>
            menu !== undefined,
        )
        .map((menu) => ({
          id: menu.id,
          label: menu.name ?? "이름 없음",
          price: menu.price,
        })) ?? []
    );
  },
});

이 단계에서는 화면의 요구사항에 따라 다음을 결정한다.

  • 목록이 없으면 빈 배열로 처리할 것인가
  • 이름이 없으면 대체 문구를 보여줄 것인가
  • 식별자가 없는 항목을 목록에서 제거할 것인가
  • 가격이 없으면 undefined 상태를 그대로 UI에 전달할 것인가

ServerModel과 ClientModel을 분리하면서 서버 응답 구조를 UI에서 직접 사용하는 범위를 줄일 수 있었다.


6. UI에서 기능별 예외 처리

모든 값에 undefined 가능성을 포함했다고 해서 같은 방식으로 처리할 수 있는 것은 아니다.

단순한 텍스트는 대체 문구를 사용할 수 있다.

<Text>{menu.name ?? "이름 없음"}</Text>

반면 API 요청에 필요한 id가 없다면 빈 문자열을 대신 넣어서는 안 된다.

<Button
  disabled={menu.id === undefined}
  onClick={() => {
    if (menu.id === undefined) return;
 
    updateMenu({
      id: menu.id,
    });
  }}
>
  수정
</Button>

상황에 따라 버튼을 아예 노출하지 않을 수도 있다.

if (menu.id === undefined) {
  return null;
}
 
return (
  <Button
    onClick={() => {
      updateMenu({
        id: menu.id,
      });
    }}
  >
    수정
  </Button>
);

핵심 데이터가 없다면 화면 전체를 오류 상태로 전환할 수도 있다.

if (menu.id === undefined || menu.name === undefined) {
  return (
    <ErrorMessage>
      메뉴 정보를 불러오지 못했습니다.
    </ErrorMessage>
  );
}

undefined를 타입에 포함하는 것은 예외 처리의 완료가 아니다.

각 UI가 해당 값이 없을 때 어떤 서비스를 제공할지 명시적으로 결정하도록 만드는 시작점이다.


Typia를 선택한 이유

초기에는 Yup 스키마를 통해 API 응답을 검증하는 방식도 검토했다.

Yup을 사용하면 TypeScript 타입과 별도로 검증 스키마를 작성해야 한다.

const menuSchema = yup.object({
  id: yup.string().required(),
  name: yup.string().required(),
  price: yup.number().required(),
});

동일한 데이터 구조를 타입과 스키마에 각각 작성하면 다음과 같은 관리 비용이 생길 수 있다.

  • TypeScript 타입과 검증 스키마를 중복 작성해야 함
  • 한쪽만 변경하면 타입과 검증 기준이 달라질 수 있음
  • 중첩된 데이터가 많을수록 스키마 코드가 길어짐
  • API 모델 변경 시 수정할 위치가 늘어남

Typia는 기존 TypeScript 타입을 런타임 검증 기준으로 사용할 수 있어 별도 스키마 작성량을 줄일 수 있었다.

또한 isvalidate를 나누어 다음과 같이 사용할 수 있었다.

  • 정상 응답: is로 확인한 뒤 바로 반환
  • 잘못된 응답: validate로 상세 오류를 추출한 뒤 값 보정

프로젝트에서 진행한 측정 결과는 다음과 같았다.

성공은 실제 응답이 ServerModel과 일치한 경우이며, 실패는 실제 응답이 명세와 달랐던 경우다.

측정 방식성공 시간실패 시간
API 검증 없음0.0009765625ms동일
typia.is0.095947265625ms0.033935546875ms
typia.validate0.125ms0.280029296875ms
typia.validate + 값 보정0.117919921875ms1.315947265625ms
typia.is + validate + 값 보정0.092041015625ms1.31396484375ms
Yup 검증1.071044921875ms측정 불가
Yup 검증 + 값 보정1.10693359375ms2.655029296875ms

측정 환경은 MacBook M2 Pro와 MSW였으며, console.timeconsole.timeEnd를 이용해 세 번 실행한 결과 중 가장 낮은 값을 사용했다.

이 수치는 라이브러리 전체의 성능을 일반화할 수 있는 공식 벤치마크는 아니다.

테스트 데이터의 크기, 모델의 복잡도, 실행 환경과 측정 방식에 따라 결과는 달라질 수 있다. 당시 프로젝트에서 검증 방식을 선택하기 위한 참고 자료로 사용했다.

최종적으로 Typia를 선택한 이유는 성능 하나만이 아니었다.

  • 기존 TypeScript 타입을 검증 기준으로 사용할 수 있음
  • 별도의 검증 스키마 작성량을 줄일 수 있음
  • 정상 응답은 is를 통해 빠르게 반환할 수 있음
  • 실패 응답은 validate로 상세 오류를 얻을 수 있음
  • 오류 경로를 값 보정 로직과 연결하기 쉬움

적용하면서 고려한 점

1. 코드 작성은 분명히 불편해진다

이 구조를 처음 반대했던 가장 큰 이유다.

기존에는 바로 사용할 수 있었던 값마다 undefined를 확인해야 한다.

menu.name ?? "이름 없음";
menu.price === undefined ? "-" : menu.price;
menu.id === undefined;

분기와 코드량이 늘어나는 것은 명확한 비용이다.

하지만 이 불편함은 새로운 문제를 만든 결과라기보다, 원래부터 존재하던 API 응답의 불확실성을 타입에 드러낸 결과라고 볼 수 있다.

타입에서 undefined를 제거한다고 실제 응답의 누락 가능성까지 사라지는 것은 아니다.

2. 일부 오류로 화면 전체가 실패하는 상황을 줄일 수 있다

검증에 실패했다고 응답 전체를 버리면 일부 필드만 잘못돼도 화면 전체를 제공할 수 없다.

{
  id: "menu-1",
  name: "김치찌개",
  price: "8000"
}

price만 잘못됐다면 다음과 같이 보정할 수 있다.

{
  id: "menu-1",
  name: "김치찌개",
  price: undefined
}

이 경우 이름과 식별자를 사용하는 기능은 유지하고, 가격을 사용하는 UI만 별도로 처리할 수 있다.

다만 모든 데이터가 필드 단위로 안전하게 분리되는 것은 아니다.

여러 값이 서로 강하게 연결돼 있다면 하나의 값만 undefined로 바꿨을 때 오히려 잘못된 상태가 만들어질 수 있다.

도메인에 따라 다음 기준을 별도로 정해야 한다.

  • 필드만 undefined로 변경할 것인가
  • 객체 전체를 목록에서 제거할 것인가
  • API 응답 전체를 실패 처리할 것인가

3. API 오류를 숨기는 구조가 되어서는 안 된다

잘못된 값을 undefined로 바꾸면 화면의 런타임 오류는 줄어든다.

반대로 서버 응답 문제가 사용자와 개발자 모두에게 보이지 않게 묻힐 수도 있다.

따라서 값 보정과 오류 확인은 함께 이뤄져야 한다.

const errorTable = errors.map((error) => ({
  path: error.path,
  expected: error.expected,
  value: error.value,
}));
 
console.table(errorTable);

프로젝트에서는 운영 환경에서 alert를 통해 오류 정보를 보여주는 방식도 사용했다.

다만 일반적인 사용자 대상 서비스라면 alert가 사용 흐름을 방해할 수 있다.

서비스 성격에 따라 오류 수집 도구나 로깅 시스템을 통해 다음 정보를 기록하는 방식을 고려할 수 있다.

  • HTTP method
  • API path
  • 오류 경로
  • 기대한 타입
  • 실제 값
  • 발생 시각
  • 요청을 구분할 수 있는 정보

응답을 보정해 서비스를 유지하는 것과, 원인을 추적해 서버 응답을 바로잡는 것은 별개의 작업이 아니다.

4. nullundefined의 의미를 구분해야 한다

서버가 의도적으로 null을 반환하는 필드와 검증 실패로 undefined가 된 필드는 의미가 다르다.

type UserServerModel = {
  deletedAt: string | null;
};

위 타입에서 null은 정상적인 도메인 상태다.

{
  deletedAt: null
}

반면 undefined는 명세와 다른 값이 들어왔거나 key가 누락된 비정상 상태를 의미할 수 있다.

{
  deletedAt: undefined
}

두 값을 같은 방식으로 처리하면 정상적인 빈 값과 API 응답 오류를 구분하기 어려워진다.

5. 적용 범위를 정해야 한다

모든 프로젝트와 모든 API에 동일한 수준의 방어 구조가 필요한 것은 아니다.

다음과 같은 경우에는 적용 가치가 높을 수 있다.

  • API 응답 불일치가 반복적으로 발생함
  • 일부 데이터 오류가 화면 전체 장애로 이어짐
  • 관리자 화면처럼 데이터 구조와 동작이 복잡함
  • 잘못된 요청이 운영 데이터에 영향을 줄 수 있음
  • 여러 화면과 서비스에서 동일한 API 모델을 사용함

반대로 API가 단순하고 안정적이며, 검증 구조의 유지 비용이 더 큰 프로젝트라면 과한 설계가 될 수도 있다.


처음에는 왜 반대했는가

처음 이 구조를 논의했을 때는 다음과 같은 이유로 반대했다.

  • 모든 필드에 undefined가 포함되면 코드가 지나치게 복잡해질 것 같았다.
  • 서버에서 해결해야 할 문제를 프론트엔드가 과도하게 방어한다고 느꼈다.
  • API 명세가 있다면 서버와의 싱크를 맞추는 것이 우선이라고 생각했다.
  • 모든 화면에 예외 처리 코드를 추가하는 비용이 크다고 판단했다.
  • 타입을 읽었을 때 실제 도메인 모델보다 실패 가능성이 더 크게 보일 수 있다고 생각했다.

이 우려들이 완전히 틀렸다고 생각하지는 않는다.

실제로 적용 후 코드량은 늘었고, 단순한 값을 사용할 때도 매번 undefined를 고려해야 했다.

서버 응답의 안정성을 높이고 배포 싱크를 맞추는 것이 가장 근본적인 해결책이라는 점도 달라지지 않는다.


적용 후 생각이 바뀐 이유

직접 적용하면서 이 구조의 목적이 서버 오류를 프론트엔드에서 대신 해결하는 것이 아니라는 점을 이해하게 됐다.

서버 응답이 잘못됐을 때 프론트엔드가 선택할 수 있는 방향은 크게 두 가지다.

  1. 잘못된 응답을 정상 값이라고 가정한 채 사용하다가 런타임에서 실패한다.
  2. 잘못된 상태를 감지하고, 제공할 수 있는 범위까지 서비스를 유지한다.

이 구조는 두 번째 방향을 선택한 것이었다.

특히 의미 있다고 느낀 부분은 예외 처리를 개발자의 기억이나 코드 리뷰에만 맡기지 않았다는 점이다.

type Menu = {
  id: string | undefined;
};

idundefined일 수 있다는 사실이 타입에 포함되면, 해당 값을 사용하는 개발자는 그 가능성을 무시하기 어렵다.

updateMenu({
  id: menu.id,
});

API가 string만 요구한다면 TypeScript가 예외 처리를 요구한다.

이 타입 오류는 코드를 작성하는 입장에서는 불편하다.

하지만 운영 환경에서 발생할 수 있는 문제를 개발 단계에서 먼저 확인하게 만든다.

결국 이 구조의 가장 큰 장점은 모든 런타임 오류를 자동으로 없애는 데 있지 않았다.

서버 응답의 불확실성을 팀 전체가 같은 방식으로 인식하고, 각 UI가 그에 맞는 동작을 결정하도록 만든다는 점이 더 중요했다.


결과

이 구조를 통해 다음과 같은 변화를 얻을 수 있었다.

  • API 명세와 실제 응답의 차이를 런타임에서 확인할 수 있었다.
  • 잘못된 필드 하나가 화면 전체 런타임 오류로 이어지는 상황을 방어할 수 있었다.
  • 런타임에서 발생할 수 있는 상태를 TypeScript 타입에도 반영할 수 있었다.
  • 예외 처리 누락을 TypeScript 오류로 확인할 수 있었다.
  • ServerModel과 ClientModel의 책임을 분리할 수 있었다.
  • UI가 값이 없을 때의 동작을 명시적으로 결정하게 됐다.
  • 검증 오류의 경로와 실제 값을 통해 서버 응답 문제를 확인할 수 있었다.

반면 다음과 같은 비용도 함께 발생했다.

  • 모든 값에 대한 예외 처리 코드가 늘어났다.
  • 재귀 유틸리티 타입과 보정 로직이 복잡해졌다.
  • 필드, 객체, 응답 중 어느 범위까지 실패 처리할지 기준이 필요했다.
  • 서버 오류가 보정 로직 뒤에 숨지 않도록 별도의 확인 수단이 필요했다.
  • 팀 구성원이 구조의 의도와 사용 방식을 공통으로 이해해야 했다.