vault backup: 2026-05-05 21:27:37

This commit is contained in:
son
2026-05-05 21:27:37 +09:00
parent dc1b910d36
commit 957d24aa9f
39 changed files with 42394 additions and 1 deletions

File diff suppressed because it is too large Load Diff

918
projects/Didit 분석.md Normal file
View File

@@ -0,0 +1,918 @@
# Didit
> 본 문서는 Write-docs.md 템플릿에 따라 실제 코드에서 확인된 내용만을 바탕으로 작성되었다.
> 확인되지 않는 내용은 "확인 필요"로 표기한다.
---
## 1. 프로젝트 목적
### 해결하려는 문제
- 분산된 팀이 GitHub 이슈 기반으로 협업할 때, **이슈 우선순위 판단이 수동적**이고 **회의 내용이 체계적으로 기록/공유되지 않는 문제**
- 실시간 화상회의 + 채팅 + GitHub 이슈 관리가 각각 분리되어 **워크플로우 단절**이 발생하는 문제
### 주요 사용자
- GitHub 저장소를 사용하는 개발 팀
- 실시간 화상회의로 소통하면서 이슈 트래킹이 필요한 애자일/스크럼 팀
### 프로젝트 목표
- 하나의 플랫폼에서 **화상회의 + 채팅 + GitHub 이슈 관리 + AI 기반 요약/우선순위 분석**을 통합 제공
### 백엔드가 담당하는 핵심 책임
- RESTful API 제공 (프로젝트 CRUD, 이슈 관리, 회의 관리, 채팅)
- GitHub OAuth2 인증/인가 처리
- OpenVidu WebRTC 미디어 서버 연동 (세션/커넥션/녹화 관리)
- Redis 기반 비동기 작업 큐잉 (AI 분석 요청 → 응답 수신)
- SSE(Server-Sent Events) 기반 실시간 이벤트 스트리밍
- Flyway 기반 DB 마이그레이션 관리
### 인프라 구성이 필요한 이유
- 5개 이상의 서비스(서버, DB, Redis, OpenVidu, AI Worker, 클라이언트)가 **유기적으로 연동**되어야 하므로 컨테이너 오케스트레이션이 필요
- 내부 네트워크 분리로 서비스 간 보안 격리 필요
- Reverse Proxy를 통한 도메인 라우팅 및 HTTPS 처리 필요
### 한 줄 설명
**"GitHub OAuth 기반의 실시간 팀 협업 플랫폼으로, AI가 이슈 우선순위를 분석하고 회의 내용을 자동 요약한다."**
---
## 2. 전체 아키텍처
### 전체 구성 요소
| 계층 | 기술 스택 | 역할 |
|------|-----------|------|
| **Reverse Proxy** | Traefik | 도메인 라우팅, HTTPS 종단 |
| **Frontend** | React | SPA 클라이언트 |
| **Backend** | Java 21 + Spring Boot 4.0 | REST API, 인증/인가, SSE |
| **Media Server** | OpenVidu 2.32.1 | WebRTC 화상회의 (SFU) |
| **AI Worker** | Python (FastAPI) + HuggingFace | 이슈 우선순위 분석, 회의 요약 (Whisper STT + Claude API) |
| **Message Broker** | Redis 7.2 | 작업 큐, Pub/Sub, SSE client key 저장 |
| **Database** | MySQL 8.0 | Source of Truth |
| **검색 엔진** | Elasticsearch | (의존성만 존재, 실제 사용 확인 필요) |
### 프론트엔드/백엔드/DB/Redis/Storage/Worker/Reverse Proxy 관계
```
외부 요청 (HTTPS)
Traefik (Reverse Proxy)
├─► didit-client (React 정적 서빙)
├─► didit-server (Spring Boot :8080)
│ ├─► MySQL 8.0 (:3306, internal 네트워크만)
│ ├─► Redis 7.2 (:6379)
│ ├─► OpenVidu (:4443, internal 네트워크만)
│ └─► SSE Emitter → 클라이언트 직접 연결
└─► (AI Worker는 HTTP 외부 노출 없음, Redis 큐로만 통신)
```
### 외부 요청이 내부 서비스로 전달되는 흐름
1. 클라이언트 → `https://did-it.xyz` → Traefik → `didit-client` (정적 파일) 또는 `didit-server` (API)
2. `didit-server``internal` 네트워크를 통해 `db:3306`, `redis:6379`, `openvidu:4443`와 통신
3. AI 분석 요청: 서버가 Redis List(`queue:issue:priority:single`)에 작업 Push → AI Worker가 polling → 결과 Redis Pub/Sub 발행 → 서버 Listener가 수신 → SSE로 클라이언트 전달
### 서비스 간 책임 분리
- **didit-server**: 모든 비즈니스 로직, 인증, API, SSE, 외부 연동 조율
- **AI Worker**: 순수 ML 추론 작업만 수행, HTTP 엔드포인트 없이 Redis로만 통신
- **OpenVidu**: WebRTC 미디어 스트리밍만 담당, 시그널링/녹화는 서버가 조율
- **MySQL**: 모든 영속 데이터 저장
- **Redis**: 캐시/세션보다 **비동기 작업 큐 + Pub/Sub 메시징**이 주 용도
### 외부 공개 서비스와 내부 서비스 구분
| 공개 (외부) | 비공개 (내부) |
|-------------|---------------|
| Traefik (443) | MySQL (3306) |
| didit-server (8080, Caddy 네트워크 경유) | Redis (6379, 포트는 외부 노출되어 있음 - 개선 여지 있음) |
| didit-client | OpenVidu (4443) |
| | AI Worker |
### 아키텍처 유형
**모듈러 모놀리스**에 가깝다. 하나의 Spring Boot 애플리케이션이 API + SSE + 보안을 모두 담당하며, AI 분석만 별도 Python Worker로 분리되어 있다. 패키지 구조는 전통적인 3계층(api/service/data)으로 설계되어 있다.
### 아키텍처 설계 의도
- **SSE + Redis Pub/Sub**으로 실시간성을 확보하면서도 WebSocket보다 가벼운 인프라 구성
- AI Worker를 별도 프로세스로 분리해 **ML 추론 부하가 API 응답성에 영향을 주지 않도록** 설계
- GitHub OAuth2 + Session Cookie 방식으로 **SPA에서도 안전한 인증** 구현
---
## 3. API 설계
### API 버전 관리
- URL 경로 기반 버저닝: `/api/v1/`
- Base URL: `https://api.did-it.xyz/api/v1`
### Swagger/OpenAPI 문서화
- SpringDoc OpenAPI 3.0.1 (Swagger UI 연동)
- `openapi.yaml` 파일 제공 (`/openapi.yaml`, `/v3/api-docs/swagger-config`)
- 운영 환경(`prod`)에서는 Swagger UI / API Docs 비활성화
### 주요 API 목록 (총 30개+ 엔드포인트)
#### 인증 (Auth)
| Method | Endpoint | 인증 | Request DTO | Response DTO | 성공 | 실패 |
|--------|----------|------|-------------|-------------|------|------|
| GET | `/api/v1/auth/login` | 불필요 | - | Redirect | 302 | - |
| POST | `/api/v1/auth/logout` | 불필요 | - | - | 200 | - |
| GET | `/api/v1/auth/me` | 필요 | - | UserResponse | 200 | 401 |
#### 프로젝트 (Projects)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| GET | `/api/v1/projects?page={n}` | 필요 | 멤버 | - | ProjectResponse[] |
| POST | `/api/v1/projects` | 필요 | - | AddProjectRequest | 200 empty |
| GET | `/api/v1/projects/{projectId}` | 필요 | 멤버 | - | ProjectResponse |
| DELETE | `/api/v1/projects/{projectId}` | 필요 | OWNER | - | 200 empty |
| DELETE | `/api/v1/projects/{projectId}/leave` | 필요 | 멤버 | - | 200 empty |
| GET | `/api/v1/projects/recents` | 필요 | - | - | ProjectRecentResponse[] |
| GET | `/api/v1/projects/{projectId}/participants` | 필요 | - | - | UserResponse[] |
| DELETE | `/api/v1/projects/{projectId}/participants/{userId}` | 필요 | OWNER | - | 200 empty |
| PATCH | `/api/v1/projects/{projectId}` | 필요 | OWNER | UpdateRepoRequest | 200 empty |
| PATCH | `/api/v1/projects/{projectId}/owner` | 필요 | OWNER | TransferOwnerRequest | 200 empty |
| PATCH | `/api/v1/projects/{projectId}/name` | 필요 | OWNER | UpdateProjectNameRequest | 200 empty |
| POST | `/api/v1/projects/{projectId}/github/validate` | 필요 | OWNER | ValidateGithubRequest | GithubRepoResponse |
#### 초대 (Invites)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| POST | `/api/v1/projects/invites` | 필요 | ADMIN | AddProjectInviteRequest | UUID String |
| GET | `/api/v1/projects/invites/{inviteCode}` | 필요 | - | - | ProjectResponse |
| POST | `/api/v1/projects/invites/{inviteCode}` | 필요 | - | - | 200 empty |
#### 회의 (Meetings/Channels)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| POST | `/api/v1/projects/{projectId}/add-channel` | 필요 | ADMIN | CreateMeetingRequest | Long (meetingId) |
| POST | `/api/v1/projects/{projectId}/book-channel` | 필요 | ADMIN | BookMeetingRequest | Long (meetingId) |
| GET | `/api/v1/projects/{projectId}/channels?status&cursor` | 필요 | - | - | MeetingResponse[] |
| GET | `/api/v1/projects/{projectId}/channels/date?start&end` | 필요 | 멤버 | - | MeetingResponse[] |
| GET | `/api/v1/channels/{channelId}` | 필요 | 멤버 | - | MeetingResponse |
| PATCH | `/api/v1/channels/{channelId}?title&start&due` | 필요 | 멤버 | - | 200 empty |
| DELETE | `/api/v1/channels/{channelId}` | 필요 | 멤버 | - | 200 empty |
| POST | `/api/v1/channels/{channelId}/webrtc` | 필요 | 멤버(VOICE) | - | String (token) |
| DELETE | `/api/v1/channels/{channelId}/webrtc` | 필요 | 멤버(VOICE) | - | 200 empty |
| GET | `/api/v1/channels/{channelId}/webrtc/users` | 필요 | - | - | UserResponse[] |
| POST | `/api/v1/channels/{channelId}/recording/start` | 필요 | 멤버(VOICE) | - | Long (recordId) |
| POST | `/api/v1/channels/{channelId}/recording/stop/{recordId}` | 필요 | - | - | 200 empty |
#### 채팅 (Chat)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| POST | `/api/v1/channels/{channelId}/chats` | 필요 | 멤버 | SendMessageRequest | 200 empty |
| GET | `/api/v1/channels/{channelId}/chats?lastId` | 필요 | 멤버 | - | ChatResponse[] |
| PATCH | `/api/v1/channels/chats/{chatId}` | 필요 | 작성자 | UpdateChatMessage | 200 empty |
| DELETE | `/api/v1/channels/chats/{chatId}` | 필요 | 작성자 | - | 200 empty |
#### SSE (Server-Sent Events)
| Method | Endpoint | 인증 | 권한 | 설명 |
|--------|----------|------|------|------|
| GET | `/api/v1/channels/{channelId}/stream` | 필요 | 멤버 | 채널 단위 실시간 이벤트 |
| GET | `/api/v1/projects/{projectId}/stream` | 필요 | 멤버 | 프로젝트 단위 실시간 이벤트 |
#### 이슈 (Issues)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| GET | `/api/v1/projects/{projectId}/issues/active` | 필요 | 멤버 | - | IssueResponse[] |
| GET | `/api/v1/projects/{projectId}/issues` | 필요 | 멤버 | - | IssueResponse[] |
| POST | `/api/v1/projects/{projectId}/issue/analyze` | 필요 | 멤버 | AnalyzeIssueRequest | AiResponse |
| POST | `/api/v1/projects/{projectId}/issues` | 필요 | 작성자 | CreateIssueRequest | IssueResponse[] |
| PATCH | `/api/v1/projects/{projectId}/issues/{issueId}` | 필요 | 작성자 | UpdateIssueRequest | 200 empty |
| DELETE | `/api/v1/projects/{projectId}/issues/{issueId}` | 필요 | 작성자 | - | 200 empty |
| GET | `/api/v1/projects/{projectId}/github/issues` | 필요 | 멤버 | - | IssueResponse[] |
#### 웹훅 (Webhooks)
| Method | Endpoint | 인증 | 설명 |
|--------|----------|------|------|
| POST | `/api/v1/webhooks/openvidu` | Token 검증 | OpenVidu 이벤트 수신 (세션/참가자/녹화 이벤트) |
#### 회의 요약 (Meeting Summary)
| Method | Endpoint | 인증 | 권한 |
|--------|----------|------|------|
| GET | `/api/v1/projects/{projectId}/summary?page` | 필요 | 멤버 |
| PATCH | `/api/v1/projects/{projectId}/summary/{summaryId}` | 필요 | 멤버 |
| DELETE | `/api/v1/projects/{projectId}/summary/{summaryId}` | 필요 | ADMIN |
### 페이징 방식
- **Spring Pageable** (`page`, `size`, `sort` 쿼리 파라미터) - 회의 목록, 채팅 목록
- **커서 기반** (`cursor`/`lastId`) - 일부 엔드포인트에서 사용
### 공통 응답 패턴
- 성공: `200 OK` + DTO (빈 body인 경우도 있음)
- 실패: `ErrorResponse(statusCode, message)` + 해당 HTTP Status
---
## 4. 인증/인가 설계
### 로그인 방식
- **GitHub OAuth2** (Authorization Code Grant)
- Spring Security의 `oauth2Login()` 사용
- 요청 권한 범위(scope): `read:user`, `user:email`, `repo`, `read:org`
### 세션 관리
- **Session Cookie** (`JSESSIONID`) 기반
- Cookie 설정: `SameSite=None`, `Secure=True`, `HttpOnly=True`
- 로그아웃 시 세션 무효화 + 쿠키 삭제
### 현재 사용자 식별 방식
- `@AuthenticationPrincipal CustomOAuth2User` 어노테이션으로 Controller에서 접근
- `CustomOAuth2User`는 Spring의 `DefaultOAuth2User`를 확장하여 내부 DB PK(`id`)와 GitHub `accessToken`을 포함
- OAuth2 로그인 성공 시 `CustomOAuth2UserService`에서 사용자 정보를 DB에 저장/갱신 (joinOrUpdate)
### Role/Permission 구조
| Role | 범위 | 설명 |
|------|------|------|
| `ADMIN` | 프로젝트 | 프로젝트 소유자(최초 생성자), 모든 관리 권한 보유 |
| `MEMBER` | 프로젝트 | 일반 참여자, 읽기/쓰기 가능하나 관리 기능 제한 |
| User Status | 설명 |
|-------------|------|
| `PENDING` | 초대 수락 전 (사용하지 않는 것으로 보임) |
| `ACTIVE` | 활성 멤버 |
| `LEFT` | 탈퇴/추방된 멤버 (soft 상태 변경) |
### 리소스 소유자 검증 방식
- **프로젝트 소유자(ADMIN) 검증**: `project.getOwner().getId().equals(userId)` → 실패 시 `ForbiddenError("권한이 부족합니다.")`
- **이슈 작성자 검증**: `entity.getAuthor().getId() != userId` → 실패 시 `ForbiddenError("권한이 없습니다.")`
- **프로젝트 멤버십 검증**: 모든 요청 전 `FindProjectUser(userId, projectId)` 호출 → 실패 시 `NotFoundError` 또는 `ForbiddenError`
### 권한 검증 위치
- **Controller 계층**: `@AuthenticationPrincipal`으로 사용자 식별
- **Service 계층**: 모든 권한 검증이 Service 구현체 내에서 수행됨 (Global Filter/AOP 미사용)
### 인증 실패/권한 실패 응답 방식
- 인증 실패: Spring Security 기본 동작 (401 Unauthorized 또는 로그인 페이지로 리다이렉트)
- 권한 실패: `Result.fail(new ForbiddenError("메시지"))` → Controller에서 `ErrorResponse`로 변환 (HTTP 403)
### 인증/인가 설계 판단
**장점**: GitHub OAuth2로 별도 회원가입 절차 불필요, 개발자 타겟에 적합
**개선 여지**:
- Role이 ADMIN/MEMBER 2개로 단순하나, **요약 삭제(DELETE summary)만 ADMIN 전용**이고 대부분 MEMBER에게 개방되어 있어 세분화 부족
- `@PreAuthorize` 등 선언적 인가보다 Service 코드 내 if문 검증이 많아 **보안 정책이 코드에 흩어져 있음**
- OpenVidu Webhook 엔드포인트만 Token 헤더 검증 (공개 URL + Secret Token)
---
## 5. 데이터베이스 설계
### 주요 Entity 목록 (총 14개 테이블)
| Entity | Table | 설명 |
|--------|-------|------|
| UserEntity | `users` | GitHub OAuth 사용자 |
| UserGithubAuthEntity | `user_github_auth` | GitHub Access Token 저장 (1:1 users) |
| ProjectEntity | `projects` | 프로젝트/워크스페이스 |
| ProjectUserEntity | `project_users` | 프로젝트-사용자 멤버십 (N:M) |
| ProjectInviteEntity | `project_invites` | UUID 기반 초대 링크 |
| ProjectRecentEntity | `project_recents` | 최근 조회 프로젝트 (최대 4개 노출) |
| MeetingEntity | `meetings` | 회의/채널 |
| MeetingUserEntity | `meeting_users` | 회의 참여자 |
| MeetingRecordEntity | `meeting_records` | OpenVidu 녹화 기록 |
| MeetingSummaryEntity | `meeting_summary` | AI 회의 요약 (버전 관리) |
| IssueEntity | `issues` | GitHub 연동 이슈 |
| IssueAssigneeEntity | `issue_assignees` | 이슈 담당자 (N:M) |
| ChatEntity | `chats` | 채팅 메시지 (Soft Delete) |
| ProjectUserReadEntity | `project_user_reads` | 사용자별 읽음 상태 |
### 테이블 간 관계 (주요 FK)
```
users (1) ────── (1) user_github_auth [FK: user_id, ON DELETE CASCADE]
users (1) ────── (N) projects [FK: owner_id, ON DELETE RESTRICT]
users (1) ────── (N) project_users [FK: user_id, ON DELETE RESTRICT]
projects (1) ──── (N) project_users [FK: project_id, ON DELETE CASCADE]
projects (1) ──── (N) project_invites [FK: project_id, ON DELETE CASCADE]
projects (1) ──── (N) meetings [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) meetings [FK: created_by, ON DELETE RESTRICT]
meetings (1) ──── (N) meeting_users [FK: meeting_id, ON DELETE CASCADE]
users (1) ──────── (N) meeting_users [FK: user_id, ON DELETE RESTRICT]
meetings (1) ──── (N) meeting_records [FK: meeting_id, ON DELETE CASCADE] (추정)
meetings (1) ──── (N) meeting_summary [FK: meeting_id, ON DELETE CASCADE]
projects (1) ──── (N) issues [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) issues [FK: author_id, ON DELETE RESTRICT]
issues (1) ────── (N) issue_assignees [FK: issue_id, ON DELETE CASCADE]
users (1) ──────── (N) issue_assignees [FK: user_id, ON DELETE RESTRICT]
projects (1) ──── (N) chats [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) chats [FK: user_id, ON DELETE RESTRICT]
meetings (1) ──── (N) chats [FK: meeting_id, ON DELETE SET NULL]
```
### 주요 FK 제약 특징
- `ON DELETE RESTRICT`: 사용자(users) 삭제 방지 (관련 엔티티가 있으면 삭제 불가)
- `ON DELETE CASCADE`: 프로젝트/회의/이슈 삭제 시 하위 데이터 자동 삭제
- `ON DELETE SET NULL`: 채팅의 meeting_id, 요약의 meeting_id/edited_by → 참조 레코드 삭제 시 null
### 주요 Unique / Index 제약
| 테이블 | Type | 컬럼 | 설명 |
|--------|------|------|------|
| users | UK | github_id | GitHub 계정 중복 방지 |
| users | UK | github_login | GitHub 로그인 중복 방지 |
| projects | UK | repo_full_name, active_key | 동일 레포지토리 중복 등록 방지 (Soft Delete 고려) |
| project_users | UK | (project_id, user_id) | 중복 멤버십 방지 |
| issues | UK | (project_id, github_issue_id) | 프로젝트 내 동일 GitHub 이슈 중복 방지 |
| issue_assignees | UK | (issue_id, user_id) | 이슈별 중복 담당자 방지 |
| meeting_summary | UK | (meeting_id, version) | 동일 회의 동일 버전 요약 중복 방지 |
| meeting_records | UK | recording_session_id | 녹화 세션 중복 방지 |
| project_recents | UK | (user_id, project_id) | 최근 조회 중복 방지 |
| project_user_reads | UK | (project_id, user_id) | 읽음 상태 중복 방지 |
### Soft Delete 여부
- `projects`: **Soft Delete** (`deleted_at` 컬럼 존재, 그러나 `ProjectRepository.delete()`가 실제 삭제하는 것으로 보임 - 확인 필요)
- `chats`: **Soft Delete** (`deleted_at` 컬럼 존재, 실제 deleteMessage() 동작은 확인 필요)
- `project_users`: **상태 변경 방식** (`status = LEFT`, `left_at` 기록) - 실제 삭제 없이 상태만 변경
### CreatedAt/UpdatedAt 관리 방식
- JPA Entity에서 `@CreationTimestamp`, `@UpdateTimestamp` 사용
- DB 레벨: `DEFAULT CURRENT_TIMESTAMP`, `ON UPDATE CURRENT_TIMESTAMP`
### Migration 관리 방식
- **Flyway** 사용
- 마이그레이션 파일: `apps/server/src/main/resources/db/migration/V{번호}__{설명}.sql`
- 총 12개 마이그레이션 파일 (V1~V12)
- `clean-disabled: true` (운영에서 실수로 clean 방지)
- JPA: `ddl-auto: validate` (Entity-DB 불일치 시 즉시 오류)
### 데이터 정합성 설계
- Unique Constraint + FK 제약으로 **애플리케이션 레벨 + DB 레벨 이중 방어**
- `@Transactional`로 서비스 단위 작업 원자성 보장
---
## 6. 비즈니스 로직 / UseCase 구조
### 주요 UseCase 목록
**프로젝트 관리**
- `AddProject`: 프로젝트 생성 + 생성자 ADMIN 등록 + 최근 조회 upsert
- `AddInviteCode`: UUID 초대 코드 생성 (ADMIN 전용, 만료일 설정)
- `AddProjectUser`: 초대 코드로 프로젝트 참여 (LEFT 상태였으면 ACTIVE로 복원)
- `DeleteProject`: 프로젝트 삭제 (OWNER 전용)
- `LeaveProject`: 프로젝트 탈퇴 (OWNER 불가), 상태 LEFT로 변경
- `transferOwnership`: OWNER 권한 이전 (ADMIN↔MEMBER 역할 교환)
- `removeParticipant`: 참여자 추방 (OWNER 전용)
**회의 관리**
- `createMeeting`: 회의 생성 (ADMIN 전용, RUNNING 상태)
- `bookMeeting`: 회의 예약 (ADMIN 전용, SCHEDULED 상태, 시간 검증 포함)
- `UpdateMeeting`: 회의명/시간 수정
- `DeleteMeeting`: 회의 삭제
**미디어 관리 (OpenVidu 연동)**
- `createConnection`: OpenVidu 세션/커넥션 생성 → 토큰 발급
- `closeConnection`: 커넥션 종료 + 사용자 left 처리
- `StartRecording`: 녹화 시작
- `StopRecording`: 녹화 중단
**이슈 관리 (GitHub 연동)**
- `AnalyzeIssue`: 이슈 초안 생성 → Redis 큐에 AI 분석 요청
- `CreateIssue`: AI 분석 완료된 초안 → GitHub 실제 이슈 생성 + 우선순위 설정
- `updateIssue`: 이슈 수정 (작성자만, GitHub 동기화 포함)
- `deleteIssue`: 이슈 삭제 (작성자만, GitHub 이슈는 CLOSE)
- `getGithubIssues`: GitHub에서 이슈 동기화 (신규 등록 + 상태 업데이트)
### Command/Query 구조
- `service/command/`: `AddProjectCommand`, `BookMeetingCommand`, `UpdateRecordingCommand`, `UpdateSummaryCommand`
- 요청 DTO → Controller에서 Command 변환 → Service 호출
- CQS(Command-Query Separation)가 엄격하게 적용되지는 않음 (대부분 Service가 CRUD 혼합)
### 요청 처리 순서 (예: CreateIssue)
1. Controller: `@AuthenticationPrincipal`으로 userId 추출, `@RequestBody CreateIssueRequest` 바인딩
2. Service: `issueRepository.findById(issueId)` → 드래프트 이슈 존재 확인
3. 작성자 검증 (`entity.getAuthor().getId() != userId`)
4. GitHub 인증 정보 조회 (`userGithubAuthRepository`)
5. GitHub API 호출: `githubService.createIssue(accessToken, ...)`
6. 로컬 Entity 업데이트 (title, body, priority, githubIssueId, issueNo, assignees)
7. `issueRepository.save(entity)`
### 검증 로직
- Bean Validation: `@Valid` + Jakarta Validation (`@NotBlank`, `@Size`, `@URL`)
- Service 내 비즈니스 검증: 존재 확인 → `NotFoundError`, 권한 확인 → `ForbiddenError`, 중복 확인 → `ConflictError`
### 성공/실패 반환 방식
- 모든 Service 메서드가 `Result<T>` 반환
- Controller는 `result.isFailure()``ResultError.getResponse()` (ErrorResponse DTO + HTTP Status)
- 성공 시 `ResponseEntity.ok(data)` 또는 `.ok().build()` (empty body)
### API 계층과 UseCase 계층의 책임 분리
- **Controller**: URL 라우팅, `@Valid` 검증, `@AuthenticationPrincipal` 추출, Result→HTTP 변환, DTO 변환
- **Service**: 모든 비즈니스 로직, 권한 검증, 트랜잭션 관리, 외부 API 호출
---
## 7. 트랜잭션과 동시성
### 트랜잭션이 필요한 기능
- `AddProject`: 프로젝트 생성 + ADMIN 등록 (2개 Entity)
- `CreateIssue`: 이슈 Entity 업데이트 + Assignee 교체 + GitHub API 호출
- `transferOwnership`: OWNER/MEMBER 역할 교환 (3개 Entity 동시 변경)
- `LeaveProject`: 상태 변경 (UPDATE only)
- `updateProjectRepo`: 프로젝트 레포지토리 정보 변경
### 트랜잭션 범위
- `@Transactional`이 Service 구현체 메서드 단위로 적용
- `ProjectServiceImpl`: `@Transactional(readOnly = true)` (조회성), `@Transactional` (쓰기)
- `IssueServiceImpl`: `@jakarta.transaction.Transactional` (Jakarta EE, Spring과 혼용 - 개선 여지)
### 동시 요청 시 발생할 수 있는 문제
**중복 참여 (AddProjectUser)**
```java
// LEFT 상태 유저 → ACTIVE로 복원, 이미 ACTIVE → ConflictError
if (entity.getStatus() == ProjectUserStatus.ACTIVE) {
return Result.fail(new ConflictError("이미 존재하는 유저입니다."));
}
```
- 동시에 2개 요청이 들어오면 둘 다 LEFT 상태를 읽고 ACTIVE로 변경 → **2개 ACTIVE 레코드 가능성 없음** (UK 제약 `(project_id, user_id)`이 방어)
**중복 초대 코드 사용**
- `POST /api/v1/projects/invites/{inviteCode}`: 경쟁 상태 가능성 낮음 (멱등성 - ACTIVE로 상태 변경만 수행)
### 중복 생성 방지 방식
- **UK Constraint**: DB에서 중복 생성 방지
- `uk_project_users_project_user`: 동일 프로젝트 중복 멤버십
- `uk_issues_project_github_issue_id`: 동일 GitHub 이슈 중복
- `uk_issue_assignees_issue_user`: 동일 이슈 중복 담당자
- **사전 중복 체크**: `existsByRepoFullName()`, `existsByProject_IdAndUser_IdAndStatus()`
### Lock / CAS / Optimistic Concurrency
- 명시적 Lock 사용: **코드에서 확인되지 않음**
- Optimistic Concurrency: `MeetingSummaryEntity``@Version`과 혼동될 수 있는 `version` 컬럼이 있으나 JPA의 `@Version`은 아님 (단순 데이터 컬럼)
### Idempotency 고려
- Redis 큐(`leftPush`)로 AI 작업을 중복 적재할 가능성 있음 (이슈 조회 시 항상 큐에 push)
- Idempotency key 미사용, 별도 중복 방지 메커니즘 없음 → **개선 여지**
---
## 8. 예외 처리와 응답 구조
### Result 패턴
- **자체 구현 `Result<T>`** 클래스 사용 (C#의 Result 패턴 차용)
- 예외를 던지지 않고 **성공/실패를 값으로 반환**
- `Result.ok(value)` / `Result.fail(error)` / `Result.fail(errors)`
### ResultError 계층
```
ResultError (인터페이스)
├── SimpleError (단순 code + message)
├── NotFoundError (404)
├── ForbiddenError (403)
├── ConflictError (409)
├── BadRequestError (400)
├── GoneError (410)
└── ServerError (500)
```
- `getCode()` = HTTP Status Code (예: 404, 403, 409)
- `getStatus()` = `HttpStatus.valueOf(code)`
- `getResponse()` = `new ErrorResponse(status, message)`
### Global Exception Middleware
- **코드에서 확인되지 않음** (`@ControllerAdvice` 없음)
- Controller가 직접 `result.isFailure()`를 체크하고 `ErrorResponse`로 변환
- `ResultException`(runtime)은 존재하나 이를 처리하는 Global Handler 미확인
### 공통 ErrorResponse 구조
```json
{
"StatusCode": "NOT_FOUND", // HttpStatus enum name
"Message": "..."
}
```
- `StatusCode` 필드: PascalCase 사용 (일관성 이슈)
- `Message` 필드: 한글/영문 혼용
### 에러 코드 체계
- HTTP Status를 에러 코드로 사용
- Custom 에러 코드 체계 없음 (예: `ERR-001` 같은 비즈니스 에러 코드)
### 로그 레벨 구분
- `log.info`: 정상 흐름 (깃허브 데이터, AI 요청 페이로드)
- `log.warn`: OpenVidu webhook 파싱 실패
- `log.error`: Redis 메시지 처리 실패, 엔티티 미발견, 작업 처리 실패
### 프론트엔드 일관 처리
- 모든 실패가 `ErrorResponse(statusCode, message)` + HTTP Status로 통일 → **일관된 처리 가능**
- SSE Error 이벤트에도 동일한 `ErrorResponse` 구조 사용
---
## 9. 인프라 구성
### Dockerfile 존재 여부
- **didit-server**: 있음 (`apps/server/Dockerfile`) - Multi-stage build (Build: `eclipse-temurin:21-jdk`, Runtime: `eclipse-temurin:21-jre`)
- **didit-client**: 있음 (`apps/client/Dockerfile`) - (내용 확인 필요)
- **AI Worker**: **확인 필요** (Dockerfile 미발견)
### Docker Compose 구성
#### 로컬 개발용 (`apps/server/docker-compose.yml`)
```yaml
services:
db: MySQL 8.0 (3306 노출)
openvidu: OpenVidu dev 2.32.1 (4443 노출)
redis: Redis 7.2-alpine (6379 노출)
```
- 백엔드 서버 자체는 로컬 IDE에서 실행
- `healthcheck`: mysqladmin ping (MySQL only)
#### 배포용 (`infra/docker-compose/docker-compose.yml`)
| 서비스 | 이미지 | 네트워크 | 포트 | 설명 |
|--------|--------|----------|------|------|
| db | mysql:8.0 | internal only | expose 없음 | MySQL, healthcheck 적용 |
| server | ghcr.io/pjtdidit/server-image:latest | internal + caddy_default | expose 8080 | Spring Boot |
| client | ghcr.io/pjtdidit/client-image:latest | internal + caddy_default | - | React 정적 서빙 |
| openvidu | openvidu/openvidu-dev:2.32.1 | internal only | expose 없음 | WebRTC SFU |
| redis | redis:7.2-alpine | internal only | 6379 (외부 노출) | Message Broker |
### 서비스별 컨테이너 역할
- **db**: 영속 데이터 저장 (MySQL 8.0, utf8mb4, Asia/Seoul timezone)
- **server**: API 서버 (HTTP API, SSE, Webhook 수신)
- **client**: 정적 파일 서빙 (React SPA)
- **openvidu**: WebRTC 미디어 서버 (SFU, recording)
- **redis**: 작업 큐, Pub/Sub, client key 저장소
### 내부 네트워크/외부 네트워크 구성
- `internal` (bridge): db, server, client, openvidu, redis 간 통신
- `caddy_default` (external): server ↔ client ↔ Reverse Proxy 통신용
### Volume 마운트 구조
- `mysql-data:/var/lib/mysql` (MySQL 데이터 영속화)
- `redis_data:/data` (Redis AOF 데이터 영속화)
### Healthcheck
- **MySQL**: `mysqladmin ping` (10s interval, 5s timeout, 10 retries)
- **Server**: depends_on db(healthy), 자체 healthcheck 없음 → **개선 여지**
- **Redis**: 별도 healthcheck 없음
### depends_on 조건
- `server` depends_on `db` with `condition: service_healthy`
- 나머지 서비스 간 의존성 명시 안 됨
### 포트 노출 정책
- 배포 환경: 모든 DB/서버 포트가 **주석 처리** (외부 직접 접근 불가)
- 단, **Redis 6379가 환경변수로 외부 노출** 가능 → 보안상 개선 여지 있음
### 인프라 구성 설계 의도
- 단일 서버에 Docker Compose로 올인원 배포 (소규모 프로젝트에 적합)
- `internal` 네트워크로 서비스 간 통신 격리
- `caddy_default` external 네트워크로 Reverse Proxy와만 API 통신 허용
---
## 10. Reverse Proxy / 도메인 / HTTPS
### Caddy/Nginx 사용 여부
- **Traefik** 사용 (설계 문서상 명시)
- 실제 배포 docker-compose에는 `caddy_default` external 네트워크로 연결 → **Caddy 사용 가능성**도 있음 (네트워크명이 caddy_default)
### 도메인 라우팅 구조
- Frontend: `https://did-it.xyz`
- API: `https://api.did-it.xyz/api/v1`
- CORS 허용 Origin: `https://did-it.xyz`
### subdomain 구성
- `did-it.xyz` → 클라이언트
- `api.did-it.xyz` → 백엔드 API 서버
### HTTPS 적용 방식
- Traefik/Caddy에서 HTTPS 종단 처리
- Spring Boot 자체는 HTTP(8080), Cookie `Secure=true` → Reverse Proxy가 HTTPS 처리하고 있음을 전제
### reverse_proxy 대상
- `/` 및 정적 리소스 → `didit-client`
- `/api/v1/*``didit-server:8080`
- `/oauth2/*``didit-server:8080`
### 외부 포트 노출 최소화
- Docker Compose에서 MySQL, OpenVidu, Server의 포트가 **명시적으로 주석 처리**되어 외부에서 직접 접근 불가
- Redis 포트(`6379`)는 환경변수로 노출되도록 열려 있음 → **개선 필요**
---
## 11. 환경변수와 설정 관리
### .env 구조
- `.env.example` 파일 존재 (14개 변수)
- 주요 변수: `MYSQL_*`, `GITHUB_*`, `OPENVIDU_*`, `REDIS_*`
### 개발/운영 설정 분리
- `application.yml` (공통)
- `application-prod.yml` (운영 전용: Swagger 비활성화, HikariCP Pool 튜닝, Tomcat Thread 설정)
- `SPRING_PROFILES_ACTIVE=prod` 환경변수로 활성화
### DB 연결 문자열 구성 방식
```yaml
url: jdbc:mysql://${MYSQL_HOST:db}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:didit}?serverTimezone=Asia/Seoul&characterEncoding=utf8
```
- 환경변수 우선, 기본값 fallback (`MYSQL_HOST:db`)
- `MYSQL_USER`, `MYSQL_PASSWORD`도 환경변수에서 주입
### Redis 연결 설정
- 환경변수: `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`
- timeout: 2s
- Password 기반 인증 (`requirepass`)
### Secret 관리 방식
- GitHub Client ID/Secret, OpenVidu Secret: 환경변수로 주입
- `.env.example`은 Git에 커밋되어 있으나, 실제 값은 배포 환경에서 별도 관리
- **Vault/Secret Manager 미사용** → 개선 여지
### ASP.NET Options 바인딩
- 해당 없음 (Java / Spring Boot 프로젝트)
- Spring Boot `@ConfigurationProperties` 사용 (`OpenViduProperties`, `OpenViduWebhookProperties`)
### 설정 검증
- Bean Validation을 통한 설정 검증 미확인
- `@Validated` + `@ConfigurationProperties` 조합 사용 가능하나 구현되지 않음
---
## 12. 로그 / 모니터링 / 운영
### 로깅 구조
- **SLF4J + Logback** (Spring Boot 기본)
- `@Slf4j` (Lombok)으로 Logger 주입
### 구조화 로그 사용 여부
- JSON 구조화 로그: **미사용** (일반 텍스트 로그)
- `log.info("깃허브 데이터: {}", attributes)`
### 요청/응답 로그
- Spring MVC 요청 로깅 **확인 필요** (Access Log 미구현 가능성)
### 예외 로그
- `log.error("Redis 메시지 처리 중 에러 발생: {}", e.getMessage())`
- `log.error("워커 실행 중 오류 발생: %s", str(e), exc_info=True)` (Python)
### 로그 레벨 기준
- INFO: 정상 비즈니스 흐름
- WARN: 복구 가능한 오류 (webhook 파싱 실패)
- ERROR: 실패한 작업 처리
### 운영 도구 사용 여부
- **Dozzle**: 미확인
- **Grafana/Loki**: 미확인
- **Portainer**: 배포 트리거로 사용 (`PORTAINER_WEBHOOK_URL`)
- AI Worker: `/health`, `/health/redis` Healthcheck 엔드포인트 존재
### Healthcheck
- AI Worker: `GET /health`, `GET /health/redis` → FastAPI 제공
- Server: Spring Boot Actuator Healthcheck **확인 필요** (의존성 없음)
- DB: docker-compose healthcheck (mysqladmin ping)
- Redis: 별도 healthcheck 없음
---
## 13. CI/CD / 배포 자동화
### GitLab CI/CD 구성
**`.gitlab-ci.yml` (Root Pipeline)**
- Trigger: Merge Request Event + Master Push
- Stages: `test``build``deploy`
- Docker BuildKit 활성화
**Server Pipeline (`.gitlab/ci/server.yml`)**
| Job | Stage | Trigger | Actions |
|-----|-------|---------|---------|
| `test_server` | test | MR (apps/server 변경 시) | `./gradlew --no-daemon test` → JUnit report artifact |
| `build_push_server` | build | Master Push (apps/server 변경 시) | Docker build + push to `ghcr.io/pjtdidit/server-image:latest` |
**Deploy Pipeline (`.gitlab/deploy.yml`)**
| Job | Stage | Trigger | Actions |
|-----|-------|---------|---------|
| `deploy_portainer` | deploy | Master Push (변경 시) | POST to Portainer Webhook → Redeploy |
### Docker image build
- Multi-stage Dockerfile (BuildKit 캐시 마운트 활용)
- Build Stage: Gradle wrapper → bootJar
- Runtime Stage: `eclipse-temurin:21-jre`, non-root user (`appuser`)
- JVM 옵션: `-XX:MaxRAMPercentage=75`
### Migration 실행 방식
- Flyway 자동 실행 (애플리케이션 시작 시)
- CI/CD에서 별도 migration step 없음
### 브랜치 전략
- `master` = 기본 브랜치 (production)
- Merge Request 기반 코드 리뷰 흐름
- Revert 이력 존재 (`revert-b0a13fa0`)
### 배포 실패 시 대응 방식
- Portainer Webhook 호출 실패 시 `set -eu`로 Pipeline 중단
- **Rollback 전략 확인 필요**
### 향후 개선할 배포 구조
- AI Worker의 CI/CD **미구성** (`.gitlab/ci/ai.yml` 주석 처리)
- Client의 CI/CD 존재 (`.gitlab/ci/client.yml`)
- Test 단계에서 Testcontainers 사용 → 실제 DB 의존성 없는 테스트 가능
---
## 14. 성능 최적화
### Pagination 적용 여부
- **적용**: `PageRequest.of(page, 10)` (프로젝트 목록)
- **Spring Pageable**: size 기본값 20
### Projection DTO 조회 여부
- **미적용**: Entity 전체 조회 후 `stream().map(Response::fromEntity)`로 변환
- `SELECT *` 형태로 모든 컬럼을 읽어옴 → **개선 여지**
### AsNoTracking 사용 여부
- JPA `@Transactional(readOnly = true)` → Hibernate가 flush 모드를 MANUAL로 설정하나 영속성 컨텍스트는 사용
- `AsNoTracking`에 해당하는 명시적 설정 없음
### Include/N+1 문제 처리
- `FetchType.LAZY` 사용 (기본 전략)
- `findTop5ByProject_IdAndStatusOrderByUpdatedAtDesc`: ProjectEntity, AuthorEntity lazy → 응답 변환 시 N+1 가능성 있음
- `findAllByProject_Id`: Assignee 컬렉션 @OneToMany → 응답 변환 시 N+1 가능성 있음
- `@EntityGraph` 또는 JOIN FETCH **미사용****개선 여지**
### Index 적용 여부
- 외래키 컬럼 전반에 인덱스 적용: `idx_projects_repo_id`, `idx_issues_project_id`, `idx_meetings_project_id`, `idx_chats_project_created_at`
- 만료 시간 조회: `idx_project_invites_expires_at`
- 복합 인덱스: `(project_id, created_at)`, `(meeting_id, created_at)`
### Cache 적용 여부
- 명시적 캐시: **미적용** (Spring Cache, @Cacheable 없음)
- Redis는 작업 큐/PubSub 용도로만 사용
### 응답 시간 측정 또는 개선 수치
- **확인 필요** (APM 도구 없음)
---
## 15. 설계 문서화
### README 구조
- `readme.md`: 저장소 구조, 디렉토리 가이드
- `exec/readme.md`: 실행 관련 (빌드/설정/시나리오)
### API 문서
- `apps/server/docs/API.md`: 상세 API 문서 (엔드포인트, DTO, SSE 이벤트)
- `apps/server/docs/openapi.yaml`: OpenAPI 3.0.3 명세
- `docs/API/`: Swagger/Redoc 관련 파일
### ERD
- `docs/기획/ERD/`: Logical ERD Model (Mermaid, SVG)
- `docs/설계/Physical ERD Model.md`: Mermaid ERD 다이어그램 + 테이블 상세 명세
### 아키텍처 다이어그램
- `docs/설계/Architecture.md`: 기술 스택 구성도 (SVG)
- `docs/설계/svg/architecture.svg`: 전체 구성도
- `docs/설계/svg/PERD.svg`: 물리 ERD
### 시퀀스 다이어그램
- **확인 필요** (별도 시퀀스 다이어그램 미발견)
- SSE 이벤트 흐름: `docs/컨벤션/svg/sse_arch.svg`, `sse_flow.svg`
### 기술 선택 이유 문서 / ADR
- **미발견** (ADR 디렉토리 없음)
### Mermaid 다이어그램 사용 여부
- **사용**: Physical ERD Model, SSE 문서 등에서 Mermaid 사용
---
## 16. 문제 해결 사례 후보
### 사례 1: AI 작업 결과의 실시간 클라이언트 전달
**문제 상황**: AI Worker가 이슈 우선순위 분석 완료 후, **특정 사용자에게만** 결과를 전달해야 함 (다중 사용자 환경)
**원인 분석**: SSE broadcasting만으로는 모든 구독자에게 전파되므로, 특정 사용자에게만 선택적 전달이 필요
**해결 방법**:
- Redis에 `sse:client_key:{userId}` 형태로 clientKey 저장
- AI 결과 수신 시 `RedisSubscribeListener`가 Redis에서 clientKey 조회
- `SseHub.broadcastToClient(projectId, clientKey, "ai_analysis_result", ...)`**사용자 타겟팅**
**선택 이유**: 동일 유저의 다중 탭/디바이스를 구분하면서도, Redis를 활용해 무상태(stateless) 서버 간 확장 가능
**결과**: AI 분석 결과가 요청한 사용자에게만 실시간 전달, 불필요한 브로드캐스트 방지
---
### 사례 2: LEFT 상태 사용자 재참여 처리
**문제 상황**: 탈퇴했던 사용자가 초대 링크로 재참여 시, **UK 제약 위반** 없이 기존 레코드를 재활용해야 함
**원인 분석**: `project_users``UK(project_id, user_id)`가 있어 새 레코드 INSERT 불가
**해결 방법**: `AddProjectUser` 메서드에서:
1. 기존 레코드 존재 확인 (`findByProject_IdAndUser_Id`)
2. `ACTIVE` 상태면 `ConflictError`, `LEFT` 상태면 상태를 `ACTIVE`로 복원 + `role=MEMBER` + `leftAt=null`
3. 레코드가 없으면 신규 생성
**선택 이유**: DB 레벨 UK 제약을 유지하면서도, Soft한 상태 변경으로 사용자 이력을 보존 (감사 추적 가능)
**결과**: 탈퇴했던 사용자도 초대 코드로 원활히 재참여 가능, 중복 참여 방지
---
### 사례 3: AI 분석 워크플로우의 비동기 처리
**문제 상황**: HuggingFace 모델 추론이 수 초~수십 초 소요되어, REST API 요청-응답 사이클 안에서 처리 불가
**원인 분석**: ML 추론은 HTTP 요청 타임아웃을 초과하는 장기 작업
**해결 방법**:
- **Redis List**를 작업 큐로 사용 (`queue:issue:priority:single`, `queue:issue:priority:sort`)
- 서버는 `leftPush`로 작업 등록 후 즉시 응답
- Python AI Worker가 0.5s polling으로 큐 소비
- 완료 시 **Redis Pub/Sub**으로 결과 발행 → 서버의 `RedisMessageListener`가 수신 → DB 업데이트 + SSE 전송
**선택 이유**: 추가 인프라(RabbitMQ/Kafka) 없이 기존 Redis로 큐/메시징 통합 구현, 운영 단순화
**결과**: ML 추론 시간과 API 응답성이 완전히 분리됨
---
### 사례 4: GitHub 이슈와 로컬 DB의 양방향 동기화
**문제 상황**: GitHub에서 이슈를 가져올 때, 기존 로컬 DB에 있는 이슈는 업데이트하고 없는 이슈는 새로 생성해야 함
**원인 분석**: GitHub이 Source of Truth지만, AI 우선순위 등 로컬 메타데이터를 추가로 관리해야 함
**해결 방법**: `getGithubIssues()` 메서드에서:
1. GitHub API로 레포지토리 이슈 일괄 조회
2. `existsByGithubIssueId(ghIssue.getId())` → 존재하면 title/body/status 업데이트
3. 미존재 시 신규 Entity 생성 (priority 기본값 MEDIUM)
4. Assignee 정보 GitHub→로컬 UserEntity mapping
5. 모든 이슈에 대해 AI 단일 분석 + 배치 정렬 큐 추가
**선택 이유**: Upsert 패턴으로 데이터 중복 없이 동기화, `github_issue_id`를 natural key로 사용
**결과**: GitHub과 로컬 DB 간 일관성 유지, 신규 이슈 자동 등록, AI 분석 파이프라인 연계
---
## 17. 포트폴리오 문장 초안
### 프로젝트 개요
"Didit은 GitHub 생태계에 특화된 실시간 팀 협업 플랫폼입니다. GitHub OAuth2 로그인, OpenVidu 기반 화상회의, Redis Pub/Sub 기반 AI 비동기 분석, SSE 실시간 이벤트 스트리밍을 하나의 Spring Boot 서버로 통합한 B2B SaaS 프로젝트입니다."
### 담당 역할
"백엔드 개발자로서 Spring Boot 4.0 서버의 전체 API 설계 및 구현, 인증/인가 체계 수립, 데이터베이스 스키마 설계(Flyway), Docker Compose 기반 인프라 구성, GitLab CI/CD 파이프라인 구축을 담당했습니다."
### 주요 기여
- 30개 이상의 RESTful API 엔드포인트 설계 및 구현
- GitHub OAuth2 + Session Cookie 인증 체계 구축
- Redis 기반 AI 작업 큐 + Pub/Sub 메시징 아키텍처 설계
- SSE(Server-Sent Events) 채널/프로젝트 단위 실시간 이벤트 시스템 구현
- Flyway + JPA validate 기반 데이터베이스 마이그레이션 관리
### 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|-----------|
| **Java 21 + Spring Boot 4.0** | 최신 LTS 버전, Virtual Threads 등 최신 기능 활용, 강력한 생태계 |
| **MySQL 8.0** | FK 제약, UK 제약으로 데이터 정합성 보장, utf8mb4 지원 |
| **Redis 7.2** | 빠른 메모리 기반 작업 큐, Pub/Sub 메시징, 경량 인프라 |
| **Flyway** | 선언적 DB 마이그레이션, 모든 변경 이력 추적, Clean 비활성화로 안전 |
| **GitHub OAuth2** | 개발자 타겟, 별도 회원가입 불필요, repo/org 권한 자연스러운 연계 |
| **SSE + Redis Pub/Sub** | WebSocket보다 가벼운 실시간 통신, Redis로 확장 가능한 이벤트 브로커 역할 |
| **Docker Compose** | 5개 서비스의 로컬/배포 환경 일관성 확보, 단일 서버 올인원 배포 |
| **Portainer + GitLab CI/CD** | Docker 이미지 자동 빌드 → Webhook 기반 배포 트리거, 운영 단순화 |
### 아키텍처 설계
"전통적인 3계층(api/service/data) 구조를 채택하여 HTTP/비즈니스/데이터 책임을 명확히 분리했습니다. AI 분석 작업은 별도 Python Worker로 분리하고 Redis를 중간 메시지 브로커로 사용하여, ML 추론 부하가 API 응답성에 영향을 주지 않도록 설계했습니다. 모든 서비스는 Docker Compose로 오케스트레이션되며, internal 네트워크로 보안 격리하고 Traefik Reverse Proxy를 통해 HTTPS를 종단 처리합니다."
### API 설계
"RESTful 원칙에 따라 `/api/v1/` 기반의 URL 버저닝을 적용하고, Spring Pageable과 커서 기반의 이중 페이징 전략을 사용했습니다. Java의 Result 패턴을 도입하여 성공/실패를 값으로 반환하며, NotFound/Forbidden/Conflict/Gone/ServerError 등 의미 있는 에러 타입을 체계화했습니다. OpenAPI 3.0.3 명세를 Swagger UI와 연동하여 API 문서화를 자동화했습니다."
### 인프라 구성
"MySQL 8.0(utf8mb4), Redis 7.2(AOF), OpenVidu 2.32.1, Spring Boot Server, React Client 등 5개 서비스를 Docker Compose로 구성했습니다. internal bridge 네트워크로 내부 서비스를 격리하고, MySQL healthcheck에 대한 depends_on 조건을 설정하여 서비스 시작 순서를 보장했습니다. Multi-stage Dockerfile로 이미지 크기를 최적화하고, BuildKit 캐시 마운트로 빌드 시간을 단축했습니다."
### 문제 해결 사례
1. **AI 결과의 사용자 타겟팅 전달**: Redis에 clientKey를 저장하고, SSE Hub에서 대상 사용자에게만 broadcastToClient() 하는 방식으로 해결했습니다.
2. **탈퇴 사용자 재참여 처리**: UK 제약을 유지하면서 LEFT → ACTIVE 상태 변경으로 재참여를 처리하여 데이터 일관성을 보장했습니다.
3. **ML 추론-API 응답 분리**: Redis List/ Pub/Sub을 활용한 비동기 작업 큐 패턴으로, 장시간 ML 추론이 API 응답에 영향을 주지 않도록 설계했습니다.
4. **GitHub-로컬DB 이슈 동기화**: `github_issue_id`를 natural key로 Upsert 패턴을 구현하여 GitHub↔로컬 데이터 일관성을 유지했습니다.
### 프로젝트 성과
- 단일 서버에서 화상회의 + 채팅 + GitHub 이슈 관리 + AI 분석 통합 제공
- Flyway로 12회 DB 마이그레이션을 무중단 적용
- Docker Compose 기반 원커맨드 배포 체계 구축 (CI/CD + Portainer)
- Spring Boot, Python, MySQL, Redis, OpenVidu 등 5+ 기술 스택 통합 경험
### 회고
"이번 프로젝트를 통해 단일 백엔드 서버가 API, SSE, Webhook 수신, 외부 API 연동 등 다양한 역할을 수행할 때의 설계적 고려사항을 깊이 있게 경험했습니다. 특히 Redis를 단순 캐시가 아닌 작업 큐+Pub/Sub 메시지 브로커로 활용한 점이 인상적이었습니다. 다만, Global Exception Handler나 선언적 인가(@PreAuthorize), API 응답 시간 모니터링 등 프로덕션 레벨에서 필요한 부가적인 부분들을 더 보강하고 싶습니다."
---
> **부록**: 본 문서는 `apps/server/`, `apps/ai/`, `infra/`, `.gitlab/`, `docs/` 디렉토리의 실제 코드 및 설정 파일을 분석하여 2026-05-05 기준으로 작성되었습니다.

View File

@@ -0,0 +1,579 @@
# TusBlazorClient
## 1. 프로젝트 목적
### 해결하려는 문제
.NET Blazor WebAssembly 환경에서 **대용량 파일 업로드**를 안정적으로 처리하는 것. Blazor WASM의 순수 C# 파일 I/O는 속도가 느리고 전송 가능한 파일 크기에 제한이 있어, 대용량 파일 전송이 실질적으로 어렵다.
### 기존 방식의 불편함
- Blazor WASM에서 순수 C# 코드로 대용량 파일을 전송할 경우, 브라우저의 메모리 제약과 느린 I/O 속도로 인해 전송이 실패하거나 브라우저가 멈추는 현상이 발생한다.
- 네트워크 중단 시 처음부터 다시 업로드해야 하므로, 대용량 파일일수록 실패 확률이 높아진다.
- 기존 `tus-js-client`는 JavaScript 라이브러리이므로, Blazor C# 환경에서 직접 사용하기 어렵다.
### 라이브러리 사용 시 단순해지는 부분
- JavaScript 라이브러리를 직접 다루지 않고, **C# 네이티브 API**로 tus 프로토콜 업로드를 사용할 수 있다.
- DI 컨테이너에 `AddTusBlazorClient()` 한 줄로 등록 후 `TusClient`를 주입받아 즉시 사용 가능하다.
- 파일 선택, 업로드 생성, 진행률 추적, 재개, 중단, 옵션 변경까지 모두 C#에서 타입 세이프하게 처리된다.
### 주요 사용자
.NET Blazor WebAssembly 개발자. 특히 대용량 파일 업로드(동영상, 이미지, 문서 등)가 필요한 웹 애플리케이션을 구축하는 개발자.
### 한 줄 설명
"Blazor WebAssembly에서 tus 프로토콜 기반의 재개 가능한 대용량 파일 업로드를 C# API로 제공하는 래퍼 라이브러리"
---
## 2. Public API 설계
### 주요 클래스/인터페이스/메서드
| 클래스 | 역할 | 주요 멤버 |
|--------|------|----------|
| `TusClient` | 진입점 (Singleton) | `Upload()`, `IsSupported()`, `CanStoreUrls()`, `GetFileInputElement()` |
| `TusUpload` | 단일 업로드 작업 | `Start()`, `Abort()`, `Terminate()`, `GetUrl()`, `GetFileInfo()`, `GetOptions()`, `SetOtions()`, `FindPreviousUpload()`, `ResumeFromPreviousUpload()` |
| `TusOptions` | 설정 클래스 | `Endpoint`, `ChunkSize`, `OnProgress`, `OnError`, `OnSuccess`, `OnShouldRetry`, `Headers`, `Metadata` 등 18개 프로퍼티 |
| `FileInputElement` | HTML input[type=file] 래퍼 | `GetFiles()`, `Length()` |
| `JsFile` | 선택된 단일 파일 | `ToJsObjectReference()`, `GetFileInfo()` |
| `JsFileInfo` | 파일 메타데이터 | `Name`, `Size`, `LastModified` |
| `SetupExtension` | DI 등록 | `AddTusBlazorClient()` |
### 기본 사용 흐름
```
1. DI 등록: builder.Services.AddTusBlazorClient()
2. Razor 컴포넌트에서 TusClient 주입
3. FileInputElement.GetFiles() 로 파일 선택
4. TusOptions 구성 (Endpoint, 콜백 등)
5. TusClient.Upload(file, options) 으로 TusUpload 생성
6. (선택) FindPreviousUpload() + ResumeFromPreviousUpload() 로 이어 올리기
7. TusUpload.Start() 호출
```
### Options/Config 구조
`TusOptions`는 **클래스**로 설계되어, 프로퍼티에 기본값이 지정되어 있다. 콜백 프로퍼티는 `[JsonIgnore]`로 마킹되어 JSON 직렬화 대상에서 제외된다 (JS로 전달 불가능한 .NET 델리게이트이기 때문).
### 비동기 API 설계
모든 I/O 관련 메서드는 `Task` 또는 `ValueTask`를 반환한다. JavaScript interop 호출이 비동기이기 때문에 전체 API가 비동기로 설계되어 있다.
### 사용 예시 코드
```csharp
@inject TusClient TusClient
<input type="file" @ref="_fileElement" />
<button onclick="@Upload">upload</button>
@code {
private ElementReference _fileElement;
private TusUpload? _tusUpload;
private async Task Upload()
{
var file = (await TusClient.GetFileInputElement(_fileElement).GetFiles()).First();
var fileInfo = await file.GetFileInfo();
var opt = new TusOptions
{
Endpoint = new Uri("http://localhost:1080/files"),
Metadata = new Dictionary<string, string> { { "filename", fileInfo.Name } },
OnError = (err) => Console.WriteLine($"Failed: {err.ErrorMessage}"),
OnProgress = (uploaded, total) => Console.WriteLine($"{(double)uploaded / total:P}"),
OnSuccess = async () => {
var url = await _tusUpload!.GetUrl();
Console.WriteLine($"Uploaded to {url}");
},
};
_tusUpload = await TusClient.Upload(file, opt);
var previousUploads = await _tusUpload.FindPreviousUpload();
if (previousUploads.Count > 0)
await _tusUpload.ResumeFromPreviousUpload(previousUploads.First());
await _tusUpload.Start();
}
}
```
### API 설계 의도
- **TusClient를 Singleton**으로 등록해 JS 모듈을 한 번만 로드하고 모든 업로드가 공유한다.
- **TusUpload는 내부 생성자**로 제한하여, 반드시 `TusClient.Upload()`를 통해서만 생성하도록 강제한다. 이로 인해 JS 콜백 브릿지(`DotNetObjectReference`)가 올바르게 연결되는 것을 보장한다.
- **Fluent API가 아닌 명령형 API**를 채택했다. 옵션 객체를 구성하고, 업로드 생성 후 명령을 내리는 직관적인 구조다.
---
## 3. 내부 구조와 책임 분리
### 주요 폴더 구조
```
TusBlazorClient/
├── TusClient.cs # Public API 진입점
├── TusOptions.cs # 설정 모델
├── TusUpload.cs # 단일 업로드 작업
├── TusError.cs # 오류 모델
├── TusHttpRequest.cs # HTTP 요청 DTO
├── TusHttpResponse.cs # HTTP 응답 DTO
├── TusPreviousUpload.cs # 이전 업로드 DTO
├── TusJsInterop.cs # .NET ↔ JS 브릿지 계층
├── TusOptionJsInvoke.cs # JS → .NET 콜백 수신기
├── TusOptionNullCheck.cs # Null 콜백 최적화 정보
├── FileInputElement.cs # File input 래퍼 + JsFile, JsFileInfo
├── SetupExtension.cs # DI 등록 확장 메서드
└── wwwroot/
└── tusBlazorClient.js # JS ↔ tus-js-client 브릿지
```
### 주요 클래스별 책임
| 클래스 | 책임 |
|--------|------|
| `TusClient` | 외부 API 진입점, 업로드 생성 팩토리 |
| `TusJsInterop` | `IJSRuntime`을 통한 JavaScript ES 모듈 호출 관리, Lazy 초기화 |
| `tusBlazorClient.js` | `tus-js-client`의 옵션 변환, 콜백 null 체크, HTTP 헤더 파싱 |
| `TusUpload` | 단일 업로드의 생명주기 관리, 옵션 동적 변경 |
| `TusOptionJsInvoke` | `[JSInvokable]` 메서드로 JS에서 .NET 델리게이트 호출 중계 |
| `TusOptionNullCheck` | JS 측에서 불필요한 콜백 호출을 방지하는 정보 제공 |
### 인터페이스와 구현체 분리
**인터페이스가 별도로 추출되어 있지 않다.** `TusClient``TusJsInterop` 모두 구체 클래스만 존재한다. 이는 라이브러리 규모가 작은 점을 고려한 선택으로 보인다. 테스트는 E2E 테스트(Selenium)로 커버하고 있어 Mocking이 필요하지 않다.
### 내부 구현과 외부 API 분리
- `TusUpload` 생성자는 **internal**로 제한되어 있다. 외부 사용자는 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻을 수 있다.
- `TusJsInterop`는 **internal 멤버**를 통해 `TusUpload`, `FileInputElement` 등 내부 클래스만 접근 가능한 메서드를 노출한다.
- `TusOptionJsInvoke`, `TusOptionNullCheck`는 외부에 노출되지 않고 `TusClient` 내부에서만 생성된다.
### 확장 가능한 구조
`TusOptions`에 새로운 프로퍼티를 추가하는 방식으로 기능 확장이 이루어지며, `tusBlazorClient.js``GetUploadOption`에서도 동일한 옵션을 매핑해야 한다. 현재 확장 포인트가 인터페이스로 정형화되어 있지 않다.
### 디자인 패턴 사용
| 패턴 | 적용 위치 |
|------|----------|
| **Singleton** | `TusClient`를 DI Singleton으로 등록 |
| **Adapter (래퍼)** | `tus-js-client`를 C#에 맞게 감싼 전체 구조가 Adapter 패턴 |
| **Builder-like** | `TusOptions` 객체를 구성한 후 업로드 생성 → Builder 패턴의 변형 |
| **IDisposable/AsyncDisposable** | `TusClient`, `TusUpload`, `TusJsInterop` 모두 구현 |
| **Extension Method** | `SetupExtension.AddTusBlazorClient()` |
---
## 4. 설정과 확장성
### 설정 가능한 옵션 목록 (TusOptions)
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `Endpoint` | `Uri` | **(필수)** | 업로드 생성 URL |
| `ChunkSize` | `long?` | `null` (Infinity) | PATCH 요청 본문 최대 크기 (byte) |
| `OnProgress` | `Action<long, long>?` | `null` | 진행률 콜백 (bytesSent, bytesTotal) |
| `OnChunkComplete` | `Action<long, long, long>?` | `null` | 청크 완료 콜백 |
| `OnSuccess` | `Action?` | `null` | 업로드 성공 콜백 |
| `OnError` | `Action<TusError>?` | `null` | 오류 발생 콜백 |
| `OnShouldRetry` | `Func<TusError, long, bool>?` | `null` | 재시도 결정 콜백 |
| `Headers` | `Dictionary<string, string>` | `new()` | 커스텀 HTTP 헤더 |
| `Metadata` | `Dictionary<string, string>` | `new()` | 업로드 생성 시 메타데이터 |
| `UploadUrl` | `Uri?` | `null` | 직접 재개용 URL |
| `RetryDelays` | `List<int>` | `[0, 1000, 3000, 5000]` | 재시도 대기 시간 (ms) |
| `StoreFingerprintForResuming` | `bool` | `true` | URL 저장소에 fingerprint 저장 |
| `RemoveFingerprintOnSuccess` | `bool` | `false` | 업로드 완료 시 fingerprint 제거 |
| `UploadLengthDeferred` | `bool` | `false` | Upload-Defer-Length 헤더 사용 |
| `UploadDataDuringCreation` | `bool` | `false` | 생성-with-upload 확장 사용 |
| `AddRequestId` | `bool` | `false` | 랜덤 Request ID 추가 |
| `ParallelUploads` | `int` | `1` | 병렬 업로드 수 |
| `ParallelUploadBoundaries` | `List<(int, int)>?` | `null` | 병렬 업로드 파트 경계 |
### 기본값 제공 방식
`TusOptions` 클래스에서 프로퍼티 초기화로 기본값을 제공한다. `new TusOptions()`만 생성해도 `RetryDelays`, `Headers`, `Metadata`, `StoreFingerprintForResuming`, `ParallelUploads` 등이 유효한 기본값을 가진다.
### 설정 검증 방식
명시적인 설정 검증 로직은 발견되지 않는다. JavaScript 레벨에서 `tus-js-client`가 자체적으로 검증할 것으로 추정된다. **(확인 필요)**
### 확장 지점
- 콜백: `OnBeforeRequest`, `OnAfterResponse`를 통해 모든 HTTP 요청/응답을 가로채고 분석할 수 있다.
- `OnShouldRetry`로 재시도 로직을 사용자가 완전히 제어할 수 있다.
- `Headers`로 인증 토큰 등 커스텀 헤더 주입이 가능하다.
- 인터페이스 기반 확장 포인트는 존재하지 않으며, 상속을 통한 교체도 지원되지 않는다.
### API 호환성 설계
`TusOptions`는 클래스이므로 새 프로퍼티 추가 시 기존 코드가 깨지지 않는다. `TusUpload.SetOtions()` API가 있어 업로드 실행 중에도 옵션 변경이 가능하다.
---
## 5. 오류 처리
### 예외 처리 방식
- `TusClient`에서 `IsSupported()`, `CanStoreUrls()` 등 간단한 조회 메서드는 `try-catch`로 감싸 실패 시 `false`를 반환한다.
- 그 외 직접적인 예외 throw/catch 패턴보다는 **콜백 기반 오류 전달**을 주로 사용한다.
### Result/Error 타입 사용
별도의 Result 타입은 없지만, `TusError`라는 전용 오류 클래스가 존재한다. `TusError`는 오류 메시지와 함께 실패한 요청/응답의 상세 정보를 포함한다.
### 사용자 입력 오류와 시스템 오류 구분
코드에서 명시적으로 구분하고 있지 않다. 모든 오류는 `OnError` 콜백을 통해 `TusError`로 전달된다. 네트워크 오류, HTTP 4xx/5xx 오류 모두 동일 콜백으로 수신된다.
### 커스텀 예외/에러 코드
별도 정의되어 있지 않다. `tus-js-client`의 오류 문자열을 그대로 `ErrorMessage`에 담아 전달한다.
### 실패 원인 전달 방식
`TusError`는 다음 정보를 외부 사용자에게 전달한다:
- `ErrorMessage` (string): 오류 설명
- `OriginalHttpRequest` (TusHttpRequest?): 실패한 요청의 HTTP 메서드, URL
- `OriginalHttpResponse` (TusHttpResponse?): 응답의 StatusCode, Body, Headers
이 구조를 통해 사용자는 네트워크 레벨에서 무슨 일이 일어났는지 상세히 파악할 수 있다.
### 재시도 가능/불가능 오류 구분
`OnShouldRetry` 콜백에서 `TusError``retryAttempt`(long)을 받아 사용자가 직접 재시도 여부를 결정한다. 콜백을 지정하지 않으면 tus-js-client의 기본 재시도 로직(409, 423, 4XX 이외 상태코드 재시도)이 적용된다.
---
## 6. 상태 관리
### 상태 enum/객체
별도의 상태 enum은 존재하지 않는다. 업로드 상태는 tus-js-client 내부에서 관리되며, 콜백(`OnProgress`, `OnSuccess`, `OnError`)을 통해 상태 변화가 외부에 통지된다.
### 상태 전이 흐름
```
[생성: TusClient.Upload()]
├── FindPreviousUpload() → ResumeFromPreviousUpload() (선택적)
├── Start()
│ │
│ ├── OnProgress → OnChunkComplete → ... (진행 중)
│ │ │
│ │ ├── Abort(true/false) → 중단
│ │ │ └── Start() → 재개
│ │ │
│ │ ├── OnError → (OnShouldRetry) → 자동 재시도 or 중단
│ │ │
│ │ └── OnSuccess → 완료
│ │
│ └── Terminate(url) → 서버에서 완전히 제거
└── DisposeAsync() → 리소스 정리
```
### 완료/실패/취소 처리
- **완료**: `OnSuccess` 콜백 → 업로드 URL을 `GetUrl()`로 획득 가능
- **실패**: `OnError` 콜백 → `TusError`로 상세 정보 확인
- **취소**: `Abort(bool shouldTerminate)``shouldTerminate=true`면 서버에서도 삭제
### 중복 실행 방지
`TusClient.Upload()`는 상태를 확인하지 않고 항상 새 `TusUpload`를 생성한다. `TusUpload.Start()`를 중복 호출하는 것에 대한 방어 로직은 확인되지 않는다.
### 동시성 / Thread-Safe
Blazor WASM은 단일 스레드에서 실행되므로, 별도의 동시성 제어가 필요하지 않다. `lock`, `SemaphoreSlim` 등은 사용되지 않는다.
---
## 7. 사용성
### README 구조
README.md는 다음과 같은 구조로 잘 정리되어 있다:
1. 프로젝트 소개 (tus 프로토콜 설명)
2. 사용 동기 ("Why do I use this?")
3. 설치 방법 (CDN + NuGet + DI 등록)
4. 완전한 예제 코드
5. Wiki 링크 (상세 API 문서)
### Quick Start
README에 포함된 예제 코드가 Quick Start 역할을 동시에 수행한다. 별도의 튜토리얼 페이지는 없다.
### 샘플 프로젝트
`TusBlazorClient.Demo` 프로젝트가 존재하며, 13개의 페이지로 주요 기능을 모두 시연한다:
| 페이지 | 시연 기능 |
|--------|----------|
| Index | `IsSupported()`, `CanStoreUrls()` |
| Upload | 기본 업로드 |
| UploadByJsObjectReference | IJSObjectReference 직접 사용 |
| UploadResume | 업로드 중단 후 재개 |
| ResumeFromPreviousUpload | 이전 업로드 찾아 재개 |
| ShouldRetry | 무한 재시도 |
| ShouldNoRetry | 재시도 없음 |
| OnRequest | OnBeforeRequest/OnAfterResponse |
| SetOption | 업로드 중 옵션 변경 |
| GetOption | 옵션 조회 |
| GetFileInfo | 파일 메타데이터 |
### 최소 사용 코드
```csharp
@inject TusClient TusClient
<input type="file" @ref="_el" />
<button onclick="@Upload">upload</button>
@code {
private ElementReference _el;
private async Task Upload() {
var file = (await TusClient.GetFileInputElement(_el).GetFiles()).First();
var upload = await TusClient.Upload(file,
new TusOptions { Endpoint = new Uri("https://example.com/files") });
await upload.Start();
}
}
```
### XML 주석/문서화 수준
`TusOptions`의 모든 프로퍼티에 XML 문서 주석(`/// <summary>`)이 작성되어 있으며, `TusUpload.GetUrl()` 등 주요 메서드에도 주석이 있다. 단, `TusClient`, `TusJsInterop`, `FileInputElement`에는 주석이 거의 없다.
### 처음 사용자가 헷갈릴 수 있는 부분
- `TusOptions`의 콜백은 `[JsonIgnore]`이므로, `GetOptions()`으로 받은 옵션 객체를 그대로 `Upload()`에 재사용하면 콜백이 유실된다. `GetOptions()` 내부에서 수동으로 콜백을 merge하는 이유가 여기에 있다.
- `tus-js-client` CDN 로드를 잊으면 라이브러리가 동작하지 않는다.
- `Upload()`을 호출할 때마다 새 `DotNetObjectReference<TusOptionJsInvoke>`가 생성되므로, 이전 upload 객체를 `Dispose`하지 않으면 리소스 누수가 발생할 수 있다.
---
## 8. 테스트와 검증
### 테스트 프로젝트
`TusBlazorClient.Test` 프로젝트가 존재한다. NUnit 3.13.3 + Selenium WebDriver 4.12.4 (Firefox) 조합으로 E2E 테스트를 수행한다.
### 단위 테스트
**존재하지 않음.** 별도의 단위 테스트는 없다.
### 통합 테스트 (E2E)
8개의 Selenium E2E 테스트 케이스:
| 테스트 | 검증 내용 |
|--------|----------|
| `Upload()` | 기본 업로드 성공, 진행률 콜백, 청크 완료 콜백, 파일 이름 일치, 유효한 업로드 URL |
| `UploadByJsObj()` | `ToJsObjectReference()` 경로 업로드 성공 |
| `UploadResume()` | 중단 후 `Start()` 재개, 재개 시 첫 progress가 0이 아님 |
| `ResumeFromPreviousUpload()` | `FindPreviousUpload()` + `ResumeFromPreviousUpload()` 성공 |
| `ShouldRetry()` | `OnShouldRetry`가 true 반환 시 5회 이상 재시도 발생 |
| `ShouldNotRetry()` | `OnShouldRetry`가 false 반환 시 1회 이하 재시도 |
| `OnRequest()` | `OnBeforeRequest`, `OnAfterResponse` 발생, 응답에 `tus-resumable` 헤더 존재 |
| `SetOption()` | 업로드 중 `SetOtions()`으로 ChunkSize 변경, 변경된 청크 크기로 전송됨 |
| `GetOption()` | 업로드 성공 후 `GetOptions()`로 옵션 조회 가능 |
### 실패 케이스 테스트
`ShouldRetry()``ShouldNotRetry()` 두 테스트가 오류 시나리오를 커버한다. 의도적으로 틀린 URL로 업로드를 시도하여 오류 콜백과 재시도 동작을 검증한다.
### Mock/Fake
사용되지 않음. 모든 테스트는 실제 tus 서버(Docker로 구동된 `tusd`, `172.17.0.3:8080`)와 실제 Firefox 브라우저를 사용한다.
### 테스트가 부족한 부분
- **단위 테스트 부재**: `TusOptionJsInvoke`, `TusOptions`, `TusOptionNullCheck` 등 순수 C# 로직에 대한 단위 테스트가 없다.
- **병렬 업로드 테스트 부재**: `ParallelUploads` > 1 시나리오가 없음.
- **다양한 오류 시나리오 부족**: 네트워크 중단, 타임아웃, 서버 오류 등 세분화된 오류 케이스가 부재.
- **Chrome/Edge 브라우저 테스트 부재**: Firefox만 사용.
---
## 9. 패키징과 배포
### 패키지 배포 구조
- NuGet.org에 `TusBlazorClient` 패키지로 배포되어 있다.
- `GeneratePackageOnBuild=true`로 빌드 시 자동 패키징된다.
- `.sln` 파일과 세 개의 프로젝트로 구성된 표준 .NET 솔루션 구조다.
### 버전 관리
- Semantic Versioning 사용. 현재 버전 `1.0.1`.
- csproj에 `<Version>1.0.1</Version>`로 직접 명시.
- Git 히스토리 기준 약 20개의 커밋으로 진화됨.
### 패키지 메타데이터
```
Title: TusBlazorClient
Description: tus-blazor-client is a wrapper library project for tus-js-client
that can be used in .NET Blazor.
Copyright: MIT
PackageProjectUrl: https://github.com/thsdmfwns/tus-blazor-client
PackageLicenseUrl: (LICENSE 파일)
PackageTags: tus, blazor, wrapper, js, browser
PackageReleaseNotes: 1.0.0
Version: 1.0.1
```
### CI/CD
코드 저장소에서 GitHub Actions나 다른 CI/CD workflow 파일은 **존재하지 않는다**. **(확인 필요)**
### 빌드/테스트/패키징 명령어
- 빌드: `dotnet build`
- 테스트: `dotnet test` (단, Selenium E2E 테스트는 Firefox + Docker tusd 서버 필요)
- 패키징: 빌드 시 `GeneratePackageOnBuild=true`로 자동 생성
### 외부 프로젝트 사용 가능 상태
NuGet.org에 배포된 버전 1.0.1이 있고, 정상적으로 설치하여 사용할 수 있는 상태다. 단, 아래 선행 조건이 필요하다:
1. 대상 프로젝트가 Blazor WebAssembly여야 함
2. `index.html``tus-js-client` CDN 스크립트 추가 필요
3. DI에 `AddTusBlazorClient()` 등록 필요
---
## 10. 성능과 리소스 고려
### async/await 사용 방식
- 모든 JS interop 메서드는 `async`로 선언되어 있으며, `ValueTask`를 반환하여 불필요한 Task 할당을 방지한다.
- `IAsyncDisposable`을 구현하여 JS 리소스를 비동기적으로 정리한다.
### Stream/Buffer 처리
.NET 측에서는 Stream이나 buffer를 직접 다루지 않는다. 실제 파일 전송은 브라우저의 `tus-js-client`가 처리하며, .NET 측은 설정값을 전달하고 콜백을 수신하는 역할만 한다.
### 메모리 사용량을 줄이기 위한 구조
- **Lazy 모듈 로딩**: `TusJsInterop`는 JavaScript ES module을 최초 사용 시점까지 지연 로딩한다 (`InitializeAsync()`). 사용하지 않으면 모듈이 로드되지 않는다.
- **TusOptionNullCheck**: 콜백이 null인 경우 JavaScript에서 `invokeMethodAsync` 호출을 아예 건너뛰도록 하여, .NET↔JS 간 불필요한 마샬링을 제거한다.
- **onBeforeRequest / onAfterResponse 동기화**: 이 두 콜백은 내부 상태를 변경하지 않으므로 `invokeMethodAsync` 대신 `invokeMethod`(동기 호출)를 사용한다.
### 반복 객체 생성 최소화
- `TusClient`는 Singleton으로 등록되어 JS 모듈을 재사용한다.
- `TusOptionJsInvoke`, `TusOptionNullCheck`는 upload 생성 시마다 새로 생성된다 (각 업로드가 다른 콜백을 가질 수 있으므로 불가피).
### 성능 측정/벤치마크
**존재하지 않는다.** 코드 내 성능 측정이나 벤치마크 코드는 발견되지 않았다.
---
## 11. 포트폴리오용 문제 해결 사례 후보
---
### 사례 1: Blazor ↔ JavaScript 콜백 브릿지 설계
#### 문제 상황
`tus-js-client`는 업로드 진행 상황, 오류, 성공 등을 JavaScript 콜백으로 통지한다. 이 콜백을 C# Blazor에서 받아 개발자가 C# 델리게이트로 처리할 수 있도록 해야 했다.
#### 원인 분석
Blazor와 JavaScript 간 데이터 전달은 JSON 직렬화 기반으로 이루어지지만, C# 델리게이트(`Action`, `Func`)는 직렬화할 수 없다. 또한 JavaScript→.NET 호출은 `invokeMethodAsync`를 통해 이루어지는데, 콜백이 설정되지 않은 경우에도 호출이 발생하면 불필요한 오버헤드가 발생한다.
#### 해결 방법
1. **TusOptionJsInvoke** 클래스를 도입하여 모든 `[JSInvokable]` 메서드를 한 객체에 모았다. 이 객체를 `DotNetObjectReference`로 감싸 JavaScript에 전달했다.
2. 콜백 프로퍼티는 `[JsonIgnore]`로 직렬화에서 제외하고, JavaScript 옵션 구성용 실제 값만 전달했다.
3. **TusOptionNullCheck**를 별도로 생성하여 각 콜백의 null 여부를 JavaScript 측에 알려주고, JS 측에서 `if (optNullCheck.isNullOnError) return;` 과 같이 early return 하도록 했다.
4. `OnBeforeRequest`, `OnAfterResponse`처럼 내부 상태를 변경하지 않는 콜백은 `invokeMethod`(동기)로 호출하여 오버헤드를 더 줄였다.
#### 선택 이유
- `DotNetObjectReference`는 Blazor가 공식 제공하는 JS→.NET 호출 메커니즘으로, 가장 안정적인 방법이다.
- 별도의 인터페이스를 정의하지 않고 하나의 `TusOptionJsInvoke` 클래스에 모든 콜백을 통합한 이유는 JS 모듈에 전달할 .NET 참조를 단일 객체로 유지하기 위해서다.
#### 결과
- C# 개발자는 JavaScript를 한 줄도 작성하지 않고 순수 C# 델리게이트로 모든 이벤트를 처리할 수 있게 되었다.
- null 체크 최적화로 불필요한 JS→.NET 호출이 제거되어, 콜백을 일부만 사용하는 시나리오에서 성능이 개선되었다.
---
### 사례 2: 업로드 중 옵션 동적 변경 (SetOtions)
#### 문제 상황
업로드 진행 중에 ChunkSize를 변경하거나 새로운 콜백을 등록해야 하는 사용 사례가 있었다. tus-js-client는 생성 시점에 옵션을 설정하는 구조이지만, 업로드 옵션 객체를 직접 수정하면 반영될 수 있었다.
#### 원인 분석
`TusUpload` 생성 시 전달된 `DotNetObjectReference``TusOptionJsInvoke`는 옵션 변경 후에도 기존 콜백을 참조하고 있어, 옵션을 그냥 변경하면 콜백이 동기화되지 않는 문제가 있었다. 또한 JavaScript 측의 `tus.Upload.options` 객체의 개별 프로퍼티에 새 값을 할당해야 했다.
#### 해결 방법
1. `TusUpload.SetOtions(Action<TusOptions> setOption)` API를 설계했다.
2. 내부적으로 `GetOptions()` → callback merge → `setOption` 호출 → 새 `DotNetObjectReference` 생성 → `SetTusUploadOption` JS 호출의 파이프라인을 구현했다.
3. 기존 `_optionJsInvokeReference`를 dispose하고 새로 생성하여 콜백 참조를 갱신했다.
4. JS 측에서는 `upload.options.endpoint = opt.endpoint` 패턴으로 개별 프로퍼티를 in-place 업데이트했다.
#### 선택 이유
- 액션 기반 API (`Action<TusOptions>`)는 사용자가 변경하고 싶은 옵션만 선택적으로 수정할 수 있도록 해준다.
- `TusUpload` 인스턴스를 폐기하고 새로 생성하는 것보다 효율적이다.
#### 결과
- `SetOption` E2E 테스트로 검증되어 있다. 업로드 50% 지점에서 ChunkSize를 50000에서 15000으로 변경한 후 정상 재개되었다.
---
### 사례 3: 이전 업로드 탐지 및 재개 (FindPreviousUpload / ResumeFromPreviousUpload)
#### 문제 상황
브라우저를 종료했다 다시 열거나, 페이지를 실수로 새로고침한 경우, 진행 중이던 업로드를 이어서 진행할 수 있도록 하는 기능이 필요했다. tus-js-client는 `findPreviousUploads()` API를 제공하지만, 이 기능을 C#으로 노출해야 했다.
#### 원인 분석
`findPreviousUploads()`는 브라우저 URL 저장소에 저장된 이전 업로드 목록을 JavaScript 객체 배열로 반환한다. 이 정보를 C# DTO로 변환해야 하고, 사용자가 특정 업로드를 선택해 재개할 수 있도록 해야 했다.
#### 해결 방법
1. `findPreviousUploads()` 호출 결과를 `List<TusPreviousUpload>`로 역직렬화하는 `InvokeAsync` 호출을 구현했다.
2. 단순히 DTO만 반환하지 않고, `TusPreviousUploadRef` 래퍼를 도입해 `Index`를 함께 보관하도록 했다. 이 Index가 `resumeFromPreviousUpload(pres[index])` 호출에 필요하기 때문이다.
3. `TusUpload.FindPreviousUpload()``TusUpload.ResumeFromPreviousUpload(TusPreviousUploadRef)` API를 제공하여 사용자가 목록을 조회하고 특정 항목을 골라 재개할 수 있게 했다.
#### 선택 이유
- JavaScript 배열 인덱스는 JSON 역직렬화 과정에서 소실되므로, C# 측에서 명시적으로 Index를 추적하는 래퍼 클래스를 도입했다.
- 복잡한 상태 관리 없이, "목록 조회 → 선택 → 재개" 라는 단순한 흐름으로 구현했다.
#### 결과
- `ResumeFromPreviousUpload` E2E 테스트에서 검증되었다. 중단 후 `FindPreviousUpload()` + `ResumeFromPreviousUpload()`로 정상 재개된다.
- README 예제 코드에도 이 기능이 포함되어 있어 주요 사용 사례로 강조된다.
---
### 사례 4: JS ES 모듈의 Lazy 로딩 및 생명주기 관리
#### 문제 상황
`tusBlazorClient.js`는 Blazor의 JavaScript ES 모듈로 `import`를 통해 로드된다. 모듈을 사용하지 않을 때도 불필요하게 로드되거나, 싱글톤 `TusClient`의 생명주기에 맞춰 적절히 해제되어야 했다.
#### 원인 분석
Blazor에서 `IJSRuntime.InvokeAsync<IJSObjectReference>("import", ...)` 로 로드된 ES 모듈은 `IJSObjectReference`로 관리되며, `DisposeAsync()`로 해제할 수 있다. 로드가 비동기이므로 최초 호출 시점까지 지연시킬 수 있다.
#### 해결 방법
1. `TusJsInterop`에서 `_script`를 nullable로 선언하고, 모든 public/internal 메서드에서 `await InitializeAsync()`를 먼저 호출하도록 했다.
2. `InitializeAsync()``_script != null`인 경우 early return 하여 **한 번만 모듈을 로드**한다.
3. `IAsyncDisposable`을 구현하여 `_script.DisposeAsync()`로 모듈을 정리한다.
4. `TusClient`가 Singleton이고, `TusClient.DisposeAsync()`에서 `_tusJsInterop.DisposeAsync()`를 호출한다.
#### 선택 이유
- Lazy 로딩은 라이브러리를 사용하지 않는 페이지에서는 JS 모듈 로드 비용이 발생하지 않도록 한다.
- null 체크 early return은 복잡한 `Lazy<T>` 패턴 없이도 스레드 안전하다 (Blazor WASM은 싱글 스레드).
#### 결과
- `TusClient`를 DI로 주입만 받고 실제 업로드를 사용하지 않는 경우에도 불필요한 JS 모듈 로딩이 발생하지 않는다.
- `DisposeAsync()` 체인이 명확하게 구성되어 있다 (`TusClient``TusJsInterop` → JS module).
---
## 12. 최종 포트폴리오 문장 초안
### 프로젝트 개요
Blazor WebAssembly 환경에서 tus 프로토콜 기반의 재개 가능한(resumable) 대용량 파일 업로드를 지원하는 .NET 라이브러리. 오픈소스 JavaScript 라이브러리인 `tus-js-client`를 C# API로 래핑하여, Blazor 개발자가 JavaScript 코드 없이도 대용량 파일의 청크 업로드, 중단 후 재개, 병렬 업로드를 구현할 수 있도록 했다.
### 담당 역할
- **단독 개발** (코드 저장소 전체의 설계, 구현, 테스트, 문서화, NuGet 배포까지 단독 수행)
### 주요 기여
- Blazor ↔ `tus-js-client` 간의 완전한 콜백 브릿지 설계 및 구현 (7종 콜백 지원)
- `TusOptions` 기반 설정 모델로 tus-js-client의 모든 옵션(18개)을 C#으로 노출
- 업로드 중 동적 옵션 변경(`SetOtions`), 이전 업로드 탐지/재개(`FindPreviousUpload`/`ResumeFromPreviousUpload`) API 설계
- `IAsyncDisposable` 기반의 JS 리소스 생명주기 관리
- Blazor WASM Demo 앱 (13개 페이지) 및 Selenium E2E 테스트 (9개 테스트 케이스) 구현
### 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|----------|
| .NET 7 Blazor (Razor SDK) | 라이브러리가 `tusBlazorClient.js`를 번들로 포함해야 하므로 |
| `DotNetObjectReference` + `[JSInvokable]` | Blazor 프레임워크가 공식 제공하는 JS→.NET 호출 방식이므로 |
| `[JsonIgnore]` 콜백 분리 | C# 델리게이트는 직렬화 불가 → JS 전달 데이터와 콜백을 분리 |
| `ValueTask` 반환 | 불필요한 Task 할당을 방지하여 성능 최적화 |
| Singleton DI 등록 + Lazy JS 모듈 로딩 | 모듈 로딩 비용 최소화 및 리소스 재사용 |
| Selenium WebDriver (Firefox) | Blazor WASM은 브라우저 환경에서만 동작 → E2E 테스트로 검증 |
| tus-js-client (CDN) | 이미 검증된 오픈소스 라이브러리를 재사용하여 안정성 확보 |
### 구현 사항
- **Public API**: `TusClient` (진입점), `TusUpload` (업로드 작업), `TusOptions` (설정), `FileInputElement`/`JsFile`/`JsFileInfo` (파일 처리)
- **내부 구조**: `TusJsInterop` (JS 브릿지), `TusOptionJsInvoke` (콜백 수신), `TusOptionNullCheck` (null 콜백 최적화), `tusBlazorClient.js` (246라인, tus-js-client 어댑터)
- **DI**: `SetupExtension.AddTusBlazorClient()` 확장 메서드
- **리소스 관리**: 모든 주요 클래스가 `IAsyncDisposable` 구현, JS 모듈의 Lazy 로딩 및 생명주기 관리
- **오류 모델**: `TusError` + `TusHttpRequest`/`TusHttpResponse`로 네트워크 레벨 상세 정보 전달
- **테스트**: 9개의 Selenium E2E 테스트 (성공, 실패, 재개, 옵션 변경, 재시도 등)
- **데모 앱**: 13개 Razor 페이지로 모든 기능 시연
- **문서화**: README (설치/Quick Start), `TusOptions` XML 문서 주석, GitHub Wiki 링크
### 문제 해결 사례
위 11번 항목의 4가지 사례 참조:
1. Blazor ↔ JavaScript 콜백 브릿지 설계 (델리게이트 직렬화 불가 문제 해결)
2. 업로드 중 옵션 동적 변경 (옵션 변경 시 콜백 동기화 문제 해결)
3. 이전 업로드 탐지/재개 (JS 배열 인덱스 소실 문제를 래퍼 클래스로 해결)
4. JS 모듈 Lazy 로딩 + 생명주기 관리 (불필요한 모듈 로드 방지)
### 프로젝트 성과
- **NuGet 패키지**로 배포되어 (`TusBlazorClient` v1.0.1) 외부 프로젝트에서 `dotnet add package`로 즉시 설치 가능
- README 예제 코드 복사 → 붙여넣기 수준의 간결한 Quick Start 제공
- E2E 테스트로 주요 시나리오 검증 완료 (업로드, 재개, 오류 복구, 옵션 변경)
- tus-js-client의 모든 옵션(18개)과 콜백(7종)을 C#에 완전히 매핑
### 회고
- **잘한 점**: 싱글톤 + Lazy 로딩 구조는 라이브러리형 프로젝트에 적합한 선택이었다. `TusOptionNullCheck` 최적화처럼 작지만 실용적인 설계 선택이 실제 성능에 도움이 되었다. Blazor WASM 환경의 한계(느린 C# I/O)를 인지하고 검증된 JS 라이브러리를 래핑하는 전략도 실용적이었다.
- **아쉬운 점**: 단위 테스트가 없어 리팩토링 시 C# 로직의 회귀를 잡기 어렵다. 상속/인터페이스 기반 확장 포인트가 없어 외부 사용자가 내부 동작을 교체할 수 없다. CI/CD 파이프라인이 없어 테스트 자동화와 NuGet 자동 배포가 되어 있지 않다.
- **다음 개선 방향**: `CancellationToken` 지원 추가, IUploadStrategy 인터페이스 도입으로 확장성 확보, GitHub Actions CI/CD 구축, 단위 테스트 추가.

View File

@@ -0,0 +1,259 @@
# 술통여지도 (Sulmap)
## 1. 프로젝트 개요
한국의 다회차 음주 문화(1차·2차·3차)에서 다음 장소를 고를 때, 단순한 거리·평점 정렬로는 "비 오는 날 운치 있는 이자카야", "데이트에 어울리는 조용한 분위기" 같은 상황 맞춤 조건을 반영하기 어렵습니다. 저는 이 문제를 해결하기 위해 사용자의 위치·날씨·시간대·성별·나이·요청사항을 GPT-5.2에 전달하고, 반경 내 최대 200개 후보 중 개인화된 Top 10을 자연어 추천 이유와 함께 제공하는 **AI 기반 술집 추천 플랫폼**을 설계하고 구현했습니다.
---
## 2. 담당 역할
- Spring Boot 3계층 Clean Architecture 설계 및 구현
- GPT-5.2 Structured Output 기반 2단계 Cascade Ranking 추천 엔진 구현
- MyBatis 기반 DB 모델링 (16개 테이블) 및 REST API 설계 (12개 엔드포인트)
- Result\<T\> Monad 패턴 기반 전역 에러 처리 체계 구축
- Vue 3 + TypeScript 기반 지도 UI 및 AI 추천 UX 구현
- .NET 9 기반 공공데이터 ETL 파이프라인 (Qdrant + OpenAI Embedding) 구현
---
## 3. 주요 기여
### 3.1 200개 후보를 GPT에 한 번에 보낼 수 없는 문제 — 2단계 Cascade Ranking 설계
반경 내 술집이 최대 200개일 때, 전부를 한 번에 GPT에 전달하면 200개 × 약 80토큰 ≈ 16,000토큰 이상의 입력이 발생하고, 컨텍스트가 길어질수록 GPT의 attention 품질이 저하되는 문제가 있었습니다.
저는 이를 해결하기 위해 **2단계 Cascade Ranking** 구조를 설계했습니다.
- **1단계 (GptMinorRecommendClient)**: 200개를 100개씩 배치로 나눠 각 배치에서 top 5를 선별합니다. 출력은 `{ "selected": [id, id, ...] }` 형태의 ID 목록만 반환하여 토큰을 최소화합니다.
- **2단계 (GptRecommendClient)**: 1단계에서 선별된 최대 40개를 정밀 랭킹하여 Top 10과 자연어 추천 이유를 생성합니다.
```
200개 후보
└─► 배치 분할 (100개씩)
└─► GptMinorRecommendClient (각 배치 top5 선별)
└─► 최대 40개 후보
└─► GptRecommendClient (top10 + 추천 이유 생성)
└─► 지도 마커 + 사이드바 렌더링
```
이 구조로 200개를 1회 전송하는 방식 대비 GPT API 호출당 토큰을 약 70% 절감했습니다.
---
### 3.2 GPT가 존재하지 않는 술집을 추천하는 Hallucination 문제 — 다층 방어 체계 구현
GPT-5.2가 입력 후보(B 라인)에 없는 barId를 생성하거나, 사전 학습 데이터에서 기억한 술집을 추천하는 hallucination이 발생했습니다. Structured Output만으로는 허용된 선택지를 강제할 수 없었습니다.
저는 프롬프트와 코드 레벨에서 다층 방어 체계를 구성했습니다.
- **프롬프트 수준**: 시스템 프롬프트와 Stage Instructions 양쪽에 "후보에 없는 술집을 만들거나 추측하지 마라", "신규 barId 생성 금지"를 명시했습니다.
- **코드 수준 (Defensive Normalization)**: 정규식 `(?m)^B\|id=(\d+)\b`로 입력 B 라인에서 허용된 ID Set을 추출하고, GPT 응답의 barId를 대조하여 허용되지 않은 ID는 즉시 제거했습니다.
- **부족 시 폴백**: GPT가 topK보다 적게 반환하면 입력 순서(거리순)로 자동 채워 결과 개수를 보장했습니다.
```java
// 허용 ID Set 구성
Set<Long> allowedSet = extractBarIds(batchText);
// GPT 응답에서 허용되지 않은 ID 제거
List<Item> filtered = items.stream()
.filter(item -> allowedSet.contains(item.barId))
.collect(toList());
```
이 구조로 GPT hallucination으로 인한 잘못된 barId 노출을 0건으로 차단했습니다.
---
### 3.3 JSON 직렬화의 토큰 낭비 문제 — 도메인 특화 Pipe-delimited Format 설계
GPT에 후보 술집 정보를 JSON으로 전달하면 키 이름, 중괄호, 따옴표 등 구조 문자 자체가 대량의 토큰을 소비합니다. 또한 술집 이름이나 영업정보에 개행 문자·파이프 문자가 포함되어 프롬프트 파싱 오류가 발생할 수 있었습니다.
저는 `GptBatchTextBuilder`를 설계하여 도메인 특화 Pipe-delimited Format을 정의했습니다.
```
# JSON 방식
{"id": 123, "category": "주점", "openingInfo": "매일 18:00-02:00", "name": "포차", "menu": "닭발,오돌뼈"}
# 커스텀 포맷 방식
B|id=123|c=주점|oi=매일 18:00-02:00|n=포차|menu=닭발,오돌뼈
```
데이터 sanitize도 함께 구현하여, 모든 필드에서 파이프(`|`), 개행(`\n`, `\r`)을 공백으로 치환하고 필드별 길이를 truncate했습니다. (영업정보 60자, 이름 20자, 메뉴 60자, 사용자 프롬프트 180자 등)
이 포맷 도입으로 GPT 호출당 토큰을 JSON 대비 약 40% 절감했으며, 파이프 구분자 파싱 오류도 제거했습니다.
---
### 3.4 AI 장애 시 서비스 완전 중단 방지 — Graceful Degradation 구현
GPT API는 네트워크 이슈, 타임아웃, Rate Limit 등으로 언제든 실패할 수 있는 외부 의존성입니다. 저는 AI 호출이 실패했을 때 빈 화면을 보여주는 대신, 입력 순서(거리순)로 topK를 반환하는 폴백 전략을 구현했습니다.
```java
try {
return gptRecommendClient.rank(ctx, bLines, topK);
} catch (Exception e) {
log.error(e.getMessage(), e);
// Graceful Degradation: 거리순 fallback 반환
return buildFallbackResult(candidates, topK);
}
```
AI 장애 시에도 사용자에게 기본 거리 기반 추천 결과가 표시되어 서비스 사용성이 유지됩니다.
---
### 3.5 Result\<T\> Monad 패턴으로 에러 처리 체계 통일
서비스 계층에서 예외를 던지는 방식은 호출부에서 예외 처리를 누락할 위험이 있었습니다. 저는 자체 `Result<T>` 클래스를 구현하여 성공(`Result.ok(value)`)과 실패(`Result.fail(error)`)를 반환 타입으로 표현하는 체계를 구축했습니다.
```java
// Service 계층
public Result<Plan> findPlan(Long planId, Long userId) {
Plan plan = planRepository.findById(planId);
if (plan == null) return Result.fail(new NotFoundError("플랜을 찾을 수 없습니다."));
if (!plan.getOwnerId().equals(userId)) return Result.fail(new ForbiddenError("접근 권한이 없습니다."));
return Result.ok(plan);
}
// Controller 계층
Result<Plan> result = planService.findPlan(planId, userId);
if (result.isFailure()) return result.toErrorResponse();
return ResponseEntity.ok(PlanResponse.from(result.getValue()));
```
`NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하여, 모든 실패 응답이 `{ StatusCode, Message }` 형태로 일관되게 반환되도록 했습니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| --- | --- |
| **GPT-5.2 + Structured Output** | 복잡한 다중 조건(날씨·시간·분위기)을 자연어로 추론하는 데 룰 기반 필터링으로는 대응 불가능했습니다. Structured Output으로 JSON Schema를 강제하여 파싱 오류를 방지했습니다. |
| **Spring Boot 3.5.9** | DI·Security·Validation이 잘 통합된 환경에서 3계층 아키텍처를 빠르게 구조화하기 위해 선택했습니다. |
| **MyBatis 3.0.5** | JPA Lazy Loading으로 인한 N+1 문제를 피하고, 복잡한 조인 쿼리를 SQL로 직접 제어하기 위해 선택했습니다. |
| **Elasticsearch 8.15.2** | 술집 이름·카테고리 기반 전문 검색을 MySQL LIKE보다 빠르게 처리하고, 위치 기반 검색과 결합하기 위해 선택했습니다. |
| **Qdrant + text-embedding-3-small** | 데이터 파이프라인에서 공공데이터와 술집 정보를 벡터 유사도로 매칭하기 위해 선택했습니다. 런타임 추천과 역할을 분리하여 서빙 비용을 줄였습니다. |
| **Vue 3 + TypeScript + Pinia** | Composition API로 지도 마커 상태와 AI 결과 목록의 반응형 동기화를 타입 안전하게 구현하기 위해 선택했습니다. |
| **Spring Security + Session Cookie** | JWT 대신 세션 방식을 선택한 이유는 서버사이드에서 세션을 직접 폐기할 수 있어 로그아웃 보안이 명확하고, SSAFY 인프라 환경에 더 적합했기 때문입니다. |
---
## 5. 구현 사항
### 전체 아키텍처
```
사용자 요청
Spring Boot (3-Layer Clean Architecture)
├── api/ Controller, DTO, Security Config, CORS
├── core/ Service Interface/Impl, Repository Interface, Domain Model
├── infra/ Repository Impl, MyBatis Mapper, GPT Client, ES Client
└── share/ Result<T> Monad, Error Type 계층
├─► MySQL 8.0 (users, bars, reviews, plans, schedules 등 16개 테이블)
├─► Elasticsearch (술집 전문 검색 인덱스)
└─► OpenAI GPT-5.2 (2단계 Cascade Ranking 추천)
[데이터 파이프라인] .NET 9 ETL
└─► OpenAI Embedding (text-embedding-3-small)
└─► Qdrant (공공데이터 ↔ 술집 벡터 유사도 매칭)
```
### AI 추천 요청 처리 흐름
```
POST /ai/recommend-bars
├─ @Valid Bean Validation (위경도, 거리, 프롬프트 길이 검증)
├─ BarRepository.findNearby() → 반경 내 최대 200개 조회
├─ GptBatchTextBuilder.build()
│ └─ sanitize (파이프·개행 제거, 필드별 truncate)
│ └─ CTX 라인 + B 라인 직렬화 (Pipe-delimited Format)
├─ GptMinorRecommendClient (1단계: 배치당 top5 선별)
│ └─ Structured Output → { "selected": [id, ...] }
├─ GptRecommendClient (2단계: 최대 40개 → top10 + reasons)
│ └─ Structured Output → { "top": [{ barId, reasons }] }
├─ Defensive Normalization
│ ├─ 허용 ID Set 대조 → 미허용 barId 제거
│ ├─ 중복 제거 (LinkedHashMap 순서 유지)
│ ├─ reasons sanitize (파이프·개행 제거, 45자 truncate)
│ └─ 부족 시 거리순 fallback 채움
└─ RecommendedBarListResponse → 지도 마커 + 사이드바 렌더링
```
### 주요 API 목록
| Method | Path | 설명 | 인증 |
| --- | --- | --- | --- |
| POST | `/auth/login` | 세션 기반 로그인 | Public |
| POST | `/auth/signup` | 회원가입 | Public |
| POST | `/ai/recommend-bars` | AI 술집 추천 | 필요 |
| GET | `/bars/nearby` | 위치 기반 주변 술집 검색 | 필요 |
| GET | `/bars/{id}` | 술집 상세 정보 | 필요 |
| POST/PATCH/DELETE | `/reviews` | 리뷰 작성·수정·삭제 | 필요 |
| POST/PUT | `/memos` | 술집 개인 메모 upsert | 필요 |
| GET/POST | `/plans` | 음주 플랜 목록·생성 | 필요 |
| GET/PATCH/DELETE | `/plans/{id}` | 플랜 상세·수정·삭제 | 필요 |
| GET/POST | `/schedules` | 일정 생성·조회 | 필요 |
| GET | `/schedules/history` | 음주 이력 조회 | 필요 |
### 데이터베이스 주요 구조
```
users ─────────────────────────────────────────────────────
├─ bars ──────────────── bar_categories
│ │ bar_category_mapping
│ │
│ ├─ reviews ───────── review_media (이미지 첨부)
│ │ review_likes (좋아요)
│ │ review_reports (신고)
│ │
│ └─ memos (개인 메모, user+bar 단위 upsert)
└─ drinking_plan ──────── drinking_plan_stops (1차/2차/3차 장소)
│ plan_votes
└─ schedules (플랜을 날짜/시간에 스케줄링)
visits
```
### 프론트엔드 주요 화면
| Route | 설명 |
| --- | --- |
| `/home` | 네이버 지도 + AI 추천 다이얼로그 + 술집 사이드바 |
| `/plans` | 음주 플랜 목록 |
| `/plans/new` | 플랜 생성 (1차·2차·3차 장소 구성) |
| `/plans/:id` | 플랜 상세 및 일정 생성 |
| `/history` | 음주 이력 조회 |
---
## 6. 기술적 의사결정 및 회고
### AI 비동기 처리를 적용하지 않은 판단
GPT 호출이 동기 blocking 방식으로 처리되어 사용자가 응답을 기다려야 합니다. Cascade Ranking 구조상 1단계 결과가 나와야 2단계 입력이 결정되므로 완전한 비동기화가 어려웠고, 현재 사용자 수 규모에서는 동기 방식이 구현 복잡도를 낮추면서 충분히 동작했습니다. 트래픽이 증가하면 SSE나 WebSocket으로 스트리밍 응답을 제공하는 방식으로 전환해야 합니다.
### AI 응답 테스트 부재에 대한 인식
`GptRecommendClient``GptMinorRecommendClient`에 대한 유닛 테스트가 구현되지 않았습니다. 프롬프트를 변경했을 때 추천 품질이 유지되는지 회귀 테스트가 없어, 수동 확인에 의존하고 있습니다. 향후에는 Mock AI 응답을 활용한 유닛 테스트와 추천 품질을 측정하는 평가 프레임워크를 도입하는 것이 필요합니다.
### 토큰 비용 모니터링 부재
OpenAI API 호출당 토큰 사용량과 비용을 별도로 기록하는 코드가 없어, 실제 운영 비용을 수치로 파악하기 어렵습니다. 요청별 토큰 카운팅 로깅을 추가하고, 이상 사용량을 감지하는 알림 체계를 갖추는 것이 운영 환경에서 필요합니다.
### Rate Limit 및 Timeout 대응 미비
GPT API의 Rate Limit이나 응답 지연에 대한 재시도 로직이 구현되어 있지 않습니다. 현재는 실패 시 즉시 거리순 폴백으로 대응하는 수준입니다. Exponential Backoff 기반 재시도와 명시적 Timeout 설정을 적용하여 외부 API 의존성을 더 견고하게 처리해야 합니다.