# 술통여지도 (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` (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 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 모나드**: 성공(`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 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 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 기준 실제 코드베이스에서 확인된 내용만을 바탕으로 작성되었습니다. "확인 필요"로 표시된 항목은 코드에서 검증되지 않은 내용입니다.