루비 온 레일스 애플리케이션 퍼포먼스 튜닝


최근 필자가 레일스 관련 세미나를 진행하다 보면 과거에 비해 퍼포먼스 관련 질문이 부쩍 늘어난 점이 눈에 띈다. 이는 레일스의 개발 생산성에 대한 시장의 검증이 끝나고, 주요 프로젝트에서 레일스 도입을 고려하는 회사들이 점차 늘어나고 있기 때문으로 풀이된다. 이 글에서는 레일스 애플리케이션의 퍼포먼스 관리를 위해 레일스 애플리케이션 개발시 주의해야할 점과 이후의 튜닝 프로세스에 대해 살펴보기로 한다.

레일스 애플리케이션의 퍼포먼스 튜닝 작업은 크게 데이터베이스 구조 최적화, 쿼리 최적화, 테이블 레벨 캐싱, 페이지 조각 캐싱, 그리고 페이지 캐싱 등의 과정으로 나뉜다. 이들 튜닝 작업을 적절히 활용하면, 레일스 애플리케이션에서 데이터베이스 서버에 걸리는 부하를 상당 부분 줄일 수 있다. 웹 애플리케이션에서 퍼포먼스 병목이 일어나는 주요 지점이 바로 데이터베이스이므로, 이들 튜닝 작업만을 통해서도 레일스 애플리케이션의 퍼포먼스 이슈를 상당 부분 해결할 수 있다.

레일스는 쿼리 최적화 및 각종 캐싱 작업을 위한 기능을 자체적으로 지원하고 있으므로 레일스 애플리케이션의 퍼포먼스 튜닝 작업은 비교적 쉬운 편이다. 지금부터 이들 과정을 차례로 살펴보자.

데이터베이스 구조 최적화

웹 애플리케이션 퍼포먼스 튜닝의 첫 번째 단계가 데이터베이스 로직의 재정비라는 것은 잘 알려진 사실이다. 하지만 개발 단계에서 데이터베이스 로직을 재정비하는 일은 종종 우선 순위가 뒷전으로 밀리는 게 사실이다. 여기에서 주의해야 할 것은 레일스 개발에서는 데이터베이스 로직을 잘 관리하는 일이 매우 특별한 중요성을 가진다는 사실이다. 이는 레일스 개발의 생산성이 상당 부분 액티브 레코드(레일스의 ORM 프레임워크)와 데이터베이스 설계의 관례에서 기인하기 때문인데, 레일스 개발에 익숙하지 않은 개발자들은 대체로 이러한 점을 간과하는 경향이 있다.

데이터베이스는 처음에 그 설계를 잘 해두면 나중에는 그 혜택을 복리로 누릴 수 있다. 이는 바꿔 말하면, 잘못된 데이터베이스 설계는 두고두고 개발 및 유지보수 비용을 낭비하게 되는 원인이 된다는 의미이기도 하다. 레일스에서는 각종 관례를 이용해 다양한 코딩 작업을 자동화하고 있으므로 올바른 데이터베이스 설계가 매우 중요하게 여겨진다.

레일스 애플리케이션의 데이터베이스 설계에 있어서 필자가 추천하는 방법은 우선 데이터베이스 정규화를 온전하게 달성하는 것이다. 이쯤에서 베테랑 웹 개발자라면 곧바로 반론을 제기할 지도 모르겠다. 실무에서는 퍼포먼스 문제 때문에 데이터베이스 정규화를 유지하는 것이 실용적이지 못한 경우가 종종 있기 때문이다. 물론 옳은 이야기다. 하지만 바로 그 때문에 추가적인 튜닝 작업이 필요한 것은 아닐까? 필자의 경험에 따르면 정규화 작업이 올바로 이뤄지지 않고 일부 퍼포먼스 튜닝을 위한 임의적인 코드가 추가된 웹 애플리케이션의 경우 체계적인 퍼포먼스 튜닝 작업이 무척 어려워진다. 따라서 우선 데이터베이스 정규화 규칙에 맞도록 데이터베이스를 설계한 후, 레일스가 자체적으로 지원하는 튜닝 기법을 단계적으로 적용할 것을 권장하고 싶다.

특히 데이터베이스의 비정규화 작업은 애플리케이션의 특성에 따라 조심스럽게 이뤄져야 하고, 이 경우 ORM 프레임워크(레일스의 경우 액티브 레코드)에서 자체적으로 지원하는 기능을 잘 활용하는 것이 중요하다. 데이터베이스의 비정규화가 일어나면 그만큼 데이터베이스 내에서 중복되는 데이터의 양이 늘어나므로, 이들 데이터의 일관성을 관리하는 것이 꽤나 복잡한 이슈가 되기 때문이다. 이에 관한 자세한 내용은 ‘테이블 레벨 캐싱’ 섹션에서 다루기로 한다.

따라서 필자는 레일스 애플리케이션 개발을 시작하기 전에 액티브 레코드의 기능에 대해 가능한 많은 내용을 숙지할 것을 권장한다. 액티브 레코드의 모든 기능을 마스터하지는 않더라도, 어떤 기능들이 있는지 정도만 기억하면 데이터베이스 설계 과정에서 다양한 의사 결정을 내리는 데 많은 도움이 되기 때문이다. 또한 이와 더불어 개발에 사용되는 데이터베이스 서버의 특징을 잘 이해하는 것 역시 무척 중요하다고 일러두고 싶다.

데이터베이스 정규화에 관한 내용은 전문 서적을 참고해야겠지만, 그 기본 아이디어는 비교적 간단하다. 줄여서 설명하면, 데이터베이스 내의 어떤 데이터도 중복되면 안 된다는 것이 데이터베이스 정규화 작업의 목표다. 따라서 논리적으로 중복되는 데이터 없이 데이터베이스가 설계되었다면 데이터베이스 정규화가 올바르게 이뤄졌다고 할 수 있다.

데이터베이스 설계에 있어서 또 한 가지 중요한 작업은 테이블의 적절한 필드에 인덱스를 추가하는 일이다. 레일스의 마이그레이션 기능은 모든 테이블의 기본 키에 자동으로 인덱스를 추가해준다. 따라서 개발자는 검색이 자주 발생하는 테이블 필드에 대해서만 인덱스를 추가하면 된다. 기본적으로 모든 테이블의 참조키에는 항상 인덱스를 추가하는 편이 바람직하다.

쿼리 최적화

일부 개발자들은 액티브 레코드와 같은 ORM 프레임워크가 개발 생산성을 개선해주는 반면, SQL 쿼리 횟수를 늘리는 등 데이터베이스 퍼포먼스에 있어서는 오히려 오버헤드를 유발한다고 말한다. 필자가 보기에 이러한 생각은 대부분 오해에서 비롯되는 것 같다. 액티브 레코드를 사용하면 오히려 더 체계적이고 코드 관리가 용이한 방법으로 쿼리 최적화를 달성할 수 있기 때문이다. 우선 사용자 데이터를 저장하기 위한 데이터베이스 테이블과 이를 접근하기 위한 액티브 레코드 모델이 <표 1>과 같이 정의되어 있다고 가정해보자.

idnameemailphone_number
1홍길동gildong@hanmail.net010-123-4567
2임꺽정ggukjung@naver.com011-456-7890
3장길산gilsan@gmail.com016-333-7777

<표 1> ‘users’ 테이블

class User < ActiveRecord::Base
end
 

<리스트 1> ‘app/model/user.rb’ 파일

다음은 위에서 정의된 User 모델 클래스를 통해 ‘gilsan@gmail.com’이라는 이메일 주소를 가진 사용자를 검색하는 루비 코드와 이 코드 실행시 실제로 사용되는 SQL 쿼리문이다.

@user = User.find_by_email("gilsan@gmail.com")

SELECT * FROM users WHERE (users.`email` = 'gilsan@gmail.com') LIMIT 1
 

이 SQL 쿼리문에서 확인할 수 있듯이 User 모델의 find_by_email 메소드는 ‘users’ 테이블에 있는 모든 필드의 데이터를 읽어 들인다. 하지만 애플리케이션에서 모든 필드 데이터를 필요로 하는 게 아니라면 이와 같은 코드는 다소 비효율적이다.

따라서 액티브 레코드에서는 모델 클래스의 find 메소드 사용시 ‘:select’ 옵션을 사용해 필요한 필드의 데이터만을 선택적으로 읽어오는 기능을 지원하고 있다. 다음은 ‘:select’ 옵션의 사용 예와 그에 해당하는 SQL 쿼리문이다.

@user = User.find(:first,
                  :select => "id, name",
                  :conditions => {:email => "gilsan@gmail.com"})

SELECT id, name FROM users WHERE (email='gilsan@gmail.com') LIMIT 1
 

이처럼 ‘:select’ 옵션을 사용하면 애플리케이션이 실제 필요로 하는 데이터만을 데이터베이스로부터 읽어 들이므로 데이터베이스로부터 전송되는 데이터의 양을 상당 부분 줄일 수 있다. 액티브 레코드가 지원하는 또 다른 쿼리 최적화 기능은 테이블 간에 관계(Association)가 설정되어 있는 경우에 적용된다. <표 2>는 게시판 글을 저장하기 위한 데이터베이스 테이블과 이에 접근하기 위한 액티브 레코드 모델 클래스를 보여주고 있다.

idtitleuser_idcontent
1오늘 미팅2오늘 오후 2시에 미팅이 있습니다.
2디자인 시안1디자인 시안이 나왔습니다.
3다음주 행사2다음주 행사에 초청할 분들 리스트를 ...

<표 2> ‘posts’ 테이블

class Post < ActiveRecord::Base
  belongs_to :user
end
 

<리스트 2> ‘app/model/post.rb’ 파일

<표 1>에서 정의했던 User 모델과 <표 2>에서 정의한 Post 모델 간에는 일대다 관계가 성립하므로, User 모델 클래스에 대한 정의도 다음과 같이 변경해줘야 한다.

class User < ActiveRecord::Base
  has_many :posts
end
 

<리스트 3> ‘app/model/user.rb’ 파일

다음은 id가 2번인 글과 그 작성자를 읽어 들이는 루비 코드와 이 코드 실행시 실제로 사용되는 SQL 쿼리문이다.

@post = Post.find(2)
@user = @post.user

SELECT * FROM posts WHERE (posts.`id` = 2)
SELECT * FROM users WHERE (users.`id` = 1)
 

여기서는 2개의 SQL 쿼리가 실행되는 것을 알 수 있다. 하지만 이런 경우에 조인(Join) 쿼리를 사용하면 이 2개의 SQL 쿼리를 하나로 줄이는 것이 가능하다. 다음에서는 Post 모델의 find 메소드에서 ‘:include’ 옵션을 활용해 글과 작성자 데이터를 하나의 SQL 쿼리로 읽어 들이고 있다(‘:include’ 옵션은 조인해 함께 읽어 들일 테이블을 지정하는 옵션이다).

@post = Post.find(:first,
                  :include => :user,
                  :conditions => {:id => 2})
@user = @post.user

SELECT posts.`id` AS t0_r0, posts.`title` AS t0_r1, posts.`user_id` AS t0_r2, posts.`content` AS t0_r3, users.`id` AS t1_r0, users.`name` AS t1_r1, users.`email` AS t1_r2, users.`phone_number` AS t1_r3 FROM posts LEFT OUTER JOIN users ON users.id = posts.user_id WHERE (posts.`id` = 2)
 

이번에는 단 하나의 SQL 쿼리만이 호출되는 것을 알 수 있다. 이처럼 쿼리의 수를 줄이는 것은 데이터베이스 부하를 줄이는 매우 효과적인 방법이다. 앞의 경우에도 물론 ‘:select’ 옵션을 사용해서 실제로 필요한 필드 데이터만을 읽어 들일 수 있다.

지금까지는 액티브 레코드를 통해 SQL 쿼리를 최적화하는 몇 가지 방법을 살펴봤다. 액티브 레코드에서는 조인 형식을 명시적으로 지정하거나, 아예 SQL 구문을 직접 입력하는 방식까지 지원하므로 다양한 상황에서 필요한 쿼리 최적화를 얼마든지 해낼 수 있을 것이다.

테이블 레벨 캐싱

테이블 레벨 캐싱이란 조인 테이블에 저장된 데이터를 부모 테이블에 캐싱해 두는 것을 말한다. 이는 데이터베이스 정규화를 명백하게 깨뜨리지만, 퍼포먼스 향상을 위해 때때로 필요한 작업이기도 하다. 조인 테이블의 데이터를 부모 테이블에 캐싱해 두면, 데이터를 읽어 들일 때 테이블 조인을 피할 수 있으므로 데이터베이스 부하를 크게 줄일 수 있다.

테이블 레벨 캐싱을 사용하는 경우에는 원래 데이터와 캐시 데이터 간의 동기화를 온전하게 유지하는 것이 가장 중요하다. 따라서 액티브 레코드는 이런 동기화 작업을 위한 몇 가지 기능을 제공하고 있다.

액티브 레코드에서 가장 많이 사용되는 테이블 레벨 캐싱은 아마도 조인 테이블에 저장된 레코드의 숫자를 캐싱하는 기능일 것이다. 여기에서는 앞에서 예로 들었던 User 모델과 Post 모델을 사용하여 이 기능의 사용법을 설명하도록 한다. 우선 ‘users’ 테이블에 ‘posts_count’란 숫자 필드를 추가한다. 그 다음에는 Post 모델 클래스의 코드를 <리스트 4>와 같이 변경한다.

class Post < ActiveRecord::Base
  belongs_to :user, :counter_cache => true
end
 

<리스트 4> ‘app/model/post.rb’ 파일

이제 특정 사용자가 새로운 Post 레코드를 추가하면 ‘users’ 테이블의 ‘posts_count’ 필드의 숫자가 1만큼 증가하고, Post 레코드를 하나 삭제하면 ‘posts_count’가 1만큼 감소하게 된다. 카운터 캐시 기능을 사용하는 경우에는 액티브 레코드가 이처럼 데이터 동기화를 자동으로 관리해준다. 조인 테이블에 저장된 레코드의 수를 카운트하는 작업은 매번 테이블을 스캔해야 하는 작업이므로 데이터베이스에 많은 부담을 준다. 따라서 앞에서와 같이 카운터 캐시 기능을 사용하면, 상당한 퍼포먼스 개선을 거둘 수 있다. 이제 다음과 같은 루비 코드가 실행되면 ‘@posts_count’ 값을 계산하는 데 캐시된 데이터가 사용된다.

@user = User.find_by_email("gildong@hanmail.net")
@posts_count = @user.posts.size
 

카운터 캐시의 경우는 액티브 레코드가 자체적으로 캐시 데이터를 동기화 해주지만, 다른 캐시 데이터의 경우에는 개발자가 직접 동기화를 위한 코드를 작성해야 한다. 이를 위해서는 액티브 레코드의 콜백 메소드나 옵져버 기능을 활용할 수 있다. 이처럼 캐시 데이터의 일관성 관리를 위한 코드는 언제나 모델 내부에 들어가는 것이 설계상 더 바람직하다.

페이지 조각 캐싱

웹 애플리케이션에서는 웹 페이지의 일부를 구성하는 데이터가 변경되지 않는 경우가 종종 발생한다. 이런 경우 페이지의 해당 부분을 캐싱해 두면, 데이터베이스로부터 데이터를 읽어 들이는 빈도를 줄여 퍼포먼스 개선을 이룰 수 있다. 이때 사용되는 것이 바로 페이지 조각 캐싱이다.

페이지 조각 캐싱을 위해서는 rhtml 파일에서 다음과 같이 cache 메소드를 사용한다. cache 메소드의 ‘:action’과 ‘:part’에 지정되는 값은 해당 캐시를 구분하는 ID와 같은 역할을 한다.

<% cache(:action => "list", :part => "posts") do %>
<ul>
  <% Post.find(:all).each do |post| %>
  <li><%=h(post.title) %> by <%=h(post.user.name) %></li>
  <% end %>
</ul>
<% end %>
 

이제 rhtml 파일에서 이 코드에 해당되는 부분은 캐시로 저장된다. 물론 이 부분의 페이지 조각을 구성하는 데이터가 변경되는 경우라면 해당 캐시를 삭제해 데이터를 갱신해야 할 것이다. 다음은 페이지 조각의 캐시 데이터를 삭제하는 루비 코드이다(이 코드는 컨트롤러의 액션 메소드에서 사용될 수 있다).

expire_fragment(:action => "list", :part => "articles")
 

페이지 조각 캐시는 보통 하드 디스크에 저장되거나 별도의 캐시 서버(memcached)에 저장된다. 캐시 보관 장소에 대한 설정은 ‘config/environment.rb’ 파일에서 이뤄진다. 다음은 캐시 보관 장소를 레일스 애플리케이션 디렉토리 내의 ‘cache’ 디렉토리로 지정하는 설정을 보여주는 예다.

ActionController::Base.fragment_cache_store = ActionController::Caching::Fragments::FileStore.new( "#{RAILS_ROOT}/cache")
 

페이지 캐싱

일부 웹 애플리케이션의 경우에는 페이지 전체를 통째로 캐시하는 것이 가능할 수 있다. 블로그나 뉴스 사이트는 글 페이지의 내용이 업데이트되는 경우가 흔치 않으므로 페이지 전체를 캐시하는 것도 한 가지 옵션이 된다. 다음의 PostController 코드에서는 show 액션의 결과를 캐시할 것을 설정하고 있다.

class PostController < ApplicationController

  caches_page :show

  ...
end
 

이 코드로 인해 생성된 캐시 파일은 ‘public/post/show/1.html’과 같은 형식으로 저장된다. 따라서 이렇게 캐시 된 페이지는 레일스 애플리케이션을 거치지 않고 웹 서버가 직접 전송한다. 페이지 캐싱의 경우 데이터베이스를 전혀 거치지 않으므로 가장 이상적인 퍼포먼스 개선이 가능하다.

해당 페이지의 내용이 업데이트되는 경우에는 캐시 파일을 지워주면 다음번 페이지 접근 때 캐시의 내용이 갱신된다. 캐시 파일을 지우는 코드는 다음과 같다(이 코드는 컨트롤러의 액션 메소드에서 사용될 수 있다).

expire_page(:action => "show", :id => 1)
 

기본적으로 페이지 조각 캐싱과 페이지 캐싱은 프로덕션(production) 환경에서만 지원된다. 만약 개발 환경에서 캐싱 기능을 활성화하고 싶다면, ‘config/environment.rb’ 파일에 다음의 코드를 추가하자.

config.action_controller.perform_caching = true
 

지금까지 레일스 애플리케이션의 퍼포먼스 튜닝을 위한 다양한 방법을 살펴보았다. 레일스가 자체적으로 제공하는 기능 덕분에 이들 작업이 비교적 단순해 보이긴 하지만, 만일 이들 기능을 직접 구현한다면 많은 시간과 노력이 들 것임을 쉽게 짐작할 수 있다.

대규모의 웹 서비스를 구축하려면 데이터베이스 클러스터링 셋업이 추가적으로 필요할 수도 있지만, 대부분의 경우에는 여기서 다뤄진 데이터베이스 튜닝 작업만을 수행해도 충분히 만족할 만한 퍼포먼스를 낼 수 있다. 아무쪼록 이 글을 통해 레일스 퍼포먼스 튜닝에 대한 대략적인 밑그림이 그려졌길 기대한다.

노트: 이 글은 마이크로 소프트웨어 2007년 6월호에 "개발 고수 12인이 말하는 실전 노하우: 루비 온 레일스 애플리케이션 퍼포먼스 튜닝"이라는 제목으로 실렸습니다.