워드프레스로 사이트를 운영하다 보면 어느 순간 “이걸 매번 관리자 화면에서 손으로 붙여야 하나” 싶은 순간이 옵니다. 외부에서 수집한 데이터를 정리해 매일 한 편씩 올려야 하거나, 로컬에서 쓴 마크다운 파일을 그대로 발행하고 싶거나, 사이트가 세 개인데 같은 공지를 세 번 복사·붙여넣기 해야 하는 상황입니다. 이때 쓰는 게 워드프레스에 기본으로 들어 있는 REST API입니다.
REST API는 거창한 게 아니고, 관리자 화면에서 마우스로 하던 일을 HTTP 요청으로 대신하는 통로입니다. 글 목록 가져오기, 글 쓰기, 이미지 올리기, 카테고리 조회 같은 동작이 각각 URL로 매핑돼 있습니다. 예전에는 이런 걸 xmlrpc.php로 했는데, 지금은 REST API가 기본이고 보안상 xmlrpc는 아예 막아 두는 곳도 많습니다. 새로 만든다면 REST API 쪽만 보면 됩니다.
한눈에 ·
/wp-json열리는지 확인 → 애플리케이션 비밀번호 발급 → curl로 draft 글 한 번 성공 → 파이썬 스크립트로 옮기고 카테고리·이미지 붙이기 → 401/403/404 오류 잡고 cron으로 정기 실행
워드프레스 REST API가 뭐고, 왜 쓰게 되나
플러그인을 깔 필요는 없습니다. 워드프레스 4.7 이후로는 기본 탑재라서, 이미 켜져 있을 확률이 높습니다. 확인은 브라우저 주소창에서 바로 됩니다.
https://사이트주소/wp-json/wp/v2/posts
이 주소를 열면 최근 글들이 JSON 덩어리로 쏟아집니다. 줄바꿈 없는 긴 텍스트가 보이면 정상입니다. 파이어폭스나 크롬은 JSON 뷰어로 예쁘게 보여주기도 합니다. 이 화면이 보였다면 읽기 쪽 API는 이미 동작하는 상태입니다.
여기서 오해하기 쉬운 게 있습니다. 글 목록이 인증 없이 보인다고 해서 글쓰기도 그냥 될 거라고 생각하면 안 됩니다. 읽기는 익명으로 열려 있지만 쓰기는 예외 없이 인증이 필요합니다. 그 인증을 어떻게 통과시키느냐가 이 작업의 절반입니다.
시작 전에 확인할 세 가지
/wp-json이 열리는 걸 봤다면, 다음은 쓰기 권한을 어떻게 얻느냐입니다. 그 전에 환경 쪽에서 미리 걸릴 만한 것 세 개를 먼저 털고 갑니다. 이걸 안 보고 진행하면 나중에 401이 떴을 때 원인을 못 찾습니다.
/wp-json 접속 테스트
https://사이트주소/wp-json/ 만 열어도 사이트 이름과 사용 가능한 경로 목록이 나옵니다. 여기서 확인할 건 두 가지입니다. 첫째, 주소가 https로 시작하는지. 애플리케이션 비밀번호는 평문 HTTP에서 기본적으로 막힙니다. 로컬 개발 환경(localhost)은 예외로 허용되지만, 실서버가 HTTP면 인증 자체가 시작되지 않습니다.
둘째, JSON이 아니라 로그인 페이지나 “REST API가 비활성화되었습니다” 같은 화면이 나오는 경우입니다. 보안 플러그인(Wordfence, iThemes Security 등)이나 테마 함수에서 REST를 잠가 둔 상태입니다. 이건 나중에 오류 섹션에서 다시 다룹니다.
퍼머링크와 REST 경로
설정 > 고유주소가 ‘기본'(?p=123 형태)으로 되어 있으면 /wp-json/... 경로가 404로 떨어질 수 있습니다. 이때는 예쁜 주소 대신 쿼리 파라미터 형태를 쓰면 됩니다.
https://사이트주소/?rest_route=/wp/v2/posts
두 주소는 같은 엔드포인트를 가리킵니다. 가능하면 퍼머링크를 ‘글 이름’ 같은 형태로 바꿔서 /wp-json 경로를 쓰는 게 편하고, 사정이 있어 못 바꾸면 스크립트에서 ?rest_route= 형태를 기본으로 잡아 두면 됩니다.
계정 권한 확인
글을 올릴 계정 권한도 봐야 합니다. 기준은 관리자 화면과 똑같습니다.
| 하려는 일 | 필요한 권한 |
|---|---|
| 초안(draft) 저장 | 작성자(Author) 이상 |
| 바로 발행(publish) | 작성자 이상 |
| 남의 글 수정 | 편집자(Editor) 이상 |
| 카테고리·태그 신규 생성 | 편집자 이상 |
| 이미지 업로드 | 작성자 이상 |
기여자(Contributor) 계정으로는 미디어 업로드가 안 됩니다. 자동화 전용 계정을 새로 만든다면 작성자나 편집자로 두는 편이 무난합니다.
애플리케이션 비밀번호 발급받기
환경 점검이 끝났으면 이제 인증 수단을 만듭니다. 여기서 절대 하지 말아야 할 게 로그인 비밀번호를 스크립트에 그대로 박아 두는 것입니다. 워드프레스는 이런 용도로 쓰는 별도 비밀번호를 지원합니다. 애플리케이션 비밀번호라고 부르고, 5.6부터 코어에 들어왔습니다.
발급 위치는 관리자 화면 사용자 > 프로필 페이지 맨 아래입니다. “애플리케이션 비밀번호” 항목에 이름을 아무렇게나 적고(예: auto-post-script) 새 애플리케이션 비밀번호 추가를 누르면 됩니다. 그러면 이런 형태의 문자열이 한 번 표시됩니다.
abcd EFGH 1234 ijkl MNOP 5678
네 글자씩 공백으로 끊긴 형태로 나옵니다. 이 값은 창을 닫거나 페이지를 새로 고치면 다시 볼 수 없습니다. 지금 복사해서 안전한 곳에 붙여 두세요. 공백은 그대로 둬도 되고 지워도 됩니다. 워드프레스가 검증할 때 공백을 제거하고 비교하기 때문에 둘 다 통과합니다. 다만 curl 명령에 넣을 때 공백이 있으면 따옴표로 감싸야 합니다.
이 항목이 아예 안 보이는 경우가 있습니다. 흔한 순서대로 보면 이렇습니다.
- 사이트가 HTTPS가 아니다 → SSL 먼저 붙여야 합니다
- 보안 플러그인이 REST API 또는 애플리케이션 비밀번호를 끔 → 플러그인 설정에서 예외 처리
- 테마나 플러그인이
wp_is_application_passwords_available필터로 비활성화 → 해당 코드 제거
발급받은 값은 소스코드에 직접 적지 말고 환경변수나 별도 설정 파일로 빼세요. 실수로 git에 올라가면 그 순간 사이트 쓰기 권한이 공개됩니다. 파일로 둔다면 나중에 권한을 600으로 조정하는 것까지 잊지 말아야 합니다.
curl로 글 하나 올려보기
비밀번호를 받았으면 파이썬으로 넘어가기 전에 curl로 딱 한 번 성공시켜 보는 게 좋습니다. 스크립트에서 안 될 때 “인증 문제인지 코드 문제인지” 구분이 안 되면 시간을 크게 낭비합니다. curl로 한 번 통해 두면 그 이후 실패는 코드 문제로 좁혀집니다.
최소 요청 만들기
필수 필드는 사실상 title, content, status 세 개입니다. 처음 테스트는 반드시 draft로 두세요. 잘못 발행되면 RSS나 검색엔진에 노출될 수 있습니다.
curl -i -u "myuser:abcd EFGH 1234 ijkl MNOP 5678" \
-H "Content-Type: application/json" \
-X POST https://사이트주소/wp-json/wp/v2/posts \
-d '{"title":"API 테스트 글","content":"본문입니다.","status":"draft"}'
-u는 HTTP Basic 인증 헤더를 만들어 줍니다. 아이디는 워드프레스 로그인 아이디이고(이메일이 아니라 사용자명입니다), 비밀번호 자리에 방금 발급받은 애플리케이션 비밀번호를 넣습니다. -i는 응답 헤더까지 같이 출력해 상태 코드를 눈으로 보게 해 줍니다. 이 습관을 들이면 디버깅이 훨씬 빨라집니다.

응답에서 확인할 것
성공하면 첫 줄에 HTTP/2 201 또는 HTTP/1.1 201 Created가 찍힙니다. 200이 아니라 201이 정상입니다. 그 뒤로 생성된 글의 전체 JSON이 돌아옵니다. 여기서 볼 필드는 두 개입니다.
id: 글 번호. 나중에 수정하거나 대표 이미지를 붙일 때 씁니다link: 실제 URL. draft면 미리보기 주소 형태로 나옵니다
status에 넣을 수 있는 값은 다음과 같습니다. 자동화에서 실제로 쓰는 건 네 개 정도입니다.
| status | 동작 | 쓰는 상황 |
|---|---|---|
draft | 초안 저장, 공개 안 됨 | 테스트, 사람이 검수 후 발행 |
publish | 즉시 발행 | 검증이 끝난 파이프라인 |
future | 지정 시각에 자동 발행 | date 필드와 함께 예약 |
private | 발행되지만 로그인한 관리자만 열람 | 내부 문서, 비공개 기록 |
파이썬 스크립트로 반복 가능하게 만들기
curl로 201을 받았다면 같은 요청을 코드로 옮깁니다. 여기서부터가 실제 자동화입니다. 파이썬 requests를 쓰면 위 curl과 거의 1:1로 대응됩니다.
기본 발행 스크립트
인증 정보는 코드에 넣지 않고 환경변수에서 읽습니다.
import os
import requests
from requests.auth import HTTPBasicAuth
SITE = "https://사이트주소"
API = f"{SITE}/wp-json/wp/v2"
AUTH = HTTPBasicAuth(os.environ["WP_USER"], os.environ["WP_APP_PASSWORD"])
def create_post(title, content, status="draft", **extra):
payload = {"title": title, "content": content, "status": status}
payload.update(extra)
r = requests.post(f"{API}/posts", json=payload, auth=AUTH, timeout=30)
if r.status_code != 201:
print("실패:", r.status_code, r.text[:500])
r.raise_for_status()
data = r.json()
print("생성됨:", data["id"], data["link"])
return data
if __name__ == "__main__":
create_post("스크립트 테스트", "<p>본문입니다.</p>", status="draft")
json=payload로 넘기면 Content-Type: application/json 헤더가 자동으로 붙습니다. 실행은 이렇게 합니다.
export WP_USER="myuser"
export WP_APP_PASSWORD="abcd EFGH 1234 ijkl MNOP 5678"
python3 post.py
실패했을 때 r.text를 앞부분만 잘라 찍는 게 중요합니다. 워드프레스는 실패 이유를 응답 본문의 code 필드에 정확히 적어 주는데, 이걸 안 찍으면 원인 추적이 불가능합니다.
카테고리와 태그 ID 처리
여기서 처음 쓰는 사람이 거의 다 걸립니다. categories와 tags는 이름 문자열이 아니라 ID 숫자 배열이어야 합니다. 이렇게 넣어야 합니다.
payload = {
"title": "제목",
"content": "본문",
"status": "draft",
"categories": [3], # 이름이 아니라 ID
"tags": [12, 45],
}
"categories": ["워드프레스"] 처럼 이름을 넣으면 rest_invalid_param 400이 돌아옵니다. ID를 모를 땐 이름으로 조회해서 찾고, 없으면 만드는 함수를 하나 두면 편합니다.
def get_term_id(taxonomy, name):
# taxonomy: "categories" 또는 "tags"
r = requests.get(f"{API}/{taxonomy}", params={"search": name, "per_page": 100},
auth=AUTH, timeout=30)
r.raise_for_status()
for term in r.json():
if term["name"] == name:
return term["id"]
# 없으면 생성 (편집자 이상 권한 필요)
r = requests.post(f"{API}/{taxonomy}", json={"name": name}, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()["id"]
search는 부분 일치로 동작하기 때문에 결과를 그대로 쓰지 말고 이름이 정확히 같은 항목만 골라야 합니다. 이걸 빼먹으면 “리눅스”를 찾다가 “리눅스 서버” 카테고리에 글이 들어갑니다.
이미지 업로드와 대표 이미지 지정
대표 이미지는 글 생성 요청 한 번으로 끝나지 않습니다. 미디어를 먼저 올려서 첨부 파일 ID를 받고, 그 ID를 featured_media에 넣는 2단계 구조입니다.

미디어 업로드는 JSON이 아니라 파일 바이너리를 그대로 보냅니다. 파일명을 Content-Disposition 헤더로 알려 줘야 합니다.
import mimetypes
def upload_media(path):
filename = os.path.basename(path)
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
with open(path, "rb") as f:
r = requests.post(
f"{API}/media",
data=f.read(),
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": ctype,
},
auth=AUTH, timeout=60,
)
r.raise_for_status()
return r.json()["id"]
media_id = upload_media("./thumb.jpg")
create_post("이미지 있는 글", "<p>본문</p>", status="draft",
featured_media=media_id, categories=[3])
한글 파일명은 서버 설정에 따라 깨지거나 거부될 수 있습니다. 업로드 전에 영문·숫자·하이픈으로 파일명을 바꿔 두는 편이 안전합니다.
예약 발행과 중복 방지
예약 발행은 status를 future로 두고 date 필드에 시각을 ISO 8601로 넣습니다. date는 사이트 설정 시간대 기준이고, date_gmt는 UTC 기준입니다. 둘을 섞어 쓰면 시간이 어긋나니 하나만 씁니다.
create_post(
"내일 아침에 나갈 글", "<p>본문</p>",
status="future",
date="2026-09-20T09:00:00",
)
자동 스크립트를 cron에 걸면 같은 글이 두 번 올라가는 사고가 반드시 한 번은 납니다. slug로 먼저 조회해서 있으면 수정, 없으면 생성하는 방식으로 막습니다.
def upsert_post(slug, title, content, **extra):
r = requests.get(f"{API}/posts", auth=AUTH, timeout=30,
params={"slug": slug, "status": "draft,publish,future,private"})
r.raise_for_status()
found = r.json()
payload = {"title": title, "content": content, "slug": slug, **extra}
if found:
post_id = found[0]["id"]
r = requests.post(f"{API}/posts/{post_id}", json=payload, auth=AUTH, timeout=30)
print("수정:", post_id)
else:
r = requests.post(f"{API}/posts", json=payload, auth=AUTH, timeout=30)
print("생성:", r.json().get("id"))
r.raise_for_status()
return r.json()
수정은 PUT도 되지만 워드프레스는 POST도 같이 받아 줍니다. 방화벽이 PUT을 막는 환경이 있어서 POST로 통일해 두는 편이 사고가 적습니다. 참고로 draft 상태의 글을 조회하려면 status 파라미터를 명시해야 합니다. 기본값은 publish만이라 초안은 검색되지 않습니다.
여기서 자주 막힙니다: 오류별 대응
스크립트까지 다 짰는데 응답이 이상하다면 대부분 아래 몇 가지 중 하나입니다. 워드프레스 REST API는 오류 코드를 문자열로 돌려주니, 그 문자열을 기준으로 찾으면 빠릅니다. 가장 많이 보는 게 이 응답입니다.
{"code":"rest_cannot_create","message":"Sorry, you are not allowed to create posts as this user.","data":{"status":401}}
401이면 인증이 아예 서버까지 전달되지 않았다는 뜻입니다. 워드프레스가 요청자를 익명 사용자로 인식한 상태입니다. 아이디·비밀번호 오타가 첫 번째 용의자이고, 그다음이 Authorization 헤더가 중간에서 잘리는 문제입니다.
| 오류 코드 | 상태 | 흔한 원인 | 확인할 곳 |
|---|---|---|---|
rest_cannot_create | 401 | 인증 미전달, 앱 비밀번호 오타, Authorization 헤더 유실 | 사용자명(이메일 아님), .htaccess |
rest_forbidden | 403 | 인증은 됐지만 권한 부족, 보안 플러그인 차단 | 계정 역할, 방화벽 플러그인 로그 |
rest_no_route | 404 | 경로 오타, 퍼머링크 ‘기본’, REST 비활성화 | ?rest_route= 로 재시도 |
rest_invalid_param | 400 | categories에 이름 문자열, date 형식 오류 | 요청 payload |
rest_cannot_edit | 403 | 남의 글 수정 시도 | 계정을 편집자로 승격 |
rest_invalid_param은 응답의 data.params에 어느 필드가 문제인지 적혀 있습니다. 거기부터 보면 됩니다.
응답이 JSON이 아니라 HTML일 때
r.json()에서 JSONDecodeError가 나거나, curl 결과가 <!DOCTYPE html>로 시작하면 요청이 워드프레스에 도달하지도 못한 겁니다. 앞단에서 누가 잘라낸 것입니다. 후보는 세 개입니다.
- Cloudflare나 웹방화벽(WAF)이 POST 요청을 차단 → 차단 로그에서 해당 시각 확인
- 호스팅사 자체 보안 룰이
/wp-jsonPOST를 막음 → 문의해서 예외 요청 - 보안 플러그인의 “REST API 비로그인 차단” 옵션 → 애플리케이션 비밀번호 예외 추가
-i 옵션으로 헤더를 보면 어디서 막혔는지 단서가 나옵니다. server: cloudflare와 함께 403이면 Cloudflare, X-Powered-By만 있고 워드프레스 오류 페이지면 플러그인 쪽입니다.
Authorization 헤더가 사라질 때
Apache + CGI/FastCGI 조합에서 자주 나는 문제입니다. Apache가 Authorization 헤더를 PHP로 넘기지 않아서, 워드프레스가 보기엔 아무 인증도 안 온 것처럼 보입니다. curl에서 -u를 제대로 넣었는데도 계속 rest_cannot_create 401이면 이걸 의심하세요.
워드프레스 설치 폴더의 .htaccess 맨 위에 다음 줄을 넣습니다.
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
RewriteEngine On 앞이든 뒤든 상관없지만, 워드프레스가 자동 관리하는 # BEGIN WordPress 블록 밖에 두세요. 안에 넣으면 퍼머링크 재저장 시 지워집니다. mod_rewrite를 쓴다면 이 형태도 같은 역할을 합니다.
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
Nginx + PHP-FPM 환경에서는 보통 문제가 없습니다. 저장 후 다시 curl을 날려서 201이 오는지 확인하면 됩니다.
자동 실행으로 붙이기 전에 정리할 것
스크립트가 손으로 잘 돌아가면 마지막은 정기 실행과 안전장치입니다. 여기를 건너뛰면 새벽에 조용히 실패하고 아무도 모르는 상태가 됩니다. 다음 순서로 정리하세요.
- 최소 며칠은
status="draft"로 운영하면서 결과물을 눈으로 검수한 뒤publish로 바꾸세요. 제목 깨짐, 카테고리 오배치, HTML 태그 이스케이프 문제는 실제 글을 봐야 보입니다 - 인증 정보를 파일로 관리한다면 권한을 좁힙니다:
chmod 600 ~/.wp_credentials. 권한 숫자가 왜 이렇게 되는지 헷갈리면 chmod 755와 644의 실제 차이, 왜 폴더는 755여야 하나를 같이 보세요 - 실행 시각, HTTP 상태 코드, 생성된 글 ID를 로그 파일에 남깁니다. 실패 시 응답 본문 앞 500자까지 함께 기록하면 원인 추적이 됩니다
- 네트워크 오류(
Timeout,ConnectionError)만 2~3회 재시도하고, 400·403 같은 논리 오류는 재시도하지 말고 즉시 중단합니다. 재시도하면 중복 글만 늘어납니다 - cron에 등록할 때는 절대 경로와 환경변수 로딩을 꼭 확인하세요. 손으로는 되는데 cron에서만 안 도는 전형적인 이유들은 crontab 작성법 정리, 등록해도 안 돌아가는 이유까지에 정리해 뒀습니다
- REST API를 외부에 열어 두는 게 부담스러우면, 스크립트를 서버 안에서 돌리고
/wp-jsonPOST는 특정 IP만 허용하는 방향도 가능합니다. 다만 플러그인이나 테마가 REST를 쓰는 경우가 있어 전면 차단은 부작용을 확인한 뒤에 하세요
여기까지 하면 “글 하나 올리는 curl 한 줄”이 매일 알아서 도는 파이프라인이 됩니다. 처음부터 전부 만들려고 하지 말고, curl로 draft 하나 성공시키는 것부터 시작하는 게 가장 빠릅니다.
