Tooling · Conventions

문서는 "끝났다"고 했다.
검사는 언제까지 늘려야 하나

수동 감사에서 어긋남을 찾으면, 그걸 잡았을 검사를 하나 쓰면 된다. 어려운 건 언제 그만 쓰느냐다. 내 경우엔 수확 곡선이 답을 줬다. 처음 몇 번은 새 규칙을 더할 때마다 위반이 3~4건씩 나왔고, 그다음은 2건, 마지막은 코드에서 0건이었다. 시작은 네이밍 정리가 끝났다고 적힌 문서였다. 끝난 건 절반뿐이었다.

같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서 있었던 일이다. 루트 가이드는 Repository 메서드 이름을 분명하게 정해 둔다. 단건이든 목록이든 조회는 find<Noun>s, 쓰기는 save<Noun>이고 update 메서드는 따로 두지 않는다. 코드가 이걸 지키는지 확인해 보니 5개 언어 중 4곳에서 위반이 나왔다. go와 fastapi는 명사 없이 Save/save만 썼다. 나중에 두 번째 Bounded Context로 들어온 Card 도메인은 java·kotlin·go·fastapi에서 "전용 findOne에 별도 findAll" 모양이었는데, 컨벤션이 허용하지 않는 형태다. 모든 곳이 깨끗했던 건 nestjs뿐이었다.

첫 발견, 한쪽만 끝난 정리

kotlin의 repository-pattern.md에는 바로 이 정리가 이미 끝났다고 적혀 있었다. 틀린 말은 아니었다. 쓰기 쪽 인터페이스인 AccountRepository는 끝나 있었다. 그런데 짝이 되는 읽기 쪽 인터페이스인 AccountQuery, CardQuery, PaymentQuery, RefundQuery는 옛 이름 그대로였다. 누가 거짓말을 한 건 아니다. 대칭인 두 쪽 중 한쪽만 고쳐지고 다른 쪽까지는 가지 못했을 뿐이다. 지금 그 문서에는 이 일이 그대로 적혀 있고, 회귀를 막는 메모 역할도 한다.

문서가 "끝났다"고 말해도, 인터페이스 리네이밍이 실제로는 빠졌다면(한때 CredentialQuery.findByUserId가 그랬듯) 그건 하네스 FAIL로 드러난다.

규칙을 쓰기 전에 왜 반복됐는지 묻는다

인터페이스는 4개 언어의 worktree에서 손으로 고쳤고, 언어마다 전체 빌드와 테스트를 돌려 확인한 뒤 푸시했다. 그다음 질문이 더 쓸모 있었다. 이렇게 단순하고 문서에도 잘 적힌 컨벤션이 왜 4개 언어에서 저마다 따로 어긋났을까.

언어마다 이미 아키텍처 검사기가 있었다. 코드가 문서의 규칙을 따르는지 정적으로 검사하는 스크립트다. 파일 위치, 레이어 순수성, import 방향처럼 많은 걸 보고 있었지만, 메서드 이름 컨벤션을 검사하는 규칙은 하나도 없었다. 문서 드리프트를 보는 스크립트(check_docs_drift.py)는 비슷해 보여도 보는 대상이 달랐다. 문서 내용이 파일 트리와 맞는지를 볼 뿐, 메서드 이름이 문서대로 쓰였는지는 보지 않는다. 어느 언어에서든 이걸 잡을 도구가 처음부터 없었던 것이다. 어느 한 구현체가 부주의해서 생긴 일이 아니었다.

첫 실행이 진단을 시험한다

repository-naming 규칙을 5개 언어 모두에 넣었다. 이미 잘 지키고 있던 nestjs에도 다음에 어긋날 때를 대비해 넣었다. 방식은 블록리스트다. *Repository/*Query 인터페이스에 findBy*, 명사 없는 findAll, count*, 명사 없는 save, 명사 없는 delete가 있으면 걸러 낸다.

돌리자마자 그동안 아무도 몰랐던 위반 3건이 더 나왔다. 모두 go·fastapi·kotlin의 Auth/Credential 도메인이었다. 도구가 없어서라는 진단이 틀렸다면, 새 규칙은 새로 찾을 게 없었을 것이다.

범위를 넓히되 안 맞는 규칙은 뺀다

첫 규칙이 성과를 내니 다음 질문은 정해져 있었다. 문서에는 있는데 강제하지 않는 컨벤션이 또 뭐가 있을까. 같은 질문으로 몇 번 더 훑었고, 구조 규칙을 모두 15개 더했다. 모든 언어에 다 들어간 건 아니다. 도메인 레이어 격리, 같은 Bounded Context 안에서 다른 Aggregate 참조 금지, config 모듈 밖에서 환경 변수 직접 읽기 금지, Aggregate ID의 16진수 형식, 필드 4개짜리 에러 응답 형태, 모든 쿼리의 soft-delete 필터 같은 것들이다.

맞지 않는 언어에 규칙을 억지로 넣으면 쓸모 있는 신호 대신 잡음만 늘어난다. 그래서 언어마다 먼저 적용할 수 있는지 따져 보고, 안 맞으면 이유를 문서에 남긴 채 규칙을 빼거나 범위를 좁혔다. 오탐만 쏟아내는 검사를 내보내지는 않았다. fastapi는 interface/infrastructure 격리 규칙을 뺐다. fastapi 문서는 Depends 팩토리 안에서 인프라를 직접 만들라고 정해 두었고, 격리할 대상인 DI 컨테이너가 아예 없다. go는 no-public-setters 규칙을 뺐다. 이 코드베이스의 Go struct는 관례상 전부 export되어 있어서, 캡슐화를 전제로 한 규칙이 성립하지 않는다. 대신 go는 자기 검사에서 위반을 하나 더 찾았다. interface/http가 infrastructure/auth를 직접 import하고 있었는데, 새 domain-layer-isolation 규칙이 잡으려던 경계가 바로 이것이었다.

그다음에는 규칙 5개를 더해 버그 2건을 더 찾았다. fastapi의 PaymentModel과 RefundModel에만 deleted_at 컬럼이 아예 없었다. 같은 코드베이스의 다른 모델에는 모두 있는 컬럼이다. 이때 예전 기록에 "알려진 갭"으로 남아 있던 항목 하나가 틀렸다는 것도 알게 됐다. 아직 연결되지 않았다고 믿었던 java의 rate-limit filter가, 기록되지 않은 이전 커밋에서 이미 고쳐져 있었다. 한 번 적어 둔 "알려진 갭"은 그 시점의 스냅샷이다. 다시 인용하기 전에 지금 코드로 확인해야 한다.

수확 곡선이 평평해지면 멈춘다

마지막에는 5개 언어 모두에 규칙 4개를 더했는데, 코드 위반은 한 건도 나오지 않았다. 대신 문서에 남은 낡은 문장 2건이 나왔다. 그중 하나는 한 언어의 tactical-ddd.md가 코드베이스를 여전히 단일 도메인으로 설명하는 문장이었다. 두 번째 Bounded Context가 생기기 전부터 아무도 손대지 않은 문장이다. "해당 없음"도 하나 있었다. go 스택에는 ORM이 아예 없어서 ORM-autosync 규칙은 적용할 수가 없다. 억지로 끼워 넣지 않고 "해당 없음"이라고 문서에 명시했다.

수확 곡선이 곧 결론이었다

처음 몇 번은 검사를 더할 때마다 위반이 3~4건씩 나왔다. 그다음은 2건, 마지막은 코드 버그 0건에 낡은 문서 2건이었다. 숫자가 줄었다고 뒤의 작업이 헛수고였던 건 아니다. 앞에서 이런 검사로 잡을 수 있는 언어 간 어긋남 중 쉬운 것을 대부분 이미 잡았다는 뜻에 가깝다. 버그를 끝없이 찾는 게 목표가 아니었다. 언제 멈출지 알고 싶었고, 그걸 솔직하게 알려 주는 건 평평해진 수확 곡선뿐이다.

"문서는 끝났다고 했다"는 거짓말이었다기보다 아무도 확인할 수 없는 주장이었다. 회귀를 막아 줄 장치 없이 스스로 적은 보고였고, 구현체 5개 중 어느 하나가 언제든 소리 없이 다시 어긋날 수 있는 코드베이스였다. 이번에 바뀐 건 이름만이 아니다. 이제 "끝났다"는 문서에 적기만 하면 되는 말이 아니게 됐다.

더 볼 자료

docs/architecture/repository-pattern.md(같은 백엔드 설계를 5개 언어로 구현해 둔 내 예제 프로젝트의 네이밍 컨벤션) · kotlin의 repository-pattern.md(이 규칙을 자동으로 검사하게 된 이유를 적은 메모) · repository_naming.go(한 언어의 회귀 방지 장치) · 컴플라이언스를 코드로, 아키텍처 검사기가 잡는 것과 계속 놓치는 것(배치 검사가 계속 놓치는 세 가지 드리프트)