글 목록으로개발 경험

타입 단언을 줄이며 정리한 런타임 검증 기준

외부 값을 관측하거나 복구하거나 차단하는 세 경계를 그린 일러스트

글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.

as를 걷어내려고 코드를 읽다 보니 같은 단언도 이유가 달랐어요. 라이브러리 콜백 타입이 느슨해서 이미 알고 있는 값을 다시 단언하는 곳이 있었고, API나 URL에서 실제로 어떤 값이 올지 모르는 곳도 있었어요.

처음에는 as가 필요하면 Zod 스키마를 두려고 했어요. 하지만 전자에는 이미 있는 타입을 제대로 이어 주는 편이 나았어요. 후자에는 스키마가 필요했지만 실패했다고 모두 화면을 멈출 수도 없었어요. 응답은 먼저 기록하고, URL은 일부 조건을 살리고, 폼은 고칠 필드를 보여 주는 식으로 처리하게 됐어요.

모든 타입 단언을 Zod로 바꾸지는 않았어요

데이터 테이블의 셀 콜백이 그런 경우였어요. 첫 번째 인자는 넓은 타입이지만 두 번째 인자인 record는 데이터 소스의 행 타입을 유지하고 있었어요.

typescript
// 느슨한 셀 값을 다시 단언하지 않습니다.
render: (_, record) => STATUS_LABEL[record.status]

이 경우에는 Zod로 셀 값을 다시 검사하는 것보다 이미 타입이 보장된 record.status를 읽는 편이 맞았어요. 제네릭이 빠진 공용 컴포넌트는 제네릭을 보강하고, null이 실제로 올 수 있는 값은 타입과 렌더링 분기를 고쳤어요.

반대로 API 응답이나 URL은 타입 선언이 맞아도 실제 값이 다를 수 있어요. 이쪽은 코드 안의 타입만 연결해서 해결할 수 없었어요.

  • 서버가 돌려주는 API 응답
  • 사용자가 직접 수정하거나 오래된 링크에서 들어오는 URL 파라미터
  • 사용자가 작성하는 폼 값
  • 디코딩한 토큰, 스트림 이벤트와 외부 라이브러리 콜백 값

jwtDecode<Claims>(token)처럼 제네릭만 붙이면 디코딩 결과가 실제로 Claims인지 확인하지 않아요. 이런 자리가 Zod를 둘 경계였어요.

typescript
const claimsSchema = z.object({ role: z.enum(ROLES) });
 
try {
  const { role } = claimsSchema.parse(jwtDecode(token));
  return ALLOWED_ROLES.includes(role);
} catch {
  return false;
}

토큰처럼 잘못된 값일 때 false를 돌려주는 자리는 parse 실패를 그 경로로 보냈어요. 이미 쓰고 있는 API 응답 전체를 같은 방식으로 막는 일은 더 조심스러웠어요.

API 응답은 먼저 어긋난 필드를 기록했어요

API 응답에는 safeParse로 스키마와 맞는지 확인하는 함수를 뒀어요. 이름은 parseApiResponse지만 반환하는 것은 파싱 결과가 아니라 원본이에요.

tsx
export function parseApiResponse<T>(schema: z.ZodType, response: AxiosResponse<T>): T {
  const result = schema.safeParse(response.data);
 
  if (!result.success) {
    reportOnce({
      code: 'RESPONSE_SCHEMA_MISMATCH',
      request: `${response.config.method} ${response.config.url}`,
      issues: result.error.issues,
    });
  }
 
  return response.data;
}

응답이 스키마와 다르면 어긋난 필드 경로를 기록해요. 성공하든 실패하든 response.data를 돌려주므로, 로그가 남아도 소비하는 화면의 값은 바뀌지 않아요.

기존 화면에 검증을 뒤늦게 넣는 중이라 처음부터 스키마가 완전하다고 믿기 어려웠어요. Zod 객체는 선언하지 않은 키를 결과에서 제거해요. result.data로 바로 바꾸면 프론트 스키마에 빠진 필드가 사라져, 그 값을 쓰던 화면이 깨질 수 있었어요.

목록 50건 중 한 건의 부가 필드가 어긋났다는 이유로 목록 전체를 내리는 것도 원하지 않았어요. 특정 필드에 기본값을 넣을 수도 있겠지만, 아직 그렇게 처리하기로 정한 건 아니에요.

대신 로그에는 원인을 다시 찾을 수 있는 정보만 남겼어요.

  • axios config에서 가져온 HTTP 메서드와 요청 경로
  • 오류가 발생한 화면 경로와 앱 구분
  • 어긋난 필드의 경로, 오류 코드, 메시지
  • 로그 한 건이 지나치게 커지지 않도록 앞쪽 20개 이슈만 기록

로그의 요청 이름은 호출부에서 따로 쓰지 않고 Axios 응답의 실제 메서드와 경로에서 가져왔어요. API 주소가 바뀌어도 예전 이름으로 경고가 남지 않도록 했어요.

같은 요청의 같은 필드가 목록 항목마다 어긋나면 경고가 쏟아질 수 있어요. 그래서 세션당 한 번만 보내요. 이 로그 건수는 오류가 일어난 총횟수가 아니라 그 불일치를 만난 세션 수에 가까워요.

이 방식은 잘못된 응답을 발견해도 막지는 않아요. 반환 타입도 여전히 Axios 제네릭을 믿고 있어요. 그래서 기존 동작을 유지하며 계약 차이를 찾는 단계로 두었어요. 기존 응답에서 파싱 결과를 쓰려면 계약을 먼저 확인해야 해요. 새 API나 인증, 스트림 이벤트처럼 실패를 요청이나 이벤트 단위로 처리할 수 있는 곳은 처음부터 파싱 결과를 써요.

검증 로그를 보고 스키마를 고쳤어요

SCHEMA_MISMATCH가 생기면 요청 경로와 앱에서 스키마를 찾고, OpenAPI와 실제 응답을 나란히 봤어요. 서버가 약속과 다르게 보낸 것인지, 프론트 스키마를 잘못 쓴 것인지부터 구분해야 고칠 쪽을 정할 수 있었어요.

text
SCHEMA_MISMATCH 로그
  → 요청 경로와 앱으로 호출부·스키마 특정
  → OpenAPI 계약과 실제 응답 대조
  → 프론트 스키마 오류 / 서버 스펙 위반 / 판단 불가로 분류
  → 프론트 오류만 스키마와 파생 타입 수정
  → 계약 테스트와 뮤테이션 체크
  → 리뷰·검증 후 PR 반영

로그를 없애려고 스키마를 무조건 느슨하게 만들지는 않았어요. 실제 응답이 OpenAPI와 다르면 프론트 스키마에 .nullish()를 추가해 조용히 통과시키지 않아요. 서버 스펙 위반으로 분류하고 프론트 수정은 멈춰요. 응답이 anyOf 형태의 유니언인데 프론트가 한쪽만 알고 있었다면 두 응답을 유니언으로 모델링하고 양쪽 계약 테스트를 추가했어요.

그 과정에서 페이징 없는 목록과 본문 없는 성공 응답도 기존 봉투 스키마에 넣고 있다는 걸 찾았어요. 한 API가 두 형태의 성공 응답을 주는데 하나만 모델링한 경우도 있었고요. 서버가 틀린 게 아니라 프론트가 응답을 잘못 이해한 경고도 있었어요.

URL은 잘못된 필드만 복구했어요

URL은 사용자가 고치거나 오래된 링크에서 그대로 가져올 수 있어요. status 하나를 모른다고 입력했던 검색어와 보고 있던 상세까지 사라지는 결과는 피하고 싶었어요.

처음에는 객체를 한 번 safeParse하고 실패하면 기본 조회 조건을 썼어요. 실제로 status가 틀리면 정상 검색어와 페이지, 상세 드로어 id까지 함께 초기화됐어요. 서로 독립적인 조건은 따로 복구해야 했어요.

그래서 개별 필드가 독립적으로 복구될 수 있는 스키마는 필드마다 fallback을 두었어요.

typescript
const searchParamsSchema = z.object({
  pageNumber: z.coerce.number().int().positive().catch(1),
  status: z.enum(STATUSES).optional().catch(undefined),
  keyword: z.string().optional().catch(undefined),
  id: z.coerce.number().optional().catch(undefined),
});
 
const params = useTypedQueryParams({ schema: searchParamsSchema });

알 수 없는 상태값은 버리되 검색어와 상세 화면은 유지해요. 스키마 전체 fallback은 예상하지 못한 구조가 들어왔을 때의 마지막 안전망으로 남겼는데요. 현재 URL을 다시 조립해 공유 링크나 탭 이동에 사용하는 화면은 .passthrough()를 명시해, 해당 화면의 스키마가 소유하지 않는 파라미터를 조용히 지우지 않도록 했어요.

필드의 .catch가 복구하면 객체 파싱은 성공하므로 불일치 로그가 남지 않아요. 오래된 링크 때문에 매번 경고가 쌓이지는 않지만, 어떤 잘못된 값이 얼마나 들어왔는지도 알 수 없어요. 유입을 추적해야 하는 파라미터는 .catch로 숨기지 않고 객체 파싱 실패로 기록해야 해요. 결제와 권한처럼 조용히 바꾸면 위험한 값에도 이 방식은 쓰지 않아요.

폼은 잘못된 필드를 보여 주고 제출을 막았어요

폼에서는 입력한 사람이 직접 값을 고칠 수 있어요. 잘못된 값을 통과시키거나 다른 값으로 바꾸는 대신 그 필드에 이유를 보여 주고 제출을 멈췄어요.

사용자 서비스의 입력 폼은 React Hook Form과 zodResolver를 연결해 스키마를 입력 타입과 제출 조건의 단일 출처로 사용했어요.

tsx
function useZodForm<TSchema extends z.ZodType>({ schema, ...options }: Options<TSchema>) {
  return useForm<z.infer<TSchema>>({
    ...options,
    resolver: zodResolver(schema),
  });
}

예약이나 리뷰처럼 단계가 이어지는 폼은 단계별 스키마가 각 화면에서 필요한 입력을 검증하도록 나눴어요. 사용자가 아직 입력하지 않은 다음 단계의 필드 때문에 현재 단계가 막히지 않도록 하기 위해서였어요.

Ant Design으로 만든 운영 도구의 검색 폼은 이 입력 폼과 달라요. createSearchFilter(schema)는 z.infer로 필드 이름을 좁히고 URL을 읽을 때 스키마를 써요. Ant Design Form의 제출 검증 전체를 교체한 건 아니에요. 필드 이름은 타입 검사에서, 외부 URL 값은 런타임에서 다루도록 한 거예요.

타입을 좁힐 때는 검증 결과를 썼어요

다음과 같은 사용자 정의 타입 가드는 가장 위험한 단언 중 하나였어요.

typescript
function isCompletedPayment(value: unknown): value is CompletedPayment {
  return value != null;
}

이 함수는 값이 null이 아니라는 것만 확인해요. 그래도 true가 나오면 컴파일러는 CompletedPayment라고 받아들여요. 나중에 그 타입에 필드를 추가해도 이 확인은 바뀌지 않으니, 실제로 확인한 내용보다 훨씬 강한 타입을 주고 있었어요.

결제 완료인지 분류만 할 때는 스키마의 성공 여부를 boolean으로 돌려줬어요. 검사하지 않은 전체 타입까지 좁힌다고 선언하지는 않았어요.

typescript
const completedPaymentSchema = z.object({
  method: z.literal('NORMAL'),
  status: z.literal('COMPLETED'),
});
 
function isCompletedPayment(value: unknown): boolean {
  return completedPaymentSchema.safeParse(value).success;
}

검증된 데이터가 실제로 필요한 자리에서는 value is T를 다시 손으로 선언하기보다 safeParse 결과의 data를 사용했어요.

typescript
const result = completedPaymentSchema.safeParse(value);
if (!result.success) return;
 
useCompletedPayment(result.data);

UI 라이브러리가 문자열로 돌려주는 탭, 정렬 키처럼 모르는 값은 무시해도 되는 자리에는 z.enum(...).optional().catch(undefined)를 사용했어요. 반대로 코드 안에서 이미 타입이 좁혀진 행 데이터는 스키마로 다시 검사하지 않았어요. 좁히기의 근거가 런타임 값인지 기존 타입 흐름인지 먼저 구분했어요.

실패 정책은 경계의 사용자에 따라 달라졌어요

정리하면 같은 Zod라도 역할이 이렇게 갈려요.

경계검증 결과 사용실패 시 처리이유
기존 API 응답원본 유지기록 후 통과검증 도입이 기존 화면을 먼저 깨뜨리지 않게 함
신규 API, 인증, 스트림파싱 결과 사용요청, 이벤트 단위 실패실패 범위를 경계에서 닫을 수 있음
URL 조회 조건파싱 결과 사용필드별 fallback 또는 객체 fallback + 기록오래된 링크 때문에 정상 조건까지 잃지 않게 함
사용자 입력 폼파싱 결과 사용제출 차단 + 필드 안내사용자가 직접 수정할 수 있음
외부 라이브러리 값상황에 따라 사용무시, fallback, 차단해당 값의 영향 범위에 맞춤

검증 도구를 통일했다고 실패 동작까지 통일하지는 않았어요. “스키마가 어긋났다”는 사실과 “화면을 중단해야 한다”는 결정은 서로 다른 문제였기 때문이에요.

검증이 실패했을 때의 동작을 테스트했어요

테스트에는 정상 응답뿐 아니라 스키마가 어긋난 뒤의 값도 넣었어요. 기존 API는 미선언 필드를 포함한 원본을 그대로 돌려주는지, 새 enum이 오면 실제 요청 메서드와 경로, 어긋난 필드가 로그에 남는지 확인해요. 본문 없는 성공과 페이징 없는 목록도 유효한 계약으로 다뤄요.

URL 테스트에서는 잘못된 enum 하나만 버리고 검색어와 페이지, 상세 id는 남는지 봐요. 폼 테스트는 틀린 값을 제출하지 않고 고칠 수 있는 오류를 보여 주는지 확인해요. 같은 불일치 로그가 세션당 한 번만 나가는 조건도 테스트에 넣었어요.

스키마를 고친 뒤에는 옛 정의로 잠시 되돌려 새 테스트가 실제로 실패하는지도 확인했어요. Mock 픽스처가 스키마와 함께 잘못 바뀌면 테스트가 계속 성공할 수 있기 때문인데요. API 응답 Mock은 가능한 곳부터 satisfies와 계약 테스트로 묶고, 실제 개발 서버에서 확인할 수 있는 계약은 Mock 성공만으로 끝내지 않았어요.

사용자 정의 타입 가드가 전부 사라진 건 아니에요. 검증 비용이 더 크거나 외부 라이브러리 타입을 보조하는 곳은 남겨 두고, 그 함수가 어떤 필드를 확인하는지 테스트로 남겼어요. OpenAPI와 프론트 스키마의 차이도 선언만으로 없어지지 않아서, 실제 서버 계약과 불일치 로그를 계속 대조해요.