Claude Code rule 셋팅 시도

바나나·2026년 7월 23일

기타

목록 보기
2/2

Claude Code Rule 셋팅 시도

룰 설계도, 게이트 로직도, secret-write-gate.sh·harness-stop-gate.sh·doc-size-guard.sh 같은 훅 스크립트도 — 실제로 짠 건 전부 AI였다. 내가 한 건 "레이어 경계를 지켜라", "변경된 메서드만 100%로 좁혀라" 같은 기준을 정하고, 나온 결과를 승인하거나 규칙과 컨벤션을 설명하거나 작업을 되돌린 것뿐이다.

그래서 방향을 틀었다. 이 글은 "무엇을 구현했나"의 기록이 아니라, 내가 AI에게 무엇을, 어떤 기준으로 요청했는지의 기록이다. 아래 나오는 코드·설계·수치는 별다른 표시가 없으면 전부 AI가 만든 것이고, 내가 직접 되돌리거나 고친 부분만 그때그때 밝힌다.

Java 11 / Spring Boot 2.5.6 / Gradle 멀티모듈 7개짜리 레거시 통합 API에 AI 작업 인프라를 넣었다. 대부분 규약·스킬·훅·검증 스크립트·문서였다.

한눈에 보기

내가 요청한 것

  • AI 테스트에 사람 테스트와 구분되는 태그
  • 테스트 실행 시 테스트 컨테이너 사용(멱등성·환경 보호)
  • 커버리지 범위를 "변경된 메서드"로 한정
  • 판정 기준을 git diff로 확인
  • 테스트 인프라를 재사용 가능한 별도 모듈로 분리
  • 컨벤션 수단은 AI가 정하되, 규칙 내용(레이어 경계 등)은 직접 지정
  • 도메인 지식·DB 스키마·작업 절차 문서화
  • 중복 도메인 정보 정리 + 메타/개념 분리 구조 제안
  • 시크릿 유출 여부 확인
  • @AiTest 없이 작업 완료 못 하게 연결
  • 토큰 절약 방법 + 공간복잡도 계산
  • 중복 문서 정리, 거대 문서 분리, AGENTS.md 슬림화
  • 뮤테이션 테스트로 커버리지 실효성 검증

AI가 알아서 한 것 (대부분의 구현은 AI가 했다.)

  • 태그+컨테이너를 메타 어노테이션 하나로 통합 설계
  • Gradle 소스셋 분리, OOM 방지용 힙 조정
  • git diff ∩ JaCoCo 교집합 자동화, 보강 ≤3회 상한
  • 레이어 경계를 구체 규칙 + 매핑 클래스 패턴으로 구체화
  • 시크릿 탐지 패턴 선택, "쓰는 순간" 검사 설계, 값 미노출 처리
  • 완료 검증 훅 설계(트랩 방지, ACK 탈출구, exit 2를 AI에게 전달)
  • 완료 조건 검증 훅 자체 도입(요청 없었음)
  • 문서 재비대 가드(doc-size-guard) 도입
  • 읽기 전용 서브에이전트 2종 도입
  • 세션당 1회 컨텍스트 주입 설계
  • 게이트를 git commit이 아닌 AI 작업 생명주기에 바인딩
  • Docker/openspec 자동 설치를 편의로 추가 → 이후 내가 opt-in으로 되돌림

1차 목표 — 테스트와 컨벤션으로 AI 작업물을 보장한다

AI가 며칠째 코드를 잘 짜고 있었다. 문제는 "잘 짜고 있다"는 걸 내가 무엇으로 확인하고 있었냐는 거였다. 테스트가 통과했다고 보고하면 믿었는데, 그 테스트도 AI가 짠 것이고, 컨벤션도 매 세션 Response DTO에 로직이 섞이고 Entity가 컨트롤러까지 내려오는 식으로 흔들렸다. 처음 목표는 하나였다. AI가 만든 결과물을 테스트와 컨벤션 두 축으로 동시에 보장하는 것. 이게 1차였다.

먼저 테스트 쪽. AI가 짠 코드를, AI가 짠 테스트로 검증하고, 그 결과를 다시 AI가 보고한다 — 이 루프 어디에도 사람이 없었다.

여기서 두 가지를 요청했다. 하나, AI가 작성한 테스트를 사람이 짠 테스트와 구분할 수 있는 태그를 만들어달라. 사람 테스트와 AI 테스트가 섞이면 나중에 뭘 신뢰해야 할지 알 수 없어진다 — 처음 목표한 건 이 구분 자체였다. 둘, AI가 테스트를 실행할 때는 테스트 컨테이너를 써달라. 이유는 두 가지였다. 매번 같은 결과가 나오는 멱등성을 보장하는 것, 그리고 AI가 mock을 임의로 단순화하거나 실제 리소스에 접근해서 다른 환경의 서비스에 피해가 가지 않게 하는 것.

AI는 이 둘을 하나의 메타 어노테이션으로 합쳐서 구현했다.

@Tag("AI")                                     // 구분 — aiTest 태스크로만 실행
@SpringBootTest(webEnvironment = NONE,
        properties = "spring.main.allow-bean-definition-overriding=true")
@ActiveProfiles("local")
@ContextConfiguration(initializers = {
        AiTestPropertyInitializer.class,
        AiTestMainConfigInitializer.class,
        MySQLTestcontainerInitializer.class,   // 격리 — 실제 컨테이너
        RedisTestcontainerInitializer.class,
        RabbitMQTestcontainerInitializer.class
})
@Transactional                                  // 종료 시 롤백
public @interface AiTest {}

클래스에 @AiTest 하나만 달면 실제 MySQL·Redis·RabbitMQ 컨테이너 위에서 돌고, 끝나면 롤백된다. AI가 "이 부분은 mock으로 충분하다"고 판단할 여지를 어노테이션이 없앤다. Gradle 소스셋도 갈라서, 사람 테스트 태스크(test)는 AI 테스트를 아예 안 돌리고 별도 aiTest 태스크로만 실행되게 만들었다.

sourceSets {
    aiTest {
        java {
            srcDirs = ['src/test/java']
            include '**/*AiTest*.java'
        }
        resources.srcDirs = ['src/test/resources']
        compileClasspath += sourceSets.main.output + configurations.runtimeClasspath
        runtimeClasspath += output + compileClasspath
    }
    test {
        java { exclude '**/*AiTest*.java' }   // 일반 빌드는 @AiTest 를 제외한다
    }
}

tasks.named('test', Test).configure {
    useJUnitPlatform { excludeTags 'AI' }   // 사람 테스트 태스크에서는 AI 테스트가 안 돈다
}

tasks.register('aiTest', Test) {
    group = 'verification'
    useJUnitPlatform { includeTags 'AI' }
    systemProperty 'spring.profiles.active', 'local'
    maxParallelForks = 1
    forkEvery = 0
    maxHeapSize = '2g'   // @MockBean 조합마다 컨텍스트가 캐시에 쌓여 기본 힙으로는 OOM이 났다
    jacoco { destinationFile = layout.buildDirectory.file('jacoco/aiTest.exec').get().asFile }
    finalizedBy 'aiTestCoverageReport'
}

maxHeapSize = '2g' 줄은 실제로 겪은 문제의 흔적이다. @MockBean 조합마다 별도 Spring 컨텍스트가 캐시에 쌓이는데, AI가 테스트를 많이 찍어낼수록 조합이 늘어나서 기본 힙으로는 감당이 안 됐다.

여기에 하나 더 요청했다. @AiTest와 컨테이너 초기화 코드를 이 프로젝트에 박아두지 말고 별도 모듈로 빼달라고 했다. 그래서 다른 프로젝트에서도 이 모듈만 의존성으로 추가하면 @AiTest 어노테이션 하나로 동일한 검증 인프라(태그 구분·컨테이너 격리·롤백)를 그대로 쓸 수 있다. 프로젝트 하나에 묶인 설정이 아니라 재사용 가능한 단위로 만든 것이다.

근데 커버리지 100%를 요구할 수가 없었다

컨테이너까지는 됐는데, 그다음이 문제였다. "테스트를 잘 짜라"는 말만으로는 AI가 무엇을 테스트해야 하는지 알 수 없다. 커버리지로 강제하고 싶었는데, 이 코드베이스엔 기존 테스트가 거의 없었다. 클래스 전체 100%를 요구하면 이번 작업과 무관한 레거시 코드까지 AI가 테스트를 채워야 해서 비현실적이었다.

그래서 기준을 좁혔다. AI가 만든 코드는 "이번에 바뀐 라인이 속한 메서드만 100%"로. 판정 기준은 git diff로 확인해달라고만 했다 — JaCoCo와의 교집합 자동화까지 명시적으로 요구한 건 아니었다. 그런데 AI는 여기서 한 걸음 더 나갔다.

① git diff --unified=0  →  변경 라인 번호 집합
② JaCoCo XML            →  실행 가능 라인 집합
③ ① ∩ ②                →  주석·공백 변경은 자동 탈락
④ ③이 속한 메서드만     →  커버리지 분모

이 로직을 jacoco-changed-method-coverage.sh 204줄로 구현해서, "수정된 메서드만 JaCoCo 100%, 클래스 전체 coverage 체크 없음"을 스크립트 수준에서 결정론적으로 계산하게 만들었다. 여기에 실무적으로 중요한 상한도 하나 스스로 붙였다 — 보강 ≤3회. 커버리지를 못 채우는 케이스에서 AI가 무한히 테스트를 덧붙이는 걸 막는 상한인데, 이것도 내가 요청한 게 아니라 AI가 끝없이 테스트를 추가하다 시간을 태우는 실패 모드를 미리 예상하고 넣은 것이다.

이렇게 해서 AI가 만든 코드는 실제 컨테이너 위에서 롤백 가능한 상태로 검증되고, 리뷰 범위는 클래스 전체가 아니라 방금 바뀐 메서드로 자동 좁혀졌다. 이 두 게이트가 이후 모든 이야기의 전제가 된다.

1차의 나머지 한 축 — 컨벤션

테스트 인프라와 나란히 요청한 게 컨벤션이었다. AI에게 어떤 수단(룰·에이전트·스킬)을 쓸지는 알아서 정하라고 했다 — 메커니즘은 위임했다. 대신 규칙의 내용, 그러니까 비즈니스 로직·팀 규칙·DB 규칙·레이어 경계는 내가 직접 구체적으로 설명했다. "Response DTO에 비즈니스 로직을 넣지 말 것", "Entity를 컨트롤러까지 내리지 말 것" 같은 것들이다. 돌이켜보면 이 분담이 이후 전부를 좌우했다.

AI는 이 지시를 실제 룰 파일(coding-patterns.md §17)로 구체화했다.

- 엔티티는 tx·영속 계층에 가둔다. tx 밖(컨트롤러/직렬화)에서 LAZY 접근 금지 →
  tx 안에서 DTO 로 추출해 넘긴다. 엔티티를 응답으로 직렬화 금지.
- DTO/Response 는 순수 데이터 홀더. 엔티티를 import 하지 않고, 생성자/of()/from() 이
  엔티티를 인자로 받지 않는다. 변환·필터·가격계산·판정 같은 비즈/매핑 로직을 DTO 에 두지 않는다.
- 레이어 경계 매핑은 전용 *DtoMapper(static)에. *DtoMapper 엔 구조 매핑만
  (if/반복/.filter/계산 금지) — 로직·필터·계산·조회는 서비스.
- 레이어 방향 Controller → Service → Repository/Mapper. 역방향·건너뛰기 금지
  (컨트롤러가 Repository/Mapper 직접 호출 금지). 컨트롤러엔 비즈 로직 없음.
// 서비스=조회·로직·계산·tx / FooDtoMapper=구조 매핑 / FooResponse=홀더
@Transactional(readOnly = true)
public FooResponse getFoo(Long id) {
    Foo foo = repo.findById(id).orElseThrow(NotFoundEntityException::new);
    FooData data = /* 비즈니스 계산·필터 (서비스, tx 안) */;
    return FooDtoMapper.toResponse(data);      // 구조 매핑은 매핑 클래스에 위임
}
class FooDtoMapper {                            // 엔티티를 아는 유일한 경계
    static FooResponse toResponse(FooData d) { return new FooResponse(d.getName(), d.getPrice()); }
    static FooData toData(Foo entity) { return new FooData(entity.getName(), entity.getPrice()); }
}

내가 준 건 "Response DTO에 비지니스로직을 넣지 말고, 비지니스 로직은 service 에 있어야하고 entity 좀 트랜젝션 밖에서 사용하못하게해줘" 였다.

컨벤션만으로는 부족했다. 세션마다 참고할 것들을 더 요청했다. 코딩 규약뿐 아니라 도메인 지식·DB 스키마 규약·테스트 프로토콜·작업 절차·완료 전 체크리스트까지 문서화해달라고 했는데, 여기서 도메인 지식은 추상적인 설명이 아니라 수정·확인 대상이 되는 현재 코드의 비즈니스 로직 그 자체를 뜻했다. DB 스키마 규약도 현재 스키마를 기준으로 컬럼 대문자 표기·audit 컬럼 같은 실제 컨벤션을 내가 직접 짚어서 요청한 것이다.

문제는 이 도메인 정보가 자꾸 중복됐다는 거다. 정리해달라고 하면서, 구조를 하나 제안했다. 다른 대화에서 RAG 같은 방식도 검토해봤는데 이 규모엔 오버스펙이라 판단했고, 대신 메타 정보(이 도메인이 뭘 다루고 언제 참조해야 하는지)와 도메인 개념(실제 비즈니스 로직 본문)을 분리해서 서로 다른 레이어로 관리하는 Index-first 구조를 제안했다. 이건 클로드 채팅에서 찾아보면서 제시했다.

domain/
  _index.md              ← 메타 레이어: 모든 도메인의 카탈로그
  payment/
    _meta.md              ← 이 도메인의 메타(요약, 관계, 트리거 조건)
    concept.md             ← 실제 도메인 개념
  coupon/
    _meta.md
    concept.md

이 구조를 실제 도메인 문서에 적용한 결과는 뒤에 나온다.

코드에 시크릿(private key·클라우드 액세스 키 등)이 실수로 들어가는지도 확인하고 싶었다. 탐지 패턴 4종(private key 블록, AWS 키 형태, mysql 인라인 비밀번호, password/secret/token 류 리터럴)은 AI가 골랐다. 여기서 AI가 고른 설계 하나가 인상적이었다 — 검사를 git commit이 아니라 "파일을 쓰는 순간"에 걸었다. git commit에 걸면 커밋을 안 하거나 세션 밖에서 커밋하면 우회되기 때문이라고 했다. 매치돼도 시크릿 값 자체는 출력하지 않고 규칙명만 보고한다 — 로그로 2차 유출되는 걸 막기 위해서다.

여기까지 AI가 만든 게 룰·훅·스킬·에이전트·검증 스크립트를 합쳐 3,400줄 정도다. 내가 한 건 위 요청들을 준 것뿐이다.

완료 검증에도 연결했다. @AiTest를 안 고치고 main 코드만 고치면 작업 완료 자체를 막는 Stop 훅을 붙였는데, 차단 사유가 사람이 아니라 AI에게 가서 AI가 스스로 고치고 재완료하는 구조다. 다만 이 상태로 영원히 못 끝나는 걸 막는 안전장치(한 번 강제로 넘긴 뒤엔 통과)와, 정말 예외적인 경우엔 "ACK 파일"에 사유를 적으면 통과되는 탈출구도 같이 들어갔다. 중요한 건 "통과했다"가 아니라 "왜 통과시켰는지가 파일로 남는다"는 점이다.

이렇게 1차 목표(테스트+컨벤션으로 AI 작업물 보장)는 달성됐다. 대신 대가가 있었다. /usage로 보면 세션당 토큰 소모가 체감상 크게 늘었다 — 매 세션 로드되는 규칙·문서·게이트가 그만큼 늘었으니 당연한 결과였다.

2차 — 너무 많이 강제한 값을 치른다

1차에서 테스트와 컨벤션을 이 정도로 강제하려니, 세션마다 로드되는 규칙·문서·체크리스트가 계속 쌓였다. 그러다 보니 토큰을 너무 많이 먹는 것 같았다. 그래서 2차로 이걸 개선하기로 했다. "토큰을 절약할 방법과, 스킬·문서 토큰을 계산 가능하게 만들 방법을 찾아달라"고 요청했다.

AI가 내놓은 답을 간단히 정리하면 이렇다. 매 세션 무조건 읽는 것(상시 비용)은 최대한 가볍게 — 예를 들면 목차 한 줄 정도로만 — 유지하고, 실제로 무거운 내용은 그 작업을 할 때만 불러온다. 그리고 "일단 무거운 파일을 열어서 뭐가 들었는지 확인"하는 건 금지했다 — 가벼운 목차부터 보고, 필요한 것만 골라 열어야 한다.

여기서 생각을 하나 고쳤다. 처음엔 "토큰이 너무 빨리 느는 걸 막자"는 게 문제라고 생각했는데, 실제로는 그게 아니었다. 원래는 작업 하나당 거의 일정해야 할 비용이, 문서 전체 분량에 비례해서 커지고 있었다. 447줄짜리 도메인 문서 하나가 그 증거였다.

같은 내용이 여러 문서에 반복돼 있는 것도 정리를 요청했다 — 한 사실은 한 곳에만 두고 나머지는 포인터로. 그리고 거대 도메인 문서를 줄 수가 아니라 함수 단위처럼 목적·기능에 따라 분리해달라고 했다. 예를 들어 447줄짜리 product.md는 이렇게 쪼개졌다.

product.md 447줄 → entities 208 + services-api 93 + detail-response 149 + _meta 16

분할 기준은 줄 수가 아니라 "언제 보는가"였다. entities.md는 엔티티 관계·필드를 파악할 때, detail-response.md는 응답 필드·직렬화 작업을 할 때 — 두 작업은 서로의 문서가 필요 없다. 파일을 쪼개는 것만으로는 부족해서, 앞서 제안했던 메타/개념 분리 구조를 여기에도 3계층 탐색 경로로 구현했다 — 도메인당 한 줄짜리 인덱스가 파트 목차(_meta.md)로, 거기서 필요한 부분(concept.md)만 열리는 식이다. 읽기 비용이 447줄에서 175줄로 떨어졌고, 더 중요한 건 도메인이 20개로 늘어도 이 계산이 안 바뀐다는 점이다.

같은 방식으로 손본 나머지도 결과만 보면 이렇다.

대상이전이후
retail_item.md (도메인 문서)341줄4개 파트 + _meta 로 분할
AGENTS.md (매 세션 무조건 로드되는 진입 문서)473줄44줄

AGENTS.md가 특히 컸다. 여기엔 RabbitMQ 발행 규약, 외부 API 호출(Feign) 규약, 엑셀 다운로드 규약, DDL 규약까지 섞여 있어서 오타 하나 고치는 세션에도 전부 로드됐다. 나머지는 사라진 게 아니라 각각 필요할 때만 로드되는 문서로 옮겨갔다 — 총량은 크게 안 줄었지만, 줄어든 건 "매번 내는 비용" 쪽이었다.

한 가지는 정정해야 한다. 이 RabbitMQ·Feign·엑셀 규칙 분리는 "토큰 문제를 인지한 뒤 AI가 판단한 것"이 아니다. 첫 커밋부터 그렇게 설계돼 있었다. 로드 시점을 나누는 발상이 처음부터 있었고, 나는 나중에야 그게 왜 중요한지 알았다.

이 단계에서 AI가 제안해 도입한 게 읽기 전용 서브에이전트 2개다. 다중 파일을 정독해 영향 분석만 하는 것, 규약 리뷰만 하는 것. 둘 다 쓰기 권한 없이 조사만 해서 요약만 돌려주는데, 여기서 원칙이 하나 있다 — 커버리지·완료 판정 같은 게이트 기준은 이 조사원들에게 위임하지 않는다. 조사는 맡기되 판정은 위임하지 않는 것.

정리는 한 번 하면 끝이지만 문서는 다시 부푼다. 그래서 도메인 문서 300줄, 룰 파일 250줄을 넘으면 편집 직후 잔소리가 나오는 훅을 하나 더 붙였다 — 차단은 안 하고 리마인더만 남긴다. 비슷한 이유로, 도메인 코드를 처음 건드릴 때 "이 순서대로 진행하라"는 안내도 매번이 아니라 세션당 딱 한 번만 주게 만들었다. 규칙을 읽으라고 매번 시키면 AI가 연쇄적으로 관련 파일을 열어보느라 그때마다 토큰을 태우기 때문이다.

게이트를 만들었는데, 이게 진짜 작동하는지는 어떻게 알지

여기까지 만들고 나니 의문이 하나 남았다. 커버리지 100%가 진짜로 결함을 잡아내는지, 아니면 숫자만 채우는 형식적인 통과인지 확인이 안 됐다. 그래서 뮤테이션 테스트로 확인해달라고 했다. 변경 메서드 3개에 뮤턴트 9개를 만들어 확인했더니 88.9%(8/9) 검출, 1건 보강 후 100%가 나왔다.

이 검증은 세션 내 일회성이고 리포지토리에 산출물을 남기지 않았다. PIT 같은 도구를 붙인 게 아니라 특정 커밋의 변경 메서드 3개에 대해 수동으로 뮤턴트를 만들어 확인한 것이라, 상시 게이트로 오해하면 안 된다.

AI가 스스로 편해지려던 순간

인프라를 다 올리고 나서 하나 짚이는 게 있었다. @AiTest 실행 전 Docker CLI가 없으면 brew로 Docker Desktop을 설치하고, 세션 시작마다 npm -g로 CLI를 설치하도록 돼 있었다. 로컬 머신 하나면 편의지만, CI 러너나 동료 머신에서 같은 스크립트가 돌면 부작용이다. 기본값을 꺼두고 환경변수로 명시 opt-in하게 바꿨다. CLI가 이미 있으면 기동·pull은 그대로 한다 — 비파괴 동작은 유지했다.

AI에게 "알아서 편하게 해줘"라고 맡기면, 결과물의 편의가 아니라 환경을 바꾸는 방향으로 편해진다는 걸 이때 알았다.

요청하지 않았는데 AI가 채택한 것들

정리하면서 보니 내가 명시적으로 시키지 않았는데 AI가 스스로 채워 넣은 게 몇 개 있었다. 완료 조건 검증 훅, 문서 재비대 가드, 트랩 방지·ACK 탈출구, 읽기 전용 서브에이전트, 세션당 1회 주입, 그리고 시크릿 게이트를 git commit이 아니라 작업 생명주기에 건 것. 이유는 각각 앞서 설명한 대로지만, 공통점 하나는 새로 눈에 띄었다 — 전부 "이렇게 하면 언젠가 이런 식으로 새겠다"는 실패 모드를 미리 예상하고 막은 것들이다. 무한 재시도, 문서 재비대, git 우회, 캐스케이드로 늘어나는 토큰 — 요청한 적 없는데 이 정도까지 예상하고 채워 넣는다는 게, 이 작업 전체에서 가장 인상 깊었던 부분이다.

후기

막연히 "잘 해줘"라고 맡긴 요청은 거의 없었다. 대부분 "무엇을 기준으로 정리·판단할지"를 먼저 짚은 다음 요청했다. 컨벤션 문제는 "레이어 경계"라는 구체적 규칙으로, 문서 비대화는 "중복 정리"와 "목적·기능 단위 분리"로, 토큰 문제는 "예측 가능하게 만들기 위한 계산법"으로, AI 코드 신뢰 문제는 "변경 메서드 커버리지 + 실제 결함 검출력"으로 좁혀서 넘겼다.

메커니즘은 AI가 훨씬 잘 안다. 훅을 어느 라이프사이클에 걸지, 스킬을 어떻게 쪼갤지는 내가 정할 필요가 없었다. 하지만 무엇을 기준으로 삼을지는 위임되지 않았다. "레이어 경계"라고 말하기 전까지 AI는 무엇을 지켜야 하는지 몰랐고, "변경된 메서드만 100%"라고 좁혀주기 전까지는 클래스 전체 커버리지를 채우려 했다.

중간중간엔 "관련 논문이나 다른 기업 사례를 찾아서 해달라"고 요청한 적도 많았다. 나는 논문 같은 걸 잘 못 찾아보고 잘 못 읽는다. 대신 AI가 찾아서 설명해주면, 그걸 지금 상황에 적용할지 말지는 내가 판단할 수 있었다.

봤던 문서

https://channel.io/kr/blog/articles/what-is-harness-2611ddf1
https://velog.io/@teo/we-programmer

profile
Java/Kotlin Spring 개발자 황재명입니다.

0개의 댓글