[MySQL] 페이징 성능 개선 여정 2편 - No-Offset과 커버링 인덱스

명확한 답이 있었으면...


지난 글 : [MySQL] 페이징 성능 개선 여정 1편 - 잘못된 성능 개선 바로잡기

반정규화와 커버링 인덱스로 조금이나마 성능을 개선한 과정을 적었던 지난 글에 이어서

No-Offset 방식으로 성능 개선을 마무리하는 과정을 적으려 한다.

그리고 마지막으로 여전히 남아있는 의문에 대한 개인적인 의견을 덧붙이겠다.


1. No-Offset을 통한 성능 개선

무한 스크롤이기에 적용이 가능한 No-Offset 방식으로 더 효율적으로 성능을 개선해보려 한다.

일단 성능 개선할 주요 쿼리의 현재 상태에 대해 간단히 살펴보자. 반정규화를 통해 comment_count 칼럼에 인덱스를 걸고 커버링 인덱스로 카드의 id 10건을 조회하는 형태이다.

select /*+ INDEX(c1_0 idx_theme_id_comment_count) */
        c1_0.id
    from
        CARD c1_0
    join
        THEME t1_0
            on t1_0.id=c1_0.theme_id
    where
        t1_0.id=2
    order by
        c1_0.comment_count desc limit 19990000, 10;


이제 저 Offset(19990000)을 없애야 한다. 저거 없이 어떻게 다음 페이지의 카드들을 조회할 수 있을까?

개선 전엔 페이지 번호를 받아서 Pageable을 통해 pageSize와 offset을 QueryDsl에 전달해주는 형식이었다면, 개선 후엔 페이지 번호 대신 이전 페이지에서 조회된 카드 중 가장 마지막으로 조회된 카드의 comment_count 값을 클라로부터 파라미터로 받으면 된다.

그래서 개선된 후의 쿼리를 살펴보면,

select /*+ INDEX(c1_0 idx_theme_id_comment_count) */
        c1_0.id,
        c1_0.back_content,
        c1_0.back_mood,
        c1_0.back_name,
        c1_0.comment_count,
        c1_0.created_at,
        c1_0.image_url,
        c1_0.member_id,
        m1_0.id,
        m1_0.card_count,
        m1_0.created_at,
        m1_0.description,
        m1_0.email,
        m1_0.image_url,
        m1_0.nickname,
        m1_0.sub,
        m1_0.updated_at,
        c1_0.theme_id,
        t1_0.id,
        t1_0.created_at,
        t1_0.theme_concept,
        t1_0.updated_at,
        c1_0.updated_at,
        c1_0.version
    from
        CARD c1_0
    join
        MEMBER m1_0
            on m1_0.id=c1_0.member_id
    join
        THEME t1_0
            on t1_0.id=c1_0.theme_id
    where
        t1_0.id=2
        and c1_0.comment_count < 999999 -- 이전 페이지에서 가장 마지막으로 조회된 카드의 comment_count 값이 999999라고 가정
    order by
        c1_0.comment_count desc
    limit 10;


가장 큰 차이점이 2가지가 보인다.

  1. Offset이 아예 사라졌다.
  2. where 구문에 comment_count로 범위를 제한하는 조건이 새로 추가되었다.

이것이 No-Offset 방식이다. 실제로 성능 테스트를 해보면 0.000 ~ 0.032를 왔다갔다 한다.

10 row(s) returned	0.000 sec / 0.000 sec
10 row(s) returned	0.032 sec / 0.000 sec


select 구문에 조회할 컬럼들도 굉장히 많은데, 어떻게 커버링 인덱스를 적용하지 않았는데도 훨씬 빨라질 수 있었을까?

where 구문에서 comment_count의 범위를 제한함으로써, 어디서부터 10개의 레코드를 차례대로 읽어올 지를 인덱스를 통해 빠르게 결정할 수 있다. Offset이 아니기 때문에 처음부터 레코드를 하나하나 세지 않고 딱 10개만 읽고 끝낼 수 있다.

예시로는 인기순 정렬일 경우로 적었지만, 실제로는 최신순도 같이 처리중이기 때문에 정렬 타입과 카드 id, 카드 comment_count를 같이 받고 있다. 그리고 QueryDSL 코드에서 어떤 정렬타입(최신순/인기순)인지에 따라 카드 id, 카드 comment_count 둘 중 어느것을 쓸지 판단한다.


2. 그럼 페이지 번호로 조회하는 건 어떻게…? (개인적인 의견)

No-Offset은 무한 스크롤일 때만 가능하다. 이유는 다음 페이지로 넘어갈 때만 가능한 방식이기 때문이다. 그렇다면 대용량 데이터 환경에서 페이지 번호 버튼을 눌러서 레코드를 조회하는 기능은 어떻게 1초 이내까지 성능 개선을 할 수 있을까?

여러가지 방법을 생각해보았다.


레코드 삭제 시 식별자 값 조정

만약 comment_count를 통해 정렬을 하고 싶을 때, 이 값에 따라 랭크를 부여하는 컬럼을 하나 더 만들어서 중간 레코드가 삭제되었을 때 혹은 comment_count 값이 변경되었을 때 그에 따라 전체 레코드의 랭크 컬럼의 값이 변동되는 것이다.

이렇게 하면 그 랭크 컬럼을 통해 No-Offset 방식을 적용할 수 있으니까.

그러나 상상만 해봐도 너무 비효율적이다. 테이블의 모든 레코드 값을 변경해야한다니… 혹시 내가 아직 모르는 좋은 알고리즘이 있나?

무튼 일단 기각


각 카테고리를 테이블로 전부 나눠서 하나의 카테고리 페이지가 20페이지를 넘기지 않게 하기

이건 어떤 종류의 서비스 혹은 어떤 도메인인지에 따라 적용을 할 수 있고 하지 못할 수도 있다고 생각한다.

가령 온라인 쇼핑몰과 같이 분류를 명확히 할 수 있고 또 그 개수가 많으며 운영자 측이 제어할 수 있는 상황이면 가능할 것 같다. 하지만 사용자들이 얼마나 글을 쓰느냐에 따라 달라지는 커뮤니티와 같은 도메인에선 적용하기 어렵지 않을까 싶다.


그냥 Offset을 사용하자 (단, 반정규화와 커버링 인덱스는 적용한다)

어차피 조회가 많이 되는 페이지는 앞쪽에 있는 페이지들이다. Offset 값을 변경하면서 테스트를 해본 결과, 반정규화와 커버링 인덱스가 모두 적용되어 성능이 개선된 상태에서 1초이상 걸리려면 Offset 값이 최소 200만은 넘어야 한다. 사용자가 많이 양보해서 아주 가끔씩이라도 4.5초까지 기다려 줄 수 있다면 1000만번째 글까지도 가능하다.

생각해보면 한 페이지당 10개씩 조회한다고 칠 때, 사용자들이 200만번째 이상의 글 즉, 20만 이상의 페이지 번호를 클릭하는 경우가 많을 지 생각해보면 별로 안할 것 같다는 생각은 든다.

혹시 내가 너무 괜한 걱정은 한 것은 아닌가? 반정규화와 커버링 인덱스만으로 사실 성능 개선은 충분했던 것이 아닐까? 하는 생각이 든다. 사실 큰 페이지 번호를 1초 이내로 성능을 개선할 방법이 생각나지 않기도 하다…ㅋ


다음 글에선 저번 프로젝트에서 동시성 처리 방식에 대한 부분도 맘에 들지 않았기 때문에, 그 부분에 대한 자가 피드백과 개선 과정을 적어보려 한다.





© 2021. All rights reserved.

----------Powered by Hydejack----------

Jun's Development Blog