목차
만든 것을 실행 환경으로 옮기는 이야기를 시작한다. 출발점은 개발자라면 한 번은 해본 그 말이다.
로컬에선 되는데 서버에서만 죽는다
금요일 저녁에 배포를 한다. 내 노트북에서는 테스트도 다 통과했고 화면도 잘 뜬다. 그런데 서버에 올리자 앱이 뜨자마자 죽는다. 로그를 보면 이런 게 찍혀 있다.
Error: Cannot find module 'sharp'
at Module._resolveFilename (node:internal/modules/cjs/loader:1145:15)이상하다. sharp는 분명 설치했다. package.json에도 있고 로컬에서는 잘 불러진다. 그런데 서버에서만 없다고 한다.
원인은 나중에 밝혀진다. sharp는 이미지 처리를 하느라 OS별로 다른 네이티브 바이너리를 같이 받는다. 내 노트북은 macOS라 macOS용 바이너리가 깔렸고, 서버는 리눅스다. 코드는 똑같은데 코드 밑에 깔린 것이 달랐다.
이 한 문장이 이 시리즈 전체의 출발점이다. 우리가 옮기는 건 코드지만, 실제로 돌아가는 데 필요한 건 코드만이 아니다.
프로그램은 혼자 돌지 않는다
우리는 보통 “앱을 배포한다”고 말한다. 그런데 실행되는 순간의 앱은 자기 코드 위에만 서 있지 않다. 아래로 여러 층을 딛고 서 있다.
배포할 때 우리가 옮기는 건 맨 위 한 칸뿐이다. 나머지 네 칸은 “거기에도 있겠지” 하고 기대한다. 그 기대가 어긋나는 순간이 아까 그 에러다.
그리고 아래 칸일수록 옮기기 어렵고, 어긋났을 때 증상이 이상하다. 라이브러리 버전이 다르면 대개 명확한 에러가 나지만, 시스템 라이브러리가 다르면 “가끔 한글이 깨진다”거나 “특정 이미지에서만 죽는다” 같은 형태로 나온다.
어긋남은 네 갈래로 온다
실제로 겪는 차이를 모아보면 대략 이 네 가지다.
| 갈래 | 예 | 증상 |
|---|---|---|
| 런타임 버전 | 로컬 Node 23, 서버 Node 18 | 최신 문법에서 SyntaxError |
| 의존성 버전 | 락파일을 안 쓰고 설치 | 로컬은 1.4.2, 서버는 1.5.0이 깔려 동작이 다름 |
| OS와 시스템 라이브러리 | macOS ↔ 리눅스, 배포판 차이 | 네이티브 모듈 없음, 폰트 없음, 시간대가 UTC |
| 환경 설정 | DB 주소, 파일 경로, 환경변수 | 로컬 localhost:5432를 그대로 들고 감 |
여기서 눈여겨볼 게 있다. 네 갈래 중 어느 것도 코드 문제가 아니다. 코드는 같다. 코드 밖이 다르다.
그래서 “왜 서버에서만 죽지”라고 코드를 아무리 들여다봐도 답이 안 나온다. 봐야 할 곳이 코드가 아니기 때문이다.
반대 방향도 있다. 서버에서는 되는데 로컬에서 안 되는 경우다. 서버에는 옛날에 누군가 손으로 깔아둔 패키지가 있고, 그게 없으면 앱이 안 뜬다. 그런데 아무도 그걸 깔았다는 사실을 기록해두지 않았다. 이 경우가 더 무섭다 - 그 서버가 죽으면 아무도 같은 서버를 다시 못 만든다.
문서로 맞추면 문서가 낡는다
가장 먼저 떠오르는 해법은 문서다. README에 적어두면 되지 않나.
## 실행 준비
1. Node 22 설치
2. PostgreSQL 15 설치
3. `libvips` 설치 (`apt install libvips-dev`)
4. `.env`에 DB 주소 설정이 방식의 문제는 하나다. 문서는 코드와 함께 실행되지 않는다. 그래서 누가 의존성을 하나 추가하고 README를 안 고치면, 그 순간부터 문서는 조용히 틀린다. 아무도 에러를 안 본다. 다음 사람이 세팅하다 막힐 때까지는.
게다가 문서는 사람이 읽고 사람이 따라 한다. 5번 항목을 빼먹었는지 아닌지는 아무도 모른다. 서버 셋을 세팅했으면 세 서버가 조금씩 다르게 세팅됐을 확률이 높다.
시간이 지나면서 서버끼리, 그리고 서버와 로컬이 서서히 벌어지는 이 현상을 **구성 드리프트(configuration drift)**라고 부른다.
스크립트로 맞추면 시작 상태에 의존한다
그러면 문서 대신 스크립트를 쓰자. 사람이 읽고 따라 하는 대신 기계가 실행한다.
#!/bin/bash
apt-get install -y libvips-dev
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
npm ci문서보다 확실히 낫다. 사람이 빼먹을 수 없고, 스크립트 자체가 코드와 함께 저장소에 들어가니 같이 관리된다.
그런데 여전히 구멍이 있다. 이 스크립트는 “어떤 상태에서 시작하는지”를 가정한다. 깨끗한 우분투에서 돌리면 잘 되지만, 이미 Node 18이 깔린 서버에서 돌리면 어떻게 될까. apt-get이 뭘 하는지, 기존 Node와 충돌하는지는 그 서버의 현재 상태에 달렸다.
즉 같은 스크립트를 돌려도 결과가 서버마다 다를 수 있다. 스크립트는 “무엇을 할지”를 적었지만 “결과가 어떤 상태여야 하는지”를 보장하지 못한다.
이 “무엇을 할지가 아니라 어떤 상태여야 하는지를 적는다”는 아쉬움을 기억해두자. 이 시리즈에서 쿠버네티스를 다룰 때 정확히 같은 이야기가 훨씬 큰 규모로 다시 나온다.
그러면 환경을 통째로 옮기면 되지 않나
여기서 발상을 뒤집는다. 앱만 옮기고 환경이 맞기를 기대하는 대신, 환경을 앱과 함께 옮긴다.
내 노트북에서 잘 돌던 그 상태 그대로를 서버에 놓을 수 있다면, 애초에 어긋날 것이 없다. 런타임도 시스템 라이브러리도 같이 갔으니까.
이 발상을 가장 먼저 실현한 게 **가상 머신(VM)**이다. OS까지 통째로 이미지로 만들어 옮긴다. 확실히 작동한다. 내 환경이 그대로 복제되니까.
문제는 값이다. VM 하나가 OS 하나를 통째로 들고 있으니 이미지가 수 기가바이트고, 켜는 데 수십 초에서 몇 분이 걸린다. 앱 하나 올리자고 운영체제를 하나 더 부팅하는 셈이다.
그래서 질문이 이렇게 바뀐다. 환경을 같이 들고 다니되, OS 전체를 복제하지 않을 수는 없나. 이 질문의 답이 컨테이너다.
어긋남은 배포에서만 터지지 않는다
지금까지 “배포하면 죽는다”로만 이야기했는데, 같은 문제는 다른 자리에서도 나온다.
- 새 팀원의 첫날. 세팅에 하루를 쓴다. 그 하루의 대부분은 문서에 안 적힌 것을 찾아내는 시간이다.
- CI에서만 실패하는 테스트. 빌드 서버의 시간대가 UTC라 날짜 비교 테스트가 깨진다. 로컬에서는 재현이 안 된다.
- 여러 프로젝트를 오가는 날. A 프로젝트는 Node 18, B는 Node 22를 요구한다. 하나만 깔 수 있다면 하나는 못 돌린다.
- 버전을 되돌려야 할 때. 어제 버전으로 롤백하려는데, 어제 서버가 어떤 상태였는지 아무도 기록해두지 않았다.
전부 뿌리가 같다. “실행에 필요한 것 전부”가 한 덩어리로 관리되지 않고 여기저기 흩어져 있다.
실무에서 이 문제를 어떻게 다뤄왔나
컨테이너가 나오기 전에도 사람들은 이 문제와 싸웠고, 그 흔적이 지금도 남아 있다. 컨테이너는 이 흐름의 끝이지 시작이 아니다.
| 수단 | 무엇을 고정하나 | 남는 구멍 |
|---|---|---|
락파일 (package-lock.json, gradle.lockfile) | 의존성 버전 | 런타임과 OS는 여전히 밖 |
| 버전 매니저 (nvm, sdkman) | 런타임 버전 | 개발자 각자가 맞춰야 함 |
| 구성 관리 도구 (Ansible, Chef) | 서버 세팅 절차 | 시작 상태에 따라 결과가 달라짐 |
| 가상 머신 이미지 | OS까지 전부 | 무겁고 느림 |
| 컨테이너 이미지 | OS 위 전부 (커널만 공유) | 커널 수준 차이는 남음 |
표의 아래로 갈수록 고정하는 범위가 넓어지고, 대신 값이 비싸진다. 컨테이너가 널리 쓰이는 건 이 표에서 가장 좋은 지점에 있어서다. 고정 범위는 VM에 가깝고 값은 스크립트에 가깝다.
그리고 여기엔 부수적인 효과가 하나 더 있다. 실행에 필요한 것이 한 덩어리로 묶이면, 그 덩어리를 버전으로 관리할 수 있다. “v1.4.2 이미지로 되돌려라”가 한 줄로 가능해진다. 서버를 어제 상태로 되돌리는 것보다 훨씬 쉽다.
컨테이너를 써도 사라지지 않는 차이가 있다. 환경 설정이다. DB 주소나 API 키는 개발과 운영이 애초에 달라야 하는 값이라, 이미지 안에 넣으면 안 된다. 이건 “어긋나면 안 되는 것”이 아니라 “달라야 하는 것”이라서, 밖에서 주입한다. 뒤에 나올 Docker 편에서 다시 짚는다.
정리
| 문제 | 코드는 옮기는데 코드 밑에 깔린 것은 안 옮긴다 |
| 어긋나는 곳 | 런타임 버전 · 의존성 버전 · OS와 시스템 라이브러리 · 환경 설정 |
| 문서로 맞추면 | 코드와 함께 실행되지 않아 조용히 낡는다 |
| 스크립트로 맞추면 | 시작 상태에 따라 결과가 달라진다 |
| 방향 | 코드만 옮기지 말고 환경을 한 덩어리로 같이 옮긴다 |
| VM은 | 그 답이 맞지만 OS를 통째로 복제해서 무겁다 |
“내 컴퓨터에선 되는데”는 실력 문제가 아니라 경계 문제다. 우리가 옮긴 것의 경계가 실행에 필요한 것의 경계보다 좁아서 생긴다.
그러면 경계를 넓히되 OS까지는 가지 않는 선이 어디인가. 프로세스는 격리하되 커널은 나눠 쓰는 것, 그게 다음 글의 컨테이너다.