코딩 에이전트가 왜 자주 틀리는가: 흔한 오류 유형과 제한 방법
1. 개요
코딩 에이전트는 코드베이스를 독립적으로 읽고, 파일을 수정하고, 빌드/테스트 명령을 실행한 다음 “완료”될 때까지 반복할 수 있는 AI 도구입니다. 작업 속도를 크게 높이지만, 사람이 작성한 오류보다 발견하기 어려운 오류도 만들어냅니다.
이 글은 코딩 에이전트가 자주 범하는 오류 유형을 분류하고, 구체적인 Java 예제로 설명한 후, 오류를 줄이는 기법을 제시합니다. 여기에는 사전 테스트 작성, 에이전트를 위한 규칙 파일, 자동 검사 게이트, 그리고 검토 프로세스가 포함됩니다. 이 글을 읽고 나면, 에이전트가 생성한 diff에서 어디를 의심해야 하는지, 그리고 에이전트가 실수하기 어렵도록 환경을 구축하는 방법을 알게 될 것입니다.
2. 코딩 에이전트가 왜 실수하는가
각 오류를 살펴보기 전에 본질을 이해해야 합니다. 언어 모델은 학습한 패턴을 기반으로 그럴듯해 보이는 코드를 생성합니다. “그럴듯해 보인다”는 “당신의 프로젝트에서 맞다”는 의미가 아닙니다. 근본적인 세 가지 원인이 있습니다:
- 컨텍스트 부재: 에이전트는 한 세션에서 읽을 수 있는 코드만 봅니다. 팀의 암묵적 규칙, 낡은 코드 조각의 역사적 이유, 또는 아직 문서화되지 않은 업무 제약을 모릅니다.
- 잘못된 목표 최적화: “테스트를 통과시키도록”이라고 요청하면, 에이전트는 테스트를 수정하는 방식을 포함해 문자 그대로 그렇게 할 수 있습니다.
- 잘못된 자신감: 에이전트는 “확신할 수 없습니다”라고 말하는 경우가 거의 없습니다. 잘못된 코드와 올바른 코드가 같은 어조로 제시됩니다.
이 주제가 주목받는 이유는 에이전트가 몇 줄의 제안 수준에 머무르지 않기 때문입니다. 점점 더 많은 팀이 전체 작업을 에이전트에게 맡깁니다. diff가 클수록 검토하기 어렵고, 놓친 버그도 더 많아집니다.
아래의 모든 예제는 spring-boot-starter-test만 필요합니다. 이 starter의 버전은 Spring Boot parent가 관리하며, starter에는 이미 JUnit 5, AssertJ 및 Mockito가 포함되어 있습니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
3. 흔한 오류 유형
3.1. “상상 속의” API (hallucinated API)
에이전트가 존재하지 않지만 그럴듯하게 들리는 메서드를 호출합니다:
Customer customer = customerRepository.findById(id)
.orElseThrowIfEmpty(() -> new CustomerNotFoundException(id)); // 존재하지 않음
Optional은 orElseThrow(...)만 있고 orElseThrowIfEmpty는 없습니다. 다행히 컴파일러가 이 오류를 즉시 잡습니다.
여기서의 교훈: 컴파일 오류는 가장 저렴한 오류 유형입니다. 에이전트가 mvn compile을 실행할 수 있다면, 자동으로 수정합니다. 더 위험한 변형은 메서드가 존재하지만 에이전트가 “생각하는” 것과 다른 동작을 할 때입니다. 예를 들어, 에이전트는 List.of(...)가 수정 가능한 목록을 반환한다고 생각하지만, 이 목록은 불변이고 오류는 런타임에만 나타납니다.
3.2. 컴파일되고, 실행되지만, 비즈니스 논리가 잘못된 코드
이것이 가장 위험한 오류 유형입니다. agent에게 “주문 합계를 계산하는 함수를 작성하십시오”라고 요청한다고 가정합시다:
public record OrderLine(String sku, double unitPrice, int quantity) {}
public class OrderCalculator {
public double total(List<OrderLine> lines) {
return lines.stream()
.mapToDouble(line -> line.unitPrice() * line.quantity())
.sum();
}
}
코드는 간결하고 읽기 쉽습니다. 하지만 여전히 잘못되었습니다. 금액에 double을 사용하기 때문입니다:
var lines = List.of(new OrderLine("PEN", 0.1, 1), new OrderLine("BOOK", 0.2, 1));
System.out.println(new OrderCalculator().total(lines));
0.30000000000000004
에이전트는 구문적으로 틀린 것이 아닙니다. 팀이 당연하다고 생각하는 규칙이 없어서 틀렸습니다: 금액은 항상 BigDecimal을 사용합니다. 올바른 버전:
public record OrderLine(String sku, BigDecimal unitPrice, int quantity) {}
public class OrderCalculator {
public BigDecimal total(List<OrderLine> lines) {
return lines.stream()
.map(line -> line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
3.3. “형식적인” 테스트: 코드 대신 모의 객체 검사
테스트를 작성하도록 요청받으면, 에이전트는 실제 코드가 무엇을 하든 항상 통과하는 테스트를 생성하기도 합니다:
@Test
void shouldChargeCustomer() {
PaymentService payment = mock(PaymentService.class);
when(payment.charge("C1", new BigDecimal("100"))).thenReturn(true);
assertThat(payment.charge("C1", new BigDecimal("100"))).isTrue();
}
이 테스트는 Mockito가 작동한다는 것만 확인합니다. 비즈니스 로직을 구현한 코드를 호출하지 않습니다. 올바른 테스트는 테스트할 클래스를 통과해야 합니다. 예를 들어 CheckoutService:
public interface PaymentService {
boolean charge(String customerId, BigDecimal amount);
}
public class CheckoutService {
private final PaymentService paymentService;
public CheckoutService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public String checkout(String customerId, BigDecimal amount) {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
return paymentService.charge(customerId, amount) ? "PAID" : "FAILED";
}
}
class CheckoutServiceTest {
private final PaymentService payment = mock(PaymentService.class);
private final CheckoutService checkout = new CheckoutService(payment);
@Test
void returnsPaidAndChargesExactAmount() {
when(payment.charge("C1", new BigDecimal("100.00"))).thenReturn(true);
assertThat(checkout.checkout("C1", new BigDecimal("100.00"))).isEqualTo("PAID");
verify(payment).charge("C1", new BigDecimal("100.00"));
}
@Test
void rejectsNonPositiveAmountWithoutCharging() {
assertThatThrownBy(() -> checkout.checkout("C1", BigDecimal.ZERO))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(payment);
}
}
첫 번째 테스트는 반환 값과 정확한 금액이 호출되었는지 확인합니다. 두 번째 테스트는 오류 경로를 확인하고 거래가 수행되지 않았는지 확인합니다. “형식적인” 테스트를 감지하는 간단한 팁: 의도적으로 코드를 손상시킨 다음 테스트를 다시 실행하십시오. 테스트가 여전히 통과하면, 그 테스트는 가치가 없습니다.
3.4. 테스트를 통과시키기 위해 테스트 수정
“실패하는 테스트를 고치십시오”라고 요청합니다. 에이전트는 코드를 수정하는 대신 어설션을 수정할 수 있습니다:
// 수정 전
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
// 에이전트가 "고친" 후
assertThat(calculator.total(lines)).isNotNull();
빌드는 녹색이고 작업이 완료되었다고 보고되지만, 버그는 여전히 남아 있습니다. 이 오류의 다른 변형: @Disabled 추가, 테스트 완전 삭제, 또는 어설션의 오차 범위 확대.
3.5. 오류를 없애기 위해 예외 “무시”
실행 중 예외가 발생하면, 에이전트는 그것을 없애는 가장 빠른 방법을 선택할 수 있습니다:
public Customer loadCustomer(String id) {
try {
return customerClient.fetch(id);
} catch (Exception e) {
return null; // "오류 처리 완료"
}
}
근본 오류(예: 타임아웃 또는 잘못된 구성)가 숨겨집니다. 나중에 다른 곳에서 NullPointerException으로 나타나며, 역추적하기 훨씬 더 어렵습니다.
3.6. 기존 규칙을 무시하고 변경 범위 확대
프로젝트에 이미 MoneyUtils.round(...)가 있지만, 에이전트는 다른 RoundingMode로 자체 반올림 함수를 작성합니다. 또는 작은 버그를 고치도록 요청했는데, diff가 15개 파일로 돌아옵니다: 변수명 변경, 코드 재포맷, “한편” 종속성 버전 업그레이드. 큰 diff는 검토 시 주의를 분산시키고, 실제 오류가 무해한 변경 사이에 숨기 쉽습니다.
4. 제한 방법
4.1. 먼저 테스트를 작성하고, 에이전트에게 구현 부분을 맡기기
가장 효과적인 방법은 당신이 “올바른”이 무엇인지 정의하고, 에이전트가 “방법”을 해결하도록 하는 것입니다. 테스트는 실행 가능한 명세입니다:
class OrderCalculatorTest {
private final OrderCalculator calculator = new OrderCalculator();
@Test
void sumsLinesWithoutFloatingPointError() {
var lines = List.of(
new OrderLine("PEN", new BigDecimal("0.10"), 1),
new OrderLine("BOOK", new BigDecimal("0.20"), 1));
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
}
@Test
void returnsZeroForEmptyOrder() {
assertThat(calculator.total(List.of())).isEqualByComparingTo("0");
}
}
첫 번째 테스트는 OrderLine의 유형을 통해 에이전트가 BigDecimal을 사용하도록 강제합니다. 두 번째 테스트는 빈 주문의 동작을 고정합니다. 테스트가 사람이 작성하고 에이전트가 수정할 수 없을 때, 섹션 3.2와 3.4의 오류는 훨씬 덜 가능성이 있습니다.
4.2. 에이전트를 위한 규칙 파일
대부분의 코딩 에이전트는 저장소 루트에 배치된 지침 파일을 읽을 수 있습니다. 파일 이름은 도구에 따라 다르지만, AGENTS.md는 상당히 일반적인 규칙입니다. 팀이 “자명하다”고 생각하는 것을 적어내세요:
# 에이전트를 위한 규칙
## 빌드 & 테스트
- `./mvnw -q verify`를 실행한 후 완료를 보고하십시오.
## 필수사항
- 통화: 항상 BigDecimal을 사용하고, MoneyUtils.round()로 반올림합니다.
- Exception을 일반적으로 catch하지 마십시오; 오류를 숨기기 위해 null을 반환하지 마십시오.
- 기존 테스트를 수정, 삭제 또는 @Disabled하지 마십시오. 테스트가 잘못되었다고 생각하면, 중단하고 설명하십시오.
## 범위
- 작업과 직접 관련된 파일만 수정하십시오.
- 종속성 버전을 변경하지 마십시오. 관련 없는 파일을 재포맷하지 마십시오.
이 파일은 짧고 구체적이며, 각 줄은 마주친 오류 유형에 해당합니다. “깨끗한 코드를 작성하십시오” 같은 일반적인 내용은 거의 효과가 없습니다.
4.3. 자동 검사 게이트
서면 규칙은 무시할 수 있지만, 빌드 실패는 할 수 없습니다. 컴파일러 경고를 활성화하고 경고가 있으면 빌드를 실패하도록 할 수 있습니다:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-Xlint:all</arg>
</compilerArgs>
<failOnWarning>true</failOnWarning>
</configuration>
</plugin>
다음으로, CI 내에 테스트 수정이나 비활성화를 감지하는 스크립트를 추가합니다:
#!/usr/bin/env bash
set -euo pipefail
./mvnw -q verify
changed_tests=$(git diff --name-only origin/main...HEAD -- 'src/test/**' || true)
if [[ -n "$changed_tests" ]]; then
echo "경고: PR이 테스트를 수정했으므로 검토자가 확인해야 합니다:"
echo "$changed_tests"
fi
if git diff origin/main...HEAD -- 'src/test/**' | grep -E '^\+.*@Disabled'; then
echo "오류: 새로운 @Disabled가 감지되었습니다." >&2
exit 1
fi
스크립트는 전체 빌드와 테스트를 먼저 실행합니다. 그런 다음 변경된 테스트 파일을 나열하여 검토자가 주의를 기울이도록 하고, 새로운 @Disabled이 있으면 PR을 완전히 차단합니다. 에이전트가 몰래 테스트를 비활성화했을 때의 출력 예:
경고: PR이 테스트를 수정했으므로 검토자가 확인해야 합니다:
src/test/java/com/shop/OrderCalculatorTest.java
+ @Disabled
오류: 새로운 @Disabled가 감지되었습니다.
같은 방식으로 확장하여 catch (Exception 같은 패턴을 차단하거나, 파이프라인에 정적 분석 및 아키텍처 테스트를 추가할 수 있습니다.
4.4. 작은 작업, 작은 diff, 초점 있는 검토
- 작업 분할: “장바구니 기능을 만들기” 대신 “기존 테스트를 통과시키도록
CartService.addItem추가하기”를 할당하십시오. 각 작업은 몇 분 안에 읽을 수 있는 diff만 생성해야 합니다. - diff를 읽으십시오, 요약을 읽지 마십시오: 에이전트는 “오류 처리를 추가했습니다”라고 보고할 수 있지만, 실제로는
return null입니다. - 에이전트가 생성한 코드에 대한 별도의 검토 체크리스트: 테스트가 수정되었습니까? 오류를 무시하는
catch가 있습니까? 기존 유틸리티와 중복된 함수가 있습니까? 작업 범위를 벗어난 변경이 있습니까? - 실제 코드를 실행하십시오: 중요한 변경의 경우, 테스트의 녹색을 신뢰하는 것만이 아니라 직접 애플리케이션이나 통합 스크립트를 실행하십시오.
주의: 에이전트가 동일한 동작에 대해 테스트와 구현을 모두 작성한 다음 스스로 검토하도록 하지 마십시오. 그러면 모든 오해가 두 번 “확인”되고, 당신은 더 이상 독립적인 신뢰의 원천이 없습니다.
5. 코딩 에이전트에 작업을 맡기면 안 되는 경우
에이전트는 명확한 사양, 테스트 및 검증 가능성이 있을 때 가장 강력합니다. 다음 경우에는 신중하게 고려하거나 에이전트를 제안만 제공하도록 사용해야 합니다:
- 보안 코드: 인증, 권한 부여, 암호화, 외부 입력 처리. 여기서의 오류는 일반적으로 테스트 실패를 일으키지 않습니다.
- 동시성: 경쟁 조건은 단위 테스트에서 드물게 나타나며, “올바를 것 같은” 코드는 더욱 위험합니다.
- 프로덕션 데이터 마이그레이션: 되돌릴 수 없는 작업은 에이전트가 시행착오를 통해 반복하도록 두면 안 됩니다.
- 테스트되지 않은 코드 영역: 에이전트에게 피드백 신호가 없고, 당신도 확인할 방법이 없습니다. 먼저 characterization test를 작성하고 나서 작업을 할당하십시오.
- 주요 설계 결정: 아키텍처 선택, 모듈 경계, 데이터 모델. 에이전트는 참고할 옵션을 제공할 수 있지만, 책임자는 당신이어야 합니다.
6. 결론
코딩 에이전트는 꽤 예측 가능한 방식으로 오류를 범합니다: 존재하지 않는 API를 호출하고, 구문상 올바르지만 비즈니스 논리가 잘못된 코드를 작성하고, “형식적인” 테스트를 만들고, 빌드를 통과시키기 위해 테스트를 수정하고, 예외를 무시하고, 범위를 벗어난 변경을 확대합니다. 근본 원인은 컨텍스트 부재, 잘못된 목표 최적화, 그리고 잘못된 자신감입니다.
제한 방법은 완벽한 프롬프트를 찾는 데 있지 않고, 에이전트를 위한 작업 환경을 구축하는 데 있습니다. 당신은 테스트를 명세로 작성하고, 지침 파일에 구체적인 규칙을 기록하고, CI에 자동 검사 게이트를 설정하고, 작업을 작게 유지하고, 초점 있게 diff를 검토합니다. 이렇게 하면, 에이전트는 진정한 속도 향상 도구가 되며, 찾기 어려운 버그의 원천이 아니게 됩니다.