트랜잭션과 ACID
두 JDBC 연결로 커밋 가시성과 행 잠금 수명, 게시일별 본문 한도의 동시성 원자성을 검증합니다.
트랜잭션은 여러 SQL을 하나의 업무 단위로 묶습니다.
이 장의 READ_COMMITTED 두 연결에서는 한 연결의 미커밋 변경을 그 연결이 읽을 수 있지만, 다른 연결의 일반 읽기는 마지막 커밋 상태를 봅니다. 같은 트랜잭션의 변경은 함께 커밋하거나 롤백합니다. 다른 연결이 새 커밋을 언제 관찰하는지는 선택한 격리 수준과 읽기 조건도 확인해야 합니다.
이 경계는 메서드 호출 수가 아니라 같은 Connection과 명시적인 완료 시점으로 결정됩니다.
ACID를 관찰 가능한 계약으로 바꾸기
ACID는 외울 약어가 아니라 실패와 동시 실행에서 확인할 속성입니다.
| 속성 | 게시글 등록에서 지켜야 할 사실 | 이 문서의 oracle |
|---|---|---|
| 원자성 | 일일 사용량과 게시글이 함께 반영되거나 함께 취소된다 | 잠금 소유자가 롤백한 뒤 사용량 480을 다시 읽는다. 사용량·게시글의 동시 롤백은 이 관찰만으로 확인하지 않는다 |
| 일관성 | FK·길이·유일성·일일 한도가 항상 성립한다 | 공유 schema.sql 제약과 한도 행 갱신 결과를 확인한다 |
| 격리성 | 미완료 변경을 다른 트랜잭션이 완료된 사실처럼 쓰지 않는다 | 두 연결의 가시성과 행 잠금 대기를 확인한다 |
| 지속성 | 커밋 완료 데이터가 장애 뒤에도 남아야 한다 | 이 문서의 두 세션 테스트만으로는 증명하지 않는다 |
격리는 “항상 직렬 실행”이라는 뜻이 아닙니다. 선택한 격리 수준과 SQL 모양에 따라 허용되는 관찰이 달라집니다.
이 장에서는 기본 계약을 분명하게 보기 위해 두 연결 모두 READ_COMMITTED를 사용합니다. 아래 테스트가 증명하는 범위도 이미 열린 두 세션 사이의 커밋 전후 가시성까지입니다. 재연결, 프로세스 재시작, 저장 장치 flush를 거친 지속성은 별도의 통합·복구 테스트가 필요합니다.
한 연결 안의 게시글 등록 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행이면 같은 트랜잭션에서 현재 사용량을 읽어 실제 한도 초과와 잘못된 fixture를 구분합니다. 한도 초과만 도메인 예외이며, 존재해야 할 통계 행이 없거나 경합 없이 갱신이 사라진 경우는 데이터 계약 오류입니다.
삽입 성공도 JDBC 메타데이터를 낙관하지 않습니다. 생성 키 결과가 정확히 1행이고 그 값이 양수일 때만 게시글 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 세션이 관찰한 행 수를 exact oracle로 둡니다. 이는 교차 세션 가시성의 증거이지 재연결이나 재시작 뒤 지속성의 증거가 아닙니다.
격리 anomaly를 제어 수단으로 바꾼다
| anomaly | 제어 수단 | 적용 기준 |
|---|---|---|
| LOST UPDATE | version 조건 | UPDATE의 이전 version 일치와 영향 행 수로 충돌을 잡는다 |
| PREDICATE RACE | lock 또는 SERIALIZABLE | 범위 조건을 만족하는 동시 삽입·변경을 직렬화한다 |
| LONG TX | scope 축소 | 외부 I/O를 트랜잭션 밖으로 옮겨 lock 보유 시간을 줄인다 |
| ABORT | bounded retry | 명령 멱등성이 있을 때 conflict를 처음부터 제한 횟수만 재실행한다 |
행 잠금은 트랜잭션 완료까지 유지된다
조건부 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 관찰 | 정리 후 잠금 요청이 성공합니다. |
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자 이하여야 합니다.
한도 경계와 잠금 대상을 직접 구하기 두 요청은 같은 회원·게시일의 통계 행을 사용하며, 도중에 정책이나 다른 데이터가 바뀌지 않고 INSERT도 성공한다고 가정합니다. 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 동시 실행 결과가 아닙니다.
잠금, 외부 I/O, 아웃박스
잠금은 UPDATE가 끝났을 때가 아니라 트랜잭션이 커밋 또는 롤백될 때 풀립니다. 트랜잭션 안에서 HTTP 호출이나 파일 변환을 기다리면 잠금과 풀 연결을 함께 오래 점유합니다.
게시글과 알림 전송 의도를 원자적으로 남겨야 한다면 공유 post_event_outbox에 PostCreatedEvent를 같은 트랜잭션으로 기록합니다. 실제 네트워크 전송은 커밋된 아웃박스를 별도 워커가 처리합니다.
post_event_outbox.post_id의 유일 제약은 같은 게시글 이벤트의 중복 기록을 막습니다. published_at이 null인 행이 아직 발행하지 않은 작업이며, 발행기는 재시도를 고려해 소비 측도 멱등하게 설계합니다.
커밋 직전 프로세스가 죽어 결과를 호출자가 모를 수 있으므로 posts(member_id, client_request_id) 유일 제약을 멱등 경계로 사용합니다. 같은 요청 ID를 재시도하면 두 게시글을 만들지 않습니다.
연습 문제
잠금 소유자가 게시일별 사용량을 늘린 뒤 외부 호출을 3초 기다리는 경우와, 외부 호출을 트랜잭션 앞에서 끝낸 경우를 비교하세요.
연결 점유 시간, 경쟁 요청의 잠금 대기, 실패 뒤 재시도 가능 시점을 각각 기록합니다.
해설 보기
외부 결과가 DB 커밋의 필수 입력이면 먼저 받아 검증한 뒤 짧은 DB 트랜잭션을 시작합니다. 커밋 뒤 보내도 되는 알림은 아웃박스로 분리합니다.
잠금 대기 시간만 줄이는 것이 목표가 아닙니다. 트랜잭션 중 실패할 수 있는 작업을 줄여 커밋 결과가 불확실한 구간과 풀 고갈 가능성도 함께 줄입니다.
다음 문서에서는 이 SQL을 한 연결에서 실행하고 성공·업무 실패·정리 실패마다 커밋과 롤백을 정확히 소유하는 수동 트랜잭션을 만듭니다.