swap 2GB 를 켰더니 일어난 변화
2GB RAM 서버에 swap을 켠 5분 작업이 OOM kill을 막았다. 하지만 진짜 수확은 과잉 설정된 JVM 힙을 발견한 것이었다. 결제·정산 파이프라인을 직접 운영하는 팀의 실전 메모리 튜닝 기록.
결제·정산 서버에서 OOM kill은 로그 하나가 아니다
일반적인 웹 서비스에서 OOM kill은 "서비스가 잠깐 내려갔다 재시작된다"는 뜻이다. 그런데 결제나 정산 파이프라인에서는 의미가 달라진다. 프로세스가 트랜잭션 처리 도중에 죽으면 상태가 미결(unknown state)로 남는다. "PG사에는 승인이 나갔는데 우리 DB에는 성공 기록이 없다"거나 "정산 배치가 절반만 처리된 채 종료됐다"는 상황이 실제로 생긴다. 이걸 복구하려면 로그를 수동으로 대조하고, 미처리 건을 골라내고, 경우에 따라 취소·재처리를 직접 집행해야 한다. 새벽 두 시에.
HEDVION 팀은 결제 승인 API, 일별·월별 정산 집계 배치, 그리고 이를 떠받치는 캐시·DB 레이어를 함께 운영한다. 인원이 적기 때문에 인프라 비용보다 장애 대응 시간이 더 비싸다. OOM kill 한 번이 야기하는 보정 작업은 짧게는 1시간, 길면 반나절이다. 그래서 "2GB 서버니까 어쩔 수 없다"는 태도를 유지하기가 어려웠다.
실제 서버 구성과 메모리 압박의 구조
문제가 됐던 인스턴스는 월 10달러대 클라우드 VM이다. RAM 2GB, vCPU 2코어. 여기에 네 가지 프로세스가 상주한다.
- Node.js API 서버: 결제 요청 수신, 외부 PG사 API 중계
- Spring Boot 배치: 일별·월별 정산 집계 및 리포트 생성
- MySQL: 거래 내역, 정산 결과 영속 저장
- Redis: 멱등성 키(idempotency key) 관리, 세션, 임시 캐시
ps aux --sort=-%mem로 정상 운영 중 각 프로세스를 측정하면 Java가 700900MB, Node.js가 150250MB, MySQL이 400600MB, Redis가 50100MB다. 합산하면 물리 메모리 2GB를 간당간당하게 채운다. 피크 타임, 즉 야간 정산 배치가 힙을 일시적으로 확장하는 순간, 커널은 가장 많은 메모리를 사용하는 프로세스를 골라 죽인다. 운이 나쁘면 MySQL이다. 그 순간 처리 중이던 결제 쓰기 트랜잭션이 그대로 날아간다.
swap 활성화: 설정 5분, 효과는 즉각적
방법 자체는 간단하다.
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
/etc/fstab에 한 줄 추가해 재부팅 후에도 유지되게 하고, /etc/sysctl.d/99-swap.conf에 vm.swappiness=10을 넣었다. swappiness=10은 "물리 메모리가 약 90% 소진될 때까지 swap을 거의 사용하지 말라"는 설정이다. 한 가지 반드시 짚을 점: swappiness=0은 "swap을 안 쓴다"가 아니다. 리눅스 커널은 심각한 메모리 압박 상황에서 값에 관계없이 swap을 쓴다. 0으로 설정하면 오히려 파일 캐시를 과도하게 비워 디스크 I/O 성능이 나빠질 수 있다. 10이 실용적인 타협점이었다.
적용 직전 2주와 적용 후 2주를 dmesg | grep -i oom-kill로 비교했다. 적용 전: 9회(야간 배치 실행 시간대에 집중). 적용 후: 0회. 숫자는 명확했다.
swap은 공짜 성능이 아니다 — p99 스파이크와의 트레이드오프
OOM kill은 사라졌지만 swap이 실제로 작동하는 순간 다른 문제가 드러났다. vmstat 1로 si(swap in), so(swap out) 컬럼을 실시간으로 보면, 배치 잡이 힙을 키우면서 swap I/O가 발생하는 구간에 결제 API의 응답 p99가 평소 80ms에서 300400ms로 35배 뛰었다. 배치가 힙을 확장할 때 Redis와 Node.js의 일부 페이지가 swap으로 밀려나고, 이후 그 프로세스들이 호출될 때 디스크에서 다시 읽어오는 과정에서 지연이 생기는 것이다. 우리 VM은 SSD 기반이라 수백 ms 수준이었지만, HDD였다면 수 초 단위 지연도 충분히 가능하다.
이 관찰이 중요한 이유는, swap을 켜는 것으로 끝내면 안 된다는 신호이기 때문이다. p99 스파이크가 일어나는 구간을 캡처해두면 "배치와 API 서버가 메모리를 동시에 경쟁하는 시간대"를 특정할 수 있다. swap은 죽음을 막는 안전망이지 리소스 부족의 해결책이 아니다. 안전망이 작동하고 있다는 신호 자체가 구조적 문제의 위치를 가리킨다.
진짜 문제는 JVM 힙 설정 오버프로비저닝이었다
swap을 켜고 나서야 각 프로세스의 메모리 사용 패턴을 처음으로 들여다봤다. jcmd <pid> VM.native_memory와 jmap -heap <pid>로 Java 프로세스를 분석하니 -Xmx가 1024MB로 설정돼 있었다. 실제 정산 배치 실행 중 힙 피크는 최대 620MB였다. 나머지 400MB는 "혹시 모를 여유"로 잡아둔 것인데, 이 여유분이 같은 서버에서 Redis와 Node.js를 죽이는 원인이 되고 있었다.
-Xmx700m -Xms256m으로 재설정했다. 시작 힙을 작게(256MB) 잡아 초기 메모리 점유를 낮추고, 최대를 실측 피크의 약 13% 여유를 더한 700MB로 제한했다. 결과는 즉각적이었다. 배치 실행 중에도 물리 메모리 사용률이 85~90% 선에서 유지됐고, free -h로 확인한 swap used는 100MB 미만으로 떨어졌다. p99 스파이크도 사라졌다.
Node.js도 마찬가지였다. --max-old-space-size를 명시하지 않으면 64비트 시스템 기준으로 1.5GB 이상을 허용한다. 결제 API는 요청 대부분이 짧고 stateless해 실제로는 150MB 이상을 거의 쓰지 않는다. --max-old-space-size=200을 명시하자 Node.js의 메모리 점유 상한이 명확해졌다. 각 프로세스가 무제한으로 힙을 키울 수 없게 되니 서버 전체의 메모리 예산이 보이기 시작했다.
우리 팀이 다음으로 할 것: 세 가지 실제 시나리오
배치 격리: 현재 가장 큰 구조적 문제는 정산 배치와 결제 API가 같은 서버에서 메모리를 경쟁한다는 것이다. 단기적으로는 배치 실행 직전에 Node.js 서버를 로드밸런서에서 잠시 제외하고(헬스체크 실패 처리), 배치 완료 후 복귀시키는 방식으로 피크 충돌을 피할 수 있다. 중기적으로는 배치를 별도 경량 컨테이너로 분리해 메모리 경쟁 자체를 없애는 것이 목표다.
Redis OOM 보호 우선순위: Redis가 죽으면 idempotency key 체크가 불가능해진다. 동일 결제 요청이 중복 처리될 수 있다는 뜻이다. OOMScoreAdjust를 systemd 서비스 파일에 명시해 커널이 Redis를 마지막에 고르도록 한다. /etc/systemd/system/redis.service 안에 OOMScoreAdjust=-900을 넣으면 재시작 후에도 유지된다. MySQL과 결제 API 서버에도 동일하게 적용해 보호 우선순위 계층을 명시적으로 정의해두는 것이 우리 기준이다.
사전 경보: 현재는 OOM이 발생한 뒤에야 인지한다. free -m을 cron으로 1분마다 실행해 물리 메모리 사용률이 85%를 초과하거나 swap used가 200MB를 넘으면 Slack 알림을 보내도록 구성 중이다. OOM kill은 결과이고 메모리 사용률 상승은 신호다. 신호를 잡으면 결과를 막을 수 있다.
지금 바로 써먹을 수 있는 시사점
swap을 켜기 전에 확인할 것, 켠 다음에 반드시 해야 할 것을 순서대로 정리한다.
1. vm.swappiness는 반드시 10~20으로 설정하라. 기본값 60은 swap을 너무 공격적으로 사용해 API 응답 지연을 키운다. 결제·API 서버가 올라간 인스턴스에서 기본값을 그대로 두는 것은 위험하다.
2. OOM kill 이력이 있다면 -Xmx를 먼저 의심하라. jmap -heap 또는 jcmd <pid> VM.native_memory로 실제 힙 피크를 측정하고, 피크의 110~120%를 상한으로 재설정하라. "넉넉하게 잡는" 습관이 다른 프로세스를 죽이는 직접적 원인이 된다.
3. Node.js에서는 --max-old-space-size를 반드시 명시하라. 기본값은 대용량 서버 기준이다. 2GB VM에서 명시 없이 쓰는 것은 상한 없는 메모리 사용을 허용하는 것과 같다.
4. 결제 처리 프로세스에는 systemd의 OOMScoreAdjust로 보호 우선순위를 지정하라. 한 줄이면 된다. MySQL, Redis, 결제 API 순으로 보호 강도를 높여두는 것이 우리 팀 기준이다.
5. swap used를 모니터링 대시보드에 반드시 포함하라. swap 사용량 증가는 p99 스파이크가 시작되기 전에 먼저 오른다. 보이지 않으면 막을 수 없다. vmstat, free -m, 또는 Prometheus node_exporter의 node_memory_SwapUsed_bytes를 쓰면 된다.
서버 사양을 올리는 결정은 이 다섯 가지를 다 해본 다음에 내려도 늦지 않는다.
— by slecs
* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.