서버 상태를 확인하다가 멈칫했다.

:443  → 설정 불완전 (400)
:3000 → econipass-frontend
:8000 → econipass-backend
:8002 → contents-hub
:8003 → oikos-finance
:8080 → econipass-keycloak

포트 목록이 어딘가 이상했다. :3000이 혼자 8천 번대 밖에 있었고, :8000은 너무 범용적이었으며, :8080은 Keycloak이 점령하고 있었다. 그리고 무엇보다 — 어떤 포트가 어느 서비스인지 기억해내야 했다.

이건 포트 문제가 아니었다. 규칙이 없다는 문제였다.


근본 원인: 포트는 임시 방편이었다

서비스가 하나일 때는 포트 번호가 중요하지 않다. :3000이면 프론트엔드고, :8000이면 백엔드다. 직관적이다.

문제는 서비스가 늘면서 발생한다. 두 번째 서비스가 :8002를 쓰고, 세 번째는 :8003을 쓴다. 네 번째가 들어올 때쯤이면 이미 번호들이 의미 없어진다. 새 팀원이 보면 왜 :8002고 왜 :8003인지 알 수가 없다.

그리고 결정적으로 — 포트를 직접 외부에 노출하고 있었다. elon.tpm.querypie.io:8002 같은 URL은 깔끔하지 않다. 포트를 암기해야 한다는 것 자체가 설계 문제의 신호다.


해결책: 포트는 내부로, 경로는 의미 있게

목표는 단순했다.

현재 이후
http://elon.tpm.querypie.io:8002 https://elon.tpm.querypie.io/contents-hub
http://elon.tpm.querypie.io:3000 https://elon.tpm.querypie.io/econipass
http://elon.tpm.querypie.io:8003 https://elon.tpm.querypie.io/oikos/finance

외부로는 :80/:443만 노출하고, 내부적으로는 서비스별 10단위 그룹으로 정리한다. 서비스 이름만 봐도 포트 범위를 알 수 있게.

econipass  →  81xx (frontend: 8100, backend: 8110, keycloak: 8120)
contents   →  82xx (hub: 8200)
oikos      →  83xx (finance: 8300)

처음 이 규칙을 잡았을 때의 느낌 — "이제 새 서비스가 추가되면 84xx를 쓰면 되겠구나"라는 생각이 자연스럽게 들었다. 좋은 규칙은 다음 결정을 쉽게 만든다.


아키텍처: 세 계층의 역할 분리

클라이언트
    │ HTTPS (:443)
    ▼
Tencent CLB  ← SSL 종료
    │ HTTP (:80)
    ▼
VM nginx
    ├── /contents-hub/*   → localhost:8200
    ├── /econipass/api/*  → localhost:8110
    ├── /econipass/auth/* → localhost:8120
    ├── /econipass/*      → localhost:8100
    └── /oikos/finance/*  → localhost:8300

CLB가 HTTPS를 처리하고, nginx가 경로 기반으로 라우팅하고, 각 서비스는 내부 포트에서만 응답한다. 각 계층이 딱 하나의 역할만 한다.


실행 계획: Phase별 순서가 중요했다

이 작업에서 까다로운 건 순서였다. 잘못된 순서로 진행하면 서비스가 중단된다.

Phase 내용 소요시간 다운타임
0 SSL 인증서 갱신 10분 없음
1 죽은 컨테이너 정리 10분 없음
2 포트 재편 30분 서비스별 30초
3 CORS + 환경변수 수정 1~2시간 없음
4 프론트엔드 재빌드 1~2시간 없음
5 nginx 설치 및 설정 20분 없음
6 CLB :80 전환 10분 없음
7 구 포트 CLB 제거 10분 없음

nginx 설정(Phase 5)이 CLB 전환(Phase 6)보다 먼저다. nginx 없이 CLB를 :80으로 바꾸면 그 순간 모든 서비스가 죽는다. 코드 수정(Phase 3)이 포트 재편(Phase 2)보다 나중인 이유도 같다 — CORS를 먼저 열어두면 기존 포트에서도 작동한다.


Phase 0. SSL 인증서 갱신 — 시작하기 전에

인증서가 2025-12-22에 만료된 상태였다. 모든 작업의 전제조건이다.

Tencent Cloud SSL 콘솔에서 새 DV 인증서 발급 (무료), CLB :443 리스너에 교체. 확인:

curl -I https://elon.tpm.querypie.io

Phase 1. 서버 정리 — 쓰레기부터

오래된 컨테이너들이 docker ps -a에 쌓여 있었다. 정리부터.

docker rm \
  contents_hub_db contents_hub_redis product-info-extractor \
  querypie-app-1 querypie-mysql-1 \
  grafana prometheus procstat mongodb-mongo-1

Phase 2. 포트 재편 — 각 서비스 docker-compose 수정

각 서비스의 docker-compose.prod.yml에서 호스트 포트만 바꾸고 재시작.

econipass (/root/econipass/deploy/docker-compose.prod.yml):

frontend:  "3000:80"   → "8100:80"
backend:   "8000:8000" → "8110:8000"
keycloak:  "8080:8080" → "8120:8080"

contents-hub (/root/contents_hub/backend/docker-compose.prod.yml):

backend: "8002:8002" → "8200:8002"

oikos-finance (/root/oikos_finance/docker-compose.prod.yml):

backend: "8003:8000" → "8300:8000"

재시작 후 헬스체크:

curl http://localhost:8100/            # econipass-frontend
curl http://localhost:8110/healthcheck # econipass-backend
curl http://localhost:8120/            # keycloak
curl http://localhost:8200/health      # contents-hub
curl http://localhost:8300/health      # oikos-finance

Phase 3. 코드 수정 — CORS와 환경변수

이 단계가 가장 손이 많이 갔다. 각 서비스마다 CORS 허용 출처에 새 HTTPS 도메인을 추가하고, 환경변수를 업데이트해야 했다.

contents-hub:

# backend/app/main.py
app.add_middleware(CORSMiddleware,
    allow_origins=[
        settings.frontend_url,
        "https://elon.tpm.querypie.io",  # 추가
    ])

app = FastAPI(root_path="/contents-hub")  # Swagger UI용
# VM .env
FRONTEND_URL=https://elon.tpm.querypie.io

# 로컬 .mcp.json
CONTENTS_HUB_URL=https://elon.tpm.querypie.io/contents-hub

oikos-finance:

app = FastAPI(root_path="/oikos/finance")
# instance/.env
CORS_ORIGINS=...,https://elon.tpm.querypie.io

econipass-backend:

allow_origins=[EnvironmentVariables.FRONT_URL, "https://elon.tpm.querypie.io", ...]
# .env
FRONT_URL=https://elon.tpm.querypie.io

Keycloak — Redirect URI에 추가:

https://elon.tpm.querypie.io/*
https://elon.tpm.querypie.io/econipass/*

Phase 4. 프론트엔드 재빌드 — PUBLIC_URL이 핵심

React 앱을 서브경로(/econipass)에서 서빙하려면 빌드 시점에 경로를 알아야 한다.

# .env.production
REACT_APP_HOST_URL=https://elon.tpm.querypie.io/econipass/api
PUBLIC_URL=/econipass
PUBLIC_URL=/econipass \
REACT_APP_HOST_URL=https://elon.tpm.querypie.io/econipass/api \
npm run build

PUBLIC_URL을 설정하지 않으면 정적 파일 경로가 /를 기준으로 생성되어 서브경로에서 깨진다. 이 부분이 가장 흔한 실수다.


Phase 5. nginx 설정 — 라우팅의 핵심

/etc/nginx/conf.d/services.conf:

server {
    listen 80;
    server_name elon.tpm.querypie.io;

    # CLB 헬스체크 (CLB는 HTTP로 체크)
    location /health {
        return 200 'ok';
        add_header Content-Type text/plain;
    }

    # CLB가 X-Forwarded-Proto: http를 붙이면 HTTPS로 리다이렉트
    if ($http_x_forwarded_proto = "http") {
        return 301 https://$host$request_uri;
    }

    location /contents-hub/ {
        proxy_pass http://localhost:8200/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    }

    # /econipass/api/는 /econipass/보다 먼저 선언 (nginx는 longest match 우선)
    location /econipass/api/ {
        proxy_pass http://localhost:8110/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    }

    location /econipass/auth/ {
        proxy_pass http://localhost:8120/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    }

    location /econipass/ {
        proxy_pass http://localhost:8100/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    }

    location /oikos/finance/ {
        proxy_pass http://localhost:8300/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    }
}
nginx -t && systemctl enable nginx && systemctl start nginx

한 가지 주의할 점: nginx는 location 블록에서 /econipass/api//econipass/보다 먼저 선언해야 제대로 동작한다. 순서가 틀리면 /econipass/api/ 요청이 프론트엔드 컨테이너로 가버린다.


Phase 6 & 7. CLB 전환, 그리고 마무리

CLB에 :80 → VM:80 리스너를 추가하고 검증:

curl -I https://elon.tpm.querypie.io/contents-hub/health
curl -I https://elon.tpm.querypie.io/econipass/
curl -I https://elon.tpm.querypie.io/oikos/finance/health

모두 200이 오면, 마지막으로 CLB에서 구 포트 리스너 제거: :3000, :8000, :8002, :8003, :8080.


작업 후 달라진 것

외부에서 보이는 포트는 이제 :80:443뿐이다. URL에서 포트 번호가 사라졌다. 서비스가 무엇인지는 경로가 말해준다.

내부적으로는 81xx, 82xx, 83xx 그룹이 생겼다. 새 서비스가 추가되면 다음 10단위 그룹을 쓰면 된다. 기억할 필요도, 물어볼 필요도 없다.

처음에는 단순히 "포트 번호를 정리하자"는 생각이었다. 끝나고 나니 HTTPS 전환, nginx 도입, 경로 기반 라우팅까지 한 번에 해결됐다. 규칙이 생기면 연쇄적으로 좋은 결정들이 따라온다.