수동 JDBC 트랜잭션
게시글 등록을 한 Connection에서 실행하며 커밋, 전체 롤백, 원래 예외 보존과 연결 상태 복원을 검증합니다.
원시 JDBC 트랜잭션은 setAutoCommit(false) 한 줄로 끝나지 않습니다.
업무에 필요한 모든 SQL에 같은 연결을 전달하고, 성공하면 커밋하며, 실패하면 롤백을 시도해야 합니다. 마지막에는 연결 상태를 복원하고 반드시 반환합니다.
각 단계가 또 실패할 수 있으므로 무엇이 최초 실패인지 잃지 않는 것도 트랜잭션 경계의 책임입니다.
트랜잭션 소유권을 한곳에 둔다
서비스는 업무 단위를 알고, 하위 SQL 객체는 전달받은 연결만 사용합니다. 하위 객체가 각각 DataSource.getConnection()을 호출하면 한 서비스 호출이 여러 트랜잭션으로 갈라집니다.
package board.jdbc;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Objects;
import javax.sql.DataSource;
public final class ManualTransaction {
private final DataSource dataSource;
public ManualTransaction(DataSource dataSource) {
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
}
public <T> T execute(SqlWork<T> work) throws SQLException {
Objects.requireNonNull(work, "work");
try (Connection connection = dataSource.getConnection()) {
boolean originalAutoCommit = connection.getAutoCommit();
if (!originalAutoCommit) {
throw new SQLException(
"nested manual transaction is not supported");
}
boolean transactionStarted = false;
Throwable failure = null;
try {
connection.setAutoCommit(false);
transactionStarted = true;
T result = work.run(connection);
connection.commit();
return result;
} catch (SQLException | RuntimeException | Error exception) {
failure = exception;
if (transactionStarted) {
rollbackPreserving(connection, exception);
}
throw exception;
} finally {
if (transactionStarted) {
restoreAutoCommitPreserving(
connection, originalAutoCommit, failure);
}
}
}
}
private void rollbackPreserving(
Connection connection,
Throwable original
) {
try {
connection.rollback();
} catch (SQLException rollbackFailure) {
original.addSuppressed(rollbackFailure);
}
}
private void restoreAutoCommitPreserving(
Connection connection,
boolean originalAutoCommit,
Throwable original
) throws SQLException {
try {
connection.setAutoCommit(originalAutoCommit);
} catch (SQLException restoreFailure) {
if (original != null) {
original.addSuppressed(restoreFailure);
return;
}
throw restoreFailure;
}
}
@FunctionalInterface
public interface SqlWork<T> {
T run(Connection connection) throws SQLException;
}
}이미 자동 커밋이 꺼진 연결은 이 최소 도우미가 소유할 수 없으므로 시작 전에 거부합니다. 중첩과 저장점 의미를 어설프게 추측하지 않습니다.
setAutoCommit(false)가 성공한 뒤의 검사 예외, 런타임 예외, Error에서는 롤백을 시도합니다. 업무 또는 커밋 실패가 이미 있으면 뒤따른 롤백·상태 복원·닫기 실패가 그 예외에 억제됩니다. 반대로 커밋까지 성공한 뒤 복원이나 닫기만 실패하면 최초 업무 실패가 없으므로 먼저 발생한 정리 실패가 주 예외이고 이후 정리 실패만 그 아래 억제됩니다.
커밋 호출이 실패하면 서버가 커밋했는지 알 수 없는 구간이 있습니다. 여기서도 롤백을 시도하지만 결과를 확정했다고 주장하지 않습니다. 호출자는 client_request_id로 상태를 조회하거나 같은 명령을 멱등하게 재시도해야 합니다.
MANUAL TRANSACTION
최초 실패를 보존하고, 시작한 상태만 역순으로 정리한다
commit 경로와 failure 경로가 restore·close에서 합쳐지되 주 예외 선택은 다릅니다.
연결을 획득하고 원래 상태를 읽는다
이미 autoCommit=false이면 중첩 의미를 추측하지 않고 거부합니다.
autoCommit=false가 성공하면 시작 상태를 기록한다
시작 전에 실패한 연결에는 rollback이나 잘못된 복원을 시도하지 않습니다.
같은 연결로 모든 업무 SQL을 실행한다
사용량 UPDATE와 게시글 INSERT가 하나의 원자적 경계입니다.
성공은 commit, 실패는 rollback
commit 실패도 결과가 불확실하므로 rollback을 시도하고 원래 실패를 유지합니다.
상태를 복원하고 close한다
업무·commit 실패가 있으면 정리 실패는 suppressed입니다. commit 성공 뒤에는 첫 cleanup 실패가 primary이고 이후 cleanup 실패만 suppressed입니다.
결과 또는 주 예외를 호출자에게 돌려준다
불확실한 commit은 멱등 요청 ID로 조회·재시도하고, cleanup-only 실패도 성공으로 숨기지 않습니다.
- 소유 트랜잭션 경계
- 성공 경로
- 실패·롤백 경로
- 불확실한 commit 실패
핵심: 원래 실패가 있을 때만 정리 실패를 그 아래 억제하며, cleanup-only 첫 실패는 주 예외로 드러냅니다.
같은 Connection으로 SQL을 조합한다
ManualPostService는 시계를 한 번 읽고, 앞 문서의 PostRegistrationSql에 같은 연결과 시각을 전달합니다.
package board.jdbc;
import java.sql.SQLException;
import java.time.Clock;
import java.util.Objects;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
public final class ManualPostService {
private final ManualTransaction transaction;
private final PostRegistrationSql registration;
private final Clock clock;
public ManualPostService(
ManualTransaction transaction,
PostRegistrationSql registration,
Clock clock
) {
this.transaction = Objects.requireNonNull(
transaction, "transaction");
this.registration = Objects.requireNonNull(
registration, "registration");
this.clock = Objects.requireNonNull(clock, "clock");
}
public long register(CreatePostCommand command) throws SQLException {
Objects.requireNonNull(command, "command");
var createdAt = clock.instant();
return transaction.execute(connection -> registration.execute(
connection, command, createdAt));
}
}연결 파라미터가 보이는 것은 트랜잭션 컨텍스트를 직접 전달했기 때문입니다. 하나의 JDBC 연결을 여러 스레드가 동시에 공유하지 않습니다.
다음 문서의 Spring 트랜잭션 관리자는 현재 스레드에 연결을 바인딩해 이 전달을 숨기지만, 업무 경계와 같은 연결을 사용해야 한다는 사실은 바뀌지 않습니다.
성공·업무 실패·정리 실패를 실행한다
테스트는 이 장의 유일한 schema.sql을 그대로 실행합니다. 첫 두 테스트는 게시일별 사용량과 게시글 삽입의 원자성을 실제 H2 트랜잭션으로 확인합니다.
세 번째 테스트는 DB 통합 테스트가 만들기 어려운 업무 실패, 커밋 실패, 성공한 커밋 뒤 정리 실패를 가짜 연결로 발생시킵니다. 한 @Test 안의 독립 시나리오로 주 예외와 억제 예외 순서를 모두 고정합니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import javax.sql.DataSource;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
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 ManualTransactionTest {
private static final Instant CREATED_AT =
Instant.parse("2026-08-29T01:02:03Z");
private static final LocalDate PUBLISHED_ON =
LocalDate.of(2026, 8, 29);
@Test
void 성공하면_사용량과_게시글을_함께_commit하고_상태를_복원한다()
throws Exception {
var dataSource = dataSource("manual_success");
var service = service(dataSource);
var command = command("manual_req_A1");
long postId = service.register(command);
assertThat(postId).isPositive();
try (var connection = dataSource.getConnection()) {
assertThat(connection.getAutoCommit()).isTrue();
assertThat(scalar(connection, """
select total_characters from daily_post_stats
where member_id = 41
and published_on = date '2026-08-29'
""")).isEqualTo(600);
assertThat(scalar(connection,
"select count(*) from posts")).isEqualTo(1);
assertThat(text(connection, """
select client_request_id from posts where id = %d
""".formatted(postId))).isEqualTo("manual_req_A1");
assertThat(instant(connection, """
select created_at from posts where id = %d
""".formatted(postId))).isEqualTo(CREATED_AT);
}
}
@Test
void 중복_request_id_INSERT가_실패하면_사용량_증가도_rollback한다()
throws Exception {
var dataSource = dataSource("manual_duplicate");
var service = service(dataSource);
var command = command("manual_req_B2");
long firstId = service.register(command);
SQLException duplicate = assertThrows(
SQLException.class,
() -> service.register(command));
assertThat(duplicate.getSQLState()).isEqualTo("23505");
try (var connection = dataSource.getConnection()) {
assertThat(connection.getAutoCommit()).isTrue();
assertThat(scalar(connection, """
select total_characters from daily_post_stats
where member_id = 41
and published_on = date '2026-08-29'
""")).isEqualTo(600);
assertThat(scalar(connection,
"select count(*) from posts")).isEqualTo(1);
assertThat(scalar(connection,
"select count(*) from posts where id = " + firstId))
.isEqualTo(1);
}
}
@Test
void 원래_실패_commit_실패와_성공후_cleanup_실패를_구별한다()
throws Exception {
verifyWorkFailureSuppression();
verifyCommitFailureSuppression();
verifyRestoreFailureAfterCommit();
verifyCloseFailureAfterCommit();
}
private void verifyWorkFailureSuppression() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
var primary = new IllegalStateException("work failed");
var rollbackFailure = new SQLException("rollback failed");
var restoreFailure = new SQLException("restore failed");
var closeFailure = new SQLException("close failed");
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
doThrow(rollbackFailure).when(connection).rollback();
doThrow(restoreFailure).when(connection).setAutoCommit(true);
doThrow(closeFailure).when(connection).close();
var transaction = new ManualTransaction(dataSource);
IllegalStateException thrown = assertThrows(
IllegalStateException.class,
() -> transaction.execute(ignored -> {
throw primary;
}));
assertThat(thrown).isSameAs(primary);
assertThat(thrown.getSuppressed()).containsExactly(
rollbackFailure, restoreFailure, closeFailure);
verify(connection, never()).commit();
var order = inOrder(dataSource, connection);
order.verify(dataSource).getConnection();
order.verify(connection).getAutoCommit();
order.verify(connection).setAutoCommit(false);
order.verify(connection).rollback();
order.verify(connection).setAutoCommit(true);
order.verify(connection).close();
}
private void verifyCommitFailureSuppression() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
var commitFailure = new SQLException("commit failed");
var rollbackFailure = new SQLException("rollback after commit failed");
var restoreFailure = new SQLException("restore after commit failed");
var closeFailure = new SQLException("close after commit failed");
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
doThrow(commitFailure).when(connection).commit();
doThrow(rollbackFailure).when(connection).rollback();
doThrow(restoreFailure).when(connection).setAutoCommit(true);
doThrow(closeFailure).when(connection).close();
var transaction = new ManualTransaction(dataSource);
SQLException thrown = assertThrows(
SQLException.class,
() -> transaction.execute(ignored -> 7L));
assertThat((Throwable) thrown).isSameAs(commitFailure);
assertThat(thrown.getSuppressed()).containsExactly(
rollbackFailure, restoreFailure, closeFailure);
var order = inOrder(dataSource, connection);
order.verify(dataSource).getConnection();
order.verify(connection).getAutoCommit();
order.verify(connection).setAutoCommit(false);
order.verify(connection).commit();
order.verify(connection).rollback();
order.verify(connection).setAutoCommit(true);
order.verify(connection).close();
}
private void verifyRestoreFailureAfterCommit() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
var restoreFailure = new SQLException("restore after success failed");
var closeFailure = new SQLException("close after restore failed");
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
doThrow(restoreFailure).when(connection).setAutoCommit(true);
doThrow(closeFailure).when(connection).close();
var transaction = new ManualTransaction(dataSource);
SQLException thrown = assertThrows(
SQLException.class,
() -> transaction.execute(ignored -> 7L));
assertThat((Throwable) thrown).isSameAs(restoreFailure);
assertThat(thrown.getSuppressed()).containsExactly(closeFailure);
verify(connection, never()).rollback();
var order = inOrder(dataSource, connection);
order.verify(dataSource).getConnection();
order.verify(connection).getAutoCommit();
order.verify(connection).setAutoCommit(false);
order.verify(connection).commit();
order.verify(connection).setAutoCommit(true);
order.verify(connection).close();
}
private void verifyCloseFailureAfterCommit() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
var closeFailure = new SQLException("close after success failed");
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
doThrow(closeFailure).when(connection).close();
var transaction = new ManualTransaction(dataSource);
SQLException thrown = assertThrows(
SQLException.class,
() -> transaction.execute(ignored -> 7L));
assertThat((Throwable) thrown).isSameAs(closeFailure);
assertThat(thrown.getSuppressed()).isEmpty();
verify(connection, never()).rollback();
var order = inOrder(dataSource, connection);
order.verify(dataSource).getConnection();
order.verify(connection).getAutoCommit();
order.verify(connection).setAutoCommit(false);
order.verify(connection).commit();
order.verify(connection).setAutoCommit(true);
order.verify(connection).close();
}
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)
""");
statement.execute("""
insert into daily_post_stats(
member_id, published_on, total_characters)
values (41, date '2026-08-29', 0)
""");
}
return dataSource;
}
private ManualPostService service(DataSource dataSource) {
return new ManualPostService(
new ManualTransaction(dataSource),
new PostRegistrationSql(),
Clock.fixed(CREATED_AT, ZoneOffset.UTC));
}
private CreatePostCommand command(String requestId) {
return new CreatePostCommand(
41L, "수동 트랜잭션", "가".repeat(600),
PUBLISHED_ON, requestId);
}
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 String text(Connection connection, String sql) throws Exception {
try (var statement = connection.createStatement();
var result = statement.executeQuery(sql)) {
result.next();
return result.getString(1);
}
}
private Instant instant(Connection connection, String sql)
throws Exception {
try (var statement = connection.createStatement();
var result = statement.executeQuery(sql)) {
result.next();
return result.getObject(1, java.time.OffsetDateTime.class)
.toInstant();
}
}
}두 번째 호출은 먼저 사용량 행을 600 → 1,200으로 바꾸지만 게시글의 (member_id, client_request_id) 유일 제약에서 실패합니다. 전체 롤백 뒤 사용량은 다시 600, 게시글은 처음 커밋한 1행입니다.
마지막 테스트는 업무 실패와 커밋 실패에서는 rollback → autoCommit 복원 → close의 정리 실패가 원래 예외 뒤에 억제되는지 확인합니다. 커밋 성공 뒤에는 복원 실패가 주 예외이고 닫기 실패가 그 아래 억제되며, 닫기만 실패하면 닫기 실패 자체가 주 예외임도 구분합니다.
연결 설정은 적용 시점과 복원 책임이 필요하다
풀 연결은 다음 요청이 다시 빌립니다. 설정을 바꾼다면 트랜잭션을 시작하기 전에 적용하고 반환 전에 원래 값으로 복원해야 합니다.
| 상태 | 적용 시점 | 완료·실패 뒤 책임 | 주의할 점 |
|---|---|---|---|
autoCommit | 업무 SQL 전 false | 명시적 commit/rollback 뒤 원래 값 | 활성 트랜잭션 중 true 변경은 커밋을 일으킨다 |
| 격리 수준 | 트랜잭션 시작 전 | 원래 격리로 복원 | 활성 트랜잭션 중 변경 결과는 JDBC 수준에서 구현 정의다 |
readOnly | 트랜잭션 시작 전 힌트 | 원래 값으로 복원 | 모든 DB가 쓰기를 강제로 막는다고 가정하지 않는다 |
| 네트워크 타임아웃 | 연결 사용 전 | 원래 값으로 복원 | SQL·업무 전체 타임아웃과 범위가 다르다 |
이 최소 도우미는 자동 커밋만 직접 바꿉니다. 옵션을 추가한다면 적용에 성공한 상태만 역순으로 복원하고 각 실패를 최초 예외에 억제해야 합니다.
저장점은 부분 성공이 업무 계약일 때만 쓴다
저장점은 같은 트랜잭션 안의 일부 변경을 되돌릴 수 있지만, 모든 중첩 호출을 새 트랜잭션처럼 만들지는 않습니다.
| 질문 | 예일 때 | 아니오일 때 |
|---|---|---|
| 부분 성공을 사용자에게 명확히 반환하는가? | 저장점 후보 | 전체 롤백 |
| DB와 드라이버가 저장점을 지원하는가? | 다음 질문 | 전체 롤백 |
| 바깥 트랜잭션이 계속 유효함을 검증했는가? | 실패 구간만 rollback-to-savepoint | 전체 롤백 |
| 외부 I/O까지 되돌릴 수 있는가? | 별도 보상 계약 검토 | 아웃박스·멱등 경계로 분리 |
일반 게시글 등록은 사용량과 게시글이 함께 성공해야 하므로 저장점으로 부분 커밋하지 않습니다. CSV 일괄 가져오기처럼 행별 성공 결과가 명시된 업무에서만 후보가 됩니다.
연습 문제
ManualTransaction의 커밋이 예외를 던졌지만 DB에는 실제로 반영된 상황을 가정하세요.
응답을 받지 못한 클라이언트가 같은 client_request_id로 재시도할 때 중복 게시글을 만들지 않고 기존 결과를 확인하는 흐름을 설계합니다.
해설 보기
재시도는 같은 회원과 같은 요청 ID를 유지합니다. 유일 제약 충돌이 나면 무조건 새 실패로 끝내지 말고 그 키로 기존 게시글을 조회해 원래 명령의 결과인지 확인합니다.
제목·본문·게시일이 다르면 같은 멱등 키를 다른 명령에 재사용한 충돌이므로 성공으로 바꾸지 않습니다. 커밋 결과가 불확실하다는 이유로 새 요청 ID를 발급하면 중복을 막을 수 없습니다.
다음 문서에서는 이 반복적인 연결 획득·커밋·롤백·복원 코드를 PlatformTransactionManager와 TransactionTemplate에 맡깁니다.