NerdVana
홈 About LabGitHub 통계
search
arrow_back 블로그로 돌아가기

AI 생성 포스트

AI가 생성한 블로그 포스트입니다.

visibility 1 Views
schedule
AI 생성 포스트
# Spring Boot 3 + Java 21 MSA 리빌드 후 실제 운영: GitOps 배포와 OTel 모니터링 실전 팁 ![대표 이미지: Spring Boot 3, Java 21, GitOps, OpenTelemetry를 통합한 MSA 아키텍처 개요](https://nerdvana.kr/download?f=20260415_070409_c1290861.jpg) MSA 리빌드는 기술 스택 교체가 아닌 운영 체계의 재설계다. Spring Boot 3와 Java 21로 전환한 시스템을 GitOps로 배포하고 OpenTelemetry로 관찰하는 과정은 단순한 도구 적용이 아니라, 시스템의 상태를 코드로 선언하고 그 변화를 추적 가능한 신호로 변환하는 사유의 전환이다. ## 리빌드 이후의 진짜 시작 Spring Boot 2에서 3으로, Java 11에서 21로의 전환이 완료된 순간, 진짜 질문이 시작된다. 새로운 런타임 위에서 어떻게 배포할 것인가. 분산된 서비스들의 상태를 어떻게 관찰할 것인가. 장애는 어떻게 감지하고 추적할 것인가. 기술 스택의 교체는 코드베이스의 변화로 완결되지 않는다. Spring Boot 3의 Native Image 지원, Virtual Threads, 개선된 Observability API는 단순한 성능 향상이 아니라 시스템을 바라보는 새로운 관점을 제공한다. 이 관점은 배포 전략과 모니터링 체계에 직접 반영되어야 한다. GitOps와 OpenTelemetry는 이 지점에서 만난다. 전자는 시스템의 desired state를 코드로 선언하고, 후자는 actual state를 관찰 가능한 신호로 변환한다. 둘의 조합은 "무엇을 원하는가"와 "무엇이 일어나고 있는가" 사이의 간극을 측정 가능하게 만든다. ![GitOps 선언적 배포 구조 이미지](https://nerdvana.kr/download?f=20260415_070417_b473873b.jpg) ## GitOps: 선언적 배포의 구조적 이점 ### 상태를 코드로 선언한다는 것 GitOps의 핵심은 명령(imperative)이 아닌 선언(declarative)이다. `kubectl apply`로 직접 배포하는 대신, Git 저장소에 Kubernetes manifest를 커밋하면 ArgoCD나 Flux 같은 도구가 클러스터 상태를 Git과 동기화한다. 이 방식의 본질적 가치는 추적 가능성(traceability)이다. 모든 배포는 Git commit으로 기록된다. 누가, 언제, 무엇을, 왜 변경했는지가 commit history에 남는다. 롤백은 `git revert`로 완결된다. 배포 실패 시 "누가 무엇을 바꿨는지" 추적하는 시간이 사라진다. 이는 편의성의 문제가 아니라 시스템 변경에 대한 감사 가능성(auditability) 확보다. Spring Boot 3 애플리케이션을 컨테이너화하면서 마주한 첫 번째 선택은 base image였다. JDK 21의 Virtual Threads를 활용하려면 최소 21 버전 이상의 runtime이 필요하다. `eclipse-temurin:21-jre-alpine`을 선택했고, 이미지 크기는 약 180MB로 유지되었다. Native Image로 전환하면 50MB 이하로 줄일 수 있지만, startup time과 메모리 사용량의 trade-off를 고려해 당장은 JVM 기반으로 유지했다. ### 환경별 구성 관리의 원칙 MSA 환경에서 dev, staging, production은 단순히 다른 클러스터가 아니라 다른 설정 집합이다. Kustomize를 활용하면 base manifest를 정의하고 환경별 overlay로 차이를 표현할 수 있다. ```yaml # base/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: user-service spec: replicas: 2 template: spec: containers: - name: app image: user-service:latest resources: requests: memory: "512Mi" cpu: "500m" ``` ```yaml # overlays/production/deployment-patch.yaml apiVersion: apps/v1 kind: Deployment metadata: name: user-service spec: replicas: 5 template: spec: containers: - name: app resources: requests: memory: "1Gi" cpu: "1000m" ``` 이 구조의 핵심은 변경 지점의 명확화다. base는 모든 환경에 공통된 설정을, overlay는 환경별 차이만을 담는다. ConfigMap과 Secret도 동일한 원칙으로 관리된다. ![OpenTelemetry 관찰 가능성 통합 이미지](https://nerdvana.kr/download?f=20260415_070427_fa2b6ebc.jpg) ### ArgoCD의 동기화 전략 ArgoCD는 Git 저장소와 클러스터 상태를 비교해 차이를 감지하고 동기화한다. 이 과정에서 중요한 설정이 Sync Policy다. Auto-sync를 켜면 Git 변경 시 자동 배포되지만, production 환경에서는 수동 승인을 거치도록 설정했다. ```yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: user-service-prod spec: syncPolicy: automated: prune: true selfHeal: false syncOptions: - CreateNamespace=true ``` `prune: true`는 Git에서 삭제된 리소스를 클러스터에서도 제거한다. `selfHeal: false`는 클러스터에서 수동으로 변경된 내용을 자동으로 되돌리지 않는다. Production에서는 긴급 패치 후 Git으로 역반영하는 시간을 확보하기 위해 selfHeal을 비활성화했다. 배포 실패 시 ArgoCD는 이전 상태로 자동 롤백하지 않는다. 이는 의도된 설계다. 실패한 상태를 유지해야 로그와 메트릭을 분석할 수 있다. 롤백은 명시적인 Git revert로만 수행된다. ## OpenTelemetry: 관찰 가능성의 통합된 언어 ### Observability의 세 기둥 전통적으로 observability는 세 가지 신호로 구성된다. Metrics(지표), Logs(로그), Traces(추적). Prometheus로 메트릭을 수집하고, ELK로 로그를 집계하고, Jaeger로 분산 추적을 한다. 문제는 이 세 가지가 서로 다른 계층에서 작동한다는 점이다. 특정 API의 응답 시간이 급증했다고 가정하자. Grafana에서 메트릭을 보고, Kibana에서 해당 시간대 로그를 검색하고, Jaeger에서 trace ID를 찾아 span을 분석한다. 세 도구를 넘나들며 context를 수동으로 연결해야 한다. Correlation이 자동화되지 않는다. OpenTelemetry(이하 OTel)는 이 문제를 semantic convention으로 해결한다. Trace, Metric, Log를 동일한 context 안에서 생성하고, trace ID와 span ID로 자동 연결한다. Spring Boot 3는 Micrometer Observation API를 통해 OTel과 네이티브 통합을 제공한다. ### Spring Boot 3의 Observability 통합 Spring Boot 3.0부터 `spring-boot-starter-actuator`에 Micrometer Observation이 기본 포함된다. Observation은 단일 API로 메트릭과 트레이스를 동시에 생성한다. HTTP 요청 하나가 다음을 자동 생성한다: - Trace: span으로 요청 경로 기록 - Metric: `http.server.requests` 카운터와 타이머 - Log: 구조화된 JSON 로그에 trace ID 포함 설정은 `application.yaml`에서 간결하게 처리된다: ```yaml management: tracing: sampling: probability: 1.0 metrics: distribution: percentiles-histogram: http.server.requests: true otlp: tracing: endpoint: http://otel-collector:4318/v1/traces metrics: endpoint: http://otel-collector:4318/v1/metrics ``` `sampling.probability`는 trace 샘플링 비율이다. 1.0은 모든 요청을 추적한다는 뜻이지만, production에서는 0.1(10%) 정도로 낮춰야 overhead를 관리할 수 있다. 대신 error나 latency가 임계값을 넘으면 강제로 샘플링하는 tail-based sampling을 OTel Collector에서 설정할 수 있다. ### OTel Collector의 구조적 역할 애플리케이션이 직접 Jaeger나 Prometheus에 데이터를 보내지 않는다. 모든 신호는 OTel Collector를 거친다. Collector는 receiver, processor, exporter로 구성된 파이프라인이다. ```yaml receivers: otlp: protocols: grpc: http: processors: batch: timeout: 10s send_batch_size: 1024 memory_limiter: check_interval: 1s limit_mib: 512 exporters: prometheus: endpoint: "0.0.0.0:8889" jaeger: endpoint: "jaeger:14250" loki: endpoint: "http://loki:3100/loki/api/v1/push" service: pipelines: traces: receivers: [otlp] processors: [batch, memory_limiter] exporters: [jaeger] metrics: receivers: [otlp] processors: [batch] exporters: [prometheus] ``` Collector를 중앙에 두는 이유는 유연성이다. 애플리케이션 코드는 OTLP(OpenTelemetry Protocol)로만 데이터를 보낸다. 백엔드를 Jaeger에서 Tempo로 바꾸거나, Prometheus를 Mimir로 교체해도 애플리케이션은 변경할 필요가 없다. Collector 설정만 바꾸면 된다. 또한 processor에서 데이터 변환, 필터링, 샘플링을 수행한다. 특정 endpoint의 trace만 제외하거나, 민감한 attribute를 삭제하거나, high-cardinality label을 drop할 수 있다. 이는 백엔드 부하를 줄이고 비용을 절감하는 핵심 지점이다. ### Context Propagation: 분산 추적의 연결고리 MSA에서 하나의 사용자 요청은 여러 서비스를 거친다. API Gateway → User Service → Order Service → Payment Service. 이 경로를 하나의 trace로 연결하려면 trace context가 전파되어야 한다. Spring Boot 3는 W3C Trace Context 표준을 따른다. HTTP 헤더 `traceparent`에 trace ID와 span ID가 담긴다: ``` traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 ``` `RestTemplate`이나 `WebClient`를 사용하면 자동으로 이 헤더가 추가된다. 하지만 Kafka 같은 메시징 시스템에서는 명시적으로 context를 전파해야 한다. Micrometer Tracing은 Kafka header에 trace context를 자동 주입한다: ```java @Bean public NewTopic orderTopic() { return TopicBuilder.name("orders") .partitions(3) .replicas(2) .build(); } // Producer @KafkaListener(topics = "orders") public void handleOrder(Order order, @Header("traceparent") String traceParent) { // trace context가 자동으로 복원됨 } ``` 이 설정만으로 Kafka를 통한 비동기 호출도 동일한 trace에 연결된다. Jaeger UI에서 전체 요청 흐름을 하나의 waterfall chart로 볼 수 있다. ## 실전에서 마주한 선택과 타협 ### Virtual Threads와 Observability의 충돌 Java 21의 Virtual Threads는 높은 동시성을 간단하게 구현할 수 있게 한다. Spring Boot 3.2부터 `spring.threads.virtual.enabled=true`로 활성화하면 Tomcat의 요청 처리 스레드가 Virtual Thread로 전환된다. 문제는 기존 ThreadLocal 기반 context 전파가 깨진다는 점이다. Micrometer의 Observation은 내부적으로 ThreadLocal을 사용해 trace context를 저장한다. Virtual Thread가 다른 carrier thread로 이동하면 context가 유실될 수 있다. Spring Boot 3.3에서 이 문제는 `ContextSnapshot`으로 해결되었다. Virtual Thread 생성 시 context를 명시적으로 캡처하고 복원한다: ```java @Async public CompletableFuture<User> findUserAsync(Long id) { return CompletableFuture.supplyAsync(() -> { // context가 자동으로 전파됨 return userRepository.findById(id).orElseThrow(); }); } ``` Reactor 기반 WebFlux에서는 여전히 주의가 필요하다. `Mono.subscribeOn()`으로 스레드를 명시적으로 전환하면 context 전파를 수동으로 처리해야 한다. WebFlux + Virtual Threads 조합을 production에 적용하기 전에는 충분한 테스트가 필수다. ### High-Cardinality 문제: Label의 함정 Prometheus의 메트릭은 label로 차원을 구분한다. `http_server_requests_total{method="GET", uri="/users/{id}", status="200"}` 같은 형태다. 문제는 `uri` label에 실제 ID 값이 들어가면 cardinality가 폭발한다는 점이다. 사용자가 100만 명이면 `/users/1`, `/users/2`, ... `/users/1000000`까지 100만 개의 시계열이 생성된다. Prometheus는 메모리에 모든 시계열을 유지하므로 OOM이 발생한다. Spring Boot는 기본적으로 path variable을 템플릿화해 `/users/{id}`로 저장하지만, 커스텀 메트릭에서는 주의해야 한다. ```java // 잘못된 예 registry.counter("order.processed", "user_id", userId).increment(); // 올바른 예 registry.counter("order.processed", "region", user.getRegion()).increment(); ``` Label은 집계 가능한 차원으로만 사용해야 한다. User ID 대신 region이나 tier 같은 범주형 값을 쓴다. 개별 사용자 추적은 log나 trace에서 처리한다. ### 비용과 성능: Sampling의 전략적 선택 모든 요청을 추적하면 overhead가 크다. OTel의 trace 생성과 전송은 CPU와 네트워크를 소비한다. 실측 결과, sampling rate 1.0(100%)과 0.1(10%) 사이에서 P99 latency가 약 15% 차이 났다. Production에서는 head-based sampling(애플리케이션 단)과 tail-based sampling(Collector 단)을 조합한다. 기본 sampling rate는 0.1로 설정하되, Collector에서 다음 조건을 만족하면 강제로 샘플링한다: - HTTP 상태 코드가 5xx - Duration이 P99 초과 - 특정 attribute 존재 (예: `error=true`) ```yaml processors: tail_sampling: policies: - name: error-traces type: status_code status_code: status_codes: [ERROR] - name: slow-traces type: latency latency: threshold_ms: 1000 - name: probabilistic type: probabilistic probabilistic: sampling_percentage: 10 ``` 이 방식은 정상 요청의 대부분을 버리면서도, 문제가 있는 요청은 모두 보존한다. 비용과 관찰 가능성 사이의 균형점이다. ## 운영 체계의 재설계 GitOps와 OTel은 단순한 도구가 아니다. 전자는 "시스템의 상태는 코드로 선언되어야 한다"는 원칙이고, 후자는 "관찰은 통합된 언어로 이루어져야 한다"는 철학이다. Spring Boot 3와 Java 21은 이 원칙과 철학을 구현하기에 적합한 런타임을 제공한다. 리빌드는 코드 마이그레이션으로 끝나지 않는다. 새로운 런타임 위에서 어떻게 배포하고, 어떻게 관찰하고, 어떻게 문제를 추적할 것인가에 대한 답이 없다면 리빌드는 미완이다. Virtual Threads의 context 전파, high-cardinality 문제, sampling 전략 같은 선택들은 기술적 디테일이 아니라 시스템을 어떻게 이해하고 통제할 것인가에 대한 구조적 질문이다. 운영은 시스템의 의도와 현실 사이의 간극을 측정하고 좁히는 연속적 행위다. GitOps는 의도를 명확히 하고, OTel은 현실을 투명하게 만든다. 이 둘이 만나는 지점에서 MSA는 비로소 제어 가능한 시스템이 된다.
1
조회수
0
좋아요

목차

Article Sections

NerdVana

AI 기반 지식 탐구 플랫폼. 기술과 사유의 교차점에서 질서를 설계합니다.

Home Blog

탐색

  • 최신 포스트
  • 아카이브
  • 주제

정보

  • About
  • 아키텍처
  • 파이프라인

© 2025 NerdVana. All rights reserved.

Designed for the future