얼쑤에서 녹음을 올리면 서버는 파일을 객체 저장소에 쓴 뒤, 세션에 원본의 키를 기록하고 상태를 PENDING으로 바꿉니다. 같은 세션에 업로드 요청이 거의 동시에 오면 두 요청 모두 처음에는 상태를 RECORDING으로 읽을 수 있습니다. DB에서 한 요청만 접수하더라도 저장소에는 두 요청 모두 파일을 쓰는 시점이 생깁니다.
세션마다 고정된 객체 키를 쓴다고 가정해 봤습니다. 요청 A가 먼저 DB 갱신을 마쳐도, 요청 B가 같은 키에 늦게 쓰면 DB가 가리키는 파일은 B의 바이트가 됩니다. B가 갱신에 실패하고 자신이 쓴 파일을 지우는 경우에도 같은 키라 A의 원본이 사라집니다. DB가 접수 순서를 정하는 것과 별도로, 어느 요청이 어느 파일을 소유하는지 저장소에서도 구분해야 했습니다.
DB 접수와 요청별 객체 키
업로드 요청을 받을 때마다 서버에서 UUID를 만들고 recordings/{sessionId}/{uuid}.m4a에 원본을 썼습니다. 같은 세션으로 들어온 파일도 다른 키에 저장됩니다. 쓰기에 성공하면 DB에서 해당 세션이 아직 RECORDING일 때만 원본 키를 저장하고 상태를 바꿉니다. 핵심 조건만 뽑으면 다음과 같습니다.
UPDATE practice_sessions
SET recording_url = :object_key, status = 'PENDING'
WHERE id = :session_id
AND user_id = :user_id
AND status = 'RECORDING'
한 요청이 갱신에 성공하면 바뀐 행은 1개입니다. 늦은 요청은 상태 조건이 달라져 0개를 받습니다. 그 요청은 방금 만든 자기 키를 지운 뒤 세션을 다시 조회합니다. 이미 다른 업로드를 접수했다면 409 Conflict를 돌려주고, 그 사이 만료 처리가 이겼다면 410 Gone을 돌려줍니다. 원본 키를 갱신한 트랜잭션이 커밋된 뒤에만 분석을 시작하므로, 분석이 읽을 파일도 접수된 요청의 원본으로 정해집니다.
응답과 남은 파일 바이트 대조
빌드한 JAR을 임시 MySQL과 실제 ffprobe로 실행하고, 객체 저장소에는 PUT과 DELETE를 기록하는 로컬 HTTP 대역을 붙였습니다. 같은 세션에 내용이 다른 녹음을 두 건 동시에 보냈습니다. 한 건이 202 Accepted, 다른 한 건이 409 Conflict를 받았습니다. 저장소에는 객체 한 개가 남았고, 바이트를 비교하니 202를 받은 요청이 보낸 녹음과 같았습니다.
단위 검사에서는 파일 쓰기와 DB 갱신 사이에 다른 요청이 먼저 성공한 상태를 만들어, 진 요청의 파일만 지워지는지 확인했습니다. 진 요청의 삭제를 일부러 빼면 이 검사가 실패했습니다. HTTP 응답이 여전히 202와 409로 나뉘어도, 저장소의 파일을 확인하지 않으면 두 번째 파일이 남는 문제를 놓칠 수 있습니다.
응답을 놓치면 세션 상태 확인
첫 업로드가 저장된 뒤 응답만 끊기면 앱은 같은 녹음을 다시 보낼 수 있습니다. 같은 성공 응답을 재현하려면 업로드 요청 키를 기록하고 파일 내용도 비교해야 합니다. 우리는 이미 접수한 세션의 재업로드에는 409를 보내고, 앱은 그 세션의 상태를 조회해 접수 여부를 확인하는 계약을 택했습니다.
별도 서버 검사에서도 이미 저장된 세션에 다시 올리면 409가 돌아왔습니다. 이 검사는 서버가 재업로드에 응답하는 범위였고, 앱의 후속 상태 조회는 계약에 정한 흐름입니다.
업로드 마감부터 확인
만료 배치가 먼저 돌면 세션은 FAILED로 바뀝니다. 상태가 RECORDING인지부터 검사하면 같은 만료 상황에서도 배치 전에는 410, 배치 후에는 409가 돌아갈 수 있습니다. 그래서 RECORDING에서 40분을 넘겼거나 이미 UPLOAD_TIMEOUT으로 끝난 세션을 먼저 410으로 처리했습니다.
고정 시계를 사용한 단위 검사에서는 정확히 40분이 된 요청은 접수됐고, 그보다 1마이크로초 지난 요청과 배치가 이미 만료시킨 세션은 410에 해당하는 결과를 받았습니다. 업로드 도중 배치가 먼저 상태를 바꾼 경우도 대역으로 재현했습니다. 이때도 결과는 만료였고 방금 쓴 객체를 지워 저장소에 0개가 남았습니다.
이 결과는 임시 DB와 저장소 대역에서 확인한 경합입니다. 운영 객체 저장소에서의 동시 실행이나 삭제 자체가 실패하는 경우의 정리까지 확인한 범위는 별도입니다.

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