이 글에서 다루는 내용

이 글에서는 Raspberry Pi 5 (8GB, SSD 부팅) 환경에서 Spring Boot JAR 서비스를 운영하면서 마주친 메모리 이슈를 실제 터미널 로그와 함께 살펴본다. ps, top, jps, jcmd 같은 명령어로 실행 중인 JAR의 커맨드라인과 메모리 사용량을 점검하는 방법, 스프링부트 JAR 하나가 실제로 얼마나 많은 메모리를 쓰는지, Pi5 8GB로는 몇 개까지 올릴 수 있는지, 그리고 안정적인 운영을 위한 Swap 설정 방법까지 정리한다.

실행 중인 JAR 점검하기

Pi5에 접속해서 현재 어떤 Java 프로세스가 돌고 있는지부터 확인했다.

$ ps -ef | grep java
enes  2198075  1  0 Apr16 ?  00:20:36 /usr/bin/java -jar /srv/ss/app/word-mov.jar --spring.profiles.active=prod
enes  2912581  1  5 09:50 ?  00:01:20 /usr/bin/java -javaagent:/opt/scouter-agent/scouter.agent.jar -Xms512m -Xmx512m -XX:MaxMetaspaceSize=160m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/ytylab/dumps/ -XX:+ExitOnOutOfMemoryError -Xlog:gc*:file=/opt/ytylab/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M -Dscouter.config=/opt/scouter-agent/conf/scouter.conf -jar /opt/ytylab/blog-spring-server.jar --spring.profiles.active=prod

두 개의 JAR가 실행 중이었고, 설정 수준은 극단적으로 달랐다.

word-mov.jar

/usr/bin/java -jar /srv/ss/app/word-mov.jar --spring.profiles.active=prod

JVM 옵션이 하나도 없다. 힙 크기 제한도, 힙 덤프도, GC 로그도 없다. 가장 큰 문제는 OOM이 발생해도 원인을 추적할 수 있는 아무런 단서가 남지 않는다는 점이다.

blog-spring-server.jar

반대로 이 JAR는 운영 환경에 필요한 옵션이 모두 들어가 있다. 힙 512MB 고정, Metaspace 160MB 상한, OOM 시 힙 덤프 생성, GC 로그 롤링, Scouter APM 에이전트 연결까지. 이상적인 세팅이다.

메모리 사용량 확인

top으로 두 프로세스의 실제 메모리 사용량을 봤다.

$ top -p 2198075,2912581
    PID USER   VIRT    RES    %CPU  %MEM   COMMAND
2912581 enes   3405716 691420  0.3  8.5   java  (blog-spring-server)
2198075 enes   5903936 823356  0.0 10.1   java  (word-mov)

여기서 주목할 값은 RES (Resident Set Size, 실제 물리 메모리 사용량)다.

프로세스 힙 설정 RES
blog-spring-server.jar -Xmx512m 691 MB
word-mov.jar 제한 없음 823 MB

힙 제한이 있는 쪽이 오히려 메모리를 덜 쓰고 있다. word-mov는 제한이 없어서 JVM이 호스트 메모리의 최대 25%까지 힙을 잡을 수 있다는 게 그대로 드러났다.

JAR 하나가 왜 700~800MB나 쓸까

이 질문이 핵심이었다. 스프링부트 JAR 하나의 메모리 구성을 뜯어보면 힙만 쓰는 게 아니다.

실제 RSS 700~800MB
├─ Heap (-Xmx)          : 256~512 MB  ← 튜닝 가능한 영역
├─ Metaspace            :  80~150 MB  (클래스 메타데이터)
├─ Code Cache (JIT)     :  50~100 MB  (컴파일된 네이티브 코드)
├─ Thread Stacks        :  50~100 MB  (톰캣 스레드 × 512KB)
├─ Direct/Native Memory :  50~200 MB  (Netty, NIO 버퍼)
├─ GC 오버헤드          :  20~50 MB
└─ JVM 자체             :  50~100 MB

힙을 256MB로 줄여도 전체 RSS는 500MB 아래로 잘 내려가지 않는다. 스프링 프레임워크가 로드하는 클래스 수 자체가 많기 때문이다.

Pi5 8GB에 JAR 몇 개나 올릴 수 있나

단순 계산으로는 가용 5GB ÷ 800MB ≈ 6개지만, 현실은 다르다. OS, 파일시스템 캐시, 스파이크 버퍼까지 2.5GB 정도는 반드시 남겨야 한다.

시나리오 추가 가능 총 JAR 수
안전 운영 +2개 4개
타이트 운영 +3개 5개
위험 +4개 이상 6개+

결론은 현재 설정 그대로라면 4~5개가 한계다. 8GB RAM에 비해 빡빡해 보이지만, 이건 Pi5의 한계가 아니라 스프링부트 JAR의 메모리 특성이다.

Swap 상태 확인

메모리 여유가 빡빡하면 Swap이라도 있어야 OOM Killer로부터 서비스를 지킬 수 있다. 그런데 확인해보니,

$ swapon --show
(아무 출력 없음)

$ free -h
               total    used    free     shared  buff/cache  available
Mem:           7.8Gi    2.9Gi   538Mi    35Mi    4.6Gi       4.9Gi
Swap:          0B       0B      0B

Swap이 완전히 비활성화 상태였다. 안전벨트 없이 운전하는 셈이다. JAR 하나만 메모리 스파이크가 나도 OOM Killer가 즉시 프로세스를 죽인다.

Swap 설정 - 간단 버전

SSD 부팅 + 8GB RAM 환경 기준, RAM과 동일한 8GB Swap을 만들었다. 핵심은 딱 5줄이다.

sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

각 줄이 하는 일은 다음과 같다.

명령어 역할
fallocate -l 8G /swapfile 8GB 빈 파일 생성
chmod 600 소유자만 접근 가능하게 권한 설정
mkswap Swap 전용 형식으로 포맷
swapon 즉시 활성화
fstab 등록 재부팅 후에도 자동 적용

실행 직후 바로 적용됐다.

$ free -h
               total    used    free     shared  buff/cache  available
Mem:           7.8Gi    2.6Gi   758Mi    35Mi    4.6Gi       5.1Gi
Swap:          8.0Gi    0B      8.0Gi

Swap used가 0B인 게 정상이다. Swap은 평소엔 쓰이지 않다가 RAM이 꽉 찼을 때만 비상용으로 동작한다. "Swap이 쓰이지 않는 상태가 베스트" 다.

Swap 설정 - 최적화 버전

간단 버전 위에 sysctl 튜닝을 추가하면 SSD 수명과 I/O 성능을 더 챙길 수 있다. 필수는 아니지만 장기 운영이라면 하는 게 좋다.

sudo tee /etc/sysctl.d/99-swap.conf > /dev/null <<EOF
# Swap 사용 적극성 (낮을수록 RAM 우선)
vm.swappiness=10

# 파일시스템 캐시 유지 (I/O 성능)
vm.vfs_cache_pressure=50

# Dirty page 디스크 쓰기 지연 (SSD 쓰기 부하 분산)
vm.dirty_background_ratio=5
vm.dirty_ratio=10
EOF

sudo sysctl --system

각 설정의 의미는 다음과 같다.

설정 의미
vm.swappiness 10 RAM이 90% 차야 Swap 사용 (기본값 60)
vm.vfs_cache_pressure 50 파일 캐시를 오래 유지해 I/O 빠르게
vm.dirty_background_ratio 5 5%부터 백그라운드 쓰기 시작
vm.dirty_ratio 10 10% 넘으면 강제 플러시

Pi 기본 설정은 Swappiness가 높은 편인데, JAR 서비스처럼 메모리를 꾸준히 쓰는 워크로드에서는 낮게 조정하는 게 유리하다. Swap으로 밀려난 JVM 메모리는 GC가 건드릴 때마다 디스크 I/O가 발생해서 체감 성능이 급격히 떨어지기 때문이다.

Swap 설정 후 운영 가능 개수

힙 설정을 추가로 손보면 JAR 운영 가능 개수가 크게 달라진다.

힙 설정 JAR당 RSS 8GB RAM + 8GB Swap에서
튜닝 없음 약 800 MB 6~7개
-Xmx512m 적용 약 650 MB 8~9개
-Xmx256m 적용 약 450 MB 12~14개
GraalVM Native 약 120 MB 40~50개

가장 큰 레버리지는 GraalVM Native Image 쪽에 있다. 같은 Spring Boot 앱을 AOT 컴파일하면 기동 시간이 10초대에서 0.05초로 줄고 메모리는 1/5 수준이 된다. Spring Boot 3.x부터 공식 지원이라 시도 비용도 예전보다 낮다.

다음 할 일

  • word-mov.jar에도 blog-spring-server.jar 수준의 JVM 옵션 추가 (-Xmx, OOM 덤프, GC 로그)
  • 힙 덤프 저장 디렉토리 사전 생성
  • 자주 재배포하지 않는 JAR부터 GraalVM Native Image 전환 검토
  • RAM 증설이 필요하다면 Pi5 16GB 모델 또는 미니 PC 고려