fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/AI/AI 코딩 에이전트의 코드 컨벤션은 규칙 문…
aijava

AI 코딩 에이전트의 코드 컨벤션은 규칙 문서와 결정적 검사로 지킨다

Claude Code나 Codex에 코드를 맡기면 컨벤션을 프롬프트에 적어 두고 싶어진다. "메서드 안에서 단계마다 빈 줄을 넣어라", "생성자는 롬복으로 줄여라" 같은 문장을 지침 파일에 한 줄씩 더하는 방식이다. 이 글은 그 방식 대신 규칙을 문서 하나에 한 개씩 적고, 도구로 판정할 수 있는 규칙은 빌드와 테스트에서 실패하게 만드는 방식을 정리한다....

2026.09.29·16 min read·14 views

Claude Code나 Codex에 코드를 맡기면 컨벤션을 프롬프트에 적어 두고 싶어진다. "메서드 안에서 단계마다 빈 줄을 넣어라", "생성자는 롬복으로 줄여라" 같은 문장을 지침 파일에 한 줄씩 더하는 방식이다. 이 글은 그 방식 대신 규칙을 문서 하나에 한 개씩 적고, 도구로 판정할 수 있는 규칙은 빌드와 테스트에서 실패하게 만드는 방식을 정리한다. 프롬프트로 전한 규칙과 AI 리뷰는 같은 코드를 두고도 실행할 때마다 판정이 달라질 수 있다. 빌드와 테스트는 결정적 검사(deterministic check, 같은 코드에는 항상 같은 판정을 내리는 검사)라 판정이 늘 같다.

소재는 Spring Boot 멀티모듈 백엔드 저장소 하나에서 실제로 한 작업이다. 뒤쪽 절에서는 그 저장소가 쓰는 ArchUnit을 기초부터 정리한다.

적용 환경은 다음과 같다.

항목버전
Java25
Spring Boot4.1
ArchUnit1.4.1
Lombok1.18.46

코드 인용은 실제 테스트 코드에서 가져왔고, 회사 패키지 이름은 com.example.app으로 바꿨다.

에이전트가 만든 코드에서 반복된 문제

GPT 계열 모델로 짠 코드를 모듈 네 개에서 점검했다(2026-09-29). 기능은 대부분 동작했지만 읽기 어려운 모양이 반복됐다.

문제점검 결과
메서드 안에서 빈 줄 없이 20줄 넘게 이어지는 코드한 모듈에만 33개
40줄을 넘는 메서드모듈마다 10개 안팎
필드 대입만 하는 생성자를 손으로 쓴 빈한 모듈에서 21개

@Autowired를 붙인 생성자와 같은 헬퍼 메서드를 여러 클래스에 복사한 코드도 함께 나왔다.

하나씩 보면 사소하다. 문제는 에이전트가 기존 코드를 보고 다음 코드를 쓴다는 점이다. 손으로 쓴 생성자가 있는 클래스 옆에 새 클래스를 만들면 같은 모양의 생성자가 또 생긴다. 실제로 한 도구 클래스가 생성자와 로거를 손으로 썼고, 비슷한 역할의 다른 도구 클래스가 그 모양을 그대로 따라 썼다.

프롬프트에 적는 규칙이 약한 이유

지침 파일에 규칙을 더하는 방식은 처음에는 잘 먹힌다. 규칙이 수십 줄로 늘어나면 에이전트는 그중 일부를 지키지 않는다. 어느 줄을 놓칠지 미리 알 수 없고, 놓쳤다는 사실도 리뷰에서 사람이 발견해야 드러난다.

반대로 빌드가 실패하면 에이전트는 그 메시지를 무시하고 작업을 끝낼 수 없다. 이 저장소의 에이전트 지침은 완료를 선언하기 전에 해당 모듈의 검증 스크립트를 통과시키라고 요구한다. 검증이 실패하면 에이전트는 실패 메시지를 읽고 코드를 고친 뒤 다시 돌린다. 실패 메시지에 규칙 문서의 경로가 들어 있으면 에이전트가 그 문서를 열어 왜 막혔는지까지 읽는다.

이 판단은 측정한 결과가 아니라 사용하면서 얻은 경험에서 나온 추론이다. 프롬프트 규칙이 몇 줄부터 무시되는지 같은 수치는 재지 않았다.

도구로 판정할 수 있는 규칙은 빌드가 막고, 도구로 판정할 수 없는 규칙만 리뷰가 본다. 두 경로 모두 같은 규칙 문서를 가리킨다는 점이 핵심이다.

규칙을 위키처럼 관리한다

규칙은 docs/code-rules/ 아래에 문서 하나당 규칙 하나로 둔다. 목록은 INDEX.md 한 파일이 가지고, 에이전트와 사람 모두 이 목록부터 읽는다.

text
docs/code-rules/
├── INDEX.md    규칙 목록, 문서 형식, 규칙을 늘리는 절차
└── rule-1.md   롬복 사용 범위

규칙으로 삼는 기준

INDEX.md는 규칙이 코드의 상태를 말해야 한다고 정한다. 지금 코드를 보고 어겼는지 판단할 수 있어야 규칙이다.

문장규칙 여부
엔티티에 @Setter를 붙이지 않는다규칙이다. 코드를 보면 안다
지우기 전에 호출처를 확인한다규칙이 아니다. 확인했는지는 코드에 남지 않는다

겪은 문제만 규칙으로 올리고, 검사 방법을 적을 수 없는 문장은 규칙으로 만들지 않는다.

규칙 문서의 구조

규칙 문서는 모두 같은 절을 가진다.

절담는 것
제목단정형 한 문장. 제목만 읽어도 무엇을 해야 하는지 안다
예외규칙이 적용되지 않는 경우
왜어겼을 때 무엇이 깨지는지 두세 줄
어겼는지 보는 법실행할 명령과 검사 코드 위치
리뷰가 보는 것도구로 판정할 수 없어 자동 리뷰가 판단할 것
실제 사례이 저장소의 코드와 커밋 해시

첫 규칙의 제목은 다음과 같다.

롬복은 @Data, @Setter, @Value(lombok.Value), @SneakyThrows 와 엔티티의 @EqualsAndHashCode, @ToString 을 쓰지 않는다

본문은 금지 목록만 두지 않고 쓰는 쪽도 표로 적는다. 생성자 주입만 하는 빈은 @RequiredArgsConstructor, 클래스 이름 로거는 @Slf4j, JPA 엔티티는 @Getter와 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 쓴다. DTO와 불변 값은 record로 만든다.

예외 절에는 스프링의 @Value(설정값 주입)가 이 규칙과 무관하다는 점을 적었다. 이 한 줄이 없으면 리뷰어와 에이전트가 두 @Value를 같은 것으로 보고 설정 주입까지 막으려 한다.

에이전트와 리뷰가 같은 목록을 읽게 한다

규칙 문서를 만들어도 아무도 열지 않으면 소용이 없다. 그래서 세 곳에서 같은 목록을 가리킨다.

읽는 쪽연결 방법
코딩 에이전트AGENTS.md에 "코드를 쓰거나 리뷰하기 전에 docs/code-rules/INDEX.md의 규칙 목록을 본다"는 한 줄
PR 자동 리뷰CI에서 Claude Code를 비대화형으로 실행하고, 리뷰 프롬프트가 INDEX.md를 읽게 한다
빌드ArchUnit 테스트의 실패 메시지에 규칙 문서 경로를 넣는다

이 저장소는 CLAUDE.md를 AGENTS.md의 심볼릭 링크로 둔다. Claude Code는 CLAUDE.md를, Codex는 AGENTS.md를 읽으므로 파일 하나로 두 에이전트가 같은 지침을 받는다.

리뷰 프롬프트의 해당 부분에서 두 줄을 옮긴다.

text
### 코드 규칙 (docs/code-rules)
docs/code-rules/INDEX.md 의 규칙 목록을 먼저 읽고, diff 에 해당하는 규칙 문서만 열어 본다.
규칙 문서의 「예외」 에 해당하면 지적하지 않는다.

두 줄 사이에는 테스트(CodeRulesTest)가 막는 규칙은 다시 보지 말고 규칙 문서의 「리뷰가 보는 것」 만 판단하라는 문장이 있다.

리뷰가 테스트와 같은 것을 다시 보지 않도록 역할을 나눈 것이 요점이다. 에이전트에게 모든 규칙 본문을 매번 읽히지 않고, 목록만 읽힌 다음 해당하는 문서만 열게 한다. Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법에서 정리한 것처럼 링크만 걸어 둔 문서는 잘 읽히지 않는다. 그래서 목록 한 파일은 지침에서 직접 가리키고, 실패 메시지에도 경로를 넣는다.

결정적 검사는 세 층으로 나눈다

규칙마다 검사할 수 있는 도구가 다르다. 하나의 도구로 모든 규칙을 막으려 하지 않고 층을 나눴다.

층보는 것도구동작상태
형식들여쓰기, 줄바꿈, import 순서Spotless와 Eclipse 포매터코드를 고친다도입 예정
밀도메서드 길이, 인자 수, 중첩 깊이, 멤버 사이 빈 줄Checkstyle검사만 한다도입 예정
설계금지 메서드, 패키지 순환, 트랜잭션 경계ArchUnit테스트로 실패시킨다적용됨

형식: Spotless는 코드를 고친다

Spotless는 Gradle 플러그인이다. spotlessCheck는 형식이 어긋난 파일을 찾아 빌드를 실패시키고, spotlessApply는 그 파일을 직접 고친다. Java 포매터로 eclipse()를 고르면 configFile(...)로 Eclipse 포매터 설정 파일을 줄 수 있다. 팀이 쓰던 IntelliJ 코드 스타일을 Eclipse 포매터 설정으로 옮겨 쓰는 것이 계획이다.

포매터를 고르기 전에 palantir-java-format을 저장소 복사본에 적용해 봤다. 들여쓰기와 줄바꿈은 정리됐지만 메서드 안에 빈 줄을 넣어 주지는 않았다. 포매터는 이미 있는 빈 줄을 유지하거나 줄일 수는 있어도, 어디서 단계가 바뀌는지는 알지 못한다.

그래서 빈 줄 없이 붙은 메서드 문제는 포매터가 해결하지 못한다. 이 문제는 메서드 길이 제한과 리뷰가 맡는다. 메서드가 짧으면 빈 줄로 단계를 나눌 필요 자체가 줄어든다.

밀도: Checkstyle은 검사만 한다

Checkstyle은 코드를 고치지 않고 위반을 보고만 한다. 그래서 에이전트가 실패 메시지를 읽고 메서드를 나누는 작업을 직접 해야 한다. 길이를 줄이는 방법은 여러 가지라 도구가 고쳐 줄 수 없는 영역이다.

쓰려는 검사는 다음과 같다.

검사보는 것공식 문서의 기본값
MethodLength메서드와 생성자의 줄 수150줄
ParameterNumber메서드 인자 수7개
NestedIfDepthif-else 중첩 깊이1
EmptyLineSeparator필드, 메서드 같은 멤버 뒤의 빈 줄멤버 사이 빈 줄 요구, 여러 줄 빈 줄은 허용

EmptyLineSeparator는 멤버 사이의 빈 줄을 보는 검사다. 메서드 안에 빈 줄을 요구하지는 않는다. 메서드 안의 빈 줄은 결국 사람이나 자동 리뷰가 봐야 한다.

기본값 150줄은 이 저장소 기준으로 너무 느슨하다. 점검에서 40줄을 넘는 메서드가 모듈마다 10개 안팎이었으므로, 기준값을 정할 때 이 분포를 함께 본다.

설계: ArchUnit은 테스트로 막는다

형식과 밀도는 한 파일 안에서 판단할 수 있다. "public setter를 두지 않는다", "기능 패키지끼리 순환하지 않는다"는 클래스 사이의 관계를 봐야 판단할 수 있다. 이 층은 ArchUnit이 맡고, 이 저장소에는 이미 테스트 세 개가 있다. 다음 절에서 ArchUnit 자체를 정리하고, 그 뒤에 세 테스트를 사례로 본다.

ArchUnit 기초

이 절은 ArchUnit 공식 사용자 가이드를 기준으로 썼다. 2026-09-29에 확인한 가이드는 1.5.1 버전이고, 저장소가 쓰는 버전은 1.4.1이다. 두 버전의 차이를 전부 비교하지는 않았다. 이 절의 API는 저장소 테스트가 1.4.1로 컴파일되고 통과하는 것으로 확인했고, 설치 아티팩트가 다른 점만 따로 적는다.

ArchUnit이 하는 일

ArchUnit은 컴파일된 .class 파일을 읽어 클래스, 메서드, 필드와 그 사이의 의존 관계를 객체로 만든다. 그 객체에 대해 "이 패키지는 저 패키지에 의존하지 않는다" 같은 규칙을 검사하고, 결과를 JUnit 테스트의 성공과 실패로 돌려준다.

Java 개발자에게 익숙한 말로 옮기면 아키텍처 규칙을 위한 AssertJ에 가깝다. 대상이 값이 아니라 코드 구조라는 점만 다르다.

소스 코드가 아니라 바이트코드를 읽는다는 점이 중요하다. 컴파일 뒤 사라지는 정보는 ArchUnit이 볼 수 없다. 뒤의 "자주 틀리는 부분"에서 이 성질 때문에 생긴 문제를 다룬다.

설치

Gradle에 테스트 의존성 하나를 더한다.

kotlin
dependencies {
    testImplementation("com.tngtech.archunit:archunit-junit5:1.4.1")
}

archunit-junit5는 규칙을 쓰는 API와 JUnit Platform에서 도는 테스트 엔진을 함께 가져온다. 1.5.1 가이드는 JUnit 6용 archunit-junit6 아티팩트를 기본으로 보여 주고, JUnit 5용은 주석으로 남겨 뒀다.

@AnalyzeClasses와 @ArchTest

JUnit 지원을 쓰면 가져올 클래스를 @AnalyzeClasses로 선언하고, 규칙을 @ArchTest 필드로 둔다.

java
@AnalyzeClasses(packages = "com.example.app", importOptions = ImportOption.DoNotIncludeTests.class)
class CodeRulesTest {
 
    @ArchTest
    static final ArchRule no_public_setters = noMethods()
            .that().arePublic().and().haveNameMatching("set[A-Z].*")
            .should().beDeclaredInClassesThat().resideInAPackage("com.example.app..")
            .because("상태는 이름 있는 메서드로만 바꾼다 (docs/code-rules/rule-1.md)");
}

가이드에 따르면 JUnit 지원은 지정한 클래스를 가져오거나 이미 가져온 것을 재사용해 @ArchTest가 붙은 규칙을 모두 평가한다. ImportOption.DoNotIncludeTests는 테스트 클래스를 검사 대상에서 뺀다.

@ArchTest 없이 일반 JUnit 테스트로 써도 된다. ClassFileImporter로 클래스를 가져와 규칙의 check(...)에 넘기는 방식이다.

java
@Test
void functionalPackagesAreFreeOfCycles() {
    slices().matching("com.example.app.(*)..")
            .should().beFreeOfCycles()
            .check(new ClassFileImporter()
                    .withImportOption(new ImportOption.DoNotIncludeTests())
                    .importPackages("com.example.app"));
}

두 방식의 차이는 가져온 클래스를 재사용하는지에 있다. ClassFileImporter를 테스트마다 부르면 그때마다 클래스 파일을 다시 읽는다. 규칙이 많고 대상 패키지가 크면 @AnalyzeClasses 쪽이 테스트 시간을 덜 쓸 것으로 본다. 직접 측정하지는 않았다.

규칙 문법: that과 should

규칙은 ArchRuleDefinition의 정적 메서드로 시작한다.

시작뜻
classes()조건에 맞는 클래스는 모두 이래야 한다
noClasses()조건에 맞는 클래스는 하나도 이러면 안 된다
methods(), noMethods()같은 뜻을 메서드에 적용한다
fields(), noFields()같은 뜻을 필드에 적용한다

그 뒤는 세 부분으로 읽는다.

  • that() 뒤에는 검사할 대상을 고르는 조건이 온다. SQL의 WHERE에 가깝다.
  • should() 뒤에는 그 대상이 만족해야 할 조건이 온다. 테스트의 단언에 해당한다.
  • because(...)는 실패 메시지에 붙을 이유다. 규칙 문서 경로를 여기에 넣는다.

and()와 or()로 조건을 잇는다.

java
noClasses().that().resideInAPackage("..service..")
        .should().dependOnClassesThat().resideInAPackage("..controller..");

패키지 표기에서 ..은 임의 깊이의 패키지를 뜻한다. "..service.."는 이름에 service 패키지가 들어간 모든 패키지와 그 하위 패키지를 가리킨다.

계층 규칙: layeredArchitecture

컨트롤러, 서비스, 영속성 계층의 호출 방향처럼 계층 구조가 있으면 규칙을 하나씩 쓰지 않고 계층을 선언한다. 가이드의 예시는 다음과 같다.

java
layeredArchitecture()
    .consideringAllDependencies()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Persistence").definedBy("..persistence..")
 
    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
    .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service");

계층을 패키지로 정의하고, 어느 계층이 어느 계층을 호출해도 되는지 적는다. Hexagonal 구조에 적용하는 예시는 Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기에 있다.

순환 규칙: slices().beFreeOfCycles()

slices()는 클래스를 패키지 이름으로 묶는다. matching("com.example.app.(*)..")에서 괄호로 잡은 부분이 묶음의 이름이 된다. com.example.app.order.domain과 com.example.app.order.api는 모두 order 묶음에 들어간다.

beFreeOfCycles()는 묶음 사이에 order → payment → order 같은 순환 의존이 없어야 한다는 조건이다. 기능 패키지 사이의 순환은 나중에 모듈을 떼어 내거나 한 기능만 바꿀 때 비용이 된다.

사용자 조건: DescribedPredicate

기본 제공 조건으로 표현할 수 없는 대상은 DescribedPredicate로 직접 만든다. 생성자에 넘기는 문자열이 실패 메시지에 들어가는 설명이 된다.

java
private static final DescribedPredicate<JavaClass> USES_NEO4J_CLIENT =
        new DescribedPredicate<>("Neo4jClient에 의존하는 클래스") {
            @Override
            public boolean test(JavaClass type) {
                return type.getDirectDependenciesFromSelf().stream()
                           .anyMatch(dependency -> dependency.getTargetClass().isEquivalentTo(Neo4jClient.class));
            }
        };

getDirectDependenciesFromSelf()는 이 클래스가 직접 의존하는 대상을 돌려준다. 필드 타입, 메서드 호출, 메서드 인자 타입처럼 바이트코드에 남는 의존이 들어간다. 만든 조건은 noClasses().that(USES_NEO4J_CLIENT)처럼 that(...)에 넣는다.

빈 대상: allowEmptyShould

ArchUnit은 기본적으로 should()에 넘어간 대상이 비어 있으면 규칙을 실패시킨다. 가이드는 그 이유를 패키지 이름 변경으로 설명한다. classes().that().resideInAPackage("com.myapp.old") 규칙이 있는데 old 패키지 이름을 바꾸면, 이 규칙은 아무 클래스도 검사하지 않으면서 계속 통과한다.

금지 규칙에서는 이 기본값이 불편하다. public setter를 금지하는 규칙은 setter가 하나도 없는 상태가 정상인데, 그러면 대상이 비어 규칙이 실패한다. 그래서 규칙마다 .allowEmptyShould(true)를 붙이거나, archunit.properties에 archRule.failOnEmptyShould=false를 둬 전체에 적용한다.

전체 설정보다 규칙마다 붙이는 쪽이 낫다. 패키지 이름을 바꿔 검사 대상이 사라지는 사고를 다른 규칙에서는 계속 잡을 수 있기 때문이다.

기존 위반 동결: FreezingArchRule

오래된 코드에 규칙을 새로 걸면 위반이 수백 개씩 나온다. 한 번에 고칠 수 없으니 지금 있는 위반은 기록해 두고 새 위반만 막는다.

java
ArchRule rule = FreezingArchRule.freeze(classes().should()./* 규칙 */);

가이드에 따른 동작은 다음과 같다.

  • 첫 실행에서 모든 위반을 ViolationStore에 기록한다. 기본 저장소는 텍스트 파일이라 버전 관리에 올릴 수 있다.
  • 다음 실행부터는 새 위반만 보고한다.
  • 기록된 위반을 고치면 저장소에 기록된 위반도 자동으로 줄인다. 고친 위반이 되살아나는 회귀를 막기 위해서다.
  • 기본값은 줄 번호를 무시한다. 위반 코드가 다른 줄로 옮겨 가도 기존 위반으로 본다.

저장소 파일을 새로 만드는 설정 freeze.store.default.allowStoreCreation의 기본값은 false다. 처음 한 번은 이 값을 true로 켜서 만들고, CI에서는 끈 채로 둔다. 그래야 CI가 저장소 파일을 잘못 새로 만들어 모든 위반을 기존 위반으로 받아들이는 일이 없다.

저장소의 ArchUnit 사례

이 저장소의 ArchUnit 테스트 세 개를 규칙 문서와 함께 본다.

public setter와 엔티티 메서드 금지

규칙 1은 롬복의 @Setter, @Data와 엔티티의 @EqualsAndHashCode, @ToString을 금지한다. 그런데 ArchUnit으로 이 애노테이션을 직접 찾을 수 없다. 롬복 애노테이션은 모두 @Retention(RetentionPolicy.SOURCE)라 컴파일러가 클래스 파일에 남기지 않는다. Lombok 1.18.46 jar를 javap -v로 열어 Setter, Data, SneakyThrows, Value, EqualsAndHashCode, ToString 모두 SOURCE인 것을 확인했다.

그래서 애노테이션 대신 롬복이 만든 결과를 검사한다.

java
@ArchTest
static final ArchRule no_public_setters = noMethods()
        .that().arePublic().and().haveNameMatching("set[A-Z].*")
        .should().beDeclaredInClassesThat().resideInAPackage("com.example.app..")
        .allowEmptyShould(true)
        .because("상태는 이름 있는 메서드로만 바꾼다 (docs/code-rules/rule-1.md)");
 
@ArchTest
static final ArchRule entities_do_not_declare_equals_hash_code_to_string = noMethods()
        .that().haveName("equals").or().haveName("hashCode").or().haveName("toString")
        .should().beDeclaredInClassesThat().areAnnotatedWith("jakarta.persistence.Entity")
        .allowEmptyShould(true)
        .because("지연 로딩 연관과 영속성 식별이 섞인다 (docs/code-rules/rule-1.md)");

이렇게 검사하면 손으로 쓴 setter와 엔티티의 equals도 함께 걸린다. 규칙 문서는 이것을 의도한 결과로 적었다. 손으로 쓴 equals, toString도 지연 로딩 연관을 건드리는 문제가 같기 때문이다.

반대로 롬복 @Value와 @SneakyThrows는 이 방식으로도 잡지 못한다. 규칙 문서의 "리뷰가 보는 것" 절이 이 두 가지를 자동 리뷰에 넘긴다.

기능 패키지 순환 금지

센서 데이터를 다루는 모듈에서 기능 패키지 사이에 순환이 생겼다. 상수 하나를 다른 패키지로 옮겨 순환을 끊고, 다시 생기지 않도록 테스트를 두었다. 앞의 slices().matching(...).should().beFreeOfCycles() 예시가 그 테스트다.

순환은 리뷰에서 발견하기 어렵다. diff에 보이는 것은 import 한 줄이고, 그 한 줄이 반대 방향 의존과 맞물려 순환이 된다는 사실은 diff 밖에 있다. 이런 규칙이 결정적 검사에 잘 맞는다.

Neo4j 클래스의 기본 트랜잭션 금지

이 저장소의 한 모듈은 Postgres와 Neo4j를 함께 쓴다. 트랜잭션 관리자가 둘인데, 이름 없이 @Transactional을 붙이면 기본 관리자인 JPA 쪽이 잡힌다. 그러면 Neo4j에 쓰는 코드가 트랜잭션 없이 쿼리마다 커밋된다.

그래서 Neo4j 쪽 관리자를 지정한 메타 애노테이션 @Neo4jTransactional을 만들고, Neo4jClient를 쓰는 클래스에는 기본 @Transactional을 금지했다.

java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional("neo4jTransactionManager")
public @interface Neo4jTransactional {
}

처음 만든 테스트는 메서드만 봤다.

java
noMethods().that().areDeclaredInClassesThat(USES_NEO4J_CLIENT)
           .should().beAnnotatedWith(Transactional.class)
           .check(productionClasses());

PR 자동 리뷰가 이 테스트의 빈틈을 지적했다. 클래스에 @Transactional을 붙이면 모든 메서드가 기본 관리자로 묶이는데, 이 테스트는 메서드에 직접 붙은 애노테이션만 보므로 통과한다는 내용이었다. 같은 조건으로 클래스를 보는 규칙을 하나 더 두었다.

java
// 클래스에 붙인 @Transactional 도 모든 메서드를 기본(JPA) 관리자로 묶는다.
noClasses().that(USES_NEO4J_CLIENT)
           .should().beAnnotatedWith(Transactional.class)
           .check(productionClasses());

자주 틀리는 부분

앞의 사례에서 나온 실수를 ArchUnit 일반의 주의점으로 정리한다.

SOURCE 보존 애노테이션은 보이지 않는다

@Retention(RetentionPolicy.SOURCE) 애노테이션은 클래스 파일에 없다. ArchUnit은 클래스 파일을 읽으므로 areAnnotatedWith(Setter.class) 같은 규칙은 위반이 있어도 통과한다. 롬복이 대표적이다. 애노테이션 대신 그 애노테이션이 만든 메서드나 필드를 검사한다.

애노테이션뿐 아니라 컴파일 과정에서 바뀌는 코드도 같다. 규칙 1 문서에는 @SneakyThrows를 의존 관계로 잡으려던 검사가 위반을 넣어도 통과해 뺐다는 기록이 있다. 컴파일 뒤 롬복 호출이 바이트코드에서 지워졌기 때문이다(2026-09-28 확인).

메서드 규칙은 클래스 애노테이션을 보지 않는다

noMethods().should().beAnnotatedWith(X.class)는 메서드에 직접 붙은 X만 본다. Spring의 @Transactional처럼 클래스에 붙여 모든 메서드에 적용되는 애노테이션은 따로 noClasses() 규칙이 필요하다. Spring은 클래스에 붙은 애노테이션을 메서드에도 적용하지만, ArchUnit의 메서드 규칙은 메서드 자신에게 붙은 애노테이션만 본다.

areAnnotatedWith와 areMetaAnnotatedWith는 다르다

메타 애노테이션은 다른 애노테이션에 붙은 애노테이션이다. @Neo4jTransactional 위의 @Transactional이 그렇다.

메서드매치하는 것
areAnnotatedWith(X.class), beAnnotatedWith(X.class)X가 직접 붙은 대상
areMetaAnnotatedWith(X.class), beMetaAnnotatedWith(X.class)X가 직접 붙었거나, X가 붙은 애노테이션이 붙은 대상

ArchUnit 1.4.1 소스의 ClassesThat Javadoc은 areMetaAnnotatedWith가 직접 붙은 대상도 매치한다고 적는다.

트랜잭션 규칙에서는 이 차이가 의도대로 쓰였다. beAnnotatedWith(Transactional.class)는 직접 붙은 기본 @Transactional만 막고, @Neo4jTransactional은 통과시킨다. 실제로 Neo4jClient를 필드로 가진 서비스 메서드에 @Neo4jTransactional이 붙어 있는 상태에서 이 테스트가 CI에서 통과했다. 여기서 beMetaAnnotatedWith를 썼다면 허용하려던 @Neo4jTransactional까지 막혔을 것이다.

반대로 @Service처럼 @Component를 메타 애노테이션으로 가진 스프링 빈을 모두 고르려면 areMetaAnnotatedWith(Component.class)를 써야 한다. 어느 쪽을 쓸지는 허용할 애노테이션과 막을 애노테이션을 먼저 적어 보고 정한다.

빈 대상 설정이 검사를 무력화한다

allowEmptyShould(true)를 붙인 규칙은 대상 패키지 이름이 바뀌어도 통과한다. 금지 규칙에 필요한 설정이지만, 그만큼 검사가 실제로 도는지는 다른 방법으로 확인해야 한다.

검사를 만들면 일부러 어겨 본다

앞의 실수는 모두 같은 특징이 있다. 검사 코드가 있고 테스트가 통과하는데, 위반이 들어와도 통과한다. 테스트가 통과한다는 사실만으로는 규칙이 지켜지는지, 검사가 아무것도 보지 않는지 구분할 수 없다.

그래서 규칙 목록 문서에 다음 원칙을 적었다.

검사를 고치면 일부러 위반을 만들어 잡히는지 확인한다.

방법은 단순하다.

  1. 검사 대상 클래스에 위반을 하나 넣는다. 엔티티에 @Setter를 붙이는 식이다.
  2. 테스트를 돌려 실패하는지, 실패 메시지에 규칙 문서 경로가 나오는지 본다.
  3. 위반을 되돌린다.

@SneakyThrows 검사는 이 절차에서 위반을 넣어도 통과했고, 그래서 테스트에서 빼고 리뷰로 넘겼다. 통과하지 않는 검사를 남겨 두면 에이전트와 사람 모두 그 규칙이 지켜지고 있다고 믿게 된다.

기존 위반은 동결하고 새 위반만 막는다

밀도 규칙을 지금 걸면 기존 메서드가 한꺼번에 실패한다. 에이전트에게 "빌드를 통과시켜라"고 하면 관계없는 파일 수십 개를 고치는 PR이 나온다. 리뷰하기 어렵고, 동작을 바꾸지 않았는지 확인하는 비용도 크다.

그래서 도구마다 기존 위반을 목록으로 남기고 새 위반만 실패시키는 방식을 쓸 계획이다. 아직 도입하지 않았다.

도구기존 위반 목록새 위반
ArchUnitFreezingArchRule의 ViolationStore 파일테스트 실패
Checkstylesuppression 파일(SuppressionFilter)에 파일과 검사 이름을 적는다빌드 실패
SpotlessratchetFrom으로 기준 브랜치 이후 바뀐 파일만 검사한다spotlessCheck 실패

목록은 줄어들기만 해야 한다. 기존 코드를 고칠 일이 생겼을 때 그 파일을 목록에서 빼고 함께 정리한다. FreezingArchRule은 고친 위반을 자동으로 목록에서 지우지만, Checkstyle suppression 파일은 사람이 직접 지워야 한다.

정리

에이전트가 컨벤션을 지키게 하는 방법을 세 가지로 나눴다.

  • 규칙은 문서 하나에 하나씩 두고, 목록 한 파일을 에이전트 지침과 자동 리뷰가 함께 가리킨다.
  • 도구로 판정할 수 있는 규칙은 형식, 밀도, 설계 세 층으로 나눠 빌드와 테스트에서 실패시킨다.
  • 도구로 판정할 수 없는 규칙만 규칙 문서의 "리뷰가 보는 것" 절에 적어 리뷰에 넘긴다.

지금 이 저장소에서 실제로 도는 것은 ArchUnit 테스트 세 개와 규칙 문서, 자동 리뷰다. Spotless와 Checkstyle, 기존 위반 동결은 도입 전이다. 도입하고 나면 빈 줄 없이 붙은 메서드와 40줄 넘는 메서드 수가 어떻게 바뀌는지 다시 점검할 계획이다.

에이전트 지침 파일의 형식은 agents.md에, 에이전트 주변 구조를 코드 규칙으로 옮기는 흐름은 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계에 정리했다.

참고 자료

  • ArchUnit User Guide — 2026-09-29 확인, 1.5.1 기준
  • ArchUnit ClassesThat 소스 (v1.4.1)
  • Spotless Gradle 플러그인
  • Checkstyle MethodLength
  • Checkstyle ParameterNumber
  • Checkstyle NestedIfDepth
  • Checkstyle EmptyLineSeparator
  • Checkstyle SuppressionFilter
on this page
  • 01에이전트가 만든 코드에서 반복된 문제
  • 02프롬프트에 적는 규칙이 약한 이유
  • 03규칙을 위키처럼 관리한다
  • 규칙으로 삼는 기준
  • 규칙 문서의 구조
  • 에이전트와 리뷰가 같은 목록을 읽게 한다
  • 코드 규칙 (docs/code-rules)
  • 04결정적 검사는 세 층으로 나눈다
  • 형식: Spotless는 코드를 고친다
  • 밀도: Checkstyle은 검사만 한다
  • 설계: ArchUnit은 테스트로 막는다
  • 05ArchUnit 기초
  • ArchUnit이 하는 일
  • 설치
  • `@AnalyzeClasses`와 `@ArchTest`
  • 규칙 문법: `that`과 `should`
  • 계층 규칙: `layeredArchitecture`
  • 순환 규칙: `slices().beFreeOfCycles()`
  • 사용자 조건: `DescribedPredicate`
  • 빈 대상: `allowEmptyShould`
  • 기존 위반 동결: `FreezingArchRule`
  • 06저장소의 ArchUnit 사례
  • public setter와 엔티티 메서드 금지
  • 기능 패키지 순환 금지
  • Neo4j 클래스의 기본 트랜잭션 금지
  • 07자주 틀리는 부분
  • SOURCE 보존 애노테이션은 보이지 않는다
  • 메서드 규칙은 클래스 애노테이션을 보지 않는다
  • `areAnnotatedWith`와 `areMetaAnnotatedWith`는 다르다
  • 빈 대상 설정이 검사를 무력화한다
  • 08검사를 만들면 일부러 어겨 본다
  • 09기존 위반은 동결하고 새 위반만 막는다
  • 10정리
  • 11참고 자료
tags
#study#insights

이런 글도

  • ai

    하네스 회고는 새 문서로 쌓지 않고 종류에 맞는 기존 단일 소스에 환원한다

    2026.10.02
  • ai

    에이전트가 배운 회피 패턴은 조건을 통과한 것만 파일로 쌓고 주기적으로 지운다

    2026.10.02
  • ai

    ADR 은 되돌리기 어려운 결정의 이유만 남기고, 대안 기각은 옵션마다 줄을 나눈다

    2026.10.02
  • ai

    스킬 문서는 반복 실행을 스크립트로 내리고 같은 지시를 한 곳에서만 소유하게 쓴다

    2026.10.02

댓글 (0)