Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,15 @@ docker compose up -d
- **세션 시간초과 자동 종료 본 구현**: `@EnableScheduling`(`common/config/SchedulingConfig`) + `SessionTimeoutSweeper`
(`@Scheduled` 기본 5분 주기)가 `maxDurationMinutes` 초과한 IN_PROGRESS 세션을 찾아 `SessionTimeoutService.endTimedOut`
호출 — 답변 있으면 COMPLETED(→`SessionEndedEvent(DURATION_EXCEEDED)`→피드백), 없으면 INTERRUPTED(피드백 없음).
좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지. 멀티 인스턴스 중복 실행도 IN_PROGRESS 재확인+피드백 멱등으로 안전.
좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지.
주기: `interview.session.sweep-interval-ms`(기본 300000)·`sweep-initial-delay-ms`(기본 60000).
- **동시 종료 안전(원자적 전이)**: 모든 종료 경로(스위퍼·수동 `SessionService.end`·콜백 `endSession`)는
`InterviewSessionRepository.finishIfInProgress`(조건부 UPDATE `WHERE status=IN_PROGRESS`)로 전이를
차지하고, **영향 행 1인 트랜잭션만** `SessionEndedEvent` 를 발행한다 → DB 행 락으로 직렬화돼 동시
종료 시에도 `generate.feedback` 가 중복 발행되지 않는다.
- **종료 후 콜백 드롭**: `QuestionsCallbackService.apply` 와 `SessionFollowupRequester.onAnswerSubmitted` 는
세션이 `isTerminal()` 이면 처리를 건너뛴다 → 자동종료 뒤 늦게 도착한 POOL/FOLLOWUP 콜백이나
막판 답변 발화가 종료 세션에 질문·placeholder 를 추가하는 사후 변조를 차단.
- **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment`
추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면
`FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.stackup.stackup.session.domain.SessionQuestionPool;
import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
import com.stackup.stackup.session.domain.SessionStatus;
import java.time.Instant;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
Expand Down Expand Up @@ -68,6 +69,14 @@ public void apply(QuestionsCallbackEnvelope envelope) {
markProcessed(envelope.messageId());
return;
}
// 종료된 세션에 늦게 도착한 콜백은 드롭(멱등 마킹). 스위퍼 자동종료·수동종료 후
// 처리 대기 중이던 POOL/FOLLOWUP 콜백이 종료 세션을 사후 변조하는 것을 방지.
if (session.getStatus().isTerminal()) {
log.info("callback.questions for terminal session, drop. id={}, status={}, messageId={}",
sessionId, session.getStatus(), envelope.messageId());
markProcessed(envelope.messageId());
return;
}

if (payload.isPool()) {
applyInitialQuestion(session, payload);
Expand Down Expand Up @@ -163,18 +172,22 @@ private void publishQuestionEvents(InterviewSession session, InterviewMessage me
}

private void endSession(InterviewSession session, String reason) {
try {
session.end();
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE,
new SessionStateNotice(session.getId(), session.getStatus().name(), reason)));
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE,
new SessionStateNotice(session.getId(), session.getStatus().name(), reason)));
events.publishEvent(new SessionEndedEvent(session.getUser().getId(), session.getId(), reason));
log.info("session auto-completed. sessionId={}, reason={}", session.getId(), reason);
} catch (IllegalStateException e) {
log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}",
// 원자적 종료 전이: IN_PROGRESS 일 때만 1행 갱신. 0이면 다른 트랜잭션(스위퍼·수동 종료)이
// 먼저 종료한 것 → 종료 부수효과 미발행(중복 피드백 방지).
int claimed = sessionRepository.finishIfInProgress(
session.getId(), SessionStatus.COMPLETED, Instant.now());
if (claimed == 0) {
log.warn("auto-end skipped — session already ended. sessionId={}, status={}",
session.getId(), session.getStatus());
return;
}
session.end(); // 인메모리 동기화(SSE status 표기용). DB는 위 조건부 UPDATE 로 이미 COMPLETED.
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE,
new SessionStateNotice(session.getId(), session.getStatus().name(), reason)));
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE,
new SessionStateNotice(session.getId(), session.getStatus().name(), reason)));
events.publishEvent(new SessionEndedEvent(session.getUser().getId(), session.getId(), reason));
log.info("session auto-completed. sessionId={}, reason={}", session.getId(), reason);
}

private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.SessionContextRepository;
import com.stackup.stackup.session.domain.SessionStatus;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -53,6 +54,15 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) {
}
InterviewSession session = parent.getSession();

// 종료된 세션이면 꼬리질문/풀 생성을 요청하지 않는다. maxDuration 직전 제출한 답변의
// AFTER_COMMIT 발화가 스위퍼 자동종료와 겹칠 때, 종료 세션에 placeholder·질문이
// 추가되거나 불필요한 AI 호출이 발생하는 것을 방지(콜백단 terminal 가드와 같은 선상).
if (session.getStatus().isTerminal()) {
log.info("generate.followup skipped — session already terminal. sessionId={}, status={}",
session.getId(), session.getStatus());
return;
}

// RAG(자료 근거/correctness) 용 세션 컨텍스트 문서 — 피드백 발행부와 동일 패턴.
List<Long> contextDocumentIds = contextRepository.findBySession_Id(session.getId()).stream()
.map(c -> c.getDocument().getId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
import com.stackup.stackup.session.domain.SessionMode;
import com.stackup.stackup.session.domain.SessionContext;
import com.stackup.stackup.session.domain.SessionContextRepository;
import com.stackup.stackup.session.domain.SessionStatus;
import com.stackup.stackup.user.domain.User;
import com.stackup.stackup.user.domain.UserRepository;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
Expand Down Expand Up @@ -108,11 +110,14 @@ public SessionResult start(Long userId, Long sessionId) {
@Transactional
public SessionResult end(Long userId, Long sessionId) {
InterviewSession session = loadOwned(userId, sessionId);
try {
session.end();
} catch (IllegalStateException e) {
// 원자적 종료 전이: IN_PROGRESS 일 때만 1행 갱신. 0이면 다른 트랜잭션(스위퍼 등)이
// 먼저 종료한 것 → 기존 동작과 동일하게 INVALID_STATE. 1을 받은 호출자만 종료 이벤트 발행.
int claimed = sessionRepository.finishIfInProgress(
sessionId, SessionStatus.COMPLETED, Instant.now());
if (claimed == 0) {
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
}
session.end(); // 응답·인메모리 동기화(DB는 위 조건부 UPDATE 로 이미 COMPLETED).
events.publishEvent(new SessionEndedEvent(userId, sessionId, "USER_REQUEST"));
return SessionResult.of(session, contextDocumentIds(sessionId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.SessionStatus;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -38,28 +39,30 @@ public void endTimedOut(Long sessionId) {
boolean hasAnswer =
messageRepository.existsBySession_IdAndRole(sessionId, MessageRole.INTERVIEWEE);
Long userId = session.getUser().getId();
try {
if (hasAnswer) {
session.end(); // COMPLETED → SessionEndedEvent 로 피드백 생성
publishState(session, userId);
events.publishEvent(new SessionEndedEvent(userId, sessionId, REASON));
log.info("session auto-completed (timeout). sessionId={}", sessionId);
} else {
session.interrupt(); // 답변 없음 → 피드백 없이 정리
publishState(session, userId);
log.info("session auto-interrupted (timeout, no answers). sessionId={}", sessionId);
}
} catch (IllegalStateException e) {
log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}",
sessionId, session.getStatus());
SessionStatus target = hasAnswer ? SessionStatus.COMPLETED : SessionStatus.INTERRUPTED;

// 원자적 종료 전이: IN_PROGRESS 일 때만 1행이 갱신된다. 0이면 다른 트랜잭션
// (다른 스위퍼 인스턴스·수동 종료·콜백 종료)이 먼저 종료한 것 → 부수효과 미발행.
int claimed = sessionRepository.finishIfInProgress(sessionId, target, Instant.now());
if (claimed == 0) {
log.info("session auto-end skipped — already ended by another tx. sessionId={}", sessionId);
return;
}

publishState(sessionId, userId, target);
if (target == SessionStatus.COMPLETED) {
// COMPLETED → SessionEndedEvent 로 피드백 생성. 전이를 차지한 트랜잭션에서 단 한 번만.
events.publishEvent(new SessionEndedEvent(userId, sessionId, REASON));
log.info("session auto-completed (timeout). sessionId={}", sessionId);
} else {
log.info("session auto-interrupted (timeout, no answers). sessionId={}", sessionId);
}
}

private void publishState(InterviewSession session, Long userId) {
SessionStateNotice notice =
new SessionStateNotice(session.getId(), session.getStatus().name(), REASON);
private void publishState(Long sessionId, Long userId, SessionStatus status) {
SessionStateNotice notice = new SessionStateNotice(sessionId, status.name(), REASON);
events.publishEvent(RealtimeNotifyEvent.session(
session.getId(), SseEventType.SESSION_STATE, notice));
sessionId, SseEventType.SESSION_STATE, notice));
events.publishEvent(RealtimeNotifyEvent.user(
userId, SseEventType.SESSION_STATE, notice));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.stackup.stackup.session.domain;

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand Down Expand Up @@ -36,4 +38,15 @@ List<Long> findRecentSessionIds(@Param("userId") Long userId,
List<InterviewSession> findByStatusAndDeletedFalse(SessionStatus status);

List<InterviewSession> findByUser_IdAndStatusOrderByEndedAtDesc(Long userId, SessionStatus status, Pageable pageable);

// 원자적 종료 전이: IN_PROGRESS 일 때만 단일 조건부 UPDATE 로 종료 상태로 바꾼다.
// 영향 행 수(0 또는 1)를 반환 — 1을 받은 호출자만 '종료를 차지(claim)'했으므로
// SessionEndedEvent 등 종료 부수효과를 단 한 번만 발행한다. 동시 종료(스위퍼·수동·콜백)
// 시 DB 행 락으로 직렬화돼 정확히 하나만 1을 받는다(중복 피드백 발행 방지).
@Modifying
@Query("update InterviewSession s set s.status = :to, s.endedAt = :now "
+ "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS")
int finishIfInProgress(@Param("id") Long id,
@Param("to") SessionStatus to,
@Param("now") Instant now);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,10 @@ public enum SessionStatus {
IN_PROGRESS,
INTERRUPTED,
COMPLETED,
CANCELLED
CANCELLED;

// 더 이상 진행/전이가 불가능한 종료 상태. 종료 후 도착하는 비동기 콜백을 드롭하는 가드에 사용.
public boolean isTerminal() {
return this == INTERRUPTED || this == COMPLETED || this == CANCELLED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ void apply_followupDontKnow_deletesPlaceholderAndAdvances() {
// advanceToNextGeneral 내부에서 sessionRepository.findById 재호출
when(poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(22L))
.thenReturn(java.util.Optional.empty());
// endSession 의 원자적 종료 전이 — 전이를 차지(1).
when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1);

service.apply(env);

Expand All @@ -322,6 +324,8 @@ void advanceToNextGeneral_endsWithMaxReached_whenMainQuestionsHitLimit() {
// maxQuestions=5; 메인질문 5개를 이미 던진 상태 → 다음 advance 에서 풀을 보지 않고 종료.
ReflectionTestUtils.setField(session, "totalQuestionCount", 5);
when(sessionRepository.findById(33L)).thenReturn(Optional.of(session));
// endSession 의 원자적 종료 전이 — 전이를 차지(1).
when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1);

service.advanceToNextGeneral(33L);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ void end_transitionsInProgressToCompleted() {
session.start();
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L))
.thenReturn(Optional.of(session));
when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1);

SessionResult result = service.end(1L, 50L);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.stackup.stackup.session.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand All @@ -15,6 +15,7 @@
import com.stackup.stackup.session.domain.SessionMode;
import com.stackup.stackup.session.domain.SessionStatus;
import com.stackup.stackup.user.domain.User;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand All @@ -33,30 +34,46 @@ class SessionTimeoutServiceTest {
@InjectMocks SessionTimeoutService service;

@Test
void endTimedOut_withAnswer_completesAndEmitsEndedEvent() {
void endTimedOut_withAnswer_claimsCompletedAndEmitsEndedEvent() {
InterviewSession session = inProgressFixture(50L);
when(sessionRepository.findById(50L)).thenReturn(Optional.of(session));
when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)).thenReturn(true);
when(sessionRepository.finishIfInProgress(eq(50L), eq(SessionStatus.COMPLETED), any(Instant.class)))
.thenReturn(1);

service.endTimedOut(50L);

assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED);
verify(events).publishEvent(any(SessionEndedEvent.class));
}

@Test
void endTimedOut_withoutAnswer_interruptsAndDoesNotTriggerFeedback() {
void endTimedOut_withoutAnswer_claimsInterruptedAndDoesNotTriggerFeedback() {
InterviewSession session = inProgressFixture(51L);
when(sessionRepository.findById(51L)).thenReturn(Optional.of(session));
when(messageRepository.existsBySession_IdAndRole(51L, MessageRole.INTERVIEWEE)).thenReturn(false);
when(sessionRepository.finishIfInProgress(eq(51L), eq(SessionStatus.INTERRUPTED), any(Instant.class)))
.thenReturn(1);

service.endTimedOut(51L);

assertThat(session.getStatus()).isEqualTo(SessionStatus.INTERRUPTED);
// 답변이 없으면 피드백을 만들지 않는다 — SessionEndedEvent 미발행.
verify(events, never()).publishEvent(any(SessionEndedEvent.class));
}

@Test
void endTimedOut_lostRace_emitsNothing() {
// 조건부 UPDATE 가 0행 → 다른 트랜잭션이 먼저 종료. 어떤 이벤트도 발행하지 않는다(중복 방지).
InterviewSession session = inProgressFixture(53L);
when(sessionRepository.findById(53L)).thenReturn(Optional.of(session));
when(messageRepository.existsBySession_IdAndRole(53L, MessageRole.INTERVIEWEE)).thenReturn(true);
when(sessionRepository.finishIfInProgress(eq(53L), eq(SessionStatus.COMPLETED), any(Instant.class)))
.thenReturn(0);

service.endTimedOut(53L);

verify(events, never()).publishEvent(any());
}

@Test
void endTimedOut_notInProgress_isNoop() {
InterviewSession session = inProgressFixture(52L);
Expand All @@ -67,6 +84,7 @@ void endTimedOut_notInProgress_isNoop() {

verify(events, never()).publishEvent(any());
verify(messageRepository, never()).existsBySession_IdAndRole(any(), any());
verify(sessionRepository, never()).finishIfInProgress(any(), any(), any());
}

private InterviewSession inProgressFixture(Long id) {
Expand Down