Java 기초 — JDBC 트랜잭션, commit과 rollback이 필요한 이유

@TSP·2026년 9월 14일

Java

목록 보기
21/28

JDBC 코드를 살펴보던 중 다음 코드를 발견했다.

connection.setAutoCommit(false);

자동 커밋을 끄는 코드지만, 이후에 commit()이나 rollback()이 호출되지 않았다.

트랜잭션은 단순히 자동 커밋을 끄는 것으로 끝나지 않는다. 작업을 확정할지 되돌릴지까지 직접 결정해야 한다.

이번에는 저장소의 코드를 바탕으로 JDBC 트랜잭션이 필요한 이유와 올바른 처리 흐름을 정리했다.

트랜잭션이란?

트랜잭션은 여러 데이터베이스 작업을 하나의 작업 단위로 묶는 것이다.

묶인 작업은 다음 두 가지 결과 중 하나만 가져야 한다.

  • 모든 작업이 성공한다.
  • 하나라도 실패하면 모든 작업을 취소한다.

예를 들어 계좌 이체를 생각할 수 있다.

  1. 보내는 사람의 잔액을 줄인다.
  2. 받는 사람의 잔액을 늘린다.

첫 번째 작업만 성공하고 두 번째 작업이 실패하면 돈이 사라진다. 따라서 두 SQL은 하나의 트랜잭션으로 처리해야 한다.

모두 성공 → commit
하나라도 실패 → rollback

이를 트랜잭션의 원자성이라고 한다.


JDBC의 자동 커밋

JDBC의 Connection은 기본적으로 자동 커밋이 활성화되어 있다.

connection.getAutoCommit(); // 기본값 true

자동 커밋이 켜져 있으면 SQL 하나가 실행될 때마다 결과가 데이터베이스에 바로 반영된다.

preparedStatement.executeUpdate();

SQL 실행이 성공한 순간 해당 작업이 자동으로 커밋된다. 이후 다른 SQL에서 오류가 발생해도 이미 반영된 작업은 되돌릴 수 없다.

여러 SQL을 하나의 작업으로 묶으려면 자동 커밋을 꺼야 한다.

connection.setAutoCommit(false);

이제 SQL을 실행해도 바로 확정되지 않는다. 개발자가 직접 commit() 또는 rollback()을 호출해야 한다.


commit과 rollback

commit

트랜잭션에서 실행한 작업을 데이터베이스에 최종 반영한다.

connection.commit();

모든 SQL이 정상적으로 실행된 뒤 호출해야 한다.

rollback

현재 트랜잭션에서 실행한 작업을 취소한다.

connection.rollback();

중간에 오류가 발생하면 rollback()을 호출해 트랜잭션 시작 전 상태로 되돌린다.

기본적인 구조는 다음과 같다.

connection.setAutoCommit(false);

try {
    // 첫 번째 SQL
    // 두 번째 SQL

    connection.commit();

} catch (SQLException e) {
    connection.rollback();
}

setAutoCommit(false)만 사용하면 안 되는 이유

저장소의 일부 삭제 코드에는 다음과 같은 흐름이 있다.

connection = DBConnecter.getConnection();
connection.setAutoCommit(false);

preparedStatement =
        connection.prepareStatement(query);

preparedStatement.setLong(1, id);
preparedStatement.executeUpdate();

자동 커밋을 껐지만 작업을 확정하는 코드가 없다.

connection.commit();

오류가 발생했을 때 작업을 취소하는 코드도 없다.

connection.rollback();

트랜잭션을 시작했다면 반드시 성공과 실패의 처리 방향을 결정해야 한다.

setAutoCommit(false)
        ↓
SQL 실행
   ↙         ↘
성공          실패
commit       rollback

연결을 닫는 동작에 기대서 트랜잭션을 처리해서는 안 된다. 커밋과 롤백을 명시적으로 호출해야 코드의 의도가 분명해진다.


단일 SQL에도 트랜잭션이 필요할까?

다음 코드는 SQL을 하나만 실행한다.

DELETE FROM tbl_member
WHERE id = ?

SQL 하나만 실행하고 별도의 후속 작업이 없다면 기본 자동 커밋을 그대로 사용해도 된다.

public void delete(Long id) {
    String sql =
            "DELETE FROM tbl_member " +
            "WHERE id = ?";

    try (
        Connection connection =
                DBConnecter.getConnection();
        PreparedStatement statement =
                connection.prepareStatement(sql)
    ) {
        statement.setLong(1, id);
        statement.executeUpdate();

    } catch (SQLException e) {
        throw new RuntimeException(
                "회원 삭제에 실패했습니다.",
                e
        );
    }
}

반대로 자동 커밋을 끈다면 SQL이 하나여도 직접 커밋해야 한다.

connection.setAutoCommit(false);

statement.executeUpdate();

connection.commit();

트랜잭션이 특히 중요한 경우는 서로 관련된 SQL을 여러 개 실행할 때다.


저장소의 게시글 조회 흐름

저장소의 PostDAO.findById()는 게시글을 조회할 때 두 개의 SQL을 실행한다.

첫 번째 SQL은 조회수를 증가시킨다.

UPDATE tbl_post
SET post_read_count = post_read_count + 1
WHERE id = ?
  AND post_status = ?;

두 번째 SQL은 회원 테이블과 JOIN해 게시글 정보를 조회한다.

SELECT p.id,
       p.post_title,
       p.post_content,
       p.post_read_count,
       m.member_name
FROM tbl_member m
JOIN tbl_post p
  ON m.id = p.member_id
WHERE p.id = ?
  AND p.post_status = ?;

이 두 작업이 자동 커밋 상태에서 실행된다고 생각해보자.

  1. 조회수 증가에 성공한다.
  2. 조회 SQL에서 오류가 발생한다.
  3. 사용자는 게시글을 전달받지 못한다.
  4. 조회수만 증가한 상태로 남는다.

두 작업을 하나의 기능으로 본다면 함께 성공하거나 함께 취소하는 편이 자연스럽다.


같은 Connection을 사용해야 하는 이유

트랜잭션은 Connection을 기준으로 관리된다.

Connection connection =
        DBConnecter.getConnection();

connection.setAutoCommit(false);

같은 트랜잭션에 포함할 SQL은 반드시 같은 Connection을 사용해야 한다.

increaseReadCount(connection, id);
findPost(connection, id);

각 메서드가 새로운 Connection을 만들면 서로 다른 트랜잭션이 된다.

// 서로 다른 연결이므로 하나의 트랜잭션이 아님
increaseReadCount(DBConnecter.getConnection(), id);
findPost(DBConnecter.getConnection(), id);

따라서 트랜잭션을 관리하는 메서드가 Connection을 만들고, 하위 메서드에 같은 Connection을 전달하는 방식으로 작성할 수 있다.


조회수 증가와 게시글 조회 묶기

먼저 조회수를 증가시키는 메서드를 작성한다.

private int increaseReadCount(
        Connection connection,
        Long id
) throws SQLException {

    String sql =
            "UPDATE tbl_post " +
            "SET post_read_count = post_read_count + 1 " +
            "WHERE id = ? " +
            "AND post_status = ?";

    try (PreparedStatement statement =
             connection.prepareStatement(sql)) {

        statement.setLong(1, id);
        statement.setString(
                2,
                Status.ENABLE.getValue()
        );

        return statement.executeUpdate();
    }
}

Connection을 내부에서 새로 만들지 않고 매개변수로 전달받았다.

게시글 조회 메서드도 같은 방식으로 작성한다.

private Optional<PostDTO> findPost(
        Connection connection,
        Long id
) throws SQLException {

    String sql =
            "SELECT p.id, p.post_title, p.post_content, " +
            "p.post_read_count, p.post_status, " +
            "p.created_datetime, p.updated_datetime, " +
            "p.member_id, m.member_name " +
            "FROM tbl_post p " +
            "JOIN tbl_member m " +
            "ON m.id = p.member_id " +
            "WHERE p.id = ? " +
            "AND p.post_status = ?";

    try (
        PreparedStatement statement =
                connection.prepareStatement(sql)
    ) {
        statement.setLong(1, id);
        statement.setString(
                2,
                Status.ENABLE.getValue()
        );

        try (ResultSet resultSet =
                 statement.executeQuery()) {

            if (resultSet.next()) {
                return Optional.of(
                        createPostDTO(resultSet)
                );
            }
        }
    }

    return Optional.empty();
}

두 메서드는 SQL만 실행하고, 커밋과 롤백은 전체 작업을 관리하는 메서드가 담당한다.


트랜잭션 적용하기

public Optional<PostDTO> readPost(Long id) {
    try (
        Connection connection =
                DBConnecter.getConnection()
    ) {
        connection.setAutoCommit(false);

        try {
            int updatedCount =
                    increaseReadCount(connection, id);

            if (updatedCount == 0) {
                connection.rollback();
                return Optional.empty();
            }

            Optional<PostDTO> post =
                    findPost(connection, id);

            if (post.isEmpty()) {
                connection.rollback();
                return Optional.empty();
            }

            connection.commit();
            return post;

        } catch (SQLException e) {
            rollback(connection, e);
            throw e;
        }

    } catch (SQLException e) {
        throw new RuntimeException(
                "게시글 조회에 실패했습니다.",
                e
        );
    }
}

처리 순서는 다음과 같다.

  1. 데이터베이스에 연결한다.
  2. 자동 커밋을 끈다.
  3. 조회수를 증가시킨다.
  4. 게시글을 조회한다.
  5. 모든 작업이 성공하면 커밋한다.
  6. 조회할 게시글이 없거나 오류가 발생하면 롤백한다.
  7. Connection을 닫는다.

rollback도 실패할 수 있다

rollback() 역시 데이터베이스 작업이므로 SQLException이 발생할 수 있다.

별도의 메서드로 분리해 처리할 수 있다.

private void rollback(
        Connection connection,
        SQLException originalException
) {
    try {
        connection.rollback();

    } catch (SQLException rollbackException) {
        originalException.addSuppressed(
                rollbackException
        );
    }
}

처음 발생한 예외는 유지하면서 롤백 중 발생한 예외를 추가로 기록한다.

rollback(connection, e);
throw e;

예외를 단순히 출력하고 정상적으로 처리된 것처럼 끝내지 않는 것도 중요하다. 데이터베이스 작업이 실패했다면 호출한 코드에서도 실패 사실을 알 수 있어야 한다.


try-with-resources와 트랜잭션

Connection, PreparedStatement, ResultSet은 사용이 끝나면 닫아야 한다.

try (
    Connection connection =
            DBConnecter.getConnection()
) {
    // 트랜잭션 처리
}

try-with-resources를 사용하면 블록이 끝날 때 Connection이 자동으로 닫힌다.

하지만 자원을 자동으로 닫는 것과 트랜잭션을 완료하는 것은 다른 문제다.

close()    → 연결과 자원을 정리
commit()   → 변경 내용을 확정
rollback() → 변경 내용을 취소

따라서 Connection이 자동으로 닫히더라도 커밋과 롤백은 코드에서 직접 처리해야 한다.


트랜잭션의 범위

모든 SQL을 하나의 거대한 트랜잭션으로 묶는 것은 좋지 않다.

트랜잭션은 하나의 기능을 완성하는 데 필요한 작업만 포함해야 한다.

조회수 증가 + 게시글 조회
→ 게시글 한 건을 읽는 기능
주문 등록 + 재고 감소 + 결제 기록
→ 주문을 완료하는 기능

트랜잭션이 너무 길어지면 데이터베이스 연결과 잠금을 오래 점유할 수 있다. 반대로 너무 잘게 나누면 일부 작업만 반영되는 문제가 생긴다.

어디까지 하나의 성공 또는 실패로 봐야 하는지를 기준으로 범위를 정해야 한다.


정리

이번에는 JDBC 트랜잭션의 기본 흐름을 정리했다.

  • JDBC Connection은 기본적으로 자동 커밋 상태다.
  • 자동 커밋 상태에서는 SQL이 실행될 때마다 결과가 확정된다.
  • setAutoCommit(false)로 직접 트랜잭션을 시작할 수 있다.
  • 모든 작업이 성공하면 commit()을 호출한다.
  • 중간에 실패하면 rollback()을 호출한다.
  • setAutoCommit(false)만 호출하고 끝내면 안 된다.
  • 같은 트랜잭션의 SQL은 같은 Connection을 사용해야 한다.
  • close()는 자원 정리이고 commit()은 변경 내용의 확정이다.
  • 트랜잭션은 하나의 기능을 완성하는 범위로 설정한다.

트랜잭션에서 중요한 것은 SQL의 개수가 아니다. 여러 작업을 하나의 성공과 실패로 다뤄야 하는지가 핵심이다.

저장소의 코드를 살펴보면서 자동 커밋을 끄는 것만으로는 트랜잭션이 완성되지 않으며, 성공과 실패에 따라 커밋과 롤백을 명확하게 호출해야 한다는 점을 확인할 수 있었다.

학습 코드: tomatosugarpickled/study-java의 y_jdbc

profile
새로운 배움을 꾸준히 작성하기 위한 공간입니다.

0개의 댓글