트랜잭션(Transaction): 트랜잭션은 데이터베이스에서 하나의 논리적인 작업 단위를 의미하며, 데이터의 일관성을 보장하기 위해 사용된다. 트랜잭션은 여러 작업을 하나로 묶어, 모두 성공하거나 모두 실패하도록 처리한다. 트랜잭션이 성공적으로 완료되면 커밋(Commit)하여 영구적으로 반영하고, 문제가 발생하면 롤백(Rollback) 하여 이전 상태로 되돌된다.
트랜잭션의 일부인 작업 C가 실패하면 나머지 작업은 전부 성공했더라도 작업이 일어나기 전으로 돌아가는 롤백(Rollback)이 일어난다.
트랜잭션 안의 모든 작업이 성공해야지 트랜잭션이 성공하고 데이터베이스에 커밋되어서 반영된다.
스프링에서는 룰백정책(rollbackFor), 전파(Propagation), 격리수준(Isolation)의 3가지 속성을 조작해서 트랜잭션의 범위와 세부적인 조작이 가능하다.
격리수준은 따로 차후 트랜잭션과 동시성 제어 포스팅에서 자세하게 다뤄 이번 포스팅에서는 제외할 것이다.
스프링의 기본 롤백 정책은 RuntimeException(언체크 에외)
와 그 하위 예외가 발생했을 때에만 롤백이 진행되는 것이다.
즉, 이 말은 트랜잭션 내에서 Exception(체크 예외)
가 발생했더라도 해당 트랜잭션에서 롤백이 일어나지 않는다는 뜻이다.
이미지처럼 만약 특정 예외를 롤백 정책에 추가하고 싶다면 rollbackFor
옵션을, 특정 예외를 롤백 정책에서 제거하고 싶다면 noRollbackFor
옵션을 사용하면 된다.
에시 코드를 통해 살펴보자.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RollbackService {
private final CourseRepository courseRepository;
/**
* 기본 롤백 정책
* 언체크예외(RuntimeException) 만 rollback 시킵니다.
*/
@Transactional
public void updateCourseWithDefaultRollback() throws Exception {
// 1. 조회: init 수업 조회
Course foundCourse = courseRepository.findById(1L)
.orElseThrow(RuntimeException::new);
// 2. 변경 후 저장: Java 수업 저장
Course updateCourse = foundCourse.updateName("Java");
Course savedCourse1 = courseRepository.save(updateCourse);
// ---------언체크 예외 발생------------
if (true) {
throw new RuntimeException();
}
// --------------------------------
// 3. 변경 후 저장: Mysql 수업 저장
Course updateCourse2 = savedCourse1.updateName("MySQL");
Course savedCourse2 = courseRepository.save(updateCourse2);
}
현재는 따로 롤백 관련해서 설정을 안 했기 때문에 RuntimeExcpetion시에만 롤백되는 기본 설정이다.
트랜잭션 안에서 Java로 수업 이름을 변경 후 RuntimeException이 일어났기 때문에 트랜잭션이 실패하여 다시 기존의 상태로 롤백되어 Course의 이름은 Java로 바뀌었던 것이 취소되고 그대로 "init"일 것이다.
그렇다면 RuntimeException이 아니라 Exception이 발생하면 어떻게 될까???
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RollbackService {
private final CourseRepository courseRepository;
/**
* 기본 롤백 정책
* 언체크예외(RuntimeException) 만 rollback 시킵니다.
*/
@Transactional
public void updateCourseWithDefaultRollback() throws Exception {
// 1. 조회: init 수업 조회
Course foundCourse = courseRepository.findById(1L)
.orElseThrow(RuntimeException::new);
// 2. 변경 후 저장: Java 수업 저장
Course updateCourse = foundCourse.updateName("Java");
Course savedCourse1 = courseRepository.save(updateCourse);
// ------ 체크 예외 발생 ---------------
if (true) {
throw new Exception();
}
// ----------------------------------
// 3. 변경 후 저장: Mysql 수업 저장
Course updateCourse2 = savedCourse1.updateName("MySQL");
Course savedCourse2 = courseRepository.save(updateCourse2);
}
앞서 말했듯이 스프링의 기본 롤백 설정은 RuntimeExcpetion이 발생했을 때에만 롤백이 발생하기 때문에 Exception만 발생한 현재 코드에서는 롤백이 일어나지 않는다. 따라서 DB의 Course의 이름을 확인해보면 Exception이 발생하기 전인 "Java"로 바뀐 것을 확인할 수 있을 것이다.
이번에는 기본 롤백 정책을 바꿔서 rollbackFor
를 통해 특정 예외를 롤백 정책 조건에 포함해보자.
@Transactional(rollbackFor = 추가하고 싶은 예외.class)
rollbackFor
조건을 사용하기 위해서는 @Transactional
애노테이션에 (rollbackFor = 추가하고 싶은 예외.class)
를 추가해주면 된다.
/**
* rollbackFor 활용
* 기존 rollback 정책 조건에 특정 예외를 추가합니다.
*/
@Transactional(rollbackFor = Exception.class)
public void updateCourseWithRollbackFor() throws Exception {
// 1. 조회: init 수업 조회
Course foundCourse = courseRepository.findById(1L)
.orElseThrow(RuntimeException::new);
// 2. 변경 후 저장: Java 수업 저장
Course updateCourse = foundCourse.updateName("Java");
Course savedCourse1 = courseRepository.save(updateCourse);
if (true) {
throw new Exception();
}
// 3. 변경 후 저장: Mysql 수업 저장
Course updateCourse2 = savedCourse1.updateName("MySQL");
Course savedCourse2 = courseRepository.save(updateCourse2);
}
기존에는 트랜잭션에서 롤백에 적용 안 되던 Exception
을 추가한 후 Exception을 발생시키면 롤백이 발생하여 "Java"로 Course 이름이 바뀐 것이 취소되어 다시 Course 이름은 "init"으로 롤백될 것이다.
이번에는 noRollbackFor
옵션을 사용하여 특정 예외를 롤백 정책에서 제외시켜 보자.
@Transactional(noRollbackFor = RuntimeException.class)
이번에도 마찬가지로 @Transactional 애노테이션에 (noRollbackFor = 제외하고 싶은 예외.class)
조건을 추가해주면 적용된다.
/**
* noRollbackFor 활용
*/
@Transactional(noRollbackFor = RuntimeException.class)
public void updateCourseWithNoRollbackFor() throws Exception {
// 1. 조회: init 수업 조회
Course foundCourse = courseRepository.findById(1L)
.orElseThrow(RuntimeException::new);
// 2. 변경 후 저장: Java 수업 저장
Course updateCourse = foundCourse.updateName("Java");
Course savedCourse1 = courseRepository.save(updateCourse);
if (true) {
throw new RuntimeException();
}
// 3. 변경 후 저장: Mysql 수업 저장
Course updateCourse2 = savedCourse1.updateName("MySQL");
Course savedCourse2 = courseRepository.save(updateCourse2);
}
기존에는 RuntimeExcpetion
이 발생하면 롤백되었지만 현재는 RuntimeException을 롤백 정책에서 제외하였기 때문에 RuntimeException이 발생하였어도 롤백이 일어나지 않아 DB의 Course 이름을 "Java"로 변경하는 코드가 적용될 것이다.
트랜잭션 전파(Propagation): 트랜잭션 전파란 하나의 트랜잭션이 실행 중일 때, 그 내부에서 호출된 메서드가 어떻게 트랜잭션에 참여하거나 새로운 트랜잭션을 생성할지를 정의하는 동작 방식을 의미한다.
쉽게 말해 스프링에서 트랜잭션이 메서드간 겹칠 때 어떤 식으로 동작할지를 개발자가 결정해줄 수 있는 속성이다.
스프링의 트랜잭션 기본 전파 속성은 REQUIRED
속성이다.
REQUIRED는 트랜잭션이 만약 존재한다면 기존 트랜잭션에 참여하고, 기존 트랜잭션이 없다면 트랜잭션을 새로 시작하는 가장 일반적으로 많이 사용되는 속성이다.
예시를 통해 살펴보자.
"processTransaction"이라는 메서드에서 하나의 트랜잭션이 이미 진행 중인 상태에서 그 안에 또 별도로 트랜잭션을 가지고 있는 메서드인 "methodA"가 호출이 되는 상황이다.
둘 다 @Transactional을 통해 각각 트랜잭션이 진행되는 상황이기 때문에 트랜잭션이 겹쳤을 때 어떤 식으로 트랜잭션을 전파해야 하는지를 결정해야 한다.
현재는 별도의 설정을 하지 않은 REQUIRED
상태이기 때문에 부모 메서드와 자식 메서드의 두 트랜잭션이 하나의 트랜잭션으로 같이 묶여 사용될 것이다.
스프링에서는 트랜잭션의 전파 속성으로 모두 7가지의 속성을 제공한다. 하지만 일반적으로 실무에서 가장 많이 사용되는 방식은 REQUIRED
, REQUIRES_NEW
이 두 가지 방식이고 이 두가지 방식으로 거의 모든 케이스를 커버할 수 있기 때문에 이 두 가지 방식을 집중적으로 공부하고 나머지 속성들은 기본적인 특징 정도만 간단하게 살펴보고 넘어가는 것을 추천한다.
모든 트랜잭션 전파의 유형을 간단하게 정리해보자면 다음과 같다.
REQUIRED(기본값)
:
현재 트랜잭션이 있으면 이를 사용하고, 없으면 새로운 트랜잭션을 생성한다.
가장 일반적으로 사용되는 방식으로, 부모 트랜잭션과 자식 트랜잭션이 같은 범위에서 관리된다.
예시: 부모 메서드와 자식 메서드가 모두 REQUIRED라면 하나의 트랜잭션으로 묶인다.
REQUIRES_NEW
:
항상 새로운 트랜잭션을 생성하며, 기존 트랜잭션은 일시 중단된다.
새로운 트랜잭션은 독립적으로 커밋되거나 롤백된다.
예시: 기존 트랜잭션이 롤백되어도 새로운 트랜잭션은 영향을 받지 않는다.
주로 독립적인 작업이 필요하거나, 부모 트랜잭션 실패 시에도 자식 트랜잭션이 영향을 받지 않도록 해야 할 때 사용한다.
하지만 REQUIRES_NEW
를 사용할 때 주의해야할 점이 있다. 만약 REQUIRES_NEW
가 다른 트랜잭션 메서드 안에서 호출되면 별도의 독립적인 트랜잭션을 생성하는 것은 맞지만 우리는 예외는 항상 상위 계층으로 전파된다는 사실을 망각해서는 안 된다.
예외 전파 속성으로 인해 REQUIRES_NEW
로 만들어진 자식 트랜잭션 안에서 예외가 발생되면 상위 계층인 부모 트랜잭션으로 예외가 전파되어 마찬가지로 예외가 발생하여 롤백되어서는 안되는 부모 트랜잭션도 자식 트랜잭션과 같이 롤백될 것이다
이러한 이유로 REQUIRES_NEW
를 사용할 때에는 부모 트랜잭션에서도 꼭 REQUIRES_NEW
로 만들어진 자식 트랜잭션에서 발생할 수 있는 예외에 대한 예외 처리가 이루어져야 한다.
SUPPORTS
:
현재 트랜잭션이 있으면 이를 사용하고, 없으면 트랜잭션 없이 실행된다. 주로 트랜잭션이 필수적이지 않은 경우에 사용한다.
예시: 읽기 전용 작업에서 유용하다.
NOT_SUPPORTED
:
현재 트랜잭션이 있으면 이를 일시 중단하고 트랜잭션 없이 실행한다.
트랜잭션이 필요하지 않은 작업에서 주로 사용된다.
예시:로깅 또는 비트랜잭션 작업.
MANDATORY
:
현재 트랜잭션이 반드시 존재해야 하며, 없으면 예외를 발생시킨다.
상위 트랜잭션 없이 동작할 수 없는 작업에 주로 사용된다.
예시: 항상 부모 트랜잭션에 의존해야 하는 경우.
NEVER
:
트랜잭션이 존재하면 예외를 발생시키고, 항상 트랜잭션 없이 실행한다.
예시: 트랜잭션이 있으면 비효율적인 작업이 될 수 있는 경우.
NESTED
:
현재 트랜잭션이 있으면 그 안에서 새로운 중첩 트랜잭션을 시작한다.
중첩 트랜잭션은 부모 트랜잭션의 일부로 간주되며, 부모가 롤백되면 중첩 트랜잭션도 롤백된다.
독립적으로 커밋될 수 있지만, 부모 트랜잭션에 영향을 받을 수 있다.
예시: 동일 트랜잭션 안에서 세분화된 작업 단위가 필요한 경우.
수강신청을 하면 Course, Payment, PaymentLog가 생성되는 시나리오가 존재한다고 가정해보자.
@Transactional
public void processEnrollV1() {
printActiveTransaction("::: EnrollmentService.processEnrollV1()");
// 1. 수강 처리: Course 엔티티 생성
processCourse();
// 2. 결제 처리: Payment 엔티티 생성
processPayment();
// 3. 로그 처리: PaymentLog 로그 생성
processLog();
}
public void processCourse() {
printActiveTransaction("processCourse()");
// 1. 저장 - Course 저장
Course newCourse = Course.createNewCourse("newSpring");
courseRepository.save(newCourse);
}
public void processPayment() {
printActiveTransaction("processPayment()");
// 1. 저장 - Payment 저장
Payment newPayment = new Payment();
paymentRepository.save(newPayment);
}
public void processLog() {
printActiveTransaction("processLog()");
// 1. 저장 - PaymentLog 저장
PaymentLog newPaymentLog = new PaymentLog();
logRepository.save(newPaymentLog);
}
/**
* 트랜잭션 적용 여부를 확인합니다.
*/
private void printActiveTransaction(String methodName) {
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
boolean isNewTx = TransactionAspectSupport.currentTransactionStatus().isNewTransaction();
log.info("{} - isActive: {}, isNew: {}", methodName, actualTransactionActive, isNewTx);
}
현재의 방식으로는 수강 신청 과정에서 Course 생성, Payment 생성되어서 수강 처리 후 결제가 완료되어도 PaymentLog 즉, 결제 기록 생성이 실패하면 수강 처리와 결제까지 전체 롤백되는 구조인데 이게 과연 옳은 것일까??
이 과정에서 중요한 것은 수강 처리와 결제 부분이지 결제 기록 생성 부분은 상대적으로 중요하지 않은 부분이기 때문에 로그 생성이 실패했다고 전체 롤백되는 것은 옳지 못할 것이다.
따라서 PaymentLog 부분은 별도의 트랜잭션으로 따로 처리하는 과정이 필요하다.
Course 로직과 Payment 로직에는 @Transactional(REQUIRED)
를 추가하여 기존의 트랜잭션과 합치고 따로 분리하고 싶은 Log 로직에는 @Transactional(REQUIRES_NEW)
를 추가하여 별도의 트랜잭션으로 처리하면 이 문제를 해결할 수 있을까???
위의 과정을 코드로 나타내면 다음과 같다.
@Transactional
public void processEnrollV1() {
printActiveTransaction("::: EnrollmentService.processEnrollV1()");
// 1. 수강 처리: Course 엔티티 생성
processCourse();
// 2. 결제 처리: Payment 엔티티 생성
processPayment();
// 3. 로그 처리: PaymentLog 로그 생성
try {
processLog();
} catch (Exception e) {
log.info("로그 예외 발생");
}
}
@Transactional
public void processCourse() {
printActiveTransaction("processCourse()");
// 1. 저장 - Course 저장
Course newCourse = Course.createNewCourse("newSpring");
courseRepository.save(newCourse);
}
@Transactional
public void processPayment() {
printActiveTransaction("processPayment()");
// 1. 저장 - Payment 저장
Payment newPayment = new Payment();
paymentRepository.save(newPayment);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processLog() {
printActiveTransaction("processLog()");
// 1. 저장 - PaymentLog 저장
PaymentLog newPaymentLog = new PaymentLog();
logRepository.save(newPaymentLog);
throw new RuntimeException("로그 예외 발생");
}
(트랜잭션 적용 여부 및 새 트랜잭션 여부)
EnrollmentService.processEnrollV1() - isActive: true, isNew: true
processCourse() - isActive: true, isNew: true
processPayment() - isActive: true, isNew: true
processLog() - isActive: true, isNew: true
결과를 확인해보면 예상과 다르다. processCourse()
와 processPayment()
는 REQUIRED
가 적용되어서 기존 부모 트랜잭션에 참여되었기 때문에 isNew
가 false가 나와야 하는데 true가 나왔다.
또한 processLog
는 isNew
가 true로 나와 제대로 적용된 것처럼 보이지만 RuntimeException
이 발생하였기 때문에 트랜잭션이 롤백되어 DB에 아무 데이터도 없어야 정상인데
select * from payment_log;
로 테이블을 확인해보면 payment_log 데이터가 1개 생성된 것이 확인되기 때문에 processLog()
의 트랜잭션에도 문제가 생겼음을 확인할 수 있다.
즉, 트랜잭션 걸어놓은 것들이 모두 정상적으로 작동을 하지 않아 Log도 꼬이고 데이터도 꼬인 상황이다...
왜 이러한 문제점이 발생했을까???
그 이유는 바로 최상단 메서드인 processEnrollV1 에 달린 @Transactional 어노테이션만 적용이 되고 나머지 processEnrollV1에서 호출된 다른 메서드들의 @Transacional 어노테이션들은 적용되지 않았기 때문이다.
여기서 잠깐 AOP 방식인 @Transactional
의 동작 원리를 살펴보자.
AOP를 적용하면 프록시 객체가 대상 객체를 감싸서 Bean으로 컨테이너에 등록되고 프록시 객체가 컨트롤러의 호출을 가로채기 때문에 AOP는 항상 프록시 객체에서 실행된다.
AOP를 적용하게 되면 프록시 객체가 타겟 객체를 상속받고 프록시 객체 내부에서 이후 트랜잭션이 시작되고 타겟 객체의 비즈니스 로직이 수행되고 그 결과에 따라 트랜잭션 커밋/롤백이 이루어지는 것이 AOP의 내부 원리이다.
이제 이 AOP 동작 원리를 기억하고 다시 문제점을 살펴보자.
메서드 안에서 호출된 메서드들에 적용된 @Transactional이 적용되지 않은 원인은 바로 self-invocation
문제가 발생했기 때문이다.
Self-Invocation 문제: Self-Invocation 문제는 스프링의 AOP(Aspect-Oriented Programming) 기능을 사용할 때 발생하는 제약사항 중 하나이다. 이는 스프링의 프록시 기반 AOP 메커니즘에서 비롯되며, 클래스 내부의 메서드가 자신의 또 다른 메서드를 호출할 때, 호출된 메서드에 적용되어 있는 AOP가 작동하지 않는 문제를 의미한다.
현재 상황에서는 porcessEnrollV1()에 @Transactional이 붙어있기 때문에 컨트롤러에서 porcessEnrollV1()를 호출하면 porcessEnrollV1()의 프록시 객체가 컨트롤러의 호출을 가로채 호출되고 프록시 객체에서 트랜잭션 안에서 상속받은 실제의 porcessEnrollV1()를 호출한다.
여기까지는 문제가 없다. 하지만 실제 porcessEnrollV1()가 프록시 객체에 의해 호출되면 porcessEnrollV1() 내부에서 호출되는 processCourse(), processPayment(), processLog()에 붙어있는 @Transactional이 제대로 적용되려면 AOP 방식으로 각각의 프록시 객체에 의해 호출되어야 하는데 현재는 각 메서드들이 같은 클래스 내부에 있는 porcessEnrollV1()에 의해 직접, AOP로 감싸지지 않은 실제 메서드들이 호출되고 있기 때문에 세 메서드 모두 AOP가 아예 적용되지 않아 @Transactional이 적용되지 않는 self-invocation 문제가 발생했다.
그럼 어떻게 이러한 self-invoction
문제를 해결할 수 있을까???
해결 방법은 AOP가 적용되어 있는(여기서는 @Transactional이 적용된) 모든 메서드들을 각각의 클래스로 분리하여 프록시 객체를 호출할 수 있는 구조로 만들어주면 된다.
각각의 메서드들을 각각의 클래스에 분리하면 컨트롤러에 의해 processEnrollV1()의 프록시 객체가 실제 대상 객체인 EnrollmentService(processEnrollV1())을 호출하고 이제 그 안에서 별도의 클래스들로 분리된 processCourse(), processPayment(), processPaymentLog()를 호출하면 이전처럼 진짜 메서드들이 호출되는 것이 아니라 각 메서드들의 프록시 객체가 호출을 가로채 호출되고 AOP(@Transactional)이 적용된다.
[TxService1]
@Slf4j
@Service
@RequiredArgsConstructor
public class TxService1 {
private final CourseRepository courseRepository;
@Transactional
public void processCourse() {
printActiveTransaction("processCourse()");
// 1. 저장 - Course 저장
Course newCourse = Course.createNewCourse("newSpring");
courseRepository.save(newCourse);
}
private void printActiveTransaction(String methodName) {
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
boolean isNewTransaction = TransactionAspectSupport.currentTransactionStatus().isNewTransaction();
log.info("-> {}: isTxActive: {}, isNew: {}", methodName, actualTransactionActive, isNewTransaction);
}
}
[TxService2]
@Slf4j
@Service
@RequiredArgsConstructor
public class TxService2 {
private final PaymentRepository paymentRepository;
@Transactional
public void processPayment() {
printActiveTransaction("processPayment()");
// 1. 저장 - Payment 저장
Payment newPayment = new Payment();
paymentRepository.save(newPayment);
}
private void printActiveTransaction(String methodName) {
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
boolean isNewTransaction = TransactionAspectSupport.currentTransactionStatus().isNewTransaction();
log.info("-> {}: isTxActive: {}, isNew: {}", methodName, actualTransactionActive, isNewTransaction);
}
}
[TxService3]
@Slf4j
@Service
@RequiredArgsConstructor
public class TxService3 {
private final LogRepository paymentLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processLog() {
printActiveTransaction("processLog()");
// 1. 저장 - PaymentLog 저장
PaymentLog newPaymentLog = new PaymentLog();
paymentLogRepository.save(newPaymentLog);
throw new RuntimeException();
}
private void printActiveTransaction(String methodName) {
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
boolean isNewTransaction = TransactionAspectSupport.currentTransactionStatus().isNewTransaction();
log.info("-> {}: isTxActive: {}, isNew: {}", methodName, actualTransactionActive, isNewTransaction);
}
}
[EnrollmentService]
@Slf4j
@Service
@RequiredArgsConstructor
public class EnrollmentService {
private final CourseRepository courseRepository;
private final PaymentRepository paymentRepository;
private final LogRepository logRepository;
private final TxService1 txService1; // processCourse 클래스 분리
private final TxService2 txService2; // processPayment 클래스 분리
private final TxService3 txService3; // processPaymentLog 클래스 분리
/**
* 문제 해결
*/
@Transactional
public void processEnrollV2() {
printActiveTransaction("::: processEnroll()");
// 1. 수강 처리
txService1.processCourse();
// 2. 결제 처리
txService2.processPayment();
// 3. 로그처리
try {
txService3.processLog();
} catch (Exception e) {
log.info("::: 로그 처리 실패");
}
}
}
[트랜잭션 적용 여부 및 새 트랜잭션 여부]
EnrollmentService.processEnrollV2() - isActive: true, isNew: true
processCourse() - isActive: true, isNew: false
processPayment() - isActive: true, isNew: false
processLog() - isActive: true, isNew: true
procecessEnrollV2
는 새로운 트랜잭션을 시작하는 거니까 true
가 맞고 processCourse()
와 processPayment
는 REQUIRED
이기 때문에 기존의 트랜잭션에 합류하기 때문에 isNew
속성이 false
가 맞다. 또한 processLog
는 REQUIRES_NEW
이기 때문에 새로운 트랜잭션을 발생시켜 isNEw
가 true
이다.
이를 통해 self-invocation
문제가 잘 해결되었음을 확인할 수 있다.