From 3b4169d3012791eee5aee88a30560fbc1ac098a9 Mon Sep 17 00:00:00 2001 From: hyunho Date: Wed, 25 Mar 2026 10:43:39 +0900 Subject: [PATCH] Harden infra validation flow --- backend/app/main.py | 75 ++++++++++++++++++++++++++++-- docker-compose.yml | 38 +++++++++++++-- docs/DEPLOYMENT_GUIDE.md | 20 ++++++-- docs/INFRA_VALIDATION_CHECKLIST.md | 50 ++++++++++++++++++++ 4 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 docs/INFRA_VALIDATION_CHECKLIST.md diff --git a/backend/app/main.py b/backend/app/main.py index fb6f029..99d34a0 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from datetime import datetime from pathlib import Path import csv from io import BytesIO, StringIO +import re import shutil import uuid @@ -57,20 +58,41 @@ class MemberBulkPayload(BaseModel): LEGACY_HEADER_MAP = { "이름": "name", + "name": "name", "소속회사": "company", + "co": "company", + "company": "company", "직급": "rank", + "rank": "rank", "직책": "role", + "pos": "role", + "role": "role", "부서": "department", + "part": "department", + "department": "department", "그룹": "grp", + "gr": "grp", + "grp": "grp", "디비전": "division", + "div": "division", + "division": "division", "팀": "team", + "team": "team", + "teal": "team", "셀": "cell", + "cell": "cell", "근무상태": "work_status", + "work_status": "work_status", "근무시간": "work_time", + "work_time": "work_time", "전화번호": "phone", + "phone": "phone", "이메일": "email", + "email": "email", "자리위치": "seat_label", + "seat_label": "seat_label", "사진": "photo_url", + "photo_url": "photo_url", } @@ -110,6 +132,20 @@ def fetch_members() -> list[dict[str, object]]: return cur.fetchall() +def get_member_count() -> int: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS count FROM members") + return int(cur.fetchone()["count"]) + + +def ensure_snapshot_month(snapshot_month: str) -> str: + value = snapshot_month.strip() + if not re.fullmatch(r"\d{4}-\d{2}", value): + raise HTTPException(status_code=400, detail="snapshot_month 형식은 YYYY-MM 이어야 합니다.") + return value + + def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: with get_conn() as conn: with conn.cursor() as cur: @@ -130,18 +166,22 @@ def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]: + def normalize_header(value: object) -> str: + return str(value or "").strip().lower() + header_idx = next( ( idx for idx, row in enumerate(rows) - if "이름" in row and "부서" in row + if {"이름", "부서"}.issubset({str(value).strip() for value in row}) + or {"name", "part"}.issubset({normalize_header(value) for value in row}) ), -1, ) if header_idx < 0: - raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다. '이름'과 '부서' 헤더를 찾지 못했습니다.") + raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다. 필수 헤더(이름/부서 또는 name/part)를 찾지 못했습니다.") - headers = [str(value).strip() for value in rows[header_idx]] + headers = [normalize_header(value) for value in rows[header_idx]] payloads: list[MemberPayload] = [] for row in rows[header_idx + 1 :]: @@ -185,8 +225,26 @@ app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=F @app.get("/api/health") -def health() -> dict[str, str]: - return {"status": "ok"} +def health() -> dict[str, object]: + checks = { + "upload_dir": UPLOAD_DIR.exists(), + "snapshot_dir": SNAPSHOT_DIR.exists(), + } + + try: + member_count = get_member_count() + checks["database"] = True + except Exception: + member_count = None + checks["database"] = False + + status = "ok" if all(checks.values()) else "degraded" + return { + "status": status, + "checks": checks, + "member_count": member_count, + "timestamp": datetime.utcnow().isoformat() + "Z", + } @app.post("/api/mock-login") @@ -311,11 +369,18 @@ def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form(" @app.post("/api/snapshots/monthly") def create_monthly_snapshot(snapshot_month: str = Form(...)) -> dict[str, str]: + snapshot_month = ensure_snapshot_month(snapshot_month) filename = f"organization-snapshot-{snapshot_month}.csv" target = SNAPSHOT_DIR / filename with get_conn() as conn: with conn.cursor() as cur: + cur.execute( + "SELECT id FROM snapshots WHERE snapshot_month = %s ORDER BY created_at DESC LIMIT 1", + (snapshot_month,), + ) + if cur.fetchone() is not None: + raise HTTPException(status_code=409, detail="해당 월의 스냅샷이 이미 존재합니다.") cur.execute( """ SELECT name, company, rank, role, department, grp, division, team, cell, diff --git a/docker-compose.yml b/docker-compose.yml index 87dde97..2efdf5e 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,17 +2,33 @@ services: proxy: image: nginx:1.27-alpine depends_on: - - frontend - - backend + frontend: + condition: service_healthy + backend: + condition: service_healthy ports: - "8080:80" volumes: - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s frontend: build: context: . dockerfile: frontend/Dockerfile + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s backend: build: @@ -21,10 +37,18 @@ services: env_file: - .env depends_on: - - db + db: + condition: service_healthy volumes: - uploads_data:/data/uploads - snapshots_data:/data/snapshots + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 20s db: image: postgres:16-alpine @@ -34,9 +58,15 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s volumes: postgres_data: uploads_data: snapshots_data: - diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md index 032f07f..7de4329 100755 --- a/docs/DEPLOYMENT_GUIDE.md +++ b/docs/DEPLOYMENT_GUIDE.md @@ -50,6 +50,8 @@ 3. `docker compose build`를 실행합니다. 4. `docker compose up -d`를 실행합니다. 5. 브라우저에서 `http://SERVER_IP:8080` 으로 접속합니다. +6. `docker compose ps` 에서 `backend`, `frontend`, `proxy`, `db` 가 모두 `healthy` 인지 확인합니다. +7. `http://SERVER_IP:8080/api/health` 응답에서 `status`, `checks`, `member_count` 를 확인합니다. ## 7. 현재 단계의 데이터 및 백업 정책 - 데이터베이스: PostgreSQL 볼륨 `postgres_data` @@ -60,17 +62,25 @@ ## 8. 현재 구조의 한계 - 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다. -- 레거시 조직도 화면은 iframe으로 연결만 해둔 상태이며, DB 기반 API와 완전히 연동되지는 않았습니다. +- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동/스냅샷 흐름 검증이 추가로 필요합니다. - 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다. ## 9. 다음 구현 권장 순서 -1. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다. -2. 구성원 데이터를 브라우저 메모리 방식에서 PostgreSQL 기반 API 저장 방식으로 전환합니다. -3. 사무실 자리배치 좌표 저장 기능을 추가합니다. -4. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다. +1. Docker Compose 기준 운영 검증과 스냅샷 검증을 완료합니다. +2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다. +3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다. +4. 사무실 자리배치 좌표 저장 기능을 추가합니다. +5. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다. ## 10. 현재 로컬 테스트 접속 정보 - 접속 주소: `http://localhost:8080` - 상태 확인 API: `http://localhost:8080/api/health` - WSL 내부 실행 경로: - `/home/hyunho/projects/mh-dashboard-organization` + +## 11. 운영 검증 체크포인트 +- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다. +- `docker compose restart backend proxy` 이후에도 데이터가 유지되는지 확인합니다. +- `POST /api/snapshots/monthly` 호출 시 `YYYY-MM` 형식만 허용되는지 확인합니다. +- 같은 월에 대해 중복 스냅샷 생성 시 409 에러가 반환되는지 확인합니다. +- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드/스냅샷 데이터가 유지되는지 확인합니다. diff --git a/docs/INFRA_VALIDATION_CHECKLIST.md b/docs/INFRA_VALIDATION_CHECKLIST.md new file mode 100644 index 0000000..539ad51 --- /dev/null +++ b/docs/INFRA_VALIDATION_CHECKLIST.md @@ -0,0 +1,50 @@ +# 인프라 검증 체크리스트 + +## 1. 컨테이너 기동 +- `docker compose build` +- `docker compose up -d` +- `docker compose ps` +- 확인 기준: `proxy`, `frontend`, `backend`, `db` 모두 `healthy` + +## 2. API 상태 확인 +- `curl http://localhost:8080/api/health` +- 확인 기준: + - `status` 가 `ok` + - `checks.database` 가 `true` + - `checks.upload_dir` 가 `true` + - `checks.snapshot_dir` 가 `true` + +## 3. 초기 데이터 업로드 +- 조직도 화면에서 `.xlsx` 또는 `.csv` 업로드 +- `curl http://localhost:8080/api/members` +- 확인 기준: + - `items` 배열이 비어 있지 않음 + - 화면 렌더링이 정상 동작함 + +## 4. 영속성 확인 +- `docker compose restart backend proxy` +- 다시 `curl http://localhost:8080/api/members` +- 확인 기준: + - 업로드했던 데이터가 그대로 유지됨 + +## 5. 스냅샷 검증 +- `curl -X POST -F snapshot_month=2026-03 http://localhost:8080/api/snapshots/monthly` +- 확인 기준: + - CSV 파일 경로가 반환됨 + - `/snapshots/...` 다운로드 가능 + +## 6. 중복/형식 오류 검증 +- 같은 월로 다시 스냅샷 생성 +- 확인 기준: + - 409 에러 반환 +- 잘못된 형식으로 스냅샷 생성 예: `202603` +- 확인 기준: + - 400 에러 반환 + +## 7. 종료 후 재기동 확인 +- `docker compose down` +- `docker compose up -d` +- 확인 기준: + - DB 데이터 유지 + - 업로드 파일 유지 + - 스냅샷 파일 유지