배경 이전 글에서 ECS → EKS 마이그레이션을 다뤘다. 그 과정에서 모니터링도 함께 전환했는데, 기존에는 Datadog을 쓰고 있었다. Datadog의 문제 Datadog 자체는 좋은 도구다. APM, 로그, 메트릭, 대시보드까지 올인원으로 제공하고, 에이전트 하나만 붙이면 바로 동작한다. 하지만 우리 상황에서는 비용이 문제였다. 호스트 기반 과금 - 서비스가 늘어날수록 비용이 선형으로 증가한다. dev, stage, prod 환경을 전부 모니터링하면 호스트 수가 금방 늘어난다 로그 과금이 특히 부담 - 로그 수집량 기반으로 과금되는데, Spring Boot 앱의 로그량이 만만치 않다. 로그 레벨을 내리면 장애 시 원인 추적이 어려워진다 기능 대비 사용률이 낮았다 - Datadog이 제공하는 풍부한 기능 중 실제로 쓰는 건 APM 트레이싱, 로그 검색, 기본 메트릭 정도였다. 비용 대비 활용도가 낮았다 EKS로 마이그레이션하면서 인프라를 새로 구성하는 김에, 모니터링도 오픈소스 기반으로 전환하기로 했다. 왜 LGTM 스택인가 Grafana Labs에서 밀고 있는 LGTM 스택은 이름 그대로 네 가지 컴포넌트로 구성된다: | 컴포넌트 | 역할 | Datadog 대응 | |||| | Loki | 로그 수집/조회 | Log Management | | Grafana | 시각화/대시보드 | Dashboards | | Tempo | 분산 트레이싱 | APM | | Mimir | 메트릭 장기 저장 | Metrics | 선택 이유는 단순하다: 비용 - 오픈소스 셀프 호스팅이라 인프라 비용만 든다. Helm 차트로 EKS에 배포하면 끝 통합 - Grafana에서 로그 → 트레이스 → 메트릭을 하나의 UI에서 오갈 수 있다. Trace ID 하나로 전부 연결된다 표준 - OpenTelemetry(OTel) 네이티브 지원. 벤더 종속 없이 표준 프로토콜로 데이터를 수집한다 확장성 - 각 컴포넌트가 독립적이라 필요한 것만 스케일할 수 있다. 로그가 많으면 Loki만 늘리면 된다 전체 아키텍처 데이터 흐름 모니터링 데이터의 흐름을 한 문장으로 요약하면 이렇다: Spring Boot → OTel Java Agent가 자동 계측 → OTel Collector 사이드카가 수신 → 로그/트레이스/메트릭을 Loki/Tempo/Mimir로 전송 → S3에 장기 저장 → Grafana에서 조회 핵심은 OTel Collector를 사이드카로 배치한 것이다. DaemonSet이 아닌 사이드카를 선택한 이유: 격리 - 서비스별 OTel 설정을 독립적으로 관리할 수 있다. 노이즈 필터링 같은 설정이 서비스마다 다르다 리소스 제어 - 서비스별로 Collector의 CPU/메모리를 독립적으로 조절할 수 있다 장애 격리 - Collector가 죽어도 해당 Pod에만 영향을 준다. DaemonSet이면 노드 전체가 영향받는다 인프라 구성 LGTM 스택 전체를 assist-tools namespace에 배포했다. 전용 Karpenter NodePool로 격리하고, Taint/Toleration으로 다른 워크로드가 섞이지 않게 했다. | 컴포넌트 | 배포 모드 | Replicas | 스토리지 | ||||| | Grafana | Single | 1 | PVC 10Gi | | Loki | SimpleScalable | backend/read/write 각 1 | S3 (my-lgtm) | | Tempo | Distributed | distributor 2, ingester 2, querier 2 | S3 (my-cluster-lgtm) | | Mimir | Classic | 각 컴포넌트 1 | S3 (my-lgtm) | OTel Java Agent - 애플리케이션 계측 자동 계측 Spring Boot 앱에 OTel Java Agent를 붙이면 코드 수정 없이 자동으로 트레이스, 메트릭, 로그를 수집한다. HTTP 요청, DB 쿼리, Redis 명령어, 외부 API 호출 등이 전부 자동으로 span으로 기록된다. Dockerfile에서 에이전트를 -javaagent로 로드한다: CI에서 빌드할 때 최신 OTel Java Agent를 다운로드하고 이미지에 포함시킨다: 환경 변수로 연결 Kustomize base Deployment에서 OTel Agent가 사이드카 Collector로 데이터를 보내도록 환경 변수를 설정한다: localhost:4317이 핵심이다. 사이드카는 같은 Pod 안에 있으니 네트워크 오버헤드가 없다. 서비스 디스커버리나 DNS 조회도 필요 없다. 샘플링 설정 Spring Boot의 Micrometer 설정으로 트레이스 샘플링 비율을 제어한다: prod에서 100% 샘플링은 Tempo 저장 비용과 성능 양쪽에서 부담이다. 40%면 장애 추적에 충분하면서도 저장 비용을 합리적으로 유지할 수 있다. OTel Collector 사이드카 - 수집/가공/전송 OTel Collector는 모든 Pod에 사이드카로 배포된다. base Deployment에 기본 컨테이너로 포함되어 있고, 환경별 설정은 overlay에서 ConfigMap 패치로 관리한다. Receiver - 데이터 수신 Collector는 두 가지 경로로 데이터를 수신한다: 첫 번째는 OTLP 프로토콜로 Java Agent가 보내는 트레이스와 로그를 수신한다. 두 번째는 Prometheus 방식으로 Spring Boot Actuator의 /status/prometheus 엔드포인트를 15초마다 스크래핑해서 메트릭을 수집한다. Kubernetes Service Discovery(kubernetes_sd_configs)로 Pod을 자동 발견하고, relabel_configs로 필터링한다. prometheus.io/scrape: "true" 어노테이션이 있는 Pod만 스크래핑 대상이 된다. Processor - 노이즈 필터링 이게 사이드카의 진짜 강점이다. 서비스별로 불필요한 데이터를 Collector 단에서 필터링해서 Tempo/Loki로 보내지 않는다. 왜 이게 중요한가? OTel Java Agent는 Redis 명령어 하나하나를 전부 span으로 기록한다. PING, SET, DEL, HGET 같은 단순 명령어가 초당 수백 건씩 쌓이면 Tempo 저장 비용이 급격히 늘어나고, 실제 비즈니스 트레이스를 찾기도 어려워진다. 헬스체크도 마찬가지다. ALB가 /status/health/liveness를 30초마다 호출하고, Prometheus가 /status/prometheus를 15초마다 스크래핑한다. 이런 시스템 요청까지 전부 트레이싱하면 노이즈만 쌓인다. filter/drop-noise 프로세서로 이런 불필요한 span을 Collector 단에서 드롭하면, 네트워크 전송과 Tempo 저장 모두 절약된다. 실제로 노이즈 필터링 적용 후 Tempo에 저장되는 스팬 수가 체감상 절반 이하로 줄었다. Exporter - LGTM으로 전송 수집/가공된 데이터를 각 백엔드로 전송한다: Tempo는 gRPC, Loki와 Mimir는 HTTP로 전송한다. 클러스터 내부 통신이라 TLS는 비활성화했다. X-Scope-OrgID 헤더는 멀티테넌시용인데, 현재는 단일 테넌트로 운영하고 있어서 고정값을 쓴다. Pipeline 조합 최종적으로 receiver → processor → exporter를 파이프라인으로 조합한다: 주목할 점: traces 파이프라인에만 filter/drop-noise 프로세서가 들어간다. 로그와 메트릭에는 노이즈 필터가 필요 없다 metrics 파이프라인은 receiver가 두 개다. OTel Agent가 보내는 런타임 메트릭과 Prometheus가 스크래핑하는 Actuator 메트릭을 모두 수집한다 batch 프로세서가 모든 파이프라인에 들어간다. 5초 단위로 모아서 전송하면 네트워크 효율이 올라간다 MDC 기반 요청 추적 로그에 사용자 컨텍스트를 넣어야 "이 요청이 누구의 것인지" 추적할 수 있다. MDC(Mapped Diagnostic Context)로 모든 요청에 사용자 ID를 태깅한다. MDCFilter 핵심은 MDC와 OTel Span을 동시에 설정하는 것이다: MDC.put("app.userId", userId) - Logback 로그에 사용자 ID가 찍힌다 span.setAttribute("user.id", userId) - Tempo 트레이스에서 사용자 ID로 검색할 수 있다 인증된 사용자는 실제 ID가, 비인증 요청은 카테고리(ANONYMOUS, ADMIN_API, WEBHOOK, SYSTEM)가 들어간다. Grafana에서 특정 사용자의 요청만 필터링해서 볼 수 있다. 비동기 MDC 전파 Spring의 @Async 메서드는 다른 스레드에서 실행되기 때문에 MDC가 자동으로 전파되지 않는다. TaskDecorator로 해결했다: @Async가 호출되는 시점에 현재 스레드의 MDC를 캡처하고, 새 스레드에서 복원한다. Virtual Threads를 쓰고 있기 때문에 SimpleAsyncTaskExecutor에 setVirtualThreads(true)를 설정한 부분도 중요하다. Logback 로그 포맷 %X{app.userId:-SYSTEM}으로 MDC에서 사용자 ID를 가져온다. 없으면 SYSTEM이 기본값으로 찍힌다. 실제 로그는 이런 식으로 나온다: 사용자 ID 12345의 요청 흐름을 로그에서 바로 추적할 수 있다. 여기에 Trace ID까지 연결되면 Grafana에서 로그 → 트레이스 → 메트릭을 원클릭으로 오갈 수 있다. LGTM 스택 배포 공통 인프라 - IRSA + Karpenter LGTM 컴포넌트 모두 하나의 ServiceAccount를 공유한다: IRSA(IAM Roles for Service Accounts)로 S3 접근 권한을 부여한다. Access Key를 Helm values에 하드코딩하지 않아도 되고, Pod이 AWS API를 호출할 때 해당 IAM Role의 권한만 사용한다. 전용 Karpenter NodePool로 LGTM 워크로드를 격리한다: r6g.large(ARM64, 2 vCPU, 16GiB)