[Artillery] How To Make Load Test for WebSocket + JWT

YUSHIN KIM·2024년 11월 14일
1

TroubleShooting

목록 보기
3/3

How To Make Load Test for WebSocket + JWT

이번에 개발하고 있는 서비스에서 웹 소켓을 사용한 채팅 기능을 구현하게 되어 이것저것 알아보면서 하다가 아래와 같은 아키텍처를 구현하는 데까지 이르렀다.

Architecture

그림처럼 Redis의 Pub/Sub Broker를 활용하여 Scalable한 아키텍처를 구현했다. 처음엔 이 Redis의 Pub/Sub Broker를 사용하기 위해 RedisRepository도 따로 정의하고 RedisService도 따로 정의하면서 아주 밑바닥부터 쌩으로 구현했기에 매우 힘이 들었다. 그러나 중간에 Redis Adapter라는 좋은 기술을 알게 되어 로직을 아주 간단하게 리팩터링할 수 있었다.

이런 식으로 채팅 애플리케이션의 시스템을 설계하는 의도는 당연히도 서버를 유연하게 Scale-out하기 위함일 것이다. 그런데 앞서 말한 것처럼 이러한 아키텍처가 Scalable한지 아닌지는 어떻게 검증할 수 있을까?

Load Test with Artillery

어떤 가설을 기각할지 아니면 받아들일지 결정하기 위해선 당연히 실험을 해보아야 한다. 실시간 채팅 서비스를 설계함에 있어 구조적인 Scalability는 매우 중요하다. 이럴 때 시행하는 것이 바로 서버 리소스의 한계를 검사하는 부하 테스트(Load Test)이다.

npm에는 Artillery라고 하는 아주 우수한 부하 테스트 도구가 존재한다. 공식 문서도 매우 깔끔하게 잘 써져 있다. 이제까지 본 공식 문서 중 손에 꼽을 정도로 깔끔하고 특이한 상황까지 잘 커버하고 있다. 하지만 오늘은 이 공식 문서만으로 도저히 커버할 수 없는 부분이 있어 글을 작성하게 되었다.

Introduction to Service Design

서비스에서 클라이언트가 WebSocket에 연결할 때 handshake 과정에서 액세스 토큰을 함께 전달하도록 설계했다. 그렇게 하지 않으면 서버의 주소만 알고도 WebSocket을 사용해 연결할 수 있게 되는데, 이는 특정이 불가능한 인물이 서버에 매우 큰 부하를 주도록 만들 수 있기에 보안상으로 매우 좋지 않다고 생각했다. 따라서 클라이언트는 Socket.io를 사용해서 연결할 때 다음과 같이 액세스 토큰을 전달해야 한다.

socket = io("http://localhost:8080/chat", {
  withCredentials: true,
  auth: {
    token: accessToken,
  },
});

이렇게 전달받은 액세스 토큰을 사용하면 사용자 검증이 간편해지기 때문에 연결 후부터는 이벤트를 발생시킬 때 사용자가 자신의 정보를 명시적으로 전달할 필요가 없어진다. 즉, io.emit("chat", { memberId, chatId, content }) 이런 식으로 자신의 정보를 전달하지 않아도 되는 것이다.

그렇게 웹 소켓 이벤트들을 설계했고, 아래와 같이 채팅 등록 시 AI 마이크로서비스를 통해 비속어 판별까지 수행하는 일련의 채팅 필터링 파이프라인까지 구성할 수 있었다.

Chat filtering pipeline

개인적으로 꼼꼼하게 설계했다고 생각했는데 이로 인해 부하 테스트에서 큰 문제가 발생하게 되었다.

Problem: How to bind access token?

Artillery 공식 문서에 따르면 socket.io 연결은 다음과 같이 수행할 수 있다.

config:
  target: "http://localhost:8080"
  phases:
    - duration: 10
      arrivalRate: 1
  socketio:
    auth:
      token: "[액세스 토큰 값]"

...

scenarios:
  - name: "WebSocket Test"
    engine: socketio
    flow:
      - log: "Start WebSocket Load Test"
      
...

그런데 여기서 연결 시 액세스 토큰을 바인딩하는 과정에서 너무 심각한 문제가 발생했다. Artillery로 socket.io 연결을 수행할 때는 파라미터를 동적으로 바인딩할 수 없다는 것이다. engine: socketio를 명시한 후 flow block을 실행하면 그 즉시 웹 소켓 연결을 수행하기 때문에 beforeScenario를 활용해 훅을 실행해도 액세스 토큰이 저장이 안 된다.

내가 원래 생각했던 테스트 시나리오는 다음과 같은 순서였다.

  1. 임의의 이메일과 비밀번호로 회원 가입한다.
  2. 로그인하여 액세스 토큰을 얻는다.
  3. 새로운 채팅방을 생성한다. 이때 사용자는 자동으로 채팅방에 참여한 상태가 되어 웹 소켓으로 연결할 수 있게 된다(그렇게 비즈니스 로직을 구성해 놓았다).
  4. 로그인과 함께 발급된 액세스 토큰으로 웹 소켓 연결을 수행하고, 생성된 채팅방에 서비스에서 설계한 채팅방의 정원인 20명만큼의 연결을 만든 후 채팅을 주기적으로 보낸다.
  5. 회원 탈퇴한다. CASCADE 효과로 채팅방, 채팅이 모두 삭제된다.

이렇게만 돼도 정말 깔끔했을 텐데 문제는 웹 소켓 연결 시 액세스 토큰을 동적으로 바인딩할 수 없어 시나리오 시작 시부터 이미 내 손에 액세스 토큰이 쥐어져 있는 상태여야만 했다.

Solution

그래서 13일 저녁부터 고민하다가 15일 새벽 2시경에 드디어 적용 가능한 해법을 찾았다. 어쩌면 이는 개발할 때의 태도와도 깊은 관련이 있는 거 같은데.. 핵심은 자동화에 집착하지 말자는 것이었다. 액세스 토큰의 동적 바인딩이 안 되면 정적으로 바인딩하면 되는 것 아닌가? 하지만 시간의 여유가 많지 않음에도 나는 자꾸만 돌아가려 하지 않고 동적 바인딩이 혹시라도 되는 경우가 있지 않을까를 한참을 찾았다. 왜냐면 그게 가능하다면 앞서 나열한 매우 깔끔한 시나리오로 테스트를 수행할 수 있기 때문이다.

config:
  target: "http://localhost:8080"
  phases:
    - duration: 60
      arrivalCount: 20
  variables:
    host: "http://localhost:8080"
    accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsIm5pY2tuYW1lIjoic3RyaW5nIiwicHJvZmlsZSI6bnVsbCwicHJlZmVyVGVhbSI6IkxHIiwiaWF0IjoxNzMxOTA5ODUyLCJleHAiOjE3MzI1MTQ2NTJ9.xke55ec1Za9eVUelABFlnvIq8zBPlwDdvLqdXXjd_k4"
  socketio:
    auth:
      token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsIm5pY2tuYW1lIjoic3RyaW5nIiwicHJvZmlsZSI6bnVsbCwicHJlZmVyVGVhbSI6IkxHIiwiaWF0IjoxNzMxOTA5ODUyLCJleHAiOjE3MzI1MTQ2NTJ9.xke55ec1Za9eVUelABFlnvIq8zBPlwDdvLqdXXjd_k4"

scenarios:
  - name: "WebSocket Test"
    engine: socketio
    flow:
      - log: "Start WebSocket Load Test"

      # 채팅방 생성
      - post:
          url: "/v1/chatrooms"
          headers:
            Authorization: "Bearer {{ accessToken }}"
          json:
            gameId: 1
            title: "Test chatroom"
            preferTeam: "LG"
          capture:
            json: "$.id"
            as: "chatroomId"

      # pause
      - think: 1

      # 채팅방 연결
      - loop:
          - namespace: "/chat"
            emit:
              channel: "joinRoom"
              data: "{{ chatroomId }}"
          - think: 1
        count: 20

      # pause
      - think: 1

      # 채팅 전송
      - loop:
          - namespace: "/chat"
            emit:
              channel: "chat"
              data:
                chatroomId: "{{ chatroomId }}"
                content: "Chat Test Content"
          - think: "{{$randomNumber(1, 3)}}"
        count: 60

      # pause
      - think: 1

      # 채팅방 연결 해제
      - namespace: "/chat"
        emit:
          channel: "leaveRoom"
          data: "{{ chatroomId }}"

      # pause
      - think: 1

      # 채팅방 삭제
      - delete:
          url: "{{ host }}/v1/chatrooms/{{ chatroomId }}"
          headers:
            Authorization: "Bearer {{ accessToken }}"
  1. 미리 회원 가입 및 로그인을 수행한 후 액세스 토큰을 발급받아 테스트 파일에 정적 바인딩한다.
  2. 채팅방을 생성한다.
  3. 채팅방에 연결한다.
  4. 채팅을 보낸다.
  5. 채팅방을 삭제한다.

서버에 부하를 주는 루틴은 3, 4의 과정이고 나머지는 부차적이다.

핵심은 회원 가입과 액세스 토큰 발급을 직접 진행한 뒤 정적으로 바인딩하고, 사용자 한 명으로 가짜 트래픽을 만드는 것이다. 어차피 서버는 테스트 환경에서 같은 사용자가 여러 번 요청을 보내는 것과 서로 다른 사용자가 여러 번 요청을 보내는 것을 구분할 수 없다.

서비스에서는 채팅방 하나당 20명까지 접속 가능하게끔 설계해놨으므로 3번의 채팅방 연결을 20회 수행하고, 나머지 시간 동안 채팅을 계속 보내게 하면 된다. 도달하는 요청 하나당 20명의 연결을 모방한 하나의 클러스터를 형성하고, 채팅을 반복해서 보내는 로직을 구현하여 다음과 같이 최종적으로 테스트 코드를 작성했다.

config:
  target: "http://localhost:8080"
  phases:
    - duration: 120
      arrivalRate: 50
  variables:
    host: "http://localhost:8080"
    accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjksIm5pY2tuYW1lIjoi7Jyg7IugIO2FjOyKpO2KuCAyIiwicHJvZmlsZSI6bnVsbCwicHJlZmVyVGVhbSI6IkxHIiwiaWF0IjoxNzMxNjAyMDM3LCJleHAiOjE3MzIyMDY4Mzd9.FZMYhdp4ZrznDF5fTnmhnq_zYdQYcAlnIwhPRpJJVS4"
  socketio:
    auth:
      token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjksIm5pY2tuYW1lIjoi7Jyg7IugIO2FjOyKpO2KuCAyIiwicHJvZmlsZSI6bnVsbCwicHJlZmVyVGVhbSI6IkxHIiwiaWF0IjoxNzMxNjAyMDM3LCJleHAiOjE3MzIyMDY4Mzd9.FZMYhdp4ZrznDF5fTnmhnq_zYdQYcAlnIwhPRpJJVS4"

scenarios:
  - name: "WebSocket Test"
    engine: socketio
    flow:
      - log: "Start WebSocket Load Test"

      # 채팅방 생성
      - post:
          url: "/v1/chatrooms"
          headers:
            Authorization: "Bearer {{ accessToken }}"
          json:
            gameId: 1
            title: "Test chatroom"
            preferTeam: "LG"
          capture:
            json: "$.id"
            as: "chatroomId"

      # pause
      - think: 1

      # 채팅방 연결
      - loop:
          - count: 20
          - flow:
              - namespace: "/chat"
                emit:
                  channel: "joinRoom"
                  data: "{{ chatroomId }}"
              - think: 1

      # pause
      - think: 1

      # 채팅 전송
      - loop:
          - count: 60
          - flow:
              - namespace: "/chat"
                emit:
                  channel: "chat"
                  data:
                    chatroomId: "{{ chatroomId }}"
                    content: "Chat Test Content"
              - think: 3

      # pause
      - think: 1

      # 채팅방 연결 해제
      - namespace: "/chat"
        emit:
          channel: "leaveRoom"
          data: "{{ chatroomId }}"

      # pause
      - think: 1

      # 채팅방 삭제
      - delete:
          url: "{{ host }}/v1/chatrooms/{{ chatroomId }}"
          headers:
            Authorization: "Bearer {{ accessToken }}"

여기서 서버 개수와 요청 도달률을 적절히 조정하면 서버가 감당 가능한 트래픽의 개수를 파악할 수 있다. 인터넷에서 본 바로는 아무것도 없이 소켓 연결 및 이벤트 발생만 시켰을 때 인스턴스 하나가 동시접속자 수 900명 정도는 감당 가능하다고 되어 있던데, 내 서비스에서는 소켓 이벤트와 비즈니스 로직이 밀접하게 엮여있기 때문에 그에 훨씬 못 미칠 것이라 생각한다.

시간이 이제 오전 3시 53분을 가리키고 있는데, 이틀 간 이 문제에 대해 고민하느라고 정말 많은 스트레스를 받았다.. 사실 다른 기능 구현에 조금 더 신경을 쓸 법도 한데 이번 프로젝트에선 자꾸 부하 테스트를 조금 더 깊이 알고 싶다는 욕심이 생겨서 그랬다.

후속 포스팅이 가능하다면 아마 부하 테스트 결과에 대한 요약과 서버가 감당 가능한 트래픽이 몇 개인지 분석하는 내용을 조금 덧붙일 듯하다.

profile
안녕하세요

1개의 댓글

comment-user-thumbnail
2025년 4월 17일

💩

답글 달기