Files
irukseo/포트폴리오/projects/Cloud#.md
2026-06-01 16:40:05 +09:00

22 KiB

CloudSharp 포트폴리오

1. 프로젝트 개요

본 프로젝트는 자가호스팅 환경에서 느린 전송 속도AI/에이전트 통합 부재라는 두 가지 한계를 해결하기 위해 개발한 Space 단위 격리형 클라우드 스토리지 서비스입니다.

기존 자가호스팅 솔루션인 Nextcloud, ownCloud는 PHP 기반 웹 서버(Apache + mod_php)의 구조적 한계로 대용량 파일 전송 시 체감 속도가 떨어지고, MCP·Claude 같은 모델/에이전트가 사용자의 파일에 안전하게 접근할 수 있는 표준 통합 지점이 없었습니다. 이로 인해 자가호스팅을 선택한 사용자는 성능과 AI 활용을 동시에 포기해야 하는 상황이 있었습니다.

이를 해결하기 위해 다음 세 가지를 핵심 목표로 서비스를 설계했습니다.

  • Space 단위 격리 + 역할 기반 권한으로 다중 사용자 시나리오에서 데이터 격리를 보장
  • tus 프로토콜 기반 재개 가능한 업로드단일 PATCH 청크 전송으로 대용량 전송 성능 확보
  • MCP 토큰(cs_mcp_*)과 MCP Console을 1차 MVP에 포함해 AI 에이전트 통합을 표준화

주요 기능은 다음과 같습니다.

  • Space 단위 워크스페이스 분리 및 멤버 초대(OWNER/ADMIN/MEMBER/VIEWER 4단계 역할)
  • tus 기반 대용량 파일 업로드와 finalize 보장
  • 도메인 이벤트 기반 SSE 실시간 알림 및 외부 Worker 작업 분기
  • ShareLink 기반 익명 공유 및 다운로드 세션
  • MCP 토큰으로 모델/에이전트가 파일을 탐색·검색·다운로드

2. 담당 역할

저는 백엔드 개발자로 참여하여 인증/인가 흐름, 업로드 finalize 파이프라인, Outbox 기반 이벤트 fan-out, 인프라 구성을 담당했으며, API 요청부터 데이터 저장과 외부 시스템 연동까지의 전체 흐름을 설계하고 구현했습니다.

주요 역할은 다음과 같습니다.

  • 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. 주요 기여

1. 인증/인가 흐름 설계

  • Opaque Bearer 토큰 발급 시스템을 구현하고, 토큰 prefix(cs_st, cs_mcp, cs_dl, cs_sh)로 토큰 종류를 분류하여 단일 Authorization 헤더로 사용자 세션·MCP·다운로드·공유링크를 모두 처리하도록 설계했습니다.
  • Space 권한 검증 로직을 RequireSpacePermissionFilter (IEndpointFilter)로 분리하여 모든 Space-scoped 엔드포인트에 일관되게 적용했습니다.
  • 매 요청마다 SpaceMember를 재조회하도록 설계하여 역할 변경이 다음 요청부터 즉시 반영되도록 했습니다.

2. 업로드 파이프라인 설계

  • 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 상태를 자동 정리하도록 설계했습니다.

3. 트랜잭셔널 Outbox와 이벤트 fan-out 설계

  • 도메인 트랜잭션과 같은 DbContextoutbox_events 행을 기록하는 OutboxEventRecorder를 구현하여 도메인 변경과 이벤트 발행을 원자적으로 묶었습니다.
  • OutboxEventRouteRegistry로 이벤트 타입과 다운스트림(RealtimeFanout, NotificationProjection, Worker)을 매핑하여 새 이벤트 추가 비용을 최소화했습니다.
  • FOR UPDATE SKIP LOCKED 대신 partial index + 조건부 ExecuteUpdate 기반 낙관적 claim 전략을 채택하여 다중 워커 환경에서도 race 없이 작동하도록 설계했습니다.

4. 인프라 및 배포 자동화

  • nginx 단일 컨테이너만 외부 노출하는 same-origin 토폴로지를 구성하여 CORS 표면과 TLS 종단을 한 곳으로 모았습니다.
  • init-storage one-shot 컨테이너로 tusdapi가 동일 UID/GID(10001)로 같은 bind mount를 공유하도록 처리하여 storage move를 in-place로 가능하게 했습니다.
  • GitLab CI에서 backend:test → backend:image → backend:deploy 파이프라인을 구성하고, Portainer webhook으로 자동 redeploy되도록 했습니다.

4. 사용 기술 및 선택 이유

기술 사용 목적 선택 이유
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로 충분했고, 동일 토폴로지를 운영/개발에서 재사용할 수 있어 선택했습니다.

5. 구현 사항

5.1 Space 권한 검증 흐름

사용자가 Space 내부 리소스에 접근하면 서버는 인증 → 정책 → 권한 필터 → UseCase 4단계로 요청을 처리합니다.

구현 흐름은 다음과 같습니다.

  1. CloudSharpSessionAuthenticationHandlerAuthorization: Bearer <token> 헤더의 prefix를 보고 토큰 종류를 분류합니다.
  2. 토큰 해시(SHA-256)로 Redis auth:session:{tokenHash}를 조회해 sub(userId), sid, system_role 클레임을 설정합니다.
  3. RequireDelegatedUserAccess() 정책이 인증 실패 시 401로 거절합니다.
  4. RequireSpacePermissionFilter가 라우트의 spaceSlugISpacePermissionService.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. TryStartFinalizingAsyncCREATED/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 + 보상으로 단순화하여 운영 복잡도를 낮췄습니다.
  • UploadFinalizeRecoveryRunnerFINALIZING 상태에 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줄만 추가하면 되도록 확장 비용을 최소화했습니다.

6. 문제 해결 사례

6.1 다중 Space 권한 staleness 문제

문제 상황

사용자가 여러 Space에 서로 다른 역할로 속할 때, JWT 기반 인증을 사용하면 토큰 발급 시점에 역할이 클레임으로 박혀 운영자가 역할을 변경해도 토큰 만료 전까지 옛 권한이 유지되는 staleness 문제가 발생할 가능성이 있었습니다.

원인 분석

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 헤더로 사용자/에이전트/익명 사용자를 모두 라우팅할 수 있게 했습니다.


6.2 storage move와 DB 트랜잭션 정합성 문제

문제 상황

업로드 finalize는 (1) temp → final 파일 이동, (2) file_items INSERT, (3) 쿼터 갱신, (4) 예약 소모, (5) 세션 완료 5단계를 모두 처리해야 했습니다. 어느 한 단계가 실패하면 스토리지에는 파일이 있는데 DB에는 메타가 없는 고아 객체나, 반대로 DB에는 행이 있는데 스토리지에는 파일이 없는 broken row가 발생할 수 있었습니다.

원인 분석

파일 시스템과 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가 후속 청소합니다.


6.3 도메인 이벤트의 다중 다운스트림 라우팅 문제

문제 상황

FileUploaded 같은 도메인 이벤트는 SSE 실시간 알림, 알림함 행 추가, 외부 Worker 작업 발송 3가지 일을 모두 수행해야 했습니다. 도메인 UseCase에서 각 다운스트림을 직접 호출하면 외부 시스템 실패가 도메인 트랜잭션을 깨거나, 도메인 트랜잭션이 commit되었는데 알림 발송이 누락되는 정합성 문제가 발생할 수 있었습니다.

원인 분석

도메인 코드가 ISseClient, INotificationRepository, IRedisPublisher를 직접 알면 (a) 외부 시스템과의 강결합이 발생하고, (b) 라우팅 규칙이 도메인 곳곳에 분산되어 변경 비용이 증가하며, (c) 트랜잭션 경계가 모호해집니다.

해결 방법

트랜잭셔널 Outbox 패턴 + 라우팅 레지스트리 구조를 도입했습니다.

  • OutboxEventRecorder.AddBestEffortAsync가 도메인 트랜잭션의 같은 DbContextoutbox_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 만료 후 자연스럽게 인계받아 단일 장애점이 없는 구조가 되었습니다.


7. 프로젝트 성과

성능 측정 결과

자체 개발한 C# 벤치마크 도구(CloudSharp.TransferBenchmark)로 동일 호스트·동일 Docker·동일 Postgres·동일 bind mount 환경에서 Nextcloud와 비교 측정한 결과는 다음과 같습니다.

항목 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배 적음

구조적 성과

  • 공통 권한 검증 필터 분리: 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 한 줄로 운영 환경을 기동할 수 있습니다.

8. 프로젝트 회고

배운 점

  • DB가 진실의 원천이라는 원칙의 실효성: 파일 시스템과 DB가 같은 트랜잭션에 묶일 수 없는 상황에서, DB commit을 가장 마지막에 두고 storage move를 먼저 한 뒤 실패 시 보상하는 saga 패턴이 분산 트랜잭션보다 운영하기 쉬웠습니다.
  • partial index와 조건부 UPDATE의 활용: PostgreSQL의 partial unique index와 WHERE 조건 기반 ExecuteUpdate로 비즈니스 불변식과 동시성을 애플리케이션 코드 없이 DB 레벨에서 보장할 수 있다는 것을 체감했습니다.
  • Outbox 패턴의 확장성: 도메인 변경과 이벤트 발행을 한 트랜잭션으로 묶고 라우팅 레지스트리로 다운스트림을 분리하니 새 기능 추가 시 도메인 코드 변경 없이 확장이 가능해지는 구조적 이점을 확인했습니다.

아쉬웠던 점

초기에는 단일 인스턴스 운영을 전제로 SSE fan-out을 메모리 기반 ISseConnectionStore로 구현했습니다. 이로 인해 다중 인스턴스 확장 시 sticky session 또는 Redis Pub/Sub로 전환해야 하는 마이그레이션 비용이 남아 있고, 현재는 ADR(docs/.llm/wiki/decisions.md 2026-05-13)로만 로드맵을 남겨둔 상태입니다.

또한 Outbox max_attempts 초과 시의 DeadLetter 상태는 enum으로 정의했지만 자동 전이 로직과 관리 API를 만들지 못해, 현재는 재시도가 누적되어 폴링에서 자연스럽게 제외되는 방식으로만 동작합니다.

개선하고 싶은 점

  • 관측 가능성 강화: 현재는 구조화 로그와 헬스체크 수준만 갖춰져 있어, OpenTelemetry 기반 분산 트레이싱과 Prometheus 메트릭 노출을 추가하여 다중 인스턴스 운영 시의 가시성을 확보하고자 합니다.
  • SSE 다중 인스턴스화: Redis Streams 또는 Pub/Sub 기반으로 SSE 메시지 fan-out을 옮겨 수평 확장 가능하도록 개선하고자 합니다.
  • OpenAPI 자동 생성: 현재 145KB OpenAPI 문서를 수동 유지하고 있어 코드 변경과 drift가 발생할 수 있는 구조입니다. 다음 작업에서는 Swashbuckle 등으로 자동 생성하고 수동 reconcile은 ADR 변경 시에만 수행하는 방식으로 전환하려 합니다.
  • 벤치마크 CI 게이트화: CloudSharp.TransferBenchmark를 CI에 통합하여 회귀가 PR 단계에서 감지되도록 하고자 합니다.