콘텐츠로 이동

카테고리 / 게시판

세무 질문을 14개의 LawCategory(양도소득세, 상속/증여세, 법인세 등)로 분류하여 RAG 검색 범위를 좁히고, 답변 품질이 충분히 검증된 세션만 "AI 지식게시판"(공개 게시판)에 노출하는 두 기능을 묶은 페이지. 카테고리는 사용자가 명시적으로 선택하거나, 미선택 시 LLM이 추론하여 후보를 제시한다. 공개 게시판은 로그인 없이 누구나 적격 세션을 열람할 수 있는 SEO 진입점이다.

사용자 여정

카테고리 선택 (사용자 명시 vs. LLM 추론)

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (StreamingConversationController)
    participant SVC as StreamingConversationService
    participant ROUTER as IntentRouter
    participant INFER as TaxCategoryInferenceService

    U->>FE: 채팅 화면 진입
    FE->>API: GET /conversations/categories
    API-->>FE: [{ id: "TRANSFER_INCOME", label: "양도소득세" }, ...]
※ LawCategory.values()로 동적 생성 alt 사용자가 카테고리 선택 U->>FE: "양도소득세" 선택 + 질문 입력 FE->>API: POST /conversations/stream
{ category: "TRANSFER_INCOME", question } Note over SVC: category가 있으므로 추론 스킵
RAG 흐름 진행 else 사용자가 미선택 ("전체 카테고리") U->>FE: 카테고리 미선택 + 질문 입력 FE->>API: POST /conversations/stream
{ category: null, question } SVC->>ROUTER: analyzeNewSession(question, null) ROUTER->>INFER: analyzeQuestionWithLlm(question)
(or unified call) INFER-->>ROUTER: TaxCategoryAnalysis (categoryScores) alt 점수 >= 2인 카테고리 존재 SVC-->>FE: category_selection 이벤트
{ message, categories: [top 3 + "해당 없음"] } Note over SVC: 세션 미생성, 사용자 선택 대기 U->>FE: 카테고리 선택 (또는 "해당 없음" → skipCategorySelection=true 재시도) FE->>API: POST /conversations/stream 재호출 else 추론 점수 부족 Note over SVC: fall through → 일반 RAG (category=null) end end

공개 게시판 — Archive eligibility 갱신 흐름

sequenceDiagram
    autonumber
    participant SVC as StreamingConversationService
    participant TURN as ConversationTurn
    participant SESSION as ConversationSession
    participant LISTENER as KeywordExtractionListener
    participant LLM as OpenAI

    SVC->>TURN: turn 저장 (assistantAnswer + refs)
    SVC->>SESSION: recomputeArchiveEligibleWith(turn)
    Note over SESSION: isArchiveEligible =
newTurn.isValidForArchive()
&& 기존 turns 모두 valid SVC->>LISTENER: KeywordExtractionEvent (AFTER_COMMIT publish) Note over LISTENER: @Async — 응답 latency 영향 0 LISTENER->>SESSION: 재조회 (별도 트랜잭션) alt isArchiveEligible=true && keywords=null LISTENER->>LLM: keyword-extraction prompt
(valid turns의 Q&A → 5개 키워드) LLM-->>LISTENER: ["양도소득세", "1세대1주택", ...] LISTENER->>SESSION: updateKeywords(list) → "키워드1,키워드2,..." else 부적격 또는 이미 추출됨 Note over LISTENER: skip (중복 LLM 호출 회피) end

공개 게시판 조회 (비로그인)

sequenceDiagram
    autonumber
    participant U as 익명 사용자
    participant FE as 프론트엔드
    participant API as Backend (StreamingConversationController)
    participant SVC as StreamingConversationService
    participant DB as MySQL

    U->>FE: AI 지식게시판 접속
    FE->>API: GET /conversations/public
?category=TRANSFER_INCOME&page=0&size=10&sort=LATEST API->>SVC: getPublicSessions(...) SVC->>DB: findByCategory...AndIsActiveTrueAndIsArchiveEligibleTrue DB-->>SVC: Page SVC-->>FE: { items: [{ sessionId, title, category, turnCount }], totalPages } U->>FE: 세션 클릭 FE->>API: GET /conversations/public/{sessionId} API->>SVC: getPublicSessionDetail(publicId) SVC->>DB: findByPublicIdAndIsActiveTrueWithTurns alt isArchiveEligible != true SVC-->>FE: 404 EntityNotExistException else 적격 SVC->>SVC: 모든 turn의 referenceDataList → ES batch fetch SVC-->>FE: ConversationSessionDetailResponse (editable=false) end

백엔드 구현

계층 클래스 / 파일 역할
Controller StreamingConversationController (apps/backend/.../controller/StreamingConversationController.kt) GET /conversations/categories, GET /conversations/public, GET /conversations/public/{sessionId}
Service StreamingConversationService (apps/backend/.../application/service/conversation/) getPublicSessions(), getPublicSessionDetail() — 적격 필터 + ES batch fetch
Service IntentRouter 새 세션 시작 시 카테고리 추론 호출 (Phase 1: 단일 LLM, Phase 0: TaxCategoryInferenceService 위임)
Service TaxCategoryInferenceService (application/service/tax/) LLM 기반 추론 (analyzeQuestionWithLlm) + 키워드 매칭 fallback (analyzeQuestion) + 검색 범위 확장 (inferAndExpand)
Service KeywordExtractionService + KeywordExtractionListener (application/service/conversation/) 적격 세션 키워드 5개 추출 (@Async @TransactionalEventListener AFTER_COMMIT)
Domain (Enum) LawCategory (apps/backend/.../application/domain/gpt/LawCategory.kt) 13개 카테고리 (예: TRANSFER_INCOME="양도소득세", INHERITANCE="상속세 및 증여세") + 카테고리별 childLawTopics / childOtherTopics (RAG 검색 토픽)
Domain (Enum) TaxCategory (apps/backend/.../application/domain/tax/TaxCategoryMapping.kt) 추론 전용 카테고리 (LawCategory와 1:1 매핑은 아님 — INHERITANCE/GIFTLawCategory.INHERITANCE 하나로 매핑)
Domain (Data) TaxCategoryMapping / TaxCategoryMappingConfig 카테고리별 키워드, baseLaws, relatedLaws, taxTypes 정적 매핑 (Issue #88 "주변법")
Domain (Entity) ConversationSession isArchiveEligible: Boolean? + keywords: String? 컬럼 보유, recomputeArchiveEligibleWith(turn) / updateKeywords(list) 메서드
Domain (Entity) ConversationTurn isValidForArchive() 규칙: refs 비어있음 / 답변이 거절 패턴이면 invalid
Repository ConversationSessionRepository findByIsActiveTrueAndIsArchiveEligibleTrue(pageable), findByCategoryAndIsActiveTrueAndIsArchiveEligibleTrue, findArchiveEligibleSessionsWithoutKeywords(pageable) (백필용)

도메인 규칙

규칙 위치 값 / 설명
카테고리 enum LawCategory 13개: TRANSFER_INCOME, INHERITANCE, STOCK, COMPREHENSIVE_REAL_ESTATE, LOCAL, INCOME, CORPORATE, VALUE_ADDED, SALARY_INSURANCE, ADJUSTMENT_INTERNATIONAL, TAX_NORMAL, ACCOUNTING, COMMERCIAL_LAW
카테고리 동적 노출 StreamingConversationController.getCategories() LawCategory.values().map { id, label } — enum 추가 시 자동 반영
카테고리 추론 점수 임계값 TaxCategoryInferenceService.MIN_SCORE_THRESHOLD 2 — 점수 2 미만은 추론 실패 처리
보조 카테고리 포함 비율 TaxCategoryInferenceService.SECONDARY_SCORE_RATIO 1위 점수의 70% 이상이면 함께 포함
카테고리 후보 표시 개수 StreamingConversationService.startNewSessionWithStreaming() top 3 + "해당 없음" (NONE)
TaxCategoryLawCategory 매핑 TaxCategoryMapping.kttoLawCategory() INHERITANCE/GIFT → LawCategory.INHERITANCE, CAPITAL_GAINS → TRANSFER_INCOME, GENERAL → TAX_NORMAL
검색 범위 확장 TaxCategoryMapping.allLaws 베이스법 + 주변법, 시행령/시행규칙 자동 확장 (X법X법, X법 시행령, X법 시행규칙)
ACCOUNTING 특수 케이스 LawCategory.ACCOUNTING childLawTopics / childOtherTopics 모두 빈 리스트 — 회계기준 전용 검색은 ReferenceType.ACCOUNTING을 추가 (StreamingRagProcessor)
Archive eligibility — 세션 단위 ConversationSession.recomputeArchiveEligibleWith(newTurn) newTurnValid && (기존 turns 모두 valid) — 한 턴이라도 invalid면 false (Issue #148 Option A 엄격)
Archive eligibility — 턴 단위 ConversationTurn.isValidForArchive() (1) refs 비어있음 invalid (2) 답변이 검색된 참고 자료/죄송로 시작 invalid (3) 세무와 관련된 포함 invalid
Public 조회 게이트 StreamingConversationService.getPublicSessionDetail() isArchiveEligible != true(false 또는 null)면 EntityNotExistException (404)
키워드 — 발행 트리거 StreamingConversationService.collectStreamAndFinalize() turn 저장 트랜잭션 내부에서 KeywordExtractionEvent publish
키워드 — 처리 시점 KeywordExtractionListener.handleKeywordExtraction() @TransactionalEventListener(AFTER_COMMIT) + @Async (응답 latency 0)
키워드 — 중복 호출 회피 KeywordExtractionListener isArchiveEligible != true || keywords != null이면 LLM 호출 skip (멀티턴 추가마다 키워드 재추출 안 함)
키워드 — 형식 ConversationSession.updateKeywords() comma-separated, 최대 5개, 빈 리스트면 NULL
키워드 — LLM 입력 KeywordExtractionService.extractKeywords() valid turn만 사용, 답변은 각 1000자로 truncate, JSON 배열 파싱
정렬 옵션 StreamingConversationService.getPublicSessions() LATEST (default, lastModifiedDateTime DESC) / WORD (title ASC)
페이징 기본값 controller @RequestParam page=0, size=10

API 엔드포인트

Method Path 설명
GET /conversations/categories 카테고리 목록 (id + 한글 label)
GET /conversations/public 공개 게시판 — 적격 세션 페이징 (category, page, size, sort)
GET /conversations/public/{sessionId} 공개 세션 상세 (비로그인, editable=false)
GET /conversations 사용자 본인 세션 목록 (category 필터 옵션)
POST /admin/conversations/backfill-keywords 적격이지만 keywords가 NULL인 세션을 LLM으로 백필 (?dryRun=true&limit=100)

스펙 상세(request/response schema)는 API 레퍼런스 / OpenAPI 페이지에서 endpoint별 검색.

데이터 모델

erDiagram
    CONVERSATION_SESSION {
        int session_id PK
        string public_id "UUID — 외부 노출"
        string account_id FK "소유자"
        string category "LawCategory enum, nullable"
        string title "첫 질문 100자"
        boolean is_active "soft delete"
        boolean is_archive_eligible "공개 게시판 노출 조건, nullable
(null = 미평가, false = 부적격)" string keywords "comma-separated max 5, nullable" datetime created_date_time datetime last_modified_date_time } CONVERSATION_TURN { int turn_id PK int session_id FK int turn_number string user_question string assistant_answer "isValidForArchive 평가 대상" } CONVERSATION_TURN_REFERENCE_DATA { int id PK int turn_id FK string data_id "ES 문서 ID" string data_type "DataType enum" } CONVERSATION_SESSION ||--o{ CONVERSATION_TURN : "1:N" CONVERSATION_TURN ||--o{ CONVERSATION_TURN_REFERENCE_DATA : "1:N (refs 비어있으면 invalid)"

LawCategory / TaxCategory / TaxCategoryMapping / ExpandedSearchScope는 모두 코드 상수 (DB 테이블 없음). category 컬럼은 LawCategory.name()을 STRING으로 저장한다.

설정

항목 위치 비고
Langfuse 프롬프트 — 카테고리 추론 tax-category-inference model gpt-5.4-nano, temperature 0. 미설정 시 코드 fallback prompt 사용
Langfuse 프롬프트 — 키워드 추출 keyword-extraction model gpt-5.4-nano, temperature 0. 미설정 시 코드 fallback (CLAUDE.md "Langfuse 프롬프트" 참조)
Langfuse 프롬프트 — 통합 라우터 intent-router unified 모드 활성 시 카테고리 추론을 함께 처리
Unified IntentRouter 토글 intent-router.unified.enabled 기본 false (Phase 0 — 카테고리 추론 분리 호출). true면 1회 통합
ES 인덱스 (게시판 detail용) application-*.yml spring.elasticsearch.* getPublicSessionDetail이 turn refs를 type별로 batch fetch
Async executor AsyncConfig.kt KeywordExtractionListener @Async — bounded ThreadPoolTaskExecutor 적용 (Issue #148 carryover)

알려진 이슈 / 개선 예정

  • Migration 미적용 환경에서 isArchiveEligible=null: Issue #148 도입 이전 세션은 컬럼 값이 NULL — findByIsActiveTrueAndIsArchiveEligibleTrue 쿼리가 false/null을 모두 제외하므로 게시판에 노출 안 됨. 백필 SQL infra/sql/...V20260430_1...을 prod에 수동 적용해야 기존 세션이 평가됨 (Flyway 미사용).
  • /admin/conversations/backfill-keywords 동기 처리 timeout: LLM 1건당 ~500ms × N건 동기 호출. limit=100이면 ~50초, ALB/Cloudflare 기본 timeout 60s 초과 위험. prod에서는 limit=100으로 chunk해서 반복 호출 권장 (Issue #148 미해결).
  • Archive eligibility 계산이 recomputeArchiveEligibleWith에 의존: recomputeArchiveEligible()(in-memory)도 존재하나 hook 시점에 JPA collection이 새 turn을 자동 반영하지 않아 recomputeArchiveEligibleWith(newTurn) (외부 인자)를 사용. 패턴 혼용 시 인덴트런타임 hook에서 stale 결과 위험 — 신규 hook 추가 시 With 변형을 써야 함.
  • TaxCategoryInferenceService.analyzeQuestionWithLlm()이 Langfuse temperature/maxTokens 무시: KeywordExtractionService는 fix됐으나 동일 패턴 fix 필요 (Issue #148 carryover).
  • COMMERCIAL_LAW 카테고리는 frontend 노출 여부 미확인: enum에 정의되어 있으나 LawCategory.values() 동적 리스트로 자동 노출됨. 클라이언트가 의도한 카테고리 셋과 일치하는지 별도 검증 필요.
  • ACCOUNTING 카테고리는 RAG 검색 범위가 빈 리스트: childLawTopics / childOtherTopics가 모두 비어있어 일반 검색에서는 hit이 없음. StreamingRagProcessorACCOUNTING 카테고리에 한해 ReferenceType.ACCOUNTING(회계기준원 인덱스)을 추가하는 우회 로직으로 동작 — 카테고리 추가 시 동일 케어 필요.
  • 공개 게시판 SEO: 현재 /conversations/public/{sessionId}editable=false만 표시 — sitemap, OG 태그, 정적 prerender 여부는 frontend 측 설계 영역 (별도 페이지 참조).
  • Keyword 재추출 트리거 부재: 한번 키워드가 채워진 세션은 추가 turn이 와도 재추출되지 않는다 (keywords != null 가드). 답변 품질이 크게 변한 세션에 대한 강제 갱신은 백필 endpoint를 통해서만 가능.

관련 문서