본문 바로가기

수동 JDBC 트랜잭션

게시글 등록을 한 Connection에서 실행하며 커밋, 전체 롤백, 원래 예외 보존과 연결 상태 복원을 검증합니다.

원시 JDBC 트랜잭션은 setAutoCommit(false) 한 줄로 끝나지 않습니다.

업무에 필요한 모든 SQL에 같은 연결을 전달하고, 성공하면 커밋하며, 실패하면 롤백을 시도해야 합니다. 커밋이나 롤백의 완료를 확인한 뒤에만 연결 상태를 복원하고, 마지막에는 연결 반환을 시도합니다.

각 단계가 또 실패할 수 있으므로 최초 실패의 원인을 보존하는 것도 트랜잭션 경계의 책임입니다.


트랜잭션 소유권을 한곳에 둔다

서비스는 업무 단위를 알고, 하위 SQL 객체는 전달받은 연결만 사용합니다. 하위 객체가 각각 DataSource.getConnection()을 호출하면 한 서비스 호출이 여러 트랜잭션으로 갈라집니다.

src/main/java/board/jdbc/ManualTransaction.java
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;
    }
}

이미 자동 커밋이 꺼진 연결은 이 최소 도우미가 소유할 수 없으므로 시작 전에 거부합니다. 중첩과 저장점 의미를 어설프게 추측하지 않습니다.

setAutoCommit(false)가 성공한 뒤의 검사 예외, 런타임 예외, Error에서는 롤백을 시도합니다. transactionStarted는 시작 성공을, transactionCompleted는 커밋이나 롤백 호출의 성공을 기록합니다. 완료가 확인된 경우에만 원래 자동 커밋 모드로 돌립니다. 롤백이 실패한 활성 트랜잭션에서 setAutoCommit(true)가 성공하면 남은 변경을 커밋할 수 있기 때문입니다.

업무 또는 커밋 실패가 이미 있으면 뒤따른 롤백·복원·닫기 실패를 그 예외에 억제 예외로 추가합니다. 롤백에 실패한 경우에는 복원을 건너뛰고 try-with-resources가 연결 닫기를 시도합니다. 활성 트랜잭션을 가진 연결의 close 결과는 JDBC에서 구현 정의입니다. 이 최소 도우미는 실패한 연결의 안전한 폐기나 풀 재사용 정책까지 구현하지 않습니다. 커밋까지 성공한 뒤 복원이나 닫기만 실패하면 먼저 발생한 정리 실패가 주 예외이고 이후 정리 실패가 그 아래에 억제 예외로 추가됩니다.

커밋 호출이 실패하면 서버가 커밋했는지 알 수 없는 구간이 있습니다. 뒤따른 롤백 호출이 성공해도 앞선 커밋의 서버 반영 여부를 확정한 것은 아닙니다. 호출자는 client_request_id로 상태를 조회하거나 같은 명령을 멱등하게 재시도해야 합니다.

수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다

획득·autoCommit 검사/시작·업무·완료·복원·close와 실패 억제의 소유 순서가 핵심이다.

Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제하고 없으면 최초 정리 실패를 주 예외로 던진다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

최초 실패를 보존하며 같은 연결의 트랜잭션을 정리한다

소유한 연결의 autoCommit=false 설정이 성공하면 같은 연결로 업무 SQL을 실행합니다. commit 또는 rollback 호출이 성공해야 상태를 복원합니다. rollback 실패에서는 복원을 생략하고 close를 시도하며 원래 실패를 유지합니다.

최초 실패를 보존하며 같은 연결의 트랜잭션을 정리한다Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제하고 없으면 최초 정리 실패를 주 예외로 던진다.Connection 획득원래 상태 확인autoCommit=false성공한 시작만 기록같은 연결quota + postcommit / rollback실패는 보존완료 확인 후 복원close결과 또는 주 예외rollback 실패 → 복원 생략
완료 확인 뒤 복원하고 rollback 실패면 바로 close로 간다여섯노드여섯간선. 원desktop의성공한시작후업무/완료/복원/close흐름에rollback실패bypass를추가하여두layout동일관계를그린다. 완료확인후복원과rollback실패시복원생략은두대체경로다. 시작전실패의close는본문코드범위이며이그림의성공한시작흐름에합치지않는다. commit호출실패의DB반영여부는이도식만으로확정하지않는다.Connection 획득원래 상태 확인autoCommit=false성공한 시작 기록같은 연결quota + postcommit / rollback호출 완료 판단완료 확인 후autoCommit 복원close 시도결과 / 주 예외완료 확인rollback 실패복원 생략

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수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다
2Connection을 획득하고 원래 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와 실패 억제의 소유 순서가 핵심이다.

Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제하고 없으면 최초 정리 실패를 주 예외로 던진다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

최초 실패를 보존하며 같은 연결의 트랜잭션을 정리한다

소유한 연결의 autoCommit=false 설정이 성공하면 같은 연결로 업무 SQL을 실행합니다. commit 또는 rollback 호출이 성공해야 상태를 복원합니다. rollback 실패에서는 복원을 생략하고 close를 시도하며 원래 실패를 유지합니다.

최초 실패를 보존하며 같은 연결의 트랜잭션을 정리한다Connection을 획득하고 원래 autoCommit을 읽어 false이면 중첩을 거부한다. autoCommit=false 시작이 성공한 뒤 같은 연결로 업무 SQL을 실행한다. commit 또는 rollback 호출이 성공해야 autoCommit을 복원하며, rollback 실패에서는 복원을 건너뛰고 close를 시도한다. 원래 실패가 있으면 정리 실패를 억제하고 없으면 최초 정리 실패를 주 예외로 던진다.Connection 획득원래 상태 확인autoCommit=false성공한 시작만 기록같은 연결quota + postcommit / rollback실패는 보존완료 확인 후 복원close결과 또는 주 예외rollback 실패 → 복원 생략
완료 확인 뒤 복원하고 rollback 실패면 바로 close로 간다여섯노드여섯간선. 원desktop의성공한시작후업무/완료/복원/close흐름에rollback실패bypass를추가하여두layout동일관계를그린다. 완료확인후복원과rollback실패시복원생략은두대체경로다. 시작전실패의close는본문코드범위이며이그림의성공한시작흐름에합치지않는다. commit호출실패의DB반영여부는이도식만으로확정하지않는다.Connection 획득원래 상태 확인autoCommit=false성공한 시작 기록같은 연결quota + postcommit / rollback호출 완료 판단완료 확인 후autoCommit 복원close 시도결과 / 주 예외완료 확인rollback 실패복원 생략

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수동 트랜잭션은 최초 실패를 보존하며 역순으로 정리한다
2Connection을 획득하고 원래 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 · 원래 실패 없음: 최초 정리 실패가 주 예외연결을 정리하되 원래 실패의 우선순위를 보존합니다.

같은 Connection으로 SQL을 조합한다

ManualPostService는 시계를 한 번 읽고, 앞 문서의 PostRegistrationSql에 같은 연결과 시각을 전달합니다.

src/main/java/board/jdbc/ManualPostService.java
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 안의 독립 시나리오에서 주 예외의 동일성, 억제 예외 순서, 자동 커밋 복원 호출 여부를 확인합니다.

src/test/java/board/jdbc/ManualTransactionTest.java
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 전 falsecommit 또는 rollback 성공 뒤 원래 값, rollback 실패면 복원 생략활성 트랜잭션 중 true 변경은 커밋을 일으킨다
격리 수준트랜잭션 시작 전원래 격리로 복원활성 트랜잭션 중 변경 결과는 JDBC 수준에서 구현 정의다
readOnly트랜잭션 시작 전 힌트원래 값으로 복원모든 DB가 쓰기를 강제로 막는다고 가정하지 않는다
네트워크 타임아웃연결 사용 전원래 값으로 복원SQL·업무 전체 타임아웃과 범위가 다르다

이 최소 도우미는 자동 커밋만 직접 바꿉니다. 옵션을 추가한다면 적용에 성공한 상태만 역순으로 복원하고 각 실패를 최초 예외에 억제 예외로 추가해야 합니다.


저장점은 부분 성공이 업무 계약일 때만 쓴다

저장점은 같은 트랜잭션 안의 일부 변경을 되돌릴 수 있지만, 모든 중첩 호출을 새 트랜잭션처럼 만들지는 않습니다.

질문예일 때아니오일 때
부분 성공을 사용자에게 명확히 반환하는가?저장점 후보전체 롤백
DB와 드라이버가 저장점을 지원하는가?다음 질문전체 롤백
바깥 트랜잭션이 계속 유효함을 검증했는가?실패 구간만 rollback-to-savepoint전체 롤백
외부 I/O까지 되돌릴 수 있는가?별도 보상 계약 검토아웃박스·멱등 경계로 분리

일반 게시글 등록은 사용량과 게시글이 함께 성공해야 하므로 저장점으로 부분 커밋하지 않습니다. CSV 일괄 가져오기처럼 행별 성공 결과가 명시된 업무에서만 후보가 됩니다.


연습 문제

ManualTransaction의 커밋이 예외를 던졌지만 DB에는 실제로 반영된 상황을 가정하세요.

응답을 받지 못한 클라이언트가 같은 client_request_id로 재시도할 때 중복 게시글을 만들지 않고 기존 결과를 확인하는 흐름을 설계합니다.

해설 보기

재시도는 같은 회원과 같은 요청 ID를 유지합니다. 유일 제약 충돌이 나면 무조건 새 실패로 끝내지 말고 그 키로 기존 게시글을 조회해 원래 명령의 결과인지 확인합니다.

제목·본문·게시일이 다르면 같은 멱등 키를 다른 명령에 재사용한 충돌이므로 성공으로 바꾸지 않습니다. 커밋 결과가 불확실하다는 이유로 새 요청 ID를 발급하면 중복을 막을 수 없습니다.

다음 문서에서는 이 반복적인 연결 획득·커밋·롤백·복원 코드를 PlatformTransactionManager와 TransactionTemplate에 맡깁니다.