들어가며
지난 글에서 Branded Type으로 유틸 함수 사용을 강제하는 방법을 다뤘어요. getSnvAnchorId, getCnvAnchorId 같은 개별 함수들을 만들어서 각 variant type마다 올바른 ID 포맷을 보장했죠.
근데 variant type이 6개가 넘어가면서 문제가 생기기 시작했어요. 함수가 너무 많아져서 어떤 게 있는지 찾기가 힘들었어요. 자동완성으로 get만 쳐도 수십 개가 뜨는데 그 중에서 anchor id 관련 함수만 골라내는 것도 일이었죠.
마침 예전에 사용하던 쿼리 키 팩토리 패턴이 떠올랐어요. 쿼리 키처럼 일괄 invalidate할 일은 없지만, 관련 함수를 하나의 객체로 묶는다는 아이디어 자체가 딱 맞았어요.
// 기존 - 흩어진 개별 함수들
export const getSnvAnchorId = (symbol: string): VariantAnchorId => ...;
export const getCnvAnchorId = (pos: string, consequence: string): VariantAnchorId => ...;
export const getUpdAnchorId = (pos: string): VariantAnchorId => ...;
// 원하는 모습 - 한 객체로 그룹핑
const anchorKeys = {
snv: (symbol: string) => ...,
cnv: (pos: string, consequence: string) => ...,
upd: (pos: string) => ...,
};
근데 여기서 한 가지 문제가 있어요. 이렇게 그냥 객체로 만들면 각 함수의 리턴 타입을 일일이 VariantAnchorId로 지정해야 해요. 6개 함수면 6번 써야 하는 거죠.
// 리턴 타입을 매번 써야 함
const anchorKeys = {
snv: (symbol: string): VariantAnchorId => `SNV${symbol}` as VariantAnchorId,
cnv: (pos: string, consequence: string): VariantAnchorId => `CNV${pos}${consequence}` as VariantAnchorId,
// ...
};
이게 귀찮기도 하지만, 더 큰 문제는 실수할 여지가 생긴다는 거예요. 새 함수를 추가할 때 리턴 타입을 깜빡하면 그냥 string이 되어버려요.
defineKeyStore - 팩토리 헬퍼
그래서 defineKeyStore라는 헬퍼 함수를 만들었어요.
export function defineKeyStore<
Tag extends string,
T extends Record<string, unknown>
>(_tag: Tag, formatters: T): BrandFormatters<Tag, T> & { readonly _tag: Tag } {
return Object.assign({ _tag }, formatters) as unknown as BrandFormatters<
Tag,
T
> & { readonly _tag: Tag };
}
사용하는 쪽은 훨씬 간단해져요.
const anchorKeys = defineKeyStore("VariantAnchorId", {
snv: (symbol: string): string => `SNV${symbol}`,
cnv: (pos: string, consequence: string): string => `CNV${pos}${consequence}`,
upd: (pos: string): string => `UPD:${pos}`,
});
// 이제 전부 VariantAnchorId 타입
anchorKeys.snv("BRCA1"); // VariantAnchorId
anchorKeys.cnv("chr1:1000", "missense"); // VariantAnchorId
함수 정의에서는 그냥 string을 리턴하면 돼요. defineKeyStore가 알아서 모든 함수의 리턴 타입을 BrandedKey<Tag>로 바꿔줘요.
BrandFormatters - 재귀적 타입 변환
defineKeyStore의 핵심은 BrandFormatters 타입이에요.
type BrandFormatters<Tag extends string, T> = {
readonly [K in keyof T]: T[K] extends (...args: infer A) => string
? (...args: A) => BrandedKey<Tag>
: T[K] extends Record<string, unknown>
? BrandFormatters<Tag, T[K]>
: never;
};
이게 하는 일을 풀어보면:
- 입력 객체
T의 각 키K를 순회해요 T[K]가 함수면 → 파라미터는 그대로 두고 리턴 타입만BrandedKey<Tag>로 바꿔요T[K]가 중첩 객체면 → 재귀적으로 내려가서 똑같이 처리해요
예를 들어 이런 입력이 들어오면:
{
snv: (symbol: string) => string,
cnv: (pos: string) => string
}
이렇게 바뀌어요:
{
snv: (symbol: string) => BrandedKey<"VariantAnchorId">,
cnv: (pos: string) => BrandedKey<"VariantAnchorId">
}
함수는 똑같은데 타입만 바뀌는 거죠.
중첩 구조 지원
SNV와 INS variant는 row/subRow 개념이 있어서 ID를 두 단계로 만들어야 했어요.
// SNV row: SNV + symbol
// SNV subRow: SNV + symbol + title + phenoId + geneId
처음엔 flat하게 snvRow, snvSubRow로 만들려다가, 중첩으로 만드는게 의미상 더 명확할 것 같았어요.
const variantAnchorKeys = defineKeyStore("VariantAnchorId", {
snv: {
row: (symbol: string): string => `SNV${symbol}`,
subRow: (
symbol: string,
title: string,
phenoId: string,
geneId: string
): string => `SNV${symbol}${title}${phenoId}${geneId}`
},
cnv: (representativePos: string, consequence: string): string =>
`CNV${representativePos}${consequence}`,
// ...
});
// 사용
variantAnchorKeys.snv.row("BRCA1");
variantAnchorKeys.snv.subRow("BRCA1", "pathogenic", "OMIM:123", "ENSG456");
variantAnchorKeys.cnv("chr1:1000", "missense");
BrandFormatters가 재귀 타입이라 중첩 깊이에 상관없이 전부 branded type으로 바뀌어요. 자동완성도 제대로 동작하고요.
_tag 프로퍼티로 디버깅 지원
팩토리 객체에는 _tag 프로퍼티가 포함돼요.
variantAnchorKeys._tag // "VariantAnchorId"
런타임에서 값이 어디서 왔는지 확인할 때 유용해요. 브랜드가 여러 개 있을 때(예: GeneKey, DiseaseKey, VariantAnchorId) 디버거에서 obj._tag를 보면 바로 알 수 있거든요.
번들 사이즈 걱정은 안 해도 돼요. _tag는 문자열 리터럴이라 minify 되면 아주 작은 크기만 차지해요.
InferKey로 타입 추출
팩토리 객체에서 branded type을 추출하는 유틸리티도 만들었어요.
export type InferKey<T extends Record<string, unknown>> = ExtractBrandedKey<
T[keyof T]
>;
사용하는 쪽에서 타입이 필요할 때 이렇게 쓰면 돼요.
export type VariantAnchorId = InferKey<typeof variantAnchorKeys>;
이전에는 BrandedKey<"VariantAnchorId">를 직접 선언했는데, 이제 팩토리에서 자동으로 추론돼요. 팩토리 정의 하나가 키 포맷과 타입의 single source of truth가 되는 거죠
마이그레이션 결과
리팩토링 전:
// get-variant-anchor-id.ts
export const getSnvAnchorId = (symbol: string): VariantAnchorId => ...;
export const getSnvSubRowAnchorId = (symbol, title, phenoId, geneId): VariantAnchorId => ...;
export const getCnvAnchorId = (pos, consequence): VariantAnchorId => ...;
// ... 18개 함수
리팩토링 후:
export const variantAnchorKeys = defineKeyStore("VariantAnchorId", {
snv: {
row: (symbol: string): string => `SNV${symbol}`,
subRow: (symbol, title, phenoId, geneId): string => `SNV${symbol}${title}${phenoId}${geneId}`,
},
cnv: (pos, consequence): string => `CNV${pos}${consequence}`,
// ...
});
export type VariantAnchorId = InferKey<typeof variantAnchorKeys>;
자동완성에서 variantAnchorKeys. 만 치면 모든 variant type이 그룹핑되어 보여요. 예전처럼 get으로 시작하는 수십 개 함수 사이에서 찾지 않아도 돼요.
비즈니스 로직과 분리
이 구조에서 좋았던 점 하나가, 브랜딩 메커니즘과 키 포맷(도메인 지식)이 완전히 분리된다는 거예요.
defineKeyStore, BrandedKey, BrandFormatters 같은 타입 인프라는 shared/lib/string-key-store.ts에 있고, 실제로 “SNV anchor id는 SNV${symbol} 포맷이다”라는 도메인 지식은 각 도메인 모듈이 가지고 있어요.
shared/lib/string-key-store.ts → 브랜딩 메커니즘 (도메인 무관)
variant/keys/variant-anchor-keys.ts → VariantAnchorId 포맷 (도메인 지식)
gene/keys/gene-keys.ts → GeneKey 포맷 (도메인 지식)
string-key-store.ts는 “문자열 함수의 리턴 타입을 branded type으로 바꿔주는” 것만 해요. variant가 뭔지, gene이 뭔지 전혀 몰라요. 덕분에 새 도메인이 추가돼도 인프라 코드를 건드릴 필요가 없어요. defineKeyStore를 호출해서 새 팩토리를 만들면 끝이에요.
팩토리 정의 파일 하나가 해당 branded type의 SSOT(single source of truth)가 돼요. 키 포맷, 타입 정의, 자동완성 목록이 전부 한 파일에서 나오니까 “이 ID는 어디서 어떻게 만드는 거지?”라는 질문에 답할 장소가 하나로 정해져요.
마치며
정리하면, defineKeyStore는 지난 글의 branded type 패턴에 발견 가능성을 더한 거예요. 지난 글에서는 “유틸을 타입으로 강제한다”까지 갔는데, 유틸이 많아지면 그 유틸 자체를 못 찾는 문제가 다시 생기거든요. 팩토리로 묶으면 자동완성 한 번에 전부 보이니까 그 문제가 사라져요.
문자열 기반 ID를 많이 다루는 프로젝트라면 써볼 만한 패턴인것 같아요. 여기 첨부는 못하지만 도메인별로 한곳에 모아 관리하니 매우 만족스러웠습니다 ㅎㅎ
전체 코드 및 유즈케이스는 아래 레포에서 확인 가능합니다
https://github.com/donghyun1998/string-key-factory
GitHub - donghyun1998/string-key-factory: branded type기반 string 리터럴 관리 팩토리
branded type기반 string 리터럴 관리 팩토리. Contribute to donghyun1998/string-key-factory development by creating an account on GitHub.
github.com
'<frontend>' 카테고리의 다른 글
| 원자적 action으로 훅의 확장성 확보하기 (0) | 2026.02.21 |
|---|---|
| Branded Type으로 유틸함수 강제하기 (0) | 2026.02.07 |
| 토스 Frontend Fundamentals 모의고사 후기 (15) | 2025.11.26 |
| 클라이언트 데이터 정규화 (15) | 2025.11.15 |
| 주석대신 순수함수로 TODO적기 (20) | 2025.10.19 |