19 KiB
Cloud# (CloudSharp) — 백엔드 / 인프라 포트폴리오
1. 프로젝트 개요
해결하려는 문제
기존 클라우드 스토리지(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 전체 구성도
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)
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
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 표준 에러 응답
{
"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에서 명시적 경계 관리.
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중 방지
- UseCase 사전 검증 (파일명 충돌)
- DB UNIQUE 제약
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로 단순 실행 순서가 아닌 실제 준비 완료를 기다린다.
api:
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
tusd: { condition: service_healthy }
9.4 nginx — tusd 스트리밍 특수 설정
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. 환경변수 / 설정 관리
# 보안 비밀 (반드시 환경변수로만 주입)
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
// ✅
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
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 도입 검토