Tooling · Architecture

컴플라이언스를 코드로,
아키텍처 검사기가 잡는 것과 계속 놓치는 것

아키텍처 문서를 자동 검사로 바꾸면, 코드가 엉뚱한 자리에 들어가는 건 잘 잡힌다. 그래도 세 가지는 계속 빠져나갔다. 맞는 파일 안의 틀린 이름, 자기 문서와 함께 틀린 코드, 두 구현 사이에만 있는 불일치다.

설계 문서는 주석과 같은 식으로 썩는다. 티 나지 않게 조금씩 낡다가, 어느 날 누군가 문서와 코드를 나란히 읽고서야 둘이 몇 달째 서로 다른 시스템을 설명하고 있었다는 걸 알게 된다. 내 경우 오래 효과가 간 해법은 문서를 더 잘 쓰는 게 아니었다. 아키텍처 검사기를 만드는 것이었다. 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에 붙인 검사기로, 코드가 문서의 규칙을 따르는지 정적으로 검사해 점수를 매기는 스크립트다. 드리프트를 잡는 일이 더는 리뷰어의 눈에 기대지 않는다.

배치 규칙(어느 폴더, 어느 계층, 어느 import 방향)은 처음부터 제 몫을 했다. 검사기가 놓친 드리프트는 세 종류였다. 셋 다 규칙으로 막기 전에 누군가 손으로 한 번은 찾아내야 했다.

규칙이 가정해도 되는 것

예제 비즈니스 코드는 처음엔 Account(계좌) 도메인이었고, 나중에 Card와 Payment가 더해졌다. 이건 설명용 샘플이다. 검사기가 기대도 되는 fixture가 아니다. 규칙은 "Account 도메인은 이렇게 동작한다"를 전제로 삼으면 안 된다. 같은 모양을 가진 도메인이라면 무엇이든 통하는 방식으로 아키텍처 패턴을 검사해야 한다.

검사기가 평가하는 건 아키텍처 규칙을 지켰는지다. 계층 배치, 의존성 방향, 네이밍, 트랜잭션 경계, Outbox 패턴 같은 것들이다. 그 구조 안에 든 비즈니스 로직이 맞는지는 절대 평가하지 않는다. 주문 취소 규칙, 결제 승인 조건, 재고 예약 정책은 모두 범위 밖이다. 이런 내용은 문서 예시나 실행 가능한 examples/ 코드에 나올 수는 있어도, 핵심 규칙의 필수 전제가 돼서는 안 된다.

이 선이 흐려지면 새 규칙이 아무도 모르게 특정 비즈니스 도메인 하나에 묶인다. 그러면 검사기는 프레임워크와 상관없는 아키텍처 가이드에서 Account 서비스 전용 린터로 떨어진다.

정답 구현 하나보다 작은 assertion 여러 개

검사기는 "이 파일은 꼭 이렇게 생겨야 한다"는 레퍼런스 구현 하나를 박아 두지 않는다. 대신 작고 독립적인 assertion을 여러 개 두고 부분 점수를 매긴다. 규칙마다 특정 위반이 있는지를 따로 검사하고, 결과를 더해 점수를 낸다. 같은 원칙을 구현하는 올바른 방법이 여러 가지 있을 수 있다고 보기 때문이다. 구조가 건전하다면 examples/의 예제와 겉모습이 다르다는 이유로 점수를 깎으면 안 된다.

첫 번째 사각지대, 맞는 파일 안의 이름

처음 만든 규칙들은 누구나 예상할 만한 것들을 검사했다. domain 폴더가 있는지, Interface 계층이 Infrastructure를 직접 import하지 않는지, Repository 인터페이스가 문서에 적힌 위치에 있는지 같은 것이다. 이것만으로도 드리프트 한 부류를 통째로 잡는다. 대신 사각지대도 분명하다. 맞는 파일 안의 메서드 이름이 제대로 지어졌는지, 맞는 폴더 안의 클래스가 맞는 대상에 의존하는지는 전혀 알려 주지 않는다.

손으로 한 감사에서 이 틈이 그대로 드러났다. Repository 메서드 네이밍 컨벤션이 있다. 목록 조회는 항상 find<Noun>s, 저장은 항상 save<Noun>로 짓는다. 그런데 5개 구현 중 4곳에서 이 규칙이 깨져 있었고, 깨진 모양도 매번 달랐다. 2곳은 명사 없이 save만 쓰고 있었고, 4곳은 오래된 도메인에 "전용 findOne과 별도 findAll" 짝이 되살아나 있었다. 이 파일들은 당시 있던 구조 규칙을 하나도 빠짐없이 통과했다. 그 규칙들 가운데 메서드 이름을 들여다본 게 하나도 없었기 때문이다.

근본 원인을 있는 그대로 적으면

같은 부류의 드리프트를 거듭 발견하면서 내린 진단은 매번 같았다. 이걸 검사하는 도구가 없었다. 구조 검사는 배치를 확인한다. 네이밍 컨벤션이나, 이미 맞는 폴더 안의 의존성 방향, 메서드 이름 패턴에 대해서는 아무 말도 하지 않는다. 이런 것들은 하나하나 전용 규칙이 필요했다. 그리고 그 규칙은 수동 감사가 그 틈을 적어도 한 번 손으로 찾아낸 뒤에야 쓸 수 있었다.

두 번째 사각지대, 문서와 함께 틀린 코드

"코드가 자기 문서와 맞는가"를 보는 감사는, 코드와 문서가 함께 틀려 있으면 아무 문제 없이 통과한다. 한 구현체의 CQRS 문서에는 Query Handler가 쓰기 가능한 Repository를 직접 쓰는 코드가 올바른 예시로 실려 있었다. 코드는 문서와 완벽하게 맞았다. 그리고 둘 다 루트 원칙을 어기고 있었다. Query 쪽은 쓰기 가능한 인터페이스를 아예 보면 안 된다는 원칙이다.

문서와 코드가 맞는지만 보는 감사로는 이걸 잡을 수 없다. 그 감사가 확인하는 게 일치 여부인데, 여기서는 일치 자체가 버그였다.

문제를 드러낸 건 로컬 문서를 루트 원칙과 비교한 일이었다. 이 비교를 한 번 하고 끝내지 않고 상시 규칙으로 만들자, 처음 돌린 날 다른 파일에서도 같은 부류의 위반을 따로 잡아냈다.

세 번째 사각지대, 구현 사이의 불일치

언어를 하나씩 감사해서는 언어 사이에만 있는 구조적 불일치를 절대 볼 수 없다. 알림 발송이라는 관심사가 5개 구현 중 2곳에서는 domain 모듈 안에 있었고, 나머지 3곳에서는 따로 떼어 낸 공용 최상위 모듈에 있었다. 어느 쪽도 그것만 보면 딱히 틀리지 않았고, 각자 자기 언어의 검사기는 깨끗하게 통과했다.

언어 하나만 리뷰해서는 애초에 보일 수가 없는 불일치였다. 같은 개념을 5개 언어에 걸쳐 나란히 놓고 비교하고 나서야 드러났다. "코드베이스 하나를 자기 문서와 대조해 리뷰한다"와는 전혀 다른 종류의 감사다.

발견을 영구 규칙으로 바꾸기

여러 번 해 보면서 자리 잡은 방식은 이렇다. 위반을 손으로 한 번 찾아서 고친다. 그 위반을 잡았을 규칙을 쓴다. 그리고 코드가 이제 깨끗하다고 믿기 전에 새 규칙을 바로 돌린다.

Repository 네이밍 규칙이 가장 분명한 예다. 글로만 적혀 있던 규칙이 기계적인 검사가 되자마자, 아무도 다시 볼 생각을 안 했던 도메인에 돌려 봤다. 5개 언어 중 3곳의 인증(authentication) 도메인이었다. 그동안 어느 감사 범위에도 들어간 적 없던 위반 3건이 곧바로 나왔다. 이전 감사는 모두 다들 신경 쓰던 비즈니스 도메인 2개만 봤기 때문이다. 의존성 방향 규칙, ID 포맷 규칙, 에러 응답 스키마 규칙, soft-delete 필터 규칙까지, 이런 식으로 5개 언어에 걸쳐 약 30개 규칙이 쌓였다. 하나하나가 상상으로 만든 버그가 아니라 이 사례처럼 겪은 버그에서 나왔다.

시간이 갈수록 찾아내는 건 줄어든다. 예상한 일이다. 초반에는 새 규칙 카테고리마다 위반을 3~4건씩 찾았다. 나중에는 새 규칙 대부분이 아무것도 찾지 못했다. 쉽게 잡히는 언어 간 드리프트는 이미 다 정리됐기 때문이다. 오후 한나절 들여 쓴 규칙이 오늘은 아무것도 못 잡아도, 다음 달에 생길 회귀는 막고 서 있다.

문서만으로는 얻을 수 없는 것

CI 파이프라인이 변경마다 검사기를 돌리면, 계층 배치나 네이밍 컨벤션, 의존성 방향을 어긴 pull request는 사람이 리뷰에서 알아채기 전에 빌드에서 떨어진다. 리뷰어가 손으로 짚기 전에 린터가 문법 오류를 잡는 것과 같다. 내 셀프 리뷰 체크리스트는 일부러 검사기 스펙을 겸하게 써 두었다. 새 항목을 넣을 때마다 "이것도 기계로 검증할 수 있나"를 먼저 묻고, 안 될 때만 글로만 남기는 항목으로 받는다.

더 볼 자료

docs/harness.md(같은 백엔드 설계를 5개 언어로 구현해 둔 내 예제 프로젝트의 검사기 설계 원칙 전문) · docs/checklist.md(대부분의 규칙이 여기서 나온 셀프 리뷰 체크리스트) · 문서는 "끝났다"고 했다, 검사는 언제까지 늘려야 하나(이름 규칙 발견을 규칙 하나하나 따라가며, 줄어드는 수확으로 언제 멈출지 정한 이야기)