Skip to main content
/actuator/healthUP이라고 해서, 로그인 뒤 비동기 작업과 다른 service까지 추적할 수 있다는 뜻은 아니에요.
Auth API가 정상 실행 중이에요. Health check도 UP이고, token 발급 요청도 200 OK를 돌려줘요. 그런데 로그인 감사(audit) 정보를 다른 service로 비동기 전송하다가 실패한다면 어떻게 알 수 있을까요? 서버가 살아 있다는 health만으로는 부족해요. 몇 번 성공하고 실패했는지 보여주는 metric, 한 요청이 어디를 지났는지 보여주는 trace, 그 요청에서 무슨 일이 있었는지 남기는 log가 서로 이어져야 하죠. 앞 글에서는 이 연결을 개념과 작은 code로 살펴봤어요. 이번에는 기존 auth-api에 직접 붙여 볼게요.
  • Actuator는 healthprometheus만 HTTP에 공개해요.
  • Token 발급 성공과 감사 전달 결과를 custom Counter로 기록해요.
  • @Async 작업이 작은 audit-api를 호출해요.
  • OpenTelemetry trace를 Jaeger에서 확인해요.
  • @Async 경계를 넘은 trace context가 유지되는지 test와 실행 결과로 확인해요.
  • 마지막에는 실패 metric에서 출발해 trace와 양쪽 log로 원인을 좁혀요.
처음부터 모든 file을 외울 필요는 없어요. 이번 실습은 다음 checkpoint로 끊어 읽으면 돼요.
이 글은 SOURCE-BACKED PRACTICE예요. Auth API 실습 프로젝트 저장소auth-api-first-commit에서 시작해 아래 변경을 차례대로 적용해요.최종 상태에서는 ./gradlew test로 20개 test를 실행하고, ./gradlew bootRun, ./gradlew bootRunAudit, docker compose up -d로 두 application과 Jaeger를 재현할 수 있어요.완성된 전체 코드는 Auth API 실습 프로젝트의 auth-api-observability에서 확인할 수 있어요.
처음 repository를 받는다면 다음 명령으로 시작점을 고정해요. Java 21과 Docker가 먼저 준비되어 있어야 해요.
AUDIT_API_FORCE_FAILUREX-Demo-Failure는 metric, trace, log를 따라가는 연습을 위한 장치예요. 운영 API에 누구나 켤 수 있는 실패 header를 그대로 두면 안 돼요. 실제 fault injection은 test 환경과 권한이 제한된 운영 도구에서만 사용해야 해요.

1. 먼저 어떤 요청을 관찰할지 정해요

기존 Auth API는 Basic credential을 access token과 refresh token으로 바꾸는 endpoint를 이미 갖고 있어요.
이번에는 이 요청이 성공한 뒤 두 가지 일을 추가할게요.
  1. auth.token.issued Counter를 올려요.
  2. 별도 thread에서 audit-api로 로그인 감사 event를 보내요.
두 번째 작업은 실패해도 token 발급 응답을 되돌리지 않아요. 그래서 사용자는 200 OK를 받았는데 감사 전달은 실패하는 장면이 생길 수 있어요. 바로 이런 부분 실패가 관측 신호를 연습하기 좋은 경계예요. HTTP 응답은 먼저 성공할 수 있지만, 비동기 전달 결과는 나중에 결정돼요. 따라서 endpoint status만 보지 말고 auth.audit.delivery metric과 trace를 함께 봐야 해요.

2. 필요한 dependency와 Jaeger부터 준비해요

기존 Actuator에 OpenTelemetry, RestClient, Prometheus registry를 추가해요.
auth-api
~ build.gradle
+ compose.yaml
build.gradle
각 dependency의 역할은 달라요. Trace를 받을 backend는 Jaeger all-in-one container 하나로 시작해요.
compose.yaml
4318은 application이 OTLP over HTTP로 span을 보내는 port예요. 16686은 사람이 trace를 검색하는 Jaeger UI port예요. 앞의 127.0.0.1은 인증 없는 local 도구가 다른 network interface에 열리지 않게 해요.
이번 목표는 collector, storage, query cluster를 운영하는 법이 아니라 trace가 실제로 service 경계를 넘는지 확인하는 일이에요. Jaeger all-in-one은 local 학습용으로는 충분하지만, 여러 instance와 장기 보존이 필요한 운영 구성을 대신하지는 않아요.

3. Actuator는 필요한 endpoint만 열어요

Dependency를 넣었다고 모든 Actuator endpoint가 HTTP로 공개되는 것은 아니에요. 어떤 endpoint를 열지는 별도로 정해야 해요.
src/main/resources
~ application.yml
기존 datasource와 token 설정은 그대로 두고, local 실습 범위를 고정하는 server.address, management, observability.audit 영역을 다음처럼 맞춰요.
src/main/resources/application.yml
base-url은 auth API가 audit API를 호출할 주소이고, force-failure는 local 장애 연습에서만 쓰는 switch예요. Local 실습에서는 모든 request를 확인하려고 sampling을 1.0으로 둬요. Metric은 OTLP로 보내지 않고 Prometheus endpoint에서 pull할 것이므로 OTLP metric export는 꺼요. Auth API도 127.0.0.1에만 binding해 인증 없이 공개한 health와 Prometheus endpoint가 같은 host 밖으로 나가지 않게 했어요. 여기서 중요한 보안 경계는 include예요. env, beans, configprops, heapdump를 편하다는 이유로 전부 공개하면 설정과 application 내부 정보가 노출될 수 있어요. Health와 metric도 운영에서는 management network나 별도 인증 경계 뒤에 두는 편이 좋아요. 이번 localhost 실습은 “노출 목록을 최소화한다”는 첫 경계만 다뤄요.

4. Auth API 쪽 file은 의존 순서대로 만들어요

JVM memory와 HTTP latency는 framework가 이미 잘 측정해요. 하지만 “token을 몇 번 발급했는가?”와 “감사 전달이 성공했는가?”는 application만 아는 사건이에요. 이번 절에서는 다음 다섯 file을 위에서 아래 순서대로 만들거나 수정해요.
me.nvim.blog.auth
~ AuthApiApplication.java
observability
+ AuditClientProperties.java
+ LoginAuditReporter.java
+ TokenIssueMonitor.java
identity/presentation
~ AuthTokenController.java

먼저 async 실행을 켜요

@Async method가 실제 별도 executor에서 실행되도록 main application에 @EnableAsync를 추가해요. 기존 file 전체에서 import와 Annotation 한 줄이 바뀌어요.
src/main/java/me/nvim/blog/auth/AuthApiApplication.java

Audit client 설정을 먼저 type으로 묶어요

application.ymlobservability.audit 값을 읽을 record를 만들어요. baseUrl을 단순 문자열이 아니라 URI로 binding하므로 잘못된 URI는 application 시작 경계에서 드러나요.
src/main/java/me/nvim/blog/auth/observability/AuditClientProperties.java
Main application에 이미 @ConfigurationPropertiesScan이 있으므로 별도의 @EnableConfigurationProperties는 추가하지 않아요.

그다음 LoginAuditReporter를 만들어요

이제 위에서 만든 AuditClientProperties를 사용해 audit API 주소와 local 고장 주입 여부를 받아요. LoginAuditReporter는 두 번째 Counter와 outbound HTTP 호출을 맡아요.
src/main/java/me/nvim/blog/auth/observability/LoginAuditReporter.java
@Async가 caller와 다른 thread에서 실행하고, 자동 설정된 RestClient.Builder가 HTTP client instrumentation을 받아요. 결과 tag는 success, failure 두 값만 사용해 cardinality를 제한해요.

Reporter를 사용하는 TokenIssueMonitor를 만들어요

LoginAuditReporter를 만든 다음에야 이를 생성자로 받는 monitor를 만들 수 있어요.
src/main/java/me/nvim/blog/auth/observability/TokenIssueMonitor.java
Counter는 계속 증가하는 누적값이에요. 현재 동시 로그인 수처럼 오르내리는 값을 Counter로 표현하면 안 돼요. 반면 “성공한 token 발급 횟수”는 사건이 발생할 때마다 하나씩 늘어나므로 잘 맞아요.

마지막으로 기존 controller 전체를 수정해요

Import, field, constructor parameter, Basic token method가 함께 달라져요. Method만 복사하면 tokenIssueMonitor field가 없어 compile되지 않으므로 아래 file 전체 모양을 확인하세요.
src/main/java/me/nvim/blog/auth/identity/presentation/AuthTokenController.java
비밀번호가 틀리면 Spring Security filter 단계에서 controller까지 오지 않아요. 그래서 auth.token.issued성공적으로 발급한 token 수만 세요. Email, user ID, token 값도 metric tag로 넣지 않았어요. 계속 새로운 값이 생기는 식별자를 tag로 쓰면 time series가 폭발하고, 개인정보나 credential이 관측 system으로 새어 나갈 수 있기 때문이에요. ./gradlew compileJava를 실행해 여기까지 만든 다섯 file이 서로 연결되는지 확인해요.

5. 세 component가 한 요청에서 어떻게 움직이는지 확인해요

Token 발급 성공 뒤에는 다음 순서가 돼요.
  1. AuthTokenControllerTokenIssueMonitor.recordIssuedToken()을 호출해요.
  2. TokenIssueMonitorauth.token.issued를 올려요.
  3. LoginAuditReporter.report()가 async executor로 넘어가요.
  4. Audit API 응답에 따라 auth.audit.deliverysuccess 또는 failure를 올려요.
실패를 Counter와 log에 남기고 token 발급 응답은 유지해요. 하지만 이것만으로 audit event의 신뢰성 있는 전달이 완성되지는 않아요. 운영에서는 outbox, message broker, retry와 DLQ, idempotency 같은 별도 설계가 필요해요. 이 주제는 뒤의 messaging 글에서 이어갈게요.

6. 작은 downstream application을 같은 project에서 실행해요

분산 trace를 확인하려면 실제로 process와 port가 다른 service가 하나 더 필요해요. 새 repository를 만들기보다 같은 source set 안에 최소 audit-api entry point를 둬요.
auth-api
~ build.gradle
src/main
java/me/nvim/blog
auth
AuthApiApplication.java
audit
+ AuditApiApplication.java
+ AuditController.java
resources
+ application-audit.yml
먼저 두 번째 application의 진입점을 만들어요. Audit API에는 datasource와 로그인 기능이 필요하지 않으므로 관련 자동 설정(auto-configuration)을 제외해요. 이 제외는 아래 local 학습용 service에만 적용되고 기존 Auth API의 보안 설정에는 영향을 주지 않아요.
src/main/java/me/nvim/blog/audit/AuditApiApplication.java
이제 Audit endpoint를 만들어요. 성공이면 202 Accepted, 고장 주입 header가 있으면 503 Service Unavailable을 돌려줘요.
src/main/java/me/nvim/blog/audit/AuditController.java
Gradle에는 두 번째 application을 실행할 task를 추가해요.
build.gradle
src/main/resources/application-audit.yml
Application 이름은 trace backend가 span을 어느 service로 묶을지 정하는 중요한 resource 정보예요. 두 process가 모두 auth-api라는 이름을 쓰면 trace는 이어져도 어느 service에서 처리했는지 구분하기 어려워져요. server.address도 loopback으로 고정했으므로 보안을 제외한 Audit API와 fault-injection header는 이 host 밖에서 접근할 수 없어요.
server.address만 바꿔 이 Audit API를 network에 공개하면 안 돼요. 비local 환경에서는 service 간 mTLS나 제한된 credential, authorization, network policy를 적용하고, Jaeger와 OTLP endpoint에도 TLS와 인증·권한 제어를 붙여야 해요.
여기까지 만든 진입점과 controller가 연결되는지 다시 compile해요.

7. 실행하기 전에 @Async의 trace 경계를 이어요

여기까지 두 application의 source와 실행 task를 만들었지만, 아직 요청을 보내기 위한 준비가 모두 끝난 것은 아니에요. Auth API가 요청을 받는 thread와 LoginAuditReporter가 동작하는 thread가 다르기 때문이에요. Trace context는 현재 실행 흐름에 매달려 있어요. 아무 설정 없이 @Async 경계를 넘으면 async log의 correlation ID가 비거나, Audit API에서 별도의 trace가 시작될 수 있어요. HTTP 호출은 202 Accepted로 성공할 수도 있어서 status만 봐서는 이 단절을 알아차리기 어렵고요. 그래서 지금 바로 application을 실행하지 않을게요. 먼저 async worker thread로 trace context를 전달할 준비를 마친 뒤, 8번에서 test로 전파를 증명하고 9~10번에서 두 application을 직접 실행해 같은 trace를 확인할 거예요. Spring Boot 4.0.7에서는 thread가 바뀔 때 observation context를 복원할 ContextPropagatingTaskDecorator Bean을 직접 등록해야 해요.
src/main/java/me/nvim/blog/auth/common
config
+ ContextPropagationConfig.java
src/main/java/me/nvim/blog/auth/common/config/ContextPropagationConfig.java
이 Bean이 auto-configured AsyncTaskExecutor에 연결되면 submit 시점의 context를 capture하고, worker thread에서 다시 scope로 열어요.
Spring Boot 4.0.7에 4.1의 spring.task.execution.propagate-context를 적어도 application이 반드시 실패하는 것은 아니에요. 설정 file에 문자열이 있다는 사실보다 현재 version의 configuration metadata와 실제 trace를 확인해야 해요.
설정이 제대로 연결되면 trace ID는 유지되고 span ID는 경계마다 달라져야 해요. 하나의 요청 여정 안에서 auth의 async 작업과 audit의 HTTP 처리가 서로 다른 span으로 기록되기 때문이에요. 이 그림은 아직 실행 결과가 아니라, 다음 절부터 확인할 기대 흐름이에요. 단순히 downstream에 traceparent header가 존재하는지만 보면 새 trace가 시작된 경우도 통과해요. 그래서 test는 알려진 incoming trace ID가 그대로 유지되는지 비교해야 해요.

8. Test는 endpoint와 metric과 trace를 따로 증명해요

이번 변경 뒤 test는 모두 20개예요. 새 관측 test가 고정하는 계약은 다음과 같아요.
  • Liveness와 readiness, Prometheus endpoint는 200이에요.
  • /actuator/env는 노출되지 않아 404예요.
  • Token 발급 뒤 auth_token_issued_total 1.0이 보여요.
  • Async downstream request가 incoming trace ID를 유지해요.
  • Audit API는 정상 요청에 202, 고장 주입 요청에 503을 돌려줘요.
이번 절에서 바꾸는 test file은 세 개예요.
src/test
java/me/nvim/blog
audit
+ AuditApiIntegrationTests.java
auth/observability
+ ObservabilityIntegrationTests.java
resources
~ application-test.yml
먼저 기존 test profile에서 외부 trace export를 꺼요. 대부분의 test는 Jaeger가 없어도 독립적으로 실행되어야 하고, 실제 전파를 검증하는 test만 뒤에서 local test server 쪽으로 export를 다시 켜요.
src/test/resources/application-test.yml
Audit API test는 고장 주입 switch의 두 결과를 각각 고정해요.
src/test/java/me/nvim/blog/audit/AuditApiIntegrationTests.java
Auth API의 관측 test는 작은 HTTP server를 직접 열어요. 한 context는 audit request의 traceparent를 받고, 다른 context는 test 중 생성된 OTLP trace를 받아요. 그래서 외부 Jaeger 없이도 endpoint 노출, metric 증가, async trace 전파를 한 file에서 검증할 수 있어요.
src/test/java/me/nvim/blog/auth/observability/ObservabilityIntegrationTests.java
Trace test의 핵심은 고정된 incoming traceparent예요. 이 test는 decorator가 없을 때 실제로 실패했고, Bean을 등록한 뒤 통과했어요. sleep으로 대충 기다리지 않고 CountDownLatch로 downstream request 도착 신호를 기다리기 때문에 timing에 덜 흔들려요.
검증 결과는 다음과 같았어요.

9. 이제 세 process를 직접 실행해요

Terminal을 세 개 열어요. 첫 번째 terminal에서는 Jaeger를 띄워요.
두 번째 terminal에서는 audit API를 실행해요.
세 번째 terminal에서는 auth API를 실행해요.
준비가 끝나면 두 application의 readiness를 확인해요.
실제 응답은 둘 다 같았어요.
노출하지 않은 endpoint도 확인해요.
Health가 성공했다는 증거와 불필요한 endpoint가 닫혔다는 증거는 서로 다른 확인이에요.

10. 요청 하나가 metric과 두 service trace를 만들어요

먼저 새 사용자를 등록해요.
Token 응답에는 credential이 있으므로 terminal이나 공용 임시 file에 남기지 않고 status만 확인해요. 나중에 trace를 바로 찾을 수 있도록 알려진 traceparent도 함께 보내요.
Audit 전달은 별도 thread에서 끝나므로 바로 한 번 읽으면 Counter가 아직 0.0일 수 있어요. 최대 5초 동안 성공 Counter를 기다린 뒤 application metric만 골라 봐요.
실제 정상 실행 결과예요.
Trace export와 Jaeger indexing도 비동기로 끝나요. Jaeger UI에서 auth-api service를 선택하고 trace ID 11111111111111111111111111111111을 검색하되, 바로 보이지 않으면 몇 초 뒤 다시 조회해요. 한 trace 안에 두 service가 보여요.
같은 trace ID 아래에 auth-apiaudit-api가 함께 있고, 각 HTTP 작업은 별도 span으로 보였어요. 이것이 “두 service 모두 trace가 있다”와 “한 요청이 두 service를 지나갔다”의 차이예요.

11. 작은 장애를 만들고 metric에서 원인까지 내려가요

마지막으로 audit API가 503을 돌려주는 local 고장 주입을 켜요. 실행 중인 auth API를 멈추고 다음 환경 변수와 함께 다시 시작해요.
Auth API를 재시작하면 이 예제의 memory DB도 초기화돼요. 따라서 새 사용자를 다시 등록한 뒤, 알려진 두 번째 trace ID와 함께 token을 발급해요.
이번에도 async 전달이 끝날 때까지 최대 5초 기다린 뒤 metric을 조회해요.
재시작했으므로 이 실행의 metric 결과는 다음과 같아요.
여기서 첫 번째 판단을 할 수 있어요.
  • Token은 정상 발급됐어요.
  • 하지만 감사 전달은 실패했어요.
Jaeger에서 같은 시각의 trace를 열면 outbound HTTP span이 ERROR였어요.
마지막으로 같은 trace ID를 log에서 찾으면 양쪽 판단이 이어져요.
이번 장애 조사는 이렇게 흘렀어요. Metric은 실패가 생겼다는 사실을 알려줬고, trace는 실패한 구간을 auth-api → audit-api 호출로 좁혔어요. 양쪽 log가 같은 trace ID로 실제 503 원인을 확인해 줬죠.
“몇 번 실패했지?”는 metric, “어느 구간이 실패했지?”는 trace, “그 구간에서 무슨 예외였지?”는 log에 먼저 물으면 도구를 무작정 오가는 일이 줄어요.
실습이 끝나면 bootRunbootRunAudit을 실행한 두 terminal에서 각각 Ctrl+C를 눌러 process를 멈춰요. 그다음 Jaeger container를 내려요.

12. 이 실습이 아직 해결하지 않은 것도 분명히 해요

이번 project는 관측 흐름을 보이기 위해 일부러 작게 만들었어요. 운영으로 가져가기 전에는 다음 경계를 더 설계해야 해요.
  • Jaeger all-in-one 대신 보존 기간과 고가용성을 갖춘 collector와 backend가 필요해요.
  • Sampling 1.0은 traffic과 비용을 보고 낮추거나 정책형 sampling으로 바꿔야 해요.
  • Prometheus endpoint는 public internet이 아니라 management network와 인증 경계 안에 둬야 해요.
  • Async audit 실패를 log로만 남기면 재시작 뒤 복구할 수 없어요. Outbox나 broker를 사용한 durable delivery가 필요해요.
  • Fault injection header는 local 실습 밖으로 노출하면 안 돼요.
  • Token, password, email 같은 값이 log, metric tag, span attribute에 들어가지 않는지 수집 pipeline 전체에서 확인해야 해요.
관측성은 dependency를 추가하는 순간 완성되는 기능이 아니에요. 실패한 요청을 실제로 만들고, metric에서 trace로, trace에서 log로 이동할 수 있을 때 비로소 운영 도구가 application 이야기와 연결돼요.

참고한 링크

자, 정리해볼까요?

  • Health는 application이 살아 있는지 답하지만, business 작업의 성공 횟수와 service 사이 실패까지 알려주지는 않아요.
  • auth.token.issuedauth.audit.delivery Counter는 token 발급과 비동기 전달이라는 application 사건을 보여줘요.
  • Metric tag는 success, failure처럼 제한된 값만 사용하고 email, user ID, token 같은 고유값을 넣지 않아요.
  • 자동 설정된 RestClient.Builder를 사용해야 outbound HTTP instrumentation과 trace propagation이 적용돼요.
  • Spring Boot 4.0.x의 @Async context propagation에는 ContextPropagatingTaskDecorator Bean이 필요하고, 4.1.x의 편의 속성과 구분해야 해요.
  • Downstream에 trace가 있다는 사실만으로는 부족해요. Incoming trace ID와 같은지 비교해야 새 trace로 끊긴 문제를 잡을 수 있어요.
  • 실제 장애 조사는 metric으로 이상을 찾고, trace로 실패 구간을 좁힌 뒤, 같은 trace ID의 log로 원인을 확인하는 순서로 이어져요.
다음 글에서는 한 application 안의 component가 서로 강하게 엮이지 않고 사건을 알리는 방법을 살펴볼게요. ApplicationEventPublisher, @EventListener, @TransactionalEventListener가 transaction 전후에 언제 실행되고, 내부 event가 외부 message broker와는 왜 다른지 이어서 볼 거예요.