avatar
띵로그

왜 '@Transactional'은 AOP인가?

스프링의 @Transactional을 통해 트랜잭션 관리가 자동화되는 과정을 설명하며, AOP와 프록시 패턴을 활용한 효율적인 트랜잭션 처리의 중요성을 다룹니다.
트랜잭션AOPSpringJava
3 months ago
·
17 min read

스프링을 처음 학습하는 초보자 분들이 흔히 하는 실수가 있습니다..! 바로 서비스 레이어에서 @Service 애노테이션과 함께 @Transactional을 습관적으로 세트로 붙이는 것입니다. 그러면 이것이 잘못된 걸까요? 아닙니다. 당연하게도 서비스 레이어에서는 @Transactional을 붙여주는 것이 좋습니다.

대부분의 개발자들은 서비스 클래스에서 DAO 혹은 리포지토리를 사용하여 CRUD와 관련한 로직을 많이 처리합니다. 리포지토리를 통해 실제 DB와 상호 작용하기 때문에 데이터베이스 작업 중 일관성, 안전성, 무결성을 보장해야 하며, 에러 발생 시 자 동 롤백이 필요합니다.

만약 @Transactional이 없다면 여러 문제점이 발생할 수 있으므로 작성해주는 것이 좋습니다.

또한 메서드 단위로 일일이 붙여주시는 분들도 계시지만, 클래스 단위에 붙여주면 해당 클래스 내부의 모든 메서드에서 자동으로 트랜잭션 처리를 해주어 편리합니다.

그런데 왜 @Transactional 어노테이션만 사용했는데 트랜잭션 처리가 자동으로 이루어질까요?


스프링을 JPA와 함께 처음 학습하신 분들은 잘 모를 수 있지만, 사실 JPA는 내부적으로 JDBC를 사용합니다. 순수 JDBC를 사용할 때는 드라이버 클래스를 로드하고, 커넥션을 직접 생성해서 트랜잭션을 시작하고, 커밋하고 하는 등 모든 작업을 개발자가 직접 해주어야 합니다. 하지만 기술이 추상화되고 발전하면서 이를 자동화해주게 되었습니다.

예를 들어, 순수 JDBC를 사용하면 아래와 같습니다.

public class MemberRepository {

    private static final String URL = "DATABASE_HOST";
    private static final String USER = "USERNAME";
    private static final String PASSWORD = "PASSWORD";

    public void saveMember(String memberId, String memberName) {

        Connection connection = null;
        PreparedStatement preparedStatement = null;

        try {
            // 1. 드라이버 로드
            Class.forName("com.mysql.cj.jdbc.Driver");
            // 2. 커넥션 생성
            connection = DriverManager.getConnection(URL, USER, PASSWORD);
            // 3. 트랜잭션 시작 (AutoCommit을 false로 설정)
            connection.setAutoCommit(false);

            // 4. SQL 작성 및 준비
            String sql = "INSERT INTO members (member_id, member_name) VALUES (?, ?)";
            preparedStatement = connection.prepareStatement(sql);

            // 5. 파라미터 바인딩
            preparedStatement.setString(1, memberId);
            preparedStatement.setString(2, memberName);

            // 6. SQL 실행
            preparedStatement.executeUpdate();

            // 7. 커밋 (트랜잭션 종료)
            connection.commit();
            System.out.println("회원 저장 성공: " + memberId + ", " + memberName);

        } catch (Exception e) {
            try {
                if (connection != null) {
                    // 예외 발생 시 롤백
                    connection.rollback();
                    System.out.println("롤백 수행: " + e.getMessage());
                }

            } catch (SQLException rollbackEx) {
                System.err.println("롤백 실패: " + rollbackEx.getMessage());
            }

            e.printStackTrace();

        } finally {
            // 8. 리소스 정리 (Connection, PreparedStatement 닫기)
            try {
                if (preparedStatement != null) {
                    preparedStatement.close();
                }

                if (connection != null) {
                    connection.close();
                }

            } catch (SQLException closeEx) {
                System.err.println("자원 해제 실패: " + closeEx.getMessage());
            }
        }
    }
}

위 코드는 다음과 같은 순서로 진행됩니다.

1. 드라이버 로드

  • JDBC 드라이버를 로드하여 MySQL과 같은 DBMS와 연결할 수 있도록 합니다.

    • Class.forName("com.mysql.cj.jdbc.Driver")를 통해 JDBC 드라이버 클래스를 명시적으로 로드합니다.

2. 커넥션 생성

  • DriverManager.getConnection()을 사용하여 데이터베이스와의 연결(`Connection`)을 만듭니다.

    • 이때 DB URL, 사용자 이름, 비밀번호를 사용합니다.

3. 트랜잭션 시작

  • connection.setAutoCommit(false)를 사용하여 트랜잭션을 수동으로 제어합니다.

    • 기본적으로 JDBC는 AutoCommit 모드이기 때문에, 이 설정을 끄면 트랜잭션을 개발자가 직접 커밋하거나 롤백할 수 있습니다.

4. SQL 작성 및 실행

  • PreparedStatement를 사용하여 SQL 쿼리를 준비합니다.

    • 쿼리에 바인딩할 파라미터(`memberId`, `memberName`)를 설정하고 executeUpdate()로 쿼리를 실행합니다.

5. 커밋 및 롤백

  • 작업이 정상적으로 완료되면 connection.commit()을 호출하여 트랜잭션을 커밋합니다.

  • 오류가 발생하면 catch 블록에서 connection.rollback()을 호출하여 롤백합니다.

6. 리소스 정리

  • JDBC를 사용할 때는 ConnectionPreparedStatement를 반드시 close()로 닫아줘야 합니다.

    • finally 블록에서 예외와 상관없이 자원을 정리합니다.

회원 저장 한 번 하는데 너무 복잡합니다 !


순수 JDBC를 사용하면 아래와 같은 단점들을 쉽게 눈치챌 수 있습니다.

- 반복되는 코드: 커넥션을 생성하고, 쿼리를 실행하며, 예외 처리 및 리소스를 정리하는 코드가 반복적으로 등장합니다. 이는 코드의 중복을 야기하고 유지보수가 어렵습니다.

- 직접적인 트랜잭션 관리: 트랜잭션을 수동으로 관리해야 하므로, 실수로 commit()을 호출하지 않거나 rollback()을 누락할 위험이 있습니다.

- 리소스 관리: 커넥션과 같은 자원을 명시적으로 닫아야 하므로, 실수로 리소스를 닫지 않으면 메모리 누수나 DB 커넥션 풀 고갈 같은 문제가 발생할 수 있습니다.

개발자들은 불필요한 반복과 중복을 싫어합니다. 중요한 것은 주요 비즈니스 로직이지, 드라이버를 로드하고 커넥션을 얻고, 직접 트랜잭션을 열고 닫고 예외 처리까지 하는 복잡하고 반복적인 작업이 아닙니다. 그래서 하나씩 불필요한 부분을 제거해보겠습니다 🔥

1. try-with-resources로 리소스 정리


자바 7부터는 try-with-resources 구문을 제공하여 AutoCloseable 인터페이스를 구현한 자원들을 자동으로 닫아줍니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class MemberRepository {

    private static final String URL = "DATABASE_HOST";
    private static final String USER = "USERNAME";
    private static final String PASSWORD = "PASSWORD";

    public void saveMember(String memberId, String memberName) {
        // 1. 드라이버 로드
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
        } catch (ClassNotFoundException e) {
            throw new IllegalStateException("JDBC 드라이버 로드 실패", e);
        }

        // 2. try-with-resources 구문을 사용해 Connection과 PreparedStatement를 자동으로 닫음
        try (
            Connection connection = DriverManager.getConnection(URL, USER, PASSWORD);
            PreparedStatement preparedStatement = connection.prepareStatement(
                "INSERT INTO members (member_id, member_name) VALUES (?, ?)")
        ) {
            // 3. 트랜잭션 시작 (AutoCommit을 false로 설정)
            connection.setAutoCommit(false);

            // 4. 파라미터 바인딩
            preparedStatement.setString(1, memberId);
            preparedStatement.setString(2, memberName);

            // 5. SQL 실행
            preparedStatement.executeUpdate();

            // 6. 커밋 (트랜잭션 종료)
            connection.commit();

            System.out.println("회원 저장 성공: " + memberId + ", " + memberName);
        } catch (SQLException e) {
            // 예외 처리 및 오류 메시지 출력
            e.printStackTrace();
            System.err.println("회원 저장 실패: " + e.getMessage());
        }
    }
}
  • ConnectionPreparedStatement는 이제 try 블록 안에서 선언되었으며, 자동으로 자원 해제가 이루어집니다.

-> 더 이상 finally 블록에서 수동으로 close()를 호출할 필요가 없습니다.

2. 드라이버 로드 자동화


JDBC 4.0 이상부터는 드라이버를 명시적으로 로드하지 않아도 됩니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class MemberRepository {

    private static final String URL = "DATABASE_HOST";
    private static final String USER = "USERNAME";
    private static final String PASSWORD = "PASSWORD";

    public void saveMember(String memberId, String memberName) {
        // try-with-resources 구문을 사용해 Connection과 PreparedStatement를 자동으로 닫음
        try (
            Connection connection = DriverManager.getConnection(URL, USER, PASSWORD);
            PreparedStatement preparedStatement = connection.prepareStatement(
                "INSERT INTO members (member_id, member_name) VALUES (?, ?)")
        ) {
            // 트랜잭션 시작 (AutoCommit을 false로 설정)
            connection.setAutoCommit(false);

            // 파라미터 바인딩
            preparedStatement.setString(1, memberId);
            preparedStatement.setString(2, memberName);

            // SQL 실행
            preparedStatement.executeUpdate();

            // 커밋 (트랜잭션 종료)
            connection.commit();

            System.out.println("회원 저장 성공: " + memberId + ", " + memberName);
        } catch (SQLException e) {
            // 예외 처리 및 오류 메시지 출력
            e.printStackTrace();
            System.err.println("회원 저장 실패: " + e.getMessage());
        }
    }
}
  • 드라이버 로드 부분 삭제: Class.forName("com.mysql.cj.jdbc.Driver") 코드를 제거했습니다.

3. 트랜잭션 관리 자동화


이제 트랜잭션 시작과 종료 부분만 제외하면 딱입니다. 바로 여기에서 스프링의 @Transactional이 등장합니다.

@Transactional 은 어떻게 트랜잭션을 자동으로 관리할까?

스프링의 @Transactional 애노테이션은 AOP(Aspect-Oriented Programming)프록시 패턴을 기반으로 동작합니다. 이를 통해 개발자는 트랜잭션 관리에 대한 코드를 직접 작성하지 않아도 됩니다.

AOP(관점 지향 프로그래밍): 핵심 비즈니스 로직과 부가적인 관심사를 분리하여 모듈화하는 프로그래밍 패러다임입니다. 예를 들어, 로깅, 보안, 트랜잭션 관리 등의 공통 기능을 비즈니스 로직과 분리할 수 있습니다.

프록시 패턴: 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리자나 대리인을 제공하는 디자인 패턴입니다.

@Transactional의 동작 과정

1. 프록시 생성

  • 스프링은 @Transactional 애노테이션이 붙은 클래스나 메서드를 감지합니다. 이를 위해 스프링은 해당 클래스의 프록시 객체를 생성합니다.

2. 메서드 호출 가로채기

  • 클라이언트가 @Transactional이 적용된 메서드를 호출할 때, 실제 객체 대신 프록시 객체가 메서드 호출을 가로챕니다.

3. 트랜잭션 시작

  • 프록시는 트랜잭션 매니저를 통해 트랜잭션을 시작합니다. 이때 데이터베이스 연결의 AutoCommitfalse로 설정하여 수동으로 트랜잭션을 제어합니다.

4. 비즈니스 로직 실행

  • 트랜잭션이 시작된 상태에서 실제 비즈니스 로직이 실행됩니다.

5. 트랜잭션 커밋 또는 롤백

  • 비즈니스 로직이 정상적으로 완료되면 프록시는 트랜잭션을 커밋합니다.

  • 예외가 발생하면 프록시는 트랜잭션을 롤백합니다.

6. 리소스 정리

  • 트랜잭션이 완료되면, 스프링은 사용된 자원들을 자동으로 정리합니다.

기존 코드에서 @Transactional 적용하기

이제 기존의 코드를 스프링의 @Transactional을 사용하여 더욱 간단하게 바꿔보겠습니다.

import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

@Repository
public class MemberRepository {

    private static final String URL = "DATABASE_HOST";
    private static final String USER = "USERNAME";
    private static final String PASSWORD = "PASSWORD";

    @Transactional
    public void saveMember(String memberId, String memberName) {
        try (
            Connection connection = DriverManager.getConnection(URL, USER, PASSWORD);
            PreparedStatement preparedStatement = connection.prepareStatement(
                "INSERT INTO members (member_id, member_name) VALUES (?, ?)")
        ) {
            // 파라미터 바인딩
            preparedStatement.setString(1, memberId);
            preparedStatement.setString(2, memberName);

            // SQL 실행
            preparedStatement.executeUpdate();
            System.out.println("회원 저장 성공: " + memberId + ", " + memberName);

        } catch (SQLException e) {
            ...
        }
    }
}

1. 트랜잭션 관리 코드 제거

  • 기존 코드에서 수동으로 처리하던 connection.setAutoCommit(false)connection.commit() 부분이 제거되었습니다.

    • 스프링이 자동으로 트랜잭션을 관리해주기 때문에, 개발자는 이런 저수준 트랜잭션 관리를 신경 쓸 필요가 없습니다.

2. `@Transactional` 사용

  • @Transactional 애노테이션을 추가하여 메서드 전체를 트랜잭션 관리 대상으로 지정했습니다.

    • 스프링은 이 애노테이션을 보고 해당 메서드를 호출할 때 트랜잭션을 시작하고, 종료 시점에 자동으로 커밋하거나 롤백합니다.

3. 프록시를 통한 자동화

  • 프록시 객체가 saveMember() 메서드를 감싸고 있으며, 클라이언트가 이 메서드를 호출하면 스프링 프록시가 트랜잭션을 시작하고, 예외 발생 시 자동으로 롤백합니다.


결론

이번 포스트를 작성하면서 저도 JDBC, 트랜잭션, AOP에 대해 다시 한 번 복습할 수 있었습니다. 물론, 모든 것을 세세하게 외워야 한다는 의미는 아닙니다. 이 부분들은 이미 충분히 자동화된 검증된 기술이기 때문에, 중요한 것은 트랜잭션의 중요성원리를 이해하고, 우리가 이를 사용할 때 어떤 원리로 동작하는지 감각적으로 파악하는 것입니다 :)

특히, AOP를 통해 핵심 로직공통 관심사를 분리하는 방식은 개발 생산성을 크게 높여줍니다. 오늘의 핵심은, 트랜잭션 관리가 AOP 기반으로 이루어지며, 이를 통해 우리는 비즈니스 로직에만 집중할 수 있는 환경을 제공받고 있다는 것입니다. (감사드립니다)

저는 개인적으로 AOP를 주로 로직의 시간 측정에 활용하고 있지만, 이 외에도 무궁무진한 가능성이 있는 기술입니다. 부족하지만 제 포스트를 읽어주셔서 정말 감사드리며, 앞으로도 AOP와 관련된 더 깊이 있는 내용을 다룰 예정이니, 많은 기대 부탁드립니다! 😆✨


- 컬렉션 아티클






주니어 백엔드 개발자입니다 :)