Self-Hosting Lecture Note

Streamlit + FastAPI + PostgreSQL 풀스택을
서브도메인으로 — air.sielain.com 구축기

단일 HTML 프로토타입이던 "인천 1호선 초미세먼지 × 통행량 대시보드"를 3-티어 웹 서비스로 재구현하고, 맥미니 한 대에서 Docker Compose와 Cloudflare Tunnel(원격 관리형)로 공개하기까지의 전 과정 기록입니다.

Core Question
이미 다른 서비스(parts.sielain.com)가 쓰고 있는 터널 하나에,
완전히 독립된 멀티컨테이너 스택을 어떻게 얹는가?
Streamlit FastAPI PostgreSQL 16 Docker Compose Cloudflare Tunnel SQLAlchemy openpyxl ETL
1

무엇을 만들었나

air.sielain.com · 5-tab dashboard
서비스 요약

인천 1호선 33개 역의 초미세먼지(PM2.5) 월보역·시간대별 통행량 엑셀 원본을 DB에 적재해, 예측·교차분석·지도·리포트를 제공하는 대시보드입니다. 데이터는 웹에서 엑셀을 업로드하면 월 단위로 누적되고, 누적분이 예측 모델과 리포트에 그대로 활용됩니다.

기능
1. 추세·기간예측역/기간 선택 → 계절성 분해(요일×시간대) 또는 선형회귀로 향후 3~14일 PM2.5 예측. 기준월 선택 또는 전체(누적) 학습
2. 교차분석PM2.5 × 통행량의 하루 중 시간대 패턴 비교 + 역별 산점도·피어슨 상관계수
3. 지도역 위치에 원 크기=통행량, 색상=PM2.5 등급으로 표시 (folium)
4. 데이터 업로드·관리통행량/초미세먼지 엑셀 업로드 → 월 단위 누적 적재, 적재 현황 목록
5. 종합 리포트LLM 없이 DB 집계값을 고정 템플릿에 대입한 사실 요약 + 마크다운 다운로드
💡 이 강의의 전신인 parts.sielain.com 배포기가 "단일 Flask 앱 + SQLite 하나"를 올리는 이야기였다면, 이번에는 프론트·백엔드·DB가 분리된 3개 컨테이너 스택을 같은 터널에 추가하는 이야기입니다. 난이도가 한 단계 올라갑니다.

2

요청이 흐르는 길 — 인터랙티브 다이어그램

browser → edge → tunnel → containers

각 단계를 클릭하거나 ▶ 순서대로 재생을 눌러 요청의 전체 여정을 따라가 보세요.

단계를 선택하세요

브라우저에서 air.sielain.com을 입력한 순간부터 PostgreSQL까지, 요청이 통과하는 7개 지점을 살펴봅니다.

🔑 핵심: 서버에 인바운드 포트가 하나도 열려 있지 않습니다. cloudflared가 Cloudflare 엣지로 아웃바운드 연결을 먼저 걸어두고, 방문자 요청이 그 연결을 타고 거꾸로 들어옵니다. 공유기 포트포워딩·공인 IP·인증서 발급이 전부 불필요한 이유입니다.

3

단일 HTML → 3-티어로 나눈 이유

Streamlit(view) · FastAPI(logic) · PostgreSQL(data)

프로토타입은 데이터(JSON)와 분석 로직(JS)과 화면(HTML)이 한 파일 안에 있었습니다. 잘 돌아가는데 왜 쪼갰을까요? 아래 비교 탭을 눌러 보세요.

  • 데이터 갱신 = 파일 재생성: 새 달 통행량이 나오면 HTML 안의 JSON을 통째로 다시 만들어야 함
  • 누적 불가: 과거 달과 새 달을 함께 보관·비교할 저장소가 없음
  • 로직 재사용 불가: 상관계수·예측 로직이 브라우저 JS에 갇혀 있어 다른 도구에서 호출 불가
  • 용량 한계: 데이터가 커질수록 HTML이 무거워짐 (이미 390KB)
  • PostgreSQL이 데이터의 단일 원본 — 엑셀을 업로드하면 월 단위로 누적, 백업은 pg_dump 한 줄
  • FastAPI가 분석 로직을 REST API로 제공 — /docs에서 Swagger 문서 자동 생성, Streamlit이 아니어도 누구나 호출 가능
  • Streamlit은 화면만 담당 — 파이썬 스크립트 하나로 위젯·차트·지도 UI가 완성되고, 위젯 조작 시 스크립트가 재실행되며 항상 최신 DB를 반영
  • 각 층을 독립적으로 교체 가능 — 예: 프론트만 React로 바꿔도 백엔드·DB는 그대로
기술맡은 일맡지 않는 일
프론트Streamlit위젯·차트·지도 렌더링, 파일 업로더 UI계산·저장 (전부 API에 위임)
백엔드FastAPI + SQLAlchemy예측(계절성분해/선형회귀), 상관계수, 엑셀 파싱, 집계 리포트화면 그리기
데이터PostgreSQL 16stations / pm25_hourly / traffic_hourly 3개 테이블, 유니크 제약으로 중복 방지비즈니스 로직
✅ 분석 로직은 프로토타입 JS를 Python으로 그대로 포팅했고, 포팅 후 상관계수 값이 프로토타입과 일치하는지 대조해 검증했습니다. "재구현했더니 숫자가 달라졌다"를 막는 가장 확실한 방법입니다.

4

Docker Compose — 스택은 독립, 네트워크는 공유

external network · healthcheck · depends_on

이 프로젝트의 결정적 설계 포인트는 compose 스택은 완전히 독립시키되, 프론트 컨테이너만 기존 터널의 네트워크에 추가로 연결한 것입니다.

# incheon_air_traffic_dashboard/docker-compose.yml (요약)
services:
  postgres:
    image: postgres:16-alpine
    healthcheck:                      # DB가 "진짜 준비될 때까지" 대기
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
    networks: [air_net]

  backend:
    build: ./backend
    depends_on:
      postgres: { condition: service_healthy }   # healthcheck 통과 후 기동
    volumes:
      - ./data:/data                  # 엑셀 원본 + 업로드 파일 보관 (rw)
    networks: [air_net]

  frontend:
    build: ./frontend
    container_name: air_traffic_frontend
    networks:
      - air_net                       # 내부 통신용 (backend, postgres)
      - sielain_net                   # ★ 기존 터널과의 공유 네트워크

networks:
  air_net: { driver: bridge }
  sielain_net:
    external: true                    # ★ 새로 만들지 않고 기존 것을 참조
    name: docker_sielain_net
왜 external network인가

cloudflared 컨테이너(sielain_tunnel)는 다른 compose 프로젝트 (/Users/sielain/docker)에 속해 있습니다. 별개 프로젝트의 컨테이너끼리는 기본적으로 서로를 볼 수 없지만, 같은 네트워크에 속하면 컨테이너 이름이 곧 DNS 호스트명이 됩니다. 터널은 http://air_traffic_frontend:8501로 프론트에 직접 도달합니다.

포트 설계

호스트에 노출한 8501(프론트)·8010(백엔드)·5433(DB)은 전부 로컬 디버깅용입니다. 외부 트래픽은 공유 네트워크의 내부 DNS로만 흐르므로, 원하면 ports를 전부 지워도 서비스는 동작합니다. DB 포트를 5433으로 둔 것은 기존 스택의 5432와의 습관적 충돌을 피하기 위한 관례입니다.

depends_on만으로는 부족합니다. 컨테이너가 "떴다"와 DB가 "접속을 받는다"는 다른 사건입니다. condition: service_healthy + pg_isready healthcheck 조합이어야 백엔드가 연결 오류 없이 기동합니다.

5

Cloudflare Tunnel — 원격 관리형의 규칙

TUNNEL_TOKEN · Published application routes

cloudflared 터널은 설정 방식이 두 갈래이고, 어느 쪽인지에 따라 라우팅 추가 방법이 완전히 다릅니다. parts 배포 때 한 번 밟았던 함정을 이번에는 설계 단계에서 피했습니다.

TUNNEL_TOKEN 환경변수로 기동. 모든 ingress 규칙을 Cloudflare 대시보드가 관리하고 터널에 실시간 내려보냅니다.

cloudflared:
  image: cloudflare/cloudflared:latest
  command: tunnel --no-autoupdate --protocol http2 run
  environment:
    - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}   # 이것만 있으면 끝
  • 라우트 추가: Zero Trust → Networks → Tunnels → 터널 선택 → Published application routes
  • 등록 즉시 DNS CNAME 자동 생성 · 터널 재시작 불필요
  • 이번 등록값: Subdomain air / URL http://air_traffic_frontend:8501

config.yml credentials 파일로 기동하고 ingress 규칙을 로컬 YAML로 관리하는 고전 방식입니다.

ingress:
  - hostname: air.sielain.com
    service: http://air_traffic_frontend:8501
  - hostname: parts.sielain.com
    service: http://parts-app:8000
  - service: http_status:404        # catch-all은 반드시 마지막

규칙을 바꿀 때마다 파일 수정 + 터널 재시작이 필요하고, DNS 연결도 cloudflared tunnel route dns를 따로 실행해야 합니다.

🚨 가장 중요한 함정: TUNNEL_TOKEN으로 기동한 터널은 config.yml을 마운트해도 ingress가 조용히 무시됩니다. 에러도 없습니다. "설정을 고쳤는데 왜 안 바뀌지?"의 정체가 대부분 이것입니다. 내 터널이 어느 방식인지부터 확인하세요 — 기동 명령에 토큰이 있으면 원격 관리형입니다.
2026-07 신규 대시보드 UI에서 달라진 점

6

데이터 파이프라인 — 엑셀 업로드가 월 단위로 누적되기까지

openpyxl · UploadFile · period_label

공공기관 원본 엑셀은 사람이 보기 좋은 양식(병합 셀, 합계 행)이라 그대로 DB에 넣을 수 없습니다. ETL이 하는 일과, "누적"이 어떻게 구현되는지를 봅니다.

업로드 → 적재 흐름
# FastAPI 업로드 엔드포인트의 골격
@router.post("/upload/traffic")
def upload_traffic(period: str = Form(...), file: UploadFile = File(...)):
    # 1) 검증: YYYY-MM 형식, .xlsx 확장자
    # 2) 원본을 /data/uploads/ 에 보관 (재현 가능성)
    # 3) openpyxl로 '1호선 ' 시트 파싱 → {역명: {승차:[20], 하차:[20]}}
    # 4) 같은 period_label 레코드만 DELETE → INSERT  ← "월 단위 교체"의 핵심
    db.query(TrafficHourly).filter(
        TrafficHourly.period_label == period).delete()
    # 5) commit → 이후 모든 탭이 즉시 새 데이터 사용
누적을 지탱하는 스키마

통행량 테이블의 유니크 제약에 기준월(period_label)이 포함되어 같은 역·방향·시간대라도 달이 다르면 공존합니다. PM2.5는 타임스탬프 자체에 연·월이 있어 (station_id, ts) 제약만으로 자연 누적됩니다.

UniqueConstraint("station_id", "direction",
                 "hour_index", "period_label")
누적분이 예측에 쓰이는 방식

탭1의 계절성 분해는 records의 실제 타임스탬프에서 요일·시간대를 읽으므로 몇 달치든 그대로 학습 구간으로 확장됩니다. "PM2.5 기준월 = 전체(누적)"를 선택하면 업로드된 모든 월이 요일×시간대 패턴 추정에 들어가 표본이 커지고 패턴이 안정됩니다.

파싱 함정 실사례: 통행량 엑셀 마지막의 "1호선 계" 행(노선 전체 합계)을 걸러내지 않으면, 병합 셀 전개 과정에서 마지막 역(송도달빛축제공원)의 하차값이 노선 전체 합계로 덮어써지는 버그가 생깁니다. ETL은 이 행을 제외하고, 적재 직전에 "마지막 역 하차 합계가 100만 명 미만인지" assert로 이중 방어합니다.

7

종합 리포트 — LLM 없이 "문장"을 만드는 법

aggregation → fixed template

탭5의 리포트는 그럴듯한 서술문이지만 언어 모델을 전혀 쓰지 않습니다. SQL/파이썬 집계값을 고정 템플릿 문자열에 대입할 뿐이라, 같은 데이터면 항상 같은 문장이 나오고 환각(hallucination)이 원천적으로 불가능합니다.

집계값 → 템플릿 문장
# 집계 (사실)
overall_avg = 28.8   # 전체 PM2.5 평균
top = {"station": "박촌", "avg": 47.9}

# 템플릿 (형식) — f-string에 값을 끼워 넣을 뿐
f"PM2.5 전체 평균은 {overall_avg}㎍/㎥이며, 역별 평균이 가장 높은 곳은 "
f"{top['station']}({top['avg']}㎍/㎥)입니다."

상관계수의 "약한/뚜렷한" 같은 해석 표현도 |r| 값의 고정 구간표 (0.2 미만=거의 없음, 0.4 미만=약함, …) 분류이지 판단이 아닙니다. 리포트 하단에 "인과관계를 의미하지 않습니다"를 항상 함께 출력합니다.

✅ 이 방식의 위치: 정확성이 절대적인 정기 보고·현황 요약에는 템플릿 리포트가, 자유 질의응답에는 LLM이 맞습니다. "AI 리포트"가 필요해 보일 때, 먼저 "이건 집계+템플릿으로 충분하지 않은가?"를 물어보는 것이 비용·신뢰성 모두에서 이득입니다.

8

실제로 막혔던 지점들

troubleshooting log
config.yml에 ingress를 적었는데 반영되지 않음

원인: 터널이 TUNNEL_TOKEN(원격 관리형)으로 기동 중 — 로컬 config.yml의 ingress는 무시됩니다(에러도 없음).
해결: Cloudflare 대시보드 "Published application routes"에서 등록. 로컬 파일은 문서용 주석으로만 유지.

대시보드에서 Service URL이 "Invalid service URL format"으로 거절됨

원인: 신규 UI는 Type 드롭다운 대신 URL에 프로토콜을 요구 — air_traffic_frontend:8501은 형식 오류.
해결: http://air_traffic_frontend:8501 로 입력 (내부 통신이므로 https 아님).

FastAPI가 기동 직후 죽음 — "Form data requires python-multipart"

원인: 파일 업로드(Form/UploadFile)를 쓰는 순간 FastAPI는 python-multipart 패키지를 요구하는데 requirements.txt에 없었음.
해결: python-multipart==0.0.20 추가 후 이미지 재빌드. 업로드 기능을 넣을 때 빠뜨리기 쉬운 단골 의존성입니다.

두 달치 통행량을 넣었더니 그래프 시간대 축이 40칸으로 깨짐

원인: 조회 쿼리가 기준월 필터 없이 전 레코드를 이어 붙여, 20개여야 할 시간대 배열이 달 수만큼 늘어남.
해결: 조회 함수에 period 파라미터를 추가하고 미지정 시 최신 월로 한정하는 기본값을 둠. "누적 저장"과 "조회 시 한 달로 한정"은 별개의 문제라는 교훈.

업로드 기능을 공개 도메인에 노출해도 되는가

위험: 탭4 업로드는 방문자 누구나 사용 가능. parts 대시보드에서는 크롤러가 GET 삭제 링크를 순회해 데이터 150건이 전량 삭제된 사고가 실제로 있었습니다.
대책: 변경 성격의 경로는 Cloudflare Access(이메일 OTP)로 보호 — parts는 /add, /delete/*에 적용해 조회는 공개, 변경만 인증으로 분리했습니다. air도 같은 패턴 적용을 권장.


9

이해도 점검 퀴즈

4 questions

Q1. TUNNEL_TOKEN 방식(원격 관리형) 터널에서 air.sielain.com 라우팅을 추가하는 올바른 방법은?

Q2. 별개의 compose 프로젝트인 cloudflared 컨테이너가 air_traffic_frontend 컨테이너에 접근할 수 있는 이유는?

Q3. "같은 달을 다시 업로드하면 그 달만 교체되고 다른 달은 유지"를 구현하는 핵심은?

Q4. 탭5 종합 리포트가 LLM 없이도 "문장"을 출력할 수 있는 이유는?