앱이 연습 세션을 만들라는 요청을 보냈는데 응답을 받기 전에 연결이 끊겼다고 가정해 봅시다. 서버에서는 이미 저장을 마쳤을 수도 있습니다. 앱이 같은 요청을 다시 보냈을 때 새 세션을 만들면 한 번의 연습에 세션이 두 개 생깁니다. 얼쑤에서는 앱이 재전송에도 같은 Idempotency-Key를 쓰도록 하고, 서버는 같은 회원·키로 만들어진 세션을 다시 돌려주도록 했습니다.

중복 삽입을 막는 유일 제약

키가 들어오면 먼저 같은 회원이 그 키를 쓴 적이 있는지 조회합니다. 이미 세션이 있으면 저장 단계까지 갈 필요가 없습니다. 다만 두 요청이 거의 함께 오면 둘 다 저장 전에 조회를 마치고 “아직 없음”으로 판단할 수 있습니다.

MySQL의 (user_id, idempotency_key)에 유일 제약을 두었습니다. 두 요청이 같은 키로 저장을 시도하면 한쪽만 행을 만들 수 있습니다. 저장소는 중복 키 예외를 만났을 때만 “다른 요청이 먼저 저장함”을 반환합니다. 서비스는 그 키로 세션을 다시 읽어 기존 요청과 비교합니다. 외래 키 위반처럼 다른 종류의 저장 실패는 별도로 드러납니다.

요청 비교에는 파싱한 필드를 정해진 순서로 이어 붙인 뒤 구한 SHA-256을 사용합니다. 앞에는 형식 버전인 v1을 둡니다. 시작 음표 번호와 장단 소리는 생략해도 각각 0과 false로 비교합니다. 끝 음표 번호와 BPM 단위는 곡 설정에서 기본값을 가져오므로, 해시를 만들 때는 생략했다는 표시를 남깁니다. 곡이 바뀌어도 같은 본문은 같은 해시를 갖고, 끝 음표 번호나 BPM 단위를 명시한 요청은 생략한 요청과 구분됩니다.

형식을 확인한 요청은 곡의 공개 상태나 부모 세션 조건을 확인하기 전에 기존 키부터 찾습니다. 같은 키·같은 내용이면 그 세션을 HTTP 201로 돌려주고, BPM처럼 내용이 달라지면 409로 답합니다. 곡이 그사이 숨겨졌더라도 이미 저장한 세션을 다시 찾을 수 있습니다. 재응답에는 같은 세션의 현재 상태가 들어갑니다. 키의 범위는 회원 한 명이므로 다른 회원은 같은 문자열로 자기 세션을 만들 수 있습니다.

중복 키 뒤에 새로 조회

조회·저장·재조회를 한 트랜잭션으로 묶으면 MySQL의 REPEATABLE READ에서 문제가 생길 수 있습니다. 첫 조회가 잡은 스냅샷에는 다른 요청이 아직 만든 적 없는 행이 없습니다. 뒤의 INSERT가 중복 키로 실패하더라도, 같은 트랜잭션의 일반 SELECT는 그 스냅샷으로 돌아가므로 먼저 커밋된 행을 못 볼 수 있습니다.

이 흐름에는 전체를 감싸는 트랜잭션을 두지 않았습니다. 저장 자체는 INSERT 한 문장으로 끝나고, 중복 키 뒤 재조회는 첫 조회와 다른 시점에 실행됩니다. 처음부터 발견한 기존 세션과 경쟁에서 진 뒤 찾아낸 기존 세션은 같은 본문 비교 절차를 거칩니다.

HTTP 201 네 건과 DB 한 행

빈 로컬 MySQL 8.4.9로 앱을 띄우고, 테스트 회원의 인증 쿠키를 붙인 HTTP 요청을 보냈습니다. 네 스레드에 같은 회원·키·본문을 담은 요청을 하나씩 맡겼습니다. HTTP 상태와 세션 ID를 모은 뒤 DB 행도 직접 세었습니다.

확인 항목결과
같은 키·본문을 보낸 요청4건 모두 HTTP 201
응답에 나타난 서로 다른 세션 ID1개
해당 회원의 연습 세션1행

별도 HTTP 테스트에서는 JSON 키 순서와 공백을 바꾸고 장단 소리 기본값 false를 명시해도 같은 세션을 받았습니다. 같은 키로 BPM을 96에서 97로 바꾸자 409가 돌아왔고, 다른 회원이 같은 키를 쓰면 회원마다 한 행이 남았습니다. 곡을 숨긴 상황은 저장소 대역에서 재현했습니다. 곡을 조회할 수 없게 바꾼 뒤 시작 음표 0과 장단 소리 false를 명시해 같은 키로 다시 요청하자, 먼저 만든 세션이 반환됐고 삽입 기록은 한 건으로 유지됐습니다.

키 형식 검사에서 공백이나 길이 제한을 넘긴 키는 실제 HTTP로 보내 400을 확인했습니다. 한글이 든 키는 Java HTTP 클라이언트가 요청을 만들 때 예외를 내, 서버까지 전달되지 않았습니다. 그 입력의 서버 응답은 이 HTTP 검사에서 확인할 수 없었습니다.

같은 409라도 어느 분기에서 나왔는지 확인

같은 키에 다른 본문을 주면, 기존 행을 처음부터 찾았을 때도 INSERT 경쟁에서 진 뒤 그 행을 찾았을 때도 409가 나옵니다. 리뷰에서 경쟁을 검사한다는 테스트가 먼저 실행한 요청의 상태를 메모리 저장소 대역에 남겨 둔 것을 발견했습니다. 두 번째 요청은 INSERT까지 가지 않고 첫 조회에서 이미 존재하는 행을 찾았습니다. 기대한 409가 나와 테스트는 그대로 통과할 수 있었습니다.

경쟁에서 진 뒤 본문이 같은 경우와 다른 경우를 분리하고, 매번 새 저장소 대역에서 시작하도록 했습니다. 응답에 더해 대역의 lostRace 표시를 단언했습니다. 같은 본문이면 앞서 저장한 세션을 반환하고, 다른 본문이면 충돌로 끝났으며, 두 검사 모두 삽입 경쟁 분기를 거쳤습니다. 이 단위 검사는 저장소가 경쟁 결과를 돌려준 뒤 서비스가 판단하는 경로를 봅니다. 위의 네 요청 실험은 실제 HTTP와 MySQL에 도달했을 때 응답과 저장 행이 가리키는 세션을 대조한 검사입니다.

이 검증에서 확인한 것은 네 요청이 도착한 뒤 응답과 저장 결과가 같은 세션을 가리킨다는 점입니다. 각 요청이 INSERT에 도달한 순서와 운영에서 처리할 수 있는 요청 수는 이 실험의 측정 항목에 포함하지 않았습니다.