배운 것
Lettuce(Spring Data Redis 기본 클라이언트)는 Netty 이벤트 루프 스레드에서 Redis 명령을 실행한다. Spring의 trace context는 ThreadLocal 기반이라 Tomcat → Netty 스레드 간 전파가 안 된다. 그래서 Lettuce의 기본 tracing 설정만으로는 Redis 스팬이 Zipkin에 나타나지 않는다.
문제 상황
POST /api/v1/coupons/{couponId}/issue 엔드포인트의 Zipkin 트레이스에 Redis 명령이 전혀 보이지 않았다.
쿠폰 발급 시 Redis를 3번 호출하는데:
SISMEMBER coupon:issued:{id} {userId} ← 중복 발급 체크
DECR coupon:stock:{id} ← 재고 차감
SADD coupon:issued:{id} {userId} ← 발급 기록
Zipkin에는 HTTP span과 DB span만 보이고 Redis span은 없었다.
원인 분석
Lettuce의 스레드 구조
Tomcat Thread (HTTP 요청 처리)
│
├─ redisTemplate.opsForSet().isMember(...) ← Tomcat 스레드에서 호출
│ │
│ ▼
│ Lettuce가 Netty 큐에 명령 등록
│ │
│ ▼
│ [Netty Event Loop Thread] ← 여기서 Redis 통신 실행
│ ├─ TCP 소켓으로 SISMEMBER 명령 전송
│ ├─ Redis 응답 수신
│ └─ Tomcat 스레드 unblock
│
└─ 응답 반환 ← Tomcat 스레드로 돌아옴
Spring의 trace context는 ThreadLocal에 저장된다. Netty Event Loop Thread에는 HTTP 요청의 trace context가 없기 때문에, Lettuce가 tracing을 시도해도 parent span이 없어서 orphaned span이 되거나, predicate에 의해 필터링된다.
시도했던 잘못된 방법 — LettuceTracingConfig
// ❌ 이 방법은 Netty 스레드 문제로 동작 안 함
@Bean
ClientResourcesBuilderCustomizer lettuceTracingCustomizer(ObservationRegistry registry) {
return builder -> builder.tracing(new MicrometerTracing(registry, "redis"));
}
MicrometerTracing을 설정해도 Netty 스레드에서 registry.getCurrentObservation()이 null을 반환하므로 span이 생성되지 않는다.
해결: @Observed 어노테이션
Netty 스레드를 거치지 않고, Tomcat 스레드에서 직접 observation을 생성하면 된다.
@Observed(name = "redis.coupon") // ← 이걸 추가
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {
// ...
}
Spring AOP (ObservedAspect)가 각 메서드 호출을 감싸서 observation을 생성한다. AOP는 Tomcat 스레드에서 실행되므로 HTTP span이 parent로 잡힌다.
별도 의존성 추가 없이 동작한 이유
- aspectjweaver — spring-boot-starter-security 경유로 이미 클래스패스에 존재
- ObservedAspect — micrometer-observation 모듈에 포함
- AopAutoConfiguration — @ConditionalOnClass(Aspect.class) 조건 충족으로 자동 활성화
- ObservationAutoConfiguration — ObservedAspect 빈 자동 등록
결과
[gateway] POST /api/v1/coupons/{id}/issue (ROOT)
[coupon-service] http post ...
[coupon-service] coupon-redis-repository#is-already-issued ← Redis ✓
[coupon-service] coupon-redis-repository#decrement-stock ← Redis ✓
[coupon-service] coupon-redis-repository#mark-issued ← Redis ✓
[omc] query (DB)
...
추가 발견: Lettuce Cold Start 지연
증상
서비스 재시작 후 첫 번째 Redis 요청이 ~3.9초나 걸렸다. 두 번째 요청부터는 17ms로 정상.
첫 번째 요청: coupon-redis-repository#is-already-issued = 3,898ms ← ❗
두 번째 요청: coupon-redis-repository#is-already-issued = 17ms ← 정상
원인
Lettuce는 lazy connect 방식이다. 첫 번째 Redis 명령이 실행될 때 비로소 TCP 연결을 맺는다. Docker Desktop(Mac)은 VM 기반 네트워크 스택을 사용하기 때문에 첫 연결 수립에 수 초가 걸릴 수 있다.
해결: 서비스 시작 시 연결 워밍업
@EventListener(ApplicationReadyEvent.class)
public void warmUpRedisConnection() {
try (var conn = connectionFactory.getConnection()) {
conn.ping();
}
}
ApplicationReadyEvent 시점에 PING을 보내서 연결을 미리 수립해 둔다. 이후 첫 실제 요청부터 바로 빠르게 동작한다.
정리
문제 원인 해결
| Redis span이 Zipkin에 없음 | Lettuce는 Netty 스레드에서 실행 → ThreadLocal trace context 전파 불가 | Repository에 @Observed 추가 → Tomcat 스레드에서 직접 span 생성 |
| 첫 Redis 요청 3~4초 지연 | Lettuce lazy connect — 첫 명령 시점에 TCP 연결 수립 | ApplicationReadyEvent에서 PING으로 연결 워밍업 |
Lettuce 레벨 tracing(MicrometerTracing)은 Reactive(WebFlux) 환경에서는 동작한다. Servlet 환경에서는 스레드 모델이 달라서 @Observed로 Repository 레벨에서 감싸는 방식이 현실적이다.
'Study' 카테고리의 다른 글
| TIL - Boolean.TRUE.equals()를 쓰는 이유 (0) | 2026.07.21 |
|---|---|
| TIL - G1GC vs ZGC : GC 방식 차이와 선택 기준 (0) | 2026.07.18 |
| [내일배움캠프 TIL] - 부하 테스트 지표와 p95가 국룰인 이유 (0) | 2026.07.03 |
| [내일배움캠프 TIL] - Redis 동시성 제어 3가지 방식 비교 (0) | 2026.07.02 |
| [내일배움캠프 TIL] GitHub Actions에서 org 레포 ghcr.io 패키지 push 권한 (0) | 2026.07.01 |