얼쑤에서 사용자가 연주 녹음을 보내면 앱은 분석 접수를 확인하고 결과를 기다립니다. 분석 서버에 일시적으로 연결하지 못해도 원본 파일은 서버에 남습니다. 저는 사용자가 그 녹음으로 분석을 다시 요청할 수 있게 했습니다.
이때 같은 세션 안에 시도가 여럿 생깁니다. 첫 시도가 시간 초과로 끝난 뒤 두 번째 시도를 시작했는데 첫 시도의 성공 응답이 도착할 수 있습니다. 두 결과는 세션 ID가 같습니다. 그대로 저장하면 앱이 두 번째 시도를 기다리는 동안 첫 시도의 점수를 보여줄 수 있습니다.
같은 세션에 새 시도 추가
어떤 실패가 났는지부터 구분했습니다. 제한 시간 초과나 서버 오류라면 원본 녹음으로 다시 분석할 수 있습니다. 소리가 없거나, 파일이 너무 짧거나, 디코딩할 수 없으면 다시 연주해 새 녹음을 받습니다.
재시도할 수 있는 세션은 기존 녹음과 세션 ID를 유지합니다. 새 분석을 시작할 때는 DB에서 상태를 FAILED에서 PENDING으로 바꾸고, 시도 번호를 하나 늘리면서 시작 시각을 기록합니다. 이 세 변경은 한 UPDATE에 들어갑니다. 갱신 조건에는 실패 사유와 녹음 존재 여부에 더해, 요청이 처음 읽었던 시도 번호도 넣었습니다.
두 요청이 동시에 같은 실패를 읽으면 먼저 갱신한 쪽이 시도 번호를 바꿉니다. 다른 요청은 자신이 읽었던 번호가 달라져 갱신에 실패합니다. 그 요청이 상태를 다시 읽어 분석이 진행 중임을 확인하면 같은 202 Accepted를 돌려줍니다. 갱신에 성공한 요청만 변경이 커밋된 뒤 백그라운드 실행기에 작업을 넘깁니다.
상태가 다시 FAILED로 돌아와도 시도 번호는 달라져 있습니다. 시도 2의 실패를 읽은 사이 다른 요청이 시도 3을 시작했다가 실패하면, 시도 2를 조건으로 한 갱신은 0행입니다. 다시 읽은 번호로 갱신에 성공해야 시도 4를 시작했다고 답할 수 있습니다. 갱신은 최대 세 번 시도합니다. 끝까지 경합에서 밀렸는데 여전히 재시도할 수 있는 상태라면, 컨트롤러는 Busy 결과를 HTTP 503으로 돌려줍니다.
202를 받은 앱은 상태 조회를 시작하거나 이어갑니다. 처음 요청의 응답이 끊겨 다시 요청했을 때도 이미 진행 중이면 같은 흐름을 탑니다. 요청의 Idempotency-Key는 형식만 검사하고 저장하지 않으므로, 중복 시작을 막는 범위는 현재 진행 중인 시도입니다. 그 시도가 다시 실패하면 같은 키로도 다음 시도를 요청할 수 있습니다. RFC 9110의 202 Accepted · 녹음 업로드 202 이후 앱이 기다리는 것
결과 수신 시 시도 번호 확인
시도 2가 진행 중인데 시도 1의 결과가 늦게 오면, 세션이 PENDING이라는 사실만으로는 그 결과를 걸러낼 수 없습니다. 그래서 분석을 시작할 때 읽은 시도 번호를 결과를 저장할 때까지 들고 갑니다. 성공 결과의 저장 여부를 결정하는 갱신은 핵심 조건만 뽑으면 아래와 같습니다.
UPDATE practice_sessions
SET status = 'DONE'
WHERE id = :session_id
AND status = 'PENDING'
AND analysis_attempt = :attempt
AND analysis_started_at >= :started_after
:started_after는 결과 저장 시점에 서버 시계로 구한 현재 시각에서 제한 시간을 뺀 값입니다. 시도 번호가 같아도 이미 제한 시간을 넘겼다면 갱신은 0행입니다. 실패 결과를 기록할 때도 같은 조건을 씁니다. 성공으로 상태를 바꾼 경우에만 평가와 음표별 결과를 저장하며, 상태 변경과 평가 저장은 같은 트랜잭션에서 처리합니다.
서버가 재시작되면 실행 중이던 분석은 사라지고 DB에 PENDING만 남을 수 있습니다. 그 경우에도 앱이 상태를 조회하면 서버가 DB에 저장된 시작 시각을 확인합니다. 마감이 지난 시도는 조건부 갱신으로 상태를 FAILED로 바꾸고 실패 사유를 TIMEOUT으로 기록합니다. 이렇게 하면 작업이 사라진 뒤에도 상태 조회로 해당 시도를 끝낼 수 있습니다.
재시도 요청도 진행 중인 시도가 만료됐는지 확인합니다. 그사이 상태 조회가 먼저 TIMEOUT을 기록하면 재시도 요청이 실행한 만료 갱신은 0행을 돌려줍니다. 이전에는 이때 처음 읽은 PENDING을 그대로 사용했습니다. 이제 만료 갱신의 결과와 관계없이 세션을 다시 읽습니다. 상태 조회가 먼저 만료시키는 순서를 만든 대역 검사에서 재시도 요청은 시도 3을 시작하고 작업을 한 건 넘겼습니다.
늦은 결과와 장애 복구 검증
MySQL 통합 테스트에서 시도 2가 진행 중인 세션에 시도 1의 성공 결과를 저장하려 하자 조건부 갱신이 0행을 반환했습니다. 세션은 PENDING을 유지했고 평가 행은 0건이었습니다. 현재 시도의 결과도 마감을 넘기면 저장되지 않았고, 제한 시간과 정확히 같은 시점의 결과는 저장됐습니다. 상태 조회 테스트에서는 마감이 지난 PENDING이 FAILED와 TIMEOUT으로 돌아와 다시 시도할 수 있음을 확인했습니다.
실제 HTTP와 MySQL을 연결한 재시도 API 테스트에서는 이미 PENDING인 세션이 202를 반환했습니다. DB에서 읽은 시도 번호는 1로 유지됐고, 실행기 대역에 기록된 새 제출은 0건이었습니다.
별도 MySQL 검사에서는 두 스레드가 출발 신호를 기다렸다가 같은 시도 번호로 갱신을 요청했습니다. 갱신은 한 번만 성공했고, 최종 행은 PENDING의 시도 2였습니다. 저장소 대역으로는 더 좁은 순서를 만들었습니다. 다른 시도가 그사이 시작했다가 실패하면 시도 4를 반환하고 작업을 한 건 넘겼습니다. 다른 시도가 세 번 연달아 끼어들면 결과는 Busy였고 넘긴 작업은 0건이었습니다.
Busy를 검사할 때는 결과 타입만 먼저 추가해 테스트가 컴파일되게 하고, 재시도 로직은 그대로 둔 채 돌렸습니다. 실패 메시지는 Busy를 기대했지만 NotRetryable을 받았다고 보여줬습니다. 여전히 재시도할 수 있는 세션을 거절하는 분기를 고친 뒤 같은 검사가 통과했습니다.
실제 HTTP와 테스트 DB를 사용한 재시도 API 검사에서는 무음으로 실패했거나 완료됐거나 아직 녹음을 올리지 않은 세션이 409를, 다른 회원의 세션이 404를 반환했습니다. 네 세션의 상태와 다른 회원 세션의 시도 번호는 그대로였습니다. 실행기는 같은 스레드에서 세션 ID를 기록하는 대역으로 바꿨으며, 거절된 요청이 남긴 제출 기록은 0건이었습니다.
운영에서는 분석 서비스를 중단한 상태에서 두 음을 합성한 1.45초짜리 AAC 녹음을 올렸습니다. 업로드가 접수된 뒤 2.22초에 FAILED와 SERVER_ERROR를 확인했습니다. 서비스를 복구해 상태가 healthy로 표시된 직후 첫 재시도를 접수했지만, 0.13초 뒤 같은 오류가 확인됐습니다. 이 재실패의 상세 원인은 확인하지 못했습니다. 이어 백엔드에서 분석 서비스에 빈 요청을 보내 422 응답을 받아, 백엔드가 분석 서비스까지 HTTP로 도달하는 것을 확인했습니다.
이후 같은 세션에서 한 번 더 재시도하자 접수 후 약 15.54초에 DONE을 확인했습니다. DB에는 처음 올린 원본 녹음이 남아 있었고, 세 번째 시도의 평가 한 건과 음표 결과 두 건이 저장됐습니다. 분석 로그에도 세 번째 시도의 완료가 기록됐습니다. 이 합성 녹음 한 건에서 파일을 다시 올리지 않고 같은 세션의 분석을 끝까지 복구할 수 있었습니다.

댓글
댓글과 반응은 GitHub 계정으로 로그인해 남길 수 있어요.