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 }
문제:
Timestamp와Date는 다른 타입Timestamp는toDate()로Date로 변환해야 함- 서버/클라이언트 환경에 따라 실제 타입이 달라질 수 있음
2. TypeScript 유니온 타입으로 타입 표현
2.1 유니온 타입이 뭔데
TypeScript의 유니온 타입(|)은 "이 타입 또는 저 타입"을 표현합니다.
export interface BlogPost { createdAt: Timestamp | Date updatedAt: Timestamp | Date }
의미:
createdAt은Timestamp또는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분기에서value를Date로 좁힘
타입 좁히기:
// 함수 시작: 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.createdAt은toDate메서드 존재 여부 확인- 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.createdAt은Timestamp | DatetoDate는Date | { 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 반환 } }
타입 추론의 흐름:
- 함수 시작:
value는Date | { toDate: () => Date } instanceof Date체크 후:true분기에서Date,false분기에서{ toDate: () => Date }- 반환: 두 분기 모두
Date반환
6. 서버/클라이언트 환경의 타입 차이
6.1 환경별 타입 차이의 원인
Next.js는 서버와 클라이언트에서 같은 코드가 실행됩니다. 하지만:
서버 사이드:
- Firestore에서 직접 가져옴
Timestamp타입
클라이언트 사이드:
- 직렬화/역직렬화 과정
Timestamp가Date로 변환되거나 다른 형태로 전달될 수 있음
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 Timestamp가falsetoDate()메서드가 없을 수 있음
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)
이 코드는:
Date인스턴스면 그대로 사용toDate()메서드가 있으면 호출- 그 외에는
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. 정리
- 유니온 타입: 여러 가능성을 타입으로 표현
- 타입 가드: 런타임 검사로 타입 좁히기
- 구조적 타이핑: 형태 기반 타입 판단
- 타입 단언: 개발자의 타입 확신 표현
- 방어적 프로그래밍: 예외 상황까지 고려
결과:
- 타입 안전성 유지
- 다양한 환경에서 안전하게 동작
- 명확한 타입 의도 표현