남의 코드 13,000줄을 안고 가기로 했다
의존성으로 쓸 수도, 포크할 수도 없었다. 세 번째 선택지를 골랐고, 그 대가로 지켜야 할 규칙이 생겼다.
기억 서비스의 저장·검색 엔진은 직접 만들지 않았다. 오픈소스 라이브러리를 가져다 썼다. 그런데 첫 글에서 쓴 대로, 그 라이브러리의 추출 프롬프트를 우리가 고쳐야 했다. 한국어가 영어로 추출되던 문제 때문이다.
여기서 선택이 갈린다.
세 갈래
| 선택 | 우리 수정 | upstream 반영 | 대조 |
|---|---|---|---|
| 의존성으로 사용 | 불가 | 자동 | 필요 없음 |
| 포크 | 자유 | cherry-pick 필요 | 금방 흐려짐 |
| 벤더링 | 자유 | 선택적 | 유지 가능 |
의존성으로 쓰면 프롬프트를 못 고친다. 포크하면 고칠 수는 있지만, 시간이 지나면 "이게 원래 저랬나 우리가 고친 건가"를 아무도 모르게 된다.
세 번째를 골랐다. 코드를 저장소 안으로 통째로 들여오되, 언제든 원본과 diff 를 뜰 수 있는 상태로 유지하는 것이다.
대가는 규율이다
벤더링은 복사가 아니다. 복사는 쉽고, 유지가 어렵다. 유지를 가능하게 하는 건 몇 가지 규칙을 예외 없이 지키는 것이다.
포매터를 대지 않는다. 린터도, 임포트 정렬도 전부 제외 목록에 넣었다. 한 번이라도 포매팅하면 원본과 다른 코드가 되고, 그 순간 대조가 불가능해진다. 커밋 훅이 걸리면 훅을 끄고 커밋한다. 이건 게으름이 아니라 규칙이다.
한 줄을 고쳐도 왜 고쳤는지 주석을 남긴다. 나중에 upstream 과 대조할 때 그 주석이 유일한 단서다. 주석 없는 수정은 다음 사람에게 "원본이 저랬나 보다"로 읽힌다.
이관은 세 단계 커밋으로 나눈다. 이게 핵심이다.
01원본 그대로 넣는다. 이 커밋이 대조 기준점이다. 여기에 다른 것을 섞으면 기준점이 사라진다.
이 순서를 지키면 언제든 커밋 1과 현재를 비교해서 "우리가 바꾼 전부"를 볼 수 있다. 섞으면 그 능력을 잃는다.
그래서 뭘 알게 되냐면
남의 코드를 읽을 수밖에 없는 상태가 된다. 그러면서 밖에서는 안 보이던 것들이 보였다.
이름만 async 인 클래스
라이브러리에는 동기 클래스와 비동기 클래스가 둘 다 있었다. 당연히 비동기 쪽을 써야 한다고 생각했다.
열어 보니 이랬다.
즉 우리가 동기 코드를 스레드풀에 넘기는 것과 같은 일을 한다. 요청 하나를 통째로 넘기느냐, 호출 하나씩 넘기느냐의 차이뿐이다.
비동기 쪽으로 갈아타면 얻는 것은 사실상 없고, 잃는 것은 명확했다 — 우리 수정을 두 벌로 유지해야 한다. 18곳의 수정이 동기 경로에 있는데, 그걸 전부 복제하고 두 벌을 계속 맞춰야 한다.
그리고 진짜 병목은 다른 데 있었다.
커넥션 풀 크기를 두 배로 열었을 뿐이다. 2.3배. async 로 바꿔도 풀이 막혀 있으면 거기서 막힌다 — 아키텍처를 바꾸기 전에 설정을 재보는 편이 빨랐다.
설정 스위치가 없는 기능
이건 아직도 조금 무섭다.
라이브러리 v2 에는 문서에 없는 엔티티 링킹 기능이 있다. 기억 문장에서 개체를 뽑아 별도 테이블에 임베딩하고, 검색 때 같은 개체를 공유하는 기억의 점수를 올린다. 그럴듯한 기능이다.
게다가 그 기능은 한국어에서 거의 무용하다. 모델이 영어용으로 하드코딩돼 있고, 인명 판정이 첫 글자가 대문자인가로 되어 있다. 한국어에는 대문자가 없으니 항상 탈락한다. 켜지면 비용만 붙고 효과는 없다.
고칠 수도 있었다. 한국어 모델로 바꾸고 대문자 판정과 영어 단어 필터를 통째로 다시 쓰면 된다. 하지만 그건 「우리 diff 를 최소로」와 정면으로 충돌한다. 그래서 고치지 않고 문서에 적어 뒀다. 지금 우리가 할 수 있는 최선은 이 지뢰의 위치를 다음 사람에게 알려주는 것이다.
조용히 무시되는 설정 키
설정 객체에 잘못된 키 이름을 넣으면 에러가 나지 않는다. 그냥 무시된다.
- 데이터베이스 연결 문자열은
dsn이 아니라 다른 이름이어야 한다. 틀리면 연결이 안 되는 게 아니라 기본값으로 조용히 떨어진다 - 이력 저장소 경로를 잘못 주면 PostgreSQL 대신 로컬 파일 DB 로 떨어진다. 로그도 없다
- v1 에 있던 그래프 설정은 v2 에서 사라졌는데, 넣어도 에러가 안 난다. 그냥 무시된다
전부 지난 글에서 말한 그 성질이다. 예외를 던지지 않는다.
갚기로 하고 남겨둔 것
삭제 경로가 원자적이지 않다. 벡터 행 삭제, 이력 기록, 정리 작업이 각각 다른 커넥션에서 트랜잭션 없이 순서대로 실행된다. 첫 단계만 커밋되고 나머지가 실패하면 "기억은 사라졌는데 삭제 이력은 없는" 상태가 된다.
벤더 코드를 원자적으로 재구성하지 않았다. 그러면 diff 가 폭증하고, 그건 이 전체 전략의 전제를 무너뜨린다. 대신 바깥에 보상 레이어를 뒀다. 이력이 빠진 것을 나중에 채우고, 고아가 된 행을 따로 걷는다.
깔끔하지 않다. 하지만 이건 의도적인 부채다. 엔진을 교체하면 보상 레이어는 자연히 사라진다. 그때까지 이 부채를 안고 가는 편이, 벤더 코드를 뜯어고쳐서 대조 능력을 잃는 것보다 낫다고 판단했다.
남은 것
벤더링은 코드를 복사하는 게 아니라 대조 가능성을 유지하는 규율이다. 포매터를 대지 않고 커밋을 나누는 것은 결벽이 아니라, 이 전략이 성립하기 위한 최소 조건이다. 한 번 어기면 그때부터는 그냥 포크다.
남의 코드를 읽을 수밖에 없는 상태가 되면 배우는 게 있다. async 클래스가 이름뿐이라는 것도, 설정 키가 조용히 무시된다는 것도, 스위치 없는 기능이 숨어 있다는 것도 전부 읽어서 알았다. 의존성으로 썼다면 셋 다 몰랐을 것이고, 나중에 장애로 알게 됐을 것이다.
고칠 수 없는 것은 문서에 적어야 한다. 한국어에서 무용한 엔티티 링킹은 우리가 못 고친다. 대신 그게 어떤 조건에서 켜지는지, 켜지면 무슨 일이 생기는지를 적어 뒀다. 지뢰를 제거하지 못하면 최소한 표시는 해야 한다.