vault backup: 2026-06-01 16:40:05

This commit is contained in:
son
2026-06-01 16:40:05 +09:00
parent 9315198179
commit 6524cd5d34
12 changed files with 2599 additions and 5700 deletions

View File

@@ -1,474 +1,271 @@
# Cloud# (CloudSharp) — 백엔드 / 인프라 포트폴리오
# CloudSharp 포트폴리오
## 1. 프로젝트 개요
### 해결하려는 문제
기존 클라우드 스토리지(Google Drive, Dropbox 등)는 **개인 계정 중심**으로 설계되어 있어, 팀/프로젝트 단위에서 폴더 공유만으로는 다음 요구를 충족하기 어렵다.
본 프로젝트는 자가호스팅 환경에서 **느린 전송 속도**와 **AI/에이전트 통합 부재**라는 두 가지 한계를 해결하기 위해 개발한 Space 단위 격리형 클라우드 스토리지 서비스입니다.
- 격리된 저장 공간 (멤버십 + Quota + 권한)
- 대용량 파일의 중단 재개 업로드
- 셀프호스트 + 외부 공유 정책의 분리
기존 자가호스팅 솔루션인 Nextcloud, ownCloud는 PHP 기반 웹 서버(Apache + mod_php)의 구조적 한계로 대용량 파일 전송 시 체감 속도가 떨어지고, MCP·Claude 같은 모델/에이전트가 사용자의 파일에 안전하게 접근할 수 있는 표준 통합 지점이 없었습니다. 이로 인해 자가호스팅을 선택한 사용자는 성능과 AI 활용을 동시에 포기해야 하는 상황이 있었습니다.
### 주요 사용자
셀프호스트를 선호하는 개인·팀·소규모 조직, Space 단위 협업이 필요한 프로젝트 그룹.
이를 해결하기 위해 다음 세 가지를 핵심 목표로 서비스를 설계했습니다.
### 백엔드 핵심 책임
| 책임 | 설명 |
|------|------|
| 인증/인가 | Opaque session token 기반 인증 + Space Role 기반 인가 |
| 메타데이터 진실 원천 | 12개 엔티티의 정합성 보장 (PostgreSQL) |
| 업로드 파이프라인 | tusd 연계 세션 생성 → 전송 → Finalize 전 생명주기 관리 |
| Quota 정책 | `used + reserved + expected ≤ allowed` 원자적 판정 |
| Storage 추상화 | Local FS / MinIO / S3 공통 `storage_key` 추상 |
| 다운로드 보안 | 5분 TTL DownloadSession 기반 Zero Information Leak |
| 동시성 제어 | CAS 기반 Finalize 중복 차단 + Recovery Worker |
- **Space 단위 격리 + 역할 기반 권한**으로 다중 사용자 시나리오에서 데이터 격리를 보장
- **tus 프로토콜 기반 재개 가능한 업로드**와 **단일 PATCH 청크 전송**으로 대용량 전송 성능 확보
- **MCP 토큰(`cs_mcp_*`)과 MCP Console**을 1차 MVP에 포함해 AI 에이전트 통합을 표준화
주요 기능은 다음과 같습니다.
- Space 단위 워크스페이스 분리 및 멤버 초대(OWNER/ADMIN/MEMBER/VIEWER 4단계 역할)
- tus 기반 대용량 파일 업로드와 finalize 보장
- 도메인 이벤트 기반 SSE 실시간 알림 및 외부 Worker 작업 분기
- ShareLink 기반 익명 공유 및 다운로드 세션
- MCP 토큰으로 모델/에이전트가 파일을 탐색·검색·다운로드
---
## 2. 담당 역할
**1인 백엔드 + 인프라 + 설계 담당.** 프론트엔드를 제외한 전 영역.
저는 백엔드 개발자로 참여하여 **인증/인가 흐름, 업로드 finalize 파이프라인, Outbox 기반 이벤트 fan-out, 인프라 구성**을 담당했으며, API 요청부터 데이터 저장과 외부 시스템 연동까지의 전체 흐름을 설계하고 구현했습니다.
- **백엔드 API**: 인증·인가, Space 관리, 파일/폴더 CRUD, 업로드/다운로드 파이프라인, Quota, 공유 링크
- **데이터베이스 설계**: 12개 엔티티 ERD, EF Core Configuration, Migration 자동화
- **인프라 구성**: Docker Compose 5-서비스 오케스트레이션, nginx Reverse Proxy, Volume·Healthcheck 설계
- **CI/CD**: GitLab CI 파이프라인 (test → build → image push to GHCR)
- **설계 문서화**: API/ERD/Conventions/ADR 등 살아있는 문서 체계 구축
주요 역할은 다음과 같습니다.
- ASP.NET Core 10 Minimal API 기반 백엔드 전체 구조 설계 (`Core`/`Infrastructure`/`Api` 3계층 분리)
- Opaque Bearer 토큰 기반 인증과 Space 단위 RBAC 권한 검증 필터 설계
- tus 프로토콜과 finalize saga 구현 (storage move + DB 트랜잭션 + 보상 처리)
- 트랜잭셔널 Outbox 패턴 설계 및 SSE/Notification/Worker 3-target fan-out 구현
- PostgreSQL 데이터 모델링과 EF Core 마이그레이션 관리 (16개 테이블, 15개 enum)
- Docker Compose 기반 same-origin 운영 환경 구성과 GitLab CI/CD 파이프라인 작성
- Nextcloud 동종 벤치마크 도구(`CloudSharp.TransferBenchmark`) 개발 및 측정
---
## 3. 아키텍처
## 3. 주요 기여
### 3.1 아키텍처 유형
**모듈러 모놀리스 + Clean Architecture.**
### 1. 인증/인가 흐름 설계
> **선택 이유:** MVP 단계에서 마이크로서비스의 운영 복잡도(분산 트랜잭션, 서비스 디스커버리, 로그 수집)를 피하면서도, 코드 레벨로는 도메인 경계를 엄격히 분리해 추후 분리 가능성을 확보했다.
> 의존성 방향은 `Api → Core ← Infrastructure`로, 도메인이 HTTP나 DB, 파일시스템을 알지 못하게 했다.
- Opaque Bearer 토큰 발급 시스템을 구현하고, 토큰 prefix(`cs_st`, `cs_mcp`, `cs_dl`, `cs_sh`)로 토큰 종류를 분류하여 단일 `Authorization` 헤더로 사용자 세션·MCP·다운로드·공유링크를 모두 처리하도록 설계했습니다.
- Space 권한 검증 로직을 `RequireSpacePermissionFilter` (`IEndpointFilter`)로 분리하여 모든 Space-scoped 엔드포인트에 일관되게 적용했습니다.
- 매 요청마다 `SpaceMember`를 재조회하도록 설계하여 역할 변경이 다음 요청부터 즉시 반영되도록 했습니다.
### 3.2 전체 구성도
### 2. 업로드 파이프라인 설계
```mermaid
flowchart TB
Client["Browser / Client"]
subgraph Docker["Docker Compose Host"]
NGINX["nginx :80"]
API["ASP.NET Core API :8080"]
TUSD["tusd :1080"]
PG[("PostgreSQL")]
REDIS[("Redis")]
FS[("Local FS /data/storage")]
end
- tus 프로토콜을 외부 `tusd` 컨테이너로 분리하고, API는 `pre-create`/`post-finish` hook만 처리하도록 책임을 분리했습니다.
- `upload_sessions` 상태 머신 7종(`CREATED → UPLOADING → FINALIZING → COMPLETED/FAILED/ABORTED/EXPIRED`)을 설계하고, 조건부 UPDATE로 finalize race를 DB 레벨에서 차단했습니다.
- finalize 도중 실패 시 storage move를 보상하는 saga를 구현했고, 보상도 실패할 경우를 대비해 `UploadFinalizeRecoveryRunner` 워커가 5분 주기로 stuck 상태를 자동 정리하도록 설계했습니다.
Client -->|"HTTP :8080"| NGINX
NGINX -->|"/api/*"| API
NGINX -->|"/files/*"| TUSD
API --> PG
API --> REDIS
API --> FS
API -.->|"hook callback"| TUSD
TUSD --> FS
```
### 3. 트랜잭셔널 Outbox와 이벤트 fan-out 설계
### 3.3 서비스 책임 분리
| 컴포넌트 | 책임 |
|----------|------|
| **nginx** | 단일 외부 진입점, tus 헤더 포워딩, 스트리밍 버퍼링 해제 |
| **ASP.NET API** | 권한·정책·메타데이터·Finalize·공유·세션 |
| **tusd (Go)** | tus 프로토콜 청크 수신, hook으로 API에 생명주기 위임 |
| **PostgreSQL** | 메타데이터 진실 원천 |
| **Redis** | 세션 저장소 + Pub/Sub 이벤트 버스 |
- 도메인 트랜잭션과 같은 `DbContext``outbox_events` 행을 기록하는 `OutboxEventRecorder`를 구현하여 도메인 변경과 이벤트 발행을 원자적으로 묶었습니다.
- `OutboxEventRouteRegistry`로 이벤트 타입과 다운스트림(`RealtimeFanout`, `NotificationProjection`, `Worker`)을 매핑하여 새 이벤트 추가 비용을 최소화했습니다.
- `FOR UPDATE SKIP LOCKED` 대신 partial index + 조건부 `ExecuteUpdate` 기반 낙관적 claim 전략을 채택하여 다중 워커 환경에서도 race 없이 작동하도록 설계했습니다.
### 3.4 핵심 설계 의도
- **업로드 plane과 API plane 분리**: 대용량 청크 전송은 Go 기반 tusd가 담당, 비즈니스 판단은 ASP.NET이 담당 → 장애 격리 + 독립 확장
- **단일 외부 포트(8080)**: 셀프호스트 사용자 입장의 운영 단순화. DB/Redis/tusd/API는 `expose`만 사용하고 외부 노출 금지
### 4. 인프라 및 배포 자동화
- `nginx` 단일 컨테이너만 외부 노출하는 same-origin 토폴로지를 구성하여 CORS 표면과 TLS 종단을 한 곳으로 모았습니다.
- `init-storage` one-shot 컨테이너로 `tusd``api`가 동일 UID/GID(10001)로 같은 bind mount를 공유하도록 처리하여 storage move를 in-place로 가능하게 했습니다.
- GitLab CI에서 `backend:test → backend:image → backend:deploy` 파이프라인을 구성하고, Portainer webhook으로 자동 redeploy되도록 했습니다.
---
## 4. 주요 기여 — 문제 해결 사례
## 4. 사용 기술 및 선택 이유
### 4.1 Finalize 중복 실행 방지: CAS Lock
**문제 상황**
tusd hook 콜백 + 백그라운드 Recovery Worker 두 경로에서 같은 UploadSession이 동시에 처리되어 FileItem이 중복 생성될 위험이 있었다. Finalize는 파일 이동(rename)이라는 느린 I/O를 포함하므로 일반적인 DB 트랜잭션만으로는 동시성 제어가 부족했다.
**원인 분석**
- 상태 판정과 처리 시작이 분리되면 race condition 발생
- 분산 락(Redis Redlock 등) 도입은 인프라 복잡도 증가
- 파일 I/O 동안 DB 트랜잭션을 잡고 있으면 락 점유 시간이 너무 길다
**설계 선택 — CAS(Compare-And-Swap)**
```sql
UPDATE upload_session
SET status = 'FINALIZING',
finalize_attempts = finalize_attempts + 1
WHERE id = :session_id
AND status = 'UPLOADING';
-- affected_rows = 1 → 점유 성공
-- affected_rows = 0 → 이미 처리 중 (즉시 무시)
```
**구현 방식**
- 파일 I/O와 DB 트랜잭션을 분리: 무거운 작업은 트랜잭션 밖, DB 변경(FileItem INSERT, Quota 갱신, FileReservation CONSUMED)만 짧은 트랜잭션
- Recovery Worker(5분 주기)가 10분 이상 `FINALIZING`에 머문 세션을 자동 보정 (FileItem 존재 시 COMPLETED, 임시 파일 소실 시 FAILED)
**결과**
- 별도 락 인프라 없이 단일 SQL UPDATE로 원자적 점유 실현
- 교착 상태 자동 복구로 운영 부담 최소화
- DB UNIQUE 제약(`storage_key`)과 함께 이중 안전장치 구성
| 기술 | 사용 목적 | 선택 이유 |
|---|---|---|
| ASP.NET Core 10 (Minimal API) | HTTP 진입점, DI, 미들웨어 | Kestrel 런타임이 PHP-FPM 대비 가벼운 응답성을 제공하고, Minimal API + Endpoint Filter 조합으로 feature-folder 기반 구조를 강제할 수 있어 선택했습니다. |
| PostgreSQL 16 | 도메인 데이터 영속화 | partial unique index, JSONB, native enum, `xmin` row version 등 정합성 강제 도구가 풍부해 비즈니스 불변식을 DB 레벨에서 보장할 수 있다고 판단했습니다. |
| Redis 7 | 세션 토큰, 다운로드 세션, Worker Pub/Sub | Opaque 토큰의 무효화를 `KeyDelete` 한 번으로 처리하고, Outbox Worker로 가는 작업 전달 채널로 사용하기 위해 선택했습니다. |
| tusd | 청크 업로드 전담 외부 컴포넌트 | 재개 가능한 업로드 표준 구현체로, 청크 I/O 부하를 API 서버에서 분리하기 위해 별도 컨테이너로 운영했습니다. |
| Docker Compose | 6-서비스 same-origin 스택 | 자가호스팅 사용자가 한 줄로 기동할 수 있어야 했고, healthcheck/depends_on 체인으로 의존성을 명확히 표현할 수 있어 선택했습니다. |
| nginx | 단일 origin reverse proxy | 외부 노출을 1개 포트로 줄여 CORS·TLS 표면을 최소화하고, `/files/`에 streaming 옵션을 별도 적용하기 위해 선택했습니다. |
| EF Core 9 | ORM, 마이그레이션 | partial index, alternate key, `IEntityTypeConfiguration` 기반 매핑 분리가 가능해 도메인-인프라 경계를 유지하기 용이하다고 판단했습니다. |
| GitLab CI + GHCR + Portainer | 이미지 빌드/배포 자동화 | 자가호스팅 인프라에서 별도 K8s 없이 webhook 기반 redeploy로 충분했고, 동일 토폴로지를 운영/개발에서 재사용할 수 있어 선택했습니다. |
---
### 4.2 Space Quota 경쟁 조건 방지
## 5. 구현 사항
**문제 상황**
한 Space의 여러 멤버가 동시에 대용량 업로드를 시작할 때, quota 판정(`expected ≤ available`)과 reserved 증가 사이의 간극에서 모든 요청이 통과하는 race condition이 발생할 수 있다.
### 5.1 Space 권한 검증 흐름
**설계 선택 — DB row-level lock**
```sql
BEGIN;
SELECT * FROM space WHERE id = :id FOR UPDATE;
-- quota 판정 + reserved += expected_size + FileReservation 생성
COMMIT;
```
사용자가 Space 내부 리소스에 접근하면 서버는 인증 → 정책 → 권한 필터 → UseCase 4단계로 요청을 처리합니다.
**선택 이유**
- 업로드 세션 생성은 빈번하지 않고 락 지속시간이 짧다
- 낙관적 동시성으로는 이미 전송 중인 요청을 거절할 수 없다
- Finalize 직전에도 quota를 재검사하여 이중 안전장치를 구성
구현 흐름은 다음과 같습니다.
**결과**
- Quota 불변조건 `used + reserved ≤ allowed`가 항상 유지
- 초과 업로드는 **시작 시점**에 거절되어 불필요한 네트워크 전송 방지
1. `CloudSharpSessionAuthenticationHandler``Authorization: Bearer <token>` 헤더의 prefix를 보고 토큰 종류를 분류합니다.
2. 토큰 해시(`SHA-256`)로 Redis `auth:session:{tokenHash}`를 조회해 `sub`(userId), `sid`, `system_role` 클레임을 설정합니다.
3. `RequireDelegatedUserAccess()` 정책이 인증 실패 시 401로 거절합니다.
4. `RequireSpacePermissionFilter`가 라우트의 `spaceSlug``ISpacePermissionService.FindAuthorizedSpaceAsync`를 호출하여 `SpaceMember`를 최신 상태로 로드하고 권한을 검증합니다.
5. 결과를 `HttpContext.Items`에 저장해 UseCase에서 회수할 수 있도록 처리합니다.
6. UseCase에서도 `command.SpaceId` 일치를 재검증하여 방어 계층을 한 단계 더 두었습니다.
주요 고려사항은 다음과 같습니다.
- JWT의 staleness 문제를 회피하기 위해 Space role을 토큰 클레임에 박지 않고 매 요청 조회 방식으로 설계했습니다.
- 권한 정의를 `Dictionary<SpaceRole, ImmutableHashSet<SpacePermission>>` 한 곳에서만 관리하여 변경 비용을 최소화했습니다.
- Preview, File details 등 민감 엔드포인트에는 `MapForbidToNotFoundFilter`로 403을 404로 변환하여 리소스 존재 여부 누설을 막았습니다.
### 5.2 tus 업로드 finalize 처리
클라이언트가 청크 업로드를 마치면 tusd가 `post-finish` hook으로 API에 통보하고, 서버는 storage move와 DB 트랜잭션을 saga 방식으로 처리합니다.
구현 흐름은 다음과 같습니다.
1. `TryStartFinalizingAsync``CREATED/UPLOADING → FINALIZING` 원자 상태 전이를 시도합니다.
2. temp 파일 크기와 예약된 `ExpectedSize`를 비교 검증합니다.
3. `IStorageProvider.MoveTempToFinalAsync`로 파일을 최종 경로로 이동합니다.
4. `ITransactionManager.ExecuteAsync` 내에서 `file_items` INSERT, `spaces.storage_used_bytes` 갱신, `file_reservations.status = CONSUMED`, `upload_sessions.status = COMPLETED`, outbox `FileUploaded`/`FileFinalized` enqueue를 단일 트랜잭션으로 처리합니다.
5. DB 실패 시 `MoveFinalToTempAsync`로 storage를 보상한 뒤 `MarkSessionFailedAsync`로 예약을 해제합니다.
6. 보상도 실패하면 `FINALIZE_STORAGE_RESTORE_FAILED` 로그를 남기고 그래도 세션을 끊어 release합니다.
주요 고려사항은 다음과 같습니다.
- 분산 트랜잭션(2PC) 대신 saga + 보상으로 단순화하여 운영 복잡도를 낮췄습니다.
- `UploadFinalizeRecoveryRunner``FINALIZING` 상태에 10분 이상 갇힌 세션을 5분 주기로 자동 정리하여 사람의 개입 없이 self-healing 되도록 설계했습니다.
- Outbox enqueue는 `AddBestEffortAsync`로 실패 시 throw하지 않도록 하여 도메인 트랜잭션을 보호했습니다.
### 5.3 Outbox 기반 이벤트 fan-out
도메인 이벤트는 한 번의 enqueue로 SSE 실시간 알림, 알림함 projection, 외부 Worker 3-target에 분기됩니다.
구현 흐름은 다음과 같습니다.
1. UseCase가 도메인 트랜잭션 안에서 `OutboxEventRecorder.AddBestEffortAsync`로 이벤트를 기록합니다.
2. `OutboxEventProcessingService` BackgroundService가 5초 주기로 폴링합니다.
3. `ReleaseExpiredLocksAsync`가 lease 만료된 `PROCESSING` 행을 `FAILED`로 회수합니다.
4. `SELECT id ORDER BY available_at LIMIT N` + 조건부 `ExecuteUpdate`로 낙관적 claim을 수행합니다.
5. `OutboxEventRouteRegistry`에서 이벤트 타입별 다운스트림(`RealtimeFanout | NotificationProjection | Worker`)을 조회합니다.
6. 각 dispatcher가 SSE push, `notifications` INSERT, Redis Pub/Sub publish를 수행합니다.
7. 실패 시 `attempts++`, `available_at = failedAt + min(base * 2^(attempts-1), max)`로 지수 백오프 재시도합니다.
주요 고려사항은 다음과 같습니다.
- `idx_outbox_events_polling`을 partial index로 만들어 `PENDING/FAILED` 상태만 인덱싱하여 폴링 비용을 줄였습니다.
- `notifications.outbox_event_id`에 partial unique를 걸어 at-most-once projection을 DB 레벨에서 보장했습니다.
- 새 이벤트 추가 시 `OutboxEventTypes` 상수와 route 등록 1줄만 추가하면 되도록 확장 비용을 최소화했습니다.
---
### 4.3 인증 토큰: Opaque Session vs JWT
## 6. 문제 해결 사례
**문제 상황**
한 사용자가 여러 Space에서 서로 다른 Role을 가지며, Role 변경이 즉시 반영되어야 한다. JWT를 사용하면 토큰 만료 전까지 stale 권한이 유지되며, 모든 Role을 claim에 담으면 토큰 크기가 비대해진다.
### 6.1 다중 Space 권한 staleness 문제
**설계 선택 — Opaque Session Token**
```
Authorization: Bearer cs_sess_{base64url(CSPRNG 32bytes)}
```
- 권한 정보를 토큰에 담지 않음
- Redis에 `HMAC-SHA-256(token, secret)` 해시만 저장 (원문 저장 금지)
- 매 요청마다 DB에서 최신 SpaceMember를 조회
#### 문제 상황
**구현 방식**
- `CloudSharpSessionAuthenticationHandler`: 헤더 검증 → 해시 계산 → Redis 조회 → 만료 확인
- `RequireSpacePermissionFilter`: route의 spaceSlug → spaceId 변환 + Role 충족 확인 → `AuthorizedSpaceContext` 주입
- UseCase 단계에서 리소스의 `space_id` 재검증 (IDOR 방어)
- Idle 12시간 / Absolute 30일 / sliding renewal
사용자가 여러 Space에 서로 다른 역할로 속할 때, JWT 기반 인증을 사용하면 토큰 발급 시점에 역할이 클레임으로 박혀 운영자가 역할을 변경해도 토큰 만료 전까지 옛 권한이 유지되는 staleness 문제가 발생할 가능성이 있었습니다.
**결과**
- Role 변경이 **다음 API 요청부터 즉시 반영**
- 로그아웃·강제 Revoke를 Redis key 삭제 한 번으로 처리
- 토큰 탈취 시에도 토큰 자체에는 정보가 없어 노출 최소화
#### 원인 분석
stateless 인증의 기본 전제는 "토큰만으로 모든 권한 표현"인데, Space role은 per-space 분산 상태이므로 토큰 payload에 박아두면 일관성 윈도우가 생깁니다. 자가호스팅 시나리오 특성상 운영자가 role을 자주 변경하는 패턴이 예상되어 이 윈도우를 0으로 만들 필요가 있었습니다.
#### 해결 방법
JWT 대신 **Opaque Bearer 토큰**을 채택하고, 토큰 자체에는 의미를 담지 않은 채 SHA-256 해시만 Redis에 저장하도록 설계했습니다. 인증 핸들러는 `sub`/`sid`/`system_role`만 클레임으로 설정하고, Space role은 `RequireSpacePermissionFilter`가 **매 요청마다 DB에서 재조회**하도록 처리했습니다.
#### 선택 이유
매 요청 DB 조회 비용은 단일 PK look-up 한 번이라 운영 가능 수준이었고, 그 대가로 무효화 즉시 반영(`KeyDelete` 한 번으로 logout-all)과 staleness 윈도우 0을 얻을 수 있다고 판단했습니다. 또한 핸들러 → 정책 → 필터 → UseCase 4중 검증 구조로 한 계층이 우회되어도 다른 계층이 차단하도록 깊이 있는 방어선을 구성했습니다.
#### 결과
역할 변경이 다음 요청부터 즉시 반영되어 운영자가 kick/role-change 후 대기 시간 없이 권한이 적용됩니다. 동일한 토큰 분류 패턴(`cs_st`, `cs_mcp`, `cs_dl`, `cs_sh`)을 MCP 토큰과 공유링크 토큰에도 재사용하여, 하나의 `Authorization` 헤더로 사용자/에이전트/익명 사용자를 모두 라우팅할 수 있게 했습니다.
---
### 4.4 파일명 충돌 정책: "명시적 실패 반환"
### 6.2 storage move와 DB 트랜잭션 정합성 문제
**문제 상황**
Google Drive 같은 서비스는 동일 폴더에 같은 이름의 파일이 업로드되면 자동으로 `파일명(1).pdf`로 rename한다. 그러나 사용자가 의도하지 않은 파일이 누적되어 데이터 일관성이 깨진다.
#### 문제 상황
**설계 선택 — `FILE_NAME_CONFLICT` 에러 반환**
- 활성 `FileItem` + 활성 `FileReservation`을 함께 검사 (사전 + Finalize 직전 이중 검증)
- DB 레벨에서 `UNIQUE (space_id, folder_id, normalized_name) on active rows` 제약
업로드 finalize는 (1) temp → final 파일 이동, (2) `file_items` INSERT, (3) 쿼터 갱신, (4) 예약 소모, (5) 세션 완료 5단계를 모두 처리해야 했습니다. 어느 한 단계가 실패하면 스토리지에는 파일이 있는데 DB에는 메타가 없는 고아 객체나, 반대로 DB에는 행이 있는데 스토리지에는 파일이 없는 broken row가 발생할 수 있었습니다.
**선택 이유**
- "모호함보다 명시적 실패가 낫다"는 설계 철학
- 사용자가 의도적으로 파일명을 결정하도록 유도
- 구현 단순성 (rename fallback, 카운터 등 불필요)
#### 원인 분석
**결과**
- 항상 예측 가능한 동작 + 사용자 의도와 저장 상태의 일관성
파일 시스템과 DB는 같은 트랜잭션에 묶일 수 없으므로 단일 트랜잭션만으로는 정합성을 보장할 수 없었습니다. 또한 finalize 도중 다른 finalize 요청이 race로 들어오면 같은 세션을 두 번 처리하거나, 클라이언트가 도중에 cancel하면 세션이 `FINALIZING` 상태에 영구히 갇힐 위험이 있었습니다.
#### 해결 방법
분산 트랜잭션 매니저 대신 **saga + 보상 + 자율 복구 워커** 3단 구조로 해결했습니다.
- `TryStartFinalizingAsync`를 조건부 UPDATE(`WHERE status IN ('CREATED','UPLOADING')`)로 구현하여 race를 DB 레벨에서 차단했습니다.
- storage move → DB transaction 순서로 처리하고, DB 실패 시 `MoveFinalToTempAsync`로 storage를 보상했습니다.
- 보상마저 실패하는 극단 케이스를 위해 `UploadFinalizeRecoveryRunner` 백그라운드 워커가 10분 이상 `FINALIZING`에 갇힌 세션을 5분 주기로 `FAILED`로 자동 전이하도록 설계했습니다.
#### 선택 이유
2PC는 운영 복잡도와 의존성을 크게 증가시키는 반면, saga + 보상은 실패 모드를 코드로 명시할 수 있어 디버깅과 운영 가시성이 좋았습니다. 또한 복구 워커가 self-healing을 담당하면 사람의 개입 없이 stuck 상태가 자동 정리되어 운영 부담이 줄어듭니다.
#### 결과
finalize의 성공/실패/취소/race가 모두 `upload_sessions.status` enum 7종 중 하나로 수렴하도록 정리되었습니다. 5분 주기 복구 워커가 동작하여 운영 중 stuck 행이 누적되지 않고, 그래도 남은 고아 파일은 `file_purge_requests` ledger를 통해 `TrashAutoPurgeRunner`가 후속 청소합니다.
---
## 5. API 설계
### 6.3 도메인 이벤트의 다중 다운스트림 라우팅 문제
### 5.1 기본 계약
| 항목 | 값 |
|------|-----|
| 내부 API | `/api/v1/*` (Bearer 인증) |
| 외부 공개 API | `/public/v1/*` (share_token + 비밀번호) |
| 내부 전용 | `/api/internal/*` (`X-CloudSharp-Internal-Token`) |
| 문서화 | Swagger UI + OpenAPI 3.x + ReDoc |
#### 문제 상황
### 5.2 URL 식별자 분리 정책
- **외부 노출**: Space는 UUID 기반 `spaceSlug` (URL 식별자)
- **내부 Core**: bigint PK `spaceId`만 사용
- **변환 위치**: `RequireSpacePermissionFilter`가 slug → id + 권한 컨텍스트로 변환
`FileUploaded` 같은 도메인 이벤트는 SSE 실시간 알림, 알림함 행 추가, 외부 Worker 작업 발송 3가지 일을 모두 수행해야 했습니다. 도메인 UseCase에서 각 다운스트림을 직접 호출하면 외부 시스템 실패가 도메인 트랜잭션을 깨거나, 도메인 트랜잭션이 commit되었는데 알림 발송이 누락되는 정합성 문제가 발생할 수 있었습니다.
> bigint PK가 외부에 노출되어 enumeration 공격에 취약해지는 문제를 차단했다.
#### 원인 분석
### 5.3 표준 에러 응답
```json
{
"requestId": "req_01JXYZ...",
"error": {
"code": "FILE_NAME_CONFLICT",
"message": "같은 이름의 파일이 이미 존재합니다.",
"details": [{ "field": "displayName", "reason": "conflict" }]
}
}
```
도메인 코드가 `ISseClient`, `INotificationRepository`, `IRedisPublisher`를 직접 알면 (a) 외부 시스템과의 강결합이 발생하고, (b) 라우팅 규칙이 도메인 곳곳에 분산되어 변경 비용이 증가하며, (c) 트랜잭션 경계가 모호해집니다.
- `requestId` = `HttpContext.TraceIdentifier` → 로그 추적 가능
- ErrorCode는 OpenAPI에 문서화된 고정 문자열 → 클라이언트가 분기 처리 가능
- 외부 공개 API는 권한 없음/리소스 없음/비활성 모두 **404로 통일** → Zero Information Leak
#### 해결 방법
**트랜잭셔널 Outbox 패턴 + 라우팅 레지스트리** 구조를 도입했습니다.
- `OutboxEventRecorder.AddBestEffortAsync`가 도메인 트랜잭션의 같은 `DbContext``outbox_events` 행을 INSERT하여 이벤트 enqueue를 도메인 변경과 원자적으로 묶었습니다.
- `OutboxEventRouteRegistry`에서 이벤트 타입과 다운스트림 enum(`None | RealtimeFanout | NotificationProjection | Worker`)을 매핑하여 라우팅 규칙을 한 곳에 모았습니다.
- `OutboxEventProcessingService` BackgroundService가 폴링하면서 낙관적 claim 전략(`SELECT id` + 조건부 `ExecuteUpdate` + 재조회)으로 다중 워커 race를 흡수했습니다.
- 재시도는 `available_at = failedAt + min(base * 2^(attempts-1), max)` 지수 백오프로 처리했습니다.
#### 선택 이유
`FOR UPDATE SKIP LOCKED` 대신 partial index 기반 낙관적 claim을 선택한 이유는, DB 잠금 의존성을 줄이면서 partial index(`WHERE status IN ('PENDING','FAILED')`)로 폴링 비용을 낮출 수 있었기 때문입니다. 또한 `AddBestEffortAsync`가 실패 시 throw하지 않고 log만 남기도록 한 이유는, outbox enqueue 실패가 도메인 트랜잭션 전체를 깨는 것을 막기 위해서였습니다. 대신 동일 이벤트 재발행에 대비해 `EventId` UUID UNIQUE와 `notifications.outbox_event_id` partial unique로 at-most-once를 DB가 보장하도록 처리했습니다.
#### 결과
새 도메인 이벤트를 추가할 때 `OutboxEventTypes` 상수 1개, route 등록 1줄, 필요 시 dispatcher 1개만 추가하면 되어 도메인 UseCase 변경 없이 확장이 가능해졌습니다. 워커 1대가 장애로 멈춰도 다른 워커가 lease 만료 후 자연스럽게 인계받아 단일 장애점이 없는 구조가 되었습니다.
---
## 6. 데이터베이스 설계
## 7. 프로젝트 성과
### 6.1 주요 엔티티 (12개)
User, Space, SpaceMember, SpaceInvite, Folder, FileItem, **UploadSession**, **FileReservation**, ShareLink, ShareLinkTarget, DownloadSession.
### 성능 측정 결과
### 6.2 핵심 모델링 결정
자체 개발한 C# 벤치마크 도구(`CloudSharp.TransferBenchmark`)로 동일 호스트·동일 Docker·동일 Postgres·동일 bind mount 환경에서 Nextcloud와 비교 측정한 결과는 다음과 같습니다.
**① UploadSession ↔ FileReservation 1:1 분리**
- UploadSession: 전송 상태 추적(tus I/O, 네트워크 관점, 7-state machine)
- FileReservation: 비즈니스 자원 선점(quota·파일명, 도메인 관점, 6-state machine)
- 변경 주기와 실패 원인이 다르므로 분리하되 1:1로 연결
| 항목 | 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배 적음 |
**② Space 중심 소유권**
- 파일/폴더의 FK는 User가 아닌 Space
- 행위자(`created_by_user_id`)와 소유자(`space_id`) 분리
- 멤버 탈퇴 시에도 파일은 Space에 남음
### 구조적 성과
**③ DownloadSession 별도 테이블**
- 로그인 세션과 다운로드 토큰은 TTL/revoke 정책/subject_type이 전혀 다름
- `subject_type` (USER/SHARE_LINK)으로 내부 인증과 외부 공유를 단일 테이블로 통합
### 6.3 정합성 보장
| 원칙 | 적용 |
|------|------|
| Soft Delete | 대부분 테이블에 `deleted_at TIMESTAMP NULL` |
| CHECK 제약 | `storage_used_bytes ≥ 0`, `received_size ≤ expected_size` |
| UNIQUE | `storage_key`, `(space_id, folder_id, normalized_name)` on active rows |
| Migration | EF Core 시작 시점 자동 적용 (`RunDatabaseMigrationsOnStartup`) |
- **공통 권한 검증 필터 분리**: `RequireSpacePermissionFilter`로 Space 권한 검증을 일원화하여 엔드포인트별 중복 코드를 제거하고, 새 Space-scoped API 추가 시 필터 한 줄만 붙이면 권한이 적용되도록 했습니다.
- **일관된 에러 응답 구조**: 150여 개 `ErrorCode` 상수와 `ErrorResponse { RequestId, Error: { Code, Message, Details[] } }` 구조로 응답 형식을 통일하여, 프론트엔드가 `Code` 한 필드만 보고 분기할 수 있도록 했습니다.
- **상태 머신 기반 정합성**: `upload_sessions` 7종 enum 상태와 조건부 UPDATE로 finalize race를 DB 레벨에서 차단하여 동시성 버그 가능성을 낮췄습니다.
- **partial unique index 7종**: root 폴더 1개/space, 멤버 1명/space, 같은 parent에 같은 이름 폴더 금지 등 비즈니스 불변식을 DB가 강제하도록 설계했습니다.
- **운영 자동화**: GitLab CI에서 빌드/푸시 후 Portainer webhook으로 자동 redeploy되어, 신규 팀원도 `boot-prod.sh` 한 줄로 운영 환경을 기동할 수 있습니다.
---
## 7. 트랜잭션과 동시성
## 8. 프로젝트 회고
### 7.1 트랜잭션 패턴
공통 `IAppDbTransactionFactory` 추상화로 UseCase에서 명시적 경계 관리.
```csharp
await using var tx = await transactionFactory.BeginAsync(ct);
var result = await repository.SaveAsync(entity, ct);
if (result.IsFailed) { await tx.RollbackAsync(ct); return ...; }
await tx.CommitAsync(ct);
```
### 배운 점
### 7.2 Finalize 트랜잭션 경계 분리
```
🔓 트랜잭션 밖: 임시 파일 검증 + 파일 이동 (느린 I/O)
🔒 DB 트랜잭션: FileItem INSERT
Space.storage_used_bytes += final_size
Space.storage_reserved_bytes -= reserved_size
FileReservation → CONSUMED
UploadSession → COMPLETED
```
> 파일 이동 동안 DB 락을 잡지 않는다. DB 트랜잭션 실패 시 발생할 고아 파일은 Recovery Worker가 정리.
- **DB가 진실의 원천이라는 원칙의 실효성**: 파일 시스템과 DB가 같은 트랜잭션에 묶일 수 없는 상황에서, DB commit을 가장 마지막에 두고 storage move를 먼저 한 뒤 실패 시 보상하는 saga 패턴이 분산 트랜잭션보다 운영하기 쉬웠습니다.
- **partial index와 조건부 UPDATE의 활용**: PostgreSQL의 partial unique index와 `WHERE` 조건 기반 `ExecuteUpdate`로 비즈니스 불변식과 동시성을 애플리케이션 코드 없이 DB 레벨에서 보장할 수 있다는 것을 체감했습니다.
- **Outbox 패턴의 확장성**: 도메인 변경과 이벤트 발행을 한 트랜잭션으로 묶고 라우팅 레지스트리로 다운스트림을 분리하니 새 기능 추가 시 도메인 코드 변경 없이 확장이 가능해지는 구조적 이점을 확인했습니다.
### 7.3 중복 생성 3중 방지
1. UseCase 사전 검증 (파일명 충돌)
2. DB UNIQUE 제약
3. `TrySaveChangesAsync()` — conflict 감지 시 `Result.Fail`
### 아쉬웠던 점
---
초기에는 단일 인스턴스 운영을 전제로 SSE fan-out을 메모리 기반 `ISseConnectionStore`로 구현했습니다. 이로 인해 다중 인스턴스 확장 시 sticky session 또는 Redis Pub/Sub로 전환해야 하는 마이그레이션 비용이 남아 있고, 현재는 ADR(`docs/.llm/wiki/decisions.md` 2026-05-13)로만 로드맵을 남겨둔 상태입니다.
## 8. 예외 처리와 응답 구조
또한 Outbox `max_attempts` 초과 시의 `DeadLetter` 상태는 enum으로 정의했지만 자동 전이 로직과 관리 API를 만들지 못해, 현재는 재시도가 누적되어 폴링에서 자연스럽게 제외되는 방식으로만 동작합니다.
### 8.1 3계층 실패 전략
| 실패 유형 | 처리 | 로그 레벨 |
|-----------|------|-----------|
| Validation | ASP.NET 400 자동 응답 | 없음 |
| 비즈니스 | `FluentResults` + `CloudSharpError``ResultHttpMapper` | 보안 실패만 Warning |
| 시스템 예외 | `ExceptionHandlingMiddleware``ProblemDetails` 500 | Error 1회 (stack trace 운영 비공개) |
### 개선하고 싶은 점
### 8.2 ErrorCode → HTTP 매핑
| 패턴 | HTTP |
|------|------|
| `*_NOT_FOUND` | 404 |
| `*_FORBIDDEN`, `*_UNAUTHORIZED` | 403 |
| `*_CONFLICT`, `*_DUPLICATE` | 409 |
| 나머지 | 400 |
### 8.3 책임 분리
- **Endpoint**: 라우팅, DTO ↔ Command 변환, Result → HTTP 변환만
- **UseCase**: `Result<T>` 반환, HTTP를 모름
- **Domain Policy**: 상태 전이 검증, 인프라 의존성 없음
---
## 9. 인프라 구성
### 9.1 Docker Compose 5-서비스
| 서비스 | 외부 노출 | 역할 |
|--------|-----------|------|
| postgres | ❌ (`expose`만) | 메타데이터 |
| redis | ❌ (`expose`만) | 세션 + Pub/Sub |
| tusd | ❌ (`expose`만) | tus 청크 업로드 |
| api | ❌ (`expose`만) | ASP.NET 백엔드 |
| **nginx** | ✅ `:8080` | **유일한 외부 진입점** |
### 9.2 Volume 구조
```
postgres_data → /var/lib/postgresql/data
redis_data → /data
./storage → /data/storage (API + tusd 공유)
├── objects/ ← 최종 파일
│ └── spaces/{shard}/{spaceId}/objects/{s1}/{s2}/{fileKey}.bin
└── tmp/tusd/ ← tusd 임시 파일
```
> **Storage Sharding**: hex hash 기반 `256 × 256 × 256 = 65,536` 버킷 분산으로 디렉토리 과밀 방지.
### 9.3 Healthcheck + depends_on
모든 서비스가 Healthcheck를 가지며, `condition: service_healthy`로 단순 실행 순서가 아닌 **실제 준비 완료**를 기다린다.
```yaml
api:
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
tusd: { condition: service_healthy }
```
### 9.4 nginx — tusd 스트리밍 특수 설정
```nginx
proxy_set_header Tus-Resumable $http_tus_resumable;
proxy_set_header Upload-Length $http_upload_length;
proxy_set_header Upload-Offset $http_upload_offset;
client_max_body_size 0; # 대용량 무제한
proxy_buffering off; # 스트리밍 버퍼링 해제
proxy_request_buffering off;
proxy_read_timeout 600s; # 장시간 업로드
```
### 9.5 Dockerfile 설계
- Multi-stage build (SDK → build → publish → runtime)로 최종 이미지 크기 최소화
- `aspnet:10.0` 기본 이미지에 `curl` 추가하여 컨테이너 HEALTHCHECK 지원
---
## 10. 환경변수 / 설정 관리
```bash
# 보안 비밀 (반드시 환경변수로만 주입)
Auth__SessionHashKey=<HMAC secret>
Uploads__FinalizeToken=<openssl rand -base64 32>
# DB / Redis / Storage / tusd
Postgres__Host=postgres
Redis__Host=redis
Storage__Provider=local
Tusd__HooksHttp=http://api:8080/internal/tusd/hooks
```
- ASP.NET Options 패턴 + `__` 환경변수 오버라이드
- 모든 연결 정보·경로·정책 값은 환경변수 또는 `appsettings.json`에서 주입 (하드코딩 금지)
- Compose가 서비스명을 DNS로 resolve (`postgres`, `redis`, `tusd`)
---
## 11. 로그 / 모니터링
**로그 철학:** *"중복 없이 검색 가능하게"*
| 위치 | 레벨 | 책임 |
|------|------|------|
| Middleware | Information | 요청당 1회 (`method/path/status/elapsedMs/traceId/userId`) |
| GlobalExceptionHandler | Error | 예상 못한 예외만 1회 (운영 stack trace 미노출) |
| UseCase Activity Proxy | Debug | 모든 UseCase 메서드 자동 추적 |
| UseCase 직접 | Warning/Information | 보안 실패, 감사 이벤트만 |
**구조화 로그 — 영어 고정 템플릿 + named placeholder**
```csharp
// ✅
logger.LogWarning(
"Space access denied userId={UserId} spaceId={SpaceId} permission={Permission}",
userId, spaceId, permission);
// ❌ 문자열 보간 금지
```
**금지사항**: 토큰/body/Authorization header 로깅, validation 실패를 Warning으로 남기기, 운영 환경 stack trace 노출.
---
## 12. CI/CD
**GitLab CI 4-stage 파이프라인**: `test → build → pr_review → deploy`
```yaml
backend:test:
image: mcr.microsoft.com/dotnet/sdk:10.0
script:
- dotnet test tests/CloudSharp.Core.Tests
- dotnet test tests/CloudSharp.Infrastructure.Tests
backend:image:
script:
- docker build → ghcr.io/cloud-sharp/cloudsharp-backend:$CI_COMMIT_SHA
- docker push (master push only)
```
| 항목 | 상태 |
|------|------|
| Backend Unit/Infrastructure 테스트 | ✅ |
| Docker Image Build → GHCR | ✅ |
| nginx 설정 검증 (`nginx -t`) | ✅ |
| API IntegrationTests / Architecture Tests | ⚠️ CI 미포함 (개선 예정) |
| 자동 배포 (CD) | ⚠️ Placeholder (개선 예정) |
---
## 13. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|-----------|
| **ASP.NET Core 10 / Minimal API** | 빠른 부트스트랩, Controller 오버헤드 없음, 최신 런타임 |
| **PostgreSQL 16** | FK·Unique·CHECK 제약이 풍부, JSON 확장성 |
| **Redis 7** | 빠른 Hash 조회 + Pub/Sub 이벤트 버스 |
| **tusd (Go)** | tus 표준 구현체, ASP.NET보다 청크 업로드에 효율적 |
| **EF Core 10 + Npgsql** | PostgreSQL Native, Migration 자동화 |
| **FluentResults** | 예외 아닌 비즈니스 실패의 명시적 표현, Bind 파이프라인 |
| **FluentValidation** | 선언적 검증 + `.WithErrorCode()`로 ErrorCode 표준화 |
| **nginx (alpine)** | tus 헤더 포워딩, 버퍼링 해제, 단일 진입점 |
| **Docker Compose** | 셀프호스트 배포 간소화 |
---
## 14. 프로젝트 성과
- **데이터 모델**: 12개 엔티티, Mermaid ERD, 11개 EF Core Configuration 클래스
- **요구사항 매핑**: 48개 기능 요구사항(SFR-001~048)을 OpenAPI + UseCase에 매핑
- **상태 머신 설계**: UploadSession 7-state, FileReservation 6-state
- **설계 문서**: 14개 컨벤션 문서로 코딩 표준 구축, 3건의 ADR
- **CI 자동화**: PR 테스트 + master push 시 GHCR 이미지 빌드
---
## 15. 회고
### 잘한 점
- **초기부터 엄격한 문서화**(API, ERD, Conventions, ADR)와 Clean Architecture를 적용해, 기능 확장 시 도메인 경계가 무너지지 않았다.
- **JWT 대신 Opaque Session Token**을 선택한 결정이 멀티 Role 모델에 정확히 부합했다. 인증 정보가 토큰에 없으므로 Role 변경이 즉시 반영된다.
- **UploadSession과 FileReservation의 1:1 분리**로 네트워크 관점과 도메인 관점의 책임이 명확해졌고, 실패 복구 경로가 단순해졌다.
### 아쉬운 점
- API IntegrationTests와 Architecture Tests가 CI 파이프라인에 포함되지 않았다.
- 운영 모니터링(Grafana, OpenTelemetry, 분산 추적)이 설계 단계에 머물러 있다.
- 자동 배포(CD)가 placeholder 상태이며, 실제 운영 환경 검증이 부족하다.
- 후처리 Worker(ffmpeg 썸네일, AI 메타데이터)는 구조만 있고 미구현이다.
### 향후 계획
- MinIO/S3 Storage Provider 구현으로 Local FS 외 백엔드 검증
- Worker 프로젝트에 ffmpeg 썸네일 파이프라인 적용
- IntegrationTest를 CI에 포함시키고, 자동 배포 파이프라인 완성
- OpenTelemetry 도입 + Grafana/Loki 운영 환경 구축
- 장기적으로 Kubernetes 전환 + OpenSearch 도입 검토
- **관측 가능성 강화**: 현재는 구조화 로그와 헬스체크 수준만 갖춰져 있어, OpenTelemetry 기반 분산 트레이싱과 Prometheus 메트릭 노출을 추가하여 다중 인스턴스 운영 시의 가시성을 확보하고자 합니다.
- **SSE 다중 인스턴스화**: Redis Streams 또는 Pub/Sub 기반으로 SSE 메시지 fan-out을 옮겨 수평 확장 가능하도록 개선하고자 합니다.
- **OpenAPI 자동 생성**: 현재 145KB OpenAPI 문서를 수동 유지하고 있어 코드 변경과 drift가 발생할 수 있는 구조입니다. 다음 작업에서는 Swashbuckle 등으로 자동 생성하고 수동 reconcile은 ADR 변경 시에만 수행하는 방식으로 전환하려 합니다.
- **벤치마크 CI 게이트화**: `CloudSharp.TransferBenchmark`를 CI에 통합하여 회귀가 PR 단계에서 감지되도록 하고자 합니다.