본문 바로가기
빌드노트 검색

빌드노트

PostgreSQL DO NOTHING 뒤에는 기존 행의 ID가 안 나온다

ON CONFLICT DO NOTHING은 중복 행을 건너뛰므로 RETURNING id도 0행이 된다. PostgreSQL 18.6에서 첫 삽입·중복·별도 SELECT와 같은 값 UPDATE의 트리거 실행을 비교하고, 기존 ID 조회 경로와 동시성 미검증 범위를 설명한다.

PostgreSQL에서 INSERT ... ON CONFLICT DO NOTHING RETURNING id를 실행하면 새 행의 ID는 나오지만, 같은 키를 다시 넣었을 때는 결과가 비어 있다. RETURNING은 그 명령이 실제로 삽입하거나 수정한 행만 반환한다. 중복 입력을 건너뛴 기존 행은 반환 대상에 들어가지 않는다. 중복이면 기존 ID를 쓰려는 코드에는 반환 결과가 0행일 때 같은 키로 SELECT하는 경로가 필요하다. PostgreSQL 18 INSERT 문서 (새 탭에서 열림)

같은 키를 두 번 넣어 확인한다

PostgreSQL 18.6의 새 DB와 전용 스키마에서 합성 데이터로 비교했다. 직접 확인하려면 PostgreSQL 서버의 실습용 DB에 psql로 접속한다. 아래 id_lookup_lab은 아직 존재하지 않는 스키마여야 하고, 접속 계정에는 스키마 생성 권한이 있어야 한다. SQL은 순서대로 실행한다.

먼저 키가 유일한 테이블을 만들고 첫 입력이 ID를 반환하는지 본다.

CREATE SCHEMA id_lookup_lab;
CREATE TABLE id_lookup_lab.items (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  item_key text NOT NULL UNIQUE,
  memo text NOT NULL
);

INSERT INTO id_lookup_lab.items (item_key, memo)
VALUES ('memo-a', 'original')
ON CONFLICT (item_key) DO NOTHING
RETURNING id;

실행 결과는 id = 1 한 행과 명령 태그 INSERT 0 1이었다. 이어서 같은 키에 다른 memo를 넣어, 충돌할 때의 반환 결과를 확인한다.

INSERT INTO id_lookup_lab.items (item_key, memo)
VALUES ('memo-a', 'replacement')
ON CONFLICT (item_key) DO NOTHING
RETURNING id;

이번 psql 출력은 다음과 같았다.

 id
----
(0 rows)

INSERT 0 0

오류 없이 끝났지만 반환 행은 없었다. ID가 NULL인 행 하나가 나온 것도 아니다. INSERT 0 0의 마지막 0은 이번 명령이 삽입하거나 수정한 행이 없다는 뜻이다. 테이블에 그 키가 없다는 뜻으로 해석하면 안 된다.

0행이면 충돌 키로 기존 행을 읽는다

다음 SELECT로 기존 ID와 저장된 값이 남아 있는지 확인한다.

SELECT id, memo
FROM id_lookup_lab.items
WHERE item_key = 'memo-a';

결과는 1 | original 한 행이었다. 중복 시도에서 보낸 replacement는 저장되지 않았다. 이 예제의 요구가 “이미 있으면 그 행을 그대로 사용한다”라면, SELECT에서 얻은 ID 1이 사용할 값이다. 중복 시 내용을 바꾸려던 요구라면 DO NOTHING 자체가 그 요구와 맞지 않는다.

애플리케이션에서도 먼저 INSERT의 반환 행 수를 본다. 한 행이면 그 ID를 쓰고, 0행이면 INSERT에 바인딩했던 같은 키로 별도 SELECT를 실행한다. 이 예제는 item_key에 NOT NULL과 UNIQUE가 있어 해당 키의 조회 결과가 최대 한 행이다. 복합 키를 쓰는 코드라면 충돌을 판정한 키 전체로 조회해야 한다.

별도 SELECT까지 0행이면 ID를 얻지 못한 상태로 처리한다. SQL 오류를 빈 반환 결과와 합쳐 처리하거나, 임의의 ID를 만들어 성공으로 넘기면 안 된다. 실제 코드에서는 조회 조건·접속 대상·트랜잭션 조건을 확인하고, 동시 변경이 가능한 요구라면 재시도나 실패 처리 기준도 정해야 한다.

ID만 얻으려고 UPDATE를 추가하면 쓰기가 된다

기존 값을 다시 대입하는 DO UPDATE로 바꾸면 반환 행을 만들 수는 있다. 아래 문장은 memo를 새 입력으로 바꾸지 않고 기존 값으로 유지하면서 ID를 돌려주는지 비교하는 SQL이다.

INSERT INTO id_lookup_lab.items AS dst (item_key, memo)
VALUES ('memo-a', 'replacement')
ON CONFLICT (item_key) DO UPDATE
SET memo = dst.memo
RETURNING id, memo;

실험에서는 1 | original 한 행과 INSERT 0 1이 나왔다. 하지만 이 문장은 기존 행을 UPDATE한다. 부작용을 확인하는 별도 비교에서는 같은 테이블에 행 단위 AFTER UPDATE 트리거를 달아 이전 memo와 새 memo를 기록했다. DO NOTHING 뒤에는 감사 기록이 0행이었고, 위 UPDATE 뒤에는 1 | original | original인 기록이 1행 생겼다. 값이 같아도 UPDATE 트리거가 실행될 수 있다. 트리거 동작 문서 (새 탭에서 열림)

DO UPDATE에는 UPDATE 권한도 필요하고, UPDATE는 대상 행의 잠금을 획득한다. 감사 기록이나 수정 시각을 바꾸는 트리거가 있는 테이블에서는 ID를 읽으려는 호출이 쓰기 부작용을 만들 수 있다. 위 실험은 트리거 실행을 확인했으며, 잠금 경합 시간이나 처리량은 측정하지 않았다. INSERT 권한 조건 (새 탭에서 열림), 행 잠금 문서 (새 탭에서 열림)

실제 내용을 갱신하는 SET memo = EXCLUDED.memo에 WHERE dst.memo IS DISTINCT FROM EXCLUDED.memo를 붙여 값이 다를 때만 쓰도록 해도, 값이 같아서 UPDATE하지 않은 행은 RETURNING에서 빠진다. 기존 memo와 입력 memo를 모두 original로 맞춘 추가 실험에서 반환은 다시 0행이었다. 불필요한 갱신을 막는 조건을 붙이면서 항상 ID도 반환받을 수 있다고 기대하면 같은 문제가 남는다. RETURNING과 DO UPDATE WHERE의 관계 (새 탭에서 열림)

별도 SELECT의 동시성 조건도 남는다

이번 실행은 단일 연결에서 자동 커밋과 Read Committed로 문장을 순서대로 실행했다. 동시에 같은 키를 삽입하거나 삭제하는 두 연결, Repeatable Read·Serializable에서의 동작은 검증하지 않았다. 별도 SELECT가 언제나 ID를 얻고 그 행을 이후 사용 시점까지 보존한다는 보장까지 확인한 실험은 아니다.

문서상 Read Committed에서는 INSERT의 조회 스냅샷에 보이지 않는 다른 트랜잭션의 행 때문에 DO NOTHING이 선택될 수 있다. 다음 독립 SELECT는 새 스냅샷을 사용하지만, 그 사이의 삭제까지 막아 주지는 않는다. 트랜잭션 격리 문서 (새 탭에서 열림)

또한 INSERT를 CTE에 넣고 기존 행 SELECT와 UNION ALL로 합치는 방식은 별도 문장을 한 번 더 실행하는 것과 조건이 다르다. 데이터 변경 CTE와 바깥 쿼리는 같은 스냅샷을 사용한다. 앞의 충돌 가시성 조건과 함께 보면, 문장을 하나로 합쳤다는 이유만으로 동시 경쟁에서도 항상 한 ID를 반환한다고 볼 수 없다. 이는 문서에 근거한 주의점이며 이번 실험에서 경쟁 상황을 재현한 결과는 아니다. 기존 행을 수정할 필요가 없다면 SELECT 경로를 두고, 반환이 비었을 때 어떻게 끝내거나 재시도할지는 실제 격리 수준과 동시 삭제 허용 여부에 맞춰 정한다. 데이터 변경 CTE 문서 (새 탭에서 열림)

← 전체 글 보기