청크 사전 생성과 VM 용량 산정

Minecraft 서버를 운영하며 JVM 플래그, view·simulation distance, mob·chunk 설정과 VM 사양을 조정한 과정과 다시 한다면 적용할 측정 방법.

친구들과 쓰던 서버가 버벅일 때마다 Java heap을 늘리고, 플레이어 주변에서 처리할 범위를 줄이고, VM 사양도 키웠다. 처음에는 1GB였던 heap을 8GB까지 늘렸고, 유명한 Minecraft 서버 권장 옵션도 한꺼번에 적용했다. view-distancesimulation-distance는 접속해서 체감해 보며 여러 번 바꿨다.

서버는 다시 원활해지기도 했지만 어느 변경이 효과를 냈는지는 분명하게 설명하기 어려웠다. heap, VM, 청크 생성과 거리 설정을 비슷한 시기에 함께 바꿨기 때문이다. 이 경험을 바탕으로 각 설정이 실제로 줄이는 작업과, 같은 최적화를 다시 할 때 무엇을 측정해야 하는지 정리했다.

1GB에서 Aikar 플래그까지

heap과 서버 사양은 다음 순서로 키웠다.

시기Java·heap함께 바꾼 것
2025-05-20Java 22, Xms1G/Xmx1GMinecraft 1.21.4 초기 실행
2025-06-02Xms4G/Xmx6GG1GC와 pause 관련 옵션 추가
2025-06-04Java 21.0.7, Xms6G/Xmx6GAikar 계열 플래그 20개 적용
2025-06-23Xms6G/Xmx6GVM을 2 vCPU·8GB에서 4 vCPU·16GB로 확대
2025-06-25Xms8G/Xmx8G늘어난 VM 메모리에 맞춰 heap 확대

6월 초에는 다음 옵션을 한 묶음으로 적용했다.

-Xms6G -Xmx6G
-XX:+UseG1GC
-XX:+ParallelRefProcEnabled
-XX:MaxGCPauseMillis=200
-XX:+UnlockExperimentalVMOptions
-XX:+DisableExplicitGC
-XX:+AlwaysPreTouch
-XX:G1NewSizePercent=30
-XX:G1MaxNewSizePercent=40
-XX:G1HeapRegionSize=8M
-XX:G1ReservePercent=20
-XX:G1HeapWastePercent=5
-XX:G1MixedGCCountTarget=4
-XX:InitiatingHeapOccupancyPercent=15
-XX:G1MixedGCLiveThresholdPercent=90
-XX:G1RSetUpdatingPauseTimePercent=5
-XX:SurvivorRatio=32
-XX:+PerfDisableSharedMem
-XX:MaxTenuringThreshold=1

Paper 문서가 소개하는 Aikar 플래그에서 식별용 -D 옵션을 제외한 구성과 같다. 당시에는 Minecraft 서버에 널리 쓰이는 권장값이라는 이유로 묶음 전체를 가져왔다. 서버를 빨리 안정시키는 데는 편했지만, 스무 개를 한꺼번에 적용하면 어떤 옵션이 GC pause나 처리량에 도움이 됐는지 따로 판단할 수 없다.

heap을 미리 확보하는 세 옵션

Xms는 초기 heap, Xmx는 최대 heap 크기다. 처음에는 4GB에서 시작해 필요하면 6GB까지 늘어날 수 있었고, 이틀 뒤에는 시작부터 6GB로 고정했다. AlwaysPreTouch까지 사용하면 JVM이 시작할 때 heap page를 미리 touch한다. 실행 중 heap을 확장하며 page를 처음 만나는 비용 일부를 시작 시점으로 옮길 수 있지만, 부팅 직후 메모리 사용과 시작 시간은 늘어난다.

이 서버는 선점 뒤 부팅하고 Minecraft readiness를 회복해야 했다. 따라서 Xms=Xmx와 pre-touch는 tick 중 변동만이 아니라 재시작 지연과도 맞바꾸는 선택이었다. 당시에는 Done (...)까지 걸린 시간과 pre-touch 적용 전후를 비교하지 않았다.

G1의 판단 범위를 좁힌 옵션

Java 21에서는 G1이 server-class machine의 기본 collector다. UseG1GC를 명시하는 것 자체는 collector 선택을 문서화하지만, 기본값과 다른 결과를 만들었다고 볼 수는 없다. MaxGCPauseMillis=200도 hard limit가 아니라 G1이 작업량을 조절할 때 쓰는 목표다.

나머지 옵션은 G1이 스스로 정할 영역까지 구체적으로 고정한다.

  • G1NewSizePercent=30, G1MaxNewSizePercent=40은 young generation이 움직일 범위를 정한다.
  • G1HeapRegionSize=8M은 heap region 크기를 고정한다.
  • G1ReservePercent, G1HeapWastePercentG1MixedGCCountTarget은 회수 여유와 mixed collection 작업량에 관여한다.
  • InitiatingHeapOccupancyPercent=15는 marking을 일찍 시작하도록 유도한다.
  • SurvivorRatio=32, MaxTenuringThreshold=1은 객체가 survivor와 old generation으로 이동하는 조건에 영향을 준다.

이 값들은 하나의 workload를 가정한 묶음이다. 반면 Oracle의 Java 21 G1 가이드는 우선 기본값에서 시작하고, 필요하면 heap과 pause 목표를 조정하라고 안내한다. 특히 young generation 크기를 고정하면 G1이 pause 목표에 맞춰 young 영역을 조절할 여지가 줄어든다고 설명한다. Aikar 플래그가 항상 잘못됐다는 뜻은 아니다. 다만 내 서버에도 30, 40, 8M, 15가 맞는지는 GC log로 확인한 뒤 결정했어야 했다.

DisableExplicitGC는 애플리케이션이나 플러그인의 System.gc() 요청을 무시하게 한다. PerfDisableSharedMem은 HotSpot performance counter용 shared memory를 끈다. 전자는 플러그인의 불필요한 explicit GC를 막을 수 있지만, 후자는 일부 JVM 관측 도구가 읽을 데이터에도 영향을 줄 수 있다. 관측 가능성까지 포함해 선택했어야 할 옵션이다.

VM 16GB와 heap 8GB

서버 부하가 커지면서 VM을 e2-standard-2에서 n2-standard-4로 바꿨다. 2 vCPU·8GB에서 4 vCPU·16GB로 늘어난 구성이다. VM을 키운 이틀 뒤에는 Java heap도 6GB에서 8GB로 늘렸다.

VM 메모리를 전부 heap에 줄 수는 없다. 8GB VM에서 Xmx8G를 사용하면 Java가 heap 밖에서 쓸 공간과 운영체제의 몫이 남지 않는다. 먼저 VM 전체 메모리 예산을 잡고 그 안에서 heap을 정해야 한다.

flowchart LR
  accTitle: Minecraft VM 메모리 예산
  accDescr: heap live set에서 계산한 Xmx, Java heap 밖의 메모리, 운영체제와 page cache, 동시 작업의 메모리가 최종 VM 메모리 예산에 함께 들어간다.
  subgraph JAVA["Java process"]
    direction TB
    LIVE[부하 시 heap live set] --> XMX[Xmx와 GC 여유]
    NATIVE[heap 밖<br/>native·thread·buffer]
  end
  subgraph SHARED["같은 VM의 다른 사용처"]
    direction TB
    HOST[Linux·guest agent<br/>page cache]
    SIDE[Discord bot·백업<br/>Chunky·Dynmap]
  end
  XMX --> VM[필요한 VM memory]
  NATIVE --> VM
  HOST --> VM
  SIDE --> VM

Xmx는 Java 프로세스 전체 크기가 아니다. metaspace, thread stack, JIT code cache, direct buffer와 GC 자료구조는 heap 밖에 있다. 같은 VM에서는 Linux, Google guest agent, Discord 봇, 백업 압축과 filesystem page cache도 메모리를 사용한다.

CPU도 단순히 vCPU 개수에 비례하지 않는다. Minecraft의 중요한 tick 작업 일부는 한 thread에 집중된다. vCPU를 두 배로 늘리면 Chunky worker, GC, 네트워크와 다른 프로세스를 분산할 여지는 생기지만 main tick thread가 두 배 빨라진다고 보장하지 않는다. VM 증설의 효과는 전체 CPU 평균보다 thread별 CPU와 MSPT 변화로 확인해야 한다.

view distance와 simulation distance

server.properties의 두 distance는 이름이 비슷하지만 줄이는 작업이 다르다.

설정조정한 값주로 바꾸는 작업
view-distance14 → 12 → 10 → 14플레이어에게 보내고 유지할 월드 범위
simulation-distance14 → 6 → 7플레이어 주변에서 tick할 범위
network-compression-threshold64 → 256압축을 시작할 packet 크기

view distance는 플레이어 기준 반경이다. 다른 조건이 같다면 반경이 커질수록 전송 후보 청크와 메모리에 유지할 데이터가 면적으로 늘어난다. 다만 플레이어끼리 영역이 겹치고 이미 load된 청크를 공유하므로 (2d+1)² × 플레이어 수가 실제 load 수는 아니다.

simulation distance 안에서는 entity, block과 각종 game tick이 계속 진행된다. 14에서 6으로 내린 변화는 멀리 보이는 지형보다 실제로 갱신할 범위를 크게 줄이려는 선택으로 읽힌다. 6까지 낮춘 뒤에는 플레이 감각을 고려해 7로 다시 올렸다.

compression threshold를 64에서 256 byte로 올리면 64~255 byte packet은 더 이상 압축 대상이 아니다. 작은 packet을 압축하는 CPU 비용은 줄 수 있지만 전송량은 늘 수 있다. 네트워크가 충분한지, CPU가 병목이었는지 측정하지 않으면 어느 쪽이 유리했다고 말할 수 없다.

flowchart LR
  accTitle: 플레이어 주변 거리 설정이 줄이는 서버 작업
  accDescr: 플레이어 위치를 기준으로 클라이언트에 보낼 범위와 서버가 tick할 범위를 나누고, 각각 view와 tracking, simulation과 activation 설정으로 제한한다.
  P[플레이어 위치] --> SEND[클라이언트 전송]
  P --> PROCESS[서버 tick 처리]
  SEND --> VIEW[view distance<br/>chunk data]
  SEND --> TRACK[tracking range<br/>entity update]
  PROCESS --> SIM[simulation distance<br/>block·entity tick]
  PROCESS --> ACTIVE[activation range<br/>AI·이동]

view distance는 14에서 12, 10까지 낮췄다가 다시 14로 올렸다. 당시에는 직접 접속해 보며 결정했지만 TPS와 MSPT를 함께 비교하지 않았다. 그래서 최종값 14는 검증된 최적값이라기보다 플레이 감각을 우선해 고른 값에 가깝다.

mob 수와 entity 처리 범위

같은 6월 4일 변경에는 거리 외의 값도 있었다.

  • Bukkit spawn limit은 monster 70 → 40, animal 10 → 8, ambient 15 → 1로 낮아졌다.
  • Spigot entity tracking range는 player 128 → 48, animal·monster 96 → 32, misc 96 → 16으로 줄었다.
  • entity activation range는 animal 32 → 16, monster 32 → 24, misc 16 → 8로 줄었다.

spawn limit을 낮추면 생성·AI·충돌 대상이 줄 수 있다. activation range 밖의 entity는 매 tick 같은 수준으로 처리되지 않고, tracking range 밖의 entity는 client에 전송하지 않는다. CPU와 네트워크 부담을 줄일 가능성이 있지만 mob farm 생산량, 멀리 있는 동물의 움직임과 플레이어가 체감하는 세계도 함께 바뀐다. 성능 설정이면서 gameplay 설정인 이유다.

paper-global.yml, paper-world-defaults.ymlpurpur.yml이 길다고 해서 모든 항목을 직접 조정할 필요는 없다. worker-threads: -1, io-threads: -1, region-file-cache-size: 256처럼 서버가 생성한 기본값도 많다. 직접 바꾼 값과 기본값을 구분하지 않으면 나중에 무엇을 되돌리거나 비교해야 할지 알기 어렵다.

sync-chunk-writes=true도 이름만 보면 동기 쓰기를 선택한 듯하지만 현재 Paper 문서는 관련 system property를 별도로 켜지 않으면 기본적으로 효과가 없다고 설명한다. use-native-transport=true는 Linux native transport를 사용하는 설정이다. 설정 이름이나 주석만으로 성능 효과를 계산하지 않고, 당시 Purpur 버전에서 실제 적용됐는지부터 확인해야 한다.

반경 10,000블록의 대가

친구들이 처음 탐험하는 순간의 지형 생성 비용을 줄이려고 Chunky로 반경 10,000블록을 미리 생성했다. 1,565,001청크를 만드는 데 15시간 53분 18초가 걸렸다.

10,000 ÷ 16 = 625이므로 중심을 포함한 정사각형 한 변은 625 × 2 + 1 = 1,251청크다. 1,251² = 1,565,001이므로 같은 작업량을 계획할 때는 정사각형 범위를 기준으로 예상 시간을 계산할 수 있다.

/chunky world world
/chunky shape square
/chunky center 0 0
/chunky radius 10000
/chunky start

57,198초 동안의 전체 평균은 약 27.36 chunks/s다. 이 숫자는 작업 규모와 총 시간을 비교할 때는 유용하지만 pause, GC, Dynmap render와 플레이어 접속이 섞인 중간 상태를 숨긴다.

flowchart LR
  accTitle: 청크 사전 생성 범위가 만드는 후속 비용
  accDescr: 반경과 shape로 정한 청크 수가 실행 중 자원 비용과 생성 후 데이터 비용으로 갈라지고, 각각 tick 경쟁과 region 쓰기, 백업과 복원 부담으로 이어진다.
  SCOPE[반경·shape<br/>생성 청크 수] --> RUN[실행 중 비용]
  SCOPE --> DATA[생성 후 데이터]
  RUN --> CPU[terrain·lighting<br/>tick 경쟁]
  RUN --> IO[region 파일 쓰기<br/>실행 시간]
  DATA --> BACKUP[월드 크기<br/>백업·업로드]
  DATA --> RESTORE[보관 용량<br/>복원 시간]

같은 shape에서 반경을 두 배로 늘리면 면적은 대략 네 배가 된다. 같은 반경의 정사각형은 원형보다 약 4/π, 27% 넓다. 생성은 미래 탐험의 first-generation 비용을 한가한 시간으로 옮기지만 CPU, disk write, 월드 크기와 이후의 모든 백업 입력은 먼저 지불한다.

경고가 줄어든 뒤 남은 질문

서버가 가장 느렸던 시기의 로그에는 Can't keep up 경고가 몰려 있었다.

날짜Can't keep up 일치 행메모리 오류 일치 행
2025-06-2050
2025-06-2120
2025-06-226030
2025-06-236490
2025-06-2400
2025-06-2500

6월 22~23일에는 Chunky와 Dynmap을 함께 실행했고, 그 뒤 VM과 heap을 차례로 늘렸다. 24일부터 표본에서 경고가 보이지 않았지만, 당시에는 MSPT, GC pause, 코어별 CPU와 disk latency를 함께 수집하지 않았다. 서버가 나아졌다는 결과만으로 어느 변경이 효과를 냈는지는 나눠 말할 수 없다.

이 경험만으로 다음과 같이 단정해서는 안 된다.

  • Chunky나 Dynmap 하나가 1,252개의 지연 경고를 모두 만들었다.
  • VM 증설이나 heap 확대 하나가 이후 경고를 없앴다.
  • OOM이 발생해서 heap을 늘렸다.
  • Aikar 플래그가 pause를 줄였다.

당시 OOM은 겪지 않았다. 메모리 여유가 걱정돼 사양을 늘린 것과 실제 메모리 부족으로 서버가 종료된 것은 다른 상황이다.

/gc라는 이름의 함정

Discord 봇은 약 5분마다 RCON으로 gc를 실행했다. 이름 때문에 JVM에 garbage collection을 강제하는 명령처럼 보이지만, 봇은 응답에서 uptime, TPS, 최대·할당·여유 메모리를 읽어 서버 정보와 차트를 만들었다.

EssentialsX의 gc는 memory, uptime과 tick 정보를 보여 주며 System.gc()를 실행하지 않는다. 이름만 보고 DisableExplicitGC와 충돌한다고 판단하지 말고, 실제로 어떤 플러그인이 명령을 처리하고 무슨 결과를 돌려주는지 먼저 확인해야 한다.

다음 부하 시험의 순서

다시 같은 작업을 한다면 먼저 작은 반경으로 다음 시간축을 남긴다.

구간수행할 작업함께 기록할 값
baseline접속자 없는 평시 운영TPS·MSPT, heap·RSS, thread CPU, disk latency
warm-up작은 반경 Chunky 시작CPS, GC cycle·pause, 코어별 CPU
load테스트 사용자가 이동·건축tail MSPT, status latency, player event
pause/chunky pausequeue와 자원이 평시로 돌아오는 시간
tune설정 하나만 변경해 재실행같은 world·버전·범위의 전후 차이
pid=$(systemctl show --property MainPID --value minecraft-server.service)
pidstat -p "$pid" -t 10
vmstat 10
iostat -xz 10
jcmd "$pid" GC.heap_info

GC는 Java 21 형식의 회전 log를 시작 옵션에 추가한다.

-Xlog:gc*:logs/gc.log:time,uptime:filecount=5,filesize=10M

Native Memory Tracking이 필요하면 시험 환경에서 overhead와 시작 옵션을 먼저 확인한다. Minecraft 쪽에서는 spark profile, TPS와 MSPT를 같은 timestamp로 보존한다. heap, VM, view distance와 mob limit을 동시에 바꾸지 않아야 어느 변경이 어떤 결과를 냈는지 비교할 수 있다.

각 실행에는 서버·Java·플러그인 버전, VM과 디스크 사양, JVM 옵션, Chunky 범위를 함께 적는다. 월드는 seed 대신 공개 가능한 별칭으로 구분한다. 시작·종료 시각과 pause 횟수, 자원 사용량, 생성 전후 월드 크기와 백업 시간도 같은 실행 단위로 묶는다. 그래야 평균 27.36 CPS가 단순한 숫자에 머물지 않고, 같은 조건으로 다시 시험할 수 있는 기준이 된다.

디스크도 종료 시점의 여유 공간만 보면 안 된다. 청크 사전 생성 뒤 백업할 때는 다음 데이터가 잠시 함께 존재할 수 있다.

peak local bytes
  = live world
  + backup working copy
  + compressed archive
  + operation 중 증가분
  + filesystem 여유

당시에는 서버를 빨리 다시 열기 위해 여러 값을 한꺼번에 바꿨다. 친구들은 다시 접속할 수 있었지만, 다음 서버에 그대로 적용할 만한 최적화 방법은 남기지 못했다. 다시 한다면 새로운 G1 플래그를 더 찾기 전에 작은 부하 시험을 만들고, 한 번에 하나씩 바꾸며 결과를 비교할 것이다.

참고

Share

공유

이미지 확대