diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 7036fbd..d6db46c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -471,6 +471,11 @@ docker compose up -d - **시간 한도 기준을 `durationAnchor()`(= resumedAt ?? startedAt) 로 바꿨다.** startedAt 기준 그대로면 한참 뒤 재개했을 때 스위퍼가 즉시 다시 중단시킨다. startedAt 은 '처음 시작한 시각' 으로 보존된다. 이어하기를 반복하면 총 시간이 늘어나지만, 연습 도구라 허용하는 트레이드오프. + - **조건부 UPDATE 뒤에는 인메모리 상태를 반드시 맞춘다**(`session.resume(now)`). 벌크 + UPDATE 는 영속성 컨텍스트를 갱신하지 않아서, 같은 트랜잭션에서 `findById` 해도 1차 캐시의 + 낡은 엔티티(INTERRUPTED)가 돌아온다. 그러면 `advanceToNextGeneral` 이 상태 검사에서 + 조용히 되돌아가 복구가 통째로 죽고 응답 status 도 INTERRUPTED 로 나간다. + `SessionService.start` 가 `startIfReady` 뒤에 `session.start()` 를 부르는 것과 같은 이유. - **핵심은 전이가 아니라 끊긴 턴 복구다**(`SessionResumeService.recoverTurn`). 중단은 보통 턴 한가운데서 일어나고 그동안 온 콜백은 terminal 가드가 전부 드롭했다. 마지막 메시지로 분기: 정상 질문이면 그대로(답하면 됨) / "(생성 중)" placeholder 면 `failFollowup` + 다음 일반질문 / 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 index 441d07b..4108754 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionResumeService.java @@ -58,17 +58,20 @@ public SessionResult resume(Long userId, Long sessionId) { throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); } // 원자적 재개 전이 — 중복 요청 중 하나만 차지한다(다른 전이와 같은 패턴). - if (sessionRepository.resumeIfInterrupted(sessionId, Instant.now()) == 0) { + Instant now = Instant.now(); + if (sessionRepository.resumeIfInterrupted(sessionId, now) == 0) { throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); } - // 조건부 UPDATE 는 영속성 컨텍스트를 우회하므로 엔티티를 다시 읽는다. - sessionRepository.flush(); - InterviewSession resumed = sessionRepository.findById(sessionId).orElseThrow(); + // 벌크 UPDATE 는 영속성 컨텍스트를 갱신하지 않는다. 다시 findById 해도 1차 캐시의 + // 낡은 엔티티(INTERRUPTED)가 돌아오므로, 같은 트랜잭션의 advanceToNextGeneral 이 + // 상태 검사에서 조용히 되돌아가고 응답 status 도 INTERRUPTED 로 나간다. + // SessionService.start 와 같은 방식으로 인메모리 상태를 맞춘다. + session.resume(now); - recoverTurn(userId, resumed); - publishState(resumed); + recoverTurn(userId, session); + publishState(session); log.info("session resumed. sessionId={}, userId={}", sessionId, userId); - return SessionResult.of(resumed, contextDocumentIds(sessionId)); + return SessionResult.of(session, contextDocumentIds(sessionId)); } /** 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 d622d76..3453ac3 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 @@ -193,6 +193,15 @@ public void start() { this.startedAt = Instant.now(); } + // 중단 → 진행 재개. 조건부 UPDATE(resumeIfInterrupted) 로 DB 는 이미 바뀌었고, + // 벌크 UPDATE 는 영속성 컨텍스트를 갱신하지 않으므로 인메모리 상태를 맞춘다 + // (start() 뒤에 session.start() 를 부르는 것과 같은 이유). now 는 UPDATE 와 같은 값을 넘긴다. + public void resume(Instant now) { + this.status = SessionStatus.IN_PROGRESS; + this.resumedAt = now; + this.endedAt = null; + } + public void end() { if (status != SessionStatus.IN_PROGRESS) { throw new IllegalStateException("session is not IN_PROGRESS to end (current=" + status + ")"); 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 index b9e59bb..cbe0079 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionResumeServiceTest.java @@ -91,6 +91,26 @@ void resume_failsWhenTransitionIsClaimedByAnotherRequest() { assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); } + // 벌크 UPDATE 는 영속성 컨텍스트를 갱신하지 않는다. 인메모리까지 맞추지 않으면 + // 같은 트랜잭션의 복구 로직과 응답이 모두 INTERRUPTED 를 보고 조용히 아무것도 안 한다. + @Test + void resume_syncsInMemoryStateAfterBulkUpdate() { + InterviewSession session = resumable(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 3, "질문"); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(question)); + + SessionResult result = service.resume(1L, 10L); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.IN_PROGRESS); + assertThat(session.getResumedAt()).isNotNull(); + // 중단 시 찍힌 종료 시각은 지워져야 한다 — 남아 있으면 '종료된 면접'으로 보인다. + assertThat(session.getEndedAt()).isNull(); + // 시간 한도는 재개 시각부터 다시 잰다. + assertThat(session.durationAnchor()).isEqualTo(session.getResumedAt()); + assertThat(result.status()).isEqualTo(SessionStatus.IN_PROGRESS); + } + // ── 턴 복구 ─────────────────────────────────────────────────────────────── // 답할 질문이 그대로 남아 있으면 건드릴 게 없다. @@ -186,17 +206,23 @@ void resume_advancesWhenSelfIntroAnsweredAndPoolExists() { // ── fixtures ────────────────────────────────────────────────────────────── - /** 소유권·전이·재조회까지 통과해 복구 단계로 들어가는 세션. */ + /** + * 소유권·전이를 통과해 복구 단계로 들어가는 세션. + * + *
**같은 인스턴스 하나만 쓴다.** 예전엔 findById 가 별도의 IN_PROGRESS 인스턴스를 + * 돌려주도록 목을 걸었는데, 그게 실제 버그를 가렸다 — 벌크 UPDATE 는 영속성 컨텍스트를 + * 갱신하지 않으므로 운영에서는 낡은 INTERRUPTED 엔티티가 돌아오고, 그 결과 + * advanceToNextGeneral 이 상태 검사에서 조용히 되돌아가 복구가 전혀 일어나지 않았다. + * 이제 서비스가 `session.resume()` 으로 인메모리 상태를 맞춰야만 테스트가 통과한다. + */ private InterviewSession resumable(Long id) { - InterviewSession interrupted = sessionFixture(id, SessionStatus.INTERRUPTED); - InterviewSession afterResume = sessionFixture(id, SessionStatus.IN_PROGRESS); + InterviewSession session = sessionFixture(id, SessionStatus.INTERRUPTED); when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(id, 1L)) - .thenReturn(Optional.of(interrupted)); + .thenReturn(Optional.of(session)); 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; + return session; } private InterviewSession sessionFixture(Long id, SessionStatus status) {