캐시를 넣기 전에 N+1과 쿼리 예산부터 고정하기
느린 화면과 많은 쿼리는 다른 문제다
응답이 느리다고 바로 fragment cache를 추가하면 원인이 가려질 수 있다. 데이터가 열다섯 건인데 쿼리가 서른 번 발생하는 N+1과, 쿼리는 세 번이지만 Markdown 렌더링이 비싼 경우는 해법이 다르다. 먼저 로그나 sql.active_record 알림으로 쿼리 수를 세고, 렌더링 시간을 별도로 측정한다.
특히 개발 환경은 query cache가 같은 SQL을 숨기므로 cold 상태를 확인해야 한다. 캐시를 비운 첫 요청과 데워진 두 번째 요청을 나눠 기록하면 다음 질문에 답할 수 있다.
- 데이터 수가 늘면 쿼리 수도 선형으로 증가하는가?
- 같은 레코드를 attachment와 blob 경로에서 반복 조회하는가?
- 캐시 miss 때만 느린가, hit에서도 느린가?
원인을 분리해야 includes, 일괄 조회, 계산 결과 캐시 중 올바른 수단을 고를 수 있다.
쿼리 예산을 회귀 테스트로 만든다
N+1 테스트는 “빠르다”는 인상 대신 허용 가능한 SQL 횟수를 계약으로 만든다. 고정된 fixture 수에만 맞추지 말고 항목을 추가해도 예산이 변하지 않는지 보는 것이 중요하다.
test "feed query count does not grow per post" do
3.times { |n| create_published_post("추가 글 #{n}") }
count = count_sql_queries do
get feed_path(format: :atom)
end
assert_operator count, :<=, 12
assert_response :success
end
스키마 조회와 트랜잭션 제어 SQL은 측정에서 제외하고, 실제 SELECT만 세도록 helper를 명시적으로 만든다. 숫자는 가능한 한 빡빡하게 잡되 Rails 내부 구현 한 번 바뀔 때마다 흔들릴 정도로 낮추지는 않는다. 왜 그 예산인지 테스트 주석에 근거를 남긴다.
Active Storage 메타데이터도 일괄 조회한다
includes(cover_image_attachment: :blob)만 추가했다고 모든 N+1이 사라지는 것은 아니다. feed에서 본문 이미지의 width와 height를 얻기 위해 각 post마다 attachment를 다시 조회하면 새로운 N+1이 생긴다. 컬렉션 렌더링 전에 필요한 attachment와 blob metadata를 한 번에 읽어 record_id 기준 Hash로 구성한다.
dimensions_by_post_id = ActiveStorage::Attachment
.where(record_type: "Post", record_id: posts.map(&:id))
.includes(:blob)
.group_by(&:record_id)
렌더러에는 DB를 조회하는 객체 대신 이미 준비한 dimension 값을 전달한다. 이렇게 하면 렌더링 계층이 데이터 접근을 몰래 수행하지 않고, feed 항목 수가 늘어도 쿼리 수가 일정하게 유지된다.
캐시 키는 출력 계약과 무효화 사건을 표현한다
쿼리를 정리한 뒤에도 Markdown 변환처럼 반복 비용이 크다면 캐시가 유효하다. 이때 키에는 레코드 갱신 시각뿐 아니라 렌더러 출력 버전도 포함해야 한다.
Rails.cache.fetch(
["post-markdown", MarkdownRenderer::CACHE_VERSION, post.cache_key_with_version]
) do
MarkdownRenderer.call(post.body)
end
sanitizer allowlist나 heading 규칙을 바꾸고 버전을 올리지 않으면 이전 HTML이 계속 노출된다. 댓글 수처럼 연관 데이터가 출력에 포함된다면 댓글 생성·삭제 시 post cache가 무효화되는지도 테스트한다. 캐시 키에 모든 모델을 무작정 넣기보다 “어떤 사건이 화면을 바꾸는가”를 먼저 적고 그 사건만 반영한다.
운영에서는 cold와 warm 결과를 함께 본다
배포 후 한 번의 빠른 응답만 보고 성공으로 판단하지 않는다. 캐시를 비운 cold 요청, 같은 URL의 warm 요청, 데이터가 충분한 목록 요청을 각각 여러 번 측정해 median과 p95를 남긴다. 캐시 hit 비율이 낮은데 warm 결과만 좋다면 실제 사용자는 이득을 거의 보지 못한다.
최종 점검 항목은 단순하다.
- 항목 수 증가에도 SQL 횟수가 일정하다.
- 캐시 miss와 hit 모두 오류 없이 같은 HTML 계약을 제공한다.
- 레코드 수정과 연관 데이터 변경 후 오래된 내용이 남지 않는다.
- 렌더러 변경 시 cache version 회귀 테스트가 실패한 뒤 새 버전에서 통과한다.
- 운영 측정은 환경, 표본 수, cold/warm 여부를 함께 기록한다.
캐시는 N+1을 숨기는 붕대가 아니다. 먼저 데이터 접근 횟수를 고정하고, 그다음 계산 비용을 줄이는 순서가 가장 예측 가능하다.
댓글
아직 댓글이 없습니다.
댓글 남기기