[Blueprint L2] PonsLink Control Tower — Threads에 dry-run으로 쏘는 이유
Declan·
Blueprint
P2P/분산기술
개발 회고
Python Threads 컨트롤타워. cardnews 재사용→생성 폴백, n8n 패치, dry-run 기본 게이트로 안전하게 쏘는 자동발행.
# [Blueprint L2] PonsLink Control Tower — Threads에 dry-run으로 쏘는 이유
> Series: `blueprint-ponslink` (2/3) · Category: `P2P/분산기술` + `개발 회고` · Slug: `blueprint-ponslink-07-control-tower-threads`
> Source: `blueprints/specs/P02_ponslink_web.md` §07 + `blueprints/07-ponslink-sns-control-tower.png` + `ponslink-sns-control-tower/`
—
## 0. TL;DR
`control-tower`는 `Python`으로 만든 `Threads` 자동발행 컨트롤타워. `cardnews`가 있으면 재사용, 없으면 생성 폴백으로 돌리고, `n8n` 워크플로를 패치한 뒤 `Threads`에 쏜다. 기본은 `dry-run`. 실제 발행은 스위치를 켜야만 간다. 컨트롤 플레인을 분리해, 발행 실패가 `cardnews` 생성까지 오염시키지 않게 했다.
—
## 1. 문제 정의 — 왜 컨트롤타워가 필요한가
`card_news` 파이프라인은 `마크다운→Hermes→storyboard→imagen→sharp`로 8단계다. 그러나 `Threads`에 올릴 때 매번 새로 생성하면 비용·시간·품질이 흔들린다. 이미 만든 `cardnews`가 있으면 재사용하는 게 낫다. 그리고 `n8n` 워크플로를 직접 고치면, `Threads API` 변경에 취약하다. 그래서 `control-tower`가 `재사용/생성`을 결정하고, `n8n`은 `패치`만 한다.
—
## 2. 설계도 한눈에 — 결정 다이아몬드와 dry-run 게이트
중앙 `HTTP API+UI` → `cardnews resolve` 다이아몬드 → 두 갈래: `재사용` 실선 / `생성 폴백` 점선(`card_news` 호출) → `n8n 워크플로 패치/트리거` → `Threads 발행 어댑터` → `Threads SNS`.
* **결정:** `cardnews`가 `DB`에 있으면 `재사용`, 없으면 `생성`. `card_news`의 `Prisma/SQLite`를 조회.
* **게이트:** 기본 경로는 `dry-run` — `Threads`에 안 쏘고 `n8n`에 `dry-run` 플래그만 전달. 실제 발행은 `?dryRun=false`를 명시해야 한다.
* **어댑터:** `Threads` API 변경 시 `어댑터`만 교체. `control-tower`는 `n8n`에 `워크플로 JSON`을 패치하는 역할만.
—
## 3. 아키텍처 해부 — 3개 컴포넌트
“`
control-tower/
├── api.py # HTTP API + UI
├── resolver.py # cardnews 재사용/생성 결정
└── n8n_client.py # 워크플로 패치/트리거
“`
* **api.py:** `FastAPI`로 `POST /publish` — `dryRun` 기본 `true`. `UI`는 `dry-run` 결과를 먼저 보여준다.
* **resolver.py:** `cardnews`의 `job`을 조회. `status=completed`이고 `7일 이내`면 재사용. 아니면 `card_news`에 `POST /api/pipeline/generate`로 생성 요청.
* **n8n_client.py:** `n8n`의 `workflowId`를 `GET`으로 읽고, `nodes` 중 `Threads` 노드의 `content`를 패치한 뒤 `POST /workflows/{id}/trigger`로 실행.
—
## 4. 로직 딥다이브 — 2개만 깊게
### 4.1 재사용/생성 폴백 결정
“`python
job = prisma.job.find_first(where={“topic”: topic, “status”: “completed”}, order={“createdAt”: “desc”})
if job and (now – job.createdAt).days < 7:
return job # 재사용
else:
return await card_news.generate(topic) # 생성
```
* `7일`은 `cardnews`의 유효기간. 그 이상이면 트렌드가 바뀌어 재생성.
### 4.2 dry-run 기본 게이트
* `control-tower`의 `POST /publish`는 `dryRun=true`가 기본. `n8n`에 `dryRun`을 전달하면, `Threads` 노드는 `API`를 호출하지 않고 `log`만 남긴다. 운영자가 `dry-run` 로그를 보고 `dryRun=false`로 재호출해야 실제 발행. 실수로 `Threads`에 도배되는 걸 막는 안전장치.
---
## 5. 왜 이 선택인가
| 선택 | 채택 | 버린 대안 | 버린 이유 |
|---|---|---|---|
| 결정 | 재사용→생성 폴백 | 항상 생성 | 비용·시간 낭비, 품질 편차 |
| 게이트 | dry-run 기본 | 바로 발행 | 테스트 중 실수 발행, `Threads` 스팸 처리 |
| 분리 | 컨트롤 플레인 분리 | `card_news`에 `Threads` 직접 연동 | `Threads` 장애가 `card_news` 생성까지 블로킹 |
---
## 6. 실패와 복구
* **실패:** 처음엔 `control-tower`가 `card_news`의 `DB`를 직접 읽었다. 그러나 `card_news`가 `Prisma` 마이그레이션을 하자 `control-tower`가 깨짐. `card_news`에 `GET /api/jobs` API를 추가해 간접 조회로 변경.
* **복구:** `n8n` 워크플로 `ID`가 바뀌면 `control-tower`가 `404`. `workflow`를 `name`으로 조회해 `ID`를 동적으로 찾게 수정.
---
## 7. 운영 증거
* `control-tower` — `Python, FastAPI, n8n, Threads API`, `dry-run` 100% 기본
* `card_news` — `Next.js, Bun pipeline-service, Prisma/SQLite, Caddy`, `Socket.io` 진행률
* `n8n` — `workflow` 패치 후 `trigger`, `Threads` 발행 어댑터
---
## 8. 다음 편 예고
**L3: `포트폴리오` — 의존성 0 정적 사이트.** `index.html` 단일 파일, `디자인 토큰 SSOT`, `13개 제품 쇼케이스`를 다룬다. 가장 단순한 아키텍처가 왜 가장 오래 가는지 보여준다.
*부록: `blueprints/07-ponslink-sns-control-tower.png` 프롬프트 `prompts.part1.json#07`, 코드 진입점 `ponslink-sns-control-tower/api.py`*
댓글