ACID와 두 데이터베이스 연결
게시글 등록과 일일 집계를 통해 원자성, 일관성, 격리, 지속성을 구분하고 자동 커밋·가시성·잠금·롤백을 두 연결 실험으로 확인합니다.
트랜잭션은 애노테이션 이름이 아니라 여러 SQL을 하나의 성공 또는 실패 단위로 묶는 경계입니다.
데이터베이스에서 세션은 클라이언트 연결 하나가 유지하는 설정·트랜잭션 문맥을 뜻합니다.
웹 로그인 세션과 다른 개념입니다.
JDBC에서는 보통 한 Connection이 한 DB 세션과 트랜잭션 문맥을 대표합니다.
같은 연결 안의 변경은 커밋 전에도 자신에게 보이지만 다른 연결에는 격리 규칙에 따라 보이지 않거나 잠금 영향을 줍니다.
자동 커밋
JDBC 연결의 기본 자동 커밋은 흔히 true입니다.
삽입 하나가 성공하면 구문 완료 시 커밋되고 다음 갱신이 실패해도 앞 삽입은 남습니다.
두 SQL이 “게시글 등록과 일일 합계 갱신”이라는 하나의 업무 단위라면 자동 커밋을 끄고 명시적으로 묶어야 합니다.
package board.jdbc;
import java.sql.Connection;
import java.sql.Date;
import java.sql.SQLException;
public final class PostRegistrationSql {
private static final String INSERT_POST = """
insert into posts(
author_id, title, content, created_on, version)
values (?, ?, ?, ?, 0)
""";
private static final String UPDATE_DAILY = """
update daily_post_stats
set total_characters = total_characters + ?
where author_id = ? and created_on = ?
""";
public void execute(Connection connection, NewPost post)
throws SQLException {
try (var insert = connection.prepareStatement(INSERT_POST)) {
insert.setLong(1, post.authorId());
insert.setString(2, post.title());
insert.setString(3, post.content());
insert.setDate(4, Date.valueOf(post.createdOn()));
if (insert.executeUpdate() != 1) {
throw new SQLException("post insert count was not one");
}
}
try (var update = connection.prepareStatement(UPDATE_DAILY)) {
update.setInt(1, post.content().length());
update.setLong(2, post.authorId());
update.setDate(3, Date.valueOf(post.createdOn()));
if (update.executeUpdate() != 1) {
throw new SQLException("daily aggregate row missing");
}
}
}
}이 클래스는 연결 소유자나 트랜잭션 매니저가 아닙니다. 전달받은 같은 연결에서 두 SQL만 실행하고 커밋하지 않습니다. 트랜잭션 경계를 리포지토리 도우미 안에 숨기지 않으면 서비스가 다른 리포지토리 작업까지 같은 단위로 묶을 수 있습니다.
원자성·일관성
원자성은 트랜잭션 안의 변경이 모두 반영되거나 모두 버려지는 성질입니다. 두 번째 일일 합계 갱신이 실패하면 첫 게시글 삽입도 롤백돼야 합니다. 일관성은 트랜잭션 전후 데이터베이스가 정의한 제약 조건과 애플리케이션 불변식을 만족하는 상태라는 의미입니다. DB가 모든 업무 규칙을 자동으로 아는 것은 아닙니다.
본문 길이 확인, 외래 키, 고유 제약 조건은 DB가 커밋 시점까지 지킬 수 있습니다. “하루 작성 분량 10,000자 이하”처럼 여러 행을 보는 규칙은 적절한 잠금·격리 또는 집계 행의 조건부 갱신이 필요합니다. 애플리케이션이 먼저 조회하고 나중에 삽입하는 것만으로 동시 요청을 막지 못할 수 있습니다.
| ACID | 회원 게시판 질문 | 보장 주체 |
|---|---|---|
| 원자성 | 게시글과 집계가 함께 반영되는가 | 커밋·롤백 |
| 일관성 | 본문 길이 범위와 중복 규칙이 유지되는가 | 제약 조건·도메인 |
| 격리 | 동시 등록이 서로 어떤 상태를 보는가 | 격리·잠금 |
| 지속성 | 성공 응답 뒤 재시작에도 남는가 | DB WAL·저장소 |
commit이 반환됐다고 원격 복제본까지 즉시 반영된다고 가정하지 않습니다.
지속성과 복제 일관성은 DB 설정에 따라 다릅니다.
쓰기 후 읽기가 복제본에서 필요한 경우 라우팅과 지연 정책을 설계합니다.
격리 수준
더티 읽기, 반복 불가능 읽기, 팬텀 읽기는 서로 다른 현상입니다.
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 같은 행의 여러 버전을 이용해 읽기와 쓰기의 충돌을 줄이는 방식입니다.
JDBC 격리 상수 이름만 외우지 않고 실제 데이터베이스가 MVCC와 잠금으로 어떤 현상을 허용하는지 공급자 문서를 봅니다.
같은 READ_COMMITTED라도 구현 세부와 잠금 대기가 다를 수 있습니다.
회원 게시판에서 두 사용자가 같은 레코드를 수정하는 갱신 유실은 격리 수준만 높이지 않고 버전 조건으로 명시적으로 탐지할 수 있습니다. 하루 합계처럼 조건식을 두고 삽입하는 쓰기 편향은 고유·확인 제약 조건만으로 해결되지 않을 수 있어 애그리거트 행 잠금이나 직렬화 가능 트랜잭션을 검토합니다.
격리를 무조건 SERIALIZABLE로 올리면 정확성이 자동 완성되는 대신 충돌 중단과 재시도가 늘 수 있습니다. 트랜잭션을 짧게 만들고 재시도 가능한 명령에 멱등성을 제공해야 합니다. 읽기 전용 보고서와 변경 명령의 요구를 분리합니다.
미커밋 데이터 관찰
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
class TransactionVisibilityTest {
@Test
void 다른_connection은_commit_전_row를_보지_못하고_rollback_후에도_없다()
throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:acid;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
try (var setup = dataSource.getConnection();
var statement = setup.createStatement()) {
statement.execute("""
create table posts(
id bigint primary key,
title varchar(80) not null)
""");
}
try (var writer = dataSource.getConnection();
var observer = dataSource.getConnection()) {
writer.setAutoCommit(false);
try (var insert = writer.prepareStatement(
"insert into posts values (?, ?)")) {
insert.setLong(1, 1L);
insert.setString(2, "Transactions");
insert.executeUpdate();
}
assertThat(count(writer)).isEqualTo(1);
assertThat(count(observer)).isZero();
writer.rollback();
assertThat(count(writer)).isZero();
assertThat(count(observer)).isZero();
}
}
private int count(java.sql.Connection connection) throws Exception {
try (var statement = connection.createStatement();
var result = statement.executeQuery(
"select count(*) from posts")) {
result.next();
return result.getInt(1);
}
}
}writer before rollback = 1
observer before rollback = 0
writer after rollback = 0
observer after rollback = 0
uncommitted row leaked = false같은 연결에서 개수가 1인 것은 자신이 쓴 값을 읽는 동작입니다. 관찰자가 0인 것은 H2 기본 격리에서 더티 읽기가 차단됐기 때문입니다. 이 테스트를 운영 DB 통합 환경에서도 실행해 기대 격리를 고정합니다.
변경 잠금과 롤백
UPDATE는 변경 대상 행에 배타적인 잠금을 잡습니다.
다른 연결의 일반 SELECT는 MVCC 스냅샷을 읽어 바로 끝날 수 있지만, 같은 행을 변경하거나 SELECT ... FOR UPDATE로 변경 의도를 선언하면 잠금을 얻을 때까지 기다립니다.
이 차이를 알아야 “조회는 되는데 저장만 멈춘다”는 증상을 설명할 수 있습니다.
다음 예제는 첫 번째 연결이 아직 커밋하지 않은 갱신 잠금을 잡고, 두 번째 연결이 짧은 잠금 타임아웃 뒤 실패하는 과정을 실행합니다. 첫 번째 연결이 롤백하면 변경과 잠금이 함께 사라지고 두 번째 연결이 원래 값을 다시 잠글 수 있습니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import java.sql.Connection;
import java.sql.SQLException;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
class RowLockLifecycleTest {
@Test
void update_lock은_timeout을_만들고_rollback이_해제한다()
throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:row_lock;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
try (var setup = dataSource.getConnection();
var statement = setup.createStatement()) {
statement.execute("""
create table daily_post_stats(
author_id bigint primary key,
total_characters integer not null)
""");
statement.execute("""
insert into daily_post_stats values (41, 480)
""");
}
try (var owner = dataSource.getConnection();
var contender = dataSource.getConnection()) {
owner.setAutoCommit(false);
contender.setAutoCommit(false);
try (var timeout = contender.createStatement()) {
timeout.execute("set lock_timeout 200");
}
assertThat(addCharacters(owner, 41L, 30)).isEqualTo(1);
SQLException timeoutFailure = catchThrowableOfType(
SQLException.class,
() -> lockedTotal(contender, 41L));
assertThat(timeoutFailure)
.isNotNull()
.hasMessageContaining("Timeout");
contender.rollback();
owner.rollback();
assertThat(lockedTotal(contender, 41L)).isEqualTo(480);
contender.rollback();
}
}
private int addCharacters(
Connection connection,
long authorId,
int characters
) throws SQLException {
try (var statement = connection.prepareStatement("""
update daily_post_stats
set total_characters = total_characters + ?
where author_id = ?
""")) {
statement.setInt(1, characters);
statement.setLong(2, authorId);
return statement.executeUpdate();
}
}
private int lockedTotal(
Connection connection,
long authorId
) throws SQLException {
try (var statement = connection.prepareStatement("""
select total_characters
from daily_post_stats
where author_id = ?
for update
""")) {
statement.setLong(1, authorId);
try (var result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("daily aggregate row missing");
}
return result.getInt(1);
}
}
}
}owner update count = 1
contender SELECT FOR UPDATE = lock timeout
owner rollback = total_characters 480, lock released
contender retry = 480, lock acquiredSET LOCK_TIMEOUT은 H2 문법입니다.
PostgreSQL의 lock_timeout, MySQL의 InnoDB 잠금 대기 타임아웃처럼 운영 환경 DB에서 대응 설정과 예외 코드를 별도로 확인합니다.
타임아웃은 잠금을 없애는 기능이 아니라 무한 대기를 실패로 바꾸는 경계입니다.
여러 행을 잠글 때는 트랜잭션마다 같은 순서로 획득해 교착 상태 가능성을 줄이고, 외부 HTTP 호출을 잠금 보유 구간에 넣지 않습니다. 타임아웃이나 교착 상태 뒤 재시도는 명령이 멱등하고 전체 기한이 남아 있을 때만 수행합니다. 롤백과 연결 반환까지 완료되지 않으면 풀의 다음 사용자가 이전 트랜잭션 상태를 물려받을 수 있습니다.
실패 원인 보존
네트워크가 끊긴 순간 커밋이 성공했는지 클라이언트가 모를 수 있습니다. 단순히 같은 명령을 다시 실행하면 중복 행이 생길 수 있어 멱등성 키와 결과 조회가 필요합니다. 커밋 예외는 “무조건 롤백됐다”는 증거가 아닙니다.
업무 SQL 예외 뒤 롤백도 실패할 수 있습니다.
롤백 예외로 원래 예외를 덮지 않고 억제된 예외로 붙이거나 로그에 둘 다 남깁니다.
연결 종료 역시 실패할 수 있으므로 try-with-resources 구문의 억제된 예외 구조를 이해합니다.
트랜잭션 안에서 이메일이나 외부 HTTP를 먼저 보내면 DB 롤백이 외부 효과를 되돌리지 못합니다. 아웃박스 패턴처럼 DB 상태와 전송할 이벤트를 함께 커밋하고 별도 워커가 전달합니다. ACID는 한 데이터베이스 트랜잭션에 참여한 리소스 범위의 보장입니다.
연습 문제
한 회원이 하루에 작성한 본문 합계가 10,000자를 넘지 않게 동시 등록 두 건을 처리하세요. 각 요청이 현재 8,000자를 읽고 1,500자씩 추가하려는 경쟁을 재현하고, 애그리거트 행 조건부 갱신 또는 잠금으로 하나만 성공하게 만드세요.
해설 보기
daily_post_stats 한 행을 update daily_post_stats set total_characters=total_characters+? where author_id=? and created_on=? and total_characters+? <= 10000으로 갱신하면 갱신 개수가 1인 요청만 게시글 삽입을 진행할 수 있습니다.
두 SQL은 같은 트랜잭션과 연결에 있어야 합니다.
package board.jdbc;
public final class DailyContentLimitExceededException extends RuntimeException {
private final int limit;
public DailyContentLimitExceededException(int limit) {
super("daily post content limit exceeded");
this.limit = limit;
}
public int limit() {
return limit;
}
}두 스레드를 장벽에서 동시에 시작하고 성공 1건, 한도 실패 1건, 최종 합계 9,500자, 게시글 행 1개 추가를 확인합니다. 잠금 대기 타임아웃과 교착 상태는 제한된 재시도 정책을 별도로 둡니다.
다음 문서에서는 이 커밋·롤백 규칙을 원시 JDBC 코드로 직접 구현하고, 자동 커밋 복원과 예외 보존이 왜 반복되는지 확인합니다.