Out Of Memory 해결기

저사양 컨테이너 환경에서 Spring Boot 서비스의 안정성을 확보하는 방법

클라우드 환경의 발전으로 서비스를 손쉽게 배포할 수 있게 되었지만, 제한된 리소스 내에서 애플리케이션의 안정성을 유지하는 것은 여전히 엔지니어에게 중요한 과제입니다. 특히 Railway와 같은 PaaS 환경의 프리티어에서 제공하는 1GB 내외의 메모리는 Spring Boot 애플리케이션을 구동하기에 다소 도전적인 수치입니다.

본 포스팅에서는 1GB 메모리 환경에서 발생한 OutOfMemory(OOM) 문제를 진단하고, 이를 해결하기 위해 진행한 JVM 튜닝, 커넥션 풀 최적화, 그리고 배치 프로세스 개선 과정을 공유하고자 합니다.


1. 문제 상황의 인지 및 원인 분석

초기 설정에서 애플리케이션은 간헐적으로 프로세스가 강제 종료되는 현상을 보였습니다. 원인은 컨테이너가 허용하는 메모리 임계치를 초과하여 발생한 OOM Kill이었습니다. 주요 원인은 다음과 같이 세 가지로 요약되었습니다.

  1. 지나치게 높은 Heap 메모리 점유율: 기존 설정은 전체 메모리의 75%를 Heap 영역에 할당했습니다. 이는 Metaspace, Stack, Code Cache 등 JVM의 Non-Heap 영역과 OS의 최소 작동 메모리를 고려하지 않은 설정이었습니다.

  2. 빌드 단계의 과도한 리소스 사용: Docker 빌드 시 Gradle 데몬이 백그라운드에서 동작하며 상당한 메모리를 점유했습니다.

  3. 배치 작업의 메모리 부하: DB에 축적된 고아 데이터를 처리하는 배치 작업이 데이터를 List 형태로 한꺼번에 조회하며 Heap 메모리를 순간적으로 고갈시키고 있었습니다.


2. 인프라 최적화: JVM 및 Docker 설정

가장 먼저 인프라 수준에서 메모리 가용량을 확보하기 위한 튜닝을 진행했습니다.

JVM 파라미터 재조정

Heap 메모리 비중을 50%로 낮추어 Non-Heap 영역과의 균형을 맞추었습니다. 또한, 성능 최적화보다는 메모리 절약이 우선인 환경임을 고려하여 Tiered Compilation 레벨을 조정했습니다.

  • MaxRAMPercentage=50.0: 1GB 환경에서 약 500MB를 Heap으로 할당하고 나머지를 여유 공간으로 확보했습니다.

  • TieredStopAtLevel=1: C2 컴파일러를 제한하여 컴파일 타임의 메모리 부하를 줄이고 시작 속도를 개선했습니다.

  • MaxMetaspaceSize 및 ReservedCodeCacheSize: 무제한으로 증가할 수 있는 영역에 상한선을 두어 예측 불가능한 메모리 팽창을 방지했습니다.

빌드 프로세스 개선

Dockerfile 내 빌드 단계에서 Gradle 인메모리 인자를 명시하여 빌드 중 발생하는 메모리 오버헤드를 제어했습니다. org.gradle.jvmargs="-Xmx512m" 설정을 통해 빌드 환경의 안정성을 높였습니다.


3. 리소스 관리 최적화: 커넥션 풀 다이어트

DB 커넥션은 그 자체로 메모리를 점유하는 리소스입니다. 트래픽이 집중되지 않는 초기 서비스 환경에서는 커넥션 풀의 기본값인 10개가 불필요하게 느껴질 수 있습니다.

이에 HikariCP 설정을 다음과 같이 최적화했습니다.

  • maximum-pool-size를 5로 축소: 커넥션 수를 제한하여 관련 객체들이 차지하는 메모리 풋프린트를 줄였습니다.

  • idle-timeout 단축: 사용되지 않는 커넥션을 보다 빠르게 회수하여 시스템에 자원을 반납하도록 유도했습니다.


4. 애플리케이션 최적화: 배치 프로세스의 페이징 및 소프트 삭제 도입

가장 체감 큰 개선은 배치 작업(R2ImageBatch)의 로직 수정에서 이루어졌습니다. 기존의 방식은 삭제 대상 데이터를 한 번에 메모리로 로드하는 구조적 결함이 있었습니다.

Slice를 활용한 페이징 처리

List 대신 Spring Data JPA의 Slice를 사용하여 데이터를 적절한 단위(BATCH_SIZE = 50)로 끊어서 처리했습니다. Slice는 전체 데이터의 개수를 세는 Count 쿼리를 생략하므로 대량 데이터 처리 시 Page보다 성능상 이점이 있습니다. 이를 통해 수만 건의 데이터가 쌓이더라도 Heap 메모리는 일정한 수준을 유지하게 되었습니다.

Soft Delete와 상태 관리

기존의 하드 삭제(Hard Delete) 방식에서 소프트 삭제(Soft Delete) 방식으로 전환했습니다.

  • 데이터 정합성: 외부 스토리지(R2)의 실제 파일은 삭제하되, DB 레코드에는 deletedAt 필드에 시각을 기록하여 데이터의 이력을 관리했습니다.

  • 조회 최적화: 배치 작업 시 deletedAt IS NULL 조건을 추가하여 이미 처리가 완료된 데이터를 중복으로 읽어오는 비용을 제거했습니다.


5. 결론 및 성과

위와 같은 일련의 최적화 과정을 거친 후, Railway 환경에서의 OOM 현상은 더 이상 발생하지 않았습니다. 리소스가 극도로 제한된 환경에서는 단순히 고사양 서버로 스케일 업을 고려하기보다, 애플리케이션의 리소스 사용 패턴을 면밀히 분석하고 JVM 수준부터 코드 수준까지 최적화하는 과정이 선행되어야 함을 다시 한번 확인했습니다.

이번 개선 작업은 서비스의 안정성을 확보했을 뿐만 아니라, 효율적인 리소스 관리 방식을 팀 전체에 내재화하는 계기가 되었습니다. 유사한 환경에서 문제를 겪는 엔지니어분들에게 본 사례가 도움이 되기를 바랍니다.

링크:
링크: » 일본어로 보기 (日本語で見る)
링크: » 영어로 보기 (Switch to English)
공유: