SSE로 인한 OutOfMemoryError와의 전쟁, 그리고 Polling으로의 전환

유영재·2025년 4월 6일
1

Morib

목록 보기
3/3
post-thumbnail

Morib은 사용자에게 친구들의 실시간 몰입 상태를 제공하기 위해 초기에는 SSE(Server-Sent Events) 기술을 도입했습니다. 하지만 이는 곧 지속적인 OutOfMemoryError 발생잦은 서버 다운으로 이어졌습니다. 😥

이번에는 SseEmitter 누수로 인한 메모리 문제/sse/refresh API의 성능 저하를 진단하고, 최종적으로 Polling 방식으로 전환하여 시스템 안정성을 확보하기까지의 여정을 공유하고자 합니다.

🚨 Situation : 끊임없는 OutOfMemoryError와 서버 다운

Sentry는 연일 OutOfMemoryError 발생을 알렸고, OOM은 잦은 서버 다운으로 이어져 서비스가 상당히 불안정했습니다. 이에, CPU 사용 100%까지 달성했습니다.

더불어, 친구 상태 등을 갱신하는 /sse/refresh API의 성능 지표 역시 좋지 않았습니다. Sentry 기준으로 p95 응답 시간이 600ms를 초과하는 경우가 빈번했습니다.

🔍 Try : 힙 덤프 분석

OOM의 주된 원인은 대부분 메모리 누수입니다. 저희는 실시간 연결을 관리하는 SSE 구현 부분을 집중적으로 분석했습니다. SseEmitter 객체가 제대로 관리되지 않으면 연결이 종료된 후에도 가비지 컬렉션(GC) 대상이 되지 못하고 메모리에 계속 쌓여 결국 OOM을 유발할 수 있기 때문입니다.

OOM 발생 직전 시점의 힙 덤프(Heap Dump) 를 분석하기로 했습니다. 힙 덤프는 특정 시점의 JVM 힙 메모리 상태를 스냅샷으로 떠서 어떤 객체들이 메모리를 차지하고 있는지, 객체 간의 참조 관계는 어떤지 등을 상세히 분석할 수 있죠.

분석 과정:

  1. 힙 덤프 생성: OOM 발생 시 자동으로 힙 덤프 파일(.hprof)이 생성되도록 JVM 옵션을 설정하거나, jmap을 사용하여 문제가 발생하는 시점에 수동으로 힙 덤프를 생성했습니다.
       jmap -dump:format=b,file=heapdump.hprof <PID>

분석 결과는 예상과 일치했습니다. 힙 덤프 분석 결과, 정상적으로 완료(complete)되지 않은 수많은 SseEmitter 객체들이 메모리에 남아있는 것을 확인할 수 있었습니다. 이는 SseEmitter의 생명주기가 제대로 관리되지 않아 발생한 전형적인 메모리 누수 패턴이었습니다.

비동기 처리 중 예외 발생 시 complete() 또는 completeWithError() 호출 누락, 타임아웃 처리 미흡 등 여러 잠재적 원인이 의심되었습니다.

🚀 Action

SseEmitter 누수라는 명확한 원인을 찾았지만, 바로 SSE를 포기하기보다는 기존 구현을 개선하여 문제를 해결해보려는 시도를 먼저 했습니다.

✨ 해결 시도 1: 로직 무한 루프 해결

가장 먼저 눈에 띈 것은 timeout 이벤트 처리 로직이었습니다.

  • 기존 문제: 이전 로직은 특정 사용자의 연결에 timeout 이벤트가 발생하면, 해당 사용자를 포함한 모든 친구 관계의 사용자들에게 refresh 이벤트를 broadcast했습니다. 이는 불필요한 부하를 유발할 뿐만 아니라, 여러 연결에서 동시에 타임아웃이 발생하거나 로직이 꼬일 경우 무한 루프에 빠질 위험이 있었습니다. 또한, 어떤 SseEmitter 객체에 대한 타임아웃인지 명확히 식별하고 처리하는 로직도 부족했습니다.

  • 수정 시도: 타임아웃 시 불필요한 broadcast 로직을 제거하고, 타임아웃이 발생한 당사자에게만 관련 이벤트(예: 연결 종료 알림 또는 재연결 유도)를 보내도록 handleSseTimeout 메서드를 수정했습니다.

이 수정으로 특정 로직의 무한 루프 가능성은 제거할 수 있었지만, 이는 근본적인 SseEmitter 객체의 생명주기 관리 문제나 다른 경로(예: 비정상 종료, 네트워크 오류)에서 발생하는 누수를 해결해주지는 못했습니다.

✨ 해결 시도 2: Nginx와 ALB 포트 설정 + Nginx 설정 파일 수정

애플리케이션 로직 외에 인프라 구성도 SSE 안정성에 영향을 미칠 수 있습니다. 특히 리버스 프록시(Nginx)와 로드밸런서(ALB) 설정이 중요합니다.

  • 기존 문제: ALB에서 애플리케이션으로 트래픽을 전달하는 포트(Target Group 포트)와 Nginx가 리스닝하는 포트가 불일치하거나, Nginx의 기본 설정(버퍼링 활성화, 짧은 타임아웃)이 SSE의 지속적인 연결 특성과 맞지 않아 연결이 불안정하거나 끊기는 문제가 발생할 수 있었습니다.

  • 수정 시도:

  1. 포트 일치: ALB의 Target Group 설정에서 지정한 포트와 Nginx 설정 파일 sites-available/default에서 listen 지시어로 설정한 포트가 동일한지 확인하고 일치시켰습니다. (예: ALB Target Port 8080 → Nginx listen 8080;)

  2. Nginx 설정 수정: SSE 통신에 적합하도록 Nginx 설정을 다음과 같이 수정했습니다.이 설정들은 Nginx를 통과하는 SSE 연결 자체의 안정성을 높이는 데 필수적입니다. 하지만 이 역시 애플리케이션 내부의 SseEmitter 객체 관리 로직이나 메모리 누수 문제를 직접 해결해주지는 못했습니다.

결론적으로, 특정 로직 오류 수정과 인프라 설정 최적화를 진행했지만, SseEmitter의 복잡한 생명주기를 완벽하게 관리하고 예측 불가능한 상황에서의 메모리 누수 가능성을 완전히 차단하는 것은 여전히 어렵고 위험 부담이 크다고 판단했습니다.

✅ Action : Polling으로의 전환

SseEmitter 누수 문제를 해결하기 위해 코드 레벨에서 생명주기 관리를 강화하고 예외 처리를 보강하는 방법도 고려할 수 있었지만, 다음과 같은 이유로 보다 근본적인 해결책인 Polling 방식으로의 전환을 결정했습니다.

  • 구현 복잡도: SSE는 연결 유지, 타임아웃, 재연결, 에러 처리 등 고려해야 할 사항이 많아 관리가 복잡하고 잠재적인 누수 포인트를 완전히 제거하기 어렵다고 판단했습니다.
  • 요구사항 재검토: Morib의 실시간 동기화 요구사항은 친구의 상태 변경 빈도가 아주 높지 않았기 때문에, 약간의 지연을 감수하더라도 Polling 방식으로 충분히 사용자 경험을 만족시킬 수 있다고 판단했습니다.
  • 안정성 확보: Polling 방식은 각 요청이 독립적으로 처리되므로 연결 관리의 복잡성이 없고, 상태 비저장(Stateless) 특성으로 인해 메모리 누수 발생 가능성이 현저히 낮아 시스템 안정성 확보에 유리했습니다.

이에 따라 기존 SSE 관련 로직을 제거하고, 클라이언트가 일정 주기마다 서버에 친구 상태 업데이트를 요청하는 단순한 HTTP Polling 방식으로 실시간 동기화 기능을 재구현했습니다.

✨ Result

Polling 방식으로 전환한 결과는 매우 성공적이었습니다.

  • OutOfMemoryError 완전 해결: SSE 관련 로직 제거 후, 이전에 발생하던 SseEmitter 누수로 인한 OOM은 더 이상 발생하지 않았습니다.
  • 서버 안정성 대폭 향상: 빈번했던 서버 다운 현상이 사라졌습니다.
  • 리소스 사용 효율화: 불필요한 연결 유지 로직이 사라지면서 서버 리소스 사용량도 이전보다 효율적으로 관리될 수 있었습니다.
profile
계속해서 의심하고, 고민하고, 질문하며 성장하는

0개의 댓글