Playwright세션관리카카오로그인트러블슈팅서버리스

블로그 발행을 자동화해보자 (12) 세션은 죽지 않게 만들었는데 왜 또 죽었을까 — 가짜 양성과 1회용 로그인 토큰

2026년 7월 15일5 분 읽기

지난 편에서 세션 연명(keep-alive) cron을 붙이고 "이제 하루 안 죽는다"고 마무리했습니다. 그런데 이틀 뒤 아침, 대시보드에 또 익숙한 빨간 글씨가 떠 있었습니다.

티스토리 로그인 세션이 만료되었습니다.

분명 12시간마다 세션을 갱신하는 cron을 붙여놨는데, 왜 또 죽었을까요. 이번 편은 그 답을 찾아가다가 만난 두 가지 반전 — 가짜 양성 판정1회용 로그인 토큰 — 에 대한 기록입니다.

1단계: 연명 cron이 진짜로 연명시키고 있었나?

가장 먼저 의심한 건 "연명 cron이 정말 세션을 살리고 있는가"였습니다. 코드를 다시 읽어보니 판정 로직이 이랬습니다.

const cookies = await context.cookies();
const alive = cookies.some((c) => c.name === 'TSSESSION');
if (alive) {
  await saveSession(userId, platform, await context.storageState()); // 갱신됐다고 믿고 덮어씀
}

storageState로 주입한 쿠키에서 TSSESSION 이름을 찾는 방식인데, 여기에 함정이 있었습니다. Playwright의 storageState는 브라우저 컨텍스트에 쿠키를 "주입"하는 것이지, 서버와 유효성을 재검증하는 게 아닙니다. 즉 서버에서 이미 만료된 세션이어도, 컨텍스트 안에는 그 쿠키가 (만료된 채로) 그대로 남아 있어서 cookies.some(...)은 언제나 true를 반환합니다.

실제로 죽은 세션 하나를 이 로직에 넣고 테스트해봤습니다.

쿠키 수: 27
  TSSESSION @ .tistory.com expires=session   ← 존재는 함
직후 URL: https://www.tistory.com/auth/login?redirectUrl=...  ← 근데 이미 로그인 페이지

쿠키는 있는데 로그인은 이미 풀려 있었습니다. 연명 cron은 이 죽은 세션을 매번 "refreshed"로 보고하며 그대로 다시 저장하고 있었던 거죠. 즉 지금까지의 "연명 성공" 로그는 상당수가 거짓 양성이었습니다.

발행 로직과 똑같은 기준으로 판정을 바꾸기

가짜 양성을 없애려면 "쿠키 존재"가 아니라 "실제로 로그인된 페이지에 접근되는가"로 판정해야 합니다. 발행 코드는 원래 이 방식을 쓰고 있었으니, 연명 cron도 똑같이 맞췄습니다.

await page.goto(`https://${blogName}.tistory.com/manage/newpost/`, {
  waitUntil: 'domcontentloaded',
});

// 살아 있는 세션도 카카오 SSO 재인증으로 accounts.kakao.com을 경유할 수 있으므로
// 리다이렉트 체인이 끝날 때까지 기다린 뒤 최종 URL로 판정한다.
await page
  .waitForURL((u) => u.pathname.includes('/manage/newpost'), { timeout: 15000 })
  .catch(() => {});

const alive = page.url().includes('/manage/newpost');

여기서 waitForURL 없이 바로 page.url()을 확인하면 또 다른 오탐이 생깁니다. 살아 있는 세션도 카카오 SSO가 토큰을 재발급하는 과정에서 잠깐 accounts.kakao.com을 들렀다가 돌아오는데, 그 중간 시점에 판정하면 "죽었다"고 착각하기 때문입니다. 리다이렉트 체인이 완전히 끝나는 걸 기다린 뒤 판정하는 게 핵심입니다.

수정한 뒤 다시 돌려보니 이번엔 판정이 정직해졌습니다.

{"refreshed": 1, "results": [
  {"platform": "tistory", "ok": false, "note": "expired"},
  {"platform": "naver",   "ok": true,  "note": "refreshed"}
]}

네이버는 정말 살아 있었고, 티스토리는 정말 죽어 있었습니다. 판정은 고쳤지만, 문제 자체는 그대로 남아 있었습니다.

2단계: 진짜로 얼마나 자주 죽는가

판정을 믿을 수 있게 됐으니, 이번엔 RSS 발행 이력을 전부 뒤져서 실제 패턴을 확인했습니다.

Tue, 14 Jul 09:42   ← 발행 성공
Mon, 13 Jul 17:43   ← 발행 성공
Sun, 12 Jul 23:02   ← 발행 성공
Sat, 11 Jul 21:39   ← 발행 성공
...

시각을 자세히 보면 전부 09:00 정각이 아닙니다. 자동 발행 cron은 매일 09:00에 도는데, 실제 발행은 밤 9시, 11시, 5시... 전부 사람이 세션을 새로 연동하거나 개발 중 수동으로 브라우저를 띄운 직후였습니다. 즉 "09:00 cron이 저장된 세션만으로 자력 발행에 성공한 사례가 단 한 번도 없었습니다." 며칠씩 버틴다고 믿었던 건, 개발하면서 수시로 세션을 건드린 게 착시를 만든 것뿐이었습니다.

카카오 로그인 세션(TSSESSION)은 무활동 24시간이 아니라, 그보다 훨씬 짧은 주기로, 특히 접속 IP가 바뀌면(로컬 → 서버리스) 즉시 죽는 것으로 보였습니다. 연명 cron으로는 애초에 이길 수 없는 싸움이었던 겁니다.

3단계: "연명"이 안 되면 "재발급"을 자동화하자

죽는 걸 막을 수 없다면, 죽을 때마다 자동으로 다시 로그인시키면 됩니다. 여기서 실마리가 하나 있었습니다. 로컬 PC에는 카카오 간편로그인(비밀번호 없이 저장된 계정 타일을 클릭만 하면 로그인되는 기능)이 등록된 영구 브라우저 프로필이 있었습니다.

const context = await chromium.launchPersistentContext(profileDir, { headless: true });
const page = await context.newPage();
await page.goto(`https://${blogName}.tistory.com/manage/newpost/`);

if (page.url().includes('/auth/login')) {
  await page.locator('a.btn_login.link_kakao_id').first().click();
  // 카카오 로그인 페이지에 저장된 계정 타일이 뜬다
  await page.locator('text=/[\\w.+-]+@[\\w.-]+\\.\\w+/').first().click();
  await page.waitForURL((u) => u.pathname.includes('/manage/newpost'));
}

테스트해보니 정말로 됐습니다. 로그인 폼 대신 저장된 이메일 계정 타일이 뜨고, 클릭 한 번으로 재로그인이 완료됐습니다. 여기에 macOS launchd로 매일 08:40(발행 cron 20분 전)에 이 스크립트를 실행하도록 등록했습니다. 이제 사람 개입 없이 매일 아침 세션이 새로 발급되고, 09:00 cron은 항상 신선한 세션으로 발행할 수 있어야 했습니다.

며칠은 이게 정말로 통했습니다.

4단계: 반전 — 간편로그인도 결국 며칠 만에 막혔다

그런데 며칠 뒤, 이번엔 그 자동 재발급 스크립트 자체가 실패하기 시작했습니다.

[tistory] 세션 만료 감지 — 카카오 간편로그인으로 재발급 시도
간편로그인 계정 타일을 찾지 못했습니다 — 카카오가 전체 로그인을 요구하는 상태입니다.

계정 타일이 아예 사라지고, 아이디/비밀번호를 처음부터 입력하라는 화면으로 바뀐 겁니다. 원인을 좁혀보려고, 방금 수동으로 로그인을 마친 프로필을 곧바로 재시작해서 쿠키를 확인해봤습니다.

총 쿠키 4개
  from_login @ .tistory.com
  _T_ANO     @ .kakao.com
  _kau       @ .kakao.com
  (❌ _kawlt, _kawltea 등 "로그인 유지" 토큰이 전부 없음)

로그인한 지 1분도 안 됐는데 벌써 "로그인 유지" 토큰이 사라져 있었습니다. 여기서 원인을 확신했습니다. 카카오의 로그인 유지 토큰(_kawlt 등)은 1회용 로테이션 토큰입니다. 로그인할 때마다 서버가 새 토큰을 발급하면서 이전 토큰은 즉시 폐기하는 방식이죠. 이건 탈취된 토큰의 재사용을 막기 위한 흔한 보안 설계입니다.

문제는 이게 헤드리스 배치 재발급 방식과 근본적으로 상충한다는 것이었습니다. 매번 새 브라우저 컨텍스트를 띄우고, 로그인하고, 세션을 캡처해서 저장하고, 브라우저를 닫는 흐름 자체가 "로그인 → 저장 완료 전에 컨텍스트 종료"로 이어지기 쉽고, 그 사이 어느 타이밍에 토큰 로테이션이 씹히면 다음번엔 저장된 토큰이 무효화된 채로 시작하게 됩니다. 브라우저 종료 전 대기시간을 늘려서 완화는 했지만, 근본적으로 "사람이 브라우저를 계속 열어두고 쓰는 상황"을 가정한 보안 장치를, "가끔 켜졌다 꺼지는 자동화 스크립트"가 우회하려는 시도라 신뢰도가 낮을 수밖에 없었습니다.

결론: 완전 무인화는 포기하고, 개입을 최소화하는 쪽으로

여기서 선택지를 정리했습니다.

방법평가
아이디/비밀번호를 저장해두고 자동 재로그인캡차·이상탐지에 막히고, 자격증명을 평문 저장하는 리스크가 큼 (11편에서 이미 기각)
헤드리스 배치로 간편로그인 자동 재발급로그인 유지 토큰의 로테이션 정책과 근본적으로 상충 — 며칠 버티다 결국 막힘
사람이 세션 만료 때마다 로그인 한 번씩완전 자동은 아니지만 가장 정직하고 안전한 임시 해법
(상용 서비스 방향) 고객의 실제 브라우저에서 세션을 매일 캡처카카오가 정상적인 "사람이 쓰는 브라우저"로 인식하므로 이 문제 자체가 없음

지금 당장은 세 번째로 타협했습니다. 자동 재발급 스크립트는 지우지 않고 남겨뒀습니다 — 간편로그인 토큰이 살아있는 날엔 여전히 도움이 되고, 실패하면 그냥 조용히 실패할 뿐 사람이 로그인해주면 되니까요. 다만 이 경험은 상용화 방향에 중요한 시사점을 남겼습니다. 계획 중이던 크롬 확장 프로그램 방식(고객이 자기 브라우저로 로그인하면 그 세션을 서버로 올려주는 방식)이 "1회 캡처"가 아니라 **"사용자의 실제 브라우저에서 매일 아침 새로 캡처해 재전송"**하는 구조여야 한다는 걸 이번에 미리 알게 된 셈입니다. 우리가 대신 로그인해주는 방식으로는 이 벽을 넘을 수 없다는 게 명확해졌으니까요.

정리

증상진짜 원인해결
연명 cron이 "성공"인데 세션은 죽어 있음storageState 쿠키는 만료돼도 컨텍스트에 남아 있어 존재 여부만으로는 생사 판정 불가발행과 동일하게 로그인 페이지 리다이렉트 여부로 판정 (SSO 경유 대기 포함)
판정을 고쳐도 세션이 하루를 못 버팀카카오 세션은 24시간보다 훨씬 짧게, 특히 IP가 바뀌면 즉시 만료"연명"을 포기하고 "매일 재발급"으로 설계 전환
자동 재발급도 며칠 뒤 다시 막힘카카오 로그인 유지 토큰이 1회용 로테이션이라 헤드리스 배치 흐름과 상충완전 무인화 포기, 최소 개입(수동 로그인 1회)으로 타협 + 근본 해법은 사용자 실브라우저 캡처로 재설정

이번 편에서 제일 크게 배운 건, "고쳤다"는 확인 자체를 의심해야 할 때가 있다는 것입니다. 연명 cron이 "성공"을 보고하고 있다고 해서 실제로 세션이 살아 있다는 뜻은 아니었습니다. 판정 로직이 검증하려는 대상과 실제로 검증하는 대상이 다르면, 로그는 계속 초록불인데 시스템은 이미 고장 나 있는 상태가 만들어집니다. 자동화를 다룰 땐 "에러가 없다"가 아니라 "실제 상태가 기대한 대로인가"를 별도로 확인하는 습관이, 이번처럼 며칠씩 걸리더라도 결국 가장 빠른 길이었습니다.

PM

backtodev

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