에디터에서 쓴 글을 벨로그에 붙여넣습니다. 발행하고 나서 오타를 발견합니다. 로컬 파일을 고치고 다시 붙여넣습니다.
이걸 몇 번 반복하면 임시저장 목록에 비슷한 글이 세 개쯤 쌓입니다. 어느 게 최신인지도 헷갈리기 시작합니다.
그래서 로컬 마크다운 파일을 그대로 올리고, 같은 파일을 다시 올리면 새 글이 아니라 그 글이 수정되는 도구를 만들었습니다. Cursor나 Claude Desktop에서 "이 문서 벨로그에 올려줘"라고 말하면 되는 MCP 서버입니다.
만들면서 부딪힌 것들을 정리했습니다. 비밀번호 로그인이 없는 인증, 짧은 토큰 수명, 그리고 에러 없이 null만 돌려주는 조용한 실패 두 가지입니다. 마지막 것에 가장 오래 붙잡혔습니다.
벨로그에는 공개된 발행 API가 없습니다. RSS는 있지만 읽기 전용입니다.
2021년에 "마크다운 문서를 API로 퍼블리싱하고 싶다"는 요청 이슈가 올라왔지만, 5년 가까이 답이 달리지 않았습니다.
그래서 남는 방법은 웹 에디터에 붙여넣기뿐입니다. 이게 왜 불편한지는 한 문장으로 요약됩니다. 원본이 로컬 파일인데 벨로그가 원본 행세를 하기 시작합니다.
글을 고칠 때마다 로컬과 벨로그 중 어느 쪽이 최신인지 확인해야 합니다. 확인이 귀찮으면 그냥 새로 붙여넣게 되고, 그러면 중복 글이 쌓입니다.
목표는 두 줄로 정리됐습니다. 로컬 파일이 유일한 원본일 것. 그리고 몇 번을 다시 실행해도 결과가 같을 것.
첫 번째 벽은 인증이었습니다. 벨로그는 이메일 매직링크와 소셜 OAuth만 지원하고 비밀번호 로그인 자체가 없습니다. 아이디와 비밀번호를 받아 자동으로 로그인할 방법이 없다는 뜻입니다.
그래서 자동화를 포기하는 대신 한 번만 사람에게 맡기기로 했습니다. Playwright로 브라우저를 띄우고, 사용자가 평소처럼 로그인하고, 쿠키에서 토큰을 꺼냅니다.
tokens = {
cookie["name"]: cookie["value"]
for cookie in await context.cookies()
if cookie["name"] in ("access_token", "refresh_token") and cookie.get("value")
}
if tokens.get("access_token"):
return tokens
핵심은 launch_persistent_context로 브라우저 프로필을 디렉터리에 남기는 것입니다. 두 번째부터는 이미 로그인된 상태로 열리니, 창을 띄우지 않고 토큰만 새로 받아올 수 있습니다.
access_token은 수명이 짧습니다. 그런데 벨로그는 만료된 access_token과 유효한 refresh_token을 함께 보내면 응답의 Set-Cookie로 새 access_token을 내려줍니다.
이걸 흘려버리면 매번 만료된 토큰으로 요청하게 됩니다. 그래서 모든 응답에서 쿠키를 훑어 갱신분을 붙잡아 둡니다.
def _absorb_rotated_tokens(self, response):
rotated = {}
for raw in response.headers.get_list("set-cookie"):
jar = SimpleCookie()
jar.load(raw)
for name in ("access_token", "refresh_token"):
morsel = jar.get(name)
if morsel is not None and morsel.value:
rotated[name] = morsel.value
# ... 바뀐 게 있을 때만 메모리와 파일을 갱신한다
save_tokens(access_token=access, refresh_token=refresh)
토큰은 홈 디렉터리의 JSON 파일에 권한 0600으로 저장합니다. 임시 파일에 쓴 뒤 교체해서, 쓰는 도중에 프로세스가 죽어도 반쪽짜리 JSON이 남지 않게 했습니다.
덕분에 처음 한 번만 브라우저를 띄우면 그다음부터는 신경 쓸 일이 없습니다.
솔직히 적어두면, 흡수한 새 토큰으로 그 요청을 재시도하지는 않습니다. 다음 요청부터 새 토큰이 나갑니다. 재시도 로직은 복잡해지는 데 비해 실익이 적어서 그냥 두었습니다.
이 도구의 핵심입니다. 방법은 단순합니다. 발행 결과를 파일에 다시 적어 넣습니다.
처음 올릴 때 프런트매터는 이렇게 생겼습니다.
---
title: 트랜잭션 경계를 다시 그은 이유
tags: [postgresql, transaction]
slug: transaction-boundary
draft: true
---
발행이 끝나면 도구가 두 줄을 추가합니다.
velog_post_id: 00000000-0000-0000-0000-000000000000
velog_url: https://velog.io/@your-id/transaction-boundary
다음부터는 이 velog_post_id가 분기 조건이 됩니다.
마크다운 파일
|
+--> 프런트매터 파싱 ............... title · tags · slug
|
+--> velog_post_id 가 있나?
|
+-- 없다 --> writePost ........ 새 글 발행
| |
| +--> 파일에 id 기록
|
+-- 있다 --> editPost ......... 그 글 수정
파일을 고치고 같은 말을 반복하면 됩니다. 실제로 같은 파일을 세 번 올려봤습니다.

첫 실행만 create이고 그다음부터는 update입니다. post_id가 세 번 다 같습니다.
작은 함정이 하나 있었습니다. 벨로그는 제목을 본문과 별도로 렌더링합니다. 그래서 본문 맨 위에 # 제목을 남겨두면 같은 제목이 두 번 보입니다.
제목은 프런트매터 title → 본문 첫 H1 → 파일명 순으로 정하고, H1에서 가져왔을 때는 본문에서 그 줄을 지웁니다.
match = _H1_PATTERN.search(body)
if not match:
return fallback, body
title = match.group("title").strip()
stripped = (body[: match.start()] + body[match.end():]).strip()
return title, stripped
가장 오래 붙잡힌 부분입니다. 발행이 실패하는데 GraphQL errors는 비어 있고 data.writePost만 null 이었습니다.
처음에는 토큰 만료로 단정했습니다. 에러 메시지도 "토큰이 만료됐을 가능성이 큽니다. 다시 로그인하세요"라고 써두었습니다. 그런데 다시 로그인해도 똑같았습니다.
이 단정이 문제를 한참 늘렸습니다. 원인이 인증이 아니었는데, 메시지가 인증 쪽만 계속 파게 만들었습니다.
로그인된 브라우저로 벨로그 웹 에디터가 보내는 요청을 캡처해서, 제 요청과 나란히 놓고 비교했습니다. 그리고 변수를 하나씩 바꿔가며 확인했습니다.

빈 객체 하나였습니다. 인자를 빼거나 null로 보내면 검증 에러조차 나지 않고 조용히 null이 돌아옵니다.
나중에 알게 됐는데, 이건 확인할 방법이 있었습니다. 벨로그는 MIT 오픈소스입니다.
스키마 파일을 보면 이 필드가 meta: JSON!, 즉 널을 허용하지 않는 필수 인자로 선언돼 있습니다. 실측 결과와 정확히 맞아떨어집니다.
두 번째 원인이 더 고약했습니다. 쓰기 뮤테이션 응답에서 short_description 필드를 요청하면 벨로그 쪽 리졸버가 터집니다.
{
"errors": [{ "message": "Cannot read properties of undefined (reading 'replace')",
"path": ["editPost", "short_description"] }],
"data": { "editPost": { "id": "...", "url_slug": "..." } }
}
errors와 data가 함께 들어 있습니다. 글은 실제로 저장됐는데 응답 한 조각만 깨진 부분 실패입니다.
그런데 제 클라이언트는 errors가 있으면 무조건 예외를 던지고 있었습니다. 그래서 이런 일이 벌어졌습니다.
editPost 호출 --> 글은 저장됨
|
+--> errors 있음 --> 예외 --> id 를 못 받음
|
+--> 파일에 id 기록 실패
|
+--> 다음 실행이 또 새 글
중복 글을 막으려고 만든 도구가 정확히 그 지점에서 중복 글을 만들고 있었습니다.
두 겹으로 막았습니다. 첫째, 쓰기 응답에서는 이 필드를 아예 요청하지 않습니다. 둘째, errors가 있어도 data에 결과가 남아 있으면 경고만 남기고 결과를 씁니다.
partial = body.get("data") or {}
if any(value for value in partial.values()):
logger.warning("%s: 일부 필드에서 오류가 있었지만 결과는 받았습니다 — %s", operation, message)
return partial
raise VelogError(f"{operation}: {message}")
에러 메시지도 함께 고쳤습니다. 원인을 단정하는 대신 후보를 나열합니다.
벨로그가 에러 없이 빈 결과를 돌려줬습니다.
가능한 원인은 토큰 만료, 필수 인자 누락, 벨로그의 일시적 거부입니다.
이 글도 이 도구로 올렸습니다. 프런트매터를 달고 "이 문서 벨로그에 임시저장으로 올려줘"라고 말했습니다. 오타를 고칠 때는 파일을 수정하고 같은 말을 다시 했습니다.
여러 번 올린 뒤의 임시 글 목록입니다.

하나뿐입니다. 예전 같으면 여기에 비슷한 글이 여러 개 쌓여 있었을 겁니다.
지금 규모는 서버 코드 1,500줄, MCP 도구 10개, 점검·검증용 보조 스크립트 6개입니다. 발행에 걸리는 시간이 얼마나 줄었는지는 재보지 않았습니다. 붙여넣던 시절을 측정해둔 적이 없어서 비교할 수가 없습니다.
안 되는 것도 분명합니다.
이미지는 업로드하지 않습니다. 본문 이미지는 이미 접근 가능한 URL이어야 합니다. 스크린샷을 자동으로 올려주지는 않습니다.
부분 수정이 안 됩니다. 한 문장만 고쳐도 본문 전체를 보냅니다. 파일 단위로 올리면 티가 나지 않지만, 도구를 직접 호출할 때는 걸립니다.
그리고 무엇보다 공식 API가 아닙니다. 웹 클라이언트가 쓰는 GraphQL을 그대로 부르기 때문에, 벨로그가 스키마를 바꾸면 깨집니다. 이 글에 적은 meta나 short_description 이야기도 언제든 유효하지 않게 될 수 있습니다.
두 가지를 배웠습니다. 조용한 실패에 원인을 단정하는 에러 메시지를 쓰면 진단이 엉뚱한 곳으로 끌려갑니다. "토큰 만료"라고 써둔 한 줄이 실제로 한참을 잡아먹었습니다.
그리고 부분 실패를 일괄 예외로 처리하면 멱등성이 조용히 깨집니다. 글은 만들어졌는데 id를 잃어버리는 경로가 하나라도 있으면, 재실행이 안전하다는 전제가 무너집니다.
남은 한계는 위에 적은 그대로입니다. 특히 비공식 API에 얹혀 있다는 점은 해결할 방법이 없어서, 깨졌을 때 어느 파일을 먼저 봐야 하는지만 문서에 남겨두었습니다.
맨 위에 링크한 저장소는 MIT로 공개해뒀습니다. 벨로그와는 무관한 개인 프로젝트입니다.