개발 기록
> [Spring Event] Spring Event 적용 - @TransactionalEventListener, @Async 본문

■
1. 출석체크 서비스에 이벤트, 트랜잭션 분리, 비동기 처리 적용
(1) 서비스 시나리오

▶단계 / 회차별 출석체크 시 포인트와 스탬프를 제공하는 서비스 ◀
☞ 단계별 1회차 200P, 2회차 350P, 3회차 450P
☞ 단계별 3회차 모두 출석체크 시 스탬프 1개 제공
ex. 총 3단계 / 9회차 모두 출석체크 성공시 -> 스탬프 3개 + 3000P
☞ 조건: 출석체크는 단계 시작일자 기준 연속으로 해야한다.
ex. (1단계 1회차 시작일: 1/1) => (1단계 2회차 출석체크 1/3) => 결과 : 1단계 실패
(2) 설계

출석체크시 포인트 적립, 스탬프 적립 등을 해야 한다면 아래 코드처럼 짤 수있다
@RequiredArgsConstructor
@Service
public class AttendanceCycleService {
private final AttendanceCycleRepository attendanceCycleRepository;
private final StampService stampService;
private final PointService pointService;
@Transactional
public AttendanceCycleDTO add(AttendanceCycleDTO request) {
// 1. 출석 체크
AttendanceCycle attendanceCycle = attendanceCycleRepository.save(request);
// 2. 포인트 적립
pointService.add(attendanceCycle);
// 3. 스탬프 적립
stampService.add(attendanceCycle);
return attendanceCycle;
}
}
여기에서 핵심은 출석일자을 저장하는 로직이고 나머지는 이와 연관된 부가적인 코드다.
코드를 보면, 출석일자를 저장하는 add 메서드에 많은 책임이 부여된 것을 볼 수 있다. 포인트도 적립해야되고, 스탬프도 적립시키고, 본인의 관련 도메인 외에 다른 도메인 작업까지 하고 있다. 그 결과 AttendanceCycleService 에 포인트, 스탬프 관련 의존성 주입이 필요해지고 이런 의존성들 사이에서 강한 결합 관계가 발생하게 된다.
■
2. 관심사 분리
(1) Spring Event
Spring 이벤트를 사용하면 핵심 로직과 부가 로직간의 결합도를 끊고 관심사를 분리할 수 있다.
키워드는 이벤트 발행(publish) 과 이벤트 수신(subscribe) 이다.
1. 이벤트 발행
applicationEventPublisher.publishEvent(event)
: 핵심로직을 진행 후, ApplicationEventPublisher 인터페이스로 ApplicationContext 에 이벤트를 넘겨준다.

2. 이벤트 구독
@EventListener
: ApplicationContext 에서 이벤트가 발행되면 @EventListener 가 메서드 중 publicEvent(Object event) 로 발행했던 event 를 파라미터로 받는 메서드를 모두 실행한다. 이 때 반드시 이벤트를 받을 메서드의 파라미터는 event 만 가지고 있어야 한다.

■
3. 트랜잭션과 이벤트 처리
핵심 로직 서비스 단에서 어찌어찌한 이유로 예외가 발생할 경우에, 리스너는 어떻게 반응 할까?!
@Transactional(rollbackOn = Exception.class)
public AttendanceCycleDTO add(AttendanceCycleDTO request) throws Exception {
.
.
// add CycleDetail
attendanceCycle = attendanceCycleRepository.save(attendanceCycle);
// 이벤트 발행
applicationEventPublisher.publishEvent(attendanceCycle);
if(true){
throw new Exception("의도적인 예외 발생");
}
return mapper.map(attendanceCycle,AttendanceCycleDTO.class);
}
리스너는 exception 발생과 상관없이 실행된다. 왜냐하면 @EventListener 는 이벤트 발행 시점을 기준으로 동작하기 때문이다.


스프링 이벤트는 동기적으로 실행 되기때문에 로직을 분리해도 핵심로직과 부가로직은 같은 트랜잭션으로 묶여있다.
즉 하나의 스레드에서 실행되기 때문에 이벤트 처리가 끝나야 이벤트를 발행한 곳의 남은 로직을 처리하고 트랜잭션을 커밋할지 롤백할지 결정한다.
여기서 발생하는 문제는
부가적인 코드는 트랜잭션이 성공적으로 수행된 이후에 실행되어야만 하는데, 위의 코드로는 EventListener 의 동작이 트랜잭션의 성공적인 수행을 보장할 수 없다는 것이다.
ex. Publisher : 회원가입 이벤트 발행 => => => Listener : 회원가입 성공 이메일 발송 흐름일 때 회원 가입을 실패해도 회원가입 성공 이메 일이 발송되는 불상사가 생긴다.
(1) 해결 : @TransactionEventListener
@TransactionalEventListener 를 사용하여 이벤트 발행 시점을 결정한다.
@TransactionalEventListener 는 트랜잭션 안에서 이벤트를 발생시킬 때 트랜잭션 처리와 결합하여 이벤트를 수신하는 로직을 처리할 수 있다.
1. phase 옵션 = 이벤트 처리를 바인딩하는 단계 설정
㉮ AFTER_COMMIT : 트랜잭션이 성공했을 때 실행
㉯ AFTER_ROLLBACK :트랜잭션 롤백시 실행
㉰ AFTER_COMPLETE : 트랜잭션 완료시 (AFTER_COMMIT+AFTER_ROLLBACK)
㉱ BEFORE_COMMIT : 트랜잭션 commit 되기전에 실행
2. ex) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
=> 트랜잭션이 성공적으로 commit 된 이후에 리스너 로직을 처리
※ phase 생략시 AFTER_COMMIT 이 기본 설정 이다.
3. 적용결과 => 리스너 로그가 출력되지 않는 것을 볼 수 있다.


■
4 . TransactionEventListener (phase = TransactionPhase.AFTER_COMMIT) 주의 사항
phase 값이 AFTER_COMMIT 으로 정의해놓은 경우 리스너 코드 안에서 데이터 조회는 가능하지만 쓰기는 불가능하다.
왜냐하면 같은 트랜잭션으로 묶여있는 상황에서 이전의 이벤트를 publish 하는 코드에서 트랜잭션이 이미 커밋 되었기 때문이다.
(1) 해결 : @PROPAGATION_REQUIRES_NEW 옵션
새로운 트랜잭션을 열어서 로직을 처리한다.
이벤트 리스너의 로직 안에서 실행되는 @Transactional 로직을 위한 새로운 트랜잭션이 이전의 트랜잭션과 구분되어 새롭게 시작한다. 따라서 이벤트를 발생시킨 트랜잭션과는 별도의 분리된 트랜잭션 안에서 이벤트 리스너 로직이 실행된다.
// 출석 체크 이벤트를 구독한다.
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(AttendanceCycleEvent attendanceCycleEvent) throws Exception {
AttendanceCycle attendanceCycle = attendanceCycleEvent.getAttendanceCycle();
UserPay userPay = userPayService.get(attendanceCycle.getUser().getId());
userPay.setPoint(Point.valueOf(String.valueOf(Progress.FIRST)).getValue()+userPay.getPoint());
하지만 트랜잭션을 분리하게 되면 분리된 트랜잭션은 기존의 커넥션과 다른 커넥션으로 연결되고 그럼 이벤트에 따라 실행되는 로직이 n개라면 n개의 커넥션으로 연결되는 상황이 생긴다. 이 스레드가 끝나지 않는 이상 다수의 커넥션은 연결되어 있는 상태고 이는 성능에 문제가 발생할 수 있다.
(2) 해결 : @Async
@Async을 추가하면 기존 스레드, 트랜잭션과 자연스럽게 분리가 된다.
이렇게 하면 이벤트 리스너 로직이 별도의 스레드에서 실행되어 트랜잭션이 커밋되기 때문에 의도한 결과를 얻을 수 있다.
스레드가 다르기 때문에 테스트코드를 작성하기는 좀 까다로울 수는 있지만 Listener 로직에서 문제가 발생하더라도
사용자의 응답을 느리게 만들지 않는다.

(2-1) @Async 결과
1. 데이터도 잘 들어온 것 확인

2. 스레드 id 확인
스레드 Id 를 확인해보면, 이벤트를 Publisher 스레드ID 와 Listener 스레드 ID 가 다른 것을 볼 수 있다.


참고
https://findstar.pe.kr/2022/09/17/points-to-consider-when-using-the-Spring-Events-feature/https://tecoble.techcourse.co.kr/post/2022-11-14-spring-event/
'Spring' 카테고리의 다른 글
| > [Spring] Bean Scope - 3. 프로토타입빈과 싱글톤 빈 함께 사용하기 (Proxy mode, Provider) (0) | 2024.01.24 |
|---|---|
| > [Spring Event] Spring Event 비동기 처리 (0) | 2024.01.19 |
| > [Spring] Bean Scope - 2. 사용자 정의 Bean Scope (0) | 2024.01.18 |
| > [Spring] Bean Scope - 1. 개념과 사용 방법(singleton, prototype, request) (0) | 2024.01.18 |
| > [Spring Event] Event 개념과 Spring Event 사용법 (0) | 2024.01.16 |