현대화 프로젝트의 가장 큰 착각은 기술 스택을 바꾸면 시스템이 저절로 새로워진다고 믿는 것입니다. 저희도 그 착각에서 출발했습니다. 처음 작성한 제안서의 제목은 “주문 시스템 전면 재구축”이었고, 첫 장에는 새 프레임워크 이름이 큼직하게 적혀 있었습니다.
이 글은 그 제안서가 왜 폐기됐는지, 대신 무엇을 결정했는지 남긴 메모를 다시 정리한 것입니다. 결론부터 적자면 저희는 Java를 버리지 않았습니다. 대신 어디서부터 어디까지가 하나의 서비스인지, 그 경계를 다시 그었습니다.
이 메모는 여러 현장에서 반복적으로 마주친 패턴을 하나의 가상 팀 이야기로 묶어 구성한 예시입니다. 등장하는 수치와 코드는 설명을 위해 만든 것이며, 특정 회사나 제품을 측정한 기록이 아닙니다. 숫자 자체보다 판단의 순서를 참고해 주세요.
배경: 월요일 아침에 본 그래프
시작은 대시보드였습니다. 월요일 아침 스탠드업에서 운영 담당자가 지난 2주 동안의 관리자 주문 상세 화면 응답 시간 그래프를 띄웠는데, p95 선이 계단처럼 올라가 있었습니다. 6개월 전에는 700ms 부근이던 선이 2초를 넘나들고 있었습니다. 같은 주에 고객 지원팀에서 올라온 티켓 문구는 더 직관적이었습니다. “주문 상세를 열면 화면이 멈춥니다. 두 번 새로고침하면 열립니다.”
시스템은 15년 된 Java 모놀리스였습니다. 주문 접수, 정산 규칙, 관리자 승인, 외부 제휴사 연동이 한 배포 단위 안에 들어 있었습니다. 코드베이스를 오래 만져 온 사람은 두 명뿐이었고, 그 두 명도 정산 규칙의 예외 처리 일부는 “원래 그렇게 돼 있다”는 설명밖에 하지 못했습니다.
그래프를 조금 더 뜯어보니 상승이 균일하지 않았습니다. 평일 오전 9시에서 11시 사이, 그리고 매월 초 며칠에 유독 뾰족한 봉우리가 있었습니다. 그 시간대는 정산 배치가 도는 구간과 겹쳤습니다. 이 관찰은 나중에 결정적인 단서가 되지만, 당시에는 “배치가 무거우니 서버가 힘들겠지” 정도로 넘어갔습니다. 관찰과 원인 규명 사이에는 생각보다 먼 거리가 있습니다.
이 상황에서 조직이 가장 쉽게 도달하는 결론은 정해져 있습니다. 오래됐으니 느리다, 느리니까 새로 만들자. 저희 제안서도 딱 그 논리였습니다.
우리가 책상에 올린 세 가지 선택지
제안서를 그대로 밀기 전에, 팀 리드가 선택지를 최소 세 개는 적어 오라고 했습니다. 하나만 적어 오면 그건 선택이 아니라 통보라는 이유였습니다. 그래서 아래 세 가지를 각각 한 페이지씩 정리했습니다.
안 A. 전면 재작성
- 장점: 15년치 예외 처리와 결별하고, 도메인 모델을 지금의 이해에 맞춰 다시 그릴 수 있습니다.
- 장점: 새로 들어오는 개발자에게 설명하기 쉬운 구조가 나옵니다. 채용과 온보딩 비용이 줄어듭니다.
- 주의점: 코드 밖에 문서화되지 않은 정산 예외가 그대로 유실됩니다. 이 시스템에서 그건 곧 돈 계산이 틀린다는 뜻입니다.
- 주의점: 재작성이 끝날 때까지 두 시스템을 동시에 유지해야 하고, 그동안 신규 기능은 양쪽에 두 번 들어갑니다.
안 B. 런타임과 프레임워크만 최신화
- 장점: 구조를 건드리지 않으므로 일정이 예측 가능합니다. 보안 패치와 지원 종료 문제도 같이 해결됩니다.
- 장점: 최신 JVM의 GC와 JIT 개선을 그대로 받아 갑니다. 손대지 않고 얻는 이득이 분명히 있습니다.
- 주의점: 경계가 그대로이므로, 배포 단위가 여전히 하나입니다. 정산 배치가 관리자 화면을 막는 구조는 남습니다.
- 주의점: “현대화를 했는데 체감이 없다”는 평가를 받기 쉽습니다. 다음 예산을 받기 어려워집니다.
안 C. 경계를 먼저 분리하고 단계적으로 이관
- 장점: 읽기 경로부터 잘라 내면, 운영 지식을 유지한 채로 서비스 계약을 문장으로 확정할 수 있습니다.
- 장점: 한 조각씩 옮기므로 언제든 멈출 수 있습니다. 실패의 단위가 프로젝트가 아니라 조각이 됩니다.
- 주의점: 중간 상태가 길어집니다. 게이트웨이 라우팅과 이중 배포를 관리할 사람이 필요합니다.
- 주의점: 발표 자료에 넣을 만한 화려한 성과가 적습니다. 성과를 숫자로 증명할 계측을 미리 붙여 둬야 합니다.
가장 유력했던 안이 틀린 이유
회의 시작 시점에 표는 안 A로 기울어 있었습니다. 저희가 공유한 가설은 이랬습니다. 느린 원인은 오래된 런타임과 낡은 프레임워크에 있으므로, 전면 재작성이 곧 성능 해결이다. 그럴듯했습니다. 다만 근거가 없었습니다.
그래서 결정을 일주일 미루고, 레거시 스택을 그대로 둔 채 느린 엔드포인트 하나만 프로파일링했습니다. 관리자 주문 상세 조회 API였습니다. 스테이징에 운영 데이터 사본을 올리고, 라인 아이템이 많은 주문 한 건을 반복 호출하면서 샘플링 프로파일러와 SQL 카운터를 같이 켰습니다.
PROFILE — 주문 상세 조회 1건 (레거시 스택 그대로)
GET /admin/orders/{id} p95 2,180ms
OrderQueryService.load 2,140ms (98%)
|- OrderRepository.findById 12ms (SQL x1)
|- LineItemRepository.findByOrder 31ms (SQL x1)
|- for (84 line items) { ... } 1,910ms (SQL x252)
| ProductRepository.findById (x84)
| StockRepository.findByProduct (x84)
| PriceRuleRepository.findByProduct (x84)
|- SettlementBatchClient.sync 180ms (동기 HTTP 호출)
총 SQL 실행 횟수: 255 / 요청 1건
JVM GC 정지 시간 합계: 6ms
프레임워크 디스패치 오버헤드: 4ms이 출력이 가설을 죽였습니다. 전체 응답 시간의 98%가 애플리케이션 코드 한 곳에 몰려 있었고, 그중 대부분은 라인 아이템 개수만큼 반복되는 조회, 즉 전형적인 N+1이었습니다. 여기에 정산 시스템을 동기로 호출하는 구간이 180ms를 더하고 있었습니다. 반대로 저희가 범인으로 지목했던 항목, 그러니까 GC 정지와 프레임워크 디스패치는 합쳐서 10ms였습니다. 응답 시간의 0.5%도 되지 않았습니다.
확인 사살로 한 가지를 더 했습니다. 같은 코드를 최신 LTS JVM으로만 올려 동일한 부하를 다시 걸었습니다. p95는 2,180ms에서 2,050ms가 됐습니다. 6% 개선이었고, 그 개선분은 전부 GC와 JIT에서 왔습니다. 구조를 그대로 둔 채 런타임만 바꾸면 얻는 것이 딱 그만큼이라는 뜻이었습니다. 그리고 같은 논리로, 이 코드를 다른 언어로 그대로 옮겨 적어도 SQL은 여전히 255번 나갈 것이었습니다.
언어를 바꿔도 따라오는 문제라면, 그건 언어의 문제가 아닙니다.
이 문장이 회의의 방향을 바꿨습니다. 재작성은 “느림”에 대한 답이 아니었습니다. 재작성이 답이 될 수 있는 질문은 따로 있었고, 그건 “왜 정산 배치가 관리자 화면의 응답 시간을 좌우하는가”였습니다. 그건 성능 문제가 아니라 경계 문제였습니다. 프로파일링으로 병목을 좁혀 가는 방법 자체가 궁금하다면 Java JVM 성능 가이드에 측정 절차를 따로 정리해 두었습니다.
실제로 결정한 것과 바꾼 코드
최종 결정은 안 C였습니다. 다만 순서를 명시적으로 못 박았습니다. 첫째, 지금 당장 아픈 곳은 구조 변경 없이 고칩니다. 둘째, 관측 가능성을 먼저 붙입니다. 셋째, 읽기 경로부터 경계를 잘라 냅니다. 넷째, 런타임 업그레이드는 경계가 정리된 조각부터 개별적으로 진행합니다.
첫 번째 단계는 며칠 만에 끝났습니다. 반복 조회를 배치 조회로 바꾸고, 정산 동기화를 응답 경로에서 빼냈습니다.
BEFORE — 라인 아이템마다 세 번씩 조회
public List<OrderLineView> toViews(List<LineItem> items) {
List<OrderLineView> views = new ArrayList<>();
for (LineItem item : items) {
Product product = productRepository.findById(item.productId());
Stock stock = stockRepository.findByProduct(item.productId());
PriceRule rule = priceRuleRepository.findByProduct(item.productId());
views.add(OrderLineView.of(item, product, stock, rule));
}
// 응답을 만들기 전에 정산 시스템을 동기로 호출하고 있었습니다.
settlementBatchClient.sync(items);
return views;
}AFTER — 배치 조회 + 정산은 이벤트로 위임
public List<OrderLineView> toViews(List<LineItem> items) {
Set<ProductId> ids = items.stream()
.map(LineItem::productId)
.collect(Collectors.toSet());
Map<ProductId, Product> products = productRepository.findAllByIds(ids);
Map<ProductId, Stock> stocks = stockRepository.findAllByProductIds(ids);
Map<ProductId, PriceRule> rules = priceRuleRepository.findAllByProductIds(ids);
List<OrderLineView> views = items.stream()
.map(item -> OrderLineView.of(
item,
products.get(item.productId()),
stocks.get(item.productId()),
rules.get(item.productId())))
.toList();
// 응답 경로에서 분리합니다. 정산은 이 이벤트를 구독해 자기 속도로 처리합니다.
events.publish(new OrderViewed(orderId, Instant.now()));
return views;
}SQL은 255회에서 4회가 됐습니다. 여기서 중요한 건 개선 폭이 아니라, 이 변경에 새 프레임워크도 새 언어도 필요하지 않았다는 사실입니다. 스트림으로 키를 모아 한 번에 조회하는 형태가 익숙하지 않다면 Java 스트림 가이드의 수집기 예제를 먼저 보시길 권합니다.
그다음이 진짜 현대화 작업이었습니다. 주문 조회 책임만 별도 서비스로 떼어 내고, 게이트웨이에서 읽기 요청만 새 서비스로 흘려보냈습니다. 쓰기는 한동안 손대지 않았습니다.
ROUTING — 읽기 경로만 먼저 잘라 낸 게이트웨이 설정
# 1단계: 읽기만 새 서비스로. 쓰기는 레거시가 계속 소유합니다.
routes:
- id: order-read-v2
path: /api/orders/**
methods: [GET]
uri: http://order-query-service
filters:
# 두 응답을 비교해 차이만 로그로 남깁니다. 사용자에게는 레거시 응답을 그대로 줍니다.
- name: ShadowCompare
args:
baseline: http://legacy-order-monolith
sample-rate: 0.05
mode: log-only
- id: order-write-legacy
path: /api/orders/**
methods: [POST, PUT, DELETE]
uri: http://legacy-order-monolith섀도 비교를 3주 돌리는 동안 응답 차이가 41건 나왔습니다. 그중 38건은 새 서비스가 놓친 정산 예외였고, 나머지 3건은 레거시 쪽 반올림 버그였습니다. 이 41건이야말로 전면 재작성이었다면 조용히 유실됐을 지식이었습니다. 문서에도 없고 사람의 기억에도 없던 규칙을, 두 시스템을 나란히 돌려서 찾아낸 셈입니다. 이 비교 결과는 전부 회귀 테스트로 옮겨 두었습니다. 계약을 테스트로 고정하는 방식은 Java 테스트 가이드에서 다룬 형태를 그대로 썼습니다.
6개월 뒤 회고
6개월 뒤 회고에서 가장 자주 나온 말은 “처음 일주일이 프로젝트를 살렸다”였습니다. 프로파일링에 쓴 그 일주일이 없었다면 저희는 18개월짜리 재작성을 시작했을 것이고, 6개월 차인 지금도 여전히 아무것도 배포하지 못한 상태였을 것입니다.
동시에 솔직하게 적어 둘 것도 있습니다. 중간 상태는 불편합니다. 게이트웨이 설정이 진실의 원천이 되면서, 새로 합류한 사람이 “이 요청이 어디로 가는가”를 파악하는 데 시간이 걸렸습니다. 저장소 상단에 라우팅 표를 붙여 두고 변경할 때마다 같이 고치는 규칙을 만들고 나서야 이 비용이 줄었습니다. 그리고 안 B를 완전히 버리지도 않았습니다. 떼어낸 조회 서비스는 처음부터 최신 LTS로 만들었고, 레거시 본체도 경계가 정리되는 순서대로 하나씩 올리고 있습니다.
회고에서 나온 또 하나의 질문은 “그럼 전면 재작성이 정답인 경우는 언제인가”였습니다. 저희가 정리한 답은 이렇습니다. 기존 시스템이 표현하는 도메인 모델 자체가 지금의 사업과 어긋나 있고, 그 어긋남을 코드로 계속 우회하고 있다면 그때는 재작성이 유일한 길일 수 있습니다. 하지만 그건 성능 문제와는 다른 질문이고, 다른 근거로 증명해야 하는 주장입니다. 저희 경우 도메인 모델은 여전히 맞았습니다. 틀린 건 그 모델을 담고 있는 배포 단위의 크기였습니다.
새 서비스의 내부 구조에는 의외로 화려한 것이 없습니다. 저장소 인터페이스, 조립 계층, 얇은 컨트롤러가 전부입니다. 그 조합은 흔한 계층형 기본형에서 크게 벗어나지 않았고, 그게 오히려 인수인계를 쉽게 만들었습니다.
이 메모에서 가져갈 원칙
- “느리다”는 증상이지 원인이 아닙니다. 원인을 한 줄로 지목할 수 없다면 아직 아무것도 결정하면 안 됩니다.
- 가설을 죽이는 실험을 먼저 설계합니다. 재작성 가설은 레거시 스택 위에서 한 번 프로파일링하는 것만으로 반증됐습니다.
- 언어를 바꿔도 따라오는 문제는 언어의 문제가 아닙니다. 그 구분이 예산의 자릿수를 바꿉니다.
- 관측 가능성이 없는 상태에서 서비스를 분리하면, 분리가 잘됐는지 판단할 방법 자체가 없습니다.
- 새 시스템은 옛 시스템의 데이터를 설명할 수 있어야 합니다. 섀도 비교의 불일치 목록이 곧 잃어버린 도메인 지식입니다.
- Java는 레거시의 상징이 아니라 장기 운영 시스템의 기본 체력입니다. 문제는 대체로 언어가 아니라 경계에 있습니다.