From 1d6e11b85c934c283899c7e9cda5ce42370ed0c5 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 25 Jun 2026 20:47:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(session):=20=EC=8B=9C=EA=B0=84=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20IN=5FPROGRESS=20=EC=84=B8=EC=85=98=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A2=85=EB=A3=8C=20(=EC=A2=80=EB=B9=84=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 면접을 중간에 버리면(자기소개 미답변·STT 실패·탭 종료) 세션이 IN_PROGRESS 로 영원히 남아 피드백도 못 받고 히스토리·중복회피를 오염시키던 갭을 해소. - @EnableScheduling(SchedulingConfig) + SessionTimeoutSweeper(@Scheduled 5분 주기)가 maxDurationMinutes 초과한 IN_PROGRESS 세션을 찾아 SessionTimeoutService.endTimedOut 호출. 진행 세션은 소수라 전체 조회 후 메모리 필터(startedAt + maxDurationMinutes). - SessionTimeoutService: 답변 있으면 COMPLETED(→ SessionEndedEvent(DURATION_EXCEEDED) → 피드백 생성), 없으면 INTERRUPTED(피드백 없음). IN_PROGRESS 재확인으로 멀티 인스턴스 안전. - repo: findByStatusAndDeletedFalse, existsBySession_IdAndRole 추가. - 주기 설정: interview.session.sweep-interval-ms(기본 300000)·sweep-initial-delay-ms(기본 60000). - 테스트(서비스 분기 + 스위퍼 필터) + CLAUDE 갱신. Co-Authored-By: Claude Opus 4.8 --- backend/CLAUDE.md | 5 ++ .../common/config/SchedulingConfig.java | 10 +++ .../application/SessionTimeoutService.java | 69 ++++++++++++++++ .../application/SessionTimeoutSweeper.java | 62 ++++++++++++++ .../application/event/SessionEndedEvent.java | 2 +- .../domain/InterviewMessageRepository.java | 2 + .../domain/InterviewSessionRepository.java | 3 + .../SessionTimeoutServiceTest.java | 81 +++++++++++++++++++ .../SessionTimeoutSweeperTest.java | 54 +++++++++++++ 9 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/SchedulingConfig.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutSweeperTest.java diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 03dc7163..fa8f317d 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -359,6 +359,11 @@ docker compose up -d - ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지) - 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행, callback.questions(POOL/FOLLOWUP) 수신, 자동 종료 +- **세션 시간초과 자동 종료 본 구현**: `@EnableScheduling`(`common/config/SchedulingConfig`) + `SessionTimeoutSweeper` + (`@Scheduled` 기본 5분 주기)가 `maxDurationMinutes` 초과한 IN_PROGRESS 세션을 찾아 `SessionTimeoutService.endTimedOut` + 호출 — 답변 있으면 COMPLETED(→`SessionEndedEvent(DURATION_EXCEEDED)`→피드백), 없으면 INTERRUPTED(피드백 없음). + 좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지. 멀티 인스턴스 중복 실행도 IN_PROGRESS 재확인+피드백 멱등으로 안전. + 주기: `interview.session.sweep-interval-ms`(기본 300000)·`sweep-initial-delay-ms`(기본 60000). - **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment` 추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면 `FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변 diff --git a/backend/src/main/java/com/stackup/stackup/common/config/SchedulingConfig.java b/backend/src/main/java/com/stackup/stackup/common/config/SchedulingConfig.java new file mode 100644 index 00000000..64c804f1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/SchedulingConfig.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +// 스케줄링 활성화. 현재 사용처: 시간 초과 IN_PROGRESS 세션 자동 종료(SessionTimeoutSweeper). +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java new file mode 100644 index 00000000..35970865 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java @@ -0,0 +1,69 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.event.SessionEndedEvent; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.SessionStatus; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// 시간 초과된 진행 중 세션을 자동 종료한다. 답변이 하나라도 있으면 COMPLETED(→ SessionEndedEvent → 피드백 생성), +// 없으면(자기소개도 안 한 채 방치) INTERRUPTED 로 정리해 피드백을 만들지 않는다. +@Service +@RequiredArgsConstructor +public class SessionTimeoutService { + + private static final Logger log = LoggerFactory.getLogger(SessionTimeoutService.class); + private static final String REASON = "DURATION_EXCEEDED"; + + private final InterviewSessionRepository sessionRepository; + private final InterviewMessageRepository messageRepository; + private final ApplicationEventPublisher events; + + @Transactional + public void endTimedOut(Long sessionId) { + InterviewSession session = sessionRepository.findById(sessionId).orElse(null); + if (session == null || session.isDeleted() + || session.getStatus() != SessionStatus.IN_PROGRESS) { + return; // 이미 종료됐거나 레이스 — skip + } + 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()); + } + } + + private void publishState(InterviewSession session, Long userId) { + SessionStateNotice notice = + new SessionStateNotice(session.getId(), session.getStatus().name(), REASON); + events.publishEvent(RealtimeNotifyEvent.session( + session.getId(), SseEventType.SESSION_STATE, notice)); + events.publishEvent(RealtimeNotifyEvent.user( + userId, SseEventType.SESSION_STATE, notice)); + } + + public record SessionStateNotice(Long sessionId, String status, String reason) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java new file mode 100644 index 00000000..6cea72cf --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java @@ -0,0 +1,62 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +// 주기적으로 시간 초과된 진행 중 세션을 자동 종료한다(maxDurationMinutes 자동종료 — 좀비 세션 방지). +// 종료 한도는 세션별 maxDurationMinutes(startedAt 기준). 진행 세션은 소수라 전체 조회 후 메모리 필터로 충분. +// 멀티 인스턴스에서 중복 실행돼도 endTimedOut 이 IN_PROGRESS 재확인 + 피드백 멱등으로 안전하다. +@Component +@RequiredArgsConstructor +public class SessionTimeoutSweeper { + + private static final Logger log = LoggerFactory.getLogger(SessionTimeoutSweeper.class); + + private final InterviewSessionRepository sessionRepository; + private final SessionTimeoutService timeoutService; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Scheduled( + fixedDelayString = "${interview.session.sweep-interval-ms:300000}", + initialDelayString = "${interview.session.sweep-initial-delay-ms:60000}") + public void sweep() { + Instant now = Instant.now(); + List inProgress = + sessionRepository.findByStatusAndDeletedFalse(SessionStatus.IN_PROGRESS); + int ended = 0; + for (InterviewSession s : inProgress) { + if (!isTimedOut(s, now)) { + continue; + } + try { + timeoutService.endTimedOut(s.getId()); // 세션마다 독립 트랜잭션 + ended++; + } catch (RuntimeException e) { + log.warn("session timeout-end failed. sessionId={}", s.getId(), e); + } + } + if (ended > 0) { + log.info("session sweeper ended {} timed-out session(s) of {} in-progress", + ended, inProgress.size()); + } + } + + private boolean isTimedOut(InterviewSession s, Instant now) { + if (s.getStartedAt() == null || s.getMaxDurationMinutes() == null) { + return false; + } + Instant deadline = s.getStartedAt().plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES); + return now.isAfter(deadline); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionEndedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionEndedEvent.java index 4430c772..fb45a0af 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionEndedEvent.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionEndedEvent.java @@ -1,7 +1,7 @@ package com.stackup.stackup.session.application.event; // 세션 종료(COMPLETED 전이) commit 후 발화. FeedbackRequester 가 수신. -// 사유: USER_REQUEST | MAX_QUESTIONS_REACHED | POOL_EXHAUSTED +// 사유: USER_REQUEST | MAX_QUESTIONS_REACHED | POOL_EXHAUSTED | DURATION_EXCEEDED(자동 종료) public record SessionEndedEvent( Long userId, Long sessionId, diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java index f3d5f11d..9145f9c2 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java @@ -14,6 +14,8 @@ public interface InterviewMessageRepository extends JpaRepository findBySession_IdAndIdempotencyKey(Long sessionId, String idempotencyKey); @Query("select coalesce(max(m.sequenceNumber), 0) from InterviewMessage m where m.session.id = :sessionId") diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index 8d725911..2db9bc7b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -32,5 +32,8 @@ List findRecentSessionIds(@Param("userId") Long userId, long countByUser_IdAndStatus(Long userId, SessionStatus status); + // 자동 종료 스위퍼용: 진행 중(또는 특정 상태) 세션 전체. 진행 세션은 소수라 메모리 필터로 충분. + List findByStatusAndDeletedFalse(SessionStatus status); + List findByUser_IdAndStatusOrderByEndedAtDesc(Long userId, SessionStatus status, Pageable pageable); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java new file mode 100644 index 00000000..d28358d7 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java @@ -0,0 +1,81 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.session.application.event.SessionEndedEvent; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.user.domain.User; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SessionTimeoutServiceTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock InterviewMessageRepository messageRepository; + @Mock ApplicationEventPublisher events; + @InjectMocks SessionTimeoutService service; + + @Test + void endTimedOut_withAnswer_completesAndEmitsEndedEvent() { + InterviewSession session = inProgressFixture(50L); + when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); + when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)).thenReturn(true); + + service.endTimedOut(50L); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); + verify(events).publishEvent(any(SessionEndedEvent.class)); + } + + @Test + void endTimedOut_withoutAnswer_interruptsAndDoesNotTriggerFeedback() { + InterviewSession session = inProgressFixture(51L); + when(sessionRepository.findById(51L)).thenReturn(Optional.of(session)); + when(messageRepository.existsBySession_IdAndRole(51L, MessageRole.INTERVIEWEE)).thenReturn(false); + + service.endTimedOut(51L); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.INTERRUPTED); + // 답변이 없으면 피드백을 만들지 않는다 — SessionEndedEvent 미발행. + verify(events, never()).publishEvent(any(SessionEndedEvent.class)); + } + + @Test + void endTimedOut_notInProgress_isNoop() { + InterviewSession session = inProgressFixture(52L); + session.end(); // 이미 COMPLETED + when(sessionRepository.findById(52L)).thenReturn(Optional.of(session)); + + service.endTimedOut(52L); + + verify(events, never()).publishEvent(any()); + verify(messageRepository, never()).existsBySession_IdAndRole(any(), any()); + } + + private InterviewSession inProgressFixture(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); + ReflectionTestUtils.setField(s, "id", id); + s.start(); + return s; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutSweeperTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutSweeperTest.java new file mode 100644 index 00000000..1409e317 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutSweeperTest.java @@ -0,0 +1,54 @@ +package com.stackup.stackup.session.application; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.JobCategory; +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.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SessionTimeoutSweeperTest { + + @Mock InterviewSessionRepository sessionRepository; + @Mock SessionTimeoutService timeoutService; + @InjectMocks SessionTimeoutSweeper sweeper; + + @Test + void sweep_endsOnlyTimedOutSessions() { + // maxDurationMinutes=30. started 2시간 전 → 초과. started 방금 → 미초과. + InterviewSession timedOut = sessionStartedMinutesAgo(60L, 120); + InterviewSession fresh = sessionStartedMinutesAgo(61L, 1); + when(sessionRepository.findByStatusAndDeletedFalse(SessionStatus.IN_PROGRESS)) + .thenReturn(List.of(timedOut, fresh)); + + sweeper.sweep(); + + verify(timeoutService).endTimedOut(60L); + verify(timeoutService, never()).endTimedOut(61L); + } + + private InterviewSession sessionStartedMinutesAgo(Long id, int minutesAgo) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create( + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); + ReflectionTestUtils.setField(s, "id", id); + s.start(); + ReflectionTestUtils.setField(s, "startedAt", Instant.now().minus(minutesAgo, ChronoUnit.MINUTES)); + return s; + } +}