유레카 종합프로젝트 리팩토링

Table of Contents

  1. 프로젝트 소개
  2. 리팩토링 목표
  3. 기술 스택 (리팩토링 기준)
  4. 로그인 · 회원가입 리팩토링
  5. 요금제 리팩토링
  6. 챗봇 리팩토링
    1. Redis Dialogue State
  7. 후속 조치 (남은 우선순위)
    1. P1 — 관측·평가
    2. P2 — 도메인 이전
    3. P3 — RAG (ES 키워드 검색과 분리)
    4. 하지 않을 것
  8. 현재 주요 기능 (구현 완료 기준)
  9. 트러블슈팅 기록
  10. 성과 요약

프로젝트 소개

유레카 종합프로젝트(요플레)를 최신 기술 스택 기준으로 재설계·이전하는 리팩토링 프로젝트입니다.
서비스 본질은 사용자 통신 성향에 맞춘 요금제 추천 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 진행 상황

  1. 환경 구성 — 인프라·의존성·JWT Resource Server 스캐폴드
  2. 인증 리팩토링 — 회원가입 / 로그인 / 로그아웃 / Refresh / 내 정보
  3. 프론트 인증 UI — Yoplanit 전용 프론트 신규 구성
  4. 요금제(Plan) 도메인 — 조회 — 엔티티·목록·상세·비교 API + 프론트 UI
  5. AI 챗봇 MVP — Intent → Handler → Tool Calling + Redis 멀티턴 + 카드 UI
  6. ES 요금제 검색 — JPA LIKE/코드 동의어 → Elasticsearch (nori + synonym)
  7. Redis Dialogue State — 검색 슬롯·intent 업데이트 (프롬프트/JVM sticky 탈피)
  8. ⏳ 리뷰 · 가입(Line) · RAG — 다음 단계

리팩토링 목표

  1. 표준화된 인증: 커스텀 헤더/필터 대신 Spring Security OAuth2 Resource Server로 업계 표준 Bearer JWT 적용
  2. 보안·응답 모델 정리: permitAll 우회, 예외 기반 API, Refresh Token Rotation(RTR)
  3. Spring AI 중심 AI 파이프라인: WebClient 직접 호출 → ChatClient / Advisor / Tool Calling
  4. 점진적 이전: 백엔드에 구현된 기능만 프론트에 노출
  5. 검색은 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, Redis ChatMemory + 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_URLhttp://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/** → Security permitAll
  • /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 (구현에 따라 상이) recommendedPlanIdsGET /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.

기대효과

  1. 멀티턴 안정성 — “OTT → 10만원 이하”가 프롬프트 운이 아니라 슬롯 기반으로 동작
  2. urjung 대비 유연함 — 대형 FSM 없이 Tool Calling을 유지하면서 조건만 구조화
  3. ChatMemory-only 대비 명확함 — 말과 조건을 분리해 오염·유실을 줄임
  4. 운영·디버깅 — Redis/로그로 현재 필터를 바로 확인, 회귀 시나리오(머지/리셋) 검증 가능
  5. 확장 기반 — 이후 관측(P1)·RAG(P3)와 상태를 공유하기 쉬운 형태

후속 조치 (남은 우선순위)

P0 Dialogue State는 적용 완료(위 「달라진 점 · 기대효과」 참고). 아래는 이후 과제.

P1 — 관측·평가

  • Tool 인자·슬롯 before/after·ES hit 구조화 로그 (또는 OpenTelemetry)
  • 고정 시나리오(동의어 / 후속 머지 / 주제 리셋) 회귀·스모크
  • (여유 시) 멀티턴 골 달성률 평가

P2 — 도메인 이전

  1. 요금제 리뷰 CRUD (+ 금칙어)
  2. Line / 내 요금제 → 진짜 popular
  3. 요금제 AI 요약·비교
  4. 마이페이지(내 요금제) UI

P3 — RAG (ES 키워드 검색과 분리)

  • ES = 키워드·동의어·필터 (완료)
  • VectorStore/RAG = FAQ·약관·추천 근거 의미 검색

하지 않을 것

  • urjung식 대형 상태머신 재도입
  • 동의어 Java/YAML/ES 삼중 관리
  • RAG 도입 시 ES synonym 검색 폐기

현재 주요 기능 (구현 완료 기준)

  1. 회원가입 / 로그인 — JWT Access + Redis Opaque Refresh
  2. 토큰 갱신 / 로그아웃 — Refresh Token Rotation
  3. 내 정보 조회GET /api/auth/me (프론트 JWT·Refresh 안정화 포함)
  4. 요금제 조회 — 목록 · 페이징 · 상세 · 비교
  5. AI 챗봇 MVP — Intent · Tool Calling · Redis ChatMemory · 카드(UUID 비노출)
  6. ES 요금제 검색 — nori + synonym
  7. Redis Dialogue State — intent + 검색 슬롯 머지/리셋
  8. 프론트 — 인증 + 요금제 UI + /chatbot
  9. 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_id PK 스키마·이관 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
  • 해결: DialogueStateStoretools.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