다시 읽은 행도 삭제할 때는 달라질 수 있다

AI Commons의 빈 슬라이드 페이지 정리는 삭제 직전에 페이지를 다시 읽고 있었다. 읽기와 삭제도 같은 트랜잭션 안에 있었다. 그래도 다른 요청이 그 사이에 페이지를 채울 수 있었다. 확인한 것은 실제 사용자 데이터 손실 사건이 아니라, 코드에서 가능한 검사와 삭제의 경합이다.

트랜잭션은 여러 변경을 함께 성공하거나 취소하게 한다. 그러나 여기서 사용한 Read Committed 수준의 평범한 조회는 읽은 행을 이후 변경으로부터 잠그지 않는다. “비어 있다”는 검사 결과를 기억한 채 ID만으로 삭제하면, 검사 이후 저장된 내용까지 삭제 대상이 된다. 삭제 건수가 예상과 같다는 검사도 이 문제를 찾지 못한다. 같은 ID의 행이 그대로 존재하기 때문이다.

다음은 두 요청이 끼어드는 순서를 설명한 예시다. 운영 로그를 옮긴 것은 아니다.

  1. 정리 요청 A트랜잭션을 열고 페이지를 다시 조회한다. 이때는 빈 페이지다.
  2. 사용자 저장 B같은 페이지에 내용을 저장하고 커밋한다.
  3. 정리 요청 A앞서 읽은 ID로 삭제한다. 건수는 맞지만 삭제 전제는 이미 달라졌다.

검사와 삭제가 같은 조건을 쓰게 했다

변경에서는 삭제 후보를 SELECT … FOR UPDATE로 잠근 뒤 다시 판정했다. 같은 행을 바꾸려는 요청은 정리 트랜잭션이 끝날 때까지 기다린다. 사용자 저장이 먼저 끝났다면 재조회가 채워진 상태를 보고 삭제 대상에서 제외한다. 정리가 먼저 잠갔다면 저장은 정리가 끝난 뒤 진행하며, 이미 삭제된 대상을 저장하려는 상황은 저장 경로에서 처리해야 한다.

삭제문에도 빈 페이지 판정 조건을 다시 넣었다. 잠금 조회와 삭제가 각각 다른 기준으로 “빈 페이지”를 정의하지 않도록 공통 SQL 조각을 사용했다. 아래는 실제 흐름을 줄인 형태다.

SELECT id FROM "SlidePage"
WHERE id IN (...) AND <빈 페이지 판정>
FOR UPDATE;

DELETE FROM "SlidePage"
WHERE id IN (...) AND <같은 빈 페이지 판정>;

이 정리는 페이지 삭제만 수행하지 않는다. 이미지 버전 정리와 남은 페이지 수 갱신도 이어진다. 기대 삭제 건수와 다르면 예외를 던져 같은 트랜잭션의 변경을 취소한다. 여러 단계가 검사한 상태를 기준으로 움직이므로 잠금이 필요했다.

한 행을 조건에 맞을 때만 지우는 작업이라면 조건부 DELETE 한 문장도 대안이다. 삭제가 영향을 준 행 수로 성공 여부를 판단하면 조회와 삭제 사이를 없앨 수 있다. 여기서는 연관 데이터 정리까지 같은 판단에 의존해 잠금 재조회와 조건부 삭제를 함께 사용했다.

모델을 기다리는 동안에는 잠그지 않는다

이미지 생성에도 오래된 상태를 저장하는 문제가 있다. 모델이 계획을 만드는 동안 교수자가 지시문을 고치면, 늦은 결과는 요청 시작 시점의 지시문과 모드를 들고 돌아온다. 생성 전체를 잠금 안에서 실행하면 편집 요청이 외부 모델의 응답 시간만큼 기다리게 된다.

생성 호출은 잠금 밖에 두고, 저장 직전에 현재 행을 잠가 다시 읽는 방식으로 경계를 좁혔다. 지시문 저장 변경도 같은 원칙을 적용했다. 처음 읽은 모드로 조건을 만들면 다른 저장이 모드를 바꾼 뒤 요청이 조용히 무시될 수 있었다. 잠금 아래에서 다시 읽은 모드가 structured일 때만 수동 텍스트 모드로 전환하고, 밀려난 계획을 이전 계획 이력으로 옮긴다.

따라서 보호할 상태는 “요청 시작 때 읽은 값”이 아니라 “저장 순간의 사용자 편집”이다. 조기 반환을 위한 비교와, 실제 저장을 허용하는 최종 비교를 구분해야 했다.

삭제 검사와 늦은 결과 검사는 증거가 다르다

경로확인 수단확인한 상태
빈 페이지 삭제SQL 호출을 모킹한 단위 테스트잠금 조회·공통 삭제 조건·건수 불일치 처리
늦은 계획·작업 결과실제 PostgreSQL 통합 테스트사용자 지시문·출처·모드·계획·이력·메타 보존

실제 DB 테스트는 모델 생성 도중 사용자 저장을 끼워 넣고, 늦은 계획이 편집 관련 컬럼을 덮지 않는지 검사한다. 작업 완료 경로에서는 이미지는 새 결과로 갱신하고 지시문 관련 컬럼은 유지하는 기대값도 확인한다. 늦은 결과가 페이지의 모든 값을 바꾸지 않는 정책은 아니다. 이전 지시문으로 만든 이미지와 최신 지시문이 나란히 남을 수 있다.

삭제 경로의 모킹 테스트를 실제 동시 트랜잭션 검증으로 확대하지 않았다. 제품 통합 전의 편집 보호 구현과 실제 DB 테스트를 다룬 사례이며, 고객 환경의 배포 검증까지 포함하지는 않는다.

잠금은 짧게 잡아도 비용이 있다

행 잠금은 경쟁 저장의 대기 시간을 만든다. 여러 행을 다룰 때 잠금 순서가 다르면 서로 기다리는 교착도 생길 수 있다. 보호할 상태를 정하고, 잠금 안에 필요한 DB 작업만 남기는 이유다. 삭제 경로의 실제 동시 실행 재현과 대기 시간 측정은 추가 검증 대상이다.

이 사례에서 트랜잭션의 존재보다 중요한 것은 어느 상태를 읽고, 그 상태를 언제까지 유지하며, 어느 결과만 저장할지를 정하는 일이었다. AI Commons의 편집 보호는 생성 작업을 취소하는 문제와도 구분해 다뤘다.