요약: Spring Boot 버전업이라는 숙원 앞에 레거시 모듈이 막아섰습니다. AOP 기반 Shadow 비교 로직으로 운영 환경에서 안전하게 레거시를 걷어내는 과정을 공유합니다.
시작하며
레거시 모듈을 안전하게 제거하기 위해 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍) 기반 Shadow 비교 전략(운영 요청에 실제 응답은 기존 로직 결과를 사용하면서, 신규 로직도 동시에 실행해 결과만 비교하는 방식)을 도입한 경험을 공유하려고 해요.
숙원사업: Spring Boot 버전업
카카오페이손해보험(카손보)에 합류하고 제가 작업하는 레포지토리의 개선점을 하나하나 해결하고 싶은 욕심이 많았어요. 도메인을 정의해서 새로운 서버를 띄우기도 하고, 통합 테스트 환경을 만들기도 했지만, 제 마음속에 가장 큰 부채로 남아 있는 게 Spring Boot 버전업이었어요.
글을 검토하는 2026년 8월 31일 기준 최신 Boot 버전은 4.1이에요. 하지만 현재 우리 팀에서 유지보수하고 있는 특정 레포지토리는 24년 11월에 릴리즈된 2.6 버전을 사용하고 있어요.
버전이 낮아서 생기는 여러 운영상의 어려움이 있었는데요, 그중에 우리 팀이 실제로 맞이한 에러는 아래와 같아요.
- @TransactionalEventListener에서 에러 발생 시 debug로 찍히는 이슈 (링크)
- JPATraversableResolver에서
WeakHashMap을 사용하여 JPA 엔티티 여부를 확인하는 과정에서 동시성 이슈 (링크)
문제: 레거시 모듈 의존
복합적으로 버전을 높이는 것이 옳다고 판단하여 작업을 하던 도중 하나의 문제를 발견하게 됐어요.
contract(가칭)라는 레포지토리는 서버 실행 시 product(가칭) 모듈에 의존하는데요. product 또한 spring bean으로 동작하게 되어 있어 boot 버전 차이가 발생하면서 contract 버전업 작업에 어려움이 있었어요.
그래서 버전업보다 먼저 레거시 코드를 안전하게 없애서 모듈을 fade-out하는 것을 목표로 잡았어요.
product 모듈을 의존해서 실행되는 레거시 레포지토리가 N개라 건드릴 경우 영향도가 컸어요. 따라서 contract 레포지토리에서 product 모듈을 없애려면 MSA 환경으로 이관이 100% 마무리가 되어야 했어요.
MSA 서버를 새로 띄우기는 했으나 아래 사유로 레거시 모듈을 빠르게 이관하지 못하고 있었어요.
- 프로덕트 출시를 위해 많은 업무 로드가 발생한 점
- 통합 테스트 코드, 회귀 테스트를 위한 스크립트 등을 고민하기도 했지만, 해당 기능이 너무 뒷단에서 호출되어 케이스 전체를 파악하기 어려운 점
- QA 크루분들도 많은 업무 로드로 인해 개발자 개선에 대한 요청을 드리기가 죄송한 점
전략: Strangler 패턴
기존에 사용하던 로직을 새로운 로직으로 쉽게 전환할 수 있는 무언가를 만들면 어떨지 생각했어요.
그러다가 Strangler 패턴을 접했어요.
열대우림의 스트랭글러 무화과나무가 숙주 나무를 서서히 감싸 대체하듯, 레거시 시스템을 한 번에 교체하지 않고 새 코드를 조금씩 덧붙여 기존 기능을 점진적으로 이전하며 결국 레거시를 대체하는 전략이에요.
Strangler 패턴을 차용한 AOP 기반 Shadow 비교 전략을 기반으로 아래 설명을 충족시킬 수 있어요.
- 호출 케이스를 운영 환경에서 어느 정도 검증할 수 있음
- QA 분들 도움 없이 개발자의 역량만 가지고 천천히 그리고 안정적으로 옮길 수 있음
- 레거시 로직에 대해 점진적으로 신규 서버로 이관할 수 있음
실행: AOP 사용
AOP를 적용했을 때 흐름은 보기 쉽게 이미지로 그려봤어요. 상세한 로직은 아래에 설명드릴게요.
코드 1. AOP Annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ShadowInquiry {
/**
* Shadow 로직 Bean
*/
Class<?> compareBean();
/**
* Shadow 메서드 이름
*/
String compareMethodName();
/**
* Shadow 메서드 파라미터 타입 명시
*/
Class<?>[] paramTypes();
/**
* 원본 메서드 파라미터 중 전달할 인덱스
*/
int[] paramIndexes() default {};
}
코드 2 AOP annotation using example
@ShadowInquiry(
compareBean = FindDuplicationCoverageNewService.class,
compareMethodName = "getDuplicatedCoverages",
paramTypes = {String.class, String.class},
paramIndexes = {0, 1}
)
public DuplicatedCoverageDto getDuplicatedCoverages(String planNo, String customerNo)
하나의 API로 동일한 비즈니스가 다른 2개의 로직으로 2번 수행되기 때문에 메모리나 커넥션 등 이슈가 있거나 에러가 나는 케이스를 모니터링하면서 런타임에 조절할 수 있게 했어요. ([코드 3] - 주석 1)
실제 비교 로직을 실행하기 위해 기존 메서드와 비교 메서드를 호출하게 되는데, 비동기로 실행되는 로직이기 때문에 리소스 사용량 측면에서 최적화가 되어야 해서 리플렉션을 매번 수행 안 하도록 캐싱 처리를 했어요. ([코드 3] - 주석 2)
코드 3 Aspect code
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ShadowInquiryAspect {
private final ApplicationContext applicationContext;
private final ShadowInquiryExecutor executor;
private final ShadowInquiryDecisionProvider provider;
private final Random random = new Random();
private final Map<String, Method> methodCache = new ConcurrentHashMap<>();
@AfterReturning(pointcut = "@annotation(shadowInquiry)", returning = "result")
public void afterReturning(
JoinPoint joinPoint,
ShadowInquiry shadowInquiry,
Object result
) {
String key = shadowInquiry.compareBean().getSimpleName() + "#" + shadowInquiry.compareMethodName();
if (provider.isNotExecute(key, random.nextInt(101))) return; // 1) 1~100 사이에 랜덤 값이 DB에 저장된 숫자보다 클 경우 수행하지 않음
Object[] shadowParams = extractParams(joinPoint.getArgs(), shadowInquiry.paramIndexes());
Object compareBean = applicationContext.getBean(shadowInquiry.compareBean());
try {
Method compareMethod = getOrCacheMethod(key, shadowInquiry); // 2) 매번 리플렉션을 방지하기 위한 캐싱 데이터 조회
executor.execute(result, compareBean, compareMethod, shadowParams);
} catch (Exception e) {
// 실행 실패 관련 알림
}
}
...
}
기존 요청에 대한 응답에 영향을 주면 안 되기 때문에 기존 로직이 다 수행되고 @AfterReturning으로 새로운 로직을 @Async로 실행해요. 동일한 비즈니스가 수행됐다고 인지하기 위해서 기존 로직의 응답값과 새로운 로직의 응답값을 각각 직렬화해서 완전 비교를 했어요. 이때 toString으로 작업할 경우 기존 로직의 dto를 건드려야 하는 점과 직렬화를 하면서 드는 메모리 비용이 있는 점을 고려하여 JSON Tree 비교를 하게 됐어요. ([코드 4] - 주석 1)
완전 비교가 될 경우 아무 액션을 취하지 않고, 다를 경우 DB 저장과 알림이 연동될 수 있게 작업했어요.
코드 4 Async Executor
@Slf4j
@Component
@RequiredArgsConstructor
public class ShadowInquiryExecutor {
...
@Async(CustomAsyncConfig.CONTRACT_EXECUTOR)
public void execute(
Object originalResult,
Object compareBean,
Method compareMethod,
Object[] params
) {
try {
Object shadowResult = compareMethod.invoke(compareBean, params);
JsonNode originalJsonNode = MAPPER.valueToTree(originalResult);
JsonNode shadowJsonNode = MAPPER.valueToTree(shadowResult);
boolean isMatch = originalJsonNode.equals(shadowJsonNode); // 기존 로직과 신규 로직의 응답값 비교
if (isMatch) {
return;
}
// 비교 실패 관련 저장 및 알림
} catch (Exception e) {
// 실행 실패 관련 알림
}
}
}
완전 동일한 비즈니스가 수행되지만, 완전 비교 실패가 날 수 있는 케이스가 있었어요.
- BigDecimal 비교 ([코드 5] - 주석 1)
- Collection 비교 ([코드 5] - 주석 2)
- 필드의 정렬 ([코드 5] - 주석 3)
BigDecimal의 경우 100.0과 100.000은 다른 객체로 인식해요. 그래서 소수점 이하를 정규화할 수 있게 처리했어요. Collection의 경우 순서 등이 문제가 될 여지가 있어서 정렬했어요. 응답 필드도 완전 비교를 위해서 알파벳순으로 정렬하는 로직을 추가했어요. (더 많은 케이스가 있지만 일단 현재는 위 케이스만 대응했어요.)
코드 5 Custom ObjectMapper
public final class StranglerObjectMapper {
public static final ObjectMapper MAPPER;
static {
SimpleModule module = new SimpleModule();
module.addSerializer(BigDecimal.class, new NormalizedBigDecimalSerializer()); // 1) BigDecimal 비교를 위한 custom serializer
module.addSerializer(Collection.class, new SortedCollectionSerializer()); // 2) Collection 비교를 위한 custom serializer
MAPPER = JsonMapper.builder()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) // 3) 응답 필드 알파벳 정렬
.serializationInclusion(JsonInclude.Include.ALWAYS)
.addModule(module)
.build();
}
...
}
검증: TPS별 안정성 측정
AOP를 사용하여 하나의 요청에 대해 2개의 로직이 수행될 경우, 메모리 / CPU / DB Pool / P95(전체 요청 중 95%가 이 시간 이내에 처리됐다는 응답시간 지표) 등 주요 모니터링 지표에 문제가 있을 수 있겠다고 생각했어요. 이에 따라 TPS(Transactions Per Second, 초당 처리 가능한 요청 수)별 수치 기반으로 안정성 검증을 하기로 했어요.
측정 환경 및 모니터링 지표
- 대상:
심사가 수행되는 SANDBOX 서버 (운영 환경보다 스펙이 낮은 환경) - 측정 방식: curl 기반 동시 요청 (worker × interval 조합으로 TPS 조절)
- 측정 시간: 각 구간별 25초 지속
- 메트릭 수집: 3초 간격 샘플링 (부하 중 실시간)
- Shadow 비율: DB 기반 제어
| 지표 | Endpoint | 설명 |
|---|---|---|
| Process CPU | /actuator/metrics/process.cpu.usage | JVM 프로세스 CPU 사용률 (0~1.0) |
| System CPU | /actuator/metrics/system.cpu.usage | 시스템 전체 CPU 사용률 |
| Heap Memory | /actuator/metrics/jvm.memory.used?tag=area:heap | JVM Heap 사용량 (bytes) |
| GC Pause | /actuator/metrics/jvm.gc.pause | GC 정지 시간 (count, total, max) |
| DB Pool Pending | /actuator/metrics/hikaricp.connections.pending | 커넥션 대기 중인 thread 수 |
| Live Threads | /actuator/metrics/jvm.threads.live | JVM 활성 thread 수 |
검증 내용
심사 서비스가 받는 평균 상대 처리량을 기준으로 피크 상대 처리량까지의 지표와, AOP를 적용하고 나서 평균~피크 상대 처리량 지표를 비교하기로 했어요. (Max TPS를 상대 처리량 100%으로 환산했으며, 이에 따라 평균 TPS는 약 28%의 상대 처리량을 가지게 표시했어요.)
AOP를 100% 적용했을 때 최대 상대 처리량 수치가 줄어든 것을 보여줬지만, 에러가 특별하게 발생하진 않았어요.
AOP 적용 전 평균~피크 TPS 확인 결과
| 상대 처리량 | CPU avg/max (%) | Heap avg/max (MB) | DB Pending (max) | Threads (max) | Avg RT (ms) | P95 (ms) | P99 (ms) | Max (ms) |
|---|---|---|---|---|---|---|---|---|
| 27.6% | 18.4 / 22.9 | 744 / 1058 | 0 | 433 | 233 | 318 | 361 | 382 |
| 60.9% | 39.2 / 54.2 | 996 / 1366 | 0 | 433 | 300 | 581 | 885 | 1101 |
| 81.0% | 50.8 / 65.3 | 802 / 1048 | 0 | 438 | 302 | 424 | 469 | 605 |
| 95.0% | 63.0 / 74.6 | 907 / 1276 | 0 | 439 | 373 | 505 | 548 | 576 |
| 99.4% | 68.5 / 80.7 | 814 / 1293 | 8 | 454 | 717 | 832 | 896 | 997 |
| 100.0% | 68.6 / 81.7 | 909 / 1310 | 26 | 469 | 1063 | 1184 | 1274 | 1537 |
- 최대 처리 용량: 100 (기준값)
- 5xx / Timeout: 전 구간 0건
AOP 적용 후 평균~피크 TPS 확인 결과
| 상대 처리량 | CPU avg/max (%) | Heap avg/max (MB) | DB Pending (max) | Threads (max) | Avg RT (ms) | P95 (ms) | P99 (ms) | Max (ms) |
|---|---|---|---|---|---|---|---|---|
| 30.0% | 33.1 / 40.3 | 1212 / 1262 | 0 | 444 | 190 | 252 | 351 | 406 |
| 59.9% | 48.5 / 58.7 | 855 / 1369 | 0 | 444 | 280 | 351 | 412 | 485 |
| 80.0% | 65.2 / 78.4 | 894 / 1317 | 3 | 450 | 365 | 482 | 537 | 625 |
| 81.0% | 69.7 / 82.5 | 1177 / 1356 | 8 | 458 | 582 | 755 | 843 | 992 |
| 81.7% | 70.3 / 83.1 | 847 / 1316 | 22 | 476 | 891 | 1182 | 1305 | 1603 |
| 83.5% | 68.9 / 83.2 | 838 / 1296 | 42 | 493 | 1352 | 1789 | 1928 | 2653 |
- 최대 처리 용량: 80% (적용 전 대비 20% 감소)
- 5xx / Timeout: 전 구간 0건
마치며
제가 맡은 심사의 영역은 외부 기관 호출 / 고객별로 가입된 모든 정보 수집 등 문제를 해결하기 위해 많은 정보가 필요하므로 꽤 무거운 API(평균의 3.5배가 최대 처리량인 점)에요. 하지만 특별하지 않은 상황이라면 위 수치를 기반으로 AOP를 100%로 적용한다고 해도 운영 환경에 문제가 없을 것 같다고 결론지었어요.
혹여나 예상치 못하게 너무나 많은 TPS가 들어온다거나 다른 이슈로 CPU / Memory 이슈가 발생한다고 했을 때, 언제든 실행률을 조절할 수 있어서 영향력을 최소화하면서도 기존 로직과 신규 로직을 완전 검증할 수 있는 수단을 마련했다고 생각했어요.
그린필드라면 통합 테스트 코드 등으로 먼저 심리적 안정감을 심어줄 수 있겠지만, 레거시가 많은 환경에서는 모든 케이스를 검증할 수 없다는 심리적 불안감이 있는 것 같아요. 하지만 해당 AOP를 기반으로 기존 레거시 로직을 옮길 수 있는 자신감을 가지게 됐어요.
다 옮기고 나서 Spring Boot 버전을 올릴 수 있을 것 같아요! 다음에는 Boot 버전업 글로 찾아뵐게요!