이 글은 루비 크리스마스 달력(Advent Calendar)에 참여하는 글(18일)입니다.

부제는 Rails에서 적절히 Ruby사용하기

초보들이 무심코 지나치기 쉬운 여러가지 항목을 적절히 루비 함수(method)를 이용하여 최적화하는 방법을 소개합니다.(단, 경계하면서 보셔야 할 점은 "이 함수를 권장한다", "이 방법을 사용하세요"라고 소개하는 글이 아닌, 이런 상황엔 이러이러하니까 이런저런 방법을 사용하면 이득을 볼 수 도 있다 라고 소개하는 글 이기때문에 자신의 코드와 조화롭게 잘 어울리는지 따져보셔야겠습니다.)

find_by와 find(detect)

전체 사용자를 출력하고, 또 따로 여권번호가 1234인 사용자를 출력하는 코드를 작성해보겠습니다.

users = User.all # (0)

puts '전체 사용자 목록'  
users.each do |user|  
  puts user.name # (1) 전체 사용자의 이름 출력
end

puts '여권번호가 1234인 사용자'  
puts users.find_by(passport: 1234).name # (2) 여권번호가 1234인 사용자의 이름 출력  
[코드2-1]

위 [코드2-1]의 문제는 무엇일까요? 없습니다!(단호박)

하지만 비뚤어진 마음으로 코드를 흠집내봅시다. 위 코드에서 쿼리는 몇개가 날아갈까요!? 바로 보이시는 분도 계실꺼고 잘 모르시는 분도 계실텐데 일단 정답은 2개의 쿼리가 날아갑니다.

주석으로 되어있는 (0)에서 User Load (0.6ms) SELECT "users".* FROM "users" 이와같은 쿼리가 날아갈꺼고, 역시 주석으로 되어있는 (2)에서 User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."passport" = $1 LIMIT 1 [["id", 1234]] 이와같은 쿼리가 날아갈겁니다.

그런데 여기서 쿼리를 하나로 줄이고 싶습니다. 속도는 얼마 차이 안나겠지만 굳이 그러고 싶다면 어떻게 해야할까요?

이 부분 소제목에서 보시는 것처럼 ActiveRecord method인 find_by가 문제(?)입니다. find_by method를 사용하게되면 인자로 들어간 값을 토대로 sql의 where 절을 만들어서 쿼리를 하게됩니다. 하지만 [코드2-1]에서는 db에 다시 쿼리를 하지 않고도 여권번호가 1234인 사용자를 가려낼 수 있죠.

ruby의 Enumerable의 method인 find를 사용하면 1개의 db통신으로 [코드2-1]과 같은 목표를 달성 할 수 있습니다.

users = User.all

puts '전체 사용자 목록'  
users.each do |user|  
  puts user.name # 전체 사용자의 이름 출력
end

puts '여권번호가 1234인 사용자'  
puts users.find{|user| user.passport == 1234).name  
코드[2-2]

위 코드[2-2]는 코드[2-1]의 마지막줄의 find_by method쓴 부분만 .find{|user| user.passport == 1234)로 바꿔서 사용했습니다. 첫 행(line)의 users에서 모든 사용자의 객체가 메모리에 올라왔으므로 메모리에 있는 값들 중에서 passport가 1234인 값을 찾으므로써 db에 추가적인 쿼리가 필요 없는 것이죠.

※ find와 detect는 이름만 다른 같은 함수입니다.

※ 이 부분에서 설명한것과 비슷한 예제는 ActiveRecord 함수 where과 Array의 select 함수가 있습니다.

select, pluck

이 부분에서는 위에서 사용한 코드를 재 사용하겠습니다.

users = User.all

puts '전체 사용자 목록'  
users.each do |user|  
  puts user.name # 전체 사용자의 이름 출력
end

puts '여권번호가 1234인 사용자'  
puts users.find{|user| user.passport == 1234).name  
코드[3-1]

위 [코드3-1]은 위에서 개선작업(?)을 거친 [코드2-2]와 같습니다. 개선작업을 거쳤지만 또 코드에 꼬투리를 잡아봅시다. 뭐가 문제일까요? 문제따위는 없습니다.

하지만 또 굳이 문제를 발견해본다면 이렇습니다. 코드의 목표는 결국 사용자들의 이름을 출력해주는 겁니다. 조금 더 정확이 이야기해보면 출력은 이름만 해주지만 밑에 여권번호(passport) 비교하는 부분이 있기때문에 이름과 여권번호만 있으면됩니다. 그런데 [코드3-1]에선 사용자의 이름과 여권번호뿐만아니라 나이, 직업, 키, 몸무게 등등 User의 모든 정보를 가져오게 됩니다. 뭐 사용자의 자기소개 column이라도 있다고 치면 사용하지 않는 많은 정보들이 가져와지게 되서 어마어마한 메모리 낭비 및 db와의 통신 시간이 더 늘어나게되어 시간낭비입니다.

이럴때 사용하는게 select 함수 입니다.

사용법도 아주 간단합니다.

users = User.all.select(:name, :passport)

puts '전체 사용자 목록'  
users.each do |user|  
  puts user.name # 전체 사용자의 이름 출력
end

puts '여권번호가 1234인 사용자'  
puts users.find{|user| user.passport == 1234).name  
[코드3-2]

위 [코드3-2]가 [코드3-1]과 비교하여 달라진 점은 첫 행에서 User.all 다음에 .select(:name, :passport)를 붙였습니다. select에 사용할 컬럼의 이름만 넘기면 됩니다.

당연히 불필요한것을 가져오지 않았기때문에 메모리에 올라온 양은 훨씬 적을꺼고 가져오는데 걸리는 시간도 훨씬 줄어듭니다.

[1] pry(main)> User.all
  User Load (1294.7ms)  SELECT "users".* FROM "users"
[2] pry(main)> User.all.select(:name, :passport)
  User Load (526.4ms)  SELECT "users"."email", "users"."passport" FROM "users"

그냥 User.all했을땐 1294ms User.all.select(:name, :passport)했을땐 526ms가 걸리네요. 저는 꽤 큰 차이라고 보여지는데 어떠신가요? 이 차이는 row의 개수가 많을수록, 불필요한 정보의 양이 클 수록 차이가 날것입니다.

pluck도 비슷한 역할을 하는 함수인데, return 값이 조금은 다릅니다.

users = User.all.select(:id)를 하게되면 users에는 id만 담긴 User::ActiveRecord_Relation이 return됩니다.

pluck는 Array가 반환되고,

[3] pry(main)> User.all.pluck(:name, :passport)
   (553.2ms)  SELECT "users"."email", "users"."passport" FROM "users"
=> [["han", 21332],
 ["gigi", 6758],
 ["char", 5346],
 ["babo", 23002]]

위와 같은 형태가 됩니다. 적절히 골라 쓰시면 되겠습니다. 저는 보통 id만 가져온다거나 그럴때 user_ids = User.pluck(:id) 처럼 사용합니다.

order와 sort_by!

rails 공식 문서에 보면 Eager Loading Associations
이 있습니다. 혹시 못보셨다면 꼭 한번 보시길 바랍니다.

User model과 Car model이 있고, 아래와 같은 코드가 있습니다.

users = User.includes(:cars).limit(2)

users.each do |user|  
  user.cars.each do |car|
    puts car.name
  end
end  

[코드4-1]

user가 소유한 car을 가져와서 이름을 출력해주는 예제입니다. 그런데 여기서 자동차의 가격 순으로 정렬 하여 보여주고 싶습니다.

users = User.includes(:cars).limit(2)

users.each do |user|  
  user.cars.order(:price).each do |car|
    puts car.name
  end
end  

[코드4-2]

ActiveRecord의 order method를 사용하여 정렬에 성공하였습니다. 아무런 문제가 없는 코드처럼 보이죠? 네 실제로도 아무런 문제가 없습니니다.

그러나 최적화 관점으로 한번 살펴보면 이렇습니다. 위 [코드4-2]을 실행하면 몇개의 쿼리가 날아갈까요? N + 1 queries problem를 피하기위해서 includes를 사용했기때문에 2개의 쿼리가 날아갈 것이다라고 착각하기 쉽습니다.

하지만 N + 1 queries problem여전히 발생합니다. 왜냐하면 정렬을 위해 사용한 order method가 sql의 order_by 구문을 붙여서 새로 쿼리를 날리기 때문입니다.

User Load (0.8ms)  SELECT  "users".* FROM "users" LIMIT 2  
Car Load (0.4ms)  SELECT "cars".* FROM "cars" WHERE "cars"."user_id" = $1  ORDER BY "cars"."price" ASC  [["user_id", 1]]  
Car Load (0.4ms)  SELECT "cars".* FROM "cars" WHERE "cars"."user_id" = $1  ORDER BY "cars"."price" ASC  [["user_id", 2]]  

그럼 다시 N + 1 queries problem를 피하기 위해서 어떻게 해야할까요? 이 부분의 소제목에 order과 함께 언급된 sort_by!를 이용합니다. sort_by는 ruby의 Array의 method이기때문에 메모리에 올라와 있는 요소들을 정렬하기때문에 위에서 사용한 order method를 사용했을때와 똑같은 결과를 얻을 수 있지만 경우에 따라서 훨씬 최적화에 이득을 볼 수 있습니다.

users = User.includes(:cars).limit(2)

users.each do |user|  
  user.cars.order(:price).each do |car|
  user.cars.to_a.sort_by!{|car| car.price}.each do |car|
    puts car.name
  end
end  
[코드4-3]

위의 [코드4-3]는 [코드4-2]의 예제를 그대로 가져와서 order method를 사용하는 대신 cars를 to_a를 사용하여 Array로 만든다음 sort_by!를 사용하여 정렬을 하였습니다. [코드4-3]를 수행하면 db 쿼리는 2개만 날라갑니다.

count와 length 그리고 size

이 부분에서 설명할 내용은 order와 sort_by!에서 사용한 코드를 조금 바꿔서 재 사용해보겠습니다.

users = User.includes(:cars).limit(2)

users.each do |user|  
  puts user.cars.count
end  
[코드5-1]

다시 N + 1 queries problem이 언급되는데요, 위 코드에는 N + 1 queries problem이 있을까요 없을까요? 있습니다.

바로 위에서 살펴본 ActiveRecord의 order method를 사용할때와 마찬가지로 count method를 사용하면 sql의 count 쿼리를 사용하여 새롭게 쿼리를 만들어 db와 통신하게 됩니다. 그렇기때문에 다시 N + 1 queries problem이 발생하게 되는거죠.

User Load (0.8ms)  SELECT  "users".* FROM "users" LIMIT 2  
Car Load (0.4ms)  SELECT COUNT(*) FROM "cars" WHERE "cars"."user_id" = $1  ORDER BY "cars"."price" ASC  [["user_id", 1]]  
Car Load (0.4ms)  SELECT COUNT(*) FROM "cars" WHERE "cars"."user_id" = $1  ORDER BY "cars"."price" ASC  [["user_id", 2]]  

다시한번 N + 1 queries problem을 극복하기위해서 length를 사용하면 됩니다.

users = User.includes(:cars).limit(2)

users.each do |user|  
  puts user.cars.length
end  
[코드5-2]

위 [코드5-2]의 결과는 [코드5-1]의 결과와 같지만 두번의 쿼리만 날아갑니다.

이 부분의 소제목에 size도 언급하였는데 ruby 공식문서에서는 length의 alias라고만 써있고 length 부분으로 연결해뒀네요.

근데 rails에서 사용하면 미묘한 차이가 있습니다. 글 The Elements of Style in Ruby #13: Length vs Size vs Count첫번째 댓글에서 아주 간결하게 설명해줬네요.

length는 메모리에 올려놓고 그 개수를 세는거고, size는 조금 똑똑하게 메모리에 없으면 count 쿼리를 하고, 메모리에 있으면 메모리에 있는 요소의 개수를 센다고 합니다.

언뜻보면 size가 무조건 좋은것 같긴한데, 저는 코드상에서 바로 파악하기위해서(count 쿼리를 날리는지 안날리는지) length를 사용합니다.

SQL Caching

SQL Caching도 굉장히 잘 이용해야 합니다.

예제 코드를 보시죠.

Participant.pluck(:no).each do |participant_no|  
  puts Seat.find_by('no < ?', participant_no).section
end  
[코드6-1]

으 예제를 생각해내서 하다보니 좀 억지 예제가 나오는것같습니다.

아무튼 참가자가 있고 입장 순으로 no를 부여받고 no에 따라서 좌석의 위치를 알려주기위한 구역을 출력하는 예제입니다. 위 [코드6-1]에는 어떤 문제가 있을까요?

이 글에서 계속 나오고있는 N + 1 queries problem입니다. Participant의 전체 개수가 10개라면 [코드6-1]을 수행을 위해선 11개의 쿼리가 날아갑니다. Participant가 100개라면 101개, 1000개라면 1001개의 쿼리가 날아갈것입니다. 반드시 개선해야할 문제입니다.

Participant과 Seat사이에는 어떠한 관계가 맺어진 model이 아니라서 includes 같은 함수를 사용해서 극복하기도 힘듭니다. 이럴땐 SQL Caching가 된다는 점을 기억하고 코드를 작성해야 합니다.

Participant.pluck(:no).each do |participant_no|  
  puts Seat.all.find{|s| s.no < participant_no}.section
end  
[코드6-2]

위 [코드6-2]가 개선된 형태입니다. Participant의 개수가 몇개이던 상관없이 두번의 db통신으로 [코드6-1]과 같은 목표를 달성 할 수 있습니다. 위에 링크해드린 SQL Caching에 보면

If Rails encounters the same query again for that request, it will use the cached result set as opposed to running the query against the database again.

이렇게 나와있습니다. 같은 하나의 리퀘스트에서 같은 쿼리를 또 날리게되면 캐쉬된 결과에서 가져오게 됩니다. [코드5-1]에서는 Participant Load (0.8ms) SELECT "participants"."no" FROM "participants" 전체 Participant를 가져오는 쿼리 1번, Seat Load (1.8ms) SELECT "seats".* FROM "seats" 전체 Seat를 가져오는 쿼리 1번 총 두번의 쿼리를 날리게 됩니다.

어떻게 된거냐면 [코드6-1]에서는 Seat를 가져오는 쿼리가 Participant의 개수 만큼 날라가는 반면에, [코드6-2]에서도 똑같이 Participant의 개수만큼 Seat를 가져오는 쿼리를 실행하지만 [코드6-2]의 2행을 수행하는 동안엔 항상 같은 쿼리가 날아갑니다. 그렇기때문에 최초에 한번 db 쿼리를 날리고 그 결과를 메모리에 가지고 있는 상태에서 두번째 이상부터는 같은 쿼리를 시도했기때문에 메모리에 있는걸 참조하게 됩니다.

log_level:debug로 해놓고 살펴보면

Participant Load (0.9ms)  SELECT  "participants"."no" FROM "participants"  
Seat Load (1.3ms)  SELECT  "seats".* FROM "seats"  
CACHE (0.0ms)  SELECT  "seats".* FROM "seats"  
CACHE (0.0ms)  SELECT  "seats".* FROM "seats"  
CACHE (0.0ms)  SELECT  "seats".* FROM "seats"  
CACHE (0.0ms)  SELECT  "seats".* FROM "seats"  
CACHE (0.0ms)  SELECT  "seats".* FROM "seats"  
CACHE (0.0ms)  SELECT  "seats".* FROM "seats"  

위와 같이 최초에 한번만 쿼리를 하고 이후부터는 캐쉬된 결과를 사용하기때문에 0.0ms라고 표시가 되고 실제로 request에 대한 response도 빨리 옵니다.

마무리

저도 rails 초보라서 초보자를 위한 글을 쓰는게 맞나 싶기도 한데, 제가 실제로 겪은 것들을 기반으로 혹시 저처럼 실수하시는분이 계실까 하여 작성해봅니다. 말씀드렸다시피 저도 rails를 잘하지 못하는 만큼 글에 오류가 있을 수 있습니다. 가르침 부탁드립니다. 감사합니다.