[콘서트 예약 시스템] - 이벤트 기반 아키텍처 , @TransactionalEventListener 를 적용하면서

Kyungmin·2024년 11월 24일
2

Spring

목록 보기
36/39

✏️ 이벤트 기반 아키텍처

  • 이벤트 : 시스템 내에서 발생하는 중요한 상태 변화나 활동을 나타내는 메시지
  • 이벤트 발행자(Publisher) : 이벤트를 생성하고 발행하는 컴포넌트
  • 이벤트 리스너(Listener) : 특정 이벤트를 구독하고, 이벤트 발생 시 이를 처리하는 컴포넌트
  • 트랜잭션 : 데이터베이스의 일관성과 무결성을 보장하기 위한 논리적 작업 단위

✏️ 이벤트 기반 아키텍처 장점

  • 관심사의 분리 : 핵심 비즈니스 로직과 부가적인 작업을 분리하여 코드의 가독성과 유지보수성을 향상
  • 비동기 처리 : 시간이 오래 걸리는 작업을 비동기적으로 처리하여 메인 트랜잭션의 성능에 영향을 주지 않는다.
  • 확장성 : 새로운 이벤트 리스너를 추가하여 기능을 쉽게 확장할 수 있다.

✏️ @EventListener & ✏️ @TransactionEventListener

  • @EventListener
    • 용도 : 일반적인 이벤트 리스너로, 트랜잭션과 무관하게 이벤트를 처리
    • 실행 시점 : 이벤트가 발행되면 즉시 실행
  • @TransactionEventListener
    • 용도 : 트랜잭션과 연계된 이벤트 리스너로, 트랜잭션의 상태에 따라 이벤트를 처리할 수 있다.
    • 실행 시점 : AFTER_COMMIT ,AFTER_ROLLBACK, AFTER_COMPLETION 등 다양한 시점에서 이벤트를 처리할 수 있다.
      • AFTER_COMMIT
        • 트랜잭션 결과를 반영(commit) 하고 난 시점에 이벤트가 실행. 기본값
      • AFTER_ROLLBACK
        • 트랜잭션에 rollback 이 일어난 시점에 이벤트가 실행
      • AFTER_COMPLETION
        • 트랜잭션이 끝났을 때(Commit or Rollback) 이벤트가 실행
      • BEFORE_COMMIT
        • 트랜잭션이 커밋되기 전에 이벤트가 실행
    • 특징 : 트랜잭션이 성공적으로 커밋된 후에만 처리되도록 보장한다.

✏️ 스프링 이벤트란 ?

이벤트란 ?

  • 시스템 내에서 발생하는 중요한 상태 변화나 활동을 나타내는 메시지이다. 예약을 완료했을 때, 좌석 상태가 변경되었을 때 등이 이벤트가 될 수 있겠다.

스프링의 이벤트 시스템

  • 스프링 프레임워크는 애플리케이션 내에서 이벤트를 발생하고 , 이를 구독하는 매커니즘을 제공한다. 이를통해 느슨하게 결합된 컴포넌트 간의 통신을 용이하게 할 수 있다.

[이벤트 사용 장점 , 단점 및 사용 예시]

  • 장점
    • 의존성을 분리하여 두 클래스를 느슨하게 결합할 수 있다. ( 의존성을 분리한다. )
    • 클래스가 독립적이므로 재사용성을 높인다.
    • 추후 별도의 서비스로 분리하기 용이
    • 메시지 구독 모듈을 추가 또는 삭제할 경우, 다른 모듈에 영향을 주지 않은 채로 수정이 가능
    • 단위 테스트 용이
  • 단점
    • 전반적인 작업량이 많아질 수 있다. (이벤트 클래스, 커스텀 어노테이션 등)
    • 코르 흐름 따라가기 어려울 가능성
    • 메시지 구독 순서를 고려해야 하는 경우 복잡
    • 전체적인 이벤트의 구독 및 발행 과정을 테스트하기 어려움
    • 특정 프레임워크 API 에 의존하게 됨

⇒ 이벤트를 사용해야 하는 경우는 특정한 도메인의 상태 변경을 외부로 알려줘야 하는 경우.

예를 들어 주문이 완료되었으면 Order 도메인에서 다른 도메인으로 변경에 따른 처리를 해야 하는 상황이 필요.

  • 기존 예약 로직
@Transactional
public ReservationResponseDto rvConcertToUser(...) {
    
    // 유저 확인() 
    // 콘서트 확인() 
    // 좌석 확인()
    								
    // 좌석 상태 변경()
    								
    // redis 로직 실행 - 토큰을 redis 에 저장 .. 
    .. 
   }
- 위 로직을 보면 이 모든 로직은 한 트랜잭션에 묶여 있다. 이말은 즉 하나의 메서드만 실패하더라도 모든 로직은 트랜잭션의 특성으로 인해 rollback 되게 된다.

redis 가 만약에 고장이 나면 예약 로직은 실행되지 못한다. 상황에 따라 가능한 시나리오일 수 있다. 하지만 redis 가 고장이 나도 서비스에서 예약을 정상적으로 성공시켜야한다면? 

- *redis 에 토큰을 저장하는 로직*이 어떤 이유에 의해 오래 걸릴 경우 전체 트랜잭션에 영향을 끼침
- *redis 에 토큰을 저장하는 로직*이 실패할 경우 예약 처리 전체가 실패하게 됨

- 결국 콘서트를 예약하는(save) 로직은 동기적으로 처리를 해주고, redis 작업은 비동기적으로 처리를 해준다면 메인스레드는 예약저장 응답만 기다리면 되기 때문에, 실제 처리시간이 더 빨라질 것 이다.
    - 동기 : 서버에 요청을 보냈을 때, 응답이 돌아오면 다음 동작을 수행 ( 즉, 순차적으로 응답을 처리   /   1 요청 처리 → 2요청 처리 → 3 요청 처리 →  ,,,, )
        - 코드가 직관적이고 간단
        - 순서를 보장받을 수 있기 때문에 명확한 데이터를 확인 가능
    - 비동기 : 동기 방식과는 반대로 요청을 보내고 응답과는  상관없이 다음 동작을 수행 ( 2 요청처리 → 1 요청 처리 → 3요청처리 → … )
        - 시간이 걸리는 동안 다른 작업을 진행할 수 있다.
        - 동기 방식보다 구현이 복잡
        - 비동기 Config.java 설정

비동기 Config.java 설정

@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigururSupport {  }
  • @Async 주의점
    • 같은 클래스 내에서 사용이 불가
    • private 메서드에서 사용 불가능
  • 트랜잭션을 분리하고 이벤트 리스너를 적용
@Transactional
public ReservationResponseDto rvConcertToUser(...) {
    
    		// 유저 확인() 
    		// 콘서트 확인() 
    		// 좌석 확인()

    		// 예약이 불가능한 경우 로직()

    		// 좌석 예약 가능 여부 변경()
    		// 예약.save()

    		// redis 이벤트 발생() 
    		.... 
    	}
    // redis 이벤트 발생 // 
    
    @Component
    @RequiredArgsConstructor
    @Slf4j
    public class ReservationEventListener {
        private final RedisTemplate<String, String> redisTemplate;
    
        @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
        public void handleReservationCompleted(ReservationCompletedEvent event) {
            log.info("예약 완료 후 예약 이벤트 처리를 시작. ReservationId: {}", event.reservationId());
    
            try {
                redisTemplate.opsForValue().set("reservation_token:" + event.token(),
                        String.valueOf(event.reservationId()));
            } catch (Exception e) {
                log.error("예약 이벤트 처리 중 오류 발생", e.getMessage());
            }
        }
    }

즉 다음과 같은 로직을

        @Transactional
        public ReservationResponseDto rvConcertToUser(Long concertId, String token, ReservationRequestDto requestDto) {
    
    				...
    				// 예약.save()
            reservationRepository.save(reservation);
    				
            redisTemplate.opsForValue().set("reservation_token:"+ token, String.valueOf(reservation.getId()));
    
            ...
        }

이와같이 변경할 수 있다.

        @Transactional
        public ReservationResponseDto rvConcertToUser(Long concertId, String token, ReservationRequestDto requestDto) {
            ...
            // 예약.save()
            reservationRepository.save(reservation);
    
            // 이벤트 발행 (만약 실패해도 에약은 정상적으로 save() )
            eventPublisher.publishEvent(new ReservationCompletedEvent(
                    reservation.getId(),
                    token,
                    user.getId()
                ));
    			...
        }

✅ 분산 디비에서 트랜잭션을 보장하는 방법

  • 비동기 처리

💡 AFTER_COMMIT 은 트랜잭션이 성공적으로 커밋된 이후 실행이 되는 것을 보장한다. 이는 트랜잭션 내에서 발생한 변경사항이 데이터베이스에 반영된 이후 이벤트가 처리됨을 의미한다. 기본적으로 트랜잭션이벤트리스너는 이벤트를 동기적으로 실행한다. 즉 이벤트 리스너가 완료될 때 까지 메인스레드는 블록된다.

💡 @Async(비동기) : 메서드를 별로의 스레드에서 비동기적으로 실행하도록 설정한다. 이를 통해 메인 스레드가 해당 작업을 기다리지 않고 즉시 다음 작업을 계속할 수 있도록 보장한다.
시간이 많이 드는 작업(외부 API 호출 등) 을 비동기적으로 처리하여 메인 스레드의 응답성을 높여주고, 애플리케이션의 전반적인 성능을 높인다.

💡 @TransationalEventlistener 와 @Async 의 관계

  • @TransationalEventlistener 단독으로만 사용할 경우
    - 트랜잭션이 성공적으로 커밋된 후에만 이벤트가 처리되므로 데이터 일관성이 유지된다.
    - 하지만 이벤트 리스너가 완료될 때 까지 메인스레드가 블록된다. 시간이 많이 소요되는 작업이 있을 경우, 메인 트랜잭션의 응답시간이 지연될 수 있다.
  • @TransationalEventlistener 과 @Async 을 같이 사용할 경우
    - 여전히 트랜잭션이 커밋된 이후 이벤트가 실행된다. 하지만 이벤트 리스너가 별도의 스레드에서 실행되므로, 메인 트랜잭션의 응답 시간이 단축된다.
    - 시간이 많이 소요되는 작업을 메인스레드와 분리하여 처리함으로써 시스템의 전반적인 성능과 응답성을 향상시킨다.

❓ [ 궁금한 부분 ] @TransactionEventListener 와 @KafkaListener 는 직접적으로 호출하지 않는데 어떻게 호출이 되는걸까?

💡 ApplicationEventPublisher 를 통해 이벤트를 발행하면, 해당 이벤트를 처리하도록 등록된 이벤트 리스너들이 호출된다.
호출을 하게 되면 그 이벤트는 스프링의 애플리케이션 컨텍스트에 게시되게 된다.
@TransactionalEventListener로 어노테이션된 메서드는 이벤트 리스너로 등록된다. Spring은 애플리케이션 컨텍스트를 초기화할 때 이 메서드를 이벤트 리스너로 인식하고 등록한다. 이벤트가 발핸되면 스프링은 해당 이벤트 타입을 처리하도록 등록된 모든 리스너들을 찾는다.

그렇다면 @TransactionalEventListener 는 어느 순서대로 실행이 되는것일까?

: @TransactionalEventListener로 어노테이션된 메서드는 Spring 프레임워크에서 이벤트를 처리하기 위해 사용되며, 트랜잭션 상태에 따라 이벤트를 리스한다. 여러 개의 @TransactionalEventListener가 있을 때, 그 실행 순서는 기본적으로 “비결정적”
즉, 순서를 보장하지 않는다. 실행 순서를 지정하고 싶다면 @Order(n) 어노테이션을 지정하여 순서를 지정해 줄 수 있다.

💡 consume 메서드는 @KafkaListener로 어노테이션되어 있어 Kafka 메시지 리스너로 등록된다.
topicsgroupId를 지정하여 어떤 토픽의 메시지를 수신할지 설정한다.
스프링 카프카는 애플리케이션 컨텍스트를 초기화할 때, @KafkaListener 가 적용된 메서드를 감지하고, 리스너 컨테이너를 생성한다.
리스너 컨테이너는 백그라운드에서 실행되며, 지정된 토픽에 대해 카프카 브로커와 연결을 유지한다.
카프카 브로커에서 메시지가 해당 토픽으로 발행되면, 리스너 컨테이너는 이를 감지하고 메시지를 가져온다. 가져온 메시지는 consume 메서드에 전달되어 호출한다.

profile
Backend Developer

0개의 댓글

관련 채용 정보