공식 API 없는 티스토리에 AI가 쓴 글을 매일 자동 발행하기
공식 API 없는 티스토리에 AI가 쓴 글을 매일 자동 발행하기

지난번에는 Blogger에 글을 자동으로 올리는 시스템을 만들었습니다.
이번에는 티스토리로 옮겨봤습니다. 그런데 시작하자마자 벽에 부딪혔습니다. 티스토리에는 더 이상 쓸 수 있는 공식 발행 API가 없었습니다.
Blogger는 Blogger API v3와 Google OAuth로 깔끔하게 글을 올릴 수 있었습니다. 티스토리도 예전에는 Open API가 있었지만, 지금은 신규 앱 등록이 사실상 막혀 있어서 같은 방법을 쓸 수 없었습니다.
그래서 이번 프로젝트의 절반은 “글을 어떻게 쓰느냐”가 아니라 “공식 창구가 없는 서비스에 어떻게 안전하게 글을 올리느냐”에 대한 이야기가 됐습니다.
프로젝트 구성
이번에도 TypeScript로 만들었습니다.
별도 서버를 띄우지 않고, 매일 정해진 시간에 한 번 실행된 뒤 종료되는 배치 방식입니다. 실행은 GitHub Actions의 cron에 맡겼습니다.
사용한 주요 기술은 다음과 같습니다.
- TypeScript
- Node.js
- GitHub Actions
- Google Gemini API
- Playwright
- Zod
- Vitest
글 생성은 Gemini가 담당하고, 발행은 티스토리 관리 화면이 실제로 보내는 요청을 그대로 흉내 내는 방식으로 처리했습니다.
매일 돌아가는 파이프라인
하루 한 번(지금은 두 번) 실행되면 다음 순서로 동작합니다.
- 주제 선정: 최근에 쓴 글과 카테고리 분포를 보고 겹치지 않는 주제를 고릅니다.
- 자료 조사: 선정한 주제에 대해 참고할 내용을 정리합니다.
- 초안 작성: Gemini가 마크다운으로 글을 씁니다.
- AI 검수: 작성한 글을 다시 평가해 기준 점수를 넘지 못하면 한 번 고쳐 씁니다.
- 발행: 검수를 통과한 글만 티스토리에 올립니다.
여기서 중요하게 잡은 원칙이 하나 있습니다. 파이프라인 자체는 어지간해서는 프로세스를 실패시키지 않고, 대신 실행 결과를 하나의 상태값으로 남깁니다. 그리고 마지막에 그 상태값만 보고 성공인지 실패인지 판정하도록 분리했습니다.
덕분에 “글은 생성했지만 발행은 건너뜀”, “세션이 만료돼 발행 못 함” 같은 상황을 각각 다른 상태로 구분할 수 있었습니다.
티스토리에는 공식 발행 API가 없다
공식 API를 못 쓰니 남은 방법은 로그인한 브라우저가 하는 일을 그대로 재현하는 것이었습니다.
카카오 로그인으로 발급된 세션 쿠키를 저장해 두고, 티스토리 관리 화면이 글을 저장할 때 보내는 요청을 똑같이 만들어 보냈습니다.
POST /manage/post.json
X-XSRF-TOKEN: (쿠키의 XSRF 값)
{ "id": "0", "title": "...", "content": "...(HTML)", "category": ..., ... }
관리 화면 구조에 의존하는 방식이라 티스토리가 화면을 바꾸면 깨질 수 있다는 위험은 있었습니다. 그래서 티스토리 내부 요청 URL과 본문 구조는 딱 한 파일 안에만 두고, 다른 코드는 그 세부 구조를 전혀 모르도록 격리했습니다.
가장 많이 마주친 오류, session_expired
이 방식의 가장 큰 약점은 세션이었습니다.
카카오 세션은 시간이 지나면 만료됩니다. 그러면 발행 요청이 로그인 페이지로 튕겨 나가고, 시스템은 이를 session_expired 상태로 기록합니다. GitHub Actions는 그 상태를 보고 빨간불을 띄웁니다.
문제는 이게 코드 버그가 아니라는 점입니다. 정상적으로 만든 시스템인데도, 세션이 만료되는 순간부터는 사람이 다시 로그인해서 쿠키를 갱신해 주기 전까지 매일 실패가 쌓입니다.
CI에서 쿠키를 자동으로 갱신하는 것도 고민했지만, 결국 접었습니다. 카카오 로그인을 무인으로 자동화하려면 데이터센터 IP에서 아이디와 비밀번호를 넣어야 하는데, 이 경우 캡차나 기기 인증에 막히기 쉽고, 계정 자격증명을 CI에 통째로 넣어야 해서 위험 부담이 컸습니다.
그래서 세션 갱신은 사람이 하는 것으로 남겨두되, 만료됐을 때 그 사실만 확실히 알 수 있도록 상태를 분리하는 선에서 타협했습니다.
썸네일이 없어서 아쉬웠다
한동안 돌려보니 글은 잘 올라가는데 목록에서 썸네일이 비어 보이는 게 아쉬웠습니다.
그림을 매번 AI로 그리는 건 비용도 들고 과하다고 느꼈습니다. 대신 글 제목을 큰 글씨로 얹은 네모난 카드 이미지를 만들면 그것만으로도 충분히 썸네일 느낌이 날 것 같았습니다. dev.to 커버 이미지나 소셜 공유용 OG 카드가 쓰는 방식입니다.
그래서 제목과 카테고리로 SVG 카드를 만들고, Playwright로 PNG로 변환하는 방식을 택했습니다. 외부 이미지 생성 API도, 별도 디자인 도구도 필요 없이 코드만으로 카드가 만들어졌습니다.
문제는 다음이었습니다. 만든 이미지를 어떻게 티스토리 대표 썸네일로 등록하느냐. 처음에는 이미지를 외부에 올려 두고 본문 맨 위에 링크로 넣으면 되지 않을까 생각했는데, 비공개 저장소에 둔 이미지는 외부에서 접근이 막혀 방문자에게는 깨진 이미지가 됩니다.
결국 티스토리에 직접 올려야 했습니다. 로그인한 상태에서 에디터에 이미지를 끌어다 놓았을 때 실제로 어떤 요청이 오가는지 개발자 도구로 하나하나 확인했습니다.
업로드 요청은 이랬습니다.
POST /manage/post/attach.json (multipart/form-data, 필드명 file)
응답으로는 업로드된 이미지의 저장 위치와 서명된 URL이 돌아왔습니다.
{ "url": "https://blog.kakaocdn.net/.../img.png?credential=...&signature=...",
"key": "...", "filename": "img.png" }
여기서 끝이 아니었습니다. 이 URL을 본문에 그냥 넣으면 서명이 만료돼 나중에 깨질 수 있었습니다. 티스토리 에디터는 대신 이런 형태의 토큰을 본문에 심고, 발행 시점에 영구 이미지 태그로 치환하고 있었습니다.
[##_Image|kage@{key}/{filename}?{query}|CDM|1.3|{"originWidth":...,"style":"alignCenter",...}_##]
그래서 업로드 응답을 이 토큰 형태로 조립해 본문 맨 위에 넣었습니다.
그런데 대표 이미지로 잡히지 않았다
처음에는 본문 첫 번째 이미지가 자동으로 대표 이미지가 될 거라고 생각했습니다. 티스토리가 대표 이미지를 따로 지정하지 않으면 상단 이미지를 쓴다는 이야기를 봤기 때문입니다.
그런데 실제로는 목록에서 대표 썸네일이 잡히지 않았습니다. 본문 안에는 이미지가 보이는데, 글 목록에서는 여전히 비어 있었습니다.
이번에도 브라우저가 보내는 요청을 다시 들여다봤습니다. 에디터에서 이미지를 대표로 지정하고 저장했더니, 요청 본문에 처음 보는 필드가 하나 붙어 있었습니다.
{ "content": "...", "thumbnail": "kage@{key}/{filename}", ... }
thumbnail이라는 필드였고, 값은 업로드 응답에서 받은 kage@{key}/{filename} 형태였습니다. 흥미로운 점은 본문에 넣는 [##_Image] 토큰과 달리, 여기서는 뒤에 붙는 서명 쿼리스트링을 떼고 순수한 이미지 경로만 넣는다는 것이었습니다.
그래서 발행 요청에 이 thumbnail 필드를 함께 보내 대표 이미지를 명시적으로 지정했더니, 목록에도 썸네일이 나타났습니다.
목록에서 살짝 잘리던 문제
대표 이미지는 잡혔지만, 목록에서 카드 양옆이 살짝 잘려 보였습니다.
처음 만든 카드는 1200×630 크기였습니다. 가로세로 비율이 약 1.9:1이라 조금 넓적한 편이었는데, 티스토리 목록은 이미지를 16:9로 잘라서 보여주고 있었습니다. 그 과정에서 좌우 끝, 특히 제목의 끝부분이 잘렸습니다.
그래서 높이는 그대로 두고 너비만 줄여 1120×630, 정확히 16:9 비율로 맞췄습니다. 이렇게 하니 목록에서 잘리지 않고 제목이 온전히 보였습니다.
참고로 지금 이 글 맨 위에 있는 카드 이미지도, 방금까지 이야기한 그 티스토리 자동 발행 시스템의 썸네일 생성기로 그대로 만든 이미지입니다. 개발 블로그 글의 대표 이미지를, 그 글이 다루는 자동화 도구로 직접 만든 셈입니다.
분명 초록불인데 글이 안 올라갔다
발행 주기를 하루 한 번에서 두 번으로 늘린 뒤 이상한 일이 있었습니다. GitHub Actions는 성공(초록불)인데 블로그에는 새 글이 안 올라오는 상황이었습니다.
로그를 보니 상태가 duplicate_skipped였고, 사유는 “동일 날짜 발행 기록”이었습니다.
원인은 두 가지가 맞물려 있었습니다.
첫째, 중복 발행을 막는 검사에 “오늘 이미 발행한 기록이 있으면 건너뛴다”는 규칙이 있었습니다. 하루 한 번 기준으로는 안전장치였지만, 하루 두 번으로 바꾸는 순간 두 번째 실행은 항상 여기에 걸려 버렸습니다.
둘째, 더 조용한 문제가 있었습니다. 주제 이력을 기록하는 시점이 발행 성공 여부와 상관없이 발행 판정 이전이었습니다. 그래서 세션 만료나 중복으로 실제로는 올라가지 않은 주제까지 “이미 쓴 주제”로 소모되고 있었습니다. 안 쓴 글이 쓴 글로 취급되니, 그 주제는 다시 후보로 돌아오지 않았습니다.
두 곳을 고쳤습니다. 날짜만 같다고 중복으로 보지 않도록 규칙을 바꿔 실제 중복(같은 주제, 같은 슬러그, 같은 내용)만 막게 했고, 주제 이력은 실제로 발행에 성공했을 때만 기록하도록 옮겼습니다.
이렇게 하니 발행에 실패한 주제는 다음 실행에서 다시 시도되고, 하루 두 번 서로 다른 글이 정상적으로 올라갔습니다.
솔직한 한계
솔직히 말씀드리면, AI가 매일 쓴 글이 사람이 정성 들여 쓴 글을 대신할 수 있다고 생각하지는 않습니다.
그래서 글 맨 아래와 썸네일 이미지에 이 글이 AI가 자동으로 작성한 글이라는 고지 문구 및 표기를 항상 넣도록 했습니다. AI가 쓴 글이라는 건 밝히는 게 맞다고 생각해서 넣었습니다.
발행 빈도도 처음에는 6시간마다 하루 네 번을 생각했지만, 주제가 금방 고갈되고 얕은 글이 늘어날 것 같아 하루 두 번으로 줄였습니다. 자동화라고 해서 무조건 많이 찍어내는 게 좋은 것은 아니라는 생각이 들었습니다.
마무리
이번 프로젝트에서 가장 오래 붙잡은 부분은 글을 생성하는 쪽이 아니라, 공식 창구가 없는 서비스에 안전하게 글을 올리는 쪽이었습니다.
공식 API가 없으니 브라우저가 하는 요청을 직접 확인하고, 세션이 만료되면 그 사실을 정확히 드러내고, 썸네일 하나를 올리기 위해 내부 업로드 요청과 토큰 형식까지 따라가야 했습니다.
그 과정에서 “성공했다는 신호”와 “실제로 원하는 일이 일어났다는 것”은 다르다는 걸 다시 느꼈습니다. 초록불이 떠도 글이 안 올라갈 수 있고, 안 올라간 글이 올라간 것처럼 기록될 수도 있었습니다.
자동화를 만들 때는 동작 자체보다, 지금 무슨 일이 일어났는지 나중에 정확히 알 수 있게 상태를 남기는 것이 더 중요하다는 걸 배운 프로젝트였습니다.