이 포스트에서 우리는 Rails 뷰 성능을 개선하기 위한 시도되고 진정한 방법을 살펴볼 것입니다. 특히 데이터베이스 효율성, 보기 조작 및 캐싱에 중점을 둘 것입니다.
"조기 최적화는 모든 악의 근원이다"라는 문구가 문맥에서 조금 벗어났다고 생각합니다. 간단한 최적화 기술이 지적될 때 개발자가 코드 검토 중에 이것을 사용하는 것을 자주 들었습니다. "작동시킨 다음 최적화할 것"이라는 유명한 말을 알고 계실 것입니다. 그런 다음 테스트하고, 디버그하고, 다시 테스트하는 식입니다.
감사하게도 코드 작성을 시작하는 순간부터 사용할 수 있는 간단하고 효과적인 성능 및 최적화 기술이 몇 가지 있습니다.
<블록 인용>👋 이 기사가 마음에 드시면 Ruby 성능 모니터링 체크리스트에서 다른 Ruby(on Rails) 성능 기사를 살펴보세요.
게시물 전체에서 우리는 기본 Rails 앱을 고수하고 개선하고 결과를 비교할 것입니다.
기본 Rails 앱에는 다음 모델이 있습니다.
-
사람(주소가 많음)
- 이름:문자열
- votes_count:정수
-
프로필(Person에 속함)
- 주소:문자열
Person 모델은 다음과 같습니다.
# == Schema Information
#
# Table name: people
#
# id :integer not null, primary key
# name :string
# votes_count :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class Person < ApplicationRecord
# Relationships
has_many :profiles
# Validations
validates_presence_of :name
validates_uniqueness_of :name
def vote!
update votes_count: votes_count + 1
end
end
이것은 프로필 모델의 코드입니다.
# == Schema Information
#
# Table name: profiles
#
# id :integer not null, primary key
# address :text
# person_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class Profile < ApplicationRecord
# Relationships
belongs_to :person
# Validations
validates_presence_of :address
end
1000명을 채울 수 있는 시드 파일도 있습니다. Faker gem을 활용하면 쉽게 할 수 있습니다.
이제 ApplicationController에서 "home"이라는 액션을 생성할 것입니다.
def home
@people = Person.all
end
home.html.erb의 코드는 다음과 같습니다.
<ul>
<% @people.each do |person| %>
<li id="<%= person.id %>"><%= render person %></li>
<% end %>
</ul>
테스트를 실행하고 이에 대한 페이지의 성능을 측정해 보겠습니다.
이 페이지를 로드하는 데 무려 1066.7ms가 걸렸습니다. 안좋다! 이것이 우리가 줄이는 것을 목표로 할 것입니다.
데이터베이스 쿼리
고성능 애플리케이션을 구축하기 위한 첫 번째 단계는 리소스 활용도를 극대화하는 것입니다. 대부분의 Rails 앱은 데이터베이스에서 뷰로 무언가를 렌더링하므로 먼저 데이터베이스 호출을 최적화하도록 합시다!
이 데모에서는 MySQL 데이터베이스를 사용하겠습니다.
1066ms의 초기 로드가 어떻게 분해되는지 살펴보겠습니다.
414.7 'controllers/application_controller#home' 실행
...
(0.1ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 996]]
Rendered people/_person.html.erb (1.5ms)
(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 997]]
Rendered people/_person.html.erb (2.3ms)
(0.1ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 998]]
Rendered people/_person.html.erb (2.1ms)
(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 999]]
Rendered people/_person.html.erb (2.3ms)
(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 1000]]
Rendered people/_person.html.erb (2.0ms)
Rendered application/home.html.erb within layouts/application (890.5ms)
Completed 200 OK in 1066ms (Views: 890.5ms | ActiveRecord: 175.4ms)
"application/home.html.erb" 및 "people/_person.html.erb" 부분을 렌더링하기 위한 519.2 및 132.8
이상한 점을 눈치채셨나요?
우리는 컨트롤러에서 하나의 데이터베이스 호출을 만들었지만 모든 부분은 자체 데이터베이스 호출도 합니다! N+1 쿼리 문제를 소개합니다.
1. N+1 쿼리
이것은 매우 인기 있고 간단한 최적화 기술이지만 이 실수가 너무 만연하기 때문에 처음으로 언급할 가치가 있습니다.
"people/_person.html.erb"가 무엇을 하는지 봅시다:
<ul>
<li>
Name: <%= person.name %>
</li>
<li>
Addresses:
<ul>
<% person.profiles.each do |profile| %>
<li><%= profile.address %></li>
<% end %>
</ul>
</li>
</ul>
<%= button_to "Vote #{person.votes_count}", vote_person_path(person) %>
기본적으로 데이터베이스에서 해당 사람의 프로필을 쿼리하고 각 프로필을 렌더링합니다. 따라서 N개의 쿼리(여기서 N은 사람 수)와 컨트롤러에서 수행한 1개의 쿼리(따라서 N+1)를 수행합니다.
이를 최적화하려면 MySQL 데이터베이스 조인을 사용하고 Rails ActiveRecord에는 기능이 포함되어 있습니다.
다음과 일치하도록 컨트롤러를 변경해 보겠습니다.
def home
@people = Person.all.includes(:profiles)
end
모든 사람들은 1개의 MySQL 쿼리에 의해 로드되고, 각각의 모든 쿼리는 다른 쿼리에 로드됩니다. N+1을 단 2개의 쿼리로 가져옵니다.
이것이 어떻게 성능을 향상시키는지 살펴봅시다!
페이지를 로드하는 데 936ms밖에 걸리지 않았습니다. 아래에서 "application_controller#home" 작업이 2개의 MySQL 쿼리를 수행하는 것을 볼 수 있습니다.
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.2ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.2ms)
Rendered application/home.html.erb within layouts/application (936.0ms)
Completed 200 OK in 936ms (Views: 927.1ms | ActiveRecord: 9.3ms)
2. 사용할 항목만 로드
홈페이지는 이렇게 생겼습니다.
주소만 필요하고 다른 것은 필요하지 않습니다. 그러나 "_person.html.erb" 부분에서 프로필 개체를 로드합니다. 그 변화를 어떻게 할 수 있는지 봅시다.
<li>
Addresses:
<ul>
<% person.profiles.pluck(:address).each do |address| %>
<li><%= address %></li>
<% end %>
</ul>
</li>
N+1 쿼리에 대한 자세한 내용은 ActiveRecord 성능:N+1 쿼리 안티패턴을 참조하세요.
ProTip:이에 대한 범위를 생성하고 "models/profile.rb" 파일에 추가할 수 있습니다. 보기 파일의 원시 데이터베이스 쿼리는 별로 쓸모가 없습니다.
3. 모든 데이터베이스 호출을 컨트롤러로 이동
이 가상 응용 프로그램의 미래에 홈 페이지에 총 사용자 수를 표시하려고 한다고 가정해 보겠습니다.
단순한! 다음과 같은 뷰에서 호출해 보겠습니다.
# of People: <%= @people.count %>
알겠습니다. 간단합니다.
또 다른 요구 사항이 있습니다. 페이지 진행률을 표시하는 UI 요소를 만들어야 합니다. 이제 페이지에 있는 사람 수를 전체 수로 나눕니다.
Progress: <%= index / @people.count %>
유감스럽게도 동료는 귀하가 이미 이 쿼리를 작성했다는 사실을 모르고 계속해서 조회를 진행합니다.
컨트롤러가 다음과 같았습니까?
def home
@people = Person.all.includes(:profiles)
@people_count = @people.count
end
이미 계산된 변수를 재사용하는 것이 더 쉬웠을 것입니다.
이것이 페이지 로드 속도의 직접적인 개선에 기여하지는 않지만 다양한 보기 페이지에서 데이터베이스에 대한 다중 호출을 방지하고 캐싱과 같이 나중에 수행할 수 있는 최적화를 준비하는 데 도움이 됩니다.
4. 가능한 모든 곳에서 페이지 매김!
필요한 것만 로드하는 것처럼 필요한 것만 표시하는 것도 도움이 됩니다! 페이지 매김을 통해 뷰는 정보의 일부를 렌더링하고 나머지는 요청 시 로드되도록 유지합니다. 이것은 많은 밀리초를 줄입니다! will_paginate 및 kaminari 보석이 몇 분 안에 이 작업을 수행합니다.
이로 인해 발생하는 한 가지 성가심은 사용자가 "다음 페이지"를 계속 클릭해야 한다는 것입니다. 이를 위해 "무한 스크롤링"을 통해 사용자에게 훨씬 더 나은 경험을 제공할 수도 있습니다.
HTML 재로드 방지
전통적인 Rails 앱에서 HTML 뷰 렌더링은 많은 시간이 걸립니다. 다행히도 이를 줄이기 위해 취할 수 있는 조치가 있습니다.
1. 터보링크
이것은 표준 Rails 앱에 포함됩니다. Turbolinks는 정적 페이지와 같이 Rails 없이도 모든 곳에서 작동하며 지원되지 않는 브라우저에서는 정상적으로 성능이 저하되는 JavaScript 라이브러리입니다.
모든 링크를 AJAX 요청으로 변환하고 JS를 통해 페이지의 전체 본문을 대체합니다. CSS, JS 및 이미지를 다시 로드할 필요가 없으므로 성능이 크게 향상됩니다.
그러나 사용자 정의 JS를 작성할 때 "Turbolinks safe JS"를 작성하기 위해 추가 예방 조치를 취해야 합니다. 여기에서 자세히 알아보세요.
2. AJAX 요청 사용
Turbolinks와 같은 맥락에서 일부 링크와 버튼도 AJAX 요청으로 변환할 수 있습니다. 여기서 차이점은 Turbolinks처럼 전체 본문을 교체하는 대신 대체되는 HTML을 제어할 수 있다는 것입니다.
AJAX가 작동하는 모습을 봅시다!
샘플 앱에는 각 사용자에 대한 "투표" 버튼이 있습니다. 해당 작업을 수행하는 데 걸리는 시간을 측정해 보겠습니다.
Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by PeopleController#vote as HTML
Person Load (0.3ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.1ms) begin transaction
Person Exists (4.5ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
SQL (1.0ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 1], ["updated_at", "2020-01-21 09:20:49.941928"], ["id", 1]]
Redirected to https://localhost:3000/
Completed 302 Found in 24ms (ActiveRecord: 7.5ms)
Started GET "/" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by ApplicationController#home as HTML
Rendering application/home.html.erb within layouts/application
Rendered people/_person.html.erb (2.4ms)
(0.3ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 30]]
Rendered people/_person.html.erb (2.2ms)
...
Rendered application/home.html.erb within layouts/application (159.8ms)
Completed 200 OK in 190ms (Views: 179.0ms | ActiveRecord: 6.8ms)
페이지를 새로고침하는 것과 같은 시간이 걸렸고 실제 투표 부분에 약간의 추가 시간이 걸렸습니다.
AJAX 요청으로 만들어 봅시다. 이제 "people/_person.html.erb"는 다음과 같습니다.
<%= button_to "Vote #{person.votes_count}", vote_person_path(person), remote: true %>
컨트롤러 작업은 다음과 같은 JS 응답을 반환합니다.
$("#<%= @person.id %>").html("<%= j render(partial: 'person', locals: {person: @person}) %>");
보시다시피 필요한 콘텐츠만 교체하고 있습니다. div에 연결하고 교체할 HTML ID를 제공합니다. 물론 버튼 콘텐츠만 교체하여 이를 더욱 최적화할 수도 있지만 이 게시물의 목적을 위해 전체 부분을 교체해 보겠습니다.
결과는?
Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:52:56 +0530
Processing by PeopleController#vote as JS
Person Load (0.2ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.1ms) begin transaction
Person Exists (0.3ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
SQL (0.4ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 2], ["updated_at", "2020-01-21 09:22:56.532281"], ["id", 1]]
(1.6ms) commit transaction
Rendering people/vote.js.erb
(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 1]]
Rendered people/_person.html.erb (3.2ms)
Rendered people/vote.js.erb (6.3ms)
Completed 200 OK in 31ms (Views: 14.6ms | ActiveRecord: 2.9ms)
30ms! 그게 다야! 얼마나 대단한 일입니까?
ProTip:언제/무엇을 대체할지 알아내기 위해 많은 HTML ID와 클래스를 사용하고 싶지 않다면 render_async gem을 사용하는 것이 좋습니다. 처음부터 많은 작업을 수행합니다.
3. 웹 소켓 사용
HTML 다시 로드의 좋은 점 중 하나는 매번 서버에서 새로운 콘텐츠를 얻을 수 있다는 것입니다. AJAX 요청을 사용하면 작은 스니펫에 대한 최신 콘텐츠만 볼 수 있습니다.
WebSocket은 클라이언트가 새로운 정보를 요청하는 대신 서버가 클라이언트에 업데이트를 푸시하도록 하는 훌륭한 기술입니다.
이것은 동적 웹 페이지를 구축해야 할 때 유용할 수 있습니다. 웹사이트에 게임의 점수를 표시해야 한다고 상상해 보십시오. 새로운 콘텐츠를 가져오려면
- 사용자에게 전체 페이지를 새로고침하도록 안내
- 점수만 새로고침하는 새로고침 버튼 제공
- JavaScript를 사용하여 1초마다 백엔드 폴링 유지
- 데이터가 변경되지 않은 경우에도 서버에 계속 ping을 보냅니다.
- 각 클라이언트는 1초마다 전화를 걸고 서버를 압도하기 쉽습니다.
- WebSocket을 사용하세요!
WebSocket을 사용하면 서버가 모든 클라이언트(또는 하위 집합)에 데이터를 푸시할 시기를 제어할 수 있습니다. 서버는 데이터가 언제 변경되는지 알기 때문에 변경이 있을 때만 데이터를 푸시할 수 있습니다!
Rails 5는 WebSocket의 모든 것을 관리할 수 있는 ActionCable을 출시했습니다. 클라이언트가 서버에 가입할 수 있는 JS 프레임워크와 서버가 변경 사항을 게시할 수 있는 백엔드 프레임워크를 제공합니다. 액션 케이블을 사용하면 원하는 WebSocket 서비스를 선택할 수 있습니다. 자체 관리 웹 소켓 서비스인 Faye 또는 구독 서비스인 Pusher가 될 수 있습니다.
개인적으로는 관리해야 하는 항목이 줄어들기 때문에 구독을 선택하겠습니다.
자, WebSocket으로 돌아갑니다. ActionCable 설정을 완료하면 뷰가 서버의 JSON 입력을 수신할 수 없습니다. 수신하면 작성한 후크 작업이 해당 HTML 콘텐츠를 대체합니다.
Rails 문서와 Pusher에는 WebSocket으로 빌드하는 방법에 대한 훌륭한 자습서가 있습니다. 반드시 읽어야 할 책입니다!
캐싱
로드 시간의 대부분은 뷰를 렌더링하는 데 사용됩니다. 여기에는 모든 CSS, JS 및 이미지 로드, ERB 파일에서 HTML 렌더링 등이 포함됩니다.
로드 시간을 줄이는 한 가지 방법은 일정 시간 동안 또는 이벤트가 발생할 때까지 정적 상태로 유지될 것으로 알고 있는 애플리케이션 부분을 식별하는 것입니다.
이 예에서 누군가가 투표할 때까지 홈 페이지는 기본적으로 모든 사람에게 동일하게 보일 것입니다(현재 사용자가 주소를 편집할 수 있는 옵션이 없음). 이벤트(투표)가 발생할 때까지 전체 "home.html.erb" 페이지를 캐시하려고 합니다.
달리 젬을 사용해보자. 이것은 Memcached를 사용하여 정보 조각을 빠르게 저장하고 검색합니다. Memcached에는 저장을 위한 데이터 유형이 없으므로 기본적으로 원하는 대로 저장할 수 있습니다.
1. 캐싱 보기
캐싱이 없는 2000개의 레코드에 대한 로드 시간은 3500ms입니다!
"home.html.erb"에 모든 것을 캐시합시다. 간단합니다.
<% cache do %>
<ul>
<% @people.each do |person| %>
<li id="<%= person.id %>"><%= render person %></li>
<% end %>
</ul>
<% end %>
다음으로 Dalli gem을 설치하고 "development.rb"의 캐시 저장소를 다음과 같이 변경합니다.
config.cache_store = :dalli_store
그런 다음 Mac 또는 Linux를 사용하는 경우 다음과 같이 Memcached 서비스를 시작하기만 하면 됩니다.
memcached -vv
이제 새로고침을 해보자!!
약 537ms가 걸렸습니다! 속도가 7배 향상되었습니다!
또한 전체 HTML이 Memcached에 저장되어 데이터베이스를 ping할 필요 없이 다시 읽기 때문에 MySQL 쿼리가 훨씬 적다는 것을 알 수 있습니다.
애플리케이션 로그로 이동하면 이 전체 페이지가 캐시에서 읽혀진 것도 알 수 있습니다.
물론 이 예는 뷰 캐싱의 표면을 긁고 있을 뿐입니다. 부분 렌더링을 캐시하고 각 개인 개체로 범위를 지정하거나(이를 조각 캐싱이라고 함) 전체 컬렉션 자체를 캐시할 수 있습니다(이를 컬렉션 캐싱이라고 함). 더 많은 중첩 뷰 렌더링을 위해 러시아 인형 캐싱을 수행할 수 있습니다.
2. 캐싱 데이터베이스 쿼리
보기 속도를 향상시키기 위해 수행할 수 있는 또 다른 최적화는 복잡한 데이터베이스 쿼리를 캐시하는 것입니다. 애플리케이션에 통계 및 분석이 표시되는 경우 각 메트릭을 계산하기 위해 복잡한 데이터베이스 쿼리를 수행하고 있을 가능성이 있습니다. 그 출력을 Memcached에 저장한 다음 타임아웃을 할당할 수 있습니다. 즉, 시간 초과 후에 계산이 다시 수행된 다음 캐시에 저장됩니다.
예를 들어 응용 프로그램이 사용자 팀의 크기를 표시해야 한다고 가정해 보겠습니다. 이것은 직속 보고자, 아웃소싱 컨설턴트 등의 수를 포함하는 복잡한 계산일 수 있습니다.
계산을 반복하는 대신 캐시할 수 있습니다!
def team_size
Rails.cache.fetch(:team_size, expires_in: 8.hour) do
analytics_client = AnalyticsClient.query!(self)
analytics_client.team_size
end
end
이 캐시는 8시간 후에 자동 만료됩니다. 이 경우 계산이 다시 수행되고 다음 8시간 동안 최신 값이 캐시됩니다.
3. 데이터베이스 색인
인덱스를 사용하여 쿼리 속도를 높일 수도 있습니다. 사람의 모든 주소를 가져오는 간단한 쿼리
person.addresses
이 쿼리는 주소 테이블이 person_id
인 모든 주소를 반환하도록 요청합니다. 열은 person.id
입니다. . 인덱스가 없으면 데이터베이스는 각 행을 개별적으로 검사하여 person.id
와 일치하는지 확인해야 합니다. . 그러나 인덱스의 경우 데이터베이스에는 특정 person.id
와 일치하는 주소 목록이 있습니다. .
다음은 데이터베이스 인덱스에 대해 자세히 알아볼 수 있는 훌륭한 리소스입니다!
요약
이 게시물에서는 데이터베이스 활용도를 개선하고, 타사 도구와 서비스를 사용하고, 사용자에게 표시되는 내용을 제한하여 Rails 앱의 보기 성능을 개선하는 방법을 살펴보았습니다.
앱의 성능을 향상시키려면 간단하게 시작하여 계속 측정해 보세요! 데이터베이스 쿼리를 정리한 다음 가능한 모든 곳에서 AJAX 요청을 만들고 마지막으로 가능한 한 많은 보기를 캐시합니다. 그 후에 WebSocket 및 데이터베이스 캐싱으로 이동할 수 있습니다.
그러나 조심해야 합니다. 최적화는 미끄러운 경사입니다. 당신도 나처럼 중독될 수 있습니다!
추신 프로덕션 환경에서 Rails 앱의 성능을 모니터링하려면 Ruby 개발자가 Ruby 개발자를 위해 빌드한 AppSignal의 APM을 확인하세요. 🚀