AI 에이전트마다 컨테이너 하나씩
머신 하나에서 코딩 에이전트 여러 개가 서로 밟지 않게 돌리는 설계를 정리했다. 에이전트마다 컨테이너를 줘서 격리 단위와 회수 단위를 맞추고, 에이전트끼리는 네트워크로 주고받고, 중첩 컨테이너는 필요할 때만 쓰고, 인증 정보는 이미지에 굽지 않고 나눠 준다. systemd로 시험해 본 cgroup만 쓰는 가벼운 방법도 같이 적었다.
글쓰기
머신 하나에서 코딩 에이전트 여러 개가 서로 밟지 않게 돌리는 설계를 정리했다. 에이전트마다 컨테이너를 줘서 격리 단위와 회수 단위를 맞추고, 에이전트끼리는 네트워크로 주고받고, 중첩 컨테이너는 필요할 때만 쓰고, 인증 정보는 이미지에 굽지 않고 나눠 준다. systemd로 시험해 본 cgroup만 쓰는 가벼운 방법도 같이 적었다.
모든 페이지가 분량 검사를 통과했고 제목도 URL도 숫자도 달랐다. 그런데도 사이트 전체 텍스트의 절반에 조금 못 미치는 양이 두 장 이상의 페이지에 똑같이 들어 있었다. 이걸 찾아낸 15줄짜리 측정 방법과, 아무도 정한 적 없는데 페이지가 옆 페이지를 베끼게 되는 세 가지 경우를 정리했다.
Kubernetes 안티패턴 검사기 5개가 모두 `---`로 구분한 문서를 가정하고 있었다. `kubectl get -o yaml` 한 번에 리소스를 2개 이상 대면 결과가 `kind: List`로 감싸진다. 그러자 모든 검사기가 아무 말 없이 리소스를 0개 찾았고, 겉보기에는 깨끗한 통과와 똑같았다.
Argo CD App-of-Apps는 증거가 전부 부모에 있고, Flux dependsOn은 자식이 선언하지만 그것만으로는 검증이 안 된다. 어느 트리든 루트를 빼고 감사하면 똑같이 실패하고, 따져 보면 이유도 같다.
여러 검사를 더한 점수에서 검사 하나가 빠졌다면, 아직 안 만들었거나 채점 대상이 영영 보여 줄 수 없는 경우다. Kubernetes 배포 실수 19개 카테고리를 채점하는 검사기에서 drift가 그 뒤쪽 경우였다. drift는 매니페스트를 적용한 뒤에야 생길 수 있어서, 작성 벤치마크로는 구조상 일으킬 수도 막을 수도 없다. 그래서 다음 버전을 기약하지 않고, 받을 수 있는 최고점을 영구히 문서에 적었다.
두 모델이 Kubernetes 매니페스트 작성 과제에서 똑같이 9/9를 받았고, 따로 다시 돌려 봐도 같았다. 그런데 각자 쓴 파일을 읽어 보니 한쪽은 NetworkPolicy가 스스로를 무력화하고 있었고, 다른 쪽은 프로모션 파이프라인이 있지도 않은 리소스를 참조하고 있었다. 리소스가 있는지만 보는 검사로는 보이지 않는, 서로 관계없는 결함 2개였다.
방금 깔끔하게 적용한 클러스터에 drift 검사기를 돌렸더니 온통 drift라고 나왔다. 클러스터가 거짓말을 한 건 아니다. API 서버의 admission 기본값 처리가 Git에 적지도 않은 필드를 채웠고, 객체 전체를 그대로 비교하는 검사기는 그걸 구별하지 못했다.
두 코드베이스가 Aggregate의 ID를 서로 다른 곳에서 만든다. 하나는 생성자에서, 다른 하나는 Factory가 Infrastructure에 요청해서 만든다. Eric Evans의 원저에는 어느 패턴을 설명하는지 인용할 수 있는 구체적인 답이 있고, 그 답은 두 코드베이스의 관례가 가정하는 것과 다르다.
거의 모든 DDD 코드베이스가 다른 Aggregate를 객체로 직접 참조하지 말고 ID로만 참조하게 한다. 그런데 Eric Evans의 2003년 원저는 직접 참조를 분명히 허용한다. ID로만 참조하라는 규칙을 쓴 Vaughn Vernon도 이 사실을 스스로 밝힌다. 그것도 더 엄격한 규칙을 주장하는 같은 논문 안에서다.
같은 시각을 같은 드라이버로 직렬화해도 프로세스 시간대에 따라 문자열이 달라진다. 4개 언어는 호출 지점에, 1개 언어는 프로세스 경계에 이 버그가 있었다. 고칠 자리도 언어마다 달랐고, 9시간 차이 나는 시간대에서 테스트를 직접 돌려 검증했다.
모든 체크를 기다리는 머지 워크플로가 그 체크 중 하나라면 성공은 우연일 뿐이다. Dependabot auto-merge 워크플로가 그동안 머지한 PR은 모두 자기 자신이 만든 6시간짜리 데드락과의 경쟁에서 이겨서 머지된 것이었다. 스텝 하나가, 그 스텝이 끝나야만 끝날 수 있는 체크를 기다리고 있었다. 고치고 나니 바로 뒤에 숨어 있던 두 번째 버그와, 평범한 GitHub 502가 남긴 반쯤 머지된 PR들이 나왔다.
CI 검사는 트리거가 지켜보는 것만 지킨다. Spring Boot 4 마이그레이션에서 낡은 문서 대신 git 히스토리를 확인했고, 검색 인덱스가 없다고 우기던 라이브러리는 우회책을 만들어 넘어갔다. 그런데 하루 뒤, 배포 이미지가 아예 빌드되지 않는 상태로 끝났다. 방금 의미가 바뀐 파일을 CI의 어떤 검사도 지켜보지 않았기 때문이다.
검사 결과 0건은 검사가 무엇을 봤는지만 알려 준다. 경로 존재 여부만 확인하는 체커는 3개 언어 감사 전후로 두 번 다 0건을 보고했다. 그 사이에 고친 문제는 약 80건이었다. 낡은 코드 인용, 파일을 하나도 보지 않고 만점을 주는 평가기, 본뜬 코드에서는 이미 고친 버그를 아직도 찍어 내는 생성기가 그 안에 있었다.
흉내 낸 앱을 따로 조립하는 e2e 스위트는 그 흉내를 시험할 뿐이다. NestJS의 e2e 스위트는 실제 앱을 한 번도 띄우지 않았다. 모든 언어의 LLM 기능도 폴백 경로로만 돌아 봤다. 둘을 고치다가 더 이상한 버그를 만났다. nock과 testcontainers가 같은 패치된 모듈을 두고 다투고 있었다.
같은 문서와 같은 과제를 두 모델에게 주고, worktree를 따로 만들어 동시에 돌렸다. 둘 다 자동 아키텍처 검사에서 만점을 받았다고 스스로 보고했다. 실제 Postgres와 LocalStack 위에서 따로 재현해 보니, 제대로 동작한 쪽은 하나뿐이었다.
이벤트마다 핸들러를 하나만 돌려 본 디스패치 코드는 둘을 돌릴 수 있다는 걸 보여 준 적이 없다. 같은 설계를 구현한 5개 언어에서 실제 기능 4개가 한 이벤트에 두 번째 구독자를 붙이자, 5개 모두 저마다 다르게 깨졌다. 부팅 때 요란하게 죽는 경우부터, 로그 한 줄 없이 핸들러 하나가 빠지는 경우까지 있었다.
월별 명세서도 GDPR식 데이터 내보내기도 "클라이언트가 직접 만들면 되지 않나?"라는 같은 질문에 무너졌다. 그 질문을 통과한 지출 분석 ETL과, 거기서 드러난 규칙을 적었다.
의심받는 사람이 직접 적는 글로 계산한 사기 신호로는 그 사람을 잡을 수 없다. 환불 사유를 읽던 LLM 분류기가 바로 그런 신호였다. 이 분류기와 함께 이력 기반 ML 스코어러까지 걷어 낸 과정과, 걷어 내고 남은 규칙 하나를 적었다.
LLM이 자유로운 질문을 DB 필터로 바꾸게 하되, 누구의 데이터가 돌아올지는 절대 정하지 못하게 하는 법이다. 소유자 필드가 아예 없는 필터 타입, 자기 거래 내역을 대상으로 한 구조화 데이터 RAG 파이프라인, 그리고 그 불변식이 5개 언어의 서로 다른 관례 속에서도 그대로 남은 과정을 정리했다.
빠진 @Transactional, JDK HTTP 클라이언트의 재시도 결함, VARCHAR(36) 오버플로, SQS FIFO 중복 제거 충돌. 실제 인프라가 있어야만 생길 수 있었던 버그 4개다.
LLM은 신호로는 쓸 만하지만 최종 판단을 맡기기엔 믿을 수 없다. 환불 사유를 읽고 값만 돌려주게 하고, 임계값은 분류기를 부르지도 않는 Domain Service에 두면 된다. 그러면 LLM 백엔드를 Claude에서 자체 호스팅 Ollama로 바꿔도 손볼 테스트가 거의 없다.
학습할 실제 데이터 없이 ML 위험 점수를 규칙 기반 환불 판정 옆에 붙이는 법이다. 인터페이스 뒤에 두고, 설정으로 바꾸고, 오류가 나면 통과시키고, 결정은 Domain Service에 남긴다. 환불 이력으로 직접 학습시킨 로지스틱 회귀 예시로 설명한다. 이 코드는 지금은 지웠다.
무엇이 Aggregate 경계를 정하는지, Domain 계층이 ID를 어떻게 스스로 만드는지 정리했다.
AsyncLocalStorage로 만든 UserContextStore, 그리고 거기까지 가려고 Guard와 Interceptor를 나눈 이야기.
로그 레벨 정책과 구조화된 로깅, SLF4J MDC로 Correlation ID를 전파하는 방법.
아키텍처를 자동으로 검사하면 엉뚱한 자리에 들어간 코드는 잘 잡힌다. 그래도 맞는 파일 안의 틀린 이름, 문서와 함께 틀린 코드, 구현 사이에만 있는 불일치는 계속 빠져나갔다.
컴파일되는 애노테이션이 곧 맞는 애노테이션은 아니다. 같은 설계를 구현한 5개 언어의 덜 된 Swagger 문서를 채우고, 애노테이션이 컴파일되니 됐다고 믿지 않고 앱을 직접 띄워 확인했다. 그렇게 찾은 버그는 문서와 상관없는 것들이었다. Spring Boot 4의 의존성 분리 때문에 프로덕션 마이그레이션이 아무 표시 없이 한 번도 돌지 않던 문제도 그중 하나다.
Repository의 연산은 명사를 붙인 find, save, delete 셋으로 충분하다. 글로만 적힌 이 규칙은 같은 설계를 구현한 5개 중 4곳에서 어긋나 있었다. 검사로 강제하자 첫 실행에서 아무도 몰랐던 위반 3건이 더 나왔다.
Scheduler는 왜 enqueue만 해야 하는지, 인스턴스가 여럿일 때 Cron job에서 어떤 버그가 나왔는지 정리했다.
AI 에이전트가 문서로 정해 둔 설계 규칙을 스스로 찾아 따르는지 재는 법을 정리했다. 과제는 성기게 주고, 채점은 직접 다시 돌리고, 난이도는 판단 하나씩 올린다.
같은 Repository/Query 분리를 TypeScript, Go, Python, Java, Kotlin에서 따로 구현해 보고 나란히 비교했다.
셸 명령 출력에 시스템 메시지처럼 꾸민 내용이 섞여 들어와, 방금 한 변경을 숨기라고 지시한 적이 한 번이 아니다. 지킬 규칙은 하나다. 따르지 말고, 그런 게 있었다고 알린다.
송금 기능에 필요한 건 Aggregate 2개를 원자적으로 쓰는 것 하나다. 모든 구현이 이미 된다고 했던 기능이다. 막상 만들어 보니 한 언어는 제대로 동작했고, 한 언어는 뻔한 수정 바로 안쪽에 회귀가 숨어 있었고, 한 언어는 문서가 자기 코드를 틀리게 설명하고 있었다.
쉬운 과제에서 모두가 100점을 받으면, 그 테스트는 누가 어디서 무너질지 알려 주지 않는다. 같은 설계를 구현한 5개 언어에서 AI 코딩 과제의 난이도를 설계 판단 하나씩 올리자 천장이 보였다. 마지막 단에서는 팬아웃 버그가 나왔다. 같은 이벤트를 둘이 구독한 적이 없어서 그동안 안 보이던 버그였다.
감사에서 찾은 것을 자동 검사 규칙으로 바꿔 나가면, 규칙을 몇 개까지 만들어야 하나. 쓰기 쪽 인터페이스에만 들어간 네이밍 수정에서 시작해 규칙을 더할 때마다 버그가 3~4건씩 나오다가 2건, 0건으로 줄었다. 평평해진 수확 곡선이 답이었다.
복잡한 요구사항을 Aggregate와 Bounded Context로 나눠 가며 무엇을 생각했는지 적었다.
메시지 전달이 실패하거나 같은 메시지를 두 번 처리하게 될 때 쓰는 실용적인 패턴을 정리했다.
로컬 개발부터 배포까지, 팀이 언제든 똑같이 재현할 수 있는 환경을 만드는 방법.
Aggregate 두 개를 한꺼번에 읽어야 하는 로직을, 실제로 쓰는 RefundEligibilityService 코드로 풀었다.
파싱도 하지 않고 코드 스니펫이 무슨 일을 하는지도 모른다. 백틱으로 적힌 경로를 실제 파일 트리와 대조할 뿐이다. 탐지 패턴 2개보다 오탐을 막은 예외 규칙이 더 중요했는데, 그런 도구가 첫 실행에서 문서 4곳의 버그를 잡았다.
스캐폴딩 생성기는 자기가 찍어 내는 모든 컨벤션의 두 번째 구현이다. 규칙이 바뀌었는데 손으로 쓴 예시만 고치면 생성기는 옛 패턴을 계속 만든다. 이름 하나로 새 도메인을 만들어 자동 검사를 전부 돌려 봐야 그 어긋남과 생성기 자체의 버그가 드러난다.
검사 규칙이 받아 본 입력에서만 깨끗했다면 범용이라고 말할 수 없다. 아키텍처 규칙 2개가 몇 달째 깨끗했는데, 그동안 받아 본 도메인은 늘 같은 두 개였다. 전혀 상관없는 세 번째 도메인을 만들어 보니 오탐 2건이 드러났다. 진짜 실수를 잡아야 하는 규칙은 여전히 그걸 잡는다는 것도 확인했다.
설계 하나를 여러 언어로 옮기면 처음 구현의 보안 구멍도 같이 옮겨 간다. 보안 감사를 해 보니 /auth/sign-in은 5개 구현 모두에서 userId 하나만 받고 아무것도 검증하지 않았다. 같은 버그가 언어마다 어떤 모양이었고 무엇으로 막았는지, 새로 쓴 401 테스트가 찾아낸 JDK 재시도 버그까지 적었다.
Query Handler가 쓰기까지 되는 Repository를 쓰고 있었다. 여러 언어에서 같은 버그가 나왔는데, 문서마저 괜찮다고 적혀 있었다.
코드가 자기 문서와 맞는지만 보는 감사는 둘이 함께 틀린 경우를 잡지 못한다. 쓰기용 Repository로 읽는 Query, JPA 애노테이션을 단 도메인 클래스, 엉뚱한 레이어에 놓인 notification 모듈이 그 많은 감사를 통과해 있었다. 진짜 버그는 하나뿐이었고, 나머지 둘을 보면 왜 어떤 감사도 이들을 잡을 수 없었는지 알 수 있다.
동기 Adapter와 비동기 Integration Event 중 어느 쪽을 고를지, 실제 보상 트랜잭션(compensating transaction) 예제와 함께 따져 본다.
SIGTERM을 받았을 때 readiness 전환, 처리 중인 요청, 리소스 정리를 어떤 순서로 해야 하는지 정리했다.
에러 메시지 enum의 key와 value를 같게 둬야 하는 이유와, 필드 4개로 된 에러 응답 모양을 정리했다.