프로그래밍에서 캐싱(caching)이란 나중에 빠르게 꺼내 쓸 수 있도록 특정 값을 미리 저장해 두는 것을 의미합니다. 주로 계산 비용이 큰 값에 적용하는데, 예를 들어 외부 API를 호출해야 하는 경우나 복잡한 연산을 거쳐야 생성되는 데이터가 대표적입니다.
캐시된 값은 보통 memcached나 Redis 같은 별도의 서버에 저장되며, 디스크에 저장할 수도 있고 RAM에 저장할 수도 있습니다. 코드 레벨에서는 비용이 큰 함수를 여러 번 호출하지 않도록 변수에 데이터를 '캐싱'하기도 합니다.
data = some_calculation()
a(data)
b(data)
빠른 속도를 얻는 대가는 '오래된 데이터'를 사용하게 된다는 점입니다. 만약 캐시된 데이터가 더 이상 정확하지 않은 '낡은(stale)' 상태가 된다면 어떻게 해야 할까요? 이때는 캐시를 삭제하여 '무효화(invalidate)'해야 합니다.
캐싱을 반대하는 이유
오래된 격언처럼, 컴퓨터 과학에는 단 2가지의 어려운 문제만 있습니다:
1. 이름 짓기
2. 캐시 무효화
3. Off-by-one 오류
왜 캐시 무효화가 그렇게 어려울까요? 캐시된 값은 본질적으로 '실제 값'을 가려주는 역할을 하기 때문입니다. 실제 값이 변경될 때마다 개발자(바로 여러분)가 직접 캐시를 무효화해서 갱신하도록 기억해야 합니다.
예를 들어 텍스트 편집기에 '단어 수' 위젯을 추가한다고 가정해 봅시다. 사용자가 타이핑할 때마다 단어 수를 갱신해야 합니다. 가장 단순한 방법은 키 입력마다 단어를 다시 세는 것이지만, 이 방식은 너무 느립니다. 그래서 다음과 같은 접근법을 사용할 수 있습니다:
- 파일을 로드할 때 단어를 센다.
- 그 결과를 변수에 저장한다(즉, '캐싱'한다).
- 변수의 내용을 화면에 표시한다.
이 구현은 훨씬 빠르지만, 캐시된 '단어 수'는 타이핑해도 변하지 않습니다. 따라서 단어 수가 변경될 것으로 예상되는 모든 지점에서 캐시를 무효화해야 합니다.
키 입력이 발생하면 공백 등으로 단어를 감지해 카운터를 증가시키고, 사용자가 단어를 삭제할 때는 감소시키면 됩니다. 쉽죠. 끝. 다음 티켓으로 넘어갑시다.
...그런데 잠깐, 사용자가 텍스트를 잘라내기 했을 때 단어 수를 갱신하는 걸 잊지 않으셨나요? 붙여넣기를 할 때는요? 맞춤법 검사기가 오타를 두 단어로 분리했을 때는요?
여기서 문제는 값을 갱신하는 것 자체가 아닙니다. 갱신 자체는 비교적 사소한 일이죠. 진짜 문제는 모든 곳에서 갱신 코드를 누락 없이 추가해야 한다는 점입니다. 단 하나라도 놓치면 캐시 무효화 문제가 발생하고, 결국 사용자에게 낡은 값을 보여주게 됩니다.
이런 맥락에서 볼 때, 캐싱을 도입하면 기술적 복잡성과 잠재적인 버그 요소가 함께 늘어난다는 사실을 알 수 있습니다. 물론 이런 문제들은 해결 가능하지만, 캐싱을 만능 해결책으로 여기기 전에 반드시 고려해야 할 부분입니다.
캐싱 없이 속도 높이기
캐싱을 선택지에서 제외하면, 애플리케이션 속도 개선은 결국 성능 병목 지점을 찾아 수정하는 작업이 됩니다. 즉, 원래보다 느리게 동작하는 시스템을 찾아내는 것이죠. 병목은 크게 세 가지 범주로 나눌 수 있습니다:
- 데이터베이스 쿼리 (너무 많거나 너무 느린 경우)
- 뷰(View) 렌더링
- 애플리케이션 코드 (예: 무거운 연산 처리)
성능 작업을 진행하려면 반드시 알아야 할 두 가지 기법이 있는데, 바로 프로파일링(profiling)과 벤치마킹(benchmarking)입니다.
프로파일링
프로파일링은 애플리케이션의 어디에 문제가 있는지 파악하는 방법입니다. 이 페이지가 느린 이유가 템플릿 렌더링 때문인가요? 아니면 데이터베이스를 백만 번 조회해서인가요?
Ruby on Rails에서는 rack-mini-profiler를 추천합니다. 이 도구는 애플리케이션 화면 모서리에 작은 위젯을 추가해 줍니다. 현재 보고 있는 페이지를 렌더링하는 데 몇 개의 DB 쿼리가 실행되었는지, 얼마나 걸렸는지, 몇 개의 파셜(partial)이 렌더링되었는지 등을 한눈에 보여줍니다.
프로덕션 환경에서는 Skylight, New Relic, Scout 같은 온라인 서비스로 페이지 성능을 모니터링할 수 있습니다. 참고 팁: rack-mini-profiler는 프로덕션에서도 잘 동작합니다. 다만 관리자나 개발자 같은 특정 사용자에게만 표시되도록 설정하세요.
흔히 인용되는 목표치인 <= 100ms는 페이지 렌더링 시간으로 좋은 기준입니다. 실제 인터넷 환경에서는 이보다 빠른 차이를 사용자가 감지하기 어렵기 때문입니다. 물론 목표는 여러 요소에 따라 달라질 수 있습니다. 저도 과거에 성능이 심각하게 나쁜 레거시 애플리케이션을 다룰 때는 목표를 <= 1초로 잡았습니다. 이상적이진 않지만 시작 시점보다는 훨씬 나은 수준이었죠.
벤치마킹
문제의 위치를 파악했다면, 이제 벤치마크를 통해 최적화 작업이 성능에 실제로 어떤 영향을 미쳤는지 확인할 수 있습니다. 개인적으로는 benchmark-ips 젬(gem)을 선호하는데, 사람이 읽기 쉬운 형태로 코드 변경에 따른 차이를 보여주기 때문입니다.
간단한 예로 문자열 연결(concatenation)과 문자열 보간(interpolation)을 비교해 보겠습니다:
require 'benchmark/ips'
@a = "abc"
@b = "def"
Benchmark.ips do |x|
x.report("Concatenation") { @a + @b }
x.report("Interpolation") { "#{@a}#{@b}" }
x.compare!
end
실행 결과는 다음과 같습니다:
Warming up --------------------------------------
Concatenation 316.022k i/100ms
Interpolation 282.422k i/100ms
Calculating -------------------------------------
Concatenation 10.353M (± 7.4%) i/s - 51.512M in 5.016567s
Interpolation 6.615M (± 6.8%) i/s - 33.043M in 5.023636s
Comparison:
Concatenation: 10112435.3 i/s
Interpolation: 6721867.3 i/s - 1.50x slower
결과를 사람이 읽기 좋은 형태로 보여주며, 문자열 보간이 연결보다 1.5배 느리다는 것을 알 수 있습니다(적어도 짧은 문자열에서는요). 이런 이유로 성능을 개선하려는 메서드를 복사해 새 이름으로 만들어 두고, 작업 과정에서 빠르게 비교 테스트를 돌려보며 개선 여부를 확인하는 방식도 추천합니다.
성능 문제 해결하기
이제 애플리케이션의 어느 부분이 느린지 알고, 개선 효과를 측정할 벤치마크도 준비되었습니다. 남은 것은 실제 성능 최적화 작업입니다. 어떤 기법을 선택할지는 문제가 데이터베이스, 뷰, 애플리케이션 중 어디에 있는지에 따라 달라집니다.
데이터베이스 성능
데이터베이스 관련 성능 문제라면 몇 가지 확인할 포인트가 있습니다. 첫째, 악명 높은 'N+1 쿼리'를 피하세요. 이런 상황은 뷰에서 컬렉션을 렌더링할 때 자주 발생합니다. 예를 들어 게시글 10개를 가진 사용자와 그의 모든 게시글을 화면에 표시한다고 해보겠습니다. 무심코 작성한 첫 코드는 다음과 같을 수 있습니다:
# Controller
def show
@user = User.find(params[:id])
end
# View
Name: <%= @user.name %>
Posts:
<% @user.posts.each do |post| %>
<div>Title: <%= post.title %></div>
<% end %>
위 코드는 사용자를 조회하는 쿼리 1번에 이어 각 게시글마다 개별 쿼리(N=10)를 실행하므로 총 11번(N+1)의 쿼리가 발생합니다. 다행히 Rails는 ActiveRecord 쿼리에 .includes(:posts)를 추가하는 간단한 해결책을 제공합니다. 위 예제에서는 컨트롤러 코드를 다음과 같이 바꾸면 됩니다:
def show
@user = User.includes(:posts).find(params[:id])
end
이제 사용자와 그의 모든 게시글을 단 한 번의 데이터베이스 쿼리로 가져올 수 있습니다.
또 하나 확인할 것은 계산 로직을 데이터베이스로 밀어낼 수 있는지 여부입니다. DB에서 연산을 수행하는 것이 대개 애플리케이션에서 직접 처리하는 것보다 빠릅니다. 흔한 예가 다음과 같은 집계(aggregation)입니다:
total = Model.all.map(&:somefield).sum
이 코드는 데이터베이스에서 모든 레코드를 가져온 뒤, 실제 합계는 Ruby에서 계산합니다. 다음과 같이 데이터베이스가 직접 계산하도록 하면 속도를 높일 수 있습니다:
total = Model.sum(:somefield)
두 컬럼을 곱하는 것처럼 좀 더 복잡한 계산이 필요할 수도 있습니다:
total = Model.sum('columna * columnb')
주요 데이터베이스는 이런 기본 산술 연산과 sum, average 같은 일반적인 집계 함수를 지원하므로, 코드베이스에서 map(...).sum 패턴을 찾아 개선할 여지가 있는지 살펴보세요.
뷰 성능
템플릿 관련 성능 문제는 캐싱이 해결책으로 적합하다고 말씀드리고 싶지만, 그 전에 먼저 제거해 볼 만한 '저 hanging fruit(쉬운 개선점)'들이 있습니다.
전반적인 페이지 로드 시간을 위해, 프로덕션 서버에서 최소한 JavaScript나 CSS 라이브러리의 minified(압축) 버전을 사용하고 있는지 확인하세요.
또한 대량의 파셜(partial)이 포함되는 경우에도 주의해야 합니다. _widget.html.erb 템플릿 하나를 처리하는 데 1ms가 걸린다면, 페이지에 위젯이 100개 있을 때 이미 100ms가 소진됩니다. 한 가지 해결책은 UI 자체를 재검토하는 것입니다. 화면에 위젯 100개를 한꺼번에 보여주는 것은 대개 좋은 사용자 경험이 아니므로, 페이지네이션을 도입하거나 필요하다면 UI/UX 전반을 과감하게 개편하는 것도 고려해 볼 만합니다.
애플리케이션 코드 성능
성능 문제가 뷰나 데이터베이스 계층이 아니라 애플리케이션 코드 자체(즉, 데이터 조작)에 있다면 몇 가지 선택지가 있습니다. 첫째, 위에서 설명한 것처럼 일부 작업을 쿼리 형태로 데이터베이스로 넘길 수 있는지 살펴보거나, scenic 젬 같은 도구를 활용한 데이터베이스 뷰(database view)를 활용하는 방법이 있습니다.
또 다른 선택지는 '무거운 작업'을 백그라운드 잡(background job)으로 옮기는 것입니다. 다만 이 경우 값이 비동기적으로 계산된다는 점을 UI가 처리할 수 있도록 인터페이스 변경이 필요할 수 있습니다.
그래도 캐싱이 필요하다면, 어떻게 해야 할까?
여기까지 읽고 나서도 '그래도 캐싱이 우리 상황에 맞는 해결책이다'라고 판단하셨을 수 있습니다. 그렇다면 무엇을 해야 할까요? 기대해 주세요. 이 글은 Ruby on Rails에서 활용할 수 있는 다양한 캐싱 방식을 다루는 시리즈의 첫 번째 글입니다.