AI Agents · Benchmark
구독자가 둘이어야만
존재하던 버그
모두가 첫 시도에 100점을 받았다면, 그 테스트는 누가 어디서 무너질지 아무것도 알려 주지 않은 것이다. 천장 효과다. 실패할 틈이 없는 테스트로는 어디가 한계인지 알 수 없다. 한계를 찾으려면 설계 판단을 하나씩 더해 가며 난이도를 올려야 한다. 그렇게 과제를 네 단계로 쌓았고, 마지막 단계에서 버그가 하나 나왔다. Bounded Context 두 개가 같은 이벤트를 구독하는 상황에서만 생기는 버그였다. 코드에 그런 상황이 생긴 건 처음이었고, 그래서 5개 언어 구현 중 어디에서도 드러날 기회가 없었다.
AI 코딩 에이전트에게 같은 과제를 주고, 결과를 아키텍처 검사로 채점하는 실험이다. 아키텍처 검사는 코드가 문서에 적힌 아키텍처 규칙을 따르는지 정적으로 확인해 점수를 매기는 스크립트다. 대상은 같은 백엔드 설계(DDD, CQRS, Outbox)를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트다.
시작은 단순했다. Voucher라는 합성 도메인 하나를 5개 언어로 동시에, 서로 따로 만들게 했다. 발행하면 ACTIVE가 되고, 사용 처리(redeem)는 이벤트 없이 상태만 바뀌고, 만료(expire)는 시스템의 다른 곳이 반응하니 이벤트를 낸다. 에이전트마다 자기 언어의 implementations/<lang>/CLAUDE.md 하나만 주고 시작했다. 문서 경로도, 뼈대 코드 생성기에 대한 힌트도 주지 않았다.
다섯 모두 검사 만점을 받았고, 서로 상의 없이 같은 판단을 내렸다. 이벤트는 expire()에서만 발행한다는 것이다. 문서가 다른 곳에서 이미 보여 준 "이걸 듣고 반응하는 쪽이 있는가" 기준 그대로였다. 문서가 언어에 상관없이 같은 뜻으로 읽힌다는 좋은 증거였다. 덤으로 아무도 몰랐던 도구 회귀 3건도 나왔다. 도메인 이름 하나로 빈 뼈대 코드를 만들어 주는 스크립트 2개가, 같은 날 만든 네이밍 규칙이 금지한 형태를 여전히 찍어 내고 있었다. 그리고 두 언어의 검사 스크립트는 파일 워커가 건너뛸 디렉터리 목록이 서로 어긋나 있었다.
모두 만점이면 오히려 불안하다
5개 언어 모두 쉬운 과제 하나를 첫 시도에 통과했다. 이 결과만으로는 어느 구현이 어디서 무너질지 알 수 없다. 늘 통과하는 테스트에는 변별력이 없고, Voucher는 일부러 쉽게 낸 첫 과제였다. 정말 궁금한 건 다음에 무엇을 만들게 할지였고, 같은 쉬운 과제를 또 돌려서는 답이 안 나온다. 반복을 늘릴 게 아니었다. 지금까지 어떤 과제도 건드리지 않은 코드 경로를 골라, 그쪽으로 일부러 어렵게 만들어야 했다.
반복 대신 사다리
레벨 2는 Booking/Cancellation이었다. Bounded Context 하나 안에 Aggregate 2개와 Domain Service를 두는 구조로, Payment/Refund의 RefundEligibilityService를 본뜬 것이다. 사다리가 변별력을 갖기 시작한 첫 단계였다. 다섯 모두 여전히 100점에 닿았고, 명세가 틀릴 여지를 남겨 둔 미묘한 판단에서도 같은 답을 냈다. Refund는 REJECTED 상태를 저장하지만, 거부된 예약은 아예 저장하지 않는다는 판단이다. 다만 NestJS가 첫 시도에서 96/100을 받았다. 이번엔 진짜 결함이었다. 컨벤션상 타입이 있는 enum을 던져야 할 자리에 그냥 문자열을 던졌고, 에이전트가 스스로 고쳤다.
레벨 3 Membership은 다른 BC인 Account의 상태를 읽는 동기 Adapter가 필요한 과제였다. 이번에도 다섯 모두 맞는 패턴을 골랐다. 레벨 4라면 비동기 Integration Event가 필요했겠지만, 여기서는 동기로 읽는 게 맞다. 모든 언어가 Account의 상태 enum을 경계 밖으로 그대로 흘리지 않고 단순한 boolean으로 바꿔 넘겼다. Java 에이전트는 한 걸음 더 나갔다. Card에 이미 있는 AccountAdapterImpl과 Spring bean 이름이 겹친다는 걸, 코드를 쓰기 전에 그 충돌을 적어 둔 기존 주석을 찾아 읽고 미리 피했다.
레벨 3을 뒤집어 만든 레벨 4
StandingOrder는 처음부터 레벨 3의 거울상으로 설계했다. Account를 대상으로 만들면 ACTIVE가 된다. 그 Account가 나중에 정지되면 StandingOrder는 자동으로 PAUSED가 되고, Account가 해지되면 CANCELLED가 되어야 한다. 이 반응은 Account 상태가 바뀌는 순간 일어나야 하고, StandingOrder를 직접 호출해서 일으키면 안 된다. 그래서 정답 패턴도 레벨 3과 반대다. 동기 조회 대신 비동기 Integration Event를 구독해야 한다.
다섯 모두 이 차이를 제대로 짚었다. 이번엔 걸린 게 큰 만큼 검증도 강화했다. 핸들러 함수만 단언하는 유닛 테스트로는 인정하지 않았다. 실제 suspend/close API를 호출하고 반응이 끝날 때까지 폴링하는 end-to-end 테스트로 증명하게 했다.
다섯 모두 통과했고, 따로 다시 검증한 결과도 각자의 자체 보고와 하나도 다르지 않았다. 재미있는 건 점수 쪽이 아니었다.
Card는 StandingOrder가 필요로 하는 Account 이벤트 2개를 이미 구독하고 있었다. 한 eventType에 두 번째 구독자가 붙는 순간, 숨어 있던 사실이 드러났다. 루트 domain-events.md는 이벤트 하나에 핸들러 여럿(1:N)을 원칙으로 적어 두었는데, 5개 언어 중 둘에서는 그 원칙이 코드로 한 번도 일을 해 본 적이 없었다. 이 실험을 만들기 전부터 문서에서는 맞는 말이었고, 코드에서는 내내 틀린 말이었다. 아무도 시도해 보지 않았기 때문이다.
java-springboot는 핸들러 맵을 Collectors.toMap(eventType, identity())로 만들고 있었다. 이미 쓰이는 eventType에 두 번째 핸들러 빈을 등록하면 IllegalStateException: Duplicate key가 난다. 부하가 걸린 런타임이 아니라 애플리케이션이 뜨려는 그 순간에 부팅이 실패한다. FastAPI의 build_event_handlers()는 키마다 콜러블 하나를 담은 dict[str, EventHandlerFn]를 돌려줬다. 여기서는 크래시도 나지 않는다. 두 번째로 등록한 핸들러가 첫 번째를 덮어써서, 나중에 등록한 구독자만 돌았을 것이다.
Go와 NestJS는 처음부터 이 문제가 없었다. Go의 main.go는 평범한 맵을 손으로 조립하니 같은 키에 호출을 하나 더 넣는 게 별일이 아니고, NestJS의 레지스트리는 원래 리스트 형태였다. 각 언어의 에이전트는 서로 맞추지 않고 자기 문제를 고쳤다. Java는 Collectors.groupingBy로 바꿔 Map<String, List<OutboxEventHandler>>를 만들었다. FastAPI는 dict[str, list[EventHandlerFn]]로 바꾸면서 consumer와 스캐폴딩 생성기, 문서까지 한꺼번에 고쳤다.
Kotlin만 결이 달랐다. 틀린 건 아니지만 우회하는 방식이 다르다. 레지스트리를 리스트 값 구조로 바꾸지 않고, 기존의 eventType별 람다 안에 두 번째 핸들러 호출을 그냥 끼워 넣었다.
"AccountSuspendedEvent" to { eventId, payload ->
accountSuspendedEventHandler.handle(objectMapper.readValue(payload, AccountSuspendedEvent::class.java), eventId)
standingOrderPauseHandler.handle(objectMapper.readValue(payload, AccountSuspendedEvent::class.java), eventId)
}구독자가 둘일 때는 제대로 동작한다. 하지만 구조로 푼 게 아니고 하드코딩이다. 같은 이벤트에 세 번째 구독자가 생기면, 일반화된 Java·FastAPI와 달리 새로 등록하는 대신 이 람다를 손으로 고쳐야 한다. 문제로 적어만 두고 고치지는 않았다. 더 확장하기 좋은 형태를 요구하는 규칙이 검사에 없으니 검사는 여전히 통과한다.
이 과제가 검증한 것
입력 두 개만 본 검사 규칙이 주는 교훈과 같은 이야기이고, 거기서 한 걸음 더 나간다. 어떤 버그는 특정한 상황 조합이 코드에 나타나야만 생긴다. 아무리 읽어도, 쉬운 과제를 아무리 반복해도, 어떤 정적 규칙을 써도 그 조합을 만들어 낼 수는 없다. 시나리오를 직접 돌려 봐야 한다.
레벨 1의 만점은 5개 언어가 쉬운 판단에서 의견이 같은지를 쟀다. 레벨 4는 만점 뒤에 얼마든지 숨을 수 있는 것을 쟀다. 같은 이벤트에 관심 있는 쪽이 둘 생기는, 현실에서 흔한 사용 방식이 처음 닥쳤을 때 코드베이스가 버티는가. 5개 언어 중 셋은 그 상황을 일부러 만든 과제를 받기 전까지, 아무도 모르는 채로 버티지 못하는 상태였다.
docs/benchmark.md(같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트 backend-service-playbook의 전체 실행 기록. 모든 레벨의 자체 보고와 독립 재검증 비교표가 있다) · OutboxEventDispatcher.java(리스트 값 핸들러 맵으로 고친 코드) · AI 에이전트는 아키텍처를 따를 수 있을까?(과제를 어떻게 짜고 점수를 사람이 어떻게 다시 확인하는지)