Strapi v4.24.2 (엔트리 식별자는 documentId가 아니라 숫자 id) · [서버 혹은 PC] 서버 192.168.0.100 · 경로 /mnt/data/02_automation/strapi · DB 컨테이너명 strapi-pg-db
이 가이드는 한 번에 완성된 게 아니라, 실제로 부딫힌 문제를 그때그때 반영해서 다듬어진 결과물입니다. 처음 따라가실 때 “왜 이렇게 되어있나” 싶은 부분이 있다면 아래 표를 참고하세요.
| 분류 | 발견된 문제 | 반영된 해결책 |
|---|---|---|
| 설치 | DB 드라이버 누락, 호스트/컨테이너 아키텍처 불일치 | 스캐폴딩을 컨테이너 안에서, Debian 계열 베이스 이미지로 고정 (STEP 03) |
| 스키마 설계 | Production 모드에서 Content-Type Builder 메뉴 자체가 비활성 | tmux + 임시 dev 컨테이너(1338)로 작업 후 재배포 (STEP 06~09) |
| 본문 필드 | content를 Rich text로 만들면 raw HTML/CSS가 깨짐 | Text(Long text)로 변경해 원본 그대로 보존 (STEP 07) |
| 카테고리 필드 | Strapi Enumeration은 GraphQL Name 규칙상 한글·비ASCII 입력이 원천적으로 불가 | 한글 대신 영문 슬러그(ai-lab, home-lab 등)를 Enum 값으로 직접 사용, 한글 표시는 categoryPath(Text)에 별도 보관 |
| n8n 연동 | 워크플로우 JSON 안의 placeholder 9곳을 어디서 어떻게 채우는지 안내 부재 | STEP 15에 노드별 체크리스트 표 + Credential 등록 절차 추가 |
| WordPress 발행 | Rank Math 커스텀 메타가 REST API로 기본 차단됨 | functions.php에 register_post_meta 등록 (STEP 14) |
| 네트워크 주소 | [서버 혹은 PC] IP를 .100으로 잘못 기록(실제는 .253) + n8n→Strapi/ComfyUI를 호스트 IP로 호출 시 타임아웃 | [서버 혹은 PC]은 .253으로 전체 수정, n8n↔Strapi·ComfyUI 내부 호출은 컨테이너명(strapi:1337, comfyui:8188)으로 통일 |
| ComfyUI 이미지 가져오기 | history 응답 구조를 단순 $json.outputs.filename으로 잘못 가정 | prompt_id → outputs[SaveImage번호] → images[0] → filename까지 정확히 탐색하는 표현식으로 수정 (STEP 17) |
| Strapi 인증 | Header Auth로 안내했으나 실제로는 Generic Credential Type의 Bearer Auth가 더 정확하고 간단함 | 10·11·17번 노드 전부 Bearer Auth Credential 방식으로 전환 (STEP 16 ③) |
Strapi란 무엇인가
WordPress는 콘텐츠 관리와 화면 출력이 하나로 묶인 CMS입니다. Strapi는 콘텐츠 모델링·저장·API 제공만 담당하는 헤드리스 CMS로, 이 가이드에서는 “콘텐츠 작성·검수 허브”로 쓰고, WordPress를 “최종 게시 채널”로 씁니다. Strapi에서 글을 쓰고 메타정보까지 채운 뒤 Publish를 누르면, 대표 이미지 생성 → WordPress 발행 → SNS 게시까지 자동으로 이어집니다.
사전 준비 — 디렉토리 구조 & PostgreSQL
mkdir -p /mnt/data/02_automation/strapi mkdir -p /mnt/data/02_automation/strapi-postgres/data mkdir -p /mnt/data/02_automation/strapi-uploads cd /mnt/data/02_automation/strapi
상대경로(./strapi-uploads)는 docker compose 실행 위치에 따라 마운트 폴더가 달라져 재빌드 시 데이터가 사라질 수 있습니다. 이 가이드는 예외 없이 /mnt/data/... 절대경로만 씁니다.
Strapi 프로젝트 생성 & Dockerfile 작성
호스트에서 직접 npx create-strapi-app을 실행하면 호스트(glibc) 기준 node_modules가 생성되고, 이게 Alpine(musl) 컨테이너에 섞이면 esbuild가 실행되지 않습니다. 스캐폴딩 자체를 컨테이너 안에서 실행해 이 문제를 원천 차단합니다.
cd /mnt/data/02_automation mkdir -p strapi && cd strapi docker run --rm -it \ -v "$(pwd):/opt/app" -w /opt/app \ node:20-bookworm-slim \ npx create-strapi-app@latest . \ --typescript --dbclient=postgres \ --dbhost=strapi-pg-db --dbport=5432 \ --dbname=strapi --dbusername=strapi \ --dbpassword=ChangeThisPassword123! \ --no-run --skip-cloud
플래그를 줘도 npx 캐시 문제로 SQLite 기본값(pg 드라이버 없음)으로 생성되는 경우가 있습니다. 생성 직후 반드시 확인하세요.
grep '"pg"' package.json || echo "❌ pg 누락 — 아래로 추가"
docker run --rm -it -v "$(pwd):/opt/app" -w /opt/app \
node:20-bookworm-slim npm install pg --save
grep '"pg"' package.json # 반드시 있어야 함.dockerignore
node_modules .tmp .cache build .git .env *.md .editorconfig
Dockerfile.prod — Debian 계열로 고정
# Stage 1: 빌드 FROM node:20-bookworm-slim AS builder WORKDIR /opt/app RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential python3 git libvips-dev && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ RUN npm ci RUN npm install pg --save COPY . . ENV NODE_ENV=production RUN npm run build # Stage 2: 운영 실행 (경량) FROM node:20-bookworm-slim AS production WORKDIR /opt/app RUN apt-get update && apt-get install -y --no-install-recommends \ libvips && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /opt/app/build ./build COPY --from=builder /opt/app/config ./config COPY --from=builder /opt/app/database ./database COPY --from=builder /opt/app/public ./public COPY --from=builder /opt/app/src ./src COPY --from=builder /opt/app/favicon.png ./favicon.png ENV NODE_ENV=production EXPOSE 1337 CMD ["npm", "run", "start"]
docker-compose 통합 & 실행
strapi-pg-db:
image: postgres:16-alpine
container_name: strapi-pg-db
restart: always
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: ${STRAPI_DB_PASSWORD}
volumes:
- /mnt/data/02_automation/strapi-postgres/data:/var/lib/postgresql/data
networks: [ai-common-net]
logging: *default-logging
strapi:
build:
context: /mnt/data/02_automation/strapi
dockerfile: Dockerfile.prod
container_name: strapi
restart: always
env_file: /mnt/data/.env
environment:
- DATABASE_CLIENT=postgres
- DATABASE_HOST=strapi-pg-db
- DATABASE_PORT=5432
- DATABASE_NAME=strapi
- DATABASE_USERNAME=strapi
- DATABASE_PASSWORD=${STRAPI_DB_PASSWORD}
- NODE_ENV=production
ports: ["1337:1337"]
volumes:
- /mnt/data/02_automation/strapi-uploads:/opt/app/public/uploads
depends_on: [strapi-pg-db]
networks: [ai-common-net]
logging: *default-loggingcd /mnt/data/02_automation/strapi cat >> .env << EOF APP_KEYS=$(openssl rand -base64 32),$(openssl rand -base64 32) API_TOKEN_SALT=$(openssl rand -base64 32) ADMIN_JWT_SECRET=$(openssl rand -base64 32) TRANSFER_TOKEN_SALT=$(openssl rand -base64 32) JWT_SECRET=$(openssl rand -base64 32) EOF echo "STRAPI_DB_PASSWORD=ChangeThisPassword123!" >> /mnt/data/.env cd /mnt/data docker compose build strapi docker compose up -d strapi-pg-db strapi docker logs strapi -f
로그에 To manage your project 🚀가 보이면 성공입니다. http://192.168.0.100:1337/admin 접속.
최초 관리자 계정 설정
http://192.168.0.100:1337/admin 접속
이름·이메일·강력한 비밀번호 입력 → 계정 생성
Super Admin — 안전하게 보관
개발자 모드 진입 — tmux로 안전하게 띄우기
스키마(Content-Type) 작업은 production이 아니라 임시 dev 컨테이너에서 해야 합니다(이유는 STEP 07에서). 이 컨테이너는 --rm -it로 떠서 터미널 연결이 끊기면 그대로 죽습니다. SSH가 끊기거나 노트북이 잠자기 모드에 들어가면 작업 중이던 스키마가 날아갈 수 있으므로, tmux로 띄워서 연결이 끊겨도 안전하게 유지합니다.
① tmux 설치 (없는 경우만)
which tmux || sudo apt install -y tmux
② 헬퍼 스크립트 준비
#!/bin/bash
set -e
ENV_FILE="/mnt/data/.env"
STRAPI_DIR="/mnt/data/02_automation/strapi"
DEV_PORT=1338
STRAPI_DB_PASSWORD=$(grep -E '^STRAPI_DB_PASSWORD=' "$ENV_FILE" | head -1 | cut -d '=' -f2-)
[[ -z "$STRAPI_DB_PASSWORD" ]] && { echo "❌ STRAPI_DB_PASSWORD 못 찾음"; exit 1; }
docker inspect strapi-pg-db &>/dev/null || { echo "❌ strapi-pg-db가 안 떠있음"; exit 1; }
NETWORK_NAME=$(docker inspect strapi-pg-db --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
docker rm -f strapi-dev-temp &>/dev/null || true
echo "Dev 모드 시작 — http://192.168.0.100:${DEV_PORT}/admin"
cd "$STRAPI_DIR"
docker run --rm -it \
--name strapi-dev-temp \
--network "$NETWORK_NAME" \
-v "$(pwd):/opt/app" -w /opt/app \
-p "${DEV_PORT}:1337" \
-e NODE_ENV=development \
-e DATABASE_CLIENT=postgres \
-e DATABASE_HOST=strapi-pg-db \
-e DATABASE_PORT=5432 \
-e DATABASE_NAME=strapi \
-e DATABASE_USERNAME=strapi \
-e DATABASE_PASSWORD="${STRAPI_DB_PASSWORD}" \
node:20-bookworm-slim \
bash -c "npm install && npm run develop"chmod +x ~/strapi-dev-mode.sh
③ tmux 세션 생성 후 그 안에서 실행
# 1. 이름이 있는 tmux 세션 새로 생성 tmux new -s strapi-dev # 2. (tmux 안으로 들어온 화면에서) 스크립트 실행 ~/strapi-dev-mode.sh # npm install 3~7분 소요. "To manage your project" 메시지 나오면 준비 완료
④ 세션에서 빠져나오기 (컨테이너는 계속 살아있음)
| 키 입력 | 의미 |
|---|---|
Ctrl + B 누른 뒤 D | tmux 세션에서 “빠져나가기”(detach). 안의 프로세스는 계속 실행됨 |
이제 SSH 연결을 끊거나 터미널 창을 닫아도 dev 컨테이너는 계속 살아있습니다. 브라우저로 http://192.168.0.100:1338/admin 접속해서 STEP 07~09 작업을 이어가시면 됩니다.
⑤ 나중에 다시 들어가기
# 세션 목록 확인 (이름이 기억 안 날 때) tmux ls # 해당 세션으로 재접속 tmux attach -t strapi-dev
detach(Ctrl+B → D)를 못 하고 연결이 끊겨도 tmux 세션 자체는 서버에 남아있습니다. 다시 SSH로 들어가서 tmux attach -t strapi-dev만 치면 끊기기 전 화면 그대로 돌아옵니다. (단, 진짜 컨테이너가 죽었는지는 docker ps -a --filter "name=strapi-dev-temp"로 별도 확인하세요)
⑥ 스키마 작업이 다 끝나면 — 개발자 모드 종료
STEP 09에서 Save를 누르고 Strapi 재시작까지 확인했다면, 이제 dev 컨테이너를 정리합니다.
tmux 세션으로 재접속 (위 ⑤ 참고)
tmux attach -t strapi-dev
컨테이너 로그가 보이는 화면에서 Ctrl + C
npm run develop 프로세스 종료 신호
컨테이너가 --rm 옵션 덕분에 자동 삭제됨
확인: docker ps -a --filter "name=strapi-dev-temp" → 결과 없어야 정상
tmux 세션 자체도 종료 (선택)
세션 안에서 exit 입력 — 더 이상 안 쓸 거면 정리
나중에 스키마를 또 고칠 일이 있다면, tmux 세션(exit 안 친 상태)은 그대로 두고 컨테이너만 Ctrl+C로 끈 뒤, 필요할 때 tmux attach -t strapi-dev → ~/strapi-dev-mode.sh 다시 실행하면 됩니다.
Content-Type Builder ① — Blog Post 기본 필드 (클릭 단위)
여기서부터는 STEP 06에서 띄운 1338 화면(개발자 모드)에서 진행합니다. http://192.168.0.100:1338/admin으로 접속한 상태에서 시작하세요.
STEP 06에서 1338 dev 컨테이너를 띄운 이유가 바로 이것입니다. Strapi는 NODE_ENV=production에서 스키마 생성·수정을 의도적으로 막습니다(Strapi 팀이 “버그가 아닌 의도된 기능”이라고 공식 확인). 지금부터의 모든 작업은 반드시 1338에서 진행하세요.
① Collection Type 생성
왼쪽 메뉴 CONTENT-TYPE BUILDER 클릭
상단 “Create new collection type” 클릭
팝업의 Display name 입력란에 Blog Post 입력
API ID가 자동으로 blog-post로 생성됨
“Continue” 클릭 → 필드 추가 화면으로 이동
② title 필드
필드 타입 목록에서 Text 클릭
Name: title 입력
Type 선택에서 Short text 라디오 버튼 선택 (기본값)
상단 ADVANCED SETTINGS 탭 클릭 → Required field 체크
오른쪽 아래 파란색 Finish 클릭
③ slug 필드
필드 목록 맨 아래 “Add another field” 클릭
필드 타입에서 UID 클릭
Name: slug 입력
“Attached field” 드롭다운에서 title 선택
title을 입력하면 slug가 자동으로 채워짐
Finish 클릭
④ content 필드 — ⚠️ Rich text가 아니라 Long text로!
실제 블로그 글은 <div style="..."> 같은 raw HTML/CSS 코드 그 자체입니다. Rich text(Blocks) 타입은 내용을 Strapi 전용 블록 JSON 구조로 변환해서 저장하기 때문에, 입력한 HTML 태그가 그대로 보존되지 않고 깨지거나 escape됩니다. Text(Long text)로 만들어야 입력한 HTML 문자열이 그대로 저장되고, n8n을 거쳐 WordPress로 보낼 때도 코드 유실 없이 발행됩니다.
Add another field → 필드 타입에서 Text 클릭
Name: content 입력
Type 선택에서 Long text 라디오 버튼 클릭 (Short text 아님!)
ADVANCED SETTINGS 탭 → Required field 체크
Finish 클릭
필드 목록에서 content 옆의 연필 아이콘(Edit) 클릭 → 팝업 상단에서 타입을 다시 Text로 선택 → 세부 옵션에서 Long text 선택 → ADVANCED SETTINGS에서 Required 재체크 → Finish. 이미 입력된 내용이 있다면 타입 변경 시 내용이 사라질 수 있으니, 초기 스키마 설계 단계(아직 글을 안 쓴 상태)에서 고치는 걸 추천합니다.
⑤ excerpt 필드
Add another field → Text 클릭
Name: excerpt, Type: Long text 선택
ADVANCED SETTINGS → Maximum length: 200 입력
Finish 클릭
⑥ coverImage 필드
Add another field → Media 클릭
Name: coverImage
“Single media” 선택 (Multiple media 아님)
ADVANCED SETTINGS → “Select allowed types of media” → Images만 체크
Finish 클릭
⑦ category & subCategory 필드 — 대/소분류 슬러그 체계
카테고리를 단일 값이 아니라 대분류(category) + 소분류(subCategory) 2단계로 나눕니다. WordPress에도 동일한 부모-자식 카테고리 구조를 미리 만들어두고, 아래 슬러그로 1:1 매핑합니다.
| 대분류 | 슬러그 | 소분류 | 슬러그 |
|---|---|---|---|
| AI랩 | ai-lab | 로컬 AI | local-ai |
| 워크플로우 | workflow | ||
| 음악/노래 생성 | music-gen | ||
| 이미지/영상 생성 | image-media-gen | ||
| 프롬프트 | prompt | ||
| 재테크랩 | finance | 일일 시황 리포트 | daily-stock-reports |
| 자동화 작업 | investment-automation | ||
| 주식/ETF | stock_etf | ||
| 홈랩 | home-lab | 나스 | nas |
| 데브옵스 | devops | ||
| 보안 | security | ||
| 서버/네트워크 | server-network |
Add another field → Enumeration 클릭
Name: category
Values 입력창에 한 줄씩: AI랩 / 재테크랩 / 홈랩
Enter로 한 줄씩 추가
Finish 클릭
다시 Add another field → Enumeration → Name: subCategory
Values에 위 표의 소분류 12개를 한 줄씩 전부 입력
로컬 AI, 워크플로우, 음악/노래 생성, 이미지/영상 생성, 프롬프트, 일일 시황 리포트, 자동화 작업, 주식/ETF, 나스, 데브옵스, 보안, 서버/네트워크
Finish 클릭
⑧ wordpressPostId 필드
Add another field → Number 클릭
Name: wordpressPostId, Number format: integer
ADVANCED SETTINGS → Private field 체크
API 응답에 안 보이게 — 내부 추적용
Finish 클릭
여기서 바로 Save해도 되지만, STEP 08의 Component 3개까지 다 만들고 한 번에 Save하면 서버 재시작이 한 번만 일어나서 더 빠릅니다. 이어서 진행하세요.
⑨ Draft & Publish 활성화
필드 목록 화면 상단의 ADVANCED SETTINGS 탭 클릭 (필드 팝업이 아니라 Blog Post 전체 설정)
“Draft & Publish” 토글을 ON으로 켜기
이게 켜져 있어야 Publish 시점에만 webhook이 트리거됩니다
Content-Type Builder ② — Component 3개 생성 (클릭 단위)
대표 이미지·SEO·SNS·티스토리 메타정보를 Component 3개로 묶습니다. Blog Post 화면은 그대로 두고, 왼쪽 메뉴로 이동합니다.
① image-meta 만들기
왼쪽 메뉴 COMPONENTS 옆의 “+”(Create new component) 클릭
Display name: image-meta 입력
Category 입력란에 meta 입력 → 새 카테고리로 생성됨
“Configure the component” (또는 Continue) 클릭
필드 추가 화면에서 아래 5개를 하나씩 추가합니다 (각각: 타입 클릭 → Name 입력 → 세부 타입 선택 → Finish):
| 순서 | Name | 필드 타입 클릭 | 세부 타입 |
|---|---|---|---|
| 1 | fileName | Text | Short text |
| 2 | altText | Text | Long text |
| 3 | imageTitle | Text | Short text |
| 4 | caption | Text | Long text |
| 5 | description | Text | Long text |
5개를 모두 추가한 뒤, 팝업창이 아니라 이 component 편집 화면 우측 상단의 Finish를 클릭해 component 생성을 완료합니다.
② seo-meta 만들기
다시 COMPONENTS → Create new component 클릭
Display name: seo-meta
Category: 드롭다운에서 방금 만든 meta 선택 (새로 안 만듦)
Configure the component 클릭
| 순서 | Name | 필드 타입 | 세부 타입 |
|---|---|---|---|
| 1 | seoTitle | Text | Short text |
| 2 | focusKeyword | Text | Short text |
| 3 | secondaryKeywords | Text | Long text |
| 4 | metaDescription | Text | Long text |
| 5 | wpTags | Text | Long text |
| 6 | snsTags | Text | Long text |
| 7 | categoryPath | Text | Short text |
7개 추가 후 Finish 클릭.
③ social-meta 만들기
COMPONENTS → Create new component 클릭
Display name: social-meta, Category: meta 선택
Configure the component 클릭
| 순서 | Name | 필드 타입 | 세부 타입 |
|---|---|---|---|
| 1 | snsSummary | Text | Long text (Rich text 아님!) |
| 2 | snsComment | Text | Long text |
| 3 | tistorySummary | Text | Long text (Rich text 아님!) |
| 4 | tistoryUrl | Text | Short text |
| 5 | tistoryPublished | Boolean | (타입 자체가 참/거짓 스위치) |
5개 추가 후 Finish 클릭. 이것으로 Component 3개가 모두 준비됐습니다.
Component를 Blog Post에 조립 → 저장 → production 재배포
① Blog Post에 Component 3개 연결
왼쪽 메뉴 COLLECTION TYPES → Blog Post 클릭
STEP 07에서 만들던 화면으로 돌아옴
필드 목록 맨 아래 “Add another field to this collection type” 클릭
필드 타입 목록 가장 아래 Component 클릭
팝업 상단 두 번째 탭 “Use an existing component” 클릭 (Create a new component 아님)
목록에서 meta.image-meta 선택
Name 입력란에 imageMeta 입력
타입 선택에서 Single component 라디오 버튼 선택
글 하나당 메타정보는 1세트면 충분하므로
Finish 클릭
같은 방식으로 2번 더 반복합니다:
| 선택할 Component | Name 입력값 | 타입 |
|---|---|---|
| meta.seo-meta | seoMeta | Single component |
| meta.social-meta | socialMeta | Single component |
② 최종 저장
3개 Component 연결이 모두 끝나면, 화면 우측 상단의 초록색 Save 버튼 클릭
Strapi가 스키마 파일(.json)을 생성하며 자동 재시작
화면에 로딩 표시가 사라지고 다시 정상 화면이 보이면 완료
③ 개발자 모드 종료 (STEP 06-⑥ 참고)
# tmux 세션으로 들어가서 tmux attach -t strapi-dev # 로그 화면에서 Ctrl+C로 종료 → --rm이라 컨테이너 자동 삭제 # 확인 (결과 없어야 정상) docker ps -a --filter "name=strapi-dev-temp"
④ 스키마 파일 생성 확인 & production 재배포
cd /mnt/data/02_automation/strapi ls src/api/blog-post/content-types/blog-post/schema.json ls src/components/meta/ cd /mnt/data docker compose build strapi docker compose up -d strapi
http://192.168.0.100:1337/admin 접속 → Content Manager에 Blog Post가 보이고, 그 안에 “Create new entry” 버튼이 보이면 정상입니다. (구조를 바꾸는 것만 막혀있고, 글을 쓰는 건 production에서도 항상 가능합니다)
API 토큰 · CORS 설정 (파일 위치 + 재배포까지)
① API 토큰 발급
1337 화면에서 Settings → API Tokens → Create new API Token
Name: n8n-integration / Token duration: Unlimited / Token type: Full access
Save 클릭 → 생성된 토큰 즉시 복사 (재확인 불가)
② CORS 설정 파일 위치 및 수정
Strapi v4에서 CORS는 별도 화면이 아니라 코드 파일로 설정합니다.
터미널에서 해당 파일 열기
nano /mnt/data/02_automation/strapi/config/middlewares.ts
기존에 'strapi::cors'라고 단순 문자열로 되어있는 줄을 찾아서, 아래 객체 형태로 교체
module.exports = [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::cors',
config: {
origin: [
'https://agibop.com',
'https://n8n.agibop.com',
'http://192.168.0.100:1337',
],
},
},
'strapi::security',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];https://agibop.com 등은 예시이므로, 실제 본인의 워드프레스·n8n 도메인(또는 IP)으로 정확히 바꿔야 합니다.
③ 파일 수정은 코드 수정일 뿐 — 반드시 재배포까지 해야 적용됩니다
# 저장(Ctrl+O → Enter → Ctrl+X) 후
cd /mnt/data
docker compose build strapi
docker compose up -d strapimiddlewares.ts는 production 이미지 안에 빌드되어 들어가는 코드입니다. 파일을 수정한 것만으로는 지금 떠있는 컨테이너에 반영되지 않으니, 반드시 build → up -d 까지 실행해야 CORS 설정이 실제로 적용됩니다.
사용 방법 — 콘텐츠 작성 & Draft/Publish
Content Manager → Blog Post → Create new entry
title 입력 시 slug 자동 생성
content에 본문 HTML 통째로 붙여넣기
Long text 필드라 그대로 보존됩니다
excerpt, category, subCategory 입력
coverImage는 비워둠 — STEP 17에서 자동으로 채워짐
imageMeta, seoMeta, socialMeta 6개 섹션 전부 채우기
이게 곧 예전에 따로 관리하던 meta.md 내용입니다
Save(Draft) → 검수 → Publish
Publish 순간에만 자동 파이프라인이 시작됩니다
Obsidian + n8n SFTP 연동 — 초안을 자동으로 Strapi에 가져오기
Strapi 관리자 화면에 직접 타이핑하는 대신, 익숙한 Obsidian에서 글을 쓰고, [NAS]에 모인 파일을 n8n이 SFTP로 직접 읽어서 Strapi에 Draft로 자동 생성하는 방식입니다. obsidian-vault Vault의 1. blog-content 폴더 구조를 그대로 활용합니다.
Obsidian에서 글을 쓰고 Synology Drive로 동기화해도, 그건 [NAS] 디스크에 파일이 저장되는 것뿐입니다. Strapi는 그 폴더의 존재 자체를 모르고, 누군가 API를 호출해줘야만 데이터가 들어갑니다. 이 STEP은 그 “다리” 역할을 — 파이썬 스크립트 대신 n8n으로 만듭니다. [NAS]에 Python·pip를 따로 설치할 필요가 없습니다.
① 기존 Vault 구조 그대로 사용
obsidian-vault/ ├── 0. system/ ← Obsidian 설정·플러그인 등 ├── 1. blog-content/ ← 이 STEP에서 사용 │ ├── attachments/ ← 본문에 넣는 이미지 등 첨부파일 │ ├── logs/ ← (선택) 처리 이력 기록용 │ ├── posts/ ← 작성 중인(아직 미완성) 글 │ ├── published/ ← Strapi 전송 완료 후 보관 │ ├── ready/ ← n8n이 주기적으로 감시하는 폴더 │ ├── scripts/ ← (n8n 방식에서는 사용 안 함) │ └── templates/ ← 새 글 작성용 frontmatter 템플릿 ├── 2. work/ ← 모바일/PC 업무용 (이 파이프라인과 무관) └── 3. life/ ← (이 파이프라인과 무관)
② Synology Drive로 서버([서버 혹은 PC])·모바일·PC 전부 연동
Obsidian을 어디서 쓰든(휴대폰, 노트북, 또는 [서버 혹은 PC] 서버에 직접 SSH로 들어가서) 결국 같은 [NAS] 폴더로 모이게 만드는 게 핵심입니다.
| 환경 | 연동 방식 |
|---|---|
| PC/노트북 | Synology Drive Client 설치 → obsidian-vault 동기화 항목 추가 |
| 휴대폰 | Synology Drive 모바일 앱 → 같은 Vault 동기화 (오프라인 작성 후 자동 업로드) |
| [서버 혹은 PC] 서버 | 아래 ③ 참고 — Drive Client 대신 NFS/SMB 마운트로 직접 연결 |
③ [서버 혹은 PC] 서버에서 [NAS] Vault에 직접 연결 (서버 연동)
서버는 Synology Drive Client(GUI 프로그램)를 설치할 수 없으므로, [NAS] 공유 폴더를 네트워크 파일시스템으로 직접 마운트합니다. 이렇게 하면 [서버 혹은 PC]에서도 마치 로컬 폴더처럼 Vault에 접근할 수 있습니다(이 가이드의 n8n SFTP 방식에서는 필수는 아니지만, [서버 혹은 PC]에서 직접 파일을 열어보거나 백업할 때 유용합니다).
[NAS] DSM → 제어판 → 파일 서비스 → NFS 탭 → NFS 서비스 활성화
제어판 → 공유 폴더 → obsidian-vault 선택 → 편집 → “NFS 권한” 탭 → 생성
Hostname/IP: [서버 혹은 PC]의 IP(192.168.0.100) · Privilege: 읽기/쓰기 · Squash: No mapping(기본값이 다르면 권한이 있어도 거부될 수 있음)
[서버 혹은 PC]에서 마운트
sudo apt install -y nfs-common
sudo mkdir -p /mnt/obsidian-vault
sudo mount -t nfs 192.168.0.150:obsidian-vault /mnt/obsidian-vault
# 재부팅 후에도 유지되게 fstab에 등록
echo "192.168.0.150:obsidian-vault /mnt/obsidian-vault nfs defaults 0 0" | sudo tee -a /etc/fstab위 NFS 마운트는 [서버 혹은 PC]에서 Vault 파일을 직접 들여다보고 싶을 때를 위한 선택 사항입니다. 아래 ④ n8n 워크플로우는 NFS 마운트 없이도 SFTP로 곧바로 [NAS]에 접근하므로, 이 ③번 단계를 건너뛰셔도 무방합니다.
④ [NAS]에서 SFTP 활성화
DSM → 제어판 → 파일 서비스 → FTP 탭
“SFTP 서비스 활성화” 체크 → 적용
SSH와 같은 계정·포트(기본 22)를 그대로 씁니다 — 추가 계정 생성 불필요
SFTP는 SSH와 같은 22번 포트를 그대로 씁니다. 이 포트가 외부에 노출되어 있다면(공유기 포트포워딩 등) 22번은 전 세계에서 가장 많이 자동 스캔·무차별 대입 공격을 받는 포트입니다. 외부 노출 여부와 무관하게, 다음을 적용하시길 권장합니다.
- DSM → 제어판 → 터미널 및 SNMP → SSH 서비스 → 포트 번호를 22가 아닌 다른 값(예: 2222, 22022 등)으로 변경
- 외부에서 접속할 계획이 없다면 공유기에서 22/SFTP 포트포워딩 자체를 열지 않기 — 이 가이드의 n8n↔[NAS] 통신은 같은 내부망이라 포트포워딩이 필요 없습니다
- 포트를 바꾸면 STEP 12 ⑦의 SFTP Credential과 STEP 12 ③의 NFS 마운트 예시에서도 Port 값을 동일하게 수정해야 합니다
⑤ 템플릿 만들기 — templates/blog-post-template.md
--- title: category: subCategory: excerpt: seoTitle: focusKeyword: secondaryKeywords: metaDescription: categoryPath: wpTags: snsTags: fileName: altText: imageTitle: caption: description: snsSummary: snsComment: tistorySummary: ---여기부터 본문 HTML 작성...
⑥ n8n 워크플로우 — SFTP로 직접 읽어서 Strapi에 전송
Webhook 대신 Schedule Trigger로 시작하는 별도의 작은 워크플로우입니다(STEP 16의 메인 발행 워크플로우와는 완전히 분리된 워크플로우입니다).
아래 ⑦~⑪에서 설명하는 노드 8개가 obsidian-sftp-to-strapi-workflow.json 파일 하나에 전부 들어있습니다. n8n에서 Workflows → Import from File로 통째로 가져오시면, 직접 하나씩 만들 필요 없이 Credential 연결과 placeholder 교체만 하면 됩니다.
⑥-1. 파일 Import & 설정 (클릭 단위)
n8n → Workflows → “…” 메뉴 → Import from File
obsidian-sftp-to-strapi-workflow.json 선택
8개 노드가 한 번에 캔버스에 펼쳐짐
Save (아직 Active는 켜지 않음)
| 노드 | 해야 할 일 |
|---|---|
| 1) SFTP 목록 조회 | Authentication → ⑦에서 만들 [NAS] SFTP Credential 연결 |
| 2) 파일 있을 때만 진행 | 그대로 둠 — ready/ 폴더가 비어있을 때 SFTP 에러가 발생하는 것을 방지하는 IF 노드. 파일이 없으면 조용히 종료됩니다 |
| 2) .md 파일만 필터 | 그대로 둠 — .md가 아닌 파일(임시파일 등)이 섞여도 자동으로 건너뜀 |
| 3) SFTP 파일 다운로드 | 위와 동일한 [NAS] SFTP Credential 연결 |
| 4) frontmatter 파싱 | 그대로 둠 — title이 비어있는 미완성 글은 자동으로 건너뜀 |
| 5) Strapi에 Draft 생성 | Authentication → Generic Credential Type → Bearer Auth → STEP 16 ③의 Strapi 토큰 Credential 그대로 재사용 |
| 6) published로 이동 (성공) | Credential 연결 (1번과 동일) — 경로는 1) SFTP 목록 조회의 실제 결과값을 직접 참조하므로 수정 불필요 |
| 6) 실패 시 FAILED_ 접두사 표시 | Credential 연결 (1번과 동일) |
6번 rename 노드의 경로(oldPath/newPath)를 손으로 고치다가 생기는 에러가 실제로 가장 흔한 문제입니다. $json.fileName은 rename 노드 시점에서 Strapi API 응답({ id, title, … })을 가리키기 때문에 항상 undefined가 됩니다. 이 파일의 두 rename 노드는 1) SFTP 목록 조회의 결과($json.path, $json.name)를 직접 참조하도록 이미 설정되어 있습니다 — 그대로 두세요.
ready/ 폴더에 FAILED_제목.md 형태의 파일이 보이면, frontmatter 작성 실수(필수 필드 누락 등)나 Strapi 연결 문제가 있었다는 신호입니다. n8n Executions에서 해당 실행 기록을 열어 5번 노드의 에러를 확인한 뒤, 파일명에서 FAILED_를 지우고 다시 ready/에 두면 다음 주기에 재시도됩니다.
⑥-2. 아래는 위 파일에 포함된 각 노드의 상세 동작 설명입니다
이미 파일을 Import하셨다면 이 부분은 참고용입니다 — 노드를 처음부터 직접 만들고 싶으신 분만 따라가시면 됩니다.
Schedule Trigger (10분마다)
↓
SFTP: ready/ 폴더 목록 조회 (List)
↓
SFTP: 파일별로 다운로드 (Download)
↓
Code: frontmatter 파싱 (순수 JavaScript, 외부 라이브러리 불필요)
↓
HTTP Request: Strapi에 Draft 생성 (POST /api/blog-posts)
↓
SFTP: 성공한 파일을 published/로 이동 (Rename)⑦ SFTP Credential 등록
n8n Credentials → Create new → “SFTP” 검색
Host: 192.168.0.150 / Port: ④에서 변경한 포트 번호(예: 2222) / Username·Password: [NAS] 로그인 계정
기본값 22를 그대로 쓰셨다면 22 입력 — 단, ④의 보안 권고대로 변경하셨다면 그 값을 정확히 입력
Save → 이름 예: [NAS] SFTP
⑧ SFTP 목록 조회 + 다운로드 노드
# 1) SFTP 노드 — List Operation: List Path: obsidian-vault/1. blog-content/ready # 2) SFTP 노드 — Download (위 결과의 각 파일에 대해 반복) Operation: Download Path: ={{ $json.path }}
⑨ frontmatter 파싱 — Python 없이 순수 JavaScript로
n8n 설정이 binaryDataMode: filesystem(Self Hosted 기본값)이면, SFTP로 받은 파일이 디스크 참조 객체({ id: "uuid" })로 전달됩니다. 예전 코드의 .binary.data.toString('utf-8')은 이 참조 객체에 직접 toString()을 호출하는 거라 실제 내용이 아니라 깨진 바이트 배열이 반환됩니다. 반드시 this.helpers.getBinaryDataBuffer()로 읽어야 합니다. Sublime Text의 UTF-8 with BOM(~)^ 형태로 오염)과 Windows 줄바꿈(\r\n) 문제도 같이 처리했습니다.
const item = $input.first();
const binary = item.binary?.data;
if (!binary) return [];
let raw;
try {
if (binary.id !== undefined) {
// binaryDataMode: filesystem — 디스크 파일을 helpers로 읽음
// (직접 .toString() 하면 참조 객체만 오고 내용이 읽히지 않음)
const buffer = await this.helpers.getBinaryDataBuffer(0, 'data');
raw = buffer.toString('utf-8');
} else if (binary.data) {
// binaryDataMode: default(memory) — base64 인코딩 상태
raw = Buffer.from(binary.data, 'base64').toString('utf-8');
} else {
return [];
}
} catch(e) {
// 읽기 실패 → 조용히 건너뜀 (다음 주기에 재시도 가능)
return [];
}
// BOM 제거 (Sublime Text UTF-8 with BOM → \uFEFF 로 시작)
// Windows 줄바꿈 정규화 (\r\n → \n, 단독 \r → \n)
raw = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// frontmatter 파싱
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return [];
const meta = {};
match[1].split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx === -1) return;
const key = line.slice(0, idx).trim();
let val = line.slice(idx + 1).trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
meta[key] = val;
});
const body = match[2];
if (!meta.title) return [];
return [{ json: { meta, body, fileName: $('1) SFTP 목록 조회').item.json.name } }];⑩ Strapi에 Draft 생성
Authentication: Bearer Auth (STEP 16 ③과 동일한 Strapi 토큰 Credential 재사용)
Body (JSON):
{
"data": {
"title": {{ JSON.stringify($json.meta.title) }},
"content": {{ JSON.stringify($json.body) }},
"excerpt": {{ JSON.stringify($json.meta.excerpt) }},
"category": {{ JSON.stringify($json.meta.category) }},
"subCategory": {{ JSON.stringify($json.meta.subCategory) }},
"imageMeta": {
"fileName": {{ JSON.stringify($json.meta.fileName) }},
"altText": {{ JSON.stringify($json.meta.altText) }},
"imageTitle": {{ JSON.stringify($json.meta.imageTitle) }},
"caption": {{ JSON.stringify($json.meta.caption) }},
"description": {{ JSON.stringify($json.meta.description) }}
},
"seoMeta": {
"seoTitle": {{ JSON.stringify($json.meta.seoTitle) }},
"focusKeyword": {{ JSON.stringify($json.meta.focusKeyword) }},
"secondaryKeywords": {{ JSON.stringify($json.meta.secondaryKeywords) }},
"metaDescription": {{ JSON.stringify($json.meta.metaDescription) }},
"categoryPath": {{ JSON.stringify($json.meta.categoryPath) }},
"wpTags": {{ JSON.stringify($json.meta.wpTags) }},
"snsTags": {{ JSON.stringify($json.meta.snsTags) }}
},
"socialMeta": {
"snsSummary": {{ JSON.stringify($json.meta.snsSummary) }},
"snsComment": {{ JSON.stringify($json.meta.snsComment) }},
"tistorySummary": {{ JSON.stringify($json.meta.tistorySummary) }}
}
}
}위 Body에 publishedAt 필드가 없으므로 Strapi에 Draft 상태로만 생성됩니다. Publish는 여전히 Strapi 화면에서 사람이 검수 후 직접 누릅니다. Obsidian에서 “자동 발행”까지 건너뛰지 않도록 하는 안전장치입니다.
⑪ 성공한 파일을 published로 이동
Operation: Rename
Old Path: =obsidian-vault/1. blog-content/ready/{{ $json.fileName }}
New Path: =obsidian-vault/1. blog-content/published/{{ $json.fileName }}⑫ 실제 사용 흐름
워크플로우는 “파일이 변경됐는지”를 전혀 감시하지 않습니다. 단순히 10분마다 ready/ 폴더 안에 .md 파일이 있는지를 확인할 뿐입니다. 따라서 templates/나 posts/에서 아무리 글을 써도, 직접 ready/ 폴더로 옮기기 전까지는 아무 일도 일어나지 않습니다.
| 폴더 | n8n이 처리하는가 | 용도 |
|---|---|---|
| templates/ | ❌ 절대 안 봄 | 빈 양식 보관 — 새 글 시작할 때 복사해서 쓰는 용도 |
| posts/ | ❌ 안 봄 | 작성 중인 미완성 글 보관 |
| ready/ | ✅ 10분마다 감시 | 여기 넣는 순간 자동 처리 대상이 됨 |
| published/ | ❌ 안 봄 | 전송 완료된 글 보관 (n8n이 자동 이동) |
① templates/blog-post-template.md 복사
↓
② posts/내글제목.md 로 이름 바꾸고 그 안에서 작성 (작성 중, 안전)
↓
③ 완성되면 Obsidian에서 ready/내글제목.md 로 이동
(Synology Drive가 [NAS]에 즉시 동기화)
↓
④ n8n Schedule Trigger(10분마다 자동 실행)
→ SFTP로 ready/ 스캔 → .md 파일 발견
→ frontmatter 파싱 → Strapi에 Draft 생성
→ 성공하면 published/로 자동 이동 / 실패하면 FAILED_ 접두사 표시
↓
⑤ Strapi(1337) 접속 → 생성된 Draft 확인·검수
→ coverImage 등 Obsidian으로 못 채운 부분 보완 → Publish
↓
(이후는 STEP 16~23과 동일한 자동 파이프라인)이 방식은 [NAS]에 Python·pip가 없어도 그대로 동작합니다. [NAS]는 SFTP 서비스만 켜두면 되고, 실제 파일을 읽고 파싱하고 전송하는 모든 작업은 이미 [서버 혹은 PC]에서 돌고 있는 n8n이 처리합니다.
Strapi 화면에서 직접 쓰는 게 더 편하시면 STEP 11 방식 그대로 쓰셔도 전혀 문제없습니다. 이 STEP은 “이동 중 휴대폰으로 초안만 빠르게 써두고 싶을 때”를 위한 보조 경로입니다.
REST API 직접 호출 테스트
curl -s "http://192.168.0.100:1337/api/blog-posts?populate=*" \ -H "Authorization: Bearer YOUR_API_TOKEN" | jq curl -s "http://192.168.0.100:1337/api/blog-posts/1?populate=*" \ -H "Authorization: Bearer YOUR_API_TOKEN" | jq
WordPress 측 사전 준비 — Application Password & Rank Math & 카테고리
① Application Password 발급
WordPress 관리자 → 사용자 → 프로필 편집
“Application Passwords” 섹션 → 이름 입력 → Add New
생성된 비밀번호 공백 포함 그대로 복사 (재확인 불가)
② WordPress에 동일한 부모-자식 카테고리 생성
STEP 07에서 만든 슬러그 체계와 정확히 일치하게, WordPress 카테고리도 미리 만들어둡니다.
글 → 카테고리 → 새 카테고리 추가
이름: AI랩, 슬러그: ai-lab, 상위 카테고리: 없음
재테크랩(finance), 홈랩(home-lab)도 동일하게 부모 카테고리로 생성
소분류 12개를 각각 알맞은 부모 아래 자식 카테고리로 생성
예: 이름 “로컬 AI”, 슬러그 “local-ai”, 상위 카테고리 “AI랩”
STEP 18의 n8n 워크플로우는 슬러그로 WordPress 카테고리 ID를 자동 조회합니다. 슬러그가 다르면 조회에 실패해 미분류로 발행됩니다.
③ Rank Math 커스텀 필드 REST API 등록
Strapi의 seoTitle·focusKeyword·metaDescription을 WordPress REST API가 기본적으로 막아두기 때문에, functions.php에 1회 등록해야 실제로 Rank Math 패널에 반영됩니다.
add_action('rest_api_init', function() {
$fields = [
'rank_math_title' => 'string',
'rank_math_description' => 'string',
'rank_math_focus_keyword' => 'string',
'rank_math_secondary_keyword' => 'string',
];
foreach ($fields as $key => $type) {
register_post_meta('post', $key, [
'show_in_rest' => true,
'single' => true,
'type' => $type,
'auth_callback' => function() { return current_user_can('edit_posts'); }
]);
}
});Strapi Webhook 설정
1337 화면 → Settings → Webhooks → Create new webhook
Name: wordpress-auto-publish
URL: https://n8n.agibop.com/webhook/strapi-publish
Headers 추가: X-Webhook-Secret = 임의의 강력한 문자열
Events에서 Entry → Publish만 체크 → Save
n8n 워크플로우 Import & 전체 설정 (클릭 단위)
이 가이드와 함께 제공하는 strapi-full-pipeline-workflow.json에는 Webhook 수신부터 페이스북 댓글까지 19개 노드가 전부 들어있습니다. 직접 하나씩 만들 필요 없이 통째로 가져온 뒤, 표시된 placeholder만 실제 값으로 교체하면 됩니다.
① 파일 가져오기
n8n 접속 → 좌측 상단 Workflows 클릭
우측 상단 “…” (More) 메뉴 → Import from File 클릭
strapi-full-pipeline-workflow.json 선택
19개 노드가 캔버스에 한 번에 펼쳐짐
우측 상단 Save 클릭 (아직 Active는 켜지 않음)
placeholder를 다 채우고 테스트까지 끝낸 뒤(이 STEP 마지막 ⑤번)에 켜는 게 안전합니다. 미리 켜두면 빈 토큰으로 실제 글이 잘못 발행될 수 있습니다.
② 노드별 설정 전체 체크리스트 — 실제 검증된 최종판
아래 표 순서대로, 해당 노드를 캔버스에서 클릭 → 값 수정 → 빠져나오기를 반복하세요. URL은 이미 컨테이너명(strapi:1337, comfyui:8188)으로 채워져 있어 손댈 필요 없습니다 — 인증(Authentication)만 연결하시면 됩니다.
| 노드 번호 | 호출 대상 | 인증 방식 | 해야 할 일 |
|---|---|---|---|
| 3. IF 시크릿 검증 | – | – | YOUR_WEBHOOK_SECRET를 STEP 15에서 만든 X-Webhook-Secret 문자열로 교체 |
| 5. ComfyUI 작업 등록 | ComfyUI | 없음 | 이미 실제 워크플로우 JSON이 채워져 있음 — 본인 ComfyUI에 체크포인트 모델명만 일치하는지 확인 |
| 7. 생성 완료 확인 | ComfyUI | 없음 | 그대로 둠 |
| 9. 이미지 가져오기 | ComfyUI | 없음 | 그대로 둠 (history 응답 구조 탐색 표현식 이미 포함됨) |
| 10, 11, 17 | Strapi | Bearer Auth | ③ 참고 |
| 12. WP 카테고리 ID 조회 | WordPress | Basic Auth | ④ 참고 |
| 13. 이미지 다시 받기 | Strapi(정적 파일) | 없음 | 그대로 둠 — 손댈 필요 없음 |
| 14. WP 미디어 업로드 | WordPress | Basic Auth | ④ 참고, URL 도메인을 본인 워드프레스로 교체 |
| 15. WP alt/caption 채우기 | WordPress | Basic Auth | ④ 참고 |
| 20. WP 부모 카테고리 조회 | WordPress | Basic Auth | ④ 참고 — 14·15·16과 동일 Credential |
| 21. 태그 이름 분리 | – | 없음 | 그대로 둠 |
| 22. WP 태그 생성 | WordPress | Basic Auth | ④ 참고 + Options → Response → Never Error를 반드시 ON (STEP 18 ⑤) |
| 23. 태그ID+카테고리 집계 | – | 없음 | 그대로 둠 |
| 16. WordPress 글 발행 | WordPress | Basic Auth | ④ 참고, URL 도메인 교체 |
| 18. 페이스북 페이지 게시 | Body에 토큰 직접 입력 | YOUR_PAGE_ID, YOUR_PAGE_ACCESS_TOKEN 교체 (STEP 19 “사전 준비”) | |
| 19. 페이스북 댓글 작성 | Body에 토큰 직접 입력 | YOUR_PAGE_ACCESS_TOKEN 교체 (위와 동일) |
③ Strapi — Bearer Auth Credential 등록 (10·11·17번 공통)
10번 노드 클릭 → Authentication 드롭다운 → Generic Credential Type 선택
Generic Auth Type 드롭다운 → Bearer Auth 선택
“Set up credential” 클릭 → Token 입력란에 토큰값만 붙여넣기
“Bearer ” 글자는 직접 적지 마세요 — n8n이 자동으로 붙여줍니다
Save → 이름 예: Strapi N8N Token
11번, 17번 노드도 각각 Authentication → Generic Credential Type → Bearer Auth → 방금 만든 Credential 선택
새로 만들지 말고 드롭다운에서 기존 것 선택
Token 칸에 Bearer 토큰값처럼 직접 “Bearer “를 같이 넣으시면, 실제 요청은 Authorization: Bearer Bearer 토큰값이 되어 Strapi가 거부합니다. 토큰 문자열만 넣으세요.
④ WordPress — Basic Auth Credential 등록 (12·14·15·16번 공통)
12번 노드 클릭 → Authentication 드롭다운 → Generic Credential Type 선택
Generic Auth Type 드롭다운 → Basic Auth 선택
“Set up credential” 클릭 → User: 워드프레스 로그인 계정명 / Password: STEP 14에서 발급한 Application Password (공백 포함 그대로)
Save → 이름 예: WordPress Application Password
14·15·16번 노드도 각각 같은 방식으로 → 드롭다운에서 방금 만든 Credential 선택
13번은 건드리지 않음 (인증 불필요)
⑤ 테스트 — 실제로 한 번 끝까지 돌려보기
Strapi(1337)에서 테스트용 글 작성 → 6개 메타 섹션 전부 입력 → Publish
n8n 좌측 메뉴 Executions 클릭 → 방금 실행 항목 열기
노드를 순서대로 클릭하며 각각 초록색 체크인지 확인
빨간색이면 그 노드를 클릭 → 우측 패널의 에러 메시지로 원인 파악
19번 노드까지 전부 성공하면 → 워크플로우 우측 상단 Active 토글 ON
Active를 켠 이후로는 Strapi에서 Publish만 누르면, 위 19개 노드가 자동으로 순서대로 실행됩니다. 다음 STEP 17~19은 각 단계의 동작 원리를 더 깊이 설명합니다 — 이미 ②~④에서 설정을 마쳤다면 참고용으로 읽으시면 됩니다.
대표 이미지 자동 생성 — ComfyUI 연동
STEP 16에서 워크플로우를 Import하고 설정까지 마치셨다면, 여기서는 그 중 이미지 생성 부분(5~11번 노드)이 내부적으로 어떻게 동작하는지 자세히 설명합니다. 5번 노드의 ComfyUI 워크플로우 JSON을 아직 안 채우셨다면 아래 내용을 먼저 참고하세요.
title/category로 프롬프트 작성 (Code 노드)
↓ ComfyUI POST /prompt (작업 등록)
↓ GET /history/{id} 반복 확인 (완료 대기, Wait+IF 루프)
↓ GET /view (완성 이미지 가져오기)
↓ Strapi POST /api/upload (Media Library 업로드)
↓ Strapi PUT /api/blog-posts/:id (coverImage 연결)노드별 핵심 설정
| 노드 | 핵심 설정 |
|---|---|
| Code: 프롬프트 생성 | category별 스타일 키워드를 매핑해 ComfyUI 프롬프트 문자열 생성 |
| HTTP: ComfyUI 작업등록 | POST http://comfyui:8188/prompt — Body에 “Save (API Format)”으로 내보낸 워크플로우 JSON 통째로, 프롬프트 텍스트 노드 값만 교체 |
| Wait | 5초 대기 |
| HTTP: 완료 확인 | GET /history/{{prompt_id}} — 결과 있을 때까지 Wait와 함께 루프 |
| HTTP: 이미지 가져오기 | GET http://comfyui:8188/view?filename={{ ComfyUI history 응답에서 prompt_id→outputs['SaveImage노드번호']→images[0]→filename까지 탐색 }}&type=output, Response Format: File |
| HTTP: Strapi 업로드 | POST /api/upload, Body Type: Form-Data, Authorization: Bearer 토큰 |
| HTTP: coverImage 연결 | PUT /api/blog-posts/{{id}}, Body: {"data":{"coverImage": 업로드결과ID}} |
ComfyUI 화면에서 설정 → “Enable Dev mode Options” 켜면 “Save (API Format)” 버튼이 생깁니다. 그 형식으로 내보낸 그대로 사용하고, 프롬프트 텍스트가 들어가는 노드의 text 값만 n8n 변수로 교체하세요.
폴링이 일정 횟수(예: 30회=2분30초)를 넘으면 타임아웃으로 처리하고, 이미지 없이 본문 발행을 계속 진행하도록 분기를 만드세요. 별도로 만들어둔 텔레그램 알림 워크플로우에 “이미지 생성 실패: {title}” 메시지를 보내면 나중에 수동 보완할 수 있습니다.
이미지 생성까지 포함하면 파이프라인이 수십 초~수 분 걸립니다. Strapi Webhook이 타임아웃 나지 않도록 n8n Webhook 노드의 Response Mode를 “Immediately respond”로 설정하세요.
n8n — WordPress 자동 발행 (카테고리·태그 자동 매핑 + Rank Math + 이미지)
① 자식 카테고리 ID 조회 (12번 노드)
STEP 14에서 만든 슬러그를 활용해, 하드코딩된 ID 대신 실시간으로 조회합니다 — 카테고리 ID가 바뀌어도 워크플로우를 안 고쳐도 됩니다.
GET https://agibop.com/wp-json/wp/v2/categories?slug={{ entry.subCategory }}
응답: [{ "id": 23, ... }]STEP 07에서 category/subCategory를 처음부터 영문 슬러그(home-lab, devops 등)로 저장해뒀기 때문에, 한글→슬러그 변환 코드가 따로 필요 없습니다. 4번 Code 노드가 만든 값을 그대로 씁니다.
② 이미지를 WordPress 미디어로 업로드 (13~15번 노드)
# 1) Strapi 이미지 다시 받기 (populate=coverImage로 채워진 URL 사용) GET http://strapi:1337{{ coverImage URL }} # 2) WP 미디어 업로드 POST https://agibop.com/wp-json/wp/v2/media Authentication: Basic Auth (Application Password) Content-Disposition: attachment; filename="{{ imageMeta.fileName }}.png" Content-Type: image/png Body: (위 binary, Input Data Field Name: data) 응답: { "id": 1234 } # 3) alt/caption/title 채우기 POST https://agibop.com/wp-json/wp/v2/media/{{ wpMediaId }} Body: { "alt_text": JSON.stringify(imageMeta.altText), "caption": JSON.stringify(imageMeta.caption), "title": JSON.stringify(imageMeta.imageTitle) }
③ 부모 카테고리 ID 조회 (20번 노드) — 신규
②까지 했을 때 자식 카테고리만 보내면 워드프레스 카테고리 목록에서 부모가 체크 안 된 상태로 발행됩니다. 부모도 별도로 조회해서 같이 보내야 합니다.
GET https://agibop.com/wp-json/wp/v2/categories?slug={{ entry.category }}
응답: [{ "id": 5, ... }]④ 태그 이름 분리 (21번 노드) — 신규
seoMeta.wpTags(쉼표로 구분된 문자열, 예: "Obsidian, Strapi, 자동화")를 태그 하나당 1개 item으로 쪼갭니다.
const entry = $('4. Code: 프롬프트 생성 + 슬러그 매핑').item.json;
const tagNames = (entry.seoMeta.wpTags || '').split(',').map(s => s.trim()).filter(Boolean);
// 태그가 없으면 빈 배열 — 다음 노드(22번)가 아예 실행 안 되고 자연스럽게 건너뜀
return tagNames.map(name => ({ json: { tagName: name } }));⑤ WordPress 태그 생성 (22번 노드) — 신규, 핵심 함정 주의
태그 이름마다 WordPress에 생성을 시도합니다. 문제는 이미 존재하는 태그 이름으로 또 만들려고 하면 WordPress가 400(term_exists) 에러로 거부한다는 점입니다 — 실제로는 거의 모든 발행에서 이런 일이 일어납니다(같은 태그를 여러 글에서 재사용하니까요).
WordPress의 400 거부 응답 본문 안에는 사실 이미 존재하는 그 태그의 ID(data.term_id)가 들어있어서, 정상적으로는 이걸 꺼내 쓰면 됩니다. 그런데 n8n의 HTTP Request 노드는 4xx/5xx 응답을 “실패”로 처리하는 게 기본값이라, 옵션을 안 켜두면 이 본문이 그대로 안 넘어오고 n8n이 자체적으로 만든 에러 래퍼만 넘어옵니다. 결과적으로 같은 태그를 두 번째 쓰는 순간부터 그 태그의 ID를 영영 못 찾고, 매번 빈 태그 목록으로 발행되고 있었습니다.
해결책은 “Never Error” 옵션을 켜서, 4xx 응답도 정상 응답처럼 본문을 그대로 받아오게 만드는 것입니다.
22번 노드 클릭 → Options 섹션 펼치기
“Add option” → Response 항목 추가
그 안의 “Never Error” 토글을 ON으로 켜기
이걸 켜야 400 응답도 정상 응답처럼 본문이 그대로 넘어옵니다
POST https://agibop.com/wp-json/wp/v2/tags
Authentication: Basic Auth
Body: { "name": "{{ $json.tagName }}" }
Options → Response → Never Error: ON
성공 시 응답: { "id": 99, "name": "...", ... }
이미 있을 때 응답(이제는 정상으로 받아짐):
{ "code": "term_exists", "data": { "status": 400, "term_id": 99 } }⑥ 태그ID + 카테고리 집계 (23번 노드) — 신규
22번이 만든 N개의 결과(새 태그든 기존 태그든)를 하나의 ID 배열로 합치고, 부모·자식 카테고리 ID까지 한 item으로 정리합니다.
const items = $input.all();
const tagIds = [];
for (const item of items) {
const d = item.json;
if (d.id) tagIds.push(d.id); // 새로 생성된 태그
else if (d.data && d.data.term_id) tagIds.push(d.data.term_id); // 이미 존재하던 태그
}
const entry = $('4. Code: 프롬프트 생성 + 슬러그 매핑').item.json;
const parentCategoryId = $('20. WP 부모 카테고리 ID 조회').item.json.id;
const childCategoryId = $('12. HTTP: WP 카테고리 ID 조회').item.json.id;
return [{ json: { ...entry, tagIds, parentCategoryId, childCategoryId } }];⑦ 글 발행 — 부모+자식 카테고리, 태그, Primary Term 전부 포함 (16번 노드)
{
"title": JSON.stringify(title),
"slug": JSON.stringify(slug),
"content": JSON.stringify(content),
"excerpt": JSON.stringify(excerpt),
"status": "publish",
"categories": [parentCategoryId, childCategoryId],
"tags": [tagIds 전체],
"featured_media": wpMediaId,
"meta": {
"rank_math_title": JSON.stringify(seoMeta.seoTitle),
"rank_math_description": JSON.stringify(seoMeta.metaDescription),
"rank_math_focus_keyword": JSON.stringify(seoMeta.focusKeyword),
"rank_math_secondary_keyword": JSON.stringify(seoMeta.secondaryKeywords),
"rank_math_primary_category": childCategoryId
}
}
응답: { "id": 5678, "link": "https://agibop.com/..." }본문·제목 등에 줄바꿈이 포함되면, {{ }}로 그냥 끼워넣을 경우 JSON 문법 자체가 깨집니다(Bad control character 에러). JSON.stringify()로 감싸면 줄바꿈이 안전하게 이스케이프되어 항상 유효한 JSON으로 만들어집니다.
워드프레스 글이 여러 카테고리에 속할 때, Rank Math가 어떤 카테고리를 “주” 카테고리(URL 구조·breadcrumb 기준)로 쓸지 지정하는 메타 필드입니다. 자식 카테고리(childCategoryId)를 Primary로 지정해, 가장 구체적인 분류가 우선되도록 설정했습니다.
⑧ Strapi에 역기록 (17번 노드)
PUT http://strapi:1337/api/blog-posts/{{ strapiId }}
Body: { "data": { "wordpressPostId": {{ wpPostId }} } }SNS 게시 & 댓글 자동화
인스타그램 게시·댓글에는 instagram_business_content_publish 권한이 필요하고, Meta 앱 심사(2~4주, 화면녹화 제출 포함)를 통과해야 발급됩니다. 심사 끝나기 전까지는 인스타그램만 socialMeta를 참고해 수동으로 게시하는 하이브리드 방식을 권장합니다.
사전 준비 — 페이지 액세스 토큰
developers.facebook.com에서 앱 생성 (Business 유형)
Graph API Explorer에서 pages_manage_posts, pages_read_engagement 권한으로 토큰 발급 → 장기 토큰(60일)으로 교환
n8n Credential로 저장, 60일마다 갱신 워크플로우 별도 구성
# 1) 페이지 게시 POST https://graph.facebook.com/v22.0/{PAGE_ID}/feed Body: { "message": "={{ $json.socialMeta.snsSummary }}", "access_token": "YOUR_PAGE_TOKEN" } 응답: { "id": "PAGEID_POSTID" } # 2) 댓글 작성 (워드프레스 링크 포함) POST https://graph.facebook.com/v22.0/{{ $json.postId }}/comments Body: { "message": "={{ $json.socialMeta.snsComment }}", "access_token": "YOUR_PAGE_TOKEN" }
티스토리는 공식 게시 API가 제한적이라, tistorySummary를 참고해 수동 게시 후 Strapi의 tistoryUrl·tistoryPublished에 결과를 기록하는 방식을 추천합니다.
전체 파이프라인 통합 그림
Strapi에서 글 작성 + 6개 메타 섹션 입력 → Publish 클릭
↓ (webhook, X-Webhook-Secret 검증, 즉시 200 응답)
n8n 워크플로우 시작
↓
① 프롬프트 생성 → ComfyUI 이미지 생성 → Strapi coverImage 연결
↓
② 슬러그로 WP 카테고리 ID 조회 + 같은 이미지를 WP 미디어로 업로드
↓
③ WordPress 글 발행 (Rank Math 메타, featured_media 포함)
↓
④ wordpressPostId를 Strapi에 역기록
↓
⑤ 페이스북 페이지에 snsSummary로 게시 → ⑥ snsComment로 댓글 작성
↓
(인스타그램 — 앱 심사 완료 후 동일 구조로 추가)
(티스토리 — tistorySummary 참고 수동 게시 → tistoryUrl 기록)처음부터 끝까지 — 실제 발행 테스트 (단계별)
여기까지 전부 설정하셨다면, 이제 진짜 글 하나를 끝까지 발행해보면서 각 구간이 정확히 어디서 성공/실패하는지 직접 확인합니다.
사전 점검 — 시작 전 5가지
- ✓STEP 15의 워크플로우 Active 토글이 켜져 있는가
- ✓STEP 15 placeholder 9곳 전부 실제 값으로 교체했는가
- ✓STEP 14 Rank Math functions.php가 적용되어 사이트가 정상 로딩되는가
- ✓STEP 14 WordPress 카테고리(부모/자식)가 슬러그까지 정확히 생성되어 있는가
- ✓ComfyUI(8188)가 평소처럼 정상 응답하는가 —
curl http://192.168.0.100:8188/system_stats
① 테스트 글 작성 (의도적으로 알아보기 쉬운 제목으로)
Strapi(1337) 또는 Obsidian(STEP 12)으로 글 작성
title 예: “파이프라인 테스트 글 — 삭제 예정”
category/subCategory를 STEP 07 슬러그 중 하나로 정확히 입력
예: home-lab / devops
6개 메타 섹션(imageMeta·seoMeta·socialMeta) 전부 채우기
비어있는 채로 두면 그 항목만 빈 값으로 발행되어 어디가 비었는지 헷갈림
Publish 클릭
② n8n Executions에서 실시간 추적
Publish 누른 직후 n8n 좌측 메뉴 Executions 클릭
몇 초 안에 새 실행 항목이 나타나야 함 — 안 나타나면 STEP 15 Webhook URL/Secret부터 재확인
실행 항목 클릭 → 19개 노드가 순서대로 초록색으로 바뀌는지 관찰
이미지 생성 구간(5~9번)에서 가장 오래 걸림 — 정상입니다, 보통 10~30초
③ 구간별 확인 포인트 — 어디서 멈췄는지 바로 판단하는 법
| 몇 번 노드에서 멈췄나 | 이 시점까지는 성공한 것 | 다음에 확인할 곳 |
|---|---|---|
| 3번(시크릿 검증)에서 멈춤 | Webhook 수신 자체는 성공 | STEP 15의 X-Webhook-Secret 값 재대조 |
| 5~9번(ComfyUI)에서 멈춤 | n8n→ComfyUI 연결까지 정상 | ComfyUI 워크플로우 JSON 안의 체크포인트 모델명이 실제 설치된 것과 일치하는지 |
| 10~11번(Strapi 업로드)에서 멈춤 | 이미지 생성까지 정상 완료 | Strapi API 토큰, STEP 15 ③ 권장 방법 재확인 |
| 12번(WP 카테고리 조회)에서 빈 배열 반환 | WP 인증까지는 정상 | STEP 14에서 만든 카테고리 슬러그와 글의 subCategory 값이 정확히 일치하는지 |
| 14~16번(WP 발행)에서 멈춤 | 이미지·카테고리까지 전부 정상 | Application Password 공백 포함 정확성, 도메인 주소 오타 |
| 18~19번(페이스북)에서 멈춤 | WordPress 발행까지 전부 성공 | 페이지 액세스 토큰 만료(60일) 여부 |
④ 최종 결과물 — 5곳 전부 확인
- ✓agibop.com에 글이 실제로 publish 상태로 보이는가
- ✓대표 이미지가 ComfyUI로 생성된 그림으로 채워져 있고, 이미지 클릭 시 alt 텍스트가 STEP07 imageMeta 값과 일치하는가 (브라우저 개발자도구 → Elements에서
alt=확인) - ✓Rank Math 플러그인 패널에 포커스 키워드·설명이 채워져 있는가 (워드프레스 글 편집 화면에서 직접 확인)
- ✓페이스북 페이지에 글이 올라오고, 그 글에 댓글(워드프레스 링크)이 달려있는가
- ✓Strapi(1337)로 돌아가서 해당 글의
wordpressPostId가 빈 값이 아니라 실제 숫자로 채워졌는가
이제 테스트 글은 WordPress·Strapi 양쪽에서 삭제하시고, 진짜 글부터는 이 STEP 21 과정 없이 그냥 Publish만 누르면 됩니다. 문제가 생기면 그때 다시 이 STEP의 표로 어느 구간인지 빠르게 짚어보시면 됩니다.
트러블슈팅 & 최종 운영 체크리스트
| 증상 | 원인 | 해결 |
|---|---|---|
Cannot find module 'pg' | quickstart로 생성되어 SQLite 드라이버만 포함 | STEP 03 — pg 직접 설치 + Dockerfile 보강 |
| esbuild 바이너리 실행 오류 | 호스트 node_modules가 Alpine 컨테이너로 유입 | STEP 03 — 컨테이너 안에서 스캐폴딩 + Debian 이미지 |
| Content-Type Builder 메뉴 없음 | NODE_ENV=production 의도적 제한 | STEP 06 — tmux + strapi-dev-mode.sh로 1338 사용 |
| 본문 HTML이 깨져서 발행됨 | content가 Rich text로 설정됨 | STEP 07 — Text(Long text)로 변경 |
| CORS 에러로 n8n 호출 실패 | middlewares.ts 수정 후 재배포 누락 | STEP 10 — build && up -d 까지 반드시 실행 |
| Rank Math 패널이 항상 비어있음 | functions.php 등록 누락 | STEP 14 — register_post_meta 코드 추가 |
| 글이 미분류로 발행됨 | WP 카테고리 슬러그 불일치 | STEP 14 — 슬러그를 표와 정확히 동일하게 |
| strapi-dev-temp가 갑자기 사라짐 | SSH 연결 끊김 → –rm으로 자동 삭제 | STEP 06 — 항상 tmux 안에서 실행 |
| 워크플로우 Execution에서 특정 노드가 빨간색(에러) | placeholder(YOUR_…)를 안 바꾼 채로 Active함 | STEP 16의 체크리스트 표로 9곳 전부 교체했는지 재확인 |
| 발행된 글에 태그가 하나도 안 들어감 | 22번 노드의 “Never Error” 옵션 누락 — 이미 존재하는 태그를 재사용할 때마다 400 에러 본문이 제대로 전달되지 않아 ID를 못 찾음 | STEP 18 ⑤ — 22번 노드 Options → Response → Never Error를 ON으로 설정 |
| 워드프레스 카테고리 목록에서 부모만 비어있음 | 발행 요청에 자식 카테고리 ID만 보내고 부모는 안 보냄 | STEP 18 ③⑦ — 20번 노드(부모 조회) 추가 후 categories 배열에 부모+자식 둘 다 포함 |
| 페이스북 댓글 작성 실패 | 페이지 토큰 만료(60일) | STEP 19 — 장기 토큰 재발급, 자동 갱신 구성 |
최종 체크리스트
- ✓스키마 작업은 항상 tmux + 1338에서, production(1337)에서는 시도하지 않기
- ✓content 필드는 Long text — Rich text 아님
- ✓WP 카테고리 슬러그가 STEP 07 표와 정확히 일치
- ✓.env 5개 보안 키 모두 기본값에서 변경
- ✓Rank Math functions.php 등록 완료
- ✓Webhook Response Mode = Immediately respond
- !strapi-pg-db 정기 백업 (DB 덤프 + uploads 폴더)
- !페이스북 페이지 토큰 60일 갱신 알림 구성
- in8n 워크플로우는 별도 JSON 파일로 제공 — STEP 16의 9곳 placeholder 체크리스트 표대로 교체 후 Active
📋 최종 요약: 설치 → tmux로 안전하게 스키마 설계(Production 모드 함정 + HTML 보존을 위한 content 타입까지) → WordPress 카테고리/Rank Math 사전 준비 → Webhook → ComfyUI 이미지 생성 → 슬러그 기반 카테고리 자동 매핑 → WordPress 발행 → 페이스북 게시·댓글까지, 처음부터 끝까지 빈틈없이 이어지는 파이프라인을 완성했습니다.



