Tooling · Developer Experience

문서에서
한 번의 명령으로 실행 가능한 코드로

문서에 있는 참조 구현은 그 패턴이 읽기 좋다는 것만 증명한다. 마감 기한 안에 누군가가 실제로 그것을 재현할 수 있다는 것까지는 증명하지 못한다. 진짜 테스트는 이름 하나만 가지고 생성한 완전히 새로운 도메인이 첫 실행부터 모든 자동화된 검사를 통과하는가다.

이 저장소의 docs/reference.md는 실용적인 구현 템플릿을 정의한다 — 모든 계층, 모든 파일, 모든 네이밍 컨벤션을 한곳에서 보여주는 작은 예시(과거에는 Order 도메인이었다)다. 문서로 작성된 템플릿은 유용하다 — 하지만 다섯 번째로 연이어 새 도메인을 만들 때도 그 내용을 사람이 일일이 손으로 정확하게 타이핑해야 한다면, 거기서부터는 한계가 드러난다. 다음 단계는 그 템플릿을 생성기(generator)로 바꾸는 것이었다: 도메인 이름 하나만 받아서 실제로 Harness를 통과하는 코드를 만들어내는 스크립트다.

한 번에 생성되는 것들

완전히 새로운 도메인 이름을 대상으로 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

생성 결과물은 의도적으로 완성된 기능이 아니라 뼈대(skeleton)다 — 비어 있는 CRUD 형태의 출발점일 뿐이다. 실제 비즈니스 규칙, 에러 메시지, 도메인 고유 필드는 여전히 손으로 채워 넣어야 한다. 생성기가 가져다주는 것은 "다시는 도메인 로직을 작성하지 않아도 된다"가 아니라, "처음부터 만드는 도메인이 첫날부터 Harness를 통과하기 위해 필요한 서른 개 남짓한 자잘한 컨벤션(파일 네이밍, 계층 배치, Repository 메서드 이름, Outbox 등록 호출)을 손으로 일일이 기억하지 않아도 된다"는 것이다.

실제로 중요한 검증

그럴듯해 보이는 코드를 만드는 생성기와, Harness가 검사하는 모든 규칙을 통과하는 코드를 만드는 생성기는 같지 않다. 그 간극이 실제로 메워졌는지 확인하려면 지금까지 아무도 써본 적 없는 도메인 — 기존 예시 도메인들과 전혀 무관한 도메인 — 을 생성해서 Harness를 실제로 돌려봐야 한다:

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

여러 단어로 이루어진 도메인 이름과 불규칙 복수형 도메인 이름을 대상으로 특별히 테스트한 이유는, 순진하게 짠 코드 생성기가 가장 먼저 깨지는 지점이 바로 거기이기 때문이다 — 단순 접미사 규칙(+s, +es, y→ies)에 기반한 복수화 규칙은 Couponcoupons는 잘 처리하지만, 그 패턴을 따르지 않는 복수형을 가진 도메인은 수동으로 손봐야 한다. 생성기의 저자가 작성 당시 염두에 두었던 그 하나의 예시에 대해 한 번 성공하는 것이 아니라, 특별히 튜닝된 적 없는 도메인들을 대상으로 100/100을 받는지 확인하는 것이야말로 문서와 도구가 실제로 서로 일치하는지를 검증하는 방법이다.

반복되는 버그 유형: 생성기가 자신이 지켜야 할 규칙보다 뒤처지는 것

서로 무관한 여러 기능 개발 라운드에서 반복적으로 발견된, 모든 언어의 생성기에 걸쳐 가장 흔한 단일 실패 패턴은 이렇다: 새로운 Harness 규칙이 추가된다 — 네이밍 컨벤션이든, request-context 패턴이든, Outbox 구조 변경이든 — 수동으로 작성된 예시 코드는 그에 맞춰 업데이트되지만, 생성기는 조용히 예전 패턴을 계속 만들어낸다. 규칙이 바뀐 뒤 아무도 생성기를 다시 실행해보지 않았기 때문이다.

이런 일은 실제로 한 번이 아니라 여러 번 벌어졌다. Repository 메서드 네이밍 규칙이 도입되었을 때(흩어져 있던 패턴들을 find<Noun>s/save<Noun>/delete<Noun>로 통일) — 전용 감사가 아니라 무관한 5개 언어 벤치마크 실행 도중 — Go와 kotlin-springboot 생성기 둘 다 여전히 예전의 find-by/명사 없는 단순 save 형태를 만들어내고 있다는 사실이 드러났다. 그 규칙이 배포된 이후로 둘 다 손댄 적이 없었기 때문이다. 둘 다 실제 도메인들이 이미 쓰고 있던 Find<Domain>/FindOne + Save<Domain> 컨벤션으로 다시 작성됐다. NestJS의 request-scope 기반 user-context 저장소(@Authenticated() + UserContextStore.getRequesterId())가 req.user 직접 접근 방식을 대체했을 때도, NestJS 생성기의 Controller 템플릿은 예전 req.user 패턴을 계속 만들어냈고 새 도메인을 스캐폴딩하는 순간 Harness에서 떨어졌다 — 손으로 작성한 예시에 적용된 수정과는 별개의 수정이었다. 둘은 같은 산출물이 아니기 때문이다. Outbox 패턴이 다섯 언어 전체에서 단일 패스 드레인에서 다중 패스 드레인으로 바뀌었을 때도 똑같은 일이 반복됐다: 모든 생성기가 손으로 작성한 예시와 동일한 구조적 수정을, 별도의 두 번째 커밋으로 필요로 했다.

이 모든 사례의 공통 패턴

생성기는 그 자체로 자신이 만들어내는 모든 컨벤션의 또 하나의 구현체이며, 형태를 베끼는 원본 코드와는 별도로 유지보수된다. 규칙과 예시를 업데이트하면서 "생성기도 여전히 이걸 만들어내는가?"를 함께 묻지 않는 모든 프로세스는, 가끔이 아니라 매번 어김없이 드리프트를 겪게 된다.

생성기 자체가 만들어낸 버그

생성기가 안고 있는 위험은 규칙보다 뒤처지는 것만이 아니다 — 템플릿 로직 자체가 자신만의 버그를 가진 별도의 코드이기 때문에, 손으로 작성한 예시에는 없던 독자적인 결함을 갖고 있을 수도 있다. 한 생성기가 스캐폴딩한 "cancel" 핸들러는 이미 취소된 상태에 대한 매핑이 빠져 있는 것으로 드러났다 — 이는 그 도구로 생성된 모든 도메인에서, 발견되어 고쳐지기 전까지 올바른 400 대신 조용히 일반적인 500 에러를 냈을 실제 결함이다. 이건 문서 드리프트 문제가 전혀 아니다; 코드를 작성하는 코드에 들어 있는 버그이며, 생성기의 소스를 읽어서가 아니라 실제로 뭔가를 생성해서 실패 경로(unhappy path)를 직접 돌려봐야만 드러난다.

이 유지보수 비용을 치를 가치가 있는 이유

이 저장소의 모든 언어는 결국 각자의 생태계에 맞는 관용적인 방식으로 이 도구의 자체 버전을 갖게 됐다 — NestJS는 Node 스크립트, Go는 go run .을 쓰는 독립된 Go 모듈(Go에는 기존 모듈에 스캐폴딩 스크립트를 자연스럽게 붙일 자리가 없기 때문이다), 두 개의 Spring Boot 포트는 Python 스크립트(파일 하나 스캐폴딩하자고 Java/Gradle 툴체인을 부팅시키게 하는 대신 의도적으로 Python을 선택했다), 그리고 FastAPI용도 하나. 다섯 개 모두 동일한 계약을 따른다 — 도메인 이름을 입력받고, 붙여넣을 스니펫만 출력하는 대신 새 도메인을 자동으로 등록하는 선택적 --wire 플래그와, 완전히 다른 프로젝트를 대상으로 하는 --out 플래그를 함께 지원한다 — 이는 이 저장소를 손으로 베껴 쓸 참조 자료로서만이 아니라, 완전히 새로운 서비스를 부트스트랩하는 템플릿으로 다루는 데도 쓸모가 있다.

생성기는 자기 몫을 두 번 해낸다: 한 번은 실제 신규 도메인을 스캐폴딩하는 진짜 생산성 도구로서, 또 한 번은 문서 자체에 대한 상시 회귀 테스트로서다 — 아무도 써본 적 없는 이름을 대상으로 다시 실행하고 Harness로 재검증할 때마다, 그것은 실제로 "문서화된 컨벤션과 그것을 구현해야 할 도구가 여전히 서로 일치하는가"를 묻는 것이다. 그 질문은 예상보다 훨씬 더 자주 다시 물어야 하는 것으로 드러났다.

저장소 내 추가 자료

docs/reference.md — 모든 생성기가 기반으로 삼는 참조 구현 템플릿 · implementations/go/scripts/create-domain — Go 생성기의 실제 소스