INTEGRATION GUIDE

관리받고 싶은 블로그, 이렇게 붙입니다

Trina 가 쓴 글을 고객사 자사 블로그로 보내는 방법입니다. 연동 방식은 세 가지가 있고, 저희는 웹훅을 권합니다. 왜 그런지와 어디서 막히는지를 먼저 적었습니다.

01도메인 접근서브도메인을 쓰려면 DNS 레코드를 추가할 수 있어야 합니다. 권한이 대행사에 있으면 임시 주소로 먼저 시작합니다.
02발행 권한수신 라우트를 넣을 수 있는 저장소 접근, 또는 CMS 관리자 계정.
03공개 글 주소 접두사발행된 글이 실제로 보이는 주소입니다. 저희가 이 값으로 글 주소를 계산해 성과를 측정합니다.
예상 소요수신 라우트를 직접 넣는 경우 30분 ~ 1시간. DNS 권한 확인이 필요하면 영업일 기준 1~2일이 더 걸립니다.
01 · OPTIONS

연동 방식 세 가지

고객사가 먼저 묻는 방식과 저희가 권하는 방식이 다릅니다. 차이는 대개 사고가 났을 때 번지는 범위에서 갈립니다.

항목웹훅권장GitHub PRDB 직접 연결
저희가 받는 것수신 URL + 시크릿GitHub App 권한 · 범위 좁게DB 관리자 키
사고 시 번지는 범위그 엔드포인트만저장소 쓰기데이터베이스 전체
고객사 코드 수정수신 라우트 10줄frontmatter 매핑 필요없음
비용직접 구현 시 면제커스텀 세팅 티어제공하지 않음
웹훅가장 안전하고 가장 가볍습니다

저희가 받는 것은 수신 URL 하나와 시크릿 하나입니다. 사고가 나도 그 엔드포인트까지만 번집니다. 고객사가 넣을 코드는 Bearer 토큰을 확인하고 글을 저장하는 10줄 정도이고, 스택에 맞게 아래 예시를 그대로 쓰실 수 있습니다. 세팅비는 직접 구현해서 테스트 발행까지 통과한 상태로 오시면 면제되고, 구현을 저희가 돕는 경우에는 커스텀 연동 세팅비가 붙습니다.

GitHub PR가능하지만 조건이 붙습니다

저희가 PR 만 올리고 병합은 고객사가 합니다. main 직접 커밋은 제공하지 않습니다 — 그건 곧 배포이고, 저희 원칙은 고객 프로덕션에 직접 배포하지 않는다는 것입니다. 그리고 frontmatter 규격이 블로그마다 다릅니다. Astro content collections 와 Next MDX 가 서로 다른 필드를 요구하기 때문에 코드 수정 0줄은 낙관적인 기대입니다. 권한을 좁힌 GitHub App 으로 붙이며, 커스텀 연동 세팅 티어에 해당합니다.

DB 직접 연결권하지 않습니다

Supabase service_role 같은 관리자 키는 글 테이블만이 아니라 그 데이터베이스 전체의 접근 제어를 우회합니다. 고객 데이터가 들어 있는 DB 의 마스터 키를 외부 업체가 보관하는 셈이고, 저희 쪽에 문제가 생기면 저희가 침투 경로가 됩니다. 웹훅이 조금 더 수고롭지만 훨씬 안전합니다.

02 · WEBHOOK

웹훅 연동 다섯 단계

각 단계에 실제로 막혔던 지점을 함께 적었습니다. 3단계와 4단계는 틀려도 발행이 성공으로 보여서 늦게 발견됩니다.

01수신 라우트 만들기

저희가 POST 로 보내는 글을 받아 저장하는 엔드포인트를 하나 만듭니다. 아래 복사용 코드를 그대로 쓰시면 됩니다. 주소는 공개되어도 괜찮습니다 — 인증은 시크릿이 담당합니다.

02시크릿 정해서 전달

임의의 긴 문자열을 시크릿으로 정해 수신 라우트의 환경변수에 넣고, 같은 값을 저희에게 별도 채널로 알려주세요. 이메일 본문에 그대로 적지 말아 주세요.

값이 어긋나면 401 로 끊깁니다

저희가 보관한 값과 고객사 환경변수가 다르면 저희 쪽 로그에 401 로 남고 글은 가지 않습니다. 마지막 단계의 테스트 전송으로 반드시 한 번 확인하세요.

03공개 글 주소 접두사 확정

발행된 글이 실제로 열리는 주소를 알려주세요. 예를 들어 글 하나가 최종적으로 어떤 주소로 보이는지 그대로 적어주시면 됩니다.

추정하면 성과 측정이 어긋납니다

저희는 이 값으로 각 글의 주소를 계산해 기록하고, 그 주소로 검색 콘솔 성과를 가져옵니다. 접두사가 틀리면 글은 정상 발행되지만 노출과 클릭이 집계되지 않습니다. 연동 확인 때 함께 받습니다.

04본문 서식 확인

보내는 본문은 인라인 스타일이 들어간 HTML 입니다. 소제목과 목록이 style 속성으로 크기를 지정하고 있습니다.

sanitizer 가 서식을 지웁니다

허용목록 sanitizer 를 기본 설정으로 통과시키면 style 속성이 제거되어 글이 조용히 못생겨집니다. 발행은 성공하고 화면만 무너지기 때문에 늦게 발견됩니다. style 속성과 h2 · h3 · ul · ol · a · img · strong · em 을 허용 목록에 넣어 주세요.

05연동 테스트

받은 값으로 저희가 연결 설정을 마친 뒤 테스트 전송을 한 번 보냅니다. 200 응답과 저장된 글 한 건이 확인되면 연동이 끝납니다. 이후로는 승인한 글만 이 경로로 나갑니다.

03 · SNIPPETS

복사해서 넣는 수신 코드

쓰시는 스택을 고르세요. 두 코드 모두 Bearer 토큰 검증까지 들어 있습니다.

docs/integration/starters/nextjs/route.tsTYPESCRIPT
/**
 * POST /api/blog/ingest — Trina 가 보내는 글을 받는다.
 *
 * 계약: docs/integration/blog-ingest.md
 */
import { NextResponse } from "next/server";
import { Pool } from "pg";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

/**
 * 위험한 마크업을 **벗기지 않고 거절한다.**
 *
 * 허용목록 sanitizer 로 털면 본문이 싣고 온 인라인 스타일까지 날아가서 글이
 * 조용히 못생겨진다 (가이드 ④). 그래서 읽을 때 거르지 않고 쓸 때 검사한다.
 */
function findForbiddenMarkup(html: string): string | null {
  const checks: [RegExp, string][] = [
    [/<\s*script\b/i, "script"],
    [/<\s*iframe\b/i, "iframe"],
    [/<\s*object\b/i, "object"],
    [/<\s*embed\b/i, "embed"],
    [/\son[a-z]+\s*=/i, "event handler"],
    [/javascript\s*:/i, "javascript: URL"],
  ];
  for (const [re, name] of checks) if (re.test(html)) return name;
  return null;
}

export async function POST(request: Request) {
  // 토큰이 없으면 열지 않는다 — 배포 실수가 공개 쓰기가 되면 안 된다 (가이드 ⑤).
  const expected = process.env.BLOG_INGEST_SECRET?.trim();
  const provided = (request.headers.get("authorization") ?? "")
    .replace(/^Bearer\s+/i, "")
    .trim();
  if (!expected || !provided || provided !== expected) {
    return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json().catch(() => null);
  const slug = String(body?.slug ?? "").trim();
  const title = String(body?.title ?? "").trim();
  const content = String(body?.content ?? "").trim();
  if (!slug || !title || !content) {
    return NextResponse.json(
      { message: "slug, title, content 는 필수입니다" },
      { status: 400 },
    );
  }

  const forbidden = findForbiddenMarkup(content);
  if (forbidden) {
    // 무엇이 걸렸는지 알려준다 — 보내는 쪽이 고칠 수 있어야 한다.
    return NextResponse.json(
      { message: `본문에 허용되지 않는 마크업이 있습니다: ${forbidden}` },
      { status: 400 },
    );
  }

  try {
    // slug 로 upsert — 재전송이 글을 둘로 만들면 안 된다 (가이드 ②).
    await pool.query(
      `insert into blog_posts
         (slug, title, content, excerpt, cover_image_url, tags, status, updated_at)
       values ($1, $2, $3, $4, $5, $6, $7, now())
       on conflict (slug) do update set
         title = excluded.title,
         content = excluded.content,
         excerpt = excluded.excerpt,
         cover_image_url = excluded.cover_image_url,
         tags = excluded.tags,
         status = excluded.status,
         updated_at = now()`,
      [
        slug,
        title,
        content,
        String(body?.excerpt ?? ""),
        body?.cover_image_url ?? null,
        Array.isArray(body?.tags) ? body.tags : [],
        body?.status === "draft" ? "draft" : "published",
      ],
    );
  } catch (err) {
    console.error("[blog/ingest]", err);
    return NextResponse.json({ message: "저장하지 못했습니다" }, { status: 500 });
  }

  // 받은 slug 를 **그대로** 돌려준다. 다르면 보내는 쪽이 기록한 주소가
  // 거짓이 되고, 성과 측정이 조용히 끊긴다 (가이드 ①).
  return NextResponse.json({ ok: true, post: { slug } });
}

시크릿을 환경변수(BLOG_INGEST_SECRET)에 넣고 저장 부분만 쓰시는 DB 호출로 바꾸세요. 응답을 post 객체로 감싸야 저희 쪽 slug 검증이 동작합니다.

04 · STATIC SITES

정적 사이트 재빌드

Astro · Gatsby · Hugo 처럼 빌드 시점에 글을 굽는 사이트는 수신만으로 화면에 나오지 않습니다. 수신 엔드포인트가 저장에 성공한 뒤 스스로 재빌드를 트리거하세요. 저희는 배포 훅 주소를 보관하지 않습니다 — 그 주소가 있으면 임의 재배포가 가능해지기 때문입니다.

VercelDeploy Hook 을 만들고, 수신 라우트에서 저장 성공 후 그 주소로 POST 하세요.
NetlifyBuild Hook 을 만들어 같은 방식으로 호출하세요. 동작은 같습니다.
여러 건이 몰릴 때하루치를 한 번에 보내면 빌드가 여러 번 돕니다. 30~60초 debounce 로 마지막 한 번만 트리거하세요.
05 · FAQ

자주 받는 질문

가능합니다. 저희가 운영하는 관리형 블로그를 서브도메인으로 만들어 시작합니다. 기존 사이트를 건드리지 않고, 나중에 자사 사이트로 옮길 수도 있습니다.

연동을 함께 확인해 드립니다

스택과 도메인만 알려주시면 어느 경로가 맞는지 먼저 판단해 회신합니다. 코드를 직접 넣기 어려우면 관리형 블로그로 시작할 수 있습니다.