개인 블로그에 Opengraph 카드 UI 만들기 Thumbnail

서버 사이드 GET, rehypeRewrite

16 min read
구현UX

개인 블로그에 오픈그래프 기능 도입하기

썸네일썸네일

티스토리 블로그나 디스코드, 카카오톡 등 플랫폼 서비스들은 대부분 외부 링크를 입력하면 하단에 해당 링크의 오픈그래프 정보를 알려주는 카드 UI를 렌더링해준다. 이런 UI가 블로그에 있어서는 무조건 있어야 한다고 생각했기 때문에 직접 만들어보기로 했다.

이전에도 블로그에 유튜브 임베딩하기 글에서 블로그에 유튜브 링크를 걸 경우에 자동으로 하단에 유튜브 영상 임베딩 iframe을 삽입하는 기능을 구현한 적이 있었다.

원래는 저 기능을 구현할 때 오픈그래프 카드도 같이 구현하려고 했었는데 그 때에는 현재 쓰고 있는 에디터가 비동기적인 데이터 리렌더링을 지원하지 않는 줄 알고 있었기 때문에 구현을 포기했었다.

최근에 다시 찾아보니 비동기로 데이터 fetch를 해와서 업데이트를 해도 UI가 리렌더링이 된다는 것을 알게 되고 나서 이 기능을 다시 구현해보려고 했다.

오픈그래프란?

오픈그래프의 사전적 개념에 대해서 알아보자.

오픈 그래프(Open Graph)는 웹 페이지가 소셜 미디어에 공유될 때 어떻게 보이는지를 제어하는 메타데이터 프로토콜입니다. Facebook이 2010년에 만들었고, 지금은 카카오톡, 트위터, Slack, Discord 등 거의 모든 플랫폼에서 사용합니다.

해당 사이트의 이름, 설명, 파비콘 (혹은 다른 대표이미지) 등을 미리 보여주는 기능이라 UX적으로 매우 좋다고 생각한다.

Discord의 Opengraph 렌더링Discord의 Opengraph 렌더링

오픈그래프 태그에는 여러가지가 있는데, 아래 태그들이 있다. 특정 플랫폼 별로 지정할 수도 있다.

<head>
  <!-- 필수 태그 -->
  <meta property="og:title"       content="페이지 제목" />
  <meta property="og:description" content="페이지 설명 (2~3문장)" />
  <meta property="og:image"       content="https://example.com/thumbnail.jpg" />
  <meta property="og:url"         content="https://example.com/page" />

  <!-- 권장 태그 -->
  <meta property="og:type"        content="website" />  <!-- article, video.movie 등 -->
  <meta property="og:site_name"   content="사이트 이름" />
  <meta property="og:locale"      content="ko_KR" />

  <!-- 트위터 카드 (별도 네임스페이스) -->
  <meta name="twitter:card"       content="summary_large_image" />
  <meta name="twitter:title"      content="페이지 제목" />
  <meta name="twitter:image"      content="https://example.com/thumbnail.jpg" />
</head>

오픈그래프의 UX 관점

오픈그래프(Opengraph)가 UX적 관점으로 훌륭하다고 생각하는 이유는 간단하다. 개인적으로 원래 옛날부터 잘 모르는 링크는 함부로 클릭하는 것이 아니라고 들어와서 그런 탓일까. 링크만 있으면 잘 클릭하지 않는 편이다.

그렇기 때문에 UX 관점에서 오픈그래프 카드 or 배너가 가지는 의미가 중요하다고 생각한다. 원래라면 링크만 있을 때는 볼 수 없는 해당 사이트의 타이틀, 설명, 썸네일 등을 한눈에 볼 수 있기 때문에 사용자로 하여금 무슨 사이트일지 알 수 있게 해준다.

와!와!

원리

직접 구현하기 전에는 사실 크롤러가 필요한 줄 몰랐다. og: 접두사로 시작하는 태그가 있기 때문에 외부 혹은 web api로 지원을 해주지 않을까라는 막연한 생각을 가지고 있었다.

알아보니 생각보다는 단순한 방법으로 가져오고 있었다. OG 데이터를 가져오는 것은 아래와 같다.

  • 사용자가 링크를 공유
  • 플랫폼의 크롤러가 해당 URL을 직접 방문
  • head 태그 내의 og: 태그를 파싱
  • UI 렌더링

의 단순한 방법이었다.

구현 코드

구현에 필요한 코드는 서버사이드의 외부 사이트 조회 API와 렌더링에 필요한 코드 2가지다.

클릭해서 API 코드보기
// GET /api/opengraph

// HTML 파일로 부터 메타데이터 파싱
const getMeta = (html: string, prop: string): string | null => {
  const patterns = [
    new RegExp(
      `<meta[^>]+(?:property|name)=["']${prop}["'][^>]+content=["']([^"']*)["']`,
      'i'
    ),
    new RegExp(
      `<meta[^>]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${prop}["']`,
      'i'
    ),
  ];
  for (const pattern of patterns) {
    const match = html.match(pattern);
    if (match?.[1]?.trim()) return match[1].trim();
  }
  return null;
};

// Open Graph API 엔드포인트
export const GET = async (request: Request) => {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');

  if (!url) return Response.json({ error: 'url required' }, { status: 400 });

  try {
    new URL(url);
  } catch {
    return Response.json({ error: 'invalid url' }, { status: 400 });
  }

  try {
    const res = await fetch(url, {
      headers: {
        'User-Agent':
          'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
        Accept: 'text/html,application/xhtml+xml',
      },
      signal: AbortSignal.timeout(5000),
    });

    if (!res.ok)
      return Response.json({ error: 'fetch failed' }, { status: 502 });

    const html = await res.text();
    const urlObj = new URL(url);

    const title =
      getMeta(html, 'og:title') ||
      getMeta(html, 'twitter:title') ||
      html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
      null;

    const description =
      getMeta(html, 'og:description') ||
      getMeta(html, 'twitter:description') ||
      getMeta(html, 'description') ||
      null;

    const image =
      getMeta(html, 'og:image') ||
      getMeta(html, 'twitter:image:src') ||
      getMeta(html, 'twitter:image') ||
      null;

    const siteName = getMeta(html, 'og:site_name') || null;

    const favicon = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`;

    return Response.json(
      {
        url,
        title,
        description,
        image,
        siteName,
        favicon,
        hostname: urlObj.hostname,
      },
      {
        headers: {
          'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
        },
      }
    );
  } catch {
    return Response.json({ error: 'fetch failed' }, { status: 502 });
  }
};


위 캐시 컨트롤에서 캐시 타임을 길게 잡았는데 이는 외부 사이트이기 때문에 해당 사이트의 대략적인 정보만 알려주는 목적이라 새로운 데이터 업데이트 중요도가 낮기 때문이다.


그리고 새롭게 알게된 사실이 있다. 원래는 rehypeRewrite 기능을 사용해서 마크다운 -> HTML 변환 과정이 있다. 변환 과정 사이에 rehypeRewrite 플러그인을 추가하여 노드를 순회하면서 원하는 특정 태그에 조작을 하는 방식으로 글 내부 조작을 했었다.

그런데 이렇게 하는 경우 rehypeRewrite 플러그인이 점점 비대해지는 단점이 있었다. 왜냐하면 JSX 방식이 아니라 dom 엘리먼트를 생성하고 하위에 children을 추가하는 식으로 UI를 만들어야했기 때문이다..

이 방법이 너무 번거롭고 특히 OgCard 같은 어느정도 복잡도를 가진 컴포넌트를 만드려고 하니 코드가 너무 길어지는 문제가 생겼다.

컴포넌트 방식으로 할 수 없나 방법을 찾아보니 MDEditor.Markdown 컴포넌트의 props로 components 로 미리 만들어둔 컴포넌트와 그 컴포넌트를 지칭하는 태그 이름 (여기서는 ogcard)을 매핑하여 전달해주면 rehype 과정에서 간단하게 컴포넌트로 불러올 수 있었다.

<MDEditor.Markdown
     ...
    components={{
      ogcard: ({ href }: { href?: string }) =>
        href ? <OgLinkCard href={href} /> : null,
    } as any} <-- ogcard를 tagName으로 OgLinkCard 컴포넌트 치환
    rehypeRewrite={(node, index?, parent?) => {
      asideStyleRewrite(node);
      renderOpenGraph(node, index, parent as Element | undefined);
      renderYoutubeEmbed(
        node,
        index || 0,
        parent as Element | undefined
      );
      addImageClickHandler(node);
      addDescriptionUnderImage(
        node,
        index,
        parent as Element | undefined
      );
    }}
  />

그런 다음 rehype 함수를 만들어서 p > a 태그가 있다면 ogcard 태그 요소를 추가하는 동작을 작성한다. 여기서 ogcard 태그는 실제 렌더링 때 OgLinkCard로 치환되어 렌더링된다.

/**
 * 링크를 감지하고 <ogcard> marker 노드로 변환
 * 실제 렌더링은 PostBody의 components prop에서 OgLinkCard 컴포넌트가 담당
 */
export const renderOpenGraph = (
  node: any,
  index?: number,
  parent?: Element
) => {
  if (node.type === 'element' && node.tagName === 'p' && node.children) {
    const aTag = node.children.find(
      (node: any) => node.type === 'element' && node.tagName === 'a'
    );
    if (!aTag) return;

    const href = aTag.properties?.href;
    if (!href || !(href.startsWith('/') || href.startsWith('http'))) return;

    const ogNode = {
      type: 'element',
      tagName: 'ogcard',
      properties: { href },
      children: [],
    };

    if (
      index !== undefined &&
      parent?.children &&
      Array.isArray(parent.children)
    ) {
      // 링크 텍스트가 URL 자체인 경우(bare URL, [url](url)) → <p> 대체
      // 커스텀 텍스트인 경우([text](url)) → <p> 유지 후 카드 삽입
      const linkText =
        aTag.children?.find((c: any) => c.type === 'text')?.value ?? '';
      const isUrlOnlyLink = linkText === href;
      if (isUrlOnlyLink) {
        parent.children.splice(index, 1, ogNode);
      } else {
        parent.children.splice(index + 1, 0, ogNode);
      }
    }
  }
};

결과

결과적으로 원래라면 StoryHelper 다운로드라는 대체 텍스트 링크만 보이던 부분의 아래에 오픈그래프 카드 UI가 렌더링 되는 것을 볼 수 있다.

결과결과

티스토리 같은 블로그 플랫폼에서는 당연하게 지원되는 기능이지만, 자유도 100%인 개인 블로그에서는 이런 기능을 직접 추가해야 한다는 점이 어떻게 보면 단점이지만 재미있는 포인트가 되는 것 같다.

Table of Contents

0
추천 글