logo
Search검색어를 포함하는 게시물들이 최신순으로 표시됩니다.
    Navigation
    Table of Contents
    OpenGraph로 링크 미리보기 구현하기

    이미지 보기

    OpenGraph로 링크 미리보기 구현하기

    link-preview-js를 사용하여 Link Preview를 구현하는 방법을 알아봅니다.

    • 25.10.30 작성

    • 읽는 데 17

    TOC

    링크 미리보기

    노션처럼 링크 미리보기

    저는 이 블로그를 개발하면서 언젠가는 꼭 해야겠다는 숙원 사업이 있었습니다. 바로 링크 미리보기였습니다. Notion처럼 말이죠.

    노션처럼 링크 미리보기

    이 링크 미리보기와 함께 Link 고도화 작업을 진행했고, 생각보다 간단히 해결했습니다. 그 과정을 공유합니다.

    작업 목표

    1. 링크를 여러 타입으로 표현할 수 있다.
    2. 링크의 OpenGraph를 활용하여 표현한다.
    3. 내부 링크와 외부 링크를 구분하여 새 창 열기 여부를 구분한다.

    작업 결과

    이 작업을 통해서 이런 결과물을 얻었습니다.

    공통

    • 기본 스타일, hover 스타일
    • internal/external 새 창 열기 구분

    url (inline)

    favicon (inline)

    inline (inline)

    bookmark (block)

    OpenGraph 데이터를 모두 사용합니다.

    https://blog.huns.site/blog/posts/dev/research/improve-seo-of-my-blog

    작업 환경은 다음과 같습니다.

    • Next.js (app router)
    • SCSS
    • Contentlayer

    오늘의 메인은 아니지만, 제 블로그는 MDX로 작성된 글을 표시하기 위해 Contentlayer를 사용하고 있습니다. MDX는 markdown(md)과 함께 JSX를 사용할 수 있도록 확장된 개념입니다. 별도의 Link 컴포넌트를 만들어서 mdxComponent로 등록해주었습니다.

    import { useMDXComponent } from 'next-contentlayer/hooks';
    import { Link } from '@/components/ui/link';
    
    const mdxComponents = { Link, ... }
    
    const PostBody = () => {
      const MDXComponent = useMDXComponent(code);
    
      return (
        <MDXComponent components={mdxComponents} />
      )
    }
    

    물론 많이 축약한 코드입니다. 이 코드를 보여드린 의미는 이렇습니다.

    1. 이후의 모든 코드 전개는 @/components/ui/linkLink 컴포넌트에서 진행됩니다.
    2. next/link를 직접 사용하는 것이 아닌, wrapping한 (custom) link 컴포넌트를 사용합니다.

    링크의 OpenGraph 데이터 찾아오기

    링크 미리보기 기능을 위한 핵심 요소입니다. link-preview-js라는 라이브러리를 사용했습니다.

    https://www.npmjs.com/package/link-preview-js

    이 라이브러리는 HTML의 OpenGraph 데이터를 쉽게 추출하기 위해 사용합니다. 하지만 쉽게 사용할 수는 없습니다.

    You cannot preview (fetch) another web page from YOUR web page. This is an intentional security feature of browsers called CORS.

    공식 README에서 강조하는 내용입니다. 치명적이게도, 같은 도메인의 페이지만 추출할 수 있다는 것입니다. 웹 개발에서 흔히 마주하는 동일 출처 정책(SOP)과, 교차 출처 리소스 공유(CORS)에 관련된 문제입니다.

    CORS 문제 해결하기

    CORS 문제는 브라우저에서 서로 다른 출처에 대해서만 적용됩니다. Client 코드에서 link-preview-jsgetLinkPreview 함수를 호출하는 경우에도 마찬가지입니다. 하지만 Node.js 서버는 브라우저를 사용하는 방식이 아니기 때문에 CORS 문제를 피할 수 있습니다.

    • Next.js를 사용한다면, API Routes를 통해 자체 Node.js 서버를 사용할 수 있습니다.
    • Vite 등 Node/Express 서버가 없는 SPA app의 경우 Node.js 서버를 별도로 구축해야 합니다.

    저는 Next.js를 사용하기 때문에 API Routes를 사용해서 해결해보겠습니다.

    API Routes란?

    API Routes는 Next.js 애플리케이션 내부에서 백엔드 기능(서버리스 API)을 간단하게 구현할 수 있도록 해주는 기능을 제공합니다. Next.js의 app router 버전으로 코드를 소개하겠습니다.

    API 구현 과정

    큰 흐름은 이렇습니다.

    1. Server: API Routes 파일 생성
    2. Client: API 호출 함수 생성
    3. Link: API 함수 호출 및 결과값 저장
    4. ...

    linkData 이후 과정은 아래에서 더 다루고, 지금은 OpenGraph의 우회 추출에 집중해보겠습니다.

    Server: API Routes 파일 생성

    // src/app/api/open-graph/route.ts
    import { getLinkPreview } from 'link-preview-js';
    
    export async function GET(request: Request) {
      const { searchParams } = new URL(request.url);
      const url = searchParams.get('url');
    
      if (!url) {
        return new Response('URL is required', { status: 400 });
      }
    
      try {
        const res = await getLinkPreview(encodeURIComponent(url));
        return new Response(JSON.stringify(res), { status: 200 });
      } catch (error) {
        console.error(error);
        return new Response('Error', { status: 500 });
      }
    }
    

    API 호출은 GET method이고, searchParams를 통해 조회하는 url을 전달합니다. link-preview-jsgetLinkPreview 함수를 호출하고, 결과를 JSON 형식으로 반환합니다. 이런 타입으로 반환됩니다.

    {
        "url": "https://mdxjs.com/",
        "title": "Markdown for the\n component era | MDX",
        "siteName": "MDX",
        "description": "MDX lets you use JSX in your markdown content. You can import components, such as interactive charts or alerts, and embed them within your content. This makes writing long-form content with components a blast.",
        "mediaType": "article",
        "contentType": "text/html",
        "images": [
            "https://mdxjs.com/og.png"
        ],
        "videos": [],
        "favicons": [
            "https://mdxjs.com/favicon.ico",
            "https://mdxjs.com/icon.svg"
        ],
        "charset": "utf-8"
    }
    

    Client: API 호출 함수 생성

    위에서 정의한 /api/open-graph 경로에 대하여 Client에서 API 호출 함수를 생성합니다.

    // src/api/open-graph/getLinkPreview.api.ts
    export const getLinkPreview = async (url: string) => {
      const response = await fetch(`/api/open-graph?url=${url}`);
      return response.json();
    };
    

    제 블로그는 API 통신이 별로 없어서 http 요청 라이브러리를 별도로 두지 않았고, 그래서 fetch를 사용했습니다. 보시는 분들께서는 개발 환경에 맞춰 axios나 ky 등의 http 클라이언트 라이브러리를 사용할 수 있겠습니다.

    // src/components/ui/link/Link.tsx
    import NextLink from 'next/link';
    import { getLinkPreview } from '@/api/open-graph/getLinkPreview.api';
    import { getFullUrl } from '@/shares/link/link.util';
    
    interface LinkData {
      url: string;
      siteName: string;
      title: string;
      description: string;
      images: string[];
      favicons: string[];
    }
    
    interface LinkProps extends Omit<ComponentProps<typeof NextLink>, 'type'> {
      type?: LinkType;
    }
    
    const Link = (props: LinkProps) => {
      const [linkData, setLinkData] = useState<LinkData | null>(null);
      
      useEffect(() => {
        const initializeLinkData = async () => {
          const url = getFullUrl(props.href);
          const res = await getLinkPreview(url);
          setLinkData(res);
        };
    
        initializeLinkData();
      }, [props.href]);
    }
    

    Link.tsx 컴포넌트는 next/link의 기본 Link(이하 NextLink)를 wrapping하는 컴포넌트입니다. href를 의존하는 useEffect 내부에서 위에서 만든 OpenGraph 조회 API를 호출하고 local state에 저장합니다.

    추가로, link href는 /blog/posts/... 등 내부 링크를 향하는 경우가 있고, https://... 등 외부 링크를 향하는 경우가 있습니다. 이를 구분하기 위해 link.util.ts에서 유틸리티 함수를 정의하여 사용했습니다.

    // src/shares/link/link.util.ts
    import { Url } from 'next/dist/shared/lib/router/router';
    
    type LinkHref = string | URL | Url;
    
    export const isInternal = (href: LinkHref) => {
      return href.toString().startsWith('/');
    };
    
    export const getFullUrl = (href: LinkHref) => {
      if (isInternal(href)) {
        return `${BASE_CONFIG.URL}${href.toString()}`;
      }
      return href.toString();
    };
    

    특히 href 만으로 내부/외부 링크 여부를 확인하여 페이지를 이동시킬지, 새 창을 열지를 결정하는 유틸리티 함수를 만들었습니다.

    export const getLinkTarget = (href: LinkHref) => {
      return isInternal(href) ? '_self' : '_blank';
    };
    
    export const getLinkRel = (href: LinkHref) => {
      return isInternal(href) ? undefined : 'noopener noreferrer';
    };
    

    이를 통해 NextLinktarget, rel 속성을 동적으로 설정하고, 외부 링크 클릭에 대해 현재 블로그 콘텐츠를 유지할 수 있습니다. 작업 목표 3번을 달성했습니다.

    다양한 타입 표현하기

    위의 작업 결과 부분에서 먼저 보여진 것처럼, Link 컴포넌트는 현재 url(default), favicon, inline, bookmark의 4가지 UI 타입으로 분류해 표현했습니다.

    LinkType

    LinkType은 현재 4가지 타입을 정의하고 있습니다.

    // src/components/ui/link/Link.tsx
    export type LinkType = 'default' | 'favicon' | 'inline' | 'bookmark';
    
    const Link = ({ type = 'default', ...props }: LinkProps) => {
      const [linkData, setLinkData] = useState<LinkData | null>(null);
      ... // linkData 초기화 로직
      
      if (!linkData) {
        return <LinkUrl {...props} />;
      }
    
      switch (type) {
        case 'favicon':
          return <LinkFavicon linkData={linkData} {...props} />;
        case 'inline':
          return <LinkInline linkData={linkData} {...props} />;
        case 'bookmark':
          return <LinkBookmark linkData={linkData} {...props} />;
        case 'default':
        default:
          return <LinkUrl {...props} />;
      }
    

    LinkFavicon

    linkData를 사용하며, children을 선택적으로 사용하는 LinkFavicon 컴포넌트 코드를 소개합니다.

    const LinkFavicon = ({
      linkData,
      children,
      ...props
    }: LinkProps & { linkData: LinkData }) => {
      const { favicons, siteName, title } = linkData;
      const favicon = favicons[0];
    
      return (
        <NextLink
          className={cx('link-favicon')}
          target={getLinkTarget(props.href)}
          rel={getLinkRel(props.href)}
          {...props}
        >
          <Favicon src={favicon} alt={siteName} />
          {children ?? title}
        </NextLink>
      );
    };
    

    다른 코드들도 linkData를 사용한다면 비슷하게 구현되었습니다.

    OpenGraph 부족에 대응하기

    OpenGraph 부족한 이유들

    기본 구현까지는 순탄했으나, 모든 웹페이지에서 OpenGraph 데이터가 잘 추출되는 것은 아닙니다.

    1. SEO 구성을 제대로 하지 않은 사이트
    2. 보안 정책으로 favicon, thumbnail image가 제공되지 않는 사이트
    3. 모종의 이유로 OpenGraph 추출이 지연되는 사이트

    다양한 이유가 있겠지만, 일단 OpenGraph 데이터가 제대로 제공이 되지 않더라도 알고 있는 데이터로 UI를 구성하는 전략이 필요했습니다.

    OpenGraph 부족 예시

    위의 상황에서 2번 예시로 https://www.npmjs.com/package/link-preview-js 같은 경우가 있었습니다.

    link-preview-js의 OpenGraph 부족

    favicon, (thumbnail) image가 아예 제공되지 않아 undefined인 경우에는 가려지게 표시했으나, Just a moment... 등의 임시 title, 비유효한 favicon url이 제공되는 경우에는 Image에 정상적으로 src가 들어간 뒤, 흔히 엑박이라고 하는 에러 UI를 표시합니다.

    OpenGraph 부족 대응

    전략 결정

    신기한 점은, 문제가 되는 사이트 중 일부는 10번 호출하면 1번 정도는 정상적으로 OpenGraph가 load가 되었습니다. 그래서 선택을 해야 했습니다.

    1. 결과 우선: 정상 응답을 받을 때까지 재호출
    2. 속도 우선: 최초 응답 결과를 기준으로 부족한 경우 fallback UI를 표시

    그리고 notion은 어떤 전략을 선택했는지 확인했습니다.

    link-preview-js의 OpenGraph 부족

    favicon, thumbnail image가 비정상인 경우엔 아예 표시하지 않고, title이 들어가는 자리는 hostname으로 대체했습니다. 저도 같은 전략을 취하기로 결정했습니다.

    Favicon

    favicon, inline, bookmark 모두 Favicon 컴포넌트를 사용하고 있습니다.

    import { LinkIcon } from 'lucide-react';
    
    const Favicon = ({
      src,
    }: {
      src?: string;
    }) => {
      const [isShowFallback, setIsShowFallback] = useState(false);
    
      if (isShowFallback || !src) {
        return <LinkIcon className={cx('favicon')} />;
      }
    
      return (
        <Image
          src={src}
          alt={'favicon'}
          className={cx('favicon')}
          width={16}
          height={16}
          onError={() => setIsShowFallback(true)}
        />
      );
    };
    

    src가 없거나, 에러가 발생하면 isShowFallback를 true로 설정하고, LinkIcon 아이콘을 대체하여 표시합니다.

    Thumbnail

    bookmark 타입에서는 OpenGraph의 images 중 첫 번째 이미지를 표시합니다. 하지만 src가 이상해 Image 태그에서 error가 발생한다면, 이를 감지하여 thumbnail 영역 자체를 미표시하게 했습니다.

    const LinkBookmark = ({
      linkData,
      ...props
    }: LinkProps & { linkData: LinkData }) => {
      const { images, ... } = linkData;
      const thumbnail = images[0];
      const [isThumbnailError, setIsThumbnailError] = useState(false);
    
      return (
        <NextLink
          className={cx('link-bookmark')}
          {...props}
        >
          {!isThumbnailError && (
            <div className={cx('thumbnail-wrapper')}>
              <Image
                className={cx('thumbnail')}
                src={thumbnail}
                alt={'thumbnail'}
                fill
                onError={() => setIsThumbnailError(true)}
              />
            </div>
          )}
          ...
        </NextLink>
      );
    };
    

    Title

    마지막으로 title 영역인데, title은 error를 확인하기 어렵고, 라이브러리 차원에서 임시 비유효 title를 반환하는 등의 일이 많았습니다. 그래서 구분이 어려워 난항을 겪었습니다.

    그런데 OpenGraph에서 정상적인 title을 추출하는 데에 문제가 있는 경우, 공통적인 특징을 발견했습니다. 바로 siteName이 없다는 것입니다. 그래서 siteName이 없다면 hostname을 추출해 대체했습니다. (이 역시 Notion의 방식을 참고했습니다.)

    const LinkBookmark = ({
      linkData,
      ...props
    }: LinkProps & { linkData: LinkData }) => {
      const { url, title, siteName } = linkData;
      const hostname = new URL(url).hostname;
    
      return ...>
          <div className={cx('content-wrapper')}>
            <div className={cx('title-wrapper')}>
              <span className={cx('title')}>
                {siteName && title ? title : hostname}
              </span>
              ...
        </NextLink>
      );
    };
    

    LinkInline 컴포넌트에서 더 극적으로 드러납니다.

    const LinkInline = ({
      linkData,
      ...props
    }: LinkProps & { linkData: LinkData }) => {
      const { url, title, siteName } = linkData;
      const hostname = new URL(url).hostname;
    
      return (
        <NextLink ...>
          {siteName ? (
            <>
              <span className={cx('site-name')}>{siteName} </span>
              <span className={cx('title')}>{title} </span>
            </>
          ) : (
            <span className={cx('title')}>{hostname} </span>
          )}
        </NextLink>
      );
    };
    

    결과

    OpenGraph 데이터가 부족한 경우에도 정상적인 UI를 표시할 수 있었습니다. 아래는 inline, bookmark 타입의 정상/비정상 표현 결과입니다.

    OpenGraph 정상 응답 OpenGraph 정상 응답

    OpenGraph 비정상 응답 OpenGraph 비정상 응답

    마치며

    숙원 사업을 무사히 마쳐서 후련하네요. 그리고 블로그가 한층 풍성해진 것 같아 기쁩니다. 그리고 사실 Link 작업을 한 건 조금 된 일이지만 늦게나마 노하우를 적을 수 있게 되어 다행입니다.

    긴 글 보신 분들께 도움이 되시길 바랍니다. 감사합니다.

    profile

    FE Developer 박승훈

    노력하는 자는 즐기는 자를 이길 수 없다