뱁새 다리찢기

들어가며

유전체 분석 플랫폼인 GEBRA에서 variant(변이)를 클릭하면 해당 row로 스크롤되는 기능이 있어요. URL 해시 기반 네비게이션인데, 사용자 입장에서의 흐름은 이래요.

  1. 테이블 row에 id="SNV----" 속성이 부여되어 있다 (strategy)
  2. variant card에서 코멘트 버튼을 누르면, "SNV----"이 포함된 메시지가 채팅에 추가된다 (message)
  3. 나중에 채팅에서 해당 메시지의 "Go to Variant" 링크를 클릭하면, #SNV---- 해시로 이동하여 row로 스크롤된다 (navigation)

row에 부여한 ID를 메시지에 심어두고, 나중에 그걸로 찾아가는 구조죠. 세 단계가 정확히 같은 문자열을 만들어야 이 흐름이 동작하며 한 글자라도 다르면 row를 찾지 못해요.


하드코딩 인식

각 variant type마다 채팅 메시지를 만드는 코드가 VariantCommentButton 컴포넌트 안에 인라인으로 흩어져 있었어요. SNV는 SNV 컴포넌트에, CNV는 CNV 컴포넌트에. 6개 variant type별로 다른 메시지 포맷을 가지고 있었죠.

전부터 모아야겠다 생각만 하고있었는데 마침 새로운 variant type을 추가하면서 메시지 관련 부분들을 전부 건드리게 되어 작업해야겠다고 생각이 들었습니다.

function SnvCommentButton({ variant }) {
  const message = `Gene: ${variant.symbol}\n...[SNV${variant.symbol}]`;
}
function CnvCommentButton({ variant }) {
  const message = `Variant: ${variant.pos}\n...[CNV${variant.pos}${variant.consequence}]`;
}

이걸 create-variant-message.ts로 추출하면서 한 곳에 모았어요.

근데 모으고 보니 각 함수 끝에 붙는 링크 텍스트 안의 ID 패턴이 메시지뿐만 아니라 strategy의 getRowId와 navigation의 getTableRowIdFromVariant에도 똑같이 있었어요.

"SNV" + symbol  // message에서
"SNV" + symbol  // strategy에서
"SNV" + symbol  // navigation에서

같은 포맷, 세 곳, 각자 하드코딩. 여기에 사이드바에서 직접 variant를 선택했을 때 해시 이동을 처리하는 useNavigateToVariantCard도 같은 ID를 만들고 있었어요. 이게 6개 variant type 전부 마찬가지라 총 18곳이었습니다.


실제 버그 발생

이 구조가 위험하다는 건 바로 증명됐어요. 다음 PR로 variant type UPD를 추가하면서 이런 일이 벌어졌습니다.

// strategy — 콜론 포함
getRowId(row) { return "UPD:" + row.data.pos; }

// navigation — 콜론 누락
getTableRowIdFromVariant(variant) { return "UPD" + variant.pos; }

strategy가 만든 DOM id는 UPD:chr15:20000000-30000000, navigation이 만든 해시는 UPDchr15:20000000-30000000. 콜론 하나 차이로 네비게이션 버튼을 클릭하면 이동 실패 토스트만 뜨게 되었어요.

해당 스트링 포맷을 만드는 유틸함수로 공통화의 필요성을 느꼈습니다.


Step 1.이름 변경

유틸을 빼기 전에 네이밍을 먼저 정리해야 했어요. 처음에는 getSnvRowId로 지었는데, 이 ID가 하는 일을 생각해보면 "row의 id"라고 부르기엔 적합하지 않았어요.

이 문자열은 테이블 row의 DOM id이기도 하고, URL #hash이기도 하고, 채팅 메시지 링크이기도 해요. 세 역할을 관통하는 개념은 anchor이므로 getSnvAnchorId로 바꿨습니다.

Step 2. 단일 소스로 추출하기

첫 번째 조치는 당연히 포맷을 한 곳에 모으는 거였어요.

// shared/lib/get-variant-anchor-id.ts
export const getSnvAnchorId = (symbol: string) => `SNV${symbol}`;
export const getCnvAnchorId = (pos: string, consequence: string) => `CNV${pos}${consequence}`;
export const getUpdAnchorId = (pos: string) => `UPD:${pos}`;

세 소비처 모두 이 유틸 호출로 교체했어요.

// strategy
getRowId(row) { return getSnvAnchorId(row.data[0].symbol); }
// navigation
getTableRowIdFromVariant(v) { return getSnvAnchorId(v.symbol); }
// message
createSnvMessage(p) { return createLinkText(getSnvAnchorId(p.symbol)); }

이제 포맷을 바꿀 일이 생기면 유틸 함수 하나만 수정하면 돼요. UPD 콜론 같은 문제는 구조적으로 불가능해졌죠.
하지만 곰곰이 생각해보면 하나 빠져있어요. 이 유틸의 존재를 모르는 개발자는 어떡하지?
먼 곳에서 해당 메시지를 사용하게 될 수도 있는데 그렇다면 해당 유즈케이스를 찾지 못할수도 있어요.
실제로 본인이 이미 많은 유틸 함수의 존재를 모르고 권한 관리 컴포넌트, 포매터 등등을 새로 만들었다가 리뷰에서 알게 된 경우가 많았거든요.


Step 3. 유틸 사용 강제 필요

새 variant type을 추가하는 개발자가 get-variant-anchor-id.ts의 존재를 알 거라는 보장이 없어요. 모르면 자연스럽게 이렇게 작성하겠죠.

getRowId(row): string {
  return "NEW" + row.data.id; // 유틸 몰라서 하드코딩
}

리턴 타입이 string이니 어떤 문자열이든 들어가요. 컴파일러 입장에서는 완벽하게 정상인 코드죠. 리뷰에서도 "NEW"라는 접두사가 맞는지 아닌지를 리뷰어가 매번 직접 확인해야 하는데, 유틸이 있다는 사실 자체를 리뷰어도 모를 수 있어요.

결국 유틸을 만드는 것만으로는 부족해요. 유틸을 반드시 쓰게 만들어야 합니다.


Step 4. Branded Type — 타입으로 강제하기

Branded Type 패턴을 도입했어요.

declare const __brand: unique symbol;

export type VariantAnchorId = string & {
  readonly [__brand]: "VariantAnchorId";
};

export const getSnvAnchorId = (symbol: string): VariantAnchorId =>
  `SNV${symbol}` as VariantAnchorId; // 여기서만 만들 수 있다

세 줄의 타입 선언인데, 각각 하는 일은 이래요.

  • declare const __brand: unique symboldeclare로 선언해서 JS 번들 0바이트. 순수하게 타입 레벨에서만 존재하는 키를 만들어요.
  • unique symbol — 이 파일에서만 유일한 심볼이라, 외부에서 같은 구조를 만들어도 타입이 호환되지 않아요.
  • string & { readonly [__brand]: "VariantAnchorId" }string의 서브타입이에요. 기존에 string을 기대하는 곳(DOM id 속성, URL hash 등)에는 아무 문제 없이 들어가요.

핵심은 타입 흐름이 단방향이라는 점이에요.

VariantAnchorId → string   (서브타입이므로 자동 호환)
string → VariantAnchorId   (이게 강제하고 싶은 것)

강제하고 싶은 곳에서 해당 타입을 사용하면 돼요. 인터페이스의 리턴 타입을 바꾸면 이제 하드코딩이 컴파일 에러가 되고, 에러 메시지를 따라가보면 해당 모듈이 나와요.

interface AnalysisResultViewStrategy<T> {
  getRowId(row: T, index: number, parent?: Row<T>): VariantAnchorId;
}
// 컴파일 에러
getRowId(row): VariantAnchorId {
  return "NEW" + row.data.id;
  // Type 'string' is not assignable to type 'VariantAnchorId'
}

// 유틸 함수를 찾아서 써야 함
getRowId(row): VariantAnchorId {
  return getNewAnchorId(row.data.id);
}

이전 PR에서 통합한 메시지 생성 함수의 createLinkText도 파라미터를 VariantAnchorId로 바꿔두었어요. strategy, navigation, message 세 곳 모두 일반 string으로는 값을 넣을 수 없게 됐죠.

또한 기존에 string을 받던 하위 UI 컴포넌트에서는 똑같이 string으로 사용이 가능해서 호환에도 문제가 없어요.

책에서 Branded 패턴 처음 봤을 땐 이걸 어디다 써먹나 싶었는데, 딱 적합한 곳을 찾은 것 같아요.


필요성 검증

인터페이스에 VariantAnchorId를 적용하고 빌드를 돌렸더니, three-asc/strategy.ts에서 즉시 컴파일 에러가 발생했어요. 7개 strategy 파일 중 1개를 리팩토링 범위에서 놓친 거였죠.

Branded Type이 없었으면? 빌드는 통과하고, 해당 variant type의 네비게이션만 조용히 안 되는 상태로 배포됐을 거예요. 앞서 UPD 콜론 버그와 정확히 같은 패턴이에요. 도입 직후 바로 효과를 봤습니다.


마치며

전체 흐름을 되돌아보면 한 번의 개선이 다음 문제를 드러내는 구조였어요.
코드를 모으다 보니 패턴이 보였고, 패턴을 정리하다 보니 구멍이 보였고, 구멍을 막다 보니 Branded 패턴이 필요해졌어요.
쓸데없다고 생각했던 패턴들이 생각보다 유용하게 쓰이는 것 같아요. 책 또 하나 읽어야겠습니다..

profile

뱁새 다리찢기

@donghyk2-eric

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!