# next.js 사이트에 라이브 채팅 넣기

Spookat 채팅을 Next.js 앱에 넣어 보세요. App Router든 Pages Router든, 디자인과 로그인한 사용자까지 넘기고, 클릭 전에 저희 API로 요청이 0건인지 확인하는 방법을 정리했어요.

2026-11-04 · Kuba

Next.js에서 라이브 채팅은 루트 레이아웃의 script 태그 하나예요. 이 가이드에서는 App Router와 Pages Router에서 태그를 어디에 넣는지, 로드 순서를 깨지 않고 위젯 디자인과 로그인한 사용자를 넘기는 방법, Content Security Policy에 추가할 것, 그리고 프로덕션 빌드에서 결과를 확인하는 방법을 보여드려요.

## 짧게 말하면

App Router에서는 `app/layout.tsx`의 `<body>` 안에 태그를 넣으세요.

```tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" async></script>
      </body>
    </html>
  );
}
```

`YOUR_SITE_KEY`는 대시보드 설치 페이지의 키로 바꾸세요. 디자인을 대시보드에서 관리한다면(기본값이에요) 설치는 이걸로 끝이에요. 이 가이드의 나머지는 더 세밀하게 제어하고 싶을 때를 위한 거예요. 처음부터 시작한다면 계정 쪽 내용은 [기본 가이드](/ko/blog/add-live-chat-to-your-website/)에 있어요.

## 왜 루트 레이아웃일까

루트 레이아웃은 모든 라우트를 감싸고, 사용자가 이동하는 동안에도 마운트된 상태로 남아요. 런처는 페이지에 한 번만 붙어요. 클라이언트 측 이동은 페이지를 새로고침하지 않으니 런처도 다시 로드되지 않고, 방문자는 여기저기 클릭하면서도 대화를 열어 둘 수 있어요.

태그를 페이지 컴포넌트에 넣으면 채팅은 그 페이지에만 있어요. 여러 페이지에서 렌더링되는 컴포넌트에 넣으면 강력 새로고침 때 중복이 생겨요(런처는 자기 두 번째 복사본을 무시하지만, 낭비되는 요청인 건 똑같아요).

## next/script로 넣기

`next/script`는 Next.js에서 서드파티 스크립트를 불러오는 방법이고, 여기서도 잘 동작해요.

```tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
      </body>
    </html>
  );
}
```

`afterInteractive`는 하이드레이션 직후에 런처를 불러와요. `lazyOnload`는 브라우저가 한가해질 때까지 기다려서, 느린 휴대폰에서는 버튼이 조금 늦게 나타나요. 2 kb 런처라면 둘 다 괜찮지만, `afterInteractive`가 버튼을 더 빨리 보여줘요. 런처에 `beforeInteractive`는 쓰지 마세요. 괜히 여러분 코드와 경쟁하게 돼요.

## 코드에서 디자인 설정하기

대시보드에서 게시한 디자인은 런처 안에 담겨 와요. 저장소에서 관리하고 싶다면 런처가 실행되기 전에 `window.Spookat`을 설정하세요. 페이지 설정이 대시보드 설정보다 우선해요.

로드 순서가 중요해요. 런처가 실행될 때 설정이 이미 있어야 해요. `next/script`에서는 설정에 `beforeInteractive`(루트 레이아웃에서 허용돼요)를, 런처에 `afterInteractive`를 주세요.

```tsx
import Script from "next/script";

const spookat = { style: "clean", font: "system", accent: "#2F6FEB", side: "right" };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script id="spookat-config" strategy="beforeInteractive">
          {`window.Spookat = ${JSON.stringify(spookat)};`}
        </Script>
        <Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
      </body>
    </html>
  );
}
```

`beforeInteractive` 스크립트는 하이드레이션 전에, `afterInteractive` 스크립트는 그 후에 실행되니까 순서가 보장돼요. 키는 다른 곳과 같아요. `style`(8가지 중 하나), `font`, `accent`, `side`, `greeting`, `label`, `lang`. 전부 [문서](/ko/docs/)에 있어요.

## pages router

Pages Router에서 공통 래퍼는 `pages/_app.tsx`예요.

```tsx
import type { AppProps } from "next/app";
import Script from "next/script";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Component {...pageProps} />
      <Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
    </>
  );
}
```

설정을 쓴다면, Pages Router에서 `beforeInteractive` 스크립트는 `pages/_document.tsx`에 들어가요. `window.Spookat` 스크립트를 그곳의 `<Head>` 안에, 위와 같은 `id`와 `strategy`로 넣고, 런처는 `_app.tsx`에 그대로 두세요.

## 로그인한 사용자

pro 이상에서는 누가 로그인했는지 채팅에 알려줄 수 있어요. 그러면 팀은 Slack 카드에서 `logged in as 42`를 보고, 웹후크, 내보내기, 에이전트의 `read_convo`에도 그 ID가 담겨요. ID는 여러분 서버만 아는 시크릿으로 서명되기 때문에, 누구도 다른 사람인 척할 수 없어요.

대시보드 설치 페이지에서 아이덴티티 시크릿을 만들고, 환경 변수에 넣고, 서버에서 서명하세요. App Router에서는 루트 레이아웃이 서버 컴포넌트라서 간단해요.

```tsx
import { createHmac } from "node:crypto";
import Script from "next/script";
import { getSession } from "@/lib/auth"; // yours

const sign = (id: string) => createHmac("sha256", process.env.SPOOKAT_IDENTITY_SECRET!).update(id).digest("hex");

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await getSession();
  const user = session ? { id: String(session.userId), sig: sign(String(session.userId)) } : undefined;
  return (
    <html lang="en">
      <body>
        {children}
        {user && (
          <Script id="spookat-config" strategy="beforeInteractive">
            {`window.Spookat = ${JSON.stringify({ user })};`}
          </Script>
        )}
        <Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
      </body>
    </html>
  );
}
```

서명은 절대 클라이언트 컴포넌트에서 계산하지 마세요. 시크릿이 브라우저로 넘어가요. 서명이 없거나 틀려도 오류는 아니고, 채팅이 누구인지 표시하지 않을 뿐이에요. 루트 레이아웃에서 세션을 읽으면 모든 페이지가 요청마다 렌더링돼요. 지금 레이아웃이 정적이라면 추가하기 전에 따져 보세요.

## content security policy

앱이 `Content-Security-Policy` 헤더를 보낸다면, 위젯에는 이게 필요해요.

- `script-src`와 `style-src`: `https://cdn.spookat.com`
- `font-src`: 호스팅된 폰트를 골랐다면 `https://cdn.spookat.com`
- `connect-src`: 클릭 후 채팅 연결을 위한 `wss://api.spookat.com`
- `img-src`: 로고와 팀원 사진을 위한 `data:`
- `style-src`: 런처는 자기 shadow root에 작은 `<style>` 블록을 넣기 때문에, 이를 위해 인라인 스타일이 허용돼야 해요

이것들이 없으면 브라우저 콘솔이 어떤 지시어가 무엇을 막았는지 정확히 알려줘요. 콘솔이 알려준 소스를 추가하고 새로고침하세요.

## 프로덕션 빌드에서 확인하기

개발 모드는 자체 스크립트와 핫 리로드를 추가하니까, 실제 빌드로 확인하세요.

```sh
next build && next start
```

DevTools를 연 시크릿 창에서 `http://localhost:3000`을 여세요. 도메인이 중요해요. 채팅은 등록한 도메인과 그 서브도메인에서만 연결되기 때문에, localhost에서는 런처는 보여도 채팅은 연결되지 않아요. 채팅 자체는 배포한 사이트에서 테스트하세요.

어느 쪽에서든:

1. **Network**에서 "Disable cache", 새로고침, `spookat`으로 필터: 요청 하나, `cdn.spookat.com`에서 오는 런처. 코드가 `window.Spookat`에서 게시한 것과 다른 스타일을 설정하면 두 개인데, 둘 다 `cdn.spookat.com`에서 와요. `api.spookat.com`으로는 아무것도 가지 않아요.
2. **Application**: Spookat 쿠키 없음, 첫 메시지 전까지 저장소도 없음.
3. **Performance나 Lighthouse**: 런처가 긴 작업으로 나타나면 안 돼요. 런처가 있을 때와 없을 때를 몇 번씩 비교해 보고, 차이가 실행 간의 일반적인 오차 안에 있는지 보세요.
4. 배포된 사이트에서 **런처를 클릭**: 패널이 로드되고, 연결이 열리고, 메시지가 Slack에 도착해요.

## 배포 대상

여기 나온 건 앱이 어디서 돌아가든 상관없어요. Vercel, Node 서버, 컨테이너, 정적 사이트용 `output: "export"` 모두 같은 HTML을 제공하고, 태그는 어디서나 똑같이 동작해요. 정적 export라면 서명해 줄 서버 렌더링이 없으니, 로그인한 사용자 정보는 여러분이 관리하는 서버에서 와야 해요.

## 답장을 연결하세요

채팅이 사이트에 들어갔다면, 대시보드에서 Slack을 연결하고 채널을 고르세요. [Slack 가이드](/ko/blog/answer-website-chat-from-slack/)에서 과정을 보여드려요. Claude Code를 쓴다면 [Claude Code 가이드](/ko/blog/claude-code-live-chat/)에서 위의 모든 걸 에이전트에게 맡기는 방법을 볼 수 있어요.

## 다국어 앱

앱에 `[locale]` 세그먼트가 있다면, 페이지가 어떤 언어인지 위젯에 알려주세요. 그러면 패널이 방문자 브라우저가 아니라 페이지에 맞춰져요. 위젯은 12개 언어를 지원하고, `lang`에는 `en`, `pl`, `de` 같은 코드를 넣어요. `app/[locale]/layout.tsx`에서는 이렇게 해요.

```tsx
<Script id="spookat-config" strategy="beforeInteractive">
  {`window.Spookat = ${JSON.stringify({ lang: locale })};`}
</Script>
```

위젯에 없는 언어는 방문자 브라우저 언어, 그다음 영어로 대체돼요. 어느 쪽이든 팀은 채팅에서 언어를 볼 수 있어요.

## 흔한 실수

- **`"use client"`가 붙은 클라이언트 컴포넌트에 태그 넣기.** 렌더링은 되지만 컴포넌트와 함께 하이드레이션되고, 다시 마운트될 때마다 중복이 생길 기회가 늘어요. 레이아웃에 두세요.
- **`useEffect`로 스크립트 만들기.** 같은 결과에 코드만 늘고, `afterInteractive`보다 늦게 로드돼요.
- **태그에 `type="module"` 붙이기.** 런처는 클래식 스크립트예요. type은 빼세요.
- **패널 미리 불러오기.** 패널 스크립트에 `<link rel="preload">`를 붙이면 모든 방문자에게 다운로드가 클릭 앞으로 와요. 기다리는 게 핵심이에요.
- **미리보기 배포에서 채팅 테스트하기.** 미리보기 URL은 다른 도메인이에요. 런처는 보여도 채팅은 연결되지 않아요. 원래 그래요.

## 번들에 더해지는 것

없어요. 런처는 저희 CDN에서 별도 파일로 오기 때문에 JavaScript 번들에 포함되지 않고, `next build`가 출력하는 "First Load JS" 수치도 그대로예요. React도 건드리지 않아요. 버튼은 자기 shadow root 안에 있어서, 여러분 CSS가 망가뜨릴 수 없고 런처 CSS가 앱으로 새어 나가지도 않아요. 빌드가 알아야 할 건 태그 하나뿐이에요.

## 체크리스트

- 루트 레이아웃(`app/layout.tsx` 또는 `pages/_app.tsx`)에 태그나 `<Script>` 하나.
- 설정이 있다면 런처보다 먼저 실행된다.
- 사용자 서명은 서버에서만 계산한다.
- CSP가 있다면 CDN과 채팅 연결을 허용한다.
- 프로덕션 빌드에서 확인함: 클릭 전 요청 하나(게시하지 않은 스타일을 코드에서 설정하면 둘, 둘 다 `cdn.spookat.com`), 쿠키 없음.

starter 기준 사이트당 월 $4.99이고, 체험 기간은 카드 없이 14일이에요.

---
source: https://spookat.com/ko/blog/live-chat-nextjs/
