Skip to content

fix: 같은 면접 기록을 다시 받아도 질문·답변과 PK를 바꾸지 않게 함(N-3) - #99

Merged
kimwuju merged 2 commits into
mainfrom
fix/interview-result-idempotent
Oct 15, 2026
Merged

kimwuju merged 2 commits into
mainfrom
fix/interview-result-idempotent

Conversation

@kimwuju

@kimwuju kimwuju commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

개요

A-1 N-3 "면접 결과 저장의 멱등성"입니다. main(bd6663e) 기준입니다. 스키마 변경은 없습니다.

  • 문제
    • POST /api/interviews/result가 요청을 받을 때마다 질문·답변을 지우고 다시 넣었습니다.
    • Chat N-2가 들어가면, 저장은 성공했는데 응답을 받지 못한 채팅 서버가 같은 기록을 다시 보냅니다. 그때마다 질문 PK가 바뀌고 채점·음성 분석 준비가 다시 돕니다.
    • 동시에 두 번 오면 질문이 두 벌 들어갈 수도 있었습니다.
  • 해결: 진행 중인 면접만 종료 상태로 바꾸는 조건부 갱신 한 번으로 처음 저장인지 판정합니다.

변경

1. 처음 저장 판정 (InterviewRepository.claimResultSave)

  • update interview set status = :status where interview_id = :id and status = 'IN_PROGRESS'의 갱신 건수로 판정합니다.
  • 면접을 종료 상태로 바꾸는 곳은 기록 저장뿐이고, 질문·답변과 같은 트랜잭션입니다. 그래서 종료 상태 = 기록 저장 완료입니다. 그래서 별도 표식이나 스키마 변경을 추가하지 않았습니다.
  • 동시에 온 요청은 앞 요청의 행 잠금에서 기다립니다. 앞 요청이 커밋되면 바뀐 상태를 보고 0을 받고, 앞 요청이 롤백되면 뒤 요청이 차지합니다(Postgres READ COMMITTED에서 조건 재평가).

2. 저장 (InterviewService.saveInterview)

  • 이미 저장한 면접이면 질문·답변을 지우거나 넣지 않습니다. 내용이 달라도 처음 저장한 것을 둡니다. 로그에는 면접 id, 받은 상태, 받은 문항 수만 info로 남깁니다.
  • 처음 저장이면 지금과 같이 저장합니다. 질문·답변·종료 상태가 한 트랜잭션이라, 중간에 실패하면 모두 롤백되고 면접은 진행 중으로 남습니다.
  • 상태가 IN_PROGRESS로 와도 COMPLETED로 저장합니다. 진행 중으로 두면 다시 온 기록을 처음 것으로 보고 또 저장합니다(기존 주석의 "기록을 넘겼다는 것 자체가 면접이 끝났다는 뜻"과 같은 판단).

3. 후속 작업 (ChatInterviewResultService.handleResult)

  • 기록을 다시 받아도 onTranscriptSaved(채점·음성 분석 준비)는 지금처럼 부릅니다(리뷰 반영). 이 메서드는 종류마다 한 번만 요청하는 멱등 동작입니다. 있는 대기 행은 다시 요청하지 않고 빠진 행만 채웁니다(FeedbackDispatchServiceTest의 "채팅 서버가 기록을 다시 보내도 두 번 요청하지 않는다").
  • 그래서 처음 저장이 커밋된 직후 서버가 내려갔거나 준비 중 DB 오류가 났어도, 다시 받은 기록으로 대기 행이 채워집니다. 스윕은 있는 대기 행만 훑으므로 이 길이 아니면 복구되지 않습니다.
  • ChatInterviewResultService는 main과 같습니다(주석만 추가).

외부에서 보이는 동작 변화

요청 이전 이후
처음 받은 기록 저장, 200 같음
같은 기록 재전송 지우고 다시 넣음(질문 PK 변경) 기록은 그대로, 빠진 채점 준비만 채움, 200
저장된 면접에 다른 내용 재전송 새 내용으로 덮음 처음 내용 유지, 200
동시 재전송 경합에 따라 중복 가능 한 번만 저장, 모두 200
저장 중 실패 후 재전송 — 처음부터 저장(부분 저장 없음)
없는 면접 404 같음

배포 순서 (중요)

  • Chat N-2와 같은 날 배포합니다. 이 PR 없이 N-2 재전송이 먼저 켜지면 질문 PK가 바뀝니다. 가능하면 이 PR을 먼저 배포하거나, Chat 재전송 활성화를 이 배포 뒤로 둡니다.
  • 이 PR만 먼저 배포해도 안전합니다. 재전송이 없으면 동작이 지금과 같습니다.

테스트

  • ./gradlew test 전체 640개가 통과했습니다.
  • InterviewResultIdempotencyTest(신규, @SpringBootTest, 실제 DB)
    • 순차 재전송(내용이 다른 것 포함)에도 질문 PK·생성 시각·답변이 그대로입니다. 채점 준비는 받을 때마다 부릅니다.
    • 처음 저장 뒤 채점 준비가 실패하면, 다시 받은 기록으로 준비를 다시 부르고 기록은 바꾸지 않습니다.
    • 3건을 동시에 보내도 질문·답변이 한 벌만 남고, 모든 요청이 예외 없이 끝납니다.
    • 답변 NOT NULL 위반으로 저장 중에 실패시킵니다. 그러면 질문까지 롤백되고 면접은 진행 중으로 남습니다. 이어서 다시 보낸 기록은 정상 저장됩니다.
  • InterviewServiceSaveResultTest: 이미 저장한 면접이면 지우기·넣기·면접 저장을 하지 않는지, IN_PROGRESS로 와도 COMPLETED로 차지하는지 확인합니다.
  • 기존 테스트는 목 설정만 더했습니다(claimResultSave → 1). 단언문은 바꾸지 않았습니다.

참고

  • 인증, 대상 확인(404), 접근 검증은 그대로입니다.
  • 명세의 "후속 작업 재실행 없이"는 "같은 요청을 두 번 보내지 않는다"로 읽었습니다. 채점 준비 호출은 멱등이라 다시 불러도 중복 요청이 없고, 빠진 준비를 복구하는 유일한 길이라 남겼습니다.

🤖 Generated with Claude Code

POST /api/interviews/result는 받을 때마다 질문·답변을 지우고 다시 넣었다. 채팅 서버가 저장 성공 후
응답을 받지 못해 같은 기록을 다시 보내면(Chat N-2) 질문 PK가 바뀌고 채점·음성 분석 준비가 다시 돌았다.

- 진행 중인 면접만 종료 상태로 바꾸는 조건부 갱신(claimResultSave)으로 처음 저장을 판정한다.
  동시에 온 요청은 행 잠금에서 기다렸다가 0을 받는다. 앞 요청이 롤백되면 뒤 요청이 차지한다
- 이미 저장한 면접이면 질문·답변을 건드리지 않고 200을 돌려준다. 내용이 달라도 처음 것을 둔다
- 처음 저장일 때만 채점·음성 분석 준비(onTranscriptSaved)를 부른다
- 상태가 IN_PROGRESS로 와도 COMPLETED로 저장한다. 진행 중으로 두면 다시 온 기록을 처음 것으로 본다

면접을 종료 상태로 바꾸는 곳은 기록 저장뿐이고 질문·답변과 한 트랜잭션이라, 종료 상태 = 저장 완료다.
스키마 변경 없음. Chat N-2와 같은 날 배포한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@danjee2009 danjee2009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검토 범위: 면접 기록의 최초 저장 판정, 질문·답변 트랜잭션과 재전송 처리를 확인했습니다. PR 본문에 적힌 테스트 통과는 확인했으며, GitHub 테스트 체크는 검토 시점에 진행 중이었습니다. 현재 코드에서 추가로 지적할 실질적 문제는 찾지 못했습니다.

@2-dayeon

Copy link
Copy Markdown
Contributor

#99 — [P2] 재전송을 건너뛰면서 누락된 후속 작업도 복구하지 못합니다.
ChatInterviewResultService.java:40
기록 저장이 커밋된 직후 서버가 종료되거나, onTranscriptSaved()가 대기 행을 만들기 전에 DB 오류가 나면 채점·음성 분석 작업이 생성되지 않습니다. 이후 Chat이 재전송해도 saveInterview() == false라 바로 반환합니다. 스윕도 이미 생성된 dispatch 행만 조회하므로 복구하지 못합니다.
기록 재저장은 막되 재전송에서도 누락된 dispatch를 생성하거나, dispatch 생성까지 기록 저장과 같은 트랜잭션에 넣어야 합니다. 외부 분석 호출은 계속 트랜잭션 밖에서 수행하면 됩니다.

재전송을 건너뛰면서 채점 준비(onTranscriptSaved)도 부르지 않아, 처음 저장이 커밋된 직후 서버가 내려가거나
준비 중 DB 오류가 나면 대기 행이 끝내 생기지 않았다. 스윕은 있는 대기 행만 훑어 복구하지 못한다(PR #99 리뷰).

- 기록은 다시 저장하지 않되, 받을 때마다 onTranscriptSaved를 부른다. 종류마다 한 번만 요청하는 멱등 동작이라
  있는 대기 행은 다시 요청하지 않고 빠진 행만 채운다(FeedbackDispatchServiceTest가 확인)
- 쓰이지 않게 된 saveInterview의 boolean 반환을 걷어내 ChatInterviewResultService를 main과 같은 흐름으로 되돌린다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kimwuju
kimwuju merged commit 718ec88 into main Oct 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants