logo

DowanKim

7. 유니온 타입을 사용하려면, 런타임에 실제 타입을 확인해야한다.(타입가드)

2025년 11월 3일

포트폴리오 사이트

트러블슈팅 : Firebase Timestamp와 Date 타입 변환 문제 - TypeScript 타입 시스템으로 해결하기

1. 문제: 타입 불일치

Firebase Firestore에서 데이터를 가져오면 날짜 필드가 Timestamp 타입입니다. JavaScript에서는 Date를 사용합니다. 이 차이로 인해 타입 불일치가 발생하였습니다.

1.1 타입 불일치의 원인

// Firestore에서 가져온 데이터 { createdAt: Timestamp, // Firebase의 Timestamp 타입 updatedAt: Timestamp } // JavaScript에서 기대하는 타입 { createdAt: Date, // JavaScript의 Date 타입 updatedAt: Date }

문제:

  • TimestampDate는 다른 타입
  • TimestamptoDate()Date로 변환해야 함
  • 서버/클라이언트 환경에 따라 실제 타입이 달라질 수 있음

2. TypeScript 유니온 타입으로 타입 표현

2.1 유니온 타입이 뭔데

TypeScript의 유니온 타입(|)은 "이 타입 또는 저 타입"을 표현합니다.

export interface BlogPost { createdAt: Timestamp | Date updatedAt: Timestamp | Date }

의미:

  • createdAtTimestamp 또는 Date 중 하나
  • 컴파일 타임에는 둘 다 가능하지만, 런타임에는 하나만 존재

2.2 유니온 타입의 장단점

장점:

  • 여러 가능성을 타입 시스템에 명시
  • 타입 안전성 유지
  • 유연한 데이터 구조 표현

단점:

  • 사용 시 타입을 좁혀야 함
  • 타입 가드가 필요함

3. 타입 가드: 런타임 타입 확인

3.1 타입 가드의 필요성

유니온 타입을 사용하려면 런타임에 실제 타입을 확인해야 합니다. TypeScript는 컴파일 타임 타입만 알고 있으므로, 런타임 검사로 타입을 좁혀야 합니다.

3.2 instanceof 연산자: 클래스 인스턴스 확인

const toDate = (value: Date | { toDate: () => Date }): Date => value instanceof Date ? value : value.toDate()

동작 원리:

  • instanceof는 프로토타입 체인을 확인
  • Date 인스턴스면 true
  • TypeScript는 true 분기에서 valueDate로 좁힘

타입 좁히기:

// 함수 시작: value는 Date | { toDate: () => Date } if (value instanceof Date) { // 여기서: value는 Date return value } else { // 여기서: value는 { toDate: () => Date } return value.toDate() }

3.3 in 연산자: 구조적 타이핑 확인

const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number)

동작 원리:

  • in은 객체에 특정 속성이 있는지 확인
  • 'toDate' in post.createdAttoDate 메서드 존재 여부 확인
  • TypeScript는 이를 타입 좁히기에 활용

구조적 타이핑:

  • TypeScript는 형태(shape)를 기준으로 타입을 판단
  • toDate() 메서드가 있으면 해당 타입으로 간주 가능

3.4 타입 가드의 계층적 구조

// 1단계: Date 인스턴스인지 확인 if (post.createdAt instanceof Date) { // Date 타입으로 좁혀짐 } // 2단계: toDate 메서드가 있는지 확인 else if ('toDate' in post.createdAt) { // { toDate: () => Date } 타입으로 좁혀짐 } // 3단계: 그 외 (문자열 또는 숫자) else { // string | number 타입으로 좁혀짐 }

각 단계에서 타입이 좁혀지며, TypeScript가 해당 분기에서 정확한 타입을 추론합니다.

4. 타입 단언: 개발자의 타입 확신

4.1 타입 단언의 개념

타입 단언(as)은 "이 값은 이 타입이다"라고 TypeScript에 알립니다. 컴파일러 검사를 우회하므로 주의가 필요합니다.

const createdAtDate = toDate(post.createdAt as Date | { toDate: () => Date })

사용 이유:

  • post.createdAtTimestamp | Date
  • toDateDate | { toDate: () => Date }를 받음
  • Timestamp{ toDate: () => Date } 형태이므로 단언으로 맞춤

4.2 타입 단언 vs 타입 변환

타입 단언:

  • 런타임 변환 없음
  • 컴파일 타임에만 영향
  • 개발자가 타입을 보장해야 함

타입 변환:

  • 런타임에 실제 변환 수행
  • toDate() 같은 메서드 호출
// 타입 단언 (런타임 변환 없음) const value = someValue as Date // 타입 변환 (런타임 변환 수행) const value = someValue.toDate() // Timestamp → Date

5. 조건부 타입 변환 함수

5.1 함수 시그니처의 의미

const toDate = (value: Date | { toDate: () => Date }): Date => value instanceof Date ? value : value.toDate()

함수 시그니처 분석:

  • 입력: Date | { toDate: () => Date }
  • 출력: Date
  • 의미: 두 입력 타입을 모두 Date로 변환

5.2 타입 추론의 동작

TypeScript는 조건문에서 타입을 좁힙니다:

function toDate(value: Date | { toDate: () => Date }): Date { if (value instanceof Date) { // TypeScript는 여기서 value를 Date로 추론 return value // Date 반환 } else { // TypeScript는 여기서 value를 { toDate: () => Date }로 추론 return value.toDate() // Date 반환 } }

타입 추론의 흐름:

  1. 함수 시작: valueDate | { toDate: () => Date }
  2. instanceof Date 체크 후: true 분기에서 Date, false 분기에서 { toDate: () => Date }
  3. 반환: 두 분기 모두 Date 반환

6. 서버/클라이언트 환경의 타입 차이

6.1 환경별 타입 차이의 원인

Next.js는 서버와 클라이언트에서 같은 코드가 실행됩니다. 하지만:

서버 사이드:

  • Firestore에서 직접 가져옴
  • Timestamp 타입

클라이언트 사이드:

  • 직렬화/역직렬화 과정
  • TimestampDate로 변환되거나 다른 형태로 전달될 수 있음

6.2 직렬화의 영향

JSON 직렬화:

// 서버에서 const post = { createdAt: Timestamp.now() // Timestamp 객체 } // JSON으로 직렬화 JSON.stringify(post) // → { "createdAt": { "seconds": 1234567890, "nanoseconds": 0 } } // 클라이언트에서 역직렬화 JSON.parse(jsonString) // → { "createdAt": { seconds: 1234567890, nanoseconds: 0 } } // Timestamp 객체가 아닌 일반 객체!

문제:

  • 직렬화 후 Timestamp 인스턴스가 아님
  • instanceof Timestampfalse
  • toDate() 메서드가 없을 수 있음

6.3 방어적 타입 처리

const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number)

이 코드는:

  1. Date 인스턴스면 그대로 사용
  2. toDate() 메서드가 있으면 호출
  3. 그 외에는 Date 생성자로 변환 시도

각 환경에서 안전하게 동작합니다.

7. 타입 시스템의 계층적 설계

7.1 인터페이스 레벨: 유니온 타입 정의

export interface BlogPost { createdAt: Timestamp | Date updatedAt: Timestamp | Date }

의도:

  • 여러 가능성을 타입으로 명시
  • 사용하는 쪽에서 변환 책임을 가짐

7.2 함수 레벨: 타입 변환 로직

const toDate = (value: Date | { toDate: () => Date }): Date => value instanceof Date ? value : value.toDate()

의도:

  • 공통 변환 로직 제공
  • 타입 안전성 보장

7.3 사용 레벨: 방어적 처리

const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number)

의도:

  • 예외 상황까지 처리
  • 최대한 안전하게 변환

8. TypeScript의 타입 시스템 철학

8.1 구조적 타이핑 (Structural Typing)

TypeScript는 형태를 기준으로 타입을 판단합니다:

interface HasToDate { toDate: () => Date } // Timestamp는 HasToDate와 호환됨 const timestamp: Timestamp = Timestamp.now() const hasToDate: HasToDate = timestamp // ✅ 가능 // toDate 메서드가 있는 모든 객체는 HasToDate로 취급 가능 const obj = { toDate: () => new Date() } const hasToDate2: HasToDate = obj // ✅ 가능

의미:

  • 이름이 아니라 구조로 타입을 판단
  • toDate() 메서드가 있으면 해당 타입으로 간주 가능

8.2 타입 좁히기 (Type Narrowing)

조건문에서 타입을 좁히는 과정:

function process(value: Date | { toDate: () => Date }) { // 여기서: value는 Date | { toDate: () => Date } if (value instanceof Date) { // 여기서: value는 Date (타입이 좁혀짐) return value.getTime() } else { // 여기서: value는 { toDate: () => Date } (타입이 좁혀짐) return value.toDate().getTime() } }

TypeScript는:

  • instanceof, in, typeof 등을 타입 좁히기에 활용
  • 각 분기에서 정확한 타입을 추론

8.3 타입 안전성과 유연성의 균형

이 문제 해결은 타입 안전성과 유연성의 균형을 보여줍니다:

타입 안전성:

  • 유니온 타입으로 가능성 명시
  • 타입 가드로 런타임 검사

유연성:

  • 여러 타입을 하나의 인터페이스로 표현
  • 환경에 따라 다른 타입 허용

9. 실제 적용: 코드에서의 타입 흐름

9.1 데이터 가져오기 단계

// lib/blog.ts export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => { // ... return { id: docSnap.id, ...docSnap.data(), createdAt: docSnap.data().createdAt.toDate(), // Timestamp → Date 변환 updatedAt: docSnap.data().updatedAt.toDate(), } as BlogPost }

타입 흐름:

  • Firestore: Timestamp
  • 변환: toDate()Date
  • 반환: BlogPost (인터페이스는 Timestamp | Date 허용)

9.2 사용 단계

// app/blog/[tag]/[slug]/page.tsx const toDate = (value: Date | { toDate: () => Date }): Date => value instanceof Date ? value : value.toDate() const createdAtDate = toDate(post.createdAt as Date | { toDate: () => Date })

타입 흐름:

  • 입력: post.createdAt (타입: Timestamp | Date)
  • 단언: as Date | { toDate: () => Date }
  • 변환: toDate() 함수로 Date로 변환
  • 결과: Date 객체

10. 타입 시스템의 한계와 극복

10.1 런타임 타입 정보의 부족

TypeScript는 컴파일 타임에만 타입 정보를 가지고 있습니다:

// 컴파일 타임: TypeScript는 이게 Timestamp | Date라고 알고 있음 const value: Timestamp | Date = getFromFirestore() // 런타임: 실제로는 Timestamp인지 Date인지 모름 // → 타입 가드 필요! if (value instanceof Date) { // 런타임 검사로 타입 좁히기 }

해결:

  • 타입 가드로 런타임 검사
  • instanceof, in, typeof 활용

10.2 직렬화로 인한 타입 정보 손실

JSON 직렬화는 타입 정보를 잃습니다:

// 서버 const timestamp = Timestamp.now() // Timestamp 객체 // 직렬화 const json = JSON.stringify({ createdAt: timestamp }) // → { "createdAt": { "seconds": ..., "nanoseconds": ... } } // 역직렬화 const data = JSON.parse(json) // → { createdAt: { seconds: ..., nanoseconds: ... } } // Timestamp 객체가 아님!

해결:

  • 구조적 타이핑 활용 ('toDate' in value)
  • 방어적 타입 처리

11. 정리

  1. 유니온 타입: 여러 가능성을 타입으로 표현
  2. 타입 가드: 런타임 검사로 타입 좁히기
  3. 구조적 타이핑: 형태 기반 타입 판단
  4. 타입 단언: 개발자의 타입 확신 표현
  5. 방어적 프로그래밍: 예외 상황까지 고려

결과:

  • 타입 안전성 유지
  • 다양한 환경에서 안전하게 동작
  • 명확한 타입 의도 표현