38 KiB
술통여지도 (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 | 일정 이력 조회 |
백엔드 요청 처리 방식
- Controller:
@Valid로 요청 DTO 검증 (Server\src\main\java\com\ssafy\sulmap\api\dto\request\GetRecommendedBarsRequest.java:6-10—@NotNull,@DecimalMin/Max,@Min/Max,@NotBlank,@Size사용) - Security: Spring Security가 JSESSIONID 쿠키로 세션 인증 (
SecurityConfig.java:81-82—SessionCreationPolicy.IF_REQUIRED)./auth/login과/auth/signup만 public, 나머지는 인증 필요 - Service: 비즈니스 로직 수행 후
Result<T>(Success/Failure) 반환 - 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 출력 형식:
{ "selected": [123, 456, 789] }
Stage 2 출력 형식:
{
"top": [
{ "barId": 123, "reasons": ["분위기가 요청과 일치", "현재 영업 중"] }
]
}
출력 형식 강제 방식
- Structured Output 사용:
.text(RecommendOutput.class)/.text(MinorRankerOutput.class)호출 - Response schema를 Java static inner class로 정의하고
@JsonPropertyDescription어노테이션으로 필드 설명 제공 - JSON only 제약을 시스템 프롬프트와 stage instructions에 모두 명시
모델 응답 검증 방식
- barId 검증:
extractBarIds()— 정규식(?m)^B\|id=(\d+)\b로 입력에서 허용된 ID set 구성 → 허용되지 않은 barId 제거 - 중복 제거:
LinkedHashMap/LinkedHashSet으로 순서 유지하며 중복 제거 - 개수 검증: topK로 자르고 부족하면 폴백
- 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.java3개 파일에 집중
프롬프트 인젝션 방어
- 사용자 입력 프롬프트
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+ BCryptPasswordEncoder로 검증 →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 proxyhttps://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 처리 중 로딩/상태 표시
aiLoadingref로 로딩 상태 관리 (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가 캐치하여 폴백
빈 응답 처리
// 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만으로는 허용된 선택지만 강제할 수 없음.
해결 방법:
- 시스템 프롬프트에 "후보에 없는 술집을 만들거나 추측하지 마라" 명시 + "신규 barId 생성 금지"
- 입력에서 추출한 허용 ID Set과 GPT 응답 ID를 대조하여 허용되지 않은 ID 제거
- 부족하면 입력 순서대로 fallback 채우기
선택 이유: GPT의 출력을 무조건 신뢰하지 않고 Defensive Normalization으로 보호막을 씌우는 접근. 프롬프트 튜닝만으로 100% 보장되지 않기 때문에 코드 레벨 검증을 병행.
결과: 잘못된 barId가 최종 결과에 포함되지 않음. 시스템 안정성 확보. 사용자에게 유효하지 않은 장소가 노출되는 사고 방지.
사례 2: 200개 후보 → GPT 직접 전송 시 토큰 폭발 문제
문제 상황: 반경 2km 내 200개 술집의 전체 정보를 GPT에게 한 번에 보내면 토큰 비용이 선형적으로 증가하고, GPT의 컨텍스트 윈도우에서 모든 후보를 동등하게 처리하지 못하는 문제.
원인 분석:
- GPT는 컨텍스트 길이에 따라 attention 품질이 저하됨
- 200개 후보 × 약 80토큰/개 ≈ 16,000 토큰 입력 + 800 토큰 출력 ≈ 막대한 비용
해결 방법: 2-Stage Cascade Ranking
- Stage 1: 200개를 100개씩 배치로 나누어 각 배치에서 top5 선별 (총 10개)
- 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% 가용성을 보장할 수 없음.
해결 방법:
- Repository에서 GPT 호출을
try-catch(Exception e)로 감싸기 - 실패 시
log.error(e.getMessage(), e)로깅 - Fallback: 입력 순서(거리순)로 topK 반환
- 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을 파싱할 때 마크다운 코드펜스를 추가하는 경향
해결 방법:
- 커스텀 Pipe-delimited format
B|id=?|c=?|oi=?|n=?|menu=?설계 — JSON 대비 약 40% 토큰 절감 - 모든 필드
sanitize(): 파이프 → 공백, 개행 → 공백, 필드별 길이 truncate - JSON only + 코드펜스 금지를 프롬프트 양쪽에 명시
- 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 기준에 맞는 보안 설정 필요
해결 방법:
- CORS:
localhost:5173명시적 등록,setAllowCredentials(true), 모든 메서드 + 헤더 허용 - CSRF: REST API 기준으로 비활성화 (
disable()) - 세션:
SessionCreationPolicy.IF_REQUIRED— 로그인 시 세션 생성, 이후 JSESSIONID 쿠키로 유지 - 로그아웃:
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 파싱 안정화 |
구현 사항
- AI 추천 파이프라인:
사용자 입력 → Bean Validation → GPTBatchTextBuilder(포맷 변환) → GptMinorRecommendClient(1차 필터링) → GptRecommendClient(2차 랭킹) → Defensive Normalization → DTO 변환 → 지도 마커 렌더링의 end-to-end AI 추천 흐름 - 음주 플랜 관리: 다차수(1차/2차/3차) 음주 플랜 CRUD, 일정 생성, 이력 조회
- 지도 기반 술집 탐색: Naver Maps + 거리 기반 주변 검색 + 마커 렌더링
- 리뷰 시스템: 별점/텍스트 리뷰, 미디어 첨부, 좋아요, 신고 기능
- 세션 기반 인증: 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 기준 실제 코드베이스에서 확인된 내용만을 바탕으로 작성되었습니다. "확인 필요"로 표시된 항목은 코드에서 검증되지 않은 내용입니다.