지난 포스팅에서는 메모리 고갈로 인해 서버가 죽어버리는 OutOfMemoryError(OOM)의 유형과 해결책을 살펴보았습니다. OOM이 발생하면 서버가 아예 재시작되면서 일시적으로나마 상황이 종료되지만, 이보다 더 골치 아픈 장애가 있습니다. 바로 Java 프로세스가 종료되지도 않은 채 CPU 사용률을 100%(또는 코어 수에 비례한 최대치)로 점유하며 서버를 껍데기만 남은 먹통 상태로 만드는 현상입니다.
이 경우 APM(성능 모니터링 도구) 화면은 온통 붉은색으로 물들고, 사용자들은 끝없는 로딩 화면(Timeout)만을 보게 됩니다. 이번 글에서는 CPU 폭주 현상이 발생하는 주된 원인과, 리눅스 서버 환경에서 ‘정확히 코드 몇 번째 라인’이 문제인지 색출해 내는 실전 디버깅 시나리오를 단계별로 추적해 보겠습니다.
1. CPU 사용률 100%를 유발하는 3가지 핵심 원인
하드웨어의 결함이 아니라면, CPU 폭주는 십중팔구 개발자가 작성한 ‘애플리케이션 코드의 논리적 결함’에서 기인합니다.
- 무한 루프(Infinite Loop):
while문이나for문의 종료 조건이 잘못 설정되어, 특정 스레드가 루프를 영원히 빠져나오지 못하고 CPU 연산을 지속하는 경우입니다. - 정규표현식 백트래킹(Catastrophic Backtracking): 실무에서 정말 자주 발생하는 숨은 복병입니다. 복잡한 형태의 데이터베이스 쿼리 결과를 파싱하거나, 대용량 JSON 텍스트에서 패턴을 찾을 때 잘못 작성된 정규표현식(
Regex)을 사용하면, 수많은 경우의 수를 탐색하느라 CPU 자원을 모두 갉아먹게 됩니다. - 가비지 컬렉션(GC) 스래싱: 이전 글에서 언급한
GC overhead limit exceeded직전의 상태입니다. 메모리가 가득 차서 공간을 확보하기 위해 GC 스레드가 쉬지 않고 풀가동되면서 CPU를 100% 점유하는 현상입니다.
2. 디버깅 1단계: CPU를 갉아먹는 범인 ‘스레드(Thread)’ 식별
장애가 발생한 리눅스(Linux) 운영 서버에 접속했다면 가장 먼저 top 명령어를 쳐서 Java 프로세스의 PID(프로세스 ID, 예: 1234)를 확인합니다. 하지만 프로세스 ID만으로는 어떤 코드가 문제인지 알 수 없습니다.
- 스레드 레벨 점검: 터미널에
top -H -p 1234명령어를 입력합니다. (여기서 1234는 Java 프로세스 ID입니다.) - 원인 스레드 포착: 프로세스 내부의 스레드 목록이 나타납니다. 유독 CPU(%) 사용률이 99%를 찍고 있는 스레드의 ID(예: 1255)를 발견할 수 있습니다. 이 녀석이 바로 범인입니다.
- 16진수 변환: 리눅스의 스레드 ID(1255)를 16진수로 변환해야 합니다. 터미널에
printf "%x\n" 1255를 입력하여4e7이라는 16진수 값을 얻어냅니다.
3. 디버깅 2단계: 스레드 덤프(Thread Dump)와 매핑(Mapping)
범인 스레드의 16진수 ID를 확보했다면, 이제 이 스레드가 정확히 무슨 일을 하고 있는지 엑스레이를 찍어볼 차례입니다.
- 덤프 추출:
jstack 1234 > thread_dump.txt명령어를 사용하여 현재 Java 프로세스의 스레드 덤프를 텍스트 파일로 추출합니다. - ID 검색: 추출한
thread_dump.txt파일을 열어, 앞서 구한 16진수 값인nid=0x4e7을 검색합니다. - 콜 스택(Call Stack) 분석: 검색된 해당 스레드의 상태는 필연적으로
RUNNABLE일 것입니다. 바로 아래에 출력된 콜 스택을 확인해 봅니다.
4. 실전 사례: 코드 라인 추적 및 해결
스레드 덤프를 확인해 보니 아래와 같은 내용이 출력되었다고 가정해 봅시다.
"http-nio-8080-exec-5" #25 daemon prio=5 os_prio=0 tid=0x00007f... nid=0x4e7 runnable [0x00007f...]
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
... (중략) ...
at com.company.util.DataParser.extractJson(DataParser.java:45)
[분석 및 해결] 정확히 com.company.util.DataParser 클래스의 45번째 라인에서 병목이 발생하고 있음을 검거했습니다! 코드를 열어보니, 45번째 라인에서 외부로부터 들어온 대용량 텍스트 데이터를 처리하기 위해 비효율적인 정규표현식(Pattern.match())을 사용하고 있었습니다. 백트래킹(Catastrophic Backtracking)이 발생하여 스레드가 무한 연산에 빠진 것입니다.
개발자는 즉시 해당 정규표현식을 간결하게 수정하거나, 단순한 String.indexOf() 등의 메서드로 로직을 대체하여 리팩토링한 후 긴급 배포를 진행함으로써 CPU 100% 이슈를 완벽하게 해결할 수 있습니다.
결론: 감에 의존하지 않는 과학적 추적
“서버가 느리니 우선 재시작(Restart)부터 하자”는 것은 초보적인 대처입니다. 재시작하면 당장은 해결된 것처럼 보이지만, 원인 코드가 그대로 남아있기 때문에 동일한 로직이 호출되면 CPU는 다시 100%로 치솟게 됩니다. 장애가 발생했을 때 당황하지 않고, top -H ➡️ 16진수 변환 ➡️ jstack 스레드 덤프 분석으로 이어지는 3단계 기법을 공식처럼 적용하여 정확한 원인을 도출하는 습관을 들여야 합니다.
내부 로직의 오류를 추적하는 방법을 알아보았으니, 다음 포스팅에서는 애플리케이션 외부와의 통신 과정에서 발생하는 골치 아픈 장애, ‘[트러블슈팅] 외부 API 호출 시 응답 지연(Timeout) 문제 구간별 추적법’에 대해 상세히 다루어 보겠습니다.