백엔드 개발자라면 여러 배치잡을 돌리게 된다. 그 중 가장 흔한 유형은 다음과 같을 것이다.
점수 기준으로 상위 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
잘보고갑니당