LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

KD TeamDevLogDockerNetworkCI/CD

도커 배포, 교수님 메일 한 통으로 파고든 개념들

2026년 4월 10일·22분 읽기

교수님 메일 한 통

졸업 프로젝트 배포 환경을 세팅하던 중에 교수님한테 메일이 왔다. 내용을 요약하면 네 가지였다.

  1. 서브도메인 차단 — 포트 번호로만 서비스를 구분할 것
  2. docker-compose 사용 불가 — 개별 이미지로 실행할 것
  3. 프론트엔드도 도커 이미지로 만들어서 레지스트리에서 pull 받게 할 것
  4. 환경변수 최소화

솔직히 처음 읽었을 때는 무슨 말인지 잘 몰랐다. 서브도메인이 왜 안 되는 건지, docker-compose가 없으면 어떻게 여러 컨테이너를 돌리는 건지, 환경변수를 최소화하라는 게 구체적으로 뭘 줄이라는 건지. 하나도 명확하지 않았다.

그런데 팀장인 내가 이걸 이해하지 못하면 팀원들한테 설명을 할 수가 없었다. 배포 구조를 바꿔야 하는 건 결국 프론트엔드 코드도 수정해야 한다는 뜻이고, 그 작업을 분배하려면 내가 먼저 전체 그림을 잡아야 했다. 그래서 메일에 적힌 네 가지를 하나씩 파고들기 시작했다. 이 글은 그 과정에서 이해한 것들을 정리한 기록이다.


서브도메인 vs 포트 번호

기존에 우리 프로젝트는 서브도메인 방식으로 서비스를 나누고 있었다. api.example.ac.kr은 API 서버, auth.example.ac.kr은 인증 서버, admin.example.ac.kr은 어드민 서버. 이렇게 도메인 이름 자체를 다르게 해서 각 서비스를 구분하는 구조였다.

그런데 학교 네트워크에서 서브도메인이 막혔다. 이유는 간단하다. 서브도메인은 DNS 레벨에서 별도의 레코드가 필요하다. example.ac.kr이라는 도메인에 api, auth, admin 이라는 하위 레코드를 만들어야 하는데, 학교 측에서는 example.ac.kr 하나만 허용하고 있었다. 서브도메인을 추가할 권한이 우리에게 없었던 것이다.

대안이 포트 번호 방식이었다. 도메인 주소는 전부 example.ac.kr 로 동일하되, 뒤에 붙는 포트 번호로 서비스를 구분하는 것이다. :8080은 API, :8081은 Admin, :8082는 Auth. 비유하자면 아파트와 같다. 동 주소(도메인)는 전부 같은데, 호수(포트)가 다른 것이다. 같은 건물에 사는데 101호, 102호, 103호로 나뉘는 것처럼, 같은 서버에서 포트 번호만 달리해서 서로 다른 서비스로 연결한다.

중요한 건 사용자가 접속하는 URL은 그대로라는 점이다. 졸업논문 시스템의 메인 페이지 주소는 바뀌지 않는다. 바뀌는 건 프론트엔드 코드 내부에서 API를 호출하는 주소다. 사용자는 이 변화를 전혀 인지하지 못한다.

우리 코드에서 바꿔야 했던 것

실제로 수정한 코드를 보면 변경점이 명확하다. 환경변수 파일이 아니라 소스코드의 fallback 값을 바꾸는 작업이었다.

ts
// 기존 — 서브도메인 기반
export const API_URL =
  import.meta.env.VITE_API_URL || "https://api.example.com";
export const API_AUTH_URL =
  import.meta.env.VITE_AUTH_API_URL || "https://auth.example.com";
export const API_ADMIN_URL =
  import.meta.env.VITE_ADMIN_API_URL || "https://admin.example.com";

// 변경 — 포트 번호 기반
export const API_URL =
  import.meta.env.VITE_API_URL || "https://example.ac.kr:8080";
export const API_AUTH_URL =
  importmetaenv  

여기서 || 뒤에 오는 값이 fallback이다. 환경변수가 주입되지 않았을 때 기본값으로 쓰이는 것이다. Vite는 빌드 시점에 import.meta.env.VITE_* 변수를 코드에 직접 삽입하는 방식이라, 이 fallback 값이 빌드 결과물에 그대로 박힌다. 그래서 학교 서버에서 -e 옵션 없이 컨테이너를 실행해도 바로 동작하게 된다. 이 부분은 뒤에서 환경변수 섹션에서 더 자세히 다룬다.


Caddy(리버스 프록시)가 하던 일, 그리고 왜 빠지는지

기존 배포 구조에서는 Caddy라는 리버스 프록시가 핵심 역할을 했다. 외부에서 요청이 들어오면 Caddy가 먼저 받아서, 도메인 이름을 보고 어느 서비스로 보낼지 결정해주는 중개자였다. api.example.com으로 들어오면 내부 8080 포트로, auth.example.com으로 들어오면 내부 8082 포트로, admin.example.com으로 들어오면 내부 8081 포트로. 이렇게 도메인 이름이 달랐기 때문에 Caddy가 요청을 구분할 수 있었다.

그런데 서브도메인이 막히면 이 구분 자체가 불가능해진다. 모든 요청이 example.ac.kr이라는 동일한 도메인으로 들어오기 때문에, Caddy 입장에서는 어떤 요청을 API로 보내야 하고 어떤 요청을 Admin으로 보내야 하는지 알 방법이 없다. 결과적으로 Caddy라는 레이어 자체가 필요 없어지는 것이다. 포트 번호를 쓰면 클라이언트가 :8080으로 직접 요청을 보내고, 그 요청은 곧바로 해당 컨테이너로 간다. 중간에 누가 라우팅해줄 필요가 없다.

처음에는 리버스 프록시가 빠진다는 게 불안했다. 뭔가 중요한 게 없어지는 느낌이 들었다. 하지만 생각해보면 리버스 프록시의 핵심 역할이 "요청을 올바른 곳으로 보내주는 것"인데, 포트 번호가 이미 그 역할을 대신하고 있었다. 클라이언트가 목적지를 직접 지정하니까 중개자가 필요 없는 것이다.

웹서버 vs 리버스 프록시

이 과정에서 웹서버와 리버스 프록시의 차이도 확실히 정리하게 됐다. 둘 다 Nginx 같은 도구로 구현할 수 있어서 헷갈리기 쉬운데, 하는 일이 완전히 다르다.

웹서버는 정적 파일을 서빙하는 역할이다. React나 Vue로 만든 프론트엔드를 빌드하면 HTML, JS, CSS 파일이 나오는데, 이걸 클라이언트 브라우저에 내보내주는 게 웹서버다. 우리 Dockerfile에서 FROM nginx:alpine을 쓰는 것도 바로 이 역할이다. Nginx가 빌드된 정적 파일을 그냥 돌려주는 것이다.

반면 리버스 프록시는 들어온 요청을 다른 포트나 서버로 넘기는 역할이다. 자기가 직접 응답을 만들지 않고, 요청을 받아서 적절한 곳으로 전달한 뒤 그 응답을 다시 클라이언트에게 돌려준다. Caddy가 하던 일이 바로 이것이다. 중요한 건 Nginx도 이 두 가지 역할을 모두 할 수 있다는 점이다. 설정 파일을 어떻게 작성하느냐에 따라 웹서버로도, 리버스 프록시로도 동작한다. 우리 프로젝트에서는 Nginx를 순수한 웹서버 용도로만 쓰고 있었다.


docker-compose vs 개별 docker run

docker-compose는 여러 컨테이너를 한 번에 정의하고 실행하는 도구다. docker-compose.yml 파일 하나에 API 서버, 인증 서버, 어드민 서버, 데이터베이스까지 전부 적어놓고, docker-compose up -d 한 줄이면 전부 뜬다. 컨테이너 간의 의존성 순서도 정의할 수 있고, 같은 네트워크 안에서 서로 이름으로 통신할 수도 있다. 개발할 때 정말 편한 도구다.

문제는 docker-compose가 Docker에 기본으로 포함된 게 아니라 별도로 설치해야 하는 도구라는 점이다. 학교 서버에는 Docker만 설치되어 있고 docker-compose는 없었다. 서버에 추가 설치를 할 수 있는 환경도 아니었기 때문에, 결국 각 컨테이너를 docker run 명령어로 하나씩 수동 실행해야 했다.

수동 실행이라고 하면 복잡할 것 같지만, 핵심은 같은 명령어를 서비스 개수만큼 반복하는 것이다. 각 컨테이너마다 이름, 포트, 환경변수를 지정해서 실행한다.

bash
# API 인스턴스 실행
docker run -d --name graduate-api \
  -p 8080:8080 \
  -e PORT=8080 \
  --restart unless-stopped \
  your-registry/aics-graduate:latest

# Admin 인스턴스 실행
docker run -d --name graduate-admin \
  -p 8081:8081 \
  -e PORT=8081 \
  --restart unless-stopped \
  your-registry/aics-graduate:latest

-d는 백그라운드 실행, --name은 컨테이너에 이름을 붙이는 것, --restart unless-stopped는 서버가 재부팅되거나 컨테이너가 크래시했을 때 자동으로 다시 띄워주는 옵션이다. 이 옵션이 없으면 서버 재시작 때마다 수동으로 컨테이너를 올려야 한다.

포트 포워딩 — -p 옵션이 하는 일

-p 8080:8080에서 앞의 숫자가 호스트(서버) 포트이고, 뒤의 숫자가 컨테이너 내부 포트다. 도커 컨테이너는 기본적으로 외부와 격리되어 있다. 컨테이너 안에서 8080 포트로 서버가 돌아가고 있어도, 외부에서는 접근할 수 없다. -p 옵션이 이 격리된 공간에 통로를 뚫어주는 것이다. 외부에서 호스트의 8080 포트로 요청이 들어오면, 그 요청을 컨테이너 내부의 8080 포트로 연결해주는 포트 포워딩이 일어난다.

참고로 앞뒤 번호가 반드시 같을 필요는 없다. -p 80:3000이라고 쓰면, 외부에서 80번 포트로 들어온 요청이 컨테이너 내부의 3000번 포트로 전달된다. 보통은 헷갈리지 않게 같은 번호를 쓰지만, 호스트에서 이미 해당 포트를 다른 서비스가 쓰고 있을 때 이렇게 다른 번호를 매핑할 수 있다.

업데이트 흐름

코드가 수정되어 새 이미지를 배포해야 할 때, docker-compose 환경이라면 docker-compose pull && docker-compose up -d 한 줄이면 끝이다. 하지만 개별 docker run 환경에서는 이 과정을 직접 해야 한다.

bash
# 1. 최신 이미지 받기
docker pull your-registry/aics-graduate:latest

# 2. 기존 컨테이너 중지 및 삭제
docker stop graduate-api
docker rm graduate-api

# 3. 새 컨테이너 실행 (위의 run 명령어 재실행)
docker run -d --name graduate-api \
  -p 8080:8080 \
  -e PORT=8080 \
  --restart unless-stopped \
  your-registry/aics-graduate:latest

이미지를 새로 받고, 기존 컨테이너를 멈추고 삭제한 다음, 새 이미지로 컨테이너를 다시 띄우는 세 단계다. 단순하지만 서비스가 여러 개면 이걸 각각 반복해야 하니까 번거롭다. 그래서 이 과정 전체를 deploy.sh 쉘 스크립트로 만들어뒀다. 스크립트 하나 실행하면 모든 컨테이너를 순서대로 업데이트하도록 해서, docker-compose가 없는 환경에서도 한 줄로 배포할 수 있게 했다.


환경변수 주입 — Build-time vs Runtime

백엔드와 프론트엔드는 환경변수를 주입받는 시점이 근본적으로 다르다. 이 차이를 이해하지 못하면 "왜 환경변수를 바꿨는데 반영이 안 되지?"라는 상황에 반드시 부딪힌다. 나도 처음에 이걸 몰라서 헤맸다.

백엔드는 런타임 주입 방식이다. 서버가 실행되는 시점에 -e DB_URL=... 같은 옵션으로 환경변수를 넘기면, 서버 프로세스가 그 값을 읽어서 사용한다. 값이 바뀌면 컨테이너만 재시작하면 된다. 이미지를 다시 빌드할 필요가 없다. process.env.DB_URL을 참조하는 코드는 실행 시점에 해당 값을 메모리에서 읽기 때문이다.

프론트엔드(Vite)는 완전히 다르다. VITE_ 접두사가 붙은 환경변수는 빌드 시점에 코드 안에 직접 삽입된다. Vite가 빌드를 돌리면서 import.meta.env.VITE_API_URL이라고 쓰여진 부분을 찾아서, 그 자리에 실제 문자열 값을 넣어버린다. 이걸 Static Replacement 라고 부른다. 빌드된 JS 파일을 열어보면 import.meta.env.VITE_API_URL 자리에 실제 URL 문자열이 그대로 박혀있는 걸 확인할 수 있다.

이게 의미하는 건 명확하다. API URL이 바뀌면 코드를 다시 빌드해야 하고, 새 이미지를 만들어야 하고, 컨테이너를 재배포해야 한다. 런타임에 -e VITE_API_URL=새주소를 넘겨봤자 아무 의미가 없다. 이미 빌드된 JS 파일 안에 이전 값이 하드코딩되어 있으니까.

교수님이 말한 "환경변수 최소화"의 의미가 여기서 연결된다. 실행할 때 -e 옵션을 주렁주렁 달지 말고, 빌드 시점에 이미 값을 포함시켜서 배포 과정을 단순하게 만들라는 것이다. 프론트엔드는 어차피 빌드 시점에 값이 고정되니까, fallback으로 기본값을 넣어두면 실행 시 환경변수를 따로 넘길 필요가 없다.

ts
// 환경변수가 없어도 fallback으로 동작
export const API_URL =
  import.meta.env.VITE_API_URL || "https://example.ac.kr:8080";

이렇게 해두면 docker run 할 때 VITE_API_URL을 따로 안 넘겨도 된다. 이미지를 굽는(빌드하는) 시점에 기본값이 코드에 포함됐기 때문이다. 배포 명령어가 깔끔해지고, 실수로 환경변수를 빠뜨려서 서비스가 안 되는 상황도 방지할 수 있다.


Multi-stage Build — 이미지를 가볍게 만드는 방법

프론트엔드 프로젝트를 도커 이미지로 만들 때, 빌드 환경과 실행 환경에 필요한 것의 차이가 크다. 빌드할 때는 Node.js, pnpm, 소스코드, node_modules 전부 필요하다. TypeScript를 컴파일하고, 번들링하고, 최적화하는 과정에서 이 모든 게 쓰인다. 그런데 빌드가 끝나고 나면? 결과물은 HTML, JS, CSS 파일 몇 개뿐이다. node_modules도, 소스코드도, Node.js 런타임도 더 이상 필요 없다.

만약 이 구분 없이 하나의 스테이지로 이미지를 만들면, 빌드에만 필요했던 모든 도구가 최종 이미지에 포함된다. Node.js 런타임 자체가 수백 MB이고, node_modules 까지 합치면 이미지 크기가 1GB를 넘기기도 한다. 이걸 레지스트리에 올리고 서버 에서 pull 받는 데 시간이 오래 걸리고, 서버 디스크도 낭비된다.

Multi-stage Build가 이 문제를 해결한다. Dockerfile 안에서 스테이지를 나눠서, 첫 번째 스테이지에서 빌드를 하고, 두 번째 스테이지에는 빌드 결과물만 복사하는 방식이다. 최종 이미지에는 두 번째 스테이지의 내용만 들어간다.

dockerfile
# 1단계: 빌드 스테이지
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app

RUN corepack enable pnpm
COPY . .
RUN pnpm install --no-frozen-lockfile
RUN turbo build --filter=@aics-client/community

# 2단계: 실행 스테이지
FROM nginx:alpine AS runner
COPY --from=builder /app/apps/community/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

AS builder와 AS runner가 스테이지를 나누는 키워드다. 첫 번째 스테이지에서는 Node.js 환경 위에서 pnpm install, turbo build 를 실행해서 빌드 결과물을 만든다. 두 번째 스테이지에서는 nginx:alpine이라는 경량 이미지를 베이스로 깔고, COPY --from=builder로 첫 번째 스테이지의 빌드 결과물(dist 폴더)만 가져온다. 첫 번째 스테이지의 나머지 — Node.js, pnpm, node_modules, 소스코드 — 는 전부 버려진다.

결과적으로 최종 이미지의 크기가 극적으로 줄어든다. node:20-alpine 베이스 이미지만 해도 900MB 이상인데, nginx:alpine은 20MB대다. 여기에 빌드된 정적 파일 몇 MB만 추가되니까, 최종 이미지는 30MB도 안 되는 수준이 된다. 레지스트리에서 pull 받는 시간도 짧아지고, 서버 디스크도 아끼고, 배포 전체가 빨라진다.


CORS — 포트가 달라지면 왜 막히나

서브도메인에서 포트 번호 방식으로 바꾸면서 예상하지 못한 문제가 하나 더 있었다. CORS(Cross-Origin Resource Sharing)다. 브라우저에는 보안 정책이 있어서, 현재 페이지의 Origin과 다른 Origin으로 요청을 보내면 기본적으로 차단한다.

여기서 Origin의 정의가 중요하다. Origin은 프로토콜 + 도메인 + 포트의 조합이다. 이 셋 중 하나라도 다르면 브라우저는 "다른 출처"로 인식한다. 그래서 https://example.ac.kr(기본 443 포트)에서 https://example.ac.kr:8080으로 API 요청을 보내면, 도메인은 같지만 포트가 다르기 때문에 Cross-Origin 요청이 된다.

브라우저가 CORS 요청을 처리하는 방식은 이렇다. 실제 요청을 보내기 전에 Preflight라는 사전 요청을 먼저 보낸다. HTTP 메서드가 OPTIONS인 요청인데, 이걸 통해 "나 이 Origin에서 요청 보내려고 하는데, 허용해줄 거야?"라고 서버에 물어보는 것이다. 서버가 응답 헤더에 Access-Control-Allow-Origin을 포함해서 허용한다고 알려주면 그때 실제 요청이 나간다. 허용 헤더가 없으면 브라우저가 실제 요청 자체를 차단해버린다.

그래서 백엔드에서 프론트엔드의 Origin을 명시적으로 허용해줘야 한다. NestJS 기준으로 이렇게 설정한다.

ts
// NestJS 예시
app.enableCors({
  origin: [
    "https://example.ac.kr", // 메인 프론트 (80/443)
    "https://example.ac.kr:8081", // Admin 포트
  ],
  credentials: true,
});

origin 배열에 허용할 Origin을 나열한다. 프론트엔드가 접속하는 주소와 Admin 페이지 주소를 넣어둔 것이다. credentials: true는 쿠키나 인증 정보가 포함된 요청도 허용하겠다는 의미다. 이 설정이 없으면 로그인 세션 같은 것들이 제대로 동작하지 않는다.

개발 중에 확인하는 법

CORS 문제는 개발 중에도 자주 마주친다. 확인하는 방법은 간단하다. 브라우저에서 F12를 눌러 개발자 도구를 열고, Network 탭에서 API 요청을 클릭한다. Status가 CORS error이거나, OPTIONS 요청이 빨간색으로 표시되면 CORS 문제다. Response Headers에서 Access-Control-Allow-Origin 값이 내 Origin과 일치하는지 확인하면 원인을 바로 찾을 수 있다.

개발 환경에서는 매번 백엔드 CORS 설정을 바꾸는 것보다, Vite의 proxy 기능으로 우회하는 게 편하다. vite.config.ts에서 server.proxy를 설정하면, 개발 서버가 중간에서 요청을 대신 전달해준다.

ts
// vite.config.ts — 개발 중에만 동작하는 proxy
export default defineConfig({
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

이 설정이 있으면 프론트엔드 코드에서 /api/xxx로 요청을 보냈을 때, Vite 개발 서버가 그 요청을 http://localhost:8080/xxx로 포워딩한다. 브라우저 입장에서는 같은 Origin(개발 서버)으로 요청을 보내는 것이라 CORS가 발생하지 않는다. 중요한 건 이 proxy 설정은 개발 서버에서만 동작한다는 점이다. 빌드된 정적 파일을 Nginx로 서빙하는 프로덕션 환경에서는 이 설정이 적용되지 않으므로, 백엔드의 CORS 설정은 반드시 필요하다.


마무리

교수님 메일의 제약 조건들이 처음에는 불편하게만 느껴졌다. 서브도메인 안 되고, docker-compose 안 되고, 환경변수도 줄이라니. 돌이켜보면 그 제약 덕분에 평소에 그냥 넘겼을 개념들을 하나씩 파고들게 됐다.

docker-compose가 없으니까 포트 포워딩이 정확히 뭘 하는 건지 알아야 했고, Caddy가 빠지니까 리버스 프록시의 역할을 이해해야 했고, 포트가 달라지니까 CORS를 직접 다뤄야 했다. 편한 도구가 가려주던 것들을 하나씩 마주한 셈이다.

팀장 입장에서 이 과정을 정리해두지 않으면 팀원한테 설명을 할 수 없다. 이 글은 그 설명을 위한 정리 기록이다.

포스트 목록

/kd-team/graduate
파일 3개, 폴더 0개
도커 배포, 한 번 더 뒤집었다 — compose와 내부 네트워크로도커 배포, 교수님 메일 한 통으로 파고든 개념들졸업 논문 제출 사이트, 4개월 반의 기록
.
.
.
VITE_AUTH_API_URL
||
"https://example.ac.kr:8082"
;
export const API_ADMIN_URL =
import.meta.env.VITE_ADMIN_API_URL || "https://example.ac.kr:8081";