수동 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;
boolean transactionCompleted = false;
Throwable failure = null;
try {
connection.setAutoCommit(false);
transactionStarted = true;
T result = work.run(connection);
connection.commit();
transactionCompleted = true;
return result;
} catch (SQLException | RuntimeException | Error exception) {
failure = exception;
if (transactionStarted) {
transactionCompleted =
rollbackPreserving(connection, exception);
}
throw exception;
} finally {
if (transactionStarted && transactionCompleted) {
restoreAutoCommitPreserving(
connection, originalAutoCommit, failure);
}
}
}
}
private boolean rollbackPreserving(
Connection connection,
Throwable original
) {
try {
connection.rollback();
return true;
} catch (SQLException rollbackFailure) {
original.addSuppressed(rollbackFailure);
return false;
}
}
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;
}
}이미 자동 커밋이 꺼진 연결은 이 최소한의 도우미가 맡을 수 없으므로 시작하기 전에 거부합니다. 중첩과 저장점(savepoint)의 의미를 어설프게 추측하지 않습니다.
setAutoCommit(false)가 성공한 뒤에 검사 예외나 런타임 예외, Error가 나면 롤백을 시도합니다. transactionStarted는 시작의 성공을, transactionCompleted는 커밋이나 롤백 호출의 성공을 기록하며, 완료가 확인된 경우에만 원래의 자동 커밋 모드로 되돌립니다. 롤백이 실패한 활성 트랜잭션에서 setAutoCommit(true)가 성공하면, 남은 변경이 커밋될 수 있기 때문입니다.
업무나 커밋의 실패가 이미 있다면, 뒤따른 롤백과 복원, 닫기의 실패는 그 예외에 억제 예외(suppressed exception)로 더합니다. 롤백이 실패하면 복원은 건너뛰고 try-with-resources가 연결 닫기를 시도합니다. 활성 트랜잭션을 가진 연결을 close하면 어떻게 되는지는 JDBC에서 구현에 맡겨져 있으며, 이 도우미는 실패한 연결을 안전하게 버리거나 풀에서 다시 쓸지까지 정하지 않습니다. 커밋까지 성공한 뒤 복원이나 닫기만 실패하면, 먼저 난 정리 실패가 주 예외가 되고 이후의 정리 실패가 그 아래에 억제 예외로 더해집니다.
커밋 호출이 실패하면 서버가 커밋했는지 알 수 없는 구간이 생깁니다. 뒤이은 롤백 호출이 성공해도 앞의 커밋이 서버에 반영되었는지는 확정되지 않으므로, 호출한 쪽은 client_request_id로 상태를 조회하거나 같은 명령을 멱등하게 다시 시도해야 합니다.
수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다
획득·autoCommit 검사/시작·업무·완료·복원·close와 실패 억제의 소유 순서가 핵심이다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
최초 실패를 보존하며 같은 연결의 트랜잭션을 정리한다
소유한 연결의 autoCommit=false 설정이 성공하면 같은 연결로 업무 SQL을 실행합니다. commit 또는 rollback 호출이 성공해야 상태를 복원합니다. rollback 실패에서는 복원을 생략하고 close를 시도하며 원래 실패를 유지합니다.
OWNERSHIP / START
이미 false인 연결은 중첩으로 거부
최소 도우미는 originalAutoCommit=true인 연결만 소유합니다. setAutoCommit(false)가 성공한 뒤에만 started=true입니다. 시작 전 실패에서도 try-with-resources의 close는 실행하지만 불필요한 rollback·복원을 하지 않습니다.
ACTUAL H2 / ATOMICITY
사용량600과 게시글1행을 함께 유지
정상 호출은 사용량600, 게시글1행·정확한 client key·fixed createdAt을 확인합니다. 같은 key의 두 번째 INSERT는 H2 23505로 실패합니다. 증가시킨1,200을 rollback하여 최종 사용량600과 기존 게시글1행만 남습니다.
ACTUAL MOCK / PRIMARY FAILURE
업무·commit 실패 뒤 정리 실패는 suppressed
가짜 연결의 rollback 실패 두 경우에서는 autoCommit=true를 호출하지 않고 rollback·close 실패를 원래 예외에 억제합니다. rollback 성공 뒤 복원 실패는 별도 시나리오에서 확인합니다. 업무 실패에서는 commit하지 않으며, 시작했다면 SQL·RuntimeException·Error에서 rollback을 시도합니다.
ACTUAL MOCK / CLEANUP ONLY
commit 성공 뒤에는 첫 정리 실패가 주 예외
commit 이후 복원 실패만 먼저 나면 복원 예외가 primary이고 close 실패가 suppressed입니다. close만 실패하면 close 예외 자체가 primary입니다. 두 경우 모두 rollback을 다시 호출하지 않으며 성공을 그대로 반환하지 않습니다.
UNCERTAIN COMMIT / ADDITIONAL
rollback 시도만으로 commit 결과를 확정하지 않는다
commit 호출 실패는 서버 반영 여부가 불확실할 수 있습니다. 뒤따른 rollback 호출이 성공해도 앞선 commit 결과는 확정하지 못합니다. client key 조회나 멱등 재시도가 필요하며, 활성 트랜잭션 close의 결과는 구현 정의입니다.
MINIMAL STATE / ADDITIONAL
이 도우미가 바꾸는 상태는 autoCommit
rollback이 실패한 연결의 안전한 폐기와 풀 재사용 정책은 별도로 정해야 합니다. readOnly·격리·network timeout을 추가하면 복원 책임도 늘어납니다. 공유 연결의 동시 사용·중첩/savepoint·외부 I/O 복구는 이 도우미의 구현 범위가 아닙니다.
Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제하고 없으면 최초 정리 실패를 주 예외로 던진다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제 예외로 추가하고 없으면 최초 정리 실패를 주 예외로 던진다.
- 여섯 노드와 여섯 간선이다. 원래 가로 도표의 성공한 시작 후 업무/완료/복원/close 흐름에 rollback 실패 bypass를 추가하여 두 배치에 동일한 관계를 그린다. 완료 확인 후 복원과 rollback 실패 시 복원 생략은 두 대체 경로다. 시작 전 실패의 close는 본문 코드의 범위이며 이 그림의 성공한 시작 흐름에 합치지 않는다. commit 호출 실패의 DB 반영 여부는 이 도식만으로 확정하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다 |
| 2 | Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제 예외로 추가하고 없으면 최초 정리 실패를 주 예외로 던진다. |
| 19 | 여섯 노드와 여섯 간선이다. 원래 가로 도표의 성공한 시작 후 업무/완료/복원/close 흐름에 rollback 실패 bypass를 추가하여 두 배치에 동일한 관계를 그린다. 완료 확인 후 복원과 rollback 실패 시 복원 생략은 두 대체 경로다. 시작 전 실패의 close는 본문 코드의 범위이며 이 그림의 성공한 시작 흐름에 합치지 않는다. commit 호출 실패의 DB 반영 여부는 이 도식만으로 확정하지 않는다. |
| 33 | 사용량 600과 게시글 1행을 함께 유지 |
| 34 | 정상 호출은 사용량 600, 게시글 1행·정확한 client key·fixed createdAt을 확인합니다. 같은 key의 두 번째 INSERT는 H2 23505로 실패합니다. 증가시킨 1,200을 rollback하여 최종 사용량 600과 기존 게시글 1행만 남습니다. |
| 37 | 가짜 연결의 rollback 실패 두 경우에서는 autoCommit=true를 호출하지 않고 rollback·close 실패를 원래 예외에 억제 예외로 추가합니다. rollback 성공 뒤 복원 실패는 별도 시나리오에서 확인합니다. 업무 실패에서는 commit하지 않으며, 시작했다면 SQL·RuntimeException·Error에서 rollback을 시도합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 획득과 중첩 확인 연결을 얻고 원래 autoCommit을 읽어 이미 false이면 중첩을 거부합니다. | 연결: 획득 · 중첩 조건: 원래 autoCommit false · 업무 SQL: 미실행 | 이 wrapper가 시작할 수 있는 transaction인지 확인합니다. |
| 업무 실행 autoCommit=false 설정에 성공한 경우 같은 연결로 업무 SQL을 실행합니다. | autoCommit: false · 업무: 같은 Connection | 연결이 이 transaction의 업무 실행 상태로 바뀝니다. |
| commit 또는 rollback 정상은 commit이고 업무 실패는 rollback을 시도합니다. | 정상 종료: commit · 실패 종료: rollback · rollback 실패: 복원 건너뜀 | transaction 완료 성공 여부가 정리 조건을 결정합니다. |
| 복원과 close 완료 호출이 성공한 경우 autoCommit을 복원하며 항상 close를 시도합니다. | 원래 실패 존재: 정리 실패 suppressed · 원래 실패 없음: 최초 정리 실패가 주 예외 | 연결을 정리하되 원래 실패의 우선순위를 보존합니다. |
수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다
획득·autoCommit 검사/시작·업무·완료·복원·close와 실패 억제의 소유 순서가 핵심이다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
최초 실패를 보존하며 같은 연결의 트랜잭션을 정리한다
소유한 연결의 autoCommit=false 설정이 성공하면 같은 연결로 업무 SQL을 실행합니다. commit 또는 rollback 호출이 성공해야 상태를 복원합니다. rollback 실패에서는 복원을 생략하고 close를 시도하며 원래 실패를 유지합니다.
OWNERSHIP / START
이미 false인 연결은 중첩으로 거부
최소 도우미는 originalAutoCommit=true인 연결만 소유합니다. setAutoCommit(false)가 성공한 뒤에만 started=true입니다. 시작 전 실패에서도 try-with-resources의 close는 실행하지만 불필요한 rollback·복원을 하지 않습니다.
ACTUAL H2 / ATOMICITY
사용량600과 게시글1행을 함께 유지
정상 호출은 사용량600, 게시글1행·정확한 client key·fixed createdAt을 확인합니다. 같은 key의 두 번째 INSERT는 H2 23505로 실패합니다. 증가시킨1,200을 rollback하여 최종 사용량600과 기존 게시글1행만 남습니다.
ACTUAL MOCK / PRIMARY FAILURE
업무·commit 실패 뒤 정리 실패는 suppressed
가짜 연결의 rollback 실패 두 경우에서는 autoCommit=true를 호출하지 않고 rollback·close 실패를 원래 예외에 억제합니다. rollback 성공 뒤 복원 실패는 별도 시나리오에서 확인합니다. 업무 실패에서는 commit하지 않으며, 시작했다면 SQL·RuntimeException·Error에서 rollback을 시도합니다.
ACTUAL MOCK / CLEANUP ONLY
commit 성공 뒤에는 첫 정리 실패가 주 예외
commit 이후 복원 실패만 먼저 나면 복원 예외가 primary이고 close 실패가 suppressed입니다. close만 실패하면 close 예외 자체가 primary입니다. 두 경우 모두 rollback을 다시 호출하지 않으며 성공을 그대로 반환하지 않습니다.
UNCERTAIN COMMIT / ADDITIONAL
rollback 시도만으로 commit 결과를 확정하지 않는다
commit 호출 실패는 서버 반영 여부가 불확실할 수 있습니다. 뒤따른 rollback 호출이 성공해도 앞선 commit 결과는 확정하지 못합니다. client key 조회나 멱등 재시도가 필요하며, 활성 트랜잭션 close의 결과는 구현 정의입니다.
MINIMAL STATE / ADDITIONAL
이 도우미가 바꾸는 상태는 autoCommit
rollback이 실패한 연결의 안전한 폐기와 풀 재사용 정책은 별도로 정해야 합니다. readOnly·격리·network timeout을 추가하면 복원 책임도 늘어납니다. 공유 연결의 동시 사용·중첩/savepoint·외부 I/O 복구는 이 도우미의 구현 범위가 아닙니다.
Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제하고 없으면 최초 정리 실패를 주 예외로 던진다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제 예외로 추가하고 없으면 최초 정리 실패를 주 예외로 던진다.
- 여섯 노드와 여섯 간선이다. 원래 가로 도표의 성공한 시작 후 업무/완료/복원/close 흐름에 rollback 실패 bypass를 추가하여 두 배치에 동일한 관계를 그린다. 완료 확인 후 복원과 rollback 실패 시 복원 생략은 두 대체 경로다. 시작 전 실패의 close는 본문 코드의 범위이며 이 그림의 성공한 시작 흐름에 합치지 않는다. commit 호출 실패의 DB 반영 여부는 이 도식만으로 확정하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다 |
| 2 | Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제 예외로 추가하고 없으면 최초 정리 실패를 주 예외로 던진다. |
| 19 | 여섯 노드와 여섯 간선이다. 원래 가로 도표의 성공한 시작 후 업무/완료/복원/close 흐름에 rollback 실패 bypass를 추가하여 두 배치에 동일한 관계를 그린다. 완료 확인 후 복원과 rollback 실패 시 복원 생략은 두 대체 경로다. 시작 전 실패의 close는 본문 코드의 범위이며 이 그림의 성공한 시작 흐름에 합치지 않는다. commit 호출 실패의 DB 반영 여부는 이 도식만으로 확정하지 않는다. |
| 33 | 사용량 600과 게시글 1행을 함께 유지 |
| 34 | 정상 호출은 사용량 600, 게시글 1행·정확한 client key·fixed createdAt을 확인합니다. 같은 key의 두 번째 INSERT는 H2 23505로 실패합니다. 증가시킨 1,200을 rollback하여 최종 사용량 600과 기존 게시글 1행만 남습니다. |
| 37 | 가짜 연결의 rollback 실패 두 경우에서는 autoCommit=true를 호출하지 않고 rollback·close 실패를 원래 예외에 억제 예외로 추가합니다. rollback 성공 뒤 복원 실패는 별도 시나리오에서 확인합니다. 업무 실패에서는 commit하지 않으며, 시작했다면 SQL·RuntimeException·Error에서 rollback을 시도합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 획득과 중첩 확인 연결을 얻고 원래 autoCommit을 읽어 이미 false이면 중첩을 거부합니다. | 연결: 획득 · 중첩 조건: 원래 autoCommit false · 업무 SQL: 미실행 | 이 wrapper가 시작할 수 있는 transaction인지 확인합니다. |
| 업무 실행 autoCommit=false 설정에 성공한 경우 같은 연결로 업무 SQL을 실행합니다. | autoCommit: false · 업무: 같은 Connection | 연결이 이 transaction의 업무 실행 상태로 바뀝니다. |
| commit 또는 rollback 정상은 commit이고 업무 실패는 rollback을 시도합니다. | 정상 종료: commit · 실패 종료: rollback · rollback 실패: 복원 건너뜀 | transaction 완료 성공 여부가 정리 조건을 결정합니다. |
| 복원과 close 완료 호출이 성공한 경우 autoCommit을 복원하며 항상 close를 시도합니다. | 원래 실패 존재: 정리 실패 suppressed · 원래 실패 없음: 최초 정리 실패가 주 예외 | 연결을 정리하되 원래 실패의 우선순위를 보존합니다. |
그림은 연결을 얻어 원래 상태를 확인하고, autoCommit=false의 성공을 기록한 뒤 같은 연결로 사용량과 게시글의 SQL을 실행하고, 커밋이나 롤백으로 끝낸 다음 완료를 확인해 상태를 되돌리고 닫는 흐름을 보여 줍니다. 롤백이 실패했을 때 복원을 건너뛰고 바로 닫기로 가는 우회 경로가 함께 그려져 있으며, 두 경로는 하나만 선택되는 대안입니다. 시작하기 전에 실패한 경우의 닫기는 본문의 코드에서 다루고, 커밋 호출이 실패했을 때 DB에 반영되었는지는 이 그림만으로 확정하지 않습니다.
같은 Connection으로 SQL을 조합합니다
ManualPostService는 시계를 한 번 읽고, 앞 글의 PostRegistrationSql에 같은 연결과 시각을 넘깁니다. 시각을 트랜잭션에 들어가기 전에 한 번만 읽으므로, SQL을 실행하는 도중 시계가 움직여도 한 등록에 쓰이는 시각은 하나입니다. transaction.execute에 넘긴 람다가 앞의 SqlWork이며, ManualTransaction이 연결을 얻어 자동 커밋을 끈 뒤 그 연결로 이 람다를 실행합니다.
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 트랜잭션으로 확인합니다.
세 번째 테스트는 가짜 연결로 업무나 커밋이 실패한 뒤의 롤백 실패, 롤백이 성공한 뒤의 복원 실패, 성공한 커밋 뒤의 정리 실패를 각각 만듭니다. 한 @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();
verifyRestoreFailureAfterRollback();
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 closeFailure = new SQLException("close failed");
when(dataSource.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
doThrow(rollbackFailure).when(connection).rollback();
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, closeFailure);
verify(connection, never()).commit();
verify(connection, never()).setAutoCommit(true);
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).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 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(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, closeFailure);
verify(connection, never()).setAutoCommit(true);
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).close();
}
private void verifyRestoreFailureAfterRollback() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
var primary = new IllegalStateException("work failed before rollback");
var restoreFailure = new SQLException("restore after rollback 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);
IllegalStateException thrown = assertThrows(
IllegalStateException.class,
() -> transaction.execute(ignored -> {
throw primary;
}));
assertThat(thrown).isSameAs(primary);
assertThat(thrown.getSuppressed()).containsExactly(
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 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, '[email protected]', '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행만 남습니다.
마지막 테스트는 롤백이 실패한 두 경우에 setAutoCommit(true)가 호출되지 않는지 먼저 확인하며, 이때 롤백과 닫기의 실패는 원래의 업무나 커밋 예외에 억제 예외로 더해집니다. 별도의 시나리오에서는 롤백이 성공해야 autoCommit 복원을 시도하고, 복원과 닫기의 실패가 원래의 업무 예외에 억제 예외로 더해지는지 확인합니다. 커밋이 성공한 뒤에는 복원 실패가 주 예외이고 닫기 실패가 그 아래에 더해지며, 닫기만 실패하면 닫기 예외 자체가 주 예외입니다. 이 호출 검사는 실제 DB의 불확실한 커밋이나 연결의 폐기 정책을 검증하지 않습니다.
연결 설정에는 적용 시점과 복원이 필요합니다
풀의 연결은 다음 요청이 다시 빌려 갑니다. 설정은 트랜잭션을 시작하기 전에 적용하고, 트랜잭션이 끝난 것을 확인한 뒤 원래 값으로 되돌립니다. 롤백이 실패한 연결을 다시 써도 되는지는 풀과 드라이버의 폐기 정책으로 따로 판단합니다.
| 상태 | 적용 시점 | 끝나거나 실패한 뒤 할 일 | 주의할 점 |
|---|---|---|---|
autoCommit | 업무 SQL 전에 false | 커밋이나 롤백이 성공한 뒤 원래 값으로, 롤백 실패면 복원 생략 | 활성 트랜잭션에서 true로 바꾸면 커밋이 일어남 |
| 격리 수준 | 트랜잭션 시작 전 | 원래 격리 수준으로 복원 | 활성 트랜잭션에서 바꾼 결과는 JDBC 수준에서 구현에 맡겨짐 |
readOnly | 트랜잭션 시작 전의 힌트 | 원래 값으로 복원 | 모든 DB가 쓰기를 강제로 막는다고 가정하지 않음 |
| 네트워크 타임아웃 | 연결을 쓰기 전 | 원래 값으로 복원 | SQL이나 업무 전체의 타임아웃과 범위가 다름 |
이 최소한의 도우미는 자동 커밋만 직접 바꿉니다. 옵션을 더한다면 적용에 성공한 상태만 역순으로 되돌리고, 각 실패를 처음 예외에 억제 예외로 더해야 합니다.
저장점은 부분 성공이 업무 요구일 때만 씁니다
저장점은 같은 트랜잭션 안에서 일부 변경을 되돌릴 수 있지만, 모든 중첩 호출을 새 트랜잭션처럼 만들어 주지는 않습니다.
| 질문 | 예일 때 | 아니오일 때 |
|---|---|---|
| 일부만 성공한 결과를 사용자에게 분명히 돌려주는가? | 저장점을 검토 | 전체 롤백 |
| DB와 드라이버가 저장점을 지원하는가? | 다음 질문 | 전체 롤백 |
| 바깥 트랜잭션이 계속 쓸 수 있는 상태인지 검증했는가? | 실패한 구간만 저장점으로 롤백 | 전체 롤백 |
| 외부 I/O까지 되돌릴 수 있는가? | 별도의 보상 처리를 검토 | 아웃박스와 멱등 처리로 분리 |
일반 게시글 등록은 사용량과 게시글이 함께 성공해야 하므로 저장점으로 일부만 커밋하지 않습니다. CSV 일괄 가져오기처럼 행마다 성공 결과를 명시하는 업무에서만 저장점을 검토합니다.
더 깊이: 구문 하나가 실패한 트랜잭션을 계속 쓸 수 있을까
표의 세 번째 질문은 데이터베이스마다 답이 다르므로 운영 DB의 규칙을 확인해야 합니다. 예를 들어 PostgreSQL은 트랜잭션 블록 안에서 오류가 나면 그 블록 전체를 중단(aborted) 상태로 만듭니다. PostgreSQL 문서는 이렇게 중단된 블록에서 다시 제어를 얻는 방법이, 전체를 롤백하고 새로 시작하는 것 말고는 ROLLBACK TO로 저장점까지 되돌리는 것뿐이라고 설명합니다. 그래서 PostgreSQL에서는 유일성 위반을 잡아 같은 트랜잭션에서 다른 행으로 계속 진행하려면, 위험한 구문 앞에 반드시 저장점을 두어야 합니다.
JDBC에서는 Connection.setSavepoint()로 저장점을 만들고 rollback(Savepoint)으로 그 지점까지 되돌리며, 더 쓰지 않을 저장점은 releaseSavepoint로 풉니다. 같은 문서에 따르면 저장점까지 되돌려도 그 저장점은 남아 있어 여러 번 다시 쓸 수 있고, 어떤 저장점을 풀거나 그곳으로 되돌리면 그 뒤에 만든 저장점은 모두 함께 풀립니다. PostgreSQL JDBC 드라이버에는 구문마다 저장점을 자동으로 두는 autosave 연결 속성도 있지만, 드라이버 문서에 적힌 기본값은 never입니다. 이 속성을 켜면 어느 실패가 되돌려졌는지 코드에서 보이지 않으므로, 부분 성공이 필요한 구간에만 저장점을 명시하는 편이 의도를 드러냅니다.
정리
- 수동 트랜잭션은 같은 연결을 모든 SQL에 넘기고, 성공하면 커밋, 실패하면 롤백을 시도한 뒤 상태를 되돌리고 연결을 닫습니다.
- 자동 커밋은 커밋이나 롤백이 성공한 것을 확인한 뒤에만 되돌리며, 롤백이 실패했을 때 되돌리면 남은 변경이 커밋될 수 있습니다.
- 업무나 커밋의 실패가 먼저 있으면 뒤의 정리 실패는 억제 예외로 더해 처음 원인을 보존합니다.
- 커밋 호출이 실패하면 반영 여부가 불확실하므로, 같은 요청 ID로 상태를 조회하거나 멱등하게 재시도합니다.
- 저장점은 일부 성공이 업무 요구일 때만 쓰고, 일반 등록은 전체를 하나로 커밋하거나 롤백합니다.
연습 문제
1. 커밋이 실패했지만 실제로는 반영된 경우
ManualTransaction의 커밋이 예외를 던졌지만 DB에는 실제로 반영된 상황을 가정합니다. 응답을 받지 못한 클라이언트가 같은 client_request_id로 다시 시도할 때, 중복 게시글을 만들지 않고 기존 결과를 확인하는 흐름을 설계하세요.
해설 보기
재시도할 때는 같은 회원과 같은 요청 ID를 그대로 씁니다. 유일성 제약의 충돌이 나면 무조건 새 실패로 끝내지 말고, 그 키로 기존 게시글을 조회해 원래 명령의 결과인지 확인합니다. 제목과 본문, 게시일이 같다면 기존 게시글을 성공 결과로 돌려줍니다.
제목이나 본문, 게시일이 다르면 같은 멱등성 키를 다른 명령에 다시 쓴 충돌이므로 성공으로 바꾸지 않습니다. 커밋 결과가 불확실하다는 이유로 새 요청 ID를 발급하면 중복을 막을 수 없습니다.
2. 실패 순서에 따른 주 예외
같은 도우미에서 ① 업무 SQL이 SQLException A를 던지고 롤백이 B로 실패한 경우, ② 업무는 성공하고 커밋도 성공했는데 자동 커밋 복원이 C로, 닫기가 D로 실패한 경우, ③ 업무와 커밋, 복원은 성공하고 닫기만 E로 실패한 경우에 호출한 쪽이 받는 주 예외와 억제 예외, setAutoCommit(true)의 호출 여부를 정하세요.
해설 보기
① 주 예외는 A이고 B가 억제 예외로 더해지며, 닫기도 실패했다면 그 예외가 이어서 더해집니다. 롤백이 실패했으므로 setAutoCommit(true)는 호출하지 않습니다. ② 커밋이 성공했으므로 복원을 시도하고, 먼저 난 C가 주 예외가 되며 D가 그 아래에 억제 예외로 더해집니다. ③ 닫기 예외 E 자체가 주 예외이고, 복원은 이미 성공했으므로 호출되었습니다. 세 경우 모두 롤백을 다시 호출하지 않으며, ②와 ③은 커밋이 반영되었더라도 성공을 그대로 돌려주지 않습니다.
다음 글 트랜잭션 관리자에서는 이 반복되는 연결 획득과 커밋, 롤백, 복원 코드를 PlatformTransactionManager와 TransactionTemplate에 맡깁니다.