테스트 없는 레거시는 운영 트래픽으로 검증한다 — Shadow 비교의 원리와 20%라는 값
![]()
레거시를 옮길 때 진짜 무서운 건 코드가 아니에요. 그 코드를 누가 어디서 부르는지 모른다는 사실이죠. 테스트가 있으면 케이스를 적어두면 되는데, 테스트를 쓸 수 없는 레거시에서는 케이스 목록 자체가 없습니다. 카카오페이손해보험 팀이 겪은 상황이 정확히 이거예요. 심사 기능이 너무 뒷단에서 호출돼서 케이스 전체를 파악하기 어렵고, QA 크루에게 부탁하기엔 다들 업무가 밀려 있고, 그런데 Spring Boot를 올려야 하는 상황. 팀이 유지보수하는 레포지토리는 2024년 11월에 나온 2.6을 쓰고 있었고, 글 검토 시점인 2026년 8월 31일 기준 최신은 4.1이었어요. 낮은 버전 때문에 실제로 맞은 문제도 있었습니다. @TransactionalEventListener에서 에러가 debug 레벨로만 찍히는 이슈, JPATraversableResolver가 WeakHashMap으로 JPA 엔티티 여부를 확인하다 생기는 동시성 이슈. 그런데 contract 레포지토리가 서버 실행 시 product 모듈에 의존하고 product도 Spring bean으로 돌아가니, Boot 버전 차이가 나는 순간 버전업 자체가 막혀요. product에 의존하는 레거시 레포지토리가 N개라 건드리는 영향도 컸고요. 그래서 버전업보다 먼저, product 모듈을 안전하게 걷어내는 게 목표가 됐습니다.
이 팀이 고른 답은 "케이스를 적는 대신 운영 트래픽을 테스트로 쓴다"였어요. 사용자가 실제로 보내는 요청마다 기존 로직과 신규 로직을 둘 다 돌리고, 응답은 기존 것을 주고, 둘의 결과가 같은지만 뒤에서 비교하는 방식. 원문은 이걸 Strangler 패턴을 차용한 AOP 기반 Shadow 비교라고 불러요. 스트랭글러 무화과나무가 숙주 나무를 서서히 감싸 대체하듯, 레거시를 한 번에 교체하지 않고 새 코드를 조금씩 덧붙여 기능을 옮기는 전략인데, Martin Fowler의 Strangler Fig Application에서 온 이름이에요. 저는 이 글을 코드 트릭으로 읽지 않았어요. QA 없이 개발자 혼자 레거시를 옮길 수 있는 안전장치를 만들겠다는 조직적 결정이고, 그 결정의 값이 숫자로 나와 있는 드문 글이에요. 원리부터 다시 풀고, 그 값이 싼지 따져볼게요.
두 로직을 겹치는 방법이 왜 하필 AOP인가
기존 메서드 하나에 애노테이션을 붙입니다. 어떤 빈의 어떤 메서드와 비교할지, 파라미터 타입은 뭔지, 원본 파라미터 중 몇 번째를 넘길지를 애노테이션 값으로 적어요.
```java @ShadowInquiry( compareBean = FindDuplicationCoverageNewService.class, compareMethodName = "getDuplicatedCoverages", paramTypes = {String.class, String.class}, paramIndexes = {0, 1} ) public DuplicatedCoverageDto getDuplicatedCoverages(String planNo, String customerNo) ```
기존 메서드 본문은 한 줄도 안 건드려요. 그게 이 설계의 요점이고요. Aspect가 `@AfterReturning`으로 기존 로직이 끝난 뒤에 끼어들고, 신규 로직은 `@Async`로 다른 스레드에서 돌립니다. 그래서 사용자 응답 시간엔 원칙적으로 영향이 없고, 신규 로직이 예외를 던져도 사용자에겐 아무 일도 안 일어나요. 레거시를 옮길 때 첫 번째 원칙이 "옮기는 동안 기존 동작을 바꾸지 않는다"인데, AOP는 그 원칙을 코드 구조로 강제하는 도구예요. if 문으로 분기했다면 언젠가 누군가 그 분기를 잘못 건드립니다.

그림에서 봐야 할 건 화살표 개수예요. 요청 하나가 들어오면 로직이 두 번 돕니다. 이게 뒤에 나오는 20%의 출처고요.
비교 로직이 실제로 내리는 결정 세 가지
"결과가 같은지 비교한다"는 말은 쉬운데, 실제로는 세 가지 결정이 숨어 있어요.
첫째, 얼마나 자주 비교할 것인가. 이 팀은 DB에 숫자 하나를 저장해두고, 요청마다 1부터 100 사이 난수를 뽑아서 그 숫자보다 크면 비교를 건너뜁니다. 실행률을 배포 없이 런타임에 조절하는 손잡이예요. 저는 이 손잡이가 AOP보다 중요하다고 봐요. 부하가 튀면 0으로 내리면 끝이고, 롤백이 필요 없거든요. 비교 메서드를 찾는 리플렉션도 매번 하지 않고 ConcurrentHashMap에 캐싱해두는데, 비동기라도 요청마다 도는 코드라서 이런 데서 아끼지 않으면 뒤의 부하 수치가 달라졌을 거예요.
둘째, 무엇을 같다고 볼 것인가. 두 응답 객체를 toString으로 비교하면 레거시 DTO에 toString을 구현해야 하는데, 그 순간 "건드리지 않는다"는 전제가 깨져요. 그래서 Jackson으로 JSON 트리를 만들어 통째로 비교합니다. 여기서 바로 함정이 나와요. BigDecimal은 100.0과 100.000을 다른 값으로 보고, 컬렉션은 순서가 다르면 다르고, 필드 순서도 직렬화 순서에 따라 달라집니다. 이 팀은 커스텀 ObjectMapper로 소수점 정규화, 컬렉션 정렬, 필드 알파벳 정렬 세 가지를 잡았어요. 원문 스스로 "더 많은 케이스가 있지만 일단 현재는 위 케이스만"이라고 적어뒀는데, 이 한 줄이 이 방식의 운영 리스크를 요약해요. 시간값, 난수 ID, 정렬 안 된 중첩 객체처럼 정규화 안 된 필드는 전부 불일치 알림이 되고, 알림이 쌓이면 사람이 안 봅니다.
셋째, 다르면 어떻게 할 것인가. 같으면 아무것도 안 하고, 다르면 DB에 저장하고 알림을 보내요. 여기까지가 이 글이 만든 것의 전부예요. 신규 로직으로 실제 전환하는 스위치는 이 글에 없습니다. 비교까지 왔고, 전환은 다음 문제예요.
최대 처리량 20%, 이 값은 싼가
요청 하나에 로직이 두 번 도니까 공짜일 리 없죠. 이 팀은 운영보다 스펙이 낮은 SANDBOX 서버에서 curl로 동시 요청을 만들어 구간별 25초씩 부하를 걸고, CPU, 힙, GC 정지, 커넥션 풀 대기, 스레드 수를 3초 간격으로 봤어요.

결과는 분명해요. 적용 전 최대 처리량을 100이라 하면 적용 후는 80. 적용 전엔 5xx와 타임아웃이 전 구간 0건이었고, 적용 후에도 에러는 특별히 발생하지 않았어요.
표를 옆으로 놓고 보면 "20% 감소"의 실체가 더 정확히 보여요. 상대 처리량 80% 지점에서 적용 후 P95는 482ms인데, 적용 전 81% 지점의 P95는 424ms예요. 그 구간까지는 사용자가 느낄 차이가 거의 없다는 뜻이죠. 문제는 그 다음이에요. 적용 후는 81.7%에서 P95가 1,182ms로 뛰고 83.5%에서 1,789ms, 최대 2,653ms까지 갑니다. 적용 전에는 같은 절벽이 99.4%(P95 832ms)를 지나 100%(P95 1,184ms)에서 나타났고요. CPU를 보면 더 분명해요. 적용 전 100% 지점의 CPU가 평균 68.6%, 최대 81.7%였고, 적용 후 83.5% 지점은 평균 68.9%, 최대 83.2%. 같은 CPU 천장에 부딪히는데 처리량만 20% 낮은 곳에서 부딪히는 거예요. 힙은 범인이 아니에요. 적용 전 100% 지점 힙이 평균 909MB, 최대 1,310MB였고 적용 후 83.5% 지점은 838MB, 1,296MB로 오히려 비슷하거나 낮으니까요. 비동기로 신규 로직을 한 번 더 돌리는 비용은 메모리가 아니라 CPU 시간으로 지불됩니다. 커넥션 풀 대기도 절벽에서만 생깁니다. 적용 전 100%에서 최대 26, 적용 후 83.5%에서 최대 42. 그 아래 구간은 양쪽 다 0에서 한 자리 수예요. 그러니까 이 비용은 "평소 응답이 느려진다"가 아니라 "절벽이 20% 앞으로 당겨진다"예요.
이 팀의 결론은 "특별하지 않은 상황이라면 100% 적용해도 운영에 문제없다"예요. 근거는 평균 TPS가 최대 처리량의 약 28%라는 것. 평소엔 여유가 3.5배쯤 있으니 20% 깎여도 2.8배가 남는다는 계산이죠. 저는 이 계산이 맞다고 보되, 조건이 붙는다고 봐요. 이 결론은 "평균이 피크의 1/3 이하인 서비스"에서만 성립해요. 프로모션이나 마감 시간에 피크가 자주 오는 서비스라면 절벽이 20% 앞으로 온다는 건 작은 일이 아니에요. 절벽 위에서는 P95가 1초를 넘고 최대 응답이 2.6초까지 가니까요. 그리고 글에 없는 비용이 하나 더 있어요. 이 심사 API는 외부 기관을 호출하는 무거운 API인데, 신규 로직도 같은 외부 기관을 부른다면 호출량이 두 배가 됩니다. 외부 호출에 건당 비용이나 쿼터가 붙어 있는 팀이라면 CPU보다 그게 먼저 문제가 돼요.
조회 API에서만 통하는 이유
애노테이션 이름이 ShadowInquiry, 즉 조회예요. 우연이 아니라고 봐요. 이 방식은 신규 로직을 진짜로 실행하기 때문에, 그 로직에 부수효과가 있으면 못 씁니다. DB에 쓰거나, 알림을 보내거나, 외부에 주문을 넣는 로직을 shadow로 돌리면 사용자가 모르는 사이에 일이 두 번 벌어져요. 그래서 이 안전장치를 도입하려는 팀이 먼저 할 일은 코드를 짜는 게 아니라 이관 대상 목록에서 부수효과 없는 조회를 골라내는 거예요. 그게 후보의 전부고, 나머지는 다른 방법이 필요합니다.
이 거래가 남는 조건은 셋이에요. 부수효과 없는 조회 API일 것, 평균 트래픽이 피크의 1/3 이하일 것, 그리고 런타임에 끌 수 있는 손잡이가 있을 것. 셋 중 하나라도 없으면 20%는 값이 아니라 손해예요.
비교는 시작이지 끝이 아니다
이 글에서 제가 가장 아쉬운 대목은 "언제 끝내는가"가 없다는 점이에요. 며칠간 불일치가 0건이면 신규로 전환한다는 종료 기준, 전환 후 shadow 코드를 걷어내는 시점. 이게 없으면 비교 코드가 다음 레거시가 됩니다. 애노테이션은 남고, 신규 로직은 두 번 돌고, 실행률은 아무도 안 만지는 상태로요. 레거시가 많은 환경에서 심리적 안정감을 얻었다는 말에 저도 동의하는데, 그 안정감은 종료 기준이 있을 때만 이관을 앞으로 밀어요. 없으면 비교를 켜둔 채로 안심하는 데서 멈추기 쉽습니다.
테스트 없는 레거시를 운영 트래픽으로 검증한다는 발상은 옳아요. 다만 그건 테스트를 대신하는 게 아니라, 테스트를 쓸 수 있는 상태로 가기 위한 임시 다리예요. 다리를 놓았으면 건너는 날짜도 정해야죠.