Stay hungry, Stay foolish

index 페이지 로딩 속도 개선기 (Rails Cache) 본문

트러블슈팅

index 페이지 로딩 속도 개선기 (Rails Cache)

Jake2 2022. 7. 17. 16:01

 

현재 운영하고 있는 서비스의 인덱스 페이지가 많이 아팠습니다. 페이지를 렌더링 하는 시간이 프로덕션 기준 7s (로컬에서는 10s 를 넘겼습니다 ㅠㅠ) 를 넘어가는 끔찍한 상태였습니다.
저 정도 로딩 속도면 유저분들 다 도망갔어도 할 말 없습니다... 근본적으로 개선을 하긴 해야겠다 마음먹었습니다. 

음.. 근데, 어디가 문제지...? 


해결과정

route53, nginx loadbalancer 부터 다 뜯어봐야 하는 건가... 싶어 절망할 뻔했으나 일단 눈에 보이는 것부터 체크하기로 했습니다.

무식하지만 정직하게 체크했습니다. 컴포넌트를 하나씩 빼서 그 부분만 렌더링 시키며 속도를 확인해 보는 방식으로 말이죠.

클래스 카테고리 목록
클래스 목록
멘토 목록

 

이런 식으로 컴포넌트를 하나씩 꺼내면서 속도가 유독 느리다 싶은 부분을 확인하다 보니 문제는 비교적 가까운 곳에 단순하게 있더군요.

일단 문제는 크게 세 가지였습니다

 

1. 불필요한 쿼리 산재
먼저 불필요한 쿼리는 개발 당시에는 필요하던 쿼리였습니다.

대표적으로 맨 위 이미지인 job category 를 조회하는 컴포넌트만 꺼내서 페이지를 로딩하는데 4초가 넘게 걸리더군요

해당 job category 가 만들어질 당시에는 전체 클래스 숫자가 적어서, 대다수의 job category 에 클래스가 존재하지 않았습니다. 그렇다 보니 유저 입장에서 빈 필터를 체크해서 어 클래스가 하나도 없네? 하는 것보다는 클래스가 존재하는 job category 만 보여주도록 쿼리가 존재했던 거고요. 하지만 현재에 와서는 모든 job category에 클래스들이 존재했고 굳이 해당 쿼리를 살려둘 필요가 없었습니다. 

해당 쿼리는 날리고 20개 남짓한 job category 는 단순하게 전부 호출시켰습니다.
이렇게만 해줬는데도 해당 컴포넌트의 렌더링 시간이 4.7s -> 0.5s 로 대폭 감소했습니다.


2. caching 미적용
살펴보니 현재 제품에 inmemory db 를 이용한 cache 는 sidekiq message queue 작업을 위한 것뿐이었습니다.
memcached 를 환경설정까지는 해놓으셨지만 현재는 사용되는 부분이 없더라고요.
그래서 index페이지에 존재하는 정적인 partial 페이지들은 rails collection caching 을 사용해 memcached에 올리기로 했습니다.
( 사실 cache 를 적용시키고 추후 새로운 문제들을 만들어 내면서, 다음부터는 무조건 제대로 알 고쓰자...라는 마인드가 생기는 계기가 되었습니다.)
rails caching에 대해 제대로 공부하지 않고 적용시킨 대가는 아래에서 다시 말씀드리겠습니다... 🥲

어쨌거나 위의 1, 2번 문제만 개선했는데도 70%가 넘게 속도가 개선되는 걸 볼 수 있었습니다 ^ㅇ^


3. 로그 모니터링에 지속적으로 보이던 N+1 쿼리들

대표적으로 보이던 N+1중에 하나입니다. 사실 보면서 알고 있었음에도 개선해야 될게 하도 많아 엄두가 안 나고 있었던 와중에 index 페이지를 개선하며 같이 개선했습니다.

N+1 쿼리도 이곳저곳 산재해 있었지만 가장 자주 보이던 위의 경우를 살펴보면,
클래스 카드 컴포넌트에서 review 존재 유무를 체크하고, review count를 하는 과정에서 불필요하게 N+1 쿼리가 호출되는 경우였습니다. 왜인지는 모르겠으나 review 존재 유무 체크 후 review count를 위한 helper 호출 함수 속에서 한 번 더 review 존재 체크, review.sum, review.count 그리고 reviews_count helper method를 따로 한번 더 호출해서 동일한 쿼리를 5번씩 호출하고 있던 상황이었죠.

일단 근본적으로 만들어놓은 helper 함수 한개만 사용하도록 코드를 수정하고 해당 쿼리는 pre_load 시켜서 반복적으로 쿼리가 호출되지 않게 만들어줬습니다.
(-> rails N+1 쿼리 해결법 )


결과

어려운 작업은 없었지만 결과는 드라마틱하긴 했습니다.
(드라마틱하게 또 다른 문제를 만들었다는것만 빼면 말이죠...)

로컬 서버 기준 10.x s -> 2.x s 까지 80% 가까이 속도가 개선 되었습니다.
프로덕션에서도 7.6 s -> 1.7s 까지 속도가 개선된 걸 확인할 수 있었습니다.

 


또다른 문제 발생...

개선 몇일 후 cs 한 건이 들어왔습니다. 멘토님이 클래스 프로필 카드를 변경했는데 적용이 안된다는것...

뭔가 직감적으로 캐시를 적용시켜놓은게 문제가 됐을거란 생각이 들었습니다.

 

역시나 cache를 적용시키지 않은 다른 페이지에서는 이미지가 정상 반영되었고, chrome inspect를 키고 확인해보니 변경 전 이미지를 호출하고 있는게 보이더군요. rails 공식문서와 다른 자료들을 꼼꼼히 다시 확인해봤습니다.

 

문제의 핵심은 제가 적용시켜놓은 cache가 정확하게 뭘 memcached에 올려놓았는지, 해당 cache가 expired 되고 fetch 가 언제 되는지 명확하게 이해하지 않고 냅다 적용시켜놔서 생긴 문제였죠 😭

 

마지막으로 제가 이 기회에 제대로 학습할 수 있었던 Rails 의 Cache 에 대해 같이 학습하며 마무리 해보겠습니다.


Rails Cache

먼저 제가 사용한 Rails 의 collection cache는

<%= render partial: "classes_card",
  collection: @meetings, as: :meeting,
  layout: "layouts/grid",
  locals: {
    ref: "classes_list",
    grid_column: "col-50 desktop-20",
  },
  cached: true 
%>

이렇게 작성 할 수 있는데요.

이걸 풀어서 보면 아래와 같다고 할 수 있습니다.

<% @meetings.each do |meeting| %>
  <% cache meeting do %>
    <%= render partial: "classes_card",
      layout: "layouts/grid",
      locals: {
        ref: "classes_list",
        grid_column: "col-50 desktop-20",
      }
    %>
  <% end %>
<% end %>

자 이건 rails 공식문서에서 설명하고 있는 Russiandoll caching 과 같다고 할 수 있습니다.

<% @users.each do |user| %>
  <% cache user do %>
    ...
    <%= render user.posts %>
    ...
  <% end %>
<% end %>

=> 이렇게 cache를 거는 user 객체를 상속받는 다른 객체 (post)가 존재할때 그 객체인 post 가 변경 된다 해서 올려놓은 cache 가 변하지는 않습니다. 해당 cache 는 user 객체에 걸려있기 때문에 user 객체에 변경이 있을 때에만 기존 cache 가 expired 되고 fetch 된다고 볼 수 있죠.

따라서 위와 같은 문제를 해결해 주기 위해

class User < ApplicationRecord
  has_many :post
end

class Post < ApplicationRecord
  belongs_to :User, touch: true
end

post 변경시 User 모델에도 변경이 생기도록 위의 코드처럼 touch 를 모델에 추가하거나

post 객체를 업데이트 하는 로직안에 User에 time stamp 를 찍어주는 코드를 추가하던지 해야합니다.

 

일단 해당 이슈를 해결하기 위해 필요했던 핵심은
view cache 가 expired 되는 시점.
→ collection cache 에서 collection 을 걸어 놓은 객체들에 변경이 있을 때 cache 가 fetch 된다.

+++

그리고 한가지 더 cs가 들어오기전에 확인 된것이 있었는데, 클래스 카드의 마감시간이 변경되지 않는 것이었습니다.

 

해당 문제는

-> 클래스 카드의 마감까지 남은 시간은 모델과는 연관 없는 static한 요소이므로 cache의 변경이 적용 되지 않았던 것이었어요. 

Rails에서는 cache 에 조건문을 줄 수 있는 방법이 있습니다.

따라서 아래 코드와 같이 클래스 상태가 종료인 클래스에 한해서만 cache를 적용하도록 해주었습니다.

<% @meetings.each do |meeting| %>
  <% cache_if meeting.ended?, meeting do %>
    <%= render partial: "classes_card",
      layout: "layouts/grid",
      locals: {
        ref: "classes_list",
        grid_column: "col-50 desktop-20",
      }
    %>
  <% end %>
<% end %>

 

이번 이슈로 얻게 된 교훈...

뭐든 새로 적용해보는 기술들 기존에 적용 되어 있던 기술들 잘 알지 못하면 안쓰니만 못하다 😇

왜 되고 왜 안되는지 꼭! 꼭! 알고 쓰도록 하자!

 

 

<참고자료>

- Rails View caching

- Rails Guide - caching

- Rails Collection Caching

- Solving N+1 problem in Rails

- Joins vs Preload vs Includes vs Eager load in Rails

'트러블슈팅' 카테고리의 다른 글

Zoom ThirdParty API 트러블 슈팅과 회고  (0) 2022.05.14
Comments