던플 개발기 (9): Docker 전체 스택으로 로컬을 프로덕션처럼
지난 편에서 실시간 게이트웨이까지 세웠다면, 이제 이 앱들을 한 번에 띄우는 이야기입니다. 던플마켓은 어느새 앱이 여섯 개(item-api·market-api·market-bff·market-cron·market-web·realtime-gateway)로 늘었고, 그 아래에는 PostgreSQL·Redis·RabbitMQ·Kafka가 깔려 있습니다. 문제는 이 조합이 제 맥에서는 잘 도는데, 윈도우 데스크톱으로 옮기면 어긋난다는 것이었습니다.
이 편의 전제는 하나입니다. "내 컴퓨터에선 되는데"를 없애려면 로컬이 프로덕션과 같은 위상이어야 했습니다. 그래서 앱 전체를 프로덕션 이미지로 감싸고, 하나의 compose 스택으로 묶어, 어느 기계에서든 같은 데이터로 같은 그림이 뜨게 만들었습니다. 다만 이걸 "매일 쓰는 개발 방식"으로 삼지는 않았습니다 — 왜 그렇게 선을 그었는지도 함께 적습니다.
한눈에 보면
- 매일의 개발 루프는 앱은 host Node/Nx로, 컨테이너는 상태 저장 의존성(PostgreSQL·Redis·RabbitMQ·Kafka)만 띄웁니다. 이게 가볍고 빠릅니다.
- 앱 전체를 담은 컨테이너 스택은
--profile full로만 뜹니다. 이건 프로덕션 이미지가 실제로 빌드·기동되는지 확인하는 무거운 smoke이자, 윈도우·맥을 같은 환경으로 맞추는 용도입니다. - 컨테이너끼리는
localhost가 아니라 서비스 이름으로 붙습니다. Kafka는 이 때문에 host용(localhost:9092)과 컨테이너용(kafka:29092) 리스너를 둘 엽니다. - 백엔드 이미지는
install → nx prune → prod-install(hoisted) → prisma generate → non-root runtime흐름이고,market-web은 Next standalone 출력을 씁니다. - 실데이터 seed는 data-only pg_dump를 빈 DB에만 한 번 로드합니다. 단, 이 스냅샷에는 synthetic fixture가 섞여 있어 런타임 조회에서 제외합니다.
- 진짜 key는 이미지에 굽지 않습니다.
.env를 런타임에env_file로 주입하고,.dockerignore가.env를 빌드 컨텍스트에서 뺍니다.
로컬이 프로덕션과 달라서 생긴 문제
인프라 편에서는 로컬에 PostgreSQL 하나와 앱 세 개만 있었습니다. 그때는 host에서 pnpm dev:up 한 방으로 충분했습니다. 그런데 메시징 백본에서 RabbitMQ와 Kafka가 들어오고, 게이트웨이가 붙고, catalog를 소유하는 item-api가 분리되면서 로컬 구성이 부쩍 무거워졌습니다.
여기서 표면에 드러나지 않던 격차들이 문제를 일으켰습니다.
- 번들·의존성이 프로덕션에서만 다르게 동작한다. host에서는
pnpm이 워크스페이스 심볼릭 링크로 모든 걸 해결해 줘서, 막상 프로덕션 이미지처럼 pruned 의존성만 남겼을 때@prisma/client가 안 잡히는 문제를 host에서는 절대 못 봅니다. - 컨테이너 네트워크 주소가 다르다. host에서는
localhost:9092로 Kafka에 붙지만, 컨테이너 안에서localhost는 자기 자신입니다. 이 차이는 컨테이너로 띄워 봐야만 드러납니다. - 기계마다 환경이 다르다. 윈도우·맥은 Node 버전, 줄바꿈, 경로 구분자, 로컬에 이미 떠 있는 PostgreSQL까지 제각각입니다. "내 맥에선 되는데"의 대부분이 여기서 옵니다.
정리하면, 이 격차들은 전부 프로덕션과 같은 모양으로 띄워 봐야 잡히는 것이었습니다. 그래서 앱을 전부 컨테이너로 감쌀 필요가 생겼습니다.
앱 6개를 컨테이너로
먼저 앱마다 프로덕션 Dockerfile을 붙였습니다. 여섯 개 앱과 그 의존 관계는 이렇습니다.
| service | 역할 | 포트(host) | 붙는 곳 |
|---|---|---|---|
market-api |
gRPC (Postgres + Redis read model) | 50051 | postgres, redis |
item-api |
gRPC item catalog/spec | 50052 | postgres |
market-bff |
REST (browser/SSR 진입점) | 3000 | market-api, item-api (gRPC) |
realtime-gateway |
Kafka 소비 → WS/SSE fan-out | 4300 | kafka |
collector |
주기 수집 worker (market-cron) |
- | postgres, kafka, rabbitmq |
market-web |
Next.js standalone UI | 4200 | market-bff (SSR) |
설계 편에서 정한 경계가 compose의 depends_on으로 그대로 내려옵니다. market-bff는 DB·Kafka에 직접 닿지 않고 오직 두 gRPC 서비스만 알며, 브라우저를 받는 market-web은 market-bff만 압니다.
flowchart TD
subgraph infra[인프라 · profile 없이도 뜸]
PG[(postgres)]
RD[(redis)]
MQ[rabbitmq]
KF[kafka]
end
MIG[migrate<br/>migrate deploy 후 종료]
SEED[seed<br/>비어 있으면 로드 후 종료]
PG --> MIG
MIG --> SEED
MIG --> API[market-api :50051]
MIG --> ITEM[item-api :50052]
PG --> API
PG --> ITEM
API --> BFF[market-bff :3000]
ITEM --> BFF
BFF --> WEB[market-web :4200]
KF --> GW[realtime-gateway :4300]
MIG --> COL[collector]
KF --> COL컨테이너는 localhost가 아니라 이름으로 붙는다
host에서 돌 때와 가장 크게 달라지는 부분입니다. compose 네트워크 안에서는 서비스 이름이 곧 호스트명입니다. 그래서 앱 서비스의 환경 변수는 localhost가 아니라 postgres, kafka, market-api 같은 이름을 가리킵니다.
# docker-compose.yml — full 프로파일의 market-api
market-api:
profiles: ['full']
build:
context: .
dockerfile: apps/market-api/Dockerfile
environment:
POSTGRES_HOST: postgres
REDIS_HOST: redis
KAFKA_BROKERS: kafka:29092
MARKET_API_GRPC_BIND_URL: 0.0.0.0:50051
depends_on:
postgres: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
ports:
- '50051:50051'Kafka에서 이 차이가 특히 까다로웠습니다. host에서 돌리는 smoke 도구는 localhost:9092로 붙어야 하고, 같은 네트워크의 컨테이너는 kafka:29092로 붙어야 합니다. 그래서 broker에 리스너를 둘 열고, 각각 다른 주소를 advertise하게 했습니다.
# 두 client 리스너: host 도구용 PLAINTEXT(localhost:9092),
# 컨테이너용 INTERNAL(kafka:29092)
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,INTERNAL://0.0.0.0:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,INTERNAL://kafka:29092flowchart LR
TOOL[host 도구<br/>kafka-smoke 등] -->|localhost:9092| K[(kafka broker)]
GW[realtime-gateway 컨테이너] -->|kafka:29092| K
COL[collector 컨테이너] -->|kafka:29092| K
API[market-api 컨테이너] -->|kafka:29092| Kadvertise한 주소가 틀리면, 연결 자체는 되고도 broker가 클라이언트에게 "나한테 붙으려면 이 주소로 와"라며 잘못된 주소를 돌려줘 그다음 요청이 조용히 실패합니다. 컨테이너로 띄우기 전엔 볼 일이 없던 함정입니다.
백엔드 이미지: 번들하고, 쳐내고, 다시 설치한다
백엔드 앱(market-api·item-api·market-bff·market-cron·realtime-gateway)은 같은 멀티 스테이지 패턴을 공유합니다. 핵심은 build 스테이지에서 모든 걸 끝내고 runtime 스테이지는 복사만 하는 것입니다.
# apps/market-cron/Dockerfile (백엔드 공통 패턴)
FROM node:24-slim AS build
RUN corepack enable
WORKDIR /workspace
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm nx prune ${APP} # 이 앱에 필요한 것만 남김
# 번들은 CommonJS인데 앱은 "type":"module" → commonjs로 교정하고,
# workspace:* 의존을 file: 참조로 바꿔 pruned 결과 안에서 설치되게 함
RUN node -e "...package.json 패치..."
WORKDIR /workspace/apps/${APP}/dist
# hoisted 설치: @prisma/client가 top-level로 올라와야 번들 require가 풀림
RUN NPM_CONFIG_NODE_LINKER=hoisted pnpm install --prod --no-frozen-lockfile --ignore-workspace
RUN ...prisma generate --schema .../schema.prisma # data-access schema가 있으면
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /workspace/apps/${APP}/dist ./ # dist만 복사
USER node # non-root
ENTRYPOINT ["node", "main.js"]nx prune은 Nx 모노레포에서 이 앱이 실제로 쓰는 것만 골라 냅니다. 그런데 여기서 두 가지 함정이 있었습니다. 웹팩 번들은 CommonJS인데 워크스페이스 package.json은 "type": "module"이라 Node가 로드에 실패하는 것, 그리고 @prisma/client가 번들된 워크스페이스 모듈의 의존이라 flat하게 hoisting하지 않으면 런타임 require가 못 찾는 것입니다. 그래서 type을 commonjs로 덮고, node-linker=hoisted로 설치합니다. 이런 건 host 개발에서는 결코 안 드러나고, 프로덕션 이미지로 만들어 봐야만 나옵니다.
item-api는 여기에 특수 케이스가 하나 더 있었습니다. item-data-access는 Prisma client를 패키지 안 generated/prisma라는 커스텀 경로로 내보내기 때문에, 워크스페이스 모듈이 아니라 설치된 node_modules의 schema를 기준으로 client를 generate해야 런타임 import가 풀립니다.
프론트엔드 이미지: Next standalone
market-web은 성격이 다릅니다. Next.js의 standalone 출력을 써서, 서버가 필요로 하는 파일만 추린 자립형 번들을 만듭니다.
# apps/market-web/Dockerfile
FROM node:24-slim AS build
# NEXT_PUBLIC_* 는 빌드 타임에 브라우저 번들로 인라인됨 → 여기 있어야 함
ARG NEXT_PUBLIC_MARKET_BFF_BASE_URL=http://localhost:3000
ARG NEXT_PUBLIC_REALTIME_WS_URL=ws://localhost:4300/realtime
ENV NEXT_PUBLIC_MARKET_BFF_BASE_URL=${NEXT_PUBLIC_MARKET_BFF_BASE_URL}
...
RUN pnpm nx build @dunplay/market-web
FROM node:24-slim AS runtime
ENV NODE_ENV=production PORT=4200 HOSTNAME=0.0.0.0
COPY --from=build /workspace/apps/market-web/.next/standalone ./
COPY --from=build /workspace/apps/market-web/.next/static ./apps/market-web/.next/static
USER node
CMD ["node", "apps/market-web/server.js"]여기서 배운 건 NEXT_PUBLIC_*의 시점입니다. 브라우저가 읽는 값(NEXT_PUBLIC_MARKET_BFF_BASE_URL 등)은 빌드 타임에 번들로 구워지므로 Dockerfile의 ARG로 넣어야 하고, SSR에서만 쓰는 MARKET_BFF_URL은 런타임 환경 변수로 둡니다. 이 둘을 헷갈리면 브라우저가 컨테이너 내부 주소(market-bff:3000)로 붙으려다 실패합니다. standalone 출력을 켠 건 next.config.js의 output: 'standalone' 한 줄입니다.
compose profile: full
이제 중요한 결정입니다. 이 전체 스택을 매일 쓰는 개발 방식으로 삼지 않았습니다. compose를 profile 없이 그냥 띄우면 인프라(PostgreSQL·Redis·RabbitMQ·Kafka)만 올라오고, 앱은 host에서 돕니다. 앱 컨테이너 전부는 full profile을 명시해야만 뜹니다.
# 기본: 상태 저장 의존성만 컨테이너, 앱은 host Node/Nx
pnpm dev:infra # postgres + redis
pnpm dev:infra:all # postgres + redis + rabbitmq + kafka
pnpm dev:all # item-api·market-api·market-bff·market-web을 host로
# 선택: 앱까지 전부 컨테이너로 (무거운 production image smoke)
docker compose --profile full up -d --buildflowchart LR
subgraph host[기본 · 가벼움]
H1[dev:infra:all<br/>인프라만 컨테이너] --> H2[dev:all<br/>앱은 host Node/Nx]
end
subgraph full[선택 · 무거움]
F1[compose --profile full<br/>앱까지 전부 컨테이너]
end이렇게 나눈 이유는 단순합니다. 전체 스택 빌드는 디스크와 메모리를 많이 쓰고 느립니다. 코드 한 줄 고치고 매번 이미지를 다시 빌드하며 개발할 수는 없습니다. 그래서 일상 개발은 host-run으로 빠르게, 프로덕션 이미지가 진짜로 빌드·기동되는지는 full로 가끔 확인하는 두 층으로 갈랐습니다. 이건 인프라 편의 "필요할 때 올린다"와 같은 결의 결정입니다.
full 스택에는 앱 여섯 개 말고도, 한 번 돌고 끝나는 one-shot 서비스 둘이 있습니다. migrate는 Prisma migration을 적용하고 종료하고, seed는 그다음에 DB가 비어 있을 때만 스냅샷을 로드합니다.
migrate:
profiles: ['full']
build: { context: ., dockerfile: tools/docker/migrate.Dockerfile }
depends_on:
postgres: { condition: service_healthy }
seed:
profiles: ['full']
image: postgres:16-alpine
depends_on:
migrate: { condition: service_completed_successfully }
command:
- |
if psql ... -tAc "SELECT 1 FROM items LIMIT 1" | grep -q 1; then
echo "seed: items already present, skipping";
else
echo "seed: loading real-data seed"; psql ... -f /seed/seed-data.sql;
fimigrate용 이미지는 Prisma CLI와 스키마만 담은 아주 작은 이미지입니다. prisma migrate deploy를 실행하고 끝냅니다 — 운영 DB에는 db push 같은 자동 동기화를 절대 쓰지 않는다는 원칙을 컨테이너에서도 지킵니다.
수집을 컨테이너 안에서까지 굴리고 싶을 때를 위해 collector 서비스도 뒀습니다. 이건 프로덕션의 EventBridge+SQS worker(인프라 편 참고)를 로컬에서만 흉내 내는 stand-in입니다. 매 tick마다 수집하고 outbox를 Kafka로 흘려, 게이트웨이가 새 가격을 밀어내는 것까지 한 바퀴 돌게 합니다.
collector:
entrypoint: ['/bin/sh', '-c']
command:
- |
node main.js import-items || echo "collector: import-items FAILED (계속; 로그 확인)";
while true; do
node main.js collect-due || echo "collector: collect-due FAILED (다음 tick; 로그 확인)";
node main.js relay-outbox || echo "collector: relay-outbox FAILED (다음 tick; 로그 확인)";
sleep "$${COLLECT_TICK:-60}";
done여기서 한 가지 원칙을 지켰습니다. 각 step은 실패해도 loop를 멈추지 않지만(일시적 DB 문제나 네오플 점검은 예상 범위입니다), 실패를 || true로 조용히 삼키지 않고 FAILED를 명시적으로 로그에 남깁니다. 그래야 컨테이너가 살아 있다는 사실만으로 "수집이 잘 되고 있다"고 착각하지 않습니다. 살아 있음과 일하고 있음은 다릅니다.
실데이터로 seed하기
전체 스택을 띄웠을 때 빈 화면을 보면 "잘 뜬 건지 고장 난 건지" 판단이 안 됩니다. 그래서 두 데스크톱이 같은 실데이터에서 출발하도록 seed를 만들었습니다. 방식은 수집한 아이템·tracked item·경매 데이터를 담은 data-only pg_dump입니다. market-cron으로 다시 수집하지 않고도 곧장 채워집니다.
seed 서비스가 SELECT 1 FROM items로 먼저 확인하고 비어 있을 때만 로드하므로, 스택을 다시 올려도 데이터가 중복되지 않습니다. 실제로 재수집 없이 매물·시세·차트가 채워진 화면을 바로 볼 수 있습니다.
다만 여기엔 정직하게 밝혀 둘 한계가 있습니다.
- 이 스냅샷은 캡처된 실데이터 행과
integration-item-%synthetic fixture가 섞여 있습니다. 테스트를 위해 함께 덤프됐기 때문입니다. - 그래서 런타임의 dashboard·popular·movers·terminal 조회는
integration-item-%(와seed-%)를 제외하고,purge-synthetic-data가 이들을 삭제 대상으로 봅니다. - 즉 이 스냅샷은 "빈 컨테이너 DB를 빠르게 채우는 편의용"이지, 기본 로컬 개발의 실데이터 기준이 아닙니다. 실데이터 baseline은 host에서 도는
pnpm dev:setup(= 실제import-items → collect-due → relay-outbox → recalculate-popularity) 부트스트랩입니다.
이 구분은 장애 대응 편의 원칙과 이어집니다 — synthetic으로 채운 자리를 실데이터인 척 집계에 섞으면 숫자가 거짓말을 하기 때문에, seed 데이터라도 무엇이 진짜인지 경계를 그어 둡니다.
크로스 플랫폼(윈도우/맥) 함정
원래 목적으로 돌아옵니다. 이 스택이 준 가장 큰 값은 윈도우와 맥이 같은 환경을 얻는 것이었습니다. 앱이 전부 같은 node:24-slim 이미지 안에서 도니, Node 버전·줄바꿈·경로 구분자 차이가 개발 결과에 끼어들 여지가 사라집니다. 그 과정에서 부딪힌 것들입니다.
포트 충돌. 인프라 편에서 겪은 5432 충돌은 여기서도 유효합니다. 로컬에 이미 PostgreSQL이 떠 있으면 바인딩이 실패하므로, host 포트를 변수로 뽑아 뒀습니다. .env에서 POSTGRES_PORT=5433 한 줄만 바꾸면 host 바인딩이 따라옵니다. 컨테이너 내부 포트는 항상 5432입니다.
ports:
- '${POSTGRES_PORT:-5432}:5432'secret은 이미지에 절대 굽지 않는다. 진짜 네오플 key는 런타임에 env_file로 주입하고, .dockerignore가 .env를 빌드 컨텍스트에서 빼서 이미지에 섞이지 않게 합니다.
# .dockerignore — secret은 런타임 compose env로, 이미지에는 절대
.env
.env.*
!.env.examplekey는 두 개로 나뉩니다. NEOPLE_ITEM_API_KEY(item-api·catalog)와 NEOPLE_MARKET_API_KEY(경매 수집)입니다. env_file이 둘 다 모든 서비스에 넘기지만, 각 프로세스는 자기 워크로드에 필요한 key만 읽습니다.
빌드 결정성. pnpm install --frozen-lockfile로 lockfile을 고정해, 어느 기계에서 빌드하든 같은 의존성 트리가 나오게 했습니다. 이게 "내 맥에선 되는데"의 상당 부분을 컨테이너 경계 안으로 밀어 넣어 없앱니다.
솔직한 비용도 적어 둡니다. 전체 스택은 디스크와 VM 메모리를 많이 먹고 빌드가 느립니다. 그래서 이 스택이 실패했다고 로컬 개발 전체가 막힌 건 아니고, host-run 경량 워크플로로 언제든 돌아갈 수 있게 둘을 분리해 둔 것입니다.
정리하면
이 편의 결정도 다른 편들과 같은 결을 따랐습니다.
- 앱 여섯 개를 프로덕션 Dockerfile로 감싸고,
depends_on으로 설계의 경계를 compose에 그대로 내렸다. - 컨테이너 주소(
kafka:29092), 번들·pruned 의존성처럼 프로덕션 이미지로 띄워야만 드러나는 격차를full스택으로 잡았다. - 그러면서도 전체 스택을 기본 워크플로로 삼지 않고, 일상은 host-run + 인프라 컨테이너로 가볍게 갈랐다.
- 실데이터 seed로 두 기계를 같은 출발점에 두되, synthetic이 섞인 한계를 숨기지 않고 런타임 집계에서 제외했다.
- secret은 이미지에 굽지 않고, 포트·빌드는 변수와 lockfile로 결정적으로 만들어 윈도우·맥을 통일했다.
"내 컴퓨터에선 되는데"는 결국 로컬이 프로덕션과 다른 위상에 있을 때 나오는 말이었습니다. 그 격차를 컨테이너 경계 안으로 옮겨 넣으니, 어느 기계에서든 같은 데이터로 같은 그림이 떴습니다.
다음 편에서는 이 collector가 매 tick 돌리던 collect-due를 정면으로 다룹니다. 고정 크론이 아니라 "지금 수집할 때가 된 것"을 질의로 뽑는 동적 수집으로 어떻게 옮겼는지 이야기하겠습니다.
