[Performance] Stress Test & Load Balancing

Noah·2022년 3월 12일
0

Performance

목록 보기
1/1

오늘은 크누 마켓 API 서버의 성능 측정과 부하 분산을 위해 로드 밸런싱을 적용한 과정을 적어보고자 한다.


Artillery

서버에 부하를 주기 위해 사용한 성능 테스트 라이브러리인 Artillery에 대한 특징에 대해 간략하게 소개하도록 하겠다.

Artillery는 API 서비스, 채팅 시스템, 게임 백엔드, 데이터베이스, 메시지 브로커, 그리고 네트워크를 통해 통신할 수 있는 모든 백엔드 시스템을 테스트할 수 있다.

프로토콜이나 작성된 언어에 관계없이 모든 백엔드를 테스트하는 데 사용할 수 있다. Artillery는 기본적으로 HTTP, WebSocket 및 Socket.io를 지원하고 플러그인을 통해 HLS, Kinesis 및 Kafka와 같은 많은 추가 프로토콜을 지원한다.

이러한 Artillery의 가장 큰 특징으로는 사용자 동작을 정의한 시나리오를 통해 서버에 부하를 줄 수 있다는 것이다. Artillery의 특징과 사용 방법에 대해 더 궁금하신 분들은 공식 문서를 참고하길 바란다. 개인적으로 공식 문서가 아주 술술 읽혔던 만큼 아주 상세하고 자세하게 설명한다.

knumarket-api-server-load-test.json

Artillery는 yml 또는 json 파일을 통해 부하를 주기 위한 여러 설정 조건들을 지정함으로써 부하 테스트를 할 수 있다. 다음은 크누 마켓 서버 부하 테스트를 위해 작성했던 json 파일이다.

{
    "config": {
        "target": "http://example.ap-northeast-2.elb.amazonaws.com",
        "http": {
            "timeout": 10
        },
        "phases": [
            {
                "duration": 60,
                "arrivalRate": 100,
                "name": "Warm up"
            },
            {
                "duration": 60,
                "arrivalRate": 100,
                "rampTo": 200,
                "name": "Ramp up load"
            },
            {
                "duration": 60,
                "arrivalRate": 200,
                "name": "Sustained load"
            }
        ],
        "payload": {
            "path": "./req-datas.csv",
            "fields": [
                "email",
                "password",
                "lastId",
                "idx"
            ]
        }
    },
    "before": {
        "flow": [
            {
                "log": "로그인"
            },
            {
                "post": {
                    "url": "/api/users/login",
                    "json": {
                        "email": "{{ email }}",
                        "password": "{{ password }}"
                    },
                    "capture": [
                        {
                            "json": "$.response.access_token",
                            "as": "accessToken"
                        }
                    ]
                }
            }
        ]
    },
    "scenarios": [
        {
            "name": "글 목록 조회 - 특정 글 조회",
            "flow": [
                {
                    "get": {
                        "url": "/api/posts",
                        "qs": {
                            "last_id": "{{ lastId }}"
                        },
                        "capture": [
                            {
                                "json": "$.response.posts[{{ idx }}].id",
                                "as": "postId"
                            }
                        ]
                    }
                },
                {
                    "think": 2
                },
                {
                    "get": {
                        "url": "/api/posts/{{ postId }}",
                        "capture": [
                            {
                                "json": "$.response.post_room.post_room_uid",
                                "as": "roomUid"
                            }
                        ]
                    }
                }
            ],
            "weight": 7
        },
        {
            "name": "글 목록 조회 - 특정 글 조회 - 채팅방 참여",
            "flow": [
                {
                    "get": {
                        "url": "/api/posts",
                        "qs": {
                            "last_id": "{{ lastId }}"
                        },
                        "capture": [
                            {
                                "json": "$.response.posts[{{ idx }}].id",
                                "as": "postId"
                            }
                        ]
                    }
                },
                {
                    "think": 2
                },
                {
                    "get": {
                        "url": "/api/posts/{{ postId }}",
                        "capture": [
                            {
                                "json": "$.response.post_room.post_room_uid",
                                "as": "roomUid"
                            }
                        ]
                    }
                },
                {
                    "think": 5
                },
                {
                    "post": {
                        "url": "/api/rooms/{{ roomUid }}",
                        "headers": {
                            "authorization": "Bearer {{ accessToken }}"
                        }
                    }
                }
            ],
            "weight": 2
        },
        {
            "name": "공동구매 글 쓰기",
            "flow": [
                {
                    "post": {
                        "url": "/api/posts",
                        "headers": {
                            "authorization": "Bearer {{ accessToken }}"
                        },
                        "json": {
                            "title": "마스크 공동 구매 시키실 분?",
                            "description": "쪽문 or 정문 근처에서 만나요! 가격은 인당 4000원 입니다.",
                            "location": 3,
                            "max_head_count": 10,
                            "images": []
                        }
                    }
                }
            ],
            "weight": 1
        }
    ]
}

크게 config 속성과 scenarios 속성 두 부분으로 나뉜다. config에서는 부하를 줄 타깃과 부하 시간, 동시 사용자 수 등을 지정할 수 있다. scenarios에는 여러 시나리오를 정의할 수 있고 실제 부하를 줄 때 가상 유저의 구체적인 행동 양식을 flow 속성을 통해 정의할 수 있다.

위 파일에서 config에 phase 속성을 보면 총 3단계로 이루어져 있고 각 단계는 60초, 즉 1분 동안 유지된다. 또한 첫 번째 단계는 동시 사용자 100명, 두 번째 단계는 100명으로 시작하여 최종적으로 200명이 될 때까지, 세 번째 단계는 200명으로 진행함을 arrivalRate, RampTo 속성을 통해 지정해 주었다.

또한 각 시나리오를 보면 weight 속성이 존재함을 알 수 있는데 이는 가상 유저가 시나리오에 맞춰 요청을 보낼 때 시나리오 간의 비율을 의미한다. 보통 웹의 경우 읽기 작업이 쓰기 작업보다 훨씬 많기 때문에 나 또한 이를 고려하여 조회 API와 쓰기 API의 비율을 7:3으로 맞추었다. 즉 100명의 동시 사용자가 존재한다면 70명은 읽기 작업을 30명은 쓰기 작업을 하도록 시나리오를 구성한 것이다.

물론 작성한 모든 API에 대해 테스트를 하진 못했지만 가장 요청 트래픽이 몰리는 API에 대해서 진행한 것이다.


API 서버 부하 측정

보통 실시간 모니터링 툴을 이용해서 각종 지표들을 측정하는 것으로 알고 있지만 프로젝트에 투자할 수 있는 시간 여건상 그렇게는 하지 못했다. 때문에 API 서버에 원격 접속을 통해 top, sar, vmstat 등의 명령어로 CPU 리소스, I/O wait, Memory 리소스 등을 확인해 보았다. 해당 명령어에 대해 궁금하신 분들을 아래 블로그들을 참고하길 바란다.

  1. sar 명령어
  2. vmstat 명령어
  3. top 명령어

단일 서버에 Artillery를 통해 최대 동시 사용자 200명으로 부하를 준 결과 아래 사진과 같이 I/O wait이나 Memory 등 다른 지표는 문제가 없었고 CPU 부하만 높았음을 확인했다.

이는 당연한 결과라고도 생각되는 것이 API 서버의 경우 유저로부터 요청을 받고 알맞은 응답을 해주는 것에만 국한되기 때문에 http 통신에 따른 네트워크 부하만 걸린다고 생각할 수 있다. 서버 구조를 보면 데이터베이스 서버의 경우 다른 서버를 사용하기 때문이다.

Artillery Report

또한 Artillery는 테스트 이후에 결과에 대한 지표들을 볼 수도 있는데 아래 사진이 그 결과들이다.

지표 요약 사진

위 사진들을 보면 각종 지표들을 약 200초간 진행한 테스트 동안 약 55,000번의 요청이 이루어졌고 그중 2000번은 타임아웃 에러가 나버렸다. 단일 서버가 네트워크 부하를 감당하지 못했음을 알 수 있다.

응답 시간

응답 시간 또한 p95의 경우 약 1초, p99의 경우 약 7초로 응답 시간이 좋지 않음을 알 수 있다.


Load Balancing & 서버 부하 재측정

위와 같이 트래픽이 급증하는 경우 CPU 부하 분산을 위해 서버 대수를 늘리는 스케일 아웃을 통해 로드 밸런싱을 적용할 수 있다. 로드 밸런싱을 적용하는 방법도 여러 가지가 있지만 크누 마켓 프로젝트의 경우 모든 환경이 AWS Cloud 환경이었기 때문에 로드밸런싱 또한 AWS Application Load Balancer를 사용했다.

ALB를 사용하는 법은 어렵지 않다. AWS 공식 문서에도 자세히 나와있고 블로그에도 관련 글이 많다. 주의할 점은 로드밸런서를 만들고 해당 로드밸런서가 속해있는 대상 그룹을 Auto Scaling 그룹에 연결해야 한다는 것이다. 연결을 해야만 Auto Scaling에 의해 자동으로 추가, 종료된 EC2 인스턴스가 로드 밸런서에도 자동으로 적용된다. 또한 로드 밸런서와 그 대상 그룹은 동일한 리전에 있어야 한다는 점이 되겠다.

위와 같이 동일한 사양(2core, 4GB RAM)의 서버를 두 대로 스케일 아웃 한 후에 부하 테스트를 다시 한 번 적용했다.

CPU 리소스

로드 밸런싱 이전 단일 서버의 CPU 리소스 사용률은 100%였으나 로드 밸런싱 후 각 서버의 CPU 리소스 사용률은 30 ~ 40% 정도를 보였다.

Artillery Report

지표 요약 사진

타임아웃이 2000에서 2로 확연히 줄었으며

응답 시간

응답 시간 또한 p95, p99 지표가 엄청 좋아진 것을 확인할 수 있다.


결론 및 아쉬웠던 점

단일 서버의 성능을 측정해 보고 로드 밸런싱을 통해 트래픽을 분산시켜 봤는데 아주 좋은 경험이었다. 하지만 실제 서비스는 모니터링 도구를 활용한 실시간 모니터링을 통해 여러 가지 지표를 측정하고 병목 현상을 찾아 튜닝을 하기 때문에 해당 부분을 경험할 수 있는 시스템을 구축해 보았으면 더 좋은 경험을 할 수 있지 않았을까 하는 아쉬움이 남는다. 마지막으로 추가적으로 참고했던 블로그의 링크를 남겨놓도록 하겠다.

profile
개발 공부는 🌳 구조다…

0개의 댓글