Rails의 ActiveRecord를 처음 본 순간이 아직도 기억납니다. 그게 바로 2005년, PHP 앱을 만들면서 SQL 쿼리를 손으로 직접 작성하던 시절이었죠. 그런데 ActiveRecord를 접하고 나서 데이터베이스를 다루는 일은 지루한 노동에서 어쩌면 '재미있기까지 한' 즐거운 작업으로 완전히 바뀌어 버렸습니다.
...그러다 곧 성능 문제를 눈치채기 시작했습니다.
ActiveRecord 자체가 느린 것은 아니었습니다. 단지 저는 실제로 실행되는 쿼리에 더 이상 주의를 기울이지 않게 되었을 뿐입니다. 그리고 알고 보니, Rails CRUD 앱에서 가장 널리 쓰이는 관용적인 데이터베이스 쿼리 중 일부는 기본 설정 그대로 사용하면 대용량 데이터셋에서 확장성이 상당히 떨어진다는 사실이 밝혀졌습니다.
이 글에서는 그중 가장 큰 원인 세 가지를 다룹니다. 그 전에 먼저, 내 DB 쿼리가 규모가 커져도 잘 동작할지 어떻게 판단하는지 살펴보겠습니다.
성능 측정하기
데이터셋이 충분히 작다면 모든 DB 쿼리는 빠릅니다. 따라서 실질적인 성능을 파악하려면 프로덕션 수준의 데이터베이스를 기준으로 벤치마크해야 합니다. 이 글의 예제에서는 약 22,000개의 레코드를 가진 faults 테이블을 사용합니다.
여기서는 PostgreSQL을 사용합니다. PostgreSQL에서 성능을 측정하는 방법은 explain을 활용하는 것입니다. 예를 들어 다음과 같습니다.
# explain (analyze) select * from faults where id = 1;
QUERY PLAN
--------------------------------------------------------------------------------------------------
Index Scan using faults_pkey on faults (cost=0.29..8.30 rows=1 width=1855) (actual time=0.556..0.556 rows=0 loops=1)
Index Cond: (id = 1)
Total runtime: 0.626 ms
이 결과에는 쿼리 실행에 대한 예상 비용 (cost=0.29..8.30 rows=1 width=1855)과 실제 소요 시간 (actual time=0.556..0.556 rows=0 loops=1)이 함께 표시됩니다.
좀 더 읽기 편한 형식을 선호한다면, PostgreSQL에게 YAML 형식으로 결과를 출력하도록 요청할 수 있습니다.
# explain (analyze, format yaml) select * from faults where id = 1;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Index Scan" +
Scan Direction: "Forward" +
Index Name: "faults_pkey" +
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.29 +
Total Cost: 8.30 +
Plan Rows: 1 +
Plan Width: 1855 +
Actual Startup Time: 0.008 +
Actual Total Time: 0.008 +
Actual Rows: 0 +
Actual Loops: 1 +
Index Cond: "(id = 1)" +
Rows Removed by Index Recheck: 0+
Triggers: +
Total Runtime: 0.036
(1 row)
일단 여기서는 "Plan Rows"와 "Actual Rows" 두 항목에만 집중하겠습니다.
- Plan Rows: 최악의 경우, 쿼리에 응답하기 위해 DB가 몇 개의 행을 훑어야 하는지
- Actual Rows: 실제로 쿼리를 실행했을 때 DB가 훑은 행의 수
위 예제처럼 "Plan Rows"가 1이라면 해당 쿼리는 아마 확장성이 좋을 것입니다. 반면 "Plan Rows"가 테이블의 전체 행 수와 같다면, 그 쿼리는 "풀 테이블 스캔(full table scan)"을 수행하게 되며 확장성이 떨어진다는 의미입니다.
이제 쿼리 성능을 측정하는 방법을 알았으니, 자주 쓰이는 Rails 관용구들이 실제로 어떻게 동작하는지 살펴보겠습니다.
카운트(Count)
Rails 뷰에서 다음과 같은 코드를 정말 흔하게 볼 수 있습니다.
Total Faults <%= Fault.count %>
이 코드는 대략 다음과 같은 SQL을 생성합니다.
select count(*) from faults;
explain에 넣어서 결과를 확인해 보겠습니다.
# explain (analyze, format yaml) select count(*) from faults;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Aggregate" +
Strategy: "Plain" +
Startup Cost: 1840.31 +
Total Cost: 1840.32 +
Plan Rows: 1 +
Plan Width: 0 +
Actual Startup Time: 24.477 +
Actual Total Time: 24.477 +
Actual Rows: 1 +
Actual Loops: 1 +
Plans: +
- Node Type: "Seq Scan" +
Parent Relationship: "Outer"+
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.00 +
Total Cost: 1784.65 +
Plan Rows: 22265 +
Plan Width: 0 +
Actual Startup Time: 0.311 +
Actual Total Time: 22.839 +
Actual Rows: 22265 +
Actual Loops: 1 +
Triggers: +
Total Runtime: 24.555
(1 row)
헉! 단순한 count 쿼리 하나가 22,265개 행, 즉 테이블 전체를 훑고 있네요. PostgreSQL에서 count는 항상 전체 레코드 집합을 순회합니다.
쿼리에 where 조건을 추가하면 순회하는 레코드 집합의 크기를 줄일 수 있습니다. 요구사항에 따라 성능이 허용 가능한 수준까지 집합 크기를 낮출 수도 있습니다.
이 문제를 우회하는 또 다른 방법은 count 값을 캐싱하는 것입니다. Rails에서는 다음과 같이 설정하면 자동으로 처리해 줍니다.
belongs_to :project, :counter_cache => true
또한, 쿼리 결과에 레코드가 존재하는지만 확인하려는 경우라면 다른 선택지가 있습니다. Users.count > 0 대신 Users.exists?를 사용해 보세요. 생성되는 쿼리가 훨씬 더 효율적입니다. (이 부분을 알려주신 독자 Gerry Shaw님께 감사드립니다.)
정렬(Sort)
인덱스 페이지. 거의 모든 앱에 최소 하나씩은 있는 화면입니다. 데이터베이스에서 최신 레코드 20개를 가져와 화면에 보여주면 됩니다. 이보다 간단할 수 있을까요?
레코드를 불러오는 코드는 대략 다음과 같을 것입니다.
@faults = Fault.order(created_at: :desc)
이 코드가 만들어내는 SQL은 다음과 같습니다.
select * from faults order by created_at desc;
그럼 분석해 보겠습니다.
# explain (analyze, format yaml) select * from faults order by created_at desc;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Sort" +
Startup Cost: 39162.46 +
Total Cost: 39218.12 +
Plan Rows: 22265 +
Plan Width: 1855 +
Actual Startup Time: 75.928 +
Actual Total Time: 86.460 +
Actual Rows: 22265 +
Actual Loops: 1 +
Sort Key: +
- "created_at" +
Sort Method: "external merge" +
Sort Space Used: 10752 +
Sort Space Type: "Disk" +
Plans: +
- Node Type: "Seq Scan" +
Parent Relationship: "Outer"+
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.00 +
Total Cost: 1784.65 +
Plan Rows: 22265 +
Plan Width: 1855 +
Actual Startup Time: 0.004 +
Actual Total Time: 4.653 +
Actual Rows: 22265 +
Actual Loops: 1 +
Triggers: +
Total Runtime: 102.288
(1 row)
여기서 확인할 수 있는 것은, 이 쿼리를 실행할 때마다 DB가 22,265개 행 전체를 매번 정렬하고 있다는 사실입니다. 좋지 않죠!
기본적으로 SQL의 모든 "order by" 절은 레코드 집합을 그 시점에 실시간으로 정렬하도록 만듭니다. 캐싱도 없고, 우리를 구원해 줄 마법 같은 장치도 없습니다.
해결책은 인덱스를 활용하는 것입니다. 이 예제처럼 단순한 경우라면 created_at 컬럼에 정렬된 인덱스를 추가하는 것만으로도 쿼리 속도가 상당히 개선됩니다.
Rails 마이그레이션에는 다음과 같이 작성할 수 있습니다.
class AddIndexToFaultCreatedAt < ActiveRecord::Migration
def change
add_index(:faults, :created_at)
end
end
이 마이그레이션은 다음 SQL을 실행합니다.
CREATE INDEX index_faults_on_created_at ON faults USING btree (created_at);
맨 끝의 (created_at)이 정렬 순서를 지정합니다. 기본값은 오름차순입니다.
이제 정렬 쿼리를 다시 실행해 보면, 더 이상 정렬 단계가 포함되지 않는 것을 확인할 수 있습니다. 인덱스에서 미리 정렬된 데이터를 그대로 읽어오기만 하면 되기 때문입니다.
# explain (analyze, format yaml) select * from faults order by created_at desc;
QUERY PLAN
----------------------------------------------
- Plan: +
Node Type: "Index Scan" +
Scan Direction: "Backward" +
Index Name: "index_faults_on_created_at"+
Relation Name: "faults" +
Alias: "faults" +
Startup Cost: 0.29 +
Total Cost: 5288.04 +
Plan Rows: 22265 +
Plan Width: 1855 +
Actual Startup Time: 0.023 +
Actual Total Time: 8.778 +
Actual Rows: 22265 +
Actual Loops: 1 +
Triggers: +
Total Runtime: 10.080
(1 row)
여러 컬럼을 기준으로 정렬한다면, 역시 여러 컬럼으로 정렬된 인덱스를 생성해야 합니다. Rails 마이그레이션에서는 다음과 같이 작성합니다.
add_index(:faults, [:priority, :created_at], order: {priority: :asc, created_at: :desc)
쿼리가 복잡해질수록 explain으로 검증해 보는 습관이 중요합니다. 초기부터 자주 확인하세요. 사소한 쿼리 변경 하나가 PostgreSQL이 정렬에 인덱스를 사용하지 못하게 만들어 버리는 경우를 발견하게 될 수도 있습니다.
LIMIT과 OFFSET
인덱스 페이지에 데이터베이스의 모든 항목을 담는 경우는 거의 없습니다. 대신 페이지네이션을 적용해 한 번에 10개, 30개, 혹은 50개씩만 보여주죠. 가장 흔한 방식은 limit과 offset을 함께 사용하는 것입니다. Rails에서는 다음과 같이 표현합니다.
Fault.limit(10).offset(100)
이 코드는 다음과 같은 SQL을 생성합니다.
select * from faults limit 10 offset 100;
그런데 explain을 돌려보면 이상한 점이 보입니다. 스캔된 행 수가 110개, 즉 limit과 offset을 더한 값이라는 것입니다.
# explain (analyze, format yaml) select * from faults limit 10 offset 100;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Limit" +
...
Plans: +
- Node Type: "Seq Scan" +
Actual Rows: 110 +
...
offset을 10,000으로 바꾸면 스캔되는 행 수가 10,010개로 늘어나고, 쿼리는 무려 64배나 느려집니다.
# explain (analyze, format yaml) select * from faults limit 10 offset 10000;
QUERY PLAN
--------------------------------------
- Plan: +
Node Type: "Limit" +
...
Plans: +
- Node Type: "Seq Scan" +
Actual Rows: 10010 +
...
여기서 불편한 결론 하나가 도출됩니다. 페이지네이션을 사용할 때, 뒤쪽 페이지일수록 앞쪽 페이지보다 로딩이 느리다는 것입니다. 위 예제에서 페이지당 100개 항목을 표시한다고 가정하면, 100페이지는 1페이지보다 13배 느립니다.
그렇다면 어떻게 해야 할까요?
솔직히 말하면, 저도 완벽한 해결책을 찾지 못했습니다. 우선 데이터셋 크기를 줄여서 애초에 수백, 수천 페이지가 필요하지 않도록 할 수 있는지 검토해 보는 것이 좋겠습니다.
레코드 집합을 줄일 수 없다면, 최선의 방법은 offset/limit을 where 절로 대체하는 것일 수 있습니다.
# 날짜 범위를 사용할 수 있습니다
Fault.where("created_at > ? and created_at < ?", 100.days.ago, 101.days.ago)
# ...아니면 id 범위를 사용할 수도 있습니다
Fault.where("id > ? and id < ?", 100, 200)
결론
이 글이 PostgreSQL의 explain 기능을 적극적으로 활용해 DB 쿼리의 잠재적 성능 문제를 찾아내야 한다는 확신을 드렸기를 바랍니다. 가장 단순해 보이는 쿼리조차 심각한 성능 문제를 일으킬 수 있으니, 미리 점검해 두는 것이 결국 이득입니다. :)