지난 글에서 노션→지라→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으로 자동 주입
전략:
- 프로젝트 규칙을 문서화 (docs/ENTERPRISE_SPRING_STANDARDS_PROMPT.md)
- SessionStart Hook에서 요약본 생성
- 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는 똑똑하지만 금붕어 급 기억력을 가졌다.
그래서 우리는 기억에 의존하지 않는 시스템을 만들어야 한다.
다섯 가지 벽:
- Git Pre-commit Hook → 커밋 단계 차단
- Claude Code Hooks → AI 실행 중 차단
- ArchUnit → 빌드 단계 차단
- Gradle 품질 게이트 → 배포 전 차단
- 문서 기반 자동 주입 → 컨텍스트 드리프트 예방
이 벽들이 중첩 방어선을 만들고,
AI는 "변명"이 아니라 "규칙을 지킬 수밖에 없는" 환경에서 작동한다.
다음 편에서는 실제 프로젝트 적용기를 공유할 예정이다.
'AI' 카테고리의 다른 글
| AI로 TDD로 돌리기 — Kent Beck의 CLAUDE를 보고 입맛대로 바꾸기 (0) | 2025.11.15 |
|---|---|
| CLAUDE 세션 고도화 하기 (0) | 2025.10.22 |
| 프롬프트가 아니라 프로세스 (Ⅲ): 커맨드로 완성하는 자동화 루틴 (0) | 2025.10.05 |
| 프롬프트가 아니라 프로세스 (Ⅰ): 세션-프루프 개발 루틴 (0) | 2025.10.05 |
| Claude Code - 완벽한 개발 파트너로 만들어보기 (0) | 2025.10.05 |