**Spring Boot 3 + Java 21로 레거시 백엔드 6개월 리빌드한 기록: MSA 전환 기준부터 GitOps·OTel·성능 튜닝까지**
**2025년 상반기 6개월 동안 Spring Boot 3·Java 21로 레거시 백엔드를 리빌드하며 MSA 전환 기준을 다시 세우고, Kubernetes(Helm/GitOps) 운영 체계를 정리하고, OpenTelemetry로 관측성을 구축했다. 실전에서 통했던 선택과 체크리스트를 공유한다.**
조회 7
> 작성 시점: 2025-12-28
> 같은 일을 다시 하게 된다면 “더 빨리 버릴 것”과 “끝까지 지킬 것”이 명확해졌다. 이 글은 그 경계선에 대한 기록이다.


## 0) 왜 리빌드였고, 왜 지금이었나

우리 팀 레거시는 전형적인 “잘 돌아가긴 하는데, 손대면 깨지는” 상태였다.
기술 스택 자체가 낡았다는 말보다 더 정확한 표현은 **의사결정의 흔적이 코드에 응고되어 있었다**는 쪽이다.

- 배포는 사람 손에 의존(야간 배포, “이번엔 제발…”)
- 장애 원인은 로그를 grep으로 추적(그리고 대개 타임아웃)
- 기능 추가는 점점 느려짐(변경 영향 범위를 가늠하기 어려움)
- DB는 사실상 “공유 메모리”(모듈 경계가 DB에서 무너짐)

결정적으로, 2025년 들어 트래픽 패턴이 바뀌었다. 피크가 더 날카로워졌고, 외부 연동이 늘면서 **지연이 곧 비용**이 됐다. 기존 구조로는 “조금 더 최적화” 같은 처방이 의미가 없었다. 그래서 **Spring Boot 3 + Java 21**로 리빌드를 선택했고, 운영은 **Kubernetes + Helm + GitOps**, 관측성은 **OpenTelemetry**로 정리하기로 했다.
이건 유행 따라 한 번 갈아엎어 본 이야기가 아니다.
**장애를 줄이기 위한 시스템 설계의 재정렬**에 가까웠다.
---
## 1) Spring Boot 3 + Java 21 선택: “최신”이 아니라 “덜 빚지는” 선택
Spring Boot 3로 넘어가면서 Jakarta 전환(Jakarta EE) 때문에 고생한다는 얘기를 많이 들었다. 실제로 초반 2~3주는 “왜 import가 이렇게 깨지지?” 같은 잡음이 있었다. 근데 그 고생은 **한 번에 끝나는 고생**이었다.
### 우리가 체감한 Java 21의 실익
- **가상 스레드(Virtual Threads)**: I/O 바운드 API에서 체감이 컸다.
특히 외부 API 호출이 많은 서비스는 스레드 풀 조정에 쓰던 시간이 줄었다.
- **GC 튜닝 스트레스 감소**: 물론 튜닝 자체가 사라지진 않는다. 다만 “버벅임”을 추적할 때 선택지가 더 명확해졌다.
- 코드 레벨에서는 “문법이 좋아졌다”보다, **동시성 모델을 더 솔직하게 쓸 수 있다**는 게 좋았다.
다만 가상 스레드는 만능이 아니다. DB 커넥션 풀(HikariCP)이나 외부 클라이언트 제한이 그대로면, 스레드만 늘려서 더 빨리 막히기도 한다. 그래서 “Virtual Threads 켰더니 빨라졌어요” 같은 결론은 위험하다. 우리는 **먼저 병목을 관측성으로 확인**하고, 그 다음에 적용했다.
---
## 2) MSA 전환 기준: “쪼개면 좋아진다”가 아니라 “쪼개야 하는 이유가 있나”
이번 리빌드에서 제일 많이 받은 질문이 이거였다.
> “MSA로 다 가요? 아니면 모놀리식 유지해요?”
내 결론은 여전히 단순하다.
**MSA는 아키텍처가 아니라 운영 능력의 문제**다.
그래서 우리는 “MSA로 간다/안 간다” 대신, 아래 기준으로 **서비스 분리 여부를 판정**했다.
### 우리가 쓴 MSA 전환 기준(현실판)
아래 중 **3개 이상** 해당하면 분리 후보로 올렸다.
1. **배포 주기가 다르다**
- 어떤 도메인은 하루에도 여러 번 바뀌는데, 어떤 도메인은 한 달에 한 번 바뀐다.
2. **스케일 요구가 다르다**
- 특정 기능만 피크 때 CPU/메모리를 잡아먹는다.
3. **장애 전파를 끊을 필요가 있다**
- 외부 연동/결제/알림 같은 건 실패해도 코어 트랜잭션을 살려야 한다.
4. **데이터 경계가 선명하다**
- “이 테이블은 이 도메인의 것”이 팀 내 합의로 유지된다.
5. **팀 소유권이 명확하다**
- 운영 주체가 분명해야 한다. “다 같이 봐요”는 결국 아무도 안 본다.
반대로 아래에 해당하면, MSA를 미뤘다.
- 데이터 경계가 불명확한데 “일단 서비스부터 쪼개자”
- 분리 후 운영(배포/모니터링/장애 대응)할 인력이 없다
- 호출 그래프가 너무 촘촘해서 네트워크 홉만 늘어날 가능성이 크다
### 실제 적용 사례(우리 팀)
- **결제/정산**은 분리했다: 장애 전파 차단이 명확했고, 배포 주기도 달랐다.
- **회원/프로필**은 초반엔 모듈로 유지했다: 데이터 경계가 생각보다 복잡했고, “분리 비용”이 컸다.
- **알림(메일/푸시)**은 처음부터 분리했다: 실패를 허용할 수 있고, 큐 기반으로 격리하기 쉬웠다.
MSA는 “정답”이 아니라 **조직의 리듬을 코드로 번역하는 방식**이다.
그 번역이 어설프면, 코드가 아니라 사람이 먼저 깨진다.
---
## 3) Kubernetes 운영: Helm + GitOps는 ‘편리함’이 아니라 ‘일관성’이다
Kubernetes로 넘어오면 처음엔 다들 YAML과 싸운다. 우리도 그랬다.
근데 시간이 지나면 싸움의 상대가 YAML이 아니라 **일관성 없는 변경**이라는 걸 깨닫는다.
### Helm을 쓴 이유(진짜 이유)
- 환경별(dev/stage/prod) 설정 차이를 “문서”가 아니라 **차트 값(values)**으로 관리
- 배포 단위가 명확해짐(차트 버전 = 릴리즈 버전)
- 신규 서비스 온보딩이 빨라짐(템플릿이 생김)
Helm 자체는 만능이 아니다. 템플릿이 과해지면 오히려 읽기 어려워진다.
우리는 “차트는 단순하게, 복잡성은 애플리케이션 설정으로”라는 원칙을 뒀다.
### GitOps(Argo CD) 도입 후 달라진 점
GitOps는 “자동 배포”가 핵심이 아니다. 내가 느낀 핵심은 이거였다.
- **클러스터 상태가 Git에 의해 설명된다**
- 누가 무엇을 언제 바꿨는지 추적이 된다
- 핫픽스가 “클러스터에서 직접 수정”으로 새지 않는다
실제로 예전엔 운영 중 급하면 kubectl edit로 땜질하고, 며칠 뒤 원인도 잊어버리는 일이 있었다. GitOps로 바꾸고 나서는 그런 “기억의 부채”가 확 줄었다.
### 운영에서 가장 효과 있었던 습관 3가지
1. **리소스 요청/제한(request/limit)을 기본값으로 강제**
- “나중에 정하자”는 대개 영원히 안 정한다.
2. **HPA는 CPU만 보지 말고, RPS/latency 기반도 고민**
- 가능하면 커스텀 메트릭(예: 요청 지연)을 붙이는 게 더 현실적이었다.
3. **PodDisruptionBudget, readiness/liveness를 초기에 설계**
- 장애는 항상 “배포 중”에 제일 잘 난다.
---
## 4) OpenTelemetry 관측성 구축: 로그를 모으는 게 아니라, 질문을 빠르게 만드는 것
관측성(Observability)을 처음 도입할 때 흔한 오해가 있다.
> “로그/메트릭/트레이스 다 모으면 되죠?”
아니다. 중요한 건 “모으는 것”이 아니라 **운영자가 질문을 던졌을 때 5분 안에 답을 얻는 구조**다.
우리는 OpenTelemetry(OTel)를 기반으로 아래 흐름을 만들었다.
- **Trace**: 요청이 어디서 느려졌는지(서비스 간 호출 포함)
- **Metric**: 시스템이 지금 건강한지(에러율, p95/p99, 큐 적재량)
- **Log**: 왜 그런 일이 일어났는지(구체적 예외, 입력 맥락)
### 도입하면서 제일 먼저 정한 “규약”
- 모든 요청에 **correlation id**를 붙인다(Trace ID와 연결)
- 외부 API 호출은 **client span**으로 남긴다(호출 시간/실패율)
- DB 쿼리는 최소한 **슬로우 쿼리 기준을 통일**한다
- 에러는 “스택트레이스”보다 **도메인 에러 코드/원인**을 구조화한다
### 실제로 도움이 됐던 장애 사례
어느 날 p95가 튀었다. CPU도 괜찮고, DB도 괜찮아 보였다. 예전 같으면 “네트워크인가?” 하며 감으로 찍었을 텐데, 트레이스를 보니 특정 외부 벤더 API가 간헐적으로 2~3초씩 지연되고 있었다. 더 재밌는 건, 우리 서비스는 그 API를 **동기 호출로 묶어둔 상태**였고, 타임아웃도 애매하게 길었다.
해결은 단순했다.
- 타임아웃을 현실적으로 낮추고
- 실패 시 폴백(캐시/기본값/재시도 정책)을 정하고
- 서킷 브레이커를 붙였다
관측성의 가치는 “장애를 막았다”가 아니라, **장애의 형태를 빠르게 확정했다**는 데 있었다.
---
## 5) 성능 튜닝 실전 체크리스트: ‘빠르게’보다 ‘예측 가능하게’
성능 튜닝은 늘 유혹적이다. 근데 리빌드에서 중요한 건 최고 속도가 아니라 **예측 가능성**이다. 운영에서 진짜 무서운 건 평균이 아니라 꼬리(p99)다.
아래는 6개월 동안 실제로 쓰고, 효과 봤던 체크리스트다. (Spring Boot 3 / Java 21 / Kubernetes 기준)
### A. 애플리케이션 레이어
- [ ] **타임아웃 기본값 통일**: connect/read/call 각각 정의
- “무한 대기”는 장애 전파 장치다.
- [ ] **재시도 정책을 명시**: 무조건 재시도 금지(특히 결제/주문)
- idempotency key 설계 없으면 재시도는 사고다.
- [ ] **커넥션 풀 크기 점검**: DB/외부 API 각각
- Virtual Threads를 켜도 풀은 현실의 문이다.
- [ ] **JSON 직렬화 비용 확인**: 큰 payload는 압축/필드 축소/페이지네이션
- [ ] **캐시 전략**: “무조건 Redis”보다 TTL/무효화 규칙부터
- [ ] **비동기 처리 분리**: 알림/집계/리포트는 큐로 빼기
### B. 데이터베이스 레이어
- [ ] **인덱스는 ‘쿼리’와 함께 관리**: 인덱스만 추가하면 쓰기 성능이 죽는다
- [ ] **N+1은 코드 리뷰에서 자동으로 잡기**
- [ ] **트랜잭션 범위 최소화**: 외부 호출을 트랜잭션 안에 넣지 않기
- [ ] **락 경합 포인트 찾기**: “가끔 느림”은 락일 때가 많다
- [ ] **슬로우 쿼리 기준 합의**: 예: 200ms 이상은 무조건 이슈
### C. JVM/런타임
- [ ] **컨테이너 리소스에 맞춘 힙 설정**(기본값 믿지 않기)
- [ ] **GC 로그는 ‘필요할 때 켜는’ 게 아니라, 기준을 정해 수집**
- [ ] **스레드 덤프/힙 덤프 획득 절차 문서화**(장애 시 사람은 생각이 느려진다)
### D. Kubernetes/인프라
- [ ] **readiness probe는 “실제로 준비됐나”를 본다**(DB 연결, 필수 의존성)
- [ ] **liveness probe는 신중하게**: 잘못 걸면 자가 DoS가 된다
- [ ] **HPA 스케일 기준 점검**: 스케일 아웃이 늦으면 p99가 무너진다
- [ ] **Pod anti-affinity / topology spread**: 한 노드에 몰리면 한 번에 터진다
- [ ] **네임스페이스/리소스쿼터**: “누가 다 먹었지?”를 막는다
이 체크리스트의 목적은 “고급 기술”이 아니라 **기본값을 제거하는 것**이다.
기본값은 편하지만, 운영에선 대개 “누가 결정했는지 모르는 결정”이 된다.
---
## 6) 6개월 리빌드의 결산: 결국은 ‘기술’보다 ‘경계’였다
이번 리빌드에서 가장 큰 수확을 하나만 꼽으면, 나는 “성능”도 “클라우드 네이티브”도 아니고 **경계(boundary)**라고 말하겠다.
- 도메인 경계(어디까지가 한 서비스의 책임인가)
- 운영 경계(누가 소유하고, 누가 깨우며, 누가 고치는가)
- 장애 경계(실패를 어디까지 허용하고, 어디서 차단하는가)
Spring Boot 3 + Java 21은 그 경계를 구현하기 좋은 도구였고,
Kubernetes(Helm/GitOps)는 그 경계를 흔들리지 않게 고정하는 장치였고,
OpenTelemetry는 그 경계가 깨질 때 **어디가 먼저 갈라졌는지** 보여주는 조명에 가까웠다.
---
## 7) 다음에 한다면 이렇게 한다(후회/교훈 몇 가지)
- **MSA는 더 천천히 쪼갠다. 대신 모듈 경계는 더 엄격히 한다.**
처음부터 네트워크로 분리하면 “경계 비용”이 너무 빨리 온다.
- **관측성은 초반에 더 강하게 표준화한다.**
서비스가 늘어난 뒤에 규약 맞추려면, 그게 더 레거시가 된다.
- **성능 튜닝은 ‘측정-가설-검증’만 한다. 감으로 하지 않는다.**
감은 빠르지만, 팀에 남지 않는다.
---
## 태그
- Spring Boot 3, Java 21, 레거시 리빌드, MSA, 마이크로서비스, Kubernetes, Helm, GitOps, Argo CD, OpenTelemetry, Observability, 성능 튜닝, JVM, 백엔드, 클라우드 네이티브, 관련주제, 블로그
---