모든 글
약 19분 분량 AI 개발

AI가 짜준 코드를 어디까지 기계한테 검증시킬 수 있을까?

목차

결론부터

  1. “구현했습니다”는 검증의 증거가 못 된다. 무엇을 읽고 무엇을 돌렸는지가 안 남는다.
  2. 그렇다고 모든 줄을 사람이 읽으면 처리량 상한이 읽는 속도에 걸린다.
  3. 처음에는 충분성을 기계가 결정적으로 판정하게 만들려 했다. 그게 잘못이었다. 사람이 하던 그 판단은 원래 확률적이었다.
  4. 가르는 기준은 비용과 일관성이다. 매번 필요하고 답이 하나면 고정하고, 맥락이 필요하면 맡긴다.
  5. 고정할 자리는 둘이다. AI가 실수해도 사고가 안 나는 경계, 그리고 답이 하나인 구조 검사.
  6. 맡길 자리에는 규칙 대신 판단할 재료를 준다. 산출물 기준의 근거와 실패 근거가 그것이다.
  7. 예외가 하나 있다. 자기를 심판하는 자리다. 자기 출력을 스스로 평가하는 편향은 연구로 확인돼 있다.
  8. 그래도 도메인 판단은 남는다. 처음 하는 도메인이면 그 판단조차 어렵다.

발표 셋과 공개된 현업 사례, 그리고 자기 출력 평가에 관한 연구를 읽으면서 정리한 글이다.
아래 구성은 그 정리에서 나온 것이고, 코드는 생각을 보여주는 예시다. 직접 붙여서 재본
결과는 아직 없으므로 수치는 나오지 않는다.

개요

토스 프론트엔드 챕터가 진행한 라이브 세션을 봤다. AI 시대의 어드민, AI 시대에 준비된 모노레포, AI로 만든 결과물의 검증. 세 발표 모두 프론트엔드 플랫폼 팀 이야기였다.

보면서 계속 든 생각은 “이건 프론트만의 문제가 아닌데”였다. 세션 중간에도 백엔드와의 협업 지점을 묻는 질문이 올라왔지만 시간 관계상 답변되지 않았고, 찾아봐도 토스 백엔드 챕터가 AI를 어떻게 쓰는지 정리된 공개 자료는 없었다. 전사 문화 프로그램인 AI Surf Day나 “개발자는 AI에게 대체될 것인가” 같은 글에서 방향성 정도는 읽을 수 있지만, 백엔드용 구현 레벨의 정답지는 공개돼 있지 않다.

그래서 직접 옮겨보기로 했다. 세 발표의 문제 정의와 해법 구조를 Spring Boot 백엔드 맥락으로 다시 설계한 기록이다. 원 발표의 내용은 요약해서 인용하되, 백엔드 이식 부분은 전부 내 해석이다.

옮기고 나서 한 가지가 더 걸렸다. 세 발표가 하나같이 최종 검증은 사람이 한다는 문장으로 끝났는데, 그 문장은 처리량의 상한을 사람이 읽는 속도에 걸어 둔다. 그 지점까지 이어서 팠고, 후반부가 그 기록이다.

원본 세 발표 요약

AI 시대 어드민. 어드민을 만들 때마다 스캐폴딩부터 배포까지의 과정이 반복되고, 마스킹이나 로그 증적 같은 컴플라이언스 규칙을 개별 어드민에 일관되게 적용하기 어렵다는 문제에서 출발한다. 해법은 데이터를 다루는 계층을 플랫폼 정책으로 고정하고, 화면을 만드는 계층만 AI에 맡긴 것. API를 등록하면 서버가 프록시하면서 마스킹과 감사 로그를 자동 적용하고, AI는 스키마를 참고해 화면 코드를 생성한다. 반년 만에 프로젝트 440여 개가 생성됐고 그중 120개가 라이브 중이다.

기술적으로 가장 어려웠던 건 AI가 만든 코드를 브라우저 안에서 안전하고 빠르게 실행해 보여주는 것이었다. 서버에 dev server를 띄우는 방식은 격리에 실패했고, Sandpack은 첫 화면까지 47초가 걸렸다. 결국 브라우저 안에 가상 파일 시스템과 패키지 시스템을 직접 구현해서 1.3초로 줄였다.

AI 시대에 준비된 모노레포. 제품마다 의존성 버전이 파편화되면 설치 시간이 늘고, 메인테이너는 breaking change의 영향 범위를 예측할 수 없어 패키지 개선을 시도하지 못한다. 패키지 매니저의 카탈로그 기능으로 의존성 버전을 한 곳에 정의하고 전 제품이 참조하게 만들어 락파일 크기를 84% 줄였다. 카탈로그는 월별로 버저닝해서 발행한다.

그런데 카탈로그 자체가 다시 파편화된다. 어떤 팀은 5월 카탈로그에 머무르고 어떤 팀은 7월 카탈로그로 올라가 있다. 진짜 문제는 마이그레이션 비용이라고 재정의하고, 이걸 자동화하는 데 두 가지 표준화를 도입했다. 행동의 표준화는 마이그레이션 절차를 플로차트로 정리한 스킬이고, 근거의 표준화는 패키지 tarball을 직접 diff해서 버전 구간별 변경점을 미리 문서화한 증분 문서다. changelog를 믿지 않고 실제 배포 산출물을 본다는 게 핵심이다.

AI 결과물 검증. AI로 결과물은 4배 이상 나오는데, 내가 짜지 않은 코드를 이해하고 검증하는 “믿기까지의 시간”이 늘었다는 문제의식. E2E에 집중했는데 첫 접근인 코드베이스 기반 자동 생성은 실패했다. 코드에 모든 것이 담겨 있는 게 오히려 문제였고, 무엇이 중요한지는 제품을 만든 사람 머릿속에 있지 코드에 드러나지 않았기 때문이다.

그래서 서비스 개발자를 직접 만나러 갔더니, E2E가 없는 건 필요 없어서가 아니라 하고 싶은데 못 한 것이었다. 목을 최대한 제거하고 실제 유저가 경험하는 표면에만 의존하는 E2E를 짜고, 테스트가 깨졌을 때 실패 지점의 영상과 로그를 남기는 리플레이 도구를 만들었다. 원래 사람이 원인을 파악하려고 만든 도구였는데, 그 로그를 에이전트에게 넘겼더니 스스로 고치기 시작했다.

1. 처음에 잘못 잡은 것

토스 발표들이 하나같이 최종 검증은 사람이 한다는 문장으로 끝났다. 그 문장이 걸렸다.
처리량의 상한을 사람이 읽는 속도에 걸어 두기 때문이다. 읽어야 할 양이 읽을 수 있는 양을
넘으면, 사람은 읽는 시간을 늘리다가 어느 지점부터 안 읽고 넘긴다.

그래서 사람이 볼 면적을 줄이자는 데까지는 맞게 갔다. 그런데 그다음에 잘못 잡았다.

“이번 변경이 충분히 검증됐는가”를 기계가 결정적으로 판정하게 만들려 했다. 실행된
테스트가 0개인가, 캐시에서 온 결과인가, 필수 검사가 등록은 됐는가를 규칙으로 고정해
INCOMPLETE 같은 판정을 내리게 하는 구상이었다.

지침은 확률적이고 아키텍처 테스트는 결정적이니 결정적인 쪽으로 가자는 논리를 그대로
받은 결과였다. 그 논리 자체는 맞다. 다만 적용할 자리를 잘못 골랐다.

2. 사람이 하던 판단은 원래 확률적이었다

시니어가 PR을 보고 “이 테스트는 아무것도 검증하지 않는데요”라고 말하는 장면을 생각해
보면 된다. 그건 결정적 검사가 아니다. 코드를 보고 판단한 것이다. 같은 사람이 다른 날
보면 다르게 말할 수도 있다.

즉 이 판단에 결정성을 요구한 적이 원래 없었다. 사람도 안 그랬는데 기계에게만
요구하려 한 셈이다.

그러면 진짜 질문이 바뀐다. 무엇을 결정적으로 고정하고 무엇을 그냥 맡길 것인가다.
그리고 기준은 능력에 있지 않다. 비용과 일관성이다.

고정한다맡긴다
조건매번 필요하고 답이 항상 같아야 함가끔이고 맥락 판단이 필요함
비용0토큰
계층 위반, 순환 의존, 커넥션 이중 획득이 테스트가 요구사항을 표현하는가, 이 변경 범위가 맞는가
근거의 위치코드 안코드 밖 맥락

계층 위반은 답이 하나라 고정하는 게 싸다. 테스트가 충분한지는 맥락이 필요해서 맡기는
게 맞다. 앞 절의 구상은 오른쪽 열에 있는 것을 왼쪽 열로 끌어오려 한 것이었다.

3. 고정할 자리 하나, 경계를 아키텍처로 만든다

첫 번째 발표에서 가장 중요한 건 어드민을 몇 분 만에 만들었다는 결과가 아니라, AI에게 맡길 것과 절대 맡기지 않을 것을 아키텍처 레벨에서 갈라놨다는 점이다.

백엔드로 옮기면 이렇게 된다.

정책 계층, AI가 손댈 수 없는 곳

  • 데이터 접근은 등록된 API나 뷰를 통해서만 가능하게 한다
  • 마스킹, 암호화, 감사 로그는 프록시 계층에서 강제한다
  • 권한 모델, 트랜잭션 격리 수준, 쿼리 타임아웃, 커넥션 풀 상한은 플랫폼이 고정한다

발표에서 API를 서버가 프록시하니 호출 기록이 자동으로 남는다고 한 부분이 있는데, 백엔드에서 그 자리를 차지하는 건 게이트웨이 필터나 DataSource 프록시다.

다만 이 계층이 보장하는 범위를 좁혀 적어야 한다. datasource-proxy의 QueryExecutionListener는 쿼리 실행을 관측하는 인터페이스라, 여기에 리스너를 등록하는 것만으로 응답 마스킹이나 권한 검사가 보장되지는 않는다. 이 자리에서 확실히 얻는 것은 어떤 쿼리가 실제로 나갔는지에 대한 기록이다. 마스킹과 권한은 각각 별도의 설계가 필요하다.

@Bean
public DataSource dataSource(DataSource actual) {
return ProxyDataSourceBuilder.create(actual)
.listener(new AuditLogListener()) // 실행된 쿼리 기록
.listener(new SlowQueryListener(1000)) // 슬로우 쿼리 캡처
.build();
}

생성 계층, AI가 마음껏 하는 곳

  • OpenAPI 스펙이나 DB 스키마를 소스로 삼아 DTO, Controller, Repository 생성
  • 페이징 조회, 필터, 상세, 엑셀 다운로드, 배치 잡 골격 같은 반복 패턴을 프롬프트에 미리 주입

여기서 배울 점은 “AI에게 컨텍스트를 잘 주자”가 아니다. AI가 실수해도 사고가 나지 않는 경계를 먼저 만들자는 쪽이다. 순서가 반대면 AI가 만든 코드가 늘어날수록 통제 불능이 된다.

브라우저 프리뷰 런타임은 백엔드에서 격리된 실행 환경 문제다

발표자가 가장 고생한 부분이 AI가 만든 코드를 안전하고 빠르게 실행해 보여주는 것이었는데, 이건 백엔드에서 그대로 번역된다.

첫 번째 시도의 실패, 그러니까 하나의 dev server에서 한 페이지의 에러가 전체로 전파됐다는 상황은 공용 개발 DB를 여러 명이 같이 쓰는 상황과 같다. A가 스키마를 바꾸거나 락을 잡으면 B의 작업이 죽는다. 격리가 안 된 것이다.

세 번째 시도에서 성공한 방식의 백엔드 대응은 세션별 완전 격리다.

  • Testcontainers로 테스트마다 격리된 DB 인스턴스를 띄우거나
  • 하나의 DB 안에서 세션별 스키마를 발급하거나
  • Flyway baseline까지 마이그레이션이 끝난 골든 이미지를 미리 만들어두고 재사용

특히 패키지 셋 해시 아이디어가 좋다. 패키지를 개별로 번들링했더니 싱글톤이 깨져서, 조합 전체를 하나의 해시로 묶어 미리 S3에 올려뒀다는 이야기다. 백엔드로 옮기면 이렇게 된다.

의존성 조합(gradle.lockfile 해시)과 스키마 버전(Flyway 체크섬)을 합쳐 하나의 키로 만들고, 그 키에 해당하는 마이그레이션 완료 DB 이미지를 캐싱한다.

런타임에 준비하지 말고, 조합을 식별해서 미리 준비해둔다는 발상이다.

이때 주의할 게 하나 있다. 실행 중인 컨테이너를 docker commit으로 굳히는 방식은 안 된다. 마운트된 볼륨의 데이터는 커밋 결과에 포함되지 않아서, DB 데이터 디렉터리가 볼륨이면 마이그레이션 결과가 이미지에 안 남는다. 스키마를 이미지에 넣으려면 이미지 빌드 시점에 초기화 스크립트를 실행해야 한다.

FROM postgres:16
# 이미지 빌드 시점에 실행돼 데이터 디렉터리에 반영된다
COPY schema/V1__baseline.sql /docker-entrypoint-initdb.d/
@Testcontainers
abstract class IntegrationTestBase {
// 스키마가 이미 들어간 이미지를 쓴다
static final PostgreSQLContainer<?> DB =
new PostgreSQLContainer<>("myorg/postgres-migrated:v2026.09");
static { DB.start(); }
}

이 방식이 실제로 얼마나 줄여주는지는 재봐야 안다. 이미지 내려받기, DB 기동, 마이그레이션, 애플리케이션 초기화를 나눠 재고 가장 큰 항목부터 손대는 게 순서다.

4. 고정할 자리 둘, 답이 하나인 검사

계층 규칙을 코드보다 먼저 완성하려고 하면 끝나지 않는다. 어디까지가 도메인이고 어디부터가 인프라인지는 경계 사례에서 늘 흐려진다. 그래서 계층 나누기가 암묵지처럼 느껴진다.

순서를 뒤집으면 된다. 위반이 실제로 나올 때마다 그 한 건을 규칙으로 박는다. 규칙 개수가 겪은 사고 개수만큼만 자라고, 안 겪은 경우는 정의하지 않는다.

Java에는 도구가 이미 있다.

@AnalyzeClasses(packages = "com.example.order")
class ArchitectureTest {
@ArchTest
static final ArchRule 도메인은_인프라에_의존하지_않는다 =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..");
@ArchTest
static final ArchRule 순환_의존이_없다 =
slices().matching("com.example.order.(*)..").should().beFreeOfCycles();
@ArchTest
static final ArchRule 프로덕션은_테스트_픽스처를_참조하지_않는다 =
noClasses().that().resideOutsideOfPackage("..test..")
.should().dependOnClassesThat().haveNameMatching(".*Fixture|.*TestBuilder");
}

막는 주체가 문서에서 빌드로 넘어간다. 위반이 들어오면 CI가 빨개지고 병합이 안 된다.

Spring Modulith를 쓰는 프로젝트라면 모듈 경계는 이미 검증되고 있다. ApplicationModules.of(App.class).verify() 한 줄이 허용되지 않은 모듈 간 참조를 테스트 시점에 잡는다. 이 경우 남는 일은 그 검증을 AI 산출물 쪽으로 겨누는 데까지다.

가드레일이 지침을 이기는 세 가지 이유

같은 고민을 앞서 정리한 곳이 있다. Codesai가 2026년 4월에 쓴 Our Architectural Guardrails for AI-Generated Code다.

출발점이 구체적이다. Cursor의 자동완성이 프로덕션 코드에 테스트 빌더를 넣었는데 아무도 못 잡았다. 이 글은 원인을 리뷰 피로와 인지적 항복이라고 부른다. 대체로 괜찮아 보이는 코드를 오래 평가하다 보면 문제 있는 제안을 그냥 받아들이게 된다는 것이다.

이 팀이 강제하는 규칙은 딱 셋이다.

  1. 도메인은 인프라에 의존하지 않는다
  2. 프로덕션 코드에 순환 의존이 없다
  3. 프로덕션 코드가 테스트에 의존하지 않는다

적다는 게 요점이다. 계층 모델 전체를 정의하지 않고, 어겼을 때 회복 비용이 큰 셋만 고른다. TypeScript에는 ArchUnitTS를, Java에는 ArchUnit을 쓴다.

강제 방식 세 가지를 비교한 대목이 알맹이다.

방식결정성토큰 비용약점
지침 (문서, 스킬)확률적매 호출마다인지 부하에서 드리프트, 컨텍스트 창을 먹으며 context rot
에이전트 리뷰확률적코드를 두 번 처리같은 위반을 어떤 때는 잡고 어떤 때는 놓침
아키텍처 테스트결정적0규칙을 미리 짜야 함

지침을 늘리면 컨텍스트 창을 차지해서 다른 정보를 밀어낸다. 에이전트 리뷰를 붙이면 같은 코드를 두 번 처리하니 토큰이 배로 든다. 아키텍처 테스트는 검사가 탐지할 수 있는 위반에 한해 결정적으로 막는다.

여기서 두 가지를 좁혀 적어야 한다. 첫째, CI가 실패해도 병합이 자동으로 막히지는 않는다. 해당 작업을 필수 상태 검사로 연결해야 한다. 둘째, 막히는 것은 그 규칙이 탐지할 수 있는 위반까지다. 규칙이 못 보는 구조 문제는 그대로 통과한다. 그 못 보는 범위를 재는 것이 다음 절이다.

fitness function이라는 이름으로 오래 있던 개념이다. 달라진 건 필요성이다. 사람이 코드를 쓰던 시절에는 리뷰가 이 자리를 감당할 수 있었다.

5. 맡길 자리, 규칙 대신 산출물 근거를 준다

카탈로그는 Gradle Version Catalog와 사내 BOM으로 이미 존재한다

프론트의 카탈로그 참조는 백엔드에 이미 있는 개념이다.

gradle/libs.versions.toml
[versions]
spring-boot = "3.4.1"
kotlin = "2.1.0"
[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }

여기에 사내 공통 라이브러리 BOM을 얹으면 구조가 완성된다. 인증 스타터, 로깅 스타터, 공통 예외 처리의 버전을 한 곳에서 고정하는 것이다.

발표에서 정의한 문제는 백엔드에 그대로 적용된다. 서비스마다 Spring Boot 2.7, 3.0, 3.4가 섞여 있으면 사내 라이브러리 메인테이너는 breaking change의 영향 범위를 몰라서 개선을 못 하고, 서비스 개발자는 버전을 못 올려 낡은 스택에 고착된다. 그리고 백엔드에는 하나가 더 붙는다. 보안 패치가 필요할 때 전사 대응이 불가능해진다. Log4Shell 같은 상황을 떠올리면 이 파편화의 비용이 프론트보다 훨씬 크다는 게 분명해진다.

월별 버저닝은 백엔드에서 Spring Boot 릴리스 트레인과 JDK LTS 주기를 고려해 분기 단위로 운영하는 게 더 현실적일 수 있다.

codemod의 백엔드 대응은 OpenRewrite다

발표에서 인상적이었던 건 AI가 codemod를 대체하지 않고 실행한다는 구조였다. 결정론적으로 처리 가능한 부분은 끝까지 결정론적 도구에 맡기고, AI는 그 사이의 판단만 담당한다.

Java 진영에서 이 자리는 OpenRewrite가 차지한다. AST를 파싱해 기계적으로 변환하는 도구고, Spring Boot 업그레이드 레시피가 공식 제공된다. 하지만 프론트와 같은 문제가 생긴다. 레시피가 커버하는 부분은 잘 되고, 나머지는 깨진다.

그래서 절차도 같아야 한다.

  1. 현재 버전과 목표 버전 확인
  2. OpenRewrite 레시피 실행
  3. 레시피가 못 잡은 부분을 AI가 추론해서 수정
  4. 테스트로 회귀 확인

증분 문서는 백엔드에서 더 강력해진다

세 발표를 통틀어 가장 잘 이식되는 아이디어다. npm tarball을 받아 diff해서 버전 구간별 변경점을 미리 문서화하고, changelog가 아니라 실제 배포 산출물을 근거로 삼는다는 원칙.

백엔드에서는 JAR 바이트코드 diff를 뜰 수 있다. japicmp나 revapi 같은 도구가 두 JAR 간 API와 바이너리 호환성 변화를 뽑아준다. 메서드 시그니처 변경, 반환 타입 변경, 제거된 public API, 필드 접근자 변경이 산출물 기준으로 나온다.

다만 이 도구가 답하는 범위는 API와 바이너리 호환성까지다. 설정 파일 변경, 기본값 변경, 런타임 동작 변경은 JAR diff에 안 나온다. 그래서 변경 보고서 하나로 결론을 내지 않고 회귀 테스트 결과를 같이 남겨야 한다.

<plugin>
<groupId>com.github.siom79.japicmp</groupId>
<artifactId>japicmp-maven-plugin</artifactId>
<configuration>
<oldVersion><dependency>
<groupId>com.myorg</groupId><artifactId>common-lib</artifactId>
<version>0.0.1</version>
</dependency></oldVersion>
<parameter>
<onlyBinaryIncompatible>true</onlyBinaryIncompatible>
</parameter>
</configuration>
</plugin>

발표에서 든 예시, 그러니까 패치 버전인데 peer dependency 이동 때문에 실제로는 breaking이었다는 케이스는 백엔드에서 훨씬 자주 터진다.

  • 사내 라이브러리가 패치 업데이트인데 내부 Jackson 버전이 올라가면서 직렬화 동작이 바뀐다
  • Spring Boot 마이너 업데이트인데 auto-configuration 조건이 바뀌어 특정 Bean이 등록되지 않는다

둘 다 컴파일을 통과한다. 다만 터지는 모양은 서로 다르다. 앞의 것은 응답 JSON 필드가 조용히 바뀌는 쪽이고, 뒤의 것은 기동 시점의 빈 등록 실패나 널 참조로 나타난다. 링크 단계에서 NoSuchMethodError가 나는 경우는 그중 일부다. 하나의 증상으로 묶어 두면 진단이 늦어진다.

여기에 백엔드만의 축을 하나 더 붙일 수 있다. 스키마 증분 문서다.

Flyway나 Liquibase 마이그레이션 이력을 구간별로 분석해서 “이 구간에서 컬럼 타입이 바뀌었다”, “인덱스가 제거됐다”, “NOT NULL 제약이 추가됐다”를 구조화된 문서로 축적하는 것이다. 여기서 한 발 더 나가면 실행 계획까지 갈 수 있다. SQL Server라면 Query Store를 기준으로 “이 마이그레이션 이후 이 쿼리의 플랜이 바뀌었다”를 근거로 남길 수 있고, 이게 백엔드판 tarball diff에 해당한다.

민감도 원칙도 그대로 가져와야 한다. breaking이 아닌데 breaking이라고 하는 편이, breaking인데 아니라고 하는 것보다 낫다. 백엔드는 잘못된 마이그레이션이 데이터 손상으로 이어지므로 이 비대칭이 더 크다.

플라이휠 구조도 성립한다. 한 팀이 Spring Boot 3.3에서 3.4로 올리면서 겪은 함정이 증분 문서로 쌓이고, 리뷰를 거쳐 저장소에 들어가면 다음 팀은 그걸 근거로 자동 마이그레이션을 받는다.

6. 맡길 자리, 실패 근거를 준다

목 없는 통합 검증

발표의 네 가지 원칙을 백엔드로 옮기면 이렇게 된다.

목을 최대한 제거한다. 백엔드에서 이건 @MockBean으로 리포지토리를 발라버린 테스트가 아니라, Testcontainers로 실제 DB를 띄우고 실제 쿼리를 날리는 테스트를 뜻한다. H2로 대체하는 순간 방언 차이 때문에 프로덕션에서만 터지는 쿼리가 생긴다. 이건 발표자가 경계한 “그냥 성공해버리는 테스트”와 같은 실패다.

외부 시스템은 목이 아니라 블랙박스로 둔다. 발표에서 얼굴 인증이나 정부기관 인증을 다룬 방식이 이거였다. 계약 테스트로 경계면만 검증하고, 그 너머는 별도로 검증한다.

표면에만 의존한다. 내부 구현이 아니라 API 응답과 DB 최종 상태만 검증한다. 리팩터링해도 깨지지 않는 테스트만이 자산이 된다.

언제 돌려도 같은 결과. 백엔드에서 플래키의 주범은 대부분 테스트 간 데이터 오염이다. 발표의 해법이 그대로 정답이다. 테스트 계정을 재사용하지 않고 매번 새로 만들며, 꼭 필요한 시드 데이터가 있으면 그것 자체를 시나리오로 만든다. 계좌가 필요하면 계좌 개설 퍼널을 한 번 돌린다는 이야기다.

백엔드 번역은 이렇다. @Sql로 하드코딩된 시드를 넣지 말고, 회원가입 API와 계좌개설 API를 실제로 호출해서 픽스처를 만든다. 스키마가 바뀌어도 깨지지 않고, 그 자체가 회원가입 API의 회귀 테스트가 된다.

@BeforeEach
void setUp() {
// 하드코딩된 시드 대신 실제 퍼널을 한 번 태운다
var member = api.post("/members", signUpRequest()).as(MemberResponse.class);
this.account = api.post("/accounts", openAccountRequest(member.id()))
.as(AccountResponse.class);
}

실패 근거를 한곳에 모으는 일은 백엔드에서 훨씬 쉽다

발표에서 나온 가장 좋은 도구는 리플레이였다. 테스트가 깨지면 영상은 사람에게, 로그는 에이전트에게 넘긴다. 버튼 텍스트가 바뀌어서 요소를 못 찾은 상황을 에이전트가 스스로 인지하고 고쳤다는 이야기다.

백엔드에서 이걸 만드는 건 더 쉽다. 실패한 통합 테스트에 대해 다음을 덤프해두면 된다.

  • 요청과 응답 전문 (헤더, 바디, 상태 코드)
  • 실행된 SQL 전문과 바인딩 파라미터
  • 실패 시점의 DB 상태 스냅샷
  • 트랜잭션 경계와 롤백 지점
  • 스택 트레이스
public class ReplayExtension implements TestWatcher {
@Override
public void testFailed(ExtensionContext context, Throwable cause) {
var dir = Path.of("build/replay", context.getRequiredTestMethod().getName());
write(dir.resolve("http.jsonl"), HttpCapture.drain());
write(dir.resolve("queries.jsonl"), QueryCapture.drain());
write(dir.resolve("db-snapshot.sql"), DbSnapshot.dump());
write(dir.resolve("stacktrace.txt"), stackTraceOf(cause));
}
}

이 디렉터리를 통째로 에이전트에게 넘기면 응답 필드명이 바뀌었다거나, 쿼리가 N+1로 터졌다거나, 격리 수준 때문에 팬텀 리드가 났다는 걸 스스로 진단한다.

다만 이것을 리플레이라고 부르면 범위를 넘는다. 여기서 하는 일은 실패 근거를 모으는 것이고, 같은 실패를 다시 실행하려면 초기 데이터, 시간, 난수, 외부 응답, 비동기 완료 조건까지 복원해야 한다. 실패 시점의 DB 상태도 그렇다. 롤백이나 정리가 끝난 뒤에는 원래 상태를 볼 수 없고, 다른 커넥션에서는 미커밋 변경이 안 보인다. JUnit의 TestWatcher 역시 일부 자원이 닫힌 뒤에 불릴 수 있다.

그래서 수집하지 못한 항목은 보고서에 빈칸으로 남기지 않고 수집 실패라고 적어 둬야 한다. 빈 결과가 문제 없음으로 읽히면 안 된다.

여기서 가장 중요한 통찰은 이 도구가 원래 AI용이 아니었다는 점이다. 사람이 실패 원인을 편하게 파악하려고 만들었는데, 만들고 보니 AI도 쓸 수 있었다. 발표자는 컨텍스트를 제공하는 것보다 사람이 쓰기 좋은 도구를 만들어 AI에게 넘겼을 때 능력이 극대화된다고 정리했다.

백엔드에서 이 말은 이렇게 읽힌다. 관측 가능성에 투자하면 그게 그대로 AI의 입력이 된다. 구조화된 로그, 분산 트레이싱, 슬로우 쿼리 로그, 실행 계획 캡처. 사람 디버깅용으로 만든 자산이 그대로 에이전트의 도구가 된다.

7. 예외는 하나, 자기를 심판하는 자리

여기까지면 고정할 것은 경계와 구조 검사뿐이고 나머지는 맡기면 된다. 그런데 맡길 수
없는 자리가 하나 있다.

자기가 만든 것을 자기가 평가하는 자리다.

이건 측정된 현상이다. 자기 출력 선호 편향은 여러 논문이 정의하고 재고
있다. 널리 쓰이는 여러 모델과 번역, 수학 추론 같은 여러 과제에서 공통으로 나타난다.1

기제도 설명돼 있다. 모델 자신의 출력은 그 모델에게 perplexity가 낮고, 모델은 perplexity가
낮은 출력에 사람 평가자보다 높은 점수를 준다. 자기가 만들었든 아니든 그렇다. 즉 품질이
품질 대신 익숙함에 반응한다는 뜻이다. 자기 출력을 알아보는 능력이 클수록 편향도 커진다. 보고된
편향 폭은 모델에 따라 마이너스 38%에서 플러스 90%까지 벌어진다.1

그리고 한 논문은 이 편향이 재귀적 자기 개선 상황에서 특히 문제라고 적는다. AI가 짠
코드를 같은 AI가 검증하고 그 결과로 다시 고치는 구조가 정확히 그 상황이다.

이 글이 인용한 조사에서 확인한 것도 같다. 앞서 본 사례들이 전부 같은 자리에 장치를 뒀다.

  • Spotify는 요구 범위를 벗어난 수정과 테스트 비활성화를 잡으려고 원 요청과 diff를
    비교하는 별도의 평가 단계를 붙였다
  • LINE은 제안자와 도전자를 갈라 어느 쪽도 자기 자신을 심판하지 않게 했다
  • 완료 게이트 도구들은 검증 대상이 게이트 정의 자체를 고치는 것을 변조로 탐지한다

그래서 기계가 붙잡아야 하는 것은 판단하는 자리다. 무엇이 충분한지는
AI가 판단해도 된다. 다만 그 판단을 자기가 뒤집지 못하게 하는 것, 검증 기준 자체를
고쳐서 통과하지 못하게 하는 것만 고정한다.

이건 규칙 몇 줄이면 된다. 검증 설정과 검증용 테스트를 변경 대상에서 빼고, 그 파일들이
같은 커밋에서 바뀌면 별도 리뷰로 보낸다.

8. 그 근거의 사각지대를 따로 잰다

통과하는 걸 보고 안심하는 것과 잡을 수 있다는 걸 확인하는 것은 다르다.

Codesai가 두 달 뒤 여기에 답했다. Testing Our Architecture Tests with LLM-Generated Counterexamples다.

방법은 단순하다.

  1. LLM에게 특정 규칙을 어기는 코드를 만들라고 시킨다
  2. 그 규칙 테스트가 실제로 실패하는지 확인한다
  3. 실패하지 않으면 규칙에 구멍이 있는 것이다
  4. 찾아낸 위반 코드는 파일로 저장해 archmutant 스크립트로 반복 실행한다

4번이 실용적이다. 매번 다시 생성하면 비결정적이고 토큰이 드니, 한 번 찾은 것을 고정해 둔다.

실제로 구멍을 찾았다. 도메인이 인프라에 의존하지 않는다는 규칙이, 인프라 쪽 static final 상수를 참조하는 경우를 못 잡았다.

원인이 인상적이다. Java 컴파일러가 컴파일 타임 상수를 호출부에 인라이닝하기 때문에, 컴파일된 .class 파일에 참조가 아예 남지 않는다. ArchUnit이 보는 대상은 바이트코드라서 볼 것이 없다. 소스에는 위반이 뚜렷하게 적혀 있는데 바이트코드에는 흔적이 없다.

앞 절에서 changelog를 믿지 않고 바이트코드를 근거로 삼자고 썼다. 그 바이트코드가 무엇을 못 보는지가 여기서 드러난다. 근거를 하나 정했으면 그 근거의 사각지대를 따로 재야 한다.

이걸 내 프로젝트에서 하려면 판정을 갈라 둬야 한다. 컴파일이 깨졌다는 이유로 검사가 위반을 잡았다고 세면 숫자가 부풀기 때문이다.

샘플의 상태검사가 해야 할 일지표 반영
규칙을 지킨 정상 코드통과정상 통과
컴파일되지만 규칙을 어긴 코드해당 규칙으로 실패탐지 성공
컴파일 자체가 안 되는 코드샘플 자체가 무효탐지율에서 제외, 건수만 공개
규칙과 무관한 환경 오류가 난 코드실행 오류로 분류성공으로 세지 않고 별도 공개

LLM은 위반 후보를 찾는 데 쓰되, 그 후보가 실제 위반인지는 규칙의 의도에 비춰 사람이 판정한다. 모델이 위반이라고 말했다는 사실을 정답 근거로 쓰지 않는다.

9. 그래도 남는 것

말이 안 되는 코드에는 두 종류가 있고, 눈에 덜 띄는 쪽이 더 위험하다.

티 나게 이상한 코드는 컴파일이 깨지거나 테스트가 빨개지거나 리뷰에서 바로 걸린다. 발견 비용이 싸다.

그럴듯하게 틀린 코드는 동작하고, 테스트도 통과하고, 리뷰어도 넘긴다. 집계 키를 하루 어긋나게 잡거나, 수수료율의 기준 금액을 총액으로 두거나, 부분 취소를 두 번 반영하는 종류다. 전부 예외 하나 안 던지고 돌아간다.

이 묶음은 더 열심히 읽어서 해결되지 않는다. 사람이 읽어도 그럴듯하게 틀린 것은 잘 안 걸린다. 여기에 필요한 건 결과를 검증하는 장치다. 원장 합계가 맞는지, 정산 금액이 승인 금액과 대사되는지 같은 불변식을 걸어 두면, 코드를 안 읽어도 틀렸다는 사실이 드러난다.

커버리지 숫자는 주관적이고 기분만 좌우한다는 지적이 발표에서 나왔다. 대신 한 번의 실수가 뼈아픈 영역과 니즈가 분명한 팀에 먼저 들어갔다고 한다. 백엔드 번역은 자명하다. 커버리지 80% 같은 목표 대신 송금, 정산, 한도 계산, 이중 결제 방지부터 시작한다.

정리하면 층이 넷이다.

무엇을 잡나누가
아키텍처 테스트구조 위반CI, 결정적
실패 근거 수집실패 진단에 필요한 자료에이전트가 로그를 읽고 고침
불변식과 대사결과의 정합성배치와 알림
남는 것도메인 판단사람

위 세 층이 받아 내는 만큼 마지막 줄이 짧아진다.

다만 마지막 줄에도 조건이 붙는다. 익숙한 도메인이면 AI가 이상한 설계를 내놔도 사람이 알아채지만, 처음 하는 도메인이면 무엇이 빠졌는지조차 알기 어렵다. 그러면 사람에게 남겨 둔 마지막 줄까지 비게 되고, 지금으로서는 레퍼런스를 직접 읽어 배경을 쌓는 것 말고 다른 길을 찾지 못했다.

정리하며

처음에는 검증 충분성을 기계가 판정하게 만들려 했다. 만들다 보니 그 판단은 원래 사람도
확률적으로 하던 것이었고, 결정성을 요구한 적이 없었다.

그래서 남은 설계는 단순해진다.

자리무엇을 두나
고정AI가 실수해도 사고가 안 나는 경계, 답이 하나인 구조 검사, 자기를 심판하지 못하게 하는 규칙
맡김산출물 기준의 근거를 주고 판단하게 함, 실패 근거를 주고 고치게 함
사람도메인 판단, 그리고 그 도메인을 처음 할 때의 배경 학습

Anthropic의 에이전트 설계 문서가 같은 방향을 적는다. 도구는 에이전트가 환경과 상호작용해
새 맥락을 들여오는 통로이고, 실행 중에 각 단계마다 환경에서 ground truth를 얻는 것
진행 상황을 판단하는 데 결정적이라고 한다.2 판단을 대신할 규칙을 넣는 자리에
판단할 재료를 놓는다는 말과 같다.

알아보면서 바뀐 생각

정리하다 두 번 방향이 바뀌었다. 남겨 둔다.

하나. 처음에는 검증이 충분한지를 기계가 판정하게 만들 생각이었다. 그런데 사람이
하던 그 판단은 원래 확률적이었다. 결정성을 요구한 적이 없는 자리에 결정성을 넣으려
했던 것이다.

둘. 그 판정기를 직접 만들 생각이었는데, 찾아보니 조각들이 이미 공개돼 있었다.
구조 검사 엔진, 기존 위반 기준선 관리, 선언형 규칙 표현, 완료 게이트, 린터 집계까지
대부분 있었다. 없다고 판단했다가 두 번 틀렸다.

그래서 이 글의 결론은 무엇을 만들자가 아니다. 고정할 자리와 맡길 자리를 가르고,
자기를 심판하는 자리 하나만 붙잡는다
는 정리다.

직접 확인하고 싶은 것은 남아 있다. 여섯 상황에서 기존 도구들이 각각 무엇을 잡고 무엇을
놓치는지다. 정상 통과, 테스트 실패, 아키텍처 위반, 검사 누락, 캐시 결과, 환경 오류.
재보게 되면 그때 수치와 함께 정리한다.


원 발표는 토스 프론트엔드 챕터의 라이브 세션이다. 발표 내용은 요약해서 인용했고,
백엔드 이식과 코드 예시는 전부 필자의 해석이다. 토스 백엔드 챕터의 실제 방식과는 무관하다.

이 글의 코드는 생각을 보여주는 예시이고 그대로 실행해 검증하지는 않았다.
공개된 사례와 연구를 읽고 정리한 것이라 필자가 직접 잰 수치는 없다.

Footnotes

  1. Self-Preference Bias in LLM-as-a-Judge 및 후속 연구들. 논문 본문 전체를 읽지 않고 초록과 공개 요약을 근거로 적었다. 2

  2. Anthropic, Building Effective AI Agents, Effective context engineering for AI agents.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.