서버 상태를 확인하다가 멈칫했다.
: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 도입, 경로 기반 라우팅까지 한 번에 해결됐다. 규칙이 생기면 연쇄적으로 좋은 결정들이 따라온다.