PostgreSQL DBeaver에서 COMMIT했는데 다른 창에 반영되지 않는 이유

옆 창의 SELECT가 이전 값을 돌려준 순간
A 창(PID 19600)에서 amount를 300으로 바꾸고 COMMIT까지 했습니다. 옆에 띄워 둔 B 창(PID 10180)에서 SELECT를 다시 돌리니 200이 나왔습니다. 재접속도, DBeaver 재시작도 하지 않았습니다. B에서 COMMIT; 한 줄을 실행하자 같은 창에 300이 보였습니다.
저장이 안 된 것처럼 보이지만 A의 커밋은 처음부터 정상이었고, 300이 안 보인 이유는 B 쪽에 있었습니다. Windows 로컬 PostgreSQL 18.6에 DBeaver 연결 두 개를 열고, 한 행의 값을 순서대로 바꿔 가며 각 창에 무엇이 보이는지 기록했습니다. 아래 표가 전체 흐름이고, 이어지는 내용은 표의 각 줄을 캡처로 확인한 과정입니다.
| 순서 | A 창 (PID 19600) | B 창 (PID 10180) | B에 보인 amount |
|---|---|---|---|
| 1 | 초기 상태 조회 | 초기 상태 조회 | 100 |
| 2 | BEGIN, 200으로 UPDATE (커밋 전) | 트랜잭션 없이 SELECT | 100 |
| 3 | COMMIT | SELECT 다시 실행 | 200 |
| 4 | 대기 | BEGIN REPEATABLE READ, 첫 SELECT | 200 |
| 5 | BEGIN, 300으로 UPDATE, COMMIT | SELECT 다시 실행 | 200 |
| 6 | 대기 | COMMIT 후 SELECT | 300 |
준비한 환경: 한 행짜리 테이블과 DBeaver 연결 두 개
| 항목 | 내용 |
|---|---|
| PostgreSQL / OS | 18.6 / Windows x86_64 |
| 접속 도구 | DBeaver, 연결 PG_A와 PG_B |
| 데이터베이스 / 계정 | postgres / postgres |
| 스키마 / 테이블 | lab_commit_visibility / commit_probe |
| 데이터 | 1행 (id = 1), amount 100 → 200 → 300 |
| 세션 PID | A 19600, B 10180 (뒤쪽 점검 캡처는 다시 접속한 PG_A 세션 19740) |
각 세션에 어떤 값이 보이는지를 보려고 데이터는 한 행만 넣고, 실행 시간 대신 조회값, PID, 격리 수준을 남겼습니다. 로컬 postgres 계정으로 접속했지만 이 과정에 슈퍼유저 권한이 필요한 부분은 없습니다.
CREATE SCHEMA IF NOT EXISTS lab_commit_visibility;
CREATE TABLE IF NOT EXISTS lab_commit_visibility.commit_probe (
id integer PRIMARY KEY,
amount integer NOT NULL,
change_note varchar(60) NOT NULL
);
INSERT INTO lab_commit_visibility.commit_probe (id, amount, change_note)
VALUES (1, 100, 'initial')
ON CONFLICT (id) DO UPDATE
SET amount = EXCLUDED.amount, change_note = EXCLUDED.change_note;
-- A와 B에서 각각 실행
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
두 연결 모두 자동 커밋을 켜 둔 상태에서, 트랜잭션이 필요한 구간만 BEGIN과 COMMIT을 직접 입력했습니다. 자동 커밋이 켜져 있어도 BEGIN으로 연 트랜잭션은 COMMIT이나 ROLLBACK을 실행할 때까지 열려 있습니다. 문장은 한 번에 하나씩 실행했고, INSERT의 ON CONFLICT ... DO UPDATE는 처음부터 다시 해 볼 때 같은 문장으로 값을 100으로 되돌리려고 넣었습니다.
1번: 두 창이 정말 다른 세션인지부터 봅니다
COMMIT은 그 문장을 실행한 연결의 트랜잭션에만 적용됩니다. 그런데 DBeaver에서 SQL 편집기 탭 하나가 데이터베이스 세션 하나라는 보장은 없습니다. Open separate connection for each editor 같은 설정에 따라 편집기마다 연결을 따로 열기도 하고, 여러 편집기가 연결 하나를 같이 쓰기도 합니다. 그래서 탭 이름이 아니라 pg_backend_pid()로 세션을 구분했습니다.
SELECT current_database() AS db_name, current_user AS login_user,
pg_backend_pid() AS pid,
inet_server_addr() AS server_addr, inet_server_port() AS server_port,
current_setting('transaction_isolation') AS isolation_level;
캡처에는 행 수, 값 범위, 인덱스 정의까지 한 화면에 보이도록 위 항목에 pg_indexes 조회를 붙여 실행한 결과가 들어 있습니다.

| 연결 | PID | 버전 | 격리 수준 | 행 수 | amount |
|---|---|---|---|---|---|
| A | 19600 | 18.6 | read committed | 1 | 100 |
| B | 10180 | 18.6 | read committed | 1 | 100 |
PID는 다르고 데이터베이스와 값은 같습니다. 인덱스는 기본키가 만든 commit_probe_pkey 하나뿐입니다. 두 창이 같은 세션이라서 생기는 문제는 여기서 빠집니다.
운영에서는 이 확인을 도구 사용 규칙으로 정해 두고 있습니다. 운영 DB에 접속하는 개발 도구는 자동 커밋을 켜고 쓰고, 명령을 실행하기 전에 도구 상단 메뉴바에서 접속 대상 DB가 어디인지부터 확인합니다. 데이터베이스 이름과 계정이 같아도 서버나 포트가 다를 수 있으니, 애매할 때는 위 쿼리의 inet_server_addr()와 inet_server_port()를 두 창에서 나란히 비교하면 됩니다.
2번: 커밋 전의 200은 A에게만 보입니다
A에서 트랜잭션을 열고 amount를 200으로 바꾼 뒤, 커밋하지 않은 채 A와 B에서 각각 조회했습니다. 조회문에 단계 이름을 문자열로 넣어서 캡처만 봐도 어느 시점의 결과인지 알 수 있게 했습니다.
-- A
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE lab_commit_visibility.commit_probe
SET amount = 200, change_note = 'A_pending_200'
WHERE id = 1;
SELECT 'A_before_commit' AS step, pg_backend_pid() AS pid,
current_setting('transaction_isolation') AS isolation_level,
id, amount, change_note
FROM lab_commit_visibility.commit_probe
WHERE id = 1;

트랜잭션은 커밋 전에도 자기가 바꾼 값을 읽습니다. 변경한 창에 새 값이 보인다고 해서 커밋이 끝났다고 판단하면 안 되는 이유입니다. 같은 순간 B에서도 같은 SELECT를 실행했습니다. B는 BEGIN을 실행하지 않은 상태입니다.

B에는 100이 나왔고, A가 잡고 있는 행 잠금을 기다리지도 않았습니다. PostgreSQL의 UPDATE는 기존 행을 덮어쓰지 않고 새 버전을 하나 더 만듭니다. 100인 버전과 200인 버전이 함께 있고, 각 SELECT는 자기 스냅샷 기준으로 보여도 되는 버전만 읽습니다. 일반 SELECT가 쓰기 잠금을 기다리지 않는 것도 이 구조 덕분입니다. READ UNCOMMITTED를 지정해도 PostgreSQL에서는 READ COMMITTED처럼 동작하므로, 다른 세션의 미커밋 값을 읽을 방법은 없습니다.
3번: A가 커밋하자 B의 새 SELECT에 200이 들어왔습니다
A에서 COMMIT;을 실행한 뒤 B에서 SELECT를 새로 실행했습니다. 여기서 핵심은 “새로”입니다. DBeaver 결과 그리드는 SELECT를 실행한 순간의 결과라서, 다른 세션이 커밋해도 화면이 저절로 바뀌지 않습니다. 결과 창을 보고만 있었다면 이것부터 의심해야 합니다. 조회문에 step 문자열을 넣은 것도 이 때문인데, B_before_A_commit과 B_after_A_commit처럼 단계마다 문자열이 달라서 캡처 속 결과가 새로 실행한 것인지 바로 구분됩니다.

B의 amount가 100에서 200으로 바뀌었습니다. READ COMMITTED에서는 SELECT 문장이 시작될 때마다 스냅샷을 새로 만들기 때문에, A의 커밋 뒤에 시작한 SELECT에는 A의 변경이 들어 있습니다. A 쪽 COMMIT 통계의 Updated Rows 0은 COMMIT이 행을 수정하는 문장이 아니라서 나온 숫자이고, 저장에 실패했다는 뜻이 아닙니다. 커밋이 적용되지 않았을 가능성도 여기서 빠집니다.
4번과 5번: B가 스냅샷을 잡은 뒤에는 300이 들어오지 않습니다
이번에는 B에서 REPEATABLE READ 읽기 전용 트랜잭션을 열고 첫 조회까지 실행했습니다. 이때 B는 200을 읽었고, 트랜잭션은 닫지 않고 그대로 두었습니다.
-- B
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT 'B_RR_snapshot_start' AS step, pg_backend_pid() AS pid,
current_setting('transaction_isolation') AS isolation_level,
id, amount, change_note
FROM lab_commit_visibility.commit_probe
WHERE id = 1;
실행 순서를 지켜야 같은 결과가 나옵니다. PostgreSQL의 REPEATABLE READ는 BEGIN을 실행한 순간이 아니라 트랜잭션 안에서 첫 번째 일반 문장을 실행할 때 스냅샷을 만듭니다. BEGIN만 실행하고 A로 넘어가면, A가 300을 커밋한 뒤에 B의 첫 SELECT가 스냅샷을 만들게 되고 B에도 300이 보입니다. B의 첫 조회까지 끝낸 다음 A로 넘어가야 비교 시점이 고정됩니다.
-- A
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE lab_commit_visibility.commit_probe
SET amount = 300, change_note = 'A_committed_300'
WHERE id = 1;
COMMIT;
-- 이후 A와 B에서 각각 같은 SELECT 실행

| 연결 | PID | 격리 수준 | amount | change_note |
|---|---|---|---|---|
| A | 19600 | read committed | 300 | A_committed_300 |
| B | 10180 | repeatable read | 200 | A_pending_200 |
처음에 본 증상이 바로 이 화면입니다. B는 SELECT를 분명히 새로 실행했으니 화면이 갱신되지 않은 경우와는 다릅니다. 새 SELECT가 첫 조회 때 만든 스냅샷을 다시 쓰기 때문에, 그 뒤에 커밋된 300이 들어 있지 않을 뿐입니다. A가 몇 번을 더 커밋해도 B의 스냅샷은 트랜잭션이 끝날 때까지 그대로입니다. 한 트랜잭션 안의 여러 조회가 같은 시점을 보도록 보장하는 것이 REPEATABLE READ의 목적이고, 여러 테이블을 한 시점 기준으로 읽어야 하는 리포트나 정합성 점검이 이 성질을 이용합니다.
B 화면의 A_pending_200은 확인용으로 넣은 문자열이라 A가 커밋한 뒤에도 그 버전에 그대로 남아 있습니다. 이 글자를 보고 트랜잭션 상태를 판단하지는 않습니다.
6번: 같은 PID에서 트랜잭션만 닫았더니 300이 보였습니다
B에서 COMMIT;으로 읽기 전용 트랜잭션을 닫고 같은 SELECT를 다시 실행했습니다.

PID는 10180 그대로이고 amount 300과 A_committed_300이 나왔습니다. 격리 수준도 read committed로 돌아왔는데, REPEATABLE READ를 BEGIN ISOLATION LEVEL ...으로 그 트랜잭션에만 지정했기 때문입니다. 연결은 그대로 두고 트랜잭션만 닫았는데 최신 값이 보였으니, 300을 가린 것은 연결이 아니라 B가 쥐고 있던 트랜잭션 스냅샷이었습니다.
다른 창에 변경이 안 보일 때 조회하는 창에서 볼 값 세 가지
1번부터 6번까지 보면, 변경한 창보다 조회하는 창을 먼저 봐야 한다는 결론이 나옵니다. 조회하는 창에서 확인할 값은 세 가지입니다.
| 확인할 것 | 사용하는 값 | 이렇게 나오면 |
|---|---|---|
| 정말 다른 세션인가 | pg_backend_pid() | 두 창의 PID가 같으면 같은 세션 |
| 트랜잭션이 열려 있는가 | now()와 statement_timestamp() | 두 시각이 다르면 트랜잭션 안에 있음 |
| 스냅샷을 유지하는 격리 수준인가 | current_setting('transaction_isolation') | repeatable read 또는 serializable이면 이전 값이 보일 수 있음 |
SELECT pg_backend_pid() AS pid,
current_setting('transaction_isolation') AS isolation_level,
now() AS xact_start_time,
statement_timestamp() AS stmt_start_time,
pg_current_xact_id_if_assigned() AS assigned_xid;
now()는 현재 트랜잭션이 시작된 시각이고, statement_timestamp()는 지금 문장이 시작된 시각입니다. 자동 커밋으로 한 문장만 실행했다면 둘이 같습니다. 재현을 끝내고 PG_A로 다시 접속한 창에서 이 쿼리를 실행해 봤는데, 그 창이 실제로 트랜잭션을 연 채 남아 있었습니다.

PID 19740에서는 00:11:19.916과 00:31:01.126으로 약 20분 차이가 났습니다. 앞선 작업에서 쿼리를 수정하다가 커밋하지 않고 둔 창이었습니다. assigned_xid가 NULL이니 그 트랜잭션 안에서 데이터 변경이나 행 잠금은 없었습니다.
다만 격리 수준이 read committed였습니다. READ COMMITTED는 문장마다 새 스냅샷을 쓰기 때문에, 이 창은 트랜잭션이 열려 있어도 다른 세션의 커밋을 새 SELECT마다 볼 수 있습니다. 시각 두 개가 다르다는 것만으로 원인을 확정하지 말고 세 번째 값인 격리 수준까지 같이 봐야 하는 이유입니다.
pg_stat_activity에서 본 PG_A 세션과 backend_xmin 779
같은 시각 B 세션에서 pg_stat_activity로 트랜잭션을 연 채 멈춰 있는 세션을 찾았습니다.
SELECT pid, usename, application_name, state,
xact_start, now() - xact_start AS xact_age,
backend_xid, backend_xmin, left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
ORDER BY xact_start;

| 컬럼 | 값 | 읽는 법 |
|---|---|---|
| pid / application_name | 19740 / DBeaver 26.2.1 - SQLEditor <PG_A.sql> | 어느 도구의 어느 편집기인지 바로 보입니다 |
| state | idle in transaction | 트랜잭션을 연 채 다음 명령을 기다리는 중 |
| xact_age | 00:20:07.374343 | 앞의 확인 쿼리에서 본 트랜잭션 시작 시각 00:11:19.916과 맞습니다 |
| backend_xid | NULL | 변경이나 잠금을 하지 않은 트랜잭션 |
| backend_xmin | 779 | 이 세션이 붙잡고 있는 가장 오래된 트랜잭션 기준 |
| last_query | SELECT typinput='pg_catalog.array_in'::regproc as is_array, ... | 사용자가 실행한 SQL이 아니라 DBeaver 내부 조회문 |
이 결과에서 예상과 다른 점이 두 가지 있었습니다.
먼저 last_query에 남은 것은 사용자가 마지막으로 실행한 SQL이 아니었습니다. 바로 앞에서 트랜잭션 확인 쿼리를 실행했는데도, DBeaver가 결과를 처리하면서 같은 연결로 보낸 내부 조회문이 찍혀 있었습니다. 클라이언트 도구가 연 세션이라면 last_query만 보고 그 창에서 무슨 작업을 하다 멈췄는지 판단하기 어렵습니다. 이번에는 application_name과 xact_start가 더 쓸모 있는 단서였습니다.
또 하나는 read committed 트랜잭션인데도 backend_xmin에 779가 남아 있었다는 점입니다. 문장이 끝나면 스냅샷을 놓는 격리 수준이라 비어 있을 줄 알았습니다. 짐작 가는 후보는 DBeaver가 결과를 나눠 가져오려고 열어 둔 커서가 스냅샷을 쥐고 있는 경우인데, 이번에 pg_cursors까지 확인하지는 않았으므로 원인은 확정하지 않겠습니다. 격리 수준만 보고 이 세션이 VACUUM과 무관하다고 판단하면 안 된다는 점은 화면으로 확인됐습니다.
backend_xmin이 남아 있는 동안 VACUUM은 그 기준 이후에 지워지거나 갱신된 행의 이전 버전을 정리하지 못합니다. 로컬 DB라서 이번에는 아무 영향이 없었지만, 운영이라면 트랜잭션을 열어 둔 편집기 창 하나가 테이블 팽창으로 이어질 수 있습니다.
운영 중인 Aurora PostgreSQL에서는 이 세션을 잡을 수 있을까
방치된 트랜잭션을 막는 설정으로는 idle_in_transaction_session_timeout이 있고, PostgreSQL 17부터는 트랜잭션 전체 시간을 제한하는 transaction_timeout도 있습니다. 둘 다 시간을 넘기면 트랜잭션만 끝내는 것이 아니라 세션을 종료하므로, 연결 풀을 쓰는 애플리케이션이 끊긴 연결을 어떻게 처리하는지 먼저 확인하고 적용해야 합니다.
제가 운영하는 Aurora PostgreSQL에서는 두 설정 모두 따로 손대지 않고 기본값으로 쓰고 있습니다. 오래 열린 트랜잭션을 별도로 점검하지도 않고, 일점검 때 모니터링 지표를 살펴보는 정도입니다. 그런데 이번 PG_A 세션처럼 변경도 잠금도 없는 트랜잭션은 잠금 대기 같은 눈에 띄는 증상을 만들지 않습니다. 지표만 훑어서는 놓치기 쉬운 종류라서, 위의 pg_stat_activity 조회처럼 xact_age가 긴 세션을 직접 보는 쿼리를 일점검에 함께 두면 지표와 따로 확인할 수 있습니다.
파라미터를 바꿀지 판단하기 전에는 지금 값이 무엇인지부터 확인합니다. transaction_timeout은 PostgreSQL 17 이상에서만 조회됩니다.
SHOW idle_in_transaction_session_timeout;
SHOW transaction_timeout;
최신 값을 보려고 COMMIT부터 누르면 안 되는 경우
6번의 B는 READ ONLY로 연 트랜잭션이라 COMMIT해도 잃을 것이 없었습니다. 운영 세션은 사정이 다릅니다. 같은 트랜잭션 안에 다른 미커밋 변경이 섞여 있을 수 있어서, 최신 값을 보려고 COMMIT하면 의도하지 않은 변경까지 확정됩니다. 반대로 ROLLBACK은 필요한 변경을 취소할 수 있습니다. 트랜잭션을 닫기 전에 assigned_xid로 변경이 있었는지부터 보고, 기존 작업을 살려야 한다면 그 트랜잭션은 그대로 둔 채 별도 연결에서 최신 값을 확인합니다.
격리 수준을 낮추는 것도 정답은 아닙니다. 매번 최신 커밋값을 봐야 하는 조회인지, 여러 문장이 같은 시점을 봐야 하는 작업인지에 따라 달라집니다. 후자라면 이전 값이 보이는 것은 오류가 아니라 의도한 동작입니다.


댓글 0
첫 댓글을 남겨보세요.