Files
irukseo/projects/술통여지도 (Sulmap) 분석.md
2026-05-05 21:27:37 +09:00

259 lines
14 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차)에서 다음 장소를 고를 때, 단순한 거리·평점 정렬로는 "비 오는 날 운치 있는 이자카야", "데이트에 어울리는 조용한 분위기" 같은 상황 맞춤 조건을 반영하기 어렵습니다. 저는 이 문제를 해결하기 위해 사용자의 위치·날씨·시간대·성별·나이·요청사항을 GPT-5.2에 전달하고, 반경 내 최대 200개 후보 중 개인화된 Top 10을 자연어 추천 이유와 함께 제공하는 **AI 기반 술집 추천 플랫폼**을 설계하고 구현했습니다.
---
## 2. 담당 역할
- Spring Boot 3계층 Clean Architecture 설계 및 구현
- GPT-5.2 Structured Output 기반 2단계 Cascade Ranking 추천 엔진 구현
- MyBatis 기반 DB 모델링 (16개 테이블) 및 REST API 설계 (12개 엔드포인트)
- Result\<T\> Monad 패턴 기반 전역 에러 처리 체계 구축
- Vue 3 + TypeScript 기반 지도 UI 및 AI 추천 UX 구현
- .NET 9 기반 공공데이터 ETL 파이프라인 (Qdrant + OpenAI Embedding) 구현
---
## 3. 주요 기여
### 3.1 200개 후보를 GPT에 한 번에 보낼 수 없는 문제 — 2단계 Cascade Ranking 설계
반경 내 술집이 최대 200개일 때, 전부를 한 번에 GPT에 전달하면 200개 × 약 80토큰 ≈ 16,000토큰 이상의 입력이 발생하고, 컨텍스트가 길어질수록 GPT의 attention 품질이 저하되는 문제가 있었습니다.
저는 이를 해결하기 위해 **2단계 Cascade Ranking** 구조를 설계했습니다.
- **1단계 (GptMinorRecommendClient)**: 200개를 100개씩 배치로 나눠 각 배치에서 top 5를 선별합니다. 출력은 `{ "selected": [id, id, ...] }` 형태의 ID 목록만 반환하여 토큰을 최소화합니다.
- **2단계 (GptRecommendClient)**: 1단계에서 선별된 최대 40개를 정밀 랭킹하여 Top 10과 자연어 추천 이유를 생성합니다.
```
200개 후보
└─► 배치 분할 (100개씩)
└─► GptMinorRecommendClient (각 배치 top5 선별)
└─► 최대 40개 후보
└─► GptRecommendClient (top10 + 추천 이유 생성)
└─► 지도 마커 + 사이드바 렌더링
```
이 구조로 200개를 1회 전송하는 방식 대비 GPT API 호출당 토큰을 약 70% 절감했습니다.
---
### 3.2 GPT가 존재하지 않는 술집을 추천하는 Hallucination 문제 — 다층 방어 체계 구현
GPT-5.2가 입력 후보(B 라인)에 없는 barId를 생성하거나, 사전 학습 데이터에서 기억한 술집을 추천하는 hallucination이 발생했습니다. Structured Output만으로는 허용된 선택지를 강제할 수 없었습니다.
저는 프롬프트와 코드 레벨에서 다층 방어 체계를 구성했습니다.
- **프롬프트 수준**: 시스템 프롬프트와 Stage Instructions 양쪽에 "후보에 없는 술집을 만들거나 추측하지 마라", "신규 barId 생성 금지"를 명시했습니다.
- **코드 수준 (Defensive Normalization)**: 정규식 `(?m)^B\|id=(\d+)\b`로 입력 B 라인에서 허용된 ID Set을 추출하고, GPT 응답의 barId를 대조하여 허용되지 않은 ID는 즉시 제거했습니다.
- **부족 시 폴백**: GPT가 topK보다 적게 반환하면 입력 순서(거리순)로 자동 채워 결과 개수를 보장했습니다.
```java
// 허용 ID Set 구성
Set<Long> allowedSet = extractBarIds(batchText);
// GPT 응답에서 허용되지 않은 ID 제거
List<Item> filtered = items.stream()
.filter(item -> allowedSet.contains(item.barId))
.collect(toList());
```
이 구조로 GPT hallucination으로 인한 잘못된 barId 노출을 0건으로 차단했습니다.
---
### 3.3 JSON 직렬화의 토큰 낭비 문제 — 도메인 특화 Pipe-delimited Format 설계
GPT에 후보 술집 정보를 JSON으로 전달하면 키 이름, 중괄호, 따옴표 등 구조 문자 자체가 대량의 토큰을 소비합니다. 또한 술집 이름이나 영업정보에 개행 문자·파이프 문자가 포함되어 프롬프트 파싱 오류가 발생할 수 있었습니다.
저는 `GptBatchTextBuilder`를 설계하여 도메인 특화 Pipe-delimited Format을 정의했습니다.
```
# JSON 방식
{"id": 123, "category": "주점", "openingInfo": "매일 18:00-02:00", "name": "포차", "menu": "닭발,오돌뼈"}
# 커스텀 포맷 방식
B|id=123|c=주점|oi=매일 18:00-02:00|n=포차|menu=닭발,오돌뼈
```
데이터 sanitize도 함께 구현하여, 모든 필드에서 파이프(`|`), 개행(`\n`, `\r`)을 공백으로 치환하고 필드별 길이를 truncate했습니다. (영업정보 60자, 이름 20자, 메뉴 60자, 사용자 프롬프트 180자 등)
이 포맷 도입으로 GPT 호출당 토큰을 JSON 대비 약 40% 절감했으며, 파이프 구분자 파싱 오류도 제거했습니다.
---
### 3.4 AI 장애 시 서비스 완전 중단 방지 — Graceful Degradation 구현
GPT API는 네트워크 이슈, 타임아웃, Rate Limit 등으로 언제든 실패할 수 있는 외부 의존성입니다. 저는 AI 호출이 실패했을 때 빈 화면을 보여주는 대신, 입력 순서(거리순)로 topK를 반환하는 폴백 전략을 구현했습니다.
```java
try {
return gptRecommendClient.rank(ctx, bLines, topK);
} catch (Exception e) {
log.error(e.getMessage(), e);
// Graceful Degradation: 거리순 fallback 반환
return buildFallbackResult(candidates, topK);
}
```
AI 장애 시에도 사용자에게 기본 거리 기반 추천 결과가 표시되어 서비스 사용성이 유지됩니다.
---
### 3.5 Result\<T\> Monad 패턴으로 에러 처리 체계 통일
서비스 계층에서 예외를 던지는 방식은 호출부에서 예외 처리를 누락할 위험이 있었습니다. 저는 자체 `Result<T>` 클래스를 구현하여 성공(`Result.ok(value)`)과 실패(`Result.fail(error)`)를 반환 타입으로 표현하는 체계를 구축했습니다.
```java
// Service 계층
public Result<Plan> findPlan(Long planId, Long userId) {
Plan plan = planRepository.findById(planId);
if (plan == null) return Result.fail(new NotFoundError("플랜을 찾을 수 없습니다."));
if (!plan.getOwnerId().equals(userId)) return Result.fail(new ForbiddenError("접근 권한이 없습니다."));
return Result.ok(plan);
}
// Controller 계층
Result<Plan> result = planService.findPlan(planId, userId);
if (result.isFailure()) return result.toErrorResponse();
return ResponseEntity.ok(PlanResponse.from(result.getValue()));
```
`NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하여, 모든 실패 응답이 `{ StatusCode, Message }` 형태로 일관되게 반환되도록 했습니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| --- | --- |
| **GPT-5.2 + Structured Output** | 복잡한 다중 조건(날씨·시간·분위기)을 자연어로 추론하는 데 룰 기반 필터링으로는 대응 불가능했습니다. Structured Output으로 JSON Schema를 강제하여 파싱 오류를 방지했습니다. |
| **Spring Boot 3.5.9** | DI·Security·Validation이 잘 통합된 환경에서 3계층 아키텍처를 빠르게 구조화하기 위해 선택했습니다. |
| **MyBatis 3.0.5** | JPA Lazy Loading으로 인한 N+1 문제를 피하고, 복잡한 조인 쿼리를 SQL로 직접 제어하기 위해 선택했습니다. |
| **Elasticsearch 8.15.2** | 술집 이름·카테고리 기반 전문 검색을 MySQL LIKE보다 빠르게 처리하고, 위치 기반 검색과 결합하기 위해 선택했습니다. |
| **Qdrant + text-embedding-3-small** | 데이터 파이프라인에서 공공데이터와 술집 정보를 벡터 유사도로 매칭하기 위해 선택했습니다. 런타임 추천과 역할을 분리하여 서빙 비용을 줄였습니다. |
| **Vue 3 + TypeScript + Pinia** | Composition API로 지도 마커 상태와 AI 결과 목록의 반응형 동기화를 타입 안전하게 구현하기 위해 선택했습니다. |
| **Spring Security + Session Cookie** | JWT 대신 세션 방식을 선택한 이유는 서버사이드에서 세션을 직접 폐기할 수 있어 로그아웃 보안이 명확하고, SSAFY 인프라 환경에 더 적합했기 때문입니다. |
---
## 5. 구현 사항
### 전체 아키텍처
```
사용자 요청
Spring Boot (3-Layer Clean Architecture)
├── api/ Controller, DTO, Security Config, CORS
├── core/ Service Interface/Impl, Repository Interface, Domain Model
├── infra/ Repository Impl, MyBatis Mapper, GPT Client, ES Client
└── share/ Result<T> Monad, Error Type 계층
├─► MySQL 8.0 (users, bars, reviews, plans, schedules 등 16개 테이블)
├─► Elasticsearch (술집 전문 검색 인덱스)
└─► OpenAI GPT-5.2 (2단계 Cascade Ranking 추천)
[데이터 파이프라인] .NET 9 ETL
└─► OpenAI Embedding (text-embedding-3-small)
└─► Qdrant (공공데이터 ↔ 술집 벡터 유사도 매칭)
```
### AI 추천 요청 처리 흐름
```
POST /ai/recommend-bars
├─ @Valid Bean Validation (위경도, 거리, 프롬프트 길이 검증)
├─ BarRepository.findNearby() → 반경 내 최대 200개 조회
├─ GptBatchTextBuilder.build()
│ └─ sanitize (파이프·개행 제거, 필드별 truncate)
│ └─ CTX 라인 + B 라인 직렬화 (Pipe-delimited Format)
├─ GptMinorRecommendClient (1단계: 배치당 top5 선별)
│ └─ Structured Output → { "selected": [id, ...] }
├─ GptRecommendClient (2단계: 최대 40개 → top10 + reasons)
│ └─ Structured Output → { "top": [{ barId, reasons }] }
├─ Defensive Normalization
│ ├─ 허용 ID Set 대조 → 미허용 barId 제거
│ ├─ 중복 제거 (LinkedHashMap 순서 유지)
│ ├─ reasons sanitize (파이프·개행 제거, 45자 truncate)
│ └─ 부족 시 거리순 fallback 채움
└─ RecommendedBarListResponse → 지도 마커 + 사이드바 렌더링
```
### 주요 API 목록
| Method | Path | 설명 | 인증 |
| --- | --- | --- | --- |
| POST | `/auth/login` | 세션 기반 로그인 | Public |
| POST | `/auth/signup` | 회원가입 | Public |
| POST | `/ai/recommend-bars` | AI 술집 추천 | 필요 |
| GET | `/bars/nearby` | 위치 기반 주변 술집 검색 | 필요 |
| GET | `/bars/{id}` | 술집 상세 정보 | 필요 |
| POST/PATCH/DELETE | `/reviews` | 리뷰 작성·수정·삭제 | 필요 |
| POST/PUT | `/memos` | 술집 개인 메모 upsert | 필요 |
| GET/POST | `/plans` | 음주 플랜 목록·생성 | 필요 |
| GET/PATCH/DELETE | `/plans/{id}` | 플랜 상세·수정·삭제 | 필요 |
| GET/POST | `/schedules` | 일정 생성·조회 | 필요 |
| GET | `/schedules/history` | 음주 이력 조회 | 필요 |
### 데이터베이스 주요 구조
```
users ─────────────────────────────────────────────────────
├─ bars ──────────────── bar_categories
│ │ bar_category_mapping
│ │
│ ├─ reviews ───────── review_media (이미지 첨부)
│ │ review_likes (좋아요)
│ │ review_reports (신고)
│ │
│ └─ memos (개인 메모, user+bar 단위 upsert)
└─ drinking_plan ──────── drinking_plan_stops (1차/2차/3차 장소)
│ plan_votes
└─ schedules (플랜을 날짜/시간에 스케줄링)
visits
```
### 프론트엔드 주요 화면
| Route | 설명 |
| --- | --- |
| `/home` | 네이버 지도 + AI 추천 다이얼로그 + 술집 사이드바 |
| `/plans` | 음주 플랜 목록 |
| `/plans/new` | 플랜 생성 (1차·2차·3차 장소 구성) |
| `/plans/:id` | 플랜 상세 및 일정 생성 |
| `/history` | 음주 이력 조회 |
---
## 6. 기술적 의사결정 및 회고
### AI 비동기 처리를 적용하지 않은 판단
GPT 호출이 동기 blocking 방식으로 처리되어 사용자가 응답을 기다려야 합니다. Cascade Ranking 구조상 1단계 결과가 나와야 2단계 입력이 결정되므로 완전한 비동기화가 어려웠고, 현재 사용자 수 규모에서는 동기 방식이 구현 복잡도를 낮추면서 충분히 동작했습니다. 트래픽이 증가하면 SSE나 WebSocket으로 스트리밍 응답을 제공하는 방식으로 전환해야 합니다.
### AI 응답 테스트 부재에 대한 인식
`GptRecommendClient``GptMinorRecommendClient`에 대한 유닛 테스트가 구현되지 않았습니다. 프롬프트를 변경했을 때 추천 품질이 유지되는지 회귀 테스트가 없어, 수동 확인에 의존하고 있습니다. 향후에는 Mock AI 응답을 활용한 유닛 테스트와 추천 품질을 측정하는 평가 프레임워크를 도입하는 것이 필요합니다.
### 토큰 비용 모니터링 부재
OpenAI API 호출당 토큰 사용량과 비용을 별도로 기록하는 코드가 없어, 실제 운영 비용을 수치로 파악하기 어렵습니다. 요청별 토큰 카운팅 로깅을 추가하고, 이상 사용량을 감지하는 알림 체계를 갖추는 것이 운영 환경에서 필요합니다.
### Rate Limit 및 Timeout 대응 미비
GPT API의 Rate Limit이나 응답 지연에 대한 재시도 로직이 구현되어 있지 않습니다. 현재는 실패 시 즉시 거리순 폴백으로 대응하는 수준입니다. Exponential Backoff 기반 재시도와 명시적 Timeout 설정을 적용하여 외부 API 의존성을 더 견고하게 처리해야 합니다.