Files
irukseo/포트폴리오/projects/술통여지도.md
2026-05-05 21:27:37 +09:00

724 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 술통여지도 (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 기준 실제 코드베이스에서 확인된 내용만을 바탕으로 작성되었습니다. "확인 필요"로 표시된 항목은 코드에서 검증되지 않은 내용입니다.