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,4 +1,3 @@
[
"copilot",
"obsidian-git"
]

View File

@@ -1,464 +0,0 @@
{
"userId": "82e491f6-82e0-4355-8fb7-3e9828e68e57",
"isPlusUser": false,
"plusLicenseKey": "",
"openAIApiKey": "",
"openAIOrgId": "",
"huggingfaceApiKey": "",
"cohereApiKey": "",
"anthropicApiKey": "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",
"azureOpenAIApiKey": "",
"azureOpenAIApiInstanceName": "",
"azureOpenAIApiDeploymentName": "",
"azureOpenAIApiVersion": "",
"azureOpenAIApiEmbeddingDeploymentName": "",
"googleApiKey": "",
"openRouterAiApiKey": "",
"xaiApiKey": "",
"mistralApiKey": "",
"deepseekApiKey": "",
"amazonBedrockApiKey": "",
"amazonBedrockRegion": "",
"siliconflowApiKey": "",
"githubCopilotAccessToken": "",
"githubCopilotToken": "",
"githubCopilotTokenExpiresAt": 0,
"defaultChainType": "llm_chain",
"defaultModelKey": "claude-opus-4-7|anthropic",
"embeddingModelKey": "openai/text-embedding-3-small|openrouterai",
"temperature": 0.1,
"maxTokens": 6000,
"contextTurns": 15,
"userSystemPrompt": "",
"openAIProxyBaseUrl": "",
"openAIEmbeddingProxyBaseUrl": "",
"stream": true,
"defaultSaveFolder": "copilot/copilot-conversations",
"defaultConversationTag": "copilot-conversation",
"autosaveChat": true,
"generateAIChatTitleOnSave": true,
"autoAddActiveContentToContext": true,
"defaultOpenArea": "view",
"defaultSendShortcut": "enter",
"customPromptsFolder": "copilot/copilot-custom-prompts",
"indexVaultToVectorStore": "ON MODE SWITCH",
"qaExclusions": "copilot",
"qaInclusions": "",
"chatNoteContextPath": "",
"chatNoteContextTags": [],
"enableIndexSync": true,
"debug": false,
"enableEncryption": false,
"maxSourceChunks": 30,
"enableInlineCitations": true,
"groqApiKey": "",
"activeModels": [
{
"name": "copilot-plus-flash",
"provider": "copilot-plus",
"enabled": true,
"isBuiltIn": true,
"core": true,
"plusExclusive": true,
"projectEnabled": false,
"capabilities": [
"vision"
]
},
{
"name": "google/gemini-2.5-flash",
"provider": "openrouterai",
"enabled": true,
"isBuiltIn": true,
"core": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-5.4",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"core": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-5-mini",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"core": true,
"capabilities": [
"vision"
]
},
{
"name": "claude-sonnet-4-6",
"provider": "anthropic",
"enabled": true,
"isBuiltIn": true,
"capabilities": [
"reasoning",
"vision"
]
},
{
"name": "gemini-3.1-flash-lite-preview",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gemini-2.5-flash",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "google/gemini-3-flash-preview",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "google/gemini-3.1-pro-preview",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "google/gemini-2.5-pro",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "openai/gpt-5.4",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "openai/gpt-5-mini",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "grok-4-1-fast",
"provider": "xai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "x-ai/grok-4.1-fast",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-4.1",
"provider": "openai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-4.1-mini",
"provider": "openai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "claude-opus-4-6",
"provider": "anthropic",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "gemini-3-flash-preview",
"provider": "google",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "gemini-3.1-pro-preview",
"provider": "google",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "gemini-2.5-pro",
"provider": "google",
"enabled": false,
"isBuiltIn": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "deepseek-chat",
"provider": "deepseek",
"enabled": false,
"isBuiltIn": true
},
{
"name": "deepseek-reasoner",
"provider": "deepseek",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"reasoning"
]
},
{
"name": "deepseek-ai/DeepSeek-V3",
"provider": "siliconflow",
"enabled": false,
"isBuiltIn": false,
"baseUrl": "https://api.siliconflow.com/v1"
},
{
"name": "deepseek-ai/DeepSeek-R1",
"provider": "siliconflow",
"enabled": false,
"isBuiltIn": false,
"baseUrl": "https://api.siliconflow.com/v1",
"capabilities": [
"reasoning"
]
},
{
"name": "claude-opus-4-7",
"provider": "anthropic",
"enabled": true
}
],
"activeEmbeddingModels": [
{
"name": "copilot-plus-small",
"provider": "copilot-plus",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"plusExclusive": true
},
{
"name": "copilot-plus-large",
"provider": "copilot-plus-jina",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"plusExclusive": true,
"believerExclusive": true,
"dimensions": 1024
},
{
"name": "copilot-plus-multilingual",
"provider": "copilot-plus-jina",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"plusExclusive": true,
"dimensions": 512
},
{
"name": "openai/text-embedding-3-small",
"provider": "openrouterai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "text-embedding-3-small",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "gemini-embedding-001",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "gemini-embedding-2-preview",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "Qwen/Qwen3-Embedding-0.6B",
"provider": "siliconflow",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"baseUrl": "https://api.siliconflow.com/v1"
},
{
"name": "text-embedding-3-large",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
},
{
"name": "embed-multilingual-light-v3.0",
"provider": "cohereai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
},
{
"name": "text-embedding-004",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
},
{
"name": "azure-openai",
"provider": "azure openai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
}
],
"embeddingRequestsPerMin": 60,
"embeddingBatchSize": 16,
"disableIndexOnMobile": true,
"showSuggestedPrompts": true,
"showRelevantNotes": true,
"numPartitions": 1,
"lexicalSearchRamLimit": 100,
"promptUsageTimestamps": {},
"promptSortStrategy": "timestamp",
"chatHistorySortStrategy": "recent",
"projectListSortStrategy": "recent",
"defaultConversationNoteName": "{$topic}@{$date}_{$time}",
"inlineEditCommands": [],
"projectList": [],
"lastDismissedVersion": null,
"passMarkdownImages": true,
"enableAutonomousAgent": true,
"enableCustomPromptTemplating": true,
"enableSemanticSearchV3": true,
"enableSelfHostMode": false,
"enableMiyo": false,
"miyoSearchAll": false,
"selfHostModeValidatedAt": null,
"selfHostValidationCount": 0,
"selfHostUrl": "",
"selfHostApiKey": "",
"miyoServerUrl": "",
"selfHostSearchProvider": "firecrawl",
"firecrawlApiKey": "",
"perplexityApiKey": "",
"supadataApiKey": "",
"enableLexicalBoosts": true,
"suggestedDefaultCommands": true,
"autonomousAgentMaxIterations": 4,
"autonomousAgentEnabledToolIds": [
"localSearch",
"readNote",
"webSearch",
"pomodoro",
"youtubeTranscription",
"writeFile",
"editFile",
"updateMemory"
],
"reasoningEffort": "low",
"verbosity": "medium",
"memoryFolderName": "copilot/memory",
"enableRecentConversations": true,
"maxRecentConversations": 30,
"enableSavedMemory": true,
"quickCommandIncludeNoteContext": true,
"autoIncludeTextSelection": false,
"autoAddSelectionToContext": false,
"autoAcceptEdits": false,
"diffViewMode": "split",
"userSystemPromptsFolder": "copilot/system-prompts",
"defaultSystemPromptTitle": "",
"autoCompactThreshold": 128000,
"convertedDocOutputFolder": ""
}

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +0,0 @@
{
"id": "copilot",
"name": "Copilot",
"version": "3.2.8",
"minAppVersion": "0.15.0",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"author": "Logan Yang",
"authorUrl": "https://twitter.com/logancyang",
"fundingUrl": {
"Buy Me a Coffee": "https://www.buymeacoffee.com/logancyang",
"GitHub Sponsor": "https://github.com/sponsors/logancyang"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

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"]
- 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 상태를 자동 정리하도록 설계했습니다.
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
### 3. 트랜잭셔널 Outbox와 이벤트 fan-out 설계
Client -->|"HTTP :8080"| NGINX
NGINX -->|"/api/*"| API
NGINX -->|"/files/*"| TUSD
API --> PG
API --> REDIS
API --> FS
API -.->|"hook callback"| TUSD
TUSD --> FS
```
- 도메인 트랜잭션과 같은 `DbContext``outbox_events` 행을 기록하는 `OutboxEventRecorder`를 구현하여 도메인 변경과 이벤트 발행을 원자적으로 묶었습니다.
- `OutboxEventRouteRegistry`로 이벤트 타입과 다운스트림(`RealtimeFanout`, `NotificationProjection`, `Worker`)을 매핑하여 새 이벤트 추가 비용을 최소화했습니다.
- `FOR UPDATE SKIP LOCKED` 대신 partial index + 조건부 `ExecuteUpdate` 기반 낙관적 claim 전략을 채택하여 다중 워커 환경에서도 race 없이 작동하도록 설계했습니다.
### 3.3 서비스 책임 분리
| 컴포넌트 | 책임 |
|----------|------|
| **nginx** | 단일 외부 진입점, tus 헤더 포워딩, 스트리밍 버퍼링 해제 |
| **ASP.NET API** | 권한·정책·메타데이터·Finalize·공유·세션 |
| **tusd (Go)** | tus 프로토콜 청크 수신, hook으로 API에 생명주기 위임 |
| **PostgreSQL** | 메타데이터 진실 원천 |
| **Redis** | 세션 저장소 + Pub/Sub 이벤트 버스 |
### 4. 인프라 및 배포 자동화
### 3.4 핵심 설계 의도
- **업로드 plane과 API plane 분리**: 대용량 청크 전송은 Go 기반 tusd가 담당, 비즈니스 판단은 ASP.NET이 담당 → 장애 격리 + 독립 확장
- **단일 외부 포트(8080)**: 셀프호스트 사용자 입장의 운영 단순화. DB/Redis/tusd/API는 `expose`만 사용하고 외부 노출 금지
- `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 단계에서 감지되도록 하고자 합니다.

View File

@@ -1,60 +1,96 @@
# 자기소개 / 기술스킬
## 슬로건
```
API 설계부터 Clean Architecture 까지, 서비스 흐름을 구조적으로 설계하는 백엔드 개발자
상태 변화가 많은 도메인을 이벤트 흐름과
Clean Architecture로 설계하는 백엔드 개발자
```
---
## 현재의 나 & 지향하는 바
## 한 줄 소개
Spring Boot와 .NET 기반으로 인증/인가, 비동기 작업 파이프라인, JS Interop 브릿지 등
서비스 안정성과 직결되는 백엔드 기능을 Clean Architecture 위에서 설계하고 구현해왔습니다.
대용량 파일 업로드 라이브러리 개발부터 Redis 기반 AI 비동기 파이프라인 구축,
GPT 추천 엔진 설계까지 도메인 문제를 API와 내부 구조로 풀어내는 경험을 쌓았으며,
인프라까지 이해하는 백엔드 개발자로 성장하고자 합니다.
ASP.NET Core와 Spring Boot 기반으로 tus 업로드 파이프라인, Opaque Session 인증, Transactional Outbox, Redis 비동기 작업 큐를 직접 설계·구현하며, 서비스의 정합성과 확장성을 함께 고려하는 백엔드 개발자를 지향합니다.
---
## 자기소개
## 기술스킬
Spring Boot와 .NET 기반으로 인증/인가, 비동기 작업 파이프라인, JS Interop 브릿지, Transactional Outbox 등 서비스 안정성과 직결되는 백엔드 기능을 Clean Architecture 위에서 설계하고 구현해왔습니다.
| 분류 | 기술 | 숙련도 | 활용 수준 |
CloudSharp에서는 ASP.NET Core 10과 PostgreSQL 기반으로 Space 단위 RBAC 인가, tus 기반 대용량 업로드 finalize saga, Outbox 기반 SSE/Notification/Worker 3-target fan-out 구조를 설계하며 파일 시스템과 DB의 정합성을 CAS + 보상 + 자율 복구 워커 3단 구조로 보장하는 방법을 깊이 다뤘습니다. Didit에서는 Spring Boot와 Redis List + Pub/Sub을 조합해 ML 추론과 API 응답성을 분리하는 비동기 파이프라인을 구축하고, SSE clientKey 매핑으로 특정 사용자에게만 결과를 전달하는 무상태 구조를 구현했습니다. 술통여지도에서는 GPT-5.2 Structured Output 기반 2단계 Cascade Ranking과 Defensive Normalization으로 LLM hallucination을 구조적으로 방어하며 토큰 40%를 절감했고, TusBlazorClient에서는 tus-js-client를 C# API로 래핑한 NuGet 라이브러리를 직접 배포했습니다.
앞으로는 단순 기능 구현을 넘어 도메인 이벤트 흐름·동시성·관측 가능성·수평 확장성을 함께 고려하며, 인프라까지 이해하는 백엔드 개발자로 성장하고자 합니다.
---
## Skills
### Main Skills
| Category | Skill | Level | 활용 경험 |
|---|---|---:|---|
| Backend | C#, ASP.NET Core | ★★★★☆ | Minimal API 기반 REST API 설계, Opaque Session Token 인증, UseCase 중심 구조, NuGet 라이브러리 배포 경험 |
| Backend | Java, Spring Boot | ★★★★☆ | 3계층 Clean Architecture 기반 API 설계, Spring Security + GitHub OAuth2, SSE 실시간 이벤트 시스템 구현 |
| Architecture | Clean Architecture | ★★★★☆ | Api / Core / Infrastructure 계층 분리, 의존성 역전 구조 설계, Result\<T\> Monad 기반 에러 전파 파이프라인 구성 |
| Architecture | Domain Modeling | ★★★★☆ | UploadSession·FileReservation 분리, Space 중심 멀티 테넌트 권한 모델, 상태 머신 기반 도메인 설계 경험 |
| Database | PostgreSQL, EF Core | ★★★☆☆ | 12개 엔티티 ERD 설계, Migration 자동화, FOR UPDATE row-level lock 기반 동시성 제어, CAS 패턴 SQL 구현 |
| Database | MySQL, MyBatis / JPA | ★★★☆☆ | 16개 테이블 모델링, Flyway 마이그레이션 관리, 복잡 조인 쿼리 작성 |
| Upload / Storage | tus, tusd | ★★★★☆ | tusd Hook 연동(pre-create / post-finish), Finalize 중복 방지 CAS Lock, Storage Sharding 65,536 버킷 설계, tus-js-client 래퍼 라이브러리 직접 구현 |
| Auth | OAuth2 / Session Token | ★★★★☆ | GitHub OAuth2 인증 흐름 구축, Opaque Session Token + Redis 세션 저장소 + HMAC 해싱 + sliding renewal 정책 구현 |
| Infra | Docker, Docker Compose | ★★★★☆ | 5-서비스 오케스트레이션, internal 네트워크 격리, Healthcheck 기반 depends_on, Multi-stage 빌드 이미지 최적화 |
| Infra | nginx Reverse Proxy | ★★★☆☆ | tus 헤더 포워딩(Tus-Resumable, Upload-Offset), 스트리밍 버퍼링 해제, 단일 진입점 라우팅 구성 |
| Cache / Async | Redis | ★★★☆☆ | 세션 저장소(Hash), Pub/Sub 기반 비동기 작업 결과 전달, AI 작업 큐(List) 구현 |
| AI Integration | OpenAI GPT API | ★★★☆☆ | Structured Output 기반 추천 엔진, 2단계 Cascade Ranking, Defensive Normalization으로 Hallucination 방어, Pipe-delimited Format으로 토큰 40% 절감 |
| CI/CD | GitLab CI, GHCR | ★★★☆☆ | PR 단위 테스트 자동화, 변경 경로 기반 조건부 빌드, Docker 이미지 자동 푸시 파이프라인 구성 |
| Frontend | Blazor WASM, Vue 3 | ★★☆☆☆ | Blazor JS Interop 브릿지 설계, Vue 3 Composition API + Pinia 기반 지도 UI 구현 |
| Backend | C# / ASP.NET Core | 3 | CloudSharp에서 Minimal API + Endpoint Filter 기반 3계층(Core/Infrastructure/Api) 구조 설계, Opaque Session Token 인증, UseCase 중심 비즈니스 흐름, TusBlazorClient NuGet 라이브러리 배포 |
| Backend | Java / Spring Boot | 3 | Didit·술통여지도에서 30개+ REST 엔드포인트 설계, Spring Security 세션 인증, GitHub OAuth2, BackgroundService 기반 워커 구조 구현 |
| Architecture | Clean Architecture | 3 | Api / Core / Infrastructure 3계층 분리, 의존성 역전 구조 설계, Result\<T\> Monad 기반 에러 전파 파이프라인 구성 |
| Architecture | Domain Modeling | 3 | UploadSession·FileReservation 분리, Space 중심 멀티 테넌트 권한 모델, upload_sessions 7종 상태머신 + 조건부 UPDATE 기반 race 차단 설계 |
| Database | PostgreSQL / EF Core | 3 | CloudSharp 16개 테이블·15개 enum 모델링, partial unique index 7종으로 비즈니스 불변식 DB 강제, 조건부 ExecuteUpdate 기반 낙관적 claim, `xmin` row version 활용 |
| Database | MySQL / JPA·MyBatis | 3 | Didit에서 JPA + Flyway V1~V12 마이그레이션 + `ddl-auto: validate` 이중 정합성 보장, 술통여지도에서 MyBatis 기반 복잡 조인 쿼리 작성 |
| Upload / Storage | tus / tusd | 3 | tusd Hook 연동(pre-create / post-finish), finalize 중복 방지 CAS UPDATE, storage move + DB 트랜잭션 saga + 보상 처리, tus-js-client C# 래퍼 라이브러리 직접 구현 |
| Auth | OAuth2 / Session Token | 3 | GitHub OAuth2 인증 흐름 구축, Opaque Session Token + Redis 세션 저장소 + SHA-256 해싱 + sliding renewal, prefix 기반 토큰 분류(`cs_st`/`cs_mcp`/`cs_dl`/`cs_sh`)로 단일 헤더 라우팅 |
| Pattern | Transactional Outbox | 3 | 도메인 트랜잭션과 같은 DbContext에 outbox 기록, partial index 기반 낙관적 claim + 지수 백오프 재시도 + 라우팅 레지스트리로 SSE/Notification/Worker 3-target fan-out 구현 |
| Realtime / Async | SSE | 3 | Didit·CloudSharp에서 SseHub/SseEmitter 기반 실시간 이벤트 전달 구조 구현, clientKey 매핑으로 동일 사용자 다중 탭 구분 및 특정 사용자 타겟팅 |
| Cache / Async | Redis | 3 | List(작업 큐) + Pub/Sub(결과 전달) 조합으로 ML 비동기 파이프라인 구축, Opaque 토큰 세션 저장소(Hash) 및 Worker 채널로 활용, KeyDelete 한 번으로 logout-all 처리 |
| Infra | Docker / Docker Compose | 3 | CloudSharp 6-서비스 same-origin 스택 구성, init-storage one-shot 컨테이너로 동일 UID/GID(10001) bind mount 공유, healthcheck 기반 depends_on 체인, Multi-stage 빌드 최적화 |
### Sub Skills
| Category | Skill | Level | 활용 경험 |
|---|---|---:|---|
| AI Integration | OpenAI GPT API | 2 | 술통여지도에서 Structured Output 기반 2단계 Cascade Ranking 추천 엔진 구현, Defensive Normalization으로 hallucination 차단, pipe-delimited 포맷으로 토큰 40% 절감 |
| Infra | nginx | 2 | CloudSharp에서 단일 origin reverse proxy 구성, tus 헤더 포워딩(Tus-Resumable, Upload-Offset), `/files/` streaming 버퍼링 해제 |
| CI/CD | GitLab CI / GHCR | 2 | test → build → push → Portainer webhook redeploy 파이프라인 구축, 변경 경로 기반 조건부 빌드, JUnit Report artifact 자동화 |
| Realtime | WebRTC (OpenVidu) | 1 | Didit에서 OpenVidu 2.32.1 기반 화상회의·녹화 기능 연동 |
| Search | Elasticsearch | 1 | 술통여지도에서 술집 전문 검색 인덱스 클라이언트 구성 |
| Frontend | Blazor WASM | 2 | TusBlazorClient 라이브러리에서 IJSRuntime JS Interop 브릿지 설계, `[JSInvokable]` + DotNetObjectReference 콜백 마샬링, DI 통합 |
| Frontend | Vue 3 | 1 | 술통여지도에서 Composition API + Pinia + Naver Maps 기반 지도 UI 구현 |
---
### 숙련도 기준
- **Level 1**: 사용 경험 있음 — 문서/예제 참고하여 기본 사용 가능
- **Level 2**: 기능 구현 가능 — 일반 요구사항 수준의 기능 구현 가능
- **Level 3**: 프로젝트 적용 가능 — 서비스 요구사항에 맞게 구조 설계 후 핵심 기능에 적용
- **Level 4**: 문제 해결 가능 — 성능·장애·구조 개선까지 가능
---
## 핵심 강점 요약
1. 두 가지 백엔드 스택을 Clean Architecture 위에서 설계
- Java/Spring Boot, C#/ASP.NET Core 모두 실무 수준으로 3계층 구조 적용 가능
- Result<T> Monad 기반 에러 전파, UseCase 중심 비즈니스 흐름 구성
### 1. 두 가지 백엔드 스택을 Clean Architecture 위에서 설계
2. 서비스 안정성과 직결되는 백엔드 기능 구현
- tus 기반 대용량 업로드: 클라이언트 라이브러리(NuGet 배포) + 서버 파이프라인 양방향
- 인증/인가: GitHub OAuth2, Opaque Session Token + Redis 세션, Space Role 기반 인가
- 동시성 제어: CAS UPDATE, FOR UPDATE row-lock, Recovery Worker 기반 자동 복구
- Java/Spring Boot, C#/ASP.NET Core 모두 실무 수준으로 3계층 구조 적용
- Result\<T\> Monad 기반 에러 전파, UseCase 중심 비즈니스 흐름
- 150여 개 ErrorCode 상수와 일관된 ErrorResponse 구조로 프론트엔드 분기 단순화
3. 도메인 문제를 비동기 파이프라인으로 풀어낸 경험
- Redis List/Pub/Sub 기반 AI 작업 큐로 ML 추론과 API 응답성 분리
- GPT 2단계 Cascade Ranking으로 토큰 70% 절감, Defensive Normalization으로 Hallucination 차단
- Blazor JS Interop 브릿지로 .NET ↔ JavaScript 콜백 마샬링 최적화
### 2. 서비스 안정성과 직결되는 백엔드 기능 구현
4. 인프라까지 고려한 백엔드 설계
- Docker Compose 멀티 서비스 오케스트레이션, internal 네트워크 격리
- nginx Reverse Proxy + tus 헤더 포워딩, GitLab CI 자동 빌드/배포 파이프라인
- **tus 기반 대용량 업로드**: tusd Hook 연동 + finalize saga + 보상 처리 + 5분 주기 Recovery Worker, NuGet 라이브러리 배포까지 양방향 구현
- **인증/인가**: GitHub OAuth2, Opaque Session Token + Redis 세션, prefix 기반 단일 헤더 라우팅, Space Role 매 요청 재조회로 staleness 0
- **동시성 제어**: 조건부 UPDATE 기반 CAS, partial unique index 7종, `xmin` row version, Recovery Worker 기반 self-healing
### 3. 도메인 문제를 비동기 파이프라인으로 풀어낸 경험
- **Transactional Outbox**: 도메인 트랜잭션과 원자적 enqueue, partial index 기반 낙관적 claim, 지수 백오프 재시도, 3-target fan-out
- **Redis List/Pub/Sub**: ML 추론과 API 응답성 분리, Kafka/RabbitMQ 없이 운영 복잡도 절감
- **GPT 2단계 Cascade Ranking**: 200개 후보 → top10으로 압축, 토큰 70% 절감, Defensive Normalization으로 hallucination 차단
- **Blazor JS Interop 브릿지**: `[JSInvokable]` + DotNetObjectReference로 .NET ↔ JavaScript 콜백 마샬링 최적화
### 4. 인프라까지 고려한 백엔드 설계
- Docker Compose 6-서비스 same-origin 스택, init-storage one-shot으로 UID/GID 공유 bind mount
- nginx reverse proxy + tus 헤더 포워딩 + streaming 버퍼링 해제
- 자체 개발 벤치마크 도구로 Nextcloud 대비 업로드 2.6배·다운로드 3.86배 성능 측정
- GitLab CI + GHCR + Portainer webhook 기반 자동 빌드/배포 파이프라인

View File

@@ -1,432 +1,543 @@
# 개발자 포트폴리오
## CloudSharp
> **API 설계부터 Clean Architecture까지, 서비스 흐름을 구조적으로 설계하는 백엔드 개발자**
### 1. 프로젝트 개요
Spring Boot와 .NET 기반으로 인증/인가, 비동기 작업 파이프라인, JS Interop 브릿지 등 서비스 안정성과 직결되는 백엔드 기능을 Clean Architecture 위에서 설계하고 구현해왔습니다. 대용량 파일 업로드 라이브러리 개발부터 Redis 기반 AI 비동기 파이프라인 구축, GPT 추천 엔진 설계까지 도메인 문제를 API와 내부 구조로 풀어내는 경험을 쌓았으며, 인프라까지 이해하는 백엔드 개발자로 성장하고자 합니다.
자가호스팅 환경에서 발생하는 **느린 파일 전송 속도**와 **AI 에이전트 통합 부재**라는 두 가지 문제를 해결하기 위해 개발한 Space 단위 격리형 클라우드 스토리지 서비스입니다.
Nextcloud, ownCloud 등 기존 자가호스팅 솔루션은 PHP + Apache 기반 구조의 한계로 대용량 파일 전송 시 체감 속도가 떨어지고, MCP·Claude 같은 모델 에이전트가 사용자 파일에 안전하게 접근할 수 있는 표준 통합 지점이 없었습니다. 이를 해결하기 위해 ASP.NET Core 10 기반 백엔드로 다음을 제공하는 협업 플랫폼을 설계했습니다.
- Space 단위 워크스페이스 격리 및 OWNER/ADMIN/MEMBER/VIEWER 4단계 RBAC
- tus 프로토콜 기반 재개 가능한 대용량 업로드와 finalize saga
- 도메인 이벤트 기반 SSE 실시간 알림 및 외부 Worker 작업 분기
- `cs_mcp_*` 토큰을 통한 AI 에이전트 표준 통합 지점
### 2. 담당 역할
백엔드 개발자로 참여하여 **인증/인가 흐름, 업로드 finalize 파이프라인, Outbox 기반 이벤트 fan-out, 인프라 구성**을 담당했습니다.
- ASP.NET Core 10 Minimal API 기반 `Core`/`Infrastructure`/`Api` 3계층 분리 설계
- Opaque Bearer 토큰 발급 시스템과 Space 단위 RBAC 권한 검증 필터 구현
- tus 외부 컨테이너(`tusd`) hook 연동과 finalize saga(storage move + DB 트랜잭션 + 보상) 구현
- 트랜잭셔널 Outbox 패턴 설계 및 SSE/Notification/Worker 3-target fan-out
- PostgreSQL 데이터 모델링(16개 테이블, 15개 enum)과 EF Core 마이그레이션 관리
- Docker Compose same-origin 인프라 구성과 GitLab CI/CD 파이프라인 작성
- Nextcloud 동종 벤치마크 도구(`CloudSharp.TransferBenchmark`) 개발
### 3. 주요 기여
**인증/인가 흐름 일원화**
토큰 prefix(`cs_st`, `cs_mcp`, `cs_dl`, `cs_sh`)로 토큰 종류를 분류하여 단일 `Authorization` 헤더로 사용자 세션·MCP·다운로드·공유링크를 모두 처리하도록 설계했습니다. Space 권한 검증을 `RequireSpacePermissionFilter` (`IEndpointFilter`)로 분리해 모든 Space-scoped 엔드포인트에 일관 적용했고, 매 요청마다 `SpaceMember`를 재조회하도록 하여 역할 변경이 다음 요청부터 즉시 반영되도록 했습니다.
**업로드 finalize 파이프라인 구현**
청크 I/O 부하를 `tusd` 외부 컨테이너로 분리하고 API는 `pre-create`/`post-finish` hook만 처리하도록 책임을 나눴습니다. `upload_sessions` 상태 머신 7종(`CREATED → UPLOADING → FINALIZING → COMPLETED/FAILED/ABORTED/EXPIRED`)을 설계하고 조건부 UPDATE로 finalize race를 DB 레벨에서 차단했습니다. 보상이 실패할 경우를 대비해 `UploadFinalizeRecoveryRunner` 워커가 5분 주기로 stuck 세션을 자동 정리합니다.
**Outbox 기반 이벤트 fan-out 설계**
같은 `DbContext``outbox_events` 행을 기록하는 `OutboxEventRecorder`로 도메인 변경과 이벤트 발행을 원자적으로 묶었습니다. `OutboxEventRouteRegistry`로 이벤트 타입과 다운스트림(`RealtimeFanout`, `NotificationProjection`, `Worker`)을 매핑하여 새 이벤트 추가 비용을 최소화했고, `FOR UPDATE SKIP LOCKED` 대신 partial index + 조건부 `ExecuteUpdate` 기반 낙관적 claim을 채택했습니다.
**인프라 자동화**
`nginx` 단일 컨테이너만 외부 노출하는 same-origin 토폴로지로 CORS·TLS 표면을 한 곳으로 모으고, `init-storage` one-shot 컨테이너로 `tusd``api`가 동일 UID/GID(10001)로 같은 bind mount를 공유하도록 처리하여 storage move를 in-place로 가능하게 했습니다.
### 4. 사용 기술 및 선택 이유
| 기술 | 사용 위치 | 선택 이유 |
|---|---|---|
| ASP.NET Core 10 (Minimal API) | HTTP 진입점, DI, 미들웨어 | Kestrel 런타임이 PHP-FPM 대비 응답성이 우수하고, 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 서버에서 분리하여 finalize 책임만 API에 남기기 위해 선택 |
| EF Core 9 | ORM, 마이그레이션 | partial index, alternate key, `IEntityTypeConfiguration` 기반 매핑 분리가 가능해 도메인-인프라 경계 유지 용이 |
| nginx | 단일 origin reverse proxy | 외부 노출 포트를 1개로 줄여 CORS·TLS 표면 최소화, `/files/` 경로에 streaming 옵션 별도 적용 |
| GitLab CI + GHCR + Portainer | 이미지 빌드/배포 자동화 | K8s 없이 webhook 기반 redeploy로 자가호스팅 인프라에 적합 |
### 5. 구현 사항
**Space 권한 검증 흐름 (인증 → 정책 → 필터 → UseCase 4단계)**
1. `CloudSharpSessionAuthenticationHandler``Bearer <token>` prefix로 토큰 종류 분류
2. SHA-256 해시로 Redis `auth:session:{tokenHash}` 조회 후 `sub`/`sid`/`system_role` 클레임 설정
3. `RequireDelegatedUserAccess()` 정책이 인증 실패 시 401 처리
4. `RequireSpacePermissionFilter``spaceSlug``SpaceMember`를 최신 상태로 로드해 권한 검증
5. UseCase에서도 `command.SpaceId` 일치를 재검증하여 방어 계층 추가
6. 민감 엔드포인트(Preview, File details)는 `MapForbidToNotFoundFilter`로 403을 404로 변환해 리소스 존재 누설 방지
**tus 업로드 finalize 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 이벤트 enqueue를 단일 트랜잭션으로 처리
5. DB 실패 시 `MoveFinalToTempAsync`로 storage 보상 후 `MarkSessionFailedAsync`로 예약 해제
6. 보상 실패 시 `FINALIZE_STORAGE_RESTORE_FAILED` 로그를 남기고 세션을 끊어 release
**Outbox fan-out 처리**
1. UseCase가 도메인 트랜잭션 내에서 `OutboxEventRecorder.AddBestEffortAsync`로 이벤트 기록
2. `OutboxEventProcessingService`가 5초 주기 폴링
3. `ReleaseExpiredLocksAsync`가 lease 만료 `PROCESSING` 행을 `FAILED`로 회수
4. partial index + 조건부 `ExecuteUpdate`로 낙관적 claim 수행
5. 라우트 레지스트리로 다운스트림 분기 후 각 dispatcher가 SSE push / `notifications` INSERT / Redis Publish 수행
6. 실패 시 `attempts++``available_at = failedAt + min(base * 2^(attempts-1), max)` 지수 백오프 재시도
### 6. 문제 해결 사례
**다중 Space 권한 staleness 문제**
- 문제 상황: 사용자가 여러 Space에 다른 역할로 속할 때 JWT의 클레임에 Space role을 박으면, 운영자가 역할을 변경해도 토큰 만료 전까지 옛 권한이 유지되는 staleness 윈도우가 발생함
- 원인 분석: stateless 토큰은 "토큰만으로 권한 표현"을 전제로 하지만, Space role은 per-space 분산 상태이므로 토큰에 담을수록 일관성 비용이 커짐. 자가호스팅 특성상 운영자의 role 변경 빈도가 높을 것으로 예상됨
- 해결 방법: JWT 대신 **Opaque Bearer 토큰**을 채택. 토큰 자체에는 의미를 담지 않고 SHA-256 해시만 Redis에 저장. Space role은 `RequireSpacePermissionFilter`가 매 요청 DB에서 재조회
- 선택 이유: 매 요청 DB 조회 비용은 단일 PK look-up 한 번이라 운영 가능 수준. 그 대가로 무효화 즉시 반영(`KeyDelete` 한 번으로 logout-all)과 staleness 윈도우 0을 얻음
- 결과: 역할 변경이 다음 요청부터 즉시 반영되어 운영자가 kick/role-change 후 대기 시간이 없음. 동일 토큰 분류 패턴을 MCP 토큰과 공유링크 토큰에도 재사용
**storage move와 DB 트랜잭션의 정합성 문제**
- 문제 상황: finalize는 (1) temp → final 이동 (2) `file_items` INSERT (3) 쿼터 갱신 (4) 예약 소모 (5) 세션 완료 5단계를 모두 처리해야 했고, 한 단계 실패 시 storage와 DB 상태 불일치 발생 가능
- 원인 분석: 파일 시스템과 DB는 같은 트랜잭션에 묶일 수 없음. 또한 finalize 도중 race 요청이나 클라이언트 cancel로 세션이 `FINALIZING`에 영구히 갇힐 위험
- 해결 방법: 분산 트랜잭션 매니저 대신 **saga + 보상 + 자율 복구 워커** 3단 구조 채택. 조건부 UPDATE로 race를 DB에서 차단하고, storage move → DB transaction 순서로 처리, DB 실패 시 storage 보상. 보상도 실패하는 극단 케이스를 위해 `UploadFinalizeRecoveryRunner`가 10분 이상 stuck 세션을 5분 주기로 자동 정리
- 선택 이유: 2PC는 운영 복잡도와 의존성을 크게 증가시키지만, saga는 실패 모드를 코드로 명시할 수 있어 디버깅과 운영 가시성이 좋음. 복구 워커가 self-healing을 담당하면 사람의 개입 없이 stuck이 정리됨
- 결과: finalize의 성공/실패/취소/race가 `upload_sessions.status` enum 7종 중 하나로 수렴. 운영 중 stuck 행이 누적되지 않고, 남은 고아 파일은 `file_purge_requests` ledger를 통해 `TrashAutoPurgeRunner`가 후속 청소
**도메인 이벤트의 다중 다운스트림 라우팅 문제**
- 문제 상황: `FileUploaded`는 SSE 실시간 알림, 알림함 행 추가, 외부 Worker 작업 발송 3가지를 모두 수행해야 했음. UseCase에서 직접 호출하면 외부 시스템 실패가 도메인 트랜잭션을 깨거나, commit된 후 알림 발송이 누락되는 정합성 문제 발생
- 원인 분석: 도메인 코드가 다운스트림을 직접 알면 강결합·라우팅 규칙 분산·트랜잭션 경계 모호 문제가 동시에 발생
- 해결 방법: **트랜잭셔널 Outbox + 라우팅 레지스트리**. `AddBestEffortAsync`가 같은 `DbContext`에 outbox 행을 INSERT해 enqueue를 도메인과 원자적으로 묶고, 라우팅 규칙은 `OutboxEventRouteRegistry` 한 곳에 모음. 다중 워커 race는 partial index 기반 낙관적 claim으로 흡수
- 선택 이유: `FOR UPDATE SKIP LOCKED` 대신 partial index를 택한 이유는 DB 잠금 의존성을 줄이고 폴링 비용을 낮추기 위함. `AddBestEffortAsync`가 throw하지 않도록 한 이유는 enqueue 실패가 도메인 트랜잭션을 깨는 것을 막기 위함이며, 대신 `EventId` UUID UNIQUE와 `notifications.outbox_event_id` partial unique로 at-most-once를 DB가 보장
- 결과: 새 도메인 이벤트 추가 시 상수 1개 + route 1줄로 확장 가능. 워커 1대가 멈춰도 다른 워커가 lease 만료 후 인계받아 단일 장애점 없음
### 7. 성과 및 결과
**성능 측정 결과** (`CloudSharp.TransferBenchmark`로 동일 호스트·동일 Docker·동일 PostgreSQL·동일 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-scoped 엔드포인트의 권한 검증 코드 중복 제거. 새 API 추가 시 필터 한 줄로 권한 적용
- 150여 개 `ErrorCode` 상수와 `ErrorResponse { RequestId, Error: { Code, Message, Details[] } }` 구조로 응답 형식 통일하여 프론트엔드가 `Code` 한 필드로 분기 가능
- `upload_sessions` 7종 상태 머신 + 조건부 UPDATE로 finalize race를 DB 레벨에서 차단
- partial unique index 7종(root 폴더 1개/space, 멤버 1명/space 등)으로 비즈니스 불변식을 DB가 강제
- GitLab CI → Portainer webhook 자동 redeploy로 신규 팀원도 `boot-prod.sh` 한 줄로 운영 환경 기동 가능
### 8. 회고
**배운 점**
파일 시스템과 DB가 한 트랜잭션에 묶이지 않는 상황에서 DB commit을 마지막에 두고 storage move를 먼저 한 뒤 실패 시 보상하는 saga 패턴이 분산 트랜잭션보다 운영하기 쉽다는 것을 체감했습니다. 또한 PostgreSQL의 partial index와 조건부 `ExecuteUpdate`로 비즈니스 불변식과 동시성 제어를 애플리케이션 코드 없이 DB 레벨에서 처리할 수 있다는 점이 인상 깊었습니다.
**아쉬웠던 점**
초기에는 단일 인스턴스를 전제로 SSE fan-out을 메모리 기반 `ISseConnectionStore`로 구현했습니다. 그 결과 다중 인스턴스로 확장하려면 sticky session 또는 Redis Pub/Sub로 전환해야 하는 마이그레이션 비용이 남았고, 현재는 ADR로만 로드맵을 남겨둔 상태입니다. Outbox `max_attempts` 초과 시 `DeadLetter` 자동 전이와 관리 API도 미구현으로 남아 있어 재시도가 누적되어 폴링에서 자연스럽게 제외되는 방식으로만 동작합니다.
**개선 방향**
다음 단계로 OpenTelemetry 기반 분산 트레이싱과 Prometheus 메트릭을 추가해 다중 인스턴스 운영 가시성을 확보하고, Redis Streams 기반으로 SSE 메시지 fan-out을 옮겨 수평 확장 가능하도록 개선할 계획입니다. 또한 145KB OpenAPI 문서를 Swashbuckle로 자동 생성으로 전환해 코드-문서 drift를 줄이고, `CloudSharp.TransferBenchmark`를 CI에 통합해 성능 회귀를 PR 단계에서 감지하도록 게이트화할 계획입니다.
---
## 핵심 강점
## 술통여지도 (Sulmap)
### 1. 두 가지 백엔드 스택을 Clean Architecture 위에서 설계
- Java/Spring Boot, C#/ASP.NET Core 모두 실무 수준으로 3계층(API / Core / Infrastructure) 구조 적용
- `Result<T>` Monad 기반 에러 전파, UseCase 중심 비즈니스 흐름 구성
- 의존성 역전을 통해 도메인이 HTTP·DB·파일시스템을 알지 못하도록 격리
### 1. 프로젝트 개요
### 2. 서비스 안정성과 직결되는 백엔드 기능 구현
- **대용량 업로드**: tus 클라이언트 라이브러리(NuGet 배포) + 서버 파이프라인 양방향 구현
- **인증/인가**: GitHub OAuth2, Opaque Session Token + Redis 세션, Space Role 기반 인가
- **동시성 제어**: CAS UPDATE, FOR UPDATE row-lock, Recovery Worker 기반 자동 복구
한국의 다회차 음주 문화(1차·2차·3차)에서 **다음 장소 선정의 어려움**을 해결하기 위해 개발한 AI 기반 술집 추천 플랫폼입니다.
### 3. 도메인 문제를 비동기 파이프라인으로 풀어낸 경험
- Redis List/Pub/Sub 기반 AI 작업 큐로 ML 추론과 API 응답성 분리
- GPT 2단계 Cascade Ranking으로 토큰 70% 절감, Defensive Normalization으로 Hallucination 차단
- Blazor JS Interop 브릿지로 .NET ↔ JavaScript 콜백 마샬링 최적화
사용자는 현재 위치·시간·날씨·그룹 구성·분위기 등 다양한 조건을 고려해야 하는데, 룰 기반 거리/평점 정렬로는 "비 오는 날 2차 가기 좋은 조용한 곳"처럼 주관적·맥락적 조건을 처리할 수 없었습니다. 이를 해결하기 위해 GPT-5.2의 맥락 추론을 활용해 사용자 컨텍스트(위치, 날씨, 시간, 성별, 나이, 자연어 요청)에 맞는 상위 10개를 추천 이유와 함께 제공하고, 추천 결과를 그대로 다회차 음주 플랜으로 저장·관리할 수 있는 지도 기반 풀스택 서비스를 개발했습니다.
### 4. 인프라까지 고려한 백엔드 설계
- Docker Compose 멀티 서비스 오케스트레이션, internal 네트워크 격리
- nginx Reverse Proxy + tus 헤더 포워딩
- GitLab CI 자동 빌드/배포 파이프라인 구성
### 2. 담당 역할
백엔드 개발자로 참여하여 Spring Boot 기반 API 서버와 AI 추천 파이프라인을 담당했습니다.
- ASP에서 Spring Boot 3계층 Clean Architecture(`api`/`core`/`infra`/`share`) 설계
- GPT-5.2 Structured Output 기반 2-Stage Cascade Ranking 추천 엔진 구현
- MyBatis 기반 16개 테이블 데이터 모델링과 도메인 쿼리 작성
- `Result<T>` Monad 패턴 기반 전역 에러 처리 체계 구축
- Spring Security 세션 인증과 CORS 설정 (Vue SPA 연동)
### 3. 주요 기여
**2-Stage Cascade Ranking 추천 엔진 구현**
200개 후보를 한 번에 GPT로 보내면 토큰 비용이 폭증하고 attention 품질이 저하되는 문제를 해결하기 위해 2단계 파이프라인을 설계했습니다. `GptMinorRecommendClient`가 1차로 100개씩 배치를 나눠 top5씩 필터링(총 ~10개)하고, `GptRecommendClient`가 2차로 최대 40개 후보를 정밀 랭킹하여 top10과 자연어 이유를 생성합니다.
**커스텀 Pipe-delimited 도메인 포맷 설계**
JSON 대신 `CTX|g=M|a=30|ts=...``B|id=123|c=주점|oi=...|n=...|menu=...` 형식의 도메인 특화 포맷을 설계했습니다. `GptBatchTextBuilder`가 모든 필드를 sanitize(파이프/개행 제거)하고 필드별 길이 truncate(프롬프트 180자, 영업정보 60자, 메뉴 60자 등)를 적용해 토큰 비용을 줄이고 파싱 안정성을 확보했습니다.
**Defensive Normalization으로 hallucination 방어**
GPT가 입력 B 라인에 없는 barId를 생성하거나 중복 ID를 반환하는 hallucination을 방어하기 위해 다층 검증을 구현했습니다. 정규식 `(?m)^B\|id=(\d+)\b` 로 허용 ID Set을 구성한 뒤 응답에서 허용되지 않은 ID 제거, `LinkedHashMap`으로 중복 제거, 부족 시 입력 순서(거리순)로 폴백 채움, reason은 개행/파이프 제거 후 45자 truncate를 적용했습니다.
**Result<T> Monad 기반 전역 에러 처리**
서비스 계층에서 예외를 던지는 대신 `Result.ok(value)` / `Result.fail(error)`로 성공·실패를 값으로 반환하도록 설계했습니다. `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError` 등 의미 있는 에러 타입에 HTTP Status를 매핑하고, Controller에서 `result.isFailure()`를 체크해 일관된 응답으로 변환합니다.
### 4. 사용 기술 및 선택 이유
| 기술 | 사용 위치 | 선택 이유 |
|---|---|---|
| Spring Boot 3.5.9 + Java 21 | API 서버 본체 | Bean Validation, Spring Security, DI가 잘 통합되어 있어 3계층 구조를 명확히 분리 가능 |
| GPT-5.2 + Structured Output | AI 추천 랭킹 | Java 클래스 기반 Response Schema로 JSON 형식을 강제할 수 있어 파싱 오류 방지 |
| MyBatis 3.0.5 | 도메인 쿼리 | 거리 기반 검색, 복합 조건 필터링처럼 SQL 직접 제어가 필요한 쿼리에 적합 |
| Elasticsearch 8.15 | 술집 키워드 검색 | 전문검색과 위치 기반 검색을 MySQL 보완용으로 활용 |
| Pipe-delimited Custom Format | GPT 통신 포맷 | JSON 대비 토큰 비용을 약 40% 절감하고, sanitize와 결합해 GPT 응답 안정성 확보 |
| Spring Security 세션 인증 | 인증/인가 | Vue SPA에서 JSESSIONID 쿠키 + CORS credentials로 안정적인 세션 유지 가능 |
### 5. 구현 사항
**AI 추천 요청 처리 흐름**
1. Controller: `@Valid`로 요청 DTO 검증 (위경도 범위, 거리 50~20000m, 프롬프트 300자 등 Bean Validation)
2. Security: `SessionCreationPolicy.IF_REQUIRED`로 JSESSIONID 기반 세션 인증
3. Service: 위경도 기준 주변 200개 술집을 거리순으로 조회
4. `GptBatchTextBuilder`: CTX 라인 1줄 + B 라인 다수를 sanitize + truncate하여 빌드
5. Stage 1 (`GptMinorRecommendClient`): 100개씩 배치로 분할하여 각 배치당 top5 선별
6. Stage 2 (`GptRecommendClient`): 선별된 후보를 정밀 랭킹하여 top10 + reasons 생성 (max output 800 토큰)
7. Defensive Normalization: 허용 ID 필터링 → 중복 제거 → 개수 강제 → reason sanitize → 부족 시 폴백
8. DTO 변환 후 응답
**Stage 1 시스템 프롬프트 핵심**
- "후보에 없는 술집을 만들거나 추측하지 마라" 명시
- 우선순위: CTX.q ↔ B.tg/c 일치도 → 영업중(o=1) → 날씨 나쁠수록 거리 짧을수록 우선 → 평점·리뷰 수
**Stage 2 시스템 프롬프트 핵심**
- "최종 선택은 CTX와 B 라인 정보가 우선이며 B와 모순되는 가정은 하지 마라"
- 우선순위: CTX.q ↔ c/oi/n 일치 → ts에 맞는 영업 가능 → 날씨 고려 → 다양성
**AI 실패 처리**
`try-catch(Exception)`로 전체 감싸 예외 시 입력 순서(거리순) 그대로 topK 반환하며, reason에 `"fallback:ai_fail"` 태그를 포함해 클라이언트가 fallback 여부를 구분할 수 있도록 처리.
### 6. 문제 해결 사례
**GPT Hallucination — 후보에 없는 술집 추천**
- 문제 상황: GPT-5.2가 입력 B 라인에 없는 barId를 생성하거나 학습 데이터에서 기억한 술집을 임의로 추천하는 hallucination 발생
- 원인 분석: LLM은 입력 프롬프트뿐 아니라 사전 학습 지식을 혼합해 응답하는 특성이 있고, Structured Output만으로는 "허용된 선택지만 사용"을 강제할 수 없음
- 해결 방법: 시스템 프롬프트에 "신규 barId 생성 금지" 명시 + 응답 후처리에서 입력 B 라인에서 추출한 허용 ID Set과 응답 ID를 대조해 허용되지 않은 ID 제거 + 부족 시 입력 순서로 폴백 채움
- 선택 이유: 프롬프트 튜닝만으로는 100% 보장되지 않으므로, 코드 레벨 검증을 병행하는 **Defensive Normalization** 접근을 채택
- 결과: 잘못된 barId가 최종 응답에 포함되지 않음. 사용자에게 유효하지 않은 장소가 노출되는 사고를 구조적으로 차단
**200개 후보 → 토큰 폭발 및 attention 품질 저하**
- 문제 상황: 반경 2km 내 200개 술집을 한 번에 GPT로 보내면 입력만 약 16,000 토큰이 발생하고, 긴 컨텍스트에서 attention 품질이 저하되어 추천 품질이 낮아짐
- 원인 분석: 토큰 비용은 후보 수에 선형 증가하고, GPT는 컨텍스트 길이가 길수록 모든 항목을 동등하게 처리하지 못함
- 해결 방법: **2-Stage Cascade Ranking** 도입. 1차에서 100개씩 배치로 빠른 필터링(ID만 출력), 2차에서 좁혀진 후보에 대해 정밀 랭킹과 reason 생성
- 선택 이유: 1차는 출력이 간단해 저비용으로 처리 가능하고, 2차에만 reason 생성 비용을 집중하여 비용·품질 트레이드오프를 최적화. 검색 시스템의 "Re-ranking" 패턴에서 영감
- 결과: 한 번에 전송 대비 토큰 비용 약 70% 절감. attention 품질 저하 없이 정확도 유지
**GPT 응답 직렬화 안정성 — Pipe 포맷 설계**
- 문제 상황: 술집 이름에 특수문자가 포함되거나 영업정보가 길고 비정형이어서 JSON으로 보낼 때 토큰 비용이 크고, GPT가 JSON에 마크다운 코드펜스를 추가하는 경향 때문에 파싱 오류가 빈번
- 원인 분석: JSON은 중괄호·따옴표·키 이름 등 구조 토큰을 포함해 토큰 비용이 큼. 또한 GPT는 자연어 모델이라 JSON 안정성이 일관적이지 않음
- 해결 방법: 커스텀 **Pipe-delimited 도메인 포맷**(`B|id=?|c=?|oi=?|n=?|menu=?`) 설계. 모든 필드를 `sanitize()`로 파이프/개행 제거 후 필드별 truncate. Stage Instructions과 System Instructions 양쪽에 "JSON only, 마크다운 금지" 명시. Structured Output으로 스키마 강제
- 선택 이유: 범용 JSON보다 도메인 특화 포맷이 토큰 효율과 파싱 안정성 모두 우수하다고 판단
- 결과: 프롬프트당 토큰 약 40% 절감. 파싱 오류율 감소. GPT 응답 형식이 엄격히 통제됨
**AI 장애 시 서비스 중단 위험**
- 문제 상황: GPT API가 네트워크 이슈·타임아웃·Rate Limit 등으로 실패할 경우 사용자에게 아무 추천도 제공하지 못하는 가능성
- 원인 분석: 외부 AI API는 제어 불가능한 외부 의존성. 100% 가용성은 보장 불가
- 해결 방법: Repository의 GPT 호출을 `try-catch(Exception)`로 감싸고, 실패 시 거리순 입력 그대로 topK 반환. reason에 `"fallback:ai_fail"`을 포함시켜 클라이언트가 구분 가능하도록 함
- 선택 이유: **Graceful Degradation** 전략. AI 장애 시 빈 화면 대신 거리 기반 기본 추천으로 서비스 사용성을 유지하고, 정직하게 fallback임을 노출
- 결과: AI 실패가 사용자 경험을 완전히 중단시키지 않음. 운영 안정성 확보
### 7. 성과 및 결과
- AI 파이프라인이 평균 200개 후보에서 컨텍스트 인지적 top10 추천을 생성하고 추천 이유를 자연어로 제공
- 커스텀 Pipe 포맷 도입으로 GPT API 호출당 토큰 약 40% 절감
- 2-Stage Cascade 구조로 단일 호출 대비 토큰 비용 약 70% 절감
- Defensive Normalization으로 hallucination 기반 잘못된 barId 응답 0건
- 16개 테이블, 11개 REST API 도메인 구현
- 12개 백엔드 유닛 테스트(Controller/Service/Repository 계층별) + Fixture Monkey 기반 랜덤 데이터 생성
- Result<T> Monad와 통일된 ErrorResponse 구조로 프론트엔드의 에러 처리 분기가 단일 `Code` 필드로 단순화됨
### 8. 회고
**배운 점**
Structured Output만으로는 LLM hallucination을 100% 방지할 수 없으며, 방어적 후처리(normalization)가 필수라는 점을 직접 확인했습니다. 또한 200개를 한 번에 처리하는 대신 2단계로 나누는 Cascade Ranking이 비용·품질 모두에서 더 나은 결과를 가져왔는데, 이는 검색 시스템의 ranking 패턴이 LLM 추천에도 그대로 적용된다는 점에서 의미가 있었습니다.
**아쉬웠던 점**
AI Client 계층(`GptRecommendClient`, `GptMinorRecommendClient`)에 대한 유닛 테스트가 없고, Mock AI 응답을 활용한 프롬프트 회귀 테스트도 구성하지 못했습니다. 프롬프트 변경 시 추천 품질이 회귀하는지 확인할 자동화 수단이 없는 상태입니다. 또한 토큰 사용량/비용을 기록하는 모니터링이 없어 실제 운영 비용을 정량 분석하기 어렵습니다. `.maxOutputTokens()` 호출이 주석 처리되어 있어 의도한 토큰 제한이 적용되지 않는 부분도 정리가 필요합니다.
**개선 방향**
다음 작업에서는 (1) Mock 기반 AI 응답 픽스처를 만들어 프롬프트 회귀 테스트를 도입하고, (2) GPT 호출에 토큰 카운팅과 비용 로깅을 추가하며, (3) Rate Limit 대응을 위한 재시도·타임아웃 정책을 명시적으로 구성하고, (4) 동일 컨텍스트에 대한 추천 결과를 일정 시간 캐싱하여 비용을 추가 절감하는 방향을 검토할 계획입니다.
---
## 기술 스킬
## TusBlazorClient
| 분류 | 기술 | 숙련도 | 활용 수준 |
|---|---|---:|---|
| Backend | C#, ASP.NET Core | ★★★★☆ | Minimal API 기반 REST API 설계, Opaque Session Token 인증, UseCase 중심 구조 구현, NuGet 라이브러리 배포 경험 |
| Backend | Java, Spring Boot | ★★★★☆ | 3계층 Clean Architecture 기반 API 설계, Spring Security + GitHub OAuth2, SSE 실시간 이벤트 시스템 구현 |
| Architecture | Clean Architecture | ★★★★☆ | API / Core / Infrastructure 계층 분리, 의존성 역전, Result\<T\> Monad 기반 에러 전파 파이프라인 |
| Architecture | Domain Modeling | ★★★★☆ | UploadSession·FileReservation 분리, Space 중심 멀티 테넌트 권한 모델, 상태 머신 기반 도메인 설계 |
| Database | PostgreSQL, EF Core | ★★★☆☆ | 12개 엔티티 ERD 설계, 마이그레이션 자동화, FOR UPDATE row-level lock, CAS 패턴 SQL 구현 |
| Database | MySQL, MyBatis / JPA | ★★★☆☆ | 16개 테이블 모델링, Flyway 마이그레이션 관리, 복잡 조인 쿼리 작성 |
| Upload / Storage | tus, tusd | ★★★★☆ | tusd hook 연동, Finalize 중복 방지 CAS Lock, Storage Sharding 65,536 버킷 설계, tus-js-client 래퍼 라이브러리 직접 구현 |
| Auth | OAuth2 / Session Token | ★★★★☆ | GitHub OAuth2 인증 흐름, Opaque Session Token + Redis 세션 + HMAC 해싱 + sliding renewal 정책 구현 |
| Infra | Docker, Docker Compose | ★★★★☆ | 5-서비스 오케스트레이션, internal 네트워크 격리, healthcheck 기반 depends_on, multi-stage 빌드 최적화 |
| Infra | nginx Reverse Proxy | ★★★☆☆ | tus 헤더 포워딩, 스트리밍 버퍼링 해제, 단일 진입점 라우팅 구성 |
| Cache / Async | Redis | ★★★☆☆ | 세션 저장소(Hash), Pub/Sub 기반 비동기 결과 전달, AI 작업 큐(List) 구현 |
| AI Integration | OpenAI GPT API | ★★★☆☆ | Structured Output 추천 엔진, 2단계 Cascade Ranking, Defensive Normalization, Pipe-delimited Format으로 토큰 40% 절감 |
| CI/CD | GitLab CI, GHCR | ★★★☆☆ | PR 단위 테스트 자동화, 변경 경로 기반 조건부 빌드, Docker 이미지 자동 푸시 |
| Frontend | Blazor WASM, Vue 3 | ★★☆☆☆ | Blazor JS Interop 브릿지 설계, Vue 3 Composition API + Pinia 기반 지도 UI 구현 |
### 1. 프로젝트 개요
Blazor WebAssembly 환경에서 대용량 파일 업로드를 안정적으로 처리하기 위한 **tus 프로토콜 기반 C# 래퍼 라이브러리**입니다.
Blazor WASM에서 순수 C# 코드로 대용량 파일을 전송하면 브라우저 메모리 제약과 느린 I/O로 인해 전송 실패·브라우저 멈춤이 발생하고, 네트워크 중단 시 처음부터 다시 업로드해야 하는 문제가 있었습니다. 또한 검증된 `tus-js-client`를 Blazor에서 직접 사용하려면 `IJSRuntime` 호출 코드를 반복적으로 작성해야 했습니다. 이 두 문제를 해결하기 위해 JS 라이브러리를 C# 타입 세이프 API로 감싸 Blazor 개발자가 JavaScript를 다루지 않고도 재개 가능한 업로드를 사용할 수 있도록 라이브러리를 설계·구현했습니다.
### 2. 담당 역할
라이브러리 전체 설계 및 구현 (1인 개발)
- Public API(`TusClient`, `TusUpload`, `TusOptions`) 설계
- JS ↔ .NET 콜백 브릿지(`TusJsInterop`, `TusOptionJsInvoke`) 구현
- DI 통합 구성 (`AddTusBlazorClient` 확장 메서드)
- Selenium 기반 E2E 테스트 환경 구성
### 3. 주요 기여
**JS Interop 복잡성을 감추는 계층 설계**
`TusClient`(팩토리, Singleton) → `TusUpload`(업로드 생명주기) → `TusOptions`(설정) 3계층으로 API를 나눠, 사용자가 DI 등록 한 줄과 직관적인 C# 코드만으로 업로드를 처리할 수 있도록 설계했습니다.
```csharp
builder.Services.AddTusBlazorClient();
var file = (await TusClient.GetFileInputElement(_fileElement).GetFiles()).First();
var upload = await TusClient.Upload(file, options);
await upload.Start();
```
**JS → .NET 콜백 브릿지 구현**
tus 이벤트(`OnProgress`, `OnError`, `OnSuccess`)는 JavaScript에서 발생하지만 사용자는 C# 델리게이트로 받아야 했습니다. `TusOptionJsInvoke``[JSInvokable]` 메서드를 정의하고 `DotNetObjectReference`로 JS에 전달하여, JS 이벤트가 발생할 때 .NET 델리게이트가 정확히 호출되도록 중계 구조를 구현했습니다. `TusOptionNullCheck`를 도입해 사용자가 등록하지 않은 콜백에 대해 JS 측에서 불필요한 interop 호출이 발생하지 않도록 최적화했습니다.
**안전한 인스턴스 생성 강제**
`TusUpload`가 외부에서 직접 생성될 경우 `DotNetObjectReference` 연결이 누락되어 콜백이 동작하지 않는 문제가 발생할 수 있었습니다. `TusUpload` 생성자를 **internal**로 제한하고 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻도록 강제하여 JS 콜백 브릿지가 항상 올바르게 연결되는 것을 보장했습니다.
**JS 모듈 Lazy 초기화**
`TusJsInterop`에서 JS ES 모듈을 Lazy 방식으로 관리하여 실제 업로드 시점에만 모듈을 로드하도록 처리했습니다. `TusClient`를 Singleton으로 등록해 모듈을 한 번만 로드하고 이후 모든 업로드가 공유합니다.
**콜백 직렬화 안전성 처리**
`TusOptions`의 콜백 프로퍼티는 `[JsonIgnore]`로 마킹하여 JSON 직렬화 대상에서 제외했습니다. .NET 델리게이트는 JS로 직접 전달할 수 없으므로, 직렬화 시 의미 없는 값이 전달되거나 오류가 발생하는 것을 방지하기 위한 명시적 설계입니다.
### 4. 사용 기술 및 선택 이유
| 기술 | 사용 위치 | 선택 이유 |
|---|---|---|
| tus-js-client | 청크 업로드 실 처리 | tus 프로토콜의 검증된 JS 구현체. 재개 가능한 업로드 로직을 직접 구현하지 않고 안정적으로 활용하기 위해 선택 |
| IJSRuntime / JS Interop | .NET ↔ JS 브릿지 | Blazor WASM에서 JS 라이브러리를 C#으로 연결하는 공식 메커니즘 |
| `DotNetObjectReference` + `[JSInvokable]` | JS → .NET 콜백 수신 | JS 이벤트를 C# 델리게이트로 안전하게 전달할 수 있는 표준 Blazor 패턴 |
| Selenium WebDriver | E2E 테스트 | JS Interop이 포함된 라이브러리는 실제 브라우저 환경에서만 의미 있는 검증이 가능하다고 판단 |
### 5. 구현 사항
**라이브러리 구조**
```
TusBlazorClient/
├── TusClient.cs # Public API 진입점, 업로드 팩토리 (Singleton)
├── TusUpload.cs # 단일 업로드 생명주기 (internal ctor)
├── TusOptions.cs # 18개 프로퍼티 설정 모델
├── TusJsInterop.cs # .NET ↔ JS 브릿지 (Lazy 초기화)
├── TusOptionJsInvoke.cs # JS → .NET 콜백 수신기 ([JSInvokable])
├── TusOptionNullCheck.cs # 불필요한 콜백 호출 방지
├── FileInputElement.cs # 파일 입력 요소 래퍼
├── TusError.cs # 오류 모델
├── TusHttpRequest.cs # HTTP 요청 DTO
├── TusHttpResponse.cs # HTTP 응답 DTO
└── TusPreviousUpload.cs # 재개 업로드 DTO
```
**업로드 기본 흐름**
1. DI 등록: `builder.Services.AddTusBlazorClient()`
2. `TusClient` 주입 후 `FileInputElement`로 파일 선택
3. `TusOptions` 구성(Endpoint, 콜백 등)
4. `TusClient.Upload(file, options)`으로 `TusUpload` 인스턴스 생성 (이 시점에 `DotNetObjectReference` 연결)
5. (선택) `FindPreviousUpload()` + `ResumeFromPreviousUpload()`로 이어 올리기
6. `TusUpload.Start()` 호출 → 내부적으로 JS Interop을 통해 `tus-js-client.start()` 실행
7. JS 이벤트 발생 시 `TusOptionJsInvoke``[JSInvokable]` 메서드가 호출되어 사용자 C# 델리게이트로 전달
### 6. 기술적 의사결정 및 회고
**인터페이스 미분리에 대한 판단**
- 문제 상황: `TusClient``TusJsInterop`에 별도 인터페이스를 추출할지 결정 필요
- 검토 내용: 라이브러리 규모가 작고, 테스트를 Mocking 기반 단위 테스트가 아닌 Selenium E2E로 커버하고 있어 인터페이스 분리의 실질 이득이 적었음. JS Interop은 본질적으로 실제 브라우저에서만 의미 있는 검증이 가능
- 선택한 방법: 인터페이스를 추출하지 않고 클래스 직접 의존
- 트레이드오프: 외부 사용자가 테스트 대역을 구성해야 하는 상황이 생기면 인터페이스 추출이 필요한 지점이 됨. 이는 회고에서 개선 후보로 남겨둠
**Fluent API 대신 명령형 API 채택**
- 문제 상황: 업로드 설정과 실행을 메서드 체이닝으로 연결할지, 옵션 객체 + 인스턴스 명령 방식으로 갈지 결정 필요
- 검토 내용: `FindPreviousUpload()`, `ResumeFromPreviousUpload()`, `Abort()` 등 업로드 생명주기 중간 단계에 개입해야 하는 시나리오가 많음. Fluent API는 일회성 빌드에 강하지만 중간 개입에는 코드가 부자연스러워짐
- 선택한 방법: 옵션 객체로 설정을 구성하고 `TusUpload` 인스턴스에 명령을 내리는 명령형 API
- 결과: 사용자가 업로드 인스턴스를 들고 있다가 임의 시점에 `Abort()`를 호출하거나 이전 업로드를 조회·재개하는 코드가 직관적으로 작성됨
### 7. 성과 및 결과
- Blazor WASM 사용자가 JavaScript 코드 작성 없이 DI 등록 한 줄과 C# API만으로 재개 가능한 대용량 업로드를 사용할 수 있는 라이브러리 완성
- `TusUpload` 생성자 internal 제한으로 콜백 브릿지 누락 가능성을 컴파일 타임에 차단
- JS 모듈 Lazy 초기화 + Singleton 구조로 모듈 로드 1회·재사용 패턴 달성
- `TusOptionNullCheck`로 등록되지 않은 콜백의 불필요한 JS interop 호출 제거
### 8. 회고
**배운 점**
JS Interop을 사용하는 라이브러리에서 가장 큰 위험은 "콜백이 연결되지 않은 인스턴스가 만들어지는 것"이라는 점을 체감했습니다. 생성자 접근 제어 같은 언어 수준 장치를 활용해 컴파일 타임에 잘못된 사용을 막는 것이 런타임 오류 메시지를 추가하는 것보다 훨씬 효과적이었습니다.
**아쉬웠던 점**
인터페이스를 추출하지 않아 외부 사용자가 단위 테스트에서 본 라이브러리를 mocking하기 어려운 상태입니다. 또한 E2E 테스트만 운영하고 있어 빠른 피드백 루프가 부족합니다.
**개선 방향**
다음 작업에서는 (1) `ITusClient`, `ITusUpload` 등 핵심 추상화를 인터페이스로 분리해 외부 사용자의 mocking을 지원하고, (2) JS Interop 호출을 추상화 계층 뒤에 두어 일부 로직만이라도 단위 테스트가 가능하도록 구조를 개선하며, (3) NuGet 배포 시 XML 문서 주석을 보강해 IntelliSense 가이드를 강화할 계획입니다.
---
## 프로젝트
## Didit
### 1. Cloud# (CloudSharp) — 셀프호스트 파일 서비스 플랫폼
### 1. 프로젝트 개요
> **Space 단위의 완전 격리형 저장 공간**을 제공하는 셀프호스트 파일 서비스로, tus 프로토콜 기반의 재개 가능한 대용량 업로드와 단명(短命) 다운로드 세션을 제공합니다.
GitHub 저장소를 사용하는 개발 팀에서 발생하는 **이슈 우선순위 수동 판단의 비효율**과 **화상회의·채팅·이슈 트래킹의 도구 분산**이라는 두 가지 문제를 해결하기 위해 개발한 팀 협업 플랫폼입니다.
#### 프로젝트 개요
기존 워크플로우는 GitHub Issues, 화상회의 도구, 채팅이 각각 분리되어 있어 컨텍스트 전환 비용이 크고, 이슈 우선순위는 사람이 수동으로 판단해야 했습니다. 이를 해결하기 위해 GitHub OAuth2 인증, OpenVidu 기반 WebRTC 화상회의, AI 이슈 우선순위 분석, SSE 실시간 이벤트를 하나의 Spring Boot 서버로 통합한 플랫폼을 설계·구현했습니다.
기존 클라우드 스토리지(Google Drive, Dropbox 등)는 개인 계정 중심으로 설계되어 팀/프로젝트 단위 협업에서 격리된 저장 공간, 대용량 중단 재개 업로드, 외부 공유 정책 분리 같은 요구를 충족하기 어려웠습니다. 이를 해결하기 위해 Space 단위 멀티 테넌트 모델 위에 tus 기반 업로드 파이프라인을 통합한 셀프호스트 파일 서비스를 설계하고 구현했습니다.
### 2. 담당 역할
#### 담당 역할
백엔드 개발자로 참여하여 다음을 담당했습니다.
**백엔드 + 인프라 + 설계 담당.**
- Spring Boot 4.0 기반 REST API 전체 설계 및 구현 (30개+ 엔드포인트)
- Redis 기반 AI 비동기 작업 큐 및 Pub/Sub 메시징 아키텍처 설계
- SSE 실시간 이벤트 시스템 구현
- Flyway 기반 14개 테이블 스키마 설계 및 12개 마이그레이션 관리
- Docker Compose 인프라 구성 및 GitLab CI/CD 파이프라인 구축
- 백엔드 API: 인증·인가, Space 관리, 파일/폴더 CRUD, 업로드/다운로드 파이프라인, Quota, 공유 링크
- 데이터베이스 설계: 12개 엔티티 ERD, EF Core Configuration, 마이그레이션 자동화
- 인프라 구성: Docker Compose 5-서비스 오케스트레이션, nginx Reverse Proxy
- CI/CD: GitLab CI 파이프라인 (test → build → image push to GHCR)
- 설계 문서화: API/ERD/Conventions/ADR 등 살아있는 문서 체계 구축
### 3. 주요 기여
#### 아키텍처 / 설계 방향
**모듈러 모놀리스 + Clean Architecture.** MVP 단계에서 마이크로서비스의 운영 복잡도를 피하면서도, 코드 레벨에서 도메인 경계를 엄격히 분리해 추후 분리 가능성을 확보했습니다. 의존성 방향은 `API → Core ← Infrastructure`로, 도메인이 HTTP나 DB, 파일시스템을 알지 못하게 했습니다.
```
Client → nginx :8080 (단일 외부 진입점)
├─► /api/* → ASP.NET Core API
└─► /files/* → tusd (Go 기반 청크 업로드)
↑ hook callback
API ──► PostgreSQL (메타데이터)
API ──► Redis (세션 + Pub/Sub)
API ──► Local FS (storage)
```
#### 주요 기여
##### 1) Finalize 중복 실행 방지: CAS Lock
tusd hook 콜백과 Recovery Worker 두 경로에서 같은 UploadSession이 동시에 처리되어 FileItem이 중복 생성될 위험이 있었습니다. Finalize는 파일 이동(rename)이라는 느린 I/O를 포함하므로 일반 트랜잭션만으로는 부족했습니다.
분산 락(Redis Redlock 등) 도입 대신 단일 SQL UPDATE로 원자적 점유를 구현했습니다.
```sql
UPDATE upload_session
SET status = 'FINALIZING',
finalize_attempts = finalize_attempts + 1
WHERE id = :session_id
AND status = 'UPLOADING';
-- affected_rows = 1 → 점유 성공 / 0 → 이미 처리 중 (즉시 무시)
```
파일 I/O와 DB 트랜잭션을 분리하여 무거운 작업은 트랜잭션 밖에서 처리하고, DB 변경(FileItem INSERT, Quota 갱신)만 짧은 트랜잭션으로 감쌌습니다. Recovery Worker(5분 주기)가 10분 이상 `FINALIZING`에 머문 세션을 자동 보정하도록 구현하여 교착 상태도 자동 복구되도록 했습니다.
##### 2) Space Quota 경쟁 조건 방지
여러 멤버가 동시에 대용량 업로드를 시작할 때 quota 판정과 reserved 증가 사이의 race condition을 방지하기 위해 `SELECT ... FOR UPDATE` row-level lock을 적용했습니다. 업로드 세션 생성은 빈번하지 않고 락 지속시간이 짧아 적합했으며, Finalize 직전에도 quota를 재검사하여 이중 안전장치를 구성했습니다.
##### 3) Opaque Session Token 인증
한 사용자가 여러 Space에서 서로 다른 Role을 가지며 Role 변경이 즉시 반영되어야 했습니다. JWT는 토큰 만료 전까지 stale 권한이 유지되는 문제가 있어, 권한 정보를 토큰에 담지 않는 Opaque Session Token 방식을 선택했습니다.
```
Authorization: Bearer cs_sess_{base64url(CSPRNG 32bytes)}
```
Redis에 `HMAC-SHA-256(token, secret)` 해시만 저장하여 원문을 노출하지 않으며, 매 요청마다 DB에서 최신 SpaceMember를 조회합니다. UseCase 단계에서 리소스의 `space_id`를 재검증하여 IDOR 공격도 방어했습니다. 결과적으로 **Role 변경이 다음 API 요청부터 즉시 반영**되며, 강제 Revoke를 Redis key 삭제 한 번으로 처리할 수 있습니다.
##### 4) UploadSession ↔ FileReservation 1:1 분리
업로드 도메인을 두 엔티티로 분리하여 책임을 명확히 했습니다.
- **UploadSession**: 전송 상태 추적 (네트워크 관점, 7-state machine)
- **FileReservation**: 비즈니스 자원 선점 — quota·파일명 (도메인 관점, 6-state machine)
변경 주기와 실패 원인이 다른 두 관점을 분리하면서도 1:1로 연결하여, 네트워크 실패와 비즈니스 실패 경로를 독립적으로 복구할 수 있도록 했습니다.
##### 5) 파일명 충돌: 명시적 실패 반환
Google Drive처럼 자동 rename(`파일명(1).pdf`) 대신 `FILE_NAME_CONFLICT` 에러를 반환하도록 설계했습니다. 활성 FileItem과 활성 FileReservation을 함께 검사하고, DB UNIQUE 제약(`(space_id, folder_id, normalized_name) on active rows`)으로 이중 보장합니다. "모호함보다 명시적 실패가 낫다"는 설계 철학을 반영한 결정이었습니다.
##### 6) 인프라 구성
| 서비스 | 외부 노출 | 역할 |
|--------|-----------|------|
| postgres | ❌ (`expose`만) | 메타데이터 |
| redis | ❌ | 세션 + Pub/Sub |
| tusd | ❌ | tus 청크 업로드 |
| api | ❌ | ASP.NET 백엔드 |
| **nginx** | ✅ `:8080` | **유일한 외부 진입점** |
- **Storage Sharding**: hex hash 기반 `256 × 256 × 256 = 65,536` 버킷 분산으로 디렉토리 과밀 방지
- **healthcheck + depends_on**: `condition: service_healthy`로 단순 실행 순서가 아닌 실제 준비 완료를 기다림
- **nginx tus 특수 설정**: `proxy_buffering off`, `client_max_body_size 0`, `proxy_read_timeout 600s`로 대용량 스트리밍 지원
#### 사용 기술
| 기술 | 선택 이유 |
|------|-----------|
| ASP.NET Core 10 / Minimal API | 빠른 부트스트랩, Controller 오버헤드 없음, 최신 런타임 |
| PostgreSQL 16 | FK·Unique·CHECK 제약이 풍부, JSON 확장성 |
| tusd (Go) | tus 표준 구현체, ASP.NET보다 청크 업로드에 효율적 |
| FluentResults | 예외 아닌 비즈니스 실패의 명시적 표현, Bind 파이프라인 |
| FluentValidation | `.WithErrorCode()`로 ErrorCode 표준화 |
#### 프로젝트 성과
- 12개 엔티티 ERD, 11개 EF Core Configuration 클래스
- 48개 기능 요구사항(SFR-001~048)을 OpenAPI + UseCase에 매핑
- UploadSession 7-state, FileReservation 6-state 상태 머신 설계
- 14개 컨벤션 문서, 3건의 ADR 작성
- PR 테스트 + master push 시 GHCR 이미지 빌드 자동화
#### 회고
- **잘한 점**: 초기부터 엄격한 문서화와 Clean Architecture 적용으로 기능 확장 시 도메인 경계가 무너지지 않았습니다. JWT 대신 Opaque Session Token 선택이 멀티 Role 모델에 정확히 부합했습니다. UploadSession과 FileReservation의 1:1 분리로 실패 복구 경로가 단순해졌습니다.
- **아쉬운 점**: API IntegrationTests와 Architecture Tests가 CI 파이프라인에 미포함된 점, 운영 모니터링(OpenTelemetry, Grafana)이 설계 단계에 머문 점이 아쉽습니다.
- **향후 계획**: MinIO/S3 Storage Provider 구현, Worker에 ffmpeg 썸네일 파이프라인 적용, IntegrationTest CI 포함 및 자동 배포 파이프라인 완성.
---
### 2. Didit — GitHub 통합 팀 협업 플랫폼
> **GitHub 저장소 기반 개발 워크플로우**를 OAuth2 인증, 화상회의, AI 이슈 분석, SSE 실시간 이벤트로 연결한 팀 협업 플랫폼입니다.
#### 프로젝트 개요
GitHub 저장소를 사용하는 개발 팀은 이슈 우선순위를 수동으로 판단하고, 화상회의·채팅·이슈 트래킹이 각각 다른 도구에 흩어져 있어 워크플로우 단절이 반복적으로 발생했습니다. 이를 해결하기 위해 **GitHub OAuth2 기반 인증, OpenVidu WebRTC 화상회의, AI 이슈 분석, SSE 실시간 이벤트**를 하나의 Spring Boot 서버로 통합한 팀 협업 플랫폼을 설계하고 구현했습니다.
#### 담당 역할
**백엔드 + 인프라 + AI 비동기 파이프라인 담당.**
- 백엔드 API: Spring Boot 4.0 기반 REST API 전체 설계 및 구현 (30개+ 엔드포인트)
- 비동기 파이프라인: Redis 기반 AI 작업 큐 및 Pub/Sub 메시징 아키텍처 설계
- 실시간 시스템: SSE(Server-Sent Events) 이벤트 허브 구현
- 데이터베이스 설계: Flyway 기반 스키마 설계 및 마이그레이션 관리
- 인프라 구성: Docker Compose 구성 및 GitLab CI/CD 파이프라인 구축
#### 아키텍처 / 설계 방향
**Spring Boot 기반 모듈러 서버 + Redis 비동기 파이프라인.** GitHub API 연동, AI 분석 요청, 실시간 이벤트 전달을 하나의 백엔드에서 처리하되, 장시간 ML 추론은 Redis List/Pub/Sub으로 분리했습니다. 서비스 계층은 `Result<T>` 패턴을 적용해 성공/실패 흐름을 값으로 통일하고, Flyway + JPA validate로 DB 스키마 정합성을 이중 확인했습니다.
#### 주요 기여
##### 1) ML 추론 부하를 API 응답성으로부터 완전히 분리
Hugging Face 모델 추론은 수 초~수십 초가 소요되어 REST API 요청-응답 사이클 안에서 처리할 수 없었습니다. Redis List를 작업 큐로, Redis Pub/Sub을 결과 전달 채널로 활용하는 비동기 파이프라인을 설계했습니다.
**ML 추론을 API 응답성으로부터 완전 분리**
HuggingFace 모델 추론은 수 초~수십 초가 소요되어 REST API 요청-응답 사이클 안에서 처리할 수 없었습니다. Redis List를 작업 큐로, Pub/Sub을 결과 전달 채널로 활용하는 비동기 파이프라인을 설계했습니다.
```
클라이언트 → POST /issue/analyze → Redis leftPush → 즉시 200 응답
AI Worker polling
AI Worker polling (0.5초)
Redis Pub/Sub 결과 발행
RedisMessageListener 수신 → DB 저장 → SSE 전송
```
RabbitMQ나 Kafka 같은 별도 인프라 없이 기존 Redis 하나로 큐와 메시징을 통합하여 운영 복잡도를 낮췄습니다.
RabbitMQ나 Kafka 같은 별도 인프라 없이 Redis 하나로 큐와 메시징을 통합하여 운영 복잡도를 낮췄습니다.
##### 2) AI 결과를 요청한 특정 사용자에게만 실시간 전달
**AI 결과의 사용자 단위 정밀 전달**
AI 분석 결과를 SSE로 브로드캐스트하면 같은 프로젝트의 다른 사용자에게 불필요한 이벤트가 전파되는 문제가 있었습니다. Redis에 `sse:client_key:{userId}` 형태로 clientKey를 저장하고, `SseHub.broadcastToClient(projectId, clientKey, ...)`로 요청한 사용자에게만 결과를 전달하도록 구현했습니다. 동일 유저의 다중 탭/디바이스를 clientKey로 구분하면서 stateless 서버 구조를 유지해 수평 확장에도 동일하게 동작합니다.
같은 프로젝트 모든 구독자에게 브로드캐스트하면 다른 사용자에게 불필요한 이벤트가 전파되는 문제가 있었습니다. Redis에 `sse:client_key:{userId}` 형태로 clientKey를 저장하고, AI 결과 수신 시 `SseHub.broadcastToClient(projectId, clientKey, ...)`를 호출하여 요청한 사용자에게만 결과를 전달하도록 구현했습니다. 동일 유저의 다중 탭/디바이스를 clientKey로 구분하면서도 무상태 서버 구조를 유지하여 수평 확장 시에도 동일하게 동작합니다.
**GitHub 이슈 Upsert 기반 양방향 동기화**
GitHub이 이슈의 Source of Truth이지만 AI 우선순위·로컬 메타데이터를 함께 관리해야 했습니다. `github_issue_id`를 natural key로 사용하는 Upsert 패턴을 구현해 기존 이슈는 title/body/status를 갱신하고 신규 이슈는 INSERT하도록 처리했고, 동기화 완료 후 모든 이슈에 AI 단일 분석 요청과 배치 정렬 큐를 자동 추가했습니다.
##### 3) GitHub 이슈와 로컬 DB의 Upsert 기반 양방향 동기화
**탈퇴 사용자 재참여 처리 (UK 제약 유지)**
`project_users``UNIQUE(project_id, user_id)` 제약 때문에 탈퇴 사용자가 초대 링크로 재참여할 때 INSERT가 UK 위반을 일으켰습니다. DB 제약을 제거하는 대신 기존 레코드 상태를 `LEFT → ACTIVE`로 복원하고 role을 `MEMBER`로 초기화·`leftAt`을 null로 복원하는 방식을 채택해 UK 제약을 유지하면서 참여 이력을 보존했습니다.
GitHub이 이슈의 Source of Truth이지만 AI 우선순위·담당자 매핑 등 로컬 메타데이터를 함께 관리해야 했습니다. `github_issue_id`를 natural key로 사용하는 Upsert 패턴을 구현하여, 기존 이슈는 title/body/status를 업데이트하고 신규 이슈는 INSERT하도록 처리했습니다. 동기화 완료 후 모든 이슈에 대해 AI 단일 분석 요청과 배치 정렬 큐를 자동으로 추가하여, GitHub에서 이슈를 가져오는 것만으로 AI 분석 파이프라인이 연계되도록 설계했습니다.
**Result 패턴 + Flyway validate로 정합성 이중 보장**
서비스 계층에서 예외를 던지는 대신 `Result<T>`로 성공/실패를 값으로 반환하도록 설계했습니다. `NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `GoneError(410)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하고, Controller에서 `result.isFailure()`로 일관 처리합니다. 또한 Flyway V1~V12로 스키마 변경을 관리하면서 JPA `ddl-auto: validate`를 함께 적용해 Entity와 DB 스키마 불일치를 애플리케이션 시작 시점에 감지하도록 처리했습니다. 운영 환경에서는 `clean-disabled: true`로 실수에 의한 전체 삭제도 방지했습니다.
##### 4) UK 제약을 유지하면서 탈퇴 사용자 재참여 처리
### 4. 사용 기술 및 선택 이유
`project_users` 테이블의 `UNIQUE(project_id, user_id)` 제약 때문에 탈퇴 사용자가 재참여할 때 UK 위반이 발생했습니다. DB 제약을 제거하는 대신 기존 레코드의 상태를 `LEFT → ACTIVE`로 복원하는 방식을 택했습니다. 이를 통해 UK 제약을 유지하면서도 사용자 참여 이력을 보존하여 감사 추적이 가능하도록 했습니다.
| 기술 | 사용 위치 | 선택 이유 |
|---|---|---|
| Java 21 + Spring Boot 4.0 | API 서버 본체 | DI·Security·Validation 통합 환경에서 30개+ 엔드포인트를 구조화하기 적합 |
| MySQL 8.0 | 도메인 데이터 영속화 | 사용자·프로젝트·이슈·회의처럼 관계가 명확한 데이터를 FK·UK 제약으로 DB 레벨에서 정합성 보장 |
| Redis 7.2 | AI 작업 큐(List) + 결과 메시징(Pub/Sub) + SSE clientKey 저장 | 단일 인프라로 큐·메시징·캐시를 통합해 Kafka/RabbitMQ 없이 운영 복잡도 절감 |
| GitHub OAuth2 | 인증 | 개발자 타겟 서비스에서 별도 회원가입 없이 repo·org 권한을 자연스럽게 연계 |
| SSE | 실시간 이벤트 전달 | 단방향 실시간 통신만 필요하고, 브라우저 네이티브 API로 별도 라이브러리 없이 연결 가능 |
| Flyway | DB 스키마 마이그레이션 | 팀원 간 동일한 DB 상태 보장, 버전 관리된 선언적 마이그레이션 |
| OpenVidu 2.32.1 | WebRTC SFU 미디어 서버 | 미디어 서버를 직접 구현하지 않고 검증된 오픈소스로 화상회의·녹화 기능을 빠르게 통합 |
| Docker Compose | MySQL·Redis·OpenVidu·Server·Client 5개 서비스 통합 실행 | 로컬과 배포 환경에서 동일한 토폴로지 |
| GitLab CI + Portainer Webhook | CI/CD | K8s 없이 코드 병합 → Docker 빌드 → Webhook 재배포로 운영 자동화 |
##### 5) Flyway + JPA validate로 스키마 정합성 이중 보장
### 5. 구현 사항
`V1__`부터 `V12__`까지 12개의 마이그레이션 파일로 스키마 변경을 관리하고, JPA `ddl-auto: validate`를 함께 적용하여 Entity-DB 스키마 불일치 시 즉시 오류가 발생하도록 했습니다. `clean-disabled: true`로 운영 데이터 실수 삭제도 방지했습니다.
**인증/인가 흐름**
##### 6) Result 패턴으로 예외 흐름을 값으로 통일
1. `GET /api/v1/auth/login` → GitHub OAuth2 리다이렉트
2. GitHub 인증 완료 → `CustomOAuth2UserService.joinOrUpdate()` → DB 저장/갱신
3. 세션 Cookie(JSESSIONID, HttpOnly + Secure + SameSite=None) 발급
4. 이후 요청: `@AuthenticationPrincipal CustomOAuth2User`로 userId와 GitHub accessToken 추출
5. Service: `FindProjectUser(userId, projectId)`로 멤버십 검증
6. OWNER 전용 작업: `project.getOwner().getId().equals(userId)` 추가 검증
서비스 계층에서 예외를 던지는 대신 자체 구현한 `Result<T>` 클래스로 성공/실패를 값으로 반환하도록 설계했습니다. `NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `GoneError(410)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하고, Controller에서 `result.isFailure()` 체크로 일관되게 변환했습니다.
**AI 분석 비동기 처리 흐름**
#### 사용 기술
1. Controller가 `/issue/analyze` 요청을 받고 분석 작업을 Redis `queue:issue:priority:single``leftPush`
2. 즉시 200 응답 반환 (사용자는 SSE 채널에 구독한 상태)
3. Python AI Worker가 0.5초 간격으로 큐 polling, 작업 소비
4. 추론 완료 시 Redis Pub/Sub 채널에 결과 발행
5. Spring 측 `RedisMessageListener`가 결과 수신 → 이슈 우선순위 DB 업데이트
6. `SseHub.broadcastToClient`로 요청 사용자에게만 SSE 이벤트 전달
| 기술 | 선택 이유 |
|---|---|
| Java 21 + Spring Boot 4.0 | 최신 LTS, DI·Security·Validation이 잘 통합된 환경 |
| MySQL 8.0 | 사용자·프로젝트·이슈 관계를 FK·UK 제약으로 정합성 보장 |
| Redis 7.2 | 작업 큐(List)와 메시징(Pub/Sub)을 하나의 인프라로 통합 |
| GitHub OAuth2 | 별도 회원가입 없이 repo·org 권한 자연스럽게 연계 |
| SSE | WebSocket보다 가벼운 단방향 실시간 통신, 브라우저 네이티브 지원 |
| Flyway | DB 스키마 변경 버전 관리 |
| OpenVidu 2.32.1 | WebRTC SFU 직접 구현 없이 화상회의·녹화 통합 |
**주요 도메인별 API**
#### 프로젝트 성과
| 도메인 | 엔드포인트 수 | 주요 기능 |
|---|---|---|
| 인증 | 3 | GitHub OAuth2 로그인/로그아웃, 현재 사용자 조회 |
| 프로젝트 | 12 | CRUD, 참여자 관리, 소유권 이전, GitHub 레포 연동 |
| 초대 | 3 | UUID 초대 코드 발급, 조회, 수락 |
| 회의/채널 | 12 | 회의 생성·예약·수정·삭제, WebRTC 연결, 녹화 관리 |
| 채팅 | 4 | 메시지 전송·조회·수정·삭제 (Soft Delete) |
| 이슈 | 7 | GitHub 이슈 동기화, AI 분석, CRUD |
| SSE | 2 | 채널/프로젝트 단위 실시간 이벤트 스트리밍 |
| 회의 요약 | 3 | AI 요약 조회·수정·삭제 |
- 30개+ REST API 엔드포인트, 8개 도메인 (인증/프로젝트/초대/회의/채팅/이슈/SSE/요약)
- 14개 주요 테이블 설계, GitHub Token 분리 보관
- MySQL·Redis·OpenVidu·Server·Client 5-서비스 Docker Compose 오케스트레이션
- GitLab CI + Portainer Webhook 기반 자동 배포 파이프라인 구축
**인프라 네트워크 격리**
#### 회고
`internal` 네트워크와 `caddy_default` 외부 네트워크를 분리해 MySQL·Redis·OpenVidu는 외부 노출 없이 `internal`에서만 통신하고, `server``client`만 외부 프록시로 노출했습니다.
- **잘한 점**: Kafka·RabbitMQ를 추가하지 않고 Redis로 큐와 Pub/Sub을 통합한 실용적 선택. 인프라 서비스를 늘리지 않고 운영 복잡도를 낮췄습니다.
- **아쉬운 점**: APM 도구가 구성되어 있지 않아 성능 병목을 수치로 확인하기 어려웠고, N+1 문제 가능성도 코드 분석으로만 파악한 상태입니다. Spring Boot Actuator + Prometheus + Grafana 도입과 `@EntityGraph`/JOIN FETCH 적용이 필요합니다.
### 6. 문제 해결 사례
**ML 추론 시간이 API 응답 시간을 잠식하는 문제**
- 문제 상황: HuggingFace 기반 이슈 우선순위 분석이 수 초~수십 초 소요되어 동기 처리 시 API 응답 시간이 사용 불가능한 수준
- 원인 분석: ML 추론과 HTTP 요청-응답 사이클은 본질적으로 SLA가 다름. 동기 호출 시 클라이언트 timeout과 서버 스레드 점유 문제가 동시에 발생
- 해결 방법: 작업을 Redis `leftPush`로 큐에 enqueue한 뒤 즉시 응답하고, Python Worker가 polling으로 소비, 결과는 Pub/Sub으로 발행하여 `RedisMessageListener`가 수신 후 SSE로 전달
- 선택 이유: 메시지 큐 도입을 위해 Kafka·RabbitMQ를 추가하는 대신, 이미 캐시·세션 용도로 사용 중인 Redis의 List와 Pub/Sub을 조합. 인프라 서비스 수를 늘리지 않고 운영 복잡도를 낮춤. 트래픽이 증가하면 전용 큐로 분리 가능한 구조
- 결과: AI 추론 시간이 API 응답성과 완전히 분리됨. 클라이언트는 즉시 200을 받고 결과는 SSE로 push 받음. 운영 인프라가 5개 서비스로 유지됨
**SSE 브로드캐스트의 불필요한 이벤트 전파**
- 문제 상황: AI 분석 결과를 같은 프로젝트의 모든 SSE 구독자에게 보내면, 분석을 요청하지 않은 다른 사용자에게도 이벤트가 전파되어 클라이언트 측 분기 처리 부담 증가
- 원인 분석: SSE 채널을 projectId 단위로 묶으면 다중 사용자 환경에서 필연적으로 불필요 이벤트가 발생. 단순히 같은 채널을 공유하면 서버는 누가 요청한 결과인지 알 수 없음
- 해결 방법: 요청 시점에 사용자의 clientKey를 Redis(`sse:client_key:{userId}`)에 저장하고, AI 결과 수신 후 `SseHub.broadcastToClient(projectId, clientKey, ...)`로 특정 사용자에게만 전달
- 선택 이유: clientKey 기반 라우팅은 동일 유저의 다중 탭/디바이스를 자연스럽게 분리할 수 있고, 키가 Redis에 있으므로 서버가 stateless를 유지해 수평 확장 시에도 동일하게 동작
- 결과: AI 결과가 정확히 요청한 사용자에게만 전달되어 클라이언트는 받은 이벤트를 그대로 표시 가능. 같은 프로젝트의 다른 멤버에게는 불필요한 이벤트가 가지 않음
**탈퇴 사용자 재참여 시 UK 위반 문제**
- 문제 상황: `project_users` 테이블의 `UNIQUE(project_id, user_id)` 제약 때문에 탈퇴(`LEFT`) 사용자가 초대 링크로 재참여할 때 INSERT가 UK 위반으로 실패
- 원인 분석: 두 가지 선택지가 있었음. (a) UK 제약을 제거하고 새 레코드를 매번 INSERT하여 이력 누적, (b) 기존 레코드를 복원. (a)는 정합성·중복 가입 방지 측면에서 약점
- 해결 방법: AddProjectUser 로직에서 기존 레코드를 먼저 조회. `ACTIVE` 상태면 `ConflictError` 반환, `LEFT` 상태면 role을 `MEMBER`로 초기화하고 `leftAt`을 null로 복원
- 선택 이유: UK 제약을 유지하면서 사용자 참여 이력은 한 레코드에 누적되도록 함. 감사 추적과 정합성을 동시에 만족
- 결과: 탈퇴 사용자가 재초대 링크로 정상 참여 가능. UK 제약 위반 사고 없음. project_users 테이블이 사용자당 1행으로 정규화 유지
### 7. 성과 및 결과
- 30개+ REST 엔드포인트와 14개 테이블 도메인을 Spring Boot 단일 서비스로 통합 구현
- Redis 단일 인프라로 큐·메시징·세션·캐시를 모두 처리하여 외부 인프라 서비스 수를 5개로 제한 (MySQL·Redis·OpenVidu·Server·Client)
- AI 추론을 비동기 큐로 분리하여 분석 요청 API의 응답 시간이 추론 시간과 무관해짐
- Flyway 12개 마이그레이션 + JPA validate 조합으로 스키마 정합성 이중 보장
- 자체 `Result<T>` 패턴과 5종 의미 있는 에러 타입 계층으로 Controller의 예외 처리 분기가 `result.isFailure()` 단일 분기로 단순화
- GitLab CI → Docker 이미지 빌드 → Portainer Webhook 재배포 파이프라인 자동화
### 8. 회고
**배운 점**
"인프라 서비스를 추가하면 그만큼 운영 복잡도가 늘어난다"는 점을 직접 체감했습니다. Redis라는 단일 인프라로 큐·Pub/Sub·세션·캐시를 모두 처리한 결정은 트래픽이 적은 시점에는 매우 합리적이었으며, 적절한 추상화(Listener, Hub) 위에 올려두면 추후 전용 큐로 분리할 때의 비용도 낮출 수 있다는 점을 배웠습니다.
**아쉬웠던 점**
API 응답 시간 측정이나 APM 도구가 구성되어 있지 않아 성능 병목을 수치로 확인하기 어렵습니다. JPA의 Lazy Loading으로 인한 N+1 가능성도 코드 분석으로만 인지한 상태이며, 실제 쿼리 카운트나 응답 시간을 모니터링하지 못했습니다. 또한 Redis가 단일 장애점이 될 수 있는데 이에 대한 가용성 대책(Redis Sentinel/Cluster, 영속화 설정 점검 등)이 미흡합니다.
**개선 방향**
다음 단계에서는 (1) Spring Boot Actuator + Prometheus + Grafana로 요청/응답 지표와 JPA 쿼리 수를 수집해 N+1을 식별 후 `@EntityGraph`·JOIN FETCH 적용, (2) Redis에 대한 가용성 보강과 작업 큐 모니터링(미처리 잔여량·소비 지연) 대시보드 구성, (3) 트래픽 증가 시 Pub/Sub에서 Redis Streams 또는 Kafka로의 이전 경로를 ADR로 정리해두는 작업을 진행할 계획입니다.
---
### 3. 술통여지도 (Sulmap) — AI 기반 술집 추천 플랫폼
## 보완 필요 사항 체크리스트
> **위치·날씨·시간·사용자 맥락**을 바탕으로 GPT 추천 엔진이 술집 Top 10과 추천 이유를 생성하는 지도형 풀스택 웹 애플리케이션입니다.
각 프로젝트에 대해 컨벤션 기준으로 점검한 결과 추가 보강이 필요한 부분입니다.
#### 프로젝트 개요
**공통**
- 정량 지표가 추가되면 좋은 항목: API 평균 응답 시간, p95 응답 시간, 쿼리 수, 동시 사용자 수, 처리량(RPS) — 현재는 측정 데이터가 없어 정성 성과 위주로 작성됨
- 시각화 권장: 인증/권한 흐름 다이어그램, AI 비동기 파이프라인 다이어그램, Outbox 라우팅 다이어그램, finalize saga 시퀀스 다이어그램
한국의 다회차 음주 문화(1차·2차·3차)에서 다음 장소 선정의 어려움을 해결하기 위해, GPT-5.2 기반 개인화 추천 엔진을 갖춘 지도형 풀스택 웹 애플리케이션을 구현했습니다. 사용자의 위치, 날씨, 시간대, 성별, 나이, 그룹 성격 등 컨텍스트 데이터를 종합하여 200개 후보 중 최적의 술집 Top 10을 자연어 이유와 함께 제공합니다.
**CloudSharp**
- TTFB·메모리 등 정량 데이터는 충분하나, 동시 업로드 처리량(예: N개 동시 업로드 시 throughput)이 추가되면 더 강해집니다.
#### 담당 역할
**술통여지도**
- "토큰 약 40% 절감", "약 70% 절감"은 측정 근거가 필요합니다. Pipe vs JSON 토큰 수 비교 실측 수치를 본문에 포함하면 신뢰도가 올라갑니다.
- 팀 구성과 본인 외 팀원의 역할 분담은 원본 문서에서 확인 불가하다고 명시되어 있어, 면접 전 본인 기여 범위를 명확히 정리할 필요가 있습니다.
**백엔드 + AI 추천 엔진 + 데이터 파이프라인 담당.**
**TusBlazorClient**
- 1인 라이브러리 특성상 "사용 사례·다운로드 수"나 "이를 사용한 다른 프로젝트의 업로드 성공률" 같은 외부 지표가 보강되면 좋습니다.
- 콜백 누락 방지 설계의 효과를 "internal 제한 도입 전후 발생했던 실수 사례"로 구체화하면 더 설득력 있습니다.
- 백엔드 API: Spring Boot 3계층 Clean Architecture 설계, MyBatis 기반 DB 모델링
- AI 연동: GPT API 추천 파이프라인, Structured Output, Defensive Normalization 구현
- 검색/추천: Elasticsearch 위치 기반 검색, 2단계 Cascade Ranking 설계
- 데이터 파이프라인: .NET 9 기반 Qdrant + OpenAI Embedding 활용 공공데이터 ETL 자동화
#### 아키텍처 / 설계 방향
**Spring Boot 3계층 구조 + AI 추천 파이프라인.** API/Service/Repository 계층을 분리하고, 추천 요청은 위치 기반 후보 검색 후 GPT 2단계 랭킹으로 정제했습니다. 외부 AI 응답은 신뢰하지 않고 허용 ID 검증, 중복 제거, reason 정제, 폴백 추천을 거치도록 설계해 AI 장애와 hallucination을 방어했습니다.
#### 주요 기여
##### 1) 2단계 Cascade Ranking 추천 엔진 설계
200개 후보 전체를 GPT에 한 번에 전달하면 토큰 비용이 폭증하고 attention 품질이 저하되는 문제가 있었습니다. 이를 해결하기 위해 2-Stage Cascade Architecture를 도입했습니다.
- **Stage 1 (GptMinorRecommendClient)**: 200개를 100개씩 배치로 나누어 각 배치에서 top5 선별 (간단한 출력: ID만)
- **Stage 2 (GptRecommendClient)**: 선별된 후보 + 보충 최대 40개를 정밀 랭킹 → top10 + reasons 생성
이 구조로 토큰 비용 약 70% 절감을 달성했으며, Google의 "Re-ranking with LLMs" 패턴을 참고했습니다.
##### 2) Defensive Normalization으로 GPT hallucination 차단
GPT-5.2가 가끔 B 라인에 없는 barId를 생성하거나 학습 데이터의 술집을 추천하는 hallucination이 발생했습니다. 시스템 프롬프트만으로는 100% 방어할 수 없어 코드 레벨 검증을 병행했습니다.
1. **시스템 프롬프트**: "후보에 없는 술집을 만들거나 추측하지 마라" + "신규 barId 생성 금지" 명시
2. **허용 ID set 검증**: 정규식 `(?m)^B\|id=(\d+)\b`로 입력 ID set 구성 → 허용되지 않은 ID 제거
3. **중복 제거**: `LinkedHashMap`으로 순서 유지하며 중복 제거
4. **reason 정제**: 개행/파이프 제거, 45자 truncate, 부족 시 폴백
결과적으로 잘못된 barId가 최종 결과에 포함되는 사고를 방지했습니다.
##### 3) Pipe-delimited Domain Format으로 토큰 40% 절감
JSON으로 후보 목록을 전달하면 토큰 비용이 크고, 술집 이름의 특수문자/개행이 파싱 오류를 유발했습니다. 도메인 특화 미니 포맷을 설계하여 이를 해결했습니다.
```
CTX|g=M|a=30|ts=2025-12-23T19:00+09:00|w=clear|md=2000|q=조용한 분위기의 이자카야
B|id=123|c=주점|oi=매일 18:00-02:00|n=포차|menu=닭발,오돌뼈
B|id=456|c=일식|oi=월-토 17:00-24:00|n=사케바|menu=사시미,사케
```
모든 필드를 `sanitize()`로 처리(파이프 → 공백, 개행 → 공백)하고 필드별 길이 truncate(이름 20자, 메뉴 60자, 사용자 프롬프트 180자 등)를 적용해 안정성을 확보했습니다. JSON 대비 약 40% 토큰 절감 효과를 얻었습니다.
##### 4) Graceful Degradation — AI 실패 시 폴백
GPT API 호출이 네트워크 이슈, 타임아웃, rate limit 등으로 실패할 경우를 대비해 Repository에서 `try-catch(Exception e)`로 감싸 실패 시 입력 순서(거리순)로 topK를 반환하도록 처리했습니다. reason에 `"fallback:ai_fail"` 태그를 포함시켜 클라이언트가 구분할 수 있도록 했습니다. AI 장애 시에도 빈 화면이 아닌 기본 추천이 표시되어 서비스 사용성을 유지했습니다.
##### 5) Result<T> Monad 기반 전역 에러 처리
모든 서비스 계층에서 성공/실패를 타입으로 표현하여 예외 처리 누락을 방지했습니다. `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`, `SimpleError`가 각각 HTTP Status를 가지며, Controller에서 `result.isFailure()` 체크로 적절한 HTTP 상태로 응답합니다.
#### 사용 기술
| 기술 | 선택 이유 |
|---|---|
| GPT-5.2 + Structured Output | JSON Schema 기반 출력으로 파싱 오류 방지 |
| Spring Boot 3.5.9 + MyBatis | Java 안정성 + SQL 직접 제어로 복잡한 도메인 쿼리 |
| Elasticsearch 8.15 | 전문검색 + 위치기반 검색, MySQL 보완 |
| Qdrant + text-embedding-3-small | 데이터 파이프라인에서 공공데이터 ↔ 술집 벡터 유사도 매칭 |
#### 프로젝트 성과
- 평균 200개 후보에서 10개의 컨텍스트 인지적 추천 결과 생성
- GPT API 호출당 토큰 약 70% 절감 (Cascade Ranking + 40% Pipe Format)
- Defensive Normalization으로 잘못된 barId 노출 0건 달성
- 16개 테이블, 11개 REST API 엔드포인트 구현
- 12개 백엔드 유닛 테스트 + Vitest/Playwright 프론트엔드 테스트
#### 회고
- **구조적 안전장치의 중요성**: GPT Structured Output만으로 hallucination을 100% 방지할 수 없으며, 방어적 후처리가 필수임을 배웠습니다.
- **도메인 특화 포맷의 가치**: 범용 JSON보다 커스텀 pipe-delimited 포맷이 토큰 효율과 파싱 안정성 모두에서 우수했습니다.
- **개선이 필요한 지점**: AI 응답 회귀 테스트, 비용 모니터링, rate limit 대응이 미구현 상태로 향후 과제입니다.
---
### 4. TusBlazorClient — Blazor WASM용 tus 프로토콜 클라이언트 라이브러리
> **tus-js-client를 C# API로 감싼 Blazor WASM 라이브러리**로, JavaScript를 직접 다루지 않고 재개 가능한 대용량 업로드를 사용할 수 있게 합니다.
#### 프로젝트 개요
Blazor WebAssembly에서 순수 C# 코드로 대용량 파일을 전송할 경우, 브라우저 메모리 제약과 느린 I/O로 인해 전송 실패 또는 멈춤 현상이 발생하고, 네트워크 중단 시 처음부터 다시 업로드해야 하는 문제가 있었습니다. JavaScript의 `tus-js-client`를 C# API로 감싸, Blazor 개발자가 JavaScript를 직접 다루지 않고도 재개 가능한 대용량 파일 업로드를 사용할 수 있도록 설계하고 구현했습니다.
#### 담당 역할
**라이브러리 설계 + JS Interop 브릿지 + NuGet 배포 담당.**
- Public API 설계: `TusClient`, `TusUpload`, `TusOptions` 중심의 C# 업로드 API 구성
- JS Interop: tus-js-client 이벤트 콜백을 .NET 델리게이트로 전달하는 브릿지 구현
- DI 통합: Blazor WASM에서 `AddTusBlazorClient()` 한 줄로 등록 가능한 구조 설계
- 배포: NuGet 패키지 배포 가능한 라이브러리 형태로 구성
#### 아키텍처 / 설계 방향
**C# Facade + JS Interop Adapter.** tus 프로토콜 구현은 검증된 `tus-js-client`에 맡기고, Blazor 사용자는 C# 타입과 DI를 통해 업로드 생명주기를 제어하도록 추상화했습니다. JS 모듈은 Lazy 초기화하고, 업로드 인스턴스 생성 경로를 `TusClient.Upload()`로 제한해 콜백 브릿지 누락을 방지했습니다.
#### 주요 기여
##### 1) C# 네이티브 API로 tus 프로토콜 추상화
`IJSRuntime` 호출을 직접 작성해야 하는 복잡성을 감추고 타입 세이프 API를 제공하기 위해 `TusClient``TusUpload``TusOptions` 구조로 계층을 나눠 설계했습니다.
```csharp
// Program.cs
builder.Services.AddTusBlazorClient();
// Component
var file = (await TusClient.GetFileInputElement(_fileElement).GetFiles()).First();
var upload = await TusClient.Upload(file, options);
await upload.Start();
```
DI 등록 한 줄과 직관적인 C# 코드만으로 업로드를 처리할 수 있습니다.
##### 2) JS → .NET 콜백 브릿지 설계
`OnProgress`, `OnError`, `OnSuccess` 등 tus의 이벤트 콜백을 C# 델리게이트로 받아야 했습니다. `TusOptionJsInvoke` 클래스에 `[JSInvokable]` 메서드를 정의하고 `DotNetObjectReference`로 JS에 전달하여, JS 이벤트 발생 시 .NET 델리게이트가 정확히 호출되도록 중계 구조를 구현했습니다. `TusOptionNullCheck`를 도입하여 사용자가 등록하지 않은 콜백에 대해 JS 측에서 불필요한 interop 호출이 발생하지 않도록 최적화했습니다.
##### 3) TusUpload 생성자를 internal로 제한하여 안전한 인스턴스 생성 강제
`TusUpload`가 외부에서 직접 생성될 경우 `DotNetObjectReference` 연결이 누락되어 콜백이 동작하지 않는 문제가 있었습니다. 생성자를 **internal**로 제한하고 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻도록 강제하여, JS 콜백 브릿지가 항상 올바르게 연결되도록 보장했습니다.
##### 4) JS 모듈 Lazy 초기화로 불필요한 로드 방지
`TusJsInterop`에서 JS ES 모듈을 Lazy 초기화 방식으로 관리하여, 실제로 업로드가 필요한 시점에만 JS 모듈을 로드하도록 처리했습니다. `TusClient`는 Singleton으로 등록되어 모듈을 한 번만 로드하고 이후 모든 업로드가 공유합니다.
#### 사용 기술
| 기술 | 선택 이유 |
|------|-----------|
| tus-js-client | tus 프로토콜의 검증된 JS 구현체로, 재개 가능한 업로드 로직을 직접 구현하지 않고 안정적으로 활용 |
| IJSRuntime / JS Interop | Blazor WASM에서 JS 라이브러리를 C#으로 연결하는 공식 메커니즘 |
#### 프로젝트 성과
- Blazor WASM에서 C# API만으로 tus 기반 재개 가능 업로드를 사용할 수 있는 라이브러리 구현
- `AddTusBlazorClient()` 기반 DI 통합과 NuGet 배포 가능한 패키지 구조 구성
- JS 이벤트 콜백을 .NET 델리게이트로 전달하는 Interop 브릿지 구현
- `TusUpload` 생성 경로 제한으로 콜백 연결 누락 가능성 제거
#### 회고
- **인터페이스 미분리에 대한 판단**: 라이브러리 규모가 작고 Selenium E2E 테스트로 커버하고 있어 인터페이스 분리의 실질적 이득이 크지 않다고 판단했습니다. 외부 사용자가 테스트 대역을 구성해야 하는 상황이 생기면 인터페이스 추출이 필요할 것입니다.
- **명령형 API 채택**: Fluent API 대신 명령형 API를 채택했는데, `FindPreviousUpload()`, `ResumeFromPreviousUpload()`, `Abort()` 등 업로드 생명주기 중간 단계에 개입해야 하는 시나리오에서 더 직관적이라고 판단했습니다.
- **콜백 JSON 직렬화 제외**: `TusOptions`의 콜백 프로퍼티는 `[JsonIgnore]`로 마킹하여 직렬화 시 오류를 방지했습니다.
---
## 종합 회고
네 프로젝트를 관통하는 공통된 기술적 관점은 다음과 같습니다.
1. **계층 분리와 의존성 역전**: Cloud#의 Clean Architecture, 술통여지도의 3계층 구조, Didit의 Service-Repository 분리 모두 도메인이 인프라를 알지 못하도록 설계했습니다.
2. **`Result<T>` Monad 기반 에러 처리**: 예외를 던지는 대신 성공/실패를 값으로 표현하여 에러 흐름을 명시적으로 관리했습니다. 이는 네 프로젝트 중 세 곳(Cloud#, 술통여지도, Didit)에서 일관되게 적용한 패턴입니다.
3. **방어적 설계와 자동 복구**: CAS Lock + Recovery Worker, Defensive Normalization, Graceful Degradation 등 외부 의존성(파일 I/O, AI API)이 실패해도 서비스가 안전하게 동작하도록 다층 방어 체계를 구축했습니다.
4. **인프라까지 고려한 백엔드**: Docker Compose 멀티 서비스 오케스트레이션, nginx 헤더 포워딩, GitLab CI/CD 자동화 등 코드 외 영역까지 책임을 가지고 설계했습니다.
앞으로는 운영 모니터링(OpenTelemetry, Prometheus, Grafana)과 통합 테스트 자동화 영역을 더 깊이 학습하여, 설계뿐 아니라 운영 단계에서도 안정성을 책임지는 백엔드 개발자로 성장하고자 합니다.
**Didit**
- AI 비동기 큐 도입 전후의 API 응답 시간 비교 수치가 없어 효과가 정성적으로만 기술되어 있습니다. "동기 처리 시 약 X초 → 비동기 분리 후 즉시 응답" 형태로 보강 권장합니다.
- 30개+ 엔드포인트 전체 인증/인가 흐름이 동일한 구조인지, 예외 케이스가 있는지 면접에서 답할 수 있도록 정리 필요합니다.

View File

@@ -149,6 +149,7 @@
- Cache 적용 여부
- 응답 시간 측정 또는 개선 수치
- 성능상 아쉬운 부분
- 벤치마킹 결과
## 15. 설계 문서화
- README 구조
@@ -180,22 +181,7 @@
### 결과
성능, 안정성, 유지보수성, 확장성, 운영 편의성 측면에서 어떤 개선이 있었는지
## 17. 포트폴리오 문장 초안
위 분석을 바탕으로 아래 항목별로 바로 사용할 수 있는 문장 초안을 작성해줘.
- 프로젝트 개요
- 담당 역할
- 주요 기여
- 사용 기술 및 선택 이유
- 아키텍처 설계
- API 설계
- 인프라 구성
- 문제 해결 사례
- 프로젝트 성과
- 회고
주의:
- 실제 코드에서 확인되는 내용만 작성해줘.
- 확인되지 않는 내용은 “확인 필요”로 표시해줘.
- 단순 기능 나열이 아니라 “문제 → 설계 선택 → 구현 방식 → 결과” 구조로 작성해줘.
- 백엔드 개발자 관점에서 API, 인증/인가, DB, 트랜잭션, 예외 처리, Docker, Reverse Proxy, 환경변수, 로그, 배포 구조를 강조해줘.

View File

@@ -0,0 +1,316 @@
# 백엔드 포트폴리오 자기소개/기술스킬 작성 컨텍스트
## 목적
백엔드 개발자 포트폴리오의 `슬로건`, `한 줄 소개`, `자기소개`, `기술스킬` 항목을 구체적이고 평가자 친화적으로 작성한다.
추상적 태도보다 실제 구현 경험, 백엔드 직무 연관성, 기술 활용 수준을 드러내는 것이 핵심이다.
---
## 작성 원칙
### 1. 추상 표현 금지
다음 표현은 단독 사용 금지:
- 끊임없이 학습하는 개발자
- 도전하는 개발자
- 성장하는 개발자
- 소통을 잘하는 개발자
- 새로운 기술을 좋아합니다
- 다양한 기술을 공부하고 있습니다
사용하려면 반드시 구체적 경험과 연결한다.
예:
- 나쁨: 새로운 기술 학습을 좋아합니다.
- 좋음: 파일 업로드와 실시간 알림 요구사항을 해결하기 위해 tus, SSE, Redis Pub/Sub 구조를 검토하고 적용했습니다.
---
## 1. 슬로건 작성 규칙
### 목적
지원자를 한 문장으로 기억시키는 문장.
### 포함 요소
- 백엔드 직무 강점
- 문제 해결 방식
- 주요 기술 영역
- 프로젝트 경험 기반 특징
### 작성 공식
```text
[구체적 강점]을 바탕으로 [백엔드에서 기여하는 방향]을 만드는 개발자
````
또는
```text
[기술/도메인 경험]을 통해 [추구 가치]를 구현하는 백엔드 개발자
```
### 좋은 예시
```text
복잡한 요청 흐름을 안정적인 API와 데이터 구조로 풀어내는 백엔드 개발자
```
```text
파일 처리와 실시간 알림 흐름을 안정적으로 설계하며, 복잡한 백엔드 요구사항을 구조화하는 개발자
```
```text
권한, 파일, 알림처럼 상태 변화가 많은 도메인을 유지보수 가능한 서버 구조로 구현하는 백엔드 개발자
```
---
## 2. 한 줄 소개 작성 규칙
### 목적
현재 집중 기술과 앞으로의 성장 방향을 짧게 설명한다.
### 포함 요소
* 현재 집중 중인 기술
* 실제 구현 경험
* 백엔드 개발자로서의 지향점
### 작성 공식
```text
현재는 [주요 기술/도메인]을 중심으로 [구현 경험]을 쌓고 있으며, 향후에는 [지향점]을 갖춘 백엔드 개발자로 성장하고자 합니다.
```
### 좋은 예시
```text
ASP.NET Core와 PostgreSQL을 기반으로 파일 처리, 권한 관리, 실시간 알림 기능을 구현하며 안정적인 서버 구조를 설계하는 백엔드 개발자를 지향합니다.
```
```text
파일 업로드, 권한 관리, 알림 기능처럼 상태 변화가 많은 기능을 구현하며 API 설계와 데이터 모델링의 중요성을 경험했고, 유지보수성과 확장성을 고려하는 백엔드 개발자를 지향합니다.
```
---
## 3. 자기소개 작성 규칙
### 목적
개발자로서의 현재 역량, 프로젝트 경험, 성장 방향을 2~3문단으로 설명한다.
### 구성
1. 내가 지향하는 백엔드 개발자상
2. 실제 프로젝트 경험과 기술적 역할
3. 앞으로의 성장 방향
### 작성 공식
```text
저는 [백엔드 개발자로서의 강점/지향점]을 중요하게 생각합니다.
[프로젝트/기능 경험]을 구현하며 [배운 점/기술 역량]을 경험했습니다. 특히 [주요 기술]을 활용해 [구체적 구현 내용]에 집중했습니다.
앞으로는 [단순 기능 구현을 넘어 추구하는 방향]을 고려하는 백엔드 개발자로 성장하고자 합니다.
```
### 예시
```text
저는 사용자 요청 흐름과 데이터 처리 과정을 명확하게 설계하는 백엔드 개발자를 지향합니다.
파일 업로드, 권한 관리, 알림 기능처럼 상태 변화가 많은 기능을 구현하며 API 설계와 데이터 모델링의 중요성을 경험했습니다. 특히 ASP.NET Core와 PostgreSQL을 중심으로 서버 기능을 구현하고, EF Core를 활용해 도메인에 맞는 데이터 구조를 설계하는 데 집중했습니다.
앞으로는 단순히 기능을 구현하는 개발자를 넘어, 서비스의 안정성, 확장성, 유지보수성을 함께 고려할 수 있는 백엔드 개발자로 성장하고자 합니다.
```
---
## 4. 기술스킬 작성 규칙
### 목적
기술 이름 나열이 아니라, 기술을 어느 수준으로 어떤 기능에 활용했는지 보여준다.
### 필수 요소
* 카테고리 구분
* 주요 기술과 보조 기술 구분
* 숙련도 기준 명시
* 프로젝트 활용 경험 작성
### 권장 카테고리
```text
Backend
Database
ORM
Realtime / Async
File / Storage
Infra / DevOps
Tools
```
### 기술스킬 작성 형식
```md
| Category | Skill | Level | 활용 경험 |
|---|---:|---:|---|
| Backend | ASP.NET Core | 3 | REST API, 인증/인가, DI, Middleware 기반 서버 기능 구현 |
| Database | PostgreSQL | 3 | 관계형 모델링, 인덱스 고려, 조회 쿼리 작성 |
| ORM | EF Core | 3 | 엔티티 매핑, Repository, 트랜잭션 처리 |
| Realtime | SSE | 2 | 서버 이벤트 기반 실시간 알림 구현 |
| Infra | Redis | 2 | Pub/Sub 기반 메시지 전달 구조 활용 |
| Storage | tus | 2 | 재개 가능한 파일 업로드 흐름 구현 |
| DevOps | Docker | 2 | 개발 환경 및 의존 서비스 구성 |
```
---
## 5. 숙련도 기준
### Level 1: 사용 경험 있음
공식 문서나 예제를 참고해 기본 사용 가능.
### Level 2: 기능 구현 가능
CRUD, API 연동, 기본 설정 등 일반 요구사항 구현 가능.
### Level 3: 프로젝트 적용 가능
서비스 요구사항에 맞게 구조를 설계하고 주요 기능에 적용 가능.
### Level 4: 문제 해결 가능
성능 문제, 장애 원인, 구조 개선, 리팩터링까지 가능.
주의:
* 모든 기술을 상/중/하 또는 별점으로만 표시하지 않는다.
* Level은 반드시 활용 경험과 함께 작성한다.
---
## 6. 주요 기술 / 서브 기술 구분 기준
### Main Skills
다음 중 하나 이상 해당:
* 프로젝트 핵심 기능 구현에 사용
* 직접 구조 설계
* 문제 해결 과정에서 깊게 사용
* 면접에서 설명 가능
### Sub Skills
다음에 해당:
* 프로젝트에서 사용했지만 깊이는 낮음
* 설정 중심 사용
* 핵심 기능 보조
* 기본 개념은 알지만 고급 활용 경험은 부족
### 예시
```md
### Main Skills
- C#
- ASP.NET Core
- PostgreSQL
- EF Core
- REST API
- Authentication / Authorization
### Sub Skills
- Redis
- SSE
- Docker
- Nginx
- tus
```
---
## 7. 기술 서술 예시
### Backend
```text
C#과 ASP.NET Core를 사용해 REST API 서버를 구현했습니다. 인증/인가, 파일 업로드, 알림, 권한 관리 기능을 중심으로 API 흐름을 설계했으며, DI, Middleware, Filter를 활용해 공통 관심사를 분리했습니다.
```
### Database
```text
PostgreSQL과 EF Core를 사용해 서비스 도메인에 맞는 데이터 모델을 설계했습니다. 파일, 사용자, 권한, 알림과 같이 관계가 있는 데이터를 엔티티로 구성하고, 조회 성능을 고려해 쿼리 구조와 인덱스를 검토했습니다.
```
### Realtime / Async
```text
SSE와 Redis Pub/Sub을 활용해 서버 이벤트를 클라이언트에 전달하는 구조를 구현했습니다. 상태 변경 이벤트를 실시간으로 전달하고, 비동기 작업 흐름을 분리하는 방식에 대해 학습하고 적용했습니다.
```
---
## 8. 최종 출력 형식
LLM은 사용자의 프로젝트/기술 정보를 바탕으로 아래 형식으로 출력한다.
```md
# 자기소개 / 기술스킬
## 슬로건
[한 문장 슬로건]
## 한 줄 소개
[현재 집중 기술 + 구현 경험 + 지향점]
## 자기소개
[2~3문단 자기소개]
## Skills
### Main Skills
| Category | Skill | Level | 활용 경험 |
|---|---:|---:|---|
| ... | ... | ... | ... |
### Sub Skills
| Category | Skill | Level | 활용 경험 |
|---|---:|---:|---|
| ... | ... | ... | ... |
```
---
## 9. 검토 체크리스트
### 슬로건
* 백엔드 직무와 연결되는가?
* 실제 프로젝트 경험과 관련 있는가?
* 흔한 추상 표현만 사용하지 않았는가?
### 한 줄 소개
* 현재 집중 기술이 보이는가?
* 구현 경험이 포함되어 있는가?
* 앞으로의 개발 방향이 명확한가?
### 자기소개
* “좋아합니다”보다 “구현했습니다 / 설계했습니다 / 경험했습니다” 중심인가?
* 기술, 프로젝트, 성장 방향이 연결되는가?
* 평가자가 어떤 백엔드 역량을 가진 사람인지 알 수 있는가?
### 기술스킬
* 기술을 카테고리별로 정리했는가?
* Main Skill과 Sub Skill을 구분했는가?
* 숙련도 기준이 명확한가?
* 기술 이름만 나열하지 않았는가?
* 각 기술의 활용 경험이 작성되어 있는가?

View File

@@ -0,0 +1,516 @@
# 백엔드 포트폴리오 프로젝트 작성 컨텍스트
이 문서는 LLM이 백엔드 개발자 포트폴리오의 프로젝트 항목을 작성할 때 따라야 할 압축 컨텍스트이다. 목적은 토큰 사용량을 줄이면서도, 프로젝트 소개가 단순 기능 나열이 아니라 **문제 정의 → 역할 → 기술 선택 → 구현 → 문제 해결 → 성과 → 회고** 흐름을 갖도록 만드는 것이다.
---
## 작성 원칙
- 추상적인 표현을 피하고 구체적인 업무 단위로 작성한다.
- “무엇을 했다”보다 “왜 그렇게 했고, 어떤 결과가 있었는지”를 함께 작성한다.
- 백엔드 관점에서 데이터 흐름, API 처리 흐름, 인증/인가, 트랜잭션, 예외 처리, 성능 개선, 운영 관점을 드러낸다.
- 팀 프로젝트라면 본인이 담당한 영역과 팀원과의 인터페이스를 구분한다.
- 가능하면 정량적 결과를 사용한다. 수치가 없다면 구조 개선, 협업 비용 감소, 유지보수성 향상 등 정성적 성과를 구체화한다.
- “팀원과 협력”, “열심히 구현”, “속도 상승”, “여러 기술 사용” 같은 모호한 표현은 사용하지 않는다.
---
## 1. 프로젝트 개요
### 목적
프로젝트가 어떤 문제를 해결하기 위해 시작되었고, 어떤 사용자를 대상으로 어떤 가치를 제공하는지 설명한다.
### 포함할 내용
- 프로젝트 배경
- 해결하려는 문제
- 프로젝트 목표
- 주요 기능 키워드
- 서비스 대상 사용자 또는 사용 환경
### 작성 구조
문제 상황 → 프로젝트 목표 → 핵심 기능 → 기대 가치
### 예시 문장
본 프로젝트는 사용자가 파일을 안전하게 저장하고 공유할 수 있는 파일 협업 서비스를 구현하는 것을 목표로 진행되었다. 기존 클라우드 기반 파일 서비스는 편리하지만 데이터 저장 위치와 접근 권한을 직접 통제하기 어렵고, 장기적으로 구독 비용이 증가한다는 문제가 있었다. 이를 해결하기 위해 사용자가 직접 관리 가능한 저장소 환경에서 파일 업로드, 다운로드, 권한 관리, 공유, 실시간 알림 기능을 제공하는 백엔드 중심의 협업 플랫폼을 개발하였다.
### 피해야 할 표현
- 프로젝트 개요를 작성하지 않음
- 책 추천 프로그램 개발
- 파일 관리 서비스 개발
- 여러 기능을 제공하는 서비스
---
## 2. 담당 역할 및 기여 1: 팀 내 역할
### 목적
팀 안에서 본인이 어떤 영역을 책임졌는지 명확히 설명한다.
### 포함할 내용
- 팀 내 역할
- 담당 도메인
- 책임진 기능 또는 모듈
- 프론트엔드, 인프라, 다른 백엔드 팀원과의 협업 지점
### 작성 구조
팀 내 역할 → 담당 도메인 → 책임진 기능 → 협업 방식
### 예시 문장
팀 내에서 백엔드 개발을 담당하였으며, 파일 관리 도메인과 권한 검증 로직을 중심으로 구현하였다. 사용자의 파일 업로드 요청이 실제 저장소에 반영되기까지의 흐름을 설계하고, 파일 메타데이터 저장, 폴더 구조 관리, 사용자별 접근 권한 확인, 다운로드 권한 검증 API를 개발하였다. 또한 프론트엔드에서 필요한 API 명세를 정의하고 예외 응답 형식을 통일하여 클라이언트 연동 과정의 혼선을 줄였다.
### 피해야 할 표현
- 팀원들을 도와 프로젝트 수행
- 분배 후 협력하여 완성
- 백엔드 전반 담당
- 프로젝트에 전반적으로 참여
---
## 3. 담당 역할 및 기여 2: 업무 단위별 기여
### 목적
본인의 기여를 기능, 모듈, API, 데이터 구조, 성능 개선 단위로 구체화한다.
### 포함할 내용
- 구현한 API
- 설계한 데이터 모델
- 담당한 도메인 로직
- 처리한 예외 상황
- 개선한 성능 또는 구조
- 테스트, 문서화, 배포 기여
### 작성 구조
기능명 → 내가 한 일 → 선택 이유 → 결과
### 예시 문장
파일 업로드 기능에서는 단순히 파일을 저장하는 방식이 아니라, 업로드 세션을 생성하고 청크 단위로 파일을 전송한 뒤 최종 완료 시점에 메타데이터를 확정하는 구조로 설계하였다. 이를 통해 대용량 파일 업로드 중 네트워크 오류가 발생하더라도 업로드 상태를 추적할 수 있도록 했고, 파일 저장소와 데이터베이스 사이의 상태 불일치를 줄이는 데 집중하였다.
### 기여도 예시 키워드
- 인증/인가: 로그인, 토큰 발급, 사용자 식별, 권한 부족 예외 처리
- 파일 처리: 업로드, 다운로드, 삭제, 메타데이터 저장, 저장소 경로 관리
- 권한 관리: 사용자/그룹/파일/폴더 단위 접근 제어
- 알림: 이벤트 기록, 알림 생성, 읽음 상태 관리, 실시간 전달
- 성능 개선: Projection, 페이징, 커서 기반 조회, 인덱스, 쿼리 최적화
- 운영: Docker, Nginx, 환경 변수, 로그, 배포 구조
---
## 4. 사용 기술 및 선택 이유
### 목적
기술 스택을 단순 나열하지 않고 프로젝트 요구사항과 연결해 설명한다.
### 포함할 내용
- 사용 기술
- 사용 위치
- 선택 이유
- 대안 대비 장점
- 프로젝트 요구사항과의 연결성
### 작성 구조
기술명 → 사용 위치 → 선택 이유 → 얻은 효과
### 예시 문장
ASP.NET Core는 API 서버 구현을 위해 사용하였다. 인증, 라우팅, 미들웨어, 의존성 주입 구조가 잘 제공되어 백엔드 기능을 계층적으로 분리하기에 적합하다고 판단하였다. 또한 정적 타입 기반 개발 환경을 활용할 수 있어 도메인 모델과 요청/응답 DTO를 명확히 정의할 수 있었다.
PostgreSQL은 사용자, 파일, 폴더, 권한, 알림처럼 관계가 명확한 데이터를 저장하기 위해 사용하였다. 파일 권한 검증과 폴더 계층 구조 조회처럼 관계형 데이터 모델이 중요한 기능이 많았기 때문에 RDBMS가 적합하다고 판단하였다.
Redis는 빠른 조회가 필요한 세션성 데이터와 실시간 이벤트 전달을 위해 사용하였다. 짧은 수명을 가진 데이터나 Pub/Sub 기반 메시지 전달을 Redis로 분리하여 DB 부하를 줄이고 실시간 처리 구조를 단순화하였다.
### 피해야 할 표현
- 여러 기술을 사용하여 완성
- React, Spring, MySQL 사용
- 익숙해서 사용
- 다양한 기술을 활용
---
## 5. 구현 사항
### 목적
본인이 구현한 핵심 기능을 백엔드 처리 흐름 중심으로 설명한다.
### 포함할 내용
- 핵심 API
- 요청 처리 흐름
- 데이터 저장 구조
- 인증/인가 검증
- 예외 처리
- 트랜잭션 처리
- 비동기 처리
- 테스트 또는 검증 방식
### 작성 구조
요청 발생 → 검증 → 처리 → 저장/변경 → 응답/이벤트
### 예시 문장
파일 업로드 기능은 업로드 요청을 받은 뒤 사용자 권한을 검증하고, 업로드 가능한 폴더인지 확인한 후 파일 메타데이터를 생성하는 흐름으로 구현하였다. 업로드 완료 후에는 파일 상태를 확정하고 관련 사용자에게 알림 이벤트를 생성하도록 구성하였다. 이를 통해 파일 저장, 메타데이터 관리, 알림 생성이 하나의 흐름으로 연결되도록 설계하였다.
### 구현 항목 예시
- 회원가입, 로그인, 로그아웃 API
- 인증 토큰 발급 및 검증
- 폴더 생성, 이름 변경, 삭제
- 파일 업로드, 다운로드, 삭제
- 사용자별 접근 가능한 파일 목록 조회
- 파일/폴더 단위 권한 설정
- 다운로드, 삭제, 공유 요청 시 권한 검증
- 알림 목록 조회 및 읽음 상태 관리
- 실시간 알림 전달
### 피해야 할 표현
- React로 구현
- 데이터베이스에 데이터 저장
- 파일 기능 구현
- 로그인 기능 구현
---
## 6. 문제 해결 사례
### 목적
프로젝트 중 발생한 기술적 문제와 해결 과정을 설명한다.
### 포함할 내용
- 문제 상황
- 원인 분석
- 고려한 해결 방법
- 최종 선택한 방법
- 선택 이유
- 해결 결과
- 배운 점
### 작성 구조
문제 상황 → 원인 분석 → 해결 대안 → 선택한 방법 → 결과
### 예시 문장 1: 조회 성능 문제
파일 목록 조회 기능에서 폴더별 파일 수와 사용자 권한 정보를 함께 조회하면서 응답 시간이 증가하는 문제가 발생하였다. 초기 구현에서는 관련 엔티티를 한 번에 Include하여 조회했지만, 데이터가 증가할수록 불필요한 Join이 많아져 쿼리 비용이 커졌다. 이를 해결하기 위해 실제 응답에 필요한 필드만 Projection으로 조회하고, 권한 검증에 필요한 조건을 쿼리 수준에서 필터링하도록 변경하였다. 그 결과 불필요한 데이터 로딩을 줄이고 파일 목록 조회 응답 시간을 개선할 수 있었다.
### 예시 문장 2: 데이터 정합성 문제
파일 업로드 과정에서 실제 파일 저장은 완료되었지만 데이터베이스 메타데이터 저장에 실패할 경우 저장소와 DB 상태가 불일치할 수 있는 문제가 있었다. 이를 해결하기 위해 업로드 상태를 임시 상태와 확정 상태로 분리하고, 업로드 완료 시점에만 파일을 최종 사용 가능 상태로 전환하도록 설계하였다. 또한 실패한 업로드는 별도 정리 대상으로 관리하여 잘못된 파일이 사용자에게 노출되지 않도록 하였다.
### 예시 문장 3: 비동기 알림 문제
알림을 API 요청 흐름 안에서 즉시 생성하고 전달할 경우 알림 처리 지연이 API 응답 시간에 영향을 줄 수 있는 문제가 있었다. 이를 해결하기 위해 도메인 이벤트를 기록한 뒤 비동기 작업에서 알림 생성과 실시간 전달을 처리하도록 분리하였다. 이 구조를 통해 핵심 API 응답 흐름과 부가 처리를 분리하고, 알림 처리 실패가 주요 기능에 직접 영향을 주지 않도록 개선하였다.
### 피해야 할 표현
- 캐싱 전략으로 문제 해결
- 팀원과 소통하여 문제 해결
- 오류를 수정함
- 성능을 개선함
---
## 7. 프로젝트 성과 및 결과
### 목적
프로젝트 결과를 정량적 또는 정성적 근거로 설명한다.
### 포함할 내용
- 응답 시간 개선
- 쿼리 수 감소
- 코드 중복 감소
- 배포 시간 단축
- 테스트 케이스 증가
- 예외 처리 통일
- API 연동 시간 단축
- 유지보수성 개선
### 작성 구조
개선 전 문제 → 적용한 변화 → 개선 후 결과
### 정량적 예시 문장
파일 목록 조회 로직을 Projection 기반 쿼리로 변경하여 불필요한 데이터 로딩을 줄였고, 테스트 데이터 기준 평균 응답 시간을 약 000ms에서 000ms로 개선하였다.
### 정성적 예시 문장
API 예외 응답 형식을 통일하여 프론트엔드에서 에러 처리 분기 로직을 단순화하였다. 이를 통해 연동 과정에서 발생하던 응답 형식 불일치 문제를 줄이고, 기능 추가 시 동일한 에러 처리 기준을 재사용할 수 있도록 개선하였다.
### 시각화 추천 항목
- API 응답 시간 전후 비교
- 쿼리 수 전후 비교
- 업로드 처리 흐름 다이어그램
- 인증/권한 구조 다이어그램
- 배포 아키텍처 다이어그램
- 이슈 처리 현황
- 기능 구현 범위 표
### 피해야 할 표현
- OO으로 팀원과 적극적인 소통
- 문제 해결 후 속도 상승
- 성능이 좋아짐
- 프로젝트를 성공적으로 완성
---
## 8. 프로젝트 회고
### 목적
프로젝트를 통해 배운 점, 아쉬운 점, 개선 방향을 기술적으로 설명한다.
### 포함할 내용
- 배운 기술적 내용
- 설계 과정에서 느낀 점
- 어려웠던 부분
- 아쉬웠던 점
- 다음 개선 방향
- 개발자로서 성장한 부분
### 작성 구조
배운 점 → 아쉬운 점 → 다음 개선 방향
### 예시 문장
이번 프로젝트를 통해 백엔드 개발에서 기능 구현만큼 중요한 것이 데이터 흐름과 예외 상황을 설계하는 일이라는 점을 배웠다. 특히 파일 업로드처럼 실제 저장소와 데이터베이스 상태가 함께 관리되는 기능에서는 단순히 API가 정상 응답하는 것보다 실패 상황에서 어떤 상태를 남길 것인지가 중요하다는 것을 경험하였다.
아쉬웠던 점은 초기 설계 단계에서 성능 테스트 기준을 명확히 잡지 못했다는 점이다. 기능 구현 이후에야 조회 성능과 업로드 처리 흐름을 점검하면서 일부 구조를 수정해야 했다. 다음 프로젝트에서는 주요 API별 예상 트래픽, 데이터 규모, 성능 측정 기준을 초기에 정의하고 구현 과정에서 지속적으로 검증하는 방식으로 개발하고자 한다.
### 피해야 할 표현
- 시간 부족으로 모든 기능 구현 X
- 팀워크의 중요성을 깨달았다
- 많은 것을 배웠다
- 다음에는 더 열심히 하고 싶다
---
## 9. 최종 출력 구조
LLM은 프로젝트 포트폴리오를 작성할 때 아래 구조를 따른다.
```markdown
## 프로젝트명
### 1. 프로젝트 개요
- 문제 배경
- 프로젝트 목표
- 핵심 기능
- 기대 가치
### 2. 담당 역할
- 팀 내 역할
- 담당 도메인
- 책임진 기능
- 협업 방식
### 3. 주요 기여
- 구현한 API/모듈
- 설계한 데이터 구조
- 개선한 로직
- 본인의 구체적 기여
### 4. 사용 기술 및 선택 이유
- 기술 스택
- 사용 위치
- 선택 이유
- 얻은 효과
### 5. 구현 사항
- 핵심 기능
- 백엔드 처리 흐름
- 인증/인가
- 예외 처리
- 데이터 저장/조회 구조
### 6. 문제 해결 사례
- 문제 상황
- 원인 분석
- 해결 방법
- 결과
### 7. 성과 및 결과
- 정량적 성과
- 정성적 성과
- 시각화 가능한 지표
### 8. 회고
- 배운 점
- 아쉬운 점
- 개선 방향
```
---
## 10. 최종 체크리스트
LLM은 최종 답변을 작성한 뒤 아래 기준을 모두 점검한다.
### 프로젝트 개요 체크리스트
- [ ] 프로젝트를 왜 만들었는지 설명했는가?
- [ ] 해결하려는 문제가 명확한가?
- [ ] 단순 기능명이 아니라 서비스 목표가 드러나는가?
- [ ] 주요 기능 키워드가 포함되어 있는가?
### 담당 역할 체크리스트
- [ ] 본인의 역할이 구체적인 도메인 단위로 작성되었는가?
- [ ] “백엔드 담당”에서 끝나지 않고 책임진 기능이 드러나는가?
- [ ] 팀원과의 협업 방식이 API 명세, 데이터 구조, 배포 환경 등 구체적 기준으로 설명되었는가?
- [ ] 본인의 기여와 팀 전체 결과가 구분되어 있는가?
### 주요 기여 체크리스트
- [ ] 구현한 API 또는 모듈이 구체적으로 작성되었는가?
- [ ] 기능명만 나열하지 않고 처리 흐름이 설명되었는가?
- [ ] 설계한 데이터 구조나 도메인 로직이 포함되어 있는가?
- [ ] 예외 처리, 권한 검증, 성능 개선 중 하나 이상이 드러나는가?
### 기술 선택 체크리스트
- [ ] 사용 기술을 단순 나열하지 않았는가?
- [ ] 각 기술의 사용 위치가 명확한가?
- [ ] 기술 선택 이유가 프로젝트 요구사항과 연결되어 있는가?
- [ ] 대안 대비 선택 근거가 드러나는가?
### 구현 사항 체크리스트
- [ ] 핵심 기능이 백엔드 관점에서 설명되었는가?
- [ ] 요청 → 검증 → 처리 → 저장 → 응답 흐름이 드러나는가?
- [ ] 인증/인가 또는 권한 검증이 설명되었는가?
- [ ] 데이터 저장, 조회, 변경 방식이 구체적인가?
- [ ] 실패 케이스 또는 예외 처리가 포함되어 있는가?
### 문제 해결 체크리스트
- [ ] 어떤 문제가 발생했는지 명확한가?
- [ ] 문제의 원인을 분석했는가?
- [ ] 해결 방법이 구체적인가?
- [ ] 왜 그 방법을 선택했는지 설명했는가?
- [ ] 해결 결과가 정량적 또는 정성적으로 작성되었는가?
### 성과 체크리스트
- [ ] 전후 변화가 드러나는가?
- [ ] 응답 시간, 쿼리 수, 코드 중복, 배포 시간, 테스트 등 지표가 있는가?
- [ ] 수치가 없다면 정성적 성과가 구체적인가?
- [ ] 시각화 가능한 데이터가 포함되어 있는가?
### 회고 체크리스트
- [ ] 단순 감상이 아니라 기술적 배운 점이 있는가?
- [ ] 아쉬운 점이 구체적인가?
- [ ] 다음 프로젝트에서의 개선 방향이 명확한가?
- [ ] “팀워크의 중요성” 같은 흔한 표현을 피했는가?
### 표현 체크리스트
- [ ] “열심히”, “전반적으로”, “도와서”, “성공적으로” 같은 모호한 표현을 제거했는가?
- [ ] 모든 문장이 가능한 한 구체적인 기능, 문제, 결과와 연결되는가?
- [ ] 면접에서 추가 질문을 받아도 설명할 수 있는 내용인가?
- [ ] 백엔드 개발자로서의 판단 과정이 드러나는가?
---
## 11. 금지 표현과 대체 표현
| 금지 표현 | 대체 표현 |
|---|---|
| 책 추천 프로그램 개발 | 사용자의 독서 이력과 선호 장르를 기반으로 개인화 추천을 제공하는 서비스 개발 |
| 팀원들을 도와 프로젝트 수행 | 백엔드 파트를 담당하며 인증, 추천 결과 저장, 사용자 히스토리 조회 API 구현 |
| 프로젝트 전반적으로 기여 | 추천 결과 저장 데이터 모델을 설계하고 사용자별 추천 이력 조회 API 구현 |
| 여러 기술을 사용하여 완성 | 빠른 추천 결과 조회를 위해 Redis 캐싱을 적용하고 사용자 행동 데이터는 PostgreSQL에 저장 |
| React로 구현 | 사용자가 선택한 조건을 API 요청으로 전달하고 서버에서 필터링된 추천 결과를 반환하는 흐름 구현 |
| 캐싱 전략으로 문제 해결 | 반복 조회되는 추천 결과를 Redis에 저장하여 동일 조건 검색 시 DB 조회를 줄이고 응답 시간 개선 |
| 문제 해결 후 속도 상승 | 평균 응답 시간이 000ms에서 000ms로 감소하여 사용자 대기 시간 감소 |
| 팀워크의 중요성을 깨달았다 | API 명세를 초기에 정리하지 않아 연동 과정에서 수정이 반복되었고, 이후 요청/응답 형식을 문서화하여 협업 비용 감소 |
---
## 12. LLM 작성 프롬프트 템플릿
아래 템플릿에 프로젝트 정보를 넣으면 이 문서의 기준에 맞춰 포트폴리오 항목을 작성한다.
```text
너는 백엔드 개발자 포트폴리오 작성 도우미다.
아래 프로젝트 정보를 바탕으로 포트폴리오 프로젝트 항목을 작성해라.
작성 기준:
- 문제 정의 → 역할 → 기술 선택 → 구현 → 문제 해결 → 성과 → 회고 흐름으로 작성
- 추상적인 표현 금지
- 백엔드 관점의 데이터 흐름, API 흐름, 인증/인가, 예외 처리, 성능 개선을 드러낼 것
- 기술 스택은 사용 이유와 함께 작성
- 성과는 수치가 있으면 수치로, 없으면 구체적인 정성 성과로 작성
- 마지막에 체크리스트 기준으로 부족한 부분을 별도로 지적할 것
프로젝트 정보:
- 프로젝트명:
- 프로젝트 배경:
- 프로젝트 목표:
- 주요 기능:
- 팀 구성:
- 내가 맡은 역할:
- 내가 구현한 기능:
- 사용 기술:
- 기술 선택 이유:
- 문제 해결 사례:
- 성과/결과:
- 아쉬운 점:
- 개선 방향:
출력 구조:
1. 프로젝트 개요
2. 담당 역할
3. 주요 기여
4. 사용 기술 및 선택 이유
5. 구현 사항
6. 문제 해결 사례
7. 성과 및 결과
8. 회고
9. 보완 필요 사항 체크리스트
```
---
## 13. 압축 요약
백엔드 포트폴리오 프로젝트 항목은 기능 소개가 아니라 **기술적 판단의 기록**이어야 한다. 좋은 작성은 다음 질문에 답한다.
1. 왜 만든 프로젝트인가?
2. 내가 맡은 백엔드 책임 범위는 무엇인가?
3. 어떤 기술을 왜 선택했는가?
4. API와 데이터 흐름은 어떻게 설계했는가?
5. 어떤 문제가 있었고 어떻게 해결했는가?
6. 결과적으로 무엇이 개선되었는가?
7. 다음에는 무엇을 더 잘할 수 있는가?