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