Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby on Rails 애플리케이션의 HTTP 캐싱 완벽 가이드

캐싱이란 무엇인가?

캐싱을 일반적으로 정의하면 데이터를 미리 저장해 두었다가 나중에 빠르게 꺼내 쓰는 것입니다. 여기에는 두 가지 의미가 있습니다. 한 번 계산한 결과를 다시 계산하지 않도록 저장하는 경우와, 데이터를 로컬에 보관해 다시 가져오지 않아도 되게 하는 경우입니다.

사실 우리 컴퓨터는 항상 이런 작업을 하고 있습니다. 운영체제는 자주 접근하는 데이터를 RAM에 유지해서 하드디스크나 SSD에서 다시 읽어올 필요가 없도록 하기 때문입니다.

브라우저도 마찬가지로 이미 내려받은 리소스를 재사용하려고 합니다. 새로운 웹사이트를 처음 방문해 본 경험이 있다면 직접 느껴보셨을 겁니다. 첫 로딩이 유독 오래 걸리는 이유는 브라우저가 이미지, 자바스크립트, 스타일시트를 포함한 모든 것을 새로 내려받아야 하기 때문입니다.

재미있는 사실 하나를 소개하자면, CNN 홈페이지를 처음 열 때 브라우저가 내려받는 데이터는 1993년 출시된 원조 게임 Doom보다 많습니다. 이 글을 작성하는 시점 기준으로 CNN 홈페이지는 애드블록을 켠 상태에서도 약 15MB를 압축해 3MB 이상 내려받는데, 반면 원조 Doom 설치 파일은 약 2.2MB에 불과했습니다.

브라우저가 이런 데이터를 캐싱하려면 서버와의 협력이 필수적입니다. 브라우저는 무엇을 얼마나 오래 캐싱할 수 있는지 알아야 합니다. 그렇지 않으면 서버에 최신 버전이 있는데도 낡은 콘텐츠를 보여줄 수 있기 때문입니다. 이 글에서는 클라이언트와 서버 사이에서 이루어지는 캐싱 협상이 어떻게 동작하는지, 그리고 Rails가 이를 제어하기 위해 어떤 도구를 제공하는지 살펴보겠습니다.

Ruby on Rails를 중심으로 설명하지만, 실제 메커니즘 자체는 HTTP 명세의 일부입니다. 즉, 여기서 다루는 캐싱은 인터넷 인프라에 내장된 기능이며, 현대적인 웹사이트와 프레임워크 개발의 초석이라 할 수 있습니다. Rails뿐 아니라 SPA(싱글 페이지 애플리케이션), 심지어 정적 사이트까지도 이 메커니즘을 활용해 성능을 개선할 수 있습니다.

HTTP 요청-응답(Request-Response) 구조

요청-응답 생명주기는 어느 정도 익숙하실 겁니다. 웹사이트에서 링크를 클릭하면 브라우저가 서버에 해당 콘텐츠를 요청하고, 서버가 그 내용을 응답으로 돌려주는 구조입니다(여기서는 의도적으로 복잡한 부분은 생략합니다).

이번에는 이 주고받기 과정에서 실제로 전송되는 데이터를 조금 더 깊이 들여다보겠습니다. 모든 HTTP 메시지는 헤더(header)바디(body)로 구성됩니다(HTML 태그인 <head>, <body>와 혼동하지 않도록 주의하세요). 요청 헤더는 서버에 어떤 경로에 접근하려 하는지, 어떤 HTTP 메서드(GET/PUT/PATCH/POST 등)를 사용할지 알려줍니다. 브라우저 개발자 도구나 curl 같은 커맨드라인 도구로 이 헤더들을 직접 확인할 수 있습니다.

# curl -v honeybadger.io
...
> GET / HTTP/1.1
> Host: honeybadger.io
> User-Agent: curl/7.64.1
> Accept: */*

출력의 첫 부분이 요청 헤더입니다. honeybadger.ioGET 요청을 보낸 것이죠. 그다음에는 서버가 돌려준 내용, 즉 응답 헤더(response header)가 이어집니다.

>
< HTTP/1.1 301 Moved Permanently
< Cache-Control: public, max-age=0, must-revalidate
< Content-Length: 39
< Content-Security-Policy: frame-ancestors 'none'
...
< Content-Type: text/plain

응답에는 HTTP 상태 코드가 포함됩니다(성공은 200, 찾을 수 없음은 404). 위 예시에서는 영구 리다이렉트(301)가 반환되었는데, curl이 http URL로 접속했기 때문에 보안이 적용된 https URL로 리다이렉트된 것입니다.

응답 헤더에는 콘텐츠 타입도 포함됩니다. 여기서는 text/plain이며, 그 외 자주 쓰이는 타입으로는 text/html, text/css, text/javascript, application/json 등이 있습니다.

응답 바디는 헤더 뒤에 따라옵니다. 이 예시에서는 301 리다이렉트에 바디가 필요 없으므로 비어 있습니다. 만약 curl -v https://www.honeybadger.io로 다시 시도하면, 브라우저에서 페이지 소스를 볼 때와 동일한 홈페이지 콘텐츠가 출력되는 것을 확인할 수 있습니다.

직접 실험해 보고 싶다면 다음 두 가지 팁이 유용합니다.

  1. curl로 응답 헤더만 보고 싶다면(요청 헤더나 응답 바디 제외) -I 옵션을 사용하세요. 예: curl -I localhost:3000.
  2. Rails는 기본적으로 개발 환경에서 캐싱을 비활성화합니다. 먼저 rails dev:cache를 실행해야 할 수 있습니다.

Cache-Control HTTP 헤더

캐싱 관점에서 가장 중요한 헤더는 바로 Cache-Control입니다. 이 헤더는 어떤 장비가 Rails 서버의 응답을 캐싱할 수 있는지, 그리고 캐싱된 데이터가 언제 만료되는지 결정하는 데 사용됩니다. Cache-Control 헤더 안에는 여러 필드가 있으며 대부분 선택 사항입니다. 여기서는 가장 중요한 항목들을 살펴보고, 더 자세한 내용은 w3.org의 공식 HTTP 명세를 참고하시기 바랍니다.

다음은 기본 상태의 Rails 애플리케이션이 반환하는 응답 헤더의 예시입니다.

< Content-Type: text/html; charset=utf-8
< Etag: W/"b41ce6c6d4bde17fd61a09e36b1e52ad"
< Cache-Control: max-age=0, private, must-revalidate

max-age

max-age 필드는 응답이 유효한 시간을 초 단위의 정수로 나타냅니다. Rails는 뷰(view)에 대한 응답에서 기본적으로 이 값을 0으로 설정합니다. 즉, 응답이 즉시 만료되며 브라우저는 항상 새 버전을 받아야 한다는 의미입니다.

public / private

헤더에 public 또는 private을 포함하면 어떤 서버가 응답을 캐싱할 수 있는지 지정됩니다. private이 포함되면 요청한 클라이언트(브라우저)만 캐싱할 수 있으며, CDN이나 프록시 같은 중간 서버는 캐싱할 수 없습니다. 반대로 public이 포함되면 이러한 중개 서버들도 응답을 캐싱할 수 있습니다. Rails는 기본적으로 private으로 설정합니다.

must-revalidate

Rails는 must-revalidate 필드도 기본으로 설정합니다. 이는 클라이언트가 캐싱된 버전을 사용하기 전에 반드시 서버에 접속해 해당 캐시가 여전히 유효한지 확인해야 함을 의미합니다. 캐시 유효성 검증에는 ETag가 사용됩니다.

ETag란?

ETag는 서버가 클라이언트에 응답을 보낼 때 추가하는 선택적 HTTP 헤더로, 일반적으로 응답 본문에 대한 일종의 체크섬(checksum)입니다. 클라이언트(브라우저)가 같은 리소스를 다시 요청할 때, 이전 응답이 캐싱되어 있다면 받았던 ETag를 If-None-Match 헤더에 담아 전송합니다. 서버는 리소스가 변경되지 않았다면 304(Not Modified) 코드와 빈 바디로 응답할 수 있으며, 이는 "서버의 버전이 그대로이므로 클라이언트는 캐싱된 버전을 사용하라"는 뜻입니다.

ETag에는 강한(strong) ETag와 약한(weak) ETag 두 종류가 있습니다(약한 ETag는 W/ 접두사로 표시). 두 방식의 동작은 같지만, 강한 ETag는 서버의 리소스와 로컬 캐시가 100% 바이트 단위로 동일함을 보장합니다. 반면 약한 ETag는 두 복사본이 바이트 단위로 완전히 같지 않을 수 있지만, 캐시된 버전을 사용해도 된다는 의미입니다.

대표적인 예가 Rails의 csrf_meta_tags 헬퍼입니다. 이 헬퍼는 매번 값이 바뀌는 CSRF(Cross-Site-Request-Forgery) 토큰을 생성하기 때문에, 정적 페이지라 해도 새로고침할 때마다 바이트 단위로 동일하지 않습니다. Rails는 기본적으로 약한 ETag를 사용합니다.

Rails에서의 ETag 활용

Rails는 뷰에 대한 ETag를 자동으로 처리합니다. 응답 헤더에 ETag를 포함하고, 들어오는 요청의 ETag 헤더를 검사하는 미들웨어를 통해 적절한 경우 304(Not Modified) 코드를 반환합니다.

다만 주의할 점이 있습니다. Rails는 뷰를 동적으로 생성하기 때문에 해당 뷰의 ETag를 계산하려면 결국 렌더링 작업을 모두 수행해야 합니다. 즉, ETag가 일치하더라도 절약되는 것은 네트워크 전송 시간과 대역폭뿐입니다. 캐싱된 버전이 있으면 렌더링 단계 자체를 건너뛸 수 있는 뷰 캐싱(view caching)과는 다른 셈입니다. 그래도 Rails는 생성되는 ETag를 조정할 몇 가지 방법을 제공합니다.

stale? 헬퍼

매번 바뀌는 CSRF 토큰 때문에 ETag가 달라지는 문제를 우회하는 방법 중 하나가 ActionControllerstale? 헬퍼입니다. 이를 통해 ETag(강한 또는 약한)를 직접 설정할 수 있습니다. 또한 ActiveRecord 모델 같은 객체를 그대로 넘기면 객체의 updated_at 타임스탬프를 기반으로 ETag를 계산하며, 컬렉션을 넘기면 그중 가장 최신의 updated_at 값을 사용합니다.

class UsersController < ApplicationController
  def index
    @users = User.includes(:posts).all

    render :index if stale?(@users)
  end
end

curl로 페이지에 접속하면 결과를 확인할 수 있습니다.

# curl -I localhost:3000 -- 첫 페이지 로드
ETag: W/"af9ae8f2d66b9b6c4d0513f185638f1a"
# curl -I localhost:3000 -- 재로드 (CSRF 토큰 때문에 변경됨)
ETag: W/"f06158417f290334f47ea2124e08d89d"

-- 컨트롤러에 stale? 추가 후

# curl -I localhost:3000 -- 재로드
ETag: W/"04b9b99835c359f36551720d8e3ca6fe" -- 이제 @users로 ETag 생성
# curl -I localhost:3000 -- 재로드
ETag: W/"04b9b99835c359f36551720d8e3ca6fe" -- 변화 없음

이렇게 하면 클라이언트가 전체 페이로드를 다시 내려받아야 하는 시점을 더 세밀하게 제어할 수 있습니다. 하지만 여전히 캐시가 유효한지 확인하려면 매번 서버에 접속해야 합니다. 이 확인 과정 자체를 생략하고 싶다면 어떻게 해야 할까요? 바로 여기서 헤더의 max-age 필드가 역할을 합니다.

expires_in과 http_cache_forever

Rails는 ActionController에서 max-age 필드를 조정할 수 있는 두 가지 헬퍼 메서드, expires_inhttp_cache_forever를 제공합니다. 이름 그대로 동작한다고 생각하면 됩니다.

class UsersController < ApplicationController
  def index
    @users = User.includes(:posts).all

    expires_in 10.minutes
  end
end
# curl -I localhost:3000
Cache-Control: max-age=600, private

Rails가 max-age를 600(10분을 초로 환산)으로 설정하고 must-revalidate 필드를 제거했습니다. public: true라는 키워드 인자를 넘기면 private 필드도 변경할 수 있습니다.

http_cache_forever는 사실상 expires_in의 래퍼(wrapper)로, max-age를 100년으로 설정하고 블록을 받습니다.

class UsersController < ApplicationController
  def index
    @users = User.includes(:posts).all

    http_cache_forever(public: true) do
      render :index
    end
  end
end
# curl -I localhost:3000
Cache-Control: max-age=3155695200, public

이런 극단적으로 긴 기간의 캐싱이 가능한 이유가 바로 Rails 에셋에 붙는 "지문(fingerprint)"입니다. 파일 내용의 해시를 파일명에 붙여 packs/js/application-4028feaf5babc1c1617b.js처럼 만드는 것이죠. 끝의 지문은 파일 내용과 파일명을 사실상 연결해 줍니다. 내용이 조금이라도 바뀌면 파일명도 함께 바뀌기 때문에, 브라우저는 이 파일을 영원히 안심하고 캐싱할 수 있습니다. 내용이 변경되면 지문도 바뀌고, 브라우저 입장에서는 완전히 새로운 파일로 인식해 다시 내려받기 때문입니다.

캐싱 전략에 대한 솔직한 조언

여기까지 다양한 캐싱 옵션을 살펴봤는데, 이제 드릴 조언은 다소 의외로 들릴 수 있습니다. 바로 이 글에서 소개한 방법들을 가급적 사용하지 않는 것입니다.

물론 ETag와 HTTP 캐싱에 대해 아는 것은 중요하고, Rails는 특정 문제를 해결할 수 있는 구체적인 도구도 제공합니다. 하지만 큰 단점이 하나 있습니다. HTTP 캐싱은 애플리케이션 외부에서 일어나기 때문에 통제권이 크게 벗어난다는 점입니다. Rails의 뷰 캐싱이나 저수준(low-level) 캐싱을 사용하다 무효화(invalidation) 문제가 생기면 대응 방법이 있습니다. 모델을 touch하거나, 업데이트된 코드를 배포하거나, 최악의 경우 콘솔에서 Rails.cache에 직접 접근할 수도 있죠. 하지만 HTTP 캐싱에는 그런 방법이 없습니다. 개인적으로는 프로덕션에서 Rails.cache.clear를 실행하는 편이, 사용자가 브라우저 캐시를 지우기 전까지 사이트가 깨져 있는 상황을 겪는 것보다 훨씬 낫다고 생각합니다(고객 지원팀도 분명 고마워할 겁니다).

마치며

지금까지 Rails 캐싱 시리즈의 마지막 글이었습니다. 캐싱에 대한 제 조언은 언제나 다음과 같습니다. 할 수 있는 만큼이 아니라, 필요한 만큼만 하라.

성능 문제를 겪고 있다면 자주 호출되는 메서드부터 찾아보세요. 메모이제이션(memoization)으로 해결할 수 있을지도 모릅니다. 요청 간에 값을 유지해야 한다면 해당 메서드에 저수준 캐싱을 적용해 볼 만합니다. 특정 메서드가 아니라 수많은 중첩된 파셜(partial) 렌더링이 병목이라면 뷰 레벨 캐싱이 도움이 될 수 있습니다. Rails는 이런 문제들을 각각 정밀하게 다룰 수 있는 "날카로운 칼"을 제공합니다. 우리가 할 일은 언제 어떤 칼을 사용할지 아는 것뿐입니다.