ecsimsw

대기열 사이즈와 OOM, 이벤트 유실 문제 해결 본문

대기열 사이즈와 OOM, 이벤트 유실 문제 해결

JinHwan Kim 2024. 12. 25. 20:48

OOM 발생

기기 이벤트를 수신하여 후처리 하는 서버에서 OOM이 발생하고 있다. 특정 시간대에서 스파이크성 트래픽이 발생하는 것은 아니고, 서버를 실행하고 N시간 후에 OOM과 함께 서버가 다운된다. 이 글에선 해당 문제를 모니터링했던 방법과 원인, 해결 방안을 정리한다.
 

원인 파악

메모리 누수 파악
 
서버의 힙 메모리와 GC 동작 기록이다. 위 보드의 노란색이 Old Gen, 아래 보드의 파란색과 녹색이 각각 Minor GC, Major GC이다. GC 동작 이후에도 Old Gen의 최저 수위가 점점 높아지는 것을 볼 수 있다. Major GC의 처리 대상이 되지 못하는 누수가 계속 쌓이고 있고, 결국 Old gen이 메모리를 가득 채워 OOM이 발생하게 된다.
 
OOM에 메모리 누수를 예상했지만서도, 다른 변경이 전혀 없이 그간 한 번도 문제를 일으키지 않았던 서버라 의아했다.
 

 
 
Heap dump
 
'-XX:+HeapDumpOnOutOfMemoryError'를 사용하면, 서버가 OOM으로 죽는 시점에서 Heap dump 파일을 남길 수 있다. LinkedBlockingQueue가 90% 이상의 메모리를 사용하고 있는 것을 확인했고, 코드에서 사용처를 쫓기 시작했다.
 

 
 

코드 분석

ExecutorService
 
해당 서버는 처리 로직 안에서 비동기 처리를 위해 ExecutorService를 사용하고 있다. ExecutorService의 ThreadPoolExecutor는 기본값으로 LinkedBlockingQueue를 사용하고 있고, 스레드가 부족한 상황에서 들어오는 작업을 이 큐에 저장하여, 후에 FIFO로 처리되게 된다. 
 
다른 Capacity를 지정이 없다면, 이 대기열은 Integer.MAX_VALUE를 크기 제한으로 갖게 된다. GC는 이 대기열을 수집 대상으로 보지 않기 때문에, 너무 많은 작업이 이 대기열에 쌓이게 되면 모든 Heap을 차지하며 OOM을 발생할 수 있는 원인이 될 수 있다.

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(
          nThreads, 
          nThreads,
          0L, 
          TimeUnit.MILLISECONDS,
          new LinkedBlockingQueue<Runnable>()
     );
}

 
처리량과 유입량
 
ExecutorService로 들어오는 비동기 작업에 대한 처리량이 유입량보다 컸던 것이 문제의 원인이었다. 해당 서버는 기기 이벤트를 수신하여 후처리 하는 서비스였는데, 회사의 활성화 기기가 점점 늘어나면서 기존에는 처리량 > 유입량이었던 것이, 지금은 처리량 < 유입량이 되었던 것이다. 처리량을 따라가지 못한 유입 이벤트는 크기가 제한되지 않았던 대기열(LinkedBlockingQueue)에 쌓였고, GC에 수집되지 못한 채 조금씩 늘어나 결국 모든 Heap 메모리 영역을 차지하게 된 것이다.
 

 

해결 방안

처리량 늘리기 - Thread 수 늘리기, 캐시 처리
 
처리량을 유입량보다 늘려 대기열에 작업이 쌓이지 않도록 한다. 스레드 수를 늘리고 병목 포인트를 확인했다. 로직 안에 DB 조회가 있어 단순히 Thread 수를 늘려도 CP 수에 처리량이 제한하는 되는 상황이 발생할 수 있겠다는 생각이 들었고, 조회에 캐시를 적용하여 CP 수 변경 없이 조회 성능과 스레드 수를 높일 수 있었다.
 
유입량 줄이기 - 이벤트 필터링
 
반대로 처리량보다 유입량을 줄여 대기열에 작업이 쌓이지 않도록 한다. 수신한 이벤트 중 처리가 필요한 이벤트 필터링 점검했다. 시간이 오래되어 처리가 불필요하다고 생각되는 이벤트는 애초에 비동기 작업을 수행하지 않고 유실시킨다.
 
메모리 사용 제한 - 대기열 사이즈 설정
 
위 방안으로 처리량을 높이고 유입량을 줄였지만, 기기 수가 늘고 이벤트 수가 늘면 언젠가는 다시 처리량보다 유입량이 커지는 상황이 발생할 것이다. 대기열 사이즈(Capacity)를 조정하여 대기 작업 수를 제한한다. OOM 발생이라는 최악의 상황을 막을 수 있다.
 

이벤트 유실

대기열을 모두 차면
 
ThreadPool의 Capacity를 제한하게 되면, 대기열도 모두 찬 상태에서 새로운 작업을 수행하지 못하고 예외를 발생시킨다. 대기열의 작업 수를 줄일 수 있었지만, 반대로 그를 넘어선 유입에는 유실 문제를 고민해야 한다.
 
유실되는 이벤트를 레디스에 저장하고, 이를 처리할 수 있는 서비스를 따로 둔다. 빠르게 저장 할 수 있으며, 처리 시점이 지나버리면 유실과 다를 바 없는 이벤트이기 때문에 TTL를 지정하여 무의미한 엔티티를 자동으로 소멸시킬 수 있고, 재난 시 다른 프로세스에서 접근하여 이벤트를 처리할 것이기 때문에 글로벌로 접근하도록 하는 것으로 레디스가 가장 적합했다.
 
물론 이런 레디스에 이벤트를 저장하는 것은 최후의 수단이 되어야 할 것이다. 레디스에 전달하는 시간이 추가되니, 비동기로 처리 로직을 던지고 바로 응답하는 지금의 처리보다 응답 시간이 길어질 것으로 예상한다. 단순히 빠른 응답 시간보다는 톰캣의 요청 처리 스레드 풀이 모두 다 차지하여 또 다른 요청 처리 불가 문제를 피하고자 한다.
 
메모리 사용 계획
 
메모리 사용 계획을 단순하게 보면 아래와 같다. ThreadPool의 대기열을 제한하여 대기열이 모든 Heap 메모리를 차지하는 OOM을 막는다. 대기열 사이즈가 메모리를 차지하기 이전에 스케일 아웃을 설정하여 처리량을 늘리고, 전체 대기열 사이즈를 늘린다.  
 
그럼에도 대기열 사이즈를 넘어선 이벤트가 있다면 이는 재난 상황으로 본다. 유실되는 이벤트를 동기로 레디스에 저장하고 다른 프로세스로 처리될 수 있도록 한다. 그 전에 스케일 아웃이 트리거 되고, 다른 서버에서 함께 이벤트를 처리하여 대기열에 찼던 작업들이 다시 제거될 수 있도록 돕는다. 그렇게 대기열의 작업이 처리되어 메모리 사용이 다시 줄어들면, 스케일 인하여 서버 리소스를 줄이게 된다.
 

 
딱 계획과 같은 메모리 사이즈, 오토 스케일링을 구성하긴 쉽지 않다. 대기열 사이즈가 어느정도일 때 메모리를 얼마나 차지하는지, 해당 서비스는 스케일링되어 요청 처리가 준비되기까지 몇 초의 시간이 걸리고, 그 시간 동안 메모리가 버텨줄 수 있는지 등에 대한 꼼꼼하고 꾸준한 테스트가 필요하다.

Comments