본문 바로가기
개발 글쓰기

매칭 서비스 구현하기(RabbitMQ, Unit Test)

by moonstal 2023. 12. 31.

매칭 서비스 구현하기(RabbitMQ, Unit Test)

그동안 많은 글을 작성했지만, 이 글이 본격 개발 글쓰기의 시작이라 떨리네요.(두근)
글에 미흡한 부분이 있다면 언제든 댓글에 남겨주세요😊 수정보완하겠습니다!
앞으로 진행될 순서는 다음과 같습니다.

  1. 왜 이 기능을 구현했나요?
  2. 왜 이 기술을 사용했나요?
  3. 어떻게 구현했나요?
  4. 더 알아보기

1. 왜 이 기능을 구현했나요?

저희 프로젝트 주제는 원어민과의 1:1 매칭 언어 교환 웹 서비스이었습니다. 학습하고자하는 언어를 모국어로 하는 대화 상대를 매칭시켜주기 위해 매칭기능을 구현하였습니다.
 
매칭 기능에는 일반 매칭과 프리미엄 매칭이 있는데 일반 매칭의 경우 언어만 충족하면 매칭이 되도록 하였고, 프리미엄 매칭의 경우 마일리지를 사용하여 일정 수준 이상의 실력매너점수를 가진 상대가 매칭되도록 했습니다.
 
마일리지는 어떻게 얻나요?
상대방과 대화한 시간에 따라 마일리지를 얻게 됩니다.
 
실력과 매너점수는 어떻게 판단하나요?

대화를 종료할 때 상대방을 평가합니다.

2. 왜 이 기술을 사용했나요?

RabbitMQ

첫번째는 서버 분리입니다. 매칭 기능은 스케줄링을 통해 많은 연산을 필요로 하는데, 이러한 연산을 RabbitMQ를 사용하여 주 서버와 분리함으로써 주 서버의 성능을 향상시키고자 하였습니다.
 
두번째는 비동기 처리를 위해 사용하였습니다. 비동기 처리는 대기 시간 동안 다른 작업을 수행할 수 있게 해줍니다. 따라서 CPU 및 기타 시스템 자원을 효율적으로 활용할 수 있습니다. 이는 시스템 전반적인 성능 향상에 기여할 수 있습니다.
 
동기 vs 비동기 vs 블로킹 vs 논블로킹에 대해 설명해주세요.
면접 때 진짜 많이 물어본다. 동기 비동기는 대충 알겠는데 블로킹, 논블로킹까지 설명하라고 해서 당황ㅜㅜ 분명 cs 공부할 때 봤는데… 뭐였더라… 암튼 그때도 개념이 헷갈렸다. 제대로 알고 가자! 친구가 재밌다고 추천해준 테코톡 영상이다.
 
우의 Block vs Non-Block & Sync vs Async
땡칠의 Blocking vs Non-Blocking과 Sync vs Async
멍토의 Blocking vs Non-Blocking, Sync vs Async
 
간단히 말하면, 동기/비동기는 작업의 진행 방식을 나타내고, 블로킹/논블로킹은 작업의 완료 여부에 따른 제어 흐름의 특성을 나타냅니다.

  • 동기 (Synchronous):
    •  동기는 작업이 순차적으로 진행되는 것을 의미합니다.
    •  한 작업이 시작되면 해당 작업이 완료될 때까지 다음 작업이 기다립니다.
    •  대표적인 예시는 함수 호출이 있습니다. 함수를 호출하면 해당 함수가 종료될 때까지 대기하고, 함수가 종료되면 다음 코드가 실행됩니다.
  • 비동기 (Asynchronous):
    •  비동기는 작업이 순차적으로 진행되지 않고, 한 작업의 완료 여부와 상관없이 다음 작업이 시작될 수 있습니다.
    •  대표적인 예시는 콜백 함수나 이벤트 핸들링입니다. 비동기적 작업을 시작하면 다음 코드가 즉시 실행되며, 작업이 완료되면 콜백 함수가 호출됩니다.
  • 블로킹 (Blocking):
    •  블로킹은 작업이 완료될 때까지 제어 흐름이 중단되는 것을 의미합니다.
    •  동기적인 작업은 일반적으로 블로킹 작업입니다. 함수 호출이 끝날 때까지 다음 코드 실행을 기다립니다.
  • 논블로킹 (Non-blocking):
    •  논블로킹은 작업이 완료될 때까지 대기하지 않고, 다른 작업을 계속 수행하는 것을 의미합니다.
    •  비동기적 작업은 일반적으로 논블로킹 작업입니다. 한 작업이 완료될 때까지 기다리지 않고, 다른 작업을 수행할 수 있습니다.

다시 돌아와서 서버 분리비동기 처리를 할 수 있도록 하는 기술이 메시지 큐이다. 카프카, 레빗엠큐, 레디스 큐가 있는데 우리 서비스에는 무엇을 사용해야 할까? 일단 차이점부터 알아봅시다.
 
카프카, 레빗엠큐, 레디스 큐의 큰 차이점! 이벤트 브로커와 메시지 브로커에 대해 알아봅시다.
 
메시지 브로커의 특징

  • 메시지를 받아서 처리하고 나면 즉시 또는 짧은 시간 내에 삭제되는 구조
  • ex. 레디스 큐, 레빗엠큐

이벤트 브로커의 특징

  • 이벤트 또는 메시지라고 불리는 레코드를 하나만 보관하여 인덱스를 통해 개별 엑세스를 관리
  • 업무상 필요한 시간동안 이벤트를 보존
  • 삭제하지 않는 이유?
    • 딱 한번 일어난 이벤트 데이터를 브로커에 저장함으로써 단일 진실 공급원으로 사용
    • 장애 발생시 일어난 지점부터 재처리 가능
    • 많은 양의 실시간 스트림 데이터 효과적으로 처리
  • ex. 카프카, 키네시스

메시지 브로커 VS 이벤트 브로커
매칭 요청의 경우에는 한번 전송하고 서버에서 받으면 삭제해도 되는 데이터라고 생각해서 메시지 브로커가 적합하다고 생각했다.
 
레디스 큐 VS 레빗엠큐
메시지 브로커 중에서 골라보자

  • 레빗엠큐는 메시지를 소비하는 컨슈머의 상태를 관리하고, 메시지를 안전하게 소비하도록 지원합니다. 이는 컨슈머의 실패 또는 장애 시에도 메시지가 중복되거나 손실되지 않도록 하는데 도움이 됩니다.
  • 레디스는 주로 발행/구독 모델이나 간단한 큐 모델을 지원하며, 메시지를 안전하게 관리하는데 레빗엠큐만큼 강력한 지원을 제공하지 않을 수 있습니다.

메시지 안정성 측면에서 레빗엠큐가 낫다고 생각했다.
근데 면접에서 말할 때는 레빗엠큐와 카프카만 비교해서 말해도 괜찮지 않을까..?

왜 이 기술을 사용했나요?

다시 정리하면 서버 분리와 비동기 처리를 위해 사용하였습니다. 레빗엠큐와 카프카를 비교했을 때 레빗엠큐는 메시지를 받아서 처리하고 나면 삭제하고 카프카는 이벤트 데이터를 브로커에 저장합니다. 매칭 요청의 경우에는 한번 전송하고 서버에서 받으면 삭제해도 되는 데이터라고 생각해서 레빗엠큐가 적합하다고 생각했습니다.

3. 어떻게 구현했나요?

프론트에서 사용자 아이디, 학습 언어, 프리미엄 매칭 여부를 백엔드 서버에 넘긴 후 데이터베이스에서 매칭에 필요한 추가 정보(매너점수, 실력점수 등)를 담아 RabbitMQ에 보내면, 매칭서버가 가져가고 매칭을 진행합니다.

 
매칭에 성공하면 백엔드 서버에서 다시 받아 프리미엄 사용자일 경우 마일리지를 차감하고, 프론트에 상대방 정보를 반환합니다. 실패 할 경우에는 실패 메시지를 보냅니다.

프로젝트 세팅

의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-amqp'
    testImplementation 'org.springframework.amqp:spring-rabbit-test'
}

docker-compose.yml

version: '3.7'
services:
  rabbitmq:
    image: rabbitmq:latest
    ports:
      - "5672:5672" # rabbit amqp port
      - "15672:15672" # manage port
    environment:
      - RABBITMQ_DEFAULT_USER=admin       # 기본사용자 이름
      - RABBITMQ_DEFAULT_PASS=admin       # 기본사용자 비밀번호

application.yml

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: admin
    password: admin

글로벌 패키지

레빗엠큐 동작방식

  • Producer(생산자):
    • Producer는 메시지를 생성하고 레빗엠큐 서버로 전송하는 주체입니다.
    • 메시지는 큐(Queue)에게 직접 전송되지 않고, Exchange라는 중간 매개체를 통해 전달됩니다.
  • Exchange(교환):
    • Exchange는 Producer로부터 받은 메시지를 적절한 큐로 라우팅하는 역할을 합니다.
    • Exchange는 다양한 라우팅 규칙을 사용하여 메시지를 하나 이상의 큐에 전달할 수 있습니다.
    • Exchange에는 다양한 타입이 있으며, 주요한 타입으로는 direct, topic, fanout, headers 등이 있습니다.
  • Queue(큐):
    • Queue는 메시지를 저장하는 장소로, Consumer가 메시지를 소비하기 전까지 보관합니다.
    • 각 큐는 이름을 가지며, Exchange에 의해 라우팅된 메시지는 하나 이상의 큐에 저장될 수 있습니다.
  • Binding(바인딩):
    • Binding은 Exchange와 Queue 사이의 연결을 나타내며, 특정 Exchange와 특정 큐 간의 바인딩을 설정함으로써 메시지의 라우팅 규칙을 결정합니다.
    • Exchange와 Queue 간에 바인딩이 이루어지면, Exchange는 해당 바인딩에 따라 메시지를 큐로 전달합니다.
  • Consumer(소비자):
    • Consumer는 큐에서 메시지를 소비하고 처리하는 역할을 합니다.
    • 큐에 쌓인 메시지 중에서 하나를 가져와 처리한 후에는 해당 메시지가 큐에서 삭제되고, 다음 메시지를 소비할 수 있습니다.
  • Acknowledgment(확인):
    • Consumer는 메시지를 성공적으로 처리한 경우에 Acknowledgment를 보내어 레빗엠큐에게 해당 메시지가 처리되었음을 알립니다.
    • Acknowledgment를 통해 메시지의 손실을 방지하고, 안정적인 메시지 전달을 보장합니다.

RabbitMqConfig

@Configuration
public class RabbitMqConfig {

    @Bean
    public DirectExchange directExchange() {
        return new DirectExchange("matching.exchange");
    }

    @Bean
    public Queue queue() {
        return new Queue("matching.queue");
    }

    @Bean
    public Binding binding(DirectExchange directExchange, Queue queue) {
        return BindingBuilder.bind(queue).to(directExchange).with("matching.key");
    }

    @Bean
    public RabbitTemplate rabbitTemplate(
        ConnectionFactory connectionFactory,
        MessageConverter messageConverter
    ) {
        var rabbitTemplate = new RabbitTemplate(connectionFactory);
        rabbitTemplate.setMessageConverter(messageConverter);
        return rabbitTemplate;
    }

    @Bean
    public MessageConverter messageConverter(ObjectMapper objectMapper) {
        return new Jackson2JsonMessageConverter(objectMapper);
    }
}
  • 여러 빈(Bean)들을 생성하여 RabbitMQ에 대한 연결, 교환(Exchange), 큐(Queue), 바인딩(Binding) 등의 설정을 정의합니다.
  • RabbitTemplate은 RabbitMQ와 통신하기 위한 템플릿으로, 메시지를 보내거나 받는 데 사용됩니다.
  • MessageConverter는 메시지를 RabbitMQ에서 전송 가능한 형식으로 변환하거나 받은 메시지를 애플리케이션에서 사용 가능한 형식으로 변환하는 데 사용됩니다. 여기서는 JSON 형식의 메시지를 다루기 위해 Jackson2JsonMessageConverter를 사용하고 있습니다.

ObjectMapperConfig

@Configuration
public class ObjectMapperConfig {

    @Bean
    public ObjectMapper objectMapper() {
        var objectMapper = new ObjectMapper();

        objectMapper.registerModule(new Jdk8Module());

        objectMapper.registerModule(new JavaTimeModule());

        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);

        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        return objectMapper;
    }
}

Jackson 라이브러리를 사용하여 JSON 데이터를 처리하는 ObjectMapper를 설정하는 Spring Bean 구성 클래스인 ObjectMapperConfig입니다.

백엔드 서버

Producer

@RequiredArgsConstructor
@Component
public class Producer {

    private final RabbitTemplate rabbitTemplate;

    public void producer(String exchange, String routeKey, Object object) {
        rabbitTemplate.convertAndSend(exchange, routeKey, object);
    }
}

Producer 클래스는 간단하게 RabbitMQ에 메시지를 전송할 수 있도록 도와주는 컴포넌트로, 필요한 곳에서 주입하여 사용합니다.

MatchingUserProducer

@RequiredArgsConstructor
@Service
public class MatchingUserProducer {

    private final Producer producer;

    private static final String EXCHANGE = "matching.exchange";
    private static final String ROUTE_KEY = "matching.key";

    public void sendMatchingUser(MatchingUserDto matchingUserDto) {
        producer.producer(EXCHANGE, ROUTE_KEY, matchingUserDto);
    }
}

MatchingUserProducer 클래스는 Producer 클래스를 이용하여 RabbitMQ에 매칭 유저 정보 메시지를 전송하는 데에 사용됩니다.

MatchingRequestDto

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "매칭 요청 정보")
public class MatchingRequestDto {

    @NotNull
    @Schema(description = "사용자 ID", example = "1")
    private Long userId;

    @NotNull
    @Schema(description = "사용자 학습 언어 ID", example = "2")
    private Long studyLanguageId;

    @NotNull
    @Schema(description = "프리미엄 매칭 여부", example = "false")
    private boolean premium;
}

프론트에서 사용자 아이디, 학습 언어, 프리미엄 매칭 여부를 받아옵니다.

MatchingController

@Slf4j
@Tag(name = "Matching Controller", description = "매칭 관련 컨트롤러")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/matching")
public class MatchingController {

    private final MatchingUserProducer producer;
    private final MatchingService matchingService;

    @Operation(summary = "Matching", description = "매칭 대기열 등록")
    @PostMapping
    public ResponseResult matching(@RequestBody @Valid MatchingRequestDto matchingRequestDto) {
        log.info("matchingRequest : {}", matchingRequestDto.toString());
        MatchingUserDto matchingUser = matchingService.findMatchingUser(matchingRequestDto);
        producer.sendMatchingUser(matchingUser);
        return ResponseResult.successResponse;
    }

    @Operation(summary = "Matching Result", description = "매칭 성공 유저들에게 SessionId, StudyId 및 상대 정보 반환(웹소켓 이용)")
    @PostMapping("/result")
    public void matchingResult(@RequestBody @Valid MatchingResponseDto matchingResponseDto) throws
        OpenViduJavaClientException, OpenViduHttpException {
        // 프리미엄인 경우 마일리지 사용
        matchingService.updateMileage(matchingResponseDto);
        log.info("matchingResult : {}", matchingResponseDto.toString());

        // 매칭에 성공한 사용자들을 대상으로 스터디 생성 및 오픈비두 세션 생성
        // WebSocketHandler를 통해 매칭에 성공한 사용자들에게 동일한 sessionId를 보내기

        webSocketHandler.sendSessionIdAndMatchingDataToUsers(sessionId, studyId, matchingResponseDto, userNicknames);
    }

    @Operation(summary = "Matching Fail Result", description = "매칭 실패 유저들에게 실패를 알림")
    @PostMapping("/result-fail")
    public void matchingFailResult(@RequestBody List<MatchingUserDto> matchingFailUserList) {
        log.info("MatchingController_matchingFailResult -> 매칭 실패!!!");

        // WebSocketHandler를 통해 매칭에 실패한 사용자들에게 동일한 sessionId를 보내기
        List<String> userNicknames = new ArrayList<>();
        userNicknames.addAll(matchingFailUserList.stream()
            .map(MatchingUserDto::getUserNickname)
            .collect(Collectors.toList()));

        webSocketHandler.sendMatchingFailMessage(userNicknames);
    }

}
  • matching 메서드: matching데이터베이스에서 매칭에 필요한 추가 정보(매너점수, 실력점수 등)를 담아 RabbitMQ에 보냅니다.
  • matchingResult 메서드: 매칭에 성공한 사용자들에 대한 결과를 처리하는 메서드입니다.
  • matchingFailResult 메서드: 매칭에 실패한 사용자들에게 실패를 알리는 메서드입니다.

매칭서버

MatchingUserConsumer

@Component
@RequiredArgsConstructor
@Slf4j
public class MatchingUserConsumer {

    private final MatchingService matchingService;

    @RabbitListener(queues = "matching.queue")
    public void matchingUserConsumer(MatchingUserDto matchingRequestDto) {
        log.info("matchingUserConsumer: {}", matchingRequestDto);
        matchingService.add(matchingRequestDto);
    }
}

MatchingUserConsumer 클래스는 RabbitMQ 큐에서 메시지를 수신하여 해당 메시지를 처리하는 역할을 합니다.

MatchingService

@Slf4j
@Service
public class MatchingService {

    private static final int PREMIUM_GRADE_SCORE = 4;
    private static final int PREMIUM_USER_RATING = 4;
    private static final String URL = "http://localhost:8081/api/matching/result";
    private static final String FAIL_URL = "http://localhost:8081/api/matching/result-fail";

    private Queue<MatchingUserDto> matchingList = new LinkedList<>();
    private Queue<MatchingUserDto> matchingFailList = new LinkedList<>();
    private final Object lock = new Object();

    /**
     * 매칭 조건
     * 1. 사용자 언어
     * 
     * 사용자 마일리지 사용여부
     * 2. 사용자 실력
     * 3. 사용자 별점
     */
    @Scheduled(fixedDelay = 10000, initialDelay = 1000) // 1초 후 10초마다 동작
    public void matching() {
        log.info("MatchingService matching size: {}", matchingList.size());
        synchronized (lock) {
            while (matchingList.size() >= 1) {
                MatchingUserDto matchingUser1 = matchingList.poll();

                // 매칭 가능 유저 필터링
                Optional<MatchingUserDto> matchingUserDto = matchingList.stream()
                        .filter(waitingUser -> isPossibleUser(matchingUser1, waitingUser))
                        .findFirst();

                // 매칭 실패 대기열 재진입
                if (!matchingUserDto.isPresent()) {
                    log.info("MatchingService matching fail");
                    matchingFailList.add(matchingUser1);
                    continue;
                }

                // 매칭 결과 반환
                MatchingUserDto matchingUser2 = matchingUserDto.get();
                matchingList.remove(matchingUser2);
                sendMatching(new MatchingResponseDto(matchingUser1, matchingUser2));
            }

            //매칭 실패한 유저 실패COUNT +1
            matchingFailList.stream().forEach(m -> m.addMatchingCount());

            //매칭 실패 COUNT가 3회 이상인 유저들에게 실패 메시지 전송
            List<MatchingUserDto> finalFailUserList = matchingFailList.stream()
                    .filter(m -> m.getMatchingFailCount() >= 3)
                    .collect(Collectors.toList());

            if (finalFailUserList.size() > 0)
                sendFinalFailMatching(finalFailUserList);

            matchingList.addAll(matchingFailList.stream()
                    .filter(m -> m.getMatchingFailCount() < 3)
                    .collect(Collectors.toList()));
            matchingFailList = new LinkedList<>();
        }
    }

    public boolean isPossibleUser(MatchingUserDto matchingUser, MatchingUserDto waitingUser) {
        log.info("MatchingService_isPossibleUser matchingUser: {}, waitingUser: {}", matchingUser, waitingUser);
        // 언어 필터링
        if (!matchingUser.isMatchLanguage(waitingUser)) {
            return false;
        }
        // 둘 중 한명이 프리미엄이면 상대방도 프리미엄이어야 한다.
        if (waitingUser.isPremium()) {
            return matchingUser.isPossiblePremiumUser(PREMIUM_GRADE_SCORE, PREMIUM_USER_RATING);
        }
        if (matchingUser.isPremium()) {
            return waitingUser.isPossiblePremiumUser(PREMIUM_GRADE_SCORE, PREMIUM_USER_RATING);
        }
        // 조건없이 매칭
        return true;
    }

    public void sendMatching(MatchingResponseDto matchingResponseDto) {
        log.info("MatchingService_sendMatching matching success : " + matchingResponseDto.toString());
        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<?> requestMessage = new HttpEntity<>(matchingResponseDto, httpHeaders);
        restTemplate.postForEntity(URL, requestMessage, MatchingResponseDto.class);
    }

    public void sendFinalFailMatching(List<MatchingUserDto> matchingUserFailList) {
        log.info("MatchingService_sendMatching matching fail ");
        log.info(matchingUserFailList.toString());

        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<?> requestMessage = new HttpEntity<>(matchingUserFailList, httpHeaders);
        restTemplate.postForEntity(FAIL_URL, requestMessage, List.class);
    }

    public void add(MatchingUserDto matchingUserDto) {
        matchingList.add(matchingUserDto);
    }

}
  • MatchingService 클래스는 매칭 관련 비즈니스 로직을 처리하며, 주기적으로 실행되는 matching 메서드를 통해 매칭 작업을 수행합니다. 또한, 매칭 결과나 실패에 대한 정보를 외부 API로 전송하고, 적절한 조건에 따라 처리하는 역할을 합니다.
  • synchronized는 멀티스레드 환경에서 matchingListmatchingFailList에 대한 안전한 동시 접근을 보장하기 위해 사용되고 있습니다. 이 구조는 여러 스레드가 동시에 해당 블록에 접근하는 것을 막아서 동시성 문제를 방지하고자 하는 목적이 있습니다.

문제해결

매칭이 되어서는 안 되는 유저들이 매칭되는 문제가 발생했다. 알고리즘에 오류가 있었고 성공 케이스와 실패 케이스를 작성하여 테스트해보았다.

class MatchingServiceTest {

    MatchingService matchingService = new MatchingService();

    @Test
    @DisplayName("배우고 싶은 언어가 상대방의 모국어가 아니면 매칭이 되지 않는다.")
    public void isPossibleUser_differentLanguage() {
        MatchingUserDto matchingUser = MatchingUserDto.builder()
                .premium(false)
                .userStudyLanguage("한국어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("영어").build())
                .userStudyLanguageGradeScore(3)
                .userRating(3.5)
                .build();

        MatchingUserDto waitingUser = MatchingUserDto.builder()
                .premium(false)
                .userStudyLanguage("한국어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("영어").build())
                .userStudyLanguageGradeScore(2)
                .userRating(4.0)
                .build();

        assertFalse(matchingService.isPossibleUser(matchingUser, waitingUser));
    }

    @Test
    @DisplayName("모두 프리미엄이 아닌 경우 매칭 성공한다.")
    public void isPossibleUser_bothNonPremium() {
        MatchingUserDto matchingUser = MatchingUserDto.builder()
                .premium(false)
                .userStudyLanguage("한국어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("영어").build())
                .userStudyLanguageGradeScore(3)
                .userRating(3.5)
                .build();

        MatchingUserDto waitingUser = MatchingUserDto.builder()
                .premium(false)
                .userStudyLanguage("영어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("한국어").build())
                .userStudyLanguageGradeScore(2)
                .userRating(4.0)
                .build();

        assertTrue(matchingService.isPossibleUser(matchingUser, waitingUser));
    }

    @DisplayName("현재 매칭 유저만 프리미엄일 경우 상대만 프리미엄 기준을 통과하면 매칭된다.")
    @Test
    public void isPossibleUser_onePremium() {
        MatchingUserDto matchingUser = MatchingUserDto.builder()
                .premium(true)
                .userStudyLanguage("한국어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("영어").build())
                .userStudyLanguageGradeScore(3)
                .userRating(3.3)
                .build();

        MatchingUserDto waitingUser = MatchingUserDto.builder()
                .premium(false)
                .userStudyLanguage("영어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("한국어").build())
                .userStudyLanguageGradeScore(4)
                .userRating(4.8)
                .build();

        assertTrue(matchingService.isPossibleUser(matchingUser, waitingUser));
    }

    @DisplayName("둘 중 한명이 프리미엄일 때 상대방이 프리미엄 기준을 통과하지 않으면 매칭 되지 않는다.")
    @Test
    public void isPossibleUser_onePremium2() {
        MatchingUserDto matchingUser = MatchingUserDto.builder()
                .premium(true)
                .userStudyLanguage("한국어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("영어").build())
                .userStudyLanguageGradeScore(4)
                .userRating(4.5)
                .build();

        MatchingUserDto waitingUser = MatchingUserDto.builder()
                .premium(false)
                .userStudyLanguage("영어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("한국어").build())
                .userStudyLanguageGradeScore(2)
                .userRating(3.8)
                .build();

        assertFalse(matchingService.isPossibleUser(matchingUser, waitingUser));
        assertFalse(matchingService.isPossibleUser(waitingUser, matchingUser));
    }

    @DisplayName("둘다 프리미엄일 때 매칭 된다.")
    @Test
    public void testIsPossibleUser_BothPremium() {
        MatchingUserDto matchingUser = MatchingUserDto.builder()
                .premium(true)
                .userStudyLanguage("한국어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("영어").build())
                .userStudyLanguageGradeScore(4)
                .userRating(4.5)
                .build();

        MatchingUserDto waitingUser = MatchingUserDto.builder()
                .premium(true)
                .userStudyLanguage("영어")
                .userNativeLanguage(LanguageDto.builder().languageKorName("한국어").build())
                .userStudyLanguageGradeScore(4)
                .userRating(4.0)
                .build();

        assertTrue(matchingService.isPossibleUser(matchingUser, waitingUser));
    }
}

일반 유저가 큐에 먼저 들어간 경우 조건 없이 매칭하다보니 프리미엄 유저를 매칭시키는 상황이었다.(그냥 내가 멍청했다) 단위 테스트를 작성하여 빠르게 버그를 찾아낼 수 있었다.

4. 더 알아보기

gpt에게 관련 기술에 대한 면접 질문과 답을 작성해달라고 하면 되게 잘 써준다. 몰랐던 내용, 보완해야할 부분도 알 수 있고, 면접 준비할 때 좋을 것 같다.
 
Q: RabbitMQ를 사용한 경험이 있나요?
A: "네, 이전 프로젝트에서 RabbitMQ를 사용한 경험이 있습니다. RabbitMQ는 메시지 큐 시스템으로서 비동기 메시지 전송과 이벤트 기반 아키텍처를 구현하는 데에 사용되었습니다."
 
Q: RabbitMQ의 주요 기능은 무엇이며, 어떤 상황에서 사용할 수 있을까요?
A: "RabbitMQ는 메시지 브로커로서 메시지 큐, 토픽 교환, 팬아웃 교환 등 다양한 기능을 제공합니다. 이를 통해 애플리케이션 간의 효율적인 통신 및 메시지 큐를 통한 비동기 처리를 구현할 수 있습니다. RabbitMQ는 분산 시스템에서 이벤트 드리븐 아키텍처를 구현하는 데 적합하며, 서비스 간 통신 및 탄력적인 마이크로서비스 아키텍처에서 효과적으로 사용됩니다."
 
Q: RabbitMQ를 사용하여 어떻게 메시지를 보내고 받는지 설명해주세요.
A: "RabbitMQ에서는 Producer가 메시지를 생성하여 Exchange에 전달하고, Exchange는 메시지를 하나 이상의 Queue에 라우팅합니다. 그리고 Consumer는 해당 Queue에서 메시지를 소비합니다. 이 프로세스는 메시지 브로커를 통해 중앙 집중적으로 이루어지며, 강력한 라우팅 및 메시지 패턴을 제공합니다."
 
Q: RabbitMQ의 Exchange의 역할은 무엇인가요?
A: "Exchange는 Producer에서 전송된 메시지를 어느 Queue로 라우팅할지를 결정하는 역할을 합니다. 다양한 Exchange 타입이 있으며, 각각의 타입에 따라 라우팅 알고리즘이 결정됩니다. Direct Exchange, Topic Exchange, Fanout Exchange 등이 주로 사용되며, 메시지의 특성에 따라 적절한 Exchange를 선택하여 사용합니다."
 
Q: RabbitMQ에서 메시지의 지속성을 어떻게 보장할 수 있나요?
A: "RabbitMQ에서 메시지의 지속성을 보장하기 위해 Producer가 메시지를 전송할 때 Delivery Mode를 2로 설정하여 지속적인 메시지로 표시합니다. 이 설정은 메시지가 디스크에 저장되어 시스템 재시작 시에도 유지되도록 합니다."
 
Q: RabbitMQ에서 Dead Letter Exchange (DLX)는 무엇이며, 어떤 상황에서 사용할 수 있을까요?
A: "Dead Letter Exchange (DLX)는 메시지가 소비자에 의해 처리되지 않아 다시 큐로 리턴되거나, TTL이 만료되어 처리되지 않은 경우 해당 메시지를 보관하는 교환소입니다. DLX를 사용하면 실패한 메시지를 따로 처리하거나 분석할 수 있으며, 재시도 또는 오류 처리에 유용합니다."
 
Q: RabbitMQ에서의 클러스터링이란 무엇이며, 왜 필요한가요?
A: "RabbitMQ 클러스터는 여러 노드 간에 메시지, 큐, 교환 등을 공유하고 분산 환경에서 안정성과 가용성을 제공합니다. 클러스터링은 단일 노드 장애에 대비하여 고가용성을 확보하고, 트래픽 분산 및 성능 향상을 위해 사용됩니다."
 
Q: RabbitMQ에서의 Acknowledgment는 어떻게 이루어지나요?
A: "RabbitMQ에서 Acknowledgment는 Consumer가 메시지를 성공적으로 처리했음을 나타냅니다. 메시지를 수신한 Consumer가 명시적으로 Acknowledgment를 보내지 않으면 RabbitMQ는 해당 메시지를 다시 큐로 전송하거나 Dead Letter Exchange로 보낼 수 있습니다."