Skip to content

fix: 테일러 콜백이 접수 응답보다 먼저 와도 결과를 잃지 않게 함(P-2) - #98

Merged
kimwuju merged 6 commits into
mainfrom
fix/tailor-callback-early-arrival
Oct 15, 2026
Merged

kimwuju merged 6 commits into
mainfrom
fix/tailor-callback-early-arrival

Conversation

@kimwuju

@kimwuju kimwuju commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

개요

A-1 P-2 "테일러 콜백 선도착 시 결과 보존"입니다. main(bd6663e) 기준입니다. 스키마와 Analysis 계약은 바뀌지 않습니다.

  • 문제
    • 준비 건(question_tailor)을 분석 서버의 접수 응답(202)을 받은 뒤에야 저장했습니다. 그래서 콜백이 먼저 오면 jobId로도 찾지 못했습니다.
    • interviewId로 찾으면 같은 면접의 이전 시도에 결과가 붙기도 했습니다.
    • 늦게 온 접수 응답이 엔티티를 통째로 저장해, 이미 들어온 결과를 PENDING으로 되돌렸습니다. 결국 시간 초과로 원질문 폴백(1:1)이나 준비 실패(다대일)로 끝났습니다.
  • 해결: 분석 서버를 부르기 전에 준비 건을 커밋합니다. 콜백 주소에 그 id(tailorId)를 실어 보내고, 콜백은 이 id로 대상을 찾습니다.

변경

1. 요청 (QuestionTailorService.startTailor)

  • 분석 서버를 부르기 전에 준비 건을 PENDING과 원질문으로 저장합니다. 이 메서드는 트랜잭션 밖에서 불리므로 저장이 곧바로 커밋되고, 다른 트랜잭션의 콜백 처리에서도 보입니다.
  • callbackUrl에 ?tailorId={id}를 붙입니다. UriComponentsBuilder를 써서 주소에 이미 붙은 쿼리는 그대로 둡니다.
  • 접수 응답은 jobId만 채웁니다. 조건부 갱신(recordJobId: jobId가 비어 있을 때만)이라 상태를 건드리지 않습니다.
  • 분석 서버 요청이 실패하면 접수되지 않은 새 건을 지웁니다(discardUnaccepted: jobId가 없고 PENDING일 때만). 그사이 콜백이 와서 결과가 들어갔으면 남깁니다.
  • 사이클을 기다리던 건을 이어가다 요청이 실패하면 조건부 차지(claimExpiration)로 닫습니다. 이 차지는 아직 PENDING·WAITING인 건만 얻을 수 있습니다. 그사이 먼저 온 콜백이 확정·전달한 결과는 실패로 덮지 않습니다(리뷰 반영).

2. 콜백 (QuestionTailorController, handleCallback·handleMultiCallback)

  • 컨트롤러가 tailorId 쿼리(선택)를 읽어 넘깁니다. 본문 형식은 같습니다.
  • tailorId로 대상을 찾습니다. 함께 온 interviewId·jobId·방식(SOLO/MULTI)이 그 건과 어긋나면 반영하지 않고 warn을 남깁니다. tailorId가 틀렸을 때 다른 건으로 우회하지 않습니다.
  • tailorId가 없으면 배포 전에 나간 요청으로 봅니다. 이때는 jobId로만 찾고, 찾은 건에도 위와 같은 면접·방식 검사를 합니다. 면접 id로는 잇지 않습니다 — 같은 면접의 새 시도(아직 jobId가 빈 건)에 이전 요청의 결과가 들어갈 수 있습니다(리뷰 반영). 배포 전에 나간 요청 가운데 접수 응답보다 콜백이 먼저 온 건은 지금처럼 시간 초과로 처리됩니다.
  • 이미 확정된 건(PENDING이 아닌 건)은 콜백 결과를 바꾸지 않습니다. 채팅 서버로 넘기지 못한 건이면 전달만 다시 합니다.
  • 콜백 결과는 settleCallback 한 문장으로 확정합니다. 아직 PENDING일 때만 상태·tailored·질문·오류·jobId(비어 있을 때만)를 함께 고칩니다. 실패 처리의 claimExpiration도 조건부 갱신이라 둘 중 먼저 차지한 쪽만 쓰고, 진 쪽은 아무것도 쓰지 않습니다(리뷰 반영). 콜백 경로에서 엔티티를 통째로 저장하던 부분은 없앴습니다. 채팅 서버 전달은 확정이 커밋된 뒤에 합니다.
  • 결과는 조회한 엔티티가 아니라 복사본(toBuilder)에 계산합니다. open-in-view가 켜져 있어 HTTP 콜백 동안 조회한 엔티티가 관리 상태로 남습니다. 그 엔티티를 고치면 조건부 확정 직전에 변경이 먼저 flush되어, 확정이 0건으로 끝나고 채팅 서버 전달이 빠지기 때문입니다(리뷰 반영).
  • 대상이 없거나 식별자가 맞지 않으면 지금처럼 warn을 남기고 200을 돌려줍니다. 결과 본문은 로그에 남기지 않습니다.

외부에서 보이는 동작 변화

상황 이전 이후
접수 응답 → 콜백 정상 같음
콜백 → 접수 응답 결과 유실, 시간 초과 폴백·실패 결과 저장, 늦은 접수 응답 뒤에도 SUCCEEDED 유지
같은 콜백 재수신 다시 반영 확정된 건은 그대로
시간 초과로 폴백한 뒤 늦은 콜백 재작성본으로 덮음(채팅 서버는 원질문으로 열린 상태) 그대로 둠
없는 tailorId, 식별자 불일치 — 저장하지 않고 warn
분석 서버 요청 실패 준비 건 없음 같음(만든 건을 지움)

Analysis 변경은 없습니다. Analysis는 callbackUrl을 불투명한 주소로 씁니다. 사이클 콜백도 이미 cycleId·requestNo 쿼리를 씁니다.

테스트

  • ./gradlew test 전체 652개가 통과했습니다.
  • QuestionTailorEarlyCallbackTest(신규, @SpringBootTest): 실제 DB에서 확인합니다. 콜백은 다른 스레드(별도 트랜잭션)에서 처리합니다.
    • 실패 처리가 먼저 차지하면 콜백 확정은 0건이고, 콜백이 먼저 확정하면 실패 처리가 차지하지 못합니다(두 순서를 교차).
    • 사이클 대기에서 이어가다 콜백이 먼저 확정·전달한 뒤 접수 요청이 실패해도 결과가 SUCCEEDED로 남습니다.
    • MockMvc로 실제 HTTP 콜백(open-in-view 적용)을 보내면 tailorId 경로와 배포 전 jobId 경로 모두 결과를 확정하고 채팅 서버로 넘깁니다. 수정 전 코드에서는 두 경우 모두 실패했습니다.
    • 1:1·다대일 모두, 콜백이 접수 응답보다 먼저 와도 결과가 SUCCEEDED로 남고 채팅 서버로 넘어갑니다.
    • 접수 응답이 먼저 오면 jobId만 남기고 PENDING으로 기다립니다.
    • 분석 서버 요청이 실패하면 준비 건을 남기지 않습니다.
  • QuestionTailorServiceWaitingTest에 2건을 추가했습니다. 이어가다 요청이 실패해도 먼저 확정된 결과는 덮지 않고, 아직 기다리는 건이면 실패로 닫고 알립니다.
  • QuestionTailorServiceCallbackTest에 9건을 추가했습니다. 다른 처리가 먼저 확정했으면 콜백이 결과를 쓰지 않는 경우도 포함합니다.
    • jobId가 아직 없어도 tailorId로 찾습니다.
    • 없는 tailorId면 우회하지 않습니다.
    • 면접·jobId·방식이 어긋나면 반영하지 않습니다.
    • 확정된 건은 바꾸지 않습니다.
    • 배포 전 콜백은 jobId로만 잇고, 못 찾으면 면접으로 잇지 않습니다.
    • 콜백 주소에 쿼리를 보존합니다.
  • 기존 테스트는 handleCallback·handleMultiCallback 호출에 tailorId 자리(null, 배포 전 콜백과 같은 경로)만 더했습니다. 콜백 결과 확인 헬퍼는 save 대신 settleCallback 인자를 보게 바꿨고, 기본 목에 settleCallback → 1을 더했습니다. 알_수_없는_콜백은_저장하지_않는다의 "저장 안 함" 확인도 같은 뜻으로 settleCallback을 보게 바꿨습니다. 단언문을 바꾼 기존 테스트는 jobId로_못_찾으면_interviewId로_매칭한다 하나입니다. 동작을 일부러 바꿔서 jobId로_못_찾으면_면접으로_잇지_않는다로 대체했습니다.

참고

  • PR #97(기업 맞춤 면접)이 같은 QuestionTailorService의 요청 빌더를 고칩니다. 명세대로 이 PR을 먼저 병합하고, #97이 그 위로 충돌을 정리합니다.
  • 배포는 Chat(N-1·N-2·T-3) → API(P-2·N-3) → Web 순서입니다.

🤖 Generated with Claude Code

준비 건을 분석 서버의 접수 응답을 받은 뒤에야 저장해, 콜백이 먼저 오면 대상을 찾지 못하거나
같은 면접의 이전 시도에 결과가 붙었다. 늦게 온 접수 응답이 엔티티를 통째로 저장해 결과를 PENDING으로
되돌리기도 했다.

- 분석 서버를 부르기 전에 준비 건을 저장(커밋)하고, callbackUrl에 ?tailorId={id}를 붙인다(UriComponentsBuilder)
- 콜백 컨트롤러가 tailorId 쿼리를 읽어 넘기고, 콜백은 tailorId로 대상을 찾는다(1:1·N:1)
- 함께 온 interviewId·jobId·방식이 그 건과 어긋나면 반영하지 않는다. tailorId가 틀리면 다른 건으로 우회하지 않는다
- tailorId 없는 콜백(배포 전 요청)은 jobId로 찾고, 없으면 면접의 마지막 건이 그 jobId를 기다릴 때만 잇는다
- 접수 응답은 jobId가 비어 있을 때만 채우는 조건부 갱신으로 바꾼다. 상태를 건드리지 않는다
- 이미 확정된 건(재전송, 시간 초과 폴백)은 콜백 결과를 바꾸지 않는다. 넘기지 못한 건이면 전달만 다시 한다
- 분석 서버 요청이 실패하면 접수되지 않은 새 건은 지운다. 그사이 콜백이 와서 결과가 들어갔으면 남긴다

스키마 변경 없음. Analysis 변경 없음(callbackUrl을 불투명한 주소로 쓴다).

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.

검토 범위: 준비 건 생성·콜백 상관관계·늦은 접수 응답 처리와 관련 테스트를 확인했습니다. GitHub 테스트 체크는 검토 시점에 진행 중이었습니다.

배포 전에 나간 요청의 콜백(tailorId 없음)을 jobId로 못 찾으면 면접의 마지막 PENDING 건에 이었다.
그 건이 배포 뒤 새 시도라 아직 jobId가 비어 있으면, 이전 요청의 질문이 새 시도에 저장될 수 있었다(PR #98 리뷰).
면접 id는 시도를 가리지 못하므로 그 경로를 없앤다.

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

Copy link
Copy Markdown
Contributor

findTarget()의 tailorId == null 분기는 jobId로 찾은 엔티티를 즉시 반환해서, 아래의 interviewId·mode 검증을 건너뜁니다. PR 설명의 “함께 온 면접·방식이 어긋나면 반영하지 않는다”는 보장이 구형 콜백에는 적용되지 않습니다.

tailorId 없는 콜백은 jobId로 찾은 건을 곧바로 돌려줘, tailorId로 찾은 콜백이 거치는 면접·방식 검사를 건너뛰었다
(PR #98 리뷰). 두 경로가 같은 검사를 거치도록 찾는 부분만 나눈다.

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

Copy link
Copy Markdown
Contributor

#98 — [P2] 대기에서 이어가는 경로는 선도착한 성공 결과를 다시 실패로 덮습니다.
QuestionTailorService.java:198
WAITING 준비를 이어가다가 성공 콜백이 먼저 도착하고, 뒤이어 접수 요청이 읽기 타임아웃으로 끝나는 경우입니다. 콜백은 결과를 저장하지만 startTailor()가 예외를 다시 던지고, resumeWaiting()의 catch가 오래된 엔티티를 FAILED, questions=null로 저장합니다. 이미 채팅 서버로 전달한 결과까지 DB에서 사라질 수 있습니다.
실패 처리를 현재도 PENDING일 때만 가능한 조건부 갱신으로 바꾸고, 이미 확정된 결과는 보존해야 합니다.

사이클을 기다리던 준비를 이어가다 접수 요청이 실패하면 resumeWaiting이 읽어 둔 엔티티를 FAILED·questions=null로
통째로 저장했다. 그사이 콜백이 먼저 와서 결과를 확정·전달했으면 그 결과가 DB에서 사라졌다(PR #98 리뷰).

시간 초과 처리와 같은 조건부 차지(claimExpiration: PENDING·WAITING일 때만)를 얻은 경우에만 실패로 닫고 알린다.
차지하지 못하면 확정된 결과를 그대로 둔다.

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

Copy link
Copy Markdown
Contributor

[P2] 콜백과 실패 처리가 같은 준비 건을 서로 덮어쓸 수 있습니다. — QuestionTailorService.java:661
closeWaiting()의 claimExpiration()과 최종 save()는 별도 트랜잭션이고, 콜백도 PENDING을 읽은 뒤 일반 save()로 결과를 저장합니다. 따라서 다음 순서가 가능합니다.

  1. 콜백이 PENDING을 읽고 alreadySettled() 통과
  2. 요청 예외 처리가 claimExpiration()으로 FAILED 차지
  3. 콜백이 성공 결과를 저장하고 채팅 서버에 전달
  4. closeWaiting()이 오래된 엔티티를 저장해 질문을 다시 삭제
    성공·실패 양쪽의 상태 확인과 결과 저장을 원자적으로 처리해야 합니다. 두 경로에서 같은 행 잠금을 사용하거나, 현재 상태를 조건으로 결과까지 함께 갱신하는 방식이 필요합니다. 외부 채팅 호출은 커밋 뒤에 두면 됩니다.
    추가된 테스트는 claimExpiration=0 분기만 확인하므로 위 경합은 검증하지 못합니다. 실제 DB에서 두 처리를 교차시키는 테스트가 필요합니다.

콜백은 PENDING을 읽고 확인한 뒤 엔티티를 따로 저장했다. 그사이 실패 처리(시간 초과, 사이클 대기에서 이어가기
실패)가 claimExpiration으로 먼저 차지하면 콜백이 그 위에 결과를 덮거나, 실패 처리가 들고 있던 낡은 엔티티로
콜백 결과를 다시 지울 수 있었다(PR #98 리뷰).

- settleCallback: 아직 PENDING일 때만 상태·tailored·questions·오류·jobId(빈 경우만)를 한 문장으로 고친다.
  실패 처리의 claimExpiration과 둘 다 조건부 갱신이라 한쪽만 이긴다. 진 콜백은 아무것도 쓰지 않는다
- 콜백 경로의 엔티티 통째 저장을 없앤다. 채팅 서버 전달은 확정이 커밋된 뒤에 한다
- 실제 DB에서 두 순서를 교차시키는 테스트와, 이어가다 콜백이 먼저 확정한 뒤 요청이 실패하는 시나리오 테스트를 추가한다

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

Copy link
Copy Markdown
Contributor

[P2] 조회한 엔티티를 수정하면 조건부 UPDATE 전에 결과가 저장될 수 있습니다. — QuestionTailorRepository.java:133
콜백은 조회한 tailor에 applySuccess()로 상태·질문을 변경한 뒤 settleCallback()을 호출합니다. 그런데 flushAutomatically=true는 조건부 쿼리 실행 전에 엔티티 변경사항을 flush합니다. Spring 문서
현재 설정은 OSIV를 끄지 않았습니다. 특히 구형 콜백의 findByJobId() 경로에서는 조회 엔티티가 관리 상태로 유지될 수 있어 다음 문제가 발생합니다.

  1. flush가 먼저 SUCCEEDED를 저장
  2. 조건부 UPDATE의 WHERE status=PENDING이 불일치해 0 반환
  3. settle()이 실패로 판단해 채팅 전달·준비 완료 알림 생략
    동시 실패 처리 결과를 조건 없이 덮을 가능성도 다시 생깁니다.
    조회 엔티티를 직접 변경하지 않고 별도 DTO나 복사본에 결과를 계산한 뒤 조건부 쿼리에 전달하도록 바꾸는 게 안전합니다. OSIV가 적용되는 HTTP 콜백 테스트도 추가해야 합니다.

open-in-view가 켜져 있어 HTTP 콜백 동안 조회한 준비 건이 관리 상태로 남는다. 그 엔티티에 결과를 적은 뒤
settleCallback을 부르면 flushAutomatically가 쿼리 전에 변경을 먼저 flush해, SUCCEEDED가 조건 없이 저장되고
WHERE status = PENDING이 어긋나 확정이 0건으로 끝났다. 채팅 서버 전달과 준비 완료 알림이 빠졌다(PR #98 리뷰).

- 콜백(1:1·N:1)은 조회한 엔티티를 고치지 않고 toBuilder 복사본에 결과를 계산해 조건부 확정에 넘긴다
- MockMvc로 실제 HTTP 콜백(open-in-view 적용)을 보내는 테스트를 추가한다. tailorId 경로와 배포 전 jobId 경로 모두
  수정 전 코드에서 실패하고 수정 후 통과한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kimwuju
kimwuju merged commit cef17e2 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