AI Agents · Benchmark
AI 에이전트는
정해 둔 아키텍처를 따를 수 있을까?
코딩 벤치마크는 대개 테스트가 통과하는지를 본다. 그런데 AI 에이전트를 실제로 도입하려는 팀이라면 더 좁고 어려운 질문이 궁금할 것이다. 팀이 문서로 정해 둔 컨벤션을 에이전트가 알아서 찾아내고, 처음 보는 요구사항에 제대로 적용할 수 있을까?
이걸 재려면 세 가지를 정해야 하고, 셋 다 틀리기 쉽다. 과제는 성기게 줘서 컨벤션을 찾는 일까지 시험에 넣는다. 점수는 에이전트의 보고를 믿지 않고 시험하는 사람이 다시 돌려서 낸다. 난이도는 판단 지점을 하나씩 더해 가며 올린다. 모든 에이전트가 통과하는 과제로는 알 수 있는 게 없다.
나는 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서 이걸 해 봤다. 거기에는 이런 용도로 써 본 적 없는 객관적인 채점기가 이미 있었다. 코드가 문서에 적힌 아키텍처 규칙을 따르는지 사람 검토 없이 기계적으로 점수를 매기는 아키텍처 검사 스크립트다. 이걸 AI 에이전트 시험으로 돌려쓰는 데 새 인프라는 하나도 필요 없었다. 과제를 내는 방식만 바꾸면 됐다.
과제는 성기게 준다
에이전트에게 주는 프롬프트에는 딱 세 가지만 들어 있고, 어떻게 구현하라는 말은 없다. 첫째는 아직 코드에 없는 도메인의 비즈니스 규칙이다. 둘째는 기존 컨벤션을 따르라는 지시와, 어디서부터 읽을지 알려 주는 최소한의 진입점이다. 진입점은 그 언어의 CLAUDE.md 하나뿐이고 다른 문서 경로는 주지 않는다. 셋째는 완료 기준인데, 검사 스크립트를 직접 돌려서 통과할 때까지 고치라는 것뿐이다. 읽을 목록을 받지 않고 문서 인덱스를 따라가며 관련 문서를 스스로 찾아내는지도 측정 대상에 들어간다.
채점은 에이전트가 아니라 시험을 돌리는 사람이 한다. 에이전트가 "100/100 확인"이라고 보고해도 그대로 믿지 않는다. 에이전트가 작업한 worktree에 검사를 따로 다시 돌린다. 아래에 나오듯, 이 규칙 덕분에 실제 문제를 여러 번 잡았다.
시키지 않아도 컨벤션을 찾아내나
처음 제대로 돌린 시험에서는 에이전트에게 Subscription 도메인을 줬다. owner와 plan 이름이 있고, 만들면 PENDING 상태로 시작한다. 단순한 activate() 전이가 있고, cancel(reason)이 있다. cancel(reason)에는 일부러 "다른 부분이 여기에 반응해야 할 수도 있다"라고만 적고, 기술적으로 무슨 뜻인지는 더 알려 주지 않았다. 레퍼런스 템플릿이나, 도메인 이름 하나로 빈 뼈대 코드를 만들어 주는 스크립트를 가리키는 말도 어디에도 없었다.
에이전트는 CLAUDE.md 인덱스를 스스로 따라갔고, 시키지 않았는데도 그 생성 스크립트를 찾아내 뼈대를 만들었다. "다른 부분이 반응해야 할 수도 있다"는 문구는 이 코드베이스에서 뜻해야 하는 그대로 해석했다. Domain Event와 Outbox 패턴이다. 그래서 cancel()은 이벤트를 발행하게 만들고, activate()는 이벤트 없이 상태만 바꾸게 뒀다. 따로 다시 채점한 결과도 자체 보고와 같은 A (100/100, raw 630/630)였다. 도메인 코드를 직접 읽어 보니 검사 점수만 맞은 게 아니고 비즈니스 로직도 명세대로였다.
따로 일한 에이전트가 같은 판단을 내리나
다음은 Voucher 도메인이었다. issue, redeem, expire 세 동작이 있고, "다른 부분이 반응해야 할 수도 있다"는 같은 힌트를 expire()에만 붙였다. 이 과제를 5개 구현에서 언어마다 에이전트 하나씩 동시에 돌렸고, 각각에게는 자기 언어의 CLAUDE.md만 진입점으로 줬다.
| 언어 | 자체 보고 | 독립 재검증 |
|---|---|---|
| NestJS | A (100/100, raw 815/815) | 815/815, 일치 |
| FastAPI | 854개 통과, 0개 실패 | 854/854, 일치 |
| Go | 652개 통과, 0개 실패 | 652/652, 일치 |
| Kotlin Spring Boot | 1172개 통과, 0개 실패 | 1172/1172, 일치 |
| Java Spring Boot | 1404개 통과, 0개 실패 | 1433/1로 어긋났다가 1404/0으로 일치 |
모든 언어가 만점을 받았다. 그리고 다섯 구현체 모두 Domain Event를 expire()에만 붙이고 redeem()에는 붙이지 않았다. 서로의 결과를 보지 않고 각자 내린 판단이다. 루트 문서 하나가 딱 한 번 설명한 패턴을 그대로 적용한 결과였다. "아무도 반응하지 않는 전이에는 이벤트가 없고, 무언가 반응해야 하는 전이에는 이벤트가 있다"는 패턴이다. 문서는 하나였고, 따로 일한 에이전트 다섯이 똑같은 아키텍처 판단을 내렸다.
이 시험에서 더 중요한 결과는 Java의 불일치였다. 따로 돌린 재검증이 자체 보고와 달랐는데, 원인은 코드에 없었다. 검사 스크립트가 오래된 Gradle 빌드 캐시 디렉터리를 소스처럼 스캔하면서 "레이어 디렉터리 없음"이라는 거짓 양성을 냈다. 빌드 산출물을 지우고 다시 돌리자 자체 보고와 똑같이 나왔다. "자체 보고를 그대로 믿지 않는다"는 규칙은 이런 경우 때문에 있다. 불일치는 분명히 버그를 가리키고 있었다. 다만 아무도 예상하지 못한 곳의 버그였다.
난이도를 한 단계씩 올리기
모든 언어가 첫 시도에 만점을 받는 과제로는 아무것도 가려낼 수 없다. 에이전트가, 혹은 문서가 어디서 무너질지 알려 주지 않는다. 그래서 이후 과제마다 새로운 판단 지점을 하나씩 일부러 더했다.
두 Aggregate에 걸친 규칙. 다음 과제는 같은 BC 안에서 Aggregate 2개를 조율하는 Domain Service가 필요한 과제였다. Booking과 Cancellation이 있고, 취소는 원래 예약이 confirmed 상태이고 요청 수량이 원래 수량을 넘지 않을 때만 유효하다. 코드에 이런 모양의 선례가 하나 있다는 말은 프롬프트 어디에도 넣지 않았다. 그런데도 모든 언어가 그 선례를 찾아냈고, 판단 로직을 상태 없는(stateless) Domain Service로 떼어 냈다.
명세의 미묘한 차이도 모두 스스로 알아챘다. 과제에는 유효하지 않은 요청은 아예 만들어지면 안 된다고 적혀 있었다. 그래서 5개 언어 모두 거절 객체를 반환해 저장하는 대신, Domain Service가 바로 예외를 던지게 바꿨다. 기존 선례와 다른 동작인데, 다르다는 걸 제대로 짚어 낸 것이다. NestJS는 이 시험들을 통틀어 처음으로 첫 결과가 만점이 아니었다. 96점을 받은 뒤 결함 하나를 스스로 고쳤다. 타입이 있는 enum 대신 그냥 문자열을 던지고 있었다.
경계 너머 동기 조회. 그다음은 동기식 cross-BC 조회가 필요한 과제였는데, 용어 힌트는 전혀 주지 않았다. 생성 전에 다른 BC의 상태를 확인해야 한다는 평범한 문장 하나만 있었다. 5개 언어 모두 동기식 Adapter/ACL 패턴을 골랐고, 기존 선례를 찾아냈다. 다른 BC의 상태 enum을 그대로 노출하지 않고 boolean으로 바꿔서 넘기는 ACL 원칙도 지켰다. 여기서도 따로 돌린 검증이 검사 스크립트의 버그를 하나 더 잡았다. 앞서 고친 평가 파일 말고도 3개가 똑같은 사각지대를 갖고 있었다. 오래된 빌드 산출물을 소스로 읽는 문제였다.
비동기 반응. 그다음 과제는 축을 완전히 뒤집었다. 다른 BC의 이벤트에 비동기로 반응하는 과제로, 바로 앞의 동기식 조회와 대비되게 일부러 설계했다. 모든 언어가 동기 호출을 하나 더 붙이지 않고 Integration Event를 구독하는 쪽을 골랐다. 그리고 실제 API로 계정을 정지한 뒤 반응이 일어날 때까지 폴링해서 end-to-end로 증명했다.
이 과제의 가장 중요한 결과는 에이전트 쪽에서 나오지 않았다. 5개 언어 중 2개의 Outbox consumer가 이벤트 타입마다 핸들러를 하나만 등록할 수 있었다. 두 번째 BC가 같은 이벤트를 구독하는 순간 에러 없이 깨지는 구조였다. 억지로 만든 엣지 케이스가 아니다. 그동안 아무도 지나간 적 없는 코드 경로를 시험 과제가 처음 건드리면서 드러난 실제 아키텍처 결함이다.
판단을 전부 한꺼번에
가장 어려운 과제는 그동안 따로 다루던 세 축을 하나에 합쳤다. 매달 자동으로 도는 정기 송금이고(이자 지급과 같은 batch/Task Outbox 패턴), 자격 여부는 Domain Service가 판단해야 하며(앞의 Refund 선례와 같은 모양), 규칙 하나가 실패해도 다른 규칙 처리에는 영향이 없어야 한다.
첫 제출에서 깔끔하게 통과한 건 5개 언어 중 3개뿐이었다. 나머지 2개는 검사 만점과 unit test 전부 통과를 받고도, 실제 인프라에 대고 돌린 end-to-end 테스트에서만 잡히는 버그를 안고 있었다. 한 언어는 생성한 참조 ID를 더 짧은 형식에 맞춰 크기를 잡은 데이터베이스 컬럼에 저장했다. 그래서 꼭 한 달 뒤, 재시도하는 시나리오마다 실패했다. 다른 언어는 end-to-end 테스트가 같은 실행 안의 서로 다른 테스트 메서드에 걸쳐 FIFO 큐의 중복 제거(deduplication) 윈도우에 걸렸다. 첫 번째 이후의 enqueue가 전부 에러 없이 버려졌고, 하마터면 거짓 통과가 나올 뻔했다.
구조 점수 만점과 unit test 전부 통과는 기능이 동작한다는 증거가 못 된다. 이 시험들에서 나온 의미 있는 결함은 하나같이 실제 인프라와 실제 동시 실행이 끼었을 때만 모습을 드러냈다. 그래서 자동으로 검사하는 "구조를 따르는가" 옆에, 따로 확인하는 "정말 동작하는가"가 늘 같이 있어야 한다.
이 시험이 재는 것
위의 단계는 겉보기엔 에이전트를 시험했지만, 매번 시험대에 오른 건 문서의 서로 다른 면이었다. 루트 문서에 한 번 적어 둔 규칙이 따로 만든 구현체 다섯 곳에서 같은 판단을 끌어내는가. 나아가 이 코드베이스를 처음 보는 엔지니어들에게서도 같은 판단을 끌어내는가. 언어마다 점수가 같게 나온다면, 문서가 그 패턴을 그만큼 정밀하게 전달한다는 뜻이다. 검사 스크립트 자체의 버그나, 코드베이스가 그동안 한 번도 지나가지 않은 구조적 빈틈을 찾아내는 시험은 통과하는 테스트만으로는 할 수 없는 일을 한다. 명세가 앞뒤만 맞는 게 아니라 빠진 데 없이 완전하다는 걸 보여 준다.
docs/benchmark.md(같은 백엔드 설계를 5개 언어로 구현해 둔 내 예제 프로젝트의 과제 설명과 결과 표 전체) · docs/harness.md(아키텍처 검사가 코드에 점수를 매기는 방식)