홈 연동 매뉴얼
연동 매뉴얼

가입부터 첫 글 발행까지,
한 번에 보는 Trina 연동 안내

시작 전에 준비할 것, 콘솔에서 누를 곳, 개발자가 넣을 코드를 순서대로 정리했어요. 처음 쓰는 분은 위에서부터, 개발자는 맨 아래 개발자용 안내부터 보시면 돼요.

2026년 10월 콘솔 화면 기준이에요.

이 페이지 목차
  1. 시작 전 준비물
  2. 가입과 결제
  3. 브랜드 등록
  4. 블로그 연결 방식
  5. 서치 콘솔 · GA4 연결
  6. 글이 만들어지고 발행되는 흐름
  7. 원고함과 외부 채널 초안
  8. 성과 확인
  9. 자주 묻는 문제 해결
  10. 개발자용 연동 안내
  11. 문의
01

시작 전 준비물

아래 다섯 가지가 있으면 가입부터 브랜드 등록까지 한 번에 끝나요.

준비물필요한 이유꼭 필요한가요
웹사이트 주소Trina가 사이트를 읽고 브랜드 소개 · 대상 · 톤을 먼저 채워 드려요.필수
Google 계정그 사이트가 Search Console에 등록된 계정이어야 해요. 로그인과 데이터 연결에 같이 써요.필수
결제할 카드플랜을 고르고 결제한 뒤에 브랜드를 만들어요.필수
GA4 속성글의 방문 · 참여 데이터도 함께 봐요. 나중에 연결해도 돼요.선택
브랜드 소개 자료(PDF)회사 소개서나 브랜드 덱을 올리면 글에 함께 반영해요. 최대 25MB예요.선택

블로그를 어디에 둘지도 미리 정해 두면 좋아요. 도메인 DNS를 바꿀 수 있는 분이 있는지, 홈페이지를 고칠 개발자가 있는지에 따라 연결 방식이 달라져요(블로그 연결 방식).

먼저 화면부터 보고 싶다면 가입 없이 둘러보기에서 실제 콘솔을 데모 브랜드로 읽기 전용으로 볼 수 있어요.
02

가입과 결제

  1. Google 계정으로 로그인시작하기를 누르고 Google로 계속하기를 골라요. 사이트를 관리하는 계정으로 로그인하면 다음 단계가 편해요.
  2. 브랜드에 쓸 플랜 고르기플랜은 브랜드마다 하나예요. 두 플랜의 차이는 채널뿐이에요.
  3. 카드 등록 후 결제월간 또는 연간(20% 할인)을 고르고 결제해요. 그 뒤로는 같은 주기로 자동 결제돼요.
  4. 브랜드 만들기결제가 끝나면 바로 브랜드 등록(온보딩)으로 넘어가요. 결제 후 48시간 안에 브랜드를 만들지 않으면 자동으로 환불돼요.
플랜월 (부가세 포함)채널
Basic · 자사 블로그19만 원자사 블로그 자동 발행
Plus · 멀티 채널29만 원자사 블로그 자동 발행 + Medium · Tistory · 브런치 · LinkedIn 초안

키워드 발굴과 글 작성(주 3~4회), AI 인용 분석과 월간 전략 재수립, 콘솔 성과 분석은 두 플랜이 같아요. 브랜드마다 멤버를 3명까지 무료로 초대할 수 있어요.

  • 플랜 변경은 프로필 설정결제플랜 변경에서 해요. 다음 결제일부터 적용되고, 지금 따로 결제되지는 않아요.
  • 결제 후 7일 이내이거나 아직 브랜드를 만들지 않았다면, 결제 설정의 결제 내역에서 전액 환불을 요청할 수 있어요.
03

브랜드 등록 4단계

결제를 마치면 온보딩 화면이 열려요. 중간에 나가도 저장 후 이어서 할 수 있어요.

  1. 브랜드 설정웹사이트 주소, 브랜드 이름, 콘텐츠 언어를 넣고 Google Search Console을 연결해요. Search Console 연결과 속성 선택을 마쳐야 다음으로 넘어갈 수 있어요. GA4는 선택이에요.
  2. 브랜드 프로필Trina가 사이트를 읽어 설명 · 대상 · 톤 & 매너를 채워요. 그대로 써도 되고 고쳐 써도 돼요. 브랜드 덱 PDF를 올리면 함께 읽어요.
  3. 목표 키워드프로필을 바탕으로 키워드 아이디어를 제안해요. 하나 이상 고르면 되고, 직접 추가할 수도 있어요.
  4. 블로그와 외부 초안글을 받을 채널을 골라요. 자사 블로그는 늘 켜져 있고, 외부 채널 초안은 Plus 플랜에서 켤 수 있어요.

등록을 마치면 콘솔로 들어가요. 브랜드 정보는 나중에 브랜딩브랜드 전략에서 고칠 수 있고, 고친 내용은 다음 날 오전 4시(KST) 실행부터 반영돼요.

04

블로그 연결 방식

Trina가 자동으로 발행하는 곳은 자사 블로그 하나예요. 그 블로그를 어디에 둘지에 따라 세 가지 방식이 있어요.

관리형 블로그

개발자 없이

blog.회사도메인 주소로 Trina가 블로그를 운영해요. 도메인 DNS에 CNAME 레코드 한 줄만 추가하면 되고, 기존 홈페이지는 건드리지 않아요.

지금 홈페이지에 연결

개발자 필요

회사도메인/blog처럼 본 도메인에 글이 쌓여요. 홈페이지가 글을 받거나 가져가도록 한 번 개발해야 해요. 방법은 개발자용 연동 안내에 있어요.

하위 경로로 붙이기

상담 후 진행

홈페이지에 블로그를 직접 만들기 어렵지만 주소는 회사도메인/blog로 두고 싶을 때예요. 서버 · CDN 구조와 보안 요건을 확인하고 리버스 프록시 설계를 도와 드려요. 설정은 고객사 쪽에서 해요.

  • 개발자는 없지만 저장소 권한을 줄 수 있다면, 저희가 코드를 PR로 넣어 드릴 수 있어요. 머지와 배포는 고객사가 해요. 저희가 고객사 서버에 직접 배포하지는 않아요.
  • WordPress · Cafe24 같은 CMS에 글을 바로 넣는 연결은 아직 없어요. 그런 경우 관리형 블로그로 시작하는 게 가장 빨라요.
  • 연결 비용은 방식과 사이트 상황에 따라 달라서 별도 견적으로 안내해요.

연결 요청하는 법

  1. 브랜드 설정채널 설정 열기자동 발행이 아직 연결되지 않았다면 화면 위에 안내가 보여요.
  2. 발행 연결 요청하기발행 위치(관리형 블로그 또는 내 사이트)를 고르고 블로그 주소, 지금 쓰는 블로그 · CMS, 메모를 적어 요청을 보내요. 하위 경로로 붙이고 싶다면 메모에 적거나 문의해 주세요.
  3. 영업일 1~2일 안에 연락Trina 팀이 내용을 확인하고 연락드려요. 그동안에도 글은 원고함에 쌓이고, 복사해서 직접 올릴 수 있어요.
  4. DNS 레코드 추가 (관리형 블로그)채널 설정 화면에 나오는 CNAME 레코드를 도메인 관리 화면에 추가해요. 화면이 DNS 상태(연결됨 · 레코드 없음 · 다른 곳을 가리킴)를 바로 보여 줘요.
  5. 연동 확인하기연결이 등록되면 같은 화면의 「연동 확인하기」로 설정 · 연결 상태 · 최근 발행을 한 번에 점검해요.
05

서치 콘솔 · GA4 연결

Trina는 Search Console 데이터를 읽고 다음 글감을 정해요. 그래서 브랜드를 만들 때 Search Console 연결이 필요해요.

  1. Google 연결온보딩 1단계에서 「Google 연결」을 누르고 Search Console과 Analytics 읽기 권한을 모두 허용해요. 읽기 전용이라 사이트 설정은 바뀌지 않아요.
  2. Search Console 속성 고르기목록에서 이 사이트의 속성을 골라요. 웹사이트 주소와 다른 사이트를 고르면 경고가 떠요.
  3. GA4 속성 고르기 (선택)지금 골라도 되고, 나중에 브랜드 설정연결에서 연결해도 돼요.

사이트가 Search Console에 없을 때

  • Search Console 등록 가이드를 따라 하면 10분 안팎이면 끝나요. DNS를 바꿀 수 있으면 도메인 방식, 홈페이지 HTML을 고칠 수 있으면 URL 접두어 방식을 골라요.
  • 온보딩 화면의 「등록을 도와주세요」를 누르면 Trina 팀이 연락드려 등록을 함께 해 드려요.
  • 다른 Google 계정이 이미 소유자라면, 그 계정에서 이 계정을 Search Console 사용자로 추가하거나 「다른 Google 계정으로 연결」로 그 계정을 연결해요.
Google 연결은 브랜드 소유자 계정으로 해요. 연결 · 해제와 GA4 속성 변경은 소유자만 할 수 있어요. 연결을 해제하면 리포트 갱신이 멈춰요.
06

글이 만들어지고 발행되는 흐름

  1. 키워드 찾기대표 키워드와 Search Console 데이터를 바탕으로 매주 월요일 이번 주 기회 키워드를 찾아요. AI 운영팀키워드 리서치에서 볼 수 있어요.
  2. 글 쓰기주 3~4회 글을 써요. 자사 블로그용 글과, Plus 플랜이라면 외부 채널용 원고를 채널마다 따로 써요.
  3. 품질 검사분량 · 소제목 · 표 · 금지 표현 같은 기준에 못 미친 글은 자동 발행에서 빠지고, 원고함에 어떤 항목이 부족한지 표시돼요.
  4. 자사 블로그 발행기본은 자동 발행이에요. 매일 08:00(KST)에 그때까지 완성된 글이 자사 블로그로 나가요.
  5. 외부 채널 초안외부 채널은 원고까지 만들어 드려요. 원고함에서 복사해 직접 올리면 돼요(다음 절).

자동 발행 끄기

브랜딩브랜드 전략발행 정책 탭에서 고를 수 있어요.

방식어떻게 나가나요알아 둘 점
자동 발행 (기본)매일 08:00(KST)에 완성된 초안이 자사 블로그로 바로 나가요.손대지 않아도 발행이 끊기지 않아요. 대신 그 시각 전에 내리지 않으면 막을 수 없어요.
직접 발행자동 발행을 꺼요. 원고함에서 직접 내보낸 글만 나가요.누르기 전에는 한 건도 나가지 않아요. 누르지 않으면 그날 발행은 비어요.

발행 연결이 아직 열리지 않았다면 자동 발행을 켜 두어도 실제로는 나가지 않아요. 먼저 연결을 요청해 주세요.

07

원고함과 외부 채널 초안

AI 운영팀원고함에 이번 회차 원고가 채널별로 모여요. 「검토 대기」와 「발행 완료」 탭으로 나뉘어요.

Plus 플랜에서는 Medium · Tistory · 브런치 · LinkedIn 원고도 함께 만들어요. 채널마다 따로 쓴 글이라 한 채널을 고쳐도 다른 채널은 바뀌지 않아요. 외부 채널은 자동으로 올라가지 않으니 아래 순서로 직접 올려 주세요.

  1. 원고 복사원고함에서 채널 원고를 열고 「원고 복사」를 눌러요.
  2. 채널 글쓰기 화면에 붙여넣고 게시「글쓰기로 이동」을 누르면 그 채널의 글쓰기 화면이 열려요.
  3. 게시한 글 주소 등록게시한 글의 https:// 주소를 넣고 「배포 완료 확인」을 눌러요. Trina가 실제로 게시됐는지 확인해요.
  • 채널은 브랜드 설정채널 설정에서 켜고 꺼요. 자사 블로그는 필수 채널이라 끌 수 없어요. 채널 변경은 다음 전략 업데이트부터 반영돼요.
  • Basic 플랜에서는 외부 채널 스위치가 잠겨 있어요. 결제 설정에서 Plus로 바꾸면 다음 결제일부터 켤 수 있어요.
08

성과 확인

대시보드

AI 가시성, 검색 클릭, 발행한 콘텐츠 수를 한눈에 봐요.

성과 분석 · AI 가시성

ChatGPT · Gemini에 정해 둔 질문을 물어 우리 브랜드가 답변에 나오는지 확인해요. 묻는 질문은 이 탭에서 추가하거나 내릴 수 있어요.

성과 분석 · 검색 리포트

고른 기간의 Search Console 검색 클릭 추이를 보여 드려요.

성과 분석 · 사이트 진단

사이트를 주기적으로 점검해 고칠 곳을 심각도별로 보여 드려요.

성과는 콘솔에서 확인해요. 따로 보내 드리는 리포트 파일은 없고, 데이터가 필요하면 요청해 주세요.

09

자주 묻는 문제 해결

Search Console 속성이 목록에 안 보여요

연결한 Google 계정에 그 사이트의 권한이 없을 때 그래요. 사이트를 등록한 계정으로 다시 연결하거나, 소유자 계정에서 이 계정을 사용자로 추가해 주세요. 아직 등록 전이라면 등록 가이드를 보세요.

온보딩 1단계에서 다음으로 넘어가지 않아요

Google 계정 연결과 Search Console 속성 선택을 둘 다 마쳐야 넘어갈 수 있어요. GA4는 비워 둬도 돼요.

자동 발행을 켰는데 글이 안 나가요

채널 설정 화면 위의 안내를 먼저 봐 주세요. 막힌 단계가 있으면 그 이유가 보이고, 「연동 확인하기」로 다시 점검할 수 있어요. 관리형 블로그라면 DNS 상태가 「DNS 연결됨」인지, 발행 정책이 「직접 발행」으로 바뀌어 있지 않은지도 확인해 주세요.

글 하나가 자동 발행에서 빠졌어요

품질 검사를 통과하지 못한 글이에요. 원고함에서 그 글을 열면 부족한 항목(분량, 소제목 수, 표 수 등)과 기준이 함께 보여요.

외부 채널을 켤 수 없어요

Basic 플랜은 자사 블로그만 발행해요. 브랜드 소유자가 결제 설정에서 Plus로 바꾸면 다음 결제일부터 외부 채널 초안을 켤 수 있어요.

브랜드 정보를 고쳤는데 글에 반영이 안 돼요

저장한 내용은 다음 날 오전 4시(KST) 실행부터 반영돼요. 그 전에 만들어진 글에는 들어가지 않아요.

팀원과 같이 쓰고 싶어요

브랜드 설정멤버에서 브랜드마다 3명까지 무료로 초대할 수 있어요. 멤버는 글과 설정을 고칠 수 있고, Google 연결 · 결제 · API 키 발급은 브랜드 소유자만 할 수 있어요.

환불은 어떻게 하나요?

결제 후 7일 이내이거나 아직 브랜드를 만들지 않았다면, 결제 설정의 결제 내역에서 전액 환불을 요청할 수 있어요. 담당자가 확인한 뒤 환불되고, 환불이 완료되면 그 브랜드의 구독이 종료돼요.

10

개발자용 연동 안내

이 절은 「지금 홈페이지에 연결」을 고른 경우에만 필요해요. 관리형 블로그를 쓰면 코드를 넣을 일이 없어요.

홈페이지로 글을 옮기는 방법은 두 가지예요. 한 브랜드에는 한 가지만 쓰는 걸 권해요.

방식누가 움직이나요시작하는 법
API로 가져가기고객사 서버가 준비된 글을 가져가 올리고, 게시 주소를 알려 줘요.콘솔에서 API 키를 직접 발급해요.
웹훅으로 받기Trina가 매일 08:00(KST)에 글을 고객사 엔드포인트로 보내요.발행 연결 요청 후 Trina 팀과 토큰을 맞춰요.

방법 1. API로 가져가기

  1. API 키 발급브랜드 설정채널 설정고객 사이트 연동 API에서 발급해요. 브랜드 소유자만 발급 · 폐기할 수 있고, 사용 중인 키는 브랜드당 5개까지예요. 전체 키는 발급할 때 한 번만 보여요.
  2. 준비된 글 가져오기GET /posts?status=ready로 목록을, GET /posts/{id}로 본문(Markdown · HTML)을 받아요.
  3. 사이트에 올리고 주소 알려 주기POST /posts/{id}/published로 실제 게시 주소를 보내면 글이 발행됨으로 바뀌고 Trina가 페이지를 확인해요.
curl 예시
# 1. 준비된 글 목록 (본문 없이, 최신순)
curl -H "Authorization: Bearer trn_live_..." \
  "https://trina.app/api/v1/posts?status=ready"

# 2. 글 하나 (본문을 markdown · html 두 가지로)
curl -H "Authorization: Bearer trn_live_..." \
  "https://trina.app/api/v1/posts/<id>"

# 3. 사이트에 올린 뒤 게시 주소 알려 주기
curl -X POST -H "Authorization: Bearer trn_live_..." \
  -H "Content-Type: application/json" \
  -d '{"url":"https://<회사도메인>/blog/<slug>"}' \
  "https://trina.app/api/v1/posts/<id>/published"

키는 서버 환경변수에만 두고 브라우저 코드나 공개 저장소에 넣지 마세요. 파라미터 · 필드 · 오류 코드는 API 문서에 정리돼 있어요.

방법 2. 웹훅으로 받기

Trina가 발행할 글을 매일 08:00(KST) 고객사 엔드포인트로 POST해요. 응답 시간 제한은 20초이고, 실패한 글은 다음 회차에 다시 보내요.

요청
# 경로는 자유롭게 정하세요
POST https://<회사도메인>/api/blog/ingest
Authorization: Bearer <공유 토큰>
Content-Type: application/json
본문 (JSON)
{
  "title": "매물 소개서 잘 쓰는 법",
  "content": "<h2 style=\"font-size:1.45em\">방법</h2><p style=\"line-height:1.85\">본문…</p>",
  "slug": "how-to-write-a-listing",
  "status": "published",
  "excerpt": "실무에서 바로 쓰는 요령.",
  "cover_image_url": null,
  "tags": []
}
성공 응답
{ "ok": true, "post": { "slug": "how-to-write-a-listing" } }

꼭 지켜야 하는 7가지

  1. slug는 받은 그대로 쓰고 그대로 돌려주세요저희가 기록한 글 주소로 Search Console 성과를 읽어요. 받는 쪽이 slug를 새로 만들면 발행은 200으로 성공한 채 성과 측정만 끊겨요. 응답의 post.slug가 보낸 값과 다르면 실패로 처리해요.
  2. slug로 upsert 하세요실패한 글은 다시 오고, 고친 글도 같은 slug로 다시 와요. 매번 새 행을 만들면 같은 글이 쌓여요.
  3. 본문은 HTML이에요content는 이미 변환된 HTML이라 그대로 렌더하면 돼요. 마크다운 파서에 넣지 마세요.
  4. 인라인 style 속성을 지우지 마세요허용목록 sanitizer로 style 속성을 털면 제목과 본문이 같은 크기로 붙어 나와요. 읽을 때 거르지 말고, 저장할 때 <script> · <iframe> · on*= · javascript:를 찾아 400으로 거절하세요.
  5. 토큰이 없으면 열지 마세요환경변수가 비었을 때 인증을 건너뛰면 배포 실수 한 번이 공개 쓰기 엔드포인트가 돼요. 이때도 401로 막아 주세요.
  6. 블로그 페이지는 서버에서 HTML을 내야 해요글 본문이 최초 응답 HTML에 있어야 검색엔진이 읽어요. rel="canonical"을 넣고, 발행된 글만 sitemap.xml에 올리고, 없는 글은 404로 답해 주세요.
  7. 글 주소 접두사를 정확히 알려 주세요접두사 + /slug가 그 글의 공개 주소가 돼요. /blog가 아니어도 되지만 실제 페이지 주소와 같아야 해요. 엔드포인트와 접두사는 둘 다 https여야 해요.
upsert 예시 (SQL)
insert into blog_posts (...) values (...)
on conflict (slug) do update set ...

토큰 맞추기

토큰 만들기
# 토큰 하나를 새로 만들어 양쪽에 같은 값을 넣어요
openssl rand -base64 32

# 고객사 서버 환경변수
BLOG_INGEST_SECRET=<위 값>
  • 같은 값을 Trina 팀과 맞춰요. 채팅 · 이슈 · PR에 붙여넣지 마세요. 로그에 남아요.
  • Cloudflare Workers의 secret은 다시 읽을 수 없어요. 맞는지 의심되면 비교하지 말고 새 값으로 양쪽을 다시 맞추세요.
  • Vercel은 환경변수를 바꾼 뒤 재배포해야 반영돼요.

연결 확인

고객사 배포가 끝나면 원고함에서 자사 블로그 원고의 「즉시 배포하기」로 한 건 보내 보세요. 성공하면 접두사/slug 주소에 글이 떠 있어요. 실패하면 그 자리에 사유가 보여요.

보이는 것뜻
ingest 401 Unauthorized양쪽 토큰이 다르거나, 받는 쪽에 토큰이 없어요.
ingest 404엔드포인트 주소가 틀렸어요.
ingest returned slug X, expected Y받는 쪽이 slug를 새로 만들었어요.
ingest 400필수 필드가 빠졌거나 본문에서 위험한 마크업이 걸렸어요.

정적 사이트라면 재빌드는 받는 쪽이 걸어요

블로그를 그리는 방식할 일
요청 때 DB에서 읽어 렌더 (SSR)필요 없어요. 저장하면 바로 보여요.
Next.js ISRrevalidatePath('/blog') 또는 revalidateTag()로 그 경로만 갱신해요.
완전 정적 (빌드 때 글이 박힘)저장한 뒤 수신 엔드포인트가 배포 훅을 불러요.
배포 훅 호출
// 저장에 성공한 다음, 수신 엔드포인트가 직접 부른다
await fetch(process.env.DEPLOY_HOOK_URL!, { method: "POST" });

저희는 배포 훅 주소를 보관하지 않습니다. 배포 훅은 다시 빌드하라는 신호일 뿐 글을 싣고 가지 않아요. 여러 건이 잇따라 오면 마지막 요청 뒤 30~60초를 기다렸다 한 번만 빌드하세요.

복사해서 쓰는 수신 코드

둘 다 Bearer 토큰 검증과 위험 마크업 거절까지 들어 있어요. 저장 부분만 쓰시는 DB에 맞게 바꾸세요. Supabase Edge Function은 배포할 때 --no-verify-jwt가 꼭 필요해요. 빼면 게이트웨이가 토큰을 JWT로 검증하려다 함수가 실행되기 전에 401을 내요.

Next.js App Router · docs/integration/starters/nextjs/route.ts
/**
 * 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;
}

/**
 * 토큰을 읽는다. **두 이름을 다 받는다.**
 *
 * 규격상 이름은 `BLOG_INGEST_SECRET` 이지만 `BLOG_INGEST_TOKEN` 으로 넣는
 * 실수가 실제로 났다(2026-09-16). 증상이 401 이 아니라 "왜 안 되는지
 * 모르겠다" 였고, 원인을 찾는 데 왕복이 여러 번 들었다. 이름 하나 때문에
 * 그 왕복이 생기는 것보다 둘 다 받는 쪽이 싸다.
 *
 * `secretEnvName()` 을 손으로 짐작해서 터진 것과 같은 클래스의 문제다.
 */
function ingestSecret(): string {
  return (
    process.env.BLOG_INGEST_SECRET?.trim() ||
    process.env.BLOG_INGEST_TOKEN?.trim() ||
    ""
  );
}

export async function POST(request: Request) {
  // 토큰이 없으면 열지 않는다 — 배포 실수가 공개 쓰기가 되면 안 된다 (가이드 ⑤).
  const expected = ingestSecret();
  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 } });
}

/**
 * `GET` 은 405 로 답한다.
 *
 * Trina 콘솔의 "연동 확인하기" 가 `GET` 을 한 번 보내고 **405 를 '살아 있음'
 * 으로 읽는다**(`client/src/lib/publish-verify.ts`). App Router 가 알아서
 * 405 를 주기도 하지만, 명시해 두면 판정이 스택에 따라 흔들리지 않는다.
 * 200 을 주면 콘솔이 "토큰까지 확인됐다" 로 표시하므로 200 은 틀린 답이다.
 */
export function GET() {
  return NextResponse.json({ message: "Method Not Allowed" }, { status: 405 });
}
Supabase Edge Function · docs/integration/starters/supabase-edge/index.ts
/**
 * Supabase Edge Function — Trina 가 보내는 글을 받는다.
 *
 * 계약: docs/integration/blog-ingest.md
 * 배포:  supabase functions deploy blog-ingest --no-verify-jwt
 *
 * ─────────────────────────────────────────────────────────────────────────
 * ⚠️ 먼저 읽으세요 — 이걸 모르면 첫 발행이 401 로 죽습니다
 * ─────────────────────────────────────────────────────────────────────────
 * `functions/v1/*` 는 기본적으로 **게이트웨이가 `Authorization` 헤더를
 * Supabase JWT 로 검증**합니다. Trina 는 그 헤더에 우리가 주고받기로 한 공유
 * 토큰을 넣어 보내므로, JWT 검증을 켜 둔 채로는 **이 함수 코드가 실행되기도
 * 전에** 401 이 납니다. 로그에도 함수 호출 기록이 안 남아서 원인 찾기가
 * 어렵습니다.
 *
 * 그래서 검증을 끄고, 토큰은 **함수 안에서 직접** 비교합니다:
 *
 *   # supabase/config.toml
 *   [functions.blog-ingest]
 *   verify_jwt = false
 *
 * 끄는 대신 아래 ①이 유일한 자물쇠가 되므로, 토큰 검사를 빼지 마세요.
 */
import { createClient } from "jsr:@supabase/supabase-js@2";

/**
 * 위험한 마크업을 **벗기지 않고 거절한다.**
 *
 * 허용목록 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;
}

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json" },
  });

Deno.serve(async (request: Request) => {
  if (request.method !== "POST") return json({ message: "POST only" }, 405);

  // ① 토큰이 없으면 열지 않는다 — 배포 실수가 공개 쓰기가 되면 안 됩니다
  //    (가이드 ⑤). verify_jwt 를 껐으므로 이 검사가 유일한 자물쇠입니다.
  // 두 이름을 다 받는다 — `BLOG_INGEST_TOKEN` 으로 넣는 실수가 실제로 났고
  // (2026-09-16), 증상이 401 이 아니라 원인을 못 찾는 침묵이었다.
  const expected =
    Deno.env.get("BLOG_INGEST_SECRET")?.trim() ||
    Deno.env.get("BLOG_INGEST_TOKEN")?.trim() ||
    "";
  const provided = (request.headers.get("authorization") ?? "")
    .replace(/^Bearer\s+/i, "")
    .trim();
  if (!expected || !provided || provided !== expected) {
    return json({ message: "Unauthorized" }, 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 json({ message: "slug, title, content 는 필수입니다" }, 400);
  }

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

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    // service_role 이어야 RLS 를 지나 씁니다. 이 함수는 위 ①로 이미 잠겨
    // 있으므로 여기서 익명 키를 쓰면 저장만 실패합니다.
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );

  // slug 로 upsert — 재전송이 글을 둘로 만들면 안 됩니다 (가이드 ②).
  const { error } = await supabase.from("blog_posts").upsert(
    {
      slug,
      title,
      content,
      excerpt: String(body?.excerpt ?? ""),
      cover_image_url: body?.cover_image_url ?? null,
      tags: Array.isArray(body?.tags) ? body.tags : [],
      status: body?.status === "draft" ? "draft" : "published",
      updated_at: new Date().toISOString(),
    },
    { onConflict: "slug" },
  );
  if (error) {
    console.error("[blog-ingest]", error);
    return json({ message: "저장하지 못했습니다" }, 500);
  }

  // 정적 사이트라면 여기서 재빌드를 트리거합니다. 훅 URL 은 **받는 쪽이**
  // 들고 있습니다 — Trina 에게 주지 마세요. 자세한 것은 blog-ingest.md §6.
  //
  // await fetch(Deno.env.get("DEPLOY_HOOK_URL")!, { method: "POST" });

  // 받은 slug 를 **그대로** 돌려줍니다. 다르면 보내는 쪽이 기록한 주소가
  // 거짓이 되고, 성과 측정이 조용히 끊깁니다 (가이드 ①).
  // 형태도 이대로 두세요 — Trina 는 `post.slug` 를 읽습니다.
  return json({ ok: true, post: { slug } });
});

개발자가 자주 묻는 것

글 주소를 /blog 말고 /insights로 해도 되나요?

돼요. 알려 주신 글 주소 접두사를 그대로 써요. /blog를 강제하지 않아요.

하루에 몇 건이나 오나요?

자사 블로그는 보통 하루 1건이에요. 웹훅은 08:00(KST)에 보내요.

이미 받은 글을 다시 보내기도 하나요?

네. 고친 글이나 실패했던 글은 같은 slug로 다시 와요. slug로 upsert 하면 글이 둘로 늘지 않아요.

연동을 끊으면 받은 글은 어떻게 되나요?

고객사 데이터베이스에 저장된 글은 그대로 남아요. 새 글만 더 오지 않아요.

11

문의

막히는 곳이 있으면
바로 알려 주세요

사이트 주소와 지금 쓰는 홈페이지 환경을 알려 주시면 어떤 연결 방식이 맞는지 먼저 정리해 드려요.

contact@trina.app · 고객센터 070-8064-3349