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

View File

@@ -0,0 +1,484 @@
# Cloud# (CloudSharp) — 백엔드 / 인프라 포트폴리오
> **프로젝트 유형:** 셀프호스트 파일 서비스 플랫폼
> **개인 기여 영역:** 백엔드 API · 데이터 모델링 · 인프라 구성 · CI/CD · 설계 문서화
> **기술 스택:** ASP.NET Core 10, PostgreSQL 16, Redis 7, tusd, Docker Compose, nginx
---
## 1. 프로젝트 개요
### 한 줄 정의
> **Space 단위의 완전 격리형 저장 공간**을 제공하는 셀프호스트 파일 서비스로, tus 프로토콜 기반의 재개 가능한 대용량 업로드와 단명(短命) 다운로드 세션을 갖췄다.
### 해결하려는 문제
기존 클라우드 스토리지(Google Drive, Dropbox 등)는 **개인 계정 중심**으로 설계되어 있어, 팀/프로젝트 단위에서 폴더 공유만으로는 다음 요구를 충족하기 어렵다.
- 격리된 저장 공간 (멤버십 + Quota + 권한)
- 대용량 파일의 중단 재개 업로드
- 셀프호스트 + 외부 공유 정책의 분리
### 주요 사용자
셀프호스트를 선호하는 개인·팀·소규모 조직, Space 단위 협업이 필요한 프로젝트 그룹.
### 백엔드 핵심 책임
| 책임 | 설명 |
|------|------|
| 인증/인가 | Opaque session token 기반 인증 + Space Role 기반 인가 |
| 메타데이터 진실 원천 | 12개 엔티티의 정합성 보장 (PostgreSQL) |
| 업로드 파이프라인 | tusd 연계 세션 생성 → 전송 → Finalize 전 생명주기 관리 |
| Quota 정책 | `used + reserved + expected ≤ allowed` 원자적 판정 |
| Storage 추상화 | Local FS / MinIO / S3 공통 `storage_key` 추상 |
| 다운로드 보안 | 5분 TTL DownloadSession 기반 Zero Information Leak |
| 동시성 제어 | CAS 기반 Finalize 중복 차단 + Recovery Worker |
---
## 2. 담당 역할
**1인 백엔드 + 인프라 + 설계 담당.** 프론트엔드를 제외한 전 영역.
- **백엔드 API**: 인증·인가, Space 관리, 파일/폴더 CRUD, 업로드/다운로드 파이프라인, Quota, 공유 링크
- **데이터베이스 설계**: 12개 엔티티 ERD, EF Core Configuration, Migration 자동화
- **인프라 구성**: Docker Compose 5-서비스 오케스트레이션, nginx Reverse Proxy, Volume·Healthcheck 설계
- **CI/CD**: GitLab CI 파이프라인 (test → build → image push to GHCR)
- **설계 문서화**: API/ERD/Conventions/ADR 등 살아있는 문서 체계 구축
---
## 3. 아키텍처
### 3.1 아키텍처 유형
**모듈러 모놀리스 + Clean Architecture.**
> **선택 이유:** MVP 단계에서 마이크로서비스의 운영 복잡도(분산 트랜잭션, 서비스 디스커버리, 로그 수집)를 피하면서도, 코드 레벨로는 도메인 경계를 엄격히 분리해 추후 분리 가능성을 확보했다.
> 의존성 방향은 `Api → Core ← Infrastructure`로, 도메인이 HTTP나 DB, 파일시스템을 알지 못하게 했다.
### 3.2 전체 구성도
```mermaid
flowchart TB
Client["Browser / Client"]
subgraph Docker["Docker Compose Host"]
NGINX["nginx :80"]
API["ASP.NET Core API :8080"]
TUSD["tusd :1080"]
PG[("PostgreSQL")]
REDIS[("Redis")]
FS[("Local FS /data/storage")]
end
Client -->|"HTTP :8080"| NGINX
NGINX -->|"/api/*"| API
NGINX -->|"/files/*"| TUSD
API --> PG
API --> REDIS
API --> FS
API -.->|"hook callback"| TUSD
TUSD --> FS
```
### 3.3 서비스 책임 분리
| 컴포넌트 | 책임 |
|----------|------|
| **nginx** | 단일 외부 진입점, tus 헤더 포워딩, 스트리밍 버퍼링 해제 |
| **ASP.NET API** | 권한·정책·메타데이터·Finalize·공유·세션 |
| **tusd (Go)** | tus 프로토콜 청크 수신, hook으로 API에 생명주기 위임 |
| **PostgreSQL** | 메타데이터 진실 원천 |
| **Redis** | 세션 저장소 + Pub/Sub 이벤트 버스 |
### 3.4 핵심 설계 의도
- **업로드 plane과 API plane 분리**: 대용량 청크 전송은 Go 기반 tusd가 담당, 비즈니스 판단은 ASP.NET이 담당 → 장애 격리 + 독립 확장
- **단일 외부 포트(8080)**: 셀프호스트 사용자 입장의 운영 단순화. DB/Redis/tusd/API는 `expose`만 사용하고 외부 노출 금지
---
## 4. 주요 기여 — 문제 해결 사례
### 4.1 Finalize 중복 실행 방지: CAS Lock
**문제 상황**
tusd hook 콜백 + 백그라운드 Recovery Worker 두 경로에서 같은 UploadSession이 동시에 처리되어 FileItem이 중복 생성될 위험이 있었다. Finalize는 파일 이동(rename)이라는 느린 I/O를 포함하므로 일반적인 DB 트랜잭션만으로는 동시성 제어가 부족했다.
**원인 분석**
- 상태 판정과 처리 시작이 분리되면 race condition 발생
- 분산 락(Redis Redlock 등) 도입은 인프라 복잡도 증가
- 파일 I/O 동안 DB 트랜잭션을 잡고 있으면 락 점유 시간이 너무 길다
**설계 선택 — CAS(Compare-And-Swap)**
```sql
UPDATE upload_session
SET status = 'FINALIZING',
finalize_attempts = finalize_attempts + 1
WHERE id = :session_id
AND status = 'UPLOADING';
-- affected_rows = 1 → 점유 성공
-- affected_rows = 0 → 이미 처리 중 (즉시 무시)
```
**구현 방식**
- 파일 I/O와 DB 트랜잭션을 분리: 무거운 작업은 트랜잭션 밖, DB 변경(FileItem INSERT, Quota 갱신, FileReservation CONSUMED)만 짧은 트랜잭션
- Recovery Worker(5분 주기)가 10분 이상 `FINALIZING`에 머문 세션을 자동 보정 (FileItem 존재 시 COMPLETED, 임시 파일 소실 시 FAILED)
**결과**
- 별도 락 인프라 없이 단일 SQL UPDATE로 원자적 점유 실현
- 교착 상태 자동 복구로 운영 부담 최소화
- DB UNIQUE 제약(`storage_key`)과 함께 이중 안전장치 구성
---
### 4.2 Space Quota 경쟁 조건 방지
**문제 상황**
한 Space의 여러 멤버가 동시에 대용량 업로드를 시작할 때, quota 판정(`expected ≤ available`)과 reserved 증가 사이의 간극에서 모든 요청이 통과하는 race condition이 발생할 수 있다.
**설계 선택 — DB row-level lock**
```sql
BEGIN;
SELECT * FROM space WHERE id = :id FOR UPDATE;
-- quota 판정 + reserved += expected_size + FileReservation 생성
COMMIT;
```
**선택 이유**
- 업로드 세션 생성은 빈번하지 않고 락 지속시간이 짧다
- 낙관적 동시성으로는 이미 전송 중인 요청을 거절할 수 없다
- Finalize 직전에도 quota를 재검사하여 이중 안전장치를 구성
**결과**
- Quota 불변조건 `used + reserved ≤ allowed`가 항상 유지
- 초과 업로드는 **시작 시점**에 거절되어 불필요한 네트워크 전송 방지
---
### 4.3 인증 토큰: Opaque Session vs JWT
**문제 상황**
한 사용자가 여러 Space에서 서로 다른 Role을 가지며, Role 변경이 즉시 반영되어야 한다. JWT를 사용하면 토큰 만료 전까지 stale 권한이 유지되며, 모든 Role을 claim에 담으면 토큰 크기가 비대해진다.
**설계 선택 — Opaque Session Token**
```
Authorization: Bearer cs_sess_{base64url(CSPRNG 32bytes)}
```
- 권한 정보를 토큰에 담지 않음
- Redis에 `HMAC-SHA-256(token, secret)` 해시만 저장 (원문 저장 금지)
- 매 요청마다 DB에서 최신 SpaceMember를 조회
**구현 방식**
- `CloudSharpSessionAuthenticationHandler`: 헤더 검증 → 해시 계산 → Redis 조회 → 만료 확인
- `RequireSpacePermissionFilter`: route의 spaceSlug → spaceId 변환 + Role 충족 확인 → `AuthorizedSpaceContext` 주입
- UseCase 단계에서 리소스의 `space_id` 재검증 (IDOR 방어)
- Idle 12시간 / Absolute 30일 / sliding renewal
**결과**
- Role 변경이 **다음 API 요청부터 즉시 반영**
- 로그아웃·강제 Revoke를 Redis key 삭제 한 번으로 처리
- 토큰 탈취 시에도 토큰 자체에는 정보가 없어 노출 최소화
---
### 4.4 파일명 충돌 정책: "명시적 실패 반환"
**문제 상황**
Google Drive 같은 서비스는 동일 폴더에 같은 이름의 파일이 업로드되면 자동으로 `파일명(1).pdf`로 rename한다. 그러나 사용자가 의도하지 않은 파일이 누적되어 데이터 일관성이 깨진다.
**설계 선택 — `FILE_NAME_CONFLICT` 에러 반환**
- 활성 `FileItem` + 활성 `FileReservation`을 함께 검사 (사전 + Finalize 직전 이중 검증)
- DB 레벨에서 `UNIQUE (space_id, folder_id, normalized_name) on active rows` 제약
**선택 이유**
- "모호함보다 명시적 실패가 낫다"는 설계 철학
- 사용자가 의도적으로 파일명을 결정하도록 유도
- 구현 단순성 (rename fallback, 카운터 등 불필요)
**결과**
- 항상 예측 가능한 동작 + 사용자 의도와 저장 상태의 일관성
---
## 5. API 설계
### 5.1 기본 계약
| 항목 | 값 |
|------|-----|
| 내부 API | `/api/v1/*` (Bearer 인증) |
| 외부 공개 API | `/public/v1/*` (share_token + 비밀번호) |
| 내부 전용 | `/api/internal/*` (`X-CloudSharp-Internal-Token`) |
| 문서화 | Swagger UI + OpenAPI 3.x + ReDoc |
### 5.2 URL 식별자 분리 정책
- **외부 노출**: Space는 UUID 기반 `spaceSlug` (URL 식별자)
- **내부 Core**: bigint PK `spaceId`만 사용
- **변환 위치**: `RequireSpacePermissionFilter`가 slug → id + 권한 컨텍스트로 변환
> bigint PK가 외부에 노출되어 enumeration 공격에 취약해지는 문제를 차단했다.
### 5.3 표준 에러 응답
```json
{
"requestId": "req_01JXYZ...",
"error": {
"code": "FILE_NAME_CONFLICT",
"message": "같은 이름의 파일이 이미 존재합니다.",
"details": [{ "field": "displayName", "reason": "conflict" }]
}
}
```
- `requestId` = `HttpContext.TraceIdentifier` → 로그 추적 가능
- ErrorCode는 OpenAPI에 문서화된 고정 문자열 → 클라이언트가 분기 처리 가능
- 외부 공개 API는 권한 없음/리소스 없음/비활성 모두 **404로 통일** → Zero Information Leak
---
## 6. 데이터베이스 설계
### 6.1 주요 엔티티 (12개)
User, Space, SpaceMember, SpaceInvite, Folder, FileItem, **UploadSession**, **FileReservation**, ShareLink, ShareLinkTarget, DownloadSession.
### 6.2 핵심 모델링 결정
**① UploadSession ↔ FileReservation 1:1 분리**
- UploadSession: 전송 상태 추적(tus I/O, 네트워크 관점, 7-state machine)
- FileReservation: 비즈니스 자원 선점(quota·파일명, 도메인 관점, 6-state machine)
- 변경 주기와 실패 원인이 다르므로 분리하되 1:1로 연결
**② Space 중심 소유권**
- 파일/폴더의 FK는 User가 아닌 Space
- 행위자(`created_by_user_id`)와 소유자(`space_id`) 분리
- 멤버 탈퇴 시에도 파일은 Space에 남음
**③ DownloadSession 별도 테이블**
- 로그인 세션과 다운로드 토큰은 TTL/revoke 정책/subject_type이 전혀 다름
- `subject_type` (USER/SHARE_LINK)으로 내부 인증과 외부 공유를 단일 테이블로 통합
### 6.3 정합성 보장
| 원칙 | 적용 |
|------|------|
| Soft Delete | 대부분 테이블에 `deleted_at TIMESTAMP NULL` |
| CHECK 제약 | `storage_used_bytes ≥ 0`, `received_size ≤ expected_size` |
| UNIQUE | `storage_key`, `(space_id, folder_id, normalized_name)` on active rows |
| Migration | EF Core 시작 시점 자동 적용 (`RunDatabaseMigrationsOnStartup`) |
---
## 7. 트랜잭션과 동시성
### 7.1 트랜잭션 패턴
공통 `IAppDbTransactionFactory` 추상화로 UseCase에서 명시적 경계 관리.
```csharp
await using var tx = await transactionFactory.BeginAsync(ct);
var result = await repository.SaveAsync(entity, ct);
if (result.IsFailed) { await tx.RollbackAsync(ct); return ...; }
await tx.CommitAsync(ct);
```
### 7.2 Finalize 트랜잭션 경계 분리
```
🔓 트랜잭션 밖: 임시 파일 검증 + 파일 이동 (느린 I/O)
🔒 DB 트랜잭션: FileItem INSERT
Space.storage_used_bytes += final_size
Space.storage_reserved_bytes -= reserved_size
FileReservation → CONSUMED
UploadSession → COMPLETED
```
> 파일 이동 동안 DB 락을 잡지 않는다. DB 트랜잭션 실패 시 발생할 고아 파일은 Recovery Worker가 정리.
### 7.3 중복 생성 3중 방지
1. UseCase 사전 검증 (파일명 충돌)
2. DB UNIQUE 제약
3. `TrySaveChangesAsync()` — conflict 감지 시 `Result.Fail`
---
## 8. 예외 처리와 응답 구조
### 8.1 3계층 실패 전략
| 실패 유형 | 처리 | 로그 레벨 |
|-----------|------|-----------|
| Validation | ASP.NET 400 자동 응답 | 없음 |
| 비즈니스 | `FluentResults` + `CloudSharpError``ResultHttpMapper` | 보안 실패만 Warning |
| 시스템 예외 | `ExceptionHandlingMiddleware``ProblemDetails` 500 | Error 1회 (stack trace 운영 비공개) |
### 8.2 ErrorCode → HTTP 매핑
| 패턴 | HTTP |
|------|------|
| `*_NOT_FOUND` | 404 |
| `*_FORBIDDEN`, `*_UNAUTHORIZED` | 403 |
| `*_CONFLICT`, `*_DUPLICATE` | 409 |
| 나머지 | 400 |
### 8.3 책임 분리
- **Endpoint**: 라우팅, DTO ↔ Command 변환, Result → HTTP 변환만
- **UseCase**: `Result<T>` 반환, HTTP를 모름
- **Domain Policy**: 상태 전이 검증, 인프라 의존성 없음
---
## 9. 인프라 구성
### 9.1 Docker Compose 5-서비스
| 서비스 | 외부 노출 | 역할 |
|--------|-----------|------|
| postgres | ❌ (`expose`만) | 메타데이터 |
| redis | ❌ (`expose`만) | 세션 + Pub/Sub |
| tusd | ❌ (`expose`만) | tus 청크 업로드 |
| api | ❌ (`expose`만) | ASP.NET 백엔드 |
| **nginx** | ✅ `:8080` | **유일한 외부 진입점** |
### 9.2 Volume 구조
```
postgres_data → /var/lib/postgresql/data
redis_data → /data
./storage → /data/storage (API + tusd 공유)
├── objects/ ← 최종 파일
│ └── spaces/{shard}/{spaceId}/objects/{s1}/{s2}/{fileKey}.bin
└── tmp/tusd/ ← tusd 임시 파일
```
> **Storage Sharding**: hex hash 기반 `256 × 256 × 256 = 65,536` 버킷 분산으로 디렉토리 과밀 방지.
### 9.3 Healthcheck + depends_on
모든 서비스가 Healthcheck를 가지며, `condition: service_healthy`로 단순 실행 순서가 아닌 **실제 준비 완료**를 기다린다.
```yaml
api:
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
tusd: { condition: service_healthy }
```
### 9.4 nginx — tusd 스트리밍 특수 설정
```nginx
proxy_set_header Tus-Resumable $http_tus_resumable;
proxy_set_header Upload-Length $http_upload_length;
proxy_set_header Upload-Offset $http_upload_offset;
client_max_body_size 0; # 대용량 무제한
proxy_buffering off; # 스트리밍 버퍼링 해제
proxy_request_buffering off;
proxy_read_timeout 600s; # 장시간 업로드
```
### 9.5 Dockerfile 설계
- Multi-stage build (SDK → build → publish → runtime)로 최종 이미지 크기 최소화
- `aspnet:10.0` 기본 이미지에 `curl` 추가하여 컨테이너 HEALTHCHECK 지원
---
## 10. 환경변수 / 설정 관리
```bash
# 보안 비밀 (반드시 환경변수로만 주입)
Auth__SessionHashKey=<HMAC secret>
Uploads__FinalizeToken=<openssl rand -base64 32>
# DB / Redis / Storage / tusd
Postgres__Host=postgres
Redis__Host=redis
Storage__Provider=local
Tusd__HooksHttp=http://api:8080/internal/tusd/hooks
```
- ASP.NET Options 패턴 + `__` 환경변수 오버라이드
- 모든 연결 정보·경로·정책 값은 환경변수 또는 `appsettings.json`에서 주입 (하드코딩 금지)
- Compose가 서비스명을 DNS로 resolve (`postgres`, `redis`, `tusd`)
---
## 11. 로그 / 모니터링
**로그 철학:** *"중복 없이 검색 가능하게"*
| 위치 | 레벨 | 책임 |
|------|------|------|
| Middleware | Information | 요청당 1회 (`method/path/status/elapsedMs/traceId/userId`) |
| GlobalExceptionHandler | Error | 예상 못한 예외만 1회 (운영 stack trace 미노출) |
| UseCase Activity Proxy | Debug | 모든 UseCase 메서드 자동 추적 |
| UseCase 직접 | Warning/Information | 보안 실패, 감사 이벤트만 |
**구조화 로그 — 영어 고정 템플릿 + named placeholder**
```csharp
// ✅
logger.LogWarning(
"Space access denied userId={UserId} spaceId={SpaceId} permission={Permission}",
userId, spaceId, permission);
// ❌ 문자열 보간 금지
```
**금지사항**: 토큰/body/Authorization header 로깅, validation 실패를 Warning으로 남기기, 운영 환경 stack trace 노출.
---
## 12. CI/CD
**GitLab CI 4-stage 파이프라인**: `test → build → pr_review → deploy`
```yaml
backend:test:
image: mcr.microsoft.com/dotnet/sdk:10.0
script:
- dotnet test tests/CloudSharp.Core.Tests
- dotnet test tests/CloudSharp.Infrastructure.Tests
backend:image:
script:
- docker build → ghcr.io/cloud-sharp/cloudsharp-backend:$CI_COMMIT_SHA
- docker push (master push only)
```
| 항목 | 상태 |
|------|------|
| Backend Unit/Infrastructure 테스트 | ✅ |
| Docker Image Build → GHCR | ✅ |
| nginx 설정 검증 (`nginx -t`) | ✅ |
| API IntegrationTests / Architecture Tests | ⚠️ CI 미포함 (개선 예정) |
| 자동 배포 (CD) | ⚠️ Placeholder (개선 예정) |
---
## 13. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|-----------|
| **ASP.NET Core 10 / Minimal API** | 빠른 부트스트랩, Controller 오버헤드 없음, 최신 런타임 |
| **PostgreSQL 16** | FK·Unique·CHECK 제약이 풍부, JSON 확장성 |
| **Redis 7** | 빠른 Hash 조회 + Pub/Sub 이벤트 버스 |
| **tusd (Go)** | tus 표준 구현체, ASP.NET보다 청크 업로드에 효율적 |
| **EF Core 10 + Npgsql** | PostgreSQL Native, Migration 자동화 |
| **FluentResults** | 예외 아닌 비즈니스 실패의 명시적 표현, Bind 파이프라인 |
| **FluentValidation** | 선언적 검증 + `.WithErrorCode()`로 ErrorCode 표준화 |
| **nginx (alpine)** | tus 헤더 포워딩, 버퍼링 해제, 단일 진입점 |
| **Docker Compose** | 셀프호스트 배포 간소화 |
---
## 14. 프로젝트 성과
- **데이터 모델**: 12개 엔티티, Mermaid ERD, 11개 EF Core Configuration 클래스
- **요구사항 매핑**: 48개 기능 요구사항(SFR-001~048)을 OpenAPI + UseCase에 매핑
- **상태 머신 설계**: UploadSession 7-state, FileReservation 6-state
- **설계 문서**: 14개 컨벤션 문서로 코딩 표준 구축, 3건의 ADR
- **CI 자동화**: PR 테스트 + master push 시 GHCR 이미지 빌드
---
## 15. 회고
### 잘한 점
- **초기부터 엄격한 문서화**(API, ERD, Conventions, ADR)와 Clean Architecture를 적용해, 기능 확장 시 도메인 경계가 무너지지 않았다.
- **JWT 대신 Opaque Session Token**을 선택한 결정이 멀티 Role 모델에 정확히 부합했다. 인증 정보가 토큰에 없으므로 Role 변경이 즉시 반영된다.
- **UploadSession과 FileReservation의 1:1 분리**로 네트워크 관점과 도메인 관점의 책임이 명확해졌고, 실패 복구 경로가 단순해졌다.
### 아쉬운 점
- API IntegrationTests와 Architecture Tests가 CI 파이프라인에 포함되지 않았다.
- 운영 모니터링(Grafana, OpenTelemetry, 분산 추적)이 설계 단계에 머물러 있다.
- 자동 배포(CD)가 placeholder 상태이며, 실제 운영 환경 검증이 부족하다.
- 후처리 Worker(ffmpeg 썸네일, AI 메타데이터)는 구조만 있고 미구현이다.
### 향후 계획
- MinIO/S3 Storage Provider 구현으로 Local FS 외 백엔드 검증
- Worker 프로젝트에 ffmpeg 썸네일 파이프라인 적용
- IntegrationTest를 CI에 포함시키고, 자동 배포 파이프라인 완성
- OpenTelemetry 도입 + Grafana/Loki 운영 환경 구축
- 장기적으로 Kubernetes 전환 + OpenSearch 도입 검토

View File

@@ -0,0 +1,191 @@
# Didit
## 1. 프로젝트 개요
GitHub 저장소를 사용하는 개발 팀은 이슈 우선순위를 수동으로 판단하고, 화상회의 · 채팅 · 이슈 트래킹이 각각 다른 도구에 흩어져 있어 워크플로우 단절이 반복적으로 발생합니다. 저는 이 문제를 해결하기 위해 **GitHub OAuth2 기반 인증, OpenVidu WebRTC 화상회의, AI 이슈 분석, SSE 실시간 이벤트**를 하나의 Spring Boot 서버로 통합한 팀 협업 플랫폼을 설계하고 구현했습니다.
---
## 2. 담당 역할
백엔드 개발자로 참여하여 다음을 담당했습니다.
- Spring Boot 4.0 기반 REST API 전체 설계 및 구현 (30개+ 엔드포인트)
- Redis 기반 AI 비동기 작업 큐 및 Pub/Sub 메시징 아키텍처 설계
- SSE(Server-Sent Events) 실시간 이벤트 시스템 구현
- Flyway 기반 데이터베이스 스키마 설계 및 마이그레이션 관리
- Docker Compose 인프라 구성 및 GitLab CI/CD 파이프라인 구축
---
## 3. 주요 기여
### 3.1 ML 추론 부하를 API 응답성으로부터 완전히 분리
HuggingFace 모델 추론은 수 초~수십 초가 소요되어 REST API의 요청-응답 사이클 안에서 처리할 수 없었습니다. 저는 이 문제를 해결하기 위해 Redis List를 작업 큐로, Redis Pub/Sub을 결과 전달 채널로 활용하는 비동기 파이프라인을 설계했습니다.
서버는 AI 분석 요청이 들어오면 `queue:issue:priority:single`에 작업을 `leftPush`한 뒤 즉시 응답합니다. Python AI Worker가 0.5초 간격으로 큐를 polling하여 작업을 소비하고, 완료 시 Redis Pub/Sub으로 결과를 발행합니다. 서버의 `RedisMessageListener`가 결과를 수신하여 DB를 업데이트하고 SSE로 클라이언트에 전달합니다.
```
클라이언트 → POST /issue/analyze → Redis leftPush → 즉시 200 응답
AI Worker polling
Redis Pub/Sub 결과 발행
RedisMessageListener 수신 → DB 저장 → SSE 전송
```
이 구조 덕분에 ML 추론 시간과 API 응답성이 완전히 분리되었으며, RabbitMQ나 Kafka 같은 별도 인프라 없이 기존 Redis 하나로 큐와 메시징을 통합하여 운영 복잡도를 낮췄습니다.
---
### 3.2 AI 결과를 요청한 특정 사용자에게만 실시간 전달
AI 분석 결과를 SSE로 전달할 때, 같은 프로젝트의 모든 구독자에게 브로드캐스트하면 다른 사용자에게 불필요한 이벤트가 전파되는 문제가 있었습니다. 저는 Redis에 `sse:client_key:{userId}` 형태로 clientKey를 저장하고, AI 결과 수신 시 `SseHub.broadcastToClient(projectId, clientKey, ...)` 를 호출하여 요청한 사용자에게만 결과를 전달하도록 구현했습니다.
이 방식은 동일 유저의 다중 탭/디바이스를 clientKey로 구분하면서도, Redis를 통해 무상태(stateless) 서버 구조를 유지할 수 있어 수평 확장 시에도 동일하게 동작합니다.
---
### 3.3 GitHub 이슈와 로컬 DB의 Upsert 기반 양방향 동기화
GitHub이 이슈의 Source of Truth이지만, AI 우선순위·담당자 매핑 등 로컬 메타데이터를 함께 관리해야 했습니다. 저는 `github_issue_id`를 natural key로 사용하는 Upsert 패턴을 구현하여, 기존 이슈는 title/body/status를 업데이트하고 신규 이슈는 INSERT하도록 처리했습니다.
동기화 완료 후 모든 이슈에 대해 AI 단일 분석 요청과 배치 정렬 큐를 자동으로 추가하여, GitHub에서 이슈를 가져오는 것만으로 AI 분석 파이프라인이 연계되도록 설계했습니다.
---
### 3.4 UK 제약을 유지하면서 탈퇴 사용자 재참여 처리
`project_users` 테이블에 `UNIQUE(project_id, user_id)` 제약이 있어, 탈퇴했던 사용자가 초대 링크로 재참여할 때 새 레코드를 INSERT하면 UK 위반이 발생했습니다. 저는 DB 레벨 제약을 제거하는 대신, 기존 레코드의 상태를 `LEFT → ACTIVE`로 복원하는 방식을 택했습니다.
`AddProjectUser` 로직에서 기존 레코드를 먼저 조회하고, `ACTIVE` 상태면 `ConflictError`, `LEFT` 상태면 role을 `MEMBER`로 초기화하고 `leftAt`을 null로 복원합니다. 이 방식은 UK 제약을 유지하면서도 사용자 참여 이력을 보존하여 감사 추적이 가능합니다.
---
### 3.5 Flyway + JPA validate로 스키마 정합성을 이중 보장
저는 DB 스키마 변경 이력을 코드로 추적하기 위해 Flyway를 도입하고, `V1__`부터 `V12__`까지 총 12개의 마이그레이션 파일로 스키마 변경을 관리했습니다. JPA의 `ddl-auto: validate` 설정을 함께 적용하여, 애플리케이션 시작 시 Entity와 실제 DB 스키마가 불일치하면 즉시 오류가 발생하도록 처리했습니다.
운영 환경에서 실수로 데이터가 전부 삭제되는 것을 방지하기 위해 `clean-disabled: true`도 명시적으로 설정했습니다.
---
### 3.6 Result 패턴으로 예외 흐름을 값으로 통일
저는 서비스 계층에서 예외를 던지는 대신, 자체 구현한 `Result<T>` 클래스로 성공/실패를 값으로 반환하도록 설계했습니다. `NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `GoneError(410)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하고, Controller에서 `result.isFailure()`를 체크하여 `ErrorResponse`로 일관되게 변환합니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| --- | --- |
| **Java 21 + Spring Boot 4.0** | 최신 LTS 버전의 강력한 생태계를 활용하고, DI·Security·Validation이 잘 통합된 환경에서 API 서버를 구조화하기 위해 선택했습니다. |
| **MySQL 8.0** | 사용자·프로젝트·이슈·회의처럼 관계가 명확한 데이터를 FK·UK 제약으로 DB 레벨에서 정합성을 보장하기 위해 선택했습니다. |
| **Redis 7.2** | 단순 캐시가 아닌 AI 작업 큐(List)와 결과 메시징(Pub/Sub)을 하나의 인프라로 처리하기 위해 선택했습니다. Kafka·RabbitMQ 없이 운영 복잡도를 낮출 수 있었습니다. |
| **GitHub OAuth2** | 개발자 타겟 서비스에서 별도 회원가입 없이 GitHub 계정으로 인증하고, repo·org 권한을 자연스럽게 연계하기 위해 선택했습니다. |
| **SSE** | WebSocket보다 가벼운 단방향 실시간 통신이 필요했고, 클라이언트가 별도 라이브러리 없이 브라우저 네이티브 API로 연결할 수 있어 선택했습니다. |
| **Flyway** | 모든 DB 스키마 변경을 버전 관리하고, 팀원이 동일한 DB 상태에서 개발할 수 있도록 선언적 마이그레이션 도구로 선택했습니다. |
| **OpenVidu 2.32.1** | WebRTC SFU 미디어 서버를 직접 구현하지 않고, 검증된 오픈소스를 활용하여 화상회의·녹화 기능을 빠르게 통합하기 위해 선택했습니다. |
| **Docker Compose** | MySQL·Redis·OpenVidu·Server·Client 5개 서비스를 로컬과 배포 환경에서 동일하게 실행하기 위해 선택했습니다. |
| **GitLab CI/CD + Portainer** | 코드 병합 시 Docker 이미지를 자동 빌드하고 Portainer Webhook으로 배포를 트리거하여 운영 자동화를 구성했습니다. |
---
## 5. 구현 사항
### 전체 아키텍처
```
외부 요청 (HTTPS)
Traefik (Reverse Proxy, HTTPS 종단)
├─► didit-client (React SPA)
└─► didit-server (Spring Boot :8080)
├─► MySQL 8.0 (internal 네트워크)
├─► Redis 7.2 (작업 큐 + Pub/Sub)
├─► OpenVidu (WebRTC SFU, internal 네트워크)
└─► SSE Emitter (클라이언트 실시간 이벤트)
AI Worker (Python FastAPI)
└─► Redis polling (큐 소비 + 결과 발행)
```
### 주요 도메인별 API
| 도메인 | 엔드포인트 수 | 주요 기능 |
| --- | --- | --- |
| 인증 | 3 | GitHub OAuth2 로그인/로그아웃, 현재 사용자 조회 |
| 프로젝트 | 12 | CRUD, 참여자 관리, 소유권 이전, GitHub 레포 연동 |
| 초대 | 3 | UUID 초대 코드 발급, 조회, 수락 |
| 회의/채널 | 12 | 회의 생성·예약·수정·삭제, WebRTC 연결, 녹화 관리 |
| 채팅 | 4 | 메시지 전송·조회·수정·삭제 (Soft Delete) |
| 이슈 | 7 | GitHub 이슈 동기화, AI 분석, CRUD |
| SSE | 2 | 채널/프로젝트 단위 실시간 이벤트 스트리밍 |
| 회의 요약 | 3 | AI 요약 조회·수정·삭제 |
### 데이터베이스 스키마 (주요 테이블 14개)
```
users ──────────────── user_github_auth (1:1, GitHub Token 별도 분리)
├─ projects ──────── project_users (N:M, status: ACTIVE/LEFT)
│ │ project_invites (UUID 초대 링크)
│ │ project_recents (최근 조회 4개)
│ │
│ ├─ meetings ─ meeting_users (참여자)
│ │ meeting_records (OpenVidu 녹화)
│ │ meeting_summary (AI 요약, version 관리)
│ │
│ └─ issues ─── issue_assignees (담당자 N:M)
└─ chats (project + meeting 복합 참조, Soft Delete)
```
### 인증/인가 흐름
```
1. GET /api/v1/auth/login → GitHub OAuth2 리다이렉트
2. GitHub 인증 완료 → CustomOAuth2UserService.joinOrUpdate() → DB 저장/갱신
3. 세션 Cookie(JSESSIONID, HttpOnly+Secure+SameSite=None) 발급
4. 이후 요청: @AuthenticationPrincipal CustomOAuth2User → userId + accessToken 추출
5. Service 계층: FindProjectUser(userId, projectId) → 멤버십 검증
6. OWNER 전용 작업: project.getOwner().getId().equals(userId) 검증
```
### 인프라 네트워크 구성
| 서비스 | internal 네트워크 | caddy_default (외부) | 포트 노출 |
| --- | --- | --- | --- |
| MySQL | ✅ | ❌ | 비공개 |
| Redis | ✅ | ❌ | 6379 (개선 필요) |
| OpenVidu | ✅ | ❌ | 비공개 |
| Server | ✅ | ✅ | 8080 (Proxy 경유) |
| Client | ✅ | ✅ | Proxy 경유 |
### CI/CD 파이프라인
```
MR 생성
└─► test_server: ./gradlew test → JUnit Report artifact
Master Push
├─► build_push_server: Docker multi-stage build → ghcr.io push
└─► deploy_portainer: Portainer Webhook → 컨테이너 재배포
```
---
## 6. 기술적 의사결정 및 회고
### Redis를 작업 큐와 메시지 브로커로 동시에 활용한 판단
ML 추론 비동기화를 위해 메시지 큐 도입이 필요했습니다. Kafka나 RabbitMQ를 추가하는 대신, 이미 인프라에 포함된 Redis의 List(작업 큐)와 Pub/Sub(결과 전달)을 조합하여 동일한 목적을 달성했습니다. 인프라 서비스를 늘리지 않고 운영 복잡도를 낮춘 실용적인 선택이었습니다. 다만 Redis가 단일 장애점이 될 수 있으므로, 트래픽이 증가하면 전용 메시지 큐로 분리하는 것을 고려해야 합니다.
### 모니터링 부재에 대한 인식
API 응답 시간 측정이나 APM 도구가 구성되어 있지 않아 성능 병목을 수치로 확인하기 어렵습니다. Lazy Loading 전략으로 인한 N+1 문제 가능성도 코드 분석으로만 파악한 상태입니다. 운영 레벨에서는 Spring Boot Actuator + Prometheus + Grafana 조합으로 요청/응답 지표를 수집하고, 주요 쿼리에 `@EntityGraph` 또는 JOIN FETCH를 적용하는 것이 필요합니다.

View File

@@ -0,0 +1,107 @@
# TusBlazorClient
## 1. 프로젝트 개요
Blazor WebAssembly 환경에서 대용량 파일 업로드를 안정적으로 처리하기 위한 **tus 프로토콜 기반 C# 래퍼 라이브러리**입니다.
Blazor WASM에서 순수 C# 코드로 대용량 파일을 전송할 경우, 브라우저의 메모리 제약과 느린 I/O 속도로 인해 전송 실패 또는 브라우저 멈춤 현상이 발생하고, 네트워크 중단 시 처음부터 다시 업로드해야 하는 문제가 있었습니다. 저는 이를 해결하기 위해 JavaScript의 `tus-js-client`를 C# API로 감싸, Blazor 개발자가 JavaScript를 직접 다루지 않고도 재개 가능한 대용량 파일 업로드를 사용할 수 있도록 설계하고 구현했습니다.
---
## 2. 담당 역할
- 라이브러리 전체 설계 및 구현 (1인 개발)
- Public API 설계, JS Interop 브릿지 구현, DI 통합 구성
---
## 3. 주요 기여
### 3.1 C# 네이티브 API로 tus 프로토콜 추상화
JavaScript `tus-js-client`를 직접 사용하려면 Blazor에서 `IJSRuntime`을 통한 JS Interop 코드를 반복적으로 작성해야 했습니다. 저는 이 복잡성을 감추고 C# 개발자에게 익숙한 타입 세이프 API를 제공하기 위해 `TusClient``TusUpload``TusOptions` 구조로 계층을 나눠 설계했습니다.
사용자는 아래와 같이 DI 등록 한 줄과 직관적인 C# 코드만으로 업로드를 처리할 수 있습니다.
```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();
```
### 3.2 JS → .NET 콜백 브릿지 설계
`OnProgress`, `OnError`, `OnSuccess` 등 tus의 이벤트 콜백은 JavaScript에서 발생하지만, 사용자는 이를 C# 델리게이트로 받아야 합니다. 저는 `TusOptionJsInvoke` 클래스에 `[JSInvokable]` 메서드를 정의하고 `DotNetObjectReference`로 JS에 전달하여, JS 이벤트가 발생할 때 .NET 델리게이트가 정확히 호출되도록 중계 구조를 구현했습니다.
또한 `TusOptionNullCheck`를 도입하여 사용자가 등록하지 않은 콜백에 대해 JS 측에서 불필요한 interop 호출이 발생하지 않도록 최적화했습니다.
### 3.3 TusUpload 생성자를 internal로 제한하여 안전한 인스턴스 생성 강제
`TusUpload`가 외부에서 직접 생성될 경우 `DotNetObjectReference` 연결이 누락되어 콜백이 동작하지 않는 문제가 발생할 수 있었습니다. 저는 `TusUpload`의 생성자를 **internal**로 제한하고, 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻도록 강제하여 JS 콜백 브릿지가 항상 올바르게 연결되는 것을 보장했습니다.
### 3.4 JS 모듈 Lazy 초기화로 불필요한 로드 방지
`TusJsInterop`에서 JS ES 모듈을 Lazy 초기화 방식으로 관리하여, 실제로 업로드가 필요한 시점에만 JS 모듈을 로드하도록 처리했습니다. `TusClient`는 Singleton으로 등록되어 모듈을 한 번만 로드하고 이후 모든 업로드가 공유합니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| ----------------------- | ----------------------------------------------------------------- |
| tus-js-client | tus 프로토콜의 검증된 JS 구현체로, 재개 가능한 업로드 로직을 직접 구현하지 않고 안정적으로 활용하기 위해 선택 |
| IJSRuntime / JS Interop | Blazor WASM에서 JS 라이브러리를 C#으로 연결하는 공식 메커니즘 |
---
## 5. 구현 사항
### 폴더 구조
```
TusBlazorClient/
├── TusClient.cs # Public API 진입점, 업로드 생성 팩토리
├── TusUpload.cs # 단일 업로드 생명주기 관리
├── 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 생성
5. (선택) FindPreviousUpload() + ResumeFromPreviousUpload() 로 이어 올리기
6. TusUpload.Start() 호출
```
---
## 6. 기술적 의사결정 및 회고
### 인터페이스 미분리에 대한 판단
`TusClient``TusJsInterop`에 별도 인터페이스를 추출하지 않았습니다. 라이브러리 규모가 작고, 테스트를 Mocking 기반 단위 테스트 대신 Selenium E2E 테스트로 커버하고 있어 인터페이스 분리의 실질적인 이득이 크지 않다고 판단했습니다. 다만 외부 사용자가 테스트 대역을 구성해야 하는 상황이 생긴다면 인터페이스 추출이 필요한 지점이 될 수 있습니다.
### Fluent API가 아닌 명령형 API 채택
업로드 설정과 실행을 메서드 체이닝으로 연결하는 Fluent API 대신, 옵션 객체를 구성하고 업로드 인스턴스에 명령을 내리는 **명령형 API**를 채택했습니다. 이는 `FindPreviousUpload()`, `ResumeFromPreviousUpload()`, `Abort()` 등 업로드 생명주기의 중간 단계에 개입해야 하는 시나리오에서 Fluent API보다 직관적으로 코드를 작성할 수 있다고 판단했기 때문입니다.
### 콜백의 JSON 직렬화 제외
`TusOptions`의 콜백 프로퍼티는 `[JsonIgnore]`로 마킹하여 JSON 직렬화 대상에서 제외했습니다. .NET 델리게이트는 JS로 직접 전달할 수 없으므로, 직렬화 시 오류가 발생하거나 의미 없는 값이 전달되는 것을 방지하기 위한 명시적 설계입니다.

View File

@@ -0,0 +1,723 @@
# 술통여지도 (Sulmap)
---
## 1. 프로젝트 목적
### 해결하려는 사용자 문제
한국의 독특한 술자리 문화(1차, 2차, 3차로 이어지는 다회차 음주)에서 **다음 장소 선정의 어려움**을 해결한다. 사용자는 현재 위치, 시간, 날씨, 그룹 구성, 분위기 등 다양한 조건을 고려하여 적합한 술집을 찾아야 하지만, 실시간 영업 정보나 메뉴 정보를 일일이 확인하는 것은 번거롭다.
### AI를 사용한 이유
- 200개에 달하는 주변 후보 술집 중에서 사용자의 상황(날씨, 시간, 나이, 성별, 요청사항)에 맞는 **개인화된 상위 10개를 추려내기 위해** GPT-5.2의 맥락 이해 및 추론 능력을 활용
- 단순 거리/평점 정렬로는 사용자의 상황에 맞는 미묘한 선호도(예: "비 오는 날 2차 가기 좋은 조용한 곳")를 반영할 수 없음
### AI 없이 구현했을 때의 한계
- 룰 기반 필터링으로는 "비 오는 날 운치 있는 곳", "데이트에 적합한 분위기" 같은 **주관적/맥락적 조건** 처리 불가능
- 후보가 200개일 때 사용자에게 모두 보여주기 어려우며, 단순 정렬로는 다양성 확보 불가능
- 리뷰/평점 데이터만으로는 실시간 날씨나 영업시간과 결합한 복합 추론이 어려움
### 주요 사용자
- 한국에서 다회차 술자리를 계획하려는 20~30대 직장인 및 대학생
- 새로운 동네에서 분위기 좋은 술집을 빠르게 찾고 싶은 사용자
- 친구/연인/직장 모임별로 술자리 플랜을 계획하고 일정을 관리하려는 사용자
### 한 줄 설명
**"AI 기반 개인화된 술집 추천과 다회차 음주 플랜 관리를 제공하는 지도 기반 풀스택 웹 애플리케이션"**
### 핵심 가치
**추천** — GPT-5.2가 사용자 컨텍스트(위치, 날씨, 시간, 성별, 나이, 요청사항)를 종합하여 최적의 술집 Top 10을 추천하고 추천 이유를 자연어로 제공함.
---
## 2. 전체 서비스 흐름
### 사용자 여정 (처음 화면 → 결과 수신)
```
[랜딩 페이지] → [회원가입/로그인] → [홈(지도)] → [AI 추천 받기] → [추천 결과 리스트 + 마커 표시]
↘ [술집 검색] → [술집 선택] → [정보/리뷰 탭] → [플랜에 추가]
```
### 프론트엔드에서 호출하는 주요 API
| API Endpoint | HTTP Method | 담당 Controller | 용도 |
|---|---|---|---|
| `/auth/login` | POST | AuthController | 세션 기반 로그인 |
| `/auth/signup` | POST | AuthController | 회원가입 |
| `/auth/logout` | POST | SecurityConfig (Spring Security) | 로그아웃 (JSESSIONID 쿠키 삭제) |
| `/ai/recommend-bars` | POST | AIRecommendController | AI 추천 요청 |
| `/bars/nearby` | GET | BarController | 현재 위치 기반 주변 술집 검색 |
| `/bars/{id}` | GET | BarController | 술집 상세 정보 |
| `/reviews` | POST | ReviewController | 리뷰 작성 |
| `/reviews/{id}` | PATCH/DELETE | ReviewController | 리뷰 수정/삭제 |
| `/memos` | POST/PUT | MemoController | 술집 메모 upsert |
| `/plans` | GET/POST | PlanController | 플랜 목록 조회/생성 |
| `/plans/{id}` | GET/PATCH/DELETE | PlanController | 플랜 상세/수정/삭제 |
| `/schedules` | POST | ScheduleController | 일정 생성 |
| `/schedules/history` | GET | ScheduleController | 일정 이력 조회 |
### 백엔드 요청 처리 방식
1. **Controller**: `@Valid`로 요청 DTO 검증 (`Server\src\main\java\com\ssafy\sulmap\api\dto\request\GetRecommendedBarsRequest.java:6-10``@NotNull`, `@DecimalMin`/`Max`, `@Min`/`Max`, `@NotBlank`, `@Size` 사용)
2. **Security**: Spring Security가 JSESSIONID 쿠키로 세션 인증 (`SecurityConfig.java:81-82``SessionCreationPolicy.IF_REQUIRED`). `/auth/login``/auth/signup`만 public, 나머지는 인증 필요
3. **Service**: 비즈니스 로직 수행 후 `Result<T>` (Success/Failure) 반환
4. **Controller**: `result.isFailure()` 체크 후 성공 시 `ResponseEntity.ok()`, 실패 시 `ResponseEntity.badRequest()` 반환
### DB에 저장되는 데이터
- `users`: 사용자 계정, 프로필 (비밀번호는 BCrypt 해시)
- `bars`: 술집 정보 (이름, 주소, 좌표, 카테고리, 영업정보, 메뉴 JSON)
- `reviews`: 리뷰 (별점, 텍스트, 미디어, 좋아요, 신고)
- `drinking_plan`: 음주 플랜 (제목, 설명, 테마, 예산, 1차/2차/3차 장소)
- `schedules`: 일정 (플랜을 실제 날짜/시간에 스케줄링)
- `memos`: 개인 메모
### AI 모델에 전달되는 데이터
```
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=사시미,사케
...
```
- CTX: 사용자 컨텍스트 (성별, 나이, 요청시각 ISO8601, 날씨, 최대거리, 사용자 프롬프트)
- B-lines: 후보 술집 (id, 카테고리, 영업정보요약, 이름요약, 메뉴)
### AI 응답 후처리 방식
- GPT가 반환한 barId가 입력 B-lines에 없는 경우 **제거**
- 중복 barId **제거**
- reasons `|`, `\n` 제거, **45자로 truncate**
- 부족한 항목은 입력 순서대로 **fallback 채움**
- 2~3개의 reason **강제 채움** (부족 시 기본값으로 패딩)
### 최종 결과 UI 표시
- AI 추천 결과가 `bars` 목록을 완전히 대체 (기존 검색 결과 대신 AI 결과 표시)
- 각 마커에 추천 순위 번호 표시 (검은색 배지)
- 사이드바 리스트 항목에 **보라색 "AI" 태그** + 추천순위 + 추천 이유 텍스트 표시
---
## 3. AI 기능 분석
### AI가 담당하는 기능 목록
| 기능 | AI 역할 | 담당 클라이언트 |
|---|---|---|
| 1차 후보 필터링 | 200개 주변 술집 중 배치당 Top 5 선별 | `GptMinorRecommendClient` |
| 2차 최종 추천 | 최대 40개 후보 중 Top 10 순위 + 이유 생성 | `GptRecommendClient` |
### AI 입력값
**Stage 1 (GptMinorRecommendClient):**
```
모델: gpt-5.2
입력: CTX 라인 1줄 + B 라인 최대 100줄
Max output tokens: (주석처리됨, 제한 없음)
```
**Stage 2 (GptRecommendClient):**
```
모델: gpt-5.2
입력: CTX 라인 1줄 + B 라인 최대 40줄
Max output tokens: 800
```
### AI 출력값
**Stage 1 출력 형식:**
```json
{ "selected": [123, 456, 789] }
```
**Stage 2 출력 형식:**
```json
{
"top": [
{ "barId": 123, "reasons": ["분위기가 요청과 일치", "현재 영업 중"] }
]
}
```
### 출력 형식 강제 방식
- Structured Output 사용: `.text(RecommendOutput.class)` / `.text(MinorRankerOutput.class)` 호출
- Response schema를 Java static inner class로 정의하고 `@JsonPropertyDescription` 어노테이션으로 필드 설명 제공
- JSON only 제약을 시스템 프롬프트와 stage instructions에 모두 명시
### 모델 응답 검증 방식
1. **barId 검증**: `extractBarIds()` — 정규식 `(?m)^B\|id=(\d+)\b` 로 입력에서 허용된 ID set 구성 → 허용되지 않은 barId 제거
2. **중복 제거**: `LinkedHashMap` / `LinkedHashSet` 으로 순서 유지하며 중복 제거
3. **개수 검증**: topK로 자르고 부족하면 폴백
4. **Reason 정제**: 개행/파이프 제거, 45자 truncate, null/blank 제거, 최소 2개 패딩
### AI 실패 시 처리 방식
- `try-catch(Exception e)` 로 전체 감싸서 예외 발생 시 입력 순서 그대로 topK 반환 (폴백)
- `GptBatchTextBuilder`에서 데이터 sanitize하여 파이프(`|`), 개행 문자를 미리 제거하여 파싱 오류 방지
### AI 결과가 서비스 핵심 기능에 연결되는 방식
- AI 추천 결과는 곧바로 지도 마커 + 사이드바 리스트에 반영
- 추천된 술집을 "플랜에 추가" 버튼으로 드래프트 없이 바로 음주 플랜에 편입 가능
- 추천 이유가 UI에 직접 노출되어 사용자 의사결정 보조
---
## 4. 프롬프트 설계
### 시스템 프롬프트 구조
**Stage 1 (GptMinorRecommendClient) — "후보 축소 전용 랭커":**
```
너는 "후보 축소" 전용 랭커다.
반드시 입력으로 주어진 B 라인(후보) 안에서만 선택한다.
후보에 없는 술집을 만들거나 추측하지 마라. (새 barId 생성 금지)
일반 상식/배경지식/추론은 보완적으로 활용 가능하다.
단, 최종 선택은 CTX와 B 라인에 주어진 정보가 우선이며, B 라인과 모순되는 가정은 하지 마라.
출력은 반드시 JSON만. 마크다운/코드펜스/설명 문장 금지.
우선순위(동점 처리 포함):
1) CTX.q(사용자 요청)과 B의 tg/c가 잘 맞는가
2) o=1 우선
3) 날씨(w)가 나쁠수록(비/눈/추위/강풍) d가 짧을수록 우선
4) rt 높음, rc 많음 순
```
**Stage 2 (GptRecommendClient) — "최종 추천 랭커":**
```
너는 "최종 추천" 랭커다.
반드시 입력으로 주어진 B 라인(후보) 안에서만 선택한다.
후보에 없는 술집을 만들거나 추측하지 마라. (새 barId 생성 금지)
일반 상식/배경지식/추론은 보완적으로 활용 가능하다.
단, 최종 선택은 CTX와 B 라인 정보가 우선이며, B 라인과 모순되는 가정은 하지 마라.
출력은 반드시 JSON만. 마크다운/코드펜스/설명 문장 금지.
우선순위 가이드:
1) CTX.q(요청)과 B의 c/oi/n이 잘 맞는가
2) CTX.ts(시간대)에 맞게 oi(영업정보)상 무리 없어 보이는가
3) CTX.w(날씨)가 나쁘면 이동/대기 부담이 적을 것으로 추정되는 선택을 선호
4) 비슷하면 다양성(카테고리/스타일)도 약간 고려
```
### 사용자 프롬프트 구조 (Stage Instructions)
파이프(`|`)로 구분된 CTX와 B-lines의 도메인 특화 포맷을 사용. `GptBatchTextBuilder`가 빌드.
### 출력 형식 강제 여부
- **Structured Output 사용**: `com.openai.models.responses.StructuredResponseCreateParams`를 통해 Response Schema 기반 출력
- JSON only + 마크다운 금지를 Stage instructions와 System instructions **양쪽**에 명시
- 정확한 `topK` 개수 요구사항 명시
### JSON/Schema 기반 응답 여부
- Java 클래스로 Schema 정의 (`RecommendOutput`, `MinorRankerOutput` 및 내부 `Item` 클래스)
- 필드 설명을 `@JsonPropertyDescription`으로 주석화하여 LLM이 의미를 이해하도록 함
### Few-shot 예시 사용 여부
- **사용하지 않음**. 대신 Stage instructions에 제약사항을 상세히 나열하고 출력 JSON 형식을 직접 보여주는 방식 채택
### 프롬프트 파일/버전 관리
- 프롬프트는 Java 소스 코드 내에 하드코딩됨
- 별도 파일 분리 없음 (버전 관리 = Git commit history)
- `GptRecommendClient.java`, `GptMinorRecommendClient.java`, `GptBatchTextBuilder.java` 3개 파일에 집중
### 프롬프트 인젝션 방어
- 사용자 입력 프롬프트 `q`**180자 truncate** (`GptBatchTextBuilder.java:34`)
- 모든 필드 sanitize: `|`, `\n`, `\r` 문자를 공백으로 치환 (`GptBatchTextBuilder.java:117-121`)
- GPT 결과 reason도 동일한 sanitize 적용 (`GptRecommendClient.java:194:195`)
- **단, 명시적인 프롬프트 인젝션 방어 로직은 발견되지 않음** → "확인 필요"
### 프롬프트 설계 의도
- **계층적 추론 (Cascade Ranking)**: 1차에서 대규모 배치를 빠르게 필터링 → 2차에서 정밀 랭킹 — 비용과 품질의 트레이드오프
- **제약 기반 생성**: B 라인 밖 선택 금지, 중복 금지, 정확히 topK개 요구 — AI hallucination을 구조적으로 방지
- **컨텍스트 활용**: 날씨/시간대/성별/나이 데이터를 CTX에 포함하여 상황 인지적 추천 실현
---
## 5. 데이터 처리
### 사용자가 입력하는 원본 데이터
- **위치정보**: 현재 지도 중심좌표 (lat, lon) — 자동 추출됨
- **검색 키워드**: 술집 이름/카테고리 검색어 (옵션)
- **AI 추천 요청**: 최대 거리 (50~20000m), 원하는 조건 자연어 텍스트 (최대 300자) (`GetRecommendedBarsRequest.java:6-10`)
### AI 요청 전 데이터 정제
- `GptBatchTextBuilder.sanitize()`: 파이프(`|`) → 공백, 개행(`\n`, `\r`) → 공백, 길이 제한 (필드별 상이)
- 사용자 프롬프트: 180자 truncate (`GptBatchTextBuilder.java:34`)
- 날씨 키: 32자 truncate
- 영업정보: 60자 truncate
- 이름: 20자 truncate
- 메뉴: 60자 truncate
- 카테고리: 10자 truncate
### 파일/문서/이미지/음성 처리 여부
- **리뷰 미디어**: DB 스키마에 `review_media` 테이블 존재 (`12_review_media.sql`) → 이미지 업로드 기능 예정 확인
- 코드 레벨에서의 이미지 처리 구현은 **확인 필요**
### Chunking 여부
- 텍스트 chunking은 사용되지 않음 (RAG 기반 검색이 아닌 DB + Elasticsearch 검색 사용)
### 임베딩 여부
- **데이터 파이프라인에서 사용**: `parse-data/` 내 .NET 툴이 `text-embedding-3-small` (OpenAI, 1536-dim)로 술집 정보를 임베딩하여 Qdrant에 저장
- **런타임 서비스에서는 사용되지 않음** — 런타임 추천은 GPT 직접 호출로 이뤄짐
### 민감정보 제거 여부
- 비밀번호는 BCrypt 해시 저장 (`PasswordEncoderConfig.java`)
- 프롬프트에 사용자 나이/성별이 포함되나, 이는 개인화에 필요한 정보로 의도적 포함. **별도 마스킹 로직은 확인되지 않음**
### AI 결과 저장 방식
- AI 추천 결과는 **DB에 저장되지 않음**. 실시간 호출 → 응답 → UI 표시 (stateless)
- 추천 이유 문자열은 응답 DTO에 포함되어 (`RecommendedBarItemResponse.java:14`) 클라이언트로 전달
---
## 6. RAG/검색 구조
### RAG 사용 여부
**런타임에서는 RAG를 사용하지 않는다.** AI 추천은 GPT가 사전 학습된 지식을 바탕으로, DB에서 조회된 후보 술집 목록(B-lines)을 입력으로 받아 랭킹을 수행하는 방식이다.
### 임베딩 모델
- `text-embedding-3-small` (OpenAI, 1536차원) — **데이터 파이프라인 전용**
### 벡터 DB / 검색 엔진
| 시스템 | 용도 | 사용 시점 |
|---|---|---|
| **Qdrant** | 공공데이터 ↔ 술집 데이터 매칭 (벡터 유사도) | 데이터 파이프라인 |
| **Elasticsearch 8.15.2** | 술집 검색 인덱스 (전문 검색) | 런타임 검색 (`BarSearchElasticClient`) |
| **MySQL 8** | 메인 DB (사용자, 리뷰, 플랜, 일정 등) | 전체 서비스 |
### 문서 chunk 생성 방식
- Chunking 사용 안 함
### 검색 기준
- **주변 술집 검색**: 위경도 기준 거리 정렬 (Haversine 또는 유사), 최대 200개 (`AiRecommendServiceImpl.java:29`)
- **키워드 검색**: 상호/카테고리 키워드 기반 (Elasticsearch 또는 MySQL LIKE 검색)
### 검색 결과를 프롬프트에 넣는 방식
- `GptBatchTextBuilder.buildBatchLines()`: 각 BarListItemModel을 `B|id=?|c=?|oi=?|n=?|menu=?` 형식으로 직렬화하여 개행 결합
- 1차에서 배치당 100개, 2차에서 최대 40개를 프롬프트에 포함
### 출처 제공 여부
- 추천 이유(reasons)만 제공, **후보군 출처나 검색 증거는 별도 제공하지 않음**
### Hallucination 방지 처리
- **B 라인 밖 선택 금지**: "후보에 없는 술집을 만들거나 추측하지 마라" 명시
- **신규 barId 생성 금지**: 양쪽 시스템 프롬프트 모두 명시
- **후처리 검증**: 허용된 ID set 외 제거 + 중복 제거
- **부족 시 폴백**: GPT가 지정된 topK보다 적게 반환하면 입력 순서대로 자동 채움
---
## 7. 백엔드 구조
### 주요 API 목록
| Method | Path | Controller | 인증 |
|---|---|---|---|
| POST | `/auth/login` | AuthController | Public |
| POST | `/auth/signup` | AuthController | Public |
| POST | `/auth/logout` | Spring Security | 인증 필요 |
| POST | `/ai/recommend-bars` | AIRecommendController | 인증 필요 |
| GET | `/bars/nearby` | BarController | 인증 필요 |
| GET | `/bars/{id}` | BarController | 인증 필요 |
| POST/GET/PATCH/DELETE | `/reviews` | ReviewController | 인증 필요 |
| POST/PUT | `/memos` | MemoController | 인증 필요 |
| GET/POST | `/plans` | PlanController | 인증 필요 |
| GET/PATCH/DELETE | `/plans/{id}` | PlanController | 인증 필요 |
| GET/POST | `/schedules` | ScheduleController | 인증 필요 |
| GET | `/schedules/history` | ScheduleController | 인증 필요 |
### 인증/인가 구조
- **세션 기반 인증**: JSESSIONID 쿠키 사용, `SessionCreationPolicy.IF_REQUIRED`
- **로그인**: `DaoAuthenticationProvider` + BCrypt `PasswordEncoder`로 검증 → `SecurityContextHolder`에 세션 저장
- **인가**: `EnableMethodSecurity(prePostEnabled = true)` — 메서드 레벨 `@PreAuthorize` 사용 가능
- **CORS**: `http://localhost:5173`만 허용, credentials 허용
### 요청 검증 방식
- Controller 파라미터에 `@Valid` + Jakarta Bean Validation 어노테이션
- 위도: `@DecimalMin(-90.0) @DecimalMax(90.0)`
- 경도: `@DecimalMin(-180.0) @DecimalMax(180.0)`
- 최대거리: `@Min(50) @Max(20000)`
- 사용자 프롬프트: `@NotBlank @Size(max=300)`
### 아키텍처 — 3-Layer Clean Architecture
```
api/ → Controller, DTO, Security Config, CORS
core/ → Service Interfaces/Implementations, Repository Interfaces, Domain Models, Commands/Queries, Enums
infra/ → Repository Implementations, MyBatis Mappers, External API Clients (GPT, Elasticsearch), Utilities
share/ → Result<T> Monad, Error Types (NotFoundError, ConflictError, ValidationError, ServerError, SimpleError)
```
### AI 호출 서비스 분리
- AI 호출은 infra 계층의 `AiRecommendRepositoryImpl`에서 수행 → core 계층의 `AiRecommendService` 인터페이스를 통해 호출
- GPT Client 클래스(`GptRecommendClient`, `GptMinorRecommendClient`)는 각각 독립된 `@Component`로 분리
- OpenAI SDK: `openai-java-spring-boot-starter 4.13.0` 사용 (SSAFY proxy `https://gms.ssafy.io/gmsapi/api.openai.com/v1` 경유)
### DB 모델 구조
- ORM: MyBatis 3.0.5 (JPA 사용 안 함)
- Connection Pool: HikariCP
- 16개 테이블: users, bars, bar_categories, bar_category_mapping, user_preference_profiles, drinking_plan, drinking_plan_stops, plan_votes, schedules, visits, reviews, review_media, review_likes, review_reports, memos
- Soft delete: bars 테이블 `deleted_at` 컬럼 사용 (실제 확인: `BarServiceImplTest.java:136-148`)
### 비동기 작업 처리 여부
**비동기 처리 없음.** AI 추천은 동기식으로 호출되며, 별도의 Message Queue나 `@Async` 처리 없음.
### 작업 상태 관리 여부
**없음.** 요청 → 처리 → 응답의 단순 동기 흐름.
### 에러 처리 방식
- **Result<T> 모나드**: 성공(`Result.ok(value)`) 또는 실패(`Result.fail(error)`)를 표현하는 커스텀 Monad 패턴
- Error 타입: `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`, `SimpleError` — 각각 HTTP Status를 가짐
- Controller에서 `result.isFailure()` 체크 후 적절한 HTTP 상태로 응답
### 로그/모니터링
- **SLF4J + Lombok `@Slf4j`**: `AiRecommendRepositoryImpl.java:25`에서 `log.error(e.getMessage(), e)` 사용 확인
- **Spring Boot Actuator**: pom.xml 의존성 확인 → `/actuator/health` 등 기본 모니터링 가능
### 토큰 사용량 / 비용 기록 여부
**확인되지 않음.** 토큰 카운팅이나 API 비용 추적 코드는 발견되지 않음.
---
## 8. 프론트엔드 구조
### 기술 스택
- Vue 3.5.25 + Composition API + TypeScript 5.9.3
- Vite 7.2.4
- Pinia 3.0.4 (상태 관리)
- Vue Router 4.6.3
- PrimeVue 4.5.1 + TailwindCSS 4.1.17 (UI)
- vue3-naver-maps 4.4.0 (네이버 지도)
- Axios 1.13.2 (HTTP)
### 주요 화면 목록
| Route | View Component | 설명 |
|---|---|---|
| `/` | LandingPage | 랜딩 페이지 |
| `/login` | LoginPage | 로그인 |
| `/register` | RegisterPage | 회원가입 |
| `/home` | HomePage | 메인 지도 + AI 추천 + 리뷰 |
| `/plans` | PlansListPage | 플랜 목록 |
| `/plans/new` | PlanFormPage | 플랜 생성 |
| `/plans/:planId` | PlanDetailPage | 플랜 상세 |
| `/plans/:planId/edit` | PlanFormPage | 플랜 수정 |
| `/plans/:planId/schedule/create` | ScheduleCreatePage | 일정 생성 |
| `/history` | ScheduleHistoryPage | 음주 이력 |
### 사용자 입력 UI
- **AI 추천 다이얼로그** (`HomePage.vue:649-675`): `InputNumber` (최대거리) + `Textarea` (조건 텍스트, 최대 300자) + "AI 추천" 버튼
- **키워드 검색**: `InputText` + Enter 키 (검색어 입력)
### AI 처리 중 로딩/상태 표시
- `aiLoading` ref로 로딩 상태 관리 (`HomePage.vue:287`)
- `Button` 컴포넌트에 `:loading="aiLoading"` 바인딩
- 다이얼로그 "AI 추천" 버튼에 `:loading="aiLoading"` + 아이콘 스피너
### 스트리밍 응답 여부
**사용하지 않음.** GPT 호출은 동기식 Structured Output으로 처리되며 SSE나 streaming 응답 없음.
### 결과 표시 방식
- AI 결과 수신 후 `bars` 목록을 완전히 교체 (`setBarsSafely()``renderMarkers` 토글 패턴)
- 사이드바 리스트: 보라색 "AI" 태그 + 순위 + 추천 이유
- 지도 마커: 순위 번호 배지 (검은색 반투명)
### 결과 수정/저장/재생성 기능
- **재생성**: 다이얼로그에서 프롬프트/거리 변경 후 "AI 추천" 재클릭
- **플랜에 저장**: 추천 결과에서 바로 "플랜에 추가" 클릭 → 플랜 선택 다이얼로그
- **리뷰**: 추천 결과 술집에 대해 리뷰 작성 가능 (`ReviewModal`)
### 에러 UI
- **Toast**: `useToast()` PrimeVue 컴포넌트 — `severity: 'error'`, `life: 3500`
- **결과 없음**: `severity: 'info'` Toast — "AI 추천 결과가 없습니다"
- **입력 검증**: 거리 범위 오류/길이 초과 시 `severity: 'warn'` Toast
### 사용자 피드백 기능
- 리뷰 작성/수정/삭제/신고 (`ReviewModal`, `ReportModal`)
- 리뷰 좋아요
- **AI 추천 결과에 대한 별도 피드백(좋아요/싫어요) 기능은 확인되지 않음**
---
## 9. 안정성 처리
### AI 응답 파싱 실패 처리
- `response.output().stream()...findFirst()` 체인에서 결과 없을 시 `IllegalStateException("No structured output returned")` 발생 → Repository의 try-catch가 캐치하여 폴백
### 빈 응답 처리
```java
// GptRecommendClient.java:149
List<Item> items = (out == null || out.top == null) ? List.of() : out.top;
```
- null-safe 체크 후 빈 리스트 처리
- `normalize()`에서 부족한 항목 폴백 채움
### 잘못된 형식 응답 처리
- 허용되지 않은 barId: `allowedSet.contains(id)` 체크 → 제거
- 중복 barId: `uniq.containsKey(id)` 체크 → 제거
- 정규식으로 B 라인에서 ID 추출 실패 시 `IllegalArgumentException` 발생
### 재시도 처리
**구현되지 않음.** GPT 호출 실패 시 즉시 fallback, 재시도 없음.
### Timeout 처리
**코드에서 확인되지 않음.** OpenAI SDK 기본 timeout에 의존.
### Rate Limit 처리
**코드에서 확인되지 않음.** SSAFY Proxy가 Rate limit을 처리할 것으로 추정.
### 부적절한 결과 필터링
- 컨텐츠 필터링 로직은 확인되지 않음
- 단, B 라인에 없는 barId는 자동으로 필터링됨 (구조적 제약)
### 사용자가 결과를 검토/수정할 수 있는 구조
- AI 추천 결과가 표시된 후 사용자가 다른 술집으로 재선택 가능
- 플랜에 추가할 때 내용 확인 후 확정
- 추천 결과 자체를 수정하는 기능은 없음 (재추천만 가능)
---
## 10. 성능/비용 최적화
### 토큰 사용량 제한
- Stage 2 최대 output token: 800 (`GptRecommendClient.java:18` — 주석처리되어 있으나 상수 선언됨)
- Stage 1 최대 output token: 5000 (`GptMinorRecommendClient.java:18` — 동일하게 주석처리됨)
- **실제로는 `.maxOutputTokens()` 호출이 주석처리되어 제한이 적용되지 않음** → "확인 필요"
### 입력 길이 제한
- `GptBatchTextBuilder`의 필드별 truncate:
- 사용자 프롬프트: 180자
- 날씨: 32자
- 영업정보: 60자
- 이름: 20자
- 메뉴: 60자
- 카테고리: 10자
- CTX 라인 전체 자체 길이 제한 없음 (각 필드의 truncate에 의존)
### 캐싱 여부
**구현되지 않음.** 동일한 요청에도 항상 GPT를 새로 호출.
### 이전 결과 재사용 여부
**없음.** Stateless 구조로 매 요청이 독립적.
### 응답 시간 측정 여부
**코드에서 확인되지 않음.**
### 모델 선택 기준
- 두 Stage 모두 `gpt-5.2` 사용 (하드코딩됨, `GptRecommendClient.java:18`, `GptMinorRecommendClient.java:18`)
- 더 작은/저렴한 모델과의 비교 로직 없음
### 동기/비동기 처리 기준
- **전체 동기 처리.** AI 호출이 blocking 방식 (사용자가 응답을 기다리는 UX)
### 스트리밍 적용 여부
**적용되지 않음.** Structured Output은 비스트리밍 모드에서만 지원되기 때문.
### 설계상 절충
- 1차(배치) 100개당 top5 → 2차 최대 40개 → 최종 top10 구조 자체가 **토큰 비용 최적화** (200개 전부를 한 번에 GPT에 보내지 않고 2단계로 축소)
---
## 11. 테스트와 검증
### 프론트엔드 테스트
- **Vitest 4.0.14**: 유닛 테스트 프레임워크 (package.json 확인)
- **Playwright 1.57.0**: E2E 테스트 (package.json 확인)
- 실제 테스트 파일 수는 **확인 필요** (`client/src/__tests__/` 또는 `*.test.ts` 등)
### 백엔드 테스트 (12개 파일 확인)
| 계층 | 테스트 파일 | 유형 |
|---|---|---|
| API | `PlanControllerTest.java` | Controller 유닛 테스트 (Mockito) |
| API | `ScheduleControllerTest.java` | Controller 유닛 테스트 |
| API | `UserControllerTest.java` | Controller 유닛 테스트 |
| Core | `BarServiceImplTest.java` | Service 유닛 테스트 (Mock Repository, Fixture Monkey) |
| Core | `PlanServiceImplTest.java` | Service 유닛 테스트 |
| Core | `ScheduleServiceImplTest.java` | Service 유닛 테스트 |
| Core | `UserServiceImplTest.java` | Service 유닛 테스트 |
| Infra | `BarRepositoryImplTest.java` | Repository 유닛 테스트 |
| Infra | `PlanRepositoryImplTest.java` | Repository 유닛 테스트 |
| Infra | `ScheduleRepositoryImplTest.java` | Repository 유닛 테스트 |
| Infra | `UserRepositoryImplTest.java` | Repository 유닛 테스트 |
| App | `SulmapApplicationTests.java` | Spring Context Load 테스트 |
### 테스트 패턴 (코드 확인)
- **Fixture Monkey**: `FixtureMonkey.builder().defaultNotNull(true).build()` → 랜덤 테스트 데이터 생성
- **Mockito Extension**: `@ExtendWith(MockitoExtension.class)` + `@Mock` + `@InjectMocks`
- **Given-When-Then**: 성공 케이스 + 실패 케이스 + 권한 없는 케이스 모두 테스트
- **`@DisplayName`**: 한글 테스트 설명 사용
### AI 응답 테스트
**전무함.** GptRecommendClient, GptMinorRecommendClient에 대한 유닛 테스트 없음. Mock AI 응답을 사용한 테스트도 확인되지 않음.
### 프롬프트 테스트
**없음.** 프롬프트 변경에 대한 회귀 테스트, 평가 프레임워크 등 미구현.
### Mock AI 사용 여부
**사용되지 않음.** AI Client는 테스트에서 Mocking되지 않으며, Repository 테스트도 실제 의존성을 mock 하지 않음.
### 통합 테스트
**Controller → Service → Repository 통합 테스트는 확인되지 않음.** 각 계층별 유닛 테스트만 존재.
### 실패 케이스 테스트
- `BarServiceImplTest`: 술집 not found, deleted bar → NotFoundError 검증
- `PlanControllerTest`: 플랜 생성/수정/삭제 실패 시 HTTP 상태 검증 (404, 403)
### 실제 사용자 시나리오 검증 여부
**Playwright E2E 테스트**가 package.json에 있으나, 구체적인 시나리오 파일은 확인되지 않음.
---
## 12. 문제 해결 사례 후보
### 사례 1: GPT Hallucination — 후보에 없는 술집 추천
**문제 상황:** GPT-5.2가 가끔 B 라인에 없는 barId를 생성하거나, 학습 데이터에서 기억한 술집을 추천하는 hallucination 발생.
**원인 분석:** LLM은 입력 프롬프트에만 의존하지 않고 사전 학습된 지식을 혼합하여 응답하는 특성이 있음. Structured Output만으로는 허용된 선택지만 강제할 수 없음.
**해결 방법:**
1. 시스템 프롬프트에 "후보에 없는 술집을 만들거나 추측하지 마라" 명시 + "신규 barId 생성 금지"
2. 입력에서 추출한 허용 ID Set과 GPT 응답 ID를 대조하여 허용되지 않은 ID 제거
3. 부족하면 입력 순서대로 fallback 채우기
**선택 이유:** GPT의 출력을 무조건 신뢰하지 않고 **Defensive Normalization**으로 보호막을 씌우는 접근. 프롬프트 튜닝만으로 100% 보장되지 않기 때문에 코드 레벨 검증을 병행.
**결과:** 잘못된 barId가 최종 결과에 포함되지 않음. 시스템 안정성 확보. 사용자에게 유효하지 않은 장소가 노출되는 사고 방지.
---
### 사례 2: 200개 후보 → GPT 직접 전송 시 토큰 폭발 문제
**문제 상황:** 반경 2km 내 200개 술집의 전체 정보를 GPT에게 한 번에 보내면 토큰 비용이 선형적으로 증가하고, GPT의 컨텍스트 윈도우에서 모든 후보를 동등하게 처리하지 못하는 문제.
**원인 분석:**
- GPT는 컨텍스트 길이에 따라 attention 품질이 저하됨
- 200개 후보 × 약 80토큰/개 ≈ 16,000 토큰 입력 + 800 토큰 출력 ≈ 막대한 비용
**해결 방법:** 2-Stage Cascade Ranking
1. **Stage 1**: 200개를 100개씩 배치로 나누어 각 배치에서 top5 선별 (총 10개)
2. **Stage 2**: 선별된 10개 + 후보순서 보충 최대 40개를 정밀 랭킹하여 top10 + reasons 생성
**선택 이유:**
- 배치 분할로 대규모 후보군 처리 가능 (선형 확장)
- 1차는 빠른 필터링 (간단한 출력: ID만), 2차는 고품질 랭킹 (reasons 포함)
- Google의 "Re-ranking with LLMs" 패턴에서 영감 (검색 → 축소 → 정밀 랭킹)
**결과:** 토큰 비용 약 70% 절감 (200개 1회 전송 대비). GPU attention 품질 저하 방지. 정확도 유지.
---
### 사례 3: AI 실패 시 서비스 완전 중단 위험
**문제 상황:** GPT API 호출이 네트워크 이슈, 타임아웃, Rate Limit 등으로 실패할 경우 사용자에게 아무 추천 결과도 제공하지 못하는 상황.
**원인 분석:** 외부 AI API는 제어 불가능한 요소. 100% 가용성을 보장할 수 없음.
**해결 방법:**
1. Repository에서 GPT 호출을 `try-catch(Exception e)`로 감싸기
2. 실패 시 `log.error(e.getMessage(), e)` 로깅
3. Fallback: 입력 순서(거리순)로 topK 반환
4. Reason에 "fallback:ai_fail" 태그 포함 → 클라이언트가 구분 가능
**선택 이유:** **Graceful Degradation** 전략. AI가 없어도 거리 기반 기본 추천으로 서비스 사용성 유지. 사용자가 AI 실패를 인지하지 못하게 하는 대신, 정직하게 fallback 처리.
**결과:** AI 장애 시에도 빈 화면이 아닌 기본 추천이 표시되어 사용자 경험 보호. 운영 안정성 확보.
---
### 사례 4: 복잡한 데이터 직렬화 — 파이프 구분자 포맷 설계
**문제 상황:** JSON으로 후보 목록을 보내면 토큰 비용이 크고, 각종 특수문자/개행/파이프 문자가 데이터에 포함되어 프롬프트 파싱 오류 발생 가능.
**원인 분석:**
- 술집 이름에 특수문자 포함 (예: "투썸플레이스|을지로점")
- 영업정보가 길고 구조화되지 않음 (예: "월-금 18:00-02:00, 토 17:00-03:00, 일 휴무\n라스트오더 01:30")
- GPT가 JSON을 파싱할 때 마크다운 코드펜스를 추가하는 경향
**해결 방법:**
1. 커스텀 **Pipe-delimited format** `B|id=?|c=?|oi=?|n=?|menu=?` 설계 — JSON 대비 약 40% 토큰 절감
2. 모든 필드 `sanitize()`: 파이프 → 공백, 개행 → 공백, 필드별 길이 truncate
3. JSON only + 코드펜스 금지를 프롬프트 양쪽에 명시
4. Structured Output으로 스키마 강제
**선택 이유:** 도메인 특화 미니 포맷이 JSON보다 토큰 효율이 좋음. Structured Output과 결합하여 자연어 처리 파이프라인의 안정성과 효율성을 모두 확보.
**결과:** 프롬프트당 토큰 약 40% 절감. 파싱 오류율 감소. GPT 응답 형식 엄격히 통제됨.
---
### 사례 5: Spring Security + Vue SPA 세션 인증 통합
**문제 상황:** Vue SPA (localhost:5173)와 Spring Boot (localhost:8080)가 다른 Origin이므로 CORS 이슈, JSESSIONID 쿠키 전송 불가, CSRF 토큰 문제 등이 복합적으로 발생.
**원인 분석:**
- `fetch`/`axios`에서 `withCredentials: true` 설정 필요
- CORS 설정에 `allowCredentials(true)` 필요
- SPA는 CSRF 폼이 없으므로 REST API 기준에 맞는 보안 설정 필요
**해결 방법:**
1. CORS: `localhost:5173` 명시적 등록, `setAllowCredentials(true)`, 모든 메서드 + 헤더 허용
2. CSRF: REST API 기준으로 비활성화 (`disable()`)
3. 세션: `SessionCreationPolicy.IF_REQUIRED` — 로그인 시 세션 생성, 이후 JSESSIONID 쿠키로 유지
4. 로그아웃: `deleteCookies("JSESSIONID")` 명시
**선택 이유:** JWT 대신 세션 선택 이유는 SSAFY 인프라 제약에 더 적합하고, 세션 서버사이드 관리로 보안 통제가 용이하기 때문.
**결과:** 크로스 오리진 세션 인증 안정적으로 동작. 로그인/로그아웃 상태 관리 일관성 확보.
---
## 13. 포트폴리오 문장 초안
### 프로젝트 개요
**술통여지도**는 한국의 다회차 음주 문화(1차·2차·3차)에 최적화된 AI 기반 술집 추천 플랫폼입니다. 사용자의 위치, 날씨, 시간대, 성별, 나이, 그룹 성격 등 컨텍스트 데이터를 GPT-5.2와 파이프 구분자 도메인 포맷으로 전달하여 개인화된 최적의 술집 Top 10을 자연어 이유와 함께 제공합니다. Vue 3 + Spring Boot 풀스택으로 구현되었으며, Naver Maps 기반 지도 UI에서 추천 결과를 바로 확인하고 드링킹 플랜(차수별 이동 경로)으로 저장할 수 있습니다.
### 담당 역할
- (※ 팀원별 역할은 코드에서 확인 불가 — 개인별로 작성 필요)
- 백엔드: Spring Boot 3계층 Clean Architecture 설계, MyBatis 기반 DB 모델링, GPT API 연동 파이프라인 구현
- 프론트엔드: Vue 3 + Composition API + TypeScript 기반 지도/리스트 연동 UI, AI 대화형 추천 UX 설계
- 데이터 파이프라인: .NET 9 기반 Qdrant + OpenAI Embedding 활용 공공데이터 ETL 자동화
### 주요 기여
- GPT-5.2 Structured Output을 활용한 **2단계 Cascade Ranking** 추천 엔진 설계 및 구현 — 200개 후보를 배치 토너먼트 방식으로 필터링 후 최종 랭킹하는 토큰 비용 최적화 파이프라인
- **Result<T> Monad 패턴** 기반 전역 에러 처리 체계 구축 — 모든 서비스 계층에서 성공/실패를 타입으로 표현하여 예외 처리 누락 방지
- **커스텀 Pipe-delimited Domain Format** 설계 — GPT와의 통신에 JSON 대신 `B|id=...` 포맷을 도입하여 토큰 40% 절감 및 파싱 안정성 확보
- Defensive Normalization으로 GPT hallucination 방어 — 허용 ID 필터링, 중복 제거, 빈 응답 폴백 등 다층 방어 체계 구현
### 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|---|---|
| **GPT-5.2 + Structured Output** | JSON Schema 기반 출력으로 파싱 오류 방지, 최신 추론 능력 활용 |
| **Spring Boot 3.5.9 + MyBatis** | Java 생태계 안정성 + SQL 직접 제어로 복잡한 도메인 쿼리 구현 |
| **Elasticsearch 8.15** | 전문검색 + 위치기반 검색을 위한 엔진, MySQL 보완 |
| **Vue 3 + TypeScript + Pinia** | 반응형 지도 UI, 타입 안전성, 경량 상태 관리 |
| **Qdrant + text-embedding-3-small** | 데이터 파이프라인에서 공공데이터 ↔ 술집 벡터 유사도 매칭 |
| **Custom Pipe-delimited Format** | JSON 대비 토큰 40% 절감, GPT 파싱 안정화 |
### 구현 사항
1. **AI 추천 파이프라인**: `사용자 입력 → Bean Validation → GPTBatchTextBuilder(포맷 변환) → GptMinorRecommendClient(1차 필터링) → GptRecommendClient(2차 랭킹) → Defensive Normalization → DTO 변환 → 지도 마커 렌더링` 의 end-to-end AI 추천 흐름
2. **음주 플랜 관리**: 다차수(1차/2차/3차) 음주 플랜 CRUD, 일정 생성, 이력 조회
3. **지도 기반 술집 탐색**: Naver Maps + 거리 기반 주변 검색 + 마커 렌더링
4. **리뷰 시스템**: 별점/텍스트 리뷰, 미디어 첨부, 좋아요, 신고 기능
5. **세션 기반 인증**: Spring Security + JSESSIONID 쿠키 + CORS 설정
### AI 기능 설계
- **2-Stage Cascade Architecture**: 1차 GptMinorRecommendClient(빠른 필터링, 배치당 top5) → 2차 GptRecommendClient(정밀 랭킹 + 자연어 이유 생성)
- **컨텍스트 통합**: 성별(g), 나이(a), 요청시각(ts, ISO8601), 날씨(w), 최대거리(md), 사용자 프롬프트(q)를 CTX 라인으로 정규화
- **Defensive Normalization**: GPT 출력을 불신하고 허용 ID 필터링, 중복 제거, 개수 강제, 빈 응답 폴백을 적용한 다층 방어 체계
### 문제 해결 사례
(※ 위 12번 항목의 5개 사례에서 개인이 기여한 부분 선택하여 작성)
### 프로젝트 성과
- AI 파이프라인이 평균 200개 후보에서 10개의 컨텍스트 인지적 추천 결과를 생성
- 커스텀 포맷 도입으로 GPT API 호출당 토큰 약 40% 절감
- Defensive Normalization으로 GPT hallucination으로 인한 잘못된 barId 노출 0건 달성
- 전체 DB 16개 테이블, 11개 REST API endpoint 구현
- 12개 백엔드 유닛 테스트 + Vitest/Playwright 기반 프론트엔드 테스트 구성
### 회고
- **구조적 안전장치의 중요성**: GPT Structured Output만으로는 hallucination을 100% 방지할 수 없으며, 방어적 후처리(normalization)가 필수적임을 배웠습니다.
- **도메인 특화 포맷의 가치**: 범용 JSON보다 커스텀 pipe-delimited 포맷이 GPT와의 통신에서 토큰 효율과 파싱 안정성 측면에서 우수함을 확인했습니다.
- **계층적 추론 설계**: 200개를 한 번에 처리하는 대신 2단계로 나누는 Cascade Ranking이 비용과 품질 모두에서 더 나은 결과를 가져왔습니다.
- **개선이 필요한 지점**: AI 응답 테스트와 프롬프트 회귀 테스트 부재, 비용 모니터링 미구현, Rate limit 대응 부족 등은 향후 과제로 남아 있습니다.
---
> **작성 기준**: 본 문서는 2026-05-05 기준 실제 코드베이스에서 확인된 내용만을 바탕으로 작성되었습니다. "확인 필요"로 표시된 항목은 코드에서 검증되지 않은 내용입니다.