네이버 검색어드바이저 자동 제출을 Playwright 쿠키 재사용으로 푼 이야기 (2026)
Naver 는 Google 같은 앱 비밀번호 발급이 없다. 2단계 인증 켜진 채로 ID/PW 자동 로그인은 거의 항상 막힌다. Playwright storage_state 로 쿠키만 재사용하는 패턴 + Vuetify SPA selector 진단 + 일 5건 한도 자동 제출까지 정리.
한 줄 요약
Naver 는 Google 같은 "앱 비밀번호" 가 없어서 2단계 인증 켜진 일반 계정은 ID/PW 로 봇이 자동 로그인 못 한다. 본인이 로컬 PC 에서 한 번 헤드풀로 로그인해 쿠키를 추출한 뒤, 서버 봇은 그 쿠키만 재사용하는 방식으로 풀었다. 비번이 어디에도 안 남고 2FA 도 살린 채 자동화 가능. 결과적으로 매일 KST 09:45 에 일 5건씩 자동 제출되는 시스템.
왜 이 짓을 했나
sitemap.xml 은 정상이고, IndexNow 도 쏘고 있는데 네이버 색인이 거의 안 됐다. GSC 와는 별개로 Naver Yetibot 이 신생 도메인에서는 sitemap.xml 만 긁고 개별 글은 안 가져가는 패턴이 있다. 공식 권장 처방은 "검색어드바이저 → 요청 → 웹페이지 수집" 에서 URL 을 하나씩 붙여넣는 것. 하루 50건 한도지만 매일 글 5~10개를 손으로 붙여넣어야 한다는 게 자율형 1인 운영 체제와 안 맞았다.
1차 시도: ID/PW 자동 로그인 (실패)
가장 단순한 안: NAVER_SA_USERNAME / NAVER_SA_PASSWORD 를 .env 에 두고
Playwright 가 자동 입력. page.keyboard.type(..., delay=80) 으로 사람 같은 타이핑 모방까지.
id_field = await page.wait_for_selector("#id", timeout=8000)
await id_field.click()
await page.keyboard.type(username, delay=80)
pw_field = await page.wait_for_selector("#pw", timeout=4000)
await pw_field.click()
await page.keyboard.type(password, delay=80)
await page.click("button[type='submit']")
결과 — 2FA 가 켜져 있으면 로그인 페이지에서 막힘:
- "OTP 확인" 으로 redirect → 봇은 OTP 입력 불가
- 새 디바이스 등록 화면 → 본인 폰 푸시 승인 필요
- 그러다 CAPTCHA 나오면 끝
구글이나 GitHub 같으면 "앱 비밀번호" 를 발급해서 그 16자리로 자동 로그인하면 되는데, Naver 는 그런 API/UI 가 없다. 보안설정 → 2단계 인증 들어가도 "인증 기기 선택" 만 나오지 봇용 토큰 발급 옵션이 없다. 직접 시도해보고 알았다.
대안 분석
| 안 | 장점 | 단점 |
|---|---|---|
| 2단계 인증 끄기 | 구현 가장 단순 | 본인 메인 계정 보안 ↓ — 메일/페이/은행까지 같이 위험 |
| 쿠키 재사용 (storage_state) | 2FA 살린 채 자동화 / 비번 어디에도 안 저장 | 쿠키 만료(1~3개월) 시 1회 재발급 필요 |
| 자동화 포기 | 0줄 코드 | 매일 손으로 5건 입력 — 자율 운영 모순 |
쿠키 재사용 안으로 결정.
2차 시도: storage_state 쿠키 재사용 (성공)
Playwright 는 browser_context.storage_state(path=...) 로 쿠키 + localStorage 를
JSON 파일에 저장하고, 다음에 new_context(storage_state=...) 로 그대로 복원할 수 있다.
이 점이 핵심.
스텝 1 — 로컬 PC 에서 헤드풀 로그인 + 캡처
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False) # 사람이 봐야 함
context = await browser.new_context(locale="ko-KR")
page = await context.new_page()
await page.goto("https://nid.naver.com/nidlogin.login")
# 여기서 사람이 직접 로그인 + 2FA 푸시 승인
input("로그인 완료되면 Enter > ")
await context.storage_state(path="naver_session.json")
await browser.close()
주의: 본인이 직접 https://searchadvisor.naver.com/console/site/request/crawl
까지 한 번 진입해서 정상 화면 보고 나서 Enter 눌러야 한다. 그래야 searchadvisor 도메인 쿠키까지
같이 캡처된다. (Naver SSO 는 .naver.com 도메인 광역이라 NID_AUT/NID_SES 가 다 공유되지만
안전하게 한 번 들러주는 게 좋다.)
스텝 2 — 서버에 쿠키 파일 업로드
scp naver_session.json \
user@server:/path/to/secrets/naver_session.json
# .env 에 추가:
# NAVER_SA_STORAGE_STATE_PATH=/path/to/secrets/naver_session.json
쿠키 파일 위치는 .gitignore 의 *secret* 패턴이
자동으로 잡는 디렉토리(secrets/) 에 두면 실수로 커밋될 위험 0.
스텝 3 — 서버 봇은 쿠키만 로드
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
storage_state="/path/to/secrets/naver_session.json",
locale="ko-KR",
)
page = await context.new_page()
# 이미 로그인된 상태로 SA 진입 가능
await page.goto("https://searchadvisor.naver.com/console/site/request/crawl?site=...")
3차 함정: Vuetify SPA 의 selector 가 안 잡힘
첫 자동 제출 시도가 5건 모두 "URL 입력 필드 못 찾음" 으로 실패했다.
일반적인 셀렉터들 — input[name="url"], #url,
input[placeholder*="URL"] — 모두 매칭 안 됨.
원인 — Naver SA 는 Vuetify (Vue 2) SPA 다. 동적 hydration 으로
input id 가 매번 다르게 생성되고 (input-209, input-214...) name 속성도 없다.
HTML 만 뜯어보면:
{
type: "text", name: "", id: "input-209",
placeholder: "", cls: "", ariaLabel: null
}
또 하나 — crawl 페이지가 ?site= 파라미터 없으면 "오류" 페이지로 리다이렉트된다.
즉 사이트별로 명시적으로 컨텍스트를 줘야 한다:
from urllib.parse import quote
url = f"https://searchadvisor.naver.com/console/site/request/crawl?site={quote('https://aicoreutility.com', safe='')}"
await page.goto(url, wait_until="networkidle")
await asyncio.sleep(3) # Vue hydration 대기
진단 스크립트로 selector 찾기
이런 SPA 는 다음 패턴이 잘 먹힌다 — 페이지의 모든 input/button 을 덤프해서 사람이 눈으로 매칭:
inputs = await page.query_selector_all("input")
for i, el in enumerate(inputs):
attrs = await el.evaluate(
"e => ({type:e.type, name:e.name, id:e.id, "
"placeholder:e.placeholder, cls:e.className})"
)
print(f"[{i}]", attrs)
btns = await page.query_selector_all("button")
for el in btns:
txt = (await el.text_content() or "").strip()
cls = await el.get_attribute("class") or ""
print(f"text={txt!r} class={cls}")
이 출력에서 페이지의 유일한 text input + 텍스트 "확인" 인 primary 버튼 발견:
# URL 입력 — 페이지의 유일한 input[type=text]
url_input = await page.wait_for_selector('input[type="text"]', timeout=8000)
await url_input.fill(url)
# 제출 — "확인" 텍스트의 font-weight-bold 버튼
submit_btn = await page.wait_for_selector(
'button.font-weight-bold:has-text("확인"), button:has-text("확인")',
timeout=5000,
)
await submit_btn.click()
이 selector 로 재실행 → 5/5 제출 성공.
운영용 마무리 — 자동 dedup + 한도 + 만료 경고
한 번 동작했다고 끝이 아니다. 운영 안정성을 위해 세 가지가 더 필요했다:
1. 30일 dedup — 같은 URL 재제출 방지
CREATE TABLE seo_naver_submissions (
id BIGSERIAL PRIMARY KEY,
url TEXT NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
success BOOLEAN NOT NULL,
error_message TEXT,
attempt_count INT NOT NULL DEFAULT 1
);
-- 최근 30일 내 성공한 URL 은 자동 skip
SELECT DISTINCT url FROM seo_naver_submissions
WHERE success=TRUE
AND submitted_at > NOW() - INTERVAL '30 days';
2. 일 5건 한도 — Naver 실제 한도(50) 보수 안전치
공식은 50건이지만 너무 한꺼번에 쏘면 봇 탐지 위험이 있다. NAVER_SA_DAILY_LIMIT=5
환경변수로 제어. 글 발행 페이스가 1~2개/일이라 5건이면 누락 없이 따라잡는다.
3. 쿠키 만료 60일 경고 — 어드민 대시보드에 노란 배너
async def cookie_status() -> dict:
path = os.environ.get("NAVER_SA_STORAGE_STATE_PATH", "")
if not os.path.isfile(path):
return {"exists": False, "reason": "쿠키 파일 없음"}
stat = os.stat(path)
age_days = (time.time() - stat.st_mtime) / 86400
return {
"exists": True, "age_days": round(age_days, 1),
"warn_renew": age_days > 60, # 60일 넘으면 재발급 권장
}
이 값이 어드민 SEO 대시보드에 카드로 떠서, 만료 직전에 본인이 헤드풀 캡처 1회 재실행하면 된다. 1~3개월에 10분짜리 작업.
4. APScheduler 매일 자동 실행
scheduler.add_job(
run_naver_sa_submit_guarded,
CronTrigger(hour=0, minute=45, timezone="UTC"), # KST 09:45
id="naver_sa_submit_daily",
replace_existing=True,
)
이 시점 선택은 GSC sitemap 일일 sync(KST 10:00) 직전 → 새로 색인된 글이 있다면 구글에 먼저 알리고 Naver 에도 같은 흐름에서 통보하는 게 자연스럽다.
실측 결과
| 지표 | before | after |
|---|---|---|
| 일일 손 작업 | 5건 × ~30초 = 2.5분 | 0초 |
| 월별 누적 제출 | ~80건 (까먹는 날 다수) | 150건 (자동) |
| 본인 계정 보안 | 2FA 켜둠 | 2FA 그대로 |
| 실패 모드 | 까먹기 | 쿠키 만료 (1~3개월 1회) |
교훈
- "앱 비밀번호" 가 있다고 가정하지 말 것. Google/GitHub/Atlassian 처럼 흔한 패턴이지만 Naver/네이트/카카오 등 한국 메이저는 대부분 그 개념이 없다. 자동화 설계 전에 SSO 공급자의 actual auth surface 부터 확인.
- 쿠키 재사용은 "비번 안 저장" + "2FA 호환" 의 좋은 절충안. 만료 주기만 받아들이면 가장 안전한 자동화 형태.
- Vuetify/Material SPA 는 selector 가 SSR HTML 과 다르다. name/id 가 동적 → text 매칭 or 페이지에 하나뿐인 element 로 잡는다. 개발 도구에서 보이는 selector 가 자동화에서 안 먹히면 100% hydration 이슈.
- "몇 번 한도" 가 운영 자유도와 직결된다. 공식 50건이라도 5건만 쏘면 봇 탐지에 안 걸리고, 1~2개 글 / 일 페이스면 5건이면 충분히 따라잡는다. 한도를 가까이 쓰지 말 것.
관련 파일
riel_backend/services/naver_sa_submitter.py— Playwright submitter + DB 기록riel_backend/scripts/naver_login_capture.py— 로컬 헤드풀 캡처 헬퍼riel_backend/api/seo_health.py— 어드민 endpoint (cookie status / 수동 트리거 / 이력)riel_backend/main.py— APScheduler 매일 KST 09:45
다음 글에서는 같은 시점에 같이 한 GSC URL Inspection 진단 결과 — "발견됨 - 색인 안 됨" 22건의 원인 추적 — 을 다룬다.
태그
📨 박주니에게 한마디
스팸·악성 메시지 방지를 위해 구글 로그인 후 메시지를 보낼 수 있어요. 비공개로 전달되며, 운영자 외에는 볼 수 없습니다.
Google 로그인 후 메시지 남기기