Rails에서 동적 OG 이미지

발행: (2026년 6월 10일 PM 08:26 GMT+9)
8 분 소요
원문: Dev.to

출처: Dev.to

Rails 애플리케이션의 모든 블로그 포스트, 제품 페이지, 프로필은 자체 Open Graph 이미지가 필요합니다. 이 이미지는 트위터, LinkedIn, Slack 등에서 링크를 공유했을 때 표시되는 그림입니다. 문제는 Ruby에서 이러한 이미지를 생성할 수 있는 옵션이 JavaScript와 PHP 세계에 비해 열악하다는 점입니다. Satori도 없고, 이미지‑템플릿화에 대한 일류 지원도 없으며, 존재하는 도구들은 직접 픽셀을 배치하거나 Puma 옆에 헤드리스 브라우저를 띄워야 합니다.

이 글에서는 Rails에서 현실적인 세 가지 방법을 살펴본 뒤, 뷰 레이어와 서버를 깔끔하게 유지할 수 있는 방법—HTTP를 통해 ERB 템플릿을 PNG로 렌더링하고, 캐시와 백그라운드 잡을 사용해 요청 경로에 부담을 주지 않는 방법—을 구현합니다.

※ 이 글은 처음에 html2img.com에 게재된 글을 교차 게시한 것입니다.

접근 방식

방법실행 환경가장 적합한 경우
이미지 라이브러리 (MiniMagick, ruby‑vips)서버에 ImageMagick 또는 libvips 설치텍스트가 거의 없고 레이아웃이 고정된 경우
헤드리스 Chrome (Grover)Puma와 함께 Node와 Chromium 바이너리 배치전체 CSS 제어가 필요하고 운영 비용을 감당할 수 있는 경우
HTML → Image API단일 HTTP 호출, 로컬에 별도 설치 필요 없음브라우저를 실행하지 않고도 CSS 레이아웃을 사용하고 싶은 경우

순수 Ruby 방식: MiniMagick / ruby‑vips

기본 이미지를 열고 텍스트를 합성합니다.

require "mini_magick"

image = MiniMagick::Image.open(Rails.root.join("app/assets/images/og-base.png"))

image.combine_options do |c|
  c.gravity "NorthWest"
  c.pointsize "64"
  c.fill "#0F172A"
  c.font "Inter-Bold"
  c.annotate "+80+80", post.title
end

image.write(Rails.root.join("public/og/#{post.id}.png"))

텍스트가 복잡해지면 문제가 생깁니다. 모든 요소를 손수 배치해야 하고, 줄 바꿈도 없으며 문자열이 차지할 너비를 측정하기 전까지는 알 수 없습니다. 또한 모든 머신에 폰트 파일을 설치하고 참조해야 합니다. 고정 배경에 짧은 한 줄 정도라면 괜찮지만, 줄 바꿈이 필요한 제목, 저자 라인, 로고 등을 넣으려면 ImageMagick 옵션으로 CSS 레이아웃을 재구현하는 셈이 됩니다. 도구의 역할에 맞지 않는 접근입니다.

실제 브라우저 사용: Grover

CSS가 필요하다면 실제 브라우저에서 HTML을 렌더링하는 것이 직관적입니다. Grover는 Puppeteer를 래핑해 HTML을 PNG로 변환합니다.

# Gemfile
gem "grover"

html = ApplicationController.render(
  template: "og/post",
  layout: false,
  assigns: { post: post }
)

png = Grover.new(html, width: 1200, height: 630).to_png
File.binwrite(Rails.root.join("public/og/#{post.id}.png"), png)

출력 품질은 브라우저 덕분에 뛰어나지만, 운영 비용이 발생합니다. Grover는 Puppeteer를 구동하므로 이미지 생성 머신마다 Node와 Chromium 바이너리를 설치해야 합니다. 컨테이너 환경이라면 수백 메가바이트 규모의 Chrome 이미지가 필요하고, 프로세스가 시작될 때 콜드 스타트 지연도 발생합니다. 즉, 사진을 만들기 위해 브라우저를 운영하게 되는 것입니다.

API 활용: HTML → Image

세 번째 옵션은 브라우저 수준의 렌더링 품질을 유지하면서 인프라에서 분리하는 방법입니다. OG 카드를 일반 ERB 템플릿으로 작성하고, 이를 HTML 문자열로 변환한 뒤 이미지 API에 POST합니다. 한 번의 HTTP 호출만 있으면 되며, 별도 설치가 필요 없습니다.

# rails credentials에 추가 (rails credentials:edit)
html2img:
  api_key: your-key-here

키는 모든 요청에 X-API-Key 헤더로 전송됩니다. 이제 템플릿을 만듭니다. 인라인 CSS로 스타일링하고, API에 요청할 정확한 크기로 맞춥니다.

<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
  * { margin: 0; box-sizing: border-box; }
  body {
    width: 1200px; height: 630px; padding: 80px;
    display: flex; flex-direction: column; justify-content: space-between;
    background: #0F172A; color: #F8FAFC; font-family: 'Inter', sans-serif;
  }
  .title { font-size: 64px; font-weight: 800; line-height: 1.1; max-width: 1000px; }
  .meta  { font-size: 26px; color: #94A3B8; }
</style>

<div class="title"><%= post.title %></div>
<div class="meta"><%= post.author %> &middot; <%= post.published_at.to_s(:short) %></div>

body에 지정한 너비와 높이가 API에 전달할 크기와 일치해야 합니다. 실제 브라우저에서 렌더링되므로 이모지, 웹 폰트, 전체 CSS가 그대로 동작합니다.

서비스 객체로 감싸기

ApplicationController.render는 템플릿을 문자열로 변환하고, Faraday를 이용해 API에 POST합니다.

# app/services/og_image_generator.rb
class OgImageGenerator
  ENDPOINT = "https://app.html2img.com/api/html".freeze

  class GenerationError < StandardError; end

  def initialize(post)
    @post = post
  end

  def call
    html = ApplicationController.render(
      template: "og/post",
      layout: false,
      assigns: { post: @post }
    )

    response = Faraday.post(
      ENDPOINT,
      { html: html }.to_json,
      {
        "Content-Type" => "application/json",
        "X-API-Key" => Rails.application.credentials.dig(:html2img, :api_key)
      }
    )

    raise GenerationError, response.body unless response.success?

    result = JSON.parse(response.body)
    @post.update!(og_image_url: result["url"])
  end
end

메타 태그 설정

twitter:cardsummary_large_image 로 지정하면 썸네일이 아니라 전체 폭 이미지가 표시됩니다.

테스트에서 HTTP 호출 스텁하기

# test/jobs/generate_og_image_job_test.rb
require "test_helper"

class GenerateOgImageJobTest < ActiveJob::TestCase
  test "generates OG image and saves URL" do
    post = posts(:one)

    stub_request(:post, OgImageGenerator::ENDPOINT)
      .to_return(
        status: 200,
        body: { url: "https://i.html2img.com/abc123.png" }.to_json,
        headers: { "Content-Type" => "application/json" }
      )

    GenerateOgImageJob.perform_now(post)

    assert_equal "https://i.html2img.com/abc123.png", post.reload.og_image_url
  end
end

위 코드는 대부분의 Rails 테스트 환경에 기본으로 포함된 WebMock을 사용합니다. RSpec에서도 동일한 패턴을 적용할 수 있습니다.

언제 어떤 방법을 써야 할까?

  • 레이아웃이 고정되고 텍스트가 거의 없을 때는 MiniMagick을 사용합니다.
  • 자체 인프라에서 브라우저가 반드시 필요하고 OG 이미지가 부수적인 혜택일 경우 Grover를 선택합니다.
  • 그 외 대부분의 경우는 HTML → Image API를 이용해 ERB 템플릿을 렌더링하고, 캐시와 백그라운드 잡을 결합하면 서버에 Chrome을 두지 않아도 되고, 실제 CSS를 그대로 사용할 수 있으며, 이미지당 한 번의 HTTP 호출 비용만 발생합니다.

전체 글과 추가적인 팁은 html2img.com에서 확인할 수 있습니다. 현재 여러분은 Rails 앱에서 OG 이미지를 어떻게 생성하고 있나요? 댓글로 알려 주세요.

0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...