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
5 changes: 5 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` + 다음 일반질문 /
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 + ")");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

// ── 턴 복구 ───────────────────────────────────────────────────────────────

// 답할 질문이 그대로 남아 있으면 건드릴 게 없다.
Expand Down Expand Up @@ -186,17 +206,23 @@ void resume_advancesWhenSelfIntroAnsweredAndPoolExists() {

// ── fixtures ──────────────────────────────────────────────────────────────

/** 소유권·전이·재조회까지 통과해 복구 단계로 들어가는 세션. */
/**
* 소유권·전이를 통과해 복구 단계로 들어가는 세션.
*
* <p>**같은 인스턴스 하나만 쓴다.** 예전엔 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) {
Expand Down
Loading