← all posts
DEV 2026.05.02 · 11 min read Intermediate

레거시를 죽이지 않고 교체하는 법

진단 없는 리팩터링이 왜 실패하는지부터 Strangler Fig로 도메인·인프라 레이어를 단계적으로 분리해 테스트 속도를 10배 높이는 과정까지, 아키텍처 전환의 현실을 추적한다.


1,247줄짜리 OrderService가 있다. 의존성이 8개, 메서드가 27개, 테스트 커버리지는 12%다. “이건 고쳐야 한다”는 데 팀 전체가 동의한다. 그런데 어디서부터 시작해야 하는가?

느낌이 아니라 숫자로 진단한다

“코드가 지저분하다”는 느낌만으로 리팩터링을 시작하면 세 가지를 모른다 — 무엇을 바꿔야 하는지, 어디서 시작해야 하는지, 얼마나 걸리는지. 진단 도구는 네 가지로 충분하다.

의존성 그래프는 Fan-out(클래스가 아는 것의 수)으로 위험도를 드러낸다. OrderService의 Fan-out이 8이라는 뜻은 이 클래스를 바꿀 때 8개 대상이 영향받을 수 있다는 뜻이다. 코드 메트릭은 클래스 크기(300줄 경고, 500줄 위험), 순환 복잡도, 결합도(Ce)를 수치화한다. 테스트 커버리지./gradlew test jacocoTestReport로 측정한다 — 핵심 시나리오 통합 테스트가 80% 미만이면 안전망이 없는 것이다. 변경 이력git log --since="6 months ago" --name-only로 가장 자주 바뀐 파일을 찾는다.

우선순위 공식은 단순하다:

위험도 = 변경 빈도 × 테스트 없음 × 의존성 수

OrderService는 145회 변경에 테스트 15%, 의존성 8개다. 1순위다.

진단 없는 리팩터링의 함정

“전체를 Hexagonal로 바꾸겠다”고 선언하면, 3개월 후 레거시와 절반짜리 새 코드가 혼재한 최악의 상태가 된다. 리팩터링 브랜치는 영원히 머지되지 않는다.

Strangler Fig — 레거시를 살려두면서 교체한다

빅뱅 전환이 실패하는 이유는 완성될 때까지 이익이 없고 위험이 한곳에 집중되기 때문이다. Strangler Fig는 반대로 작동한다. 레거시를 그대로 두고 새 구조를 옆에 세운 뒤, 기능 단위로 교체하며 점진적으로 레거시가 사라지게 한다.

구체적 순서는 5단계다.

  • Phase 0: @SpringBootTest + Testcontainers로 핵심 시나리오 5-10개를 통합 테스트로 커버한다. 이것이 리팩터링 내내 “기능이 깨지지 않았다”는 증거가 된다.
  • Phase 1: OrderRepository 인터페이스를 domain/port/out/으로 옮기고, JpaOrderRepository가 이를 구현하게 한다. Service는 인터페이스에만 의존한다.
  • Phase 2: Service에 흩어진 비즈니스 로직을 Domain 객체로 이동한다. OrderService.placeOrder() 안의 최소 금액 검증과 총액 계산이 Order.place()Order.calculateTotal()이 된다.
  • Phase 3: 결제 SDK, Kafka, 메일을 PaymentPort, OrderEventPublisher 같은 Port 인터페이스로 추상화하고, Adapter가 구현하게 한다.
  • Phase 4: PlaceOrderUseCase, CancelOrderUseCase 인터페이스를 도입해 Controller가 Service 구체 클래스를 모르게 한다.

각 Phase는 “구현 → 기존 통합 테스트 통과 → 단위 테스트 추가 → PR → 배포” 사이클로 끝난다. 배포 가능한 단위를 유지하는 것이 핵심이다. 중간에 요구사항이 바뀌어도 완성된 Phase는 운영 중이다.

도메인과 인프라를 분리하면 무엇이 달라지는가

Phase 2와 3이 완료되면 PlaceOrderService는 JPA도, Kafka도, 결제 SDK도 모른다. Port 인터페이스 세 개만 안다. 이 순간부터 단위 테스트 방식이 바뀐다.

// InMemory Adapter로 Spring 없이 직접 생성
private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
private final InMemoryPaymentPort paymentPort = new InMemoryPaymentPort();
private final PlaceOrderService sut = new PlaceOrderService(orderRepository, paymentPort);

@Test
void 결제_실패_시_주문_저장_안_됨() {
    paymentPort.willFail(new PaymentFailedException("한도 초과"));

    assertThatThrownBy(() -> sut.placeOrder(validCommand()))
        .isInstanceOf(PaymentFailedException.class);

    assertThat(orderRepository.count()).isEqualTo(0);
}

@SpringBootTest가 없다. DB도 없다. Kafka도 없다. 실행 시간은 0.01초다.

Domain 객체 테스트는 더 빠르다.

@Test
void 최소금액_미달_예외() {
    Order order = createOrder(orderLine("item-1", 1, BigDecimal.valueOf(500)));
    assertThatThrownBy(order::place)
        .isInstanceOf(MinOrderAmountException.class);
}
// 실행 시간: 0.001초

@SpringBootTest가 30초라면, 이 테스트는 30,000배 빠르다.

트레이드오프

수치로 정직하게 보자.

지표BeforeAfter
전체 테스트 실행43분4분
단위 테스트 비율8%72%
OrderService 크기1,247줄198줄
Service Fan-out83
JPA→MongoDB 교체 파일 수12개2개
파일 수 (전체)35개78개

파일 수는 2.2배 늘었다. 매핑 코드(Domain ↔ JPA Entity)가 새로 생긴다. 처음 2-3개월은 개발 속도가 떨어진다. 팀 전체가 Port/Adapter 개념을 이해해야 한다.

@Transactional 안에서 Kafka를 발행하면 DB 롤백 시 이미 발행된 메시지를 되돌릴 수 없다. 이 문제는 Outbox Pattern으로 해결하지만, Port 추상화만으로는 해결되지 않는다. InMemory 테스트가 통과해도 JPA의 유니크 제약이나 LAZY 로딩 예외는 재현하지 못한다. @DataJpaTest + Testcontainers로 Adapter를 독립적으로 검증하는 테스트가 별도로 필요하다.

정리

  • 진단 없는 리팩터링은 중요하지 않은 곳을 바꾼다. 위험도 = 변경 빈도 × 테스트 없음 × 의존성 수 공식으로 순서를 정한다.
  • Strangler Fig는 각 Phase가 독립 배포 가능하기 때문에 빅뱅 전환보다 안전하다. 중간에 요구사항이 바뀌어도 완성된 부분은 운영된다.
  • 도메인 로직을 Domain 객체로, 인프라를 Port/Adapter로 분리하면 단위 테스트가 Spring 없이 동작한다. 테스트가 0.001초면 TDD가 가능해진다.
  • 파일 수 증가, 매핑 코드, 초기 학습 비용은 실제 비용이다. 팀이 이 비용을 인지하고 동의한 뒤 시작해야 한다.

아키텍처 전환의 성공 기준은 단위 테스트 비율 70% 이상, CI 5분 이내, 새 기능 추가 시 기존 UseCase 파일 수정 없음, 외부 시스템 교체 시 Domain 코드 변경 없음이다.