Table of Contents
프로젝트 소개
유레카 종합프로젝트(요플레)를 최신 기술 스택 기준으로 재설계·이전하는 리팩토링 프로젝트입니다.
서비스 본질은 사용자 통신 성향에 맞춘 요금제 추천 AI 챗봇이며, 백엔드(yoplanit_back_main)와 프론트(yoplanit_front_main)를 분리해 단계적으로 이전합니다.
| 구분 | Before (요플레) | After (Yoplanit) |
|---|---|---|
| Backend | Spring Boot 3.5 / Java 17 | Spring Boot 4.1 / Java 21 |
| 인증 | jjwt 커스텀 필터 + X-AUTH-TOKEN |
OAuth2 Resource Server(JWT) + Bearer |
| AI | Gemini + WebClient 직접 호출 · 전체 카탈로그 프롬프트 | Spring AI 2.0 + OpenAI Tool Calling |
| Chat Memory | 직접 구현 / Redis 수동 | Spring AI Redis ChatMemory |
| 대화 상태 | 키워드·상태머신 | Redis Dialogue State (intent + 검색 슬롯) |
| 의도 분류 | 키워드·상태머신 중심 | LLM Intent Classifier + Handler 라우팅 |
| Vector / RAG | 임베딩 서비스 분리 | 다음 단계 |
| 요금제 검색 (챗) | 전체 카탈로그 프롬프트 / 텍스트 | Elasticsearch (nori + synonym) + Tool Calling |
현재 Phase 진행 상황
- ✅ 환경 구성 — 인프라·의존성·JWT Resource Server 스캐폴드
- ✅ 인증 리팩토링 — 회원가입 / 로그인 / 로그아웃 / Refresh / 내 정보
- ✅ 프론트 인증 UI — Yoplanit 전용 프론트 신규 구성
- ✅ 요금제(Plan) 도메인 — 조회 — 엔티티·목록·상세·비교 API + 프론트 UI
- ✅ AI 챗봇 MVP — Intent → Handler → Tool Calling + Redis 멀티턴 + 카드 UI
- ✅ ES 요금제 검색 — JPA LIKE/코드 동의어 → Elasticsearch (nori + synonym)
- ✅ Redis Dialogue State — 검색 슬롯·intent 업데이트 (프롬프트/JVM sticky 탈피)
- ⏳ 리뷰 · 가입(Line) · RAG — 다음 단계
리팩토링 목표
- 표준화된 인증: 커스텀 헤더/필터 대신 Spring Security OAuth2 Resource Server로 업계 표준 Bearer JWT 적용
- 보안·응답 모델 정리:
permitAll우회, 예외 기반 API, Refresh Token Rotation(RTR) - Spring AI 중심 AI 파이프라인: WebClient 직접 호출 →
ChatClient/ Advisor / Tool Calling - 점진적 이전: 백엔드에 구현된 기능만 프론트에 노출
- 검색은 ES로 이전: 챗봇 Tool 검색을 JPA LIKE·코드 동의어에서 Elasticsearch로 전환 (RAG는 별도)
기술 스택 (리팩토링 기준)
Backend (yoplanit_back_main)
- Java 21, Spring Boot 4.1
- Spring Security + OAuth2 Resource Server (Nimbus
JwtEncoder/JwtDecoder) - Spring Data JPA (MySQL), Redis, MongoDB, Elasticsearch (요금제 검색)
- Spring AI 2.0 (OpenAI
ChatClient,@Tool, RedisChatMemory+ Dialogue State) - springdoc OpenAPI
- Docker Compose (MySQL / Redis Stack / MongoDB / Elasticsearch+nori)
Frontend (yoplanit_front_main)
- React 19, React Router 7, Tailwind CSS
react-markdown+remark-gfm(챗봇 답변 렌더링)REACT_APP_API_BASE_URL→http://localhost:8080
로그인 · 회원가입 리팩토링
기존 요플레 인증을 기능은 유지·개선하고, 구현 방식은 교체했습니다.
| 항목 | Before | After |
|---|---|---|
| 토큰 발급 | jjwt TokenProvider |
Spring Security JwtEncoder |
| 요청 인증 | 커스텀 JwtTokenFilter |
OAuth2 Resource Server |
| Access 헤더 | X-AUTH-TOKEN |
Authorization: Bearer |
| Refresh | JWT를 Redis @RedisHash 키로 저장 |
Opaque UUID + Redis TTL |
| Subject | email (재발급 시 userId와 혼동) | userId + claim(email, roles) |
| 보안 설정 | permitAll("/**") (사실상 비활성) |
공개 API만 화이트리스트 |
| 가입 응답 | 성공 메시지만 → 재로그인 필요 | 가입 즉시 Access/Refresh 발급 |
| 실패 처리 | 서비스에서 ApiResponse(FAIL) 직접 반환 |
예외 + GlobalExceptionHandler |
| DTO | UserDto 입출력 혼용, password 노출 위험 |
Request / Response 분리, password 미포함 |
API 설계
| Method | Path | 설명 |
|---|---|---|
POST |
/api/auth/signup |
회원가입 (201 + 토큰) |
POST |
/api/auth/login |
로그인 |
POST |
/api/auth/refresh |
Access/Refresh 재발급 (RTR) |
POST |
/api/auth/logout |
Refresh 폐기 |
GET |
/api/auth/me |
내 정보 (Bearer 필요) |
핵심 구현 포인트
1) Access Token (JWT) + Refresh Token (Opaque)
- Access: Nimbus JWT (HS256), claim에
email,name,roles - Refresh: UUID를 Redis
auth:refresh:{token}에 저장, TTL은 설정값과 동기화 rotate()로 구토큰 폐기 후 신토큰 발급 → Refresh Token Rotation
Login/Signup
→ Access JWT 발급 (subject = userId)
→ Refresh UUID 발급 → Redis 저장
Refresh
→ Redis에서 userId 조회
→ 구 Refresh 삭제
→ 새 Access + 새 Refresh 발급
2) 예외 기반 API 응답
BusinessException계층 +GlobalExceptionHandler- Bean Validation(
@Valid) 실패도 일관된INVALIDATED응답
3) SecurityFilterChain
- 공개: auth 공개 API / swagger / 요금제 GET /
POST /api/chat - 그 외: authenticated
요금제 리팩토링
인증 다음으로 요금제 조회(목록 · 상세 · 비교) 를 이전했습니다.
리뷰·가입(Line)은 보류하고, 읽기 API + UI부터 고정한 뒤 챗봇이 이 API를 재사용하도록 했습니다.
이전 전략
Step 1 도메인 모델 (Plan / Tag / PlanTag)
Step 2 조회 API (list / filter / detail / compare) ← 완료
Step 3 AI 챗봇 MVP (Tool Calling + ChatMemory) ← 완료
Step 4 리뷰 CRUD (보류)
Step 5 Line · 인기순 · 내 요금제 (보류)
Step 6 Elasticsearch 검색 ← 완료
Step 7 Redis Dialogue State ← 완료
Step 8 RAG (다음 단계)
도메인 모델 정리
| 항목 | Before | After |
|---|---|---|
| Plan 테이블 | plan |
plan (동일) |
| Tag 테이블 | tag |
tag (동일) |
| 연결 테이블 | plan_tag (plan_tag_id, plan_id, tag_id) |
동일 구조 |
| M:N 매핑 | @JoinTable + PlanTag 엔티티 이중 매핑 |
PlanTag 엔티티만 (@OneToMany) |
| Tag 로딩 | EAGER | LAZY + @EntityGraph |
| 응답 | 엔티티 Tag 직접 노출 |
PlanResponse / TagResponse |
dataAmount |
수치 뒤에 "GB"를 붙이는 오표기 |
MB 저장, 표시용 dataAmountText (1024 배수 → GB, -1 → 무제한) |
Plan 1 ──< PlanTag >── 1 Tag
plan_tag_id (PK)
plan_id / tag_id
조회 API
| Method | Path | 설명 |
|---|---|---|
GET |
/api/plans?sortBy= |
전체 목록 |
GET |
/api/plans/filter?sortBy&page&size |
페이징 목록 |
GET |
/api/plans/{planId} |
상세 |
GET |
/api/plans/compare?planIds= |
비교 (요청 순서 유지) |
정렬
| sortBy | 동작 |
|---|---|
priceAsc / priceDesc |
DB Sort |
dataAsc |
DB Sort |
dataDesc |
무제한(-1)을 최상단으로 두는 정렬 |
popular |
Line 도메인 이전이므로 데이터 많은 순으로 임시 대체 |
개선 포인트
- 인증과 동일한
ApiResponse+ResourceNotFoundException GET /api/plans/**→ SecuritypermitAll/compare를/{planId}보다 명시적으로 분리해 path 충돌 방지- 페이징 응답을
PlanPageResponse(content / totalPages …)로 고정
챗봇 리팩토링
기존 챗봇은 전체 요금제 카탈로그를 프롬프트에 넣고 Gemini로 추천하는 방식이었고, 키워드·상태머신 비중이 컸습니다.
Yoplanit에서는 DB를 Tool로만 조회하고, 의도별로 Handler를 나누는 Spring AI 파이프라인으로 재설계했습니다.
Before → After
| 항목 | Before | After |
|---|---|---|
| LLM 호출 | Gemini WebClient | Spring AI ChatClient |
| 요금제 조회 | 전체 카탈로그를 프롬프트에 삽입 | @Tool → Elasticsearch |
| 의도 | 키워드 / 상태머신 | LlmIntentClassifier → Handler |
| 멀티턴 | 수동 / 커스텀 | Redis ChatMemory + Dialogue State |
| 카드 UI | (구현에 따라 상이) | recommendedPlanIds → GET /api/plans/{id} |
| ES / RAG | — | ES 검색 도입 / RAG는 다음 단계 |
파이프라인
POST /api/chat
→ ChatService
→ LlmIntentClassifier (intentChatClient, ChatMemory 없음)
→ DialogueState.activeIntent (UNKNOWN이면 이전 intent)
→ IntentHandlerRegistry
PlanRecommendHandler / PlanLookupHandler / FallbackHandler
→ ChatClient + .tools(PlanTools) + Redis ChatMemory
→ PlanTools가 DialogueState 슬롯과 머지 후 ES 검색
← ChatResponse { conversationId, reply, intent, recommendedPlanIds }
의도 (ChatIntent)
| Intent | Handler | 역할 |
|---|---|---|
PLAN_RECOMMEND |
PlanRecommendHandler |
조건 기반 추천 (searchPlans) |
PLAN_LOOKUP |
PlanLookupHandler |
특정 요금제 설명 (getPlanById / 검색) |
COMPARE / SMALL_TALK |
(확장용 enum) | MVP에서는 폴백·확장 여지 |
UNKNOWN |
FallbackHandler |
안내 메시지 |
Spring AI 설정 (AiConfig)
- 기본
ChatClient:MessageChatMemoryAdvisor+ 로거 — 추천/조회 대화용 intentChatClient: Memory Advisor 없음 — 분류 프롬프트가 대화 로그를 오염시키지 않도록 분리- Memory: Redis
RedisChatMemoryRepository+MessageWindowChatMemory - Tool은 전역 등록하지 않고 Handler의
prompt().tools(PlanTools)에서만 부착
Tool (PlanTools) — 검색 엔진 전환
| 항목 | Before | After |
|---|---|---|
| 조회 | 전체 카탈로그 프롬프트 또는 JPA LIKE |
Elasticsearch only |
| 동의어 | (없음) | ES synonym (plan-settings.json) |
| 형태소 | — | nori (동의어 자동 추론 아님) |
| 태그 | 메타데이터 성격 (챗 필터 핵심 아님) | 인덱스에 tagNames 미포함 |
| hydrate | — | ES id → MySQL 상세·태그 → PlanToolView |
| 카드 id | 답변에 [uuid] 심기 |
Tool 결과 Collector + 답변 UUID 제거 |
nori = 한국어 형태소 분리. 어르신↔시니어 같은 동의어는 사전 등록이 필요하고, 위치만 Java → ES로 옮긴 것.
멀티턴 · Dialogue State
conversationId
├─ Redis ChatMemory (chat-memory:{id}) ← 발화 히스토리
└─ Redis DialogueState (dialogue:{id}) ← activeIntent + slots
slots: keyword, maxPrice, minPrice, minDataMb, sortBy
TTL ≈ 24h
| 규칙 | 동작 |
|---|---|
| 후속 보완 (가격만 등) | 기존 keyword 유지 + non-null 머지 |
| keyword 변경 | 슬롯 리셋 후 새 인자 적용 (주제 전환) |
| Intent UNKNOWN | activeIntent로 핸들러 유지 |
ConversationIntentStore(JVM Map)는 제거하고 DialogueState.activeIntent로 통합.
Redis Dialogue State
urjung FSM으로 돌아가지 않고, MVP의 LLM + Tool + ES 위에 명시적 슬롯만 얹은 단계.
달라진 점
| 항목 | MVP (적용 전) | Dialogue State (적용 후) |
|---|---|---|
| 검색 조건 정본 | 프롬프트 + ChatMemory에 의존 | Redis slots (서버 원본) |
| Intent sticky | ConversationIntentStore JVM Map |
activeIntent Redis |
| 후속 “10만원 이하?” | LLM이 keyword를 다시 넣길 기대 | Tool이 가격만 와도 서버가 keyword 머지 |
| 주제 전환 | 프롬프트 지시만 | keyword 변경 시 슬롯 리셋 |
| 모니터링 | 추론 결과만 로그 | 슬롯 before → after 로그 |
| 재시작·스케일아웃 | sticky intent 유실 | Redis TTL로 세션 유지 |
저장 내용 (발화 아님): activeIntent, keyword, maxPrice/minPrice, minDataMb, sortBy, updatedAt.
기대효과
- 멀티턴 안정성 — “OTT → 10만원 이하”가 프롬프트 운이 아니라 슬롯 기반으로 동작
- urjung 대비 유연함 — 대형 FSM 없이 Tool Calling을 유지하면서 조건만 구조화
- ChatMemory-only 대비 명확함 — 말과 조건을 분리해 오염·유실을 줄임
- 운영·디버깅 — Redis/로그로 현재 필터를 바로 확인, 회귀 시나리오(머지/리셋) 검증 가능
- 확장 기반 — 이후 관측(P1)·RAG(P3)와 상태를 공유하기 쉬운 형태
후속 조치 (남은 우선순위)
P0 Dialogue State는 적용 완료(위 「달라진 점 · 기대효과」 참고). 아래는 이후 과제.
P1 — 관측·평가
- Tool 인자·슬롯 before/after·ES hit 구조화 로그 (또는 OpenTelemetry)
- 고정 시나리오(동의어 / 후속 머지 / 주제 리셋) 회귀·스모크
- (여유 시) 멀티턴 골 달성률 평가
P2 — 도메인 이전
- 요금제 리뷰 CRUD (+ 금칙어)
- Line / 내 요금제 → 진짜
popular - 요금제 AI 요약·비교
- 마이페이지(내 요금제) UI
P3 — RAG (ES 키워드 검색과 분리)
- ES = 키워드·동의어·필터 (완료)
- VectorStore/RAG = FAQ·약관·추천 근거 의미 검색
하지 않을 것
- urjung식 대형 상태머신 재도입
- 동의어 Java/YAML/ES 삼중 관리
- RAG 도입 시 ES synonym 검색 폐기
현재 주요 기능 (구현 완료 기준)
- 회원가입 / 로그인 — JWT Access + Redis Opaque Refresh
- 토큰 갱신 / 로그아웃 — Refresh Token Rotation
- 내 정보 조회 —
GET /api/auth/me(프론트 JWT·Refresh 안정화 포함) - 요금제 조회 — 목록 · 페이징 · 상세 · 비교
- AI 챗봇 MVP — Intent · Tool Calling · Redis ChatMemory · 카드(UUID 비노출)
- ES 요금제 검색 — nori + synonym
- Redis Dialogue State — intent + 검색 슬롯 머지/리셋
- 프론트 — 인증 + 요금제 UI +
/chatbot - API 문서 — springdoc Swagger + Bearer Authorize
트러블슈팅 기록
1) JWT 발급 실패: secret length must be at least 256 bits
- 원인: HS256은 시크릿 최소 32바이트.
.env미로딩 시 짧은 값으로 서명 - 해결:
spring.config.import: optional:file:.env[.properties]+ 길이 검증
2) Plan–Tag Join 테이블 불일치
- 원인:
@ManyToMany@JoinTable은(plan_id, tag_id)만 만들어, 기존plan_tag_idPK 스키마·이관 SQL과 불일치 - 해결: 기존과 동일하게
PlanTag엔티티(plan_tag_id) +@OneToMany로 통일
3) ChatMemory ↔ Tool 인자 (양방향 이슈)
- 오염: 이전 OTT/가격이 새 질문에 잘못 재사용 → intent 전용 Client 분리
- 반대: “이번 질문만” 과도 강제 → 후속 keyword 유실
- 해결: Redis Dialogue State — 후속 머지, keyword 변경 리셋
4) plan_tag / 코드 동의어 → ES
- 태그 미연결·
LIKE한계 → ES name/description + synonym - YAML/
expandKeywords제거, 동의어는plan-settings.json만
5) 로그인·내 정보 (아래 표 참고)
- base64url JWT 파싱, Refresh 동시성, 로그인 후 랜딩(
/)
6) Boot 4 Jackson — ObjectMapper 빈 없음
- 원인: Boot 4 기본은 Jackson 3
JsonMapper - 해결:
DialogueStateStore에tools.jackson.databind.json.JsonMapper주입
| 증상 | 원인 | 해결 |
|---|---|---|
| 헤더는 로그인인데 내 정보 → 로그인창 | 헤더는 userInfo만 봄 / 내 정보는 Access 만료만 검사, Refresh 미시도 |
requireAuth에서 Refresh 후 통과 |
| 로그인 직후에도 내 정보 튕김 | Nimbus JWT는 base64url인데 atob만 사용 → 항상 만료로 오판 → Refresh RTR이 겹치며 토큰 폐기 |
base64url 디코딩 + Refresh 단일 flight |
| 로그인 후 내 정보로 이동 | redirectAfterLogin=/mypage 잔존 |
로그인 성공 시 항상 메인(/) |
urjung은 커스텀 헤더·필터 중심이라 프론트 JWT 파싱 이슈 유형이 달랐고, Yoplanit은 Bearer JWT + opaque Refresh 표준 흐름에서 클라이언트 파싱·RTR 동시성을 맞춘 사례.
성과 요약
- 레거시 커스텀 JWT → OAuth2 Resource Server + RTR, 프론트 JWT/Refresh 트러블슈팅까지 정리
- Gemini 전체 카탈로그 → Spring AI Tool Calling + Intent Handler
- 요금제 검색: urjung 프롬프트 카탈로그 / JPA LIKE → Elasticsearch (nori + synonym)
- 멀티턴: ChatMemory(발화) + Dialogue State(슬롯·intent) 분리 — urjung FSM 없이 현업형 slot filling
- 카드 UI(UUID 비노출) ·
/chatbot연결
Start the conversation