AI

프롬프트가 아니라 프로세스 (Ⅱ): 컨텍스트 드리프트를 막는 다섯 가지 벽

류큐큐 2025. 10. 5. 14:37

지난 글에서 노션→지라→LLM→PR의 파이프라인을 만들었다.
태스크를 잘게 쪼개고, 한 세션에 하나씩만 던지는 구조.
그런데 여전히 구멍이 있었다.

클로드는 똑똑하지만 금붕어 급 기억력을 가졌다.
세션이 길어지면 컨텍스트가 흐려지고, 어느 순간 "일단 주석 처리하고 넘어가겠습니다" 같은 변명과 함께 룰을 이탈한다.

더 열받는 건, 프롬프트에 아무리 "절대 이렇게 하지 마"라고 박아놔도

 

그럴듯한 이유를 덧붙이며 우회한다는 점이다.

 

"현재 컨텍스트에서는 임시 구현이 더 효율적입니다."
"리팩토링은 다음 태스크에서 처리하는 것이 좋겠습니다."

 


프롬프트는 약속이고, Hooks는 계약서다.

AI에게 "제발 이렇게 해줘~"가 아니라, "안 되면 실행이 안 돼"를 시스템 레벨에서 강제해야 한다.

이 글에서는 내가 만든 다섯 가지 벽을 공개한다.

 

 

다섯 가지 벽의 구조

1️⃣ Git Pre-commit Hook     → 커밋 단계에서 차단
2️⃣ Claude Code Hooks       → AI 실행 중 차단  
3️⃣ ArchUnit 테스트         → 빌드 단계에서 차단
4️⃣ Gradle 품질 게이트      → 배포 전 차단
5️⃣ 문서 기반 자동 주입      → 컨텍스트 드리프트 예방

 

각 벽은 독립적으로 작동하지만, 함께 사용하면 중첩 방어선이 된다.
하나를 뚫어도 다음 벽이 막는다.

 

1. Git Pre-commit Hook: 커밋 전 마지막 방어선

문제 상황

  • AI가 @Transactional을 Persistence 레이어에 붙임 (Application 레이어만 허용)
  • lombok.* import가 Domain에 들어감 (전체 금지)
  • //TODO 주석이 PR에 포함됨 (임시 구현 금지)

해결 방법

모듈별 검증기로 레이어마다 다른 규칙 적용:

# hooks/pre-commit (마스터 훅)
#!/bin/bash

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "\.java$")

for FILE in $STAGED_FILES; do
  if [[ "$FILE" =~ domain/ ]]; then
    ./hooks/validators/domain-validator.sh "$FILE" || exit 1
  elif [[ "$FILE" =~ application/ ]]; then
    ./hooks/validators/application-validator.sh "$FILE" || exit 1
  elif [[ "$FILE" =~ adapter/ ]]; then
    ./hooks/validators/adapter-in-validator.sh "$FILE" || exit 1
  fi
done

# 공통 검증
./hooks/validators/common-validator.sh "$STAGED_FILES" || exit 1

# 데드코드 감지
./hooks/validators/dead-code-detector.sh || exit 1

 

Domain Validator 예시:

#!/bin/bash
FILE=$1

# 금지 import 체크
FORBIDDEN_IMPORTS=(
  "org.springframework"
  "jakarta.persistence"
  "lombok"
)

for IMPORT in "${FORBIDDEN_IMPORTS[@]}"; do
  if grep -q "import $IMPORT" "$FILE"; then
    echo "❌ DOMAIN VIOLATION: $IMPORT 사용 금지"
    echo "   파일: $FILE"
    exit 1
  fi
done

# Lombok 어노테이션 체크
if grep -qE "@(Data|Builder|Getter|Setter|AllArgsConstructor)" "$FILE"; then
  echo "❌ LOMBOK VIOLATION: Lombok 사용 금지 (Plain Java만 허용)"
  exit 1
fi

 

Application Validator 예시:

#!/bin/bash
FILE=$1

# Adapter 직접 참조 금지
if grep -q "import.*adapter\." "$FILE"; then
  echo "❌ ARCHITECTURE VIOLATION: Adapter 직접 참조 금지"
  echo "   Application은 Port만 의존해야 합니다"
  exit 1
fi

# @Transactional 체크 (Application만 허용)
# ... (정상 케이스이므로 통과)

 

실행 결과:

$ git commit -m "feat: Upload 도메인 추가"

🔍 Validating domain/src/main/java/.../Upload.java
❌ DOMAIN VIOLATION: org.springframework 사용 금지
   파일: domain/src/main/java/com/company/domain/model/Upload.java

커밋이 차단되었습니다. 다음 중 하나를 선택하세요:
1. 규칙을 준수하도록 코드 수정
2. git commit --no-verify (리뷰에서 논의 필요)
3. 규칙이 잘못되었다면 팀과 논의 후 validator 수정

 

핵심 포인트

  • 모듈별 검증기로 레이어 규칙 강제
  • AI가 우회할 수 없음 (커밋 자체가 안 됨)
  • --no-verify는 마지막 수단이며, PR에서 명시적 논의 필요

 

2. Claude Code Hooks: AI 실행 중 차단

Git Hook은 커밋 시점에만 작동한다.
하지만 AI가 실행 중에 룰을 어기는 걸 미리 막으면 더 효율적이다.

Claude Code Hooks 시스템

Claude Code는 9가지 이벤트 훅을 제공:

 

 

이벤트 실행 시점 용도
SessionStart 세션 시작 시 프로젝트 규칙 주입
UserPromptSubmit 프롬프트 제출 시 금지어 차단
PreToolUse 도구 실행 전 아키텍처 규칙 검증
PostToolUse 도구 실행 후 로깅 및 감사
PreCompact 컨텍스트 압축 전 핵심 규칙 보존

 

 

 

Hooks reference - Claude Docs

This page provides reference documentation for implementing hooks in Claude Code.

docs.claude.com

 

설정 위치

# 프로젝트 루트
.claude/
├── settings.json          # 프로젝트 공유 설정
├── settings.local.json    # 개인 설정 (.gitignore 추가)
└── scripts/
    ├── init-session.sh
    ├── validate-architecture.sh
    └── inject-rules.sh

설정 위치

 

실전 Hook 구성

1) SessionStart: 규칙 자동 주입

{
  "hooks": {
    "SessionStart": {
      "command": "./.claude/scripts/init-session.sh",
      "timeout": 3000
    }
  }
}
#!/bin/bash
# .claude/scripts/init-session.sh

BRANCH=$(git branch --show-current)

# 지라 태스크 파싱
JIRA_TASK=$(echo "$BRANCH" | grep -oP 'FF-\d+')

if [ -z "$JIRA_TASK" ]; then
  echo "⚠️ 경고: 브랜치명에 지라 태스크가 없습니다 (예: feature/FF-123-xxx)"
fi

# 아키텍처 규칙 요약 생성
cat > /tmp/claude-session-context.md <<EOF
# 현재 작업 정보
- 브랜치: $BRANCH
- 지라 태스크: $JIRA_TASK

# 핵심 규칙
1. Domain: Spring/JPA/Lombok 사용 금지
2. Application: @Transactional만 허용, Adapter 직접 참조 금지
3. Adapter: 각 계층별 분리 (In/Out)
4. 금지: 임시 주석, TODO, "나중에 처리"

# 품질 게이트
- 커버리지: Domain 90%, Application 80%, Adapter 70%
- ArchUnit 테스트 필수 통과
- Javadoc + @author 태그 필수
EOF

echo "✅ 세션 초기화 완료"
exit 0

 

2) UserPromptSubmit: 금지어 차단

{
  "hooks": {
    "UserPromptSubmit": {
      "command": "./.claude/scripts/validate-prompt.sh",
      "timeout": 1000
    }
  }
}
#!/bin/bash
# .claude/scripts/validate-prompt.sh

INPUT=$(cat)
USER_PROMPT=$(echo "$INPUT" | jq -r '.text')

# 금지어 목록
FORBIDDEN_PHRASES=(
  "일단 주석"
  "나중에 정리"
  "임시로"
  "TODO로"
  "skip.*test"
)

for PHRASE in "${FORBIDDEN_PHRASES[@]}"; do
  if echo "$USER_PROMPT" | grep -qiE "$PHRASE"; then
    echo '{"decision": "blocked", "message": "🚫 금지어 감지: '"$PHRASE"'. AC 변경이 필요하면 지라 태스크부터 수정하세요."}'
    exit 0
  fi
done

echo '{"decision": "allowed"}'
exit 0

 

3) PreToolUse: 코드 작성 전 아키텍처 검증

 
{
  "hooks": {
    "PreToolUse": {
      "command": "./.claude/scripts/validate-architecture.sh",
      "matchers": {
        "tool": "Write|Edit"
      },
      "timeout": 2000
    }
  }
}
#!/bin/bash
# .claude/scripts/validate-architecture.sh

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.input.file_path // .input.path')
CONTENT=$(echo "$INPUT" | jq -r '.input.content // .input.new_str')

# Domain 레이어 체크
if [[ "$FILE_PATH" =~ domain/ ]]; then
  if echo "$CONTENT" | grep -qE "(import.*springframework|import.*jakarta|@Data|@Builder)"; then
    echo '{"decision": "blocked", "message": "⛔ DOMAIN VIOLATION: Spring/JPA/Lombok 사용 금지"}'
    exit 0
  fi
fi

# Application 레이어 체크
if [[ "$FILE_PATH" =~ application/ ]]; then
  if echo "$CONTENT" | grep -qE "import.*adapter\."; then
    echo '{"decision": "blocked", "message": "⛔ ARCHITECTURE VIOLATION: Adapter 직접 참조 금지. Port를 사용하세요."}'
    exit 0
  fi
fi

# Persistence Adapter에서 @Transactional 체크
if [[ "$FILE_PATH" =~ adapter.*persistence ]]; then
  if echo "$CONTENT" | grep -q "@Transactional"; then
    echo '{"decision": "blocked", "message": "⛔ PERSISTENCE VIOLATION: 트랜잭션은 Application 레이어에서만 관리합니다."}'
    exit 0
  fi
fi

echo '{"decision": "allowed"}'
exit 0

 

4) PreCompact: 컨텍스트 압축 전 규칙 보존

{
  "hooks": {
    "PreCompact": {
      "command": "./.claude/scripts/preserve-rules.sh"
    }
  }
}
#!/bin/bash
# .claude/scripts/preserve-rules.sh

CRITICAL_RULES=$(cat <<'EOF'
🔒 CRITICAL RULES (절대 잊지 말 것)
1. Domain: 순수 Java만, 프레임워크 의존 금지
2. Application: @Transactional만 허용
3. Adapter: Port 통해서만 Application과 통신
4. Lombok 전체 금지
5. 임시 구현/주석 금지

❌ 금지 문구: "일단", "나중에", "TODO", "임시로"
EOF
)

echo "$CRITICAL_RULES"
exit 0

 

 

실행 흐름 예시

[사용자] "Upload 엔티티를 만들어줘"
    ↓
SessionStart Hook 실행
    ↓ (프로젝트 규칙 주입)
    ↓
UserPromptSubmit Hook 체크
    ↓ (금지어 없음 → 통과)
    ↓
PreToolUse Hook 실행
    ↓ (Domain 레이어 → Spring import 체크)
    ↓
[AI가 @Entity 사용 시도]
    ↓
❌ BLOCKED: "Domain에서 jakarta.persistence 사용 금지"
    ↓
[AI가 Plain Java로 재작성]
    ↓
✅ ALLOWED

 

핵심 포인트

  • AI가 실행하기 전에 차단 (시간 절약)
  • 프롬프트 엔지니어링 불필요 (시스템이 강제)
  • 컨텍스트 드리프트 예방 (PreCompact로 핵심 규칙 반복 주입)

 

3. ArchUnit: 컴파일 후에도 아키텍처 강제

Git Hook과 Claude Hook을 뚫었다 해도, 빌드 단계에서 다시 한번 검증한다.

ArchUnit이란?

Java 아키텍처를 테스트 코드로 검증하는 라이브러리.
"Domain은 Application을 참조하면 안 돼"를 실패하는 테스트로 만든다.

실전 규칙 예시

// bootstrap/src/test/java/.../HexagonalArchitectureTest.java

@AnalyzeClasses(packages = "com.company.template")
class HexagonalArchitectureTest {

    @ArchTest
    static final ArchRule domain_레이어는_독립적이어야_함 =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage(
                "..application..",
                "..adapter..",
                "org.springframework..",
                "jakarta.persistence.."
            )
            .because("Domain은 순수 비즈니스 로직만 포함해야 합니다");

    @ArchTest
    static final ArchRule application_레이어는_adapter를_직접_참조하면_안됨 =
        noClasses()
            .that().resideInAPackage("..application..")
            .should().dependOnClassesThat()
            .resideInAPackage("..adapter..")
            .because("Application은 Port를 통해서만 Adapter와 통신해야 합니다");

    @ArchTest
    static final ArchRule transactional은_application에서만_사용 =
        classes()
            .that().resideInAPackage("..adapter..")
            .should().notBeAnnotatedWith(Transactional.class)
            .because("트랜잭션은 Application 레이어에서만 관리합니다");

    @ArchTest
    static final ArchRule lombok_사용_금지 =
        noClasses()
            .should().dependOnClassesThat()
            .resideInAPackage("lombok..")
            .because("Lombok 대신 Plain Java를 사용합니다");

    @ArchTest
    static final ArchRule 순환_참조_금지 =
        slices()
            .matching("com.company.template.(*)..")
            .should().beFreeOfCycles();
}

 

 

실행 결과

$ ./gradlew :bootstrap:test

> Task :bootstrap:test FAILED

HexagonalArchitectureTest > domain_레이어는_독립적이어야_함 FAILED
    Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..' 
    should depend on classes that reside in any package ['..application..', '..adapter..', 
    'org.springframework..', 'jakarta.persistence..']' was violated:
    
    Class <com.company.template.domain.model.Upload> depends on 
    <org.springframework.stereotype.Component> 
    in (Upload.java:5)
    
    because Domain은 순수 비즈니스 로직만 포함해야 합니다

BUILD FAILED

 

핵심 포인트

  • Git Hook을 우회(--no-verify)해도 빌드에서 걸림
  • CI/CD에서 자동 검증 (배포 전 마지막 게이트)
  • 규칙 위반 = 테스트 실패 (명확한 피드백)

 

5. Gradle 품질 게이트: 코드 품질 + 커버리지 강제

구성 요소

// build.gradle.kts

plugins {
    id("checkstyle")
    id("com.github.spotbugs") version "6.0.0"
    id("jacoco")
}

// 1. Checkstyle: 코드 스타일 강제
checkstyle {
    toolVersion = "10.12.5"
    configFile = file("${rootProject.projectDir}/config/checkstyle/checkstyle.xml")
}

// 2. SpotBugs: 정적 분석
spotbugs {
    effort = com.github.spotbugs.snom.Effort.MAX
    reportLevel = com.github.spotbugs.snom.Confidence.LOW
}

// 3. JaCoCo: 커버리지 검증
jacoco {
    toolVersion = "0.8.11"
}

tasks.jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                counter = "LINE"
                value = "COVEREDRATIO"
                minimum = when {
                    project.name.contains("domain") -> 0.90.toBigDecimal()
                    project.name.contains("application") -> 0.80.toBigDecimal()
                    project.name.contains("adapter") -> 0.70.toBigDecimal()
                    else -> 0.60.toBigDecimal()
                }
            }
        }
    }
}

tasks.check {
    dependsOn(tasks.jacocoTestCoverageVerification)
}

 

 

실행 결과

$ ./gradlew build

> Task :domain:checkstyleMain FAILED
[ERROR] Upload.java:15:5: Missing Javadoc comment. [JavadocMethod]

> Task :domain:jacocoTestCoverageVerification FAILED
Rule violated for bundle domain: 
  lines covered ratio is 0.85, but expected minimum is 0.90

> Task :application:spotbugsMain
[WARN] UploadService.java:23: Possible null pointer dereference

BUILD FAILED

 

핵심 포인트

  • Checkstyle: 코딩 스타일 통일 (Javadoc, @author 강제)
  • SpotBugs: 잠재적 버그 탐지 (NPE, 리소스 누수)
  • JaCoCo: 레이어별 차등 커버리지 (Domain 90%, Application 80%, Adapter 70%)
  • 빌드 실패 = 배포 불가

 

5. 문서 기반 자동 주입: 컨텍스트 드리프트의 근본 해결

지금까지의 벽들은 "잘못된 것을 막는" 방어적 접근이었다.
하지만 가장 중요한 건 **"올바른 컨텍스트를 계속 주입"**하는 것이다.

문제: 긴 세션에서의 컨텍스트 드리프트

[초반 10턴] 
  - 규칙 준수 ✅
  - 아키텍처 정확 ✅

[20턴 이후]
  - "이 부분은 임시로..." ❌
  - Port 없이 직접 참조 ❌
  
[컨텍스트 컴팩팅 후]
  - 핵심 규칙 소실 💀
  - 완전히 다른 코드 생성 💀

 

해결: SessionStart + UserPromptSubmit으로 자동 주입

전략:

  1. 프로젝트 규칙을 문서화 (docs/ENTERPRISE_SPRING_STANDARDS_PROMPT.md)
  2. SessionStart Hook에서 요약본 생성
  3. UserPromptSubmit Hook에서 매 프롬프트마다 재주입
#!/bin/bash
# .claude/scripts/inject-rules.sh

INPUT=$(cat)
USER_PROMPT=$(echo "$INPUT" | jq -r '.text')

# 프로젝트 표준 문서 읽기
STANDARDS=$(cat docs/ENTERPRISE_SPRING_STANDARDS_PROMPT.md)

# 현재 작업 중인 모듈 파악
CURRENT_MODULE=$(pwd | grep -oP '(domain|application|adapter-[^/]+)')

# 해당 모듈의 규칙만 필터링
MODULE_RULES=$(echo "$STANDARDS" | sed -n "/## $CURRENT_MODULE/,/## /p")

# 프롬프트에 규칙 추가
ENHANCED_PROMPT=$(cat <<EOF
$USER_PROMPT

---
📋 현재 모듈 규칙 ($CURRENT_MODULE):
$MODULE_RULES
EOF
)

echo "$ENHANCED_PROMPT"
exit 0

 

결과

[사용자 입력]
"Upload 엔티티의 validate 메서드를 추가해줘"

        ↓ (Hook 자동 주입)

[AI가 받는 실제 입력]
"Upload 엔티티의 validate 메서드를 추가해줘

---
📋 현재 모듈 규칙 (domain):

## Domain Layer Rules
1. 순수 Java만 사용 (Spring/JPA 금지)
2. 모든 필드는 final (불변성)
3. Lombok 사용 금지
4. Public 메서드는 Javadoc 필수
5. 예외는 domain.exception 패키지의 커스텀 예외만

❌ 금지 import:
- org.springframework.*
- jakarta.persistence.*
- lombok.*

✅ 허용 import:
- java.util.*
- java.time.*
- org.apache.commons.lang3.*
"

 

핵심 포인트

  • 세션이 길어져도 규칙 유지 (매 프롬프트마다 재주입)
  • 모듈별 맞춤 규칙 (Domain/Application/Adapter 구분)
  • 문서 단일 출처 (변경 시 한 곳만 수정)

 

🎯 다섯 가지 벽의 시너지

사용자 입력
    ↓
5️⃣ 문서 기반 자동 주입 (규칙 재주입)
    ↓
2️⃣ Claude Code Hooks (실행 전 검증)
    ↓
[AI 코드 생성]
    ↓
1️⃣ Git Pre-commit Hook (커밋 전 검증)
    ↓
3️⃣ ArchUnit 테스트 (빌드 검증)
    ↓
4️⃣ Gradle 품질 게이트 (배포 전 검증)
    ↓
✅ Production

 

 

실제 적용 사례: "@Transactional 사건"

시나리오: AI가 UploadPersistenceAdapter에 @Transactional을 붙이려고 시도

 

2️⃣ Claude Code Hook (PreToolUse)
  → ❌ BLOCKED: "Persistence 레이어에서 @Transactional 금지"
  → AI가 포기하고 다른 방법 시도
  
(만약 Hook이 없었다면?)
    ↓
1️⃣ Git Pre-commit Hook
  → ❌ BLOCKED: "PERSISTENCE VIOLATION 감지"
  → 커밋 차단
  
(만약 --no-verify로 우회했다면?)
    ↓
3️⃣ ArchUnit 테스트
  → ❌ FAILED: "transactional은_application에서만_사용"
  → 빌드 실패

 

어느 단계에서든 반드시 걸린다.

 

⚠️ 주의사항: 과도한 제약의 부작용

1. 합리적인 예외는 허용하라

# 예외 케이스를 .claude/exceptions.json에 명시
{
  "allowed_violations": [
    {
      "rule": "lombok_금지",
      "path": "adapter-out-persistence-jpa/src/main/java/.../entity/*",
      "reason": "JPA Entity는 @Entity/@Id 필요"
    }
  ]
}

 

2. 팀과 합의하라

  • Hook은 팀 전체의 합의가 필요
  • .claude/settings.local.json으로 개인 실험 후 공유
Week 1: Git Hook만 (가장 기본)
Week 2: ArchUnit 추가
Week 3: Claude Hooks 실험
Week 4: 전체 통합

 

📦 템플릿 공개: claude-spring-standards

이 글의 모든 설정이 포함된 템플릿을 공개한다:
👉 github.com/ryu-qqq/claude-spring-standards

포함 내용

  • ✅ Git Pre-commit Hooks (모듈별 검증기)
  • ✅ Claude Code Hooks 샘플 (.claude/settings.json)
  • ✅ ArchUnit 테스트 (87개 규칙)
  • ✅ Gradle 품질 게이트 (Checkstyle, SpotBugs, JaCoCo)
  • ✅ 엔터프라이즈 표준 프롬프트 (docs/ENTERPRISE_SPRING_STANDARDS_PROMPT.md)
  • ✅ 헥사고날 아키텍처 템플릿 (Domain/Application/Adapter)

시작 방법

# 1. 템플릿 클론
git clone https://github.com/ryu-qqq/claude-spring-standards.git my-project
cd my-project

# 2. Git Hook 활성화
ln -s ../../hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

# 3. Claude Hooks 복사 (선택)
cp .claude/settings.json.example .claude/settings.json

# 4. 빌드 (품질 게이트 확인)
./gradlew build

# 5. 프로젝트 정보 변경
# - build.gradle.kts: group, version
# - 패키지명: com.company.template → 실제 도메인

 

 

 

프롬프트는 부탁이고, Hooks는 강제다.

AI는 똑똑하지만 금붕어 급 기억력을 가졌다.
그래서 우리는 기억에 의존하지 않는 시스템을 만들어야 한다.

 

다섯 가지 벽:

  1. Git Pre-commit Hook → 커밋 단계 차단
  2. Claude Code Hooks → AI 실행 중 차단
  3. ArchUnit → 빌드 단계 차단
  4. Gradle 품질 게이트 → 배포 전 차단
  5. 문서 기반 자동 주입 → 컨텍스트 드리프트 예방

 

이 벽들이 중첩 방어선을 만들고,
AI는 "변명"이 아니라 "규칙을 지킬 수밖에 없는" 환경에서 작동한다.

다음 편에서는 실제 프로젝트 적용기를 공유할 예정이다.