실시간 멀티플레이 게임을 HestiaCP 서버에 올리기 — Node 12를 24로 올리고 배포까지

이온디 2026.09.28 12 0

모노윈드 게임 인트로 화면 — 고지도 스타일 타이틀

PHP 사이트 여럿이 이미 돌고 있는 웹호스팅 서버에, 상주 프로세스가 필요한 Node 실시간 게임을 얹어야 할 때가 있다. 요청이 올 때만 깨어나는 PHP 와 달리 WebSocket 서버는 계속 떠 있어야 해서, "FTP 로 파일만 올리면 끝"이 성립하지 않는다. 이 글은 HestiaCP 로 관리하는 공용 서버에 그런 게임 하나(모노윈드 — Colyseus 기반 항해 멀티플레이)를 올린 과정을 그대로 적은 것이다. 같은 상황에 놓인 사람에게 판단의 근거와 함정을 남긴다.

왜 일반 웹호스팅에 그냥 못 올리나

이 게임은 클라이언트(React + Vite 로 빌드한 정적 파일)와 서버(Colyseus WebSocket) 둘로 나뉜다. 항구 하나가 서버 방(room) 하나이고, 그 방이 접속자 목록과 항해 상태를 메모리에 들고 계속 살아 있어야 한다. 그래서 "요청이 오면 깨어나 응답하고 죽는" 구조로는 안 되고, 서버는 systemd 로 상주시켜야 한다. 정적 파일은 nginx 가 직접 서빙하고, WebSocket 과 API 만 상주 서버로 넘기는 형태가 된다.

Node 12 를 24 로 — "안 쓰는데 왜 올리나"에서 출발

서버의 Node 가 12 였다. 게임은 최소 20 이 필요했다. 이유는 막연한 "최신이 좋아서"가 아니라 실제로 쓰는 것들 때문이었다. 서버 코드가 process.loadEnvFile(Node 20.6+)를 쓰고, Colyseus 0.16 은 package 에 "Node 20 이상"을 못 박아 두었고, Vite 6 은 12 를 지원 목록에서 아예 뺐다. 12 로는 서버가 첫 줄에서 죽는다.

문제는 이 서버가 여러 사이트가 도는 공용 서버라는 점이었다. 그래서 "시스템 Node 를 올려도 다른 게 안 깨지나"부터 확인했다. 추적해 보니 시스템 Node 를 실제로 쓰는 서비스는 하나도 없었다. 누군가 npm 을 한 번 설치하면서 딸려 온 라이브러리들만 서로 물려 있었고, HestiaCP 자체는 통짜 바이너리라 시스템 Node 와 무관했다.

그래도 apt 로 갈아엎기 전에 dry-run 으로 무엇이 제거될지 먼저 봤다. 실제 설치가 아니라 시뮬레이션이라 아무것도 바뀌지 않는다.

curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -s nodejs # -s = 시뮬레이션(실제 변경 없음)

제거 목록은 npm, node-tap 같은 테스트 라이브러리, nodejs-doc 문서뿐이었다. 돌아가는 서비스는 하나도 없었다. 확인이 끝나서 실제 설치로 넘어갔고, 12 는 24 LTS 로 교체됐다. 참고로 Node 20 은 이미 수명이 끝났고(2026-03), 이 시점의 유일한 Active LTS 는 24 라 20 대신 24 를 골랐다.

설치가 파일 충돌로 한 번 멈췄다

한 방에 되진 않았다. dry-run 에 안 잡혔던 libnode-dev(옛 헤더 패키지)가 새 nodejs 와 같은 파일을 갖고 있어 dpkg 가 중간에 막았다. 이때 npm 은 이미 지워지고 새 nodejs 는 설치 전이라, 잠깐 npm 이 없는 어중간한 상태가 됐다. 충돌 패키지를 정리하고 다시 설치하니 24.21.0 + npm 11 로 정상화됐다. 공용 서버에서 런타임을 바꿀 때는 "될 것 같다"와 "된다"가 다르다는 걸 다시 확인한 대목이다.

HestiaCP 구조를 그대로 활용 — private 와 public_html

HestiaCP 는 도메인마다 public_html 과 private 두 폴더를 준다. 이 둘을 그대로 썼다.

  • public_html — nginx 가 서빙하는 곳. 빌드된 정적 파일(dist)만 둔다. 웹에 노출돼도 괜찮은 것들이다.
  • private — nginx 가 서빙하지 않는 곳. 서버 소스, node_modules, DB 접속 정보가 든 .env 를 여기 둔다. 서버 소스나 비밀번호가 URL 로 읽힐 위험이 사라진다.

nginx 설정은 HestiaCP 가 도메인 재빌드 때 덮어쓰므로, 도메인 파일을 직접 고치면 안 된다. 대신 커스텀 프록시 템플릿을 만들어 지정하는 게 정석이다. 이 서버엔 이미 다른 앱을 위한 커스텀 템플릿 사례가 있어 같은 방식을 따랐다. 정적 파일은 nginx 가 직접 주고, Colyseus 의 매치메이킹·WebSocket 업그레이드·API 경로만 상주 서버(127.0.0.1:2567)로 넘기는 템플릿이다.

배포 스크립트 — 커밋된 것만, 자격증명은 코드 밖

배포는 번호 메뉴가 있는 콘솔 스크립트로 만들었다. 원칙 세 가지를 지켰다. 코드는 git 에 커밋된 상태만 올린다(작업 중 변경은 안 올라간다). 자격증명은 스크립트에 박지 않고 .env 에서 읽어 환경변수로만 넘긴다. 그리고 삭제 동기화(rsync --delete)는 소스 폴더에만 걸고, .env 와 node_modules, 로그는 그 밖에 둬서 배포로 지워지지 않게 했다.

./deploy.sh setup # 최초 1회: 폴더·.env·systemd·nginx 템플릿
./deploy.sh deploy # 빌드 → 업로드 → 의존성 → DB → 재시작 → https 확인

배포하며 만난 버그 셋

한 번에 성공하지 않았다. 서버 측 배포를 끝내며 잡은 버그가 셋이다.

1. rsync 가 로그 폴더를 지워 서비스가 죽었다. 삭제 동기화가 소스를 맞추면서 서버의 로그 디렉토리를 함께 지웠다. systemd 는 로그 파일 경로를 못 열면 프로그램을 실행하기도 전에 죽는다(상태 209/STDOUT). 로그 폴더를 삭제 대상에서 빼고, 재시작 직전에 폴더를 보장하도록 이중으로 막았다.

2. 인증 키가 배포마다 사라질 위치에 만들어졌다. 서버 코드는 인증 비밀키가 없으면 스스로 만들어 파일에 저장하는데, 그 위치가 하필 삭제 동기화 대상이었다. 그대로 두면 배포할 때마다 키가 바뀌어 모든 로그인이 풀린다. 비밀키를 배포에도 살아남는 곳(systemd 가 읽는 .env)에 미리 만들어 두는 것으로 해결했다.

3. 자격증명 파일의 위치와 키 이름이 어긋났다. 스크립트가 기대한 경로·키 이름과 실제로 채워 둔 값이 달라 접속이 안 됐다. 스크립트가 실제 파일을 읽고 키 이름을 맞추도록 고쳤다.

세 버그의 공통점은 전부 "삭제 동기화가 지우면 안 되는 것을 지웠거나, 파일이 엉뚱한 곳에 있었다"는 것이다. 상주 서버 배포에서 가장 자주 밟는 지뢰다.

확인은 추측이 아니라 실제 응답으로

"됐습니다"라고 말하기 전에 실제로 응답을 받아 확인했다.

  • 정적 화면과 자산 파일이 https 로 200 을 돌려준다.
  • 게임 접속의 핵심인 매치메이킹 요청이 nginx 프록시를 통과해 실제로 방을 생성한다(roomId 반환).
  • 로그인 API 가 잘못된 요청을 정상적으로 거부한다(DB 조회까지 살아 있다는 뜻).

세 가지가 다 통과하면서 서버 측 배포가 끝났다. 위 대표 이미지가 실제로 뜬 게임 첫 화면이다.

정리 — 공용 서버에 Node 앱을 얹는 사람에게

  • 런타임을 올리기 전에 "지금 그걸 실제로 쓰는 서비스가 있는지"부터 추적하고, apt 는 dry-run 으로 제거 목록을 먼저 본다.
  • 버전은 높다고 좋은 게 아니라 그 앱이 요구하는 범위여야 한다. LTS 수명도 함께 본다.
  • HestiaCP 는 도메인 nginx 를 자기 방식으로 관리한다. 도메인 파일을 직접 고치지 말고 커스텀 템플릿으로 넣는다. public_html 과 private 를 용도대로 나눈다.
  • 삭제 동기화(rsync --delete)는 소스에만. .env·node_modules·logs·업로드는 그 바깥에 둔다.
  • 서버가 스스로 만드는 비밀키의 저장 위치를 확인한다. 배포로 지워지는 곳이면 로그인이 매번 풀린다.

데모: https://monowind.eond.com

댓글 0

목록 보기
댓글을 작성하려면 로그인하세요.
  • 첫 댓글을 남겨보세요.