Playwright세션관리카카오로그인서버리스아키텍처

블로그 발행을 자동화해보자 (13) 세션을 옮기지 말고, 발행을 옮기자 — 서버리스에서 로컬 하이브리드로

2026년 7월 17일4 분 읽기

지난 편에서 "카카오 로그인 유지 토큰은 1회용 로테이션이라 완전 무인 재발급이 불가능하다"는 결론에 도달했습니다. 그리고 딱 하루 뒤, 그 결론이 다시 한번 실제로 증명됐습니다. 자동 재발급이 또 막혔고, 사람이 로그인 창을 열어야 했습니다. 이쯤 되면 질문을 바꿔야 할 시점이었습니다. "어떻게 하면 세션을 안 죽게 할까"가 아니라, **"애초에 세션을 옮길 필요가 있었나"**로요.

지금까지의 접근이 공유하던 전제

1편부터 12편까지 시도한 방법들을 한 줄로 요약하면 전부 같은 패턴이었습니다.

  1. 어디선가(로컬 PC든, Browserbase 원격 브라우저든) 카카오 로그인을 한다
  2. 로그인 세션(쿠키)을 storageState로 캡처한다
  3. 그 세션을 Redis에 저장해뒀다가, Vercel 서버리스 함수(미국 데이터센터 IP) 에 주입해서 발행한다

이 구조는 "발행은 클라우드에서, 매일 자동으로" 라는 목표에 자연스러운 접근이었습니다. 그런데 뒤에 숨어 있던 전제 하나를 당연하게 여기고 있었습니다. "로그인한 곳"과 "발행하는 곳"이 달라도 세션이 멀쩡히 살아있을 거라는 전제입니다.

카카오 입장에서 생각해보면 이 전제가 얼마나 위태로운지 보입니다. 사람이 한국 IP에서 로그인한 세션이, 몇 시간 뒤 갑자기 미국 IP에서 사용됩니다. 이건 보안 시스템이 정확히 걸러내도록 설계된 패턴 그 자체입니다. 지난 며칠간 우리가 겪은 "세션이 반나절을 못 버틴다", "간편로그인 자동 재발급도 며칠 만에 막힌다" 같은 증상들은 사실 전부 같은 근본 원인의 다른 얼굴이었던 셈입니다.

발상의 전환: 세션을 옮기지 말고, 발행을 옮기자

캡처해서 옮기는 게 문제라면, 답은 단순합니다. 로그인한 바로 그 자리에서 발행하면 됩니다. 로컬 PC의 영구 브라우저 프로필로 티스토리에 직접 발행하면:

  • 로그인 IP와 발행 IP가 항상 같습니다 (내 집 인터넷)
  • 매일 발행하는 행위 자체가 "정상적인 세션 사용"이 되어, 그게 곧 세션 갱신입니다
  • 세션을 어딘가로 전송하는 단계가 아예 사라지니, 전송 과정에서 죽을 일도 없습니다

문제는 "그럼 클라우드 대시보드는 왜 만들었나"입니다. 주제 등록, 초안 확인·수정, 발행 현황 조회 같은 기능은 여전히 웹 대시보드로 어디서든 하고 싶었거든요. 그래서 역할을 쪼갰습니다.

역할담당
주제 등록, 초안 작성/수정, 발행 현황 조회클라우드 (Redis + Vercel 대시보드) — 그대로 유지
티스토리 실제 발행로컬 PC (영구 브라우저 프로필)
네이버 실제 발행클라우드 서버리스 — 그대로 유지 (세션이 안정적이라 문제 없음)

데이터는 계속 클라우드(Redis)에 있고, 로컬 스크립트가 그 데이터를 읽고 쓰는 클라이언트 중 하나가 되는 구조입니다. "로컬 CLI 백업 vs 클라우드 SaaS"라는 이분법 대신, 클라우드를 진실의 원천(source of truth)으로 두고 발행 실행기만 상황에 맞게 고른 셈이죠.

구현: publish-cloud 명령

핵심 로직은 이렇습니다.

export async function publishTodayFromCloud(): Promise<void> {
  const accounts = loadAccounts();
  const userId = accounts.cloudUserId;

  // 1. 클라우드에서 오늘 날짜의 미발행 티스토리 주제만 골라낸다
  const topics = await getCloudTopics(userId);
  const due = topics.filter(
    (t) =>
      t.date === todayKST() &&
      (t.platform === 'tistory' || t.platform === 'both') &&
      !(t.publishedPlatforms ?? []).includes('tistory'),
  );
  if (due.length === 0) return; // 할 일 없으면 조용히 종료 (멱등)

  // 2. 발행 전에 세션부터 확보한다 (만료면 간편로그인 재발급 먼저 시도)
  await ensureTistorySession();

  // 3. 클라우드 초안이 있으면 그대로, 없으면 로컬 생성 후 클라우드에 반영
  for (const entry of due) {
    const draft = await resolveDraft(entry, userId);
    await publishToTistory(draft, accounts.tistory); // 로컬 영구 프로필로 발행
    await markPublished(userId, entry.id);           // 클라우드 상태 갱신
  }
}

여기서 "클라우드 초안이 있으면 그대로 쓰고, 없으면 로컬에서 생성해서 다시 클라우드에 올려준다"는 부분이 은근히 중요합니다. 대시보드에서 미리 초안을 다듬어놨다면 그걸 존중하고, 아무것도 안 해뒀다면 로컬이 알아서 만들어서 대시보드에도 반영해주는 거죠. 어느 쪽에서 작업하든 한 군데(Redis)로 수렴합니다.

스케줄링: "컴퓨터만 켜면 자동 발행"

이 구조의 남은 약점은 명확합니다. 로컬 PC가 꺼져 있으면 발행도 안 됩니다. 이건 어쩔 수 없는 트레이드오프지만, macOS launchd의 두 옵션을 같이 쓰면 체감상 꽤 줄일 수 있습니다.

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key><integer>8</integer>
  <key>Minute</key><integer>50</integer>
</dict>
<key>RunAtLoad</key>
<true/>
  • StartCalendarInterval: 매일 08:50에 실행 시도
  • RunAtLoad: 로그인하거나 부팅할 때도 한 번 실행

이 둘을 같이 쓰면, 8시 50분에 맥이 잠들어 있었어도 나중에 화면을 열어 깨우는 순간(macOS는 깨어날 때 밀린 스케줄을 알아서 실행해줍니다) 또는 아예 꺼져 있다가 켜는 순간 발행이 따라잡습니다. 그리고 클라우드 쪽 발행 cron(09:00)은 "이미 발행된 주제는 건너뛴다"는 로직이 원래 있었으니, 로컬이 늦게라도 먼저 발행해두면 클라우드 cron은 그냥 할 일이 없어서 조용히 지나갑니다. 서로 부딪히지 않고 자연스럽게 백업 관계가 되는 거죠.

아직 검증 전인 것

이 글을 쓰는 지금은 구조를 막 갈아엎은 직후라, 첫 자동 실행(오늘 아침 08:50)은 아직 일어나지 않았습니다. 지금까지 확인한 건 수동으로 명령을 돌려봤을 때의 동작 — 오늘 발행할 주제가 없을 때 조용히 종료하는지, 미발행 주제가 있을 때 실제로 발행되고 클라우드 상태가 갱신되는지 정도입니다. 진짜 시험대는 사람이 아무것도 안 건드린 상태로 아침에 눈떠보니 글이 올라와 있는가입니다. 이건 다음 편에서 확인한 결과를 그대로 옮기겠습니다.

정리

이전 접근이번 접근
로그인한 곳과 발행하는 곳이 다름 (로컬/원격 → 서버리스)로그인한 곳에서 그대로 발행 (로컬 → 로컬)
세션을 "옮기는" 단계가 항상 존재세션을 옮기는 단계 자체가 없음
세션이 죽으면 "왜 죽었지"부터 다시 추적발행=사용이라 애초에 죽을 기회가 적음
로컬 CLI는 클라우드의 "백업 수단"로컬 CLI가 클라우드의 "실행기" 중 하나 (데이터는 계속 클라우드)

몇 편에 걸쳐 세션을 안 죽게 하는 방법, 죽으면 되살리는 방법을 찾아다녔는데, 결국 답은 문제를 우회하는 것이었습니다. "이 시스템이 왜 이렇게 동작하는지"를 이해하고 나면, 그 동작을 거스르려 애쓰기보다 순응하는 설계가 훨씬 적은 코드로 훨씬 튼튼하게 동작할 때가 있습니다. 카카오의 보안 정책과 싸우는 대신 그 정책이 편안해하는 방식(같은 곳에서 계속 로그인) 그대로 발행 파이프라인을 맞춘 것이, 지금까지의 시행착오 중 가장 근본적인 전환이었습니다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.