답한 질문이 지킨 매출쿠키 배너 필요 없는 채팅팀원 무제한slack, 저희 앱에서 답장

/blog / 가이드

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

· 글: kuba · 8분 읽기

next.js라고 적힌 플러그가 분홍색 콘센트에 막 꽂히려 하고, 케이블 위에 Spookat 고양이가 타고 있어요.

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

짧게 말하면

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

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는 대시보드 설치 페이지의 키로 바꾸세요. 디자인을 대시보드에서 관리한다면(기본값이에요) 설치는 이걸로 끝이에요. 이 가이드의 나머지는 더 세밀하게 제어하고 싶을 때를 위한 거예요. 처음부터 시작한다면 계정 쪽 내용은 기본 가이드에 있어요.

왜 루트 레이아웃일까

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

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

next/script로 넣기

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

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를 주세요.

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. 전부 문서에 있어요.

pages router

Pages Router에서 공통 래퍼는 pages/_app.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에서는 루트 레이아웃이 서버 컴포넌트라서 간단해요.

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> 블록을 넣기 때문에, 이를 위해 인라인 스타일이 허용돼야 해요

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

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

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

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 가이드에서 과정을 보여드려요. Claude Code를 쓴다면 Claude Code 가이드에서 위의 모든 걸 에이전트에게 맡기는 방법을 볼 수 있어요.

다국어 앱

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

마음에 들었다면 대기 명단에 올려 두세요.

초대는 조금씩 나눠 보내요. 초대가 오면 14일 무료, 카드 필요 없음.

초대와 출시 소식만 메일로 보내요. 언제든 그만 받을 수 있어요. 개인정보