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

84 KiB
Raw Blame History

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 구성 요소

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 DTOIValidator<T> → use case interface → HttpContextCancellationToken(항상 마지막).
  • 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 현재 사용자 식별

  • CloudSharpSessionAuthenticationHandlerBearer <token>을 검증하고 sub(userId), sid(session id), system_role 클레임 설정.
  • MCP 핸들러는 동일 형태 + token_type=mcp, mcp_token_id 클레임.
  • 핸들러는 ICurrentUser/CurrentUserExtensionsGetRequiredUserId() 등 노출.

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)가 라우트의 spaceSlugISpacePermissionService.FindAuthorizedSpaceAsync(userId, slug, [perm]) 호출.
  4. 결과 AuthorizedSpaceContextHttpContext.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/headersfile_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에, 자동 생성된 것도 사람이 리뷰.
  • 마이그레이션 명령:
    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_idSetNull).
  • 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(필터에서 사용)

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. TryStartFinalizingAsyncCREATED/UPLOADINGFINALIZING 원자 전이. 실패 시 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가 FileFinalizedcloudsharp: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.TryStartFinalizingAsyncWHERE 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 공통 구조

{
  "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.
  • CodeErrorCodeMetadataKey에서, 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)ValidationFailureResult.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-storagechown -R 10001:10001 + chmod u+rwX,g+rwX로 동일 UID 권한 통일.

9.1.3 depends_on / healthcheck

init-storagepostgres/redis/tusd(healthy) → apinginx 순. 모든 서비스 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

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 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.TransferBenchmarkdocker 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_eventdefault-branch push에서만 실행.

스테이지: testbuildpr_reviewdeploy

.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 30PORTAINER_BACKEND_WEBHOOK_URL
frontend:deploy default-branch push, frontend:image 완료 curlPORTAINER_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. MapForbidToNotFoundFilterPreview, 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.TryStartFinalizingAsyncWHERE 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 + UploadFinalizeRecoveryServiceUploads:FinalizeRecoveryIntervalSeconds=300마다 finalizing_started_at < NOW - Uploads:FinalizeRecoveryStaleMinutes=10FINALIZING 행을 찾아 MarkFailedIfFinalizingAsync (status-conditional UPDATE)로 FAILED로 전이 + MoveFinalToTempAsync로 보상.
  5. 테스트로 명세 고정: apps/backend/tests/CloudSharp.Api.IntegrationTests/BackgroundServices/UploadFinalizeRecoveryServiceTests.csMock<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 모두 UploadSessionstatus 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와 같은 DbContextoutbox_events 행을 INSERT. 실패 시 LogWarning만 하고 throw 안 함.
  2. outbox_events 테이블:
    • statusVARCHAR(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. 라우팅 레지스트리: OutboxEventRouteRegistryeventType → OutboxDispatchTarget (None|RealtimeFanout|NotificationProjection|Worker) 매핑. FileUploadedRealtimeFanout | NotificationProjection, FileFinalizedWorker.
  5. 재시도 + 백오프: RecordDispatchFailureAsyncattempts 증가 + 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.DispatchNotificationAsyncnotifications 행 생성. ux_notifications_outbox_event_id partial unique가 at-most-once 보장.
    • WorkerPubSubOutboxEventDispatcher: WorkerJobDispatchUseCasesWorkerJobPayloadcloudsharp: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.ymlnginx만 호스트 포트 ${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=10001init-storageapi, tusd가 공유. init-storagechown -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).
    • DockerResourceMonitordocker 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(문제 해결 사례)을 먼저 압축하고 나머지는 그대로 활용 가능합니다.