271 lines
22 KiB
Markdown
271 lines
22 KiB
Markdown
# 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 설계
|
|
|
|
- 도메인 트랜잭션과 같은 `DbContext`에 `outbox_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 컨테이너로 `tusd`와 `api`가 동일 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. `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줄만 추가하면 되도록 확장 비용을 최소화했습니다.
|
|
|
|
---
|
|
|
|
## 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`가 도메인 트랜잭션의 같은 `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 만료 후 자연스럽게 인계받아 단일 장애점이 없는 구조가 되었습니다.
|
|
|
|
---
|
|
|
|
## 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 단계에서 감지되도록 하고자 합니다. |