SSOT(Single Source of Truth) 패턴
하나의 원본 타입에서 화면·API 타입을 파생하는 TypeScript SSOT 패턴
정보
같은 데이터를 타입으로 두 번 정의하지 말고, 원본 타입 하나에서
Pick·Omit·extends로 필요한 형태만 파생해서 쓰는 게 TypeScript에서의 SSOT예요.
🤔 시작하기 전에
User랑 Post를 프론트에서 새로 짜고, 목록용·상세용·폼용으로 또 따로 만든 적 있나요?
처음엔 빠른데, API가 바뀌거나 id가 number에서 string으로 바뀌면 손댈 파일이 한꺼번에 늘어나요.
이 글에서 말하는 SSOT(Single Source of Truth, 단일 진실 공급원) 는 타입 버전으로 이렇게 이해하면 돼요.
User, Post 같은 원본은 한 곳에만 두고, 목록 카드·상세 화면·API 응답·폼 입력 타입은 전부 그걸로부터 파생한다는 뜻이에요.
원본이 Prisma나 OpenAPI에서 생성됐든, types/schema에 수동으로 한 번 적어뒀든, 이후 패턴은 같아요.
🤔 문제/배경
0️⃣ 왜 이 주제를 다루는가
타입은 자동완성용 장식이 아니라 백엔드랑 맞춰 둔 계약에 가깝다고 봐요.
name이 optional인지, userId가 있는지가 파일마다 다르면 컴파일은 통과해도 런타임에서 깨지기 쉬워요.
프론트·BFF·백이 나뉘면 “프론트만의 User”가 생기고, 필드 하나 바꿀 때 grep 지옥이 되기도 하고요.
원래 SSOT는 데이터 쪽 말이에요. 위키의 SSOT처럼 같은 사실은 한 곳에서만 정의하고 나머지는 읽거나 파생하는 구조죠.
The Pragmatic Programmer의 DRY—“지식은 한 번, 명확하게”—와도 방향이 같아요. 타입도 지식의 일부라서, interface Post를 여러 파일에 복붙하는 건 DRY를 깨는 셈이에요.
1️⃣ 기존 방식의 한계
화면마다 비슷한 interface를 새로 쓰는 경우가 대표적이에요.
interface User { id: number; name: string; email: string; passwordHash: string; } interface Post { id: number; title: string; authorId: number; } interface PostDetail { id: number; title: string; authorName: string; }
authorName은 결국 User.name인데 이름도 다르고, Post랑 PostDetail의 title도 따로 놀아요.
API 문서 보고 IUser, IGetStoriesAPIResponse를 각각 손으로 쓰는 것도 비슷해요. openapi-typescript 글처럼 도구로 타입을 받아와도, 받아온 User 옆에 또 interface MyUser { ... }를 두면 원본이 둘이 돼요.
백엔드에 이미 User 타입이 있는데 프론트 types/user.ts에 비슷한 interface를 또 두는 경우도 있어요. 마이그레이션 후 한쪽만 고치는 실수가 나오죠.
정리하면 이런 일이 반복돼요.
- 중복: 같은 필드를 여러 interface에 복붙
- 불일치:
authorNamevsname처럼 의미는 같은데 이름·타입이 다름 - 동기화 비용: 바뀔 때 “진짜 타입이 어디지?”부터 찾음
이번 글은 타입을 어떻게 만드는 도구 이야기가 아니라, 원본 하나를 정한 뒤 어떻게 파생할지예요.
⚙️ 해결 방법
0️⃣ 핵심 아이디어
정보
엔티티(
User,Post…)는 한 번만 정의하고, 화면·API·폼 타입은extends,Pick,Omit,Partial로만 만든다.
흐름은 단순해요.
- 원본 타입 —
User,Post(SSOT) - 파생 타입 —
PostListItem,PostWithUser,PublicUser…
새 화면이 생길 때마다 interface PostCard { id; title }를 쓰지 말고, interface PostCard extends Pick<Post, "id" | "title"> {}처럼 원본에서만 만드는 게 목표예요.
1️⃣ 원본 타입은 어디에 두나
원본은 “내가 직접 필드를 나열한 딱 한 벌”이면 돼요. 보통은 아래 둘 중 하나예요.
- 백엔드·스키마에서 생성된 타입을 import — Prisma
prisma generate, openapi-typescript로 OpenAPI 스펙에서 뽑은User등. 도구는 원본을 만들어 주는 역할이고, 이 글의 핵심은 그다음 파생이에요. - 생성이 없을 때 —
src/types/schema/index.ts처럼 프로젝트 안 한 파일에만interface User를 두기
OpenAPI 쪽은 orval 같은 생성기도 있지만, 저는 쓰지 않아서 여기서는 깊게 다루지 않을게요. 원본만 import해서 아래 패턴을 쓰면 동일해요.
// 원본 — import이거나 schema 한 파일 // import type { User, Post } from '@prisma/client' // import type { components } from '@/generated/openapi' interface User { id: number; name: string; email: string; passwordHash: string; } interface Post { id: number; title: string; authorId: number; }
여러 파일에 interface User { ... }를 복붙하지 않는 게 1단계예요.
2️⃣ Pick·Omit·extends로 파생하기
Pick: 노출할 필드만 (목록, 공개 프로필)Omit: 빼야 할 필드가 분명할 때 (passwordHash,id)Partial: 수정·패치처럼 일부만 optionalextends: 원본에 관계·뷰 필드 붙일 때
제가 자주 쓰는 형태는 이거예요.
interface PostWithUser extends Post { user: Pick<User, "name">; } interface PostListItem extends Pick<Post, "id" | "title"> {} interface PostFormInput extends Pick<Post, "title"> {} interface PostUpdatePayload extends Partial<Pick<Post, "title">> {} interface PublicUser extends Omit<User, "passwordHash" | "email"> {} interface CreatePostInput extends Omit<Post, "id" | "authorId"> {}
PostWithUser는 Post를 extends하고, 붙는 user는 User 전체가 아니라 Pick<User, "name">만 써요. 상세 화면에 이름만 필요할 때 전형적인 패턴이죠.
Pick/Omit은 얕은(shallow) 연산이에요. Post 안에 metadata: { tags: string[] }가 있으면 metadata 통째로 가져오고, 안쪽 tags만 빼려면 별도 타입이 필요해요.
extends랑 & 둘 다 쓰이는데, 엔티티 확장은 interface X extends Post가 읽기 편해서 interface를 선호해요.
3️⃣ 유틸 타입 조합
한 타입에서 “일부만 optional”이면 Partial<Pick<...>> 조합이 편해요.
interface UpdateUserPayload extends Partial<Pick<User, "name" | "email">> {} interface AdminUserUpdate extends Partial<Pick<Omit<User, "passwordHash">, "name" | "email">> { role: "admin" | "member"; }
Omit → Pick → Partial 순서만 익혀두면, 원본 User/Post는 그대로 두고 화면마다 다른 “뷰”만 붙일 수 있어요.
🧪 예시
0️⃣ import한 원본에서 바로 파생
원본을 백엔드 생성 타입에서 가져온 경우도 파생 방식은 동일해요.
import type { User, Post } from "@prisma/client"; // 또는 import type { components } from '@/generated/openapi' // interface User extends components['schemas']['User'] {} interface PostWithAuthor extends Post { user: Pick<User, "name">; }
생성 파일(generated, @prisma/client 출력물)은 수정하지 않고, features/post/types.ts 같은 곳에서만 extends/Pick을 씁니다.
1️⃣ React·API에서 쓰기
interface PostListItem extends Pick<Post, "id" | "title"> {} interface PostCardProps { post: PostListItem; onSelect: (id: PostListItem["id"]) => void; } interface GetPostResponse extends Post { user: Pick<User, "name">; } async function fetchPost(id: Post["id"]): Promise<GetPostResponse> { const res = await fetch(`/api/posts/${id}`); return res.json(); }
Post["id"], PostListItem["id"]처럼 쓰면 id 타입이 바뀔 때 원본 Post만 고치면 props·함수 시그니처까지 따라가요.
2️⃣ 관계가 있는 도메인
interface Product { id: number; name: string; price: number; } interface Order { id: number; userId: number; lines: { productId: number; quantity: number }[]; createdAt: string; } interface OrderSummary extends Pick<Order, "id" | "createdAt"> { lineCount: number; totalAmount: number; } interface OrderDetail extends Order { products: Pick<Product, "name" | "price">[]; }
Order가 원본이고, 목록용 OrderSummary는 계산 필드만 추가해요. lines 구조를 또 정의하지 않아도 됩니다.
3️⃣ 반복 패턴·안티패턴
여러 번 쓰는 조합은 types/utils.ts에 이름을 붙여두면 편해요.
interface WithoutId<T extends { id: unknown }> extends Omit<T, "id"> {} interface PartialBy<T, K extends keyof T> extends Omit<T, K>, Partial<Pick<T, K>> {} interface CreateUserInput extends WithoutId<User> {} interface PatchUserInput extends PartialBy<User, "name" | "email"> {}
// ❌ 화면마다 Post 재정의 interface PostCard { id: number; title: string; } // ✅ 원본 Post에서 파생 interface PostCard extends Pick<Post, "id" | "title"> {}
// ❌ API 응답을 매번 새로 interface GetUsersResponse { users: { id: number; name: string }[]; } // ✅ 원본 User에서 파생 interface GetUsersResponse { users: Pick<User, "id" | "name">[]; }
Zod·class-validator 같은 런타임 검증은 컴파일 타임 SSOT와 별개예요. Zod를 또 하나의 “진짜”로 두면 원본이 둘로 갈라질 수 있어서, 필요하면 “DB/OpenAPI 원본 → Zod 생성” 또는 “Zod → z.infer” 중 하나만 고르는 편이 낫다고 봐요.
📝 정리
0️⃣ 핵심 요약
- 원본 하나:
User,Post는 한 곳(import 또는types/schema한 파일) - 파생만 추가:
interface PostWithUser extends Post { user: Pick<User, "name"> }처럼 화면·API·폼은 유틸 타입만 - 도구는 부가: Prisma·openapi-typescript는 원본을 가져오는 수단일 뿐, 설계의 중심은 파생
- 주의:
Pick/Omit은 shallow, 생성된 원본 파일은 직접 수정하지 않기
1️⃣ 다음에 해볼 것
- 새
interface만들기 전에 “원본Post에서Pick/Omit/extends로 되나?” - 파생 타입은
features/.../types.ts처럼 원본 옆 레이어에만 두기 - 팀에서 원본 위치(import 경로 vs
types/schema)만 합의하기
2️⃣ 관련 글
- openapi-typescript: OpenAPI 스펙으로 TypeScript 타입 자동 생성하기 — 원본 타입을 만드는 예시
- Single source of truth (Wikipedia)