WebPiki
tutorial

Docker 입문 가이드 — 컨테이너 개념부터 실전까지

Docker가 왜 필요한지, 컨테이너가 뭔지, 이미지는 어떻게 만드는지. 명령어 위주로 정리한 Docker 입문 가이드.

컨테이너를 싣고 항해하는 Docker 고래

"내 컴에서는 되는데?" — 개발자라면 한 번쯤 이 말을 했거나 들어봤을 거다. 로컬에서는 잘 돌아가던 앱이 서버에 올리니까 안 된다. Node.js 버전이 다르거나, Python 패키지 의존성이 꼬이거나, 환경 변수가 빠져있거나. 환경 차이 때문에 생기는 문제들.

Docker는 이 문제를 해결하는 도구다. 앱이 돌아가는 환경 자체를 패키징해서, 어디서든 동일하게 실행할 수 있게 만든다.

컨테이너가 뭔데

가상 머신(VM)과 비교하면 이해가 빠르다.

VM은 운영체제 전체를 가상화한다. 호스트 OS 위에 하이퍼바이저를 올리고, 그 위에 게스트 OS를 설치하고, 거기에 앱을 돌린다. 무겁다. Ubuntu VM 하나가 수 GB씩 잡아먹고, 부팅에 몇 분씩 걸린다.

컨테이너는 OS 커널을 호스트와 공유한다. 게스트 OS가 필요 없으니까 가볍다. 이미지 크기는 수십수백 MB 수준이고, 시작하는 데 12초면 충분하다. 대신 호스트와 같은 커널을 쓰니까 Linux 컨테이너는 Linux 위에서 돌아간다는 제약이 있다. (맥이나 윈도우에서는 경량 Linux VM 위에서 컨테이너가 돌아간다.)

핵심은 격리다. 각 컨테이너는 자기만의 파일 시스템, 네트워크, 프로세스 공간을 가진다. 컨테이너 A에서 Node 20을 쓰든, 컨테이너 B에서 Node 18을 쓰든 서로 영향이 없다.

Docker를 쓰는 이유

첫째, 환경 일관성. 개발, 테스트, 프로덕션 환경이 동일하니까 "내 컴에서는 되는데" 문제가 사라진다.

둘째, 빠른 셋업. 새 팀원이 들어왔을 때 "이거 설치하고, 저거 설정하고, 그다음에..." 대신 docker compose up 한 줄이면 개발 환경이 세팅된다.

셋째, 격리와 정리. 프로젝트마다 다른 DB 버전, 다른 런타임 버전을 쓸 수 있다. 더 이상 쓰지 않는 프로젝트의 환경도 컨테이너를 지우면 깔끔하게 정리된다.

핵심 개념 세 가지

이미지

컨테이너의 설계도다. "이 앱을 실행하려면 이런 OS에, 이런 패키지가 필요하고, 이 코드를 이 위치에 넣어라"라는 정보가 레이어 형태로 담겨있다.

이미지는 읽기 전용이다. 한번 만들어지면 변하지 않는다. 같은 이미지에서 컨테이너를 10개 만들어도 전부 동일한 상태에서 시작한다.

Docker Hub에 공개된 이미지가 수십만 개 있다. node, python, postgres, redis 같은 공식 이미지를 베이스로 쓰고, 거기에 자기 앱 코드를 얹는 게 일반적인 방법이다.

컨테이너

이미지를 실행한 인스턴스다. 이미지가 클래스라면 컨테이너는 객체라고 비유할 수 있다. 같은 이미지에서 여러 컨테이너를 만들 수 있고, 각 컨테이너는 독립적으로 동작한다.

컨테이너는 기본적으로 **일시적(ephemeral)**이다. 컨테이너를 삭제하면 안에서 변경한 내용도 사라진다. 데이터를 유지하고 싶으면 볼륨(Volume)을 사용해야 한다.

Dockerfile

이미지를 어떻게 만들지 정의하는 텍스트 파일이다. 레시피라고 생각하면 된다.

# Node.js 앱의 Dockerfile 예시
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

한 줄씩 보면:

  • FROM — 베이스 이미지 지정. node:20-alpine은 Node.js 20이 설치된 경량 리눅스
  • WORKDIR — 작업 디렉토리 설정
  • COPY + RUN — 먼저 package.json만 복사해서 의존성 설치, 그다음 나머지 코드 복사. 이렇게 순서를 나누면 코드만 바꿨을 때 의존성 설치 레이어를 캐시에서 재사용할 수 있다
  • EXPOSE — 컨테이너가 사용하는 포트 명시 (문서화 목적)
  • CMD — 컨테이너 시작 시 실행할 명령어

자주 쓰는 명령어

처음에는 이것만 알아도 충분하다.

# 이미지 빌드
docker build -t my-app .

# 컨테이너 실행
docker run -d -p 3000:3000 --name my-app my-app

# 실행 중인 컨테이너 확인
docker ps

# 컨테이너 로그 보기
docker logs my-app

# 컨테이너 안에서 명령어 실행
docker exec -it my-app sh

# 컨테이너 중지 & 삭제
docker stop my-app
docker rm my-app

# 이미지 목록
docker images

# 안 쓰는 이미지/컨테이너 정리
docker system prune

-d는 백그라운드 실행, -p 3000:3000은 호스트의 3000번 포트를 컨테이너의 3000번 포트에 연결, --name은 컨테이너에 이름 부여. -it는 인터랙티브 터미널.

Docker Compose — 여러 컨테이너를 한번에

실제 프로젝트에서는 앱 하나만 돌리는 경우가 드물다. 웹 서버 + 데이터베이스 + 캐시 서버 조합이 흔하다. 이걸 매번 docker run으로 하나씩 띄우는 건 번거로우니까, Docker Compose를 쓴다.

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
    depends_on:
      - db
      - redis

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - db-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  db-data:

이 파일 하나로 docker compose up을 실행하면 앱, PostgreSQL, Redis가 동시에 뜬다. docker compose down으로 전부 내리고. 개발 환경 셋업이 이 파일 하나로 끝나는 거다.

volumesdb-data를 정의한 건 데이터베이스 데이터를 컨테이너가 삭제되어도 유지하기 위해서다. 이걸 안 하면 docker compose down 할 때마다 DB가 초기화된다.

Docker Compose 좀 더 파보기

위에서 기본적인 docker-compose.yml을 봤는데, 실무에서는 몇 가지 패턴을 더 알아두면 좋다.

서비스 간 의존성 제어

depends_on만으로는 부족할 때가 있다. PostgreSQL 컨테이너가 떴다고 해서 DB가 바로 접속 가능한 건 아니다. 컨테이너는 시작됐지만 내부 초기화가 아직 진행 중일 수 있다.

services:
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

healthcheck를 설정하고 condition: service_healthy를 쓰면 DB가 실제로 준비된 후에 앱이 시작된다. 이걸 모르면 앱이 뜨자마자 DB 연결 실패로 크래시 나는 상황을 겪게 된다.

개발용 오버라이드

docker-compose.override.yml 파일을 만들면 기본 설정을 덮어쓸 수 있다. 프로덕션용 compose 파일은 그대로 두고, 개발 환경에서만 볼륨 마운트를 추가하거나 디버그 포트를 열 때 쓴다.

# docker-compose.override.yml (개발용)
services:
  app:
    volumes:
      - .:/app        # 코드 변경을 실시간 반영
    environment:
      - DEBUG=true

docker compose up을 실행하면 두 파일이 자동으로 합쳐진다. 별도 플래그 없이.

이미지 크기 줄이기

이미지가 크면 빌드도 느리고, 레지스트리 push/pull도 느리고, 배포도 느리다. 몇 가지 방법이 있다.

멀티 스테이지 빌드

빌드에 필요한 도구와 런타임에 필요한 것은 다르다. 빌드 도구(컴파일러, devDependencies 등)는 최종 이미지에 들어갈 이유가 없다.

# 빌드 스테이지
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 실행 스테이지
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

builder 스테이지에서 빌드하고, runner 스테이지에서는 빌드 결과물만 가져온다. devDependencies가 포함된 무거운 node_modules도 빌드 스테이지에만 존재한다. 이것만으로 이미지 크기가 절반 이하로 줄어들 수 있다.

경량 베이스 이미지

앞서 -alpine 태그를 언급했는데 좀 더 구체적으로 비교하면:

  • node:20 — Debian 기반, ~900MB
  • node:20-slim — Debian 최소 설치, ~200MB
  • node:20-alpine — Alpine Linux 기반, ~130MB

대부분의 Node.js 앱은 alpine으로 충분하다. 다만 네이티브 바이너리 의존성(sharp, bcrypt 등)이 있으면 Alpine에서 빌드가 안 되는 경우가 있다. 그때는 slim을 쓰거나 빌드 스테이지에서만 필요한 패키지를 설치하면 된다.

.dockerignore를 꼭 만들어야 하는 이유

.gitignore가 git에서 추적하지 않을 파일을 지정하는 것처럼, .dockerignore는 Docker 빌드 컨텍스트에서 제외할 파일을 지정한다.

COPY . .를 실행하면 Docker는 현재 디렉토리의 모든 파일을 빌드 컨텍스트로 보낸다. .git 디렉토리(수백 MB가 될 수 있다), node_modules(로컬 것은 어차피 안 쓰고 새로 설치한다), .env(시크릿이 이미지에 포함되면 보안 사고다) — 이런 것들이 전부 포함된다.

실무에서 쓰는 .dockerignore 예시:

node_modules
.git
.gitignore
.env*
*.md
.vscode
.idea
coverage
.next
dist

특히 .env 파일이 이미지에 들어가는 건 심각한 보안 문제다. 이미지를 레지스트리에 올리면 누구든 pull 받아서 시크릿을 꺼낼 수 있다. .dockerignore에 반드시 .env* 패턴을 넣자.

빌드 컨텍스트가 작을수록 docker build 시작 속도도 빨라진다. "Sending build context to Docker daemon" 단계에서 시간이 오래 걸린다면 .dockerignore를 확인해보자.

자주 하는 실수들

레이어 캐싱을 활용하지 않는다 — Dockerfile에서 자주 바뀌는 부분을 뒤쪽에 배치해야 캐싱 효과가 높다. package.json이 안 바뀌었으면 npm ci 단계를 다시 실행할 필요가 없으니까, 코드 복사(COPY . .)보다 먼저 의존성 설치를 하는 게 맞다.

RUN 명령어를 너무 많이 나눈다 — 각 RUN이 레이어 하나를 만든다. 패키지 설치처럼 관련 있는 명령어는 &&로 묶어서 하나의 RUN으로 합치는 게 이미지 크기를 줄이는 데 도움이 된다.

# 이렇게 하지 말고
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# 이렇게
RUN apt-get update && apt-get install -y curl && apt-get clean

어디서부터 시작하면 되나

Docker Desktop을 설치하고, 자기가 진행 중인 프로젝트에 Dockerfile을 하나 만들어보는 게 가장 좋다. 공식 이미지 기반으로 간단한 Dockerfile을 작성하고, docker builddocker run으로 돌아가는 걸 확인하는 것만으로도 핵심 개념은 잡힌다.

그다음 Docker Compose로 DB를 추가해보고, 개발 환경 전체를 컨테이너화하는 단계로 넘어가면 된다. 이 두 단계를 거치면 Docker를 왜 쓰는지 몸으로 이해하게 된다.

#Docker#컨테이너#DevOps#배포#인프라

관련 글