Date 타입 런타임 에러 수정

해진·2026년 2월 12일
post-thumbnail

개발을 하던 중 타입에 기묘한 부분을 발견했어요.
인자로 받는 값은 분명 Date 타입이었어요. 그런데 타입을 Date로 명확히 지정하자 런타임 에러가 발생했어요.
“왜 이런 일이 생기는 걸까?”
이번 글에서는 그 원인을 하나씩 짚어보고, 문제를 어떻게 정리했는지 설명해보려고 해요.


기묘한 인자와 코드의 ‘냄새’

커머스와 교육 도메인을 다루면 ‘일시(Date)’를 자주 다뤄요.
상품 오픈과 마감, 할인 기간, 강의 수강 기간처럼 비즈니스 로직의 핵심이 시간과 맞닿아 있어요.
어느 날 프로젝트의 공통 포맷터 함수를 살펴보다가 위화감을 주는 코드를 발견했어요.

const formatDateTime = (date: Date | string | null | undefined) => {
  if (!date) return "-";

  // 분명 Date로 들어와야 할 것 같은데, 왜 string 처리를 하고 있을까?
  const dateObj = typeof date === "string" ? new Date(date) : date;

  if (isNaN(dateObj.getTime())) return "-";
  return dateObj.toLocaleString("ko-KR", { ... });
};

인자로 받는 date의 타입은 Date | string이었어요.
“어차피 Date 객체만 들어오지 않을까?”라는 생각에 string을 제거해보았지만, 결과는 예상과 달랐어요.

  • string을 제거하면 → 런타임 에러 발생
  • Date를 제거하고 string만 남기면 → 타입 에러 발생 (API 응답 타입은 Date로 선언한 상태였어요.)

정의된 타입은 Date인데, 실제로 들어오는 값은 string이었던 것이죠.

코드는 동작하고 있었지만, 이 순간부터 이런 의문이 들기 시작했어요.

“TypeScript를 쓰고 있는데, 이 상태가 맞을까?”

이 코드는 동작은 하지만, 분명 어딘가 묘한 ‘코드의 냄새’를 풍기고 있었어요. 🧐


HTTP와 JSON의 한계

이 문제는 TypeScript의 버그가 아니었어요. 원인은 통신 구조에 있었어요.

  • HTTP는 타입을 알지 못해요. 네트워크를 통해 오가는 데이터는 모두 텍스트예요.
  • JSON 명세에는 Date 타입이 없어요.

서버에서 Date 객체로 관리해도, 네트워크를 거치면 ISO 8601 문자열로 바뀌어요.

즉, 우리가 API 응답 타입(DTO)을 Date로 선언한 것은 사실 “이 문자열은 나중에 Date로 변환될 값이에요”라는 개발자의 기대에 불과했어요. 런타임 데이터는 그 기대를 저버리고 있었고, 그 간극이 문제의 시작이었어요.


고쳐야 할까요?

“그냥 필요할 때마다 new Date()로 감싸서 쓰면 되지 않나요?”

음…맞죠…?

하지만 커머스와 학습 콘텐츠 도메인에서 시간은 단순한 표시용 데이터가 아니에요.

  • 결제 마감 시간: 단 1초 차이로 주문의 성패가 갈려요
  • 쿠폰 만료 및 할인 기간: 매출과 고객 신뢰도에 직접적인 영향
  • 수강 기간 및 접근 권한: 사용자가 서비스를 이용할 수 있는지 결정

“마감까지 얼마나 남았는지”를 계산하는 과정은 단순한 UI 처리가 아니라 비즈니스 로직 그 자체에 가까웠어요.

이런 상황에서 stringDate 타입이 섞여 있으면, 어떤 시점에 어떤 타입이 문제를 일으킬지 예측하기 어려워져요. 이것은 마치 언제 터질지 모르는 지뢰처럼 느껴졌어요. 팀 내에서 이 문제를 공유했고, 의도하지 않은 에러를 막기 위해 원칙을 세우게 되었어요.

👉 통신 경계까지만 string 을 허용해요.

👉 도메인 내부에서는 Date 만 사용해요.


Zod vs Interceptor

원칙은 세웠지만, 구현 방법을 정하는 건 또 다른 문제였어요.

1) Zod 같은 런타임 타입 체크 도구

Zod를 사용하면 파싱과 검증을 동시에 할 수 있어서 꽤 매력적이었어요. 하지만 이미 서버와 타입을 공유하는 내부 패키지를 사용 중이었고, Zod를 도입하면 동일한 인터페이스를 한 번 더 정의해야 했어요.

이 문제는 “검증이 부족해서” 생긴 문제가 아니라 JSON이 Date를 표현하지 못하는 구조적 한계에서 비롯된 문제였기 때문에, 이 경우에는 배보다 배꼽이 더 크다고 판단했어요.

2) API 인터셉터에서의 자동 파싱

“통신 레이어에서 한 번만 파싱하고, 그 이후 코드에서는 Date만 보게 하면 어떨까?”라는 아이디어도 있었어요. 하지만 TypeScript의 타입 정보는 런타임에 사라져요(Type Erasure) 인터셉터 입장에서는 어떤 필드가 Date인지 알 방법이 없었어요.

결국 필드 이름이나 값의 포맷에 의존한 휴리스틱한 방식이 될 수밖에 없었고, 이는 컨벤션이 조금만 어긋나도 더 큰 런타임 사고로 이어질 수 있었어요.

명확한 해답이 보이지 않는 상황에서, “완전 자동화”보다는 명확한 기준을 사람이 이해할 수 있게 만드는 방향이 더 적절하다고 느꼈어요.

선택한 방식

결론적으로, 완벽한 자동화 대신 명시적인 컨벤션을 선택했어요.

아이디어는 단순했어요. “무엇을 Date로 파싱할지 명확하지 않다면,그 기준을 우리가 직접 정하자”는 거였어요.

날짜 필드에 대한 규칙

createdAt, expiredAt, endDate처럼 특정 접미사나 접두사를 가진 필드는 Date의 의미를 가진 값으로 정의했어요.

그리고 이 규칙은 서버와 클라이언트가 함께 사용하는 공유 패키지에서 관리했어요.

exportconstDATE_FIELD_REGEX =/(At|Date|Until)$/;

통신 이후에는 이 규칙에 명확히 매칭되는 필드만 Date로 변환했어요.

  • 매칭되지 않으면 변환하지 않아요
  • 파싱 실패(Invalid Date)는 조용히 넘기지 않아요
  • “우연히 Date가 되는 상황”을 의도적으로 막았어요

빌드 시점에서의 검증

컨벤션은 사람이 지켜요. 따라서 휴먼 에러를 막는 장치가 필요했기에 빌드 단계에서 컨벤션을 검증하는 스크립트를 추가했어요.

타입이 Date인데 필드 이름이 규칙에 맞지 않으면, 빌드 단계에서 에러를 발생시켰어요. 그러면 개발자는 바로 수정할수 있죠.

물론 이 방식에도 한계는 있어요.

  • 이름은 거짓말할 수 있음
  • 컴파일 타임에서 완전히 보장되지는 않음
  • 팀 컨벤션에 의존

그럼에도 불구하고 API 규모와 중복 비용을 고려했을 때, 지금 시점에서 우리 팀에 가장 현실적인 선택이라고 판단했어요.


‘코드의 냄새’를 맡는 감각

최근에는 AI를 활용한 이른바 ‘바이브 코딩’을 얼마나 잘 활용하느냐도 중요한 역량이 되었어요.

하지만 코드를 생성하는 비용이 낮아질수록, 역설적으로 더 중요해지는 것은 ‘이 코드가 뭔가 이상하지 않은가?’를 감지하는 감각이라고 생각해요. 이 글은 “TypeScript에서는 반드시 이렇게 해야 한다”는 정답을 말하고 싶어서 쓴 글이 아니에요. 코드의 냄새를 맡고, 그 냄새를 어떻게 정리하고 청소할지 고민했던 과정을 기록하고 싶었어요.

도메인과 팀의 상황에 맞는 현실적인 해법을 찾아가는 경험, 그리고 이런 작은 컨벤션들이 쌓여 AI도 우리의 의도를 더 잘 이해하고 우리가 원하는 방향의 코드를 만들어가는 기반이 된다고 믿어요.

profile
안녕하세요, Frontend 개발자 윤해진입니다.

0개의 댓글