[F-Lab 모각코 챌린지 47일차] 메세지 큐

부추·2023년 7월 17일
0

F-Lab 모각코 챌린지

목록 보기
47/66

# BottleNeck

웹 어플리케이션은 사실 웬만해선 IO Bound 어플리케이션이다. 어플리케이션 프로그램의 병목이 주로 IO 작업에서 생긴다는 뜻이다.

서버 어플리케이션의 포트를 열어 사용자의 HTTP 메세지를 받는 것도 IO, 사용자가 요청한 데이터에 대해 DB에 쿼리를 날리고 결과를 받아오는 것도 IO 작업이다. 엄청나게 빨리 서비스 계산 로직을 수행하는 서버가 있어도 로직 수행을 위한 데이터가 들어오지 않으면 말짱 도루묵이다.. ㅜㅜ

전날까지 구축한 서버의 상황은 위와 같다. 웹 서버로 엔진엑스를 두고, 그 뒤에 postgresql DB와 통신하는 톰캣 서버 인스턴스를 3개 띄웠다.

CPU bound 인스턴스를 띄웠을 때 간단히 설명했지만, 서버 인스턴스를 3개 띄운 이유는 사용자 요청을 받는 스레드 수를 늘려 실패하는 사용자 요청을 최소화하고 빠른 응답을 돌려주기 위함이다. 톰캣의 기본 스레드 수는 200개, 대기 큐 길이는 100이다. 200개 이상의 요청이 몰려들어 톰캣 서버가 더이상 동작하지 못하는 상황에서 요청이 더 들어오면 요청이 대기 큐에 쌓인다. 그마저도 꽉차면 나머지 요청은 버려지게된다. 사용자 입장에서 500, 혹은 502 에러를 만나게 되는 것이다. 이를 극복하기 위해 서버를 3개로 늘리는 scale-out을 진행했다.

엔진엑스는 자신의 80번 포트로 들어오는 사용자 요청을 적절히 분산하여 3개의 서버에 보낸다. 그러나 3개의 서버가 모이는 곳은 DB 한 곳이기 때문에, 각 서버는 postgresql DB 인스턴스 하나로 쿼리를 보낸다. 결국 분산된 서버의 쿼리를 DB가 다시 모아 받는다는 것이고.. 이는 DB 서버가 서비스의 병목이 될 확률이 굉장히 높다는 것을 뜻한다. 앞서 대부분의 웹 어플리케이션은 IO Bound라고 했다.... 맞다. 어플리케이션의 전체 성능은 DB가 좌우하는 경우가 많다(절대적인거 XXXXXXXX). scale out을 통해 서버 입장에선 수많은 사용자들 요청을 받을 수 있게 되었지만, 서버 안쪽의 DB에서 그만한 요청을 받아들일 수 없게 될 수도 있다.

그러면 다시 원점이다. synchronous / blocking IO를 사용한다는 가정 하에, DB의 쿼리 결과가 도착할 때까지 스레드는 대기 상태가 된다. 사용자 요청이 많이 몰려 쿼리도 많이 쌓이면, 이런식으로 모든 스레드가 대기 상태로 들어가고 다시 500에러가 뜨는 상황이 생길 수도 있다. 이를 막기 위해, "메세지 큐"를 둘 수 있다.



# 메세지 큐란?

메세지 큐는 병목이 생길 수 있는 지점 앞에 요청을 큐 형태로 쌓고, 그를 비동기적으로 처리할 수 있도록 해준다.

카페를 생각해보자. 손님들은 주문을 한다. 점원은 계산을 하고 "음료 준비되면 불러드리겠다" 말하고 다음 손님의 주문을 받는다. 만약 앞 손님의 음료가 다 제작되고 나서 다음 손님의 주문을 받으면 어떻게 될까? 카페 회전률은 극악이 될테고 손님들의 불만도 이만저만이 아닐 것이다. 이것이 메세지 큐가 없는 상황이다. 음료는 어떻게 말하면, 어플리케이션에서 바로 return할 필요가 없는 부분에 해당한다. 서버는 바로 return할 필요가 없는 무거운 작업은 뒤로 미뤄놓고(=메세지 큐에 쌓아놓고) 바로 다음 사용자의 요청을 처리한다. 스레드 입장에선 사용자 요청에 대한 내부 작업이 끝날 때까지 해당 요청을 hold할 필요 없이, 요청 정보를 메세지 큐에 쌓은 뒤 성공 정보를 간단히 응답으로 내려주면 된다. 사용자는 빠른 응답을 받게 되고, 스레드는 쓸데없이 대기시간을 늘릴 필요도 없다.

메세지 큐의 장점을 조금 더 체계적으로 정리해보겠다!

1) 요청을 비동기적으로 처리할 수 있다.

게시판 사이트에서 사용자가 글을 작성하는 작업을 한다고 가정해보자. 메세지 큐를 사용하지 않으면, 서버는 사용자의 글이 body로 담겨있는 메세지를 받고, 해당 내용에 대해 DB Insert 쿼리를 날리고, 성공 응답을 받고, 다시 사용자에게 응답을 내려줄 것이다. DB에 부하가 걸려있어 DB 작업이 느리다면 post 요청 하나에 사용자는 앞선 과정이 이뤄질 동안 필요 이상으로 기다려야 할 수 있다. 그 시간동안 서버 스레드 역시 아무것도 하지 못하고 대기해야하니 자원의 낭비도 있다.

이 때 메세지 큐를 이용하면, 서버는 사용자의 글 내용을 DB에 넣는 작업을 메세지 큐에 던져놓고 바로 사용자 응답을 줄 수 있다. 마치 음료 주문을 마친 뒤 다음 손님~을 외치는 점원처럼 말이다.

사실 비동기가 정답은 아니다. 동기적으로 동작해야 하는 상황은 분명 있기 때문이다. 그리고 모든 상황에서 비동기가 100% 더 빠르다고 말할 수도 없다. 그러나 "비동기 처리가 가능하다"라는건 메세지 큐를 사용함으로써 얻을 수 있는 특징이고, 이는 많은 상황에서 장점으로 작용한다.

2) 어플리케이션 간 의존성 제거

문제가 있어 DB 서버가 멈추는 상황을 생각해보자. 단순히 DB 서버의 문제일 수도 있고, 데이터 migration이 일어나는 상황일 수도 있다. 이 때 앞단의 웹 어플리케이션 서버가 DB의 기능이 있어야 동작할 수 있는 "강한 의존성"을 갖고 있다면, DB 문제로 인해 웹 어플리케이션까지 문제가 생기는 상황이 발생할 수도 있다.

그러나? 중간에 MQ를 둔다면? DB가 죽어도 메세지 큐에 요청은 안전하게 쌓이고 있으므로 나중에 서버를 복구한 뒤 큐에 쌓인 내용들을 처리하면 된다.

3) 신뢰성

2번에서 이어지는 내용이다. 요청이 폭주하거나 서버의 문제로 어플리케이션이 죽어도, 메세지 큐에 쌓인 메세지들은 유실되지 않는다. (물론 이것도 모~~든 상황에서 100% 보장되는 것은 아니다^^;; 세상일에 100%가 어딨어!)

4) 확장성

어플리케이션의 노드들이 P2P(Point to Point)로 연결되어 있다면, 새로운 노드가 추가될 때마다 모든 노드들을 연결해줘야한다. 이는 프로그램 아키텍처 구조가 거미줄처럼 엮이게 되는 결과를 낳고.. 복잡해진 아키텍처는 유연한 확장성을 잃는다.

하지만! 노드들간 데이터 교환의 허브로써 MQ를 이용하면 노드간 데이터 교환의 앞선 문제를 완화할 수 있다.



1. RabbitMQ 인스턴스 띄우기

전날과 전전날 했던 GCP 인스턴스 띄우기-에 따라 ec2-micro 서버 인스턴스를 하나 만들고, 링크를 참조하여 rabbitMQ 프로세스를 돌리는 서버 인스턴스 하나를 띄웠다. 사실 강의에선 docker를 사용했는데 CentOS7에서는 또!!!! 안돌아가더라 ㅜㅜ 왜 맘대로 되는게 하나도 없니 센트오에스칠아. 결국 직접 erlang까지 다운받았다.

그리고 과거엔 guest:guest 계정이 관리자의 기본 ID:PASSWORD로써 존재했는데, 최신 버전에서는 localhost가 아니면 해당 계정을 사용할 수 없다. 그러니 서버에 rabbitmq를 다운받을 때 꼭! 관리자 계정을 추가하도록 하자.

실제 rabbitMQ 프로세스가 구동중인 5672번 포트, 그리고 rabbitMQ GUI가 구동중인 15672번 포트의 방화벽을 뚫어놓고 {rabbitMQ 인스턴스 IP}:15672에 접속하겠다!
그리고 Queues 탭에 들어가 위 사진에 보이는 Add queue버튼을 누르고 CREATE_POST_QUEUE라는 Classic 타입의 큐를 하나 만들자. (사진을 보면 알겠지만 나는 이미 만들어놓은 뒤다)


2. Producer / Consumer

초간단하게 메세지 큐의 구조를 살펴보면 생산자 / 소비자 구조로 되어있다. 아래 사진 예시는 앞선 예시와 같이, DB를 사용하는 상황에 DB 요청을 쌓는 웹 서버와 쌓인 요청을 DB에 전달하는 웹 서버가 있는 상황을 표현한 것이다.
현재 서버 인스턴스를 3개나 띄운 상황에서 consumer 인스턴스를 따로 띄우긴 귀찮고.. 그냥 서버 안에서 MQ에 요청을 쌓는 과정 / 요청을 consume하는 과정을 같이 작성해보자. 글을 작성하려는 사용자 post 요청을 받도록 하자!

가장 먼저 RabbitMQ 의존성을 pom.xml, 혹은 build.gradle에 추가해야한다. 그리고 application.yml에 아까 띄운 RabbitMQ 인스턴스의 설정 정보를 추가해줘야 한다.

spring:
  rabbitmq:
    host: {RabbitMQ 서버 IP}
    username: {관리자ID}
    password: {관리자PW}
    port: 5672

이제 MQ에 요청을 쌓고, 그를 consume하는 Producer-Consumer를 만들자.

먼저 Producer의 코드이다.

@Component
public class Producer {
    @Autowired
    private RabbitMessagingTemplate rabbitMessagingTemplate;

    public void sendTo(String message) {
        rabbitMessagingTemplate.convertAndSend(
        	"CREATE_POST_QUEUE", message);
    }
}

RabbitMessagingTemplate빈을 의존성으로 주입받았다. 처음 rabbitMQ 인스턴스를 띄웠을 때, "CREATE_POST_QUEUE"이름을 가진 큐를 만들었던 것을 기억하자! 그 이름을 가진 큐에 message문자열을 집어넣을 것이다.

다음은 Consumer의 코드이다.

@Component
public class Consumer {
    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private PostRepository postRepository;

    @RabbitListener(queues = "CREATE_POST_QUEUE") // 이 큐를 consume 하겠다.
    public void handler(String message) throws JsonProcessingException {
        Post post = objectMapper.readValue(message,Post.class);
        postRepository.save(post);
    }
}

@RabbitListener 어노테이션을 통해, "CREATE_POST_QUEUE" 큐에 메세지가 쌓일 때 handler()메소드를 사용할 것임을 명시했다. 인자의 message는 예상할 수 있듯, ProducersendTo()에서 큐에 넣은 문자열과 일치한다.

자바 오브젝트를 문자열로 매핑해주는 ObjectMapper를 이용해 message를 DB에 저장했다.

그럼 Producer를 호출하는, 그러니까 큐에 쌓을 데이터를 만들어내는 컨트롤러 코드를 살펴보자.

@PostMapping("/post")
public Post createPost(@RequestBody Post post) throws JsonProcessingException {
    producer.sendTo(objectMapper.writeValueAsString(post));
    return post;
}

@RequestBody에 사용자의 데이터 요청이 들어온다. 이를 문자열로 변환에 Producer에 넘겨준다. 사용자에게 반환하는 값은 기존의 post와 동일하다.

위 코드의 이전 코드는, 컨트롤러에서 직접 postRepository.save()를 호출해 DB 트랜잭션을 수행한 뒤 사용자에게 저장된 포스트의 id를 포함해 응답을 내려주는 코드였다. 직관적으로 생각해봐도 MQ를 사용하는 것이 사용자 입장에서 훨씬 빠른 응답을 받을 것이 예상이 간다.


3. 부하 테스트를 통해 MQ 구경하기

artillery 스크립트를 작성했다.

config:
  target: "http://{엔진엑스IP}"
  phases:
    - duration: 60
      arrivalRate: 8
      name: warm up

    - duration: 120
      arrivalRate: 8
      rampTo: 160
      name: ramp up!

    - duration: 200
      arrivalRate: 80
      name: sustained loaded

  payload:
    skipHeader: true
    path: "ratings_test_50k.csv"
    fields:
      - "content"

scenarios:
  - name: post test
    flow:
      - post:
          url: "/post"
          json:
            content: "{{ content }}"

처음 60초동안은 초당 4개의 요청을, 그 뒤 120초동안은 초당 4개의 요청에서 시작해 60개의 요청을, 그리고 마지막 200초 동안은 초당 60개의 요청을 보낼 수 있도록 구성했다.

기도를 하며.. RabbitMQ GUI서버에 들어가서 실제로 메세지큐에 메세지가 쌓이고, 이를 consume하는 과정을 구경하자.
믿힌.. 개신기해..

profile
부추튀김인지 부추전일지 모를 정도로 빠싹한 부추전을 먹을래

2개의 댓글

comment-user-thumbnail
2023년 7월 18일

글이 많은 도움이 되었습니다, 감사합니다.

답글 달기
comment-user-thumbnail
2023년 7월 18일

좋은 글 감사합니다!

답글 달기