상위 N건만 뽑아주세요

문제 상황

백엔드 개발자라면 여러 배치잡을 돌리게 된다. 그 중 가장 흔한 유형은 다음과 같을 것이다.

점수 기준으로 상위 4,000개의 게시물만 뽑아주세요! 점수가 같다면 먼저 생성된 걸 뽑아주실래요?

테이블의 스키마는 다음과 같다.

create table posts
(
    id         bigserial
        primary key,
    title      varchar,
    content    varchar,
    score      integer,
    created_at timestamp(6) not null,
    updated_at timestamp(6) not null
);
create index index_posts_on_score
    on posts (score);

위와 같이 score를 대상으로는 인덱스가 잡혀있는 상황이다. 실제와 비슷한 값을 만들어주기 위해, 다음과 같이 DB를 준비하자.

# seeds.rb
class Generator
  DEFAULT_SIZE = 1000

  def initialize(size = nil)
    @size = size || DEFAULT_SIZE
  end

  def generate
    title = Faker::Lorem.sentence(word_count: 3).freeze
    content = Faker::Lorem.paragraph(sentence_count: 100).freeze
    (0...@size).map do |_|
      {
        title: title,
        content: content,
        score: Faker::Number.between(from: 1, to: 20000),
        created_at: Time.now,
        updated_at: Time.now
      }
    end
  end
end

g = Generator.new(1000)

3000.times do |i|
  puts "Generating #{i} of 3000"
  Post.insert_all(g.generate)
end

실험을 위해 위 커맨드를 여러번 조건을 바꿔가며 집어넣어 약 1,800만건의 row를 준비해두었다.

select *
from posts
order by score, created_at desc
limit 4000;

나이브한 구현은 위와 같다. 실행해보면 약 6초 이상이 걸린다.

실제 사용되는 서비스에서는 위보다 쿼리가 복잡한 경우가 많고, row도 훨씬 많아 아주 긴 쿼리가 되기 쉽다.

위 쿼리의 실행계획이다. sort에서 당연히 가장 많은 시간이 소요되는 것을 알 수 있다.

자르고 정렬하기

우선 4,000개만 가져오면 된다는 것을 생각해보자. 위 쿼리는 나머지 1,800만개를 전부 정렬하느라 오랜 시간이 걸리고 있다. 그런데 우리가 필요한 건 4,000개뿐인데, 나머지를 다 정렬할 필요가 있을까?

아니다. where절로 범위를 좁힌 후에 정렬하자.

select count(*)
from posts
where score >= 19900;

대략적인 점수의 확인을 위해 위 쿼를 실행해보면 4,500개 정도의 row를 반환함을 알 수 있다.

이제 위 개념을 도입한 쿼리는 다음과 같을 것이다.

select *
from posts
where score >= 19900
order by score, created_at desc
limit 4000;

실행해보면 약 500ms정도가 걸린다. 아까의 쿼리보다 약 10배 정도의 성능 향상을 거둔 것이다. 실행 계획은 다음과 같다.

구현

실제 배치잡 처리를 위해서는 좀 더 복잡한 로직이 필요하다. 우리가 score 기준으로 필터링을 했는데 4,000개보다 적은 row를 얻었다 하자. 이 때 추가적인 fetching이 필요하기 때문이다. 루비를 이용하면 대략 다음과 같이 구현할 수 있다.

# bound.rb
class Bound
  STEP = 5

  def initialize(upper_bound:, lower_bound:)
    @upper_bound = upper_bound
    @lower_bound = lower_bound
  end

  def next
    return nil if @upper_bound <= 0
    Bound.new(upper_bound: @lower_bound, lower_bound: @lower_bound - STEP)
  end

  def range
    @lower_bound..@upper_bound
  end
end
# sorted_post.rb
class SortedPost
  attr_reader :fetched_count

  DEFAULT_SIZE = 1000
  DEFAULT_BOUND = Bound.new(upper_bound: 9999, lower_bound: 90)

  def initialize(size: DEFAULT_SIZE)
    @size = size
    @fetched_count = 0
  end

  def posts
    fetch(DEFAULT_BOUND, []).first(size)
  end

  private

  attr_reader :size

  def fetch(bound, current_posts)
    return current_posts if bound.nil?
    return current_posts if current_posts.size >= size

    @fetched_count += 1
    fetch(bound.next, current_posts + Post.where(score: bound.range).order(score: :desc).limit(size).to_a)
  end
end

사용할 때는 다음과 같이 사용하면 된다.

sorted_posts = SortedPost.new(size: 100)
sorted_posts.posts
sorted_posts.fetched_count

좋은 웹페이지 즐겨찾기