AI 코딩 에이전트에게 리팩터링을 시키면 코드는 깔끔해집니다. 그런데 서비스가 깨집니다. 코드 품질과 시스템 정합성은 다른 문제이기 때문입니다.
결론부터 말하면 이렇습니다. AI 에이전트는 "코드 수준"의 리팩터링에는 뛰어나지만, "시스템 수준"의 리팩터링에서는 위험합니다. 문제는 능력이 아니라 맥락입니다. 에이전트는 코드를 읽을 수 있지만, 그 코드가 운영 환경에서 어떤 영향 반경(blast radius)을 갖는지는 모릅니다.
목차
- AI 에이전트가 잘하는 리팩터링
- AI 에이전트가 위험한 리팩터링 4가지
- 위험 1: 트랜잭션 경계를 모르는 리팩터링
- 위험 2: 숨겨진 부수효과 무시
- 위험 3: 테스트가 통과해도 로직이 틀린 경우
- 위험 4: 인프라 의존성 변경
- 안전하게 맡기는 원칙
- 맡겨도 되는 것
- 반드시 사람이 리뷰해야 하는 것
- 실전 체크리스트
- 요약
AI 에이전트가 잘하는 리팩터링
공정하게 시작하겠습니다. AI 에이전트가 확실히 잘하는 영역이 있습니다.
- 변수/메서드 이름 변경: 일관성 있는 네이밍 컨벤션 적용
- 메서드 추출: 긴 메서드에서 의미 단위를 분리
- 데드 코드 제거: 호출되지 않는 메서드, 사용되지 않는 import 정리
- 포맷팅 통일: 코드 스타일, 들여쓰기, 줄바꿈 규칙 적용
- 단순 구조 변환: DTO 변환, Builder 패턴 적용, Optional 체이닝
이런 작업들의 공통점은 영향 반경이 해당 파일 또는 해당 메서드 안에 한정된다는 것입니다. 에이전트가 컨텍스트를 완전히 파악할 수 있는 범위 안의 작업이라 실수할 여지가 적습니다.
문제는 여기서부터입니다.
AI 에이전트가 위험한 리팩터링 4가지
에이전트가 다음 네 가지 유형의 리팩터링을 수행할 때, 코드는 컴파일되고 테스트도 통과하지만 운영에서 장애가 발생할 수 있습니다.
| 위험 유형 | 핵심 문제 | 에이전트가 놓치는 것 |
|---|---|---|
| 트랜잭션 경계 변경 | 원자성 깨짐 | DB 커밋/롤백 범위 |
| 숨겨진 부수효과 무시 | 이벤트/알림 누락 | 암묵적 의존 관계 |
| 로직은 틀리고 테스트는 통과 | 잘못된 동치 변환 | 비즈니스 불변식 |
| 인프라 의존성 변경 | 연결 풀/캐시 오염 | 런타임 환경 차이 |
하나씩 실제 코드로 살펴보겠습니다.
위험 1: 트랜잭션 경계를 모르는 리팩터링
가장 흔하고 가장 치명적인 케이스입니다. 에이전트에게 "이 서비스 클래스가 너무 크니 분리해줘"라고 요청하면, 에이전트는 깔끔하게 메서드를 추출하고 새 서비스로 이동시킵니다. 문제는 @Transactional 경계가 함께 움직이지 않는다는 것입니다.
Before: 하나의 트랜잭션 안에서 동작
@Service
public class OrderService {
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(toEntity(request));
inventoryRepository.decrease(order.getProductId(), order.getQuantity());
paymentService.charge(order.getUserId(), order.getTotalPrice());
notificationService.sendOrderConfirmation(order);
}
}
주문 저장, 재고 차감, 결제, 알림이 하나의 트랜잭션 안에 있습니다. 결제가 실패하면 재고 차감도 롤백됩니다.
After: 에이전트가 서비스를 분리한 결과
@Service
public class OrderService {
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(toEntity(request));
inventoryService.decrease(order.getProductId(), order.getQuantity());
}
}
@Service
public class InventoryService {
@Transactional // 새로운 트랜잭션이 열린다
public void decrease(Long productId, int quantity) {
inventoryRepository.decrease(productId, quantity);
paymentService.charge(/* ... */); // 여기서 실패하면?
notificationService.sendOrderConfirmation(/* ... */);
}
}
에이전트는 코드를 깔끔하게 분리했습니다. 메서드 시그니처도 맞고, 컴파일도 됩니다. 하지만 InventoryService.decrease()에 새로운 @Transactional이 붙으면서 트랜잭션이 분리됩니다. 기본 전파 속성이 REQUIRED라 같은 트랜잭션에 참여할 수도 있지만, 에이전트가 REQUIRES_NEW로 바꾸거나, 비동기 호출로 전환하는 순간 원자성이 깨집니다.
에이전트는 @Transactional이라는 어노테이션의 문법은 알지만, 이 트랜잭션이 비즈니스적으로 왜 하나여야 하는지는 모릅니다.
위험 2: 숨겨진 부수효과 무시
코드에는 보이는 로직과 보이지 않는 로직이 있습니다. 에이전트는 보이는 로직만 추적합니다.
Before: 순서가 중요한 이벤트 발행
public void processPayment(Payment payment) {
payment.setStatus(PaymentStatus.COMPLETED);
paymentRepository.save(payment);
// 이 순서가 중요하다: 정산 -> 알림 -> 포인트
eventPublisher.publish(new PaymentCompletedEvent(payment));
eventPublisher.publish(new SettlementRequestEvent(payment));
eventPublisher.publish(new PointAccumulationEvent(payment.getUserId(), payment.getAmount()));
}
After: 에이전트가 "최적화"한 결과
public void processPayment(Payment payment) {
payment.setStatus(PaymentStatus.COMPLETED);
paymentRepository.save(payment);
List<DomainEvent> events = List.of(
new PaymentCompletedEvent(payment),
new PointAccumulationEvent(payment.getUserId(), payment.getAmount()),
new SettlementRequestEvent(payment) // 순서가 바뀌었다
);
events.forEach(eventPublisher::publish);
}
에이전트는 반복되는 eventPublisher.publish() 호출을 리스트로 묶었습니다. 코드는 더 깔끔해졌습니다. 하지만 이벤트 순서가 바뀌었습니다. SettlementRequestEvent가 PointAccumulationEvent 뒤로 밀렸습니다.
정산 시스템이 이벤트 순서에 의존하고 있었다면, 포인트가 먼저 적립되고 정산이 나중에 처리되면서 정산 금액이 꼬입니다. 단위 테스트는 이벤트 발행 여부만 검증하지 순서는 검증하지 않으니, 테스트는 통과합니다.
에이전트에게 이벤트 순서가 비즈니스 규칙이라는 사실은 코드 어디에도 명시되어 있지 않습니다. 주석이 없으면 사람도 놓칠 수 있는 부분인데, 에이전트는 거의 확실히 놓칩니다.
위험 3: 테스트가 통과해도 로직이 틀린 경우
에이전트의 리팩터링이 위험한 진짜 이유는, "테스트가 통과한다"는 사실이 안전의 증거가 되지 않기 때문입니다.
Before: 할인 계산 로직
public BigDecimal calculateDiscount(Order order) {
BigDecimal discount = BigDecimal.ZERO;
if (order.getTotalPrice().compareTo(new BigDecimal("50000")) >= 0) {
discount = order.getTotalPrice().multiply(new BigDecimal("0.05"));
}
if (order.getTotalPrice().compareTo(new BigDecimal("100000")) >= 0) {
discount = order.getTotalPrice().multiply(new BigDecimal("0.10"));
}
if (order.isFirstOrder()) {
discount = discount.add(new BigDecimal("3000"));
}
return discount;
}
두 개의 if문이 의도적으로 독립적입니다. 5만 원 이상이면 5%, 10만 원 이상이면 10%로 덮어씁니다. 누적이 아니라 교체입니다.
After: 에이전트가 "정리"한 결과
public BigDecimal calculateDiscount(Order order) {
BigDecimal price = order.getTotalPrice();
BigDecimal discount = BigDecimal.ZERO;
if (price.compareTo(new BigDecimal("100000")) >= 0) {
discount = price.multiply(new BigDecimal("0.10"));
} else if (price.compareTo(new BigDecimal("50000")) >= 0) {
discount = price.multiply(new BigDecimal("0.05"));
}
if (order.isFirstOrder()) {
discount = discount.add(new BigDecimal("3000"));
}
return discount;
}
에이전트는 두 개의 독립적인 if를 if-else if로 바꿨습니다. 논리적으로는 동일한 결과를 내는 것처럼 보입니다. 실제로 대부분의 입력에서 결과가 같습니다.
하지만 원래 코드의 의도는 "조건을 순차적으로 평가하면서 마지막에 매칭된 값으로 덮어쓰기"입니다. 만약 나중에 7만 원 구간이 추가된다면, 원래 구조에서는 if문을 하나 더 추가하면 됩니다. 에이전트가 바꾼 구조에서는 else if 체인의 순서를 신중하게 조정해야 합니다.
테스트는 현재 존재하는 케이스(5만, 10만, 첫 주문)만 검증합니다. 두 코드 모두 이 테스트를 통과합니다. 하지만 확장성에 대한 가정이 바뀌었습니다. 이것은 테스트가 잡을 수 없는 영역입니다.
위험 4: 인프라 의존성 변경
에이전트가 코드만 보고 리팩터링하면, 코드 바깥의 인프라 의존성을 건드릴 수 있습니다.
Before: 캐시를 직접 사용하는 서비스
@Service
public class ProductService {
@Cacheable(value = "products", key = "#productId")
public Product getProduct(Long productId) {
return productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}
After: 에이전트가 "캐시 로직 분리"를 한 결과
@Service
public class ProductService {
private final ProductCacheService cacheService;
public Product getProduct(Long productId) {
return cacheService.getCachedProduct(productId);
}
public Product updateProduct(Product product) {
Product saved = productRepository.save(product);
cacheService.evictProduct(saved.getId());
return saved;
}
}
@Service
public class ProductCacheService {
@Cacheable(value = "products", key = "#productId")
public Product getCachedProduct(Long productId) {
return productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
@CacheEvict(value = "products", key = "#productId")
public void evictProduct(Long productId) {
// evict only
}
}
코드만 보면 문제가 없어 보입니다. 하지만 Spring의 @Cacheable은 프록시 기반입니다. 같은 클래스 내부 호출에서는 캐시가 작동하지 않는다는 제약이 있고, 에이전트는 이 점을 인지해서 분리한 것일 수도 있습니다.
진짜 문제는 다른 곳에 있습니다. updateProduct에서 save와 evictProduct 사이에 다른 스레드가 getProduct를 호출하면, 이전 데이터가 캐시에 다시 올라갈 수 있습니다. 원래 코드에서는 @CacheEvict가 메서드 실행과 원자적으로 동작했지만, 분리된 코드에서는 두 개의 별도 호출이 되면서 경합 조건(race condition) 이 생겼습니다.
에이전트는 Spring 어노테이션의 문법은 완벽하게 이해하지만, 멀티스레드 환경에서의 실행 순서까지는 추론하지 못합니다.
안전하게 맡기는 원칙
네 가지 위험을 보면 패턴이 보입니다. 에이전트가 위험해지는 지점은 항상 코드 바깥의 맥락이 개입하는 곳입니다. 이 패턴을 기준으로 위임 규칙을 만들 수 있습니다.
맡겨도 되는 것
| 작업 | 이유 |
|---|---|
| 이름 변경 (rename) | 영향 반경이 IDE가 추적 가능한 범위 |
| 메서드 추출 (같은 클래스 내) | 트랜잭션/프록시 경계를 넘지 않음 |
| 데드 코드 제거 | 호출이 없으므로 부수효과 없음 |
| DTO 변환, Builder 적용 | 데이터 구조만 변경, 로직 불변 |
| 테스트 코드 리팩터링 | 프로덕션 영향 없음 |
반드시 사람이 리뷰해야 하는 것
| 작업 | 확인 포인트 |
|---|---|
| 클래스 간 메서드 이동 | 트랜잭션 전파 속성이 바뀌는가 |
| 이벤트/메시지 관련 변경 | 발행 순서, 구독자 영향 |
| 조건문 구조 변경 | 비즈니스 불변식이 유지되는가 |
| 캐시/커넥션 풀 관련 변경 | 경합 조건, 리소스 생명주기 |
| DB 스키마에 영향 주는 변경 | 마이그레이션, 하위 호환성 |
실전 체크리스트
에이전트의 리팩터링 PR을 리뷰할 때 이 세 가지를 확인합니다.
- 경계를 넘었는가: 변경이 단일 메서드/클래스 안에 머무는가, 아니면 서비스 경계를 넘는가
- 암묵적 계약이 있는가: 이벤트 순서, 캐시 정책, 트랜잭션 범위 등 코드에 명시되지 않은 규칙이 있는가
- 테스트가 충분한가: 기존 테스트가 변경된 동작까지 커버하는가, 아니면 우연히 통과하는가
이 세 질문 중 하나라도 "아니오"가 나오면, 그 리팩터링은 에이전트에게 맡기되 머지는 사람이 해야 합니다.
요약
- AI 에이전트는 rename, extract, dead code 제거 등 코드 수준 리팩터링에 뛰어나다
- 트랜잭션 경계, 이벤트 순서, 캐시 정합성 등 시스템 수준의 맥락은 에이전트의 사각지대다
- 테스트 통과가 안전의 증거가 아니다 - 비즈니스 불변식은 테스트로 검증되지 않는 경우가 많다
- 핵심 판단 기준은 "영향 반경"이다 - 단일 파일 안이면 맡기고, 경계를 넘으면 리뷰한다
- 에이전트에게 리팩터링을 시키되, 머지 결정은 반드시 사람이 한다
관련글:
- AI 코딩 에이전트 설계 원리 7가지 — 인간 감독 루프가 필요한 이유
- 감독 가능한 자동화가 중요한 이유 — 완전 자동의 함정
'AI 코딩 에이전트' 카테고리의 다른 글
| Claude Code 소스코드에서 발견된 비밀 방어 시스템 3가지 (0) | 2026.04.07 |
|---|---|
| Claude Code Buddy 리롤하는 법: buddy-pick으로 원하는 컴패니언 직접 고르기 (0) | 2026.04.07 |
| Blue-Green 무중단 배포를 도입해도 사고가 나는 이유: 실패 패턴 5가지 (0) | 2026.04.04 |
| Kafka 동기화 트랜잭션에서 실제로 자주 터지는 장애 5가지 (0) | 2026.04.04 |
| AI 코딩 에이전트 보안 사고는 어떻게 터지는가: 권한, 토큰, 쉘의 삼중주 (0) | 2026.04.04 |
