티스토리 뷰

Projects/Askers

[Askers] Open Graph 적용

장일영 2024. 8. 11. 15:39

Askers에서 익명의 질문자로부터 온 질문에 유저가 답변을 달았을 때, 이를 SNS에 공유하는 기능을 구현하려 한다. 현재 Askers는 최소 기능만 구현해 빠르게 서비스하는 것을 목표로 하고 있다(Askers v1). 따라서 서비스의 타겟 유저가 가장 많이 사용하는 SNS인 트위터(X)에 공유하는 기능을 우선 개발하기로 했다.

 

기능

익명의 질문자가 보낸 질문에 유저가 답변했을 때, 연동된 트위터 계정이 있다면 답변 시 해당 답변 페이지 링크와 답변 내용을 트위터에 게시할 수 있다.

Askers 답변 페이지

현재 이 질문의 링크를 트위터에 게시하면 아래와 같이 노출된다.

Open Graph 태그가 적용되지 않았을 때 웹 페이지를 트위터에 공유한 화면

 

 

기댓값

답변을 트위터에 공유하는 유저 플로우는 다음과 같다.

  • 트위터 공유하기 버튼 클릭 시, 현재 로그인 된 트위터 계정으로 이동하고, 답변 내용과 답변 페이지 링크가 자동완성 되어 있다.
  • 게시된 트윗에서 답변 페이지 링크는 URL이 아닌 미리 보기 링크로 처리되고, 링크 이미지에 질문 내용이 노출된다.
  • 트윗의 링크를 클릭하면 Askers 웹 사이트의 해당 답변 페이지로 이동한다.

 

구현 방안

구현을 위해 작업 단위를 세 부분으로 나눴다.

  1. 트위터 게시물 작성 버튼 추가
  2. 질문 이미지 생성
  3. 답변 페이지 Open Graph 적용

작업 단위를 나눈 이유는 1-3의 개발 범위가 겹치지 않아 서로 영향을 주지 않기 때문이다. 각각의 작업 기댓값은 다음과 같다.

  • 트위터 게시물 작성 버튼 추가 작업: 트위터 공유하기 버튼 클릭 시, 트윗을 작성할 수 있는 링크로 연결된다.
  • 질문 이미지 생성: 유저가 답변을 하는 시점에 질문을 이미지로 저장한다.
  • 답변 페이지에 Open Graph 적용: 포스팅 된 트윗에 링크 문자열이 아닌 이미지가 노출된다.

 

개발

트위터 게시물 작성 버튼 추가

공유하기 버튼 클릭 시 바로 트위터 서비스로 이동해 자동으로 게시해야 할 문자열이 삽입된 트윗 작성 화면이 연결되도록 구현하기로 했다.

서비스 기획 당시에 생각했던 기능은 유저가 답변을 게시하는 시점에 연동된 트위터 계정으로 동시에 트윗을 작성하는 것이었다. 그러나 트위터 API에 요금이 부과되면서 프로젝트 1개 당 한 달에 1,500개의 트윗만 작성할 수 있게 되었다.

 

Twitter Developer - Embedded buttons

 

Guides

The Tweet button is a small button displayed on your website to help viewers easily share your content on Twitter. A Tweet button consists of two parts: a link to the Tweet composer on Twitter.com and Twitter for Websites JavaScript to enhance the link w

developer.x.com

트위터 공유하기 버튼 클릭 시 동작 화면

 

 

답변 페이지 Open Graph 적용

최종 목적은 익명의 유저가 질문한 내용을 Open Graph 이미지로 저장해 공유 시 해당 이미지가 트윗에 노출되도록 하는 것이다. 이 작업에서는 임의의 이미지를 가지고 Open Graph를 적용했다. 각 답변 페이지에 따라 동적으로 이미지를 생성하는 작업은 작업 범위에서 우선 제외했다. 설정한 이미지가 기댓값대로 노출되면, 이미지를 동적으로 생성하는 작업은 다음 커밋에서 진행해도 무방하기 때문에 작업 범위를 나눴다.

 

Next.js Metadata 

Askers는 Next.js 14 버전으로 개발했다. 기존의 Pages Router가 아닌 App Router 방식을 사용하고 있다. Next.js 공식 문서에 Metadata에 대한 부분이 잘 설명되어 있어서 이를 기반으로 Askers에 적용했다. 기본적으로 프레임워크에서 Metadata 적용을 지원하는 방식은 두 가지다. Config-based Metadata, 그리고 File-based Metadata.

 

Askers에서 택한 방식은 Config-based Metadata다. 이유는 다음과 같다.

  • 별다른 설정이 없는 경우 기본적으로 정의된 Metadata를 반환하고, 특정 페이지에서 Metadata의 일부만 수정해 사용하려 한다.
  • 특정 페이지 컴포넌트에서 필요한 경우 데이터를 fetch해 Metadata에 포함시켜야 한다.
  • Config-based Metadata의 경우 Metadata 파일을 각 세그먼트에 추가해야 하기 때문에 Metadata 관련 코드가 분산된다.

따라서 기본적으로 Static Metadata 방식을 사용하되, Metadata를 동적으로 생성해야 하는 페이지에서 Dynamic Metadata 방식을 사용하기로 결정했다.

 

우선 가장 기본적인 정보를 상수로 지정해 따로 저장했다.

// @/app/libs/constants/metadata.ts

export const META = {
  title: "Askers",
  siteName: "Askers",
  description: "Ask anything what you want.",
  keyword: [
    "askers",
    "asker",
    "익명질문",
    "애스커",
    "애스커스",
    "에스커",
    "트위터익명",
    "질문",
    "답변",
    "질문답변",
    "소통",
  ],
  url: "https://as-kers.com",
  ogImage: "/og-dark.png",
} as const;

 

그리고 이 정보를 기반으로 변경이 필요한 값을 파라미터로 받아 Metadata를 생성하는 유틸 함수를 만들었다.

우선 Askers 서비스에서 동적으로 Metadata 변경이 필요한 페이지는 답변 상세 페이지다. 변경이 일어날 부분을 파라미터로 만들고, 함수에 인자가 전달되지 않거나, MetadataParams에 일부 데이터가 빠져있는 경우 기본값이 지정되도록 했다.

// @/app/libs/metadata.ts

export interface MetadataParams {
  title: string | null;
  description: string | null;
  asPath: string | null;
  ogImage: string | null;
}
export const getMetadata = (metadataParams?: MetadataParams) => {
  const { title, description, asPath, ogImage } = metadataParams || {};

  const TITLE = title ? `${title} | Askers` : META.title;
  const DESCRIPTION = description || META.description;
  const PAGE_URL = asPath ? asPath : "";
  const OG_IMAGE = ogImage || META.ogImage;

  return {
    metadataBase: new URL(META.url),
    alternates: {
      canonical: PAGE_URL,
    },
    title: TITLE,
    keywords: [...META.keyword],
    openGraph: {
      title: TITLE,
      description: DESCRIPTION,
      siteName: TITLE,
      locale: "ko_KR",
      type: "website",
      url: PAGE_URL,
      images: {
        url: OG_IMAGE,
      },
    },
    verification: {}, // google, naver
    twitter: {
      title: TITLE,
      description: DESCRIPTION,
      images: {
        url: OG_IMAGE,
      },
    },
  };
};

 

답변 상세 페이지에서 Dynamic Metadata 방식으로 변경될 내용을 지정했다.

// @/(tabs)/answers/[id]/page.tsx

type Props = { params: { id: string } };
export const generateMetadata = async ({ params }: Props) => {
  const dispatch = await getAnswer(params.id);
  if (!dispatch) return;

  return getMetadata({
    title: dispatch.targetUser.nickname,
    asPath: `/answers/${params.id}`,
    description: dispatch.ask.content,
    ogImage: "/og-default.png",
  });
};

export default async function Page({ params }: Props) {}

 

 

답변 상세 페이지에서 변경되는 값은 웹 페이지 타이틀과 URL, 사이트 설명과 Open Graph 이미지다. 적용한 후 렌더링된 HTML Meta 태그에 기댓값대로 설정된 것을 확인할 수 있었다.

기본 Metadata와 답변 상세 페이지 Metadata

 

 

서비스 메인 링크를 공유하면 `og-dark.png`, 답변 상세 페이지 링크를 공유하면 `og-default.png` 파일이 이미지로 노출된다.

Twitter Open Graph가 적용된 후 게시한 트윗 이미지

 

질문 이미지 생성

답변 상세 페이지 공유 시 `og-default.png` 파일이 노출되는 대신, 각 페이지의 정보(질문 텍스트)가 포함된 이미지를 동적으로 생성하도록 했다. 작업 완료 시 기댓값은 다음과 같다.

  • 동적으로 생성된 이미지가 없는 경우 기본값인 `og-default.png` 파일이 Open Graph 이미지로 지정된다.
  • 답변 공유 시 노출되는 이미지가 페이지마다 다르다(질문 내용이 이미지에 포함되어야 함).

우선 이미지를 동적으로 생성하는 방식은 Vecel의 라이브러리를 사용하기로 했다. Askers는 Next.js 14 버전으로 개발했다. 버전 14에서는 이미 해당 라이브러리가 내장되어 있다.

 

@vecel/og Document

@vecel/satori Github Repository

 

동적 이미지 생성

`ImageResponse`를 반환하는 함수를 작성했다.

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const text = searchParams.get("text");

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          padding: "6rem 3rem",
          backgroundImage: "url(https://as-kers.com/og-answer.png)",
        }}
      >
        <div tw="flex justify-center items-start overflow-hidden text-4xl tracking-tight text-neutral-50 leading-[3.2rem]">
          {text}
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
    },
  );

 

데이터 호출 부분 쿼리 새로 만들어서 다시 해보기!!!

동적 이미지가 필요한 웹 페이지에서 `GET /api/og` 엔드포인트를 호출하면 Search Parameter로 넘긴 `text` 데이터를 기반으로 이미지를 생성한다. 

export const generateMetadata = async ({ params }: Props) => {
  const dispatch = await getAnswer(params.id);
  if (!dispatch) return;

  return getMetadata({
    title: dispatch.targetUser.nickname,
    asPath: `/answers/${params.id}`,
    description: dispatch.ask.content,
    ogImage: `/api/og?text=${dispatch.ask.content}`,
  });
};

export default async function Page({ params }: Props) {}

 

답변 상세 페이지에 Twitter Open Graph를 적용한 모습

 

이미지를 CDN에 저장

이미지 캐싱 처리 작업을 해야 하는 이유는 다음과 같다.

HTML `<meta name=og:image>`에 설정된 `content`는 해당 메타 태그가 위치한 웹 페이지가 공유되거나 크롤러가 페이지를 스캔할 때 호출된다. 따라서 `content`에 이미지를 생성하는 API 엔드포인트를 지정하면 공유 및 스캔 시 해당 엔드포인트가 호출되고 서버에서는 이미지를 생성하는 작업을 수행하게 된다.

 

Askers 서비스 특성상 답변 상세 페이지의 Open Graph 이미지는 유저가 해당 답변을 삭제하기 전까지 유효하며, 중간에 이미지가 업데이트되어야 하는 경우는 없다. 그렇다면 공유 시마다 이미지 생성 작업을 하는 것은 리소스 낭비다. 이 작업을 최대한 줄이기 위해 최초 생성한 이미지를 서버 측에서 캐시 하는 작업이 필요하다고 판단했다.

 

동적으로 생성된 이미지는 서버의 파일 시스템이 아닌 in-memory로 저장된다. 따라서 서버 측에서 이를 캐시하더라도 서버의 메모리가 휘발되면 이미지를 다시 생성해야 한다. 만약 생성한 이미지를 서버의 파일 시스템에 저장한다면, 공유되는 답변의 수가 증가함에 따라 서버의 파일 시스템 공간을 차지하게 된다. 만약 오토스케일링 등을 이용해 여러 대의 서버를 유동적으로 사용한다면 생성된 이미지를 모두 복사해 새로운 서버를 생성하거나, 모든 서버에서 동일한 이미지를 가지고 있어야 한다는 문제가 따라온다.

 

따라서 이미지를 CDN에 저장하는 방식을 택했다. 최초 생성된 이미지를 CDN에 저장하고, CDN에 이미지가 있다면 이미 생성한 이미지를 가져오도록 한다. 없다면 그때 이미지를 만들어 CDN에 저장한다.

 

유저가 질문에 답하는 시점에 Open Graph 이미지를 만들지 않는 이유는 웹 페이지가 외부에 공유될 때만 이미지가 필요하기 때문이다. 따라서 공유되지 않는 질문과 답변에 대한 Open Graph 이미지를 생성할 필요는 없다고 생각했다.

 

답변 상세 페이지에서 `generateMetadata()` 함수로 Metadata를 생성할 때 다음과 같이 R2에 해당 키로 이미지가 확인하는지 체크한다.

// Check image exists on Cloudflare R2.
// If image absent, generate it.
const imageExist = await getImageExist(imageCacheKey);

return getMetadata({
  title: dispatch.targetUser.nickname,
  asPath: `/answers/${dispatchId}`,
  description: dispatch.ask.content,
  ogImage: imageExist
    ? `https://cdn.askers.org/${imageCacheKey}`
    : `/api/og?text=${dispatch.ask.content}&key=${imageCacheKey}`,
});

 

이미지가 있는 경우 설정한 CDN 링크를, 이미지가 없는 경우 호출해 이미지를 생성한 뒤 해당 이미지를 반환하는 API 엔드포인트가 `<meta url>`에 삽입되도록 처리했다.

 

 

 


개선하고 싶은 부분

포스트에 기록한 Open Graph 설정 작업에서 사용한 이미지는 답변 상세 페이지의 특성상 한 번 생성되면 유저가 질문을 삭제하기 전까지 변경되거나 삭제되지 않는다. 따라서 서버에서 페이지를 렌더링 할 때마다 R2에 이미지가 있는지 여부를 확인하는 요청도 가능하면 줄이고 싶다. 하지만 이 부분은 실제로 이미지 여부를 확인하는 로직이 불필요하게 많은 부하를 주거나 문제를 일으키는 경우에 개선해보고 싶다.

'Projects > Askers' 카테고리의 다른 글

[Askers] 기능 소개(v1) 및 DB  (0) 2024.08.11
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함