트랜잭션과 ACID
두 JDBC 연결로 커밋 가시성과 행 잠금 수명, 게시일별 본문 한도의 동시성 원자성을 검증합니다.
트랜잭션은 여러 SQL을 하나의 업무 단위로 묶습니다. 이 글의 두 READ_COMMITTED 연결에서는 한 연결이 아직 커밋하지 않은 변경을 그 연결은 읽을 수 있지만, 다른 연결의 일반 읽기는 마지막으로 커밋된 상태를 봅니다. 같은 트랜잭션의 변경은 함께 커밋되거나 함께 롤백되며, 다른 연결이 새 커밋을 언제 보는지는 고른 격리 수준과 읽기 조건도 함께 확인해야 합니다. 트랜잭션의 범위는 메서드를 몇 번 호출했는지가 아니라, 같은 Connection과 명시적인 완료 시점으로 정해집니다.
ACID를 확인할 수 있는 속성으로 바꾸기
ACID는 외울 약어가 아니라 실패와 동시 실행에서 확인할 속성입니다. 네 성질의 정의는 데이터베이스 교재의 트랜잭션과 멱등성에서 MySQL 예제와 함께 다루고, 여기서는 게시글 등록에서 각 성질을 무엇으로 확인하는지 정리합니다.
| 속성 | 게시글 등록에서 지켜야 할 사실 | 이 글에서 확인하는 것 |
|---|---|---|
| 원자성 | 하루 사용량과 게시글이 함께 반영되거나 함께 취소됨 | 잠금을 쥔 쪽이 롤백한 뒤 사용량 480을 다시 읽음. 사용량과 게시글의 동시 롤백은 이것만으로 확인하지 않음 |
| 일관성 | 외래 키, 길이, 유일성, 하루 한도가 언제나 성립 | 공유 schema.sql의 제약과 한도 행의 갱신 결과 |
| 격리성 | 끝나지 않은 변경을 다른 트랜잭션이 끝난 사실처럼 쓰지 않음 | 두 연결의 가시성과 행 잠금의 대기 |
| 지속성 | 커밋된 데이터가 장애 뒤에도 남음 | 이 글의 두 세션 테스트만으로는 확인하지 않음 |
격리는 언제나 하나씩 차례로 실행한다는 뜻이 아닙니다. 고른 격리 수준과 SQL의 모양에 따라 허용되는 결과가 달라집니다. 표준 SQL의 격리 수준이 각각 어떤 이상 현상을 허용하는지와 FOR UPDATE 같은 잠금 읽기는 데이터베이스 교재의 격리 수준과 잠금에서 MySQL의 두 연결로 비교합니다. 이 글에서는 기본 동작을 분명히 보려고 두 연결 모두 READ_COMMITTED를 씁니다. 아래 테스트가 보여 주는 범위도 이미 열린 두 세션 사이에서 커밋 전후의 가시성까지이며, 재연결이나 프로세스 재시작, 저장 장치의 플러시를 거친 지속성은 별도의 통합 테스트와 복구 테스트가 필요합니다.
연결 하나 안에서 실행하는 게시글 등록 SQL
앞 장의 CreatePostUseCase.CreatePostCommand와 DailyLimitExceededException을 그대로 씁니다. 새 명령이나 같은 이름의 예외를 다시 만들지 않습니다. 저장소는 연결을 얻거나 커밋하지 않고, 호출한 쪽이 넘긴 연결에서 게시일별 사용량을 조건부로 늘린 뒤 게시글을 저장합니다.
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행이면, 같은 트랜잭션에서 현재 사용량을 읽어 실제로 한도를 넘었는지 아니면 예제 데이터가 잘못되었는지 구분합니다. 한도 초과만 도메인 예외이며, 있어야 할 통계 행이 없거나 경쟁도 없이 갱신이 사라진 경우는 데이터의 규칙이 깨진 오류입니다.
삽입에 성공했을 때도 JDBC 메타데이터를 낙관하지 않습니다. 생성 키의 결과가 정확히 한 행이고 그 값이 양수일 때만 게시글 ID로 돌려주며, 키가 없거나 여럿이거나 0 이하이면 모두 SQL 규칙의 실패입니다.
커밋 전후의 가시성
첫 번째 연결이 삽입한 행은 그 연결에서 바로 조회할 수 있습니다. READ_COMMITTED인 두 번째 연결은 첫 번째 연결이 커밋하기 전에는 그 행을 볼 수 없습니다.
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, '[email protected]', '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 세션이 본 행 수를 기대 결과로 삼습니다. 이것은 세션 사이의 가시성을 보여 줄 뿐, 재연결이나 재시작 뒤의 지속성을 보여 주지는 않습니다.
격리 이상 현상을 막는 수단
| 이상 현상 | 막는 수단 | 적용 기준 |
|---|---|---|
| 갱신 손실 | 버전 조건 | UPDATE에서 이전 버전이 같은지와 영향받은 행 수로 충돌을 잡음 |
| 조건 범위의 경쟁 | 잠금이나 SERIALIZABLE | 범위 조건에 맞는 동시 삽입과 변경을 직렬화 |
| 긴 트랜잭션 | 범위 축소 | 외부 I/O를 트랜잭션 밖으로 옮겨 잠금을 쥐는 시간을 줄임 |
| 중단 | 제한된 재시도 | 명령이 멱등할 때 충돌을 처음부터 정해진 횟수만 다시 실행 |
행 잠금은 트랜잭션이 끝날 때까지 유지됩니다
조건부 UPDATE도 바꾸려는 행을 잠급니다. 잠금을 쥔 트랜잭션이 끝나기 전까지, 경쟁하는 트랜잭션의 잠금 읽기는 기다리거나 타임아웃됩니다.
행 잠금은 트랜잭션 완료까지 유지된다
owner 510/contender 480·잠금 timeout·rollback·재시도 480으로 같은 행과 두 TX 상태가 바뀐다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
일반 읽기와 잠금 읽기는 다른 값을 관찰한다
READ_COMMITTED인 두 H2 연결에서 소유자는 미커밋 510, 경쟁자의 일반 읽기는 마지막 커밋 480을 봅니다. 경쟁 잠금 읽기는 timeout 뒤 양쪽 rollback을 거쳐 480을 잠가 읽습니다.
ACTUAL H2 LOCK FIXTURE
잠금은 UPDATE 종료 뒤에도 남는다
owner가 30을 더한 뒤 아직 완료하지 않았으므로 경쟁 FOR UPDATE는 기다립니다. test는 contender lock_timeout=100과 vendor errorCode=50200을 확인합니다. owner rollback, contender rollback 뒤 다시 잠가480을 읽고 마지막에도 rollback합니다.
ACTUAL TWO-SESSION VISIBILITY
같은 observer session에서 커밋 전후 비교
별도 rollback test는 writer1/observer0 → 양쪽0입니다. commit test는 이미 열린 READ_COMMITTED observer에서0 → commit 뒤1을 확인합니다. 프로세스 재시작·재연결·장애 뒤 지속성을 검증한 것은 아닙니다.
ACTUAL CONCURRENT H2 QUOTA
9,000 + 600 중 정확히 하나만 커밋
CyclicBarrier로 두 연결을 함께 시작합니다. 한 요청은9,600으로 커밋하고 다른 요청은 limit10,000/used9,600/attempt600 예외로 rollback합니다. 최종 quota9,600, posts1행을 확인하며 각 본문은 최대720 계약을 지킵니다.
SQL / CONSTRAINT / ADDITIONAL
조건부 quota UPDATE와 INSERT를 같은 연결로
SQL helper는 연결을 얻거나 완료하지 않습니다. 전달받은 연결로 현재 회원 정책을 참조해 quota를 늘리고 한 양수 generated key의 INSERT를 수행합니다. outbox 발행·운영 DB 격리·복구는 별도 구현과 검사 범위입니다.
H2 READ_COMMITTED의 owner가 480을510으로 바꿔 자신은510을 본다. contender의 일반 SELECT는480이고 FOR UPDATE는 H2 50200으로 timeout된다. owner와 contender rollback 뒤 contender가 재시도해480을 잠근다. 이 관찰은 장애 뒤 지속성 검사가 아니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- H2 READ_COMMITTED의 owner가 480을 510으로 바꿔 자신은 510을 본다. contender의 일반 SELECT는 480이고 FOR UPDATE는 H2 50200으로 timeout된다. owner와 contender rollback 뒤 contender가 재시도해 480을 잠근다. 이 관찰은 장애 뒤 지속성 검사가 아니다.
- owner(소유자), DB, contender(경쟁자)의 세 시간축이다. RC는 두 연결의 READ_COMMITTED다. 원래 가로 도표와 같은 아홉 방향 간선/값/timeout 50200/양쪽 rollback 후 재시도 480을 유지한다. 세로 간격은 순서를 위한 배치이며 시간 길이에 비례하지 않는다. 장애 뒤 지속성 관찰이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 행 잠금은 트랜잭션 완료까지 유지된다 |
| 2 | H2 READ_COMMITTED의 owner가 480을 510으로 바꿔 자신은 510을 본다. contender의 일반 SELECT는 480이고 FOR UPDATE는 H2 50200으로 timeout된다. owner와 contender rollback 뒤 contender가 재시도해 480을 잠근다. 이 관찰은 장애 뒤 지속성 검사가 아니다. |
| 21 | owner(소유자), DB, contender(경쟁자)의 세 시간축이다. RC는 두 연결의 READ_COMMITTED다. 원래 가로 도표와 같은 아홉 방향 간선/값/timeout 50200/양쪽 rollback 후 재시도 480을 유지한다. 세로 간격은 순서를 위한 배치이며 시간 길이에 비례하지 않는다. 장애 뒤 지속성 관찰이 아니다. |
| 39 | owner가 30을 더한 뒤 아직 완료하지 않았으므로 경쟁 FOR UPDATE는 기다립니다. test는 contender lock_timeout=100과 vendor errorCode=50200을 확인합니다. owner rollback, contender rollback 뒤 다시 잠가 480을 읽고 마지막에도 rollback합니다. |
| 42 | 별도 rollback test는 writer 1/observer 0 → 양쪽 0입니다. commit test는 이미 열린 READ_COMMITTED observer에서 0 → commit 뒤 1을 확인합니다. 프로세스 재시작·재연결·장애 뒤 지속성을 검증한 것은 아닙니다. |
| 45 | CyclicBarrier로 두 연결을 함께 시작합니다. 한 요청은 9,600으로 커밋하고 다른 요청은 limit 10,000/used 9,600/attempt 600 예외로 rollback합니다. 최종 quota 9,600, posts 1행을 확인하며 각 본문은 최대 720 계약을 지킵니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| owner가 변경 H2 READ_COMMITTED에서 owner가 480을 510으로 변경합니다. | owner 관찰: 510 · 원래 값: 480 · commit: 하지 않음 | owner transaction 안의 값이 바뀝니다. |
| contender가 읽고 잠금 대기 contender의 일반 SELECT는 480이고 FOR UPDATE는 timeout됩니다. | contender SELECT: 480 · FOR UPDATE: H2 50200 · owner: 변경 유지 | 일반 읽기와 잠금 획득의 결과가 다릅니다. |
| 두 transaction rollback owner와 contender를 rollback합니다. | owner: rollback · contender: rollback · 값: 480 | 미완료 변경과 잠금 경쟁 상태가 정리됩니다. |
| 잠금 재시도 contender가 다시 시도하여 480을 잠급니다. | 재시도 값: 480 · 잠금: 획득 · 범위: H2 fixture 관찰 | 정리 후 잠금 요청이 성공합니다. |
행 잠금은 트랜잭션 완료까지 유지된다
owner 510/contender 480·잠금 timeout·rollback·재시도 480으로 같은 행과 두 TX 상태가 바뀐다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
일반 읽기와 잠금 읽기는 다른 값을 관찰한다
READ_COMMITTED인 두 H2 연결에서 소유자는 미커밋 510, 경쟁자의 일반 읽기는 마지막 커밋 480을 봅니다. 경쟁 잠금 읽기는 timeout 뒤 양쪽 rollback을 거쳐 480을 잠가 읽습니다.
ACTUAL H2 LOCK FIXTURE
잠금은 UPDATE 종료 뒤에도 남는다
owner가 30을 더한 뒤 아직 완료하지 않았으므로 경쟁 FOR UPDATE는 기다립니다. test는 contender lock_timeout=100과 vendor errorCode=50200을 확인합니다. owner rollback, contender rollback 뒤 다시 잠가480을 읽고 마지막에도 rollback합니다.
ACTUAL TWO-SESSION VISIBILITY
같은 observer session에서 커밋 전후 비교
별도 rollback test는 writer1/observer0 → 양쪽0입니다. commit test는 이미 열린 READ_COMMITTED observer에서0 → commit 뒤1을 확인합니다. 프로세스 재시작·재연결·장애 뒤 지속성을 검증한 것은 아닙니다.
ACTUAL CONCURRENT H2 QUOTA
9,000 + 600 중 정확히 하나만 커밋
CyclicBarrier로 두 연결을 함께 시작합니다. 한 요청은9,600으로 커밋하고 다른 요청은 limit10,000/used9,600/attempt600 예외로 rollback합니다. 최종 quota9,600, posts1행을 확인하며 각 본문은 최대720 계약을 지킵니다.
SQL / CONSTRAINT / ADDITIONAL
조건부 quota UPDATE와 INSERT를 같은 연결로
SQL helper는 연결을 얻거나 완료하지 않습니다. 전달받은 연결로 현재 회원 정책을 참조해 quota를 늘리고 한 양수 generated key의 INSERT를 수행합니다. outbox 발행·운영 DB 격리·복구는 별도 구현과 검사 범위입니다.
H2 READ_COMMITTED의 owner가 480을510으로 바꿔 자신은510을 본다. contender의 일반 SELECT는480이고 FOR UPDATE는 H2 50200으로 timeout된다. owner와 contender rollback 뒤 contender가 재시도해480을 잠근다. 이 관찰은 장애 뒤 지속성 검사가 아니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- H2 READ_COMMITTED의 owner가 480을 510으로 바꿔 자신은 510을 본다. contender의 일반 SELECT는 480이고 FOR UPDATE는 H2 50200으로 timeout된다. owner와 contender rollback 뒤 contender가 재시도해 480을 잠근다. 이 관찰은 장애 뒤 지속성 검사가 아니다.
- owner(소유자), DB, contender(경쟁자)의 세 시간축이다. RC는 두 연결의 READ_COMMITTED다. 원래 가로 도표와 같은 아홉 방향 간선/값/timeout 50200/양쪽 rollback 후 재시도 480을 유지한다. 세로 간격은 순서를 위한 배치이며 시간 길이에 비례하지 않는다. 장애 뒤 지속성 관찰이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 행 잠금은 트랜잭션 완료까지 유지된다 |
| 2 | H2 READ_COMMITTED의 owner가 480을 510으로 바꿔 자신은 510을 본다. contender의 일반 SELECT는 480이고 FOR UPDATE는 H2 50200으로 timeout된다. owner와 contender rollback 뒤 contender가 재시도해 480을 잠근다. 이 관찰은 장애 뒤 지속성 검사가 아니다. |
| 21 | owner(소유자), DB, contender(경쟁자)의 세 시간축이다. RC는 두 연결의 READ_COMMITTED다. 원래 가로 도표와 같은 아홉 방향 간선/값/timeout 50200/양쪽 rollback 후 재시도 480을 유지한다. 세로 간격은 순서를 위한 배치이며 시간 길이에 비례하지 않는다. 장애 뒤 지속성 관찰이 아니다. |
| 39 | owner가 30을 더한 뒤 아직 완료하지 않았으므로 경쟁 FOR UPDATE는 기다립니다. test는 contender lock_timeout=100과 vendor errorCode=50200을 확인합니다. owner rollback, contender rollback 뒤 다시 잠가 480을 읽고 마지막에도 rollback합니다. |
| 42 | 별도 rollback test는 writer 1/observer 0 → 양쪽 0입니다. commit test는 이미 열린 READ_COMMITTED observer에서 0 → commit 뒤 1을 확인합니다. 프로세스 재시작·재연결·장애 뒤 지속성을 검증한 것은 아닙니다. |
| 45 | CyclicBarrier로 두 연결을 함께 시작합니다. 한 요청은 9,600으로 커밋하고 다른 요청은 limit 10,000/used 9,600/attempt 600 예외로 rollback합니다. 최종 quota 9,600, posts 1행을 확인하며 각 본문은 최대 720 계약을 지킵니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| owner가 변경 H2 READ_COMMITTED에서 owner가 480을 510으로 변경합니다. | owner 관찰: 510 · 원래 값: 480 · commit: 하지 않음 | owner transaction 안의 값이 바뀝니다. |
| contender가 읽고 잠금 대기 contender의 일반 SELECT는 480이고 FOR UPDATE는 timeout됩니다. | contender SELECT: 480 · FOR UPDATE: H2 50200 · owner: 변경 유지 | 일반 읽기와 잠금 획득의 결과가 다릅니다. |
| 두 transaction rollback owner와 contender를 rollback합니다. | owner: rollback · contender: rollback · 값: 480 | 미완료 변경과 잠금 경쟁 상태가 정리됩니다. |
| 잠금 재시도 contender가 다시 시도하여 480을 잠급니다. | 재시도 값: 480 · 잠금: 획득 · 범위: H2 fixture 관찰 | 정리 후 잠금 요청이 성공합니다. |
그림은 소유자와 DB, 경쟁자의 세 시간 축으로 아홉 개의 사건을 보여 줍니다. 소유자가 480을 510으로 바꾸면 자신은 510을 보지만, 경쟁자의 일반 SELECT는 마지막으로 커밋된 480을 봅니다. 경쟁자의 FOR UPDATE는 H2의 50200으로 타임아웃되고, 양쪽이 롤백한 뒤 경쟁자가 다시 시도하면 480을 잠가 읽습니다. 세로 간격은 순서를 나타낼 뿐 시간의 길이에 비례하지 않으며, 이것은 장애 뒤의 지속성을 확인한 것이 아닙니다.
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, "[email protected]");
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자라는 앞 장의 규칙도 지킵니다.
예를 들어 조건부 UPDATE는 같은 회원과 게시일의 사용량 행 하나를 바꿉니다. 지금 9,000자이고 한도가 10,000자일 때 첫 요청 600자가 먼저 커밋되면, 다음 요청은 9,600자를 기준으로 판단합니다. 다음 요청도 600자라면 합계 10,200자로 한도를 넘지만, 400자라면 정확히 10,000자로 <= 조건을 만족합니다. 두 요청 각각의 본문은 720자 이하여야 합니다.
더 깊이: 기다렸다가 실행한 UPDATE는 어떤 값을 볼까
두 번째 요청의 조건부 UPDATE가 첫 번째 요청의 커밋을 기다렸다면, 명령을 시작할 때 커밋되어 있던 9,000이 아니라 그 뒤에 커밋된 9,600으로 다시 판단해야 한도가 지켜집니다. 이 동작은 데이터베이스가 정합니다. PostgreSQL 문서는 기본 격리 수준인 Read Committed에서 UPDATE, DELETE, SELECT FOR UPDATE가 명령을 시작한 시점에 커밋된 행만 찾는다고 설명합니다. 찾은 행을 다른 트랜잭션이 이미 바꾸고 있다면 그 트랜잭션이 끝날 때까지 기다리고, 상대가 커밋하면 바뀐 행에 대해 WHERE 조건을 다시 평가해 여전히 맞을 때만 갱신합니다. 그래서 조건을 UPDATE 문 안에 넣은 이 글의 방식은 Read Committed에서도 합계 10,200자를 만들지 않습니다.
격리 수준을 올리면 오히려 실패 방식이 바뀝니다. 같은 문서에 따르면 PostgreSQL의 Repeatable Read 트랜잭션은 자기가 시작한 뒤 다른 트랜잭션이 바꾸고 커밋한 행을 수정할 수 없으므로, 기다린 쪽이 could not serialize access due to concurrent update 오류로 롤백됩니다. 이때 애플리케이션은 트랜잭션 전체를 처음부터 다시 실행해야 합니다. 또 PostgreSQL은 네 가지 표준 격리 수준을 모두 받지만 내부 구현은 세 가지뿐이라서, Read Uncommitted를 요청해도 Read Committed처럼 동작합니다. 표준 표만 보고 격리 수준의 효과를 판단하지 말고, 운영 DB의 문서와 통합 테스트로 확인해야 하는 이유입니다.
잠금과 외부 I/O, 아웃박스
잠금은 UPDATE가 끝났을 때가 아니라 트랜잭션이 커밋되거나 롤백될 때 풀립니다. 트랜잭션 안에서 HTTP 호출이나 파일 변환을 기다리면 잠금과 풀의 연결을 함께 오래 쥐게 됩니다.
게시글과 알림 전송의 의도를 원자적으로 남겨야 한다면, 공유 post_event_outbox에 PostCreatedEvent를 같은 트랜잭션으로 기록합니다. 실제 네트워크 전송은 커밋된 아웃박스를 별도의 워커가 처리합니다. post_event_outbox.post_id의 유일성 제약은 같은 게시글 이벤트가 두 번 기록되는 것을 막고, published_at이 null인 행이 아직 발행하지 않은 작업입니다. 발행기는 재시도를 고려해 소비하는 쪽도 멱등하게 설계합니다.
커밋 직전에 프로세스가 죽으면 호출한 쪽이 결과를 모를 수 있으므로, posts(member_id, client_request_id)의 유일성 제약으로 중복을 막습니다. 같은 요청 ID로 다시 시도해도 게시글이 두 개 생기지 않습니다.
정리
READ_COMMITTED에서 한 연결의 커밋되지 않은 변경은 그 연결만 보고, 다른 연결의 일반 읽기는 마지막으로 커밋된 상태를 봅니다.- 트랜잭션의 범위는 같은
Connection과 명시적인 커밋·롤백 시점으로 정해지며, 두 세션의 가시성 테스트는 지속성까지 확인하지 않습니다. - 조건부
UPDATE는 한도를 같은 행에서 원자적으로 판단하고, 0행이면 같은 트랜잭션에서 한도 초과와 데이터 오류를 구분합니다. - 행 잠금은 트랜잭션이 끝날 때까지 유지되므로, 경쟁하는 잠금 읽기는 기다리거나 타임아웃됩니다.
- 트랜잭션 안에서 외부 I/O를 기다리지 않고, 전송 의도는 아웃박스에 같은 트랜잭션으로 남겨 워커가 처리하게 합니다.
연습 문제
1. 한도와 잠금 대상을 직접 구하기
두 요청은 같은 회원과 게시일의 통계 행을 쓰며, 도중에 정책이나 다른 데이터가 바뀌지 않고 INSERT도 성공한다고 가정합니다. 사용량이 9,000자, 한도가 10,000자일 때 600자와 400자 요청이 어느 순서로 커밋하든 마지막 사용량과 새 게시글 수는 얼마인가요? 두 요청이 모두 600자인 경우와 비교하세요. 회원이나 게시일이 다르다면 같은 한도 행을 두고 다툰다고 답할 수 있는지도 설명하세요.
해설 보기
600자와 400자는 합계 1,000자이므로 어느 순서로 실행해도 마지막 사용량은 10,000자, 새 게시글은 2행입니다. 400자가 먼저면 9,400을 거쳐 10,000이 되고, 600자가 먼저면 9,600을 거쳐 10,000이 됩니다. 두 600자 요청은 합계 1,200자이므로 먼저 커밋한 요청만 반영되어 9,600자와 새 게시글 1행이 되며, 뒤의 요청은 실패해 그 요청 전체가 롤백됩니다.
회원이나 게시일이 다르면 서로 다른 통계 행이므로, 이 행 하나를 두고 다투는 계산을 그대로 쓰지 않습니다. 다른 제약이나 잠금, INSERT의 실패가 없다는 전제도 필요합니다. 이 숫자는 조건부 SQL을 따라 계산한 것이며, 실제 H2에서 동시에 실행한 결과는 아닙니다.
2. 외부 호출을 기다리는 잠금
잠금을 쥔 쪽이 게시일별 사용량을 늘린 뒤 외부 호출을 3초 기다리는 경우와, 외부 호출을 트랜잭션보다 앞에서 끝낸 경우를 비교하려고 합니다. 연결을 쥐는 시간과 경쟁 요청의 잠금 대기, 실패한 뒤 다시 시도할 수 있는 시점을 각각 어떻게 기록하고 비교하나요?
해설 보기
외부 결과가 DB 커밋에 꼭 필요한 입력이라면 먼저 받아 검증한 뒤 짧은 DB 트랜잭션을 시작하고, 커밋한 뒤에 보내도 되는 알림은 아웃박스로 나눕니다. 첫 경우에는 연결을 쥐는 시간과 경쟁 요청의 잠금 대기가 모두 3초 이상으로 늘어나고, 경쟁 요청은 잠금 타임아웃으로 실패한 뒤 소유자가 끝난 다음에야 다시 시도할 수 있습니다. 둘째 경우에는 두 시간이 SQL을 실행하는 동안으로 줄어듭니다.
잠금 대기 시간만 줄이는 것이 목표가 아닙니다. 트랜잭션 안에서 실패할 수 있는 작업을 줄여, 커밋 결과가 불확실한 구간과 풀이 바닥날 가능성도 함께 줄입니다. 테스트에서는 각 경우의 연결 대여 시간과 경쟁 요청의 대기 시간, 실패 시각을 기록해 비교합니다.
다음 글 수동 JDBC 트랜잭션에서는 이 SQL을 한 연결에서 실행하고, 성공과 업무 실패, 정리 실패마다 커밋과 롤백을 누가 정확히 맡는지 정하는 수동 트랜잭션을 만듭니다.