feat: 중단된 면접 이어하기 (B-5) - #180
Merged
Merged
Conversation
`PATCH /api/sessions/{id}/resume` — INTERRUPTED 세션을 다시 진행 가능한 상태로 되돌린다.
완료·취소 세션은 이어할 수 없다(422). 새로 하려면 기존 `/retry` 를 쓴다.
## 핵심은 상태 전이가 아니라 끊긴 턴 복구다
중단은 보통 **턴 한가운데**에서 일어난다(탭 종료·네트워크 끊김·수동 중단). 그동안 도착한
콜백은 terminal 가드가 전부 드롭했으므로, 상태만 IN_PROGRESS 로 되돌리면 사용자는
답할 질문이 없거나 "(생성 중)" 에 멈춘 화면을 본다.
`SessionResumeService.recoverTurn` 이 마지막 메시지로 분기한다:
| 마지막 메시지 | 조치 |
|---|---|
| 정상 질문 | 없음 — 그 질문에 답하면 된다 |
| "(생성 중)" placeholder | `failFollowup` + 다음 일반질문 (그 꼬리질문은 영영 안 온다) |
| 자기소개 답변 + 풀 0건 | `SelfIntroAnsweredEvent` 재발행 (풀 생성 요청이 유실된 것) |
| 그 외 답변 | 다음 일반질문 |
자기소개 분기가 중요하다. 풀이 없는데 `advanceToNextGeneral` 로 넘기면 POOL_EXHAUSTED 로
세션이 그대로 끝나버린다.
## 시간 한도
`max_duration_minutes` 를 `started_at` 기준으로 재고 있어서, 한참 뒤에 재개하면 스위퍼가
즉시 다시 중단시킨다. V27 로 `resumed_at` 을 추가하고 스위퍼가
`durationAnchor()`(= resumedAt ?? startedAt) 기준으로 재도록 했다 — 이어하기마다 그 자리의
시간이 새로 시작된다. `started_at` 은 '처음 시작한 시각'으로 보존된다(히스토리 표시).
이어하기를 반복하면 총 시간이 늘어난다. 연습 도구라 제한하지 않았다.
## 전이
`resumeIfInterrupted` 조건부 UPDATE 로 전이를 차지하고 `ended_at` 을 지운다. 다른 전이
(start/cancel/finish)와 같은 패턴이라 중복 요청 중 하나만 1을 받는다.
## 테스트
`SessionResumeServiceTest` 신설 (8) — 완료 세션 거부, 타인 세션 거부, 전이 경합,
그리고 복구 분기 5종(정상 질문 무개입, placeholder 실패 처리, 답변 후 다음 질문,
자기소개+풀 없음 → 재요청, 자기소개+풀 있음 → 다음 질문).
- 종료 패널: 중단 세션에 '이어서 진행하기' 를 primary 로. 하던 대화가 그대로 남아 있으니 새로 만드는 것보다 이어가는 게 먼저다. '같은 설정으로 다시' 는 secondary 로 내렸다 - 히스토리 카드: 중단 세션 CTA 를 '기록 보기 →' 에서 '이어하기 →' 로 - `useResumeSession` — 서버가 끊긴 턴까지 복구하므로(다음 질문 발행 등) 세션과 메시지를 모두 무효화해야 화면이 살아난다 테스트: `SessionEndedPanel.test.tsx` +2(중단 세션의 이어하기 호출, 완료 세션엔 미노출), `SessionCard.test.tsx` CTA 문구 갱신. vitest 113/113 · eslint 0 · tsc · build 통과.
This was referenced Aug 19, 2026
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Aug 28, 2026
직전 PR(Team-StackUp#180)에서 재개 후 엔티티를 `findById` 로 다시 읽었다. **벌크 UPDATE 는 영속성 컨텍스트를 갱신하지 않으므로** 같은 트랜잭션의 `findById` 는 1차 캐시에 있는 낡은 엔티티(status=INTERRUPTED)를 그대로 돌려준다. 결과적으로: - `advanceToNextGeneral` 이 `getStatus() != IN_PROGRESS` 에서 조용히 되돌아가 **턴 복구가 통째로 죽었다** — 생성 중 placeholder도, 다음 질문 발행도 일어나지 않는다 - 응답 `status` 가 INTERRUPTED 로 나가 프론트가 라이브 화면으로 전환하지도 않는다 즉 이어하기 기능 전체가 동작하지 않았다. `SessionService.start` 가 `startIfReady` 뒤에 `session.start()` 로 인메모리를 맞추는 것과 같은 방식으로 `InterviewSession.resume(now)` 을 추가해 맞춘다. UPDATE 와 같은 `now` 를 넘겨 DB 와 값이 어긋나지 않게 했다. ## 테스트가 왜 못 잡았나 `sessionRepository.findById` 를 **별도의 IN_PROGRESS 인스턴스**를 돌려주도록 목을 걸어서, 운영에서 낡은 엔티티가 오는 상황이 재현되지 않았다. 픽스처를 인스턴스 하나만 쓰도록 바꾸고 `resume_syncsInMemoryStateAfterBulkUpdate` 를 추가했다 — 상태·resumedAt·endedAt· durationAnchor·응답 status 를 한 번에 못 박는다. 수정 전 코드로 되돌리면 이 테스트가 실패하는 것을 확인했다. `backend/CLAUDE.md` 에 "조건부 UPDATE 뒤 인메모리 동기화" 를 규약으로 적었다.
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Aug 28, 2026
이어하기(Team-StackUp#180)가 terminal 가드의 전제를 깼다. 종료 세션에 늦게 도착한 콜백은 `isTerminal()` 로 드롭되지만, **재개된 세션은 IN_PROGRESS 라 그대로 통과한다.** 경합: 1. 꼬리질문 요청 → placeholder("(생성 중)") 생성 2. 사용자 중단 → INTERRUPTED 3. 사용자 이어하기 → 복구가 placeholder 를 FAILED 로 확정하고 다음 일반질문 발행 4. **뒤늦게 AI 콜백 도착** → 세션이 IN_PROGRESS 라 terminal 가드를 통과 → `completeFollowup` 이 FAILED placeholder 를 덮어써 질문으로 되살림 결과적으로 살아있는 질문이 두 개가 되고(실패로 표시됐던 꼬리질문 + 복구가 발행한 일반질문), TTS 도 그 자리에 붙는다. `applyFollowup` 에서 placeholder 가 이미 FAILED 면 그 턴은 지나간 것으로 보고 드롭한다. POOL 경로는 `countBySessionId > 0` 로 이미 멱등이라 같은 문제가 없다(재개 시 풀 재요청이 있어도 중복 저장되지 않는다). 테스트: `apply_followupOnFailedPlaceholder_isDropped` — 실패 상태·문구가 유지되고 save 가 호출되지 않는지 확인.
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Aug 28, 2026
점검 중 발견: `useSessionLifecycle` 의 `interrupt` 뮤테이션이 **어디서도 호출되지 않는다.** 라이브 화면의 유일한 이탈 경로는 '종료'(COMPLETED, 되돌릴 수 없음, 즉시 피드백)뿐이었다. 그 결과 어제 만든 이어하기(Team-StackUp#180)가 사실상 도달 불가였다. INTERRUPTED 는 스위퍼가 **답변이 하나도 없는** 방치 세션에만 붙이기 때문이다(`hasAnswer ? COMPLETED : INTERRUPTED`). 즉 '이어하기' 가 닿는 건 아무것도 답하지 않고 떠난 경우뿐 — 정작 이어하고 싶은 "2개 답하고 자리를 비운" 경우는 COMPLETED 로 굳어 피드백까지 만들어진다. - 헤더에 '잠시 중단' 추가 → 확인 후 `interrupt` → INTERRUPTED - 그 직후 화면이 종료 패널로 바뀌고, 거기 이미 있는 '이어서 진행하기'(Team-StackUp#180)로 바로 복귀 가능 - 확인 문구에서 종료와의 차이를 분명히 한다(대화 보존 / 피드백 미생성 / 나중에 재진입) 테스트 `InterviewStage.pause.test.tsx` (3) — 확인 없이는 중단되지 않음, '계속 진행' 시 미실행, 중단과 종료가 서로 다른 콜백을 부름(다이얼로그 확인 버튼과 헤더 버튼의 이름이 같아 다이얼로그 범위로 한정해 조회).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
변경 사항
감사 백로그 B-5. 마지막 남은 항목이다.
PATCH /api/sessions/{id}/resume로 INTERRUPTED 세션을 다시 진행 가능한 상태로 되돌린다.완료·취소 세션은 이어할 수 없다(422). 완료는 피드백이 이미 나갔고, 취소는 시작한 적이 없다 — 둘 다 기존
/retry로 새 세션을 만드는 게 맞다.핵심은 상태 전이가 아니라 끊긴 턴 복구다
INTERRUPTED → IN_PROGRESS만 열면 되는 줄 알았는데 아니었다. 중단은 보통 턴 한가운데에서 일어나고(탭 종료·네트워크 끊김·수동 중단), 그동안 도착한 콜백은isTerminal()가드가 전부 드롭했다. 상태만 되돌리면 사용자는 답할 질문이 없거나 "(생성 중)" 에 멈춘 화면을 본다.SessionResumeService.recoverTurn이 마지막 메시지로 분기한다:"(생성 중)"placeholderfailFollowup+ 다음 일반질문SelfIntroAnsweredEvent재발행POOL_EXHAUSTED로 세션이 그대로 끝남자기소개 분기가 특히 중요하다. 자기소개만 답하고 끊긴 경우 질문 풀 생성 요청이 유실됐을 수 있는데, 이때
advanceToNextGeneral로 넘기면 풀이 비어 있어 세션이 즉시 종료된다.시간 한도 —
resumed_at(V27)max_duration_minutes를started_at기준으로 재고 있어서, 한참 뒤에 재개하면 스위퍼가 즉시 다시 중단시킨다. 어제 중단한 면접을 오늘 이어가면 재개하자마자 끝난다.resumed_at을 추가하고 스위퍼가durationAnchor()(=resumedAt ?? startedAt) 기준으로 재도록 했다. 이어하기마다 그 자리(sitting)의 시간이 새로 시작되고,started_at은 '처음 시작한 시각'으로 보존된다(히스토리 표시용).트레이드오프: 이어하기를 반복하면 총 시간이 늘어난다. 대안은 누적 활동시간 추적(컬럼 추가 + 모든 종료 경로에서 갱신)인데, 연습 도구에서 총 시간을 엄격히 제한할 이유가 약해서 단순한 쪽을 골랐다. 필요하면 나중에 바꿀 수 있다.
전이
resumeIfInterrupted조건부 UPDATE 로 전이를 차지하고ended_at을 지운다. 다른 전이(startIfReady/cancelIfReady/finishIfInProgress)와 같은 패턴이라 중복 요청(더블클릭·재전송) 중 하나만 1을 받는다.프론트
테스트
SessionResumeServiceTest신설 (8) — 완료 세션 거부, 타인 세션 거부, 전이 경합, 복구 분기 5종(정상 질문 무개입 / placeholder 실패 처리 / 답변 후 다음 질문 / 자기소개+풀 없음 → 재요청 / 자기소개+풀 있음 → 다음 질문)SessionEndedPanel.test.tsx+2 — 중단 세션의 이어하기 호출, 완료 세션엔 미노출SessionCard.test.tsx— CTA 문구 갱신backend
test통과(ArchUnit 포함) · frontend vitest 113/113 · eslint 0 · tsc · build.영향 범위
resumed_at컬럼 추가만. 기존 행은 NULL = 기존과 동일 동작)PATCH /api/sessions/{id}/resume)started_at→COALESCE(resumed_at, started_at). 재개한 적 없는 세션은 그대로다리뷰어 체크포인트
INTERRUPTED가isTerminal()에 남아 있다 — 재개하면 IN_PROGRESS 라 가드를 정상 통과하므로 그대로 뒀다. 중단 상태에서 늦게 오는 콜백은 여전히 드롭되는 게 맞다