단일 HTML 프로토타입이던 "인천 1호선 초미세먼지 × 통행량 대시보드"를 3-티어 웹 서비스로 재구현하고, 맥미니 한 대에서 Docker Compose와 Cloudflare Tunnel(원격 관리형)로 공개하기까지의 전 과정 기록입니다.
인천 1호선 33개 역의 초미세먼지(PM2.5) 월보와 역·시간대별 통행량 엑셀 원본을 DB에 적재해, 예측·교차분석·지도·리포트를 제공하는 대시보드입니다. 데이터는 웹에서 엑셀을 업로드하면 월 단위로 누적되고, 누적분이 예측 모델과 리포트에 그대로 활용됩니다.
| 탭 | 기능 |
|---|---|
| 1. 추세·기간예측 | 역/기간 선택 → 계절성 분해(요일×시간대) 또는 선형회귀로 향후 3~14일 PM2.5 예측. 기준월 선택 또는 전체(누적) 학습 |
| 2. 교차분석 | PM2.5 × 통행량의 하루 중 시간대 패턴 비교 + 역별 산점도·피어슨 상관계수 |
| 3. 지도 | 역 위치에 원 크기=통행량, 색상=PM2.5 등급으로 표시 (folium) |
| 4. 데이터 업로드·관리 | 통행량/초미세먼지 엑셀 업로드 → 월 단위 누적 적재, 적재 현황 목록 |
| 5. 종합 리포트 | LLM 없이 DB 집계값을 고정 템플릿에 대입한 사실 요약 + 마크다운 다운로드 |
각 단계를 클릭하거나 ▶ 순서대로 재생을 눌러 요청의 전체 여정을 따라가 보세요.
브라우저에서 air.sielain.com을 입력한 순간부터 PostgreSQL까지, 요청이 통과하는 7개 지점을 살펴봅니다.
프로토타입은 데이터(JSON)와 분석 로직(JS)과 화면(HTML)이 한 파일 안에 있었습니다. 잘 돌아가는데 왜 쪼갰을까요? 아래 비교 탭을 눌러 보세요.
pg_dump 한 줄/docs에서 Swagger 문서 자동 생성, Streamlit이 아니어도 누구나 호출 가능| 층 | 기술 | 맡은 일 | 맡지 않는 일 |
|---|---|---|---|
| 프론트 | Streamlit | 위젯·차트·지도 렌더링, 파일 업로더 UI | 계산·저장 (전부 API에 위임) |
| 백엔드 | FastAPI + SQLAlchemy | 예측(계절성분해/선형회귀), 상관계수, 엑셀 파싱, 집계 리포트 | 화면 그리기 |
| 데이터 | PostgreSQL 16 | stations / pm25_hourly / traffic_hourly 3개 테이블, 유니크 제약으로 중복 방지 | 비즈니스 로직 |
이 프로젝트의 결정적 설계 포인트는 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
cloudflared 컨테이너(sielain_tunnel)는 다른 compose 프로젝트
(/Users/sielain/docker)에 속해 있습니다. 별개 프로젝트의 컨테이너끼리는 기본적으로
서로를 볼 수 없지만, 같은 네트워크에 속하면 컨테이너 이름이 곧 DNS 호스트명이 됩니다.
터널은 http://air_traffic_frontend:8501로 프론트에 직접 도달합니다.
호스트에 노출한 8501(프론트)·8010(백엔드)·5433(DB)은 전부 로컬 디버깅용입니다. 외부 트래픽은 공유 네트워크의 내부 DNS로만 흐르므로, 원하면 ports를 전부 지워도 서비스는 동작합니다. DB 포트를 5433으로 둔 것은 기존 스택의 5432와의 습관적 충돌을 피하기 위한 관례입니다.
condition: service_healthy + pg_isready healthcheck 조합이어야
백엔드가 연결 오류 없이 기동합니다.
cloudflared 터널은 설정 방식이 두 갈래이고, 어느 쪽인지에 따라 라우팅 추가 방법이 완전히 다릅니다. parts 배포 때 한 번 밟았던 함정을 이번에는 설계 단계에서 피했습니다.
TUNNEL_TOKEN 환경변수로 기동. 모든 ingress 규칙을 Cloudflare 대시보드가 관리하고 터널에 실시간 내려보냅니다.
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate --protocol http2 run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} # 이것만 있으면 끝
air / URL http://air_traffic_frontend:8501config.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를 따로 실행해야 합니다.
air_traffic_frontend:8501은 거절되고 http://air_traffic_frontend:8501이어야 함공공기관 원본 엑셀은 사람이 보기 좋은 양식(병합 셀, 합계 행)이라 그대로 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 기준월 = 전체(누적)"를 선택하면 업로드된 모든 월이 요일×시간대 패턴 추정에 들어가 표본이 커지고 패턴이 안정됩니다.
탭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 미만=약함, …) 분류이지 판단이 아닙니다. 리포트 하단에 "인과관계를 의미하지 않습니다"를 항상 함께 출력합니다.
원인: 터널이 TUNNEL_TOKEN(원격 관리형)으로 기동 중 — 로컬 config.yml의 ingress는 무시됩니다(에러도 없음).
해결: Cloudflare 대시보드 "Published application routes"에서 등록. 로컬 파일은 문서용 주석으로만 유지.
원인: 신규 UI는 Type 드롭다운 대신 URL에 프로토콜을 요구 — air_traffic_frontend:8501은 형식 오류.
해결: http://air_traffic_frontend:8501 로 입력 (내부 통신이므로 https 아님).
원인: 파일 업로드(Form/UploadFile)를 쓰는 순간
FastAPI는 python-multipart 패키지를 요구하는데 requirements.txt에 없었음.
해결: python-multipart==0.0.20 추가 후 이미지 재빌드. 업로드 기능을 넣을 때 빠뜨리기 쉬운 단골 의존성입니다.
원인: 조회 쿼리가 기준월 필터 없이 전 레코드를 이어 붙여, 20개여야 할 시간대 배열이 달 수만큼 늘어남.
해결: 조회 함수에 period 파라미터를 추가하고 미지정 시 최신 월로 한정하는 기본값을 둠.
"누적 저장"과 "조회 시 한 달로 한정"은 별개의 문제라는 교훈.
위험: 탭4 업로드는 방문자 누구나 사용 가능. parts 대시보드에서는 크롤러가 GET 삭제 링크를 순회해
데이터 150건이 전량 삭제된 사고가 실제로 있었습니다.
대책: 변경 성격의 경로는 Cloudflare Access(이메일 OTP)로 보호 — parts는 /add,
/delete/*에 적용해 조회는 공개, 변경만 인증으로 분리했습니다. air도 같은 패턴 적용을 권장.
Q1. TUNNEL_TOKEN 방식(원격 관리형) 터널에서 air.sielain.com 라우팅을 추가하는 올바른 방법은?
Q2. 별개의 compose 프로젝트인 cloudflared 컨테이너가 air_traffic_frontend 컨테이너에 접근할 수 있는 이유는?
Q3. "같은 달을 다시 업로드하면 그 달만 교체되고 다른 달은 유지"를 구현하는 핵심은?
Q4. 탭5 종합 리포트가 LLM 없이도 "문장"을 출력할 수 있는 이유는?