From 159c06a5b1ef17902976a8d6a08bf0975250795c Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 1 Jun 2026 16:14:01 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=B8=EC=85=98=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20+=20=EB=B6=84=EC=84=9D=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20cascade=20soft=20delete=20+=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=ED=92=80=20=ED=86=A0=ED=81=B0=20=EC=A0=88=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 코드 분석에서 발견된 4가지 이슈 일괄 패치. ## F1. SessionCreateRequest Bean Validation 정리 - maxQuestions @Min(1) → @Min(2). applyPool 자동 종료 검사 없고 applyFollowup 에서만 종료 → 1 설정해도 최소 첫질문+꼬리질문 2개. 최소값 2로 명시. - maxDurationMinutes 시간 기반 자동 종료 미구현 → @Min/@Max/@NotNull 제거, nullable 허용 (DB 저장만 유지, Sprint 4 에서 정식 분기). - SessionMode 사용처 entity·DTO 외 0건 → @NotNull 풀고 null 시 default ONLINE. ## F2. Resume/GithubRepository 삭제 시 AnalyzedDocument cascade soft delete - Resume/Repository/AnalyzedDocument 에 markDeleted() 도메인 메서드 추가 (User.markDeleted() 동일 패턴). - ResumeService.delete / GithubRepositoryService.delete 가 entity.markDeleted() 호출 + ResumeDeletedEvent / RepositoryDeletedEvent 발화. - document.application.AnalyzedDocumentCascadeListener 가 두 이벤트 수신 → 관련 AnalyzedDocument soft delete. - 직접 의존 회피 (ArchUnit 도메인 cycle 차단 — resume/github 가 document import X). - 임베딩(document_embeddings) 은 컨텍스트 선택 시 deleted=false 만 노출되므로 별도 정리 불필요. ## F3. AI 질문 풀 토큰 낭비 차단 - Core QuestionsCallbackService.applyPool 이 questions[0] 만 INSERT 하고 나머지 폐기. envelope.max_questions=10 면 LLM 출력 9개 버려져 토큰 약 10배 낭비. - AI QuestionsConsumer 에 initial_pool_size (default 1) 추가, envelope 무시. - settings.questions_initial_pool_size = 1. 풀 저장 도입 시 늘리기 쉬움. ## 테스트 - Backend BUILD SUCCESSFUL (ArchUnit 도메인 cycle 통과) - AI 144 passed (test_questions_consumer max_questions assertion 갱신) --- ai/src/ai_server/config/settings.py | 3 ++ .../messaging/consumers/questions_consumer.py | 8 ++- ai/src/ai_server/messaging/runner.py | 1 + ai/tests/test_questions_consumer.py | 3 +- .../AnalyzedDocumentCascadeListener.java | 52 +++++++++++++++++++ .../document/domain/AnalyzedDocument.java | 4 ++ .../application/GithubRepositoryService.java | 5 +- .../event/RepositoryDeletedEvent.java | 5 ++ .../github/domain/GithubRepository.java | 4 ++ .../resume/application/ResumeService.java | 7 ++- .../application/event/ResumeDeletedEvent.java | 5 ++ .../stackup/stackup/resume/domain/Resume.java | 4 ++ .../dto/SessionCreateRequest.java | 11 ++-- 13 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 4b54bb99..b4d57bfb 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -33,6 +33,9 @@ class Settings(BaseSettings): ai_callback_routing_feedback: str = "callback.feedback" ai_callback_routing_voice: str = "callback.voice" feedback_rag_top_k: int = 5 + # 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단. + # 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5). + questions_initial_pool_size: int = 1 # STT (음성 답변). "auto" 면 deepgram > openai_whisper > mock 순으로 키 보유 여부에 따라 자동 선택. stt_provider: Literal["auto", "mock", "openai_whisper", "deepgram"] = "auto" diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 9b5fe451..5a415565 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -24,11 +24,15 @@ def __init__( publisher: CallbackPublisher, idempotency: LruIdempotencyStore, callback_routing_key: str, + initial_pool_size: int = 1, ) -> None: self._generator = generator self._publisher = publisher self._idempotency = idempotency self._callback_routing_key = callback_routing_key + # Core 의 QuestionsCallbackService.applyPool 은 questions[0] 만 INSERT 하고 나머지는 폐기. + # 토큰 낭비를 줄이기 위해 envelope.max_questions 대신 풀 크기를 강제. 후속 작업에서 풀 저장 도입 시 늘리기 쉬움. + self._initial_pool_size = max(1, initial_pool_size) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -53,12 +57,14 @@ async def handle(self, message: AbstractIncomingMessage) -> None: return req = envelope.payload + effective_pool_size = self._initial_pool_size # envelope.max_questions 무시 log.info( "questions.generate.start", message_id=envelope.message_id, session_id=req.session_id, doc_count=len(req.documents), max_questions=req.max_questions, + pool_size=effective_pool_size, trace_id=envelope.trace_id, ) @@ -66,7 +72,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: pool = await self._generator.generate( job_category=req.job_category, interview_type=req.interview_type, - max_questions=req.max_questions, + max_questions=effective_pool_size, context=context_text, ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index a17712a3..ec372fbd 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -153,6 +153,7 @@ def __init__(self, settings: Settings) -> None: publisher=self._publisher, idempotency=self._idempotency, callback_routing_key=settings.ai_callback_routing_questions, + initial_pool_size=settings.questions_initial_pool_size, ) # 꼬리질문 생성 (US-19) diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index 64b2e2a1..27e5cf59 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -93,7 +93,8 @@ async def test_consumer_generates_questions_and_publishes_callback(): call = generator.generate.await_args assert call.kwargs["job_category"] == "BACKEND" assert call.kwargs["interview_type"] == "TECHNICAL" - assert call.kwargs["max_questions"] == 5 + # envelope.max_questions(=5) 무시하고 initial_pool_size(default 1) 로 강제. Core 가 첫 질문만 사용. + assert call.kwargs["max_questions"] == 1 assert "Java" in call.kwargs["context"] publisher.publish.assert_awaited_once() diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java new file mode 100644 index 00000000..ca576967 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java @@ -0,0 +1,52 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.github.application.event.RepositoryDeletedEvent; +import com.stackup.stackup.resume.application.event.ResumeDeletedEvent; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +// Resume / GithubRepository soft delete 발생 시 관련 AnalyzedDocument 도 cascade soft delete. +// 도메인 cycle 회피 — resume/github 가 document 도메인을 직접 import 하지 않고 이벤트로 위임. +@Component +@RequiredArgsConstructor +public class AnalyzedDocumentCascadeListener { + + private static final Logger log = LoggerFactory.getLogger(AnalyzedDocumentCascadeListener.class); + + private final AnalyzedDocumentRepository analyzedDocumentRepository; + + @EventListener + @Transactional + public void on(ResumeDeletedEvent event) { + List docs = analyzedDocumentRepository + .findActiveByResumeIdAndOwner(event.resumeId(), event.userId()); + for (AnalyzedDocument doc : docs) { + doc.markDeleted(); + } + if (!docs.isEmpty()) { + log.info("AnalyzedDocument cascade soft delete (resume). userId={}, resumeId={}, count={}", + event.userId(), event.resumeId(), docs.size()); + } + } + + @EventListener + @Transactional + public void on(RepositoryDeletedEvent event) { + List docs = analyzedDocumentRepository + .findActiveByRepositoryIdAndOwner(event.repositoryId(), event.userId()); + for (AnalyzedDocument doc : docs) { + doc.markDeleted(); + } + if (!docs.isEmpty()) { + log.info("AnalyzedDocument cascade soft delete (repository). userId={}, repositoryId={}, count={}", + event.userId(), event.repositoryId(), docs.size()); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java index 6fefdd59..95ca76d3 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java @@ -106,4 +106,8 @@ public void markFailed(String errorCode, String errorMessage) { this.errorCode = errorCode; this.errorMessage = errorMessage; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java index 599eaf79..39ba37e3 100644 --- a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java +++ b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java @@ -5,6 +5,7 @@ import com.stackup.stackup.github.application.dto.CandidateRepositoryResult; import com.stackup.stackup.github.application.dto.GithubRepositoryResult; import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand; +import com.stackup.stackup.github.application.event.RepositoryDeletedEvent; import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent; import com.stackup.stackup.github.domain.GithubRepository; import com.stackup.stackup.github.domain.GithubRepositoryRepository; @@ -80,7 +81,9 @@ public GithubRepositoryResult get(Long userId, Long repositoryId) { @Transactional public void delete(Long userId, Long repositoryId) { GithubRepository repo = loadOwned(userId, repositoryId); - repositoryRepository.delete(repo); + repo.markDeleted(); + // 분석 결과 cascade — document 도메인 listener 가 RepositoryDeletedEvent 수신해 AnalyzedDocument soft delete. + events.publishEvent(new RepositoryDeletedEvent(userId, repositoryId)); } // US-08: GitHub 메타 재동기화. 등록된 레포의 default_branch / name / url 등을 최신화. diff --git a/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java new file mode 100644 index 00000000..45614a30 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryDeletedEvent.java @@ -0,0 +1,5 @@ +package com.stackup.stackup.github.application.event; + +// GithubRepository soft delete commit 후 발화. document 도메인이 수신해 관련 AnalyzedDocument cascade soft delete. +public record RepositoryDeletedEvent(Long userId, Long repositoryId) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index 4a6b7f45..09f967b6 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -113,4 +113,8 @@ public void markAnalyzed() { public void markFailed() { this.status = RepositoryStatus.FAILED; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java index ab282eb9..3c112e78 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java +++ b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java @@ -5,6 +5,7 @@ import com.stackup.stackup.common.storage.ObjectStorageClient; import com.stackup.stackup.resume.application.dto.ResumeResult; import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; +import com.stackup.stackup.resume.application.event.ResumeDeletedEvent; import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; import com.stackup.stackup.resume.domain.Resume; import com.stackup.stackup.resume.domain.ResumeFileType; @@ -65,8 +66,10 @@ public ResumeResult get(Long userId, Long resumeId) { @Transactional public void delete(Long userId, Long resumeId) { Resume resume = loadOwned(userId, resumeId); - // 객체(S3 본문) 삭제는 후속 정책(보존 vs 즉시 cleanup)에 따라. 우선 row soft delete. - resumeRepository.delete(resume); + resume.markDeleted(); + // 분석 결과 cascade — document 도메인 listener 가 ResumeDeletedEvent 받아 AnalyzedDocument soft delete. + // 직접 의존 회피 (ArchUnit 도메인 cycle 방지). + events.publishEvent(new ResumeDeletedEvent(userId, resumeId)); } private Resume loadOwned(Long userId, Long resumeId) { diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java new file mode 100644 index 00000000..662d99e4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeDeletedEvent.java @@ -0,0 +1,5 @@ +package com.stackup.stackup.resume.application.event; + +// Resume soft delete commit 후 발화. document 도메인이 수신해 관련 AnalyzedDocument cascade soft delete. +public record ResumeDeletedEvent(Long userId, Long resumeId) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index a7e1bec7..33df1d51 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -80,4 +80,8 @@ public void markAnalyzed() { public void markFailed() { this.status = ResumeStatus.FAILED; } + + public void markDeleted() { + this.deleted = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java index d50a705b..e1f3f844 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -13,18 +13,21 @@ public record SessionCreateRequest( @Size(max = 200) String title, @Size(max = 4000) String memo, - @NotNull SessionMode mode, + // mode: 현재 시연 범위에선 ONLINE 만 유효. 미입력 시 default 적용 (Sprint 4 에서 정식 분기). + SessionMode mode, @NotNull InterviewType interviewType, @NotNull JobCategory jobCategory, - @Min(1) @Max(30) Integer maxQuestions, - @Min(5) @Max(180) Integer maxDurationMinutes, + // 첫 질문 + 꼬리질문 최소 1쌍 보장을 위해 하한 2. applyFollowup 자동 종료 흐름 결합. + @Min(2) @Max(30) Integer maxQuestions, + // maxDurationMinutes: 시간 기반 자동 종료 미구현. DB 저장만 유지, 입력 검증 풀어 미입력 허용. + Integer maxDurationMinutes, List contextDocumentIds ) { public SessionCreateCommand toCommand() { return new SessionCreateCommand( title, memo, - mode, + mode == null ? SessionMode.ONLINE : mode, interviewType, jobCategory, maxQuestions,