새소식

Spring

[Spring] 트랜잭션(Transaction) 우아하게 분리하기

  • -

트랜잭션 단위는 일반적으로 짧게 가져가고 것이 이상적입니다.

그 이유에 대해서 살펴보고, 어떻게 구성해야 하는지에 대해서 고민해 보겠습니다.

 

@Transactional 남용의 문제점

@Transactional 어노테이션은 너무나도 편리합니다.

매번 트랜잭션의 시작과 종료를 코드로 명시하지 않아도, Spring AOP를 통해 간단하게 어노테이션으로 트랜잭션 범위를 설정합니다.

공통적인 횡단 관심사를 해결하는 Spring AOP의 대표적인 사례라고 볼 수 있겠습니다.

 

그렇지만 데이터 관점의 트랜잭션을 실제 비즈니스 로직 관점에서 적용하다 보면, 분명 트랜잭션 적용 범위에 대한 괴리가 발생하게 됩니다.

 

다음 게시글 작성 예시를 보면서 해당 매서드의 범위가 실제로 이상적인 트랜잭션의 범위인지 고민해봅시다.

@Transactional // 적절한 트랜잭션인가?
public void processPost(Long userId, String content, MultipartFile image) {
    // 유저 조회 - select
    User user = userRepository.getById(userId);

    // 게시글 생성 - insert
    Post post = new Post();
    postRepository.save(post);

    // 게시글 이미지 업로드
    s3Uploader.upload(image);

    // 해당 유저 구독자들에게 푸시 알람 전송
    notifyService.notify(userId, postId, reportCount);
}

 

다음 고민거리가 생기게 됩니다.

  • 게시글 이미지가 100MB라면 어떻게 될까요?
  • 푸시 서버가 장애가 나면 어떻게 될까요?

 

Item. 트랜잭션을 짧게 가져가자 - undo log

트랜잭션을 짧게 가져가야 하는 이유는 MVCC 의 내부 구현 때문에 그렇습니다.

 

Oracle이나 MySQL 등 RDBMS를 보면, MVCC 구현을 위해서  Undo Log를 활용합니다. 

 

언두 로그는 트랜잭션의 롤백을 지원하고, 트랜잭션끼리의 격리를 위해 설계된 매커니즘입니다.

또한 읽기를 위한 SELECT 절의 경우 트랜잭션으로 인한 Lock을 기다리지 않고, 잠금 없는 일관된 읽기를 가능하게 해줍니다.

 

그런데 이 언두 로그는 해당 인덱스(컬럼)를 기준으로 트랜잭션이 중첩되면 로그 또한 중첩되서 쌓이게 되며, 해당 컬럼에 대해서 아무 트랜잭션이 없을 때까지 정리되지 않습니다. 그래서 트랜잭션을 오래 물고 있는 경우 다음과 같은 상태가 됩니다.

 

언두 로그가 과도하게 쌓이게 되면 잠금 없는 일관된 읽기를 위해 탐색 비용이 발생해 읽기 성능이 떨어지고, 또한 만약 다른 트랜잭션들이 동일한 자원을 필요로 하는 경우 데드락이 발생할 가능성이 높아집니다.

 

 

Item. 외부 연결은 트랜잭션에서 제외하자

트랜잭션의 길이를 짧게 가져가야 하는 것에 대한 필요성을 알았다면, 왜 외부 연결을 트랜잭션에서 제외해야 하는지에 대해 알게 됩니다.

 

위의 코드를 문제와 함께 다시 살펴봅시다.

// 게시글 이미지 업로드 - 이미지 업로드가 10초 걸린다면?
s3Uploader.upload(image);

// 해당 유저 구독자들에게 푸시 알람 전송 - 푸시 서버가 고장난다면?
notifyService.notify(userId, postId, reportCount);

 

위의 문제가 발생했다고 가정하고, 트랜잭션이 어떻게 될 지 생각해볼까요?

외부 서버에서 정상적인 응답을 받기 전까지 해당 트랜잭션은 무한이 지속될 것 입니다.

 

만약 게시글 작성이 몇 백개가 일어난다면, DB 뿐만 아니라 서블릿의 커넥션 풀도 고갈되면서 서버는 바로 먹통이 될 것이고,

이렇게 되면 해당 DB를 참조하는 다른 서비스들도 같이 장애가 발생합니다.

 

트랜잭션 분리하기 - 주의! 매서드로 분리하기는 안됩니다!

그렇다면 간단하게 매서드로 빼내서 @Transactional을 붙여주는 건 어떨까요?

다음 코드를 보겠습니다.

public void processPost(Long userId, String content, MultipartFile image) {

    // 게시글 이미지 업로드 - 이미지 업로드가 10초 걸린다면?
    s3Uploader.upload(image);

    uploadPostTransaction(userId);
            
    // 해당 유저 구독자들에게 푸시 알람 전송 - 푸시 서버가 고장난다면?
    notifyService.notify(userId, postId, reportCount);
}

@Transactional // 가능한가?
private void uploadPostTransaction(Long userId) {
    // 유저 조회 - select
    User user = userRepository.getById(userId);

    // 게시글 생성 - insert
    Post post = new Post(user);
    postRepository.save(post);
}

 

첫 번째로 스프링 AOP 는 public 매서드에만 동작합니다. 결국 프록시 패턴으로 인해 외부에서 해당 매서드를 호출해야 하기 때문입니다.

 

그러면 접근제어자로 인한 캡슐화를 포기하고, public을 사용하면 트랜잭션이 동작할까요?

public void uploadPostTransaction(Long userId) {

신기하게도 동작하지 않습니다.

다음 로그 옵션을 켜고, 예제 코드를 실행시켜 보겠습니다.

logging.level.org.hibernate.engine.transaction.internal.TransactionImpl=debug
public void processPost() {
    System.out.println("트랜잭션 시작 전");
    uploadPostTransaction();
    System.out.println("트랜잭션 종료 후");
}

@Transactional // 트랜잭션이 열릴까?
public void uploadPostTransaction() {
    System.out.println("트랜잭션 로직 시작");
    System.out.println("트랜잭션 로직 종료");
}

 

앞 뒤의 로그를 봐도, 트랜잭션을 열고 있지 않습니다.

 

동일한 Bean 내에서 순수한 함수가 @Transactional이 선언된 public 메서드를 호출해도, 트랜잭션은 적용되지 않습니다.

 

스프링 AOP 의 내부 동작을 본다면 이유를 찾아볼 수 있습니다.

 

 

첫 호출이 original 객체의 proccessPost 매서드를 호출하게 되고, 해당 매서드 내부에서 uploadPost를 호출하게 되면 같은 객체를 내부에서  호출하게 됩니다.

 

그렇게 되면 프록시 객체를 호출하지 않게 되기 때문에, 내부 매서드를 호출할 때는 실제로 코드에서 호출되는 매서드가 프록시 객체의 매서드인지 원본 객체의 프록시인지 확인해야할 필요가 있습니다.

 

트랜잭션 분리하기 - 별도의 객체(빈)으로 분리하기

그렇다면 위의 문제를 해결하는 방법 중 하나는 별도의 객체로 분리하는 것입니다.

 

어찌 보면 당연하지만,

상위 객체는 새로운 별도의 프록시 객체를 가리킬 것이고, 문제 없이 트랜잭션을 실행할 수 있습니다.

 

    public void processPost(Long userId, String content, MultipartFile image) {
        // 게시글 이미지 업로드 - 이미지 업로드가 10초 걸린다면?
        s3Uploader.upload(image);

        // 포스트 생성
        postCommand.postCreateCommand(userId, content);

        // 해당 유저 구독자들에게 푸시 알람 전송 - 푸시 서버가 고장난다면?
        notifyService.notify(userId, postId, reportCount);
    }
}

//별개의 클래스(빈)으로 분리
@Component
class PostCommand() {

	@Transactional
    public void postCreateCommand(Long userId) {
        // 유저 조회 - select
        User user = userRepository.getById(userId);

        // 게시글 생성 - insert
        Post post = new Post(user);
        postRepository.save(post);
    }
}

 

 

트랜잭션 분리하기 - 트랜잭션이 불필요한 클래스를 비동기로 처리하기

불필요한 클래스로 분리하게 되면 비동기로 처리하는 방식도 선택지가 될 수 있습니다.

그렇지만 동기로 처리해야 하거나, 해당 로직을 트랜잭션에 포함해야 하는 경우에는 사용하지 못하겠죠.

 

 

@Async
public void notify(Long postId, Long userId, Long reportCount) {
    slackPostReportFeignClient.call(test);

 

트랜잭션 분리하기 - TransactionTemplate 활용하기

Spring 에서는 TransactionTemplate을 제공합니다.

따로 Transactional을 사용하지 않고도 트랜잭션을 사용할 수 있으며, JDBC template 에서 직접 트랜잭션을 열고 닫는 것의 차선책이라고 볼 수 있을 것 같습니다.

 

그렇지만 실제로 몇 번 경험해보니, mock을 통한 유닛 테스트가 쉽지 않았습니다.

transactionTemplate을 mocking 하게 되면 transaction에 묶인 내부 로직들이 테스트 하기 힘들어지는데, 다음에 한 번 더 생각을 해봐야겠네요.

 

public void processPost(Long userId, String content, MultipartFile image) {
    // 게시글 이미지 업로드 - 이미지 업로드가 10초 걸린다면?
    s3Uploader.upload(image);

    // 게시글 생성 - insert
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            User user = userRepository.getById(userId);
            Post post = new Post(user);
            postRepository.save(post);
        }
    });

    // 해당 유저 구독자들에게 푸시 알람 전송 - 푸시 서버가 고장난다면?
    notifyService.notify(userId, postId, reportCount);
}

 

 

 

 

마무리

트랜잭션을 분리해야 하는 이유와, 어떻게 분리하는 게 좋을지를 알아봤습니다.

또 스프링 AOP의 특성 때문에 주의해야 하는 케이스까지 알아봤습니다.

 

그렇지만 또 걱정되는 것 중에 하나는, '이른 최적화'입니다.

 

작은 서비스에서는 위의 문제로 인해 장애가 발생할 확률이 낮고, 첫 개발부터 위의 케이스들을 모두 고려해서 TransactionTemplate 등을 넣는다던가 클래스를 분리한다면 코드를 작성하는 비용이 꽤나 커지게 될 것 같습니다.

 

일단 무조건 @Transactional을 걸고, 필요한 시점에 리팩토링할 수 있도록 문서화하는 것이 어쩌면 더 좋은 방향일 수 있지 않을까요?

 

 

 

참고자료

https://altasumut.medium.com/spring-aop-q-a-b91a165d50f5

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.