왜 '@Transactional'은 AOP인가?
스프링을 처음 학습하는 초보자 분들이 흔히 하는 실수가 있습니다..! 바로 서비스 레이어에서 @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를 사용할 때는
Connection
과PreparedStatement
를 반드시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());
}
}
}
Connection
과PreparedStatement
는 이제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. 트랜잭션 시작
프록시는 트랜잭션 매니저를 통해 트랜잭션을 시작합니다. 이때 데이터베이스 연결의
AutoCommit
을false
로 설정하여 수동으로 트랜잭션을 제어합니다.
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와 관련된 더 깊이 있는 내용을 다룰 예정이니, 많은 기대 부탁드립니다! 😆✨