왜 "내 컴퓨터에서는 됐는데요"가 반복되는가
신입 개발자 시절 저는 이 말을 정말 자주 했습니다. "제 노트북에서는 완벽하게 돌아갔는데요." 문제는 이 말이 거짓말이 아니었다는 겁니다. 제 노트북에서는 진짜로 돌아갔습니다. 다만 제 노트북에는 팀장님이 3년 전에 깔아둔 특정 버전의 라이브러리가 있었고, 서버에는 없었을 뿐입니다.
배포가 매번 도박이었던 이유
2010년대 초반까지 서버에 애플리케이션을 올리는 절차는 대략 이랬습니다. 서버에 접속해서 필요한 언어 런타임을 설치하고, 의존 라이브러리를 하나씩 맞추고, 환경변수를 설정하고, 코드를 복사한 다음 실행합니다. 이 과정에서 다음과 같은 일이 실제로 벌어졌습니다.
- 개발 환경은 Python 3.9인데 서버는 3.7이라 어떤 문법이 동작하지 않음
- 로컬에는 설치했다는 사실조차 잊어버린 시스템 라이브러리가 있어서, 서버에서만 실패함
- 같은 서버에서 서비스 A는 Node 16을, 서비스 B는 Node 20을 요구해서 둘 중 하나는 항상 문제가 생김
- "운영 서버 세팅 문서"가 있긴 한데 마지막 업데이트가 8개월 전이라 이미 틀린 내용
이 문제의 본질은 "코드"만 옮기고 "코드가 실행되는 환경"은 옮기지 않았다는 데 있습니다. 우리는 늘 코드가 결과물이라고 생각하지만, 실제로 애플리케이션을 움직이게 하는 건 코드 + 런타임 버전 + 라이브러리 + OS 설정 + 환경변수의 조합 전체입니다. 그 중 하나만 어긋나도 "제 컴퓨터에서는 됐는데요"가 성립합니다.
가상머신이 먼저 시도했던 해법
이 문제를 처음 해결하려던 시도가 가상머신(VM)입니다. VM은 하드웨어 자체를 흉내 내는 하이퍼바이저 위에 완전한 운영체제를 통째로 얹습니다. 각 VM은 자기만의 커널, 자기만의 파일시스템, 자기만의 모든 것을 가집니다. 그래서 "환경을 통째로 포장해서 옮긴다"는 목표는 달성했습니다.
문제는 대가였습니다. VM 이미지는 보통 수 GB이고, 부팅에는 수십 초가 걸립니다. 서버 한 대에 VM을 여러 개 띄우면 커널만 N개가 중복으로 메모리를 차지합니다. 실험 삼아 "이 라이브러리 버전으로 바꾸면 어떻게 되는지" 확인해보고 싶어도 VM을 새로 하나 만드는 데 드는 시간과 리소스가 부담스러워서 결국 안 해보고 넘어가는 경우가 많았습니다.
컨테이너가 자른 부분
Docker가 2013년에 대중화시킨 컨테이너는 다른 선택을 합니다. VM처럼 OS를 통째로 복제하지 않고, 호스트 커널을 공유하면서 프로세스가 보는 세계만 격리합니다. 리눅스 커널의 namespace(프로세스가 볼 수 있는 파일시스템·네트워크·프로세스 목록을 격리)와 cgroup(CPU·메모리 사용량을 제한)이라는 두 기능을 조합한 결과입니다.
flowchart LR
subgraph VM["가상머신 방식"]
H1["호스트 OS"] --> HV["하이퍼바이저"]
HV --> G1["게스트 OS 1"] --> A1["앱 A"]
HV --> G2["게스트 OS 2"] --> A2["앱 B"]
end
subgraph CT["컨테이너 방식"]
H2["호스트 OS + 커널"] --> D["Docker 엔진"]
D --> C1["컨테이너 A (격리된 프로세스)"]
D --> C2["컨테이너 B (격리된 프로세스)"]
end그 결과 컨테이너는 VM보다 훨씬 가볍습니다. 이미지는 보통 수십~수백 MB, 시작 시간은 1초 미만인 경우가 흔합니다. 커널을 공유하니 서버 한 대에 컨테이너 수십 개를 띄워도 VM만큼 자원을 잡아먹지 않습니다.
더 중요한 건 이겁니다. 컨테이너 이미지에는 애플리케이션 실행에 필요한 모든 것 — 런타임, 라이브러리, 설정, 코드 — 이 하나의 파일 뭉치로 굳어 있습니다. 개발자 노트북에서 빌드한 이미지와 운영 서버에서 실행하는 이미지가 바이트 단위로 동일합니다. "제 컴퓨터에서는 됐는데요"가 성립하려면 이제 컨테이너 자체가 다르게 동작해야 하는데, 그런 일은 거의 일어나지 않습니다.
이게 완벽한 해법은 아닙니다
다만 오해하지 말아야 할 게 있습니다. 컨테이너는 커널을 공유하기 때문에 VM만큼 격리가 강하지 않습니다. 컨테이너 안에서 커널 취약점을 이용한 탈출(container escape)이 이론적으로 가능하고, 실제로 사례도 있었습니다. 여러 사용자의 신뢰할 수 없는 코드를 실행해야 하는 환경(예: 코드 실행 서비스)이라면 컨테이너만으로는 부족해서 gVisor나 Firecracker 같은 추가 격리 계층을 쓰기도 합니다. 이 책에서는 다루지 않지만, "컨테이너 = 완벽한 보안 경계"라고 생각하면 나중에 발등을 찍힐 수 있다는 점만 기억해 두세요.
또한 컨테이너는 리눅스 커널의 기능에 기대고 있어서, 원래는 리눅스 전용 기술입니다. Windows나 macOS에서 Docker를 쓸 수 있는 건 사실 내부적으로 경량 리눅스 VM을 하나 띄우고 그 안에서 컨테이너를 돌리기 때문입니다. Docker Desktop을 설치하면 뒤에서 조용히 VM이 하나 생긴다는 뜻이고, 이게 macOS에서 Docker가 순수 리눅스 서버보다 약간 느리게 느껴지는 이유이기도 합니다.
이제 이 책에서는 이미지를 어떻게 만들고, 컨테이너를 어떻게 실행하고, 여러 컨테이너를 어떻게 조합해서 실제 서비스를 배포하는지를 순서대로 다룹니다. 다음 장에서는 가장 먼저 헷갈리는 지점 — "이미지"와 "컨테이너"가 정확히 뭐가 다른지부터 짚고 넘어가겠습니다.