멘지의 기록장

JOIN 제거, Covering Index 적용, DTO projection을 사용해 성능 최적화 하기 본문

SpringBoot

JOIN 제거, Covering Index 적용, DTO projection을 사용해 성능 최적화 하기

멘지 2025. 2. 18. 00:10

프로젝트를 진행하면서 어떠한 기능에 대해 쿼리가 많이 발생하는 것을 확인하였고, 이를 리팩토링하며 쿼리를 최적화 해보았습니다. 

쿼리를 최적화 하는 과정과 함께 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️⃣ 사용자에게 좋은 서비스 경험을 제공하기 위해 성능을 향상시켜나가는 것을 꼭 필요한 과정이라는 것을 다시 배울 수 있었습니다.