JDK 8u212가 AWS SQS의 새 중간 CA 인증서를 처리하지 못해 배치서버가 다운됐습니다. lumen-sidecar로 자동 수집한 JFR을 Gemini CLI + lumen-mcp로 1차 분석하고, Claude Code로 코드 레벨까지 추적해 30분 만에 원인을 특정한 과정을 정리했습니다.
💡 한 줄 요약: JDK 8u212(2019년)가 AWS의 새 중간 CA 인증서를 처리하지 못해 SQS SSL 핸드셰이크가 9분간 폭주했고, 결국 서버가 다운됐습니다. JFR + lumen-mcp + Claude Code 조합으로 원인을 특정하고 JDK 교체로 해결했습니다.
환경
- JDK: Azul Zulu 8.38.0.13 (OpenJDK 8u212, 2019년 버전)
- WAS: Apache Tomcat 9.0.19
- AWS SDK: v1 (AmazonSQSClient)
- 분석 도구: lumen-sidecar, lumen-mcp, Gemini CLI, Claude Code
- 발생일: 2026년 3월 12일
📑 목차
- 들어가며
- 1. 원인 모를 때는 데이터부터 — lumen-sidecar 투입
- 2. Gemini CLI + lumen-mcp로 1차 분석
- 3. Claude Code로 코드 레벨 추적
- 4. 범인 특정 — JDK 8u212와 Amazon RSA 2048 M04
- 5. 해결 — Zulu JDK 심볼릭 링크 교체
- 마무리
- 참고 자료
들어가며
운영 배치서버가 조용히 죽었습니다. 알림도 없었고, 에러 로그도 남기지 못했습니다. 배치서버가 죽었다는 걸 알아챈 건 모니터링 알림이 아니라 특정 처리가 밀리기 시작하면서였습니다. 서버에 접속해보니 Tomcat 프로세스는 살아있는데 아무 응답도 하지 않는 상태였습니다. 전형적인 스레드 고갈 증상입니다.
그나마 다행이었던 건, 며칠 전부터 이 서버가 반복적으로 죽는 패턴이 있어서 JFR 사이드카를 미리 붙여뒀다는 점입니다. 덕분에 장애 발생 시점을 포착한 덤프 파일 3개가 S3에 올라와 있었습니다. "또 죽었다"에서 "이번엔 잡아보자"로 전환할 수 있었던 이유입니다.
1. 원인 모를 때는 데이터부터 — lumen-sidecar 투입
이 서버는 이유를 알 수 없이 주기적으로 죽고 있었습니다. 죽을 때마다 로그에는 뚜렷한 단서가 없었고, 재시작하면 일단 돌아갔습니다. 재현도 안 되고, 원인을 추측으로만 좁혀가는 상황이 반복됐습니다. 그래서 다음번에 죽을 때 데이터를 남기기 위해 lumen-sidecar를 붙여뒀습니다.
lumen-sidecar는 JFR 설정을 직접 건드릴 필요 없이 사이드카로 띄워두면 JVM을 상시 모니터링하다가, 트리거 조건이 충족되면 자동으로 JFR 덤프를 뜨고 S3에 업로드합니다. 트리거는 세 가지입니다. 로그 파일에서 지정한 정규표현식이 매칭될 때, CPU 사용률이 임계값을 초과할 때, 메모리 사용률이 임계값을 초과할 때입니다. JVM 내부에서 무슨 일이 벌어지는지 사람이 판단하기 전에 데이터가 먼저 쌓입니다.
다음 명령어로 붙여뒀습니다. 로그에서 ERROR 또는 EXCEPTION이 찍히거나, CPU 90% 또는 메모리 85%를 넘으면 직전 1분치 JFR을 덤프합니다.
sudo ./lumen-sidecar-linux \
-port 8080 \
-target "{서비스명}" \
-log "/path/to/logs/catalina.out" \
-pattern "(?i)ERROR|EXCEPTION" \
-monitor-cpu \
-cpu 90 \
-monitor-mem \
-mem 85 \
-cooldown 1m \
-duration 1m \
-java-home "$JAVA_HOME" \
-s3-bucket "{s3-버킷명}" \
-s3-region "ap-northeast-2" \
-s3-prefix "{s3-prefix}" \
-keep-local \
-output "/home/ec2-user/lumen-data" \
-v \
> sidecar.log 2>&1 &
마침 그날 배치서버가 또 죽으면서 JFR 파일 3개가 S3에 올라왔습니다. 파일명에는 lumen-sidecar가 로그에서 자동으로 추출한 에러 시그니처가 붙습니다. NullPointerException, DelegatingErrorHandlingRunnable이 파일명에 들어간 건 제가 지정한 게 아니라 sidecar가 로그 라인에서 파싱한 결과입니다.
# S3에서 받아온 덤프 파일들
lumen_19368_log_java_lang_NullPointerException_20260312_171054.jfr # 4.2MB
lumen_19368_log_org_springframework_scheduling_support_DelegatingE_...jfr # 4.5MB
lumen_19368_log_ERROR_2026_03_12_1_20260312_171221.jfr # 서버 복구 후
파일명 자체에 예외 클래스명과 타임스탬프가 박혀있어서 어느 시점의 덤프인지 바로 파악됩니다. 서버가 죽기 전 17:10:54에 NPE가 터졌고, 그 1초 뒤 Spring 스케줄러 예외가 발생했다는 걸 파일명만으로 알 수 있었습니다.
2. Gemini CLI + lumen-mcp로 1차 분석
JFR 파일은 바이너리 형식입니다. jfr CLI로 분석할 수 있지만, 무엇을 뽑아야 할지 알아야 합니다. 처음엔 lumen-mcp를 Gemini CLI에 연결해서 1차 분석을 맡겼습니다.
lumen-mcp는 JFR 덤프 분석에 특화된 MCP 서버로, 예외 통계(analyze_exceptions), CPU 핫스팟(analyze_hot_methods), 네트워크 I/O(analyze_network_io), 락 경합(analyze_lock_contention) 같은 도구를 제공합니다. JFR의 방대한 데이터에서 의미 있는 시그널을 빠르게 추출해주는 역할입니다.
Gemini CLI를 쓴 이유는 단순합니다. lumen-mcp 같은 분석 도구를 붙여서 JFR 데이터를 빠르게 훑는 용도로는 충분했고, 무엇보다 무료입니다. 코딩이나 복잡한 추론이 필요한 작업에서는 Claude Code 대비 아쉬운 부분이 있지만, "이 JFR에서 예외 통계 뽑아줘", "네트워크 I/O 정리해줘" 같은 단순 분석 요청에는 잘 맞습니다. 개인적으로 두 도구를 비교해서 정리해둔 페이지도 있는데, 요약하면 Gemini CLI는 탐색에, Claude Code는 코드 레벨 추론에 강합니다.

1차 분석으로 나온 예외 목록은 이랬습니다.
| 예외 | 발생 횟수 |
|---|---|
| CertificateParsingException (SubjectKeyIdentifier) | 31회 |
| NumberFormatException (NO_ENGINE_SUBSTITUTION) | 28회 |
| SocketTimeoutException (Read timed out) | 83회 |
| BadPaddingException (Decryption error) | 8회 |
| NullPointerException | 1회 |
네트워크 분석 결과도 의미심장했습니다. SQS와의 통신 누적 지연이 470초, Redis가 545초. 9분짜리 JFR에서 이 숫자가 나왔다는 건 두 연결이 사실상 대부분의 시간 동안 블로킹 상태였다는 뜻입니다.
여기까지가 lumen-mcp가 해준 작업입니다. 어떤 예외가, 어떤 외부 서비스와의 통신에서 터졌는지 구조를 잡아줬습니다. 다만 "SQS SSL 오류가 났다"는 사실은 알았는데, 정확히 어떤 코드 경로에서 왜 터졌는지까지는 MCP 분석만으로 특정하기 어려웠습니다.
3. Claude Code로 코드 레벨 추적
lumen-mcp가 "SQS SSL 오류"라는 구조는 잡아줬지만, 정확히 어떤 코드 경로에서 왜 터졌는지까지는 특정하기 어려웠습니다. 이 지점부터는 Claude Code로 넘겼습니다. 실제 프로젝트 코드와 JFR 파일을 함께 전달하고, 어느 클래스 몇 번째 줄에서 발생했는지, 왜 서버 전체에 영향을 미쳤는지를 파악하는 건 Claude Code가 훨씬 잘합니다.
Claude Code가 jfr print --events "jdk.JavaExceptionThrow"로 예외별 스택트레이스를 뽑아 분석했고, 핵심 단서가 나왔습니다.
jfr print --events "jdk.JavaExceptionThrow" dump.jfr 2>&1 \
| grep -E "(thrownClass|message|eventThread)" \
| sort | uniq -c | sort -rn
# 결과 — 160회 전부 taskScheduler-1 스레드에서 발생
160 eventThread = "taskScheduler-1" (javaThreadId = 30)
83 thrownClass = java.net.SocketTimeoutException
31 thrownClass = java.security.cert.CertificateParsingException
31 message = "No extension found with name SubjectKeyIdentifier"
28 message = "For input string: \"NO_ENGINE_SUBSTITUTION\""
모든 예외가 taskScheduler-1 단 하나의 스레드에서 나오고 있었습니다. NPE 스택트레이스를 뽑아보니 발원지도 특정됐습니다. SQS 메시지를 수신해서 처리하는 스케줄러 태스크였고, 코드를 보니 구조적인 문제가 두 가지 있었습니다.
첫째, SQS long polling(10초 대기) 중 SSL 핸드셰이크가 실패해도 @Scheduled(fixedDelay) 특성상 즉시 재시도가 걸립니다. 실패 → 재시도 → 실패가 9분간 반복되는 구조였습니다.
@Scheduled(fixedDelay = 1000)
public void pollSqsTask() {
sqsService.process();
}
public void process() {
// SSL 실패해도 fixedDelay로 즉시 재시도
List<Message> items = sqsClient.waitReceive(queueUrl, 10, 10);
for (Message message : items) {
Long id = parseMessage(message).getId();
Map<?, ?> record = repository.findById(id); // DB에 없으면 null 반환
// null 체크 없이 바로 접근 → NPE
String name = (record.get("NAME") != null) ? record.get("NAME").toString() : null;
}
}
둘째, SQS 메시지에 담긴 ID로 DB를 조회했는데, 해당 레코드가 없으면 null이 반환되고 null 체크 없이 바로 접근하다 NPE가 발생했습니다. SSL 오류가 반복되는 와중에 어쩌다 메시지를 수신하면 NPE까지 터지는 구조였습니다.
4. 범인 특정 — JDK 8u212와 Amazon RSA 2048 M04
SQS SSL이 왜 실패하는지를 파고들었습니다. 서버에서 SQS 엔드포인트의 인증서 체인을 직접 확인했습니다.
openssl s_client -connect sqs.ap-northeast-2.amazonaws.com:443 -showcerts 2>/dev/null \
| grep -E "s:|i:"
# 결과
0 s:/CN=sqs.ap-northeast-2.amazonaws.com
i:/C=US/O=Amazon/CN=Amazon RSA 2048 M04 ← 중간 CA
1 s:/C=US/O=Amazon/CN=Amazon RSA 2048 M04
i:/C=US/O=Amazon/CN=Amazon Root CA 1
2 s:/C=US/O=Amazon/CN=Amazon Root CA 1
i:/C=US/.../CN=Starfield Services Root Certificate Authority - G2
중간 CA로 Amazon RSA 2048 M04가 쓰이고 있었습니다. 이 CA는 AWS가 2023년에 도입한 것입니다. JDK 8u212는 2019년 버전이니 이 시점의 TLS 체인 검증 코드에 버그가 있습니다.
![다크 배경의 기술 다이어그램으로, 상단부터 하단으로 흐르는 TLS 인증서 신뢰 체인을 보여줍니다. 가장 상위의 [Starfield Services Root CA - G2]에서 [Amazon Root CA 1]으로 서명이 이어지며, 이 박스 옆에는 녹색으로 '✓ cacerts에 등록됨'이라고 표시되어 있습니다. 그 아래로 [Amazon RSA 2048 M04] 인증서 박스에는 '2023년 AWS 도입'이라는 작은 주석이 있고, 박스 옆에는 빨간색 경고 아이콘과 함께 'JDK 8u212 — SubjectKeyIdentifier 파싱 실패'라는 라벨이 붙어 버그 지점을 명시합니다. 마지막으로 최하단의 [sqs.ap-northeast-2.amazonaws.com] 인증서로 체인이 마무리되며, 전체 다이어그램 하단에는 'JDK 8u261에서 패치됨'이라는 안내 텍스트가 작게 적혀 있습니다.](https://blog.kakaocdn.net/dna/dA7k6Y/dJMcagxWpoQ/AAAAAAAAAAAAAAAAAAAAAL5dP1h-3tapCrk-VlhMLIUkTY85cnXD3978eFozhXY2/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1782831599&allow_ip=&allow_referer=&signature=Qcme7s1NqKBnxbG3hMPwfoVD4FA%3D)
JDK의 cacerts에는 Amazon Root CA 1이 있어서 체인 신뢰 자체는 가능한데, 실제 PKIX 경로 검증 과정에서 중간 CA의 Authority Key Identifier를 처리하는 코드 경로에서 SubjectKeyIdentifier를 못 찾고 예외를 던집니다. 이 버그는 JDK 8u261에서 패치됐습니다.
# JDK cacerts 확인
keytool -list -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit | grep -i amazon
# Amazon Root CA 1, 2, 3, 4는 있지만
# Amazon RSA 2048 M04 처리 로직이 8u212에서 버그 있음
cert_113_amazon_root_ca_1113, Mar 29, 2019, trustedCertEntry
여기에 AWS SDK v1(AmazonSQSClient)의 연결 풀 관리 문제가 겹쳤습니다. SSL 핸드셰이크에 실패한 연결이 풀에 제대로 반환되지 않고, 다음 요청에서 이 죽은 연결을 재사용하려다 GCM nonce 재사용 오류(IllegalStateException: Must use either different key or iv for GCM encryption)와 RSA 복호화 실패(BadPaddingException)가 연쇄로 발생했습니다.
정리하면 이런 흐름입니다.
| 시각 | 이벤트 |
|---|---|
| 17:01:54 | SQS SSL 첫 번째 SocketTimeoutException |
| 17:02:38 | CertificateParsingException 연쇄 시작 |
| 17:04:25 | BadPaddingException, AEADBadTagException — 죽은 연결 재사용 시도 |
| 17:10:54 | NPE 발생 (SQS 폴링 태스크) → lumen-sidecar JFR 덤프 트리거 |
| 17:10:55 ~ 17:12:21 | 세 번째 JFR 기록 중 — 스레드 풀 완전 고갈 |
| 17:12:21 | 서버 완전 응답 불가 |
17:10:54 덤프와 17:12:21 서버 다운 사이 87초의 인과관계를 짚어볼 필요가 있습니다. JVM 힙은 402MB/512MB로 OOM이 아니었고, 직접적인 사인은 SQS/Redis 누적 지연 1,000초로 인한 스레드 고갈입니다. 다만 수천 번의 예외 처리로 이미 OS 자원이 한계에 달한 상태에서, 사이드카가 덤프 파일을 생성하고 S3 업로드 버퍼링을 시작하자 OS가 bash: fork: Cannot allocate memory를 토해낸 정황이 있습니다. 덤프가 죽음의 원인은 아니지만, 마지막 방아쇠가 됐을 가능성은 있습니다.
그럼에도 17:12:21에 마지막 덤프가 S3에 올라왔다는 사실 자체가 역설적인 증거입니다. 서버가 완전히 응답 불가가 되기 직전까지 사이드카와 Tomcat이 간신히 통신하고 있었다는 뜻이고, 덕분에 우리는 죽는 순간의 기록을 손에 넣을 수 있었습니다.
5. 해결 — Zulu JDK 심볼릭 링크 교체
배포 스크립트가 JAVA_HOME=/path/to/java를 사용하고 있었고, java는 zulu8.38.0.13-ca-jdk8.0.212-linux_x64로 향하는 심볼릭 링크였습니다. 2019년 4월에 만든 링크가 그대로였습니다.
cd /path/to/jdk
# Azul Zulu 8 최신 버전 다운로드
sudo wget https://cdn.azul.com/zulu/bin/zulu8.82.0.21-ca-jdk8.0.432-linux_x64.tar.gz
sudo tar xzf zulu8.82.0.21-ca-jdk8.0.432-linux_x64.tar.gz
# 심볼릭 링크만 교체 — 배포 스크립트 수정 불필요
sudo ln -sfn ./zulu8.82.0.21-ca-jdk8.0.432-linux_x64 java
Tomcat 재기동 후 SQS 연결이 정상화됐습니다. CertificateParsingException은 더 이상 발생하지 않습니다.
NPE 버그(SQS 메시지의 ID로 DB 조회 시 null 반환 → null 체크 누락)는 별도로 수정이 필요합니다. 이번 장애의 직접 원인은 아니지만, SQS에서 받은 ID가 DB에 없는 경우(탈퇴 회원 등)에 언제든 NPE가 발생할 수 있는 잠재 버그입니다.
for (Message message : items) {
Long id = parseMessage(message).getId();
Map<?, ?> record = repository.findById(id);
// 수정 전: null 체크 없이 바로 .get() 호출 → NPE
// 수정 후: null이면 메시지 삭제 후 continue
if (record == null) {
sqsClient.delete(queueUrl, message.getReceiptHandle());
continue;
}
String name = record.get("NAME") != null ? record.get("NAME").toString() : null;
}
⚠️ 주의: AWS SDK v1(AmazonSQSClient)은 deprecated 상태입니다. 연결 풀 관리 문제가 이번 장애를 키운 요인 중 하나이므로, SDK v2 마이그레이션을 중기 과제로 잡아두는 것이 좋습니다.
마무리
이번 장애에서 얻은 교훈을 정리하면 두 가지입니다. 하나는 "JDK도 주기적으로 업그레이드해야 한다"는 것이고, 다른 하나는 "장애가 났을 때 데이터가 있어야 빠르게 원인을 찾을 수 있다"는 겁니다.
반복적으로 죽는 서버에 lumen-sidecar를 붙여둔 게 결정적이었습니다. 원인을 모르는 상태에서 "다음에 죽을 때 데이터를 남기자"는 판단이 맞았습니다. JFR이 있었기 때문에 Gemini CLI + lumen-mcp로 예외 분포와 네트워크 병목을 빠르게 파악하고, Claude Code로 코드 레벨까지 추적해서 30분 안에 원인을 특정할 수 있었습니다. 장애가 나고 나서 분석 도구를 설치하는 건 이미 늦습니다.
JDK 버전은 "잘 돌아가니까" 그냥 두는 경우가 많습니다. 저도 그랬습니다. 2019년에 설치한 Zulu 8u212가 2026년까지 한 번도 업그레이드되지 않았습니다. AWS가 인증서 체인을 교체하기 전까지는 아무 문제가 없었으니까요. 클라우드 인프라는 조용히 바뀌고, 오래된 JDK는 그 변화를 따라가지 못합니다.
참고 자료
'트러블슈팅' 카테고리의 다른 글
| iOS WebView 파일 업로드 확장자 누락 — 매직 바이트로 해결한 방법 (0) | 2026.03.26 |
|---|---|
| OpenSearch EC2 연결 오류 트러블슈팅 가이드 – 로컬선 정상, EC2선 타임아웃·403 해결 (3) | 2025.07.10 |