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,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개+ 엔드포인트 전체 인증/인가 흐름이 동일한 구조인지, 예외 케이스가 있는지 면접에서 답할 수 있도록 정리 필요합니다.