Loading the catalog…
Loading the catalog…
새 버전을 무중단 배포하고 나면 이상하게 배포 직후 1~2초 동안만 간헐적으로 500 에러 가 튀는 현상이 발생했습니다. 잠시 후 다시 요청하면 아무 일도 없었다는 듯 정상 동작하는 기묘한 버그... 범인은 바로 JJWT 라이브러리 내부의 Java ServiceLoader 동시성 경합이었습니다. 🚨 1. 증상: 배포 직후 첫 요청들의 의문의 500 폭탄 서비스를 새 버전으로 빌드하여 배포한 직후, 로그인된 상태로 대기 중이던 사용자의 요청이나 헬스체크 트래픽 중 일부가 500 Internal Server Error를 뱉었습니다. 서버의 app.log 를 열어보니 아래와 같은 스택트레이스가 찍혀 있었습니다: java.util.NoSuchElementException at java.base/java.util.ServiceLoader$2.next(ServiceLoader.java:1308) at io.jsonwebtoken.impl.lang.Services.loadFirst(Services.java:98) at io.jsonwebtoken.impl.DefaultJwtParserBuilder.build(DefaultJwtParserBuilder.java:128) at kr.co.allteachers.global.auth.jwt.JwtUtil.parseToken(JwtUtil.java:50) 더 당황스러웠던 점은: 배포 직후 첫 몇 번의 요청에서만 발생하고, 그 이후에는 아무리 부하를 주어도 100% 정상 작동하며 재현되지 않았습니다. 🔍 2. 원인 분석: ServiceLoader 는 스레드 안전(Thread-safe)하지 않다 스택트레이스를 따라 io.jsonwebtoken 라이브러리의 소스코드를 파고들었습니다. JJWT는 JWT 토큰을 파싱하기 위해 JwtParser 를 처음 빌드할 때, JSON 파서 프로바이더(예: Jackson 프로바이더인 jjwt-jackson )를 찾기 위해 Java 표준 유틸리티인 java.util.ServiceLoader 를 사용합니다. // JJWT 내부의 Services.java 발췌 public static <T> T loadFirst(Class<T> spi) { ServiceLoader<T> l = ServiceLoader.load(spi); Iterator<T> i = l.iterator(); if (i.hasNext()) { // 💥 여러 스레드가 동시에 접근하면? return i.next(); } // ... } 왜 기동 직후에만 터졌을까? 스프링 부트 애플리케이션이 뜨자마자 임베디드 톰캣(Tomcat)이 외부 요청을 받기 시작합니다. 이때 여러 사용자의 요청이 동시에 인입 되면서, 각자의 스레드에서 아직 초기화되지 않은 JwtUtil.parseToken() 을 동시에 호출합니다. 문제의 ServiceLoader 는 Thread-safe 하지 않은 내부 Iterator 상태를 가집니다. 스레드 A가 hasNext() 를 확인하고 next() 를 꺼내려는 찰나에, 스레드 B가 이미 요소를 소모해 버려 스레드 A에서 NoSuchElementException 이 터진 것입니다! 한 번이라도 프로바이더 로딩이 끝나면 메모리에 캐싱되므로, 그 이후에 들어오는 요청들은 경합 없이 정상 통과되었던 것입니다. 🛠️ 3. 해결책: 애플리케이션 시작 시 웜업(Warm-up) 시키기 원인을 알았으니 해결은 명쾌했습니다. "실제 사용자의 요청이 들어오기 전에, 스프링 컨텍스트가 뜰 때 미리 동기적으로 JWT 파서를 한 번 호출해서 캐싱을 끝내두자!" 스프링 빈의 생명주기를 활용하여 @PostConstruct 를 통해 웜업 로직을 추가했습니다: @Component public class JwtUtil { // ... 기존 JWT 유틸 코드 ... /** * JJWT 내부 ServiceLoader 동시성 경합 방지를 위한 웜업 * 임베디드 웹서버가 실제 트래픽을 받기 전, 스프링 초기화 시점에 * 더미 토큰을 한 번 파싱하여 JSON 프로바이더 캐싱을 완료한다. */ @PostConstruct void warmUpJwtParser() { try { String dummyToken = generateAccessToken(0L, "WARMUP_WARMUP"); parseToken(dummyToken); log.info("JWT Parser warmup completed successfully."); } catch (Exception e) { log.warn("JWT Parser warmup failed: {}", e.getMessage()); } } } 🧪 4. 검증 결과 멀티스레드 동시 요청 테스트 : 웜업 적용 전: 기동 직후 20개 스레드로 동시 요청 시 1~3건 간헐적 500 에러 발생. 웜업 적용 후: 기동 직후 50개 스레드 동시 요청 시 500 에러 0건 , 전건 200 OK 통과. 운영 환경 배포 : 배포 직후 헬스체크 및 실사용자 인입 시 500 에러 발생률 0% 달성. 💡 이번 트러블슈팅의 핵심 교훈 지연 로딩(Lazy Loading)의 동시성 위험 : 라이브러리 내부에서 첫 호출 시점에 리소스를 로딩하는 구조는 멀티스레드 환경에서 언제든 경합(Race Condition)을 유발할 수 있습니다. 배포 직후 발생하는 에러는 '웜업'을 의심하라 : 배포 직후에만 잠깐 터졌다가 사라지는 에러는 대부분 클래스 로딩, 커넥션 풀 초기화, 캐시 미스에 의한 동시성 이슈일 가능성이 높습니다. @PostConstruct 를 통한 사전 웜업은 복잡한 서드파티 라이브러리의 동시성 문제를 가장 깔끔하고 안전하게 방어하는 테크닉 중 하나입니다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
[Spring Boot] 배포 직후 간헐적 500 에러... 범인은 JJWT ServiceLoader 동시성 경합이었다. 새 버전을 무중단 배포하고 나면 이상하게 배포 직후 1~2초 동안만 간헐적으로 500 에러 가 튀는 현상이 발생했습니다. 잠시 후 다시 요청하면 아무 일도 없었다는 듯 정상 동작하는 기묘한 버그... 범인은 바로 JJWT 라이브러리 내부의 Java ServiceLoader 동시성 경합이었습니다. 🚨 1. 증상: 배포 직후 첫 요청들의 의문의 500 폭탄 서비스를 새 버전으로 빌드하여 배포한 직후, 로그인된 상태로 대기 중이던 사용자의 요청이나 헬스체크 트래픽 중 일부가 500 Internal Server Error를 뱉었습니다. 서버의 app.log 를 열어보니 아래와 같은…
Open source