트랜잭션(Transaction) 분리하기
프로젝트 리팩토링을 진행하다가 트랜잭션의 관리가 미흡하다고 생각되서 정리하게 되었다.
보통 트랜잭션 단위는 일반적으로 짧게 가져가는 것이 이상적이다.
그 이유에 대해 살펴보고, 어떻게 구성해야 하는지 고민해 보겠다.
@Transactional 남용의 문제점
@Transactional 어노테이션은 Spring Boot 를 쓰는 사람들은 다들 너무나도 편리하다는 생각을 할 것이다.
매번 트랜잭션의 시작과 종료를 코드로 명시하지 않아도, Spring AOP를 통해 간단하게 어노테이션으로 트랜잭션 범위를 설정한다.
공통적인 횡단 관심사를 해결하는 Spring AOP의 대표적인 사례라고 볼 수 있다.
횡단 관심사(Aspect-Oriented Programming, AOP)는 프로그래밍에서 자주 등장하는 개념이다.
이 개념의 핵심은 애플리케이션의 여러 부분에서 반복되는 기능들을 하나의 장소에 모아 관리하는 것이다.
예를 들어보면 로깅, 보안, 트랜잭션 처리 같은 기능들이 횡단 관심사에 해당한다.
그렇지만 데이터 관점의 트랜잭션을 실제 비즈니스 로직 관점에서 적용하다 보면, 트랜잭션 적용 범위에 대한 괴리가 발생하게 된다.
다음 게시글 작성 예시로 보면서 해당 메서드의 범위가 실제로 이상적인 트랜잭션의 범위인지 고민해보자
@Transactional(readOnly = false)
public void pushNotification(List<FcmToken> fcmTokens, String title, String content) {
for (FcmToken fcmToken : fcmTokens) { CompletableFuture.runAsync(() -> {
try {
Message message = Message.builder()
.setNotification(Notification.builder()
.setTitle(title)
.setBody(content)
.build())
.setToken(fcmToken.getToken())
.build();
FirebaseMessaging.getInstance().send(message);
fcmToken.updateDate();
} catch (FirebaseMessagingException e) {
log.error("Failed to send message to device {}: {}", fcmToken.getId(), e.getMessage());
} catch (Exception e) {
log.error("An unexpected error occurred: {}", e.getMessage());
}});
}
다음 고민거리가 생기게 된다.
- 파이어 베이스 서버와 네트워크 장애가 발생하면 어떻게 될까?
- 메세지의 용량이 크면 어떻게 될까?
1. 트랜잭션을 짧게 가져가자 - undo log
트랜잭션을 짧게 가져가야 하는 이유는 MVCC의 내부 구현 때문에 그렇다.
Oracle이나 MySQL 등 RDBMS를 보면, MVCC 구현을 위해서 Undo Log 를 활용한다.
Undo Log는 트랜잭션의 롤백을 지원하고, 트랜잭션끼리의 격리를 위해 설계된 매커니즘이다.
또한 읽기를 위한 SELECT 절의 경우 트랜잭션으로 인한 Lock을 기다리지 않고, 잠금 없는 일관된 읽기를 가능하게 해준다.
그런데 이 Undo Log는 해당 인덱스(컬럼)를 기준으로 트랜잭션이 중첩되면 로그 또한 중첩되서 쌓이게 되며, 해당 컬럼에 대해서 아무 트랜잭션이 없을 때까지 정리되지 않습니다. 그래서 트랜잭션을 오래 물고 있는 경우 다음과 같은 상태가 된다.
Undo Log가 과도하게 쌓이게 되면 잠금 없는 일관된 읽기를 위해 탐색 비용이 발생해 읽기 성능이 떨어지고, 또한 만약 다른 트랜잭션들이 동일한 자원을 필요로 하는 경우 데드락이 발생할 가능성이 높아진다.
2. 외부 연결은 트랜잭션에서 제외하자
트랜잭션의 길이를 짧게 가져가야 하는 것에 대한 필요성을 알았다면, 왜 외부 연결을 트랜잭션에서 제외해야 하는지에 대해 알게 된다.
위의 코드를 문제와 함께 다시 살펴보자
// 유저들에게 푸시 알람 전송 - 푸시 서버에 장애가 생긴다면?
Message message = Message.builder().setNotification(Notification.builder()
.setTitle(title)
.setBody(content)
.build())
.setToken(fcmToken.getToken())
.build();
FirebaseMessaging.getInstance().send(message);
위의 문제가 발생했다고 가정하고, 트랜잭션이 어떻게 될 지 생각해보자
외부 서버에서 정상적인 응답을 받기 전까지 해당 트랜잭션은 무한히 지속될 것이다.
만약 알림 전송이 몇 천개가 일어난다면, DB 뿐만 아니라 서블릿의 커넥션 풀도 고갈되면서 서버는 바로 먹통이 될 것이다.
이렇게 되면 해당 DB를 참조하는 다른 서비스들도 같이 장애가 발생한다.
트랜잭션 분리하기 - 주의! 메서드로 분리하기는 안됨!
그렇다면 간단하게 메서드로 빼내서 @Transactional을 붙혀주는 건 어떨까?
다음 코드를 봐보자
public void sendNotification() {
LocalDate today = LocalDate.now();
List<Notification> notificationList = getNotificationList();
for (Notification notification : notificationList) {
if (notification.getStatus().equals(NotificationStatus.PENDING) && notification.getExecuteTime().isEqual(today)) {
updateStatus(notification);
List<FcmToken> fcmTokens = notification.getMember().getFcmTokens();
fcmTokenService.pushNotification(fcmTokens, notification.getTitle(), notification.getContent());
}
}
}
@Transactional(readOnly = true)
private List<Notification> getNotificationList() {
// select
return notificationService.findEntityAll();
}
@Transactional
private void updateStatus(Notification notification) {
// update
notification.statusUpdateToComplete();
}
첫 번째로 Spring AOP 는 public 메서드에만 동작한다. 결국 프록시 패턴으로 인해 외부에서 해당 메서드를 호출해야 하기 때문이다.
그러면 접근제어자로 인한 캡슐화를 포기하고, public 을 사용하면 트랜잭션이 동작할까?
public List<Notification> getNotificationList() {}
public void updateStatus(Notification notification) {}
신기하게도 동작하지 않는다.
왜냐하면 동일한 Bean 내에서 순수한 함수가 @Transactional이 선언된 public 메서드를 호출해도, 트랜잭션은 적용되지 않기 때문이다. 그 이유는 Spring AOP의 내부 동작을 본다면 이유를 찾을 수 있다.
Spring에서는 메서드나 클래스에 단순히 @Transactional을 붙여주면 하나의 Transaction으로 동작하게 해준다. 이는 Spring 의 특징인 AOP를 통해 구현이 되는데, Transaction으로 묶어서 실행하고자 하는 메서드 앞뒤로 Transaction 처리 코드를 자동으로 생성해줘서 편리하고 깔끔하게 개발이 가능하도록 한다.
AOP는 "핵심기능 코드에 존재하는 공통된 부가기능 코드를 독립적으로 분리해주는 기술"이라고 한다. @Transactional을 붙이게 되면 해당 메서드의 이전, 이후에 트랜잭션을 커밋하고 롤백하는 코드가 부가적으로 추가되게 된다.
이렇게 추가해주는 부가기능을 어드바이스(Advice)라고 하고 부가기능이 부여될 타깃을 선정하는 룰을 포인트 컷(Point Cut)
이라고 한다. 어드바이스와 포인트 컷을 통틀어 어드바이저라고 하면서 어드바이저는 단순한 형태의 에스펙트(Aspect)라 부를 수 있다. 에스펙트란 핵심기능에 부가되는 특별한 모듈을 뜻하며, 이 에스펙트를 통해 애플리케이션을 설계하여 부가기능을 분리하며 개발하는 방법을 AOP라고 한다.
AOP는 이를 2가지 방식으로 제공한다.
- JDK Dynamic Proxy
- CGlib Proxy
JDK Dynamic Proxy는 아래와 같이 동작하는 방식이다. 이 방식은 interface를 상속받아서 추상메서드를 구현한다. 구현한 메서드 내부에서는 타겟의 메서드를 호출하고 앞뒤로 필요한 로직을 붙여준다. 이는 Java의 리플렉션 패키지의 Proxy 클래스를 통해 동적으로 프록시 객체를 생성한다.
이 방식은 타겟이 interface를 implements하고 있지 않으면 프록시 객체를 생성할 수 없다. 그래서 두 번째 방식인 CGlib Proxy는 이를 해결해 준다.
CGlib Proxy는 Java 리플랙션 대신 바이트 코드 생성 프레임워크를 사용해 런타임 시점에 프록시 객체를 만들어준다. 타겟이 인터페이스를 상속하고 있지 않으면 CGlib 를 사용해서 인터페이스 대신 상속하는 프록시 객체를 만든다.
즉, @Transactional을 붙임으로써 해당 클래스가 실행되기 전/후 등의 단계에서 자동으로 트랜잭션을 묶게 되고 내부에서 호출하는 메서드는 처음으로 호출하는 메서드나 클래스의 속성을 따라가게 된다. 그래서 동일한 클래스/빈 안에 상위 메서드가 @Transactional이 없으면 하위에는 선언이 되었다고 해도 전이되지 않는다.
결론
- @Async 는 새로운 Thread를 생성하는데 @Transactional은 트랜잭션을 ThreadLocal로 관리하기 때문에 동작하지 않는다.
- @Transactional은 AOP를 통해 제공된다. AOP는 proxy로 구현이 되어있어 앞뒤로 원하는 로직을 붙여주는데, 이 때문에 내부에서 @Transactional이 붙은 메서드를 호출하는 경우 트랜잭션이 동작하지 않을 수 있다.
트랜잭션 분리하기 - 별도의 객체(빈)으로 분리하기
위의 문제를 해결하는 방법 중 하나는 별도의 객체로 분리하는 것이다.
당연한 말이겠지만,
상위 객체는 새로운 별도의 프록시 객체를 가르킬 것이고, 문제 없이 트랜잭션을 실행할 수 있다.
private void sendNotification() {
LocalDate today = LocalDate.now();
List<Notification> notificationList = notificationCommand.getNotificationList();
for (Notification notification : notificationList) {
if (notification.getStatus().equals(NotificationStatus.PENDING) && notification.getExecuteTime().isEqual(today)) {
notificationCommand.updateStatus(notification);
List<FcmToken> fcmTokens = notification.getMember().getFcmTokens();
fcmTokenService.pushNotification(fcmTokens, notification.getTitle(), notification.getContent());
}
}
}
@RequiredArgsConstructor
@Component
public class NotificationCommand {
private final NotificationRepository notificationRepository;
@Transactional(readOnly = true)
public List<Notification> getNotificationList() {
// select
return notificationRepository.findAll();
}
@Transactional
public void updateStatus(Notification notification) {
// update
notification.statusUpdateToComplete();
}
}
트랜잭션 분리하기 - 트랜잭션이 불필요한 클래스를 비동기로 처리하기
불필요한 클래스로 분리하게 되면 비동기로 처리하는 방식도 선택지가 될 수 있다.
그렇지만 동기로 처리해야 하거나, 해당 로직을 트랜잭션에 포함해야 하는 경우에는 사용하지 못한다.
트랜잭션 분리하기 - TransactionTemplate 활용하기
Spring 에서는 TransactionTemplate을 제공한다.
따로 Transactional을 사용하지 않고도 트랜잭션을 사용할 수 있으며, JDBC template 에서 직접 트랜잭션을 열고 닫는 것의 차선책이라고 볼 수 있을 것 같다.
결론
외부 연결된 메서드를 트랙잭션으로부터 분리시켰고 트랙잭션 존재 여부를 확인하여 제대로 적용된 것을 볼 수 있다.