diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 2489022..7036fbd 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -465,6 +465,17 @@ docker compose up -d
`findByParentMessage_IdIn` 으로 한 번에 받아 매핑한다(질문마다 조회하면 N+1).
`QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은 이유로
session 슬라이스에 둔다(user → session 직접 의존 회피).
+- **중단 세션 이어하기 본 구현 (B-5)**: `PATCH /api/sessions/{id}/resume` — INTERRUPTED 만 재개
+ 가능(완료·취소는 422, 새로 하려면 `/retry`). `resumeIfInterrupted` 조건부 UPDATE 로 전이를
+ 차지하고 `ended_at` 을 지우며 `resumed_at`(V27)을 찍는다.
+ - **시간 한도 기준을 `durationAnchor()`(= resumedAt ?? startedAt) 로 바꿨다.** startedAt 기준
+ 그대로면 한참 뒤 재개했을 때 스위퍼가 즉시 다시 중단시킨다. startedAt 은 '처음 시작한 시각'
+ 으로 보존된다. 이어하기를 반복하면 총 시간이 늘어나지만, 연습 도구라 허용하는 트레이드오프.
+ - **핵심은 전이가 아니라 끊긴 턴 복구다**(`SessionResumeService.recoverTurn`). 중단은 보통 턴
+ 한가운데서 일어나고 그동안 온 콜백은 terminal 가드가 전부 드롭했다. 마지막 메시지로 분기:
+ 정상 질문이면 그대로(답하면 됨) / "(생성 중)" placeholder 면 `failFollowup` + 다음 일반질문 /
+ 자기소개 답변인데 풀이 0건이면 `SelfIntroAnsweredEvent` 재발행(넘기면 POOL_EXHAUSTED 로
+ 세션이 끝나버린다) / 그 외 답변이면 다음 일반질문.
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.
diff --git a/backend/openapi.json b/backend/openapi.json
index a95769c..b89417d 100644
--- a/backend/openapi.json
+++ b/backend/openapi.json
@@ -1848,6 +1848,65 @@
}
}
},
+ "/api/sessions/{sessionId}/resume" : {
+ "patch" : {
+ "tags" : [ "Sessions" ],
+ "summary" : "중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)",
+ "description" : "중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, 질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. 완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다.",
+ "operationId" : "resumeSession",
+ "parameters" : [ {
+ "name" : "sessionId",
+ "in" : "path",
+ "required" : true,
+ "schema" : {
+ "type" : "integer",
+ "format" : "int64"
+ }
+ } ],
+ "responses" : {
+ "200" : {
+ "description" : "재개됨",
+ "content" : {
+ "*/*" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/SessionResponse"
+ }
+ }
+ }
+ },
+ "401" : {
+ "description" : "인증 실패",
+ "content" : {
+ "*/*" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/SessionResponse"
+ }
+ }
+ }
+ },
+ "404" : {
+ "description" : "세션 없음",
+ "content" : {
+ "*/*" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/SessionResponse"
+ }
+ }
+ }
+ },
+ "422" : {
+ "description" : "INTERRUPTED 아님",
+ "content" : {
+ "*/*" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/SessionResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/sessions/{sessionId}/interrupt" : {
"patch" : {
"tags" : [ "Sessions" ],
diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java
new file mode 100644
index 0000000..441d07b
--- /dev/null
+++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java
@@ -0,0 +1,155 @@
+package com.stackup.stackup.session.application;
+
+import com.stackup.stackup.common.exception.ApiErrorCode;
+import com.stackup.stackup.common.exception.DomainException;
+import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
+import com.stackup.stackup.common.sse.SseEventType;
+import com.stackup.stackup.session.application.dto.SessionResult;
+import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent;
+import com.stackup.stackup.session.domain.InterviewMessage;
+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.SessionContextRepository;
+import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
+import com.stackup.stackup.session.domain.SessionStatus;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+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;
+
+/**
+ * 중단된 면접 이어하기 (US-17 확장).
+ *
+ *
상태를 되돌리는 것만으로는 부족하다. 중단은 보통 턴 한가운데 에서 일어나고,
+ * 그동안 도착한 콜백은 terminal 가드가 전부 드롭했다. 그대로 재개하면 사용자는 답할 질문이
+ * 없거나 "(생성 중)" 에 멈춰 있는 화면을 본다. 그래서 재개는 두 단계다:
+ * 원자적 상태 전이 + 끊긴 턴 복구 .
+ */
+@Service
+@RequiredArgsConstructor
+public class SessionResumeService {
+
+ private static final Logger log = LoggerFactory.getLogger(SessionResumeService.class);
+ private static final String RESUME_REASON = "RESUMED";
+
+ private final InterviewSessionRepository sessionRepository;
+ private final InterviewMessageRepository messageRepository;
+ private final SessionContextRepository contextRepository;
+ private final SessionQuestionPoolRepository poolRepository;
+ private final QuestionsCallbackService questionsCallbackService;
+ private final ApplicationEventPublisher events;
+
+ @Transactional
+ public SessionResult resume(Long userId, Long sessionId) {
+ InterviewSession session = sessionRepository
+ .findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
+ .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND));
+
+ // 이어할 수 있는 건 중단된 세션뿐이다. 완료 세션은 피드백이 이미 나갔고,
+ // 취소 세션은 시작한 적이 없다(둘 다 '다시 하기'로 새 세션을 만드는 게 맞다).
+ if (session.getStatus() != SessionStatus.INTERRUPTED) {
+ throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
+ }
+ // 원자적 재개 전이 — 중복 요청 중 하나만 차지한다(다른 전이와 같은 패턴).
+ if (sessionRepository.resumeIfInterrupted(sessionId, Instant.now()) == 0) {
+ throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
+ }
+ // 조건부 UPDATE 는 영속성 컨텍스트를 우회하므로 엔티티를 다시 읽는다.
+ sessionRepository.flush();
+ InterviewSession resumed = sessionRepository.findById(sessionId).orElseThrow();
+
+ recoverTurn(userId, resumed);
+ publishState(resumed);
+ log.info("session resumed. sessionId={}, userId={}", sessionId, userId);
+ return SessionResult.of(resumed, contextDocumentIds(sessionId));
+ }
+
+ /**
+ * 끊긴 턴을 이어붙인다. 마지막 메시지가 무엇이냐로 갈린다.
+ *
+ *
+ * 정상 질문 → 할 일 없음. 사용자가 그 질문에 답하면 된다.
+ * 생성 중 placeholder → 그 꼬리질문은 영영 오지 않는다(콜백이 드롭됐다).
+ * 실패로 확정하고 다음 일반질문으로 넘긴다.
+ * 자기소개 답변인데 질문 풀이 없음 → 풀 생성 요청이 유실된 것. 다시 요청한다.
+ * 그 외 답변 → 다음 질문이 오지 않은 것. 다음 일반질문으로 넘긴다.
+ *
+ */
+ private void recoverTurn(Long userId, InterviewSession session) {
+ InterviewMessage last = messageRepository
+ .findFirstBySession_IdOrderBySequenceNumberDesc(session.getId())
+ .orElse(null);
+ if (last == null) {
+ log.warn("resume: session has no messages — nothing to recover. sessionId={}",
+ session.getId());
+ return;
+ }
+
+ if (last.getRole() == MessageRole.INTERVIEWER) {
+ if (!isPendingPlaceholder(last)) {
+ return; // 답할 질문이 그대로 있다
+ }
+ log.info("resume: dangling followup placeholder — failing and advancing. sessionId={}, msg={}",
+ session.getId(), last.getId());
+ last.failFollowup();
+ questionsCallbackService.advanceToNextGeneral(session.getId());
+ return;
+ }
+
+ // 마지막이 답변 = 다음 질문이 오지 않은 상태.
+ InterviewMessage parent = last.getParentMessage();
+ boolean selfIntroAnswer = parent != null && parent.isSelfIntroduction();
+ if (selfIntroAnswer && poolRepository.countBySessionId(session.getId()) == 0) {
+ log.info("resume: question pool never generated — re-requesting. sessionId={}",
+ session.getId());
+ requestQuestionPool(userId, session, last.getContent());
+ return;
+ }
+ log.info("resume: answer without next question — advancing. sessionId={}", session.getId());
+ questionsCallbackService.advanceToNextGeneral(session.getId());
+ }
+
+ // 내용이 아직 채워지지 않은 꼬리질문 placeholder 인지.
+ private boolean isPendingPlaceholder(InterviewMessage message) {
+ return InterviewMessage.FOLLOWUP_GENERATING_TEXT.equals(message.getContent());
+ }
+
+ // SessionFollowupRequester 가 자기소개 답변 직후 내는 것과 같은 이벤트.
+ // AFTER_COMMIT 리스너(SessionQuestionsRequester)가 받아 generate.questions 를 발행한다.
+ private void requestQuestionPool(Long userId, InterviewSession session, String selfIntroAnswer) {
+ events.publishEvent(new SelfIntroAnsweredEvent(
+ userId,
+ session.getId(),
+ session.getMode(),
+ new ArrayList<>(session.getJobCategories()),
+ session.getMaxQuestions(),
+ session.getGeneralQuestionCount(),
+ contextDocumentIds(session.getId()),
+ selfIntroAnswer,
+ session.getTargetCompanyName(),
+ session.getTargetJobDescription()
+ ));
+ }
+
+ private void publishState(InterviewSession session) {
+ SessionTimeoutService.SessionStateNotice notice = new SessionTimeoutService.SessionStateNotice(
+ session.getId(), SessionStatus.IN_PROGRESS.name(), RESUME_REASON);
+ events.publishEvent(RealtimeNotifyEvent.session(
+ session.getId(), SseEventType.SESSION_STATE, notice));
+ events.publishEvent(RealtimeNotifyEvent.user(
+ session.getUser().getId(), SseEventType.SESSION_STATE, notice));
+ }
+
+ private List contextDocumentIds(Long sessionId) {
+ return contextRepository.findBySession_Id(sessionId).stream()
+ .map(c -> c.getDocument().getId())
+ .toList();
+ }
+}
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
index 6cea72c..688b866 100644
--- a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java
+++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutSweeper.java
@@ -52,11 +52,13 @@ public void sweep() {
}
}
+ // 기준 시각은 startedAt 이 아니라 durationAnchor() — 이어하기로 재개했다면 그 시각부터
+ // 다시 잰다. 아니면 재개하자마자 스위퍼가 즉시 다시 중단시킨다.
private boolean isTimedOut(InterviewSession s, Instant now) {
- if (s.getStartedAt() == null || s.getMaxDurationMinutes() == null) {
+ Instant anchor = s.durationAnchor();
+ if (anchor == null || s.getMaxDurationMinutes() == null) {
return false;
}
- Instant deadline = s.getStartedAt().plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES);
- return now.isAfter(deadline);
+ return now.isAfter(anchor.plus(s.getMaxDurationMinutes(), ChronoUnit.MINUTES));
}
}
diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java
index c79bd37..d622d76 100644
--- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java
+++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java
@@ -110,6 +110,11 @@ public class InterviewSession extends BaseSoftDeleteEntity {
@Column(name = "ended_at")
private Instant endedAt;
+ // 중단 후 이어하기로 재개한 시각. 시간 한도는 이 값 기준으로 다시 잰다(없으면 startedAt).
+ // startedAt 은 '처음 시작한 시각'으로 보존한다.
+ @Column(name = "resumed_at")
+ private Instant resumedAt;
+
private InterviewSession(User user, String title, String memo, SessionMode mode,
List jobCategories,
Integer maxQuestions, Integer maxDurationMinutes,
@@ -175,6 +180,11 @@ public void assignTargetRole(String companyName, String jobDescription) {
this.targetJobDescription = jobDescription;
}
+ // 시간 한도의 기준 시각. 이어하기로 재개했다면 그 자리(sitting)의 시작이 기준이다.
+ public Instant durationAnchor() {
+ return resumedAt != null ? resumedAt : startedAt;
+ }
+
public void start() {
if (status != SessionStatus.READY) {
throw new IllegalStateException("session is not READY to start (current=" + status + ")");
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 0cdec81..259bda1 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
@@ -58,6 +58,15 @@ int finishIfInProgress(@Param("id") Long id,
+ "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.READY")
int startIfReady(@Param("id") Long id, @Param("now") Instant now);
+ // 원자적 재개 전이: INTERRUPTED 일 때만 IN_PROGRESS 로 되돌린다. endedAt 을 지우고
+ // resumedAt 을 새로 찍어 시간 한도를 이 자리 기준으로 다시 재게 한다.
+ // 다른 전이와 같은 조건부 UPDATE 패턴 — 중복 요청 중 하나만 1을 받는다.
+ @Modifying
+ @Query("update InterviewSession s set s.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS, "
+ + "s.resumedAt = :now, s.endedAt = null "
+ + "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.INTERRUPTED")
+ int resumeIfInterrupted(@Param("id") Long id, @Param("now") Instant now);
+
// 원자적 취소 전이: READY 일 때만 CANCELLED 로 (동시 start 와의 레이스 차단).
@Modifying
@Query("update InterviewSession s set s.status = com.stackup.stackup.session.domain.SessionStatus.CANCELLED "
diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java
index 420a269..149db4e 100644
--- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java
+++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java
@@ -2,6 +2,7 @@
import com.stackup.stackup.common.response.PageResponse;
import com.stackup.stackup.common.security.UserPrincipal;
+import com.stackup.stackup.session.application.SessionResumeService;
import com.stackup.stackup.session.application.SessionService;
import com.stackup.stackup.session.presentation.dto.SessionCreateRequest;
import com.stackup.stackup.session.presentation.dto.SessionResponse;
@@ -36,6 +37,7 @@
public class SessionController {
private final SessionService sessionService;
+ private final SessionResumeService resumeService;
@Operation(
operationId = "createSession",
@@ -132,6 +134,28 @@ public SessionResponse update(
));
}
+ @Operation(
+ operationId = "resumeSession",
+ summary = "중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)",
+ description = "중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 "
+ + "끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, "
+ + "질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. "
+ + "완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다."
+ )
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "재개됨"),
+ @ApiResponse(responseCode = "401", description = "인증 실패"),
+ @ApiResponse(responseCode = "404", description = "세션 없음"),
+ @ApiResponse(responseCode = "422", description = "INTERRUPTED 아님")
+ })
+ @PatchMapping("/{sessionId}/resume")
+ public SessionResponse resume(
+ @AuthenticationPrincipal UserPrincipal principal,
+ @PathVariable Long sessionId
+ ) {
+ return SessionResponse.from(resumeService.resume(principal.userId(), sessionId));
+ }
+
@Operation(operationId = "startSession", summary = "세션 시작 (READY→IN_PROGRESS) (US-17)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "시작됨"),
diff --git a/backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql b/backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql
new file mode 100644
index 0000000..2e4065b
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V27__add_session_resumed_at.sql
@@ -0,0 +1,6 @@
+-- B-5 중단 세션 이어하기. INTERRUPTED → IN_PROGRESS 재개를 허용한다.
+-- 시간 한도(max_duration_minutes)는 started_at 기준인데, 한참 뒤에 재개하면 스위퍼가
+-- 즉시 다시 중단시킨다. 재개 시각을 따로 두고 스위퍼가 COALESCE(resumed_at, started_at)
+-- 기준으로 재도록 해, 이어하기마다 그 자리(sitting)의 시간이 새로 시작되게 한다.
+-- started_at 은 '처음 시작한 시각'으로 보존된다(히스토리 표시용).
+ALTER TABLE interview_sessions ADD COLUMN resumed_at TIMESTAMPTZ;
diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java
new file mode 100644
index 0000000..b9e59bb
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java
@@ -0,0 +1,212 @@
+package com.stackup.stackup.session.application;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.stackup.stackup.common.exception.ApiErrorCode;
+import com.stackup.stackup.common.exception.DomainException;
+import com.stackup.stackup.session.application.dto.SessionResult;
+import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent;
+import com.stackup.stackup.session.domain.InterviewMessage;
+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.MessageStatus;
+import com.stackup.stackup.session.domain.SessionContextRepository;
+import com.stackup.stackup.session.domain.SessionMode;
+import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
+import com.stackup.stackup.session.domain.SessionStatus;
+import com.stackup.stackup.user.domain.User;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+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;
+
+/**
+ * 이어하기의 본질은 상태 전이가 아니라 **끊긴 턴 복구**다.
+ * 중단은 턴 한가운데서 일어나고, 그동안 도착한 콜백은 terminal 가드가 전부 드롭했다.
+ */
+@ExtendWith(MockitoExtension.class)
+class SessionResumeServiceTest {
+
+ @Mock InterviewSessionRepository sessionRepository;
+ @Mock InterviewMessageRepository messageRepository;
+ @Mock SessionContextRepository contextRepository;
+ @Mock SessionQuestionPoolRepository poolRepository;
+ @Mock QuestionsCallbackService questionsCallbackService;
+ @Mock ApplicationEventPublisher events;
+ @InjectMocks SessionResumeService service;
+
+ // ── 상태 전이 ─────────────────────────────────────────────────────────────
+
+ @Test
+ void resume_rejectsSessionThatIsNotInterrupted() {
+ InterviewSession completed = sessionFixture(10L, SessionStatus.COMPLETED);
+
+ when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L))
+ .thenReturn(Optional.of(completed));
+
+ assertThatThrownBy(() -> service.resume(1L, 10L))
+ .isInstanceOfSatisfying(DomainException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE));
+
+ verify(sessionRepository, never()).resumeIfInterrupted(anyLong(), any());
+ }
+
+ @Test
+ void resume_rejectsSessionOfAnotherUser() {
+ when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.resume(1L, 10L))
+ .isInstanceOfSatisfying(DomainException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_NOT_FOUND));
+ }
+
+ // 중복 요청(더블클릭·재전송) 중 하나만 전이를 차지한다.
+ @Test
+ void resume_failsWhenTransitionIsClaimedByAnotherRequest() {
+ InterviewSession interrupted = sessionFixture(10L, SessionStatus.INTERRUPTED);
+
+ when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L))
+ .thenReturn(Optional.of(interrupted));
+ when(sessionRepository.resumeIfInterrupted(eq(10L), any(Instant.class))).thenReturn(0);
+
+ assertThatThrownBy(() -> service.resume(1L, 10L))
+ .isInstanceOfSatisfying(DomainException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE));
+ }
+
+ // ── 턴 복구 ───────────────────────────────────────────────────────────────
+
+ // 답할 질문이 그대로 남아 있으면 건드릴 게 없다.
+ @Test
+ void resume_leavesAnsweredQuestionAlone() {
+ InterviewSession session = resumable(10L);
+ InterviewMessage question = InterviewMessage.interviewer(session, 3, "ACID 를 설명해 주세요.");
+ ReflectionTestUtils.setField(question, "id", 100L);
+ when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
+ .thenReturn(Optional.of(question));
+
+ SessionResult result = service.resume(1L, 10L);
+
+ assertThat(result.status()).isEqualTo(SessionStatus.IN_PROGRESS);
+ verify(questionsCallbackService, never()).advanceToNextGeneral(anyLong());
+ verify(events, never()).publishEvent(any(SelfIntroAnsweredEvent.class));
+ }
+
+ // "(생성 중)" 에서 끊겼다면 그 꼬리질문은 영영 오지 않는다(콜백이 드롭됐다).
+ // 그대로 두면 재개해도 화면이 생성 중에 멈춘다.
+ @Test
+ void resume_failsDanglingPlaceholderAndAdvances() {
+ InterviewSession session = resumable(10L);
+ InterviewMessage parent = InterviewMessage.interviewer(session, 3, "부모 질문");
+ InterviewMessage placeholder = InterviewMessage.followupPlaceholder(session, 4, parent);
+ ReflectionTestUtils.setField(placeholder, "id", 101L);
+ when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
+ .thenReturn(Optional.of(placeholder));
+
+ service.resume(1L, 10L);
+
+ assertThat(placeholder.getStatus()).isEqualTo(MessageStatus.FAILED);
+ assertThat(placeholder.getContent())
+ .isEqualTo(InterviewMessage.FOLLOWUP_GENERATION_FAILED_TEXT);
+ verify(questionsCallbackService).advanceToNextGeneral(10L);
+ }
+
+ // 답변까지 하고 다음 질문을 못 받은 채 끊긴 경우 — 그냥 재개하면 답할 게 없다.
+ @Test
+ void resume_advancesWhenAnswerHasNoNextQuestion() {
+ InterviewSession session = resumable(10L);
+ InterviewMessage question = InterviewMessage.interviewer(session, 3, "일반 질문");
+ InterviewMessage answer = InterviewMessage.interviewee(session, 4, "제 답변", question, null);
+ ReflectionTestUtils.setField(answer, "id", 102L);
+ when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
+ .thenReturn(Optional.of(answer));
+
+ service.resume(1L, 10L);
+
+ verify(questionsCallbackService).advanceToNextGeneral(10L);
+ verify(events, never()).publishEvent(any(SelfIntroAnsweredEvent.class));
+ }
+
+ // 자기소개만 답하고 끊겼는데 풀이 아예 없으면, 다음 질문으로 넘길 게 아니라
+ // 질문 풀 생성을 다시 요청해야 한다(넘기면 POOL_EXHAUSTED 로 세션이 끝나버린다).
+ @Test
+ void resume_reRequestsQuestionPoolWhenSelfIntroAnsweredButPoolMissing() {
+ InterviewSession session = resumable(10L);
+ InterviewMessage selfIntro = InterviewMessage.selfIntroduction(session, 1);
+ InterviewMessage answer =
+ InterviewMessage.interviewee(session, 2, "안녕하세요, 백엔드 3년차입니다", selfIntro, null);
+ ReflectionTestUtils.setField(answer, "id", 103L);
+ when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
+ .thenReturn(Optional.of(answer));
+ when(poolRepository.countBySessionId(10L)).thenReturn(0L);
+
+ service.resume(1L, 10L);
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(SelfIntroAnsweredEvent.class);
+ verify(events).publishEvent(captor.capture());
+ assertThat(captor.getValue().sessionId()).isEqualTo(10L);
+ assertThat(captor.getValue().selfIntroAnswer()).isEqualTo("안녕하세요, 백엔드 3년차입니다");
+ verify(questionsCallbackService, never()).advanceToNextGeneral(anyLong());
+ }
+
+ // 풀이 이미 있으면(생성은 됐고 다음 질문만 못 받은 것) 다음 질문으로 넘긴다.
+ @Test
+ void resume_advancesWhenSelfIntroAnsweredAndPoolExists() {
+ InterviewSession session = resumable(10L);
+ InterviewMessage selfIntro = InterviewMessage.selfIntroduction(session, 1);
+ InterviewMessage answer = InterviewMessage.interviewee(session, 2, "자기소개", selfIntro, null);
+ ReflectionTestUtils.setField(answer, "id", 104L);
+ when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
+ .thenReturn(Optional.of(answer));
+ when(poolRepository.countBySessionId(10L)).thenReturn(4L);
+
+ service.resume(1L, 10L);
+
+ verify(questionsCallbackService).advanceToNextGeneral(10L);
+ verify(events, never()).publishEvent(any(SelfIntroAnsweredEvent.class));
+ }
+
+ // ── fixtures ──────────────────────────────────────────────────────────────
+
+ /** 소유권·전이·재조회까지 통과해 복구 단계로 들어가는 세션. */
+ private InterviewSession resumable(Long id) {
+ InterviewSession interrupted = sessionFixture(id, SessionStatus.INTERRUPTED);
+ InterviewSession afterResume = sessionFixture(id, SessionStatus.IN_PROGRESS);
+
+ when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(id, 1L))
+ .thenReturn(Optional.of(interrupted));
+ when(sessionRepository.resumeIfInterrupted(eq(id), any(Instant.class))).thenReturn(1);
+ when(sessionRepository.findById(id)).thenReturn(Optional.of(afterResume));
+ lenient().when(contextRepository.findBySession_Id(id)).thenReturn(List.of());
+ return afterResume;
+ }
+
+ private InterviewSession sessionFixture(Long id, SessionStatus status) {
+ User user = User.createGithubUser(1L, "u", null, null, "t");
+ ReflectionTestUtils.setField(user, "id", 1L);
+ InterviewSession s = InterviewSession.create(
+ user, "면접", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null
+ );
+ ReflectionTestUtils.setField(s, "id", id);
+ ReflectionTestUtils.setField(s, "status", status);
+ return s;
+ }
+}
diff --git a/docs/database.md b/docs/database.md
index 10c67c7..a5383ac 100644
--- a/docs/database.md
+++ b/docs/database.md
@@ -169,6 +169,7 @@ CREATE TABLE interview_sessions (
-- 직무 맞춤(JOB_TAILORED) 모드 전용. 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 NULL. (V18)
target_company_name VARCHAR(200),
target_job_description TEXT,
+ resumed_at TIMESTAMPTZ, -- 이어하기로 재개한 시각. 시간 한도를 이 값 기준으로 다시 잰다(V27)
focus_areas JSONB, -- 약점 집중 재도전의 겨냥 축 배열(TECHNICAL|LOGIC|COMMUNICATION). 일반 면접은 NULL
status VARCHAR(20) NOT NULL DEFAULT 'READY'
CHECK (status IN ('READY','IN_PROGRESS','INTERRUPTED','COMPLETED','CANCELLED')),
diff --git a/frontend/src/features/history/ui/SessionCard.test.tsx b/frontend/src/features/history/ui/SessionCard.test.tsx
index 0f370e4..f4ab022 100644
--- a/frontend/src/features/history/ui/SessionCard.test.tsx
+++ b/frontend/src/features/history/ui/SessionCard.test.tsx
@@ -30,12 +30,12 @@ describe('SessionCard', () => {
expect(screen.getByText('리포트 →')).toBeInTheDocument()
})
- // 중단된 면접은 피드백이 없다. 문답 기록으로 갈 경로가 없으면 한 말이 통째로 사라진다.
- it('중단 세션은 세션 화면(기록)으로 간다', () => {
+ // 중단된 면접은 피드백이 없다. 세션 화면에서 기록을 보고 이어서 진행할 수 있다.
+ it('중단 세션은 세션 화면(이어하기)으로 간다', () => {
renderCard({ status: 'INTERRUPTED' })
expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7')
- expect(screen.getByText('기록 보기 →')).toBeInTheDocument()
+ expect(screen.getByText('이어하기 →')).toBeInTheDocument()
})
// 진행 중 면접에서 이탈했을 때 돌아갈 경로.
diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx
index cb5adce..bcdb95b 100644
--- a/frontend/src/features/history/ui/SessionCard.tsx
+++ b/frontend/src/features/history/ui/SessionCard.tsx
@@ -32,7 +32,7 @@ const JOB: Record = {
*/
const LINK: Record string; cta: string }> = {
COMPLETED: { to: (id) => `/sessions/${id}/feedback`, cta: '리포트 →' },
- INTERRUPTED: { to: (id) => `/sessions/${id}`, cta: '기록 보기 →' },
+ INTERRUPTED: { to: (id) => `/sessions/${id}`, cta: '이어하기 →' },
IN_PROGRESS: { to: (id) => `/sessions/${id}`, cta: '이어서 →' },
READY: { to: (id) => `/sessions/${id}`, cta: '시작하기 →' },
}
diff --git a/frontend/src/features/interview/api/sessionApi.ts b/frontend/src/features/interview/api/sessionApi.ts
index 0d06686..f2eca0a 100644
--- a/frontend/src/features/interview/api/sessionApi.ts
+++ b/frontend/src/features/interview/api/sessionApi.ts
@@ -27,6 +27,11 @@ export async function startSession(id: number): Promise {
return (await apiClient.patch(`/api/sessions/${id}/start`)).data
}
+// 중단된 면접 이어하기. 서버가 상태 전이 + 끊긴 턴 복구까지 한다.
+export async function resumeSession(id: number): Promise {
+ return (await apiClient.patch(`/api/sessions/${id}/resume`)).data
+}
+
export async function endSession(id: number): Promise {
return (await apiClient.patch(`/api/sessions/${id}/end`)).data
}
diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts
index 30192cb..86ab609 100644
--- a/frontend/src/features/interview/index.ts
+++ b/frontend/src/features/interview/index.ts
@@ -5,6 +5,7 @@ export { InterviewSetupForm } from './ui/setup/InterviewSetupForm'
export type { DocOption } from './ui/setup/ContextDocumentPicker'
export { useCreateSession } from './model/useCreateSession'
export { useRetrySession } from './model/useRetrySession'
+export { useResumeSession } from './model/useResumeSession'
export { useSession } from './model/useSession'
export { useBookmarks, useSetQuestionBookmark, bookmarkKeys } from './model/useBookmarks'
export { useLiveInterview } from './model/useLiveInterview'
diff --git a/frontend/src/features/interview/model/useResumeSession.ts b/frontend/src/features/interview/model/useResumeSession.ts
new file mode 100644
index 0000000..f7e77ac
--- /dev/null
+++ b/frontend/src/features/interview/model/useResumeSession.ts
@@ -0,0 +1,24 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from '@/shared/ui'
+import { resumeSession } from '../api/sessionApi'
+import { sessionKeys } from './useSession'
+import { messageKeys } from './useSessionMessages'
+
+/**
+ * 중단된 면접 이어하기. 서버가 끊긴 턴까지 복구하므로(다음 질문 발행 등)
+ * 세션과 메시지를 모두 다시 읽어야 화면이 살아난다.
+ */
+export function useResumeSession(sessionId: number) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: () => resumeSession(sessionId),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
+ void queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) })
+ void queryClient.invalidateQueries({ queryKey: sessionKeys.all })
+ toast.success('면접을 이어서 진행합니다')
+ },
+ onError: () =>
+ toast.error('면접을 이어가지 못했어요. 잠시 후 다시 시도해 주세요.'),
+ })
+}
diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx
index d3d621d..b56521b 100644
--- a/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx
+++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.test.tsx
@@ -12,6 +12,10 @@ vi.mock('../../model/useRetrySession', () => ({
return { mutate: retryMutate, isPending: false }
},
}))
+const resumeMutate = vi.fn()
+vi.mock('../../model/useResumeSession', () => ({
+ useResumeSession: () => ({ mutate: resumeMutate, isPending: false }),
+}))
vi.mock('../InterviewTranscript', () => ({
InterviewTranscript: () =>
,
}))
@@ -19,6 +23,7 @@ vi.mock('../InterviewTranscript', () => ({
beforeEach(() => {
retryMutate.mockClear()
lastSourceCount.mockClear()
+ resumeMutate.mockClear()
})
function renderPanel(status: 'COMPLETED' | 'INTERRUPTED' | 'CANCELLED') {
@@ -80,6 +85,24 @@ describe('SessionEndedPanel', () => {
).not.toBeInTheDocument()
})
+ // 중단된 면접의 첫 선택지는 새로 만드는 게 아니라 하던 걸 이어가는 것이다.
+ it('중단 세션은 이어서 진행하기를 제안한다', async () => {
+ renderPanel('INTERRUPTED')
+
+ await userEvent.click(screen.getByRole('button', { name: '이어서 진행하기' }))
+
+ expect(resumeMutate).toHaveBeenCalled()
+ })
+
+ // 완료 세션은 이미 피드백이 나갔다 — 이어갈 대화가 없다.
+ it('완료 세션에는 이어하기를 제안하지 않는다', () => {
+ renderPanel('COMPLETED')
+
+ expect(
+ screen.queryByRole('button', { name: '이어서 진행하기' }),
+ ).not.toBeInTheDocument()
+ })
+
// 자료 수를 넘겨야 "삭제된 자료 N개 제외" 안내가 가능하다.
it('원본 세션의 자료 수를 재도전 훅에 넘긴다', () => {
render(
diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx
index fa10b51..89b6379 100644
--- a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx
+++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { Button } from '@/shared/ui/Button'
import { Eyebrow } from '@/shared/ui'
import type { Session, SessionStatus } from '@/domain/session'
+import { useResumeSession } from '../../model/useResumeSession'
import { useRetrySession } from '../../model/useRetrySession'
import { InterviewTranscript } from '../InterviewTranscript'
@@ -27,6 +28,7 @@ export function SessionEndedPanel({
session?: Session
}) {
const retry = useRetrySession(session?.contextDocumentIds?.length)
+ const resume = useResumeSession(sessionId)
return (
@@ -39,6 +41,12 @@ export function SessionEndedPanel({
{messageByStatus[status] ?? '면접이 종료되었습니다.'}
+ {/* 중단된 면접은 이어서 하는 게 첫 선택지다 — 하던 대화가 그대로 남아 있다. */}
+ {status === 'INTERRUPTED' && (
+ resume.mutate()}>
+ 이어서 진행하기
+
+ )}
{status === 'COMPLETED' && (
피드백 보기
@@ -46,7 +54,7 @@ export function SessionEndedPanel({
)}
{/* 중단됐든 끝났든, 같은 조건으로 한 번 더 해보는 게 다음 행동이다. */}
retry.mutate(sessionId)}
>
diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts
index a145a5d..b3aca3b 100644
--- a/frontend/src/shared/api/generated.ts
+++ b/frontend/src/shared/api/generated.ts
@@ -511,6 +511,26 @@ export interface paths {
patch: operations["startSession"];
trace?: never;
};
+ "/api/sessions/{sessionId}/resume": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * 중단된 면접 이어하기 (INTERRUPTED→IN_PROGRESS)
+ * @description 중단된 세션을 다시 진행 가능한 상태로 되돌린다. 상태만 바꾸는 게 아니라 끊긴 턴을 복구한다 — 생성 중이던 꼬리질문은 실패로 확정하고 다음 질문으로 넘기며, 질문 풀 생성 요청이 유실됐다면 다시 요청한다. 시간 한도는 재개 시각부터 다시 잰다. 완료·취소 세션은 이어할 수 없다(422) — 새로 시작하려면 /retry 를 쓴다.
+ */
+ patch: operations["resumeSession"];
+ trace?: never;
+ };
"/api/sessions/{sessionId}/interrupt": {
parameters: {
query?: never;
@@ -3045,6 +3065,55 @@ export interface operations {
};
};
};
+ resumeSession: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sessionId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 재개됨 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["SessionResponse"];
+ };
+ };
+ /** @description 인증 실패 */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["SessionResponse"];
+ };
+ };
+ /** @description 세션 없음 */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["SessionResponse"];
+ };
+ };
+ /** @description INTERRUPTED 아님 */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["SessionResponse"];
+ };
+ };
+ };
+ };
interruptSession: {
parameters: {
query?: never;