# CloudSharp — 백엔드 · 인프라 · 설계 포트폴리오 초안 > 본 문서는 `C:\Users\SSAFY\irukseo\프롬프트\백엔드 + 인프라 + 설계 프로젝트 포트폴리오 정보 추출 요청.md`의 16개 항목을 기준으로 CloudSharp 저장소를 분석한 결과입니다. 기술 면접·포트폴리오 상세 설명용 한국어 서술형 초안으로, 압축하지 않고 근거 파일 경로와 짧은 코드 인용을 함께 남깁니다. > > 상태 표기 규칙 > - **구현됨**: 실제 코드에서 동작이 확인됨 > - **설계/문서**: 문서/ADR/이슈에 명시되어 있으나 코드 미구현 또는 부분 구현 > - **개선 여지**: 현재 구현은 동작하지만 운영/확장 관점에서 한계가 보이는 지점 --- ## 0. 한 줄 요약 > **"Space 단위로 격리된 자가호스팅 클라우드 스토리지를, Nextcloud 대비 2.6배 빠른 업로드/3.86배 빠른 다운로드로 제공한다"** — Nextcloud 동종 벤치마크에서 측정한 수치(`docs/nextcloud-transfer-benchmark.md`)를 README 헤드라인에 그대로 박은 점이 차별화 포인트입니다. --- ## 1. 프로젝트 목적 ### 1.1 해결하려는 문제 자가호스팅 클라우드 스토리지(Self-Hosted Cloud Storage)는 Nextcloud / ownCloud 같은 OSS로 이미 존재하지만, 다음 두 가지 운영/UX 불편이 남아 있습니다. 1. **느린 전송 속도**: 대용량 파일 업로드/다운로드에서 PHP-기반 웹 서버(Apache + mod_php)의 한계로 체감 속도가 떨어집니다. 2. **AI·MCP 통합의 부재**: 자가호스팅 환경에서 모델·에이전트(MCP/Claude 등)가 자신의 파일에 안전하게 접근하기 어렵습니다. CloudSharp는 (1)을 Kestrel + 단일 PATCH 업로드 + 동일-오리진 nginx로 해결하고, (2)을 위한 **MCP 토큰(`cs_mcp_*` Bearer)**과 **MCP Console(stdio 브리지, `apps/mcp-console/`)**을 1차 MVP에 포함시킵니다. ### 1.2 주요 사용자 | 페르소나 | 시나리오 | |---|---| | **개인/소규모 팀 운영자** | 자가호스팅 NAS/미니PC에 Docker Compose 한 줄로 띄움 | | **팀원** | Space 단위로 파일 업로드/공유, 다른 Space의 권한과 격리 | | **AI/에이전트** | MCP 토큰을 받아 Space 안의 폴더/파일을 표준 tool로 탐색/검색/다운로드 | | **관리자(ADMIN)** | 사용자 상태, Space 상태·쿼터, 시스템 헬스 체크 | ### 1.3 프로젝트 목표 - **Space 단위 격리 + 역할 기반 권한**: 한 User가 여러 Space에 다른 역할(Owner/Admin/Member/Viewer)로 속함. - **대용량·재개 가능한 업로드**: tus 프로토콜 + 청크 업로드 + 서버 측 finalize 보장. - **부분 실패 복원력**: `FINALIZING` 상태에 갇힌 업로드를 별도 복구 워커가 청소. - **알림·실시간 fan-out**: 도메인 이벤트를 Outbox로 발행 → SSE/Realtime + Notification Projection + Worker 3-타깃 분기. - **관측 가능한 운영**: 구조화 로그, 헬스체크, 일관된 에러 응답. ### 1.4 백엔드가 담당하는 핵심 책임 | 책임 | 위치 | 비고 | |---|---|---| | 도메인 모델 + UseCase 인터페이스 | `apps/backend/src/CloudSharp.Core/` | `Core`는 EF/ASP 의존 없음 | | 영속화 + 외부 시스템 어댑터 | `apps/backend/src/CloudSharp.Infrastructure/` | Npgsql, Redis, LocalFileStorage, AI, Messaging | | HTTP/실시간 진입점 | `apps/backend/src/CloudSharp.Api/` | Minimal API, Opaque 토큰 인증, BackgroundService | | 재처리(썸네일, 메타데이터, 검색 인덱싱) | `apps/backend/src/CloudSharp.MediaWorker/` | 별도 프로세스, Redis Pub/Sub 잡 수신 | | 업로드 청크 수신 | `tusd` (외부) | hooks로 API와 통합 | | 빌드/푸시/CI | `.gitlab-ci.yml`, `.gitlab/ci/*` | GHCR 빌드 + Portainer redeploy webhook | | 배포 토폴로지 | `infra/docker-compose.yml` | 6-서비스 same-origin 스택 | ### 1.5 인프라 구성이 필요한 이유 세 가지 외부 상태가 코드만으로 다루기 어렵기 때문입니다. 1. **tusd의 파일 디렉터리와 API의 objects 디렉터리가 같은 호스트의 같은 경로여야** finalize 시 `MoveTempToFinalAsync`가 가능 → `CloudSharp__Uid/Gid` 통일 + `init-storage` 컨테이너가 동일 UID로 chmod. 2. **PostgreSQL/Redis가 컨테이너로 기동 + 호스트에 데이터 보존** → named volume + healthcheck + depends_on 체인. 3. **외부 노출은 단일 nginx origin** → CORS 회피, TLS 종료를 호스트(또는 그 앞단 LB)에 위임, 컨테이너는 HTTP 80으로만 통신. ### 1.6 한 줄 설명 (이력서용) > 자가호스팅 파일 저장소를 Space 단위 RBAC + tus 재개 업로드 + 트랜잭셔널 Outbox + SSE 실시간 fan-out으로 설계·구현한 ASP.NET Core 10 백엔드 프로젝트. 인프라까지 same-origin Docker Compose/nginx/GHCR/Portainer로 end-to-end 운영 가능. --- ## 2. 전체 아키텍처 ### 2.1 모놀리식/모듈러 모놀리식/마이크로서비스 여부 **모듈러 모놀리식 + 외부 분리 컴포넌트**입니다. 한 개의 ASP.NET 10 바이너리(`CloudSharp.Api`)에 모든 도메인 API가 들어 있고, **세 가지 외부 컴포넌트**만 분리돼 있습니다. - **tusd** — 대용량 청크 업로드 전담(파일 I/O) - **CloudSharp.MediaWorker** — 썸네일/메타데이터/검색 인덱싱(Redis Pub/Sub로 작업 수신) - **nginx** — 단일 오리진 reverse proxy(컨테이너 외부 노출은 nginx만) 내부적으로는 **세 개의 .NET 프로젝트**(`Api`, `Core`, `Infrastructure`)로 Clean Architecture를 강제하고, **BackgroundService 3종**(Outbox 처리, Trash 자동 purge, Upload finalize recovery)을 같은 바이너리 내에서 `IHostedService`로 운영합니다. ### 2.2 구성 요소 ```mermaid graph TB Browser[Browser / MCP Host] Nginx[nginx :8080 same-origin] Frontend[React 19 SPA :80] Api[CloudSharp.Api :8080] Tusd[tusd :1080] Mw[CloudSharp.MediaWorker] Pg[(PostgreSQL 16)] Redis[(Redis 7)] FS[/Host bind mount
./storage/objects/] Browser -->|HTTPS LB → :8080| Nginx Nginx -->|/| Frontend Nginx -->|/api/, /public/, /swagger/| Api Nginx -->|/files/| Tusd Tusd -->|/internal/tusd/hooks| Api Api --> Pg Api --> Redis Api --> FS Api -. Redis Pub/Sub .-> Mw ``` ### 2.3 외부 요청이 내부로 전달되는 흐름 1. 클라이언트(브라우저/MCP 호스트)는 `https:///<...>` 단일 오리진으로만 요청. 2. nginx가 path-prefix로 분기: - `/` `/assets/` → `frontend:80` - `/api/`, `/public/`, `/swagger/`, `/openapi/` → `api:8080` - `/files/` → `tusd:1080/files/` 3. `/api/v1/spaces/{slug}/upload-sessions`로 업로드 세션 생성 → 응답의 토큰을 tus의 `Upload-Metadata`에 base64로 실어 `POST /files/`. 4. tusd가 청크 PATCH 수신 후 `post-finish` hook을 `api:8080/internal/tusd/hooks`로 호출. 5. API가 finalize(상태천이 → storage move → DB 트랜잭션 → outbox enqueue) 처리. 6. `FileFinalized` outbox 이벤트가 `cloudsharp:worker:jobs` Redis 채널로 publish → MediaWorker가 구독해 썸네일/메타데이터/검색 색인화. ### 2.4 서비스 간 책임 분리 | 컴포넌트 | 책임 | 비책임 | |---|---|---| | `nginx` | TLS 종단, 경로 라우팅, 헤더 전달, `/files/`의 streaming passthrough | 인증, 비즈니스 로직 | | `tusd` | 청크 수신/병합, 재개 가능 offset 유지, `pre/post-create/finish` hook | 파일 의미 해석, 메타데이터 영속화 | | `CloudSharp.Api` | 도메인 로직, 인증/인가, Outbox 발행, finalize 오케스트레이션, SSE | ffmpeg/magika 같은 무거운 미디어 처리 | | `CloudSharp.MediaWorker` | 썸네일, AI 메타데이터, 검색 인덱스 갱신 | HTTP API 노출 없음, Outbox도 직접 소비하지 않음(API가 publish) | | `PostgreSQL` | 도메인 truth | 캐시/세션/Pub-Sub | | `Redis` | 세션 토큰, 다운로드 세션, MCP 토큰 해시, outbox lease, worker pubsub | 영속 데이터 | ### 2.5 외부 공개 vs 내부 서비스 | 노출 여부 | 서비스 | 경로/포트 | |---|---|---| | **외부 공개(호스트 포트)** | nginx | `${Nginx__Port:-8080}:80` | | **컨테이너 네트워크만 노출** | frontend, api, tusd, postgres, redis | `expose`만 사용, 호스트 포트 미매핑 | | **내부 finalize(같은 컨테이너 네트워크)** | `/internal/tusd/hooks`, `/api/internal/uploads/*` | `X-CloudSharp-Internal-Token` 헤더, `Uploads__FinalizeToken`(운영에서 `:?` 필수) | ### 2.6 아키텍처 설계 의도 - **도메인 이벤트를 모르는 다운스트림에 강결합시키지 않기 위해 Outbox 사용** — SSE/Realtime, 알림, 외부 Worker가 같은 이벤트를 서로 다른 형태로 소비. - **DB가 진실의 원천**이라 storage move를 먼저 하고 DB를 마지막에 commit. 실패 시 `MoveFinalToTempAsync`로 보상, 그래도 실패하면 `FINALIZE_STORAGE_RESTORE_FAILED` 로그 + `MarkSessionFailedAsync`로 끊어 release. - **컨테이너 외부 노출은 1개**로 줄여 CORS/TLS 표면을 최소화. - **`init-storage` one-shot 컨테이너**가 `/data/storage` 디렉터리를 UID 10001로 미리 만들고 종료 → API/tusd가 같은 UID로 읽고 쓸 수 있게 함. 근거: `docs/.llm/design/erd.md`, `docs/.llm/design/pipelines/upload.md`, `docs/.llm/design/strategies/finalize-lock.md`, `docs/아키텍처.md`, `infra/docker-compose.yml`, `infra/nginx/cloudsharp.conf`. --- ## 3. API 설계 ### 3.1 라우팅 컨벤션 - **컨트롤러 없음**. ASP.NET Core 10 Minimal API + Feature 폴더(`Endpoints/{Feature}/`). - 라우트 prefix: - 내부 API: `/api/v1/{feature}` - 익명 공개: `/public/v1/{feature}` - 내부 tusd: `/internal/tusd/hooks` - 내부 finalize: `/api/internal/uploads/*` - 핸들러 시그니처 순서: `Request DTO` → `IValidator` → use case interface → `HttpContext` → `CancellationToken`(항상 마지막). - 4-step 흐름: `ValidateAsync` → Command/Query 매핑 → 단일 UseCase 호출 → `ResultHttpMapper`로 HTTP 응답. 근거: `docs/.llm/conventions/http-endpoints.md`, `apps/backend/src/CloudSharp.Api/Program.cs:403-427`. ### 3.2 주요 엔드포인트 표 | Feature 그룹 | Prefix | Method/Path (대표) | 인증/인가 | |---|---|---|---| | Auth | `/api/v1/auth` | POST `/register`, `/login`, `/logout` | 익명/세션 | | Me | `/api/v1/me` | GET | 세션 | | Admin | `/api/v1/admin/*` | GET users/spaces, PATCH status/quota | ADMIN | | Spaces | `/api/v1/spaces` | GET/POST/PATCH/DELETE, GET `/{slug}/quota` | 세션 + Space 권한 | | Members | `/api/v1/spaces/{slug}/members` | GET/PATCH/DELETE/POST `/leave` | Space 권한 | | Invites | `/api/v1/spaces/{slug}/invites`, `/api/v1/invites/{token}` | GET/POST/DELETE/POST 수락 | Space 권한/세션 | | Folders | `/api/v1/spaces/{slug}/folders` | GET children, POST/PATCH/DELETE | Space 권한 | | Files | `/api/v1/spaces/{slug}/files` | GET, PATCH, DELETE, POST download-session, GET thumbnail | Space 권한 | | Search | `/api/v1/spaces/{slug}/search` | GET | `ReadFiles` | | Tags | `/api/v1/spaces/{slug}/tags` | GET/POST/PATCH/DELETE | `ManageTags` 등 | | Trash | `/api/v1/spaces/{slug}/trash/files` | GET list, POST `/{id}/restore` | `ReadFiles` | | Upload Sessions | `/api/v1/spaces/{slug}/upload-sessions` | POST, GET `/{token}` | `UploadFile` | | Uploads (internal) | `/api/internal/uploads` | POST `/uploading`, POST `/finalize` | `X-CloudSharp-Internal-Token` | | Tusd Hooks | `/internal/tusd/hooks` | POST 4종 hook | (hook 내부에서 사용자 세션 재검증) | | ShareLinks | `/api/v1/spaces/{slug}/share-links`, `/api/v1/share-links`, `/public/v1/share-links/*` | GET/POST/PATCH/DELETE, POST verify/browse/download-sessions | 세션 + Space 권한 / 익명 | | Downloads | `/public/v1/download-sessions/{sessionToken}/stream` | GET (Range) | 익명(URL 토큰) | | Events (SSE) | `/api/v1/events/stream` | GET text/event-stream | 세션 | | Notifications | `/api/v1/notifications` | GET, GET `/unread-count`, POST `/read-position` | 세션 | | McpTokens | `/api/v1/mcp-tokens` | GET/POST/DELETE `{id}` | 세션 | | Preview | `/api/v1/spaces/{slug}/files/{id}/preview` | GET | Space 권한 | | Health | `/api/v1/health` | GET | 익명 | ### 3.3 OpenAPI 문서화 - **핸드 작성된 OpenAPI 3.0.3 스펙** `docs/.llm/design/openapi.yaml` (145KB, `version: 0.2.0-draft`). - ReDoc 사이드바 5그룹: Identity, Collaboration, Content, Sharing, Operations. - `docs/.llm/design/api.md`는 모든 엔드포인트에 `구현됨 / 내부 구현됨 / deprecated 구현됨 / 미구현` 상태를 라벨링. - `docs/.llm/wiki/api-contract-patch-notes.md`에 실제 백엔드와 스펙의 diff 로그가 누적됨(수동 reconcile). - `infra/nginx/cloudsharp.conf`가 `/swagger/`, `/openapi/`을 외부에 노출. **개선 여지**: Swashbuckle 등으로 자동 생성하지 않고 수동 유지 중이라, 변경 시 누락 가능성이 있습니다. ADR/위키에서 "수동 reconcile"로 명시. ### 3.4 버전 관리 - prefix에 `/v1/` 박혀 있고 라우트 자체에 major 버전이 있음. - `docs/.llm/wiki/api-contract-patch-notes.md`가 변경 로그 역할(시맨틱 버전은 미사용). ### 3.5 인증·권한 요약 | 인증/인가 정책 | 적용 그룹 | 정의 위치 | |---|---|---| | `RequireUserAccess()` | 로그아웃, `/me`, MCP 토큰 등 사용자 자기 자신 | `AuthorizationPolicies.UserSessionOnly` | | `RequireAdminAccess()` | `/api/v1/admin/*` | `AuthorizationPolicies.AdminSessionOnly` | | `RequireDelegatedUserAccess()` | Space-scoped 도메인 API | `AuthorizationPolicies.UserOrMcpToken` | | `RequireInternalUploadAccess()` | `/api/internal/uploads/*` | `InternalUploadFinalizeAuthenticationHandler.PolicyName` | | `RequireSpacePermissionFilter` | Space-scoped 라우트 | `Filters/RequireSpacePermissionFilter.cs` | | `MapForbidToNotFoundFilter` | Preview, File details 등 | 존재 누설 방지 | | `AllowAnonymous()` | `/public/v1/*`, `/api/v1/health` | 익명 공개 | 자세한 인증/인가 설계는 §4 참조. 근거: `apps/backend/src/CloudSharp.Api/Auth/CloudSharpAuthorizationEndpointExtensions.cs`, `apps/backend/src/CloudSharp.Api/Auth/AuthorizationPolicies.cs`. --- ## 4. 인증/인가 설계 ### 4.1 토큰 모델 **Opaque Bearer 토큰**(JWT 미사용)을 사용합니다. 형식: | 토큰 종류 | Prefix | Redis 키 | TTL | 발급 코드 | |---|---|---|---|---| | 사용자 세션 | `cs_st` | `auth:session:{tokenHash}`, `auth:user_sessions:{userId}`(set) | `Auth__SessionExpiresInSeconds` (기본 7일) | `RedisSessionTokenIssuer` | | MCP 토큰 | `cs_mcp` | `mcp_tokens.token_hash` (PG) | 토큰별 | `McpTokenAuthenticationHandler` | | 다운로드 세션 | `cs_dl` | `download:session:{tokenHash}` | 5분 | `RedisDownloadSessionIssuer` | | Share Link | `cs_sh` | `share_links.token_hash` (PG) | 링크별 | `ShareLink.Create` + 5회 재시도 | | 내부 tusd finalize | (헤더) | n/a | n/a | `X-CloudSharp-Internal-Token` HMAC 비교 | - 토큰 분류는 `BearerTokenClassifier.Classify()`가 prefix로 분기. - 발급 시 256-bit random + prefix → 서버에는 `SHA-256(token)`만 저장. - 운영에서 `Auth__SessionHashKey`는 32바이트 이상 검증(`AuthOptionsValidator`). ### 4.2 현재 사용자 식별 - `CloudSharpSessionAuthenticationHandler`가 `Bearer `을 검증하고 `sub`(userId), `sid`(session id), `system_role` 클레임 설정. - MCP 핸들러는 동일 형태 + `token_type=mcp`, `mcp_token_id` 클레임. - 핸들러는 `ICurrentUser`/`CurrentUserExtensions`로 `GetRequiredUserId()` 등 노출. ### 4.3 Role/Permission 구조 - `system_role_t`: `ADMIN`, `USER` (사용자 단위) - `space_member_role_t`: `OWNER`, `ADMIN`, `MEMBER`, `VIEWER` (Space 단위) - `SpacePermission` enum 16종, `SpacePermissionService.RolePermissions` (정적 `Dictionary>`): - `OWNER` = 모든 권한 - `ADMIN` = `DeleteSpace` 제외한 모든 권한 - `MEMBER` = 읽기/쓰기 + 자기 ShareLink 발급 - `VIEWER` = 읽기/다운로드만 ### 4.4 리소스 소유자/권한 검증 위치 파이프라인 순서(§3.5 표 참조): 1. `UseAuthentication()` → `UseAuthorization()` 미들웨어가 `sub` 클레임 설정. 2. `RequireDelegatedUserAccess()` (또는 그 변형) 정책이 401 거절. 3. `RequireSpacePermissionFilter` (`IEndpointFilter`)가 라우트의 `spaceSlug`로 `ISpacePermissionService.FindAuthorizedSpaceAsync(userId, slug, [perm])` 호출. 4. 결과 `AuthorizedSpaceContext`를 `HttpContext.Items["__CloudSharp.AuthorizedSpace"]`에 저장. 5. 핸들러는 `http.GetRequiredAuthorizedSpace()`로 회수. 6. UseCase도 `command.SpaceId` 일치를 재검증(방어 깊이 추가). 7. `MapForbidToNotFoundFilter`가 Preview·File 등에서 403을 404로 변환(존재 누설 방지). ### 4.5 인증/인가 설계 판단 | 결정 | 이유 | |---|---| | **JWT 대신 Opaque** | Space role은 자주 바뀜. JWT 클레임 staleness로 인한 일관성 버그를 사전 차단. Logout-all도 `auth:user_sessions:{userId}` set `KeyDelete` 한 번. | | **매 요청 `SpaceMember` 재조회** | role 변경이 다음 요청부터 즉시 반영. 캐시 staleness 윈도우 0. | | **403 → 404 변환** | `Preview`, `File details` 등에서 Space/File 존재 여부 누설 방지. | | **filter + service + use case 3중 검증** | 한 계층이 빠져도 다른 계층이 차단. 단, 정책은 한 곳(`RolePermissions`)에서만 정의. | | **prefix로 토큰 종류 분류** | 같은 `Authorization: Bearer` 헤더 하나로 사용자/MCP를 라우팅. | 근거: `docs/.llm/conventions/auth-policy.md`, `apps/backend/src/CloudSharp.Api/Auth/*`, `apps/backend/src/CloudSharp.Core/UseCases/Members/SpacePermissionService.cs`, ADR `docs/.llm/wiki/decisions.md`. **개선 여지**: SSE fan-out이 단일 인스턴스 메모리 의존이라 다중 인스턴스 확장 시 Redis Pub/Sub로 옮겨야 함(ADR 2026-05-13에 명시). --- ## 5. 데이터베이스 설계 ### 5.1 ERD와 정합성 - 정식 ERD: `docs/.llm/design/erd.md` (구현 정합). `docs/ERD 설계서.md`는 outbox/notification/mcp/tag 등 신규 테이블 누락으로 다소 시일이 지났음(연구/설계 의도 문서). - 18개 `IEntityTypeConfiguration`가 `apps/backend/src/CloudSharp.Infrastructure/Persistence/Configurations/`에 위치. 테이블 16개 + enum 매핑. ### 5.2 주요 테이블 | 테이블 | 역할 | 핵심 제약 | |---|---|---| | `users` | 사용자 | `email` UNIQUE, `status` enum | | `spaces` | Space(컨테이너) | `slug` UNIQUE, 쿼터 check 3종 | | `space_members` | 멤버십 | partial unique `(space_id, user_id) WHERE deleted_at IS NULL AND status IN ('ACTIVE','INVITED','SUSPENDED')` — 한 명당 Space 1개 | | `space_invites` | 링크형 초대 | `token_hash` UNIQUE, role/status 컬럼은 link 모델로 단순화(`20260507084016_ConvertSpaceInvitesToLinkInvites`) | | `folders` | 트리 | partial unique root 1개/space, partial unique name/parent, `chk_folders_name_not_blank` | | `file_items` | 파일 메타 | alternate key `(id, space_id)` (file_tags 조인용), unique `storage_key`, partial unique `(space, folder, normalized_name) WHERE deleted_at IS NULL AND file_status <> 'DELETED'`, `chk_size_non_negative` | | `file_reservations` | 업로드 예약 | 1:1 `upload_session_id` UNIQUE, partial unique name with `status IN ('RESERVED','ACTIVE')` | | `upload_sessions` | 업로드 상태 | 상태 enum 7종(`CREATED,UPLOADING,FINALIZING,COMPLETED,FAILED,ABORTED,EXPIRED`), check 4종 | | `share_links` | 공유 | `token_hash` UNIQUE, `xmin` row version (Postgres concurrency token), polymorphic target check | | `share_link_targets` | 공유 타깃 | check: `target_type='FILE' XOR folder` (정확히는 OR) | | `download_sessions` | 다운로드 스트리밍 | polymorphic subject check, `session_token_hash` UNIQUE | | `outbox_events` | 트랜잭셔널 outbox | polling/lease partial index, status VARCHAR (enum 회피) | | `notifications` | 알림 | partial unique `outbox_event_id` (at-most-once projection), `xmin` row version | | `notification_read_positions` | 사용자별 read cursor | `user_id` UNIQUE (커서 1개/유저) | | `mcp_tokens` | MCP 액세스 토큰 | `token_hash` UNIQUE, `chk_expires_after_created` | | `tags` / `file_tags` | 태그 | 태그/파일 cross-space 조인 방지를 위한 alternate key + composite FK | | `file_purge_requests` | 정리 워커 원장 | EF FK 없음(의도적, worker ledger) | ### 5.3 컬럼 타입과 JSON - 15개 PostgreSQL enum (`system_role_t`, `space_status_t`, `file_status_t` 등)은 `modelBuilder.HasPostgresEnum()`로 매핑. - `outbox_events.payload/headers`와 `file_items.metadata_json`은 Postgres에서 `jsonb`, 그 외 provider에서는 `string`(`CloudSharp:DatabaseProviderName` 어노테이션으로 분기). - enum 신규 추가는 마이그레이션이 모든 기존 enum을 `AlterDatabase`로 다시 등록(EF Core 9 관용). `NpgsqlDataSource.ReloadTypes()` + `Clear()`를 startup에서 호출해야 새 enum이 보인다. ### 5.4 Soft Delete - **글로벌 쿼리 필터 없음** — 모든 리포지토리가 `WHERE deleted_at IS NULL`을 명시적으로 작성(49회 등장). - unique 인덱스도 `HasFilter("deleted_at IS NULL AND …")`로 soft-deleted 행 충돌 회피. - 의도: 코드 리뷰에서 soft-delete 동작이 보이게 함. `IgnoreQueryFilters()` 남용 방지. ### 5.5 CreatedAt / UpdatedAt - `users`, `spaces`, `space_members`, `space_invites`, `folders`, `file_items`, `file_reservations`, `share_link_targets` 8개 테이블은 `set_updated_at()` BEFORE UPDATE 트리거(InitialCreate에 설치)로 `updated_at = NOW()` 자동 갱신. - 나머지는 애플리케이션 측 기본값 또는 수동 설정. - `SaveChanges` 인터셉터는 사용하지 않음. ### 5.6 Migration 관리 - `apps/backend/src/CloudSharp.Infrastructure/Persistence/Migrations/`에 14개. - 자동 적용: `Program.cs`에서 `RunDatabaseMigrationsOnStartup=true`(기본)일 때 startup에서 `dbContext.Database.Migrate()` 실행 후 `dataSource.ReloadTypes()` + `Clear()` 호출. - 규칙(`docs/.llm/conventions/migrations.md`): `Core`는 EF Core 참조 금지, 마이그레이션은 `Infrastructure`에, 자동 생성된 것도 사람이 리뷰. - 마이그레이션 명령: ```bash dotnet ef migrations add \ --project src/CloudSharp.Infrastructure \ --startup-project src/CloudSharp.Api \ --context CloudSharpDbContext \ --output-dir Persistence/Migrations ``` ### 5.7 데이터 정합성 보장 - **부분 unique 인덱스**로 비즈니스 불변식 강제: root 폴더 1개/space, 멤버 1명/space, 같은 parent에 같은 이름 폴더 0개, 같은 folder에 active 같은 이름 파일 0개, 같은 folder에 라이브 예약 0개, 같은 (file, tag) active 0개, 한 유저 알림 read 커서 1개. - **상태 머신 + atomic transition**으로 finalize race 방지(§7 참조). - **DbUpdateException → Result.Fail 변환**(`DbContextSaveExtensions.TrySaveChangesAsync`)으로 unique violation을 Result 오류로 변환. - **낙관적 동시성**은 `share_links`, `notifications`, `notification_read_positions`만 Postgres `xmin` 사용. 나머지는 application-level 가드. ### 5.8 데이터 모델링 설계 의도 - **멀티 Space는 User와 분리**: User가 사라져도 Space 데이터는 살아남고, Space가 삭제돼도 outbox 이벤트는 남는다(`outbox_events.space_id/user_id`는 `SetNull`). - **`file_purge_requests`는 의도적으로 EF FK 없음**: 워커가 `spaces`/`file_items`에 의존하지 않고 독립적으로 정리할 수 있도록 ledger로 설계. - **alternate key `(id, space_id)`로 cross-entity FK**: file_tags가 file과 tag를 join할 때 "같은 space에 있는지"를 DB 레벨에서 강제. ### 5.9 개선 여지 - `20260423063036_AddFolderUniquenessConstraints`가 데이터 백필을 트랜잭션 안에서 수행(중복 행 soft-delete) — 대량 데이터셋에서는 잠금 시간 증가. - 마이그레이션에 `CREATE INDEX CONCURRENTLY` 미사용(개발 편의 우선, 운영 다운타임은 compose redeploy로 흡수). - `notes.md` 같은 컬럼이 일부 테이블(`upload_sessions`, `mcp_tokens`)에 application-side 기본값 의존 → 트리거 일관성 없음. 근거: `apps/backend/src/CloudSharp.Infrastructure/Persistence/Configurations/*`, `apps/backend/src/CloudSharp.Infrastructure/Persistence/Migrations/*`, `docs/.llm/design/erd.md`, `docs/.llm/conventions/migrations.md`. --- ## 6. 비즈니스 로직 / UseCase 구조 ### 6.1 UseCase 계층 규칙 - `apps/backend/src/CloudSharp.Core/UseCases/{Feature}/{Action}{Domain}Command/Query/Result.cs` 형태로 1-기능-1-디렉토리. - `I{Feature}UseCases` 인터페이스를 먼저 정의, 구현은 `sealed class {Feature}UseCases`로 같은 디렉토리. - 반환 타입은 `Task` / `Task>`만. HTTP/IActionResult/DTO 노출 금지. - `CancellationToken`은 항상 마지막 파라미터. - DI: `builder.Services.AddScoped()`. - `UseCaseActivityLoggingProxy` (DispatchProxy) 가 DI 시 모든 I*UseCases를 감싸 성공/실패/소요시간/추출한 userId·spaceId·fileId 등을 구조화 로그로 남김. 근거: `docs/.llm/conventions/usecases-*.md`. ### 6.2 4-Step Handler 모든 endpoint는 다음 4단계로만 흐릅니다(예: `FolderEndpoints.CreateAsync`). ```csharp // 1) Validate (FluentValidation) → Result.Fail on failure var validation = await validator.ValidateAsync(request, ct); // 2) Build Command/Query from request + HttpContext var cmd = new CreateFolderCommand { ... }; // 3) Single use case call var result = await useCase.CreateAsync(cmd, ct); // 4) Map Result → IResult return result.ToHttpResult(value => Results.Created(...)); ``` 근거: `apps/backend/src/CloudSharp.Api/Endpoints/Folders/FolderEndpoints.cs`, `apps/backend/src/CloudSharp.Api/Endpoints/_Common/ResultHttpMapper.cs`. ### 6.3 주요 UseCase 인터페이스 | 인터페이스 | 위치 | 대표 메서드 | |---|---|---| | `IFolderUseCases` | `UseCases/Folders` | Provision, ListChildren, Create, UpdateMetadata, Delete | | `IFileUseCases` / `IFileReadUseCases` | `UseCases/Files` | UpdateMetadata, Delete / GetDetails, GetThumbnail | | `IUploadSessionUseCases` | `UseCases/Uploads` | AddUploadSession, FindUploadSession, ReserveUploadSession, StartUploadSession | | `IUploadUseCases` | `UseCases/Uploads` | MarkUploading, CompleteUpload | | `IFileTrashUseCases` | `UseCases/Trash` | RestoreFile, ListTrashFiles, PurgeTrashFile | | `IShareLinkUseCases` | `UseCases/ShareLinks` | Create, List, Update, ChangeStatus, Revoke, Verify, Browse, CreateDownloadSession | | `IUserUseCases` | `UseCases/Auth` | Register, Login, ValidateUserToken, AddUserToken, ... | | `ISpaceUseCases` / `ISpaceInviteUseCases` / `ISpaceMemberUseCases` | `UseCases/Spaces`, `Members` | CRUD/Role/Kick | | `IOutboxProcessingUseCases`, `IWorkerJobDispatchUseCases` | `UseCases/Outbox` | Claim/Mark/Dispatch | | `INotificationUseCases` | `UseCases/Notifications` | DispatchNotification, FindNotifications, CountUnread, MarkRead | | `IAdminUserUseCases`, `IAdminSpaceUseCases` | `UseCases/Admin` | | | `ISpacePermissionService` | `UseCases/Members` | FindAuthorizedSpaceAsync(필터에서 사용) | ### 6.4 대표 UseCase 상세 — ShareLink Create `ShareLinkUseCases.CreateAsync` (`UseCases/ShareLinks/ShareLinkUseCases.cs:45-117`): 1. `createValidator.ValidateAsync` → 실패면 `Result.Fail(ValidationError)`. 2. `spacePermissionService.FindAuthorizedSpaceAsync(actor, slug, [CreateShareLink])` → null이면 `CloudSharpShareLinkError(ShareLinkForbidden)`. 3. `ShareLinkTarget`을 FILE/FOLDER 분기 서브-UseCase로 빌드. 4. `tokenGenerator.CreateHashedToken("cs_sh")`로 토큰 생성, `ExistsByTokenHashAsync` 5회까지 재시도(collide 회피). 5. `ShareLink.Create(...)` 도메인 팩토리 (Result). 6. `shareLinkRepository.SaveAsync` → unique violation이면 재시도. 7. 성공 시 같은 트랜잭션 내 `OutboxEventRecorder.AddBestEffortAsync(OutboxEventTypes.ShareLinkCreated, ...)` 호출. 8. `CreateShareLinkResult { ShareLink, PublicUrl, ShareToken }` 반환. ### 6.5 대표 UseCase 상세 — Upload Finalize `UploadUseCases.CompleteUploadAsync` (`UseCases/Uploads/UploadUseCases.cs:90-345`): 1. `FinalizeUploadCommand` 검증. 2. `TempStorageKey` 안전성 검증(`..`, NUL, rooted, ≤100자 차단). 3. `TryStartFinalizingAsync` — `CREATED/UPLOADING` → `FINALIZING` 원자 전이. 실패 시 `UploadInvalidStateTransition`. 4. temp 파일 크기 vs `ExpectedSize`/`FinalSizeBytes` 일치 검증. 5. Space 존재/쿼터/파일명 충돌 확인, 최종 `StorageKey` 계산. 6. `MoveTempToFinalAsync` — storage move. 7. `_transactionManager.ExecuteAsync(PersistFinalizedUploadAsync)`로 `FileItem` INSERT, `upload_sessions.status=COMPLETED`, outbox `FileUploaded`/`FileFinalized` enqueue를 **한 트랜잭션**으로. 8. DB 실패 → `MoveFinalToTempAsync`(보상) + `MarkSessionFailedAsync(releaseReservedStorage=true)`. 9. 보상도 실패 → `LogError(FINALIZE_STORAGE_RESTORE_FAILED)` + 그래도 `MarkSessionFailedAsync`. 10. 성공 시 dispatcher가 `FileFinalized`를 `cloudsharp:worker:jobs`로 publish. ### 6.6 UseCase 책임 분리 — API vs UseCase | 계층 | 책임 | 비책임 | |---|---|---| | API endpoint | 요청 파싱, 인증, Validation, command/query 매핑, HTTP 매핑 | 도메인 규칙, 영속화 | | UseCase | 도메인 규칙, 트랜잭션, 외부 시스템 호출(storage), outbox enqueue | HTTP, 인증, 프레젠테이션 | | Repository | DB I/O 캡슐화, `Result` 변환(`TrySaveChangesAsync`) | 도메인 규칙 | | Domain (Entity) | 불변식, 팩토리, 상태 머신 | 영속화, IO | 근거: `apps/backend/src/CloudSharp.Core/UseCases/**/*UseCases.cs`, `docs/.llm/conventions/usecases-coding.md`, `docs/.llm/conventions/command-query.md`. --- ## 7. 트랜잭션과 동시성 ### 7.1 트랜잭션 추상화 두 가지가 공존합니다. | 추상화 | 사용처 | 위치 | |---|---|---| | `ITransactionManager.ExecuteAsync(Func>>)` | 도메인 단위 작업 (EF execution strategy 호환) | `CloudSharp.Core` 포트, `EfCoreTransactionManager` 구현 | | `IAppDbTransactionFactory.BeginAsync()` | 다단계 명시적 트랜잭션 (Serializable 격리에 필요) | `CloudSharp.Core` 포트, `EfCoreAppDbTransactionFactory` 구현 | `DbContext`는 Scoped로 등록되어 한 HTTP 요청 안의 모든 리포지토리가 같은 context·tracked entity graph를 공유. ### 7.2 주요 트랜잭션 사용처 | UseCase | 트랜잭션 종류 | 보장 대상 | |---|---|---| | `SpaceUseCases.CreateSpaceAsync` | `BeginAsync` | Space + Owner 멤버 + Root Folder 동시 생성 | | `UploadSessionUseCases.CreateAsync` | `BeginAsync` | 쿼터 검사 + FileReservation INSERT | | `UploadSessionUseCases.MarkUploadingAsync` | `BeginAsync` | `CREATED → UPLOADING` 원자 전이 | | `UploadSessionUseCases.StartUploadSessionAsync` | `BeginAsync` | 토큰 발급 + `tus_upload_id` 매핑 | | `UploadUseCases.PersistFinalizedUploadAsync` | `ITransactionManager` | FileItem + quota + reservation + session status (단일 tx) | | `UploadUseCases.MarkSessionFailedAsync` | `ITransactionManager` | session + reservation release + 에러 메타 | | `TagUseCases.DeleteTagAsync` | `BeginAsync` | soft-delete tag + cascade file_tag | | `TagUseCases.SetFileTagsAsync` | `BeginAsync` | 기존 file_tag purge + batch insert | | `FolderRepository.{CreateAsync,RenameAsync,MoveAsync,SoftDeleteAsync,UpdateMetadataAsync}` | `BeginAsync(Serializable)` | 폴더 트리 동시 변경 | | `FileRepository.{RequestPurgeAsync,MarkPurgeRunningAsync,MarkPurgeCompletedAsync}` | `BeginAsync(Serializable)` | `file_purge_requests` ledger 동시성 | ### 7.3 Saga — DB + Storage `UploadUseCases.CompleteUploadAsync`는 명시적 2-단계 saga입니다. 1. **Storage move**: `MoveTempToFinalAsync`(temp → final). 2. **DB transaction**: FileItem/Quota/Reservation/Session 단일 트랜잭션. 3. **DB 실패 시 보상**: `MoveFinalToTempAsync`(final → temp 복원) + `MarkSessionFailedAsync`. 4. **보상 실패**: `LogError("FINALIZE_STORAGE_RESTORE_FAILED")` + 그래도 `MarkSessionFailedAsync`로 끊어 release(예약 해제). 스토리지 오브젝트 고아(스토리지만 옮기고 DB가 commit 직전에 죽은 경우)는 `file_purge_requests` ledger + `TrashAutoPurgeRunner`가 주기적으로 청소. ### 7.4 동시성 제어 - **낙관적 동시성**: `share_links`, `notifications`, `notification_read_positions`만 Postgres `xmin` row version 사용(EF shadow `xmin uint`). - **상태 머신 atomic transition**: `upload_sessions.TryStartFinalizingAsync`는 `WHERE status IN ('CREATED','UPLOADING')` 조건부 UPDATE로 한 번에 한 finalize만 통과. - **부분 unique 인덱스**: 7개 비즈니스 불변식을 DB가 강제. - **Serializable 격리 + catch 40001 → `CloudSharpConflictException`**: 폴더 hot path(`CreateAsync`, `RenameAsync`, `MoveAsync`, `SoftDeleteAsync`, `UpdateMetadataAsync`)에서 사용. - **Outbox claim**: `SELECT id` + `ExecuteUpdate WHERE status IN ('PENDING','FAILED')` + 다시 `SELECT WHERE status='PROCESSING' AND worker_id=?` 3-라운드 trip으로 2-worker race를 흡수. `ReleaseExpiredLocksAsync`가 매 사이클 시작 시 lease 만료분을 `FAILED`로 회수. ### 7.5 Idempotency - 도메인 수준의 멱등 토큰: - `space_invites.token_hash` UNIQUE - `share_links.token_hash` UNIQUE - `mcp_tokens.token_hash` UNIQUE - `upload_sessions.token` UNIQUE - `upload_sessions.tus_upload_id` UNIQUE(nullable) - `outbox_events.event_id` UUID UNIQUE - `notifications.outbox_event_id` partial UNIQUE (at-most-once projection) - API 레벨 멱등성 키(Idempotency-Key 헤더)는 미구현 — **개선 여지**. ### 7.6 외부 작업 + DB 정합성 - **tusd post-finish → API finalize**: tusd hook은 idempotent하게 `StartUploadSessionAsync`를 재호출 가능, finalize state machine이 멱등. - **Outbox best-effort enqueue**: `OutboxEventRecorder.AddBestEffortAsync`는 실패 시 `LogWarning`만 하고 throw 안 함 → 도메인 트랜잭션을 깨지 않음. 대신 동일 이벤트 재발행의 위험은 도메인 코드 자체에서 멱등 처리로 흡수. - **`file_purge_requests` ledger**: 워커가 storage를 정리할 때 DB row를 함께 남겨 추적성 확보 + 재시도 안전. 근거: `apps/backend/src/CloudSharp.Core/UseCases/Uploads/UploadUseCases.cs`, `apps/backend/src/CloudSharp.Core/UseCases/Outbox/OutboxEventRepository.cs:80-132`, `apps/backend/src/CloudSharp.Core/UseCases/Outbox/OutboxProcessingUseCases.cs`, `apps/backend/src/CloudSharp.Core/UseCases/Spaces/SpaceUseCases.cs:52-104`. --- ## 8. 예외 처리와 응답 구조 ### 8.1 정책 - **비즈니스 실패 = `Result.Fail`**, 시스템 실패(DB down, 버그) = 예외 throw. - `Result`/`Result`가 use case 경계까지 흘러나오고, `ResultHttpMapper`가 단일 지점에서 HTTP로 변환. - 4-단계 핸들러 흐름에서 `try/catch`는 최소화(validation과 use case 실패만 명시 처리). 근거: `docs/.llm/conventions/error-handling.md`, `apps/backend/src/CloudSharp.Core/Common/Errors/*`. ### 8.2 CloudSharpError / ErrorCode - `ErrorDefinition(string Code, HttpStatusCode StatusCode)`로 150여 개의 코드 상수 정의(`ErrorCodes.cs`). - `CloudSharpError` 추상 베이스가 `Metadata["ErrorCode"]`/`Metadata["StatusCode"]` 자동 부여. - 도메인별 서브클래스 13종: `CloudSharpAuthError`, `CloudSharpFolderError`, `CloudSharpFileError`, `CloudSharpUploadError`, `CloudSharpShareLinkError`, `CloudSharpTagError`, `CloudSharpSpaceError`, `CloudSharpSpaceInviteError`, `CloudSharpSpaceMemberError`, `CloudSharpMcpTokenError`, `CloudSharpNotificationError`, `CloudSharpUserError`, `CloudSharpOutboxError`, `CloudSharpDownloadError`. ### 8.3 ErrorResponse 공통 구조 ```json { "RequestId": "0HMV...AAA", "Error": { "Code": "FolderNameConflict", "Message": "같은 위치에 이미 존재하는 폴더 이름입니다.", "Details": [ { "Field": "name", "Reason": "이미 존재" } ] } } ``` - `ErrorResponse { RequestId, Error: { Code, Message, Details[] } }` (`apps/backend/src/CloudSharp.Api/Endpoints/_Common/ErrorResponse.cs`). - `RequestId` = `HttpContext.TraceIdentifier`. - `Code`는 `ErrorCodeMetadataKey`에서, status는 `StatusCodeMetadataKey`에서. 둘이 어긋날 수 없게 같은 metadata에서 추출. ### 8.4 상태 코드 매핑 | Status | 사용 사례 | |---|---| | 200 / 201 / 204 | 성공 | | 400 | Validation, missing field, invalid input | | 401 | missing/expired session, share link password required, download session expired | | 403 | `*Forbidden`, ADMIN 권한 없음 | | 404 | `*NotFound` | | 409 | `*Conflict` (state transition, name collision, concurrent modification) | | 416 | Range not satisfiable (`Downloads/RangeParser.cs`) | | 422 | `*Invalid*` 비즈니스 규칙(quota too small, invalid move) | | 500 | `*Failed` (storage, DB) | | 499 | 클라이언트 cancel (`OperationCanceledException`) | ### 8.5 Validation - HTTP DTO: DataAnnotations 또는 `FluentValidation`(`Endpoints/{Feature}/Validators/*RequestValidator.cs`). - UseCase command/query: `Core/UseCases/{Feature}/Validators/*CommandValidator.cs` (FluentValidation). - `FluentValidationResultMapper.ToResult(ValidationResult)`가 `ValidationFailure` → `Result.Fail` 변환, `ErrorCode`/`PropertyName`/`AttemptedValue` 메타 보존. - **규칙**: Validator는 절대 DB 쿼리 안 함(권한/쿼터/상태 검사는 use case 책임). ### 8.6 글로벌 예외 처리 - `ExceptionHandlingMiddleware` (`apps/backend/src/CloudSharp.Api/Middlewares/ExceptionHandlingMiddleware.cs`): - `BadHttpRequestException` (검색 API 한정) → 400 - `OperationCanceledException` → 499 - 그 외 → 500 + `Results.Problem` - `ProblemResults` (`Endpoints/_Common/`)가 공통 problem-details 응답 빌더. ### 8.7 로깅 - 영문 고정 메시지 템플릿 + placeholder(`{UserId}`, `{SpaceId}`, `{ErrorCode}`) 사용. 문자열 보간 금지. 토큰/비밀번호 로깅 금지. - `UseCaseActivityLoggingProxy`가 모든 `I*UseCases` 호출을 자동 계측(성공/실패/소요/추출한 correlation ID). - `RequestLoggingMiddleware`가 method/path/status/elapsed/traceId/userId를 한 줄로 출력. - `ExceptionHandlingMiddleware`는 stack trace를 Development에서만 포함. ### 8.8 프론트엔드 일관성 - 프론트엔드는 `apiFetch`(`apps/frontend/src/api/client.ts`)가 `ErrorResponse.Code`를 보고 분기. - `Code`는 안정적 식별자(메시지 변경돼도 바뀌지 않음) → 다국어 메시지 바인딩 가능. - 한 사용자의 토큰이 만료되면 `ErrorResponse` 그대로 수신 → 401 코드 보고 재로그인/리다이렉트. 근거: `apps/backend/src/CloudSharp.Api/Endpoints/_Common/ResultHttpMapper.cs`, `apps/backend/src/CloudSharp.Core/Common/Errors/CloudSharpError.cs`, `apps/backend/src/CloudSharp.Api/Middlewares/ExceptionHandlingMiddleware.cs`. --- ## 9. 인프라 구성 ### 9.1 Docker Compose 세 개의 compose 파일이 존재합니다. | 파일 | 용도 | 토폴로지 | |---|---|---| | `infra/docker-compose.yml` | 운영(GHCR 이미지) | 6서비스 + init-storage | | `infra/docker-compose.local-build.yml` | 로컬 소스 빌드 개발 | 동일 6서비스, image → build | | `apps/backend/docker-compose.yml` | 백엔드 개발자용(API는 호스트에서 `dotnet run`) | 3서비스(pg/redis/tusd) | #### 9.1.1 운영 스택 (`infra/docker-compose.yml`) | Service | Image | container_name | 포트(호스트:컨테이너) | Healthcheck | |---|---|---|---|---| | `postgres` | `postgres:16-alpine` | `cloudsharp-postgres` | (내부) | `pg_isready` 10s | | `redis` | `redis:7-alpine --appendonly yes` | `cloudsharp-redis` | (내부) | `redis-cli ping` 10s | | `init-storage` | `alpine:3.20` (one-shot, root) | `cloudsharp-init-storage` | — | `service_completed_successfully` | | `tusd` | `tusproject/tusd:latest` | `cloudsharp-tusd` | (내부 expose 1080) | `wget /health` 10s | | `api` | `ghcr.io/cloud-sharp/cloudsharp-backend:${Image__Tag:-latest}` | `cloudsharp-api` | (내부 expose 8080) | Dockerfile `HEALTHCHECK /api/v1/health` | | `frontend` | `ghcr.io/cloud-sharp/cloudsharp-frontend:${Image__Tag:-latest}` | `cloudsharp-frontend` | (내부 expose 80) | (운영) / wget (dev) | | `nginx` | `ghcr.io/cloud-sharp/cloudsharp-nginx:${Image__Tag:-latest}` | `cloudsharp-nginx` | `${Nginx__Port:-8080}:80` (유일한 호스트 publish) | — | #### 9.1.2 볼륨과 바인드 마운트 - 네임드 볼륨: `postgres_data:/var/lib/postgresql/data`, `redis_data:/data` - 바인드 마운트: `${Storage__HostRoot:-./storage}:/data/storage` (init-storage/tusd/api 공유) - `init-storage`가 `chown -R 10001:10001` + `chmod u+rwX,g+rwX`로 동일 UID 권한 통일. #### 9.1.3 depends_on / healthcheck `init-storage` → `postgres/redis/tusd`(healthy) → `api` → `nginx` 순. 모든 서비스 `restart: unless-stopped`, 단일 `cloudsharp` 브리지 네트워크. #### 9.1.4 환경변수 핵심 변수(전체는 `infra/.env`): - `ASPNETCORE_ENVIRONMENT=Production`, `ASPNETCORE_URLS=http://+:8080` - `Postgres__Host=postgres`, `Postgres__Db/User/Password/Port` - `Redis__Host=redis`, `Redis__Port/User/Password` - `Tusd__Host=tusd`, `Tusd__Port=1080`, `Tusd__UploadDir=/data/storage/tmp/tusd` - `Storage__Provider=local`, `RootPath=/data/storage`, `TempPath=/data/storage/tmp/tusd`, `ObjectsPath=/data/storage/objects` - `Auth__SessionHashKey`(32B base64), `Auth__SessionExpiresInSeconds=604800` - `Uploads__FinalizeToken` (**운영은 `:?` 필수, dev는 `test` 기본값**) - `Uploads__FinalizeRecoveryEnabled/IntervalSeconds=300/StaleMinutes=10/BatchSize=100` - `Space__MaxStorageAllowedBytes`(빈 값 = 무제한) - `UseHttpsRedirection=false`, `RunDatabaseMigrationsOnStartup=true` - `CloudSharp__Uid/Gid=10001` ### 9.2 헬퍼 스크립트 (`infra/`) | 스크립트 | 동작 | |---|---| | `boot-prod.sh` / `.ps1` | GHCR 이미지 기반 운영 스택 기동. `Uploads__FinalizeToken`/`Auth__SessionHashKey` 기본값 검출 시 32-byte base64 자동 생성 또는 프롬프트. `--pull` 옵션. | | `dev-up.sh` / `.ps1` | 로컬 빌드 스택 기동. dev 기본값(`Uploads__FinalizeToken=test`, `ASPNETCORE_ENVIRONMENT=Development`) 주입. | | `dev-reset.sh` / `.ps1` | `down -v` + storage 디렉터리 비우기 + 재기동. `RESET` 확인 필요. | ### 9.3 인프라 설계 의도 - **컨테이너 외부 노출은 nginx 1개** — CORS 회피, TLS는 호스트(또는 그 앞단 LB)에서. - **이미지 기반 운영 / 소스 빌드 개발을 두 compose로 분리** — 동일 토폴로지, 다른 posture. - **shared UID/GID**로 같은 bind mount를 tusd와 API가 안전하게 공유. - **단일 헬스체크 패턴**: postgres/redis/tusd는 compose `healthcheck`, api는 Dockerfile `HEALTHCHECK` → 운영 compose는 api healthcheck를 두지 않음(이미지 안에 있음). - **DB init은 API startup의 EF Migrate**로 단일화 → 별도 init SQL 없음, `apps/backend/scripts/inital_ddl.sql`은 reference. 근거: `infra/docker-compose.yml`, `infra/docker-compose.local-build.yml`, `apps/backend/docker-compose.yml`, `infra/boot-prod.sh`, `infra/dev-up.sh`, `infra/dev-reset.sh`, `infra/.env`, `infra/.env.example`. --- ## 10. Reverse Proxy / 도메인 / HTTPS ### 10.1 nginx 구성 `infra/nginx/cloudsharp.conf`는 **단일 `server { listen 80; server_name localhost; }`** 블록 안에 모든 라우팅을 둡니다. `upstream {}` 블록 없음 — host를 직접 `proxy_pass`로 지정. ### 10.2 Location 매핑 | Location | proxy_pass | 비고 | |---|---|---| | `/` | `http://frontend:80` | 일반 헤더 | | `/assets/` | `http://frontend:80` | 일반 헤더 | | `/api/` | `http://api:8080` | 일반 헤더 | | `/public/` | `http://api:8080` | `proxy_buffering off`, `proxy_request_buffering off`, `proxy_read/send_timeout 600s` | | `= /api/v1/health` | `http://api:8080` | 정확 매치 | | `= /swagger` | (302 → `/swagger/index.html`) | redirect | | `/swagger/` | `http://api:8080` | UI | | `/openapi/` | `http://api:8080` | JSON | | `/files/` | `http://tusd:1080/files/` | tus streaming — 아래 인용 참조 | ### 10.3 /files/ tus passthrough ```nginx location /files/ { proxy_pass http://tusd:1080/files/; proxy_http_version 1.1; proxy_set_header Host $cloudsharp_forwarded_host; proxy_set_header Authorization $http_authorization; # tusd가 API로 forward 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; proxy_set_header Connection ""; } ``` ### 10.4 HTTPS / TLS / Basic Auth - **TLS 없음**(nginx 80) — TLS는 호스트 앞단(예: reverse LB, Cloudflare Tunnel) 종단 가정. 컨테이너 내부 통신은 평문. - **Basic Auth 없음** — 운영 외부 접근은 OAuth/IDP 앞단에서 처리 가정. - **Rate limit 없음** — 앞단 LB의존. - **도메인 라우팅 없음** — path-based 라우팅만 사용, 단일 origin. - **관리자 도구 접근 제한** — `/api/v1/admin/*`는 정책으로 ADMIN role 요구, nginx 레벨 차단 없음. 운영에서 앞단 LB가 추가로 IP allowlist 가능. ### 10.5 설계 의도 - **단일 origin**으로 CORS를 회피하고, SameSite 쿠키/CSRF 단순화. - **`/files/`만 streaming-aware 옵션** — 큰 청크도 안전하게 통과. - **`/swagger/`, `/openapi/` 외부 노출** — 운영에서는 앞단에서 차단을 권장(현재는 정책으로만 보호). **개선 여지**: - TLS 종료를 컨테이너 외부에 의존 — 운영 환경에서 종단 지점이 명시되지 않음. - `/swagger/`, `/openapi/`가 공개 — 운영 차단을 nginx 레벨에서 켤 수 있는 flag 필요. 근거: `infra/nginx/cloudsharp.conf`, `infra/nginx/Dockerfile`. --- ## 11. 환경변수와 설정 관리 ### 11.1 .env 구조 `infra/.env` (실제 값, 커밋된 예시): ```bash TimeZone=Asia/Seoul Nginx__Port=8080 Image__Tag=latest Frontend__Image=ghcr.io/cloud-sharp/cloudsharp-frontend Backend__Image=ghcr.io/cloud-sharp/cloudsharp-backend Nginx__Image=ghcr.io/cloud-sharp/cloudsharp-nginx ASPNETCORE_ENVIRONMENT=Production UseHttpsRedirection=false RunDatabaseMigrationsOnStartup=true CloudSharp__Uid=10001 CloudSharp__Gid=10001 Postgres__Host=postgres Postgres__Db=cloudsharp Postgres__User=cloudsharp Postgres__Password=cloudsharp Postgres__Port=5432 Redis__Host=redis Redis__Port=6379 Redis__User= Redis__Password= Auth__SessionHashKey=QNQ+qHQghQDKSoI/xvSGGUS5qxv3KQBvDRLi3rYa6S8= Auth__SessionExpiresInSeconds=604800 Space__MaxStorageAllowedBytes= Storage__HostRoot=./storage Storage__Provider=local Storage__RootPath=/data/storage Storage__TempPath=/data/storage/tmp/tusd Storage__ObjectsPath=/data/storage/objects Tusd__Host=tusd Tusd__Port=1080 Tusd__UploadDir=/data/storage/tmp/tusd Uploads__FinalizeToken=33/vSNMzYpIQe/ooho8Lc36kPDrnY9EdUsHlnshY2dw= Uploads__FinalizeRecoveryEnabled=true Uploads__FinalizeRecoveryIntervalSeconds=300 Uploads__FinalizeRecoveryStaleMinutes=10 Uploads__FinalizeRecoveryBatchSize=100 ``` `infra/.env.example`은 동일한 구조, secret 자리는 `change-me-docker-session-hmac-key` / 빈 값. `apps/backend/.env`는 별도의 dev용(`Postgres__Host=127.0.0.1` 등). ### 11.2 로딩 모델 - compose는 `docker compose --env-file .env`로 명시 로딩(스크립트가 항상 지정). - 컨테이너에는 `env_file:` 대신 `environment:` 블록을 통해 주입 — compose가 `${VAR}` 치환 후 보냄. - API는 `Program.cs:65-70`에서 `DotNetEnv.Env.NoClobber().Load(bin/.env)`로 시작 → 이미 설정된 env(예: compose가 주입)는 덮어쓰지 않음. - `appsettings.json` + `appsettings.Development.json` + env var (예: `Postgres__Host`)가 순차적으로 적용. - 비밀(예: `Postgres__Password`, `Auth__SessionHashKey`, `Uploads__FinalizeToken`)은 `.env` + GitLab CI Variables에만 존재. README에 하드코딩 금지 명시. ### 11.3 ASP.NET Options 바인딩 + ValidateOnStart 세 가지 옵션 클래스가 `IOptions`로 바인딩되고 `ValidateOnStart()`로 startup 검증: | 옵션 | 섹션 | Validator | 검증 항목 | |---|---|---|---| | `AuthOptions` | `Auth` | `AuthOptionsValidator` | `SessionHashKey ≥ 32 chars`, `SessionExpiresInSeconds > 0` | | `SpaceOptions` | `Space` | `SpaceOptionsValidator` | `MaxStorageAllowedBytes` 합리성 | | `InfraOptions` | root | `InfraOptionsValidator` | Postgres/Redis/Storage/Tusd 각 하위 옵션 | `FluentValidateOptions`(`Options/FluentValidateOptions.cs`)가 `IValidator`를 `IValidateOptions`로 어댑트. ```csharp builder.Services .AddOptions() .Bind(builder.Configuration.GetSection(AuthOptions.SectionName)) .ValidateOnStart(); ``` ### 11.4 토글 가능한 운영 노브 | 노브 | 기본 | 위치 | |---|---|---| | `RunDatabaseMigrationsOnStartup` | true | Program.cs | | `UseHttpsRedirection` | true | Program.cs | | `Outbox:ProcessingEnabled` | true | `OutboxProcessingOptions` | | `Outbox:ProcessingIntervalSeconds` | 5 | | | `Outbox:BatchSize` | 50 | | | `Outbox:LockDurationSeconds` | 60 | | | `Outbox:RetryBaseDelaySeconds` | 30 | | | `Outbox:RetryMaxDelaySeconds` | 300 | | | `Trash:AutoPurgeEnabled` | true | | | `Trash:AutoPurgeRetentionDays` | 30 | | | `Trash:AutoPurgeIntervalSeconds` | 600 | | | `Uploads:FinalizeRecoveryEnabled` | true | | | `Uploads:FinalizeRecoveryIntervalSeconds` | 300 | | | `Uploads:FinalizeRecoveryStaleMinutes` | 10 | | | `Uploads:FinalizeRecoveryBatchSize` | 100 | | | `WorkerPubSub:Enabled` | true | `WorkerPubSubOptions` | 근거: `Program.cs:65-70, 247-268, 361-374`, `apps/backend/src/CloudSharp.Api/Options/`, `infra/.env`, `infra/.env.example`. --- ## 12. 로그 / 모니터링 / 운영 ### 12.1 로깅 구조 - **영문 고정 템플릿 + placeholder** 사용. 문자열 보간 금지. 토큰/비밀번호 로깅 금지. - 구조화 로그(`{UserId}`, `{SpaceId}`, `{ErrorCode}` 등) 사용. - Stack trace는 Development에서만 포함. - 주요 로그 발생 지점: - `RequestLoggingMiddleware` — method/path/status/elapsed/traceId/userId를 한 줄. - `ExceptionHandlingMiddleware` — 예외 타입/메시지/요약 stack. - `UseCaseActivityLoggingProxy` — 모든 I*UseCases 자동 계측(useCase/action/succeeded/elapsedMs/traceId/추출 ID). - `LoggedStorageProvider` — storage 호출 debug 로그. - `OutboxEventProcessingService` — 사이클 에러만 error, 정상은 info. ### 12.2 헬스체크 - DB/Redis/tusd는 compose `healthcheck` 10s 간격. - API는 Dockerfile `HEALTHCHECK` (`curl /api/v1/health`) — 운영 compose는 그대로 사용. - nginx는 healthcheck 없음(컨테이너 안에서 `nginx -t` 통과 + `wget`이 nginx가 살아있는지만 확인 가능). ### 12.3 운영 도구 - **Dozzle / Grafana / Loki / Prometheus 없음** — **개선 여지**. - 컨테이너 로그 확인은 `docker compose logs -f`로만. - 측정 도구 `apps/backend/tools/CloudSharp.TransferBenchmark`가 `docker stats --no-stream`으로 CPU/mem/disk IO를 함께 캡처 → 회귀 비교 가능. ### 12.4 운영 관점 아쉬운 점 - **중앙 로그 수집 없음** — 멀티 인스턴스화 시 컨테이너 로그를 단일 저장소로 보낼 어댑터 없음. - **메트릭 노출 없음** — Prometheus endpoint / OpenTelemetry exporter 미설치. - **분산 트레이싱 없음** — `RequestId`만 전달, OpenTelemetry SDK 미통합. - **SSE가 단일 인스턴스 의존** — `ISseConnectionStore`가 메모리, ADR에 Redis Streams/Pub-Sub 로드맵만 존재. 근거: `apps/backend/src/CloudSharp.Api/Middlewares/RequestLoggingMiddleware.cs`, `apps/backend/src/CloudSharp.Api/Middlewares/ExceptionHandlingMiddleware.cs`, `apps/backend/src/CloudSharp.Api/UseCases/UseCaseActivityLoggingProxy.cs`, `docs/.llm/conventions/logging.md`, `docs/.llm/wiki/decisions.md`(2026-05-13 SSE ADR). --- ## 13. CI/CD / 배포 자동화 ### 13.1 GitLab CI 파이프라인 루트 `.gitlab-ci.yml` + `.gitlab/ci/*.yml` + `.gitlab/deploy.yml`. 워크플로우는 `merge_request_event`와 `default-branch push`에서만 실행. **스테이지**: `test` → `build` → `pr_review` → `deploy` `.docker-sock` + `.ghcr-login` 앵커가 모든 image-build 잡에서 재사용. ### 13.2 잡 상세 | 잡 | 트리거 | 동작 | |---|---|---| | `backend:test` | MR, `apps/backend/**` 변경 | `dotnet restore` + Core/Infrastructure 단위 테스트, TRX 업로드 | | `backend:image` | default-branch push, `apps/backend/**` | `docker build`, `:$CI_COMMIT_SHA` + `:latest` GHCR push | | `frontend:test` | MR | `npm ci`, `npm run build` | | `frontend:image` | default-branch push | 이미지 빌드/푸시 + `:cd` 태그(`--build-arg VITE_API_URL=$VITE_API_URL`) 빌드/푸시 | | `mcp-console:test` | MR + main, mcp-console 변경 | corepack + pnpm build | | `nginx:test` | MR, `infra/nginx/**` 변경 | `docker run cloudsharp-nginx:ci nginx -t` (구문 검증) | | `nginx:image` | default-branch push, `infra/nginx/**` | 이미지 빌드/푸시 | | `pr_agent_job` | MR (manual, allow_failure) | CodiumAI PR-Agent 리뷰 (GMS OpenAI 호환) | | `backend:deploy` | default-branch push, `backend:image` 완료 | `curl --retry 3 --max-time 30` → `PORTAINER_BACKEND_WEBHOOK_URL` | | `frontend:deploy` | default-branch push, `frontend:image` 완료 | `curl` → `PORTAINER_FRONTEND_WEBHOOK_URL` | ### 13.3 배포 흐름 - CI는 빌드/푸시 + Portainer redeploy webhook만 호출. SSH/ansible/kubectl 없음. - Portainer가 GHCR에서 새 이미지를 pull 받아 동일 토폴로지로 stack redeploy. - 운영 비밀(GHCR_TOKEN, PORTAINER_WEBHOOK_URL, GMS_KEY, VITE_API_URL)은 GitLab CI Variables에만. ### 13.4 브랜치 전략 - `master`만 영속. 모든 변경은 feature branch → MR → fast-merge (squash 없음). - MR 템플릿(`.gitlab/merge_request_templates/Default.md`): 요약/카테고리(feature|bug|refactor|docs|other)/설명/변경 파일/Jira 링크. ### 13.5 실패 대응 - 빌드 실패 → MR 차단. - 배포 실패 → Portainer webhook 3회 재시도. 재시도 후 실패는 알람 시스템 없음(개선 여지). - 마이그레이션 실패 → API 컨테이너가 boot 실패 → Portainer가 crashloop으로 표시. ### 13.6 향후 개선할 배포 구조 - 멀티 인스턴스(API N개) + 외부 sticky session / sticky SSE 시 Redis Pub/Sub가 필수 — 현재 ADR로만 존재. - Blue/Green 또는 카나리 없음 — Portainer redeploy는 in-place. - 컨테이너 이미지 SBOM/취약점 스캔 없음. 근거: `.gitlab-ci.yml`, `.gitlab/ci/backend.yml`, `.gitlab/ci/frontend.yml`, `.gitlab/ci/nginx.yml`, `.gitlab/ci/mcp-console.yml`, `.gitlab/ci/pr_review.yml`, `.gitlab/deploy.yml`. --- ## 14. 성능 최적화 ### 14.1 측정된 수치 (`docs/nextcloud-transfer-benchmark.md`) | 항목 | CloudSharp | Nextcloud | 비율 | |---|---|---|---| | 1GB 업로드 (총) | 12.22s (83.80 MB/s) | 31.75s (32.27 MB/s) | 2.6× | | 1GB 다운로드 (총) | 6.86s (149.43 MB/s) | 26.54s (38.68 MB/s) | 3.86× | | 다운로드 TTFB | 21.46ms | 275.21ms | 12.8× | | Peak 메모리 | 52MB | 320MB | 6.2× | | SPA 번들 | 95 KiB | 10 998 KiB | — | 원인 분석(문서/측정 기반): - Kestrel vs Apache + mod_php 런타임 차이. - CloudSharp는 단일 PATCH 업로드(tus 청크), Nextcloud는 다중 요청. - PHP-FPM 컨텍스트 전환 vs .NET JIT 직렬. - 동일 호스트·동일 Docker·동일 Postgres·호스트 bind mount로 공정 비교. ### 14.2 적용된 최적화 패턴 | 패턴 | 위치 | |---|---| | `AsNoTracking()` 94회 — 모든 read query | `Infrastructure/Persistence/Repositories/*` | | 수동 `Select(...)` projection — `ProjectTo<>` 미사용 | repositories | | partial unique 인덱스 — 비즈니스 invariant을 DB에서 강제 | `Persistence/Configurations/*` | | `idx_outbox_events_polling` `(available_at, id) WHERE status IN ('PENDING','FAILED')` | `OutboxEventEntityConfiguration` | | `idx_outbox_events_worker_lock_expires_at` `WHERE status='PROCESSING'` | 동일 | | `idx_file_items_checksum_sha256` | dedupe | | `idx_folders_space_parent` | 트리 traversal | | `idx_file_tags_space_tag_file` | 태그 기반 file list | | 단일 `Include` (ThenInclude 0) | 단순 join만 | | `Take(batchSize)` for batch processing | trash purge, file purge, outbox claim | | JSONB 컬럼 (`metadata_json`, `outbox_events.payload/headers`) | Postgres-native | | Host bind mount + 동일 UID/GID → storage move가 in-place | `infra/docker-compose.yml` | | nginx `proxy_buffering off` on `/files/` and `/public/` | streaming | | `client_max_body_size 0` on `/files/` | 무제한 청크 | | `proxy_read/send_timeout 600s` | 장시간 업로드 | ### 14.3 미적용 / 개선 여지 - `AsSplitQuery()` 미사용 — 현재 `Include`가 단일 레벨이라 효용 없음(다대다가 늘어나면 도입). - Pagination: `ListDeletedFilesAsync(paged)` 같은 일부만 `Skip/Take`, 다수는 전체 load. - 캐시 계층이 Redis에 토큰/세션/Pub-Sub만 — 도메인 read 결과 캐시 없음(예: space 멤버십 list). 다만 매 요청 1회 조회로 충분한 트래픽 가정. - OTel / OpenTelemetry 미통합. - gzip / brotli 미적용(nginx) — JSON API가 큰 응답을 반환하는 경우 효과 있음. - HTTP/2 활성화 여부 불명(컨테이너 내부 nginx는 HTTP/1.1, 외부 LB가 HTTP/2/3 담당). ### 14.4 벤치마킹 도구 `apps/backend/tools/CloudSharp.TransferBenchmark/`(README 있음): - 같은 C# 클라이언트로 Nextcloud(WebDAV)와 CloudSharp(tus + API) 측정. - `docker stats --no-stream`으로 CPU% / max-mem / disk-IO deltas 캡처. - `--runs`, `--target both`, `--output ` 옵션. - 출력 컬럼: `provider, operation, totalTimeMs, throughputMBps, throughputMbps, ttfbMs, success, error, cpuAvgPercent, cpuMaxPercent, memoryMaxMB, diskReadMB, diskWriteMB, resourceSamples`. - 측정에 사용한 컨테이너 목록을 `--cloudsharp-docker-containers`, `--nextcloud-docker-containers`로 명시. `apps/backend/tools/CloudSharp.TusUploadClient/`: 단발성 tus 파이프라인 스모크 테스트. CLI 옵션은 README 없음(csproj + Program.cs만). 동작: 1. `POST /api/v1/spaces/{slug}/upload-sessions` → 토큰. 2. `POST /files/` Tus-Resumable 헤더 + Upload-Metadata(base64) + Authorization. 3. `HEAD`로 offset. 4. PATCH 5MiB 청크 (default, `--chunk-size`로 변경). 5. `GET upload-sessions/{token}` 폴링 → `COMPLETED` 또는 실패. 근거: `docs/nextcloud-transfer-benchmark.md`, `apps/backend/tools/CloudSharp.TransferBenchmark/Program.cs`, `apps/backend/tools/CloudSharp.TusUploadClient/Program.cs`. --- ## 15. 설계 문서화 ### 15.1 문서 트리 - 루트: `README.md`(13.9KB), `AGENTS.md`(10.4KB), `CLAUDE.md`(10.4KB) - `docs/`: - `CloudSharp - Space 기반 파일 호스팅 서비스 기획서.md` (575줄, 설계 의도) - `ERD 설계서.md` (920줄, 부분 시일 지남) - `Space 기반 파일 서비스와 멀티 클라우드 통합 탐색 아키텍처.md` (전략) - `storage-finalize-env.md`, `storage-finalize-recovery-worker.md` - `nextcloud-transfer-benchmark.md` (측정 결과) - `아키텍처.md` (143줄, 배포 mermaid 포함) - `기능요구사항.md` (SFR-001..SFR-048) - `Wireframe.md` (이미지) - `docs/.llm/` — **AI 협업용 knowledge hub** - `INDEX.md` (7.5KB, 모든 문서의 인덱스) - `context/` (overview, product-plan, space-architecture, wireframe, architecture, domain, requirements, directory) - `conventions/` (16개 규칙 문서) - `design/` (api.md, erd.md, infra-packaging-plan.md, mcp-stdio-bridge.md, openapi.yaml, pipelines/{upload,download,trash}.md, strategies/15개) - `wiki/` (progress.md, decisions.md, notes.md, trash-api-team-share.md, post-processing-roadmap.md, frontend-sse-integration.md, api-contract-patch-notes.md, api-endpoints-structure-refactor.md, usecases-structure-refactor.md, file-finalized-failure-policy.md) - `exec/` (운영 런북) — README.md, build-and-deploy.md, external-services.md, script.sql ### 15.2 다이어그램 12개 파일에 mermaid 포함. 주요: - `docs/ERD 설계서.md` — 11-엔티티 ERD + 상태머신 3종 + finalize 시퀀스. - `docs/아키텍처.md:86-142` — 배포 mermaid(Client→API/tusd→PG/Redis/FS, ffmpeg/AI 점선). - `docs/.llm/conventions/auth-policy.md:81-86, 152-158, 619-632` — User→SpaceMember, Redis 키, 로그인/role-change 시퀀스. - `docs/.llm/design/pipelines/upload.md`, `download.md`, `trash.md` — 파이프라인 시퀀스. - `docs/.llm/design/strategies/finalize-lock.md`, `quota.md` — 전략 플로우. ### 15.3 ADR (Architecture Decision Records) `docs/.llm/wiki/decisions.md`에 누적. 템플릿: `### [날짜] 제목` + `결정 / 이유 / 트레이드오프`. 기록: - 2026-05-13 **MVP SSE fan-out은 서버 메모리 + userId delivery 기준** — 단일 인스턴스 `ISseConnectionStore` 채택. 트레이드오프: 멀티 인스턴스 미지원. `FileUploaded` → RealtimeFanout|NotificationProjection, `FileFinalized` → Worker로 라우팅 분리. - 2026-04-25 **API Endpoints 타입별 하위 디렉토리 + namespace 정렬** — `Endpoints/{Feature}/{Requests,Responses,Validators,Filters}` + `_Common/`. - 2026-04-25 **UseCases 타입별 하위 디렉토리 + namespace 정렬** — `UseCases/{Feature}/{Commands,Queries,Validators,Results,Dtos,Extensions}`. - 2026-04-23 **HTTP Endpoint 구현 방식 확정** — Minimal APIs + feature-folder Endpoints (no controllers). ### 15.4 OpenAPI / API 계약 - `docs/.llm/design/openapi.yaml` (145KB) — 수동 작성 OpenAPI 3.0.3 (`version: 0.2.0-draft`). - `docs/.llm/design/api.md` — 각 엔드포인트에 `구현됨 / 내부 구현됨 / deprecated 구현됨 / 미구현` 라벨. - `docs/.llm/wiki/api-contract-patch-notes.md` — 실제 백엔드와 스펙의 diff 로그. ### 15.5 운영 문서 - `exec/README.md` — 시나리오 인덱스. - `exec/build-and-deploy.md` — 버전/빌드/deploy/.env/secret 표. - `exec/external-services.md` — GitLab/GHCR/Portainer/GMS/PR-Agent/Docker Hub/MCR/npm/pnpm/PG/Redis/tusd 카탈로그 + secret 목록. - `exec/script.sql` — 운영 reference DDL(`__EFMigrationsHistory` 포함). - `apps/backend/scripts/inital_ddl.sql` — 동일 의도, filename typo(`inital`) 그대로 유지. 근거: 각 문서 파일. ### 15.6 개선 여지 - `docs/ERD 설계서.md`는 outbox/notification/mcp/tag 누락 — 신규 기능 추가 시 동기화 필요. - OpenAPI 수동 유지로 코드 변경과 drift 가능. - ADRs는 `decisions.md` 한 파일에 누적 — `docs/adr/0001-*.md` 식 분리가 더 검색 친화적. --- ## 16. 문제 해결 사례 > **형식**: 문제 상황 / 원인 분석 / 해결 방법 / 선택 이유 / 결과. 실제 코드/문서에서 확인된 내용만 작성. ### 16.1 Space 기반 RBAC + Endpoint Filter — 권한 staleness 없이 다중 Space를 안전하게 격리 **문제 상황** 사용자가 여러 Space에 서로 다른 역할(예: Space A는 OWNER, Space B는 VIEWER)로 속합니다. JWT를 쓰면 토큰 발급 시점에 role이 클레임에 박히므로, 운영자가 역할을 바꾸더라도 토큰 만료 전까지 권한이 옛 상태로 남습니다. Space별 권한은 자주 바뀌므로 staleness로 인한 사고 가능성이 높습니다. **원인 분석** - 클라이언트-서버 stateless 인증의 기본 전제: 토큰만으로 모든 권한을 표현하려는 시도. - Space role은 user-role과 달리 분산된(per-space) 상태이므로 토큰 payload에 한 번 박아두면 일관성 윈도우가 생김. **해결 방법** 1. **Opaque Bearer 토큰** 채택(`docs/.llm/conventions/auth-policy.md`). 토큰 자체는 랜덤 256-bit, 서버는 SHA-256 해시만 Redis(`auth:session:{tokenHash}`, TTL 7일)에 저장. 2. `CloudSharpSessionAuthenticationHandler`는 인증 시 `sub`/`sid`/`system_role`(system role만) 클레임만 설정. **Space role은 클레임에 넣지 않음**. 3. `RequireSpacePermissionFilter`(`IEndpointFilter`)가 매 요청마다 `ISpacePermissionService.FindAuthorizedSpaceAsync(userId, spaceSlug, [SpacePermission])` 호출 → 최신 `SpaceMember` 로드 + `RolePermissions` 사전으로 권한 확인. 4. 결과를 `HttpContext.Items["__CloudSharp.AuthorizedSpace"]`에 캐시(한 요청 안에서만). 5. 핸들러는 `http.GetRequiredAuthorizedSpace()`로 회수. UseCase도 `command.SpaceId` 일치를 재검증(방화 깊이). 6. `MapForbidToNotFoundFilter`로 `Preview`, `File details`의 403을 404로 변환 — Space/File 존재 여부 누설 방지. 7. role 데이터는 `Dictionary>`로 한 곳(`SpacePermissionService.RolePermissions`)에서 정의. 새 권한 = enum 값 추가 + 사전 한 줄 갱신. **선택 이유** - Opaque 토큰 + 매 요청 조회: 무효화 즉시 반영, `KeyDelete` 한 번으로 logout-all. - 핸들러/UseCase/필터 3중 검증: 한 계층이 우회돼도 다른 계층이 차단. - 403→404 변환: 보안(Space 존재 여부 미노출)과 UX(예측 가능한 404) 동시 달성. **결과** - Role 변경이 다음 요청부터 즉시 반영 — 운영자가 kick/role-change 후 1초 대기 불필요. - 1,000개 Space × 10개 멤버 규모에서 권한 조회 비용은 단일 PK look-up 한 번. - 동일한 패턴을 MCP 토큰(`cs_mcp_*`)에도 적용 — 같은 `Bearer` 헤더 하나로 사용자/MCP를 라우팅. 근거: `apps/backend/src/CloudSharp.Api/Auth/CloudSharpSessionAuthenticationHandler.cs`, `apps/backend/src/CloudSharp.Api/Filters/RequireSpacePermissionFilter.cs`, `apps/backend/src/CloudSharp.Api/Filters/MapForbidToNotFoundFilter.cs`, `apps/backend/src/CloudSharp.Core/UseCases/Members/SpacePermissionService.cs`, `docs/.llm/conventions/auth-policy.md`. --- ### 16.2 tusd 업로드 finalize — 보상/복구로 외부 시스템 + DB 정합성 보장 **문제 상황** tusd가 청크 업로드를 마치고 `post-finish` hook을 호출하면 백엔드는 다음 4가지를 한꺼번에 처리해야 합니다. 1. 파일을 temp에서 final로 이동. 2. `file_items` INSERT. 3. `spaces.storage_used_bytes` 갱신. 4. `file_reservations.status = CONSUMED`. 5. `upload_sessions.status = COMPLETED`. 각각이 부분 실패할 수 있고, 시스템 외부(파일 시스템)와 DB 사이의 정합성도 깨질 수 있습니다. 또 finalize 도중 클라이언트가 cancel하거나 다른 finalize가 race로 들어오면 `FINALIZING` 상태에 행이 영구히 갇힐 수 있습니다. **원인 분석** - 분산 트랜잭션 매니저를 도입하면 운영/의존성이 무거워짐(잠재 2PC, SAGA 코디네이터). - 단일 DB transaction만으로는 파일 시스템 단계의 실패를 흡수할 수 없음. - 상태 머신 전이가 원자적이지 않으면 두 finalize가 같은 `UploadSession`을 동시에 진행시킬 수 있음. **해결 방법** 1. **상태 머신 atomic transition**: `upload_sessions.TryStartFinalizingAsync`는 `WHERE status IN ('CREATED','UPLOADING')` 조건부 UPDATE로 한 번에 한 finalize만 통과. 실패 시 `UploadInvalidStateTransition` (409). 2. **storage move → DB tx → 보상 saga**: - storage move: `IStorageProvider.MoveTempToFinalAsync`. - DB: `ITransactionManager.ExecuteAsync(PersistFinalizedUploadAsync)`로 file_items/quota/reservation/session을 한 트랜잭션. - DB 실패: `MoveFinalToTempAsync` (보상) + `MarkSessionFailedAsync(releaseReservedStorage=true)`. - 보상도 실패: `LogError("FINALIZE_STORAGE_RESTORE_FAILED")` + `MarkSessionFailedAsync`로 끊어 release(예약 해제로 쿼터 회복). 3. **Outbox 같은 트랜잭션**: `FileUploaded`/`FileFinalized` 이벤트를 같은 DB tx에 enqueue — 이벤트 누락 방지. 4. **자율 복구 워커**: `UploadFinalizeRecoveryRunner` + `UploadFinalizeRecoveryService`가 `Uploads:FinalizeRecoveryIntervalSeconds=300`마다 `finalizing_started_at < NOW - Uploads:FinalizeRecoveryStaleMinutes=10`인 `FINALIZING` 행을 찾아 `MarkFailedIfFinalizingAsync` (status-conditional UPDATE)로 `FAILED`로 전이 + `MoveFinalToTempAsync`로 보상. 5. **테스트로 명세 고정**: `apps/backend/tests/CloudSharp.Api.IntegrationTests/BackgroundServices/UploadFinalizeRecoveryServiceTests.cs`가 `Mock` + `FakeUploadSessionRepository` + `FakeStorageProvider` + `ListLogger`로 staleMinutes/BatchSize clamping, 기본값 fallback을 명세. 6. **운영 변수 가드**: `Uploads__FinalizeToken`을 운영 compose에서 `:?Uploads__FinalizeToken is required`로 강제(빈 값이면 부팅 실패). **선택 이유** - 2PC 대신 saga + 보상 + ledger로 단순화 — 실패 모드를 코드로 명시. - Atomic status transition으로 race를 DB 레벨에서 차단. - 별도 복구 워커는 자가 치유(self-healing) — 사람의 개입 없이 stuck 행 청소. - best-effort outbox enqueue + tx outbox의 균형: 도메인 tx를 깨지 않으면서 at-least-once 보장. **결과** - finalize 성공/실패/취소/중복 race 모두 `UploadSession`의 `status` enum 7종(`CREATED,UPLOADING,FINALIZING,COMPLETED,FAILED,ABORTED,EXPIRED`) 중 하나로 수렴. - stuck `FINALIZING` 행이 5분 안에 자동 정리(`Uploads__FinalizeRecoveryStaleMinutes=10` + interval 300s). - 스토리지 오브젝트 고아 가능성은 `file_purge_requests` ledger + `TrashAutoPurgeRunner`가 후속 청소. 근거: `apps/backend/src/CloudSharp.Core/UseCases/Uploads/UploadUseCases.cs:90-345`, `apps/backend/src/CloudSharp.Api/BackgroundServices/UploadFinalizeRecoveryRunner.cs`, `apps/backend/tests/CloudSharp.Api.IntegrationTests/BackgroundServices/UploadFinalizeRecoveryServiceTests.cs`, `docs/storage-finalize-recovery-worker.md`, `docs/storage-finalize-env.md`. --- ### 16.3 트랜잭셔널 Outbox + 다중 타깃 fan-out — 도메인 이벤트를 단일 발행으로 여러 다운스트림에 안전 라우팅 **문제 상황** `FileUploaded`가 발생했을 때 다음 3가지 일을 모두 해야 합니다. 1. UI에 SSE로 실시간 알림(`FileUploaded` event). 2. 알림함에 행 추가(`NotificationProjection`). 3. 외부 Worker로 썸네일/메타데이터 작업 발송(`Worker` — Redis Pub/Sub). 각각 별도 UseCase를 호출하면 도메인 트랜잭션과 외부 호출 사이의 정합성이 깨집니다. 또 한 도메인 변경에 대해 다른 형식/다른 라우팅을 일관되게 적용하기 어렵습니다. **원인 분석** - 도메인 코드에서 `ISseClient.Send()`, `INotificationRepository.AddAsync()`, `IRedisPublisher.PublishAsync()`를 직접 호출하면 (a) 트랜잭션 안에 있지 않아서 실패 시 도메인 변경은 롤백되지 않음, (b) 라우팅 규칙이 분산되어 변경 비용 증가. - 폴링 기반 outbox도 락/재시도/멱등을 잘못 다루면 중복 발송 또는 deadlock. **해결 방법** 1. **트랜잭셔널 outbox**: `OutboxEventRecorder.AddBestEffortAsync`가 도메인 tx와 같은 `DbContext`에 `outbox_events` 행을 INSERT. 실패 시 `LogWarning`만 하고 throw 안 함. 2. **outbox_events 테이블**: - `status`는 `VARCHAR(30)` (enum 회피 — 새 상태 추가 시 마이그레이션 불필요). - `EventId` UUID UNIQUE — at-most-once enqueue. - `idx_outbox_events_polling` `(available_at, id) WHERE status IN ('PENDING','FAILED')`. - `idx_outbox_events_worker_lock_expires_at` `WHERE status='PROCESSING'`. 3. **Dispatcher claim (낙관적 claim, FOR UPDATE 미사용)**: - `SELECT id ORDER BY available_at LIMIT N`. - `ExecuteUpdate SET status='PROCESSING', worker_id, worker_lock_expires_at WHERE status IN ('PENDING','FAILED')`. - 다시 `SELECT WHERE status='PROCESSING' AND worker_id=?`로 실제 claim된 행만 회수. - 사이클 시작 시 `ReleaseExpiredLocksAsync`가 lease 만료분을 `FAILED`로 회수 + `available_at=NOW()`로 재시도 가능 상태로. 4. **라우팅 레지스트리**: `OutboxEventRouteRegistry`가 `eventType → OutboxDispatchTarget` (None|RealtimeFanout|NotificationProjection|Worker) 매핑. `FileUploaded` → `RealtimeFanout | NotificationProjection`, `FileFinalized` → `Worker`. 5. **재시도 + 백오프**: `RecordDispatchFailureAsync`가 `attempts` 증가 + `available_at = failedAt + min(RetryBaseDelay * 2^(attempts-1), RetryMaxDelay)`. 기본 `30s/60s/120s/.../300s`. `max_attempts=10` 초과 시 후보에서 제외(현재 `DeadLetter` 상태 코드 정의됨, 자동 전이 로직은 미구현). 6. **dispatcher 3종**: - `RealtimeFanoutOutboxEventDispatcher`: `ISseConnectionStore`에 audience(space 멤버, requester, removed user 등)별로 push. - `NotificationProjectionOutboxEventDispatcher`: `INotificationUseCases.DispatchNotificationAsync`로 `notifications` 행 생성. `ux_notifications_outbox_event_id` partial unique가 at-most-once 보장. - `WorkerPubSubOutboxEventDispatcher`: `WorkerJobDispatchUseCases`가 `WorkerJobPayload`를 `cloudsharp:worker:jobs` Redis 채널에 publish. 7. **단일 Best-Effort 보장과 at-least-once 결합**: 도메인 tx가 commit된 후 outbox enqueue가 실패하면 `LogWarning`만 — 도메인 상태는 보존, but 동일 이벤트가 도메인 코드에서 멱등 처리되도록 작성. **선택 이유** - 2PC/Outbox 패턴 + 라우팅 레지스트리로 다운스트림 추가/제거가 SQL 한 줄 + dispatcher 등록 한 개로 끝. - FOR UPDATE SKIP LOCKED 대신 optimistic claim — DB 종속성↓, partial index로 throughput 확보. - 3-target fan-out이 한 번의 enqueue로 처리되어 도메인 코드는 `AddBestEffortAsync` 한 줄만 신경 씀. - SSE vs 알림 vs Worker가 같은 envelope 형식을 공유 → 클라이언트/워커는 한 번의 deserializer로 모든 이벤트를 처리. **결과** - 새 도메인 이벤트 추가 = (a) `OutboxEventTypes` 상수 1개, (b) `OutboxEventRouteRegistry`에 route 1줄, (c) dispatcher 1개(또는 기존 라우팅 재사용). 도메인 UseCase 변경 없음. - 워커 1대 장애 시 다른 워커가 lease 만료 후 자연스럽게 인계 — 사람의 개입 불필요. - `notifications` 행은 outbox 이벤트당 정확히 0~1개(at-most-once). - **개선 여지**: `max_attempts` 초과 시 `DeadLetter` 테이블/관리 API가 없음 — 현재는 폴링에서 제외되어 누적. 근거: `apps/backend/src/CloudSharp.Core/UseCases/Outbox/OutboxEventRecorder.cs`, `apps/backend/src/CloudSharp.Core/UseCases/Outbox/OutboxEventRepository.cs:80-132`, `apps/backend/src/CloudSharp.Core/UseCases/Outbox/OutboxProcessingUseCases.cs`, `apps/backend/src/CloudSharp.Api/Outbox/OutboxEventRouteRegistry.cs`, `apps/backend/src/CloudSharp.Api/BackgroundServices/OutboxEventProcessingService.cs`, `apps/backend/src/CloudSharp.Api/Realtime/RealtimeFanoutOutboxEventDispatcher.cs`. --- ### 16.4 Same-Origin Docker Compose + nginx — CORS/TLS 표면 최소화 **문제 상황** 자가호스팅 시나리오에서 단일 호스트(혹은 VM/미니PC) 한 대로 FE/BE/tusd/DB를 다 띄우는데, 다음 요구가 동시에 있습니다. - 외부 노출 포트는 최소(보안·TLS 비용). - SPA가 백엔드에 credentialed 요청(쿠키/Bearer) 가능. - tusd의 청크 streaming이 nginx에서 깨지지 않아야 함. - Swagger/OpenAPI 같은 운영 도구를 같은 origin에서 제공. **원인 분석** - FE/BE를 분리 origin으로 두면 CORS, preflight, 쿠키 정책, TLS 인증서 2장 등 비용 증가. - 반대로 BE에 SPA를 embed하면 운영 도구(Swagger)와의 충돌 + Vite 개발 흐름 깨짐. - tusd의 청크는 buffer 없이 흘려야 하므로 일반 reverse proxy 설정과 다름(`proxy_buffering off`, `client_max_body_size 0`). **해결 방법** 1. **3-tier 컨테이너 + 1-tier 외부 노출**: - `infra/docker-compose.yml`은 `nginx`만 호스트 포트 `${Nginx__Port:-8080}:80` 게시. - `frontend`, `api`, `tusd`, `postgres`, `redis`는 모두 `expose`만 — `cloudsharp` 브리지 네트워크 안에서만 통신. 2. **단일 `server { listen 80; server_name localhost; }`** + path-based 라우팅: - `/`, `/assets/` → `frontend:80` - `/api/`, `/public/`, `/swagger/`, `/openapi/` → `api:8080` - `/files/` → `tusd:1080/files/` (streaming 옵션) 3. **tusd streaming passthrough**: - `proxy_buffering off`, `proxy_request_buffering off` - `client_max_body_size 0` - `proxy_read/send_timeout 600s` - `proxy_set_header Authorization` — tusd가 API의 pre-create hook으로 그대로 forward 4. **same UID/GID 공유**: `CloudSharp__Uid/Gid=10001`을 `init-storage` → `api`, `tusd`가 공유. `init-storage`가 `chown -R 10001:10001 /data/storage` 후 종료. 5. **운영 vs 개발 posture 분리**: - `docker-compose.yml` (운영): GHCR 이미지, `:?`로 비밀 강제, `ASPNETCORE_ENVIRONMENT=Production`. - `docker-compose.local-build.yml` (개발): 로컬 Dockerfile build, `Uploads__FinalizeToken=test`, `Development`. 6. **boot-prod 스크립트 안전망**: `Uploads__FinalizeToken`이 기본값/빈 값이면 32-byte base64 자동 생성 또는 사용자 프롬프트. 7. **TLS는 호스트(또는 앞단 LB)에서 종단** — 컨테이너 내부 통신은 평문 80. `UseHttpsRedirection=false` 기본. **선택 이유** - 컨테이너 외부 노출 1개 = 방화벽/감사/CORS가 한 곳으로 모임. - path-based 라우팅은 subdomain/DNS 없이도 단순, 자가호스팅 시 DNS 부담 0. - 개발/운영을 두 compose로 분리하되 토폴로지/네트워크/볼륨 동일 → 운영 검증이 곧 개발 검증. - shared UID = tusd temp dir을 API가 직접 move 가능(추가 IO 없음). **결과** - `boot-prod.sh` 한 번으로 GHCR에서 pull → 6-서비스 기동 → `/api/v1/health`까지 한 번에. - Same-Origin으로 CORS 미적용. CSRF는 토큰 기반 인증으로 회피. - 운영에서 TLS는 호스트 앞단(LB/Caddy/Cloudflare Tunnel)에 위임 — 유연성↑, 인증서 자동화 용이. - **개선 여지**: `/swagger/`, `/openapi/`가 정책으로만 보호 — 운영에서는 nginx 레벨 IP allowlist 또는 앞단 LB에서 차단 권장. 근거: `infra/docker-compose.yml`, `infra/docker-compose.local-build.yml`, `infra/nginx/cloudsharp.conf`, `infra/boot-prod.sh`, `infra/.env`. --- ### 16.5 성능 측정 도구화 — 자체 C# 클라이언트 + docker stats로 회귀 가능 벤치마크 **문제 상황** "CloudSharp가 Nextcloud보다 빠르다"는 주장은 README 헤드라인에 박혀 있지만, 동종 환경(같은 호스트·같은 DB·같은 스토리지 바인드 마운트)에서 같은 도구로 측정한 결과여야 신뢰할 수 있습니다. 단순 부하 테스트 스크립트가 아니라 다음이 필요했습니다. - 같은 C# 클라이언트로 두 시스템 모두 측정. - 단순 RPS가 아니라 사용자 체감(throughput, TTFB, peak memory, CPU). - 변경 시 회귀가 없는지 추적 가능. **원인 분석** - 웹 기반 벤치마크 도구는 헤더/메서드/청크 동작이 정확히 같지 않음. - CPU/메모리 측정을 빠뜨리면 런타임 차이(Apache/PHP vs Kestrel) 효과를 분리하지 못함. **해결 방법** 1. **자체 C# 도구** `apps/backend/tools/CloudSharp.TransferBenchmark`: - `NextcloudBenchmarkClient` (WebDAV MKCOL + PUT + GET, Basic auth, `TransferMeteredStream`으로 byte 카운트). - `CloudSharpBenchmarkClient` (Bearer + tus POST/HEAD/PATCH + 폴링 + download session). - `DockerResourceMonitor`가 `docker stats --no-stream` JSON을 샘플링 → CPU% / max-mem / disk-IO deltas. - 출력: CSV + 콘솔 요약(`provider, operation, totalTimeMs, throughputMBps, throughputMbps, ttfbMs, success, error, cpuAvgPercent, cpuMaxPercent, memoryMaxMB, diskReadMB, diskWriteMB, resourceSamples`). - CLI: `--file`, `--runs N`, `--target both`, `--output `, `--cloudsharp-docker-containers`, `--nextcloud-docker-containers`. 2. **단발성 tus 스모크 테스트** `apps/backend/tools/CloudSharp.TusUploadClient`: - 세션 생성 → tus POST → HEAD offset → 5MiB PATCH → poll COMPLETED. - CLI: `--api-url`, `--tus-endpoint`, `--access-token`, `--space-slug`, `--folder-id`, `--file`, `--chunk-size`, `--mime-type`, `--checksum`, `--poll-timeout-seconds`. 3. **측정 결과 문서화** `docs/nextcloud-transfer-benchmark.md`: - 동일 호스트·동일 Docker·동일 Postgres·호스트 bind mount 환경 명시. - 1GB 업로드 12.22s (83.80 MB/s) vs 31.75s (32.27 MB/s), 1GB 다운로드 6.86s (149.43 MB/s) vs 26.54s (38.68 MB/s), TTFB 21.46ms vs 275.21ms, 메모리 52MB vs 320MB. 4. **원인 분석을 솔직하게 기술**: Kestrel vs Apache+mod_php, 단일 PATCH 업로드, 좁은 application path. **선택 이유** - 같은 도구/같은 시나리오로만 비교 가능 — apples-to-apples. - 런타임 리소스까지 함께 캡처해서 단순 throughput 비교보다 원인 분석이 가능. - CSV 출력으로 추후 회귀 추적 가능. **결과** - README 헤드라인 "2.6× 업로드, 3.86× 다운로드"가 측정 가능한 수치로 뒷받침됨. - 동일 도구로 추후 변경(예: Redis Streams 도입, Kestrel 옵션 튜닝)의 효과를 A/B 측정 가능. - **개선 여지**: 두 도구 모두 README는 `TransferBenchmark`만 있고 `TusUploadClient`는 없음. CI에 자동 벤치마크 게이트는 없음(수동 실행). 근거: `apps/backend/tools/CloudSharp.TransferBenchmark/Program.cs` + `README.md`, `apps/backend/tools/CloudSharp.TusUploadClient/Program.cs`, `docs/nextcloud-transfer-benchmark.md`, `README.md`. --- ## 부록 A. 근거 파일 인덱스 (요약) | 영역 | 핵심 파일 | |---|---| | 백엔드 아키텍처 | `apps/backend/src/CloudSharp.{Api,Core,Infrastructure}/` | | API endpoint | `apps/backend/src/CloudSharp.Api/Endpoints/**/*Endpoints.cs` | | 인증/인가 | `apps/backend/src/CloudSharp.Api/Auth/*`, `Filters/*`, `apps/backend/src/CloudSharp.Core/UseCases/Members/SpacePermissionService.cs` | | UseCase | `apps/backend/src/CloudSharp.Core/UseCases/**/*UseCases.cs` | | Error / Result | `apps/backend/src/CloudSharp.Core/Common/Errors/*`, `apps/backend/src/CloudSharp.Api/Endpoints/_Common/ResultHttpMapper.cs` | | DB Configurations | `apps/backend/src/CloudSharp.Infrastructure/Persistence/Configurations/*` | | Migrations | `apps/backend/src/CloudSharp.Infrastructure/Persistence/Migrations/*` | | Outbox | `apps/backend/src/CloudSharp.Core/UseCases/Outbox/*`, `apps/backend/src/CloudSharp.Api/Outbox/OutboxEventRouteRegistry.cs`, `apps/backend/src/CloudSharp.Api/BackgroundServices/OutboxEventProcessingService.cs` | | BackgroundServices | `apps/backend/src/CloudSharp.Api/BackgroundServices/*` | | Storage | `apps/backend/src/CloudSharp.Infrastructure/Storage/LocalFileStorage/LocalStorageProvider.cs` | | Options | `apps/backend/src/CloudSharp.Api/Options/*` | | Docker | `infra/docker-compose.yml`, `infra/docker-compose.local-build.yml`, `apps/backend/docker-compose.yml` | | nginx | `infra/nginx/cloudsharp.conf`, `infra/nginx/Dockerfile` | | 스크립트 | `infra/boot-prod.{sh,ps1}`, `infra/dev-up.{sh,ps1}`, `infra/dev-reset.{sh,ps1}` | | 환경 | `infra/.env`, `infra/.env.example`, `apps/backend/.env.example` | | CI/CD | `.gitlab-ci.yml`, `.gitlab/ci/{backend,frontend,nginx,mcp-console,pr_review}.yml`, `.gitlab/deploy.yml` | | 도구 | `apps/backend/tools/CloudSharp.TransferBenchmark/`, `apps/backend/tools/CloudSharp.TusUploadClient/` | | 문서 | `README.md`, `AGENTS.md`, `CLAUDE.md`, `docs/.llm/INDEX.md`, `docs/.llm/conventions/*`, `docs/.llm/design/*`, `docs/.llm/wiki/*`, `docs/*.md`, `exec/*` | ## 부록 B. 미구현 / 미래 확장 메모 | 항목 | 상태 | 위치 | |---|---|---| | 멀티-인스턴스 SSE (Redis Pub/Sub) | ADR로만 존재 | `docs/.llm/wiki/decisions.md` 2026-05-13 | | Outbox DeadLetter 테이블/관리 API | `DeadLetter` 상태 enum 정의, 자동 전이 로직 미구현 | `OutboxEvent` 도메인 | | S3/MinIO storage provider | `StorageProvider` 컬럼 + enum은 있음, S3 어댑터 미구현 | `docs/.llm/design/strategies/storage.md` | | OpenSearch 검색 | 마이그레이션 없음, 디자인만 | `docs/.llm/context/architecture.md` | | Kafka/Redis Streams | 디자인만 | 동일 | | API-level Idempotency-Key 헤더 | 미구현 | — | | 중앙 로그 / 메트릭 (Loki, Prometheus, OTel) | 미도입 | §12.4 | | Blue/Green, 카나리 배포 | 미도입, Portainer in-place redeploy | §13.6 | | 운영 nginx TLS, IP allowlist | 앞단 LB에 위임 | §10.4 | | 마이그레이션 `CREATE INDEX CONCURRENTLY` | 미사용 | §5.9 | --- > **문서 끝**. 본 초안은 16개 항목 + 부록 2개로 구성되어 있습니다. 각 항목은 실제 코드/문서 근거를 명시하며, 구현/설계/개선 여지를 분리해 표기했습니다. 면접/포트폴리오용으로 발췌할 때 §16(문제 해결 사례)을 먼저 압축하고 나머지는 그대로 활용 가능합니다.