N+1 + Pageable 트러블슈팅 여행기에서 이어지는 포스팅입니다.
작성자: 우아한테크코스 5기 여우
이돈이면 프로젝트를 진행하는 과정에서
ORM과 관련해 유명한 성능 문제인 N+1 문제에 부딫히고 해결하는 경험을 했습니다. (뿌듯)
본래 22번 나가던 쿼리를 2번으로 줄이면서
괄목할 만한 성능 개선을 해냈다고 생각하지만,
정말로 성능이 개선된 것인지를 쿼리 개수만으로 판단하기에는 무리가 있다는 생각이 들었습니다.
예를 들어, 1개의 데이터를 조회하는 연결을 20번 하는 것과
100만개의 데이터를 조회하는 연결을 1번 하는 것 중 어느 쪽의 성능이 더 좋을까요?
실제로 N+1 문제를 해결하는 과정에서 일대다 연관관계에 Fetch join을 사용했더니
쿼리 개수는 딱 한 번만 나갔지만 페이징이 적용되지 않아 limit가 없는 전체 조회 쿼리가 나간 적이 있었습니다.
데이터 개수가 1억개라면 한 번의 쿼리로 1억개의 데이터를 메모리에 올려서 서버를 폭파시킬 수도 있었죠!
그래서 어떤 API나 기능의 성능을 측정하고자 한다면
조금 더 정확한 데이터를 비교해야겠구나 싶었고,
이참에 성능 테스트라는 개념을 공부해 적용해보면 좋겠다는 생각이 들었습니다.
성능 테스트란 무엇일까요?
GPT에게 물어봅시다.
글러먹었습니다
It위키에서는 '시스템에서 수용 가능한 처리량을 판별하기 위한 테스트' 라고 설명하고 있네요!
이전 블로그에서 썼던 4가지 쿼리 구현 방법을
동일한 시나리오 아래에서 테스트하면서 결과값을 비교해볼 거에요.
먼저 시나리오부터 구상해보도록 합쉬다 👀
총 3가지 쿼리 구현 환경에서 테스트를 진행할 거에요.
1. N+1 with Paging : 게시글 20개 + 회원 20개 조회 쿼리 1개 + 게시글 이미지 조회 쿼리 20개 -> 21개의 쿼리
2. CollectionFetch Fetch Join : 게시글 10,000개 + 이미지 20,000개 + 회원 10,000개를 fetch join하는 쿼리 1개 + 애플리케이션 코드에서 페이징
3. BatchSize : 게시글 20개 + 회원 20개 조회 쿼리 1개 + in절을 사용해 이미지를 20개 단위로 가져오는 쿼리 1개 -> 2개의 쿼리
먼저 성능 테스트에 필요한 데이터는 미리 세팅해 두었어요!
(Member는 원래 더미 데이터로 3명의 member가 등록되어 있어서, 총 10,003명의 회원이 조회됨)
그리고 로깅으로 인한 성능 저하를 방지하기 위해 콘솔 로깅도 꺼버렸어요.
조아요
자바 코드에 시간을 측정하는 로그를 남겨 직접 관찰하는 방법도 있지만
1초에 1만 번 API 호출하는 작업을 자바 코드로 어떻게 표현하지라고 생각해보면 조금 암담해지기 때문에,
성능 테스트를 쉽게 할 수 있도록 도와주는 여러 도구들을 사용해보면서
성능 테스트도 진행할 겸, 각 도구들의 차이점도 알아보려 합니다. 🙂
AB라고 줄여부르는 Apache HTTP server benchmarking tool은
커맨드 라인을 이용해서 엄청 쉽고 간단하게 성능 테스트를 할 수 있게 해주는 도구입니다.
나중에 사용법을 보면 아시겠지만,
쿠키나 헤더 정보 등이 필요한 복잡한 페이지를 테스트하기보다
간단한 API를 호출하여 테스트하기에 적합한 도구에요!
맥 또는 리눅스 계열의 컴퓨터에는 기본으로 설치가 되어 있어서
터미널에서 바로 사용할 수 있다는 장점도 있습니다.
우리의 테스트 방법을 AB로 표현하려면 요렇게 입력하면 돼요.
ab -n 100000 -c 1000 -t 10 http://localhost:8080/posts
위 명령어를 실행해 봅시다!
안된대요. 넘 많대용
조건을 훨씬 줄이고 다시 테스트했습니다. 어차피 동일한 조건이기만 하면 되니까요~
ab -n 100 -c 10 -t 3 http://localhost:8080/posts
N+1 with Paging
CollectionFetch Fetch Join
OMG
N+1 문제를 갖지만 한 커넥션 당 1개의 데이터만 가져오는 N+1 쿼리와
한 번의 커넥션만 가지지만 한 번에 40,000개의 데이터를 가져오는 쿼리는
RPS와 TPR에서 무려 321배 차이가 납니다 ! !!!!!
커넥션 여러 번 일어나더라도 페이징이 데이터베이스에서
한방쿼리 날린다고 와 성능 개선했다~~ 하면서 이 코드 머지했었다간
이돈이면 서버 날려먹을 뻔했네욥
BatchSize
N+1 쿼리와 비교했을 때 BatchSize를 적용한 쿼리는
RPS와 TPR 약 1.23배 나은 성능을 보이는 것을 확인했어요!
이 0.23배만큼의 성능차가
N번의 커넥션으로 인해 발생하는 오버헤드라고 보아도 될 것 같네요 🥹
그럼 Apache Bench로 측정/비교한 성능을 표로 정리해 보아요
요것도 Apache에서 만든, 자바로 만든 오픈소스 성능 테스트 도구에요!
사용법이 인프런 강의로 따로 나와있을 정도로 가장 인지도있는 성능 테스트 툴 중 하나입니다.
먼저 Jmeter 사용법을 자세히 다룬 블로그를 따라 Jmeter를 실행해 보아요.
Thread Group을 하나 만들어서 테스트 시나리오를 작성했어요!
1,000명의 사용자가 10초에 걸쳐 100번의 요청을 보낸다, 총 100,000번의 요청을 보내겠다는 내용을 적어주었습니다
(자세한 사용법은 위에 블로그 링크를 통해 학습하세용)
HTTP Request 항목도 만들어 우리가 테스트할 URL도 정확히 적어주었습니다.
테스트 결과는 Summary Report를 통해 확인할 겁니다!
원래 결과를 분석하는 다양한 도구가 있지만 저는 Summary Report 만으로도 충분해요
아까처럼 각 상황별로 성능 측정을 해보겠습니다.
(처리한 총 요청 개수는 약간 무의미해서 집계에서 제외했습니다!)
N+1 with Paging
CollectionFetch Fetch Join
BatchSize
Apache Bench보다 JMeter가 더 무거운 도구라서 성능을 더 많이 잡아먹었는지,
전체적으로 RPS 수치가 실제보다 높게 측정되네요!
그래서 원래 성능 테스트는 테스트를 실행하는 서버와 애플리케이션을 실행하는 서버가 분리되어 있어야 한다고 해요.
아무튼 위 지표도 표로 정리해 봅시당!
지표의 정확성 면에서 약간 아쉬웠지만
그래도 각 쿼리 전략의 성능 차이를 잘 보여준 것 같아요~
Locust라는 테스트 도구도 있습니다! 설치와 사용이 간편하고,
테스트 스크립트를 작성하기 위해
파이썬으로 코딩을 해야 한다는 특징이 있어요.
그럼 locust 사용법을 자세히 다룬 블로그의 내용을 바탕으로
성능 테스트를 시작해 보겠습니다!
먼저 Locust가 테스트에 사용할 때의 지침서에 해당하는 코드를 파이썬으로 작성해 주어야 해요.
from locust import HttpUser, task
class sample(HttpUser):
@task
def request(self):
self.client.get("/posts",
headers={"Content-Type" : "application/json"}
)
요 파이썬 파일에는 가상의 유저가 수행할 작업들을 각각의 클래스로 만들 수 있고,
클래스 안에 @task를 달고 있는 메서드가 있다면 그 메서드들을 랜덤하게 수행해요!
저는 /posts로 GET 요청을 보낸다는 작업만 지시할 예정이니
위처럼 간단한 파이썬 파일을 작성했습니다.
이제 터미널에서 해당 파이썬 파일을 locust 명령어로 실행하면 돼요!
성능 테스트 시간을 30초로 한다는 설정도 함께 명시해주었습니다.
그럼 localhost:8089로 접속했을 때
이렇게 초록초록한 UI가 나옵니다. 신기하죵
Number of users는 최대 유저 수,
Spawn rate는 한번에 유저가 생성되는 수,
Host 부하 테스트할 서버 주소이니
적절하게 적어주겠습니다.
이제 실행하고 결과를 알아내봅시다!
N+1 with Paging
CollectionFetch Fetch Join
Requests per second(RPS, 1초당 처리할 수 있는 요청 수) : 약 15.2개
Time per request(요청 1건이 처리되는 평균 시간) : 약 13651ms
BatchSize
체감하기로는 지금까지 중 가장 사실에 가까운 수치가 나온 것 같아요.
아무튼 표로 정리하면
세계 최초의 기관총 이름이 개틀링건이래요.
그냥 그렇다구요
마지막으로 사용해 볼 Gatling도
부하 테스트를 통해 애플리케이션의 성능을 측정하는 오픈소스 도구에요!
Gatling또한 Locust처럼 테스트 시나리오를 작성한 코드 파일이 필요한데,
이번에는 Scala라는 언어로 작성해야 해요. 모르는 언어가 자꾸 나오지만 겁먹지 말고 씩씩하게 공부해 봅시다.
Gatling 사용법을 친절히 설명해준 블로그 가이드에 따라 Gatling을 다운로드하고, 프로젝트의 특정 폴더에
테스트 시나리오를 작성한 scala 파일을 추가해주면 돼요.
package test
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration.DurationInt
import scala.language.postfixOps
class RecordedSimulation extends Simulation {
val httpProtocol = http("postSimaulation")
.get("http://localhost:8080/posts")
val scn = scenario("Scenario")
.exec(httpProtocol)
setUp(
scn.inject(
rampUsers(20000) during(30 seconds)
)
)
}
scala도 jvm을 기반으로 하는 언어이기 때문에
플러그인만 추가하면 intellij에서 편하게 사용할 수 있어요. 짱이죵
위 코드는 ''/posts로 GET요청을 보내는' 작업을
30초에 걸쳐 20,000번 보내겠다는 의미의 코드입니다.
요걸 바탕으로 테스트를 하려면 터미널에서 Gatling 프로젝트를 실행하기만 하면 돼요!
테스트를 마치고 나면 터미널에서 이케 파일 주소를 보여주는데,
이 주소로 접속하면
테스트 결과를 시각적으로 보여주는 정적 페이지가 나와요!
마지막으로 이 Gatling을 사용하여, 쿼리 전략 별 성능을 알아내 봅시다.
N+1 with Paging
CollectionFetch Fetch Join
(한 커넥션 처리가 너무 오래 걸려, 후순위 쓰레드가 모두 타임아웃에 걸림)
BatchSize
여기선 N+1과 BatchSize 전략이
RPS에서는 거의 차이가 없고, TPR에서 비교적 눈에 띄는 차이가 나타나네요.
마지막으로 이것도 표로 정리해 보겠습니다.
각 성능 테스트 도구 별로 측정한 데이터를 정리된 그래프로 다시 그려보았습니다.
(그래프중독자)
각 도구마다 측정된 값의 편차가 어마어마하게 크네요 👀
아무래도 테스트 도구를 실행하는 환경과 애플리케이션을 실행하는 환경이 동일해서 생기는 문제로 보입니다.
N+1 쿼리를 1로 하여 상대적인 차이를 그래프로 그리면
편차가 조금 줄어들까요?
Gatling은 중간에 실패한 요청응답이 많아 버리는 데이터라고 한다면,
세 쿼리 전략 간 성능 차이가 어느 정도인지 짐작할 수 있게 된 것 같아요!
위 데이터를 바탕으로 정리한다면
유후 신기하군요
다양한 성능 테스트 도구의 사용 방법을 알게 되었으니
앞으로 이돈이면 프로젝트를 유지보수하고 성능개선을 할 때 유용하게 활용할 수 있을 것 같아요!
👍🏻
일주일전까지 n+1이 뭔지 몰랐던 사람 맞나.. 잘 읽고 갑니다 👍