블로그 발행을 자동화해보자 (8) 상업화 코드 하나 배포했다가 자동 발행이 조용히 죽을 뻔한 이야기
지금까지 일곱 편에 걸쳐 만든 건 철저히 제 개인용 도구였습니다. 그런데 "이거 다른 티스토리/네이버 블로거한테 월 구독제로 팔면 어떨까?"라는 생각이 들면서, 하루아침에 요구사항이 완전히 바뀌었습니다. 계정 시스템, 사용자별 데이터 분리, 남의 블로그 로그인 연동까지 — 개인 스크립트를 진짜 서비스로 바꾸는 작업이 시작됐습니다. 그리고 이 과정에서 지금까지 나온 버그 중 가장 조용하고 위험한 버그를 만났습니다.
계획: Google 로그인 + 원격 브라우저로 블로그 연동
상업화하려면 최소 두 가지가 필요했습니다.
- 사이트 자체 로그인 — 사용자 확정 요구사항대로 Google OAuth만 지원 (Auth.js v5)
- 사용자가 자기 티스토리/네이버 계정을 연동하는 방법 — 이게 진짜 어려운 부분이었습니다
두 번째 문제부터 짚어보면, 후보는 세 가지였습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 원격 브라우저 서비스(Browserbase) 임베드 | 온보딩 매끄러움, 비밀번호를 코드가 안 다룸 | 세션당 비용, 외부 벤더 종속 |
| 브라우저 확장 프로그램으로 쿠키 추출 | 세션당 인프라 비용 없음 | 확장 개발·배포·유지보수 필요 |
| 수동 쿠키 붙여넣기 | 구현 제일 단순 | 일반 사용자에겐 너무 기술적 |
Browserbase를 골랐습니다. 이미 쓰고 있던 playwright-core로 chromium.connectOverCDP()만 하면 원격 브라우저에 붙을 수 있고, proxies: [{ geolocation: { country: 'KR' } }]로 한국 IP 지정도 됩니다. 카카오/네이버 로그인이 해외 IP발 접속을 이상 로그인으로 막을 수 있다는 점을 감안하면 실무적으로 중요한 옵션이었습니다.
흐름은 이렇습니다. 사용자가 "연동하기"를 누르면 원격 브라우저 세션이 뜨고, 그 화면을 iframe으로 우리 사이트에 그대로 띄웁니다. 사용자가 그 안에서 직접 로그인하면, 우리 서버는 로그인 완료를 세션 쿠키 존재 여부로 감지해서 storageState만 캡처해 저장합니다. 비밀번호는 우리 코드를 단 한 번도 거치지 않습니다.
Redis 키를 전부 사용자별로 쪼갰다
기존엔 전역 키 하나(blog-auto-writer:topics)에 모든 주제가 다 들어있었습니다. 이걸 사용자마다 완전히 분리했습니다.
function topicsKey(userId: string): string {
return `user:${userId}:topics`;
}
function sessionKey(userId: string, platform: string): string {
return `user:${userId}:session:${platform}`;
}
기존 함수들(getTopics, saveTopics, publishToTistoryServerless 등)은 로직을 다시 짜지 않고, 전부 첫 인자에 userId만 끼워 넣는 식으로 최소 침습적으로 바꿨습니다. 이미 두 번의 트러블슈팅(본문 주입 검증, 공개 여부 검증)으로 다듬어둔 핵심 자동화 로직은 건드리지 않는 게 중요했습니다.
cron도 마찬가지로 손봤습니다. 예전엔 전역 topics 하나만 스캔했지만, 이제는 전체 사용자(users:index)를 순회하며 각자의 주제와 블로그 설정으로 발행합니다.
const userIds = await listUserIds();
for (const userId of userIds) {
const topics = await getTopics(userId);
const due = topics.filter(/* 오늘 날짜, 미발행 */);
const blogConfig = await getBlogConfig(userId, 'tistory');
if (!blogConfig) continue;
// ... 발행 ...
}
여기까지는 typecheck도 통과했고 next build도 문제없이 성공했습니다. 로컬에서 검증할 수 있는 건 다 했다고 생각하고 배포했습니다.
배포 직후, 아무 에러도 없이 조용히 아무것도 안 하기 시작했다
UI 버그(다음 편에서 다룹니다)를 하나 고치는 김에 이 멀티테넌트 코드를 통째로 프로덕션에 올렸습니다. 사이트는 잘 떴고, 페이지도 정상 렌더링됐습니다. 그런데 뭔가 찜찜해서 cron 엔드포인트를 직접 호출해봤습니다.
{"date":"2026-07-09","users":0,"processed":0}
users: 0. 아무도 처리하지 않았다는 뜻입니다. 원인은 명확했습니다 — Google 로그인 자격증명이 아직 없어서 아무도 실제로 로그인한 적이 없었고, 그러니 users:index(전체 사용자 목록)가 텅 비어 있었던 겁니다. 그리고 새 cron 코드는 이 빈 목록을 순회하니, 발행할 게 하나도 없다고 "정상적으로" 판단하고 끝나버립니다.
이게 무서운 이유는 에러가 하나도 안 난다는 점입니다. HTTP 200, 로그도 깨끗합니다. 다음 날 아침 09시가 되어도 아무 글도 안 올라오고, 그걸 알아차리는 유일한 방법은 사람이 직접 블로그에 들어가서 "어? 오늘 글이 없네" 하고 눈치채는 것뿐이었습니다. 이 프로젝트에서 계속 반복돼온 "화면엔 문제없어 보이는데 실제 상태는 다르다"는 패턴이, 이번엔 UI가 아니라 아키텍처 전환 자체에서 터진 셈입니다.
롤백부터, 그다음 과도기 우회
가장 먼저 한 일은 원인 분석이 아니라 되돌리기였습니다. vercel rollback으로 어제까지 정상 작동하던 단일 사용자 버전으로 즉시 복구했습니다.
vercel rollback dpl_FqwU4PixqrfctcN2h4A4AKZBkpyg --yes
자동 발행을 다시 안전하게 만들어둔 다음에야 진짜 문제 해결에 들어갔습니다. Google 로그인 자격증명이 없는 지금 상황에서, 새 멀티테넌트 코드를 안전하게 쓰려면 "로그인 없이도 예전처럼 동작"하는 과도기 장치가 필요했습니다.
export const LEGACY_USER_ID = 'legacy-single-tenant';
export async function requireUserId() {
if (process.env.DISABLE_AUTH === 'true') return { userId: LEGACY_USER_ID };
const session = await auth();
// ... 실제 로그인 로직 ...
}
기존 전역 데이터(주제 33개, 초안, 티스토리 세션)를 전부 legacy-single-tenant라는 고정 사용자 아래로 한 번 이관하고, cron도 users:index가 비어 있으면 이 계정을 대신 처리하도록 안전망을 걸었습니다. 이 상태로 다시 배포하니 UI 수정과 새 아키텍처가 안전하게 함께 살아남았습니다.
이후 실제로 Google OAuth 자격증명을 발급받고, 사용자가 직접 로그인에 성공한 걸 확인한 뒤에야 legacy-single-tenant의 데이터를 진짜 계정(Google이 발급한 고유 ID)으로 다시 옮기고 DISABLE_AUTH를 지웠습니다. 이제는 로그인 없이는 접속도 안 되고, cron도 실제 계정 기준으로만 동작합니다.
정리 — "빌드가 성공했다"는 "운영이 안전하다"의 증거가 아니다
| 확인한 것 | 실제로 보장하는 것 |
|---|---|
| typecheck 통과 | 타입이 맞다는 것뿐 |
next build 성공 | 번들링이 된다는 것뿐 |
| 사이트가 200으로 뜬다 | 페이지 렌더링이 된다는 것뿐 |
| cron이 200을 반환한다 | 아무것도 안 해도 200일 수 있다 |
이번 사고에서 가장 뼈아팠던 건, 위 네 가지를 전부 통과했는데도 실제로는 자동 발행이 완전히 멈춰 있었다는 점입니다. 아키텍처를 바꾸는 배포일수록 "빌드 성공"이 아니라 "핵심 지표(이번엔 users 카운트)가 기대한 값인지"까지 직접 curl로 찍어보는 습관이 필요하다는 걸 다시 배웠습니다. 다음 편에서는 같은 날 겪은 더 사소하지만 똑같이 헷갈렸던 UI 버그 두 가지를 다루겠습니다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.