빌드노트
PostgreSQL에서 같은 값을 넣어도 UPDATE 1이 나오는 이유
PostgreSQL의 UPDATE 건수는 같은 값이나 NULL을 다시 대입한 행도 포함한다. 실제 RETURNING 결과와 명령 태그를 비교하고, NULL을 포함한 값 차이에 IS DISTINCT FROM을 적용하는 방법과 0건의 해석을 설명한다.
PostgreSQL에서 ready가 들어 있는 열에 다시 ready를 넣어도 UPDATE 1이 나올 수 있다. 이 숫자는 업데이트된 행 수다. 대입 전후의 값이 달라진 행만 세는 숫자가 아니다. 저장 요청의 반환 건수를 그대로 “내용 변경 1건”으로 표시하면 같은 값을 다시 저장한 경우까지 포함된다.
PostgreSQL 18의 UPDATE 문서 (새 탭에서 열림)는 값이 달라지지 않은 행도 건수에 포함한다고 설명한다. 값이 다를 때만 갱신하려면 WHERE에 비교 조건을 추가해야 한다. NULL을 허용하는 열이라면 IS DISTINCT FROM으로 비교할 수 있다.
같은 값, 다른 값, NULL을 직접 대입해 보면
아래는 실제 장애 기록이 아니라 전용 컨테이너의 PostgreSQL 18.6에서 합성 행으로 실행한 비교다. 따라 하려면 실습용 PostgreSQL DB에 접속한 psql의 새 연결에서 시작하고, 뒤의 SQL까지 같은 연결을 유지한다. 임시 테이블은 이 연결에서만 존재한다. 첫 줄은 SQL NULL을 빈칸 대신 [NULL]로 표시하는 psql 설정이며, 그 문자열을 저장하는 명령은 아니다.
이 입력으로 대입 전 값을 고정하고, 네 UPDATE의 반환 행과 명령 태그를 비교한다.
\pset null '[NULL]'
CREATE TEMP TABLE update_count_demo (id integer PRIMARY KEY, note text);
INSERT INTO update_count_demo VALUES
(1, 'ready'), (2, 'ready'), (3, NULL), (4, NULL), (5, 'ready');
UPDATE update_count_demo SET note = 'ready' WHERE id = 1 RETURNING id, note;
UPDATE update_count_demo SET note = 'done' WHERE id = 2 RETURNING id, note;
UPDATE update_count_demo SET note = NULL WHERE id = 3 RETURNING id, note;
UPDATE update_count_demo SET note = 'done' WHERE id = 99 RETURNING id, note;
실행 순서대로 다음 결과를 얻었다. UPDATE 1과 UPDATE 0은 psql이 결과 아래에 출력한 명령 태그다.
- 1번은
ready → ready였다.RETURNING은(1, ready)한 행을 반환했고 태그는UPDATE 1이었다. - 2번은
ready → done이었다.(2, done)한 행과UPDATE 1이 나왔다. - 3번은
NULL → NULL이었다.(3, [NULL])한 행과UPDATE 1이 나왔다. - 99번은 테이블에 없었다. 반환 행은 0개였고
UPDATE 0이 나왔다. 오류는 발생하지 않았다.
RETURNING id, note는 갱신 후 값을 보여 준다. 따라서 RETURNING으로 한 행을 받았다는 사실도 값이 달라졌다는 증거는 아니다. 위 비교에서는 입력할 때 정한 이전 값과 반환된 값을 대조해 차이를 확인했다.
값이 다를 때만 UPDATE하도록 조건을 넣는다
같은 값의 저장을 건너뛰려면 키 조건에 값 비교를 함께 쓴다. 아래 SQL은 앞에서 만든 임시 테이블에 이어서 실행한다. 1·3번은 같은 값을 다시 보내고, 4·5번은 NULL과 값 사이를 바꿔 조건이 두 경우를 구별하는지 확인한다.
UPDATE update_count_demo SET note = 'ready'
WHERE id = 1 AND note IS DISTINCT FROM 'ready' RETURNING id, note;
UPDATE update_count_demo SET note = NULL
WHERE id = 3 AND note IS DISTINCT FROM NULL RETURNING id, note;
UPDATE update_count_demo SET note = 'ready'
WHERE id = 4 AND note IS DISTINCT FROM 'ready' RETURNING id, note;
UPDATE update_count_demo SET note = NULL
WHERE id = 5 AND note IS DISTINCT FROM NULL RETURNING id, note;
같은 ready를 보낸 1번과 NULL을 다시 보낸 3번은 반환 행 없이 UPDATE 0이었다. NULL에서 ready로 바뀐 4번은 (4, ready), ready에서 NULL로 바뀐 5번은 (5, [NULL])을 반환했고 둘 다 UPDATE 1이었다.
자기 쿼리에 적용할 때는 SET note = ...의 새 값과 note IS DISTINCT FROM ...의 비교값을 같게 둔다. 애플리케이션에서 바인딩하는 입력값도 두 위치에 같은 값으로 전달한다. 이 예제는 note 한 열만 바꾸므로 그 열만 비교했다. 여러 열 중 하나라도 다를 때 갱신하려면 각 열의 IS DISTINCT FROM 조건을 OR로 연결하고, 실제로 바꿀 열을 빠뜨리지 않아야 한다.
NULL이 있으면 <>로 대신할 수 없다
note <> 'ready'도 값 차이를 검사하는 것처럼 보이지만, 현재 note가 NULL이면 결과가 NULL이 된다. WHERE는 참인 행만 통과시키므로 NULL에서 ready로 바꾸려던 행이 빠진다. 반대로 note <> NULL을 쓰면 현재 값이 ready여도 참이 되지 않는다.
다음 조회는 일반 비교와 NULL을 다루는 비교의 차이를 한 행으로 확인한다.
SELECT NULL::text <> 'ready' AS ordinary,
NULL::text IS DISTINCT FROM 'ready' AS to_value,
'ready'::text IS DISTINCT FROM NULL AS to_null,
NULL::text IS DISTINCT FROM NULL::text AS both_null;
실제 결과는 ordinary=[NULL], to_value=t, to_null=t, both_null=f였다. t와 f는 각각 참과 거짓이다. 공식 비교 연산 문서 (새 탭에서 열림)는 IS DISTINCT FROM이 양쪽 모두 NULL이면 거짓, 한쪽만 NULL이면 참을 반환한다고 설명한다. 양쪽이 NULL이 아니면 <>처럼 값 차이를 검사한다.
별도 UPDATE 비교에서도 NULL인 행에 note <> 'ready'를 붙이자 UPDATE 0이었고 값이 NULL로 남았다. 현재 값이 ready인 행에 note <> NULL을 붙인 경우도 0건이었다. 두 조건을 각각 IS DISTINCT FROM으로 바꿨을 때는 각 1건이 갱신돼 ready와 NULL이 저장됐다.
조건을 추가한 뒤의 0건은 “없는 키”만 뜻하지 않는다
값 비교를 추가하면 같은 값을 보낸 기존 행과 없는 키가 모두 UPDATE 0이 될 수 있다. 따라서 이 숫자만으로 “대상 없음” 오류를 만들면 안 된다. 위 예제의 1번은 실제로 존재하면서도 조건에 걸려 0건이었다.
다음 조회로 실습 테이블의 1번과 99번이 존재하는지 구별할 수 있다.
SELECT id, note FROM update_count_demo WHERE id IN (1, 99) ORDER BY id;
실행 결과는 (1, ready) 한 행이었다. 99번은 없고 1번은 같은 값을 이미 가진 상태다. 실제 서비스에서 이처럼 별도로 조회하면 조회 시점의 상태를 보는 것이므로, 동시 변경까지 포함한 원자적인 결과 판정으로 해석해서는 안 된다.
이번 실험은 트리거 없는 임시 테이블, 단일 연결, text 열만 확인했다. 공식 UPDATE 문서는 BEFORE UPDATE 트리거가 갱신을 막으면 조건에 맞은 행보다 건수가 작을 수도 있다고 명시한다. 트리거가 있는 테이블의 최종 값이나 동시 갱신 동작은 이 실험으로 검증하지 않았다. 같은 값 대입이 디스크 쓰기를 생략하는지, 어떤 성능 차이를 만드는지도 측정하지 않았다.