# CloudSharp — 백엔드 + 인프라 + 설계 포트폴리오 분석 > **작성 기준일:** 2026-05-05 > **분석 범위:** `apps/backend/`, `infra/`, `docs/.llm/` — 프론트엔드 제외 > **주의:** 확인되는 내용만 코드 기반으로 작성. 미구현/미확인 항목은 "확인 필요" 또는 "예정"으로 표시. > **구조:** "문제 → 설계 선택 → 구현 방식 → 결과" 지향. 단순 기능 나열이 아닌 설계 판단 기록. --- ## 1. 프로젝트 목적 ### 1.1 해결하려는 문제 - 기존 클라우드 스토리지는 **개인 계정 중심**으로 설계되어 있다. - 팀/프로젝트 단위로 **완전히 독립된 파일 저장 공간**이 필요한 경우, 폴더 공유만으로는 격리·권한·Quota를 충분히 통제할 수 없다. - 셀프호스트 환경에서 **대용량 파일의 중단 재개 업로드**와 안전한 공유가 필요하다. ### 1.2 주요 사용자 - 셀프호스트를 선호하는 개인, 팀, 프로젝트 그룹, 소규모 조직 - Space 단위로 파일을 저장·공유·협업하려는 사용자 - MCP/AI 연계를 원하는 파워유저 (향후) ### 1.3 프로젝트 목표 **Space**라는 개념을 중심으로, 완전히 격리된 팀 저장 공간을 제공하는 셀프호스팅 파일 서비스 플랫폼을 구축한다. MVP는 Docker Compose로 간편하게 배포할 수 있어야 한다. ### 1.4 백엔드가 담당하는 핵심 책임 | 책임 | 설명 | |------|------| | **인증/인가** | opaque session token 기반 사용자 인증 + Space Role 기반 인가 | | **메타데이터 관리** | Space, Folder, FileItem, ShareLink, UploadSession, DownloadSession의 진실 원천(PostgreSQL) | | **업로드 파이프라인** | tusd와 연계한 업로드 세션 생성 → 전송 → Finalize까지 전체 생명주기 관리 | | **Quota 정책** | Space 단위 `used + reserved + expected <= allowed` 판정 및 예약/확정/해제 정합성 | | **파일 저장 추상화** | Local FS, MinIO, S3를 동일한 storage_key로 추상화 | | **다운로드 보안** | 단명(5분 TTL) DownloadSession 기반 Zero Information Leak 정책 | | **공유 제어** | 내부 협업(SpaceMember/SpaceInvite)과 외부 공유(ShareLink) 분리 | | **파일명 충돌 정책** | 자동 리네임 없이 실패 반환 | | **Finalize 동시성 제어** | CAS(Compare-And-Swap) 기반 중복 실행 방지 + Recovery Worker | ### 1.5 인프라 구성이 필요한 이유 - PostgreSQL: 메타데이터 진실 원천 - Redis: 사용자 세션 저장소(opaque session), 캐시, Redis Pub/Sub 이벤트 전달 - tusd: tus 프로토콜 기반 재개 가능한 대용량 청크 업로드 (Go 바이너리) - Local FS: 물리 파일 저장 - nginx: Reverse Proxy — 단일 진입점, 스트리밍 버퍼링 해제, tus 헤더 포워딩 - ffmpeg/AI Worker (향후): 썸네일, 메타데이터 추출 등 비동기 후처리 ### 1.6 프로젝트 한 줄 > **Cloud#**은 Space 단위의 완전 격리형 저장 공간을 제공하는, tus 기반 재개 가능 업로드와 단명 다운로드 세션을 갖춘 셀프호스트 파일 서비스다. --- ## 2. 전체 아키텍처 ### 2.1 아키텍처 유형: **모듈러 모놀리스 (Simplified Clean Architecture)** - MVP에서는 하나의 ASP.NET Core 백엔드로 시작하고, 서비스 분리는 실제 병목이 생긴 뒤 결정한다. - 다만 프로젝트 내부는 Clean Architecture로 레이어를 엄격히 분리한다. ### 2.2 전체 구성 요소 관계 ```mermaid flowchart TB subgraph External["외부"] CLIENT["Browser / Client"] end subgraph DockerHost["Docker Compose Host"] NGINX["nginx :80"] API["ASP.NET Core API
:8080"] TUSD["tusd :1080"] PG[("PostgreSQL :5432")] REDIS[("Redis :6379")] STORAGE[("Local FS
/data/storage")] end CLIENT -->|"HTTP :8080"| NGINX NGINX -->|"/api/*"| API NGINX -->|"/files/*"| TUSD API --> PG API --> REDIS API --> STORAGE API -.->|"internal hook"| TUSD TUSD --> STORAGE TUSD -->|"hook callback"| API ``` ### 2.3 외부 요청 → 내부 서비스 흐름 ``` Browser Request → nginx (Reverse Proxy) → /api/* → ASP.NET Core API → PostgreSQL (메타데이터) → Redis (세션 검증) → Local FS (파일 읽기/쓰기) → /files/* → tusd (청크 업로드/다운로드) → API hook callback (pre-create, post-finish 등) → Local FS (임시 저장 → 최종 이동) ``` ### 2.4 서비스 간 책임 분리 | 컴포넌트 | 책임 | 알고 있는 것 | |----------|------|-------------| | **nginx** | 리버스 프록시, 라우팅, tus 헤더 포워딩, 버퍼링 해제 | API, tusd 위치 | | **ASP.NET API** | 권한·정책·메타데이터·finalize·공유·검색·세션 관리 | PostgreSQL, Redis, Local FS, tusd hook | | **tusd** | tus 프로토콜 청크 업로드 수신, 파일 저장, hook 호출 | Local FS, API hook endpoint | | **PostgreSQL** | 서비스의 진실 원천 (모든 메타데이터) | 없음 | | **Redis** | 세션 저장소, Pub/Sub 이벤트 버스 | 없음 | | **Local FS** | 물리 파일 바이트 저장 | 없음 | ### 2.5 외부 공개 vs 내부 서비스 구분 | 구분 | 경로 | 인증 | |------|------|------| | **내부 API** | `/api/v1/*` | `Authorization: Bearer {opaque_session_token}` | | **외부 공개 API** | `/public/v1/*` | share_token + 비밀번호 | | **내부 전용** | `/api/internal/*` | `X-CloudSharp-Internal-Token` (서버 간) | | **tusd hook** | `/internal/tusd/hooks` | tusd 전용 | ### 2.6 아키텍처 설계 의도 - **모듈러 모놀리스 선택 이유**: MVP에서 마이크로서비스의 운영 복잡도(분산 트랜잭션, 서비스 디스커버리, 로그 수집)를 피하면서도, Clean Architecture로 도메인 경계를 코드 레벨에서 명확히 분리한다. - **nginx를 앞에 둔 이유**: 단일 진입점으로 CORS/HTTPS 관리 단순화, tusd 스트리밍에 필요한 버퍼링 해제 및 장시간 타임아웃 설정. - **tusd를 분리한 이유**: 업로드 전송(tus 프로토콜)은 ASP.NET보다 Go 기반 tusd가 효율적이며, 업로드 plane과 API plane을 분리해 장애 격리 및 독립 확장이 가능하다. --- ## 3. API 설계 ### 3.1 기본 계약 | 항목 | 값 | |------|-----| | 내부 API base path | `/api/v1` | | 외부 공개 base path | `/public/v1` | | 인증 방식 | `Authorization: Bearer {cs_sess_...}` (opaque session token) | | 기본 데이터 형식 | `application/json` | | API 버전 관리 | URL path 기반 (`v1`) | | 문서화 | Swagger UI (`/swagger`), OpenAPI 3.x (`/openapi/v1/openapi.yaml`), ReDoc | | OpenAPI bearer format | `bearerFormat: opaque` (JWT가 아님) | ### 3.2 구현 상태 범례 | 상태 | 의미 | |------|------| | `구현됨` | 현재 API 앱에 매핑 완료 | | `내부 구현됨` | tusd hook / 내부 인증 전용 | | `예정` | 계약 초안만 존재, 엔드포인트 미구현 | ### 3.3 구현된 API 목록 #### 인증 및 사용자 | Method | Path | 설명 | 상태 | |--------|------|------|------| | `GET` | `/api/v1/health` | Docker/API health check | `구현됨` | | `POST` | `/api/v1/auth/register` | 회원가입 + 세션 토큰 발급 | `구현됨` | | `POST` | `/api/v1/auth/login` | 로그인 (→ 미구현) | `예정` | | `POST` | `/api/v1/auth/logout` | 로그아웃 | `예정` | | `POST` | `/api/v1/me` | 내 프로필 조회 | `예정` | **구현된 Request DTO (`RegisterRequest`):** ```csharp // 실제 코드 public sealed record RegisterRequest( [property: Required] string Email, [property: Required] string Username, [property: Required] string DisplayName, [property: Required] string Password ); ``` **구현된 Response (`AuthResponse`):** ```csharp // 실제 코드 public sealed record AuthResponse( string AccessToken, // cs_sess_... opaque token string TokenType, // "Bearer" long ExpiresInSeconds, UserProfileResponse User ); ``` #### Space | Method | Path | 설명 | 최소 Role | 상태 | |--------|------|------|-----------|------| | `GET` | `/api/v1/spaces` | 내 Space 목록 | `VIEWER` | `예정` | | `POST` | `/api/v1/spaces` | Space 생성 | 로그인 | `예정` | | `GET` | `/api/v1/spaces/{spaceSlug}` | Space 상세 | `VIEWER` | `예정` | | `PATCH` | `/api/v1/spaces/{spaceSlug}` | 이름/설명 변경 | `ADMIN` | `예정` | | `DELETE` | `/api/v1/spaces/{spaceSlug}` | 소프트 삭제 | `OWNER` | `예정` | | `GET` | `/api/v1/spaces/{spaceSlug}/quota` | Quota 조회 | `ADMIN` | `예정` | | `PATCH` | `/api/v1/spaces/{spaceSlug}/quota` | Quota 변경 | `OWNER` | `예정` | #### 폴더 | Method | Path | 설명 | 최소 Role | 상태 | |--------|------|------|-----------|------| | `GET` | `/api/v1/spaces/{spaceSlug}/folders/{folderId}/children` | 자식 목록 | `VIEWER` | `구현됨` | | `POST` | `/api/v1/spaces/{spaceSlug}/folders` | 폴더 생성 | `MEMBER` | `구현됨` | | `PATCH` | `/api/v1/spaces/{spaceSlug}/folders/{folderId}` | 이름 변경/이동 | `MEMBER` | `구현됨` | | `DELETE` | `/api/v1/spaces/{spaceSlug}/folders/{folderId}` | 폴더 삭제 | `MEMBER` | `구현됨` | **실제 정렬 쿼리 (코드 기반):** ``` sortBy = Name | Size | UpdatedAt sortDirection = Asc | Desc ``` #### 파일 | Method | Path | 설명 | 상태 | |--------|------|------|------| | `PATCH` | `/api/v1/spaces/{spaceSlug}/files/{fileId}` | 파일명 변경/이동 | `예정` | | `DELETE` | `/api/v1/spaces/{spaceSlug}/files/{fileId}` | 파일 삭제 | `예정` | #### 업로드 | Method | Path | 설명 | 최소 Role | 상태 | |--------|------|------|-----------|------| | `POST` | `/api/v1/spaces/{spaceSlug}/upload-sessions` | 업로드 세션 생성 | `MEMBER` | `구현됨` | | `GET` | `/api/v1/spaces/{spaceSlug}/upload-sessions/{token}` | 세션 상태 조회 | `MEMBER` | `구현됨` | | `POST` | `/api/internal/uploads/uploading` | tus 전송 시작 반영 | 내부 인증 | `내부 구현됨` | | `POST` | `/api/internal/uploads/finalize` | Finalize 수행 | 내부 인증 | `내부 구현됨` | | `POST` | `/internal/tusd/hooks` | tusd HTTP hook 수신 | tusd | `내부 구현됨` | **업로드 세션 생성 Request (실제 코드):** ```csharp public sealed record CreateUploadSessionRequest( [property: Required] long TargetFolderId, [property: Required] [property: MaxLength(255)] string OriginalName, [property: Required] [property: Range(1, long.MaxValue)] long ExpectedSize, [property: MaxLength(255)] string? ClientMimeType, [property: MaxLength(64)] string? Checksum ); ``` #### 다운로드 | Method | Path | 설명 | 상태 | |--------|------|------|------| | `POST` | `/api/v1/spaces/{spaceSlug}/files/{fileId}/download-sessions` | 다운로드 세션 발급 | `예정` | | `GET` | `/public/v1/download-sessions/{sessionToken}/stream` | 실제 스트리밍 | `예정` | #### 공유 링크 | Method | Path | 설명 | 상태 | |--------|------|------|------| | `POST` | `/api/v1/share-links` | 공유 링크 생성 | `예정` | | `PATCH` | `/api/v1/share-links/{shareLinkId}` | 옵션 수정 | `예정` | | `DELETE` | `/api/v1/share-links/{shareLinkId}` | 비활성화 | `예정` | | `POST` | `/public/v1/share-links/{shareToken}/verify` | 유효성 검증 | `예정` | | `POST` | `/public/v1/share-links/{shareToken}/browse` | 열람 | `예정` | | `POST` | `/public/v1/share-links/{shareToken}/download-sessions` | 공유 기반 다운로드 | `예정` | ### 3.4 공통 응답 구조 **성공 응답** — 리소스 중심 JSON, 공통 `data` envelope 없음. **실패 응답 구조 (실제 `ErrorResponse` 코드):** ```json { "requestId": "req_01JXYZ...", "error": { "code": "FILE_NAME_CONFLICT", "message": "같은 이름의 파일이 이미 존재합니다.", "details": [ { "field": "displayName", "reason": "conflict" } ] } } ``` ### 3.5 대표 비즈니스 에러 코드 | 코드 | 설명 | HTTP 상태 | |------|------|-----------| | `AUTH_REQUIRED` | 인증 토큰 누락/만료 | 401 | | `INVALID_CREDENTIALS` | 로그인 실패 | 401 | | `SPACE_ACCESS_DENIED` | Space 접근 권한 없음 | 403 | | `FILE_NAME_CONFLICT` | 동일 폴더 내 파일명 충돌 | 409 | | `FOLDER_NAME_CONFLICT` | 동일 부모 내 폴더명 충돌 | 409 | | `QUOTA_EXCEEDED` | 업로드 시작 전 quota 초과 | 413/422 | | `QUOTA_EXCEEDED_FINALIZE` | finalize 직전 quota 재검사 실패 | 422 | | `UPLOAD_SESSION_EXPIRED` | 업로드 세션 만료 | 410 | | `DOWNLOAD_NOT_AVAILABLE` | 삭제·손상·격리로 다운로드 불가 | 404 | | `SHARE_LINK_EXPIRED` | 공유 링크 만료 | 404 | | `SHARE_LINK_PASSWORD_REQUIRED` | 비밀번호 필요한 링크 | 401 | | `UNSUPPORTED_PREVIEW_TYPE` | 미리보기 미지원 | 415 | --- ## 4. 인증/인가 설계 ### 4.1 설계 결정: **Opaque Session Token (≠ JWT)** ``` Authorization: Bearer cs_sess_{256-bit CSPRNG random} ``` ### 4.2 왜 JWT가 아니라 opaque session token인가 CloudSharp의 권한 모델 특성상 JWT의 self-contained claim이 오히려 단점이 된다: | 전제 | Opaque token이 더 적합한 이유 | |------|-------------------------------| | 같은 사용자가 여러 Space에서 서로 다른 Role을 가진다 | JWT에 모든 Role을 담으면 토큰 크기 증가, Role 변경 시 stale | | Space Role 변경이 즉시 반영되어야 한다 | opaque token은 매 요청마다 최신 DB 권한을 조회하므로 stale 문제 없음 | | 계정 정지, 세션 강제 만료가 필요하다 | opaque는 Redis key 삭제만으로 즉시 revoke 가능 | | 다운로드 세션은 별도 단명 토큰이 필요하다 | 목적별 토큰 분리가 자연스럽다 | ### 4.3 토큰 종류 구분 | 토큰 | 용도 | 형식 | Revoke | |------|------|------|--------| | **사용자 세션 토큰** | 내부 API 로그인 상태 | `cs_sess_{random}` | 즉시 (Redis) | | **다운로드 세션 토큰** | 실제 파일 스트리밍 | 단명 opaque | TTL 내 미지원 | | **공유 링크 토큰** | 외부 공유 접근 | opaque | 링크 상태 조회 시 | | **초대 토큰** | Space 초대 수락 | opaque | 초대 상태 조회 시 | ### 4.4 토큰 생성 규칙 (실제 코드 기반) ```text 1. CSPRNG.GetBytes(32) → 256-bit 난수 2. accessToken = "cs_sess_" + base64url(randomBytes) 3. tokenHash = HMAC-SHA-256(accessToken, serverSecret) 4. Redis key = "auth:session:{tokenHash}" 5. Redis에 원문 저장 금지, token_hash만 저장 ``` ### 4.5 Redis 세션 모델 | Redis Key | Type | 설명 | |-----------|------|------| | `auth:session:{token_hash}` | Hash | 단일 세션 본문 (user_id, status, idle/absolute 만료 등) | | `auth:user_sessions:{user_id}` | Set | 특정 사용자의 활성 세션 인덱스 | ### 4.6 세션 수명 정책 | 항목 | 값 | |------|-----| | Idle timeout | 12시간 (미사용 시 만료) | | Absolute timeout | 30일 (사용 중이어도 최대) | | Sliding renewal | idle 만료만 연장, absolute는 연장 안 함 | ### 4.7 ASP.NET Core 인증 아키텍처 ``` 요청 진입 → CloudSharpSessionAuthenticationHandler 1. Authorization 헤더 존재 확인 2. "Bearer " prefix 확인 3. opaque token hash 계산 4. Redis UserSession 조회 5. 세션 상태 + 만료 확인 6. DB 사용자 상태 및 system_role 확인 7. 최소 claim(sub, sid, system_role)만 담은 ClaimsPrincipal 생성 → RequireSpacePermissionFilter (Endpoint Filter) 1. route의 spaceSlug 추출 2. ISpacePermissionService.FindAuthorizedSpaceAsync() - Space.status = ACTIVE 확인 - SpaceMember.user_id + status + Role 확인 - 요청 permission 충족 확인 3. 성공 → HttpContext에 AuthorizedSpaceContext 저장 4. 실패 → 403 Forbidden → UseCase 리소스의 space_id 검증 (IDOR 방어) ``` ### 4.8 Role 매트릭스 | 작업 | OWNER | ADMIN | MEMBER | VIEWER | |------|:-----:|:-----:|:------:|:------:| | Space 조회 | O | O | O | O | | 파일/폴더 조회 | O | O | O | O | | 다운로드 세션 발급 | O | O | O | O | | 업로드 세션 생성 | O | O | O | X | | 파일/폴더 수정 | O | O | O | X | | 공유 링크 생성 | O | O | O | X | | 멤버 초대/변경 | O | O | X | X | | Quota 변경 | O | X | X | X | | Space 삭제 | O | X | X | X | ### 4.9 예외 규칙 - 마지막 OWNER 제거 금지 - ADMIN이 OWNER를 강등/제거할 수 없음 - 자기 자신을 마지막 OWNER에서 제거할 수 없음 - Space가 ARCHIVED/DELETED이면 Role과 무관하게 쓰기 금지 ### 4.10 외부 공개 API 보안: Zero Information Leak 외부 `/public/v1/*`에서는 권한 없음, 리소스 없음, 비활성, 격리 상태를 **모두 `404 Not Found`로 통일**하여 리소스 존재 여부를 외부에 노출하지 않는다. ### 4.11 인증/인가 설계 판단 > **JWT가 아니라 opaque session token을 선택한 것은 CloudSharp의 "한 사용자 = 여러 Space × 여러 Role"이라는 멀티 테넌트 권한 모델을 고려한 의도적 설계다.** 인증과 인가를 분리하고, 권한 변경을 즉시 반영하며, 토큰에 정보를 담지 않아 토큰 탈취 시 노출을 최소화한다. --- ## 5. 데이터베이스 설계 ### 5.1 주요 Entity 목록 (12개) | Entity | 설명 | 주요 특징 | |--------|------|-----------| | `User` | 계정 주체, 인증 정보 | `system_role` (ADMIN/USER), 소프트 딜리트 | | `Space` | 파일 저장 최상위 단위 | `slug` (UUID, URL 식별자), `storage_*_bytes` | | `SpaceMember` | User ↔ Space 소속 관계 | Role(OWNER/ADMIN/MEMBER/VIEWER), Status | | `SpaceInvite` | 멤버 초대 워크플로 | `token_hash`, 만료일, Role | | `Folder` | Space 내 폴더 트리 | `parent_folder_id` self-join, `full_path` 캐시 | | `FileItem` | 최종 저장 완료 파일 | `storage_key`, 후처리 3종 상태, metadata_json | | `UploadSession` | tus 업로드 전송/확정 추적 | 7상태 머신, `finalize_attempts`, `tus_upload_id` | | `FileReservation` | 파일명 + quota 선점 | UploadSession과 1:1, 6상태 머신 | | `ShareLink` | 외부 공유 링크 정책 | `password_hash`, `allow_download`, 다운로드 카운트 | | `ShareLinkTarget` | ShareLink → File/Folder 연결 | `target_type` (FILE/FOLDER) | | `DownloadSession` | 실제 파일 스트리밍 단명 세션 | `subject_type` (USER/SHARE_LINK), 5분 TTL | ### 5.2 ERD (Mermaid) ```mermaid erDiagram USER ||--o{ SPACE : "creates" USER ||--o{ SPACE_MEMBER : "joins" SPACE ||--o{ SPACE_MEMBER : "has" SPACE ||--o{ FOLDER : "owns" FOLDER ||--o{ FOLDER : "parent_of" SPACE ||--o{ FILE_ITEM : "owns" FOLDER ||--o{ FILE_ITEM : "contains" SPACE ||--o{ UPLOAD_SESSION : "scopes" UPLOAD_SESSION ||--|| FILE_RESERVATION : "1:1 paired" UPLOAD_SESSION ||--o| FILE_ITEM : "creates" FILE_RESERVATION ||--o| FILE_ITEM : "consumed_to" SPACE ||--o{ SHARE_LINK : "owns" SHARE_LINK ||--o{ SHARE_LINK_TARGET : "maps" FILE_ITEM ||--o{ SHARE_LINK_TARGET : "shared_file" FOLDER ||--o{ SHARE_LINK_TARGET : "shared_folder" FILE_ITEM ||--o{ DOWNLOAD_SESSION : "streamed_by" ``` ### 5.3 EF Core Entity Configuration (실제 코드 — 11개 Config 클래스) ``` Configurations/ ├── UserEntityConfiguration.cs ├── SpaceEntityConfiguration.cs ├── SpaceMemberEntityConfiguration.cs ├── SpaceInviteEntityConfiguration.cs ├── FolderEntityConfiguration.cs ├── FileItemEntityConfiguration.cs ├── UploadSessionEntityConfiguration.cs ├── FileReservationEntityConfiguration.cs ├── ShareLinkEntityConfiguration.cs ├── ShareLinkTargetEntityConfiguration.cs └── DownloadSessionEntityConfiguration.cs ``` ### 5.4 설계 특징 | 원칙 | 적용 | |------|------| | **Space 중심 소유** | 파일/폴더/공유의 FK 기준은 User가 아닌 Space | | **행위자와 소유자 분리** | 생성자는 User(crated_by_user_id), 소유자는 Space(space_id) | | **모든 Timestamp 관리** | `created_at`, `updated_at` 모든 테이블 공통 | | **Soft Delete** | `deleted_at TIMESTAMP NULL` — User, Space, Folder, FileItem, SpaceMember, ShareLinkTarget 등 대부분 | | **Unique 제약** | slug, email, storage_key, token, (space_id, parent_folder_id, name) 등 | | **CHECK 제약** | `storage_used_bytes >= 0`, `size_bytes >= 0`, `received_size <= expected_size` | | **Migration 자동 적용** | `RunDatabaseMigrationsOnStartup = true` — EF Core가 시작 시점에 자동 마이그레이션 | ### 5.5 제약 조건 예시 (FileItem) | 제약 | 내용 | |------|------| | PK | `id` | | UNIQUE | `storage_key` | | UNIQUE | `(space_id, folder_id, normalized_name)` on active rows | | CHECK | `size_bytes >= 0` | | FK | `space_id → Space`, `folder_id → Folder`, `created_by_user_id → User` | ### 5.6 데이터 모델링 설계 이유 - **UploadSession과 FileReservation 분리**: 전송 상태 추적(tus I/O)과 비즈니스 자원 선점(quota·파일명)은 변경 주기와 실패 원인이 다르다. 전자는 네트워크, 후자는 도메인 정책. 1:1 연결로 두었다. - **ShareLinkTarget 다대다**: 하나의 링크가 여러 파일/폴더를 가리킬 수 있고, 하나의 파일이 여러 링크로 공유될 수 있다. - **DownloadSession 별도 테이블**: 로그인 세션과 다운로드 스트리밍 토큰은 TTL, revoke 정책, subject_type이 전혀 다르다. --- ## 6. 비즈니스 로직 / UseCase 구조 ### 6.1 UseCase 목록 (실제 구현 코드 기준) | Feature | 인터페이스 | 구현 클래스 | 주요 메서드 | |---------|-----------|-------------|-------------| | Auth | `IUserUseCases` | `UserUseCases` | `RegisterAsync` | | Spaces | `ISpaceUseCases` | `SpaceUseCases` | `CreateAsync` + 생성 정책 | | Members | `ISpaceMemberUseCases` | `SpaceMemberUseCases` | `FindSpacesAsync`, `AddMemberAsync` | | Members | `ISpacePermissionService` | `SpacePermissionService` | `FindAuthorizedSpaceAsync` | | Folders | `IFolderUseCases` | `FolderUseCases` | `CreateAsync`, `ListChildrenAsync`, `UpdateMetadataAsync`, `DeleteAsync`, `ProvisionAsync` | | Files | `IFileUseCases` | `FileUseCases` | `UpdateMetadataAsync`, `DeleteAsync` | | Uploads | `IUploadUseCases` | `UploadUseCases` | `CompleteUploadAsync`, `MarkUploadSessionUploadingAsync` | | Uploads | `IUploadSessionUseCases` | `UploadSessionUseCases` | `AddUploadSessionAsync`, `ReserveUploadSessionAsync` | | Downloads | `IDownloadUseCases` | `DownloadUseCases` | `CreateSessionAsync`, `GetDownloadStreamAsync` | ### 6.2 Command/Query/Result 패턴 각 Feature 디렉토리 구조 (실제 코드 기반): ``` UseCases/{Feature}/ ├── I{Feature}UseCases.cs ├── {Feature}UseCases.cs ├── Commands/ ← CQRS Command (입력 모델) ├── Queries/ ← CQRS Query (조회 입력) ├── Validators/ ← FluentValidation validator ├── Results/ ← UseCase 반환 Result ├── Dtos/ ← 내부 DTO └── Extensions/ ← 변환 헬퍼 ``` ### 6.3 요청 처리 순서 (4단계) ```csharp // 표준 UseCase 패턴: 1. Command/Query 생성 (DTO 매핑) 2. Validation (FluentValidation → .ValidateAsync) 3. 도메인 검증 (Space 상태, 권한, quota, 파일명 충돌 등) 4. Result 반환 (성공 시 Result DTO, 실패 시 CloudSharpError) ``` ### 6.4 Bind 파이프라인 패턴 (업로드) ```csharp // 설계 기준 패턴: return EnsureSpaceExists(spaceId) .Bind(_ => EnsureCanUpload(spaceId, userId)) .Bind(_ => EnsureQuotaAvailable(spaceId, expectedSize)) .Bind(_ => ReserveFileName(command)) .Bind(reservation => CreateUploadSession(command, reservation)); ``` → 각 단계 실패 시 뒤 단계 실행되지 않음. 실패 이유 보존. ### 6.5 API ↔ UseCase 책임 분리 | 계층 | 하는 일 | 하지 않는 일 | |------|---------|-------------| | **Endpoint** | 라우팅, Request DTO → Command 변환, UseCase 호출, Result → HTTP 변환, OpenAPI metadata | DB 직접 조회, 여러 UseCase 혼합, 비즈니스 판단 | | **UseCase** | 비즈니스 흐름, 권한/상태/정책 검증, 트랜잭션 경계, Result 반환 | HTTP 상태 코드 결정, Response DTO 생성, `IResult` 반환 | | **Domain Policy** | 상태 전이 검증, 값 객체 생성, 불변조건 확인 | 인프라 의존성 참조 | --- ## 7. 트랜잭션과 동시성 ### 7.1 트랜잭션이 필요한 기능 Space 생성, 업로드 세션 생성, Finalize 등 **여러 repository가 하나의 원자적 흐름을 이룰 때** UseCase에서 트랜잭션을 시작한다. ### 7.2 트랜잭션 패턴: `IAppDbTransactionFactory` ```csharp await using var tx = await transactionFactory.BeginAsync(ct); var saveResult = await repository.SaveAsync(entity, ct); if (saveResult.IsFailed) { await tx.RollbackAsync(ct); return saveResult.ToResult(); } await tx.CommitAsync(ct); ``` ### 7.3 Finalize 트랜잭션 경계 분리 Finalize는 파일 I/O(time-consuming)와 DB 변경을 분리한다: ``` 🔓 트랜잭션 밖: 1. 임시 파일 검증 (크기, 해시, MIME, quota) 2. 파일 이동 (rename or copy+delete) 🔒 DB 트랜잭션: 3. FileItem INSERT 4. Space.storage_used_bytes += final_size 5. Space.storage_reserved_bytes -= reserved_size 6. FileReservation.status = CONSUMED 7. UploadSession.status = COMPLETED ``` > **설계 판단**: 파일 이동 같은 느린 I/O 동안 DB 락을 잡지 않는다. 단, 이동 성공 후 DB 트랜잭션 실패 시 고아 파일이 생길 수 있으므로 Recovery Worker와 정리 배치가 필요하다. ### 7.4 동시 요청 경쟁 방지 #### Finalize 중복 방지: CAS (Compare-And-Swap) ```sql -- 동시 finalize 요청 차단: 단 하나의 프로세스만 점유 UPDATE upload_session SET status = 'FINALIZING', finalizing_started_at = now(), finalize_attempts = finalize_attempts + 1 WHERE id = :session_id AND status = 'UPLOADING'; -- affected_rows = 1 → 점유 성공 -- affected_rows = 0 → 점유 실패 (이미 다른 프로세스가 처리 중) ``` #### Quota 경쟁 방지: DB row-level lock ```sql -- 동일 Space 동시 업로드 판정: FOR UPDATE로 단일 트랜잭션 원자화 BEGIN; SELECT * FROM space WHERE id = :id FOR UPDATE; -- quota 판정 + reserved += expected_size -- FileReservation 생성 COMMIT; ``` ### 7.5 중복 생성 방지 (이중 안전장치) | 단계 | 방식 | 위치 | |------|------|------| | 1차 | UseCase 사전 검증 (파일명 충돌 확인) | Core | | 2차 | DB UNIQUE 제약 `(space_id, folder_id, normalized_name)` | PostgreSQL | | 3차 | `TrySaveChangesAsync()` — conflict 감지 시 `Result` 실패 | Infra | ### 7.6 Idempotency - 동일 UploadSession에 대한 Finalize 재호출은 CAS로 차단된다. - `affected_rows = 0` → 요청 무시, 기존 상태 반환. - `file_item_id`가 이미 존재하면 `COMPLETED`로 간주하고 별도 처리. ### 7.7 외부 파일 ↔ DB 정합성 처리 Recovery Worker(매 5분)가 `FINALIZING` 상태에 10분 이상 머문 세션을 발견하면: - `FileItem` 이미 존재 → `COMPLETED`로 보정 - 임시 파일 소실 → `FAILED`로 보정 - 임시 파일 존재 + 재시도 가능 → Finalize 재시도 - 최대 재시도 초과 → `FAILED` + 운영 알림 --- ## 8. 예외 처리와 응답 구조 ### 8.1 3계층 실패 전략 | 실패 유형 | 예시 | 처리 | 로그 레벨 | |-----------|------|------|-----------| | **Validation 실패** | `Required` 누락, `MaxLength` 초과 | ASP.NET Core `400 Bad Request` (자동) | 없음 | | **비즈니스 실패** | `QUOTA_EXCEEDED`, `FILE_NAME_CONFLICT`, `SPACE_ACCESS_DENIED` | `Result.Fail(CloudSharpError)` → `ResultHttpMapper`가 HTTP 변환 | 대부분 없음 (Debug). 보안 실패만 Warning | | **시스템 예외** | DB 연결 장애, 버그, OOM | Exception 전파 → `ExceptionHandlingMiddleware`에서 `500` + `ProblemDetails` | Error (1회), 운영에선 stack trace 금지 | ### 8.2 FluentResults + CloudSharpError 패턴 ```csharp // 공통 베이스 public abstract class CloudSharpError : Error { protected CloudSharpError(string errorCode, string message) : base(message) { Metadata.Add("ErrorCode", errorCode); } } // 도메인별 에러 (예시) public sealed class DuplicateEmailError : CloudSharpError { public DuplicateEmailError(string email) : base("USER_DUPLICATE_EMAIL", "이미 사용 중인 이메일입니다.") { } } ``` ### 8.3 Result → HTTP 변환 (`ResultHttpMapper`) `ResultHttpMapper`는 모든 UseCase 실패를 4가지 HTTP 상태로 매핑한다: | ErrorCode 패턴 | HTTP 상태 | |----------------|-----------| | `*_NOT_FOUND` | 404 | | `*_FORBIDDEN`, `*_UNAUTHORIZED` | 403 | | `*_CONFLICT`, `*_DUPLICATE` | 409 | | 나머지 | 400 | ### 8.4 Global Exception Middleware - 처리되지 않은 시스템 예외만 `Error` 레벨로 1회 기록 - 운영 환경에서는 stack trace 출력 금지 (`exception.GetType().Name`만) - Development 환경에서만 디버깅을 위해 stack trace 허용 - 클라이언트에는 `ProblemDetails`로 응답하고 내부 exception message 노출 금지 ### 8.5 로그 레벨 구분 기준 | 분류 | 로그 레벨 | 기준 | |------|-----------|------| | Validation 실패 | 없음 | 정상적 사용자 입력 실패 | | `*_NOT_FOUND` | 없음 | 예상 가능한 비즈니스 결과 | | `*_CONFLICT`, `*_ALREADY_*` | 없음 | 예상 가능한 충돌 | | `*_FORBIDDEN`, 소유권 위반 | **Warning** | 보안 관련 | | `AUTH_PASSWORD_VERIFY_FAILED` | **Warning** | 인증 보안 | | 처리되지 않은 시스템 예외 | **Error** | 예상 못한 장애 | ### 8.6 프론트엔드-백엔드 에러 일관성 - 모든 실패 응답은 `ErrorResponse` 형식(`requestId`, `error.code`, `error.message`, `error.details[]`) - `requestId` = `HttpContext.TraceIdentifier` → 요청 추적 가능 - ErrorCode는 API 문서화된 고정 문자열 → 클라이언트가 분기 처리 가능 - 외부 공개 API는 404로 마스킹 → 정보 누출 최소화 --- ## 9. 인프라 구성 ### 9.1 Docker Compose 서비스 구성 | 서비스 | 이미지 | 역할 | 포트 | |--------|--------|------|------| | **postgres** | `postgres:16-alpine` | 메타데이터 진실 원천 | 내부:5432 | | **redis** | `redis:7-alpine` | 세션 저장소, Pub/Sub | 내부:6379 | | **tusd** | `tusproject/tusd:latest` | tus 프로토콜 청크 업로드 | 내부:1080 | | **api** | `mcr.microsoft.com/dotnet/aspnet:10.0` (직접 빌드) | ASP.NET Core 백엔드 | 내부:8080, 8081 | | **nginx** | `nginx:alpine` (직접 빌드) | Reverse Proxy 단일 진입점 | **외부:8080→80** | ### 9.2 Volume 마운트 구조 | Volume | 마운트 | 용도 | |--------|--------|------| | `postgres_data` | `/var/lib/postgresql/data` | DB 데이터 영속화 | | `redis_data` | `/data` | AOF 기반 Redis 영속화 | | `./storage` (호스트) | `/data/storage` (컨테이너) | API + tusd 공유 파일 저장소 | 파일 저장 디렉토리: ``` /data/storage/ ├── objects/ ← 최종 저장 파일 (shard 기반) │ └── spaces/{shard}/{spaceId}/objects/{s1}/{s2}/{fileKey}.bin └── tmp/tusd/ ← tusd 임시 업로드 파일 ``` ### 9.3 Healthcheck 구성 | 서비스 | Healthcheck | 간격 | |--------|-------------|------| | postgres | `pg_isready` | 10s × 5 retry | | redis | `redis-cli ping` | 10s × 5 retry | | tusd | `wget localhost:1080/health` | 10s × 5 retry | | api (컨테이너) | `curl localhost:8080/api/v1/health` | 30s × 3 retry | | api (앱) | `GET /api/v1/health` | - | ### 9.4 depends_on 조건 ``` api는 postgres(healthy), redis(healthy), tusd(healthy) 이후 시작 nginx는 api, tusd 이후 시작 ``` → `condition: service_healthy`로 단순 실행 순서가 아닌 실제 준비 완료를 기다린다. ### 9.5 포트 노출 정책 | 정책 | 적용 | |------|------| | **외부 노출** | nginx:8080 → host:8080 (단일 진입점) | | **내부 only** | postgres, redis, tusd, api (모두 `expose`만, `ports` 없음) | | **보안 원칙** | DB, Redis, tusd, API는 외부에서 직접 접근 불가. nginx만 유일한 외부 진입점 | ### 9.6 인프라 구성 설계 의도 - **단일 외부 포트(8080)**: 셀프호스트 사용자가 하나의 포트만 열면 된다. 복잡한 포트 매핑 불필요. - **Dockerfile 멀티 스테이지 빌드**: SDK 이미지 → 빌드 → publish → runtime(`aspnet:10.0`). 최종 이미지 크기 최소화. - **HEALTHCHECK `curl`을 위해 apt-get install curl**: ASP.NET 기본 이미지에 curl이 없으므로 런타임 이미지에만 curl을 추가했다. - **tusd hook**: `pre-create`, `post-create`, `post-finish` 이벤트를 API로 전달하여 업로드 생명주기를 백엔드가 통제한다. --- ## 10. Reverse Proxy / 도메인 / HTTPS ### 10.1 nginx 선택 nginx를 리버스 프록시로 사용. alpine 기반 경량 이미지, 단일 설정 파일(`cloudsharp.conf`). ### 10.2 도메인 라우팅 구조 ``` server_name: localhost (→ 운영 시 실제 도메인) listener: 80 /api/* → http://api:8080 (ASP.NET Core API) /files/* → http://tusd:1080/files/ (tusd 업로드) /swagger/* → http://api:8080 (Swagger UI) /openapi/* → http://api:8080 (OpenAPI 문서) ``` ### 10.3 tusd 스트리밍 특수 설정 nginx가 tusd 업로드를 프록시할 때 필요한 특수 설정: ```nginx # tus 관련 헤더 전달 proxy_set_header Tus-Resumable $http_tus_resumable; proxy_set_header Upload-Length $http_upload_length; proxy_set_header Upload-Offset $http_upload_offset; proxy_set_header Upload-Metadata $http_upload_metadata; # 대용량 업로드 client_max_body_size 0; # 무제한 # 스트리밍 버퍼링 해제 proxy_buffering off; proxy_request_buffering off; # 장시간 업로드 타임아웃 proxy_read_timeout 600s; proxy_send_timeout 600s; ``` ### 10.4 HTTPS 적용 - MVP: `ASPNETCORE_ENVIRONMENT=Production`, `UseHttpsRedirection=false` (nginx 뒤에서 HTTP로 통신) - 운영: nginx에 Let's Encrypt/Caddy 적용하거나, Cloudflare Tunnel로 HTTPS 처리 (확인 필요) ### 10.5 Basic Auth - 현재 미적용. 관리자 도구 접근 제한은 확인 필요. --- ## 11. 환경변수와 설정 관리 ### 11.1 .env.example — 전체 변수 구조 (실제 코드) ```bash # 필수: ASP.NET 환경 ASPNETCORE_ENVIRONMENT=Production # PostgreSQL 연결 Postgres__Host=postgres Postgres__Db=cloudsharp Postgres__User=cloudsharp Postgres__Password=cloudsharp Postgres__Port=5432 # Redis 연결 Redis__Host=redis Redis__Port=6379 # Opaque Session Token HMAC 비밀키 Auth__SessionHashKey=<반드시 교체> # Local File Storage Storage__Provider=local Storage__RootPath=/data/storage Storage__TempPath=/data/storage/tmp/tusd Storage__ObjectsPath=/data/storage/objects # tusd 설정 Tusd__Host=tusd Tusd__Port=1080 Tusd__UploadDir=/data/storage/tmp/tusd Tusd__HooksHttp=http://api:8080/internal/tusd/hooks # 내부 Finalize API 인증 토큰 (반드시 설정) Uploads__FinalizeToken= # Finalize Recovery Worker Uploads__FinalizeRecoveryEnabled=true Uploads__FinalizeRecoveryIntervalSeconds=300 Uploads__FinalizeRecoveryStaleMinutes=10 Uploads__FinalizeRecoveryBatchSize=100 # Space 정책 Space__MaxStorageAllowedBytes= (공백 = 무제한) ``` ### 11.2 설정 관리 특징 | 항목 | 방식 | |------|------| | **연결 문자열** | 개별 `Postgres__Host/Port/Db/User/Password` → ASP.NET Options 패턴 바인딩 | | **Secret** | `Auth__SessionHashKey`, `Uploads__FinalizeToken` — 환경변수 주입, 소스코드에 하드코딩 금지 | | **개발/운영 분리** | `ASPNETCORE_ENVIRONMENT` + `.env` 파일. `docker-compose.yml`은 기본값 제공 | | **컨테이너 내부 주소** | compose가 서비스명을 DNS로 resolve (`postgres`, `redis`, `tusd`) | | **하드코딩 제거** | 모든 연결 정보·파일 경로·정책 값은 환경변수 or `appsettings.json`에서 주입 | ### 11.3 ASP.NET Options 바인딩 `appsettings.json` + 환경변수 오버라이드 (`Postgres__Host` → `Postgres:Host`). `IOptions` / `IConfiguration` 패턴 사용. --- ## 12. 로그 / 모니터링 / 운영 ### 12.1 로그 철학: "중복 없이 검색 가능하게" **한 줄 기준:** Middleware(기본 관측성) + GlobalExceptionHandler(예외 1회) + UseCase(보안 Warning, 감사 Information만) ### 12.2 계층별 로깅 정책 | 위치 | 레벨 | 책임 | |------|------|------| | **Middleware** | Information | 요청당 1회: `method/path/statusCode/elapsedMs/traceId/userId` | | **GlobalExceptionHandler** | Error | 예상 못한 exception만 1회, stack trace 없음(Production) | | **UseCase Activity Proxy** | Debug | 모든 `I*UseCases` 메서드 완료 자동 기록 (디버깅/추적) | | **UseCase 직접** | Warning / Information | 보안 실패(Warning), 감사 이벤트(Information) | | **Repository** | 없음 | 기본 로깅 안 함 | | **EF Core** | Warning (Prod) | SQL command 로깅은 Development만 | ### 12.3 구조화 로그 — 영어 고정 템플릿 + named placeholder ```csharp // 좋음: logger.LogWarning( "Space access denied userId={UserId} spaceId={SpaceId} permission={Permission} errorCode={ErrorCode}", userId, spaceId, permission, ErrorCodes.SpaceForbidden.Code); // 금지: logger.LogWarning($"Space access denied userId={userId} ..."); // 문자열 보간 ``` ### 12.4 금지 사항 - 토큰/body/Authorization header 로깅 - 운영/공유 환경에서 `logger.LogError(exception, ...)` 사용 (stack trace 노출) - validation 실패를 Warning으로 남기기 (노이즈) - repository에서 DB 장애를 Result로 감싸기 (시스템 장애 감지 흐려짐) ### 12.5 운영 도구 | 도구 | 상태 | |------|------| | Grafana / Loki / Dozzle | 확인 필요 (설계서에서 언급만 됨) | | OpenTelemetry / 분산 추적 | 예정 (확장 로드맵) | ### 12.6 장애 확인 방법 - `GET /api/v1/health` (Docker HEALTHCHECK) - nginx access log - ASP.NET 기본 로그 + 구조화 로그 - Recovery Worker가 `FINALIZING` stale 세션 보고 --- ## 13. CI/CD / 배포 자동화 ### 13.1 CI 플랫폼: GitLab CI/CD Pipeline은 Merge Request + Default Branch Push에만 트리거. ### 13.2 Stage 구성 ``` test → build → pr_review → deploy ``` ### 13.3 Backend CI (`backend:test`) ```yaml # 실제 코드 image: mcr.microsoft.com/dotnet/sdk:10.0 script: - dotnet restore - dotnet test tests/CloudSharp.Core.Tests # 단위 테스트 - dotnet test tests/CloudSharp.Infrastructure.Tests # 인프라 테스트 ``` > **현재 범위**: Core.Tests + Infrastructure.Tests. API IntegrationTests, Architecture.Tests는 파이프라인에 미포함. ### 13.4 Docker Image Build (`backend:image`) ```yaml # 실제 코드 extends: .docker-sock + .ghcr-login script: - docker build -f Dockerfile → ghcr.io/cloud-sharp/cloudsharp-backend:$CI_COMMIT_SHA + :latest - docker push to GHCR trigger: master push only, backend 변경 시 ``` ### 13.5 Nginx CI (`nginx:test` + `nginx:image`) - `nginx -t` 구문 검증 - 이미지 빌드 → `ghcr.io/cloud-sharp/cloudsharp-nginx` - trigger: `infra/nginx/**` 변경 시 ### 13.6 배포 (Deploy) ```yaml # 현재: Placeholder script: echo "Deploy stage placeholder. Real deployment will be added later." ``` - GHCR 이미지 배포용 `docker-compose.ghcr.yml` 파일은 존재. - 실제 자동 배포는 미구현. ### 13.7 브랜치 전략 - `master`: Default Branch → push 시 이미지 빌드 - `feat/*` → Merge Request 시 테스트 실행 - PR Review Job: 별도 포함 (`.gitlab/ci/pr_review.yml`) --- ## 14. 성능 최적화 ### 14.1 실제 적용 사항 | 기법 | 적용 위치 | 확인 | |------|-----------|------| | **Pagination** | `GET /spaces` (page=1, pageSize=20/50/100), `GET /folders/{id}/children` | 코드 확인 | | **AsNoTracking** | 조회 전용 쿼리에서 사용 권장 (EF Core 컨벤션) | 확인 필요 | | **Projection** | 도메인 객체 → DTO 변환 시 `Map()` 사용, 필요한 필드만 반환 | 디자인 확인 | | **Storage Sharding** | 파일 저장 경로: 256(Space) × 256×256(File) = 65,536 버킷 분산 → 디렉터리 과밀 방지 | 전략 문서 | | **sendfile / X-Accel-Redirect** | 다운로드 파이프라인 설계에 언급 (운영 시 커널 레벨 전송) | 예정 | ### 14.2 다운로드 Range 지원 설계 - `Range: bytes=N-M` 단일 Range 지원 (206 Partial Content) - `If-Range` + `ETag` 지원 → 파일 변경 시 전체 재전송 - multi-range (`Range: bytes=0-99,200-299`) 미지원 → 416 또는 전체 응답 ### 14.3 성능 관련 확인 필요 - Include/N+1 문제 처리 (EF Core configuration) - Index 적용 현황 - Redis 응답 캐시 - 실제 응답 시간 측정 --- ## 15. 설계 문서화 ### 15.1 문서 체계 ``` docs/.llm/ ├── INDEX.md ← LLM 개발의 단일 지식 허브 ├── context/ ← 프로젝트 배경 │ ├── overview.md 서비스 개요, 핵심 컨셉, 기술 스택 │ ├── architecture.md Mermaid 포함 전체 구성도, 기술 선택·확장 방향 │ ├── domain.md 핵심 도메인 엔티티 요약 + 설계 원칙 │ ├── requirements.md 48개 기능 요구사항 (SFR-001 ~ SFR-048) │ ├── directory.md 모노레포 전체 디렉토리 구조 │ ├── product-plan.md 기획서 │ └── space-architecture.md 멀티 클라우드 확장 아키텍처 ├── design/ ← 기술 설계 │ ├── api.md 모든 Endpoint, Request/Response DTO, 구현 상태 │ ├── erd.md Mermaid ERD + 11개 엔티티 상세 컬럼 │ ├── openapi.yaml OpenAPI 3.x 전체 스펙 │ ├── pipelines/ │ │ ├── upload.md 업로드 파이프라인 (Mermaid 시퀀스·상태 다이어그램) │ │ └── download.md 다운로드 파이프라인 (8단계 상세) │ └── strategies/ │ ├── finalize-lock.md CAS 기반 Finalize 설계 │ ├── quota.md Quota 판정 공식 + 상태 전이 │ ├── storage.md Sharding 전략, 버킷 전략, Provider 추상화 │ └── ... 파일명 충돌, 세션 정책, 정리 배치 등 ├── conventions/ ← 코딩 규칙 (14개) │ ├── auth-policy.md Opaque Session Token + Space Role 인가 │ ├── error-handling.md FluentResults + CloudSharpError │ ├── http-endpoints.md Minimal API Endpoint 작성 규칙 │ ├── persistence.md 트랜잭션 + TrySaveChangesAsync │ ├── logging.md 로그 레벨·메시지·금지 규칙 │ ├── usecases-coding.md UseCase 인터페이스·구현·DI │ ├── validation.md FluentValidation 규칙 │ ├── testing.md NUnit + Bogus 테스트 컨벤션 │ └── ... └── wiki/ ← 살아있는 개발 기록 ├── progress.md 기능별 구현 상태 트래커 ├── decisions.md ADR (3건: API 구조, UseCase 구조, Endpoint 방식) └── notes.md gotcha, 주의사항 ``` ### 15.2 다이어그램 현황 | 유형 | 포함 | 도구 | |------|------|------| | **ERD** | O | Mermaid (`erd.md`) | | **아키텍처 구성도** | O | Mermaid (`architecture.md`) | | **상태 전이 다이어그램** | O (UploadSession, FileReservation, FileItem) | Mermaid | | **시퀀스 다이어그램** | O (업로드, Finalize, 다운로드, 중복 차단, Recovery) | Mermaid | | **배포 구조도** | O (Docker Compose 서비스 관계, nginx 라우팅) | Mermaid + 문서 | | **ADR** | O (3건: 2026-04-23 ~ 2026-04-25) | `wiki/decisions.md` | | **기술 선택 이유** | O (`architecture.md`에 MVP 선택 근거 + 확장 방향 포함) | 표 | ### 15.3 기술 선택 이유 문서화 `architecture.md`에 **모든 기술 스택의 MVP 채택 이유 + 추후 확장 방향**이 표로 정리되어 있다. (React부터 ASP.NET, tusd, PostgreSQL, Redis, Local FS, ffmpeg, AI Worker까지.) --- ## 16. 문제 해결 사례 후보 ### 16.1 업로드 Finalize 중복 실행 방지: CAS Lock #### 문제 상황 - tusd 업로드 완료 후 hook이 API로 전달됨 - 여러 Worker 또는 hook 중복 호출 시 같은 업로드 세션이 두 번 FileItem으로 생성될 위험 - 분산 환경에서는 분산 락을 잡기도 어렵고, 파일 이동이라는 느린 I/O를 포함 #### 원인 분석 - Hook 콜백 + 백그라운드 Finalize 두 경로에서 동시에 같은 세션을 처리할 수 있음 - 파일 이동(rename)은 원자적이지만, DB 반영 전까지는 상태가 모호함 #### 해결 방법 - **CAS(Compare-And-Swap)**: `UPDATE upload_session SET status = 'FINALIZING' WHERE status = 'UPLOADING'` - 하나의 SQL UPDATE로 원자적 점유. `affected_rows = 1` → 성공, `0` → 이미 처리 중 - 파일 I/O와 DB 트랜잭션을 분리하고, DB는 짧은 트랜잭션만 유지 - Recovery Worker가 10분 이상 `FINALIZING`에 머문 세션을 자동 정리 #### 선택 이유 - 별도 분산 락(Redis Redlock, ZooKeeper) 인프라 불필요 - UploadSession의 기존 상태 머신을 락으로 재활용 - 단순하고 예측 가능한 거버넌스 #### 결과 - **안전성**: CAS + DB UNIQUE로 이중 중복 방지 - **단순성**: 추가 인프라 없이 SQL 한 라인 - **복구성**: Recovery Worker가 교착 상태 자동 해제 --- ### 16.2 Space Quota 경쟁 조건 방지 #### 문제 상황 - 한 Space의 여러 멤버가 여러 탭/클라이언트에서 동시에 대용량 업로드를 시작 - quota 판정(`expected <= available`)과 reserved 증가가 분리되면, 모든 요청이 동시에 통과하는 race condition #### 원인 분석 - 판정 시점과 reserved 증가 시점 사이의 간극에서 동시 요청이 통과 - 낙관적 동시성으로는 업로드 거절 시점에 이미 클라이언트가 전송 중일 수 있음 #### 해결 방법 - **DB 트랜잭션 + row-level lock**: `SELECT ... FOR UPDATE`로 Space 행 잠금 - 판정 → reserved 증가 → FileReservation 생성을 **하나의 트랜잭션**으로 원자화 - Finalize 직전에도 quota 재검사 (안전장치) #### 선택 이유 - 업로드는 자주 발생하지 않으므로(수명이 긴 세션), 경합에서의 락 지속시간이 짧다 - PostgreSQL FOR UPDATE는 격리 수준을 높이지 않고도 충분 #### 결과 - Quota 불변조건(`used + reserved <= allowed`)이 항상 유지됨 - 초과 업로드는 조기에(업로드 시작 전) 거절 → 불필요한 네트워크 전송 방지 --- ### 16.3 파일명 충돌 정책: "실패 반환" #### 문제 상황 - 클라우드 스토리지에서 동일 폴더에 같은 이름의 파일이 업로드될 때 Google Drive 등의 "자동 이름 변경(파일명(1).pdf)"이 혼란을 줄 수 있음 #### 원인 분석 - 자동 rename은 의도를 숨기고, 사용자가 모르는 파일이 누적됨 - 충돌은 드물게 발생하며, 사용자 명시적 의사결정이 필요 #### 해결 방법 - **실패 반환 정책** 채택: `FILE_NAME_CONFLICT` 에러 코드 반환 - 동일 폴더 내 활성 `FileItem` + 활성 `FileReservation`을 함께 검사 (사전 + Finalize 직전 이중 검증) - 충돌 감지에 `normalized_name` 사용 #### 선택 이유 - "모호함보다 명시적 실패가 낫다"는 설계 철학 - 사용자가 의도적으로 파일명을 바꾸도록 유도 - 구현 단순성 (rename fallback, 증분 카운터 등 불필요) #### 결과 - **일관성**: 항상 예측 가능한 동작 - **데이터 정합성**: 사용자 의도와 저장된 파일명이 항상 일치 --- ### 16.4 Opaque Session Token vs JWT #### 문제 상황 - 사용자가 여러 Space에서 서로 다른 Role을 가짐 - Role 변경 시 JWT 만료 전까지 stale 권한이 유지되는 문제 #### 원인 분석 - JWT에 Role claim을 포함하면 토큰 만료 전까지 기존 권한이 남음 - 블랙리스트/토큰 버전 관리가 필요해지면 JWT의 stateless 장점이 약화됨 #### 해결 방법 - **Opaque Session Token**: 무작위 문자열. 권한 정보 없음. - Redis에 `token_hash` → `UserSession` 저장 - 인가 판단은 매 요청마다 DB에서 최신 `SpaceMember` 조회 #### 선택 이유 - CloudSharp의 "한 사용자 = 여러 Space × 여러 Role" 모델에 적합 - stale 권한 문제 원천 차단 - 토큰 탈취 시 정보 노출 최소화 #### 결과 - Role 변경이 **다음 API 요청부터 즉시 반영** - 로그아웃·강제 Revoke·계정 정지 즉시 적용 - 토큰 prefix로 운영자 구분만 가능 (`cs_sess_...`, `cs_dl_...`, `cs_share_...`) --- ## 17. 포트폴리오 문장 초안 ### 프로젝트 개요 > Cloud#은 Space 단위의 완전히 격리된 팀 파일 저장 공간을 제공하는 셀프호스트 서비스입니다. tus 프로토콜 기반의 재개 가능한 대용량 업로드, 단명 세션 기반의 안전한 다운로드, 그리고 내부 협업과 외부 공유를 분리한 2계층 공유 모델을 갖추고 있습니다. ASP.NET Core 10 + PostgreSQL + Redis + tusd + Docker Compose 스택 위에 Clean Architecture를 적용했습니다. ### 담당 역할 > 백엔드 API 전체(인증·인가, Space 관리, 파일·폴더 CRUD, 업로드/다운로드 파이프라인, Quota 정책, 공유 링크), 데이터베이스 설계(ERD + EF Core Configuration + Migration), 인프라 구성(Docker Compose 5-서비스 오케스트레이션, nginx Reverse Proxy, Volume 설계, Healthcheck), CI/CD 파이프라인(GitLab CI), 그리고 Clean Architecture 기반 도메인 모델링과 UseCase 설계. ### 주요 기여 - **Space 중심 멀티 테넌트 아키텍처 설계**: 파일 소유권을 User에서 Space로 승격하고, SpaceMember Role 기반 권한 체계 구축 - **업로드 파이프라인 설계**: UploadSession(전송 추적)과 FileReservation(자원 선점)을 분리한 2-Entity 업로드 모델, CAS 기반 Finalize 중복 방지 메커니즘 - **Opaque Session Token 인증 시스템**: JWT가 아닌 opaque token을 선택하고, Redis 기반 세션 관리 + HMAC 토큰 해싱 + sliding renewal 정책 구현 - **Zero Information Leak 다운로드 보안**: 외부 API에서 리소스 존재 여부를 404로 통일 마스킹, 단명 5분 TTL DownloadSession - **Storage Sharding 전략**: hex hash prefix 기반 3단계 shard(space 2자리 + file 2+2자리) → 65,536 버킷 분산, Local FS ↔ S3/MinIO 공통 storage_key 추상화 ### 사용 기술 및 선택 이유 | 기술 | 선택 이유 | |------|-----------| | **ASP.NET Core 10 + Minimal APIs** | 빠른 부트스트랩, Controller 오버헤드 없음, .NET 10 최신 런타임 | | **PostgreSQL 16** | 메타데이터(릴레이션, FK, Unique)에 최적, JSON 확장성 | | **Redis 7** | 세션 저장소(빠른 Hash 조회) + Pub/Sub 이벤트 버스 겸용 | | **tusd (Go)** | tus 표준 구현체, ASP.NET보다 대용량 청크 업로드에 효율적 | | **EF Core 10 + Npgsql** | PostgreSQL Native driver, Migration 자동화 | | **FluentResults** | 예외 아닌 비즈니스 실패 명시적 표현, Bind/Map/Merge 파이프라인 | | **FluentValidation** | 선언적 도메인 검증, .WithErrorCode() 표준화 | | **nginx** | 리버스 프록시, tus 헤더 포워딩, 버퍼링 해제, 단일 진입점 | | **Docker Compose** | 셀프호스트 배포 간소화 | | **Clean Architecture** | 계층 분리로 테스트 용이성 + 장기 유지보수성 확보 | ### 아키텍처 설계 > Space를 중심으로 한 모듈러 모놀리스 Clean Architecture를 설계했습니다. `Api → Core ← Infrastructure` 의존성 방향을 통해, 도메인 로직이 HTTP나 DB, 파일시스템을 전혀 알지 못하게 했습니다. 업로드와 다운로드라는 핵심 파이프라인은 각각 전용 설계 문서화(state machine + Mermaid sequence/flowchart)와 함께, UseCase 계층에서 Bind 파이프라인으로 조합했습니다. nginx → API / tusd 분리로 업로드 plane과 API plane을 독립적으로 확장할 수 있습니다. ### API 설계 > Space의 URL 식별자로 UUID 기반 `spaceSlug`를 사용하고, 내부 Core 계층은 DB의 bigint PK(`spaceId`)만 사용하는 경계 변환 정책을 적용했습니다. `RequireSpacePermissionFilter` 엔드포인트 필터가 slug → spaceId + 권한 컨텍스트로 변환하며, IDOR 방어는 UseCase에서 리소스의 `space_id`를 재검증합니다. 모든 엔드포인트는 OpenAPI metadata(`WithName`, `WithSummary`, `Produces`)가 의무화되어 있습니다. ### 인프라 구성 > 5-서비스 Docker Compose 오케스트레이션을 구성했습니다. postgres, redis, tusd는 `expose`로 내부 네트워크에만 노출하고, nginx만 유일한 외부 진입점(8080)으로 두었습니다. 모든 서비스는 Healthcheck를 가지며, API는 postgres, redis, tusd의 healthy 조건을 충족한 후에 시작합니다. API 이미지는 Multi-stage build(SDK → build → publish → runtime)로 크기를 최소화했습니다. ### 문제 해결 사례 > 1. **Finalize 중복 실행 방지**: CAS(Compare-And-Swap) `UPDATE ... WHERE status='UPLOADING'`을 통해 분산 락 없이 원자적 점유를 구현하고, Recovery Worker로 교착 상태를 자동 복구했습니다. > 2. **Quota 경쟁 조건**: PostgreSQL `SELECT ... FOR UPDATE`로 Space 행을 잠그고, 판정 → 예약 증가를 단일 트랜잭션으로 원자화했습니다. > 3. **stale 권한 문제**: JWT 대신 Opaque Session Token을 선택하여, Role 변경이 다음 API 요청부터 즉시 반영되게 했습니다. > 4. **파일명 충돌**: "자동 rename"보다 "명시적 실패 반환"을 선택하여 사용자 의도와 저장 상태의 일관성을 보장했습니다. ### 프로젝트 성과 - 12개 엔티티의 관계형 데이터 모델 완성 + Mermaid ERD + 모든 엔티티에 대한 EF Core Configuration - 48개 기능 요구사항(SFR-001~048)을 OpenAPI + UseCase 매핑으로 연결 - 업로드/다운로드 양대 파이프라인의 완전한 상태 머신 설계 (UploadSession 7상태, FileReservation 6상태) - 14개 컨벤션 문서로 팀 코딩 표준 구축 - GitLab CI로 PR 테스트 자동화 + Docker 이미지 GHCR 배포 자동화 ### 회고 > **잘한 점**: 초기부터 엄격한 문서화(API, ERD, Conventions, ADR)를 하고, Clean Architecture로 도메인 경계를 나눈 것이 기능 확장 시 큰 도움이 되었다. JWT 대신 Opaque Session Token을 선택한 것은 CloudSharp의 멀티 Role 모델에 딱 맞는 판단이었다. > > **아쉬운 점**: API IntegrationTests와 Architecture Tests가 CI에 포함되지 않았다. 운영 환경의 모니터링(Grafana, OpenTelemetry)이 아직 설계에만 머물러 있다. 자동 배포(CD)가 placeholder 상태다. Worker(ffmpeg, AI) 프로젝트는 구조만 있고 실제 구현은 진행되지 않았다. > > **향후 계획**: MinIO/S3 Storage Provider 구현, 실제 Worker 붙이기, Kubernetes 전환, OpenSearch 도입.