가상 오피스 개발기 — 맵을 코드에서 데이터로 옮기고, git push 한 번으로 배포되게 만들기
한 세션 동안 성격이 정반대인 두 작업을 했다. 하나는 기능 개발 — 게임 맵을 인게임 에디터로 편집할 수 있도록 하드코딩된 맵 요소를 DB로 이관하는 일. 다른 하나는 인프라 — “git push 한 번이면 배포”가 조용히 깨져 있던 걸 근본부터 고치는 일. 겉보기엔 무관한 두 작업인데, 끝내고 보니 결국 같은 이야기였다. 코드와 실행 환경에 박혀 있던 암묵적 가정을 명시적인 데이터와 설정으로 걷어내자, 그 뒤로 반복 노동이 사라졌다.
1부. 맵을 코드에서 데이터로
가상 오피스에는 방과 방을 잇는 포털(밟으면 다른 맵으로 이동)과, 방명록·유튜브 TV 같은 인터랙션 오브젝트가 있다. 원래 이 둘은 씬을 만드는 코드 안에 배열로 박혀 있었다. 로비에서 포털 위치 하나를 옮기려 해도 코드를 고치고 다시 배포해야 했다.
목표는 이걸 인게임 MapMaker 에디터에서 마우스로 배치하는 것. 그러려면 맵의 “진실”이 코드가 아니라 DB에 있어야 한다. 이미 가구·바닥·조명은 map_objects 테이블로 옮겨둔 상태였고, 여기에 포털과 인터랙션을 마저 얹었다.
-- map_objects 에 kind 종류 추가 (마이그레이션 007)
-- kind = 'portal' : { targetMap, x, y }
-- kind = 'interaction' : { type: 'guestbook' | 'iframe', url?, label? }
로더에 toPortals() / toInteractions() 변환 함수를 두고, 씬은 코드 배열이 아니라 DB에서 읽은 행으로 오브젝트를 생성하도록 바꿨다. 코드에 남아 있던 하드코딩 배열은 비웠다. 에디터도 로비 전용 가드를 걷어내고, 어떤 맵을 편집 중인지 알도록 스토어에 mapId를 추가해 회의실·휴게실도 편집 가능하게 만들었다.
함정 1 — 화이트리스트 하나가 저장을 통째로 거부하다
본 작업에 앞서 잡아야 할 선행 버그가 하나 있었다. 저장이 통째로 400으로 튕겼다. 저장 API에는 허용된 kind만 받는 화이트리스트 VALID_KINDS가 있었는데, 이전 단계에서 추가한 light/beam/collider가 여기에 빠져 있었다.
// 저장 route 의 검증
const VALID_KINDS = ['furniture', 'floor', /* light, beam, collider 누락! */];
if (rows.some(r => !VALID_KINDS.includes(r.kind))) {
return res.status(400); // ← 조명 하나 섞이면 20개 전부 거부
}
고약했던 건 부분 실패가 아니라 전체 거부였다는 점이다. 유효하지 않은 행 하나 때문에 멀쩡한 행까지 몽땅 저장되지 않았다. 증상이 “가끔 저장이 안 된다”가 아니라 “조명이 섞인 맵만 저장이 안 된다”여서, 재현 조건을 좁히기 전까지는 원인이 보이지 않았다. 화이트리스트에 세 종류를 마저 넣으니 풀렸다.
함정 2 — 완전 이관의 부작용: 안전망도 같이 사라진다
포털을 DB로 완전히 옮기고 나니, 리뷰에서 이런 지적이 나왔다. 이제 포털의 존재 자체가 map_objects fetch 성공에 의존한다. 회의실에서 fetch가 실패하면 로비로 돌아갈 포털이 하나도 생성되지 않아, 새로고침 전까지 그 방에 갇힌다.
예전 하드코딩 배열은 “코드에 박혀 있으니 항상 존재한다”는 안전망이기도 했다. 데이터로 옮기면 유연해지는 대신, 데이터를 못 읽으면 아무것도 없는 상태가 새로 생긴다. 대응은 간단하다 — fetch 실패 시 대상 맵의 기본 복귀 포털을 폴백으로 넣거나 1회 재시도. 병합을 막을 문제는 아니었지만(드문 케이스), 데이터화할 때는 이 “빈 상태”를 항상 같이 설계해야 한다는 걸 남겨뒀다.
2부. git push 한 번으로 배포되게 만들기
배포는 Jenkins가 master push마다 빌드하고 pm2를 재시작하도록 이미 구성돼 있었다. 그런데 조용히 안 됐다. 에러가 요란하게 나면 차라리 쉬운데, 두 함정 모두 “에러 없이 딴 걸 보고 있는” 종류라 오래 걸렸다.
함정 A — pnpm 설정이 .npmrc 에서 조용히 무시되다
CI(비대화형 셸)에서 pnpm --filter web build가 죽었다. 범인은 pnpm의 사전 검증이었다. 빌드 전에 의존성이 최신인지 확인하고, 어긋나면 자동으로 install을 시도하는데, 비대화형 환경에서는 그 install이 그대로 멈춰버린다. 이 검증을 끄는 옵션이 verifyDepsBeforeRun이다. 그래서 .npmrc에 넣었는데 — 아무 일도 일어나지 않았다.
# .npmrc ← 여기에 넣으면 조용히 무시됨
verify-deps-before-run=false
# pnpm-workspace.yaml ← 여기에 둬야 인식됨
verifyDepsBeforeRun: false
verifyDepsBeforeRun은 pnpm 전용 설정이라 .npmrc가 아니라 pnpm-workspace.yaml에 있어야 읽힌다. .npmrc에 넣으면 에러도 경고도 없이 그냥 무시된다. 조용히 무시되는 설정이 디버깅에서 제일 무섭다 — 옵션은 맞는데 “안 먹히네”로 한참 헤매게 된다. 워크스페이스 파일로 옮기니 빌드가 통과했다.
함정 B — pm2가 “다른 계정의 프로세스 목록”을 보고 있었다
빌드는 통과하는데 이번엔 pm2 restart web이 “process web not found”로 실패했다. 터미널에서 내 손으로 치면 멀쩡히 되는데, Jenkins에서만 안 됐다. 범인은 실행 계정이었다.
pm2는 프로세스 목록을 계정별 PM2_HOME(기본 ~/.pm2)에 저장한다. 데몬과 web·game-server 프로세스는 내 계정(guifi)으로 등록돼 있었다. 그런데 Jenkins는 NT AUTHORITY\SYSTEM 계정으로 돌아서, SYSTEM의 텅 빈 PM2_HOME을 보고 “web이라는 프로세스는 없다”고 한 것이다. 같은 머신, 같은 pm2, 다른 장부.
// Jenkinsfile 의 Deploy 단계
environment {
PM2_HOME = 'C:\Users\guifi\.pm2' // guifi 의 장부를 명시적으로 지목
}
// + 해당 폴더에 SYSTEM 읽기/실행 권한 부여 (icacls)
Jenkins가 guifi의 PM2_HOME을 바라보게 하고 SYSTEM 권한을 열어주니, SYSTEM(Jenkins)이 guifi pm2 데몬의 web·game-server를 재시작했다. 이제 정말로 git push 한 번이면 web·game-server가 자동 재배포된다. 수동 배포 단계가 통째로 사라졌다.
두 작업의 공통 교훈
맵 데이터화와 배포 자동화는 다른 일 같지만, 막힌 지점의 정체는 같았다.
- “왜 안 되지”보다 “지금 무엇을 보고 있나”를 물어야 풀린다. pnpm은 잘못된 설정 파일을, pm2는 다른 계정의 장부를 보고 있었다. 값이 틀린 게 아니라 보는 대상이 어긋나 있었다.
- 조용한 실패가 가장 비싸다.
.npmrc의 무시된 옵션, 전체를 거부하는 화이트리스트 — 에러를 안 내는 실패일수록 재현 조건을 좁히는 게 디버깅의 전부다. - 암묵을 명시로 바꾸면 반복 노동이 사라진다. 하드코딩 배열을 DB로, 실행 계정 가정을
PM2_HOME명시로 옮긴 대가로, 이제 맵은 에디터로 고치고 배포는 push로 끝난다. 단, 그 유연함에는 “빈 상태”라는 새 책임이 따라온다는 것도 함께.
기능이든 인프라든, 결국 개발은 시스템이 무엇을 ‘진실’로 여기는지를 다루는 일이었다.