Files
irukseo/projects/CloudSharp-백엔드-포트폴리오-분석.md
2026-06-01 16:40:05 +09:00

1328 lines
84 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<br/>./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://<host>/<...>` 단일 오리진으로만 요청.
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<T>` → 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 <token>`을 검증하고 `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<SpaceRole, ImmutableHashSet<SpacePermission>>`):
- `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<T>``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<T>()`로 매핑.
- `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 <Name> \
--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<Result>` / `Task<Result<T>>`만. HTTP/IActionResult/DTO 노출 금지.
- `CancellationToken`은 항상 마지막 파라미터.
- DI: `builder.Services.AddScoped<I{Domain}UseCases, {Domain}UseCases>()`.
- `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<Task<Result<T>>>)` | 도메인 단위 작업 (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<T>`가 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<T>`로 바인딩되고 `ValidateOnStart()`로 startup 검증:
| 옵션 | 섹션 | Validator | 검증 항목 |
|---|---|---|---|
| `AuthOptions` | `Auth` | `AuthOptionsValidator` | `SessionHashKey ≥ 32 chars`, `SessionExpiresInSeconds > 0` |
| `SpaceOptions` | `Space` | `SpaceOptionsValidator` | `MaxStorageAllowedBytes` 합리성 |
| `InfraOptions` | root | `InfraOptionsValidator` | Postgres/Redis/Storage/Tusd 각 하위 옵션 |
`FluentValidateOptions<T>`(`Options/FluentValidateOptions.cs`)가 `IValidator<T>`를 `IValidateOptions<T>`로 어댑트.
```csharp
builder.Services
.AddOptions<AuthOptions>()
.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 <csv>` 옵션.
- 출력 컬럼: `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<SpaceRole, ImmutableHashSet<SpacePermission>>`로 한 곳(`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<IServiceScopeFactory>` + `FakeUploadSessionRepository` + `FakeStorageProvider` + `ListLogger<T>`로 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 <csv>`, `--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(문제 해결 사례)을 먼저 압축하고 나머지는 그대로 활용 가능합니다.