이전글) 무한스크롤 리스트 구현

CHECK 👉 Next.js] InfiniteScroll로 무한 스크롤 구현하기



결과 미리보기

중고큐 키워드 검색시 최상단 노출에 성공 했습니다 👏👏 당연히 다른 키워드나 상품명 (브랜드, 카테고리 등) 전부 검색엔진에 잘 표시 됩니다.

구글 검색

구글 검색

네이버 검색

네이버 검색



들어가며

위 글에서 다룬 내용중 무한스크롤 + Modal로 상품상세페이지를 빠르게 표시하는 방법이 있었습니다. API에서 리스트를 불러올때 리스트안에 상품상세페이지를 구성하는 모든 값들이 있었기 때문에 해당 인자를 Modal로 넘겨주면 새로운 Fetch가 필요 없으므로 로딩이 필요 없는 것이 중요한 포인트였습니다. 최상의 로딩속도 경험을 제공해주는데 비해 여러가지 문제점들이 있었습니다.

문제점

  1. 모달은 SEO가 되지 않으므로 구글검색에서 상품이 표시되지 않는 문제 😵
  2. 모바일 기기에서 자연스럽게 왼쪽으로 스와이프로 뒤로가기시 모달이 닫히는 것 이 아니라 페이지 이동이 되어버림.
  3. Google Analytics에서 모달을 페이지로 인식하지 못하는 문제.

리스트에서 상세를 모달로 오픈하는 것은 속도나 경험 측면에서 유리한 점을 제공하기도 하지만 일반적인 상품상세페이지에서는 득보다 실이 더 많다고 판단되어 Next.js의 꽃이라고도 할 수 있는 Dynamic Route를 이용해 모달 방식이 아닌 상세페이지로 링크 이동되게 진행하였습니다. (리퀘스트 1번이 API서버에 영향을 주진 않겠지만, 유저의 속도 경험을 위해 고민했던 내용입니다.)

궁금했던점

나는 이미 리스트에서 상세페이지에 대한 내용을 전부 가지고 있는데 이동되면서 한번더 Fetch를 하게되면 손해가 발생하는 것이 아닐까? 라는 생각을 했습니다. 상세페이지로 이동하는 경우의수는 두가지입니다.

  1. 리스트에서 상품을 클릭하여 상세페이지로 넘어가는 경우 (이때 불필요한 request 발생)
  2. 검색이나 링크로 직접 상세페이지로 접속하는 경우

따라서 1번의 경우에 <Link>에 인자를 넣어서 넘겨주거나, LocalStorage에 인자를 저장해놓고 [id].tsx에서 인자가 있는지 없는지 여부를 확인하여 부분적 SSR를 이용할 수 있지 않을까? 라는 생각이 들었습니다.

해당 내용은 vercel/next.js에 Discussion을 통해 질문하였습니다. 상세한 답변과 내용은 링크를 확인해주세요 👉 Is there a trick on getServerSideProps to check if user types the URL directly #45077

결론

리스트에서 이미 Fetch된 데이터를 모달 or 상세페이지로 인자로 넘겨주고 상세페이지에서는 인자가 있는지 없는지 여부를 확인해 부분적으로 Fetch 하는 것은 오히려 비효율적이다 !!! 특히 SEO가 안되므로 !!




SSR (Server-Side-Rendering)

SEO 최적화를 위해서는 🔥꼭 서버 사이드 렌더링을🔥 사용해야합니다. Next.js에서 페이지가 생성된 이후에 Fetch된 Data는 인식하지 못하고 Undefined로 표시됩니다. 따라서 useEffect (조건문에 의한 Axios or Request)로 불러올 경우에는 SEO가 적용되지 않습니다. DynamicRoute에서 SSR을 적용한 코드는 다음과 같습니다. [id].tsx처럼 다이나믹 라우트가 적용된 페이지에서는 별도의 사이트맵을 생성해주어야 합니다

src > pages > findcue > [id].tsx

src > pages > findcue > [id].tsx
 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export default function CueDetail({ cue }: { cue: Results }) {
  ...
  return (
    <Layout> // <-- 카카오 공유하기 sdk 초기화

      // SEO
      {cue ? (
        <Seo
          title={seo_title(cue)}
          description={seo_description(cue)}
          image={seo_OG_images(cue)}
        />
      ) : (
        <Seo />
      )}

      <main>
        <section className="bg-white">
          <div className="layout">
            <div>
              {/* 큐 영역 보여지는 구간 */}
              {cue && <div data-aos="fade-up">...</div>}
            </div>
          </div>
        </section>
      </main>
    </Layout>
  );
}

// SERVER SIDE RENDERING
export const getServerSideProps: GetServerSideProps = async (context) => {
  const fetchUrl = `${apiUrl}/.../v3/?id=${context.params?.id}`;
  const res = await axios.get(fetchUrl);
  const cue = await res.data.results[0];

  // 못불러오면 404 페이지로
  if (!cue) {
    return {
      notFound: true,
    };
  }

  return {
    props: { cue },
  };
};

찾으려는 id가 없을 경우에는 notFound = true를 리턴해야 404 페이지로 넘어갑니다. API에 따라서 catch문을 사용해야할 수 도 있습니다. 해당 코드에서는 정상적으로 리턴된 데이터 값을 cue로 인자를 전달하였습니다.



SEO

공통 컴포넌트 만들기

SEO 최적화를 위해 모든 페이지에서 SEO를 불러드릴 컴포넌트를 만듭니다. 해당 컴포넌트는 @theodorusclarence/ts-nextjs-tailwind-starter 를 사용하였습니다.

src > components > Seo.tsx

src > components > Seo.tsx
  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
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import Head from "next/head";
import { useRouter } from "next/router";

import { openGraph } from "@/lib/helper";

const defaultMeta = {
  title: "큐찾사 | 중고큐 거래는 큐찾사",
  siteName: "큐찾사 | 중고큐 거래는 큐찾사",
  description:
    "1,000만 당구인을 위해 (주)김치빌리아드가 제작 운영하는 안전한 중고큐 거래 어플리케이션",
  url: "https://www.cue8949.com",
  type: "website",
  robots: "follow, index",
  image: "https://www.cue8949.com/....../default.png",
};

type SeoProps = {
  date?: string;
  templateTitle?: string;
} & Partial<typeof defaultMeta>;

export default function Seo(props: SeoProps) {
  const router = useRouter();
  const meta = {
    ...defaultMeta,
    ...props,
  };
  meta["title"] = props.templateTitle
    ? `${props.templateTitle} | ${meta.siteName}`
    : meta.title;

  return (
    <Head>
      <title>{meta.title}</title>
      <meta name="robots" content={meta.robots} />
      <meta content={meta.description} name="description" />
      <meta property="og:url" content={`${meta?.url}${router.asPath}`} />
      <link rel="canonical" href={`${meta?.url}${router.asPath}`} />
      {/* Open Graph */}
      <meta property="og:type" content={meta.type} />
      <meta property="og:site_name" content={meta.siteName} />
      <meta property="og:description" content={meta.description} />
      <meta property="og:title" content={meta.title} />
      <meta name="image" property="og:image" content={meta.image} />
      {/* Twitter */}
      {/* <meta name='twitter:card' content='summary_large_image' />
      <meta name='twitter:site' content='@th_clarence' />
      <meta name='twitter:title' content={meta.title} />
      <meta name='twitter:description' content={meta.description} />
      <meta name='twitter:image' content={meta.image} /> */}
      {meta.date && (
        <>
          <meta property="article:published_time" content={meta.date} />
          <meta
            name="publish_date"
            property="og:publish_date"
            content={meta.date}
          />
          <meta name="author" property="article:author" content="Cuechatsa" />
        </>
      )}

      {/* Favicons */}
      {favicons.map((linkProps) => (
        <link key={linkProps.href} {...linkProps} />
      ))}
      <meta name="msapplication-TileColor" content="#ffffff" />
      <meta
        name="msapplication-TileImage"
        content="/favicon/ms-icon-144x144.png"
      />
      <meta name="theme-color" content="#ffffff" />
    </Head>
  );
}

type Favicons = {
  rel: string;
  href: string;
  sizes?: string;
  type?: string;
};

const favicons: Array<Favicons> = [
  {
    rel: "apple-touch-icon",
    sizes: "57x57",
    href: "/favicon/apple-icon-57x57.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "60x60",
    href: "/favicon/apple-icon-60x60.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "72x72",
    href: "/favicon/apple-icon-72x72.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "76x76",
    href: "/favicon/apple-icon-76x76.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "114x114",
    href: "/favicon/apple-icon-114x114.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "120x120",
    href: "/favicon/apple-icon-120x120.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "144x144",
    href: "/favicon/apple-icon-144x144.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "152x152",
    href: "/favicon/apple-icon-152x152.png",
  },
  {
    rel: "apple-touch-icon",
    sizes: "180x180",
    href: "/favicon/apple-icon-180x180.png",
  },
  {
    rel: "icon",
    type: "image/png",
    sizes: "192x192",
    href: "/favicon/android-icon-192x192.png",
  },
  {
    rel: "icon",
    type: "image/png",
    sizes: "32x32",
    href: "/favicon/favicon-32x32.png",
  },
  {
    rel: "icon",
    type: "image/png",
    sizes: "96x96",
    href: "/favicon/favicon-96x96.png",
  },
  {
    rel: "icon",
    type: "image/png",
    sizes: "16x16",
    href: "/favicon/favicon-16x16.png",
  },
  {
    rel: "manifest",
    href: "/favicon/manifest.json",
  },
];

어느 페이지에서든 SEO 컴포넌트를 불러올 때 Override가 필요한 인자값들을 입력하면 됩니다.

src/pages/findcue/[id].tsx

src > components > src/pages/findcue/[id].tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export default function CueDetail({ cue }: { cue: Results }) {
  return (
    <Layout>
      {/* <Seo templateTitle='Home' /> */}
      {cue ? (
        <Seo
          title={seo_title(cue)}
          description={seo_description(cue)}
          image={cue.images.length > 0 ? cue.images[0].image : DefaultOGImage}
        />
      ) : (
        <Seo />
      )}
      <section>...</section>
    </Layout>
  );
}

파비콘 만들기

파비콘의 경우 https://www.favicon-generator.org/ 여기서 기기별, 사이즈별 한번에 제작할 수 있습니다. 해당 사이트에서 제작한 파비콘을 통째로 /public/favicon 경로에 넣어주었습니다.

다운로드하면 사이즈별로 알아서 제작됩니다.

다운로드하면 사이즈별로 알아서 제작됩니다.

OpenGraph 테스트

카카오톡 OG 디버깅 : https://developers.kakao.com/tool/debugger/sharing
배포된 서버만 테스트가 가능합니다.

카카오 공화국에선 OG는 필수

카카오 공화국에선 OG는 필수


여러 플랫폼 OG 테스트 : https://www.opengraph.xyz/
localhost로는 테스트가 불가하므로 포트포워딩을 통해 ip로 접근해야합니다.

사이트에서 수정해서 어떻게 보여지는지 볼 수 있다

사이트에서 수정해서 어떻게 보여지는지 볼 수 있다

결과

위 컴포넌트만 잘 활용하신다면 Lighthouse SEO 테스트 100점은 쉽게 가져가실 수 있습니다. 나머지 점수도 100점을 향해 고도화가 되어야 겠습니다.

100점!!

100점!!



next-sitemap (네이버, 구글이 SSR을 인식하게하자)

Next는 [id].tsx 와 같은 다이나믹 라우팅에 대해서 사이트맵을 생성하지 않습니다. 당연하게도 id가 0부터 100까지인지 aaa부터 zzzzz까지 인지 모르기 때문이죠. 따라서 운영하는 사이트에서 어떤 글들이 있는지 상품들이 있는지 직접 추가해야합니다.

하지만 !! 여기서 핵심은 새로 게시글이나 상품이 올라왔을때 자동으로 해당 페이지를 사이트맵에 포함시켜야합니다. 정확히 말하자면 서치 로봇이 크롤링을 돌때마다 해당 페이지를 사이트맵에 포함시켜줘야합니다. 그러기 위해서는 사이트맵 또한 다이나믹하게 API로 부터 추가된 내용을 받아서 변경되어야합니다.next-sitemap#generating-dynamicserver-side-sitemaps를 참고하여 제작하였습니다.


어떤 글들이 있는지, 어떤 상품들이 있는지 리턴해주는 API

예를들어 내큐찾기의 상품의 경우 /findcue/[상품번호] url을 가지고 있는데 어떤 상품번호들이 유저에게 보여져도 되는지, 구글 네이버가 수집해도 좋을지를 Robots.txt를 통해 알려주어야 합니다. 그래서 조건에 맞는 상품번호들과 최초 생성일을 알려주는 간단한 API를 제작해야합니다. Django로 제작하였습니다.

views.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class SiteModelModelViewWeb_V3(viewsets.ModelViewSet):
    """
        네이버, 구글 서치어드바이저에 사이트맵 제출 위해서 필요한 도큐멘트 ID만 리턴해준다.
    """
    queryset = ContentModel.objects.exclude(Q("쿼리셋 조건")).all()
    permission_classes = ("권한")
    serializer_class = ContentWebSitemapSerializer
    pagination_class = LimitOffsetPagination
    filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
    ordering_fields = ('document_id')
    ordering = ('-document_id')

    def list(self, request, *args,**kwargs):
        data = super().list(request, *args, **kwargs).data
        return Response(data=data)

serializer.py
1
2
3
4
5
6
7
class ContentWebSitemapSerializer(serializers.ModelSerializer):
    class Meta:
        model = ContentModel
        fields = (
            'document_id',
            'updated_at',
        )

리턴 결과

sitemap을 위한 api

sitemap을 위한 api


동적 페이지를 사이트맵에 추가하자

next-sitemap라이브러리 설치가 필요합니다.

설치 이후 pages > server-sitemap.xml > index.tsx 를 생성해줍니다. 해당 server-sitemap.xml 에서 기본적인 사이트맵 이외에 다이나믹 라우트를 활용한 즉, SSR되는 리스트들을 사이트맵에 추가해줍니다.

pages > server-sitemap.xml > index.tsx

pages > server-sitemap.xml > index.tsx
 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/* eslint-disable import/no-anonymous-default-export */
/* eslint-disable @typescript-eslint/no-empty-function */
import { GetServerSideProps } from "next";
import { getServerSideSitemap, ISitemapField } from "next-sitemap";

import { getGoodsSiteMap, getPostsSiteMap } from "@/pages/api";

type Posts = {
  id: string;
  created_at: string;
};

type Goods = {
  document_id: string;
  updated_at: string;
};

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  // Method to source urls from cms
  const posts: Posts[] = await getPostsSiteMap();
  const posts_sitemap_fields: ISitemapField[] = posts.map((post) => {
    return {
      loc: `${process.env.NEXT_PUBLIC_URL}/community/post/${post["id"]}`,
      lastmod: post["created_at"],
      changefreq: "always",
      priority: 1.0,
    };
  });

  const goods: Goods[] = await getGoodsSiteMap();
  const goods_sitemap_fields: ISitemapField[] = goods.map((good) => {
    return {
      loc: `${process.env.NEXT_PUBLIC_URL}/goods/${good["document_id"]}`,
      lastmod: good["updated_at"],
      changefreq: "always",
      priority: 1.0,
    };
  });

  const sitemap_fields = [...posts_sitemap_fields, ...goods_sitemap_fields];

  return getServerSideSitemap(ctx, sitemap_fields);
};

// Default export to prevent next.js errors
export default function Sitemap() {}

/server-sitemap.xml에 접속하면 다음과 같이 xml파일이 잘 만들어진 것을 확인할 수 있습니다.

실행결과

실행결과

잘 만들어진 사이트맵을 빌드할 때 robots.txt에 포함될 수 있도록 설정해주어야 합니다.

next-sitemap.config.js

next-sitemap.config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/**
 * @type {import('next-sitemap').IConfig}
 * @see https://github.com/iamvishnusankar/next-sitemap#readme
 */
module.exports = {
  siteUrl: `${process.env.NEXT_PUBLIC_URL}`,
  generateRobotsTxt: true,
  exclude: [
    "/mypage",
    "/404",
    "/sandbox/dialog-zustand",
    "/findcue/utils",
    "/components",
  ],
  robotsTxtOptions: {
    policies: [{ userAgent: "*", allow: "/" }],
    additionalSitemaps: [`${process.env.NEXT_PUBLIC_URL}/server-sitemap.xml`], // <-- 여기
  },
};

package.json

package.json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "name": "cuechatsa_apppage_v3",
  "version": "0.1.1",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3001",
    "build": "next build",
    "export": "next export -o out",
    "start": "next start",
    "lint": "next lint",
    "lint:fix": "eslint src --fix && yarn format",
    "lint:strict": "eslint --max-warnings=0 src",
    "typecheck": "tsc --noEmit --incremental false",
    "test:watch": "jest --watch",
    "test": "jest",
    "format": "prettier -w .",
    "format:check": "prettier -c .",
    "postbuild": "next-sitemap --config next-sitemap.config.js", // <- 이부분
    "prepare": "husky install"
  },
  ...

}

이후에 yarn build하면 robots.txt에 자동으로 추가되는걸 볼 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# *
User-agent: *
Allow: /

# Host
Host: https://www.cue8949.com

# Sitemaps
Sitemap: https://www.cue8949.com/sitemap.xml
Sitemap: https://www.cue8949.com/server-sitemap.xml << 여기


네이버, 구글 웹마스터도구 등록

네이버 서치어드바이저(웹마스터도구)에 등록 후 몇가지 추가로 해야할 것 이 있습니다. 이 url이 나의 소유임을 확인하는 Meta 태그를 추가해야합니다. 이는 구글과 동일한 방식을 사용합니다. 네이버 서치어드바이저에서 활용할 수 잇는 사이트 연관채널도 추가하려합니다.

네이버 검색 연관채널

네이버 검색 연관채널

앞서 만들었던 Seo.tsx 컴포넌트를 활용합니다. 연관채널은 가이드를 참고하여 JSON-LD 형식으로 구현합니다.

네이버 검색 연관채널 가이드

네이버 검색 연관채널 가이드

src > components > Seo.tsx

src > components > Seo.tsx
 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
<Head>
  ... ...
  {/* 네이버 서치어드바이저 */}
  <meta name="naver-site-verification" content="발급받은 문자열" />
  // 연관채널
  <script
    type="application/ld+json"
    dangerouslySetInnerHTML={{
      __html: JSON.stringify({
        "@context": "http://schema.org",
        "@type": "Person",
        name: "큐찾사 | 중고큐 거래는 큐찾사",
        url: "https://www.cue8949.com",
        sameAs: [
          "https://www.facebook.com/kimchibilliards",
          "https://www.youtube.com/channel/UCO_ynY-y2HaR9LiHr03beVw",
          "https://www.instagram.com/kimchibilliards",
          "https://play.google.com/store/apps/details?id=com.cuechatsaapp",
          "https://apps.apple.com/app/id1524591264",
        ],
      }),
    }}
  />
  {/* 구글 서치어드바이저 */}
  <meta name="google-site-verification" content="발급받은 문자열" />
  ... ...
</Head>


네이버 서치어드바이저에서 수집된 robots.txt를 보면 잘 수집된걸 볼 수 잇습니다.
네이버 서치어드바이저

네이버 서치어드바이저


구글 웹마스터도구에서도 사이트맵 제출이 잘 되는걸 볼 수 있습니다.

구글 웹마스터도구

구글 웹마스터도구

만들어진 사이트맵은 저처럼 직접 제출해도 되고 (빠름), 검색 로봇이 서치하게 두어도 됩니다(느림).



카카오 공유하기

좌: 카카오 공유하기 | 우: OpenGraph

좌: 카카오 공유하기 | 우: OpenGraph

카카오 공화국인 한국인 만큼 카카오 공유하기도 추가해야겠다고 생각하였습니다. 커스텀 템플릿을 이용하면 매우 쉽게 카카오 공유하기를 입맛대로 구성할 수 있습니다.

카카오디벨로퍼에서 키 발급

카카오디벨로퍼에서 애플리케이션을 만들고 Javascript 키.env에 복사해줍니다.

.env

.env
1
2
3
...
NEXT_PUBLIC_KAKAO_API_KEY="발급받은 Javascript 키"
...

변수명에 NEXT_PUBLIC을 붙혀주지 않으면 페이지에서 불러올 수 없으니 유의해주세요.

카카오 SDK 설치

src > pages > _app.tsx 에서 Window 인터페이스를 선언해줍니다.

src > pages > _app.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
declare global {
  interface Window {
    Kakao: any;
  }
}

const MyApp = ({...}: {...}) => {
  return (
  ...
  );
};

export default MyApp;

src > pages > _document.tsx에서 카카오 sdk를 실행합니다.

src > pages > _document.tsx
 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
31
import Document, {
  Head,
  Html,
  Main,
  NextScript,
} from 'next/document';

class MyDocument extends Document {
  ...
  render() {
    return (
      <Html lang='en'>
        <Head>
          ...
          {/* kakao */}
          <script
            defer
            src='https://developers.kakao.com/sdk/js/kakao.min.js'
          ></script>
        </Head>
        ...
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

src > components > layout > Layout.tsx Window.kakao를 초기화 해주어야 합니다. 꼭 해당 컴포넌트에서 안해도 되지만 프로젝트 구성상 Layout 컴포넌트가 모든 페이지의 뼈대가 되므로 저는 Layout에서 초기화 해주었습니다. 초기화를 여러번 진행하면 에러가 발생하므로 useEffect로 이미 초기화가 진행되었는지 확인해주어야합니다.

src > components > layout > Layout.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export default function Layout({ children }: { children: React.ReactNode }) {
  ...
  // kakao 공유하기
  React.useEffect(() => {
    if (!window.Kakao.isInitialized()) {
      window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_API_KEY);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  ...
  return (
    ...
  );
}

카카오 메세지 템플릿 만들기

카카오디벨로퍼 > 도구 > 메세지템플릿 에서 템플릿을 만들 수 있습니다. 피드형으로 진행하였습니다.

파라미터는 입력필드에 포맷으로 ${key변수명}을 입력하면 해당 값은 변수처럼 사용될 수 있습니다.

코드 적용하기

KakaoBtn
 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const KakaoBtn = ({
  title,
  description,
  price,
  state,
  document_id,
  imageUrl,
  like_cnt,
  view_cnt,
  ...
}: {
  title: string;
  description: string;
  price: number;
  state: string;
  document_id: string;
  imageUrl: string;
  like_cnt: number;
  view_cnt: number;
  ...
}) => {
  const onClick = () => {
    // eslint-disable-next-line unused-imports/no-unused-vars
    const { Kakao, location } = window;

    Kakao.Link.sendCustom({
      templateId: 89436, // <-- 자신의 템플릿 ID를 입력
      templateArgs: { // <-- 템플릿에서 만들었던 변수명을 보냄
        title: `${title}`,
        description: `${description}`,
        sub_title: `${sub_title}`,
        sub_description: `${sub_description}`,
        button_title: `${button_title}`,
        document_id: `${document_id}`,
        imageUrl: `${imageUrl}`,
        header: `${document_id}`,
        like_cnt: `${like_cnt}`,
        view_cnt: `${view_cnt}`,
        price: `${price}`,
      },
    });
  };
  return (
    <>
      <div
        onClick={onClick}
        className="mb-3 flex cursor-pointer justify-center"
      >
        <div className="flex h-[45px] w-full flex-row rounded-xl bg-[#FFEB01] py-2 px-5 md:flex md:h-[55px]">
          <KakaoSVG className="my-auto w-12 text-3xl md:text-4xl" />
          <div className="h5 md:h4 my-auto w-full pl-4 text-[#3C1E1E]">
            카카오톡으로 공유하기
          </div>
        </div>
      </div>
    </>
  );
};

만들어진 버튼은 다음과 같습니다. 해당 버튼을 누르면 PC는 새로운 창이 열리면서 공유할 친구 목록 선택 할 수 있으며 모바일은 카톡앱이 켜진 후 보낼 대상을 선택할 수 있습니다.



마치며

이로서 SSR + SEO + Sitemap 모두 적용되어 네이버, 구글 검색결과에 표시될 준비가 모두 끝났습니다. 🔥

구글, 네이버 검색결과에 상위에 노출되기만 남았습니다(제발~~).

Reference


해당글이 Next.js를 이용해서 SEO 최적화 하시는데 도움이 되셨길 바랍니다 😆


궁금하신 점이 있으시면 아래 Comments 남겨주세요 👇