수동 JDBC 트랜잭션
게시글 등록을 한 Connection에서 실행하며 자동 커밋 해제, 커밋, 롤백 예외 보존, 연결 상태 복원과 원자적 롤백을 실제 실패 테스트로 검증합니다.
원시 JDBC 트랜잭션은 setAutoCommit(false) 한 줄로 끝나지 않습니다.
같은 연결을 모든 SQL에 전달하고, 정상일 때 커밋하며, 모든 실패에서 롤백을 시도하고, 원래 예외를 보존한 뒤 연결을 반환해야 합니다.
풀 연결이라면 상태 복원까지 고려해야 다음 사용자에게 트랜잭션 설정이 새지 않습니다.
트랜잭션 소유권
애플리케이션 서비스가 업무 단위를 알고 트랜잭션을 시작하면 하위 리포지토리는 전달받은 연결에서 SQL만 실행합니다. 리포지토리 메서드가 각각 새 연결을 얻으면 겉보기에는 한 서비스 호출이어도 서로 다른 트랜잭션이 됩니다.
package board.jdbc;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
public final class ManualTransaction {
private final DataSource dataSource;
public ManualTransaction(DataSource dataSource) {
this.dataSource = dataSource;
}
public <T> T execute(SqlWork<T> work) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
boolean originalAutoCommit = connection.getAutoCommit();
Throwable failure = null;
try {
connection.setAutoCommit(false);
T result = work.run(connection);
connection.commit();
return result;
} catch (SQLException exception) {
failure = exception;
rollbackPreserving(connection, exception);
throw exception;
} catch (RuntimeException | Error exception) {
failure = exception;
rollbackPreserving(connection, exception);
throw exception;
} finally {
try {
connection.setAutoCommit(originalAutoCommit);
} catch (SQLException restoreFailure) {
if (failure != null) {
failure.addSuppressed(restoreFailure);
} else {
throw restoreFailure;
}
}
}
}
}
private void rollbackPreserving(
Connection connection,
Throwable original
) {
try {
connection.rollback();
} catch (SQLException rollbackFailure) {
original.addSuppressed(rollbackFailure);
}
}
@FunctionalInterface
public interface SqlWork<T> {
T run(Connection connection) throws SQLException;
}
}이 구현도 모든 문제를 해결한 운영 환경 트랜잭션 매니저는 아닙니다. 커밋 실패 결과의 불확실성, 중첩 트랜잭션, 격리, 타임아웃, 동기화 콜백이 빠져 있습니다. 다만 검사 예외, 런타임 예외, 오류에서 원래 예외를 추적해 롤백·상태 복원 실패를 억제된 예외로 보존합니다. 수동 코드가 얼마나 쉽게 복잡해지는지 드러내고 다음 절의 Spring 추상화가 해결하는 범위를 이해하기 위한 최소 구현입니다.
공유 Connection 전달
package board.jdbc;
import java.sql.Connection;
import java.sql.SQLException;
public final class ManualPostService {
private final ManualTransaction transaction;
private final ConnectedPostRepository posts;
private final ConnectedDailyPostRepository daily;
public ManualPostService(
ManualTransaction transaction,
ConnectedPostRepository posts,
ConnectedDailyPostRepository daily
) {
this.transaction = transaction;
this.posts = posts;
this.daily = daily;
}
public long register(NewPost draft) throws SQLException {
return transaction.execute(connection -> {
daily.addWithinLimit(
connection,
draft.authorId(),
draft.createdOn(),
draft.content().length());
return posts.insert(connection, draft);
});
}
public interface ConnectedPostRepository {
long insert(Connection connection, NewPost draft)
throws SQLException;
}
public interface ConnectedDailyPostRepository {
void addWithinLimit(
Connection connection,
long authorId,
java.time.LocalDate date,
int characters
) throws SQLException;
}
}연결 파라미터가 서비스와 리포지토리 API 전체에 퍼지는 것은 JDBC 트랜잭션 컨텍스트를 수동으로 전달한 결과입니다.
스레드-로컬 바인딩을 쓰면 시그니처에서 사라지지만 어떤 코드가 현재 트랜잭션 연결을 얻는지 규칙이 필요합니다.
Spring의 DataSourceUtils와 트랜잭션 매니저가 이 역할을 합니다.
트랜잭션 람다 안에서 다른 스레드를 시작하면 연결 컨텍스트가 자동으로 전달되지 않습니다. 하나의 JDBC 연결을 여러 스레드가 동시에 사용하지 않습니다. 비동기 작업은 커밋 뒤 별도 트랜잭션과 멱등 이벤트로 실행합니다.
예외별 롤백 정책
원시 JDBC에서는 예외 처리 범위를 직접 정합니다.
SQL 검사 예외뿐 아니라 도메인 런타임 예외에서도 롤백해야 합니다.
Error까지 잡아 롤백을 시도할지, 예외 처리 Throwable이 인터럽트와 치명적 오류를 어떻게 다룰지 신중해야 합니다.
Spring 기본 선언적 트랜잭션은 런타임 예외와 오류에서 롤백하고 검사 예외는 기본 커밋할 수 있어 필요하면 rollbackFor를 지정합니다.
예외 타입만으로 롤백 정책을 결정하지 않고 업무 의미를 봅니다. 검증은 트랜잭션 전에 끝내고, 중복 제약 조건과 낙관적 충돌은 롤백합니다. 외부 이메일 실패는 DB 트랜잭션에 포함되지 않으므로 아웃박스로 분리합니다.
저장점을 사용하면 트랜잭션 전체가 아니라 일부 작업만 되돌릴 수 있지만, 사용자에게 부분 성공을 정확히 표현해야 합니다. 일반 등록 흐름에 중첩 저장점을 남용하면 이해하기 어렵습니다. 배치 가져오기처럼 명시적인 부분 성공 결과가 필요한 경우에만 사용합니다.
SQL 실패 전체 롤백
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.sql.SQLException;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
class ManualTransactionRollbackTest {
@Test
void 두번째_SQL_실패는_첫번째_INSERT도_rollback한다()
throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:manualtx;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
try (var connection = dataSource.getConnection();
var statement = connection.createStatement()) {
statement.execute("create table first_change(id int primary key)");
statement.execute("create table second_change(id int primary key)");
statement.execute("insert into second_change values(1)");
}
var transaction = new ManualTransaction(dataSource);
assertThatThrownBy(() -> transaction.execute(connection -> {
try (var first = connection.prepareStatement(
"insert into first_change values(?)")) {
first.setInt(1, 1);
first.executeUpdate();
}
try (var second = connection.prepareStatement(
"insert into second_change values(?)")) {
second.setInt(1, 1);
second.executeUpdate();
}
return 1;
})).isInstanceOf(SQLException.class);
try (var connection = dataSource.getConnection()) {
assertThat(count(connection, "first_change")).isZero();
assertThat(count(connection, "second_change")).isEqualTo(1);
assertThat(connection.getAutoCommit()).isTrue();
}
}
private int count(java.sql.Connection connection, String table)
throws Exception {
String sql = switch (table) {
case "first_change" -> "select count(*) from first_change";
case "second_change" -> "select count(*) from second_change";
default -> throw new IllegalArgumentException("unknown table");
};
try (var statement = connection.createStatement();
var result = statement.executeQuery(sql)) {
result.next();
return result.getInt(1);
}
}
}first insert before failure = executed
second insert = unique violation
rollback = executed
first_change rows after request = 0
preexisting second_change rows = 1
autoCommit on new connection = true테이블 이름은 자리표시자 바인딩이 불가능하므로 테스트 도우미도 허용 목록 분기로 제한했습니다. 애플리케이션 쿼리에서 사용자 입력을 식별자에 직접 연결하지 않습니다.
연결 설정 복원
connection.setTransactionIsolation, setReadOnly, 네트워크 타임아웃을 트랜잭션마다 바꿀 수 있지만 풀 반환 전 초기화가 필요합니다.
격리 변경 자체가 암묵 커밋을 일으키는 데이터베이스가 있는지 공급자 문서를 확인합니다.
트랜잭션 시작 후 뒤늦게 바꾸지 않습니다.
구문 쿼리 타임아웃은 한 SQL 실행 제한, 트랜잭션 타임아웃은 전체 업무 경계 제한입니다. 둘을 같은 숫자로 설정해도 연결 대기 시간과 애플리케이션 계산 시간이 포함되는 범위가 다릅니다. 타임아웃 예외를 재시도할 때 명령 멱등성과 잠금 상태를 확인합니다.
연습 문제
수동 트랜잭션 도우미에 읽기 전용과 격리 옵션을 추가하세요. 옵션 적용, 정상 커밋, 런타임 예외 롤백, 롤백 실패, 상태 복원 실패를 가짜 Connection으로 테스트하고 원래 예외가 억제된 실패에 묻히지 않게 하세요.
해설 보기
옵션은 변경 가능한 연결 설정을 직접 흩뿌리지 않고 불변 값으로 전달합니다. 지원하지 않는 격리는 트랜잭션 시작 전에 빠르게 실패시킵니다.
package board.jdbc;
public record TransactionOptions(
boolean readOnly,
Isolation isolation,
int timeoutSeconds
) {
public enum Isolation {
READ_COMMITTED,
REPEATABLE_READ,
SERIALIZABLE
}
public TransactionOptions {
if (timeoutSeconds < 1 || timeoutSeconds > 300) {
throw new IllegalArgumentException("invalid timeout");
}
}
}가짜는 메서드 호출 순서와 던질 예외를 기록합니다. 통합 테스트에서는 H2가 실제로 지원하는 격리와 롤백 결과를 별도로 확인합니다.
다음 문서에서는 이 반복 코드를 PlatformTransactionManager와 TransactionTemplate에 맡기고 애플리케이션 서비스가 업무 경계만 표현하게 만듭니다.