# 완료 계획서 — B01 프로젝트 삭제: 개발용 하드 삭제 스위치 - **일자**: 2026-08-08 - **상태**: 완료 (검증 완료) ## 배경 대시보드 프로젝트 삭제 버튼은 현재 **소프트 삭제만** 한다 — `projects.deleted_at`에 시각을 찍고 `system_audit_logs`에 `PROJECT_DELETE` 한 줄을 넣는 것이 전부다(`B01_Dashboard_Repository.py:255`). 파일시스템과 자식 테이블은 손대지 않는다. 목록 쿼리가 `deleted_at IS NULL`로 걸러서 화면에서만 사라진다. 배포 환경에서는 이게 맞다 — 사용자가 올린 라이다 원본은 나중에 다른 프로젝트 개발에 재활용할 수 있는 자산이다. 그러나 지금은 개발 단계라 프로젝트를 반복 생성·삭제하는데, 삭제할 때마다 수십 GB 라이다 원본이 디스크에 영구 누적된다. 하드 삭제 잡도 없다. **사용자 지시(2026-08-08)**: `config_system.py`에 true/false 스위치를 두고, 배포 시 `false`(현행 소프트 삭제 유지), 개발 중 `true`(DB + 영구저장소 프로젝트 폴더 자체 삭제)로 운용한다. ## 설계 결정 **1. 설정 변수** — `config/config_system.py` 맨 위 `0. 위험 스위치` 전용 절에 배치. `load_dotenv()` 직후, 기존 1절(FastAPI 서버) 앞이다. ```python PROJECT_DELETE_HARD_ENABLED = os.getenv("PROJECT_DELETE_HARD_ENABLED", "False").lower() == "true" ``` 기본값 `False` — 환경변수를 안 넣은 배포 환경이 자동으로 안전한 쪽에 선다. 개발 PC는 `.env`에 `PROJECT_DELETE_HARD_ENABLED=True`. **2. 삭제 대상** — `storage/{회사}/{사용자}/{project_id}/` 폴더 자체를 `shutil.rmtree`로 제거(`B03_FileInput` 원본 포함). 기존 `purge_output_folders()`는 B04~B09만 비우고 B03을 남기므로 재사용하지 않는다. **3. DB는 FK CASCADE에 맡긴다** — `db_management/001_create_schema.sql:505~586` 확인 결과 `projects.id`를 참조하는 자식 테이블 전부가 `ON DELETE CASCADE`다: `project_versions`, `input_files`, `processed_point_cloud`, `surface_models`, `routes`, `longitudinal_sections`, `cross_sections`, `structures`, `quantity_items`, `outputs`, `change_logs`, `upload_sessions`(`010_temp_upload.sql:68`), `project_workflow_stages`(`006_workflow_state.sql:22`). `route_points`·`route_statistics`는 `routes` 경유로 연쇄(`001:538,543`). 따라서 `DELETE FROM projects WHERE id = %s` 한 줄이면 충분하다 — 테이블 목록을 코드에 다시 나열하지 않는다(스키마 변경 시 어긋남 방지). **4. 남기는 것 2가지** - `system_audit_logs` — 감사 기록은 삭제 후에도 남겨야 한다. `resource_id`가 현재 `NULL`로만 들어가고 FK도 없어 고아 참조 문제 없음. `action`은 `PROJECT_HARD_DELETE`로 구분해 기록. - `temp_batches.linked_project_id` — FK 없는 이력 보존용 컬럼. 설계 의도대로 그대로 둔다. **5. 실행 순서 — DB 먼저, 파일 나중** 1. `storage_path` 조회 (삭제 전에 읽어 둬야 함) 2. `DELETE FROM projects` + 감사 로그 INSERT → **커밋** 3. 커밋 성공 후 `rmtree` DB가 먼저 지워지면 rmtree 실패 시 고아 폴더(디스크 낭비)만 남고 시스템은 정합하다. 반대 순서면 파일 없는 DB 행이 남아 화면이 깨진다. rmtree 실패는 `logger.error`로 경로를 남겨 수동 정리 가능하게 한다 — 삭제 API 자체는 성공으로 응답한다. **6. 경로 안전장치 (필수)** — `resolve_stored_project_path()`는 내부에서 `os.makedirs` + `ensure_project_storage_layout()`을 호출해 폴더를 만든다. 삭제 경로에 그대로 쓰면 지우기 직전에 폴더를 되살린다. 따라서 생성하지 않는 별도 해석 함수를 추가한다. `common_util_storage.py`에 신규 함수: ```python resolve_project_root_for_delete(relative_path: str) -> str ``` 검증 조건 (하나라도 어기면 `ValueError`, 삭제 중단): - 상대 경로이고 `..` 미포함, `storage/`로 시작 (기존 `resolve_stored_project_path` 앞부분과 동일) - 정규화 후 `STORAGE_BASE_DIR` 하위이고 루트 자신이 아님 - 세그먼트가 정확히 `storage/{회사}/{사용자}/{project_id}` 4개 — 상위 폴더 통삭제 방지 - 마지막 세그먼트가 URL의 `project_id`와 문자열 일치 — DB `storage_path`가 오염돼도 남의 폴더를 못 지운다 - `os.makedirs` 호출하지 않음 **7. 빈 상위 폴더** — 프로젝트 삭제 후 비게 된 `{사용자}/`, `{회사}/` 폴더는 정리하지 않는다. 동시 생성과 경합할 수 있고 실익이 없다. **8. 권한 불변** — `SYSTEM_ADMIN`만 삭제 가능한 현행 유지. 주석 처리된 ADMIN 분기도 그대로 둔다. **9. 화면 경고** — 하드 삭제 모드일 때 삭제 확인 모달 문구를 다르게 보여준다. 개발 중 실수로 원본을 날리는 사고를 막는 최소 장치다. `GET /api/dashboard/me` 응답에 `project_delete_hard: bool`을 실어 프론트가 판단한다. ## 대상 파일 | 파일 | 변경 | |---|---| | `config/config_system.py` | `PROJECT_DELETE_HARD_ENABLED` 추가 (4절, `AUTO_DESIGN_CHAIN_ENABLED` 아래) | | `common_util/common_util_storage.py` | `resolve_project_root_for_delete()` 신규 | | `B01_Dashboard/B01_Dashboard_Repository.py` | `hard_delete_project()` 신규. 기존 `soft_delete_project()`는 그대로 | | `B01_Dashboard/B01_Dashboard_Router.py` | `dashboard_delete_project()`에서 플래그 분기. `/me` 응답에 플래그 노출 | | `B01_Dashboard/B01_Dashboard_UI_Modals.ts` | `openDeleteProjectModal()` 경고 문구 분기 | | `ui_template/` 다국어 | `B01_Dashboard_Confirm_DeleteProject_Hard` 키 추가 | `B01_Dashboard_Repository.py`는 현재 790줄로 이미 700줄 제한 초과 상태(기존 기술부채). 이번에 함수를 더하면 악화되므로, 삭제 로직은 저장소 파일이 아니라 **`common_util/common_util_project_delete.py` 신규 파일**에 둔다. 저장소 파일에는 손대지 않는다. ## 구현 체크리스트 - [x] `config_system.py` 최상단 `0. 위험 스위치` 절에 `PROJECT_DELETE_HARD_ENABLED` 추가 (기본 `False`, 주석에 배포/개발 운용 방침 명시) - [x] `common_util_storage.py:77`에 `resolve_project_root_for_delete()` 추가 — 4세그먼트 검증 + `project_id` 일치 + 폴더 생성 없음 - [x] `common_util/common_util_project_delete.py` 신규 — `hard_delete_project(project_id, actor_id)` : storage_path 조회 → 경로 검증 → `DELETE FROM projects` + `PROJECT_HARD_DELETE` 감사 로그 → 커밋 → `rmtree`(실패 시 error 로그만) - [x] `B01_Dashboard_Router.py:237` 분기 — 플래그 True면 `hard_delete_project()`, False면 기존 `soft_delete_project()` - [x] `/api/dashboard/me` 응답 `user`에 `project_delete_hard` 필드 추가 - [x] `B01_Dashboard_UI_Modals.ts:121` — 하드 삭제 모드일 때 원본까지 지운다는 경고 문구로 교체. `openDeleteProjectModal(user, project)`로 시그니처 변경, 호출부 `B01_Dashboard_UI_Projects.ts:39` 수정 - [x] 다국어 키 `B01_Dashboard_Confirm_DeleteProject_Hard` 추가 (`ui_template_locale_b1.ts:174`) - [x] `ruff format` + `ruff check` + `prettier` + `tsc --noEmit` 전부 통과 - [x] `.env`에 `PROJECT_DELETE_HARD_ENABLED=True` 기재 — `.env.example`은 이 저장소에 없다 ## 완료 조건 - 플래그 `False`: 삭제 동작이 지금과 100% 동일 (deleted_at UPDATE + 감사 로그) - 플래그 `True`: `projects` 행과 자식 테이블 전부 소멸, `storage/.../{project_id}/` 폴더 부재, `system_audit_logs`에 `PROJECT_HARD_DELETE` 잔존 - 조작된 `storage_path`(`..` 포함, 3세그먼트, project_id 불일치)로는 `ValueError` 발생해 아무것도 지워지지 않음 ## 검증자 인계 사항 경로 가드 8케이스 수동 확인 완료 (정상 허용 1 / 차단 7: 3세그먼트, 저장소 루트, `..` 탈출, ID 불일치, 절대경로, 5세그먼트, 역슬래시 탈출). 실제 프로젝트 생성→삭제 E2E는 미수행. **미결 리스크**: 이 저장소는 `.env`를 git으로 추적한다(`.gitignore:12`의 `.env` 줄이 주석 처리됨). 따라서 `PROJECT_DELETE_HARD_ENABLED=True`가 커밋되면 다른 PC와 배포 환경까지 하드 삭제가 켜진 채 전파된다. 배포 전에 `.env`를 추적 해제(`git rm --cached .env` + `.gitignore` 주석 해제)하거나, 최소한 배포 브랜치에서 `False`로 되돌려야 한다.