홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

타입체크도 통과하고 파싱도 정상인데 메시지만 사라졌습니다 — zod 4 무음 실패

정기창·2026년 8월 28일

스키마 검증 라이브러리를 메이저 버전으로 올렸습니다. 여섯 개 워크스페이스에서 네 갈래로 버전이 갈라져 있던 것을 하나로 모으는 작업이었습니다. 그런데 실제로 위험했던 것은 깨지는 API가 아니라 조용히 무시되는 API였습니다.

먼저 파괴 표면을 좁혔습니다

메이저 업그레이드에서 가장 불안한 것은 무엇이 깨질지 모른다는 점입니다. 그래서 올리기 전에 그 라이브러리를 import하는 파일 47개를 전수로 훑고, 변경 목록의 항목마다 "이 레포에 해당 사례가 있나"를 셌습니다.

변경 항목이 레포의 사례판정
기본값과 변환의 실행 순서기본값 33콜, 변환 0건위험 조건은 "변환 뒤의 기본값" — 해당 없음
전처리 함수의 반환 타입 변경사용 2곳, 타입 명시 참조 0건영향 없음
오류 객체 필드 삭제1곳수정 필요
커스텀 메시지 옵션 통합1곳수정 필요

결과적으로 실제 코드 수정은 두 곳뿐이었습니다. 47개를 다 열어보는 데 든 시간이 아깝지 않았던 이유는, 나머지 45개가 안전하다는 것을 세어서 알았기 때문입니다. "아마 괜찮을 것"과 "0건임을 확인했다"는 배포할 때의 마음이 다릅니다.

깨지는 쪽은 오히려 안전합니다

수정한 두 곳 중 하나는 오류 객체의 필드였습니다. 이 라이브러리는 v4에서 errors를 issues로 이름을 바꾼 게 아니라 삭제했습니다. 그래서 예전 코드는 컴파일 단계에서 바로 멈춥니다.

불편하지만 이런 변경은 안전합니다. 배포 전에 반드시 드러나기 때문입니다. 고치지 않으면 빌드가 안 되니 사용자에게 도달할 방법이 없습니다.

문제는 나머지 하나였습니다

다른 하나는 커스텀 오류 메시지를 지정하는 옵션이었습니다. v4는 여러 갈래로 나뉘어 있던 옵션들을 하나로 통합했는데, 구 옵션을 그대로 두면 이런 일이 벌어집니다.

  • 타입체크는 통과합니다.
  • 검증도 정상 동작합니다. 잘못된 입력은 여전히 거절됩니다.
  • 다만 메시지만 조용히 기본 영문으로 바뀝니다.

직접 뮤테이션 검사로 확인했습니다. 한국어 안내 문구가 나가야 할 자리에 Invalid input: expected true가 나갑니다. 빌드도 잡지 못하고, 성공·실패를 보는 단언도 잡지 못합니다. 검증은 성공적으로 실패했으니까요.

깨지는 API는 저에게 청구되고, 조용해지는 API는 사용자에게 청구됩니다. 어느 날 폼을 채우던 누군가가 영문 에러를 보게 되고, 그 사람은 버그 신고를 하지 않고 그냥 떠납니다.

단언을 어디에 걸 것인가

이 문구를 단언하는 테스트가 하나 있긴 했습니다. 브라우저 e2e였습니다. 문제는 그 층이 무겁다는 점입니다. 서버를 띄우고 브라우저를 열어야 확인되는 사실이라, 라이브러리를 만지는 사람이 그 자리에서 돌려볼 수가 없습니다.

그래서 스키마가 정의된 패키지의 유닛 테스트에 메시지 단언을 추가했습니다. 스키마와 단언이 같은 패키지에 있으니 그 패키지를 건드리는 순간 같이 돕니다.

넣고 나서 음성 대조를 한 번 했습니다. 수정을 도로 되돌렸을 때 정확히 이 테스트만 빨강으로 바뀌는지 확인한 것입니다. 이 절차를 안 하면 "테스트를 추가했다"까지만 참이고 "그 테스트가 이 결함을 잡는다"는 아직 미확인입니다.

더 나빴던 두 번째 무음 실패

업그레이드 이후 코드를 훑다가 하나를 더 발견했습니다. 스키마를 JSON Schema로 바꿔주는 외부 변환 라이브러리를 쓰고 있었는데, 그 라이브러리는 구 버전 전용이었습니다. v4 스키마를 넣으면 예외를 던지지 않고 빈 스키마를 반환합니다.

zodToJsonSchema(z.object({ title, score, kind }), { name: 'Response' })
// => { "$ref": "#/definitions/Response", "definitions": { "Response": {} } }
//                                                                     ^^ 비어 있음

이 값은 LLM에게 구조화 출력을 요구할 때 쓰이고 있었습니다. 즉 제약이 하나도 없는 스키마를 넘기고 있었으니, 구조화 출력이 사실상 무제약이 된 상태였습니다. 응답이 오긴 오고 형태가 대충 맞을 때가 많으니 한동안 몰랐습니다.

왜 컴파일러가 안 잡았는지가 더 뼈아팠습니다. 두 호출부 모두 인자에 캐스트가 붙어 있었습니다.

zodToJsonSchema(schema as any, { name: 'Response' })

이 캐스트가 붙은 시점에는 그럴 이유가 있었을 것입니다. 다만 결과적으로 버전이 어긋났다는 유일한 신호를 지운 것이 되었습니다. 캐스트는 타입 오류를 없애는 게 아니라 타입 오류가 알려주려던 사실을 없앱니다. 결국 라이브러리를 걷어내고 v4에 내장된 변환 함수로 교체했습니다.

재현되지 않은 실패 하나

검증 과정도 적어둡니다. 전체 빌드 통과, 스키마 패키지 278건 통과, 백엔드 6,639건 통과였습니다.

다만 백엔드 첫 실행에서 1건이 실패했는데 재실행에서 재현되지 않았습니다. 실패 양상이 검증 오류(400)가 아니라 인증 오류(401)라 이번 변경과는 무관해 보였고, 그 파일만 격리해 돌리면 구버전·신버전 양쪽에서 전부 통과했습니다. flaky로 판단했습니다.

다만 "원래 있던 문제임이 증명됐다"고까지 쓰지는 않으려 합니다. 비교 대조를 한 번밖에 하지 않았기 때문입니다. 판단을 내린 것과 증명한 것은 다르고, 그 차이를 적어두지 않으면 나중에 제가 저를 오해합니다.

남는 것

메이저 업그레이드를 앞두고 저는 늘 "무엇이 깨지나"를 물었습니다. 이제 질문을 하나 더 답니다. 무엇이 조용해지나.

구체적으로는 이런 것들을 봅니다. 커스텀 메시지·라벨처럼 기본값이 존재하는 옵션은 무시돼도 티가 안 납니다. 옵션 객체를 통째로 받는 API는 모르는 키를 그냥 버립니다. 그리고 캐스트가 붙은 호출부는 이미 컴파일러의 눈이 가려진 자리라 업그레이드에서 가장 먼저 확인해야 할 곳입니다.

세 가지 다 이번에 직접 겪고 나서야 목록에 넣었습니다. 다음번 업그레이드에서는 이 목록부터 훑고 시작할 생각입니다.

zodTypeScript의존성 관리테스트마이그레이션트러블슈팅

관련 글

Compiled successfully를 찍고 실패했습니다 — CI가 안 보고 있던 패키지

타입체크가 깨진 채 main에 머물러 있었는데 아무도 몰랐습니다. 그 패키지는 CI에 빌드 잡도 e2e 잡도 없었기 때문입니다. 같은 종류의 구멍이 이 레포에서 세 번째였습니다.

관련도 93%

삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다

서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.

관련도 93%

주석이 거짓말을 하고 있었습니다 — 이스케이프가 실제로 막는 범위

경로를 감싸는 함수가 콜론을 이스케이프하고 있었습니다. 그래서 윈도우 경로를 막아준다고 주석에 적혀 있었는데, 재보니 콜론만 여전히 실패했습니다. 이스케이프하는 것과 지켜주는 것은 다른 말이었습니다.

관련도 92%