에이전트는 메모리보다 좋은 문서가 필요하다: 사람과 에이전트를 위한 레포 작성
1. 개요
코딩 에이전트가 점차 팀의 한 “멤버”가 되어가고 있다. 코드를 읽고, 버그를 고치고, 테스트를 작성하고, pull request를 생성한다. 문제는 에이전트가 세션 사이에 아무것도 기억하지 못한다는 것이다. 일부 도구에서는 “memory” 기능을 제공하지만, 그러한 기억은 보통 특정 사람에게만 종속되어 있고, 리뷰하기 어렵고 코드의 버전을 따라가지 못한다.
이 글은 레포지토리 내 문서가 에이전트의 메모리보다 더 믿을 만하다는 이유를 설명한다. 또한 사람 신입과 에이전트가 처음부터 올바르게 일할 수 있도록 Spring Boot 레포를 작성하는 방법을 안내한다. 이 글을 읽고 나면, AGENTS.md 파일을 작성하는 방법, 검증을 하나의 명령으로 통합하는 방법, 관례를 테스트로 변환하는 방법, 그리고 설계 결정을 기록하는 방법을 알게 될 것이다.
2. 메모리가 충분하지 않은 이유
에이전트가 “domain 계층은 Spring에 의존하면 안 된다”는 것을 알고 있는 두 가지 방법을 비교해보자:
- 메모리: 지난번에 이를 말해줬고 에이전트가 어딘가에 기록해놨다.
- 레포: 규칙이
AGENTS.md에 있고, 누군가 이를 위반하면 실패할 테스트가 있다.
첫 번째 방법에는 명백한 약점이 여러 개 있다:
- 기록은 한 사람의 기계나 계정에만 존재한다. 다른 에이전트를 사용하는 팀원은 그것을 갖지 못한다.
- 아무도 그 기록을 리뷰하지 않는다. 에이전트가 “잘못 기억”하면 이를 발견하기 매우 어렵다.
- 기록이 브랜치를 따라가지 못한다.
main에서 규칙이 변경되어도 예전 메모리는 남아있다.
두 번째 방법은 버전 관리되고, pull request를 통해 리뷰되며, 각 commit에 맞게 유지된다. 더 중요한 것은 이것이 인간에게도 도움이 된다는 점이다. 에이전트가 올바르게 일하기 위해 필요한 것은 거의 팀에 새로 입사한 개발자가 첫 주에 필요한 것과 같다.
이 주제가 주목받는 이유다. 에이전트가 새로운 문제를 드러내는 것이 아니다. 에이전트가 형편한 문서의 비용을 드러낼 뿐이다. 왜냐하면 에이전트는 새 입사자처럼 “형님, 이건 어떻게 돌아가요?“라고 물을 수 없기 때문이다.
3. 에이전트 가이드: AGENTS.md
많은 에이전트 도구는 레포 루트의 Markdown 파일을 읽어서 지시사항을 가져온다. 파일 이름은 도구에 따라 다르지만, AGENTS.md는 흔한 관례다. 당신의 도구가 다른 이름을 사용한다면, 내용을 복제하는 대신 symlink를 만들 수 있다.
3.1. 포함해야 할 내용
# AGENTS.md
## 프로젝트
주문 서비스 (Spring Boot 3, Java 21). 주요 모듈: `order`, `payment`, `customer`.
## 명령어
- Build + 전체 검증: `./scripts/verify.sh`
- 한 테스트 실행: `./mvnw -q test -Dtest=DiscountPolicyTest`
- 로컬에서 앱 실행: `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
## 필수 관례
- `..domain..`은 `org.springframework..`을 import하면 안 됨 (테스트: `ArchitectureTest`).
- 통화는 `BigDecimal`을 사용하고, `double`을 사용하지 않음.
- 모든 비즈니스 로직 변경은 `src/test/java/.../domain`에 테스트가 있어야 함.
## 하지 말아야 할 것
- 병합된 `src/main/resources/db/migration` 내 파일을 수정하지 말 것; 새로운 migration 생성.
- `application-local.yml` commit 금지.
## 더 읽어보기
- 아키텍처 결정: `docs/adr/`
- 결제 흐름: `docs/payment-flow.md`
각 섹션의 목적:
- 명령어: 에이전트가 명령어를 매우 자주 실행한다. 잘못된 명령어 하나가 많은 시행착오를 초래한다. 정확하고 복사-붙여넣기 가능한 명령어를 작성하자.
- 필수 관례: 각 줄은 검증 방법을 나타내야 한다 (테스트 이름, lint 규칙). 검증 메커니즘이 없는 관례는 아주 쉽게 무시된다.
- 하지 말아야 할 것: 되돌리기 어려운 부작용을 일으키는 작업을 나열한다.
- 더 읽어보기: 여기에 내용을 다시 복사하는 대신 상세 문서를 가리킨다.
3.2. 짧게 유지하고, 정보의 근원을 가리키기
AGENTS.md는 짧아야 하며, 1분 안에 읽을 수 있을 정도면 충분하다. 에이전트는 매 세션마다 이 파일을 문맥에 로드한다. 파일이 길수록 더 많은 문맥을 사용하고 오래된 정보를 포함할 가능성이 높다.
일반 원칙: 코드나 빌드가 이미 명확하게 말하는 것은 다시 설명하지 말자. 각 의존성을 나열하지 말자, pom.xml이 이미 갖고 있다. 각 엔드포인트를 설명하지 말자, OpenAPI spec이 이미 갖고 있다. 코드에서 추론할 수 없는 것들만 작성하자.
주의: 절대로
AGENTS.md에 secret, token, 또는 민감한 내부 URL을 넣지 말 것. 이 파일은 외부 도구에서 읽히고 git 히스토리에 영원히 남아있다.
4. 하나의 검증 명령어
에이전트는 명확한 피드백 루프가 있을 때 가장 잘 작동한다: 코드를 수정하고, 명령어를 실행하고, pass인지 fail인지 확인한다. 완전한 검증을 위해 네 개의 다른 명령어를 순서대로 실행해야 한다면, 에이전트가 뭔가 놓칠 것이다. 사람도 마찬가지다.
#!/usr/bin/env bash
# scripts/verify.sh — CI와 동일하게 실행. Exit code가 0이 아니면 아직 merge 준비 안 됨.
set -euo pipefail
echo ">> 컴파일 + 단위 테스트 + 아키텍처 테스트"
./mvnw -B -q verify
echo ">> 남은 TODO-AGENT이 없는지 확인"
if grep -rn "TODO-AGENT" src/; then
echo "처리되지 않은 TODO-AGENT가 있음" >&2
exit 1
fi
echo "OK: pull request 생성 준비 완료"
설명:
set -euo pipefail은 한 명령어가 실패하면 스크립트를 멈춘다. 덕분에 에이전트는 0이 아닌 exit code를 받는다. 끝에 “OK”가 있어서 속지 않는다.-B(batch mode)는 색상 출력과 진행률 표시를 끈다. 로그가 깔끔해서 에이전트가 에러를 읽기 쉬워진다.grep단계는 팀의 고유한 관례의 예다: 에이전트는 미완료 부분을TODO-AGENT로 표시할 수 있지만, PR을 열 때 빠진 부분이 있으면 안 된다.
성공하면 다음과 같이 보인다:
>> 컴파일 + 단위 테스트 + 아키텍처 테스트
>> 남은 TODO-AGENT이 없는지 확인
OK: pull request 생성 준비 완료
가장 중요한 점: CI는 반드시 이 스크립트를 호출해야 한다. CI가 다른 명령어 세트를 실행하면, verify.sh는 점차 현실과 벗어나 잘못된 문서가 되어버린다.
5. ArchUnit으로 관례를 테스트로 변환하기
“domain은 Spring에 의존하지 않는다”는 문장은 문서에서 조언일 뿐이다. 이를 테스트로 변환하면 법칙이 된다. ArchUnit을 사용하면 JUnit 테스트 형태로 아키텍처 규칙을 작성할 수 있다.
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
package com.nonla.shop;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.nonla.shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule domainDoesNotDependOnSpring = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..")
.because("domain은 Spring context 없이 테스트할 수 있어야 하며, docs/adr/0003-domain-thuan.md 참고");
@ArchTest
static final ArchRule webDoesNotCallRepositoryDirectly = noClasses()
.that().resideInAPackage("..web..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
.because("controller는 service를 거쳐서 transaction을 한 곳에 유지");
}
가장 가치 있는 부분은 .because(...)에 있다. 규칙이 위반되면 에러 메시지에 이유와 ADR 경로가 포함된다. 누군가 domain 패키지의 DiscountPolicy에 @Component를 추가하면, 출력은 다음과 같다:
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package
'..domain..' should depend on classes that reside in a package 'org.springframework..',
because domain은 Spring context 없이 테스트할 수 있어야 하며, docs/adr/0003-domain-thuan.md 참고'
was violated (1 times):
Class <com.nonla.shop.order.domain.DiscountPolicy> is annotated with <org.springframework.stereotype.Component> ...
에이전트가 이 메시지를 읽으면 즉시 무엇을 해야 하는지 알 수 있다: annotation을 제거하고, infra 계층의 @Configuration 클래스에서 bean을 등록하자. 좋은 에러 메시지는 바로 읽는 사람이 필요한 시점에 나타나는 문서다.
6. 테스트는 비즈니스 문서
비즈니스 규칙은 에이전트가 가장 실수하기 쉬운 부분이다. “첫 주문에 최대 50,000동 할인”이라는 것을 추측할 수 없다. 코드와 테스트가 말하게 하자.
package com.nonla.shop.order.domain;
import java.math.BigDecimal;
import java.math.RoundingMode;
public record Order(String customerId, BigDecimal subtotal, boolean firstOrder) {}
class DiscountPolicy {
static final BigDecimal FIRST_ORDER_RATE = new BigDecimal("0.10");
static final BigDecimal FIRST_ORDER_CAP = new BigDecimal("50000");
BigDecimal discountFor(Order order) {
if (!order.firstOrder()) {
return BigDecimal.ZERO;
}
BigDecimal raw = order.subtotal().multiply(FIRST_ORDER_RATE)
.setScale(0, RoundingMode.DOWN);
return raw.min(FIRST_ORDER_CAP);
}
}
함께 가는 테스트는 메서드 이름이 아니라 동작 방식에 따라 명명해야 한다:
package com.nonla.shop.order.domain;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("주문 할인")
class DiscountPolicyTest {
private final DiscountPolicy policy = new DiscountPolicy();
@Test
@DisplayName("첫 주문은 총액의 10% 할인")
void firstOrderGetsTenPercent() {
var order = new Order("c-1", new BigDecimal("200000"), true);
assertThat(policy.discountFor(order)).isEqualByComparingTo("20000");
}
@Test
@DisplayName("첫 주문 할인은 최대 50,000동을 초과할 수 없음")
void firstOrderDiscountIsCapped() {
var order = new Order("c-1", new BigDecimal("2000000"), true);
assertThat(policy.discountFor(order)).isEqualByComparingTo("50000");
}
@Test
@DisplayName("두 번째 주문부터는 할인 없음")
void repeatOrderGetsNoDiscount() {
var order = new Order("c-1", new BigDecimal("2000000"), false);
assertThat(policy.discountFor(order)).isZero();
}
}
설명:
@DisplayName은 테스트 보고서를 읽을 수 있는 사양으로 변환한다. 제품 담당자도 이해할 수 있다.isEqualByComparingTo는BigDecimal을 값으로 비교하므로,20000과20000.00은 같은 것으로 간주된다. 여기서isEqualTo를 사용하는 것은 흔한 실수인데, 이는 scale도 비교하기 때문이다.- 에이전트가 “할인을 15%로 올려달라”는 요청을 받으면, 어느 테스트를 수정할지 즉시 알 수 있다. 실수로 제한선을 제거하면 두 번째 테스트가 실패할 것이다.
실행하면 리포트는 다음과 같다:
주문 할인
✔ 첫 주문은 총액의 10% 할인
✔ 첫 주문 할인은 최대 50,000동을 초과할 수 없음
✔ 두 번째 주문부터는 할인 없음
7. “왜”를 기록하기: ADR과 package-info
코드는 시스템이 무엇을 하는지 보여준다. 테스트는 그것이 반드시 무엇을 해야 하는지 보여준다. 하지만 왜 그렇게 선택했는지를 설명하는 것은 없다. “왜”가 없으면, 에이전트는 불필요해 보이지만 실제로는 이유가 있는 것을 “정리”하기 쉽다.
Architecture Decision Record (ADR)는 각 결정마다 짧은 파일이다:
# 0003. 도메인 계층이 Spring에 의존하지 않음
상태: 수용됨
## 배경
도메인 테스트가 Spring context를 부팅해야 했으므로 느리고 깨지기 쉬웠다.
## 결정
`..domain..` 패키지는 JDK만 사용한다. Bean은 `..infra.config`에서 선언된다.
## 결과
- 도메인 테스트는 순수 JUnit으로 실행된다.
- 추가 설정 클래스를 작성해야 한다. `ArchitectureTest`로 검증된다.
패키지에 붙어있는 메모의 경우, package-info.java가 적절한 장소인데, 에이전트가 디렉토리를 열면 바로 보인다:
/**
* 주문의 비즈니스 규칙. JDK만 사용하며, Spring은 사용하지 않음 (ADR 0003).
* 모든 금액은 BigDecimal, 단위 VND, 동 단위로 내림.
*/
package com.nonla.shop.order.domain;
8. 흔한 실수와 하지 말아야 할 경우
중복된 문서. 같은 규칙이 README.md, AGENTS.md, wiki에 기록돼 있다. 몇 개월 후, 세 곳이 세 가지 다른 말을 한다. 하나의 정보 소스를 선택하고 나머지 곳에서 그 소스를 가리키자.
가이드가 CI와 모순. AGENTS.md는 “mvn test 실행”이라고 하지만 CI는 추가로 integration test를 실행한다. 에이전트는 “완료”라고 보고하지만 PR은 여전히 빨간색이다.
에이전트에게 모호한 명령어로 작성. “깨끗한 코드를 작성하자”는 도움이 안 된다. “..service..의 public 메서드는 테스트가 있어야 한다”는 도움이 되고, 더 나은 것은 검증 가능하다는 것이다.
모든 것을 한 파일에. 수천 줄의 AGENTS.md는 문맥을 흐릿하게 한다. 중요한 정보가 묻힌다.
주의: 모든 레포가 이 전체 설정이 필요한 것은 아니다. 일회용 스크립트, 곧 폐기될 프로토타입, 또는 몇 백 줄의 코드만 있는 레포라면 실행 명령어가 있는
README.md면 충분하다. ArchUnit도 실제로 지키고 싶은 경계가 있는 여러 계층이나 모듈이 있을 때만 추가할 가치가 있다. 아직 존재하지 않는 것을 위해 문서를 작성하는 것은 유지할 것만 만드는 것이다.
반대로, 에이전트의 메모리도 여전히 쓸모가 있다. 설명할 때의 어조나 당신이 사용하는 편집기와 같은 개인적 선호도에 적합하다. 그런 것들은 레포에 속하지 않는다. 하지만 프로젝트의 규칙은 항상 레포에 있어야 한다.
9. 결론
에이전트는 더 좋은 메모리가 아니라 더 이해하기 쉬운 레포가 필요하다. 다음 방법들을 봤다:
- 짧은
AGENTS.md를 작성하되 정확한 명령어와 검증 방법이 있는 관례를 포함한다. - 모든 검증 단계를
scripts/verify.sh에 모으고 CI도 같은 스크립트를 사용하게 한다. - ArchUnit 테스트로 아키텍처 관례를 변환하되, 에러 메시지가 이유를 가리킨다.
@DisplayName이 있는 테스트를 비즈니스 사양으로 사용한다.- ADR과
package-info.java로 “왜”를 기록한다.
위의 모든 것은 먼저 인간에게 유용하다. 에이전트는 이를 더 이상 미룰 수 없다는 이유일 뿐이다. 이번 주 안에 작은 것부터 시작하자: verify.sh를 작성하고 AGENTS.md의 처음 다섯 줄을 작성하자.