본문으로 건너뛰기
wooncloud

트랜잭션 안에서 에러를 삼키면 COMMIT 이 조용히 ROLLBACK 된다 (node-postgres)

실패해도 되는 조회를 try/catch 로 삼켰더니 앞선 UPDATE 까지 사라졌습니다. aborted 트랜잭션의 COMMIT 이 에러 없이 ROLLBACK 이 되는 이유와 SAVEPOINT 로 막는 방법입니다.

·4 min read·— views··

파일을 휴지통으로 옮기는 상태 전이를 UPDATE 하는 트랜잭션 안에서, "읽어 두면 좋지만 실패해도 전이는 막지 않는" 조회를 하나 더 했습니다. try/catch 로 감싸고 실패하면 null 을 돌려주게 했습니다.

그런데 이 조회가 실패하는 순간, 앞서 한 UPDATE 까지 같이 사라졌습니다.

try/catch 로 감쌌는데 왜 사라질까요

PostgreSQL 은 트랜잭션 안에서 문장 하나가 실패하면 트랜잭션 전체가 aborted 상태가 됩니다. 이후 문장은 전부 current transaction is aborted, commands ignored until end of transaction block 으로 거부됩니다.

더 나쁜 건 마지막 COMMIT 입니다. aborted 트랜잭션에 COMMIT 을 보내면 서버는 에러 없이 ROLLBACK 을 수행하고, 명령 태그로 ROLLBACK 을 돌려줍니다. node-postgres 의 client.query("COMMIT") 은 예외를 던지지 않습니다.

그래서 앞선 UPDATE 는 통째로 사라지는데 애플리케이션은 성공 경로로 계속 갑니다. 응답도 성공이고, 검색 색인이나 감사 로그, 이벤트 같은 후속 부수효과는 "전이됨"으로 실행됩니다.

실패해도 막지 않겠다는 의도와 정반대로, 실패가 본 작업을 조용히 취소한 셈입니다.

가두거나 던지거나

방법은 둘 중 하나입니다.

  • SAVEPOINT 로 감쌉니다. 실패해도 되는 문장만 부분 롤백합니다.
  • 삼키지 말고 던집니다. 트랜잭션 전체를 실패시켜 호출자가 알게 합니다.

그리고 트랜잭션 헬퍼에서 COMMIT 결과의 command 를 확인해 ROLLBACK 이면 throw 하게 하면, 같은 종류의 조용한 유실을 한 곳에서 막을 수 있습니다.

// 나쁜 예: 삼키면 트랜잭션이 aborted 가 되고, COMMIT 이 ROLLBACK 이 된다
await client.query("BEGIN");
await client.query("UPDATE items SET status = 'trashed' WHERE id = $1", [id]);
let extra = null;
try { extra = (await client.query("SELECT ... possibly failing ...")).rows; } catch { /* ignore */ }
const r = await client.query("COMMIT"); // 예외 없음, r.command === "ROLLBACK"
 
// 좋은 예 1: SAVEPOINT 로 실패를 가둔다
await client.query("SAVEPOINT optional_read");
try {
  extra = (await client.query("SELECT ...")).rows;
  await client.query("RELEASE SAVEPOINT optional_read");
} catch {
  await client.query("ROLLBACK TO SAVEPOINT optional_read");
  extra = null;
}
 
// 좋은 예 2: 헬퍼에서 조용한 롤백을 잡는다
const res = await client.query("COMMIT");
if (res.command === "ROLLBACK") throw new Error("transaction was aborted; COMMIT rolled back");

에러 없이 성공하는 실패가 제일 찾기 어렵습니다. 트랜잭션 헬퍼에 COMMIT 검사를 넣어 두는 건 싸고 확실한 방어였습니다.

참고