[Project] Spring Event 기반 email 전송과 트랜잭션 관리

bagt13·2023년 2월 25일
2

Project

목록 보기
8/19
post-thumbnail

회원 가입 시 가입 확인 이메일을 전송하는 로직이 존재한다고 해보자.

이때, 만일 회원 가입과 이메일 전송 비즈니스 로직을 함께 관리하면 어떤 문제가 생길까?


❌ 높은 결합

현재 구현하려는 로직에서 높은 결합이 발생시킬 수 있는 문제는 다음과 같다.

1. 여러가지 책임을 가지게 된다.

기본적인 SOLID의 원칙 중 SRP에 위배된다. 즉, 이메일 전송 로직에 변화가 생기면 회원 가입(member service) 로직에도 변화가 필요하다.

2. 성능 문제

이메일 전송은 다른 로직에 비해 꽤 많은 시간이 소요되며, 이로 인해 회원 가입 로직 처리에도 그만큼 시간이 더 걸리게 된다 (동기 처리)

하지만 회원 가입 로직은 이메일 전송 완료를 기다릴 필요가 없다. 즉, 이메일 전송만 호출하고 나머지 로직을 수행하면 더 효율적으로 동작할 수 있다 (비동기 처리)

3. 유지 보수와 확장성

회원 로직에 이메일 전송 뿐만 아니라 다른 로직도 필요해졌다고 가정하자. 그러면 해당 로직과 또다시 연관관계를 맺어야 하기 때문에 결합도가 높아지고, 이 경우 트랜잭션 처리도 복잡해질 것이다.

이 문제점들은 event 기반 비동기 처리를 통해 해결할 수 있다.


♻️ Spring Event

Spring은 event 기반 로직을 편리하게 처리할 수 있도록 기능을 제공하며, event 구현에 필요한 요소들은 다음과 같다.

1. ApplicationEvent

ApplicationEvent를 구현하여 event를 생성하며, event는 ApplicationEventPublisher에 의해 발행되고, EventListener에 의해 처리된다.

쉽게 말하면, event 처리에 필요한 정보라고 보면 될 것 같다.

public class MemberRegistrationEvent extends ApplicationEvent {

    private final Long id;
    private final String name;
    private final String email;

    public MemberRegistrationEvent(Object source, Member member) {
        super(source);
        this.id = member.getId();
        this.name = member.getName();
        this.email = member.getEmail();
    }
}

2. ApplicationEventPublisher

생성해놓은 event를 발행하는 trigger 역할을 한다. 발행한 event는 이후 알맞는 event listener에 의해 처리된다.

만일 이벤트 없이 여러 service를 호출해야 한다고 생각해보면, service의 개수만큼 의존성이 추가되어 강결합이 발생했을 것이다.

하지만 이벤트 기반으로 처리하면 아래와 같이 ApplicationEventPublisher 하나만으로 처리할 수 있기 때문에, 느슨한 결합이 가능하다.

public class MemberService {

    private final ApplicationEventPublisher publisher;

	/**
     * event 로직 강조를 위해 다른 로직 생략
     */
    public JoinResponse join(JoinRequest request) {
        Member savedMember = memberRepository.save(member);
        publisher.publishEvent(new MemberRegistrationEvent(this, savedMember));
        return toJoinResponse(savedMember);
    }

3. EventListener

EventListener는 publisher에 의해 실행된 event를 처리하는 클래스이며, @EventListener 애노테이션을 사용하여 구현할 수 있다.

@Component
@Slf4j
@RequiredArgsConstructor
public class MemberRegistrationEventListener {

    private final EmailSender emailSender;
    private final MemberRegistrationMessage memberRegistrationMessage;

    @Async
    @EventListener
    public void listen(MemberRegistrationEvent event) {
        sendRegistrationEmail(event);
    }
}

앞서 말했듯 이메일 전송은 꽤 시간이 걸리는 동작이며, 이는 회원가입 로직에도 영향을 미친다. 따라서 @Async로 별도의 스레드로 실행하여 비동기 처리하도록 할 수 있다.

@Async를 사용하기 위해서는 해당 클래스에 @Component, main 클래스에 @EnableAsync가 추가되어야 한다.
@EnableAsync
@SpringBootApplication
public class FlyAwayApplication {

📬 event 기반 로직을 구현할 때에는 항상 트랜잭션 처리를 고려해야 한다.

현재 상황에서는 다음 두 가지 경우를 고려할 수 있다.

1. 이메일 전송 실패

  • 요구사항에 따라 다르겠지만, 이 경우엔 회원 가입이 rollback 되어야 할 수도 있다.

2. 회원 가입 트랜잭션 실패

  • 이것도 정하기 나름이겠지만, 이메일이 전송되지 않도록 구현하려고 한다.

현재는 '이메일이 전송되면 뭐 어때?' 라고 생각할 수 있는 critical한 상황이 아니지만, 만일 외부 결제 서비스를 호출하는 event라고 생각하면 감이 올 것이다.


1. 이메일 전송 실패 상황

이메일 전송에 실패할 경우에 대비하여, try-catch로 이메일 전송 실패 시 member를 삭제 처리하였다.

@Async
@EventListener
public void listen(MemberRegistrationEvent event) {
	sendRegistrationEmail(event);
}

/**
 * 이메일 전송
 */
public void sendRegistrationEmail(MemberRegistrationEvent event) {
	try {
		String[] to = new String[]{event.getEmail()};
		String message = memberRegistrationMessage.createMessage(event.getName());
		String subject = memberRegistrationMessage.createSubject(event.getName());
		emailSender.sendEmail(to, subject, message);
	} catch (Exception e) {
		e.printStackTrace();
		rollbackRegistration(event);
	}
}

/**
 * rollback
 */
public void rollbackRegistration(MemberRegistrationEvent event) {
	log.error("###MailSendException : rollback memberId - {}", event.getId());
	memberRepository.deleteById(event.getId());
	throw new BusinessLogicException(EMAIL_SEND_FAILED);
}

2. 회원 가입 트랜잭션 실패 상황

event 처리에는 성공했지만 기존의 로직이 rollback되는 상황이 발생하는 경우는 어떻게 막을 수 있을까?

이는 @TransactionalEventListener를 사용하면 간편하게 처리할 수 있으며, 아래와 같이 @EventListener를 가지고 있는 것을 볼 수 있다.

@TransactionalEventListener

@TrransactionalEventListener를 사용하면 이벤트 publish 후 바로 실행되는 것이 아닌, 해당 트랜잭션이 commit되고 난 후에 실행되도록 설정할 수 있다.

@Async
@TransactionalEventListener(
	classes = MemberRegistrationEvent.class,
	phase = TransactionPhase.AFTER_COMMIT
)
public void listen(MemberRegistrationEvent event) {
	sendRegistrationEmail(event);
}

phase = TransactionPhase.AFTER_COMMIT 을 통해 해당 트랜잭션 commit 후 실행되도록 설정하였다.


✅ 결과

  • Spring Event를 통해 비즈니스 로직 간 강결합을 느슨한 결합으로 개선했다.

  • 이후 기능 추가로 다른 비즈니스 로직이 개입되더라도 결합(연관관계)이 추가되는 것이 아닌, listener 추가만으로 해결하여 간결한 비즈니스 로직을 유지할 수 있다.

  • 이메일 전송 처리시간으로 인한 사용자의 응답 지연을 @Async(비동기 처리)를 통해 해결하였다.

  • @TransactionalEventListener를 통해 기존의 로직과 event 로직 간의 트랜잭션 및 rollback 문제를 해결하였다.

이외에도 추가적으로 고려해야할 점이 있을 것 같은데, 공부하며 추가해볼 예정이다.


email 전송 관련 이전 포스트

Spring Event 기반 email 전송과 여러가지 Image Embedding 방식

profile
주니어 백엔드 개발자입니다😄

0개의 댓글