vault backup: 2026-05-05 21:27:37

This commit is contained in:
son
2026-05-05 21:27:37 +09:00
parent dc1b910d36
commit 957d24aa9f
39 changed files with 42394 additions and 1 deletions

View File

@@ -0,0 +1,484 @@
# Cloud# (CloudSharp) — 백엔드 / 인프라 포트폴리오
> **프로젝트 유형:** 셀프호스트 파일 서비스 플랫폼
> **개인 기여 영역:** 백엔드 API · 데이터 모델링 · 인프라 구성 · CI/CD · 설계 문서화
> **기술 스택:** ASP.NET Core 10, PostgreSQL 16, Redis 7, tusd, Docker Compose, nginx
---
## 1. 프로젝트 개요
### 한 줄 정의
> **Space 단위의 완전 격리형 저장 공간**을 제공하는 셀프호스트 파일 서비스로, tus 프로토콜 기반의 재개 가능한 대용량 업로드와 단명(短命) 다운로드 세션을 갖췄다.
### 해결하려는 문제
기존 클라우드 스토리지(Google Drive, Dropbox 등)는 **개인 계정 중심**으로 설계되어 있어, 팀/프로젝트 단위에서 폴더 공유만으로는 다음 요구를 충족하기 어렵다.
- 격리된 저장 공간 (멤버십 + Quota + 권한)
- 대용량 파일의 중단 재개 업로드
- 셀프호스트 + 외부 공유 정책의 분리
### 주요 사용자
셀프호스트를 선호하는 개인·팀·소규모 조직, 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 |
---
## 2. 담당 역할
**1인 백엔드 + 인프라 + 설계 담당.** 프론트엔드를 제외한 전 영역.
- **백엔드 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 등 살아있는 문서 체계 구축
---
## 3. 아키텍처
### 3.1 아키텍처 유형
**모듈러 모놀리스 + Clean Architecture.**
> **선택 이유:** MVP 단계에서 마이크로서비스의 운영 복잡도(분산 트랜잭션, 서비스 디스커버리, 로그 수집)를 피하면서도, 코드 레벨로는 도메인 경계를 엄격히 분리해 추후 분리 가능성을 확보했다.
> 의존성 방향은 `Api → Core ← Infrastructure`로, 도메인이 HTTP나 DB, 파일시스템을 알지 못하게 했다.
### 3.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
Client -->|"HTTP :8080"| NGINX
NGINX -->|"/api/*"| API
NGINX -->|"/files/*"| TUSD
API --> PG
API --> REDIS
API --> FS
API -.->|"hook callback"| TUSD
TUSD --> FS
```
### 3.3 서비스 책임 분리
| 컴포넌트 | 책임 |
|----------|------|
| **nginx** | 단일 외부 진입점, tus 헤더 포워딩, 스트리밍 버퍼링 해제 |
| **ASP.NET API** | 권한·정책·메타데이터·Finalize·공유·세션 |
| **tusd (Go)** | tus 프로토콜 청크 수신, hook으로 API에 생명주기 위임 |
| **PostgreSQL** | 메타데이터 진실 원천 |
| **Redis** | 세션 저장소 + Pub/Sub 이벤트 버스 |
### 3.4 핵심 설계 의도
- **업로드 plane과 API plane 분리**: 대용량 청크 전송은 Go 기반 tusd가 담당, 비즈니스 판단은 ASP.NET이 담당 → 장애 격리 + 독립 확장
- **단일 외부 포트(8080)**: 셀프호스트 사용자 입장의 운영 단순화. DB/Redis/tusd/API는 `expose`만 사용하고 외부 노출 금지
---
## 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`)과 함께 이중 안전장치 구성
---
### 4.2 Space Quota 경쟁 조건 방지
**문제 상황**
한 Space의 여러 멤버가 동시에 대용량 업로드를 시작할 때, quota 판정(`expected ≤ available`)과 reserved 증가 사이의 간극에서 모든 요청이 통과하는 race condition이 발생할 수 있다.
**설계 선택 — DB row-level lock**
```sql
BEGIN;
SELECT * FROM space WHERE id = :id FOR UPDATE;
-- quota 판정 + reserved += expected_size + FileReservation 생성
COMMIT;
```
**선택 이유**
- 업로드 세션 생성은 빈번하지 않고 락 지속시간이 짧다
- 낙관적 동시성으로는 이미 전송 중인 요청을 거절할 수 없다
- Finalize 직전에도 quota를 재검사하여 이중 안전장치를 구성
**결과**
- Quota 불변조건 `used + reserved ≤ allowed`가 항상 유지
- 초과 업로드는 **시작 시점**에 거절되어 불필요한 네트워크 전송 방지
---
### 4.3 인증 토큰: Opaque Session vs JWT
**문제 상황**
한 사용자가 여러 Space에서 서로 다른 Role을 가지며, Role 변경이 즉시 반영되어야 한다. JWT를 사용하면 토큰 만료 전까지 stale 권한이 유지되며, 모든 Role을 claim에 담으면 토큰 크기가 비대해진다.
**설계 선택 — 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
**결과**
- Role 변경이 **다음 API 요청부터 즉시 반영**
- 로그아웃·강제 Revoke를 Redis key 삭제 한 번으로 처리
- 토큰 탈취 시에도 토큰 자체에는 정보가 없어 노출 최소화
---
### 4.4 파일명 충돌 정책: "명시적 실패 반환"
**문제 상황**
Google Drive 같은 서비스는 동일 폴더에 같은 이름의 파일이 업로드되면 자동으로 `파일명(1).pdf`로 rename한다. 그러나 사용자가 의도하지 않은 파일이 누적되어 데이터 일관성이 깨진다.
**설계 선택 — `FILE_NAME_CONFLICT` 에러 반환**
- 활성 `FileItem` + 활성 `FileReservation`을 함께 검사 (사전 + Finalize 직전 이중 검증)
- DB 레벨에서 `UNIQUE (space_id, folder_id, normalized_name) on active rows` 제약
**선택 이유**
- "모호함보다 명시적 실패가 낫다"는 설계 철학
- 사용자가 의도적으로 파일명을 결정하도록 유도
- 구현 단순성 (rename fallback, 카운터 등 불필요)
**결과**
- 항상 예측 가능한 동작 + 사용자 의도와 저장 상태의 일관성
---
## 5. API 설계
### 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 + 권한 컨텍스트로 변환
> bigint PK가 외부에 노출되어 enumeration 공격에 취약해지는 문제를 차단했다.
### 5.3 표준 에러 응답
```json
{
"requestId": "req_01JXYZ...",
"error": {
"code": "FILE_NAME_CONFLICT",
"message": "같은 이름의 파일이 이미 존재합니다.",
"details": [{ "field": "displayName", "reason": "conflict" }]
}
}
```
- `requestId` = `HttpContext.TraceIdentifier` → 로그 추적 가능
- ErrorCode는 OpenAPI에 문서화된 고정 문자열 → 클라이언트가 분기 처리 가능
- 외부 공개 API는 권한 없음/리소스 없음/비활성 모두 **404로 통일** → Zero Information Leak
---
## 6. 데이터베이스 설계
### 6.1 주요 엔티티 (12개)
User, Space, SpaceMember, SpaceInvite, Folder, FileItem, **UploadSession**, **FileReservation**, ShareLink, ShareLinkTarget, DownloadSession.
### 6.2 핵심 모델링 결정
**① UploadSession ↔ FileReservation 1:1 분리**
- UploadSession: 전송 상태 추적(tus I/O, 네트워크 관점, 7-state machine)
- FileReservation: 비즈니스 자원 선점(quota·파일명, 도메인 관점, 6-state machine)
- 변경 주기와 실패 원인이 다르므로 분리하되 1:1로 연결
**② 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`) |
---
## 7. 트랜잭션과 동시성
### 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가 정리.
### 7.3 중복 생성 3중 방지
1. UseCase 사전 검증 (파일명 충돌)
2. DB UNIQUE 제약
3. `TrySaveChangesAsync()` — conflict 감지 시 `Result.Fail`
---
## 8. 예외 처리와 응답 구조
### 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 도입 검토