title: content-orchestration 외부 연동 설계서 (L1)
date: 2026-02-25T16:07:00+09:00
type: design
layer: L1
status: in-review
tags: [content-orchestration, external-integration, L1, brevo, getlate, telegram]
author: ext-design-pl
project: content-orchestration
reviewed_by: "jarvis"
reviewed_at: "2026-02-25T18:50:00+09:00"
approved_by: ""
approved_at: ""
content-orchestration 외부 연동 설계서 (L1)
Task ID: dc1d0d04-5bca-4d69-abc7-e0b4f3e40c79
작성일: 2026-02-25
작성자: ext-design-pl
근거 문서:
- L0:
content-orchestration-biz-v4.md (비즈 기획서)
- L1:
content-orchestration-design-db.md (DB 설계서, 검수 완료)
- L1:
content-orchestration-design-pipeline.md (파이프라인 설계서, 승인)
1. 현황 분석
1-1. 현재 외부 연동 코드 현황
content-pipeline 프로젝트(projects/content-pipeline/src/)에 이미 구현된 외부 연동:
| 연동 대상 | 코드 파일 | 라이브러리 | 현재 상태 |
|---|
| Brevo (이메일) | lib/brevo.ts | @getbrevo/brevo (BrevoClient) | 구현 완료. mock/live 모드 분기. API 키 설정 시 캠페인 생성+즉시발송 동작 |
| getlate.dev (SNS) | lib/getlate.ts, pipeline/publish-sns.ts | fetch (REST API 직접) | 구현 완료. 계정 조회, 멀티플랫폼 발행, 예약 발행 지원 |
| AppPro 블로그 DB | pipeline/publish-blog.ts, pipeline/publish.ts | @libsql/client/web (Turso) | 구현 완료. blog_posts 테이블 INSERT, slug 중복 검사 |
| Telegram Bot | 미구현 | — | 자비스 스크립트(vice-reply.sh, ceo-reply.sh)로 알림. 파이프라인에서 직접 호출 없음 |
1-2. 환경 변수 현황
현재 .env에 설정된 외부 연동 관련 키:
| 환경 변수 | 용도 | 설정 상태 |
|---|
BREVO_API_KEY | Brevo 이메일 캠페인 API 인증 | 설정됨 |
BREVO_LIST_ID | 기본 발송 대상 리스트 ID (테스트 그룹 ID=8) | 설정됨 |
GETLATE_API_KEY | getlate.dev SNS 배포 API 인증 | 설정됨 |
CONTENT_OS_DB_URL | content-os Turso DB URL (뉴스레터/수집 데이터) | 설정됨 |
CONTENT_OS_DB_TOKEN | content-os Turso 인증 토큰 | 설정됨 |
TURSO_DB_URL | apppro-kr Turso DB URL (블로그 포스트) | 설정됨 |
TURSO_DB_TOKEN | apppro-kr Turso 인증 토큰 | 설정됨 |
CODEX_VP_BOT_TOKEN | Telegram Bot 토큰 (VP 보고용) | 설정됨 |
CODEX_VP_CHAT_ID | Telegram Chat ID (VP 채팅) | 설정됨 |
1-3. 현재 한계점
| # | 한계 | 상세 | 설계서 해결 방안 |
|---|
| 1 | Brevo 예약 발송 미구현 | sendCampaignNow만 사용. scheduledAt 파라미터 미활용 | 캠페인 생성 시 scheduledAt 예약 발송 추가 |
| 2 | Brevo 캠페인 상태 조회 미구현 | 발송 후 성공/실패/오픈율 추적 불가 | getCampaign API로 상태 폴링 |
| 3 | getlate 에러 처리 미흡 | NO_ACCOUNTS 외 에러는 일반 로그만 | error_logs 연계 + 재시도 전략 |
| 4 | 연동 결과가 DB에 미기록 | 발송/배포 결과가 content_distributions에 저장되지 않음 | 모든 연동 결과를 content_distributions에 기록 |
| 5 | 인증 갱신 자동화 없음 | API 키 만료/변경 시 수동 .env 수정 필요 | channels.credentials_ref로 환경 변수 키 참조, 인증 실패 시 에스컬레이션 |
| 6 | Telegram 파이프라인 알림 없음 | 파이프라인 내 승인 요청/에러 알림을 Telegram으로 전송하는 로직 없음 | Phase 2에서 Telegram Bot API 연동 |
| 7 | 블로그 발행 확인 메커니즘 없음 | blog_posts에 INSERT 후 실제 발행 여부 확인 불가 | Vercel Cron 발행 후 published=1 확인 폴링 |
2. 연동 대상별 상세 설계
2-1. Brevo API (뉴스레터 발송) — Phase 1
개요
| 항목 | 상세 |
|---|
| 서비스 | Brevo (구 SendinBlue) — 이메일 마케팅 플랫폼 |
| SDK | @getbrevo/brevo (BrevoClient 클래스) |
| 용도 | 뉴스레터 캠페인 생성, 예약 발송, 구독자 관리 |
| 현재 코드 | lib/brevo.ts — addContact, createList, sendCampaign, getBrevoStatus |
| Phase | Phase 1 (MVP) |
API 엔드포인트 명세
1) 캠페인 생성 + 예약 발송
| 항목 | 상세 |
|---|
| SDK 메서드 | client.emailCampaigns.createEmailCampaign() |
| 대응 REST | POST /emailCampaigns |
| 인증 | api-key 헤더 (BREVO_API_KEY) |
요청 파라미터:
| 파라미터 | 타입 | 필수 | 설명 |
|---|
name | string | Y | 캠페인 이름 (내부 식별용) |
subject | string | Y | 이메일 제목 |
htmlContent | string | Y | HTML 본문 |
sender | object | Y | {name: "AI AppPro", email: "hello@apppro.kr"} |
recipients | object | Y | {listIds: [BREVO_LIST_ID]} |
scheduledAt | string | N | 예약 발송 시각 (ISO 8601, 예: 2026-02-26T10:00:00+09:00) |
응답:
{
"id": 12345
}
2) 캠페인 즉시 발송
| 항목 | 상세 |
|---|
| SDK 메서드 | client.emailCampaigns.sendEmailCampaignNow({campaignId}) |
| 대응 REST | POST /emailCampaigns/{campaignId}/sendNow |
| 조건 | scheduledAt 미설정 시에만 사용 |
3) 캠페인 상태 조회 (신규 구현)
| 항목 | 상세 |
|---|
| SDK 메서드 | client.emailCampaigns.getEmailCampaign({campaignId}) |
| 대응 REST | GET /emailCampaigns/{campaignId} |
응답 주요 필드:
| 필드 | 설명 |
|---|
status | draft / queued / sent / archive |
statistics.globalStats.delivered | 발송 성공 수 |
statistics.globalStats.opens | 오픈 수 |
statistics.globalStats.clicks | 클릭 수 |
statistics.globalStats.bounces | 반송 수 |
4) 구독자 추가
| 항목 | 상세 |
|---|
| SDK 메서드 | client.contacts.createContact() |
| 대응 REST | POST /contacts |
| 현재 코드 | addContact() — 이미 구현 완료 |
인증 방식
| 항목 | 상세 |
|---|
| 방식 | API Key (헤더 인증) |
| 헤더 | api-key: {BREVO_API_KEY} |
| 환경 변수 | BREVO_API_KEY |
| SDK 초기화 | new BrevoClient({ apiKey: process.env.BREVO_API_KEY }) |
| 만료 | API 키는 영구. Brevo 대시보드에서 수동 폐기/재발급 |
| 보안 | channels 테이블 credentials_ref = "BREVO_API_KEY" (키 이름만 저장, 실제 값은 Vercel env) |
Rate Limit
| 제한 | 값 | 대응 전략 |
|---|
| Free Plan 일일 발송 한도 | 300통/일 | 일 300통 초과 시 → 다음 날로 자동 지연 예약 |
| API 호출 한도 | 없음 (Brevo Free는 API 호출 자체는 무제한) | — |
| HTTP 429 (과다 요청) | 발생 가능 (연속 빠른 호출 시) | 60초 대기 후 1회 재시도 → 실패 시 error_logs |
| 캠페인 생성 한도 | Free Plan 기준 명시적 제한 없음 | — |
Fallback 처리
| 실패 유형 | HTTP 코드 | Fallback 행동 |
|---|
| 인증 실패 | 401 | error_logs(error_type='auth_fail', escalated=1) → CEO에게 키 갱신 요청 |
| 리스트 미존재 | 404 | error_logs → BREVO_LIST_ID 확인 에스컬레이션 |
| 발송 한도 초과 | 402/429 | scheduledAt을 다음 날 06:00 KST로 변경하여 재예약 |
| 캠페인 생성 실패 | 400 | error_logs(error_type='validation_fail') → 요청 파라미터 검증 후 재시도 |
| 네트워크 에러 | timeout | 30초 후 1회 재시도 → 실패 시 error_logs |
| mock 모드 | — | BREVO_API_KEY 미설정 시 → console.log만 출력, content_distributions.platform_status='failed', error_message='MOCK_MODE' |
DB 연계
| 테이블 | 연계 방식 |
|---|
channels | id='ch-brevo', type='newsletter', platform='brevo', credentials_ref='BREVO_API_KEY', config='{"list_id":8,"template":"weekly"}' |
content_distributions | 캠페인 생성 성공 시 → platform_status='registered', platform_id=campaign_id, scheduled_at=예약시각. 발송 완료 확인 후 → platform_status='published', published_at=발송시각 |
error_logs | 실패 시 → component='brevo', error_type, error_message, channel_id='ch-brevo' |
newsletters | email_service_id에 Brevo campaign_id 저장 (기존 컬럼 활용) |
발송 플로우
content_queue.status = 'approved'
│
↓
content_distributions INSERT (channel_id='ch-brevo', platform_status='pending')
│
↓
newsletters 테이블에서 HTML 콘텐츠 조회
│
↓
Brevo createEmailCampaign(scheduledAt 포함)
│
├─ 성공: content_distributions UPDATE
│ platform_status='registered'
│ platform_id={campaign_id}
│ scheduled_at={예약시각}
│
└─ 실패: content_distributions UPDATE platform_status='failed'
error_logs INSERT
│
↓ (예약 시각 도래 후 Brevo 자체 발송)
│
Brevo 캠페인 상태 폴링 (getCampaign)
│
├─ status='sent': content_distributions UPDATE
│ platform_status='published'
│ published_at={실제발송시각}
│
└─ status='failed': error_logs INSERT + 에스컬레이션
2-2. AppPro.kr 블로그 DB (Turso 직접 INSERT) — Phase 1
개요
| 항목 | 상세 |
|---|
| 서비스 | apppro.kr 블로그 (자체 운영) |
| DB | Turso (LibSQL) — apppro-kr DB |
| 테이블 | blog_posts |
| 라이브러리 | @libsql/client/web |
| 용도 | AI 생성 블로그 포스트를 DB에 저장하여 자동 발행 (Vercel Cron 연계) |
| 현재 코드 | pipeline/publish-blog.ts — publishBlogPost() |
| Phase | Phase 1 (MVP) |
연동 방식 (API가 아닌 DB 직접 접근)
블로그는 외부 API가 아니라 apppro-kr Turso DB에 직접 INSERT한다. 기존 Vercel Cron(/api/cron/publish, 6시간 주기)이 scheduled_at 시간이 도래한 포스트를 published=1로 전환하여 자동 발행한다.
INSERT SQL:
INSERT INTO blog_posts (
id, title, slug, content, excerpt, category, tags,
author, published, publishedAt, metaDescription, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?)
published=0: 예약 상태 (Cron이 시간 되면 1로 전환)
publishedAt=NULL: Cron이 발행 시 현재 시각으로 설정
인증 방식
| 항목 | 상세 |
|---|
| 방식 | Turso Auth Token (Bearer Token) |
| 환경 변수 | TURSO_DB_URL, TURSO_DB_TOKEN |
| SDK | createClient({ url, authToken }) |
| 만료 | Turso 토큰은 대시보드에서 관리. 명시적 만료 기간 없음 |
| 보안 | channels 테이블 credentials_ref = "TURSO_DB_TOKEN" |
Rate Limit
| 제한 | 값 | 대응 전략 |
|---|
| Turso Free Plan reads | 500M reads/월 | 현재 사용량 극소. 문제 없음 |
| Turso Free Plan writes | 10M writes/월 | 블로그 포스트 수준에서 문제 없음 |
| Turso Free Plan storage | 5GB | 텍스트 포스트 기준 수만 건 가능 |
| 동시 연결 | 제한 없음 (HTTP 기반) | — |
Fallback 처리
| 실패 유형 | Fallback 행동 |
|---|
| slug 중복 | slug에 날짜+순번 자동 추가 ({slug}-2026-02-25-2) |
| DB 연결 실패 | 1회 재시도 (5초 대기) → 실패 시 error_logs |
| 인증 토큰 만료 | error_logs(error_type='auth_fail', escalated=1) → CEO에게 토큰 갱신 요청 |
| 데이터 유효성 오류 | error_logs(error_type='validation_fail') → 콘텐츠 검증 후 재시도 |
DB 연계
| 테이블 | 연계 방식 |
|---|
channels | id='ch-apppro-blog', type='blog', platform='apppro.kr', credentials_ref='TURSO_DB_TOKEN', config='{"publish_api":"/api/cron/publish","auto_publish":true}' |
content_distributions | INSERT 성공 시 → platform_status='registered', platform_id={blog_post_id}, scheduled_at. Vercel Cron 발행 확인 후 → platform_status='published', platform_url=https://apppro.kr/blog/posts/{slug} |
error_logs | 실패 시 → component='publisher', error_type, channel_id='ch-apppro-blog' |
발행 플로우
content_queue.status = 'approved'
│
↓
content_distributions INSERT (channel_id='ch-apppro-blog', platform_status='pending')
│
↓
content_queue에서 title, content_body, 메타데이터 조회
│
↓
apppro-kr Turso DB blog_posts INSERT (published=0)
│
├─ 성공: content_distributions UPDATE
│ platform_status='registered'
│ platform_id={blog_post_id}
│ scheduled_at={발행예약시각}
│
└─ 실패 (slug 중복): slug 변경 후 재시도
└─ 실패 (DB 에러): error_logs INSERT
│
↓ (Vercel Cron /api/cron/publish, 6시간 주기)
│
blog_posts.published = 1, publishedAt = now
│
↓ (발행 확인 폴링 — Phase 1)
│
content_distributions UPDATE
platform_status='published'
platform_url='https://apppro.kr/blog/posts/{slug}'
published_at={실제발행시각}
발행 확인 메커니즘 (신규)
Vercel Cron이 발행한 뒤 content_distributions를 갱신하기 위한 확인 로직:
content_distributions에서 platform_status='registered' AND channel_id='ch-apppro-blog' 조회
│
↓
apppro-kr DB에서 해당 blog_post_id로 published 컬럼 확인
│
├─ published=1: platform_status='published', published_at 갱신
└─ published=0: 아직 대기 중 (다음 폴링에서 재확인)
- 확인 주기: Vercel Cron
/api/cron/pipeline 실행 시 함께 체크 (1일 1회)
- 또는 별도
/api/cron/check-publish 엔드포인트 (Phase 2)
2-3. getlate.dev API (SNS 멀티플랫폼) — Phase 1~2
개요
| 항목 | 상세 |
|---|
| 서비스 | getlate.dev (Late) — SNS 멀티플랫폼 예약 발행 SaaS |
| API | REST API v1 (https://getlate.dev/api/v1) |
| 라이브러리 | fetch (직접 호출) |
| 지원 플랫폼 | X(Twitter), LinkedIn, Threads, Instagram, Facebook, TikTok, YouTube, Reddit, Pinterest, Bluesky, Telegram, Snapchat, Google Business |
| 용도 | 블로그 포스트 홍보, 뉴스레터 알림을 SNS에 자동 배포 |
| 현재 코드 | lib/getlate.ts, pipeline/publish-sns.ts |
| Phase | Phase 1 (Twitter/X, LinkedIn), Phase 2 (Threads, Instagram) |
API 엔드포인트 명세
1) 계정 조회
| 항목 | 상세 |
|---|
| 메서드 | GET /accounts |
| 인증 | Authorization: Bearer {GETLATE_API_KEY} |
응답:
{
"data": [
{
"accountId": "abc123",
"platform": "twitter",
"name": "AI AppPro",
"username": "@apppro_ai"
}
]
}
2) 포스트 생성 (즉시/예약)
| 항목 | 상세 |
|---|
| 메서드 | POST /posts |
| 인증 | Authorization: Bearer {GETLATE_API_KEY} |
요청 파라미터:
| 파라미터 | 타입 | 필수 | 설명 |
|---|
content | string | Y | 기본 콘텐츠 텍스트 |
platforms | array | Y | 대상 플랫폼 배열: [{platform, accountId, customContent?}] |
publishNow | boolean | N | true면 즉시 발행 (scheduledFor 무시) |
scheduledFor | string | N | 예약 시각 (ISO 8601: 2026-02-26T12:00:00) |
timezone | string | N | 타임존 (기본: Asia/Seoul) |
mediaUrls | array | N | 이미지/미디어 URL 목록 |
응답:
{
"data": {
"id": "post_xyz789",
"content": "...",
"status": "scheduled",
"scheduledFor": "2026-02-26T12:00:00+09:00",
"platforms": [
{"platform": "twitter", "accountId": "abc123", "status": "scheduled"},
{"platform": "linkedin", "accountId": "def456", "status": "scheduled"}
],
"createdAt": "2026-02-25T16:00:00+09:00"
}
}
3) 포스트 상태 조회 (Phase 2)
| 항목 | 상세 |
|---|
| 메서드 | GET /posts/{postId} |
| 인증 | Authorization: Bearer {GETLATE_API_KEY} |
인증 방식
| 항목 | 상세 |
|---|
| 방식 | Bearer Token (API Key) |
| 헤더 | Authorization: Bearer {GETLATE_API_KEY} |
| 환경 변수 | GETLATE_API_KEY |
| 발급 | getlate.dev 대시보드 > API Keys |
| 보안 | channels 테이블 credentials_ref = "GETLATE_API_KEY" |
Rate Limit
| 제한 | 값 | 대응 전략 |
|---|
| API 호출 한도 | 문서 미명시 (일반적으로 분당 60회 정도) | 연속 호출 간 1초 딜레이 |
| 일일 포스트 한도 | 플랜별 상이 (Free: 30포스트/월) | 월간 한도 추적, 초과 시 다음 월로 지연 |
| HTTP 429 | 과다 요청 시 발생 가능 | 60초 대기 후 재시도 |
플랫폼별 콘텐츠 변환 규칙
기존 publish-sns.ts의 convertToSnsContent() 함수에 정의된 규칙:
| 플랫폼 | 글자 제한 | 변환 규칙 |
|---|
| Twitter/X | 280자 | {제목}\n\n{블로그URL}\n\n{해시태그 5개} — 초과 시 해시태그 제거 |
| Bluesky | 300자 | Twitter와 동일 |
| LinkedIn | 3,000자 | {제목}\n\n{요약 전문}\n\n{블로그URL}\n\n{해시태그} |
| Threads | 500자 | {제목}\n\n{요약}\n\n{블로그URL}\n\n{해시태그} — 초과 시 해시태그 제거 |
| Instagram | 2,200자 | Threads와 동일 포맷 |
| Facebook | 2,000자 | 기본 포맷: {제목}\n\n{요약}\n\n{URL}\n\n{해시태그} |
Fallback 처리
| 실패 유형 | Fallback 행동 |
|---|
| API 키 미설정 | mock 모드 → console.log만 출력. content_distributions.platform_status='failed', error_message='MOCK_MODE' |
| 계정 미연결 (NO_ACCOUNTS) | error_logs(error_type='auth_fail') → "getlate.dev에서 SNS 계정 연결 필요" 에스컬레이션 |
| 특정 플랫폼 계정 없음 | 해당 플랫폼만 skip, 나머지 플랫폼 정상 진행 |
| API 오류 (4xx/5xx) | 1회 재시도 (30초 대기) → 실패 시 error_logs |
| 네트워크 타임아웃 | 1회 재시도 → 실패 시 해당 플랫폼 skip + error_logs |
| 일부 플랫폼만 실패 | 성공 플랫폼은 registered, 실패 플랫폼은 failed. 각각 별도 content_distributions 레코드 |
DB 연계
| 테이블 | 연계 방식 |
|---|
channels | SNS 플랫폼별 별도 레코드: id='ch-twitter', id='ch-linkedin' 등. type='sns', credentials_ref='GETLATE_API_KEY' |
content_distributions | 플랫폼별 별도 레코드 생성. channel_id='ch-twitter', platform_id={getlate_post_id}. 발행 확인 후 platform_url={플랫폼별 URL} |
error_logs | component='sns_publisher', channel_id에 해당 채널 ID |
발행 플로우
content_queue.status = 'approved'
│
↓
channels 테이블에서 type='sns' AND is_active=1 조회
│
↓
getlate GET /accounts → 연결된 계정 확인
│
↓
플랫폼별 콘텐츠 변환 (convertToSnsContent)
│
↓
채널별 content_distributions INSERT (platform_status='pending')
│
↓
getlate POST /posts (scheduledFor 포함, platforms 배열)
│
├─ 성공: 플랫폼별 content_distributions UPDATE
│ platform_status='registered'
│ platform_id={getlate_post_id}
│ scheduled_at={예약시각}
│
└─ 부분 실패: 성공 플랫폼은 registered, 실패 플랫폼은 failed
error_logs INSERT (실패 플랫폼별)
│
↓ (예약 시각 도래 후 getlate 자체 발행)
│
발행 확인 (Phase 2: GET /posts/{id} 폴링)
│
↓
content_distributions UPDATE
platform_status='published'
platform_url={각 플랫폼 포스트 URL}
published_at={실제발행시각}
2-4. Telegram Bot API (관리자 알림) — Phase 2
개요
| 항목 | 상세 |
|---|
| 서비스 | Telegram Bot API |
| 용도 | CEO에게 승인 요청 알림, 에러 에스컬레이션 알림 |
| 현재 상태 | vice-reply.sh, ceo-reply.sh 스크립트로 보고. 파이프라인 직접 연동은 없음 |
| Phase | Phase 2 |
연동 범위 (Phase 2)
Telegram 연동은 기존 보고 스크립트를 활용하되, 파이프라인에서 특정 이벤트 발생 시 자동 알림을 추가한다.
알림 유형:
| # | 이벤트 | 알림 내용 | 방법 |
|---|
| 1 | 콘텐츠 검수 요청 | content_queue reviewing 전환 시 CEO에게 미리보기 링크 전송 | ceo-reply.sh를 API에서 호출 |
| 2 | 에러 에스컬레이션 | error_logs.escalated=1일 때 에러 요약 전송 | vice-reply.sh를 API에서 호출 |
| 3 | 일일 파이프라인 리포트 | 당일 수집/생성/발행 건수 요약 | vice-reply.sh (Cron에서 호출) |
| 4 | 발송 완료 알림 | Brevo/SNS 발행 완료 시 결과 요약 | vice-reply.sh |
구현 방식
기존 스크립트를 shell 명령으로 호출하는 방식을 유지한다. Telegram API를 직접 호출하지 않는다 (CLAUDE.md 규칙: "Telegram API 직접 호출 / secrets 접근 절대 금지").
파이프라인 이벤트 발생
│
├─ CEO 검수 요청: exec(`ceo-reply.sh "🤖 [자비스] 콘텐츠 검수 요청\n제목: {title}\n미리보기: {url}" "vice-claude"`)
│
├─ 에러 에스컬레이션: exec(`vice-reply.sh "🤖 [자비스] 파이프라인 에러\n컴포넌트: {component}\n에러: {message}" "error"`)
│
└─ 발행 완료: exec(`vice-reply.sh "🤖 [자비스] 콘텐츠 발행 완료\n제목: {title}\n채널: {channels}" "publish"`)
인증 방식
| 항목 | 상세 |
|---|
| 방식 | 기존 스크립트 (vice-reply.sh, ceo-reply.sh) 통해 간접 호출 |
| 환경 변수 | CODEX_VP_BOT_TOKEN, CODEX_VP_CHAT_ID (스크립트 내부에서 참조) |
| 직접 API 호출 | 금지 (보안 정책) |
Rate Limit
| 제한 | 값 | 대응 전략 |
|---|
| Telegram Bot API | 30 메시지/초 (그룹), 1 메시지/초 (개인) | 알림 간 1초 딜레이. 대량 알림 시 배치 |
| vice-reply.sh | 자체 제한 없음 | 호출 간 최소 2초 간격 유지 |
Fallback 처리
| 실패 유형 | Fallback 행동 |
|---|
| 스크립트 실행 실패 | error_logs 기록. 대시보드에서 알림 내용 확인 가능 (pipeline_logs) |
| Bot 토큰 만료 | error_logs(escalated=1) → 대시보드 에러 표시 |
DB 연계
Telegram은 channels 테이블에 등록하지 않는다 (배포 채널이 아닌 관리 알림용). pipeline_logs의 metadata에 알림 전송 결과를 기록한다.
2-5. 외부 플랫폼 (티스토리, 미디엄, EO 등) — Phase 3
개요
Phase 3에서 추가 예정인 외부 콘텐츠 플랫폼 연동. 현재는 설계 개요만 기록하며, 실제 구현 시점에 상세 설계를 작성한다.
플랫폼별 연동 방법 요약
| 플랫폼 | API 지원 | 연동 방법 | 인증 | 난이도 | 우선순위 |
|---|
| 티스토리 | Open API 있음 | REST API | OAuth 2.0 | 하 | Phase 3 우선 |
| 미디엄 | API 있음 | POST /users/{id}/posts | Bearer Token (Integration Token) | 하 | Phase 3 우선 |
| LinkedIn 아티클 | Marketing API | POST /ugcPosts | OAuth 2.0 (3-legged) | 중 | Phase 3 |
| EO (eopla.net) | 확인 필요 | API 또는 Playwright | 미확인 | 중 | Phase 3 |
| 네이버 블로그 | 공식 API 없음 | Playwright 브라우저 자동화 | 쿠키/세션 | 상 | Phase 3+ (최후순위) |
| 브런치 | API 없음 | Playwright 브라우저 자동화 | 쿠키/세션 | 상 | Phase 3+ (최후순위) |
Phase 3 연동 원칙
- API 우선: API가 있는 플랫폼(티스토리, 미디엄)부터 연동
- Playwright는 최후수단: API 없는 플랫폼은 캡차/보안 이슈로 불안정. 최후순위
- channels 테이블 확장: 새 플랫폼 추가 시
channels INSERT만으로 활성화
- 콘텐츠 변환: 플랫폼별 포맷 변환 로직 추가 (마크다운 → HTML 등)
3. 공통 설계 사항
3-1. channels 테이블 기반 동적 채널 관리
모든 외부 연동은 channels 테이블을 통해 관리한다. 채널 추가/비활성화를 코드 수정 없이 DB 조작만으로 가능하게 한다.
Phase 1 시드 데이터:
| id | name | type | platform | project | credentials_ref | config | is_active |
|---|
ch-apppro-blog | AppPro 블로그 | blog | apppro.kr | apppro | TURSO_DB_TOKEN | {"publish_api":"/api/cron/publish","auto_publish":true} | 1 |
ch-brevo | Brevo 뉴스레터 | newsletter | brevo | apppro | BREVO_API_KEY | {"list_id":8,"template":"weekly","sender_name":"AI AppPro","sender_email":"hello@apppro.kr"} | 1 |
ch-twitter | Twitter/X | sns | twitter | NULL | GETLATE_API_KEY | {"max_chars":280,"via_getlate":true} | 0 |
ch-linkedin | LinkedIn | sns | linkedin | NULL | GETLATE_API_KEY | {"max_chars":3000,"via_getlate":true} | 0 |
SNS 채널(ch-twitter, ch-linkedin)은 초기에 is_active=0으로 설정. getlate.dev에서 계정 연결 확인 후 is_active=1로 전환.
3-2. content_distributions 연계 패턴 (공통)
모든 외부 연동은 동일한 패턴으로 content_distributions에 기록한다:
1. 배포 시작: INSERT (content_id, channel_id, platform_status='pending')
2. 외부 등록 성공: UPDATE platform_status='registered', platform_id, scheduled_at
3. 발행 확인: UPDATE platform_status='published', platform_url, published_at
4. 실패: UPDATE platform_status='failed', error_message, retry_count++
상태 전이 규칙:
pending ──→ registered ──→ published
│ │
│ └──→ failed (발행 실패)
│
└──→ failed (등록 실패)
failed ──→ pending (재시도 시 새 레코드 INSERT)
3-3. error_logs 연계 패턴 (공통)
모든 외부 연동 에러는 동일한 패턴으로 error_logs에 기록한다:
| 필드 | 채워야 하는 값 |
|---|
component | 'brevo' / 'publisher' / 'sns_publisher' / 'scheduler' |
error_type | 'timeout' / 'auth_fail' / 'api_error' / 'rate_limit' / 'validation_fail' |
error_message | 원본 에러 메시지 (HTTP 상태 코드 + 응답 본문) |
content_id | 관련 content_queue.id |
channel_id | 관련 channels.id |
auto_fix_attempted | 자동 재시도 여부 (0/1) |
auto_fix_result | 재시도 결과 ('success' / 'failed' / 'skipped') |
escalated | CEO/VP 알림 필요 여부 (0/1) |
에스컬레이션 기준:
| 조건 | escalated |
|---|
| 인증 실패 (auth_fail) | 1 (즉시) |
| 3회 연속 자동 교정 실패 | 1 |
| 모든 채널 실패 | 1 |
| 단일 채널 실패 (나머지 성공) | 0 |
3-4. 환경 변수 → channels.credentials_ref 매핑
channels.credentials_ref 값 → 참조하는 환경 변수
─────────────────────────────────────────────────
"BREVO_API_KEY" → process.env.BREVO_API_KEY
"GETLATE_API_KEY" → process.env.GETLATE_API_KEY
"TURSO_DB_TOKEN" → process.env.TURSO_DB_TOKEN
"TISTORY_ACCESS_TOKEN" → process.env.TISTORY_ACCESS_TOKEN (Phase 3)
"MEDIUM_INTEGRATION_TOKEN" → process.env.MEDIUM_INTEGRATION_TOKEN (Phase 3)
런타임에서 채널의 인증 정보를 가져오는 로직:
channels 테이블에서 credentials_ref 조회
→ process.env[credentials_ref] 값 참조
→ null이면 해당 채널 skip (mock 모드)
3-5. mock 모드 통일
모든 외부 연동은 API 키 미설정 시 mock 모드로 동작한다:
| 행동 | 상세 |
|---|
| 실행 중단 없음 | 파이프라인은 계속 진행 |
| console.log | mock 모드임을 로그에 기록 |
| content_distributions | platform_status='failed', error_message='MOCK_MODE: {credentials_ref} not set' |
| error_logs | 기록하지 않음 (mock은 에러가 아님) |
| 대시보드 표시 | "API 키 미설정" 경고 배지 |
4. Phase별 구현 시점
Phase 1 (MVP, 1주) — 필수 연동
| # | 연동 대상 | 구현 내용 | 기존 코드 활용 |
|---|
| 1 | Brevo 뉴스레터 | 캠페인 생성 + 예약 발송 + content_distributions 기록 | lib/brevo.ts sendCampaign 확장 (scheduledAt 추가) |
| 2 | AppPro 블로그 DB | blog_posts INSERT + content_distributions 기록 + 발행 확인 | pipeline/publish-blog.ts 래퍼 |
| 3 | getlate SNS | 멀티플랫폼 예약 발행 + content_distributions 기록 | lib/getlate.ts + pipeline/publish-sns.ts 래퍼 |
Phase 1 제한:
- Brevo 캠페인 상태 폴링은 수동 확인 (대시보드에서 campaign_id 표시)
- getlate 발행 확인 폴링 없음 (registered → published 수동 전환)
- Telegram 알림 없음
Phase 2 (2주) — 알림 + 상태 추적
| # | 연동 대상 | 구현 내용 |
|---|
| 4 | Telegram 알림 | 검수 요청, 에러 에스컬레이션, 발행 완료 알림 (스크립트 호출) |
| 5 | Brevo 상태 폴링 | getCampaign으로 발송 상태/오픈율/클릭 추적 |
| 6 | getlate 상태 폴링 | GET /posts/{id}로 발행 결과/URL 추적 |
| 7 | 블로그 발행 확인 | apppro-kr DB 폴링으로 published=1 자동 확인 |
Phase 3 (3주+) — 외부 플랫폼 확장
| # | 연동 대상 | 구현 내용 |
|---|
| 8 | 티스토리 | Open API OAuth + 블로그 포스트 자동 발행 |
| 9 | 미디엄 | Integration Token + 포스트 발행 (draft 상태) |
| 10 | LinkedIn 아티클 | Marketing API로 긴 형식 아티클 발행 |
| 11 | EO/기타 | API 조사 후 결정 |
5. 연동 코드 구조 (content-orchestration 레포)
content-orchestration/
└── src/
└── lib/
├── integrations/
│ ├── brevo.ts ← Brevo 연동 래퍼 (content_distributions 연계)
│ ├── blog-publisher.ts ← 블로그 DB INSERT 래퍼
│ ├── sns-publisher.ts ← getlate SNS 래퍼
│ ├── telegram.ts ← Telegram 알림 (스크립트 호출, Phase 2)
│ └── types.ts ← 공통 인터페이스 (PublishResult, ChannelConfig 등)
├── pipeline/
│ └── publish.ts ← 통합 배포 오케스트레이터 (channels → integrations 호출)
└── db/
└── content-os.ts ← Turso 클라이언트 (기존)
통합 배포 오케스트레이터 흐름:
publishContent(contentId: string)
│
├─ channels 테이블에서 is_active=1 채널 목록 조회
│
├─ 채널별 content_distributions INSERT (pending)
│
├─ 채널 type별 분기:
│ ├─ type='blog' → blog-publisher.ts
│ ├─ type='newsletter' → brevo.ts
│ └─ type='sns' → sns-publisher.ts
│
├─ 결과별 content_distributions UPDATE
│
├─ 전체 결과 pipeline_logs 기록
│
└─ 에러 시 error_logs 기록 + 필요 시 에스컬레이션
6. 전체 연동 요약 매트릭스
| 연동 대상 | Phase | 인증 방식 | Rate Limit 핵심 | Fallback 핵심 | DB 연계 (channels.id) |
|---|
| Brevo | 1 | API Key (헤더) | 300통/일 (Free) | 예약 지연 + error_logs | ch-brevo |
| 블로그 DB | 1 | Turso Token | 500M reads/월 | slug 변경 재시도 | ch-apppro-blog |
| getlate (SNS) | 1~2 | Bearer Token | 플랜별 포스트 한도 | 플랫폼별 skip | ch-twitter, ch-linkedin |
| Telegram | 2 | 스크립트 간접 | 1 msg/초 | error_logs 기록 | (미등록) |
| 티스토리 | 3 | OAuth 2.0 | 미확인 | error_logs | (Phase 3 추가) |
| 미디엄 | 3 | Bearer Token | 미확인 | draft 상태 발행 | (Phase 3 추가) |
리뷰 로그
[ext-design-pl 초안 작성] 2026-02-25 16:07
- 현재 외부 연동 코드 4개 파일 분석 완료 (publish.ts, publish-blog.ts, publish-sns.ts, lib/brevo.ts, lib/getlate.ts)
- 환경 변수 현황 파악 (10개 외부 연동 관련 키)
- 한계점 7건 정리
- Phase 1 연동 3건 상세 설계: Brevo API (캠페인 생성+예약+상태조회), 블로그 DB (Turso INSERT+발행확인), getlate.dev (멀티플랫폼 예약+플랫폼별 변환)
- Phase 2 연동 1건 설계: Telegram Bot (스크립트 간접 호출, API 직접 호출 금지)
- Phase 3 연동 개요: 티스토리(Open API), 미디엄(API), 네이버(Playwright, 최후순위)
- 공통 설계: channels 기반 동적 관리, content_distributions 연계 패턴, error_logs 패턴, mock 모드 통일, credentials_ref 매핑
- 연동 코드 구조: integrations/ 디렉토리 기반 래퍼 패턴
- 자비스 1차 검수 요청
[자비스 1차 검수] 2026-02-25 18:50
검수 결과: 수정 없이 승인 ✅
검수 항목:
- ✅ 현황 분석 완료 — 기존 4개 연동 파일 전수 분석, 환경 변수 10개 현황, 한계점 7건 정리
- ✅ Phase 분리 명확 — Phase 1(Brevo/블로그/getlate), Phase 2(Telegram/상태폴링), Phase 3(티스토리/미디엄/네이버)
- ✅ Telegram 보안 정책 준수 — API 직접 호출 금지, vice-reply.sh/ceo-reply.sh 간접 호출로 설계
- ✅ mock 모드 통일 — API 키 미설정 시 에러 없이 MOCK_MODE 기록, 파이프라인 중단 없음
- ✅ channels 기반 동적 관리 — credentials_ref 패턴으로 코드 수정 없이 채널 추가/비활성화
- ✅ content_distributions 패턴 통일 — pending→registered→published 상태 전이 일관성
- ✅ error_logs 에스컬레이션 기준 명확 — auth_fail 즉시, 3회 연속 실패 시 에스컬레이션
- ✅ Phase 1 시드 데이터 정의 — ch-apppro-blog, ch-brevo(active), ch-twitter/linkedin(inactive 초기값)
- ✅ Fallback 처리 상세 — Brevo 발송한도 초과 시 다음날 재예약, slug 중복 자동 해결
- ✅ env vars 현황 — CONTENT_OS_DB_URL/TOKEN 포함 모두 .env에 설정됨 확인
추가 확인 사항: env 파일에서 CONTENT_OS_DB_URL/TOKEN 설정 완료 확인 → CEO 블로킹 없음