OOM 문제를 해결하고 systemd로 자동 복구까지 갖춰놓고 나서, 재배포 겸 새 jar를 올리고 서비스를 재시작했다. systemctl status도 active (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
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로 다시 확인하는 습관을 들여야겠다.curl → ss → nginx 로그 → 애플리케이션 로그 → /proc까지 계층을 하나씩 내려가며 확인한 게 결정적이었다.