Tooling · Developer Experience

코드 생성기는
모든 규칙의 두 번째 구현이었다

스캐폴딩 생성기는 자기가 찍어 내는 모든 컨벤션의 두 번째 구현이고, 모양을 베껴 오는 원본 코드와는 따로 관리된다. 규칙과 손으로 쓴 예시만 고치고 생성기를 그대로 두면 생성기는 옛 패턴을 계속 만든다. 이걸 잡으려면 이름 하나만 넣어 새 도메인을 만들고 자동 검사를 전부 돌려 보면 된다.

내 예제 프로젝트는 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 것인데, docs/reference.md에 구현 템플릿이 있다. 모든 계층과 파일, 네이밍 컨벤션을 한곳에 모아 보여 주는 작은 예시다(예전에는 Order 도메인이었다). 글로 된 템플릿은 쓸모가 있다. 그런데 새 도메인을 연달아 다섯 번째 만들면서도 그 내용을 손으로 하나하나 틀리지 않게 쳐야 한다면 얘기가 달라진다. 그래서 템플릿을 생성기로 바꿨다. 도메인 이름 하나만 받아서, 아키텍처 검사를 통과하는 코드를 만들어 내는 스크립트다. 아키텍처 검사는 코드가 문서의 규칙을 따르는지 정적으로 검사해 점수를 매기는 스크립트다. 생성기는 잘 돌았다. 더 어려운 건 생성기를 규칙에 계속 맞춰 두는 일이었다.

한 번에 만들어지는 것

Go 생성기에 처음 보는 도메인 이름을 넣고 돌리면 한 번에 다음이 만들어진다. PENDING/ACTIVE/CANCELLED를 오가는 상태 필드 하나를 가진 Aggregate, CQRS Command/Query Handler, Domain Event 하나, Repository(domain 인터페이스와 infrastructure 구현체), HTTP Handler와 DTO, migration이다.

# Default: generates under examples/internal/..., doesn't touch main.go/router.go,
# just prints to the console the content you should paste in
go run . Coupon

# With --wire, it also auto-inserts into cmd/server/main.go (repository assembly + registration
# in the shared outbox handler map) and internal/interface/http/router.go (Handler assembly +
# route registration)
go run . Coupon --wire

# To generate into a different project (e.g. one cloned from this repo as a template), specify --out
go run . Coupon --out /path/to/other-project --wire

나오는 건 일부러 완성된 기능이 아닌 뼈대로 만들었다. 비어 있는 CRUD 모양의 출발점이다. 비즈니스 규칙, 에러 메시지, 도메인 고유 필드는 여전히 손으로 채워야 한다. 생성기 덕분에 도메인 로직을 안 짜도 되는 건 아니다. 대신 새 도메인이 첫날부터 검사를 통과하는 데 필요한 자잘한 컨벤션 30개 남짓(파일 이름, 계층 배치, Repository 메서드 이름, Outbox 등록 호출)을 머릿속에 일일이 담아 둘 필요가 없어진다.

맞춰 준 적 없는 이름으로 시험한다

그럴듯해 보이는 코드를 내놓는 생성기와 검사 규칙을 전부 통과하는 코드를 내놓는 생성기는 다르다. 그 차이가 없어졌는지 보려면 한 번도 써 본 적 없는 도메인, 기존 예시 도메인과 전혀 상관없는 도메인을 만들어서 검사를 직접 돌려 봐야 한다.

go run . Coupon --wire
bash harness.sh <projectRoot>
# → A (100/100)

여러 단어로 된 이름과 불규칙 복수형 이름을 일부러 넣어 본 건, 단순하게 짠 코드 생성기가 가장 먼저 깨지는 곳이 거기라서다. 접미사 규칙(+s, +es, y→ies)만으로 복수형을 만들면 Coupon → coupons는 문제없지만, 이 규칙을 따르지 않는 복수형은 손으로 고쳐 줘야 한다. 생성기를 짠 사람이 염두에 둔 예시 하나로 한 번 성공해 봐야 의미가 없다. 따로 맞춰 준 적 없는 도메인에서도 100/100이 나와야 문서와 도구가 서로 맞는다고 말할 수 있다.

규칙은 바뀌고 생성기는 그대로

언어를 가리지 않고 생성기에서 가장 흔했던 실패는 이렇다. 서로 상관없는 기능을 만들 때마다 같은 일이 되풀이됐다. 검사에 새 규칙이 들어온다. 네이밍 컨벤션일 수도, request-context 패턴일 수도, Outbox 구조 변경일 수도 있다. 손으로 쓴 예시 코드는 규칙에 맞게 고친다. 그런데 생성기는 옛 패턴을 계속 찍어 낸다. 규칙이 바뀐 뒤 아무도 생성기를 다시 돌려 보지 않았기 때문이다.

이런 일이 한 번으로 끝나지 않았다. Repository 메서드 네이밍 규칙을 도입했을 때다. 흩어져 있던 패턴을 find<Noun>s/save<Noun>/delete<Noun>로 통일했는데, Go와 kotlin-springboot 생성기가 아직도 예전 find-by 형태와 명사 없는 save를 만들고 있다는 걸 알았다. 따로 감사를 한 것도 아니고, AI 에이전트의 작업을 이 검사로 채점해 보는 상관없는 실험을 돌리다 발견했다. 규칙이 들어간 뒤로 두 생성기 모두 아무도 손대지 않았던 것이다. 둘 다 실제 도메인이 이미 쓰고 있던 Find<Domain>/FindOne + Save<Domain> 컨벤션으로 다시 짰다.

NestJS에서 request-scope 기반 user-context 저장소(@Authenticated() + UserContextStore.getRequesterId())로 req.user 직접 접근을 바꿨을 때도 그랬다. 생성기의 Controller 템플릿은 옛 req.user 패턴을 계속 만들었고, 새 도메인을 스캐폴딩하자마자 검사에서 떨어졌다. 손으로 쓴 예시를 고친 것과는 따로 고쳐야 했다. 둘은 서로 다른 산출물이기 때문이다. Outbox 드레인을 5개 언어 모두에서 한 번 훑기에서 여러 번 훑기로 바꿨을 때도 똑같았다. 생성기마다 손으로 쓴 예시와 똑같은 구조 수정이 필요했고, 커밋도 따로 하나씩 더 나왔다.

이 모든 사례에 깔린 패턴

생성기는 자기가 찍어 내는 모든 컨벤션의 두 번째 구현체다. 게다가 모양을 베껴 오는 원본 코드와는 따로 관리된다. 규칙과 예시를 고치면서 "생성기도 아직 이걸 만들어 내나?"를 같이 묻지 않으면, 가끔이 아니라 매번 어긋난다.

생성기가 스스로 만든 버그

생성기의 위험은 규칙을 못 따라가는 것만이 아니다. 템플릿 로직은 그 자체로 별도의 코드이고 자기만의 버그가 있을 수 있다. 손으로 쓴 예시에는 없던 결함이 생성기에만 있을 수 있다는 뜻이다. 한 생성기가 만든 "cancel" 핸들러에는 이미 취소된 상태에 대한 매핑이 빠져 있었다. 고치기 전까지 그 도구로 만든 모든 도메인이 400을 내야 할 자리에서 아무 경고 없이 일반 500 에러를 냈을 것이다. 문서가 어긋난 문제가 전혀 아니다. 코드를 짜는 코드에 든 버그이고, 생성기 소스를 읽어서는 안 보인다. 뭔가를 직접 생성해서 실패 경로까지 돌려 봐야 드러난다.

다시 생성하는 것이 회귀 테스트다

결국 모든 언어 구현이 저마다 생태계에 맞는 방식으로 이 도구를 하나씩 갖게 됐다. NestJS는 Node 스크립트다. Go는 go run .으로 도는 독립 Go 모듈인데, 기존 모듈에 스캐폴딩 스크립트를 붙일 마땅한 자리가 없어서다. Spring Boot 구현 2개는 Python 스크립트를 쓴다. 파일 하나 만들자고 Java/Gradle 툴체인을 띄우게 하고 싶지 않아서 일부러 Python으로 했다. FastAPI에도 하나 있다.

5개 모두 쓰는 법이 같다. 도메인 이름을 받고, 선택적으로 --wire 플래그를 주면 붙여 넣을 스니펫을 출력하는 대신 새 도메인을 직접 등록한다. --out 플래그로는 아예 다른 프로젝트에 만들 수 있다. 덕분에 코드베이스 전체를 손으로 베껴 쓰는 참고 자료로도, 새 서비스를 시작하는 템플릿으로도 쓸 수 있다.

생성기는 두 가지 몫을 한다. 하나는 새 도메인을 스캐폴딩하는 생산성 도구이고, 다른 하나는 문서 자체를 지키는 상시 회귀 테스트다. 써 본 적 없는 이름으로 다시 돌리고 검사로 다시 확인할 때마다 "문서에 적힌 컨벤션과 그걸 구현해야 할 도구가 아직 서로 맞는가"를 묻게 된다. 생각보다 훨씬 자주 다시 물어야 하는 질문이었다.

더 볼 자료

implementations/go/docs/reference.md(같은 백엔드 설계를 5개 언어로 구현해 둔 내 예제 프로젝트에서, Go 생성기의 바탕이 되는 Go 참조 구현 템플릿. 언어마다 따로 있다) · implementations/go/scripts/create-domain(Go 생성기 소스)