[GDSC] Docker로 다중 컨테이너 애플리케이션으로 확장하기
보통 실제 웹 서비스는 하나의 컨테이너로 끝나지 않는다. 강의에서처럼 데이터베이스, 백엔드, 프론트엔드 세 덩어리로 나누는 구조를 흔히 볼 수 있다.
- 데이터 저장소: MongoDB
- 백엔드 API: Node.js + Express 기반 REST API
- 프론트엔드: React SPA
사용자 브라우저는 React 앱만 직접 본다. React가 화면을 렌더링하고, 필요할 때마다 백엔드 REST API를 호출한다. 백엔드는 이 요청을 받아 비즈니스 로직을 처리하고, MongoDB와 통신해서 데이터를 읽거나 쓴다. 데이터베이스는 브라우저와 직접 대화하지 않고, 오직 백엔드와만 통신한다.
이 구조의 핵심은 “역할 분리”다. 저장은 DB, 외부와의 계약(REST API)은 백엔드, 화면·상호작용은 프론트엔드가 맡는다. 각자 컨테이너를 따로 두면 배포·스케일링·장애 분석도 훨씬 깔끔해진다.
물론 세상이 항상 교과서처럼 돌아가는 건 아니다.
희망편에서는 “서비스마다 컨테이너가 하나씩” 깔끔하게 나눠져 있다. Database, Backend, Frontend, Redis, Nginx가 각자의 컨테이너에서 돌아가고, 적절히 네트워크로 묶이고, 헬스체크와 모니터링까지 잘 붙어 있는 구조다.
절망편에서는 Database, Backend, Frontend, Redis, Nginx가 전부 한 컨테이너 안에 뒤엉켜 있다. docker run my-monolith 한 번으로 다 뜨긴 뜨는데, 뭔가 잘못되면 “어디가 터졌는지” 찾는 데 시간 대부분을 쓴다. 로그도 섞여 있고, 리소스 사용량도 같이 올라가고, 특정 하나만 다시 띄우는 것도 쉽지 않다.
이렇게 “모든 걸 한 컨테이너에 우겨넣는” 패턴이 남아 있는 이유도 있다. 이미지 하나만 가져다가 환경변수 몇 개 넣으면 바로 동작하도록 만들면, 서버 운영자 입장에서는 편하다. 또 리눅스 컨테이너 이전 시대, 가상 공간 격리 기능이 빈약한 유닉스 환경에서 여러 서비스를 한 프로세스/호스트에 몰아넣던 관성이 그대로 이어져 온 경우도 많다.
하지만 장기적으로는 컨테이너 하나에 주요 애플리케이션 하나만 두는 편이 좋다. 어느 서비스가 죽었는지, 왜 죽었는지, 어디서 병목이 생겼는지를 분리해서 볼 수 있기 때문이다. 이번 글은 “MongoDB, Node 백엔드, React 프론트엔드”를 각각 컨테이너로 분리해서 개발 환경을 세팅하는 흐름을 정리한다.
MongoDB 컨테이너: 공식 이미지와 볼륨, 인증
데이터베이스부터 시작해 보자. MongoDB는 Docker Hub에서 공식 이미지를 제공한다.
docker run --name mongodb --rm -d -p 27017:27017 mongo
처음에는 이렇게만 실행해도 된다. mongo 이미지는 기본적으로 27017 포트를 노출하고 있고, -p 27017:27017으로 호스트와 매핑하면 로컬에서 동작하는 백엔드 애플리케이션이 localhost:27017으로 MongoDB에 접근할 수 있다.
하지만 이 상태는 두 가지 문제가 있다.
첫째, 데이터가 컨테이너에 묶여 있다. 컨테이너를 멈추고 제거하면 DB에 있던 데이터도 함께 사라진다. 둘째, 인증이 걸려 있지 않다면 누구나 해당 포트만 알고 있으면 접근이 가능하다.
MongoDB 이미지는 데이터 위치와 인증을 모두 환경변수와 볼륨으로 설정할 수 있게 해 둔다. 데이터 파일은 기본적으로 /data/db에 저장된다. 따라서 아래처럼 명명된 볼륨을 사용하면 컨테이너를 지웠을 때도 데이터가 살아남는다.
docker run --name mongodb --rm -d \
-v mymongo:/data/db \
-p 27017:27017 \
mongo
이제는 mymongo 볼륨에 데이터가 남는다. 컨테이너를 멈추고 똑같은 옵션으로 다시 실행해도 이전 데이터가 그대로 보인다.
인증을 걸고 싶다면 공식 이미지가 제공하는 환경변수를 쓰면 된다.
MONGO_INITDB_ROOT_USERNAME: admin 계정 사용자명
MONGO_INITDB_ROOT_PASSWORD: admin 계정 비밀번호
MONGO_INITDB_DATABASE: 초기 생성할 데이터베이스 이름
각 환경변수에는 _FILE 접미사를 붙여 파일에서 값을 읽어오게 설정할 수도 있다.
docker run --name mongodb --rm -d \
-v mymongo:/data/db \
-e MONGO_INITDB_ROOT_USERNAME=myadmin \
-e MONGO_INITDB_ROOT_PASSWORD=myadmin \
mongo
이렇게 띄운 뒤에는 클라이언트에서 다음과 같은 형태로 접속한다.
mongodb://myadmin:myadmin@mongodb:27017/mydatabase?authSource=admin
여기서 mongodb는 도커 네트워크 안에서의 컨테이너 이름이고, authSource=admin은 인증 정보가 저장된 DB가 admin임을 알려주는 옵션이다.
실제로 도커 볼륨을 썼다가 인증 설정을 바꾸면, 이전 자격증명이 볼륨에 남아서 “자꾸 인증 에러가 난다”는 상황이 생길 수 있다. 이럴 땐 docker volume ls로 볼륨을 확인하고, 해당 볼륨을 docker volume rm으로 삭제한 뒤 새 자격증명으로 다시 띄워야 깔끔하게 초기화된다.
Node.js 백엔드: 이미지 빌드, 네트워크, 로그와 코드 업데이트
백엔드는 Node.js + Express로 작성된 REST API다. 도커 이미지로 만들기 위해 Dockerfile부터 준비한다.
FROM node
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 80
CMD ["node", "app.js"]
docker build -t backend .를 실행하면 backend라는 이름의 이미지를 하나 얻게 된다. 여기서 EXPOSE 80은 “이 컨테이너 안에서 애플리케이션이 80 포트를 듣고 있다”는 메타데이터일 뿐, 포트를 실제로 외부에 노출하는 기능은 아니다. 외부에서 접근하려면 컨테이너를 실행할 때 -p 80:80처럼 포트 매핑을 별도로 지정해야 한다.
docker run --name mybackend --rm -d -p 80:80 backend
여기까지는 단일 컨테이너일 때의 얘기다. MongoDB와 함께 네트워크 안에서 묶어서 사용하면 구조가 조금 바뀐다.
호스트, 컨테이너, 그리고 host.docker.internal
처음에는 백엔드를 로컬에서 실행하고, MongoDB는 컨테이너로 띄운다고 가정해 보자. 이때 백엔드는 localhost:27017으로 DB에 접속한다. 그런데 백엔드를 컨테이너 안으로 옮기면 이 localhost의 의미가 달라진다. 이제 localhost는 호스트가 아니라 “백엔드 컨테이너 자신”이 된다.
MongoDB는 별도 컨테이너에서 돌고 있기 때문에, 다음 두 가지 방식 중 하나로 접근해야 한다.
-
로컬 호스트 머신을 통해 접근: host.docker.internal:27017
-
도커 네트워크를 만들어 그 안에서 컨테이너 이름으로 접근: mongodb:27017
host.docker.internal은 도커에서 제공하는 특수 도메인이다. 컨테이너 안에서 이 주소로 접속하면 실제 호스트 머신의 IP로 치환된다. 로컬에서만 쓰는 개발 환경에서는 꽤 유용하다.
하지만 MongoDB와 백엔드 둘 다 컨테이너 안으로 옮기고, 사용자 정의 네트워크에 붙여서 관리한다면 아예 localhost나 host.docker.internal을 쓰지 않는 편이 낫다. 이 경우에는 docker network create mynet으로 네트워크를 하나 만든 뒤, MongoDB 컨테이너를 다음처럼 띄운다.
docker network create mynet
docker run --name mongodb --rm -d \
--network mynet \
-v mymongo:/data/db \
-e MONGO_INITDB_ROOT_USERNAME=myadmin \
-e MONGO_INITDB_ROOT_PASSWORD=myadmin \
mongo
그리고 백엔드를 동일 네트워크에 붙인다.
docker run --name mybackend --rm -d \
--network mynet \
-p 80:80 \
backend
이제 백엔드 코드에서는 MongoDB 접속 주소를 mongodb:27017으로 적어주면 된다. 도커는 네트워크 수준에서 컨테이너 이름을 DNS처럼 사용하게 해 주기 때문에, 같은 네트워크에 있는 컨테이너끼리는 서로의 이름을 도메인으로 인식한다.
로그와 소스 코드를 볼륨으로 관리하기
백엔드가 로그를 파일로 남기고 있다면, 이 파일도 컨테이너 수명과 분리하는 것이 좋다. 예제에서는 /app/logs 폴더에 로그를 남긴다고 가정해 볼 수 있다. 이 경로에 명명된 볼륨을 붙이면, 컨테이너를 지웠을 때도 로그 파일은 남는다.
docker run --name mybackend --rm -d \
--network mynet \
-p 80:80 \
-v logs:/app/logs \
backend
여기에 개발 편의를 위해 바인드 마운트 하나를 더 추가할 수 있다. 로컬 프로젝트의 소스 코드를 컨테이너 내부 /app에 그대로 반영하면, 코드를 수정할 때마다 컨테이너를 다시 빌드하지 않고도 변경사항을 바로 확인할 수 있다.
docker run --name mybackend --rm -d \
--network mynet \
-p 80:80 \
-v logs:/app/logs \
-v /path/to/backend:/app \
backend
이 경우 한 가지 주의할 점이 있다. 컨테이너 안에서 npm install로 설치한 node_modules가, 로컬에는 없는 node_modules 폴더로 덮어씌워져 버릴 수 있다는 것이다. 그러면 컨테이너 안에서 의존성을 찾지 못하고 애플리케이션이 바로 죽는다. 이걸 피하려고 익명 볼륨을 하나 더 추가해 /app/node_modules를 보호하는 패턴을 많이 쓴다.
docker run --name mybackend --rm -d \
--network mynet \
-p 80:80 \
-v logs:/app/logs \
-v /path/to/backend:/app \
-v /app/node_modules \
backend
이렇게 하면 /app 전체는 로컬과 바인딩되지만, /app/node_modules만은 컨테이너 내부에 남아 있게 된다.
nodemon으로 자동 재시작
소스 코드가 바뀔 때마다 컨테이너를 껐다 켜는 건 금방 귀찮아진다. Node.js 쪽에서는 보통 nodemon을 써서 코드 변경 시 서버를 자동 재시작하게 만든다.
package.json에 dev 의존성으로 nodemon을 추가하고, 스크립트를 새로 정의한다.
{
"devDependencies": {
"nodemon": "2.0.4"
},
"scripts": {
"start": "nodemon app.js"
}
}
그다음 Dockerfile에서 CMD ["node", "app.js"] 대신 CMD ["npm", "start"]로 바꿔준다.
CMD ["npm", "start"]
이 상태에서 앞서 말한 바인드 마운트를 사용하면, 호스트에서 파일을 수정할 때마다 nodemon이 변화를 감지하고 서버를 자동으로 다시 띄워 준다.
MongoDB 자격증명 환경변수로 빼기
MongoDB 연결 문자열에 사용자 이름과 비밀번호를 하드코딩하는 것은 오래 가지 못한다. Dockerfile에 환경변수를 정의해 두고, Node.js 코드에서는 process.env로 읽어오는 쪽이 훨씬 유연하다.
Dockerfile에는 다음과 같이 기본값을 지정할 수 있다.
ENV MONGODB_USERNAME=root
ENV MONGODB_PASSWORD=secret
Node.js 쪽에서는 템플릿 리터럴을 사용해 연결 문자열에 주입한다.
const username = process.env.MONGODB_USERNAME;
const password = process.env.MONGODB_PASSWORD;
const uri = `mongodb://${username}:${password}@mongodb:27017/mydatabase?authSource=admin`;
컨테이너를 실행할 때 -e 옵션으로 값을 덮어쓸 수 있다.
docker run --name mybackend --rm -d \
--network mynet \
-p 80:80 \
-e MONGODB_USERNAME=myadmin \
-e MONGODB_PASSWORD=myadmin \
backend
마지막으로, 백엔드 이미지 빌드 속도를 개선하고 싶다면 .dockerignore 파일을 만들어 node_modules, .git, Dockerfile 같은 것들을 복사 대상에서 제외해 두면 좋다. 이미 컨테이너 안에서 npm install을 한 뒤인데, 다시 호스트의 node_modules를 복사하는 건 시간과 용량 낭비에 가깝다.
React 프론트엔드: 컨테이너에서 돌지만, 코드는 브라우저에서 돈다
React 프론트엔드는 구조가 약간 다르다. 개발 단계에서는 npm start로 개발 서버를 띄우고, 이 서버가 React 앱을 빌드·제공한다. 하지만 실제로 React 코드가 실행되는 위치는 브라우저다. 이 차이를 이해해야 도커 네트워크와 URL을 헷갈리지 않는다.
프론트엔드도 Node 이미지를 베이스로 Dockerfile을 작성할 수 있다.
FROM node
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
이미지를 빌드한 뒤 다음과 같이 실행한다.
docker build -t frontend .
docker run --name myfrontend --rm -it -p 3000:3000 frontend
여기서는 -d 대신 -it를 쓰는 것이 중요하다. 일부 React 개발 서버 설정에서는 터미널 입력이 없는 비인터랙티브 환경을 “사용자가 없는 상황”으로 판단하고 바로 프로세스를 종료해 버리기 때문이다. -it로 인터랙티브 모드를 켜두면 컨테이너가 바로 종료되지 않고, 브라우저에서
프론트엔드에서 백엔드로 어떻게 접근할까
백엔드와 MongoDB를 같은 네트워크에 묶고, 서로를 컨테이너 이름으로 부르는 구조를 이미 봤다. 여기서 실수하기 쉬운 부분이 하나 있다. React 코드에서 백엔드 URL을 작성할 때 /api나
같은 컨테이너 이름을 쓰면 될 것처럼 보인다. 하지만 이 코드는 컨테이너가 아니라 브라우저에서 실행된다.브라우저는 도커 네트워크를 모른다. 컨테이너 이름을 DNS로 해석하지도 않는다. 도커 네트워크 이름, 컨테이너 이름, IP 매핑은 전부 “컨테이너끼리” 통신할 때만 의미가 있다. 사용자의 브라우저는 그 바깥, 호스트 관점에서 localhost, 혹은 호스트 머신의 실제 IP로 백엔드에 접근해야 한다.
결국 프론트엔드 JavaScript 코드에서는
fetch("http://localhost:80/goals")
같은 식으로 작성하는 편이 안전하다. 그리고 백엔드 컨테이너를 띄울 때 -p 80:80으로 포트를 공개해 둔다. React 앱은 도커 네트워크를 몰라도, 브라우저 입장에서 “로컬에서 돌아가는 HTTP 서버”를 호출할 수 있게 된다.
프론트엔드 컨테이너를 네트워크에 붙였다고 해서, 브라우저와의 통신 방식이 바뀌는 것은 아니다. 네트워크로 묶인 컨테이너끼리만 서로의 이름을 DNS처럼 쓸 수 있다는 점을 기억해 두면 이런 실수를 줄일 수 있다.
프론트엔드 코드 라이브 리로드
React 개발 서버는 기본적으로 파일 변경을 감지해 자동으로 다시 빌드하고 브라우저에 핫리로드를 해 준다. 도커 컨테이너 안에서 이 기능을 그대로 쓰고 싶다면, 프론트엔드 프로젝트의 src 폴더를 바인드 마운트하면 된다.
docker run --name myfrontend --rm -it \
-p 3000:3000 \
-v /path/to/frontend/src:/app/src \
frontend
이제 로컬에서 src 폴더 안의 파일을 수정하면, 컨테이너 안에서 돌아가는 개발 서버가 이를 감지하고 다시 빌드한다. 브라우저에서 페이지를 열어둔 상태로 바꿔 보면 바로 결과를 확인할 수 있다.
마지막으로, 프론트엔드 이미지 빌드 속도가 너무 느리다면 .dockerignore를 만들어 node_modules, .git, Dockerfile 등을 제외하는 것도 백엔드와 마찬가지 패턴으로 적용할 수 있다.
정리
지금까지 MongoDB, Node 백엔드, React 프론트엔드를 각각 컨테이너로 분리해서 개발 환경을 만드는 과정을 한 번에 훑어봤다. 여기서 중요한 포인트만 다시 정리하면 대략 이런 흐름이다.
MongoDB는 공식 이미지를 기반으로 컨테이너를 띄운 뒤, /data/db에 명명된 볼륨을 붙여 데이터 지속성을 확보한다. MONGO_INITDB_ROOT_USERNAME, MONGO_INITDB_ROOT_PASSWORD 같은 환경변수로 기본 admin 계정을 만들고, 필요하면 _FILE 패턴으로 비밀번호를 별도 파일에서 읽어오게 구성한다.
백엔드는 Node 기반 Dockerfile을 정의하고, package.json → npm install → 소스 코드 복사 → EXPOSE 80 → CMD 순서로 이미지를 빌드한다. 개발 편의를 위해 /app에 바인드 마운트를 사용해 코드 변경을 바로 반영하고, /app/logs에는 명명된 볼륨을 붙여 로그를 보존한다. /app/node_modules에는 익명 볼륨을 설정해서 의존성이 덮어씌워지지 않게 막는다. MongoDB 접속 정보는 환경변수로 빼고, Node.js 코드에서는 process.env를 사용해 연결 문자열을 동적으로 구성한다.
React 프론트엔드는 비슷한 구조로 이미지를 만들되, CMD ["npm", "start"]로 개발 서버를 실행한다. 컨테이너는 -it로 실행해 바로 종료되지 않게 하고, 소스 코드 라이브 리로드를 위해 src 폴더만 바인드 마운트한다. 이때 백엔드 URL은 도커 네트워크의 컨테이너 이름이 아니라, 브라우저 관점에서 접근 가능한 localhost 기준으로 작성해야 한다는 점을 잊지 않는다.
세 컨테이너는 사용자 정의 네트워크에 묶어서, 백엔드와 데이터베이스는 서로를 컨테이너 이름으로 해석하게 만들 수 있다. 이 네트워크 안에서는 각 컨테이너 이름이 일종의 DNS 엔트리처럼 동작한다. 반면 브라우저는 이 네트워크 바깥에 있기 때문에, 여전히 호스트 포트(-p 옵션)로 접근해야 한다.
마지막으로, 이 모든 설정은 철저히 “개발용”이다. 코드 변경 시 자동 재시작, 라이브 리로드, 바인드 마운트 등은 로컬에서 개발할 때 큰 힘이 되지만, 실제 운영 환경에서는 전혀 필요하지 않다. 프로덕션에서는 보통 React 앱을 npm run build나 Vite 같은 도구로 정적 파일로 빌드하고, Nginx 같은 웹 서버에 올려 정적으로 서빙하는 편이 훨씬 안전하고 빠르다.