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,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 기준 실제 코드베이스에서 확인된 내용만을 바탕으로 작성되었습니다. "확인 필요"로 표시된 항목은 코드에서 검증되지 않은 내용입니다.