유전자 검사 뽀개기 — 구조화를 통한 리팩토링

James
None
Published in
15 min readAug 30, 2021

안녕하세요, 생명과학을 전공한 백엔드 개발자 James입니다.

희귀질환 맞춤정보 플랫폼 레어노트에서는 직관적으로 이해가 어려운 유전자 검사 결과를 좀 더 이해하기 쉽게 풀어 설명해주는 기능을 제공하고 있습니다. 유전자에 나타나는 여러 변이는 그 유형이 다양하고 이를 기록할 수 있는 방법은 더욱 다양하지만, 대부분 HGVS에서 제공하는 공통 및 각 유형에 대한 가이드라인을 따라 작성하고 있습니다. 유전자 변이를 어떻게 나타내는지 알아보기 전에, 유전자가 무엇인지 잘 모르겠다면 아래 부분을 읽어보시는 것을 권해드립니다.

우리 몸을 구성하는 세포에는 핵이 있고, 핵에는 DNA와 단백질의 복합체인 염색질(chromatin)이 존재합니다.

DNA에는 단백질 또는 RNA와 같은 기능적 물질을 암호화하는 영역인 유전자가 존재하고, 이는 전사(transcription) 과정을 통해 RNA를 만들어내며, 그 중 messenger(m) RNA는 번역(translation) 과정을 통해 단백질을 만들어냅니다.

이렇게 만들어진 단백질은 생명 활동에 필요한 다양한 기능을 수행합니다.

유전자의 변이는 NM_004006.2:c.145_147delinsTGG와 같은 형식으로 나타내는데, 콜론(:) 전 부분은 염기서열 식별자(sequence identifier)로 변이를 묘사하는 기준이 되는 참조 서열(reference sequence)이 어떤 것인지를 표시합니다. 다음으로, c. 부분은 참조 서열의 종류를 나타내는 prefix로, c.는 암호화 서열(coding sequence, 유전자에서 단백질로 번역되는 염기들), p.는 아미노산 서열(protein, 단백질은 아미노산으로 구성)을 의미합니다. 145_147delinsTGG 부분은 변이의 내용을 나타내며, 위 예시는 145번째부터 147번째까지의 염기가 결실(del; deletion)되고, 염기서열 TGG가 삽입(ins; insertion)되었다는 뜻입니다. 다른 prefix나 이외 일반적인 변이 묘사 가이드라인이 궁금하다면 이 페이지를 참고하시기 바랍니다.

이전까지는, 사용자의 유전자 검사 결과에 있는 변이들을 유형화하여 각 유형별 해석 문구 포맷을 정리한 시트를 사용했습니다. 제가 개편 작업을 하기 전까지 암호화 서열 변이는 23개, 아미노산 서열 변이는 12개 유형이 있었으며, 시트에 없던 유형의 변이가 들어오면 시트에도 추가를 하고, 해당 유형을 대응하는 코드를 작성하여 핫픽스를 나가는 식으로 작업을 했었습니다.

암호화 서열 변이 시트의 일부

몇몇 분들은 눈치채셨겠지만, 위 사진에서 1~4번은 전부 치환(substitution) 유형입니다. 다만, 2번의 경우 HGVS 가이드라인을 준수하지 않았고(검사 결과지에도…), 3번과 4번의 경우 단백질로 번역되지 않는 인트론(intron) 부분의 변이입니다. 기존에는 이런 유형들이 전부 별도로 구분되어 있었고, 코드 또한 그렇게 되어있었습니다. 아무래도 개발 당시 개발팀이 관련 분야의 배경 지식이 없는 상태였다보니, 최대한 구체적으로 가이드라인을 만들어서 그대로 옮겨 개발하는 방식이 최선이었습니다.

유형별 정규식 (일부)
유형별 문구 생성 로직 (일부)

저는 이 부분을 개선하기 위해 HGVS 가이드라인을 전부 읽어보았고, 그 결과 원본 시트의 내용을 모두 포함하여 아래와 같은 구조화를 할 수 있다고 판단하였습니다. 이번 포스트의 목적이 모든 유형의 해석을 소개하는 것은 아니어서, 일부분은 생략하였습니다.

암호화 서열 변이
- 위치
- 엑손: 숫자
- 예시: 213
- 인트론: 숫자+숫자 또는 숫자-숫자
- 예시: 213+3, 213-3
- 영역: [엑손/인트론 위치]_[엑손/인트론 위치]
- 예시: 213_217+15, 213-25_213-12
- 유형
- 치환(Substitution) // 위치 염기1 > 염기2, 실제로는 띄어쓰기 없음
- 공식 표기 (예시: c.213+3C>T)
- 비공식 표기 (예시: c.C213T)
- 중복(Duplication) // 위치 dup (염기/염기서열) ([횟수]), ()는 선택
- 단일 염기, 중복 염기 표시 (예시: c.213dupC)
- 단일 염기 (예시: c.213dup)
- 다수 염기, 중복 염기 표시 (예시: c.213_215dupCAG)
- 다수 염기 (예시. c.213_215dup)
- 단일 염기, 중복 염기 표시, 다회 중복 (예시: c.213+221dupC[9])
- 다수 염기, 다회 중복 (c.213+221_213+223dup[3])
- 결실(Deletion)
- (...)
- 결실-삽입(Deletion-Insertion)
- (...)
- 삽입(Insertion)
- (...)
- 역위(Inversion)
- (...)
- 대립유전자 (allele)
- 같은 쪽
- 예시: c.[213C>T;492G>A]
- 다른 쪽
- 예시: c.[213C>T];[492G>A]
- 알 수 없음
- 예시: c.213C>T(;)492G>A
아미노산 서열 변이
- 코돈
- 아미노산
- 1글자 약자 (예시: A -> 알라닌)
- 3글자 약자 (예시: Ala -> 알라닌)
- 종결 코돈
- 1글자 약자 (*, 비공식 X)
- 3글자 약자 (Ter)
// 아미노산과 위치(숫자)는 보통 함께하며, 이를 아미노산위치로 부르기로 하자
- 유형
- 치환(Substitution) // 아미노산위치1 아미노산/종결코돈/=
- 일반 (예시: p.Ser137Phe)
- 종결 코돈 변동 (예시: p.Y841*)
- 변화 없음 (예시: p.Thr44=)
- 중복(Duplication)
- (...)
- 결실(Deletion)
- (...)
- 결실-삽입(Deletion-Insertion)
- (...)
- 삽입(Insertion)
- (...)
- 틀이동(Frameshift) // 아미노산위치1 (아미노산) fs 종결코돈 숫자
- 바뀐 아미노산 표시 (예시: p.S1653Kfs*2)
- 미표시 (예시: p.Ser1653fsTer2)
- 대립유전자
- (암호화 서열 변이와 같음)

위와 같은 구조에서 필요한 단위 태스크는 아래와 같이 정리할 수 있습니다.

  • 참조서열 유형 파악 (c., p.)
  • 각 변이 유형에 일치하는 정규식 구축
  • 여러 변이를 표현하는 경우를 인식하는 정규식 구축 및 대립유전자 위치에 따른 문구 작성
  • 암호화 서열 변이의 경우, 여러 가지 위치 형식 대응
  • 아미노산 서열 변이의 경우, 여러 가지 약자 형식 대응
  • 각 변이유형별 필수 인자, 선택 인자(바뀐 염기/아미노산 정보 제공 여부 등)에 따른 문구 작성

여기에는 여러 곳에서 공유할 수 있는 로직이 많기 때문에, 이를 작은 함수들로 쪼개고 적절한 이름을 부여하는 것이 가독성과 복잡도 관리 측면에서 유리합니다. 참조서열 유형 파악부터 대립유전자 부분까지는 이 포스트에서 다루지 않습니다.

암호화 서열 상의 위치 형식에는 단순히 엑손 또는 인트론을 표시하는 것도 있고, 두 위치 사이의 영역을 표시하는 것도 있습니다. 재사용성을 고려해 전자는 정규식에 들어갈 수 있는 문자열을 반환하는 함수로 만들고, 이를 후자에서 활용해보았습니다. 이 때, 코드 가독성을 위해 추출하고자 하는 각 그룹에 이름을 부여(named capturing group)했습니다.

const getPosRegexString = (pos) =>
`(?<number${pos}>\\d+)((?<direction${pos}>[-+])(?<offset${pos}>\\d+))?`;
// pos: 1이면 "2+7": { number1: "2", direction1: "+", offset1: "7" }
const posOrRangeRegexString = `${getPosRegexString(1)}(_${getPosRegexString(2)})?`;
// "9-4_15": { number1: "9", direction1: "-", "offset1: "4", number2: "15" }

이를 활용하여 치환 유형에 대응하는 정규식을 만들어볼 수 있습니다.

const nucleotideRegexString = '[ACGT]';const codingSequenceVariantRegex = {
substitution: new RegExp(
`^(?<pos>${getPosRegexString(
1,
)})(?<nucleotide1>${
nucleotideRegexString
})>(?<nucleotide2>${nucleotideRegexString})$`,
),
/* ...other types */
};
/*
* matching result for '123C>T'
* groups: {
* pos: "123",
* number1: "123",
* direction1: undefined,
* offset1: undefined,
* nucleotide1: "C",
* nucleotide2: "T",
* }
*/
/*
* matching result for '10237-112A>C'
* groups: {
* pos: "10237-112",
* number1: "10237",
* direction1: "-",
* offset1: "112",
* nucleotide1: "A",
* nucleotide2: "C",
* }
*/

정규식 매칭 결과를 직관적인 구조로 가져올 수 있게 되었으니, 이를 활용하여 문구를 작성해 보겠습니다. 123C>T123번째 염기 시토신(C; Cytosine)이 티민(T; Thymine)으로 바뀌는 변이 , 10237-112A>C[10237번째 염기에 인접한 인트론의 뒤에서부터 112번째] 염기 아데닌(A; Adenine)이 시토신(C; Cytosine)으로 바뀌는 변이 라는 문구로 처리됩니다.

const nucleotideMap = {
A: '아데닌(A; Adenine)',
C: '시토신(C; Cytosine)',
G: '구아닌(G; Guanine)',
T: '티민(T; Thymine)',
};
const directionText = { '+': '앞', '-': '뒤' };const parsePos = (groups, index) => {
const direction = groups[`direction${index}`];
const posString = `${groups[`number${index}`]}번째`;
return direction
? `[${posString} 염기에 인접한 인트론의 ${
directionText[direction]
}에서부터 ${groups[`offset${index}`]}번째]`
: posString;
}; // 변이가 영역에서 나타나는 경우 index: 1, 2 모두 필요
const [type, groups] = interpretVariant(
codingSequenceVariant, // prefix(c.) 이후의 변이 문자열
codingSequenceVariantRegex,
);
switch (type) {
case 'substitution':
return `${parsePos(groups, 1)} 염기 ${
nucleotideMap[groups.nucleotide1]
}이 ${nucleotideMap[groups.nucleotide2]}으로 바뀌는 변이`;
/* ...other types */
}

다른 유형에 대한 코드를 적지는 않았지만, 치환 유형과 거의 비슷하게 구현했고 그 과정에서 parsePos 등을 쉽게 재사용 할 수 있었습니다. 이어서, 아미노산 틀이동 변이 유형도 살펴보겠습니다.

const aminoAcidRegexString = '(A(la|rg|s[np]))|Cys|(Gl[uny])|His|Ile|(L(eu|ys))|Met|(P(he|ro))|Se[rc]|(T(rp|[ehy]r))|Val|[AC-IK-NP-WY\\*X]'; // 3글자, 1글자const aminoAcidVariantRegex = {
frameshift: new RegExp(
`^(?<aminoAcid1>${
aminoAcidRegexString
})(?<number1>\\d+)(?<aminoAcid2>${
aminoAcidRegexString
})?fs(Ter|\\*)(?<number2>\\d+)$`,
),
/* ...other types */
};
const aminoAcidMap = {
Ala: '알라닌(Ala, A; Alanine)',
A: '알라닌(Ala, A; Alanine)',
/* ...other amino acids */
Ter: '종결 코돈(Ter, *; Termination)',
'*': '종결 코돈(Ter, *; Termination)',
};
const variantTypeText = {
fs: '틀 이동 돌연변이(fs; frameshift)',
/* ...other types */
};
const handleOptionalAminoAcid = (aminoAcid) =>
aminoAcid ? `${aminoAcidMap[aminoAcid]}으로 ` : '';
const [type, groups] = interpretVariant(
aminoAcidVariant, // prefix(p.) 이후의 변이 문자열
aminoAcidVariantRegex,
); // 암호화 서열 변이 해석에도 사용한 그 함수 맞습니다.
switch (type) {
case 'frameshift':
return `${groups.number1}번째 아미노산 위치에서 ${
aminoAcidMap[groups.aminoAcid1]
}이 ${
handleOptionalAminoAcid(groups.aminoAcid2)
}바뀌고, 종결 코돈(Ter; Termination)이 아미노산 ${
groups.number2
}개 뒤에 나타나 단백질 생성이 중단되는 ${variantTypeText.fs}`;
/* ...other types */
}

아미노산의 경우 3글자 약자(예: Ala)와 1글자 약자(예: A)모두 유효한 표기법이나, 일반적인 사용자는 둘이 같은 의미라는 것을 직관적으로 인식하기 어려우므로 aminoAcidMap을 두어 통일된 포맷으로 정보를 전달하기로 했습니다. 또한, 몇몇 유형은 대상 아미노산을 명시하는 경우도 있고 아닌 경우도 있어 이를 handleOptionalAminoAcid 함수를 통해 선택적으로 처리하였습니다.

그 결과, 조금 다르게 생긴 두 변이 p.S1653Kfs*2p.Ser1653fsTer2는 각각

1653번째 아미노산 위치에서 세린(Ser, S; Serine)이 리신(Lys, K; Lysine)으로 바뀌고, 종결 코돈(Ter; Termination)이 아미노산 2개 뒤에 나타나 단백질 생성이 중단되는 틀이동 돌연변이(fs; frameshift)1653번째 아미노산 위치에서 세린(Ser, S; Serine)이 바뀌고, 종결 코돈(Ter; Termination)이 아미노산 2개 뒤에 나타나 단백질 생성이 중단되는 틀이동 돌연변이(fs; frameshift)

라는 문구로 변환됩니다.

위와 같은 개편 작업이 사실 결과 측면에서 보면 기존 시트에 없던 몇 가지 유형을 얼떨결에(?) 추가로 대응했다는 것과, 코드가 (제가 보기엔) 조금 더 예뻐졌다는 것 이외에는 별 게 없다고도 볼 수 있습니다. 하지만, 기존 코드로 해결이 안 되는 새로운 변이 유형을 추가해야 할 때 어디를 어떻게 고쳐야 하는지 찾는 시간은 확실히 빨라졌을 것이라 생각합니다.

또한, 나중에 다른 형식으로 저장된 두 변이가 실제로 같은 것인지를 비교하는 기능이 생기거나 변이 유형별 설명 이미지가 추가되어 적절한 이미지를 함께 제공해야 된다고 하면, 이번에 재사용 가능하도록 구축한 변이 유형과 인자를 추출하는 로직을 활용하는 쪽이 훨씬 구현이 쉬울 것입니다.

이번 경우 저는 기존에 가지고 있던 배경 지식을 활용하여 마치 지도 학습과 같은 맥락에서 복잡한 작업을 구조화했지만, 꼭 그렇지 않더라도 비지도 학습과 같이 시간을 들여 스펙을 분석하며 공유되는(재사용 가능한) 부분은 무엇인지, 선택적으로 존재하는 부분은 무엇인지, 이도 저도 아니어서 반드시 분기를 통해 해결해야 하는 부분은 무엇인지를 고민해보고 개발하는 것이 가능하다고 생각합니다.

Walk with us!

기술이 세상을 더 아름답게 할 수 있다고 믿으신다면, 휴먼스케이프와 함께 소중한 뜻을 펼칠 수 있습니다.
함께 걸어가며 성장하실 분, 언제든지 연락해주세요 :)

휴먼스케이프 개발자 채용공고 보러가기

--

--