마이데이터 중계서버 회고: 40초를 5초로 줄인 Virtual Thread · Circuit Breaker · Bulk Upsert
마이데이터 연동이 평균 40초였다. 5초대로 줄였다고 했더니 모두가 "뭘 했길래" 물었다. 한 방의 비밀이 있는 게 아니다. 비동기·격리·관측, 세 축을 한 번에 갈아 끼웠을 뿐이다.
마이데이터 중계서버 도메인은 묘하다. 사용자 한 명이 버튼 한 번을 누르면, 우리는 40개 보험사의 API를 동시에 두드려서 응답을 모은 뒤 가공해 돌려줘야 한다. 보험사 한 곳의 평균 응답이 1초여도, 40곳을 순차로 부르면 40초가 된다. 그리고 그중 한두 곳은 항상 느리거나, 일시적으로 죽어 있다. 즉 이 도메인의 본질적인 문제는 "느린 API"가 아니라 "느린 API가 빠른 API들을 끌어내린다는 합산 지연" 이다.
이번 글은 그 합산 지연을 풀기 위해 레거시 중계서버를 Java 21 기반으로 재설계한 과정과, 그 과정에서 다시 한 번 배운 것들을 정리한 회고다.
1. 도메인: "40개 보험사 한 묶음"이라는 비대칭
마이데이터 중계의 트래픽 패턴은 다음과 같다.
- 사용자 1명의 한 요청 = 보험사 N개(평균 40개) 호출 + 결과 저장 + 응답 가공
- 보험사 응답은 품질이 균일하지 않다. 평균 800ms~1.5s지만, P99는 보험사별로 천차만별
- 일부 보험사는 주기적으로 죽거나 느려진다. 우리가 통제할 수 없는 외부 의존성
- 데이터 신선도 SLA: 사용자가 인내하는 한도 안에 결과가 와야 한다 (체감 5초 이내)
여기서 한 가지 사실이 모든 설계를 결정짓는다. 응답 시간의 분포는 평균이 아니라 최악값이 지배한다. 40개를 순차로 부르면 평균이 1초여도 합은 40초고, 한 곳이 30초가 걸리면 39곳을 1초에 받아도 31초가 된다. 즉 이 도메인의 핵심 KPI는 평균이 아니라 "가장 느린 정상 호출" 이다.
도메인 설계의 첫 단추는 무엇이 비용을 지배하는가를 정확히 짚는 것이다. 우리의 경우 그것은 보험사 수도, DB도 아니라 "가장 느린 외부 의존성" 이었다.
2. 진짜 문제: 코드의 무질서가 만든 합산 지연
레거시 코드를 처음 열었을 때 가장 먼저 보인 건 기술 스택이 아니라 유지보수가 가능한 상태인지 자체였다.
- 사용되지 않는 변수와 죽은 주석이 곳곳에 박혀 있고
@Autowired필드 주입이 기본값이라 어떤 의존이 주입되는지 추적이 안 되고- 하나의 메서드 안에
if/else가 7~8단계로 중첩되어 사이드 이펙트 영역을 가늠하기 어려운 상태
이 상태에서 "연동 속도가 느립니다"라는 보고를 받으면, 어디서부터 손대야 할지조차 모른다. 응답이 느린 이유가 외부 API 때문인지, DB 락 때문인지, 코드 안의 분기 로직 때문인지 구분할 수단이 없기 때문이다.
// ❌ Before: 사이드 이펙트가 추적되지 않는 분기 더미
public Result fetchAndSave(String userId) {
if (userCache.isHit(userId)) {
if (insurerService.isOnline()) {
if (mode.equals("BATCH")) {
// ... 깊은 중첩
} else {
// ...
}
}
}
// 어떤 분기가 어떤 부수효과를 일으키는가?
return new Result();
}그래서 가장 먼저 한 일은 속도 개선이 아니라 코드의 가독성과 테스트 가능성을 회복하는 것이었다.
- 필드 주입을 생성자 주입으로 일괄 전환해 의존을 가시화
if/else더미를 전략 객체와 가드 절로 분리, 한 메서드의 책임을 줄임- 핵심 흐름에 대해 Unit Test를 먼저 깔아, 이후의 성능 개선이 기능 회귀를 일으키지 않게 안전망을 설치
- API 표면은 OAS(Open API Spec) 라이브러리로 자동 문서화
이 단계의 결과는 속도 지표가 아니라 변경 가능성이었다. 즉, 여기까지는 "빠른 것"이 아니라 "이제 만질 수 있는 것"을 만든 단계다. 이 토대 없이 다음 단계로 가면, 비동기든 캐시든 도입하는 순간 추적 불가한 새로운 버그를 만들게 된다.
3. 첫 번째 결정: Java 21 + Virtual Thread, Reactor가 아닌 이유
코드가 안전해진 다음에 본격적으로 속도에 손댔다. 가장 먼저 결정한 건 언어 버전과 동시성 모델이었다.
선택지는 크게 셋이었다.
- Java 8 그대로 +
CompletableFuture+ 별도 Executor - Reactor / WebFlux로 전면 전환
- Java 21 + Virtual Thread +
CompletableFuture
우리는 3번을 골랐다. 이유는 단순하다.
- 이 시스템은 I/O 바운드다. CPU가 아닌 외부 API 응답을 기다리는 게 99%다. Virtual Thread는 정확히 이 케이스를 위해 설계된 모델이다.
- Reactor 전환은 멘탈 모델까지 전환해야 한다. AI 상담사 회고에서도 같은 결론에 도달했지만, 팀이 Reactor의 스레드 모델을 충분히 이해하지 못한 채 도입하면 빠르게 망가지는 도구가 된다. 우리에겐 그 학습 비용을 감당할 시간이 없었다.
- Virtual Thread는 기존의 동기 코드 형태를 거의 그대로 유지하면서, JVM이 캐리어 스레드로 안전하게 멀티플렉싱한다. "쉬워졌다"는 것이 곧 운영 측면에서 가장 큰 가치다.
// ✅ After: Virtual Thread 위에서 CompletableFuture로 fan-out
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
List<CompletableFuture<InsurerData>> futures = insurers.stream()
.map(insurer -> CompletableFuture.supplyAsync(
() -> insurerClient.fetch(insurer, userId),
vt
))
.toList();
List<InsurerData> results = futures.stream()
.map(CompletableFuture::join)
.toList();이 한 변경만으로도 합산 지연이 곱하기에서 더하기를 거쳐 최댓값으로 줄어든다. 평균 1초짜리 호출 40개가 1초 가까이에 끝난다. 가장 느린 정상 호출이 곧 전체 응답이 되는 모델이다.
Virtual Thread의 진짜 의미는 "더 빠르다"가 아니다. "동기 스타일로 짠 코드를 그대로 동시 처리할 수 있게 됐다" 는 것이다. 그게 운영 시스템에서 가지는 가치는, 단순 처리량 향상보다 훨씬 크다.
4. 두 번째 결정: Circuit Breaker로 느린 보험사를 격리한다
비동기 fan-out만 하면, 한 가지 함정에 빠진다. 느린 보험사 한 곳이 여전히 전체 응답을 결정한다. 모든 future를 join 하면 가장 느린 것의 응답을 기다리기 때문이다.
이 문제를 타임아웃으로만 풀려고 하면 안 된다. 타임아웃은 응답 시간을 보장해 줄 뿐, 그 보험사가 계속 죽어 있는 상태로 들어오는 모든 요청에 대해 매번 같은 시간을 기다리게 만든다. 즉 타임아웃은 1회성 보호일 뿐, 시스템 차원의 격리가 아니다.
진짜 답은 Circuit Breaker다. 한 보험사가 일정 시간 안에 임계치 이상의 실패율을 보이면, 그 보험사에 대한 호출 자체를 즉시 실패로 단락시킨다. 그러면 전체 응답은 정상 보험사들의 가장 느린 응답 시간으로 수렴한다.
// Resilience4j 설정 예시
@Bean
public CircuitBreakerRegistry insurerCircuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 50% 실패시 OPEN
.slowCallRateThreshold(60) // 60%가 느리면 OPEN
.slowCallDurationThreshold(Duration.ofSeconds(3))
.waitDurationInOpenState(Duration.ofSeconds(30))
.permittedNumberOfCallsInHalfOpenState(3)
.slidingWindowSize(20)
.build();
return CircuitBreakerRegistry.of(config);
}
public InsurerData fetchSafely(Insurer insurer, String userId) {
CircuitBreaker cb = registry.circuitBreaker(insurer.code());
return cb.executeSupplier(() -> insurerClient.fetch(insurer, userId));
}여기서 시니어 시선으로 한 번 더 짚을 게 있다. Circuit Breaker는 장애를 복구하는 도구가 아니다. OPEN 상태에서 보험사는 여전히 죽어 있다. 우리가 한 일은 그 죽음을 다른 39곳에 전염시키지 않도록 분리한 것뿐이다. 이 차이를 모르면 CB의 임계치를 잘못 잡거나, OPEN 상태에서 "왜 복구가 안 되지" 하고 시스템 탓을 하게 된다.
// ❌ Anti-pattern: CB가 "고쳐줄 것"이라 기대
if (cb.getState() == State.OPEN) {
// 보험사가 곧 살아날 거니까... 라고 가정하지 말 것
retry();
}
// ✅ CB는 OPEN인 동안 그 의존을 "없는 셈" 친다
public Optional<InsurerData> tryFetch(Insurer insurer, String userId) {
try {
return Optional.of(cb.executeSupplier(() -> insurerClient.fetch(insurer, userId)));
} catch (CallNotPermittedException | Exception e) {
return Optional.empty(); // 정상 보험사만으로 응답 조합
}
}5. 세 번째 결정: TraceId가 없으면 분산 호출은 추측이다
여기서 한 가지 새로운 문제가 생긴다. 40개 비동기 호출을 하기 시작하면, 로그를 한 줄씩 봐서는 어떤 호출이 어디에 속하는지 알 수 없다. "보험사 #07 응답 지연" 같은 줄이 떴을 때, 그게 어떤 사용자, 어떤 요청, 어떤 시도였는지 추적할 수 없다면 결국 운영은 추측에 의존한다.
그래서 micrometer-tracing-bridge-brave를 붙여 TraceId·SpanId를 모든 호출에 자동 전파했다.
// build.gradle
implementation("io.micrometer:micrometer-tracing-bridge-brave")
implementation("io.zipkin.reporter2:zipkin-reporter-brave")# logback-spring.xml 패턴 일부
%d{HH:mm:ss.SSS} [%X{traceId:-},%X{spanId:-}] %-5level %logger{36} - %msg%n// 호출 사이트에 별도 코드 없이도 MDC에 traceId/spanId가 들어온다
log.info("insurer={} userId={} retry={}", insurer.code(), userId, attempt);
// → 11:03:21.554 [a1b2...,c3d4...] INFO InsurerClient - insurer=#07 userId=42 retry=2이 한 가지로 인해, 운영 중 발생한 사고 분석 시간이 체감상 절반 이하로 줄었다. "40개 호출 중 어떤 게 누가 보낸 요청이었는가" 를 grep 한 번으로 추적할 수 있게 됐기 때문이다.
비동기 시스템에서 관측성은 사치가 아니라 전제다. 로그가 추적 가능하지 않으면, 비동기는 운에 맡기는 운영과 같다.
6. 마지막 병목: Bulk Upsert와 MySQL Connector/J 9
여기까지 오면 외부 호출은 빨라졌는데, 저장 단계가 새로운 병목이 된다. 40개 보험사 결과를 건별로 INSERT/UPDATE 하면, 외부에서 5초 안에 받아온 데이터를 DB에 넣는 데 또 다시 수 초가 걸린다.
해결은 두 갈래였다.
갈래 1: Bulk Upsert로 라운드트립을 합친다.
// ❌ Before: 건별 저장 (N회 라운드트립)
for (InsurerData d : results) {
repository.upsert(d);
}
// ✅ After: 단일 statement로 일괄 Upsert
@Modifying
@Query(value = """
INSERT INTO insurer_data (user_id, insurer_code, policy_no, payload, fetched_at)
VALUES (:userId, :insurerCode, :policyNo, :payload, :fetchedAt)
ON DUPLICATE KEY UPDATE
payload = VALUES(payload),
fetched_at = VALUES(fetched_at)
""", nativeQuery = true)
void upsertBatch(...);JPA 영속성 컨텍스트가 1차 캐시·플러시 비용으로 도리어 발목을 잡는 경우, JDBC 레벨 native SQL이 정답일 때가 많다. 이 결정에는 정답이 없다. 케이스마다 측정해서 고른다.
갈래 2: MySQL Connector/J 9.x로 잠금 처리 효율 개선.
Connector/J가 9.x로 올라오면서 내부 ReentrantLock 사용 방식이 개선되어, Virtual Thread 환경에서 캐리어 스레드 핀닝(pinning)이 줄어들었다. 이 둘은 같이 쓰일 때 시너지가 크다. Virtual Thread를 도입했는데도 처리량이 기대만큼 안 나오면, 십중팔구 드라이버 레벨에서 synchronized로 캐리어를 잡고 있는 라이브러리가 어딘가 있다. 의존 라이브러리들의 Loom 친화성을 한 번 점검할 필요가 있다.
7. 시니어 시선으로 정리하는 인사이트
① 응답 시간은 평균이 아니라 분포가 말한다
평균 1초짜리 호출 40개라는 표현은 거의 의미가 없다. P95, P99, 그리고 가장 느린 정상 호출이 시스템의 SLA를 결정한다. 모든 비동기·격리 설계는 결국 "가장 느린 것을 어떻게 다룰 것인가"의 답이다.
② Virtual Thread의 진짜 의미는 "쉬워졌다"는 것
Reactor / WebFlux는 강력하지만, 팀의 멘탈 모델까지 전환해야 한다. Virtual Thread는 동기 스타일을 유지하면서 동시 처리가 가능해진 모델이다. 운영 시스템에서 "기존 코드를 거의 그대로 둘 수 있다"는 것의 가치는 단순 처리량보다 훨씬 크다. 단, 라이브러리들의 캐리어 핀닝 여부는 반드시 검증한다.
③ Circuit Breaker는 보호하는 게 아니라 분리하는 도구다
OPEN이 됐을 때 보험사는 여전히 죽어 있다. 우리가 한 일은 그 죽음의 비용을 다른 39곳에 전이시키지 않도록 끊어낸 것뿐이다. 이 차이를 분명히 해 두지 않으면, 운영 중 CB의 임계치 튜닝과 OPEN 동안의 동작 정책을 잘못 잡게 된다.
④ 관측 없이 비동기를 운영하면 운에 맡기는 것과 같다
TraceId / SpanId 없는 비동기는 사고 분석을 추측의 영역으로 밀어 넣는다. micrometer-tracing-bridge-brave 같은 라이브러리는 무거운 인프라가 아니다. MDC 패턴 한 줄, 의존성 두 줄. 이걸 안 한 채로 비동기를 굴리는 것은, 적어도 운영자 입장에서는 무책임에 가깝다.
⑤ 성능 개선은 코드가 안전해진 다음의 일이다
이번 프로젝트에서 가장 큰 결정은 튜닝을 시작하기 전에 코드를 정리한 것이었다. 필드 주입을 생성자 주입으로 바꾸고, 분기 더미를 전략 객체로 쪼개고, 핵심 흐름에 Unit Test를 깐 단계. 이 토대 없이 Virtual Thread와 Circuit Breaker를 얹었다면, 빨라진 시스템 위에 추적 불가한 버그를 새로 만들었을 가능성이 높다. 속도 개선은 항상 안전 위에 올라가는 한 층이다.
마무리: 한 문장으로 줄이면
합산 지연을 푸는 답은 한 가지가 아니라 세 가지다. 비동기로 합을 최댓값으로 바꾸고, Circuit Breaker로 최댓값에서 비정상치를 빼고, 관측으로 그 모든 결정을 사후에 검증한다.
다음 액션 아이템으로 남겨둔 것들은 다음과 같다.
- 부하 테스트 시나리오 표준화: 보험사 N곳을 일부러 느리게 만든 합산 지연 시나리오를 정형화해, 코드 변경마다 회귀 검증한다.
- CB 임계치의 자동 튜닝: 보험사별로 평균 응답이 다르므로, 단일 임계치는 도리어 위험하다. 보험사별 baseline을 일정 주기로 갱신하는 흐름이 필요하다.
- 트레이스 샘플링 정책: 현재는 100% 샘플링이지만, 트래픽이 더 커지면 비용을 통제해야 한다. 에러·지연 트레이스만 골라 보존하는 정책으로 옮길 것.
이번 회고에서 가장 인상 깊은 한 줄을 남기자면, "기술은 함께 쓰일 때 빛난다" 가 가장 가깝다. Virtual Thread만으로는 합산 지연을 못 줄이고, Circuit Breaker만으로는 처리량을 못 늘리고, 관측만으로는 코드를 못 빠르게 한다. 셋이 같이 들어와야 마이데이터 중계라는 도메인이 비로소 운영 가능한 상태가 된다.