중복처럼 보이는 코드를 함부로 합치면 안 되는 이유

오래 운영한 코드베이스를 점검하다 보면 비슷하게 생긴 코드 뭉치를 발견하고 "이거 중복이네, 합치자"라는 충동이 듭니다. 중복 제거는 리팩토링의 기본이라고 배우기도 했고, 지우는 작업은 손도 빠릅니다. 저는 얼마 전 그 충동대로 갔다가 큰일 날 뻔했습니다.

운영 중인 서비스의 API 폴더를 점검하는데 /api/admin/docs와 /api/admin/documents라는 두 트리가 있었습니다. 하위 구조까지 거의 판박이였습니다. 둘 다 [id]가 있고, snapshots가 있고, restore가 있습니다. 파일을 열어 보면 핸들러 구성도 비슷합니다. 이 정도면 한쪽이 레거시라고 거의 확신했습니다. 예전에 경로를 정리하면서 옛날 트리를 지우다 만 흔적이겠거니 했습니다.

지우기 전에 한 가지만 확인하기로 했습니다. 프론트엔드에서 실제로 누가 어느 경로를 부르는가. 문서 관리 화면 코드를 열어 보니 스페이스별로 endpoint를 매핑하는 부분이 있었고, 거기서 확신이 무너졌습니다. 전역 문서 공간은 /api/admin/docs를 부르고, 프로젝트별 문서 공간은 /api/admin/documents를 부르고 있었습니다. 둘 다 현역이었습니다.

더 파 보니 겉모양만 닮은 남남이었습니다. 한쪽은 정수 autoincrement id를 쓰는 전역 문서 모델이고, 다른 쪽은 cuid 문자열 id를 쓰는 프로젝트 소속 문서 모델입니다. 필드 구성이 다르고, 한쪽만 zod로 입력을 검증하고 있었습니다. 구조가 닮은 건 복붙의 흔적이 아니라 같은 패턴으로 두 도메인을 각각 만든 결과였습니다. CRUD에 스냅샷과 휴지통 복구가 붙는 문서 관리라면 어느 도메인이든 이 모양이 나옵니다.

만약 호출자 확인을 건너뛰고 한쪽 트리를 지웠다면 프로젝트 문서 기능이 통째로 죽었을 겁니다. 무리해서 하나로 합치는 쪽을 택했어도 id 타입부터 충돌합니다. 정수와 문자열 id를 한 라우트에서 받으려면 분기가 생기고, 그 분기는 두 트리를 따로 두는 것보다 읽기 어려웠을 겁니다. 결국 그대로 두는 것이 정답인 중복이었습니다. 정확히는 중복이 아니었던 거지만.

이날 이후로 저는 중복 판단 기준을 코드 모양에서 두 가지 질문으로 바꿨습니다. 실제 호출자가 같은가, 그리고 다루는 데이터 모델이 같은가. 둘 다 예일 때만 중복입니다. 모양이 닮았다는 것은 판단 근거가 못 됩니다. 잘 만든 코드일수록 같은 패턴을 반복하기 때문에, 오히려 닮은 코드가 많이 나옵니다. 중복 제거가 목표가 되는 순간 이 구분이 흐려지니, 지우는 손보다 확인하는 눈이 먼저 가야 한다고 이제는 생각합니다.

같은 점검에서 겪은 다른 이야기:

반복 코드 131줄을 정규식으로 한 번에 바꿔도 괜찮았던 이유