NerdVana
  • 홈
  • About
  • 아카이브
  • 메인으로
블로그로 돌아가기

**Spring Boot 3·Java 21로 백엔드 리빌드한 6개월: MSA 전환, Kubernetes 배포, OpenTelemetry 관측성, 그리고 성능 튜닝 체크리스트**

**레거시 모놀리스를 Spring Boot 3·Java 21로 리빌드하며 MSA 전환 전략을 정리했다. Docker/Kubernetes 배포 흐름, OpenTelemetry 기반 관측성(로그·메트릭·트레이스) 구축, 실무에서 바로 쓰는 성능 튜닝 체크리스트까지 시행착오 중심으로 공유한다.**
조회 4
# 레거시 백엔드를 Spring Boot 3 + Java 21로 리빌드한 6개월의 기록 ![대표 이미지: Spring Boot 3와 Java 21 기반 MSA 백엔드 개발 환경](https://paycurr.com/download?f=20251227_100504_4rpoKR7q.png) "그냥 새로 만들자"는 말은 언제나 달콤하게 들린다. 문제는 그다음이다. 올해 초부터 한 서비스의 백엔드를 Spring Boot 3와 Java 21로 리빌드하면서, 나는 모놀리스를 무턱대고 쪼개는 대신 '운영 가능한 MSA'에 방점을 찍었다. 솔직히 말하면, 기술 선택보다 더 어려웠던 건 팀의 리듬을 바꾸는 일이었다. ![MSA 모듈 경계 설정 다이어그램](https://paycurr.com/download?f=20251227_100535_cXi9EyBR.png) 이 글은 그 6개월의 기록이다. 정답이라기보다는, 실무에서 덜 다치기 위해 내가 결국 선택한 방식들을 하나의 흐름으로 정리해본다. --- ## 왜 Spring Boot 3과 Java 21이었나 처음엔 "최신이니까"가 아니라, 아주 현실적인 이유가 있었다. ![Docker 멀티스테이지 빌드와 레이어 캐시 구조](https://paycurr.com/download?f=20251227_100604_xAfSpULB.png) Spring Boot 3과 Spring Framework 6은 Jakarta EE 전환 이후 생태계가 정리되면서, 언젠가 해야 할 마이그레이션을 더 미루기 어려운 시점에 와 있었다. Java 21은 LTS(Long Term Support) 버전으로 장기 지원이 보장되고, 성능과 GC 개선이 누적되어 서버 사이드에서 체감이 컸다. 무엇보다 중요한 건 관측성과 운영 자동화를 염두에 둔 설계가 훨씬 자연스러워졌다는 점이다. 개인적으로 Java 21은 새로운 문법이 예뻐서가 아니라, 운영 비용을 예측 가능하게 만드는 선택이었다. 참고로 virtual thread 도입은 서두르지 않았는데, 이 이야기는 뒤에서 다시 다룬다. ![Kubernetes 핵심 운영 설정 다이어그램](https://paycurr.com/download?f=20251227_100631_hqlmxUGw.png) --- ## MSA 전환 전략: 쪼개기보다 경계 세우기부터 MSA 전환에서 가장 흔한 실패는 서비스 수가 늘어나는 만큼 장애 지점과 배포 부담도 같이 늘어난다는 사실을 과소평가하는 것이다. 그래서 나는 다음 순서를 고집했다. ### 먼저 모놀리스 안에서 모듈 경계를 확정하기 ![OpenTelemetry 관측성 3축과 Trace 분석 플로우](https://paycurr.com/download?f=20251227_100656_HXa5TBmg.png) 처음부터 물리적으로 분리하지 않았다. 대신 모놀리스 내부를 모듈 단위로 강하게 분리했다. `order`, `payment`, `member` 같은 도메인별 모듈을 나누고, 모듈 간 호출은 되도록 인터페이스로 제한했다. DB도 당장 분리하지 않고, 테이블 소유권만 명시했다. 어떤 테이블이 어떤 도메인에 속하는지를 문서가 아닌 코드 수준에서 정리한 것이다. 이 단계에서 효과가 컸던 건, MSA로 가는 길을 열어두면서도 배포는 여전히 단순하다는 점이었다. 팀원들이 도메인 경계를 말로만이 아니라 코드로 체감하게 된다는 것도 큰 수확이었다. ### Strangler Fig 패턴으로 가장 안전한 것부터 꺼내기 실제로 분리할 때는 핵심이 아니라 가장 안전한 것부터 꺼냈다. 이미지 리사이징이나 파일 업로드처럼 비즈니스 핵심도가 낮은 기능, 어드민 조회 API처럼 쓰기가 없는 기능, 배치나 알림 발송처럼 비동기 처리로 분리하기 쉬운 기능이 첫 대상이었다. 이건 심리적으로도 중요하다. 첫 분리에서 사고가 나면 조직이 MSA 자체를 싫어하게 된다. 반대로 첫 성공은 "이 방식이 통하는구나"라는 학습을 만든다. ### 데이터 분리는 생각보다 잔인하다 DB를 서비스별로 딱 분리하면 깔끔해 보이지만, 현실에선 조인과 트랜잭션이 발목을 잡는다. 내가 택한 접근은 이랬다. 초기에는 공유 DB를 유지하되 스키마와 테이블 소유권을 명확히 했다. 분리 대상 서비스는 먼저 쓰기 경로부터 독립시키고, 조회는 한동안 뷰나 리드 모델로 타협했다. Kafka를 활용한 이벤트 기반 동기화는 필요할 때만 확장했다. 핵심은 정규화된 이상보다 운영 가능한 현실을 먼저 확보하는 것이다. --- ## Docker/Kubernetes 배포: 사람의 불안을 줄이는 장치 MSA로 갈수록 배포는 잦아진다. 잦은 배포를 가능하게 하려면, 배포 자체가 덜 무섭고 덜 예외적이어야 한다. ### 작고 빠르고 예측 가능한 Docker 이미지 실무에서 자주 보는 함정이 이미지 크기와 빌드 시간이다. 나는 JDK 21 기반으로 빌드하고, 런타임 이미지는 JRE나 distroless로 경량화한다. 애플리케이션은 레이어 캐시가 잘 먹도록 구성해서 빌드 시간을 단축한다. 레이어 캐시란 Docker가 변경되지 않은 부분을 재사용하는 기능으로, 의존성과 소스코드를 분리해 빌드하면 소스만 바뀔 때 의존성 다운로드를 건너뛸 수 있다. Spring Boot 3에서 빌드팩도 좋은 선택지다. 다만 팀이 Dockerfile을 더 잘 이해한다면, 명시적인 Dockerfile이 운영에 유리한 순간이 있다. 사람이 납득할 수 있어야 문제가 생겼을 때 대응할 수 있기 때문이다. ### Kubernetes에서 중요한 건 YAML이 아니라 운영 신호다 Kubernetes 배포에서 내가 가장 먼저 고정하는 건 네 가지다. 첫째, Readiness/Liveness Probe 설정이다. 살아있다는 것과 받을 준비가 됐다는 것은 다르다. 둘째, Resource Requests/Limits로 안정적인 스케줄링과 OOM(Out of Memory) 방지를 확보한다. 셋째, HPA(Horizontal Pod Autoscaler)를 설정하되 CPU만 보지 말고 가능하면 RPS(초당 요청 수)나 latency 기반 지표도 고려한다. 넷째, PodDisruptionBudget으로 노드 유지보수나 롤링 업데이트 시 서비스 흔들림을 방지한다. 여기서 관건은 프로브 엔드포인트다. 나는 `/actuator/health/readiness`와 `/actuator/health/liveness`를 기본으로 두고, DB나 외부 의존성 체크는 readiness에만 묶는다. liveness에 DB를 넣어버리면 DB가 잠깐 흔들릴 때 앱이 줄줄이 재시작하면서 장애를 더 키운다. --- ## OpenTelemetry 관측성: 원인까지 추적할 수 있어야 한다 MSA로 가면 장애가 났을 때 제일 먼저 드는 감정은 이런 것이다. "이거 우리 서비스 문제야? 결제 쪽이야? 아니면 게이트웨이야?" 그래서 관측성은 옵션이 아니라 전제가 된다. ### Logs, Metrics, Traces의 세 축 로그는 사건의 서술이다. 메트릭은 건강 상태의 수치화다. 트레이스는 분산 환경에서의 인과관계를 보여준다. OpenTelemetry의 좋은 점은 이 셋을 각자도생이 아니라 같은 철학으로 묶어준다는 것이다. ### Trace ID의 일상화가 가져온 변화 가장 큰 변화는 장애 분석 방식이었다. 예전에는 Kibana에서 에러 로그를 검색하고, 추측하고, 관련 로그를 더 검색하고, 감으로 결론을 내렸다. 지금은 에러가 난 요청의 traceId로 시작해서 어떤 서비스에서 시간을 얼마나 썼는지 한눈에 보고, 병목 지점을 바로 파악한다. 이건 단순히 도구의 문제가 아니라 팀의 대화가 바뀌는 것이다. "아마 DB가 느린 듯"이라는 추측이 "payment-service에서 외부 PG 호출이 p95 기준 1.2초를 찍고, 그 구간이 전체 latency의 70%야"라는 구체적인 진단으로 바뀐다. p95란 전체 요청 중 95%가 이 시간 안에 처리된다는 의미다. 추측이 줄어들면 회의도 짧아진다. ### 로그에는 반드시 구조를 준다 나는 로그를 최대한 JSON 형식으로 구조화한다. 그리고 필드 규칙을 정해버렸다. `trace_id`와 `span_id`는 기본이고, 가능한 경우 `user_id`, `order_id` 같은 도메인 키를 포함한다. `http.method`, `http.status_code`, `duration_ms` 같은 표준 필드도 넣는다. 이건 나중에 검색이 아니라 조립을 가능하게 한다. 사람이 로그를 읽기 전에 시스템이 먼저 읽을 수 있어야 자동화된 분석과 알림이 가능해진다. --- ## 성능 튜닝 체크리스트: 튜닝은 요령이 아니라 순서다 성능은 늘 복합 문제다. 그래서 나는 체크리스트를 대충 좋은 팁이 아니라 순서가 있는 절차로 둔다. ### 먼저 측정한다 API별 latency를 p50, p95, p99로 본다. p95가 튀는 API는 대개 외부 호출, DB, 락 중 하나가 원인이다. OpenTelemetry trace에서 시간을 먹는 span을 먼저 찾는다. 측정 없이 튜닝하면 보통 열심히 했는데 효과 없음이 된다. ### DB는 인덱스보다 먼저 쿼리 형태를 의심한다 실무에서 많이 본 패턴들이 있다. N+1 문제는 여전히 성능 저하의 주범이다. JPA를 쓰면 특히 그렇다. 페이지네이션에서 offset이 커지면 급격히 느려진다. 인덱스 추가로 해결될 것 같지만 사실은 불필요한 조인이나 조건이 문제인 경우가 많다. 내 경험상 가장 효과가 컸던 개선은 이것이었다. 자주 조회하는 화면을 위해 읽기 전용 리드 모델을 따로 두는 것이다. 별도 테이블이나 Materialized View, 캐시를 활용한다. 쓰기 모델은 정합성 중심으로, 읽기 모델은 응답 속도 중심으로 설계한다. ### JVM/GC는 안정성을 우선한다 Java 21에서 GC는 기본 설정만으로도 꽤 안정적이다. 그래서 나는 몇 가지만 고정했다. 컨테이너 환경에서 메모리 인식 옵션이 제대로 동작하는지 확인한다. 대부분 기본으로 잘 동작하지만 런타임이나 이미지에 따라 점검이 필요하다. OOM 발생 시 힙덤프와 에러 로그를 남기도록 설정한다. GC 로그는 항상 켜두기보다 문제가 있을 때 바로 켤 수 있게 런북에 절차를 넣어둔다. 쿠버네티스에서는 requests/limits 설정이 사실상 JVM 튜닝의 일부가 된다. 메모리 limit이 타이트하면 GC가 아무리 좋아도 OOMKilled가 발생한다. ### Virtual Thread는 만병통치약이 아니다 Java 21의 virtual thread는 매력적이다. 하지만 나는 외부 I/O가 압도적으로 많은 서비스부터 제한적으로 실험했다. DB 드라이버와 HTTP 클라이언트가 virtual thread 친화적인지, 모니터링 지표가 어떻게 변하는지, 장애 시 디버깅 난이도가 올라가진 않는지를 확인했다. 결론적으로, 도입 자체는 쉬운데 운영에서의 해석이 어려울 수 있다는 걸 배웠다. 그래서 전면 도입보다 구간 도입이 안전했다. ### 캐시는 적중률보다 무효화 전략이 먼저다 캐시는 성능을 올리지만 복잡도를 같이 올린다. TTL(Time To Live)로만 버티면 데이터 신선도가 흔들린다. 이벤트 기반 무효화는 깔끔하지만 운영이 까다롭다. 나는 보통 이렇게 타협한다. 조회 폭주 구간에서는 짧은 TTL 캐시와 백그라운드 리프레시를 조합한다. 정합성이 중요한 구간에서는 캐시 대신 쿼리 최적화, 인덱스, 리드 모델로 해결한다. --- ## 리빌드의 결론 이번 리빌드에서 가장 의미 있었던 순간은 성능이 20% 좋아졌을 때가 아니었다. 배포가 하루 3번으로 늘어나도 팀이 더 이상 겁내지 않게 된 날이었다. 배포는 자동화되어 있고, 장애는 trace로 추적 가능하고, 튜닝은 감이 아니라 절차로 진행되고, 서비스 경계는 문서가 아니라 코드로 남아 있다. 이런 상태가 되면 MSA는 멋있어 보이는 아키텍처가 아니라 조직의 속도를 유지하는 시스템이 된다. --- ## 다시 시작한다면 이 순서로 한다 정리 차원에서, 2025년 기준으로 다시 한다면의 우선순위를 적어본다. 첫째, 모놀리스 내부 모듈 경계를 확정한다. 바로 MSA로 뛰지 않는다. 둘째, OpenTelemetry로 트레이스부터 구축한다. 관측성 없이 분리하지 않는다. 셋째, Kubernetes 프로브와 리소스, 롤링 전략을 정비해 운영 신뢰를 확보한다. 넷째, DB 소유권과 쓰기 경로를 분리한다. 데이터가 제일 늦게 변한다. 다섯째, 성능은 p95/p99와 trace 기반으로 접근한다. 감으로 튜닝하지 않는다. --- 다음 글에서는 실제로 우리가 겪었던 관측성 구축 초반의 함정을 다루려 한다. 샘플링 비율 설정, 로그 비용 폭증, trace context 누락, 게이트웨이 전파 문제 같은 것들이다. 비슷한 리빌드를 진행 중이라면 어떤 지점이 가장 막히는지 댓글로 알려주면 좋겠다. 그 부분부터 정리해볼 생각이다.
4
조회수
0
좋아요
0
공유

© 2025 NerdVana. All rights reserved.

홈으로 블로그