기술적 고민

예약 관리 서비스에 RabbitMQ 를 도입하게 된 이유

땍땍 2025. 3. 26. 18:28

현재 진행 중인 프로젝트에서는 하나의 매장에서 여러 고객을 관리하고, 고객 별 예약을 관리 해야만 한다. 예약은 등록 되어 있는 고객만이 가능하고, 동일 시간에 최대 예약 가능 갯수가 제한 되어 있다.

이외에도 여러 부가적인 기능이 존재하지만, 가장 중요하게 바라봐야할 포인트는

  1. 고객 등록
  2. 예약 등록

이라고 생각을 했다.

한 명의 고객에 대한 등록은 단 한 번만 진행을 하며, 해당 데이터가 유실이 되면 예약을 포함한 다른 서비스를 이용할 수 없었고, 결국 동일한 고객의 정보를 재입력 하는 과정이 부정적인 사용자 경험으로 이어질 것이라 예상됐다.

예약 등록은 데이터 유실 방지는 물론, 최대 갯수가 제한되어 있는 만큼 예약 순서를 보장해야 했고 잘못된 데이터가 입력될 경우 서비스에도 장애가 발생할 것이라 예상이 되었다.

위와 같은 사항의 대안을 고민하던 중, 현재 가동 중인 서버가 한 개 뿐이기에 서버가 중단 되어 데이터 유실하는 경우를 방지하기 위해 부담을 분산 시켜야할 것같다는 생각으로 이어지게 되었고 최종적으로 외부 시스템의 큐를 활용한 방식을 도입하기로 하였다.

외부 시스템을 사용함으로 서버가 중단 되어도 데이터가 유실되지 않고, 큐의 선입선출 방식을 활용해 예약 순서 또한 보장할 수 있었다.

메세지 큐 시스템을 활용하기 위한 방안은 여럿 존재했지만, 세팅이나 이후 트러블슈팅 등을 생각하여 가장 많이 활용 되고 레퍼런스가 많은 RabbitMQ 와 Kafka 를 찾아 비교하게 되었다.

가장 큰 차이점은, RabbitMQ는 브로커가 소비자로 Push 를 Kafka는 소비자가 메세지를 Pull 하는 방식으로 실시간 처리를 위해서는 RabbitMQ 가 낮은 지연 시간을 갖고 있어 유리하다는 점이었다.

이외에는 Kafka 가 대규모 처리, 로깅 등 다수의 서버를 관리해야할 때 사용할 수 있는 속도나 분산 등의 이점이 있었으나 현재 하나의 서버만을 구동하고 있는 해당 프로젝트에서는 고려할 사항은 아니었다.

결국 기능적으로, 성능적으로 어떤 것을 선택해도 위의 두 가지 사항을 해결하는데 큰 차이는 없을 것같다는 결론을 내리게 되었고, 비교적 낮은 지연 시간을 갖고 있는 RabbitMQ 를 도입하기로 결정하였다.

구현에 앞서 RabbitMQ 가 어떤 식으로 동작하는지 알고 넘어가고자 한다.

 

순서는 위의 사진과 같다.

클라이언트가 RabbitMQ 로 메세지를 전송하면, Exchange가 이를 받고 Queue로 전송한다.

그리고 Queue 는 서버로 메세지를 Push 하고 서버는 이를 처리하는 순서이다.

처음 해당 내부 구조를 확인했을 때, 왜 클라이언트가 바로 Queue 로 메세지를 전달하지 않고 Exchange 를 거치는 걸까? 라는 의문이 들어 찾아보았다.

정말 간단하게 설명하자면 Exchange 는 폴더 같은 느낌으로, Exchange 내부에 여러 Queue를 저장할 수 있고, Queue 내부에는 여러 메세지를 저장할 수 있는 구조이다.

즉, Exchange가 최상위 폴더, Queue가 하위 폴더 같은 느낌으로 이해하면 편할 것같다.

이러한 구조 덕분에, 메세지를 Exchange 에서 어떤 Queue 로 전달할 것인지 결정할 수 있고 때문에 하나의 메세지를 여러 Queue 로 전달할 수 있는 기능 또한 지원한다.

다만, 이번에는 Exchange 를 활용할 부분이 없었기에 Queue 만을 활용해 기능을 구현하게 되었다.

RabbitMQ 를 도입하기 전 코드는 아래와 같다.

  public AddReservationDto.Response addReservations(AddReservationDto.Request req) {

    Account account = ,,
    Institute institute = ,,

    Customer customer = customerReader.findByIdAndInstituteId(,,)

    instituteValidator.isValidSeatNumber(,,);
    reservationValidator.isTimeSlotAvailable(,,);
    
    ,,

    reservationCreator.save(reservation);
    return reservationMapper.entityToAddReservaionResponse(reservation);
  }
  1. 계정, 매장, 고객의 정보를 조회하고
  2. 예약이 가능한 시간인지, DB 의 데이터와 비교해 유효성을 검사한 후
  3. 예약을 DB 에 저장하는 코드이다.

RabbitMQ 를 사용하기 위해서는 의존성을 추가해야 한다. 최신 버전은 아래 링크에서 확인할 수 있다.

https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-amqp

	implementation 'org.springframework.boot:spring-boot-starter-amqp:3.4.3'

다음으로 메세지를 저장할 큐를 생성해야 하는데, 큐 이름은 노출되지 않게 SecretKey 로 관리하며 다수 존재하기에 ConfigurationProperties 를 활용하기로 하였다.

// secretKey.yml

rabbitmq:
  queues:
    - name: customer_queue
      exchange: customer_exchange
      routingKey: customer_routingKey
    - name: reservation_queue
      exchange: reservation_exchange
      routingKey: reservation_routingKey
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "rabbitmq")
public class RabbitMqProperties {
  private List<QueueConfig> queues;

  @Getter
  @Setter
  public static class QueueConfig {
    private String name;
    private String exchange;
    private String routingKey;
  }
}
@Configuration
@RequiredArgsConstructor
public class RabbitMqConfig {

  private final RabbitMqProperties rabbitMqProperties;
  @Bean
  public Declarables rabbitDeclarables() {
    List<Queue> queues = rabbitMqProperties.getQueues().stream()
        .map(q -> new Queue(q.getName(), true))
        .toList();
    return new Declarables(queues);
  }

	...
}

위와 같은 방식으로 secretKey.yml 에 작성 되어 있는 큐를 동적으로 생성할 수 있게 하였다.

@Component
@Getter
public class RabbitMqMapper {
  
  private final String customerQueueName;
  private final String reservationQueueName;

  public RabbitMqMapper(RabbitMqProperties rabbitMqProperties) {
    this.customerQueueName = rabbitMqProperties.getQueues().get(0).getName();
    this.reservationQueueName = rabbitMqProperties.getQueues().get(1).getName();
  }
}

이렇게 저장한 큐는 Properties 파일 내에서만 관리하고자 SecretKey 를 읽어와 변수로 매핑하는RabbitMqMapper 라는 클래스를 별도로 생성하였고,

@Component
@Slf4j
@RequiredArgsConstructor
public class RabbitMqManager {

  private final RabbitTemplate rabbitTemplate;
  private final RabbitMqMapper rabbitMqMapper;

  public void sendCustomerMessage(Message message) {
    rabbitTemplate.convertAndSend(rabbitMqMapper.getCustomerQueueName(), message);
  }

  public void sendReservationMessage(Message message) {
    rabbitTemplate.convertAndSend(rabbitMqMapper.getReservationQueueName(), message);
  }

  public Message getMessage(Object object) {
    return rabbitTemplate.getMessageConverter().toMessage(object, new MessageProperties());
  }
}

모든 메세지를 관리하는 RabbitMqManger 클래스를 생성하여, 서비스 단에서는RabbitMqManger 만을 활용하도록 하였다.

@Component
@Slf4j
@RequiredArgsConstructor
public class RabbitMqManager {

  private final RabbitTemplate rabbitTemplate;
  private final RabbitMqMapper rabbitMqMapper;

  public void sendCustomerMessage(Message message) {
    rabbitTemplate.convertAndSend(rabbitMqMapper.getCustomerQueueName(), message);
  }

  public void sendReservationMessage(Message message) {
    rabbitTemplate.convertAndSend(rabbitMqMapper.getReservationQueueName(), message);
  }

  public Message getMessage(Object object) {
    return rabbitTemplate.getMessageConverter().toMessage(object, new MessageProperties());
  }
}

RabbitMQ 의 기본적인 세팅을 끝내고, 기존 검증과 데이터 저장을 함께 하던 서비스 코드를

데이터를 검증하고 요청을 메세지 큐로 전송하는 전송 역할과, 전달 받은 메세지를 엔티티로 매핑하고, 저장하는 수신 역할로 나누려 했으나 문제가 발생하였다.

RabbitMQ 에서 메세지를 전송 받아 처리하는 리스너는 비동기로 처리가 되었고, 전송 과정과 수신 과정이 동시에 처리되는 중 동시성 문제가 발생할 수 있었다.

때문에 임시적으로 DB 에서 데이터를 가져와 검증하는 과정을 수신 역할로 이동하였다.

```
  public AddReservationDto.Response addReservations(AddReservationDto.Request req) {

    Account account = ,,
    Institute institute = ,,
    Customer customer = customerReader.findByIdAndInstituteId(,,)

    instituteValidator.isValidSeatNumber(,,);    
    ,,
  }
```
  @Transactional
  public void addReservations(Account account, Customer customer, AddReservationDto.Request req) {

    reservationValidator.isTimeSlotAvailable(,,,);

    Reservation reservation = reservationMapper.dtoToEntity(,,,);

    reservationCreator.save(reservation);
  }

다만, 전송 역할에서 발생한 오류는 즉시 예외 메세지와 함께 반환할 수 있었으나 수신 역할에서 발생한 오류는 클라이언트로 전송할 수 있는 방법이 존재하지 않았고

SSE 등을 활용하거나 해당 메세지의 상태를 확인할 수 있는 API 를 따로 제공하는 방안을 생각했으나 소모되는 자원이 너무 큰 것같아 채택하지 않았다.

최종적으로는 분산 락을 활용한 방법으로 문제를 해결하였는데, 왜 이러한 방법을 선택했는지와 어떻게 해결하였는지 등 관련된 내용은 다음 글에서 이어 작성하도록 하겠다.