Morib은 사용자에게 친구들의 실시간 몰입 상태를 제공하기 위해 초기에는 SSE(Server-Sent Events) 기술을 도입했습니다. 하지만 이는 곧 지속적인 OutOfMemoryError
발생과 잦은 서버 다운으로 이어졌습니다. 😥
이번에는 SseEmitter
누수로 인한 메모리 문제와 /sse/refresh
API의 성능 저하를 진단하고, 최종적으로 Polling 방식으로 전환하여 시스템 안정성을 확보하기까지의 여정을 공유하고자 합니다.
Sentry는 연일 OutOfMemoryError
발생을 알렸고, OOM은 잦은 서버 다운으로 이어져 서비스가 상당히 불안정했습니다. 이에, CPU 사용 100%까지 달성했습니다.
더불어, 친구 상태 등을 갱신하는 /sse/refresh
API의 성능 지표 역시 좋지 않았습니다. Sentry 기준으로 p95 응답 시간이 600ms를 초과하는 경우가 빈번했습니다.
OOM의 주된 원인은 대부분 메모리 누수입니다. 저희는 실시간 연결을 관리하는 SSE 구현 부분을 집중적으로 분석했습니다. SseEmitter
객체가 제대로 관리되지 않으면 연결이 종료된 후에도 가비지 컬렉션(GC) 대상이 되지 못하고 메모리에 계속 쌓여 결국 OOM을 유발할 수 있기 때문입니다.
OOM 발생 직전 시점의 힙 덤프(Heap Dump) 를 분석하기로 했습니다. 힙 덤프는 특정 시점의 JVM 힙 메모리 상태를 스냅샷으로 떠서 어떤 객체들이 메모리를 차지하고 있는지, 객체 간의 참조 관계는 어떤지 등을 상세히 분석할 수 있죠.
분석 과정:
jmap
을 사용하여 문제가 발생하는 시점에 수동으로 힙 덤프를 생성했습니다. jmap -dump:format=b,file=heapdump.hprof <PID>
분석 결과는 예상과 일치했습니다. 힙 덤프 분석 결과, 정상적으로 완료(complete)되지 않은 수많은 SseEmitter
객체들이 메모리에 남아있는 것을 확인할 수 있었습니다. 이는 SseEmitter
의 생명주기가 제대로 관리되지 않아 발생한 전형적인 메모리 누수 패턴이었습니다.
비동기 처리 중 예외 발생 시 complete()
또는 completeWithError()
호출 누락, 타임아웃 처리 미흡 등 여러 잠재적 원인이 의심되었습니다.
SseEmitter
누수라는 명확한 원인을 찾았지만, 바로 SSE를 포기하기보다는 기존 구현을 개선하여 문제를 해결해보려는 시도를 먼저 했습니다.
✨ 해결 시도 1: 로직 무한 루프 해결
가장 먼저 눈에 띈 것은 timeout
이벤트 처리 로직이었습니다.
기존 문제: 이전 로직은 특정 사용자의 연결에 timeout
이벤트가 발생하면, 해당 사용자를 포함한 모든 친구 관계의 사용자들에게 refresh
이벤트를 broadcast했습니다. 이는 불필요한 부하를 유발할 뿐만 아니라, 여러 연결에서 동시에 타임아웃이 발생하거나 로직이 꼬일 경우 무한 루프에 빠질 위험이 있었습니다. 또한, 어떤 SseEmitter
객체에 대한 타임아웃인지 명확히 식별하고 처리하는 로직도 부족했습니다.
수정 시도: 타임아웃 시 불필요한 broadcast 로직을 제거하고, 타임아웃이 발생한 당사자에게만 관련 이벤트(예: 연결 종료 알림 또는 재연결 유도)를 보내도록 handleSseTimeout
메서드를 수정했습니다.
이 수정으로 특정 로직의 무한 루프 가능성은 제거할 수 있었지만, 이는 근본적인 SseEmitter
객체의 생명주기 관리 문제나 다른 경로(예: 비정상 종료, 네트워크 오류)에서 발생하는 누수를 해결해주지는 못했습니다.
✨ 해결 시도 2: Nginx와 ALB 포트 설정 + Nginx 설정 파일 수정
애플리케이션 로직 외에 인프라 구성도 SSE 안정성에 영향을 미칠 수 있습니다. 특히 리버스 프록시(Nginx)와 로드밸런서(ALB) 설정이 중요합니다.
기존 문제: ALB에서 애플리케이션으로 트래픽을 전달하는 포트(Target Group 포트)와 Nginx가 리스닝하는 포트가 불일치하거나, Nginx의 기본 설정(버퍼링 활성화, 짧은 타임아웃)이 SSE의 지속적인 연결 특성과 맞지 않아 연결이 불안정하거나 끊기는 문제가 발생할 수 있었습니다.
수정 시도:
포트 일치: ALB의 Target Group 설정에서 지정한 포트와 Nginx 설정 파일 sites-available/default
에서 listen
지시어로 설정한 포트가 동일한지 확인하고 일치시켰습니다. (예: ALB Target Port 8080 → Nginx listen 8080;
)
Nginx 설정 수정: SSE 통신에 적합하도록 Nginx 설정을 다음과 같이 수정했습니다.이 설정들은 Nginx를 통과하는 SSE 연결 자체의 안정성을 높이는 데 필수적입니다. 하지만 이 역시 애플리케이션 내부의
SseEmitter
객체 관리 로직이나 메모리 누수 문제를 직접 해결해주지는 못했습니다.
결론적으로, 특정 로직 오류 수정과 인프라 설정 최적화를 진행했지만, SseEmitter
의 복잡한 생명주기를 완벽하게 관리하고 예측 불가능한 상황에서의 메모리 누수 가능성을 완전히 차단하는 것은 여전히 어렵고 위험 부담이 크다고 판단했습니다.
SseEmitter
누수 문제를 해결하기 위해 코드 레벨에서 생명주기 관리를 강화하고 예외 처리를 보강하는 방법도 고려할 수 있었지만, 다음과 같은 이유로 보다 근본적인 해결책인 Polling 방식으로의 전환을 결정했습니다.
이에 따라 기존 SSE 관련 로직을 제거하고, 클라이언트가 일정 주기마다 서버에 친구 상태 업데이트를 요청하는 단순한 HTTP Polling 방식으로 실시간 동기화 기능을 재구현했습니다.
Polling 방식으로 전환한 결과는 매우 성공적이었습니다.
OutOfMemoryError
완전 해결: SSE 관련 로직 제거 후, 이전에 발생하던 SseEmitter
누수로 인한 OOM은 더 이상 발생하지 않았습니다.