한 UPDATE도 유일 제약의 중간 충돌을 만들 수 있다

슬라이드 페이지의 (slideId, index)는 유일 키다. 첫 페이지를 지운 뒤 남은 index를 1씩 줄이면 최종 결과는 중복이 없다. 하지만 index 2인 행을 먼저 1로 옮기면, 아직 옮기지 않은 index 1과 충돌한다. PostgreSQL의 즉시 유일 제약 검사는 이 중간 상태도 본다.

UPDATE ... SET index = index - 1을 한 문장으로 썼다고 행의 처리 순서가 보장되지는 않는다. 삽입을 위해 1씩 늘릴 때도 앞쪽 행을 먼저 옮기면 같은 문제가 생긴다. 변경은 이 두 경로를 기존 순서 변경처럼 음수 임시 공간을 거치게 했다.

기존 공간을 비운 다음 최종 index를 쓴다

같은 트랜잭션에서 영향받는 페이지를 먼저 -(index + 1000)으로 옮긴다. 두 번째 UPDATE는 음수 값을 최종 위치로 바꾼다. 정상 index가 0 이상의 값이라는 전제에서 두 공간이 겹치지 않는다.

원래 index 2인 페이지의 이동 예시
변경임시 값최종 식최종 값
앞에 삽입-1002-index - 9993
앞의 페이지 삭제-1002-index - 10011

아래는 삭제 경로의 SQL이며 slideId와 삭제 위치를 전달하는 부분은 단순화했다.

UPDATE "SlidePage"
	SET "index" = -("index" + 1000)
	WHERE "slideId" = :slideId AND "index" > :deletedIndex;
UPDATE "SlidePage"
	SET "index" = -"index" - 1001
	WHERE "slideId" = :slideId AND "index" < 0;

행별 순서에 의존하지 않는 대신 UPDATE가 두 번이고 트랜잭션의 쓰기도 늘어난다. 정상 데이터에 음수 index가 이미 있으면 두 번째 갱신의 범위에 섞일 수 있어 저장 규칙이 필요하다. 두 번의 갱신만으로 동시 reorder·delete 전체의 정합성이나 분산 잠금을 보장하지는 않는다.

논리 index와 물리 삽입 순서를 다르게 준비한다

회귀 테스트는 Testcontainers의 실제 PostgreSQL에 페이지를 넣는다. 삭제와 중간 삽입 시나리오는 index 내림차순으로 시딩하고, 맨앞 삽입은 오름차순으로 시딩한다. 삭제와 맨앞 삽입에서는 각 단일 갱신이 충돌하기 쉬운 행 배치를 만든다. index를 정렬해서 읽는 테스트만으로는 드러나지 않는 차이다.

일곱 시나리오는 첫·중간 페이지 삭제, reorder 뒤 삭제, 중간·맨앞 삽입과 썸네일 갱신을 다룬다. 남은 index의 연속성과 제목 순서를 확인하지만 구버전 SQL의 23505를 따로 assert하는 대조 테스트는 아니다. 권한 도우미는 mock이고 썸네일 생성은 파일·sharp 의존을 stub하므로 실제인 범위는 DB 행 처리다.

첫 페이지 삭제 뒤 썸네일 갱신은 DB 커밋 다음에 진행한다. 새 첫 페이지에 이미지가 없으면 이전 썸네일을 비우며, 그 표시 갱신이 실패해도 로그를 남기고 페이지 삭제는 성공으로 유지한다. 순서의 DB 계약과 삭제 이후의 표시 상태도 서로 다른 완료 시점을 가진다.