본문 바로가기
Lecture

자바 코드 리팩토링: 스프링 AplicationEventPublisher를 이용해 시스템 강결합을 해결해보자

by Renechoi 2023. 7. 9.

시스템 강결합 문제와 이에 대한 해결 방안으로서 Event Publisher를 통한 해결 법을 살펴본다.  

 

문제점 

 

회원가입을 수행하는 서비스 로직을 생각해보자. 

 

다음과 같은 요구사항을 만족해야 한다. 

1) Member entity 영속화 

2) 외부 시스템에 이메일 전송 

3) 회원가입 쿠폰 발급 

 

@Service
@RequiredArgsConstructor
public class MemberSignUpService {

    private final MemberRepository memberRepository;
    private final CouponIssueService couponIssueService;
    private final EmailSenderService emailSenderService;


    @Transactional
    public void signUp(final MemberSignUpRequest dto) {
        final Member member = memberRepository.save(dto.toEntity()); // 1. member 엔티티 영속화
       emailSenderService.sendSignUpEmail(member); // 2. 외부 시스템 이메일 호출
        couponIssueService.issueSignUpCoupon(member.getId()); // 3. 회원가입 쿠폰 발급 -> 예외 발생, 회원, 쿠폰 모두 롤백, 문제는 회원 가입 이메일 전송 완료...

    }
}

 

이때 하나의 Service에서 signUp 메서드는 이 3가지 역할을 수행해야 하며 서비스층은 MemberRepository외에 쿠폰 관련 서비스, Email 관련 서비스를 의존해야 하는 문제가 생긴다. 

 

이렇게 부가 로직에 대한 처리의 문제가 있다. 

 

또 하나는 트랜잭션의 문제다. 

 

쿠폰 발행이 가장 마지막에 실행되는데 이때 예외가 발생하면 어떻게 될까? 트랜잭션 컨텍스트의 실패 정책에 따라 처음 것까지 전부 롤백이 된다. 그런데 로직 수행 순서상 메일은 이미 보내진 상황이다. 유저 입장에서는 회원 승인에 대한 안내를 받았지만 실제적으로 시스템 상에서는 회원 가입이 실패한 것으로 처리되어 정합성의 문제가 발생한다. 

 

그럼 3번을 먼저하고 2번을 마지막으로 순서를 바꾸면 해결이 될 것인가? 

 

마찬가지의 문제이다. 3번에서 오류가 나면 1,2번도 롤백이 되어야 하는데, 롤백 정책에 따라 다를 수 있지만 이메일 전송이 실패되었다고 해서 전체 회원가입 로직을 돌려 유저에게 재 가입을 요구하는 것이 맞느냐와 같은 정책적 문제가 발생한다. 

 

 

제안 

 

이와 같은 문제점을 해결하는 방법 중 하나로 이벤트 퍼블리셔를 도입하는 방법을 생각해보자. 

 

다양한 Event Queue가 있겠지만 스프링에서 제공하는 이벤트 발생 클래스를 이용한다. 

 

로직 순서는 다음과 같다. 

 

회원가입 -> 회원가입 쿠폰 발행 -> 회원가입 이벤트 리스너 동작 -> 회원가입 이메일 전송

 

이때 @TransactionalEventLister를 이용하여 트랜잭션 문제를 해결한다. 해당 리스너를 등록하는 경우 트랜잭션이 Commit 된 이후에 리스너가 동작하게 된다. 회원 가입 쿠폰 발행시 예외가 발생하면 트랜잭션 Commit이 진행되지 않기 때문에 리스너가 동작하지 않으므로 다음 순서를 진행하지 않아 트랜잭션 문제를 해결한다. 

 

 

다음과 같이 서비스 레이어의 sigunUp 메서드를 변경한다. 

@Service
@RequiredArgsConstructor
public class MemberSignUpService {

    private final MemberRepository memberRepository;
    private final CouponIssueService couponIssueService;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void signUp(final MemberSignUpRequest dto) {
        final Member member = memberRepository.save(dto.toEntity()); // 1. member 엔티티 영속화
        eventPublisher.publishEvent(new MemberSignedUpEvent(member));
        couponIssueService.issueSignUpCoupon(member.getId()); // 3. 회원가입 쿠폰 발급 -> 예외 발생, 회원, 쿠폰 모두 롤백, 문제는 회원 가입 이메일 전송 완료...

    }
}

 

이때 2번 로직인 email send를 publishEvent에서 담당한다. 

 

@Component
@RequiredArgsConstructor
public class MemberEventHandler {

    private final EmailSenderService emailSenderService;

    @TransactionalEventListener
    public void memberSignedUpEventListener(MemberSignedUpEvent dto){
        emailSenderService.sendSignUpEmail(dto.getMember());
    }
}
 

 

위의 코드에서 @TransactionEventLister 애노테이션에 의해 아래 코드에서 마지막사항인 쿠폰 발행이 실패할 경우 Commit 실패에 따라 email 송신을 하지 않는다. 

 

 

순서가 어떻게 되는 것일까? 

 

코드의 흐름은 

1. 영속화 

2. Event 발행

3. 쿠폰 발급 

 

이지만 

 

실제로는 

1. 영속화 

2. 쿠폰 발급 

3. 이벤트 발행

순서로 진행된다.

 

왜냐하면 commit이 찍히는 시점은 2번 쿠폰 발급이 완료되는 시점이기 때문이다. 

 

 

그렇다면 맨 마지막인 email 송신 에서 실패가 발생하는 경우는 어떨까? 

 

 

이 경우 비동기 로직을 도입해 해결할 수 있다. 

 

현재 코드에서는 간단하게 @Async 애노테이션으로 비동기 작동으로 변경할 수 있다. EventLister에는 @Async 애노테이션을 적용하고 @SpringBootApplication이 붙은 Application 메인 클래스에 @EnableAsync 애노테이션을 붙여준다. 

 

 먼저, @Async 애노테이션을 사용하기 위해 메인 클래스에 @EnableAsync 애노테이션을 추가한다.

 
@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

 

그 후, 이벤트 핸들러에서 @Async 애노테이션을 사용하여 이메일 전송 메서드를 비동기로 처리할 수 있다.

 

@Component
@RequiredArgsConstructor
public class MemberEventHandler {
     private final EmailSenderService emailSenderService;
     @Async
    @TransactionalEventListener
    public void memberSignedUpEventListener(MemberSignedUpEvent dto){
        emailSenderService.sendSignUpEmail(dto.getMember());
    }
}

 

이렇게 하면, 이메일 전송이 실패하더라도 그에 따른 예외는 별도의 스레드에서 처리되므로 메인 스레드의 트랜잭션에는 영향을 주지 않는다. 이로써 회원 가입과 쿠폰 발행이 정상적으로 이루어진 후에 이메일 전송이 실패하더라도, 회원 가입과 쿠폰 발행에 대한 트랜잭션은 롤백되지 않는다.


이러한 방식으로 @Async 애노테이션을 사용하면, 복잡한 비즈니스 로직을 간결하게 처리하면서 동시에 트랜잭션 관리를 효과적으로 할 수 있다.

 

 

 

결론 

 

이처럼 시스템 강결합 문제가 복잡한 비즈니스 로직에서 발생할 때 이벤트 퍼블리셔를 도입하여 시스템 간 결합도를 낮추고, 트랜잭션 관리를 효과적으로 할 수 있다. 또한 이를 비동기 방식으로 처리한다면 메인 로직과 별개의 별도 스레드에서 처리하므로 메인 로직에 영향을 주지 않고 분리된 트랜잭션 환경을 구성할 수 있다. 

 

 

 

 


 

참고자료

- 패스트 캠퍼스 (한 번에 끝내는 Spring 완.전.판 초격차 패키지 Online - Part9)

 

반응형