블로그 발행을 자동화해보자 (12) 세션은 죽지 않게 만들었는데 왜 또 죽었을까 — 가짜 양성과 1회용 로그인 토큰
지난 편에서 세션 연명(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이 "성공"을 보고하고 있다고 해서 실제로 세션이 살아 있다는 뜻은 아니었습니다. 판정 로직이 검증하려는 대상과 실제로 검증하는 대상이 다르면, 로그는 계속 초록불인데 시스템은 이미 고장 나 있는 상태가 만들어집니다. 자동화를 다룰 땐 "에러가 없다"가 아니라 "실제 상태가 기대한 대로인가"를 별도로 확인하는 습관이, 이번처럼 며칠씩 걸리더라도 결국 가장 빠른 길이었습니다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.