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

Contact Me

© 2026 SEOJing. All rights reserved.

KD TeamDevLogDockerNginxNetwork

도커 배포, 한 번 더 뒤집었다 — compose와 내부 네트워크로

2026년 4월 23일·16분 읽기

또 한 통의 피드백

이전 글에서 교수님의 제약 조건에 맞춰 배포 구조를 잡았다. 서브도메인이 막혀 있으니 포트 번호로 서비스를 구분하고, docker-compose가 없으니 개별 docker run으로 돌리고, 환경변수도 빌드 시점에 fallback으로 박아두는 방식이었다.

그렇게 한 번 배포를 끝낸 뒤에 교수님께서 추가 피드백을 주셨다. 요약하면 두 가지였다.

  1. docker-compose 써도 된다
  2. 도커 내부 네트워크를 적극적으로 활용해라

처음에는 약간 허탈했다. 바로 전에 compose를 빼는 방향으로 구조를 갈아엎었는데, 이번에는 다시 compose로 가도 된다니. 그런데 다시 보니 이건 단순히 compose를 써도 된다는 허가의 문제가 아니었다. 1차 방식이 남긴 찜찜함 — 포트 세 개를 외부에 그대로 열어둔 구조, 프론트 코드에 박힌 example.ac.kr:8080 같은 고정 주소, 포트가 달라서 생기는 CORS — 이걸 전부 걷어낼 수 있는 길이었다.

그래서 다시 갈아엎기로 했다. 이 글은 그 2차 재설계 기록이다.


1차 방식을 오해하지 않기

재설계에 들어가기 전에 먼저 짚고 넘어가야 할 게 있다. 1차에서 Caddy(리버스 프록시)를 제거한 이유를 잘못 기억하고 있으면, 이번 설계에서도 같은 실수를 반복하게 된다.

리버스 프록시가 금지됐던 게 아니다. 학교가 서브도메인을 차단했기 때문에, 리버스 프록시가 요청을 구분할 근거(호스트명)가 사라졌던 것이다. api.example.ac.kr과 auth.example.ac.kr 처럼 도메인이 다르면 Caddy가 호스트 헤더를 보고 "이건 API로, 이건 Auth로"라고 나눠줄 수 있는데, 전부 example.ac.kr로 들어오면 나눌 기준 자체가 없었다.

즉 당시의 결정은 "리버스 프록시가 필요 없다"가 아니라 " 호스트 기반 으로 구분하는 리버스 프록시가 필요 없다"에 가까웠다. 다른 구분 기준이 생긴다면, 리버스 프록시는 얼마든지 다시 쓸 수 있는 도구다. 이번 설계의 핵심이 바로 여기에 있다. 호스트 대신 경로(path)를 구분 기준으로 삼는 것이다.

/api/auth/...로 들어오면 Auth 서버로, /api/admin/...으로 들어오면 Admin 서버로, 나머지는 프론트엔드 정적 파일로. 이렇게 경로로 나누면 호스트가 하나여도 라우팅이 가능해진다. 1차에서 잠시 치워뒀던 리버스 프록시가 다시 돌아올 자리가 여기다.


docker-compose와 내부 네트워크

1차에서 개별 docker run으로 컨테이너를 하나씩 띄우던 구조는, 사실 각 컨테이너가 외부 포트를 통해서만 서로를 인식할 수 있었다. 프론트 코드가 Auth 서버를 부르려면 https://example.ac.kr:8082라는 외부 주소를 알아야 했다. 컨테이너끼리 가까이 있는데도, 굳이 호스트 포트를 거쳐서 서로를 부르는 이상한 구조였다.

docker-compose가 열어주는 건 사용자 정의 브릿지 네트워크다. compose 파일에서 네트워크를 하나 선언하고 서비스들을 그 안에 넣으면, 도커가 내부 DNS를 세팅해준다. 서비스 이름을 호스트명처럼 쓸 수 있게 되는 것이다. aics-auth라는 서비스는 같은 네트워크 안의 다른 컨테이너에서 http://aics-auth:8080으로 부를 수 있다. 외부 포트가 아니라 컨테이너 내부 포트를 바로 찌르는 것이다.

yaml
# docker-compose.yml — 뼈대만 추린 예시
services:
  aics-auth:
    image: your-registry/aics-auth:latest
    restart: unless-stopped
    networks:
      - aics-network

  aics-admin:
    image: your-registry/aics-admin:latest
    restart: unless-stopped
    networks:
      - aics-network

  aics-graduate:
    image: your-registry/aics-graduate:latest
    restart: unless-stopped
    networks:
      - aics-network

  aics-client

눈여겨볼 건 백엔드 서비스들(aics-auth, aics-admin, aics-graduate)에 ports 설정이 없다는 점이다. 1차에서는 8080:8080, 8081:8081, 8082:8082를 전부 호스트로 뚫어놨었다. 이번엔 아니다. 이 서비스들은 aics-network 내부에서만 접근 가능하다. 외부에서 example.ac.kr:8080으로 찔러봐도 닫혀 있다.

외부로 열린 포트를 가진 건 aics-client(프론트엔드) 하나뿐이다. 이게 이번 구조의 핵심이다. 요청이 들어오는 입구는 단 하나로 좁히고, 그 뒤는 전부 내부 네트워크로 처리한다.


그런데, 요청의 출발지는 결국 브라우저다

여기서 한 번 막혔다. "좋아, 컨테이너끼리는 서비스 이름으로 부를 수 있게 됐다"까지는 됐는데, 정작 우리 프론트엔드는 브라우저에서 돌아간다는 점을 잠깐 놓쳤다.

React 앱이 빌드되어 사용자 브라우저에서 실행될 때, 그 코드 안에서 fetch("http://aics-auth:8080/login")이라고 쓰면 어떻게 될까. 당연히 동작하지 않는다. 브라우저는 학교 PC에서, 카페 노트북에서, 휴대폰에서 돌아가는 것이고, 그 클라이언트들은 aics-auth라는 이름이 뭔지 모른다. 그 이름은 도커 내부 네트워크의 DNS에만 존재한다. 바깥에서는 풀리지 않는 주소다.

다시 말해

compose의 내부 네트워크는 "컨테이너끼리" 통하는 길이지, "브라우저와 컨테이너"를 이어주는 길이 아니다.

이걸 명확히 구분하지 않으면, 컴포즈 파일만 잘 짜두면 모든 게 해결될 것 같은 착각에 빠지기 쉽다.

필요한 건 브라우저 ↔ 내부 네트워크를 이어줄 게이트웨이다. 외부에서 들어온 요청을 받아서 적절한 내부 서비스로 넘겨주는 존재. 1차 초반에 잠시 치워뒀던 리버스 프록시가 다시 필요해지는 지점이 정확히 여기다.


프론트 Nginx를 게이트웨이로

프론트엔드 이미지는 이미 Nginx를 베이스로 깔려 있었다. 1차에서는 이 Nginx를 순수한 웹서버(정적 파일 서빙) 용도로만 썼다. 이번엔 역할을 하나 더 얹는다. 같은 Nginx에서 리버스 프록시 설정을 함께 돌려서, 웹서버와 게이트웨이를 겸하게 하는 것이다.

nginx
# apps/client/nginx.conf — 뼈대 예시
server {
  listen 80;
  server_name _;
  root /usr/share/nginx/html;

  # 정적 파일 서빙 (웹서버 역할)
  location / {
    try_files $uri $uri/ /index.html;
  }

  # /api/auth → aics-auth 컨테이너로 (리버스 프록시 역할)
  location /api/auth/ {
    proxy_pass http://aics-auth:8080/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }

  location /api/admin/ {
    proxy_pass http://aics-admin:8080/;
    proxy_set_header Host 

여기서 proxy_pass의 주소가 http://aics-auth:8080인 점이 핵심이다. Nginx가 동작하는 컨테이너(aics-client)가 같은 aics-network 안에 있기 때문에, 이 서비스 이름이 DNS로 풀린다. 브라우저가 풀 수 없는 주소를, 게이트웨이가 대신 풀어주는 구조다.

프론트엔드 코드의 API 호출도 같이 바뀐다. 1차에서 fallback으로 박아뒀던 외부 절대 주소가 사라지고, 상대 경로만 남는다.

ts
// 1차 — 외부 포트를 직접 때리는 고정 주소
export const API_URL =
  import.meta.env.VITE_API_URL || "https://example.ac.kr:8080";
export const API_AUTH_URL =
  import.meta.env.VITE_AUTH_API_URL || "https://example.ac.kr:8082";

// 2차 — 같은 Origin의 상대 경로
export const API_URL = "/api/graduate";
export const API_AUTH_URL = "/api/auth";
export const API_ADMIN_URL = "/api/admin";

브라우저는 /api/auth/login으로 요청을 보낸다. example.ac.kr의 80 포트로 들어가고, 그 입구에 있는 Nginx가 받아서 http://aics-auth:8080/login으로 넘긴다. 응답은 역순으로 돌아온다. 브라우저 입장에서는 자기가 부른 적 없는 aics-auth 같은 이름은 존재조차 모른다.


부수 효과들 — 저절로 풀리는 1차의 숙제

이 구조로 바꾸고 나서 눈치 챈 건, 1차에서 꾸역꾸역 메웠던 자리들이 자연스럽게 사라진다는 점이었다.

CORS가 사라진다

1차에서는 프론트가 https://example.ac.kr에 있고 API가 https://example.ac.kr:8080에 있어서, 포트가 달라 Cross-Origin 요청이 됐다. 백엔드마다 app.enableCors로 Origin을 열어줘야 했고, Preflight 요청 OPTIONS 때문에 네트워크 탭에서 요청이 두 번씩 찍혔다.

2차에서는 브라우저가 보내는 모든 요청의 Origin이 동일하다. https://example.ac.kr에서 /api/auth/login 으로 보내는 건 같은 Origin 내부의 요청이다. 프로토콜, 도메인, 포트가 전부 동일하니까. 브라우저는 이걸 Cross-Origin으로 취급하지 않는다. Preflight도, CORS 헤더 설정도 더는 필요 없다.

방화벽에서 포트 80만 열면 된다

1차 방식은 외부에서 8080, 8081, 8082 세 개의 포트를 전부 접근 가능한 상태로 두어야 했다. 학교 네트워크 담당자 입장에서도 보안적으로 깔끔하지 않은 그림이다. 백엔드 서비스가 외부에 그대로 노출되어 있으니까.

2차에서는 aics-client의 80 포트 하나만 외부로 열려 있다. 8080~8082는 compose 내부 네트워크에만 존재하고 호스트로 바인딩되지 않았기 때문에, 방화벽에서 전부 닫아도 서비스는 정상 동작한다. 공격 표면이 압도적으로 줄어든다.

Build-time 고정 주소 문제가 풀린다

1차 포스트에서 꽤 길게 다뤘던 주제다. Vite는 VITE_ 접두사가 붙은 환경변수를 빌드 시점에 코드에 직접 박아버리기 때문에, API URL이 바뀌면 이미지 재빌드가 필요하다고 했다. 그래서 fallback에 example.ac.kr:8080 같은 값을 박아둔 것이다.

2차에서는 애초에 도메인이 등장하지 않는다. /api/auth 같은 상대 경로만 쓰니까, 서비스가 도메인을 옮기든 포트를 바꾸든 프론트 코드는 그대로다. Build-time이냐 Runtime이냐 하는 문제 자체가 덜 중요해진다. 도메인 정보는 프론트 번들이 아니라 Nginx 설정에만 존재하고, Nginx 설정은 이미지 빌드 없이도 재시작만으로 바꿀 수 있다.


남은 고민들

개선된 지점이 많은 만큼, 새로 생긴 트레이드오프도 있다.

첫째, aics-client가 단일 장애점(SPOF)이 된다. 1차에서는 한 서비스가 죽어도 다른 포트의 서비스는 멀쩡히 살아 있었다. 2차에서는 프론트 Nginx가 죽으면 모든 API 요청이 같이 끊긴다. compose의 restart: unless-stopped와 헬스체크로 어느 정도 보완할 수는 있지만, 본질적으로 입구가 하나인 구조의 특성이다.

둘째, 디버깅할 때 한 레이어가 더 생긴다. 1차에서는 브라우저 Network 탭에서 :8080이 빨간색이면 백엔드를, :8081이 빨간색 이면 Admin을 바로 의심할 수 있었다. 2차에서는 모든 요청이 /api/...로 찍히니까, 문제의 원인이 Nginx의 proxy 설정인지, 컨테이너 내부 서비스인지, 네트워크 자체인지 한 번 더 분리해서 확인해야 한다. Nginx 로그와 백엔드 로그를 같이 봐야 하는 경우가 늘었다.

셋째, compose 파일과 Nginx 설정 사이의 정합성이 배포 대상이 된다. 서비스 이름을 aics-auth에서 바꾸려면 compose뿐 아니라 Nginx proxy_pass도 같이 바꿔야 한다. 1차 때는 이런 이름 일치 문제가 거의 없었다. 변경 포인트가 늘어난 셈이다.


1차 → 2차의 궤적

정리하면 이렇게 된다. 1차는 "서브도메인이 막혔다"는 제약 하나에 끌려다니면서, 필요한 걸 다 내놓고 남는 것만으로 버티는 구조였다. 리버스 프록시를 뺐고, 포트를 여러 개 외부에 노출했고, 프론트에 절대 주소를 박았다. 그게 당시 조건에서는 타당한 선택이었다.

2차는 "compose와 내부 네트워크를 써도 된다"는 조건이 하나 풀리면서, 1차에 내려놨던 도구들을 다시 집어들었다. 리버스 프록시는 경로 기반으로 다시 돌아왔고, 포트 노출은 80 하나로 좁혀졌고, 절대 주소는 상대 경로로 갈아엎였다. 같은 문제를 다른 수준의 도구로 풀었다.

한 줄로 정리하면, 1차는 제약에 맞게 구조를 단순화한 것이고, 2차는 제약이 풀린 자리에서 구조를 재조립한 것이다. 다음에 또 어떤 피드백이 와서 이걸 다시 엎게 될지는 모르겠지만, 최소한 왜 이렇게 돌아왔는지는 기록해둘 수 있게 됐다.

포스트 목록

/kd-team/graduate
파일 3개, 폴더 0개
도커 배포, 한 번 더 뒤집었다 — compose와 내부 네트워크로도커 배포, 교수님 메일 한 통으로 파고든 개념들졸업 논문 제출 사이트, 4개월 반의 기록
:
image: your-registry/aics-client:latest
ports:
- "80:80"
depends_on:
- aics-auth
- aics-admin
- aics-graduate
networks:
- aics-network
networks:
aics-network:
driver: bridge
$host
;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/graduate/ {
proxy_pass http://aics-graduate:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}