본문으로 건너뛰기

안동민 개발노트

본문 시작

트랜잭션과 ACID

두 JDBC 연결로 커밋 가시성과 행 잠금 수명, 게시일별 본문 한도의 동시성 원자성을 검증합니다.

트랜잭션은 여러 SQL을 하나의 업무 단위로 묶습니다.

한 연결에서 아직 커밋하지 않은 변경은 같은 연결에는 보이지만, 다른 연결에는 공개되지 않습니다. 커밋하면 전체가 보이고 롤백하면 전체가 사라집니다.

이 경계는 메서드 호출 수가 아니라 같은 Connection과 명시적인 완료 시점으로 결정됩니다.


ACID를 관찰 가능한 계약으로 바꾸기

ACID는 외울 약어가 아니라 실패와 동시 실행에서 확인할 속성입니다.

속성게시글 등록에서 지켜야 할 사실이 문서의 oracle
원자성일일 사용량과 게시글이 함께 반영되거나 함께 취소된다중복 키 실패 뒤 사용량 증가도 롤백된다
일관성FK·길이·유일성·일일 한도가 항상 성립한다공유 schema.sql 제약과 한도 행 갱신 결과를 확인한다
격리성미완료 변경을 다른 트랜잭션이 완료된 사실처럼 쓰지 않는다두 연결의 가시성과 행 잠금 대기를 확인한다
지속성커밋 완료 데이터가 장애 뒤에도 남아야 한다이 문서의 두 세션 테스트만으로는 증명하지 않는다

격리는 “항상 직렬 실행”이라는 뜻이 아닙니다. 선택한 격리 수준과 SQL 모양에 따라 허용되는 관찰이 달라집니다.

이 장에서는 기본 계약을 분명하게 보기 위해 두 연결 모두 READ_COMMITTED를 사용합니다. 아래 테스트가 증명하는 범위도 이미 열린 두 세션 사이의 커밋 전후 가시성까지입니다. 재연결, 프로세스 재시작, 저장 장치 flush를 거친 지속성은 별도의 통합·복구 테스트가 필요합니다.


한 연결 안의 게시글 등록 SQL

앞 장의 CreatePostUseCase.CreatePostCommandDailyLimitExceededException을 그대로 사용합니다. 새 명령이나 같은 이름의 예외를 다시 만들지 않습니다.

리포지토리는 연결을 얻거나 커밋하지 않습니다. 호출자가 건넨 연결에서 게시일별 사용량을 조건부로 늘린 뒤 게시글을 저장합니다.

src/main/java/board/jdbc/PostRegistrationSql.java
package board.jdbc;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Objects;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.DailyLimitExceededException;

public final class PostRegistrationSql {
    private static final String ADD_WITHIN_LIMIT = """
            update daily_post_stats
               set total_characters = total_characters + ?
             where member_id = ?
               and published_on = ?
               and cast(total_characters as bigint) + ? <= (
                     select daily_character_limit
                       from members
                      where id = ?)
            """;

    private static final String READ_QUOTA = """
            select d.total_characters, m.daily_character_limit
              from daily_post_stats d
              join members m on m.id = d.member_id
             where d.member_id = ? and d.published_on = ?
            """;

    private static final String INSERT_POST = """
            insert into posts(
              member_id, title, content, published_on,
              client_request_id, created_at, version)
            values (?, ?, ?, ?, ?, ?, 0)
            """;

    public long execute(
            Connection connection,
            CreatePostCommand command,
            Instant createdAt
    ) throws SQLException {
        Objects.requireNonNull(connection, "connection");
        Objects.requireNonNull(command, "command");
        Objects.requireNonNull(createdAt, "createdAt");

        int attempted = command.content().length();
        try (var statement = connection.prepareStatement(ADD_WITHIN_LIMIT)) {
            statement.setInt(1, attempted);
            statement.setLong(2, command.memberId());
            statement.setObject(3, command.publishedOn());
            statement.setInt(4, attempted);
            statement.setLong(5, command.memberId());
            if (statement.executeUpdate() != 1) {
                throw quotaFailure(connection, command, attempted);
            }
        }

        try (var statement = connection.prepareStatement(
                INSERT_POST, Statement.RETURN_GENERATED_KEYS)) {
            statement.setLong(1, command.memberId());
            statement.setString(2, command.title());
            statement.setString(3, command.content());
            statement.setObject(4, command.publishedOn());
            statement.setString(5, command.clientRequestId());
            statement.setObject(
                    6, OffsetDateTime.ofInstant(createdAt, ZoneOffset.UTC));
            if (statement.executeUpdate() != 1) {
                throw new SQLException("post insert did not affect one row");
            }
            try (var keys = statement.getGeneratedKeys()) {
                return readSinglePositiveGeneratedPostId(keys);
            }
        }
    }

    static long readSinglePositiveGeneratedPostId(ResultSet keys)
            throws SQLException {
        if (!keys.next()) {
            throw new SQLException("generated post id missing");
        }
        long postId = keys.getLong(1);
        if (postId <= 0) {
            throw new SQLException("generated post id must be positive");
        }
        if (keys.next()) {
            throw new SQLException("multiple generated post ids");
        }
        return postId;
    }

    private RuntimeException quotaFailure(
            Connection connection,
            CreatePostCommand command,
            int attempted
    ) throws SQLException {
        try (var statement = connection.prepareStatement(READ_QUOTA)) {
            statement.setLong(1, command.memberId());
            statement.setObject(2, command.publishedOn());
            try (var result = statement.executeQuery()) {
                if (!result.next()) {
                    throw new SQLException("daily quota row missing");
                }
                int used = result.getInt("total_characters");
                int limit = result.getInt("daily_character_limit");
                if ((long) used + attempted > limit) {
                    return new DailyLimitExceededException(
                            limit, used, attempted);
                }
                return new IllegalStateException(
                        "daily quota update did not affect one row");
            }
        }
    }
}

daily_character_limit를 Java 상수로 복사하지 않고 members의 현재 정책을 조건에 넣습니다. cast(... as bigint)는 덧셈 중간값이 int 범위를 넘는 경우까지 안전하게 비교합니다.

조건부 UPDATE가 0행이면 같은 트랜잭션에서 현재 사용량을 읽어 실제 한도 초과와 잘못된 fixture를 구분합니다. 한도 초과만 도메인 예외이며, 존재해야 할 통계 행이 없거나 경합 없이 갱신이 사라진 경우는 데이터 계약 오류입니다.

삽입 성공도 JDBC 메타데이터를 낙관하지 않습니다. 생성 키 결과가 정확히 1행이고 그 값이 양수일 때만 게시글 ID로 반환하며, 누락·여분·0 이하 키는 모두 SQL 계약 실패입니다.


커밋 전후의 가시성

첫 번째 연결이 삽입한 행은 그 연결 자신에게 즉시 보입니다. READ_COMMITTED인 두 번째 연결은 첫 번째 연결이 커밋하기 전에는 그 행을 볼 수 없습니다.

src/test/java/board/jdbc/TransactionVisibilityTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;

import java.sql.Connection;
import java.time.OffsetDateTime;

import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

class TransactionVisibilityTest {
    @Test
    void rollback하면_writer만_보던_행도_사라진다() throws Exception {
        var dataSource = dataSource("visibility_rollback");

        try (Connection writer = dataSource.getConnection();
             Connection observer = dataSource.getConnection()) {
            writer.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
            observer.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
            writer.setAutoCommit(false);

            insert(writer, 1L);

            assertThat(count(writer)).isEqualTo(1);
            assertThat(count(observer)).isZero();

            writer.rollback();

            assertThat(count(writer)).isZero();
            assertThat(count(observer)).isZero();
        }
    }

    @Test
    void commit한_뒤에는_같은_observer_session에_행이_보인다()
            throws Exception {
        var dataSource = dataSource("visibility_commit");

        try (Connection writer = dataSource.getConnection();
             Connection observer = dataSource.getConnection()) {
            writer.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
            observer.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
            writer.setAutoCommit(false);

            insert(writer, 1L);
            assertThat(count(observer)).isZero();

            writer.commit();

            assertThat(count(observer)).isEqualTo(1);
        }
    }

    private JdbcDataSource dataSource(String name) throws Exception {
        var dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:" + name
                + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        new ResourceDatabasePopulator(
                new ClassPathResource("schema.sql")).execute(dataSource);
        try (var connection = dataSource.getConnection();
             var statement = connection.createStatement()) {
            statement.execute("""
                    insert into members(
                      id, email, password_hash, name,
                      active, daily_character_limit)
                    values (
                      41, 'member41@example.com', 'hash', '회원41',
                      true, 10000)
                    """);
        }
        return dataSource;
    }

    private void insert(Connection connection, long id) throws Exception {
        try (var statement = connection.prepareStatement(
                """
                insert into posts(
                  member_id, title, content, published_on,
                  client_request_id, created_at, version)
                values (?, ?, ?, ?, ?, ?, 0)
                """)) {
            statement.setLong(1, 41L);
            statement.setString(2, "가시성 " + id);
            statement.setString(3, "본문");
            statement.setObject(4, java.time.LocalDate.of(2026, 8, 29));
            statement.setString(5, "visible_req_" + id);
            statement.setObject(
                    6, OffsetDateTime.parse("2026-08-29T00:00:00Z"));
            statement.executeUpdate();
        }
    }

    private int count(Connection connection) throws Exception {
        try (var statement = connection.createStatement();
             var result = statement.executeQuery(
                     "select count(*) from posts")) {
            result.next();
            return result.getInt(1);
        }
    }
}

테스트는 두 쿼리를 순서대로 호출했다는 사실이 아니라 이미 열린 서로 다른 두 READ_COMMITTED 세션이 관찰한 행 수를 exact oracle로 둡니다. 이는 교차 세션 가시성의 증거이지 재연결이나 재시작 뒤 지속성의 증거가 아닙니다.

격리 anomaly를 제어 수단으로 바꾼다

anomaly제어 수단적용 기준
LOST UPDATEversion 조건UPDATE의 이전 version 일치와 영향 행 수로 충돌을 잡는다
PREDICATE RACElock 또는 SERIALIZABLE범위 조건을 만족하는 동시 삽입·변경을 직렬화한다
LONG TXscope 축소외부 I/O를 트랜잭션 밖으로 옮겨 lock 보유 시간을 줄인다
ABORTbounded retry명령 멱등성이 있을 때 conflict를 처음부터 제한 횟수만 재실행한다

행 잠금은 트랜잭션 완료까지 유지된다

조건부 UPDATE도 쓰려는 행을 잠급니다. 소유 트랜잭션이 완료되기 전까지 경쟁 트랜잭션의 잠금 읽기는 기다리거나 타임아웃납니다.

잠금 소유 연결과 게시일별 사용량 행, 경쟁 연결 사이의 커밋 전 가시성, 잠금 타임아웃, 롤백 뒤 재시도 순서

ROW LOCK LIFECYCLE

일반 읽기는 마지막 커밋을, 잠금 읽기는 완료를 기다린다

READ_COMMITTED 두 연결이 같은 행을 볼 때의 정확한 순서를 따라갑니다.

행 잠금과 커밋 가시성 시퀀스 소유 연결이 사용량 행을 480에서 510으로 미커밋 변경한다. 경쟁 연결의 일반 조회는 480을 보고, 잠금 조회는 타임아웃된다. 소유 연결이 롤백하면 경쟁 연결이 다시 잠가 480을 읽는다. 잠금 소유 Connection autoCommit=false daily_post_stats committed = 480 경쟁 Connection READ_COMMITTED UPDATE 480 → 510 self read = 510 normal SELECT last commit = 480 SELECT … FOR UPDATE timeout · 50200 ROLLBACK 잠금 해제 · 480 유지 retry FOR UPDATE locked = 480
READ_COMMITTED 시간은 위에서 아래로
  1. 소유자가 480을 510으로 바꾼다

    자기 연결에는 510이 보이지만 아직 커밋하지 않았습니다.

  2. 경쟁자의 일반 SELECT는 480을 본다

    다른 연결에는 마지막 커밋 값만 공개됩니다.

  3. 경쟁자의 FOR UPDATE는 기다린다

    같은 행의 쓰기 잠금이 풀리지 않아 H2 50200으로 타임아웃됩니다.

  4. 소유자가 롤백한다

    미완료 510은 사라지고 잠금이 풀리며 480이 남습니다.

  5. 경쟁자가 재시도해 480을 잠근다

    잠금의 수명은 SQL 종료가 아니라 트랜잭션 완료까지입니다.

  • 잠금을 얻는 SQL
  • 관찰 결과
  • 타임아웃·롤백
  • 연결 수명선·응답

핵심: 일반 읽기의 가시성과 쓰기 잠금 대기는 다른 계약이며, 둘 다 트랜잭션 완료 시점에 의해 바뀝니다.

src/test/java/board/jdbc/RowLockLifecycleTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Executors;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.DailyLimitExceededException;

import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

class RowLockLifecycleTest {
    @Test
    void normal_read는_마지막_commit을_보고_lock_read는_timeout_뒤_재시도된다()
            throws Exception {
        var dataSource = new JdbcDataSource();
        dataSource.setURL(
                "jdbc:h2:mem:row_lock;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        new ResourceDatabasePopulator(
                new ClassPathResource("schema.sql")).execute(dataSource);
        seedQuota(dataSource, 480);

        try (Connection owner = dataSource.getConnection();
             Connection contender = dataSource.getConnection()) {
            owner.setAutoCommit(false);
            contender.setAutoCommit(false);
            owner.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
            contender.setTransactionIsolation(
                    Connection.TRANSACTION_READ_COMMITTED);

            add(owner, 30);
            assertThat(read(owner, false)).isEqualTo(510);
            assertThat(read(contender, false)).isEqualTo(480);
            try (var statement = contender.createStatement()) {
                statement.execute("set lock_timeout 100");
            }

            SQLException timeout = assertThrows(
                    SQLException.class,
                    () -> read(contender, true));
            assertThat(timeout.getErrorCode()).isEqualTo(50200);

            owner.rollback();
            contender.rollback();

            assertThat(read(contender, true)).isEqualTo(480);
            contender.rollback();
        }
    }

    @Test
    void 동시에_같은_게시일_한도를_늘려도_한_명만_commit한다()
            throws Exception {
        var dataSource = new JdbcDataSource();
        dataSource.setURL(
                "jdbc:h2:mem:daily_race;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        new ResourceDatabasePopulator(
                new ClassPathResource("schema.sql")).execute(dataSource);
        seedQuota(dataSource, 9_000);

        var barrier = new CyclicBarrier(2);
        var registration = new PostRegistrationSql();
        var publishedOn = LocalDate.of(2026, 8, 29);
        var first = new CreatePostCommand(
                41L, "첫 번째", "가".repeat(600),
                publishedOn, "race_req_A1");
        var second = new CreatePostCommand(
                41L, "두 번째", "나".repeat(600),
                publishedOn, "race_req_B2");

        List<Outcome> outcomes;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var left = executor.submit(
                    () -> attempt(dataSource, registration, barrier, first));
            var right = executor.submit(
                    () -> attempt(dataSource, registration, barrier, second));
            outcomes = List.of(left.get(), right.get());
        }

        assertThat(outcomes).extracting(Outcome::kind)
                .containsExactlyInAnyOrder("committed", "limited");
        var limited = outcomes.stream()
                .filter(outcome -> outcome.failure() != null)
                .findFirst()
                .orElseThrow()
                .failure();
        assertThat(limited.limitCharacters()).isEqualTo(10_000);
        assertThat(limited.usedCharacters()).isEqualTo(9_600);
        assertThat(limited.attemptedCharacters()).isEqualTo(600);

        try (var connection = dataSource.getConnection()) {
            assertThat(scalar(connection, """
                    select total_characters from daily_post_stats
                     where member_id = 41
                       and published_on = date '2026-08-29'
                    """)).isEqualTo(9_600);
            assertThat(scalar(connection,
                    "select count(*) from posts")).isEqualTo(1);
        }

        assertGeneratedKeyContract();
    }

    private void assertGeneratedKeyContract() throws Exception {
        ResultSet valid = generatedKeys(41L, false);
        assertThat(PostRegistrationSql
                .readSinglePositiveGeneratedPostId(valid)).isEqualTo(41L);

        ResultSet missing = mock(ResultSet.class);
        SQLException missingFailure = assertThrows(
                SQLException.class,
                () -> PostRegistrationSql
                        .readSinglePositiveGeneratedPostId(missing));
        assertThat((Throwable) missingFailure)
                .hasMessage("generated post id missing");

        ResultSet nonpositive = generatedKeys(0L, false);
        SQLException nonpositiveFailure = assertThrows(
                SQLException.class,
                () -> PostRegistrationSql
                        .readSinglePositiveGeneratedPostId(nonpositive));
        assertThat((Throwable) nonpositiveFailure)
                .hasMessage("generated post id must be positive");

        ResultSet extra = generatedKeys(41L, true);
        SQLException extraFailure = assertThrows(
                SQLException.class,
                () -> PostRegistrationSql
                        .readSinglePositiveGeneratedPostId(extra));
        assertThat((Throwable) extraFailure)
                .hasMessage("multiple generated post ids");
    }

    private ResultSet generatedKeys(long firstId, boolean hasExtra)
            throws Exception {
        ResultSet keys = mock(ResultSet.class);
        when(keys.next()).thenReturn(true, hasExtra);
        when(keys.getLong(1)).thenReturn(firstId);
        return keys;
    }

    private Outcome attempt(
            JdbcDataSource dataSource,
            PostRegistrationSql registration,
            CyclicBarrier barrier,
            CreatePostCommand command
    ) throws Exception {
        try (Connection connection = dataSource.getConnection()) {
            connection.setAutoCommit(false);
            connection.setTransactionIsolation(
                    Connection.TRANSACTION_READ_COMMITTED);
            barrier.await();
            try {
                long id = registration.execute(
                        connection, command,
                        Instant.parse("2026-08-29T00:00:00Z"));
                connection.commit();
                return new Outcome("committed", id, null);
            } catch (DailyLimitExceededException exception) {
                connection.rollback();
                return new Outcome("limited", 0L, exception);
            } catch (SQLException | RuntimeException exception) {
                connection.rollback();
                throw exception;
            }
        }
    }

    private void seedQuota(JdbcDataSource dataSource, int used)
            throws Exception {
        try (var connection = dataSource.getConnection();
             var member = connection.prepareStatement("""
                    insert into members(
                      id, email, password_hash, name,
                      active, daily_character_limit)
                    values (41, ?, 'hash', '회원41', true, 10000)
                    """);
             var quota = connection.prepareStatement("""
                    insert into daily_post_stats(
                      member_id, published_on, total_characters)
                    values (41, date '2026-08-29', ?)
                    """)) {
            member.setString(1, "member41@example.com");
            member.executeUpdate();
            quota.setInt(1, used);
            quota.executeUpdate();
        }
    }

    private void add(Connection connection, int characters) throws Exception {
        try (var statement = connection.prepareStatement("""
                update daily_post_stats
                   set total_characters = total_characters + ?
                 where member_id = 41
                   and published_on = date '2026-08-29'
                """)) {
            statement.setInt(1, characters);
            statement.executeUpdate();
        }
    }

    private int read(Connection connection, boolean forUpdate)
            throws Exception {
        String sql = """
                select total_characters from daily_post_stats
                 where member_id = 41
                   and published_on = date '2026-08-29'
                """ + (forUpdate ? " for update" : "");
        try (var statement = connection.createStatement();
             var result = statement.executeQuery(sql)) {
            result.next();
            return result.getInt(1);
        }
    }

    private int scalar(Connection connection, String sql) throws Exception {
        try (var statement = connection.createStatement();
             var result = statement.executeQuery(sql)) {
            result.next();
            return result.getInt(1);
        }
    }

    private record Outcome(
            String kind,
            long postId,
            DailyLimitExceededException failure
    ) {}
}

첫 실험의 일반 SELECT는 잠금 소유자의 미커밋 510이 아니라 마지막 커밋 값 480을 읽습니다. 같은 행을 잠그는 FOR UPDATE는 H2의 잠금 타임아웃 코드 50200으로 실패하고, 소유자가 롤백한 뒤에는 480을 잠가 읽을 수 있습니다.

두 번째 실험은 공유 스키마와 실제 명령을 사용합니다. 9,000 + 600을 커밋한 뒤 경쟁 요청은 9,600 + 600 > 10,000을 관찰하므로 정확히 하나만 성공합니다. 본문 하나가 최대 720자라는 앞 장 계약도 지킵니다.


잠금, 외부 I/O, 아웃박스

잠금은 UPDATE가 끝났을 때가 아니라 트랜잭션이 커밋 또는 롤백될 때 풀립니다. 트랜잭션 안에서 HTTP 호출이나 파일 변환을 기다리면 잠금과 풀 연결을 함께 오래 점유합니다.

게시글과 알림 전송 의도를 원자적으로 남겨야 한다면 공유 post_event_outboxPostCreatedEvent를 같은 트랜잭션으로 기록합니다. 실제 네트워크 전송은 커밋된 아웃박스를 별도 워커가 처리합니다.

post_event_outbox.post_id의 유일 제약은 같은 게시글 이벤트의 중복 기록을 막습니다. published_atnull인 행이 아직 발행하지 않은 작업이며, 발행기는 재시도를 고려해 소비 측도 멱등하게 설계합니다.

커밋 직전 프로세스가 죽어 결과를 호출자가 모를 수 있으므로 posts(member_id, client_request_id) 유일 제약을 멱등 경계로 사용합니다. 같은 요청 ID를 재시도하면 두 게시글을 만들지 않습니다.


연습 문제

잠금 소유자가 게시일별 사용량을 늘린 뒤 외부 호출을 3초 기다리는 경우와, 외부 호출을 트랜잭션 앞에서 끝낸 경우를 비교하세요.

연결 점유 시간, 경쟁 요청의 잠금 대기, 실패 뒤 재시도 가능 시점을 각각 기록합니다.

해설 보기

외부 결과가 DB 커밋의 필수 입력이면 먼저 받아 검증한 뒤 짧은 DB 트랜잭션을 시작합니다. 커밋 뒤 보내도 되는 알림은 아웃박스로 분리합니다.

잠금 대기 시간만 줄이는 것이 목표가 아닙니다. 트랜잭션 중 실패할 수 있는 작업을 줄여 커밋 결과가 불확실한 구간과 풀 고갈 가능성도 함께 줄입니다.

다음 문서에서는 이 SQL을 한 연결에서 실행하고 성공·업무 실패·정리 실패마다 커밋과 롤백을 정확히 소유하는 수동 트랜잭션을 만듭니다.