PostgreSQL의 Transaction 및 Isolation Level

1. 개요

여러 요청이 동시에 같은 테이블을 읽고 쓸 때, 각 SQL 문이 올바르더라도 결과가 잘못될 수 있습니다. Isolation level은 한 transaction이 다른 transaction의 변경을 얼마나 “볼 수” 있는지를 결정합니다.

이 글에서 우리는 PostgreSQL이 isolation level을 어떻게 구현하는지 살펴볼 것입니다. 당신은 lost update와 write skew 오류를 재현할 수 있을 것입니다. 글의 끝에서 당신은 적절한 level을 선택하는 방법, Spring Boot에서 설정하는 방법, 그리고 재시도 로직을 올바르게 작성하는 방법을 알게 될 것입니다.

2. PostgreSQL의 Transaction

Transaction은 여러 명령을 하나의 단위로 묶습니다. 모두 성공하거나, 아니면 어떤 것도 효과가 없습니다:

CREATE TABLE account (
    id      BIGINT PRIMARY KEY,
    owner   TEXT NOT NULL,
    balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
);

INSERT INTO account VALUES (1, 'An', 1000), (2, 'Binh', 500);

BEGIN;
UPDATE account SET balance = balance - 300 WHERE id = 1;
UPDATE account SET balance = balance + 300 WHERE id = 2;
COMMIT;

두 번째 UPDATE 문이 실패하면 ROLLBACK을 호출하고 An의 잔액이 유지됩니다. BEGIN이 없으면 PostgreSQL은 autocommit 모드에서 실행됩니다: 각 문은 별도의 transaction입니다.

PostgreSQL은 MVCC (Multi-Version Concurrency Control)를 사용합니다. 각 UPDATE 명령은 행을 덮어쓰지 않고 새로운 버전을 만듭니다. 각 transaction은 snapshot을 통해 데이터를 읽습니다. 따라서 읽기 작업이 쓰기 작업을 차단하지 않고, 그 반대도 마찬가지입니다. Isolation level은 snapshot이 언제 생성되는지를 결정합니다: 각 문의 시작 시점에, 아니면 전체 transaction에 대해 한 번만.

3. 이상 현상과 네 가지 Isolation Level

SQL 표준은 다음의 현상들을 정의합니다:

  • Dirty read: 다른 transaction이 아직 commit하지 않은 데이터를 읽는 것.
  • Non-repeatable read: 같은 행을 두 번 읽었을 때 다른 값을 받는 것.
  • Phantom read: 같은 조건부 쿼리를 다시 실행했을 때 행이 추가되거나 사라지는 것.
  • Lost update: 두 transaction이 같은 행을 읽고 쓸 때, 한 번의 쓰기가 덮어써지는 것.
  • Write skew: 두 transaction이 공통 데이터를 읽은 후 각각 다른 행에 쓸 때, 비즈니스 제약을 위반하는 것.

아래 표는 PostgreSQL이 각 현상을 어떻게 처리하는지 설명합니다:

Level              Dirty read  Non-repeatable  Phantom  Lost update  Write skew
READ UNCOMMITTED   No          Yes             Yes      Yes          Yes
READ COMMITTED     No          Yes             Yes      Yes          Yes
REPEATABLE READ    No          No              No       No (*)       Yes
SERIALIZABLE       No          No              No       No (*)       No (*)
(*) Transaction is aborted with SQLSTATE 40001; the application must retry.

SQL 표준과 다른 두 가지 점을 기억해야 합니다:

  • PostgreSQL에서 READ UNCOMMITTED는 READ COMMITTED와 정확히 동일하게 작동합니다. Dirty read를 경험하지 않습니다.
  • REPEATABLE READ는 phantom read까지 차단합니다, 왜냐하면 그것은 사실 snapshot isolation이기 때문입니다.

기본 level은 READ COMMITTED입니다. 다음 명령으로 확인할 수 있습니다:

SHOW default_transaction_isolation;
 default_transaction_isolation
-------------------------------
 read committed

4. READ COMMITTED

이 level에서 각 명령은 새로운 snapshot을 생성합니다. 두 개의 psql 세션을 열고 다음 순서대로 교대로 실행하세요:

-- Session A
BEGIN;
SELECT balance FROM account WHERE id = 1;   -- 700.00

-- Session B
UPDATE account SET balance = 900 WHERE id = 1;  -- autocommit

-- Session A
SELECT balance FROM account WHERE id = 1;   -- 900.00
COMMIT;

같은 transaction 내에서 Session A는 두 개의 다른 값을 봅니다. 이것이 non-repeatable read입니다.

4.1. Lost update

응용 프로그램에서 가장 흔한 오류는 “Java로 읽고, 계산하고, 다시 쓰는” 패턴입니다:

BigDecimal balance = jdbc.queryForObject(
        "SELECT balance FROM account WHERE id = ?", BigDecimal.class, 1L);
jdbc.update("UPDATE account SET balance = ? WHERE id = ?",
        balance.subtract(new BigDecimal("100")), 1L);

두 개의 thread가 이 코드를 동시에 실행하고 모두 1000을 읽는다고 가정하세요. 둘 다 900을 쓸 것입니다. 결과적으로 한 번의 출금이 손실됩니다.

READ COMMITTED를 유지하면서 수정하는 두 가지 방법이 있습니다.

방법 1: 데이터베이스가 계산하도록 하고, 원자적 명령으로 작성하세요.

UPDATE account SET balance = balance - 100 WHERE id = 1;

다른 transaction이 업데이트 중인 행을 만나면, PostgreSQL은 그 transaction이 commit될 때까지 기다립니다. 그 후 행의 최신 버전을 다시 읽고 뺄셈을 적용합니다.

방법 2: SELECT ... FOR UPDATE를 사용하여 읽을 때 행을 즉시 잠그세요.

BEGIN;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- 응용 프로그램이 비즈니스 로직을 확인하고 계산합니다...
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;

두 번째 transaction은 첫 번째 transaction이 commit될 때까지 SELECT ... FOR UPDATE 명령에서 차단됩니다. 따라서 그것은 항상 새로운 값을 읽습니다.

5. REPEATABLE READ

이 level에서 snapshot은 transaction의 첫 번째 명령에서 한 번만 생성됩니다. 이후의 모든 읽기는 동일한 데이터 상태를 봅니다.

또 다른 transaction이 snapshot을 생성한 후에 수정하고 commit한 행을 업데이트하려고 하면, PostgreSQL은 거부합니다:

-- Session A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1;          -- 1000.00

-- Session B
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- commit

-- Session A
UPDATE account SET balance = balance - 200 WHERE id = 1;
ERROR:  could not serialize access due to concurrent update

Lost update는 더 이상 조용히 발생하지 않습니다. 대신 Session A는 ROLLBACK을 하고 처음부터 다시 실행해야 합니다. 오류 코드 40001 (serialization_failure)은 응용 프로그램이 재시도하라는 신호입니다.

6. SERIALIZABLE과 Write Skew

REPEATABLE READ는 여전히 write skew를 허용합니다. 다음의 전형적인 예를 생각해보세요: 병원은 항상 최소 한 명의 의사가 대기하고 있어야 합니다.

CREATE TABLE doctor (
    id      BIGINT PRIMARY KEY,
    name    TEXT NOT NULL,
    on_call BOOLEAN NOT NULL
);
INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true);

두 의사 모두 동시에 대기를 벗어나고 싶어하고, 각각 별도의 transaction에서:

-- A and B both run:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctor WHERE on_call;  -- both see 2

-- A
UPDATE doctor SET on_call = false WHERE id = 1;
-- B
UPDATE doctor SET on_call = false WHERE id = 2;

COMMIT;  -- A: OK
COMMIT;  -- B: OK  -> no one is on call anymore!

두 transaction은 서로 다른 행에 쓰기 때문에 쓰기 충돌이 감지되지 않습니다. SERIALIZABLE로 전환하면, PostgreSQL은 Serializable Snapshot Isolation (SSI) 메커니즘을 사용합니다. SSI는 transaction 간의 읽기-쓰기 의존성을 추적하고 둘 중 하나를 중단합니다:

ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

오류는 UPDATE 명령 또는 COMMIT 시점에 나타날 수 있습니다. 오류 코드는 여전히 40001입니다.

주의: SERIALIZABLE은 추가로 행을 잠그지 않습니다. 단지 충돌을 감지하고 transaction을 중단할 뿐입니다. SSI를 사용하면 PostgreSQL은 실제로 충돌하지 않은 transaction까지 실수로 중단할 수 있습니다. 따라서 동시 쓰기 부하가 높을 때 재시도 비율이 상당히 증가할 수 있습니다.

7. Spring Boot에서 Isolation Level 사용

다음 dependencies를 추가하세요:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

가장 간단한 방법은 @Transactional에서 isolation level을 선언하는 것입니다:

@Service
public class ReportService {

    private final JdbcTemplate jdbc;

    public ReportService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
    public BigDecimal totalBalance() {
        BigDecimal total = jdbc.queryForObject(
                "SELECT sum(balance) FROM account", BigDecimal.class);
        Long count = jdbc.queryForObject(
                "SELECT count(*) FROM account", Long.class);
        // both queries use the same snapshot -> data is consistent
        return total;
    }
}

Spring은 transaction을 시작하기 전에 Connection.setTransactionIsolation(...)을 호출합니다. PostgreSQL 드라이버는 이를 SET TRANSACTION ISOLATION LEVEL ...로 변환합니다.

7.1. Serialization Failure 발생 시 재시도

재시도는 transaction 외부를 감싸야 합니다. Transaction 내부의 재시도는 의미가 없습니다. transaction이 이미 실패 표시가 되었기 때문입니다. TransactionTemplate을 사용하면 @Transactional의 self-invocation 함정을 피할 수 있습니다:

@Service
public class OnCallService {

    private static final int MAX_ATTEMPTS = 3;

    private final JdbcTemplate jdbc;
    private final TransactionTemplate tx;

    public OnCallService(JdbcTemplate jdbc, PlatformTransactionManager tm) {
        this.jdbc = jdbc;
        this.tx = new TransactionTemplate(tm);
        this.tx.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
    }

    public void goOffCall(long doctorId) {
        for (int attempt = 1; ; attempt++) {
            try {
                tx.executeWithoutResult(status -> doGoOffCall(doctorId));
                return;
            } catch (ConcurrencyFailureException e) {
                if (attempt >= MAX_ATTEMPTS) {
                    throw e;
                }
            }
        }
    }

    private void doGoOffCall(long doctorId) {
        Integer onCall = jdbc.queryForObject(
                "SELECT count(*) FROM doctor WHERE on_call", Integer.class);
        if (onCall == null || onCall < 2) {
            throw new IllegalStateException("Must have at least one doctor on call");
        }
        jdbc.update("UPDATE doctor SET on_call = false WHERE id = ?", doctorId);
    }
}

위의 코드는 다음과 같이 작동합니다:

  • setIsolationLevel(ISOLATION_SERIALIZABLE)은 모든 executeWithoutResult 호출에 적용됩니다.
  • Spring은 SQLSTATE 40001 (및 deadlock 40P01)을 ConcurrencyFailureException의 서브클래스로 변환합니다. 따라서 드라이버의 오류 코드를 읽을 필요가 없습니다.
  • 재시도 시 새로운 transaction이 새로운 snapshot을 획득합니다. 다른 의사가 이미 대기를 벗어났음을 보고, IllegalStateException을 던집니다. 비즈니스 제약이 올바르게 유지됩니다.

주의: Spring Boot 3은 JdbcTransactionManager를 자동으로 구성합니다. 이 클래스는 commit 중에 발생하는 오류를 DataAccessException으로 변환합니다. 직접 DataSourceTransactionManager bean을 선언하면, commit 오류는 TransactionSystemException이 되고 위의 재시도 루프는 이를 포착하지 못합니다.

8. Testcontainers로 Write Skew 테스트

정확한 순서 교대를 재현하기 위해 같은 thread에서 직접 두 개의 JDBC Connection을 사용합니다. 이 방식은 thread 실행 시간에 의존하지 않습니다. 다음 테스트 dependency를 추가하세요:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

테스트 클래스:

@Testcontainers
class WriteSkewTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @BeforeEach
    void setUp() throws SQLException {
        try (Connection c = connect(); Statement st = c.createStatement()) {
            st.execute("DROP TABLE IF EXISTS doctor");
            st.execute("CREATE TABLE doctor (id BIGINT PRIMARY KEY, name TEXT, on_call BOOLEAN)");
            st.execute("INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true)");
        }
    }

    @Test
    void repeatableReadAllowsWriteSkew() throws SQLException {
        runConcurrently(Connection.TRANSACTION_REPEATABLE_READ);
        assertThat(countOnCall()).isZero();
    }

    @Test
    void serializableRejectsWriteSkew() throws SQLException {
        assertThatThrownBy(() -> runConcurrently(Connection.TRANSACTION_SERIALIZABLE))
                .isInstanceOf(SQLException.class)
                .extracting(e -> ((SQLException) e).getSQLState())
                .isEqualTo("40001");
        assertThat(countOnCall()).isEqualTo(1);
    }

    private void runConcurrently(int level) throws SQLException {
        try (Connection a = begin(level); Connection b = begin(level)) {
            assertThat(countOnCall(a)).isEqualTo(2);
            assertThat(countOnCall(b)).isEqualTo(2);
            offCall(a, 1);
            a.commit();
            offCall(b, 2);
            b.commit();
        }
    }

    private Connection begin(int level) throws SQLException {
        Connection c = connect();
        c.setAutoCommit(false);
        c.setTransactionIsolation(level);
        return c;
    }

    private Connection connect() throws SQLException {
        return DriverManager.getConnection(
                postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
    }

    private void offCall(Connection c, long id) throws SQLException {
        try (PreparedStatement ps = c.prepareStatement(
                "UPDATE doctor SET on_call = false WHERE id = ?")) {
            ps.setLong(1, id);
            ps.executeUpdate();
        }
    }

    private int countOnCall(Connection c) throws SQLException {
        try (Statement st = c.createStatement();
             ResultSet rs = st.executeQuery("SELECT count(*) FROM doctor WHERE on_call")) {
            rs.next();
            return rs.getInt(1);
        }
    }

    private int countOnCall() throws SQLException {
        try (Connection c = connect()) {
            return countOnCall(c);
        }
    }
}

두 test의 예상 결과:

repeatableReadAllowsWriteSkew  -> PASSED (both commit, 0 doctors on call)
serializableRejectsWriteSkew   -> PASSED (B is aborted with 40001, 1 doctor still on call)

두 번째 test는 오류가 B의 UPDATE 또는 COMMIT에서 발생하는지 단언하지 않습니다. 그것은 단지 코드 40001을 확인합니다. SSI가 충돌을 감지하는 시점이 PostgreSQL 버전에 따라 달라질 수 있기 때문입니다.

9. 흔한 실수와 언제 사용하면 안 되는지

  • Level을 높이지만 재시도하지 않기. REPEATABLE READ와 SERIALIZABLE에서 40001 오류는 버그가 아닌 정상 동작입니다. 재시도가 없으면 사용자는 임의로 500 오류를 받게 됩니다.
  • @Transactional 내부에서 재시도하기. 손상된 transaction은 rollback되고 처음부터 다시 시작해야 합니다. 재시도를 외부 호출 계층에 배치하세요.
  • 재시도가 있는 transaction 내에서 외부 API 호출하기. 이메일 또는 결제 명령이 여러 번 전송될 수 있습니다. commit이 성공한 후 side effect를 수행하세요.
  • “안전하게 하기 위해” 전체 시스템에 SERIALIZABLE을 설정하기. 이것은 SSI 추적 오버헤드와 많은 재시도를 유발합니다. 여러 행에 걸친 제약이 있는 부분에만 사용하세요.
  • 같은 비즈니스 로직 내에서 level을 혼합하기. SSI는 모든 관련 transaction이 SERIALIZABLE을 실행할 때만 보호합니다. 같은 테이블에 쓰는 READ COMMITTED transaction은 여전히 제약을 위반할 수 있습니다.
  • REPEATABLE READ transaction이 너무 오래 실행되기. 오래된 snapshot은 VACUUM이 죽은 행 버전을 정리하는 것을 방지하여 테이블이 비대해집니다. Transaction을 짧게 유지하세요.

주의: 스키마로 표현할 수 있는 제약의 경우, 예를 들어 CHECK, UNIQUE 또는 exclusion constraint는 제약을 우선적으로 사용하세요. 이것이 isolation level을 높이는 것보다 저렴하고 더 신뢰할 수 있습니다.

10. 결론

PostgreSQL은 기본적으로 READ COMMITTED를 실행합니다. 이 level은 대부분의 응용 프로그램에 충분합니다. 원자적 업데이트를 작성하거나 lost update를 피하기 위해 SELECT ... FOR UPDATE를 사용하는 한.

REPEATABLE READ는 transaction 전체에 대해 일관된 snapshot을 제공하며 보고에 적합합니다. SERIALIZABLE은 write skew까지 차단합니다. 두 level 모두 응용 프로그램이 SQLSTATE 40001을 만날 때 재시도해야 합니다.

Spring Boot에서는 @Transactional(isolation = ...) 또는 TransactionTemplate을 통해 level을 선언하고, transaction 외부에서 ConcurrencyFailureException을 포착하고, 확정된 작업 순서로 Testcontainers를 사용하여 동작을 검증합니다.

postgresqltransactionisolation levelspring bootconcurrency