재배포하다 서버를 반쯤 죽인 이야기 (NoClassDefFoundError 디버깅기)

CHOI HONGSEO·2026년 7월 31일

OOM 문제를 해결하고 systemd로 자동 복구까지 갖춰놓고 나서, 재배포 겸 새 jar를 올리고 서비스를 재시작했다. systemctl statusactive (running)으로 정상, 로그에도 크롤링이 잘 돌고 있다고 찍히고 있었다. 그런데 브라우저로 사이트에 들어가보니 다시 "연결 끊김"이 떠 있었다.

분명 서비스는 살아있다는데, 대시보드는 왜 끊겼다고 하는 걸까?

## 1단계: 프로세스는 살아있다


sudo systemctl status stock-backend

Active: active (running) since ...

Main PID: 2700 (java)

journalctl에는 이런 로그도 계속 찍히고 있었다.


Hibernate: select d1_0.id from disclosure d1_0 where d1_0.link=? limit ?

✅ [2026-07-21] 내 종목 14건 처리 완료

크롤링 스케줄러는 멀쩡히 DB에 쿼리하고 있다. 애플리케이션이 죽은 게 아니다. 그럼 대시보드는 왜 "연결 끊김"을 띄우는 걸까 — 웹 요청을 처리하는 쪽에서만 뭔가 문제가 있다는 뜻이었다.

## 2단계: curl로 직접 찔러봤는데... 아무것도 안 나온다

nginx나 브라우저를 거치지 말고, 서버 안에서 백엔드에 직접 요청을 보내봤다.


curl -s http://localhost:8080/api/crawling/status

결과: 아무 출력도 없음. 에러 메시지도, 응답 본문도 없이 그냥 프롬프트로 돌아왔다.

여기서 잠깐 멈칫했다. 서버가 뭘 리턴했다는 건지, 아니면 애초에 연결이 안 된 건지 이 상태로는 알 수가 없었다. 이유는 -s(silent) 옵션 때문이었다 — 이 옵션은 진행률 표시줄뿐 아니라 curl 자체의 에러 메시지까지 조용히 삼켜버린다. 서버가 뭐라도 응답했다면(설령 404나 500이라도) -s를 써도 응답 본문은 찍히는데, 그것마저 없었다는 건 애초에 연결 자체가 실패했다는 뜻이었다.

## 3단계: -v로 다시 — 진짜 에러가 나왔다


curl -v http://localhost:8080/api/crawling/status

- Connected to localhost (::1) port 8080

> GET /api/crawling/status HTTP/1.1

> Host: localhost:8080

>

- Recv failure: Connection reset by peer
- Closing connection

curl: (56) Recv failure: Connection reset by peer
  • TCP 연결 자체는 성공했다. 근데 요청을 보내고 응답을 받으려는 순간 연결이 강제로 끊겼다(Connection reset by peer). 포트가 완전히 죽은 것도 아니고, 완전히 살아있는 것도 아닌 애매한 상태였다.

## 4단계: 포트가 진짜 리스닝 중인지 확인


sudo ss -tlnp | grep 8080

LISTEN 0 100 *:8080 *:* users:(("java",pid=2701,fd=19))

자바 프로세스가 8080을 분명히 리스닝하고 있었다. 그런데 연결하면 리셋된다. listening은 되는데 request를 처리하는 순간 뭔가 터진다는 뜻이다.

## 5단계: nginx 로그와 대조

nginx가 백엔드로 프록시하면서 겪는 에러도 확인해봤다.


sudo tail -50 /var/log/nginx/error.log

recv() failed (104: Connection reset by peer) while reading response header from upstream,

request: "GET /api/crawling/status HTTP/1.1", upstream: "http://127.0.0.1:8080/api/crawling/status"

시간대도, 증상도 curl로 직접 확인한 것과 정확히 일치했다. 브라우저에서 뜨는 "연결 끊김"의 실체가 바로 이거였다 — 프론트가 폴링할 때마다 nginx가 백엔드에 연결은 되는데 응답을 못 받고 계속 리셋당하고 있었던 것이다.

## 6단계: 진짜 원인 — 로그에 남아있던 결정적 증거

애플리케이션 로그 전체에서 예외를 검색해봤다.


sudo journalctl -u stock-backend --no-pager | grep -iE "exception|error"

Exception in thread "HikariPool-1:connection-closer" java.lang.NoClassDefFoundError: org/mariadb/jdbc/message/client/QuitPacket

Caused by: java.lang.ClassNotFoundException: org.mariadb.jdbc.message.client.QuitPacket

NoClassDefFoundError. 그것도 jar 안에 분명히 존재하는 클래스를 못 찾겠다는 에러였다. 의존성이 진짜로 빠진 거라면 시작하자마자 계속 실패했어야 하는데, 한참 잘 돌다가 갑자기 이러는 게 이상했다.

이런 패턴은 보통 실행 중인 프로세스가 참조하는 파일이 도중에 바뀌었을 때 나타난다. 재배포하면서 FileZilla로 새 jar를 업로드했는데, 그때 이전 프로세스가 아직 살아있는 상태에서 파일이 덮어써진 것이다. JVM은 클래스를 처음부터 다 읽어서 메모리에 올려두는 게 아니라 필요할 때마다(lazy) 읽는데, 이미 로드된 클래스는 멀쩡히 동작하지만(그래서 크롤링 스케줄러는 계속 잘 돌아갔다), 아직 안 읽은 클래스를 나중에 읽으려 할 때 디스크의 내용이 이미 바뀌어 있어서 깨진 걸 읽게 된 것이다.

쐐기를 박기 위해 하나 더 확인했다.


cat /proc/2701/cmdline | tr '\0' ' '

/usr/bin/java -jar /home/ubuntu/stock/backend/backend-0.0.1-SNAPSHOT.jar

서비스 파일에는 분명 -Xmx400m을 넣어뒀는데, 실제로 떠 있는 프로세스에는 그 옵션이 없었다. 이건 지금 살아있는 프로세스가 최신 설정이 반영되기 전에 떴다는 확실한 증거였다. 즉 파일을 덮어쓰고 daemon-reload까지는 했지만, 정작 서비스를 restart하지 않아서 예전 프로세스가 계속 떠 있었던 거였다.

## 해결


sudo systemctl restart stock-backend

새 프로세스가 뜨면서 디스크에 있는 온전한 jar를 처음부터 새로 읽었고, -Xmx400m도 정상 적용됐다. curl도 바로 정상 응답을 돌려주기 시작했다.

## 회고

  • curl -s는 생각보다 많은 걸 숨긴다. 뭔가 이상한데 아무 정보도 안 나올 땐 -v로 다시 확인하는 습관을 들여야겠다.
  • "프로세스가 살아있다 = 정상이다"가 아니라는 걸 뼈저리게 느꼈다. 백그라운드 스레드는 멀쩡한데 요청 처리 스레드만 맛이 간, 애매하게 살아있는 상태도 있다.
  • 증상이 같아 보여도(둘 다 "연결 끊김") 원인은 완전히 다를 수 있다. 지난번엔 OOM으로 프로세스 자체가 죽은 거였고, 이번엔 프로세스는 살아있지만 파일이 손상된 경우였다. 겉으로 보이는 증상만으로 성급하게 지난 원인이라고 단정하지 않고, curl → ss → nginx 로그 → 애플리케이션 로그 → /proc까지 계층을 하나씩 내려가며 확인한 게 결정적이었다.
  • 배포 습관을 바꿨다: jar를 새로 올릴 땐 먼저 서비스를 내리고, 업로드하고, 그다음 다시 올린다. "덮어쓰고 나중에 재시작"은 이번처럼 애매한 반쪽 장애를 만들 수 있다는 걸 배웠다.
profile
곧 성공할 개발자입니다.

0개의 댓글