2D와 실사 캐릭터 타입을 완전히 분리해 스키마 오염 해소
목차
캐릭터 시스템에서 2D와 실사를 별개의 관계로 완전히 분리했다. 한 번에 끝낼 수 있는 작은 작업이지만, 그 배경과 의미를 풀어 이야기해보려 한다.
왜 분리가 필요했나
기존에는 characters.ts에서 2D 캐릭터와 실사 이미지/비디오를 동일한 데이터 구조로 다루고 있었다. 처음엔 간단해 보였다. 양쪽 다 "캐릭터"니까 같은 테이블, 같은 필드로 묶으면 되지 않나 싶었던 것 같다.
하지만 시간이 지날수록 문제가 드러났다:
- 2D 캐릭터는 레이어 정보, 애니메이션 프레임 인덱스, 이모션 변수 같은 걸 필요로 한다.
- 실사는 해상도, 자르기 좌표, 뒤 배경과의 블렌드 모드 같은 다른 메타데이터가 필요하다.
- 둘 다 관리해야 하니 스키마가 비효율적으로 부풀어올랐다. null 필드가 많아지고, 쿼리/필터링할 때도 "type='2d'인 경우만" 같은 조건이 난무했다.
코드리뷰 때 팀원들도 이 구조를 자주 지적했다. "이거 두 가지 케이스를 따로 봐야 하지 않나?", "필드가 너무 섞여 있어서 헷갈려" 같은 의견들.
어떻게 분리했나
분리 패턴은 직관적이었다:
// Before (혼재된 구조)
interface Character {
id: string
name: string
type: '2d' | 'photo'
// 2D만 필요
layers?: Layer[]
animationFrames?: Frame[]
// 실사만 필요
photoUrl?: string
resolution?: '1080p' | '4k'
backgroundColor?: string
}
// After (분리된 구조)
interface Character2D {
id: string
name: string
format: '2d'
layers: Layer[]
animationFrames: Frame[]
// 2D 고유 속성만
}
interface CharacterPhoto {
id: string
name: string
format: 'photo'
photoUrl: string
resolution: '1080p' | '4k'
backgroundColor: string
// 실사 고유 속성만
}
type Character = Character2D | CharacterPhoto
핵심은 Union Type으로 "2D 캐릭터는 이 필드들을 가짐", "실사는 다른 필드들을 가짐"을 타입 레벨에서 명확하게 한 것. TypeScript를 쓰고 있다면 이 정도면 충분하다. 런타임에서 type guard 추가하고, 쿼리 레이어에서도 분리해서 처리하면 된다.
이게 주는 영향
작은 변경처럼 보이지만 꽤 광범위했다:
| 측면 | 변경 전 | 변경 후 |
|---|---|---|
| 필드 오염 | 2D용 필드가 실사에도 존재 (null로 채워짐) | 각 타입의 필드만 필요 |
| 쿼리 복잡도 | WHERE type='2d' AND layers IS NOT NULL 형태 |
분리된 테이블/쿼리로 깔끔함 |
| 새 기능 추가 | 2D와 실사 둘 다 고려해서 스키마 확장 | 한쪽만 건드리면 됨 |
| 타입 안정성 | runtime에 체크해야 함 | 컴파일 타임에 자동 검증 |
| 성능 | 필터링/인덱싱 시 타입 조건 추가 필요 | 조건 감소 |
프론트엔드에서도 "이 캐릭터가 2D인지 실사인지" 계속 확인하던 패턴이 명확해졌다. 렌더링 로직도 이제 "Character2D면 이렇게, CharacterPhoto면 저렇게" 분기가 훨씬 읽기 좋아졌다.
비슷한 상황에서의 일반론
이런 분리 패턴은 도메인 주도 설계(DDD)나 SOLID 원칙에서 자주 나오는 형태다. 단일 책임 원칙(Single Responsibility Principle)으로 보면, 2D와 실사는 서로 다른 책임을 가지고 있다. 같은 객체로 묶는 게 오히려 혼란을 만든다.
다른 예시:
- 사용자 계정 vs 운영자 계정: 권한, 접근 범위가 다르면 분리
- 결제 완료 주문 vs 환불 대기 주문: 상태에 따른 속성이 완전히 다르면 별개 타입으로
- 공개 포스트 vs 드래프트: 공개 포스트만 필요한 필드(댓글 수, 좋아요)가 있으면 분리
공통점은: 조건부로 존재하는 필드가 많아질수록, 그리고 처리 로직이 발산할수록 분리의 필요성이 커진다는 것.
코드리뷰 관점에서
이 작업을 PR로 올렸을 때 팀의 피드백은 긍정적이었다. 그럴만한 이유가 몇 개 있었다:
- 기존 버그/이상 동작이 명확해짐: "어? 왜 이 필드가 null이지?" 같은 혼동이 줄어들었다.
- 마이그레이션 범위가 명확함: 어떤 쿼리를 바꿔야 하고, 어떤 렌더링 로직을 테스트해야 하는지 일목요연했다.
- 미래 확장이 쉬움: 다음에 캐릭터 표현 방식이 추가되어도 (예: 3D 모델), 같은 패턴으로 추가하면 된다.
다만 주의할 점은 마이그레이션이다. 기존 데이터를 새 스키마로 옮길 때 실수하면 타입 정보를 손실할 수 있다. 데이터베이스 마이그레이션 스크립트를 정확하게 작성했는지 여러 번 검증해야 했다.
배운 점
결국 이건 "설계는 요구사항이 명확해진 후에도 계속 진화한다"는 교훈이다. 초기에 2D와 실사가 이렇게까지 다를 줄 몰랐고, 초기 설계는 합리적이었다. 하지만 시스템이 성장하면서 불일치가 눈에 띄면, 그때 정리하는 게 중요하다. 너무 미루다 보면 쌓인 코드가 너무 많아져서 리팩토링이 훨씬 힘들어진다.
또 하나는 타입 시스템의 가치다. 동적 언어였다면 "아, 이 필드는 2D일 때만 쓰니까 undefined일 수 있겠지?" 정도로 끝날 텐데, TypeScript 덕분에 컴파일 에러로 미리 잡을 수 있었다. 코드를 짤 때는 번거로울 수 있지만, 장기적으로는 이런 게 버그를 줄인다.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.