84 KiB
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 불편이 남아 있습니다.
- 느린 전송 속도: 대용량 파일 업로드/다운로드에서 PHP-기반 웹 서버(Apache + mod_php)의 한계로 체감 속도가 떨어집니다.
- 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 인프라 구성이 필요한 이유
세 가지 외부 상태가 코드만으로 다루기 어렵기 때문입니다.
- tusd의 파일 디렉터리와 API의 objects 디렉터리가 같은 호스트의 같은 경로여야 finalize 시
MoveTempToFinalAsync가 가능 →CloudSharp__Uid/Gid통일 +init-storage컨테이너가 동일 UID로 chmod. - PostgreSQL/Redis가 컨테이너로 기동 + 호스트에 데이터 보존 → named volume + healthcheck + depends_on 체인.
- 외부 노출은 단일 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 구성 요소
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 외부 요청이 내부로 전달되는 흐름
- 클라이언트(브라우저/MCP 호스트)는
https://<host>/<...>단일 오리진으로만 요청. - nginx가 path-prefix로 분기:
//assets/→frontend:80/api/,/public/,/swagger/,/openapi/→api:8080/files/→tusd:1080/files/
/api/v1/spaces/{slug}/upload-sessions로 업로드 세션 생성 → 응답의 토큰을 tus의Upload-Metadata에 base64로 실어POST /files/.- tusd가 청크 PATCH 수신 후
post-finishhook을api:8080/internal/tusd/hooks로 호출. - API가 finalize(상태천이 → storage move → DB 트랜잭션 → outbox enqueue) 처리.
FileFinalizedoutbox 이벤트가cloudsharp:worker:jobsRedis 채널로 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-storageone-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/*
- 내부 API:
- 핸들러 시그니처 순서:
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 단위)SpacePermissionenum 16종,SpacePermissionService.RolePermissions(정적Dictionary<SpaceRole, ImmutableHashSet<SpacePermission>>):OWNER= 모든 권한ADMIN=DeleteSpace제외한 모든 권한MEMBER= 읽기/쓰기 + 자기 ShareLink 발급VIEWER= 읽기/다운로드만
4.4 리소스 소유자/권한 검증 위치
파이프라인 순서(§3.5 표 참조):
UseAuthentication()→UseAuthorization()미들웨어가sub클레임 설정.RequireDelegatedUserAccess()(또는 그 변형) 정책이 401 거절.RequireSpacePermissionFilter(IEndpointFilter)가 라우트의spaceSlug로ISpacePermissionService.FindAuthorizedSpaceAsync(userId, slug, [perm])호출.- 결과
AuthorizedSpaceContext를HttpContext.Items["__CloudSharp.AuthorizedSpace"]에 저장. - 핸들러는
http.GetRequiredAuthorizedSpace()로 회수. - UseCase도
command.SpaceId일치를 재검증(방어 깊이 추가). 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_targets8개 테이블은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에, 자동 생성된 것도 사람이 리뷰. - 마이그레이션 명령:
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만 Postgresxmin사용. 나머지는 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).
// 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):
createValidator.ValidateAsync→ 실패면Result.Fail(ValidationError).spacePermissionService.FindAuthorizedSpaceAsync(actor, slug, [CreateShareLink])→ null이면CloudSharpShareLinkError(ShareLinkForbidden).ShareLinkTarget을 FILE/FOLDER 분기 서브-UseCase로 빌드.tokenGenerator.CreateHashedToken("cs_sh")로 토큰 생성,ExistsByTokenHashAsync5회까지 재시도(collide 회피).ShareLink.Create(...)도메인 팩토리 (Result).shareLinkRepository.SaveAsync→ unique violation이면 재시도.- 성공 시 같은 트랜잭션 내
OutboxEventRecorder.AddBestEffortAsync(OutboxEventTypes.ShareLinkCreated, ...)호출. CreateShareLinkResult { ShareLink, PublicUrl, ShareToken }반환.
6.5 대표 UseCase 상세 — Upload Finalize
UploadUseCases.CompleteUploadAsync (UseCases/Uploads/UploadUseCases.cs:90-345):
FinalizeUploadCommand검증.TempStorageKey안전성 검증(.., NUL, rooted, ≤100자 차단).TryStartFinalizingAsync—CREATED/UPLOADING→FINALIZING원자 전이. 실패 시UploadInvalidStateTransition.- temp 파일 크기 vs
ExpectedSize/FinalSizeBytes일치 검증. - Space 존재/쿼터/파일명 충돌 확인, 최종
StorageKey계산. MoveTempToFinalAsync— storage move._transactionManager.ExecuteAsync(PersistFinalizedUploadAsync)로FileItemINSERT,upload_sessions.status=COMPLETED, outboxFileUploaded/FileFinalizedenqueue를 한 트랜잭션으로.- DB 실패 →
MoveFinalToTempAsync(보상) +MarkSessionFailedAsync(releaseReservedStorage=true). - 보상도 실패 →
LogError(FINALIZE_STORAGE_RESTORE_FAILED)+ 그래도MarkSessionFailedAsync. - 성공 시 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입니다.
- Storage move:
MoveTempToFinalAsync(temp → final). - DB transaction: FileItem/Quota/Reservation/Session 단일 트랜잭션.
- DB 실패 시 보상:
MoveFinalToTempAsync(final → temp 복원) +MarkSessionFailedAsync. - 보상 실패:
LogError("FINALIZE_STORAGE_RESTORE_FAILED")+ 그래도MarkSessionFailedAsync로 끊어 release(예약 해제).
스토리지 오브젝트 고아(스토리지만 옮기고 DB가 commit 직전에 죽은 경우)는 file_purge_requests ledger + TrashAutoPurgeRunner가 주기적으로 청소.
7.4 동시성 제어
- 낙관적 동시성:
share_links,notifications,notification_read_positions만 Postgresxminrow version 사용(EF shadowxmin 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_hashUNIQUEshare_links.token_hashUNIQUEmcp_tokens.token_hashUNIQUEupload_sessions.tokenUNIQUEupload_sessions.tus_upload_idUNIQUE(nullable)outbox_events.event_idUUID UNIQUEnotifications.outbox_event_idpartial 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_requestsledger: 워커가 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 공통 구조
{
"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 한정) → 400OperationCanceledException→ 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://+:8080Postgres__Host=postgres,Postgres__Db/User/Password/PortRedis__Host=redis,Redis__Port/User/PasswordTusd__Host=tusd,Tusd__Port=1080,Tusd__UploadDir=/data/storage/tmp/tusdStorage__Provider=local,RootPath=/data/storage,TempPath=/data/storage/tmp/tusd,ObjectsPath=/data/storage/objectsAuth__SessionHashKey(32B base64),Auth__SessionExpiresInSeconds=604800Uploads__FinalizeToken(운영은:?필수, dev는test기본값)Uploads__FinalizeRecoveryEnabled/IntervalSeconds=300/StaleMinutes=10/BatchSize=100Space__MaxStorageAllowedBytes(빈 값 = 무제한)UseHttpsRedirection=false,RunDatabaseMigrationsOnStartup=trueCloudSharp__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는 DockerfileHEALTHCHECK→ 운영 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
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 (실제 값, 커밋된 예시):
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>로 어댑트.
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
healthcheck10s 간격. - 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만). 동작:
POST /api/v1/spaces/{slug}/upload-sessions→ 토큰.POST /files/Tus-Resumable 헤더 + Upload-Metadata(base64) + Authorization.HEAD로 offset.- PATCH 5MiB 청크 (default,
--chunk-size로 변경). 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.mdnextcloud-transfer-benchmark.md(측정 결과)아키텍처.md(143줄, 배포 mermaid 포함)기능요구사항.md(SFR-001..SFR-048)Wireframe.md(이미지)
docs/.llm/— AI 협업용 knowledge hubINDEX.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에 한 번 박아두면 일관성 윈도우가 생김.
해결 방법
- Opaque Bearer 토큰 채택(
docs/.llm/conventions/auth-policy.md). 토큰 자체는 랜덤 256-bit, 서버는 SHA-256 해시만 Redis(auth:session:{tokenHash}, TTL 7일)에 저장. CloudSharpSessionAuthenticationHandler는 인증 시sub/sid/system_role(system role만) 클레임만 설정. Space role은 클레임에 넣지 않음.RequireSpacePermissionFilter(IEndpointFilter)가 매 요청마다ISpacePermissionService.FindAuthorizedSpaceAsync(userId, spaceSlug, [SpacePermission])호출 → 최신SpaceMember로드 +RolePermissions사전으로 권한 확인.- 결과를
HttpContext.Items["__CloudSharp.AuthorizedSpace"]에 캐시(한 요청 안에서만). - 핸들러는
http.GetRequiredAuthorizedSpace()로 회수. UseCase도command.SpaceId일치를 재검증(방화 깊이). MapForbidToNotFoundFilter로Preview,File details의 403을 404로 변환 — Space/File 존재 여부 누설 방지.- 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가지를 한꺼번에 처리해야 합니다.
- 파일을 temp에서 final로 이동.
file_itemsINSERT.spaces.storage_used_bytes갱신.file_reservations.status = CONSUMED.upload_sessions.status = COMPLETED.
각각이 부분 실패할 수 있고, 시스템 외부(파일 시스템)와 DB 사이의 정합성도 깨질 수 있습니다. 또 finalize 도중 클라이언트가 cancel하거나 다른 finalize가 race로 들어오면 FINALIZING 상태에 행이 영구히 갇힐 수 있습니다.
원인 분석
- 분산 트랜잭션 매니저를 도입하면 운영/의존성이 무거워짐(잠재 2PC, SAGA 코디네이터).
- 단일 DB transaction만으로는 파일 시스템 단계의 실패를 흡수할 수 없음.
- 상태 머신 전이가 원자적이지 않으면 두 finalize가 같은
UploadSession을 동시에 진행시킬 수 있음.
해결 방법
- 상태 머신 atomic transition:
upload_sessions.TryStartFinalizingAsync는WHERE status IN ('CREATED','UPLOADING')조건부 UPDATE로 한 번에 한 finalize만 통과. 실패 시UploadInvalidStateTransition(409). - 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(예약 해제로 쿼터 회복).
- storage move:
- Outbox 같은 트랜잭션:
FileUploaded/FileFinalized이벤트를 같은 DB tx에 enqueue — 이벤트 누락 방지. - 자율 복구 워커:
UploadFinalizeRecoveryRunner+UploadFinalizeRecoveryService가Uploads:FinalizeRecoveryIntervalSeconds=300마다finalizing_started_at < NOW - Uploads:FinalizeRecoveryStaleMinutes=10인FINALIZING행을 찾아MarkFailedIfFinalizingAsync(status-conditional UPDATE)로FAILED로 전이 +MoveFinalToTempAsync로 보상. - 테스트로 명세 고정:
apps/backend/tests/CloudSharp.Api.IntegrationTests/BackgroundServices/UploadFinalizeRecoveryServiceTests.cs가Mock<IServiceScopeFactory>+FakeUploadSessionRepository+FakeStorageProvider+ListLogger<T>로 staleMinutes/BatchSize clamping, 기본값 fallback을 명세. - 운영 변수 가드:
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의statusenum 7종(CREATED,UPLOADING,FINALIZING,COMPLETED,FAILED,ABORTED,EXPIRED) 중 하나로 수렴. - stuck
FINALIZING행이 5분 안에 자동 정리(Uploads__FinalizeRecoveryStaleMinutes=10+ interval 300s). - 스토리지 오브젝트 고아 가능성은
file_purge_requestsledger +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가지 일을 모두 해야 합니다.
- UI에 SSE로 실시간 알림(
FileUploadedevent). - 알림함에 행 추가(
NotificationProjection). - 외부 Worker로 썸네일/메타데이터 작업 발송(
Worker— Redis Pub/Sub).
각각 별도 UseCase를 호출하면 도메인 트랜잭션과 외부 호출 사이의 정합성이 깨집니다. 또 한 도메인 변경에 대해 다른 형식/다른 라우팅을 일관되게 적용하기 어렵습니다.
원인 분석
- 도메인 코드에서
ISseClient.Send(),INotificationRepository.AddAsync(),IRedisPublisher.PublishAsync()를 직접 호출하면 (a) 트랜잭션 안에 있지 않아서 실패 시 도메인 변경은 롤백되지 않음, (b) 라우팅 규칙이 분산되어 변경 비용 증가. - 폴링 기반 outbox도 락/재시도/멱등을 잘못 다루면 중복 발송 또는 deadlock.
해결 방법
- 트랜잭셔널 outbox:
OutboxEventRecorder.AddBestEffortAsync가 도메인 tx와 같은DbContext에outbox_events행을 INSERT. 실패 시LogWarning만 하고 throw 안 함. - outbox_events 테이블:
status는VARCHAR(30)(enum 회피 — 새 상태 추가 시 마이그레이션 불필요).EventIdUUID UNIQUE — at-most-once enqueue.idx_outbox_events_polling(available_at, id) WHERE status IN ('PENDING','FAILED').idx_outbox_events_worker_lock_expires_atWHERE status='PROCESSING'.
- 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()로 재시도 가능 상태로.
- 라우팅 레지스트리:
OutboxEventRouteRegistry가eventType → OutboxDispatchTarget(None|RealtimeFanout|NotificationProjection|Worker) 매핑.FileUploaded→RealtimeFanout | NotificationProjection,FileFinalized→Worker. - 재시도 + 백오프:
RecordDispatchFailureAsync가attempts증가 +available_at = failedAt + min(RetryBaseDelay * 2^(attempts-1), RetryMaxDelay). 기본30s/60s/120s/.../300s.max_attempts=10초과 시 후보에서 제외(현재DeadLetter상태 코드 정의됨, 자동 전이 로직은 미구현). - dispatcher 3종:
RealtimeFanoutOutboxEventDispatcher:ISseConnectionStore에 audience(space 멤버, requester, removed user 등)별로 push.NotificationProjectionOutboxEventDispatcher:INotificationUseCases.DispatchNotificationAsync로notifications행 생성.ux_notifications_outbox_event_idpartial unique가 at-most-once 보장.WorkerPubSubOutboxEventDispatcher:WorkerJobDispatchUseCases가WorkerJobPayload를cloudsharp:worker:jobsRedis 채널에 publish.
- 단일 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).
해결 방법
- 3-tier 컨테이너 + 1-tier 외부 노출:
infra/docker-compose.yml은nginx만 호스트 포트${Nginx__Port:-8080}:80게시.frontend,api,tusd,postgres,redis는 모두expose만 —cloudsharp브리지 네트워크 안에서만 통신.
- 단일
server { listen 80; server_name localhost; }+ path-based 라우팅:/,/assets/→frontend:80/api/,/public/,/swagger/,/openapi/→api:8080/files/→tusd:1080/files/(streaming 옵션)
- tusd streaming passthrough:
proxy_buffering off,proxy_request_buffering offclient_max_body_size 0proxy_read/send_timeout 600sproxy_set_header Authorization— tusd가 API의 pre-create hook으로 그대로 forward
- same UID/GID 공유:
CloudSharp__Uid/Gid=10001을init-storage→api,tusd가 공유.init-storage가chown -R 10001:10001 /data/storage후 종료. - 운영 vs 개발 posture 분리:
docker-compose.yml(운영): GHCR 이미지,:?로 비밀 강제,ASPNETCORE_ENVIRONMENT=Production.docker-compose.local-build.yml(개발): 로컬 Dockerfile build,Uploads__FinalizeToken=test,Development.
- boot-prod 스크립트 안전망:
Uploads__FinalizeToken이 기본값/빈 값이면 32-byte base64 자동 생성 또는 사용자 프롬프트. - 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) 효과를 분리하지 못함.
해결 방법
- 자체 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-streamJSON을 샘플링 → 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.
- 단발성 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.
- 측정 결과 문서화
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.
- 원인 분석을 솔직하게 기술: 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(문제 해결 사례)을 먼저 압축하고 나머지는 그대로 활용 가능합니다.