일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- dto projection
- Promtail
- 13975
- Blue/Green
- slice개념
- binary search
- NCP
- greedy
- 2512
- 이분 탐색
- Upper bound
- 로그
- 1495
- Java
- 11501
- 3187
- 14921
- 20115
- DP
- 12738
- 모니터링
- join제거
- no offset
- 그리디
- 무한페이징
- 19598
- EntityGraph
- Lower bound
- 이진 탐색
- 백준
- Today
- Total
멘지의 기록장
JOIN 제거, Covering Index 적용, DTO projection을 사용해 성능 최적화 하기 본문
프로젝트를 진행하면서 어떠한 기능에 대해 쿼리가 많이 발생하는 것을 확인하였고, 이를 리팩토링하며 쿼리를 최적화 해보았습니다.
쿼리를 최적화 하는 과정과 함께 SQL 쿼리 실행 시간을 비교해보고, 마지막에 성능 비교도 진행해보도록 하겠습니다.
문제 상황
우선 문제가 발생했던 기능은 다음과 같습니다.
유저가 질문에 대해 대답하는 문제를 풀 때
1️⃣ 자신이 등록한 문제 or 관리자가 등록한 문제 중
2️⃣ 랜덤한 문제 N개를 가져와야 한다.
Return 예시
{
"isSuccess": true,
"code": "REQUEST_OK",
"message": "request succeeded",
"results": {
"answerQuizList": [
{
"quizId": 17,
"question": "가장 좋아하는 사람이 누구야?",
"title": "가장 좋아하는 사람"
},
{
"quizId": 1,
"question": "오늘 점심 뭐 먹었어?",
"title": "점심"
},
{
"quizId": 7,
"question": "가장 재미있는 책이 뭐야?",
"title": "가장 재미있는 책"
},
{
"quizId": 24,
"question": "제일 좋아하는 옷이 뭐야? 왜 좋아해?",
"title": "제일 좋아하는 옷"
},
{
"quizId": 25,
"question": "지금 제일 보고 싶은 사람이 누구야?",
"title": "보고 싶은 사람"
}
]
}
}
기존 코드
해당 기능을 개발하기 위해 문제를 어떻게 랜덤하게 가져와야 할지에 대한 방법에 대해 고민하였습니다.
findAll() 함수를 통해 사용자가 풀 수 있는 문제를 모두 가져온 후, 난수를 생성하여 값에 해당하는 문제를 응답값으로 제공한다면
➡️ 쿼리가 1번만 나간다는 장점이 있지만,
➡️ 유저가 풀 수 있는 문제가 많아질수록 메모리에 올려야하는 데이터의 값이 많아져 결국 Out Of Memory 문제가 발생할 수도 있습니다.
그렇기에 유저가 풀 수 있는 문제의 총 갯수(N)를 먼저 구하고,
0 ~ N-1 사이의 난수를 생성하여 난수에 해당하는 page를 PageRequest로 가져와야겠다고 생각하였습니다.
Service
public AnswerQuizListResponse selectRandomAnswerQuiz(Long memberId, Integer quizNum) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
int totalQuizNum = getAnswerQuizCount(member);
List<AnswerQuizResponse> answerQuizResponseList =
RandNumUtil.generateRandomNumbers(0, totalQuizNum - 1, quizNum).stream()
.map(quizIdx -> generateRandomPageWithCategory(member, quizIdx))
.map(AnswerQuizResponse::from)
.toList();
return AnswerQuizListResponse.from(answerQuizResponseList);
}
private Integer getAnswerQuizCount(Member member) {
return answerQuizRepository.countByMemberOrAdmin(member);
}
private AnswerQuiz generateRandomPageWithCategory(Member member, int quizIdx) {
return answerQuizRepository.findSingleResultByMember(member, PageRequest.of(quizIdx, 1)).get(0);
}
Repository
@Query("SELECT count(*) FROM AnswerQuiz aq WHERE aq.member = :member OR aq.member.role = 'ADMIN'")
Integer countByMemberOrAdmin(Member member);
@Query("SELECT aq FROM AnswerQuiz aq WHERE aq.member = :member OR aq.member.role = 'ADMIN'")
List<AnswerQuiz> findSingleResultByMember(Member member, Pageable pageable);
RandNumUtil
public class RandNumUtil {
private static final Random random = new Random();
public static List<Integer> generateRandomNumbers(int startNum, int endNum, int size) {
if (size > (endNum - startNum + 1)) {
throw new IllegalArgumentException("요청된 크기가 가능한 난수 범위를 초과합니다.");
}
Set<Integer> randNumSet = new HashSet<>();
while (randNumSet.size() < size) {
int randNum = generateRandomNum(startNum, endNum);
randNumSet.add(randNum);
}
return new ArrayList<>(randNumSet);
}
private static int generateRandomNum(int startNum, int endNum) {
return startNum + random.nextInt(endNum - startNum + 1);
}
}
DTO
@Builder
public record AnswerQuizResponse(
Long quizId,
String question,
String title
) {
public static AnswerQuizResponse from(AnswerQuiz answerQuiz) {
return AnswerQuizResponse.builder()
.quizId(answerQuiz.getQuizId())
.title(answerQuiz.getTitle())
.question(answerQuiz.getQuestion())
.build();
}
}
하지만 위의 방법은 문제를 1개만 가져올 경우에는 효과적이지만, 1번의 요청에
유저를 찾는 쿼리 1개, 유저가 풀 수 있는 문제의 갯수 세는 count 쿼리 1개, 난수에 해당하는 문제 가져오는 쿼리 N개가 발생하며
🚨 랜덤한 문제 N개를 Response로 제공할 때 N+1 문제가 발생하기 때문에 더욱 비효율적인 코드가 되었습니다.
쿼리 결과
Hibernate:
select // 1
m1_0.member_id,
m1_0.birth_date,
m1_0.created_date,
m1_0.email,
m1_0.gender,
m1_0.last_modified_date,
m1_0.name,
m1_0.oauth_type,
m1_0.role,
m1_0.score,
m1_0.social_id
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select // 2
count(*)
from
answer_quiz aq1_0
join
member m1_0
on m1_0.member_id=aq1_0.member_id
where
aq1_0.member_id=?
or m1_0.role='ADMIN'
Hibernate:
select // 3
aq1_0.quiz_id,
aq1_0.is_default,
aq1_0.quiz_level,
aq1_0.member_id,
aq1_0.question,
aq1_0.category,
aq1_0.title
from
answer_quiz aq1_0
join
member m1_0
on m1_0.member_id=aq1_0.member_id
where
aq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate:
select // 4
aq1_0.quiz_id,
aq1_0.is_default,
aq1_0.quiz_level,
aq1_0.member_id,
aq1_0.question,
aq1_0.category,
aq1_0.title
from
answer_quiz aq1_0
join
member m1_0
on m1_0.member_id=aq1_0.member_id
where
aq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate:
select // 5
aq1_0.quiz_id,
aq1_0.is_default,
aq1_0.quiz_level,
aq1_0.member_id,
aq1_0.question,
aq1_0.category,
aq1_0.title
from
answer_quiz aq1_0
join
member m1_0
on m1_0.member_id=aq1_0.member_id
where
aq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate:
select // 6
aq1_0.quiz_id,
aq1_0.is_default,
aq1_0.quiz_level,
aq1_0.member_id,
aq1_0.question,
aq1_0.category,
aq1_0.title
from
answer_quiz aq1_0
join
member m1_0
on m1_0.member_id=aq1_0.member_id
where
aq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate:
select // 7
aq1_0.quiz_id,
aq1_0.is_default,
aq1_0.quiz_level,
aq1_0.member_id,
aq1_0.question,
aq1_0.category,
aq1_0.title
from
answer_quiz aq1_0
join
member m1_0
on m1_0.member_id=aq1_0.member_id
where
aq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
1차 리팩토링 - ORDER BY RAND( ) 사용
그렇기에 1차 리팩토링으로는 쿼리의 갯수를 줄일 수 있는 방법에 대해 생각해보았습니다.
현재 유저가 풀 수 있는 문제의 갯수를 센 후, 각 난수에 해당하는 문제를 가져오기 때문에 여러 쿼리가 발생했기 때문에난수를 발생시키는 부분을 해결하기 위해 MySQL에서 제공하는 RAND() 함수를 사용하여 문제를 해결하고자 하였습니다.
RAND() 함수는 말 그대로 랜덤한 숫자를 불러오는 함수로, ➡️ WHERE 절에서 유저가 풀 수 있는 문제를 column 들을 선택한 후 ➡️ 결과를 무작위로 정렬하고서, ➡️ LIMIT을 통해 요청한 문제의 갯수만을 return 하게 만들면
요청에 대해 단 1번의 쿼리만 나가기 때문에 문제가 해결되었습니다.
Service
public AnswerQuizListResponse selectRandomAnswerQuiz(Long memberId, Integer quizNum) {
List<AnswerQuizResponse> answerQuizResponseList =
answerQuizRepository.findRandomQuizzesByMemberOrAdmin(memberId, quizNum).stream()
.map(AnswerQuizResponse::from)
.toList();
return AnswerQuizListResponse.from(answerQuizResponseList);
}
Repository
@Query(value = "SELECT aq.* FROM answer_quiz aq " +
"JOIN member m ON aq.member_id = m.member_id " +
"WHERE aq.member_id = :memberId OR m.role = 'admin' " +
"ORDER BY RAND() LIMIT :limit", nativeQuery = true)
List<AnswerQuiz> findRandomQuizzesByMemberOrAdmin(Long memberId, int limit);
쿼리 결과
Hibernate:
SELECT
aq.*
FROM
answer_quiz aq
JOIN
member m
ON aq.member_id = m.member_id
WHERE
aq.member_id = ?
OR m.role = 'admin'
ORDER BY
RAND()
LIMIT
?
쿼리의 갯수가 줄며 DB에 접근하는 빈도수도 줄었기 때문에 성능이 1차적으로 향상되었습니다.
하지만 위 방식에서는 유저가 풀 수 있는 문제를 가져오기 위해 Member 테이블과 항상 JOIN을 하여
연산 비용이 발생한다는 문제가 있었습니다.
⚡ JOIN을 하는 비용을 줄일 수 있는 방법에 대해 고민하다가 DB 수업시간에 배웠던 반정규화가 생각났습니다.
반정규화란 성능 향상을 위해 정규화된 데이터 모델에서 중복, 통합, 분리 등을 수행하는 모든 과정을 의미합니다.
즉, 이전에는 JOIN을 통해 MemberId의 값이 같거나, 관리자가 등록한 문제를 가져오는 과정을 거쳐야 했지만,
AnswerQuiz 테이블에 관리자가 등록한 문제인지를 알 수 있는 Column을 추가하면 Member 테이블과 JOIN을 하지 않아도 되기에
JOIN 비용을 없애 성능이 향상될 것이라 생각하였습니다.
2차 리팩토링 - 반정규화, JOIN 제거
AnswerQuiz 테이블에 관리자가 등록한 문제인지 유무를 나타내기 위해 isDefault column을 추가하였습니다.
AnswerQuiz
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AnswerQuiz {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "quiz_id")
private Long quizId;
@Column(name = "question", length = 40)
private String question;
@Column(name = "title", length = 40)
private String title;
@Column(name = "category")
@Enumerated(EnumType.STRING)
private QuizCategory quizCategory;
@Column(name = "quiz_level")
@Enumerated(EnumType.STRING)
private Level level;
@Column(name = "is_default")
private Boolean isDefault;
@JoinColumn(name = "member_id")
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@Builder
public AnswerQuiz(String question, String title, Member member, Boolean isDefault, QuizCategory quizCategory, Level level) {
this.question = question;
this.title = title;
this.quizCategory = quizCategory;
this.level = level;
this.isDefault = isDefault;
this.member = member;
}
}
Service
public AnswerQuizListResponse selectRandomAnswerQuiz(Long memberId, Integer quizNum) {
List<AnswerQuizResponse> answerQuizResponseList =
answerQuizRepository.findRandomQuizzesByMember(memberId, quizNum).stream()
.map(AnswerQuizResponse::from)
.toList();
return AnswerQuizListResponse.from(answerQuizResponseList);
}
Repository
@Query("SELECT aq FROM AnswerQuiz aq "
+ "WHERE aq.isDefault = TRUE OR aq.member.memberId = :memberId "
+ "ORDER BY RAND() LIMIT :quizNum")
List<AnswerQuizResponse> findRandomQuizzesByMember(Long memberId, int quizNum);
쿼리 결과
Hibernate:
select
aq1_0.quiz_id,
aq1_0.is_default,
aq1_0.quiz_level,
aq1_0.member_id,
aq1_0.question,
aq1_0.category,
aq1_0.title
from
answer_quiz aq1_0
where
aq1_0.is_default=1
or aq1_0.member_id=?
order by
rand()
limit
?
JOIN을 사용하지 않고 WHERE 절에서 isDefault를 통해 관리자가 등록한 문제인지 유무를 판별하는 것을 확인할 수 있습니다.
📌 이때, aq.member.memberId와 파라미터로 받아온 memberId 값이 같은지를 비교하는 부분에서 member 칼럼 값을 사용하지만 Member 테이블과 JOIN 연산이 필요하지 않습니다!
➡️ 그 이유는 프록시 객체를 사용하기 때문입니다. JPA에서는 지연 로딩을 위해 Entity의 기본생성자를 상속받은 프록시 객체를 사용합니다. 이러한 프록시 객체는 ID 값을 가지고 있기 때문에 Member 테이블에서 ID 이외의 값을 사용하기 위해선 JOIN을 통해 값을 가져와야 하지만, ID 값만을 사용한다면 JOIN을 사용하지 않고 프록시 객체에서 바로 값을 가져올 수 있습니다!
JOIN 연산 비용을 줄여 2차적으로 성능을 향상시켰습니다.
하지만 성능을 더 향상시킬 수 있는 방법이 있지 않을까 고민하였습니다.그래서 필요한 Column들만 가져오고, Index를 적용해보자는 생각을 하였습니다.
3차 리팩토링 - Covering Index, DTO projection 적용
⚡ 현재는 필요하지 않은 Column 값들도 다같이 조회하기 때문에 필요한 값들만 가져오면 성능을 향상시킬 수 있지 않을까 생각하였습니다.
⚡ 또한, 현재는 Full Table Scan을 통해 AnswerQuiz 테이블의 모든 데이터를 조회한 후, WHERE 절의 조건을 만족하는 데이터를 선별합니다. 하지만 필요한 Column 값들에 Index를 적용한다면 필요한 데이터만을 빠르게 검색할 수 있어 성능을 향상할 수 있을 것이라 생각하였습니다.
Covering Index란, 쿼리를 충족시키는데 필요한 모든 데이터를 가지고 있는 인덱스로
테이블 자체에 접근할 필요 없이 인덱스에서 쿼리의 모든 필요한 데이터를 찾을 수 있어 성능 향상에 도움이 됩니다.
AnswerQuiz
@Table(name = "answer_quiz", indexes = {
@Index(name = "idx_answer_quiz", columnList = "question, title, member_id, is_default")
})
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AnswerQuiz {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "quiz_id")
private Long quizId;
@Column(name = "question", length = 40)
private String question;
@Column(name = "title", length = 40)
private String title;
@Column(name = "category")
@Enumerated(EnumType.STRING)
private QuizCategory quizCategory;
@Column(name = "quiz_level")
@Enumerated(EnumType.STRING)
private Level level;
@Column(name = "is_default")
private Boolean isDefault;
@JoinColumn(name = "member_id")
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@Builder
public AnswerQuiz(String question, String title, Member member, Boolean isDefault, QuizCategory quizCategory, Level level) {
this.question = question;
this.title = title;
this.quizCategory = quizCategory;
this.level = level;
this.isDefault = isDefault;
this.member = member;
}
}
Service
public AnswerQuizListResponse selectRandomAnswerQuiz(Long memberId, Integer quizNum) {
List<AnswerQuizResponse> answerQuizResponseList =
answerQuizRepository.findRandomQuizzesByMember(memberId, quizNum).stream()
.toList();
return AnswerQuizListResponse.from(answerQuizResponseList);
}
Repository
@Query("SELECT new soongsil.kidbean.server.answerquiz.dto.response.AnswerQuizResponse(aq.quizId, aq.question, aq.title) "
+ "FROM AnswerQuiz aq "
+ "WHERE aq.isDefault = TRUE OR aq.member.memberId = :memberId "
+ "ORDER BY RAND() LIMIT :quizNum")
List<AnswerQuizResponse> findRandomQuizzesByMember(Long memberId, int quizNum);
쿼리 결과
Hibernate:
select
aq1_0.quiz_id,
aq1_0.question,
aq1_0.title
from
answer_quiz aq1_0
where
aq1_0.is_default=1
or aq1_0.member_id=?
order by
rand()
limit
?
성능 비교
그렇다면 성능을 각 리팩토링 단계마다 실제로 성능이 향상되었는지 확인해보도록 하겠습니다.
서버에 요청을 발생시켜 요청에 대한 분석을 진행하기 위해 k6를 사용하였습니다.
k6란 Grafana Labs에서 만든 부하 테스트 툴로, API endpoint에 대한 성능 테스트 도구로 사용됩니다.
k6를 설치 한 후, script.js 파일을 만들어 아래와 같이 작성합니다.
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 50,
duration: '20s',
};
export default function() {
const url = '테스트 하고자 하는 API';
const params = {
headers: {
'Authorization': 'Bearer accessToken'
}
};
http.get(url, params);
sleep(1);
}
작성한 테스트 조건은 유저가 50명 존재하고, 해당 유저들이 20초 동안 1초에 1번씩 요청을 보냅니다.
현재 DB에는 총 1000명의 유저가 존재하고, AnswerQuiz의 갯수는 150000개 입니다.
위의 조건을 기반으로 k6를 단계별로 실행시켜보도록 하겠습니다.
기존 코드 결과
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: script.js
output: -
scenarios: (100.00%) 1 scenario, 50 max VUs, 50s max duration (incl. graceful stop):
* default: 50 looping VUs for 20s (gracefulStop: 30s)
data_received..................: 403 kB 18 kB/s
data_sent......................: 150 kB 6.8 kB/s
http_req_blocked...............: avg=2.71ms min=0s med=0s max=36.89ms p(90)=19.29ms p(95)=22.65ms
http_req_connecting............: avg=1.14ms min=0s med=0s max=24.03ms p(90)=6.52ms p(95)=9.62ms
http_req_duration..............: avg=1.46s min=552.96ms med=1.35s max=3.47s p(90)=2.38s p(95)=2.55s
{ expected_response:true }...: avg=1.46s min=552.96ms med=1.35s max=3.47s p(90)=2.38s p(95)=2.55s
http_req_failed................: 0.00% 0 out of 432
http_req_receiving.............: avg=690.02µs min=0s med=595.55µs max=8.54ms p(90)=1.42ms p(95)=1.66ms
http_req_sending...............: avg=217.3µs min=0s med=0s max=11.05ms p(90)=0s p(95)=1.51ms
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=1.46s min=552.46ms med=1.34s max=3.47s p(90)=2.38s p(95)=2.55s
http_reqs......................: 432 19.400366/s
iteration_duration.............: avg=2.47s min=1.55s med=2.35s max=4.47s p(90)=3.38s p(95)=3.55s
iterations.....................: 432 19.400366/s
vus............................: 9 min=9 max=50
vus_max........................: 50 min=50 max=50
running (22.3s), 00/50 VUs, 432 complete and 0 interrupted iterations
default ✓ [======================================] 50 VUs 20s
결과를 확인해보면 요청을 처리하는 평균 시간은 1.46초, 최소 소요시간은 552.96ms가 걸리는 것을 확인할 수 있습니다.
1차 리팩토링 결과
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: script.js
output: -
scenarios: (100.00%) 1 scenario, 50 max VUs, 50s max duration (incl. graceful stop):
* default: 50 looping VUs for 20s (gracefulStop: 30s)
data_received..................: 573 kB 26 kB/s
data_sent......................: 214 kB 9.8 kB/s
http_req_blocked...............: avg=2.66ms min=0s med=0s max=46.72ms p(90)=0s p(95)=30.59ms
http_req_connecting............: avg=602.63µs min=0s med=0s max=25.83ms p(90)=0s p(95)=2.7ms
http_req_duration..............: avg=714.57ms min=285.83ms med=548.49ms max=2.36s p(90)=1.27s p(95)=1.51s
{ expected_response:true }...: avg=714.57ms min=285.83ms med=548.49ms max=2.36s p(90)=1.27s p(95)=1.51s
http_req_failed................: 0.00% 0 out of 614
http_req_receiving.............: avg=726.53µs min=0s med=517.2µs max=19.1ms p(90)=1.12ms p(95)=1.5ms
http_req_sending...............: avg=87.98µs min=0s med=0s max=4.01ms p(90)=0s p(95)=999.6µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=713.76ms min=285.12ms med=547.93ms max=2.36s p(90)=1.27s p(95)=1.51s
http_reqs......................: 614 28.142265/s
iteration_duration.............: avg=1.71s min=1.28s med=1.54s max=3.38s p(90)=2.28s p(95)=2.54s
iterations.....................: 614 28.142265/s
vus............................: 32 min=32 max=50
vus_max........................: 50 min=50 max=50
running (21.8s), 00/50 VUs, 614 complete and 0 interrupted iterations
default ✓ [======================================] 50 VUs 20s
결과를 보면 요청을 처리하는 평균 시간은 714.57ms, 최소 소요시간은 285.83ms가 걸리며 기존 코드보다 확인히 성능이 좋아진 것을 확인할 수 있습니다.
2차 리팩토링 결과
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: script.js
output: -
scenarios: (100.00%) 1 scenario, 50 max VUs, 50s max duration (incl. graceful stop):
* default: 50 looping VUs for 20s (gracefulStop: 30s)
data_received..................: 682 kB 32 kB/s
data_sent......................: 254 kB 12 kB/s
http_req_blocked...............: avg=1.52ms min=0s med=0s max=91ms p(90)=0s p(95)=18.15ms
http_req_connecting............: avg=526.61µs min=0s med=0s max=21.61ms p(90)=0s p(95)=5.54ms
http_req_duration..............: avg=426.96ms min=77.41ms med=295.63ms max=1.57s p(90)=841.64ms p(95)=975.83ms
{ expected_response:true }...: avg=426.96ms min=77.41ms med=295.63ms max=1.57s p(90)=841.64ms p(95)=975.83ms
http_req_failed................: 0.00% 0 out of 731
http_req_receiving.............: avg=470.43µs min=0s med=431.4µs max=6.05ms p(90)=1ms p(95)=1.14ms
http_req_sending...............: avg=180.91µs min=0s med=0s max=58.15ms p(90)=0s p(95)=999.1µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=426.3ms min=76.94ms med=294.95ms max=1.57s p(90)=841.06ms p(95)=973.47ms
http_reqs......................: 731 33.774226/s
iteration_duration.............: avg=1.42s min=1.07s med=1.3s max=2.6s p(90)=1.84s p(95)=1.98s
iterations.....................: 731 33.774226/s
vus............................: 22 min=22 max=50
vus_max........................: 50 min=50 max=50
running (21.6s), 00/50 VUs, 731 complete and 0 interrupted iterations
default ✓ [======================================] 50 VUs 20s
요청을 처리하는 평균 시간은 426.96ms, 최소 소요시간은 77.41ms가 걸리며 1차 리팩토링보다 약 1/2의 시간이 걸리며 성능이 향상되었습니다.
3차 리팩토링 결과
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: script.js
output: -
scenarios: (100.00%) 1 scenario, 50 max VUs, 50s max duration (incl. graceful stop):
* default: 50 looping VUs for 20s (gracefulStop: 30s)
data_received..................: 794 kB 38 kB/s
data_sent......................: 297 kB 14 kB/s
http_req_blocked...............: avg=2.27ms min=0s med=0s max=50.67ms p(90)=0s p(95)=26.66ms
http_req_connecting............: avg=1.04ms min=0s med=0s max=30.68ms p(90)=0s p(95)=9.42ms
http_req_duration..............: avg=203.8ms min=65.68ms med=136.91ms max=1.29s p(90)=337.51ms p(95)=729.32ms
{ expected_response:true }...: avg=203.8ms min=65.68ms med=136.91ms max=1.29s p(90)=337.51ms p(95)=729.32ms
http_req_failed................: 0.00% 0 out of 852
http_req_receiving.............: avg=725.14µs min=0s med=507.1µs max=23.35ms p(90)=1.11ms p(95)=1.5ms
http_req_sending...............: avg=216.45µs min=0s med=0s max=24.51ms p(90)=0s p(95)=122.79µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=202.86ms min=65.68ms med=136.23ms max=1.29s p(90)=335.93ms p(95)=706.52ms
http_reqs......................: 852 40.318816/s
iteration_duration.............: avg=1.2s min=1.06s med=1.13s max=2.34s p(90)=1.33s p(95)=1.8s
iterations.....................: 852 40.318816/s
vus............................: 2 min=2 max=50
vus_max........................: 50 min=50 max=50
running (21.1s), 00/50 VUs, 852 complete and 0 interrupted iterations
default ✓ [======================================] 50 VUs 20s
요청을 처리하는 평균 시간은 203.8ms, 최소 소요시간은 65.68ms가 걸리며 사용했던 DTO projection과 Covering Index가 성능이 향상에 도움이 된 것을 확인할 수 있었습니다.
그렇다면 위의 결과들을 토대로 그래프를 그려 한눈에 차이를 확인해보도록 하겠습니다.
결과를 확인했을 때 기존 요청을 처리하던 시간보다 3차 리팩토링의 결과 약 86% 성능 향상된 것을 확인할 수 있었습니다.
또한, 리팩토링 과정에 따라 SQL 쿼리 실행 시간도 비교해보았습니다.
1차 리팩토링 결과
-> Limit: 5 row(s) (actual time=218..218 rows=5 loops=1)
-> Sort: rand(), limit input to 5 row(s) per chunk (actual time=218..218 rows=5 loops=1)
-> Stream results (cost=18929 rows=174140) (actual time=0.692..203 rows=20000 loops=1)
-> Nested loop inner join (cost=18929 rows=174140) (actual time=0.645..95.3 rows=20000 loops=1)
-> Filter: ((m.member_id = 1) or (m.`role` = 'admin')) (cost=0.75 rows=2.33) (actual time=0.101..0.15 rows=2 loops=1)
-> Table scan on m (cost=0.75 rows=5) (actual time=0.0952..0.118 rows=5 loops=1)
-> Index lookup on aq using FKbfur0g6waq6klsmwsh4ebpw5f (member_id=m.member_id) (cost=3847 rows=74632) (actual time=0.49..45.8 rows=10000 loops=2)
1차 리팩토링에서는 RAND()를 사용하여 쿼리 수를 줄이는 것이 목표였기 때문에
Inner Join이 발생하고, Table Scan이 이루어진 것을 확인할 수 있었습니다.
5개의 문제를 가져온다고 가정했을 때 약 218ms 시간이 걸리는 것을 확인할 수 있습니다.
2차 리팩토링 결과
-> Limit: 5 row(s) (actual time=151..151 rows=5 loops=1)
-> Sort: rand(), limit input to 5 row(s) per chunk (actual time=151..151 rows=5 loops=1)
-> Stream results (cost=15143 rows=111947) (actual time=0.141..148 rows=20000 loops=1)
-> Filter: ((aq.is_default = true) or (aq.member_id = 1)) (cost=15143 rows=111947) (actual time=0.127..132 rows=20000 loops=1)
-> Table scan on aq (cost=15143 rows=149263) (actual time=0.123..109 rows=150000 loops=1)
2차 리팩토링에서는 JOIN 비용을 없애는 것이 목표였기 때문에
1차 리팩토링과 달리 Inner Join이 발생하지 않았지만, 여전히 Table Scan이 이루어지는 것을 확인할 수 있었습니다.
5개의 문제를 가져온다고 가정했을 때 약 151ms 시간이 걸리는 것을 확인할 수 있습니다.
3차 리팩토링 결과
-> Limit: 5 row(s) (actual time=90.8..90.8 rows=5 loops=1)
-> Sort: rand(), limit input to 5 row(s) per chunk (actual time=90.8..90.8 rows=5 loops=1)
-> Stream results (cost=15143 rows=111947) (actual time=0.0895..88.6 rows=20000 loops=1)
-> Filter: ((aq.is_default = true) or (aq.member_id = 1)) (cost=15143 rows=111947) (actual time=0.082..82.2 rows=20000 loops=1)
-> Covering index scan on aq using idx_answer_quiz (cost=15143 rows=149263) (actual time=0.0794..67.6 rows=150000 loops=1)
3차 리팩토링에서는 DTO Projection과 Covering Index를 사용하였기에
Join 비용이 발생하지 않고, Covering Index도 잘 적용된 것을 확인할 수 있었습니다.
5개의 문제를 가져온다고 가정했을 때 약 90.8ms 시간이 걸려 조회 시간이 향상된 것을 확인할 수 있습니다.
위 결과들을 기반으로 SQL 쿼리 실행 시간을 그래프로 비교해보도록 하겠습니다.
결과를 확인했을 때 지속적으로 실행 시간이 향상되어, 1차 리팩토링 대비 3차 리팩토링에서 약 71% 향상됨을 확인할 수 있었습니다.
결과 분석
리팩토링 과정을 거치며 쿼리/성능 최적화를 해보았습니다.
1️⃣ RAND() 사용, 반정규화, JOIN 제거, DTO projection, Covering Index 등 최적화를 할 수 있는 방법이 다양하다는 것을 확인할 수 있었고,
2️⃣ 사용자에게 좋은 서비스 경험을 제공하기 위해 성능을 향상시켜나가는 것을 꼭 필요한 과정이라는 것을 다시 배울 수 있었습니다.
'SpringBoot' 카테고리의 다른 글
yml 파일을 서브 모듈로 관리하기 (0) | 2025.03.21 |
---|---|
로그에서 발견한 Time out 문제 해결하기 (1) | 2025.01.28 |
Loki와 Promtail을 사용해 로그 모니터링 환경 구축하기 (0) | 2025.01.27 |
Logback 사용하여 로그 관리하기 (0) | 2025.01.25 |
Prometheus, Grafana 를 이용해 모니터링 환경 구축하기 (0) | 2025.01.24 |