뱁새 다리찢기

배경

genomic variant를 분석하는 플랫폼을 만들고 있었어요. variant에는 여러 종류가 있고, 각 종류마다 선택 상태를 key로 관리해요.
UPD변이를 예로 들면 key 구조가 이래요.

region key:  subjectId+pos
disease key: subjectId+pos::phenoId

disease key가 region key를 prefix로 포함하는 구조예요. 이 덕분에 "이 variant가 이 UPD에 속하는가"를 startsWith 하나로 판별할 수 있어요.

const isChecked = selected.some(variant =>
  updList.some(upd =>
    variant.key.startsWith(regionKey(upd))
  )
);

동작은 해요. 근데 코드를 볼 때마다 뭔가 걸렸어요.

읽을 때마다 세 가지를 연결해야 한다

이 코드를 처음 읽는 사람은 세 가지를 머릿속에서 직접 이어야 해요.

  • regionKey(upd)가 어떤 문자열을 반환하는지
  • disease key가 region key를 prefix로 포함한다는 key 구조 규칙
  • 그래서 startsWith가 "이 variant가 이 UPD에 속하는가"를 의미한다는 사실

이 세 가지는 전부 UPD 도메인 지식인데, 지금은 사용처마다 직접 조립하고 있어요. 다른 variant 타입도 똑같은 문제를 반복하고 있었어요.

// CNV
variant.key.startsWith(cnvBaseKey(cnv))

// BND
variant.key.startsWith(bnd.key)

key 구조가 바뀌면 사용처를 전부 찾아서 고쳐야 해요. 포맷을 한 곳에서 관리하지 않으면 언젠가 한 곳이 다르게 작성돼요. 실제로 UPD 추가 PR에서 콜론 하나 때문에 네비게이션이 조용히 실패한 적이 있었거든요.

도메인 질문을 함수명으로 드러내기

startsWith 자체가 문제가 아니에요. "왜 prefix 매칭이 소속 판별이 되는가"라는 도메인 지식이 사용처에 노출되는 게 문제예요.

도메인 질문을 함수명으로 드러내면 돼요.

const isChecked = selected.some(variant =>
  updList.some(upd => isKeyBelongsToUpd(variant.key, upd))
);
/**
 * variant의 key가 해당 UPD에 속하는지 판별한다.
 *
 * disease key는 region key를 접두어로 포함하므로,
 * region/disease 선택 모두를 매칭한다.
 */
const isKeyBelongsToUpd = (
  variantKey: string,
  upd: Upd
): boolean => variantKey.startsWith(regionKey(upd));

isKeyBelongsToUpd라는 이름이 "이 variant가 이 UPD에 속하는가"에 직접 답해요. prefix 구조가 어떻게 생겼는지, 왜 startsWith가 소속 판별이 되는지 — 사용처는 더 이상 알 필요가 없어요.

범용 함수는 안 되나?

이렇게 만들면 어떨까 생각해봤어요.

const isKeyOfGroup = (key: string, groupKey: string): boolean =>
  key.startsWith(groupKey);

isKeyOfGroupstartsWith라는 구현에 이름을 붙인 거고, isKeyBelongsToUpd는 질문에 이름을 붙인 거예요. "왜 prefix 매칭이 소속 판별인가"라는 질문이 "왜 isKeyOfGroup인가"로 바뀔 뿐이고, 도메인 지식은 여전히 사용처에서 조립해야 해요.

// 여전히 사용처가 이 조합을 알아야 함
prev.filter(v => !isKeyOfGroup(v.key, regionKey(upd)))

startsWith에 이름을 붙이는 게 아니라, "속하는가"라는 질문에 이름을 붙여야 해요.

단일 책임 맞나?

isKeyBelongsToUpd가 key 계산과 prefix 매칭, 두 가지를 하는 거 아니냐는 생각도 들었어요.

 

Domain-Driven Design 관점으로 보면 함수는 "무엇을 하는가"가 아니라 "어떤 의도를 드러내는가"로 평가돼요. isKeyBelongsToUpd는 "이 variant가 이 UPD에 속하는가"라는 하나의 도메인 질문에 답해요. key 계산과 매칭은 그 의도를 구현하는 절차일 뿐, 별개의 책임이 아니에요.

 

각 구성 요소는 이미 독립적으로 접근 가능해요.

// key만 필요하면
regionKey(upd)

// 소속 판별이 필요하면
isKeyBelongsToUpd(variantKey, upd)

+ 변이 타입마다 함수를 따로 만들어야 하나?

글을 공유했을 때 이런 질문을 받았어요.

isKeyBelongsToUpd 같이 코드는 같지만 도메인별로 필요할 때마다 만들게 되지 않나요?
isKeyBelongsTo라고만 쓰고 두 번째 매개변수의 타입을 제네릭으로 넣는 건 어떨까요?

저도 처음엔 그렇게 작업했었습니다.

const isKeyBelongsTo = <T>(
  variantKey: string,
  variant: T,
  getBaseKey: (v: T) => string
): boolean => variantKey.startsWith(getBaseKey(variant));

근데 실제로 적용하다 보니 막히는 케이스가 있었어요. 변이 타입마다 "base key를 어떻게 추출하는가"가 다 달랐거든요.

// UPD: key 팩토리 함수로 계산
isKeyBelongsToUpd  →  variantKey.startsWith(regionKey(upd))

// CNV: key 팩토리 함수로 계산
isKeyBelongsToCnv  →  variantKey.startsWith(defaultKey(cnv))

// BND: 서버에서 내려온 key 프로퍼티 직접 사용
isKeyBelongsToBnd  →  variantKey.startsWith(bnd.key)

BND는 서버에서 key를 직접 내려줘서 팩토리 함수가 없어요. 제네릭으로 통일하려면 getBaseKey 콜백을 넘겨야 하는데, 그러면 사용처가 이렇게 돼요.

// 제네릭 버전
isKeyBelongsTo(variantKey, upd, regionKey)
isKeyBelongsTo(variantKey, cnv, defaultKey)
isKeyBelongsTo(variantKey, bnd, b => b.key)

// 도메인 함수 버전
isKeyBelongsToUpd(variantKey, upd)
isKeyBelongsToCnv(variantKey, cnv)
isKeyBelongsToBnd(variantKey, bnd)

제네릭 버전은 "어떤 key로 비교할지"를 사용처가 다시 알아야 해요. 캡슐화하려고 만든 함수인데, 캡슐화가 안 된 거예요.

결국 현 상황에서는 변이 타입마다 함수를 따로 만드는 게 맞았어요. 코드가 비슷해 보여도, 각 함수가 담고 있는 도메인 지식은 달라요.

어디에 두는가

그래서 이 함수를 어디에 둘까요?
각 도메인 모듈 안에 뒀어요. UPD 소속 판별 함수는 UPD 모듈이, CNV 소속 판별 함수는 CNV 모듈이 소유하는 구조예요.
도메인 지식은 그 도메인 모듈이 소유해야 해요. key 구조가 바뀌면 사용처가 아니라 해당 모듈 하나만 고치면 돼요. 이전 글에서 다뤘던 variant anchor id 포맷을 한 곳에 모은 것과 같은 이유예요.

마치며

코드 한 줄이 작아 보여도, 그 안에 도메인 지식 세 개가 녹아있을 수 있어요. 함수를 만들 때 "무엇을 하는가"보다 "어떤 질문에 답하는가"를 먼저 물으면, 사용처가 그 질문만 던지면 되는 구조가 돼요.
코드를 읽을 때 "왜 이렇게 동작하지?"를 계속 물어야 한다면, 그 답이 함수명에 있어야 한다는 신호예요.

profile

뱁새 다리찢기

@donghyk2-eric

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