0820 실습은 "네이버 카페에 글을 도배했다가 한 번에 지우는" 코드였습니다. 제목만 보면 장난 같은데, 사실 이 실습에서 진짜로 배운 건 브라우저가 뒤에서 하는 일을 파이썬으로 그대로 재현하는 법이었어요. 우리가 카페에서 글을 쓸 때 브라우저는 (1) 로그인해서 쿠키를 받고 (2) 그 쿠키를 붙여 서버의 API를 호출합니다. 실습은 이 두 단계를 각각 Selenium과 requests에게 나눠 맡깁니다.
왜 굳이 둘로 쪼갤까요? 로그인은 자바스크립트·봇 탐지가 얽혀 있어 requests만으로 흉내 내기가 까다롭습니다. 그래서 로그인이라는 귀찮은 일만 진짜 브라우저(Selenium)에게 시키고, 그 결과물인 쿠키를 뽑아 가벼운 requests로 넘겨 API를 두드리는 거죠. 이 글에서는 그 흐름을 다섯 조각 — 쿠키 추출 → 쿠키 이관 → 내부 API 호출 → JSON 이중 직렬화 → 목록 수집·삭제 예절 — 로 나눠, 강의 실습 코드를 공식 문서 예제와 나란히 놓고 뜯어보겠습니다.
sleep으로 간격을 둡니다. 기술 자체보다 이 경계를 먼저 몸에 붙이는 게 이 실습의 핵심이에요.1. 큰 그림 — 조각들이 어떻게 맞물리나
코드를 한 줄씩 보기 전에, 다섯 조각이 어떤 질문에 답하는지 지도로 먼저 펼쳐 둘게요. 이후 각 섹션이 이 지도의 한 칸씩을 채웁니다.
2. Selenium으로 로그인하고 "쿠키만" 뺏어오기
첫 칸, "누가 로그인해?"부터 봅니다. 답은 진짜 브라우저예요.
Selenium (셀레니움): 웹 브라우저를 코드로 조종하는 자동화 도구 — 사람이 클릭·입력하는 걸 파이썬이 대신 합니다. 여기서는 크롬을 띄워 네이버 로그인 폼에 아이디/비번을 입력하고 엔터를 칩니다.
로그인에 성공하면 서버는 브라우저에게 "인증 도장"을 찍어 줍니다. 그게 바로 쿠키예요. 우리가 원하는 건 딱 이 도장 하나입니다. 브라우저는 로그인시키는 용도로만 쓰고, 도장을 챙긴 다음엔 창을 닫아 버리죠.
공식 문서는 이렇게 설명합니다. Selenium 문서에서 get_cookies()는 현재 브라우징 컨텍스트의 쿠키 데이터를 돌려주고, 쿠키 하나하나는 {"name": ..., "value": ...} 형태의 딕셔너리라고 못 박습니다. 문서 표현을 짧게 빌리면 "serialized cookie data"를 반환한다는 거죠.
(출처: selenium.dev — Working with cookies)
# 공식 문서 예제 — 대조 기준 from selenium import webdriver driver = webdriver.Chrome() driver.get("http://www.example.com") driver.add_cookie({"name": "test1", "value": "cookie1"}) # 지금 브라우저가 들고 있는 쿠키 전부를 리스트[dict]로 반환 print(driver.get_cookies()) # → [{"name": "test1", "value": "cookie1", ...}, ...]
강의 실습에서는 이렇게 썼습니다. 공식이 준 리스트[dict]를, 뒤에서 requests가 바로 삼킬 수 있는 {이름: 값} 딕셔너리로 한 번에 눌러 담습니다.
userid = driver.find_element(By.CSS_SELECTOR, "#id") userid.send_keys(naver["id"]) userpw.send_keys(naver["pw"] + Keys.ENTER) # 로그인 실행 # 리스트[dict] → {name: value} dict 로 변환 (requests 가 원하는 형태) cookies = dict(map(lambda row: (row["name"], row["value"]), driver.get_cookies())) driver.close() # 도장 챙겼으니 브라우저는 닫는다
| 항목 | 공식 문서 예제 | 실습 코드 | 차이 · 이유 |
|---|---|---|---|
| get_cookies() 결과 | 그대로 print만 함 | dict(map(...))로 재가공 | 실습은 requests의 cookies=에 넣으려고 {이름:값} 형태로 변환 |
| 쿠키 출처 | add_cookie로 직접 심음 | 실제 로그인이 남긴 세션 쿠키 | 실습은 인증이 목적이라 "진짜 로그인"이 필요 |
| 브라우저 수명 | 계속 유지 | 쿠키 확보 후 close() | 이후 작업은 requests가 하므로 브라우저 불필요 |
requests로 넘기는 이 "하이브리드" 패턴은 실무 크롤러의 단골 구조입니다.→ 다음은 이 쿠키를 requests에 붙여 "나 아까 그 사람"임을 증명하는 단계입니다.
3. 쿠키를 requests로 넘겨 "같은 사람"인 척하기
도장(쿠키)은 챙겼습니다. 지도의 둘째 칸, "신분증은?"에 답할 차례예요. 이제 브라우저 없이도 그 도장만 있으면 서버는 우리를 로그인한 사용자로 인정합니다.
쿠키 기반 인증 (cookie-based auth): 로그인 성공 시 서버가 발급한 세션 쿠키를 매 요청마다 함께 실어 "나 아까 로그인한 그 사람"임을 증명하는 방식 — 요청 하나하나는 원래 서로를 기억 못 하는데(HTTP는 stateless), 쿠키가 그 기억을 이어 줍니다.
공식 문서는 이렇게 설명합니다. requests 퀵스타트는 "서버에 내 쿠키를 보내려면 cookies 파라미터를 쓰라"고 안내하며, 딕셔너리를 그대로 넘기는 예제를 보여줍니다.
(출처: requests Quickstart — Cookies)
# 공식 문서 예제 — 대조 기준 url = "https://httpbin.org/cookies" cookies = dict(cookies_are="working") r = requests.get(url, cookies=cookies) print(r.text) # → {"cookies": {"cookies_are": "working"}}
강의 실습에서는 이렇게 썼습니다. 공식과 똑같이 cookies=에 딕셔너리를 넘깁니다. 다만 실습은 "쿠키가 실제로 먹히는지"를 확인하는 영리한 트릭을 하나 더 얹습니다 — 응답 <title>이 '메일'이면 로그인 성공, 'NAVER 로그인'이면 실패라는 걸로요.
url = "https://mail.naver.com" resp = request(url=url, cookies=cookies, method="GET") # 쿠키 O print(re.search(r"<title.*>(.+)</title>", resp.text).group(1)) # → '메일' resp = request(url=url, method="GET") # 쿠키 X print(re.search(r"<title.*>(.+)</title>", resp.text).group(1)) # → 'NAVER 로그인'
| 항목 | 공식 문서 예제 | 실습 코드 | 차이 · 이유 |
|---|---|---|---|
| 쿠키 전달 | cookies=cookies | cookies=cookies (동일) | 거의 동일 — 실습이 공식 패턴을 그대로 씀 |
| 호출 방식 | requests.get(...) | request(url=..., method="GET") | get/post는 request()의 얇은 래퍼 — 실습은 method만 바꿔 재사용하려 저수준 함수 선택 |
| 검증 | 없음(예시라서) | <title> 정규식으로 성공/실패 판정 | 실전에선 "인증이 실제로 됐나"를 눈으로 확인하는 습관이 중요 |
request(method, url, **kwargs)는 requests의 가장 저수준 함수이고, 우리가 흔히 쓰는 get()·post()는 여기에 method만 고정해 둔 단축키입니다. 실습이 get() 대신 request(method="GET")를 쓴 이유는, 같은 코드 뼈대에서 method만 갈아 끼워 GET/POST를 돌려쓰기 위해서예요.→ 이제 신분증을 들고, 브라우저만 아는 "내부 API"의 문을 두드립니다.
4. 내부(undocumented) API를 직접 호출하기
셋째 칸, "어디를 두드려?"입니다. 카페에 글을 쓰면 브라우저는 보이지 않는 곳에서 apis.cafe.naver.com 같은 주소로 요청을 보냅니다. 이걸 우리가 직접 부르는 거예요.
내부 API (undocumented API): 공식 개발자 문서에 공개돼 있지 않은, 브라우저가 내부적으로만 쓰는 엔드포인트 — 정식 명세가 없으니 "규격서"가 곧 브라우저의 동작 그 자체입니다. 그래서 찾는 방법도 브라우저를 관찰하는 것뿐이에요: F12(개발자도구) → Network 탭에서 글 작성 시 나가는 요청을 잡아, 그 주소·헤더·Request Payload를 그대로 베낍니다.
공식 문서 예제는… 없습니다(설명만). 내부 API는 정의상 공개 문서가 없으므로 "네이버 카페 글쓰기 API 예제" 같은 건 존재하지 않습니다. 대신 이 API를 부르는 데 쓰는 도구 — requests의 커스텀 헤더 — 는 공식 근거가 있습니다. 문서는 "헤더를 추가하려면 headers에 딕셔너리를 넘기라"고 안내하죠.
(출처: requests Quickstart — Custom Headers)
# 공식 문서 예제 — 커스텀 헤더를 넘기는 방법(대조 기준) url = "https://api.github.com/some/endpoint" headers = {"user-agent": "my-app/0.0.1"} r = requests.get(url, headers=headers)
강의 실습에서는 이렇게 썼습니다. 브라우저가 보내던 것과 똑같이 x-cafe-product: pc 헤더를 붙여, 캡처한 에디터 API로 POST합니다. 서버 입장에선 "브라우저가 보낸 요청"과 구분이 안 되죠.
url = f"https://apis.cafe.naver.com/editor/v2.0/cafes/{CAFE_ID}/menus/{MENU_ID}/articles" headers = {"x-cafe-product": "pc"} # F12 Network 에서 관찰한 헤더 그대로 resp = request(url=url, json=params, headers=headers, cookies=cookies, method="POST") print(resp.status_code, resp.content)
| 항목 | 공식 문서 예제(도구) | 실습 코드 | 차이 · 이유 |
|---|---|---|---|
| 엔드포인트 | 문서화된 공개 API | 문서 없는 내부 API | 실습은 F12로 직접 관찰해 알아냄 — 공식 예제 없음 |
| 헤더 | user-agent 예시 | x-cafe-product: pc | 브라우저가 보내던 헤더를 그대로 재현해야 서버가 정상 처리 |
| 인증 | 보통 토큰/키 | 2번에서 뽑은 쿠키 | 내부 API는 로그인 쿠키만으로 인증 → 반드시 내 계정에만 |
robots.txt를 먼저 확인하는 게 순서입니다.→ 그런데 이 params 안의 본문 데이터가 좀 특이합니다. JSON 안에 JSON 문자열이 들어 있어요.
5. contentJson — JSON 안의 JSON, 이중 직렬화
넷째 칸, "뭘 담아 보내?"입니다. 글 작성 payload를 뜯어보면 contentJson이라는 필드가 있는데, 이 값이 객체가 아니라 "문자열로 변한 JSON"입니다. 처음 보면 "왜 이렇게 꼬아 놨지?" 싶은데, 다루는 요령만 알면 간단합니다.
직렬화 (serialization, 直列化): 파이썬 객체(dict 등)를 저장·전송할 수 있는 문자열로 변환하는 것. 반대로 문자열을 다시 객체로 푸는 건 역직렬화(deserialization). 서버가 contentJson을 "문자열"로 기대하니, 우리는 수정할 땐 객체로 풀고(역직렬화) → 보낼 땐 다시 문자열로 잠그는(직렬화) 두 번의 변환을 거칩니다.
공식 문서는 이렇게 설명합니다. 파이썬 json 모듈에서 dumps()는 객체를 JSON 문자열로 직렬화하고, loads()는 JSON 문자열을 객체로 역직렬화합니다. 딱 서로 반대 방향이에요.
(출처: docs.python.org — json)
# 공식 문서 예제 — 대조 기준 import json json.dumps(["foo", {"bar": ("baz", None, 1.0, 2)}]) # → '["foo", {"bar": ["baz", null, 1.0, 2]}]' (dict → str) json.loads('["foo", {"bar":["baz", null, 1.0, 2]}]') # → ['foo', {'bar': ['baz', None, 1.0, 2]}] (str → 객체)
강의 실습에서는 이렇게 썼습니다. 문자열 상태인 contentJson을 loads로 풀어 dict로 만든 뒤, 제목·본문을 파이썬 문법으로 편하게 수정하고, 마지막에 dumps로 다시 문자열로 잠가 서버에 보냅니다.
# 1) 문자열 → dict 로 풀기(역직렬화). 그래야 파이썬으로 값을 만질 수 있음 params["article"]["contentJson"] = json.loads('{"document": {... "components": [...]}}') # 2) dict 상태에서 본문 텍스트를 자유롭게 수정 params["article"]["contentJson"]["document"]["components"][0]["value"][0] = "붓이 수정한 내용 - 1" # 3) dict → 다시 문자열로 잠그기(직렬화). 서버는 "문자열"을 기대하므로 필수 params["article"]["contentJson"] = json.dumps(params["article"]["contentJson"]) print(type(params["article"]["contentJson"])) # → <class 'str'>
| 항목 | 공식 문서 예제 | 실습 코드 | 차이 · 이유 |
|---|---|---|---|
| loads 용도 | str → 객체 시연 | 중첩 JSON 문자열을 dict로 풀어 수정 | 같은 함수, 실무 맥락(수정 전 풀기)에 적용 |
| dumps 용도 | 객체 → str 시연 | 수정 끝난 dict를 다시 문자열로 잠금 | 서버가 contentJson을 문자열로 요구하기 때문 |
| 왜 2번 변환? | 해당 없음 | loads → 수정 → dumps | "JSON 안의 JSON" 구조를 안전하게 편집하는 정석 순서 |
dumps를 빼먹고 dict인 채로 보내면, 서버는 "문자열이어야 할 자리에 객체가 왔다"며 거절합니다. 반대로 이미 문자열인 걸 또 loads 없이 인덱싱하려 하면 "문자열엔 그런 키 없다"는 에러가 나고요. 편집 전엔 loads, 전송 전엔 dumps — 이 짝을 기억하세요.→ 글은 썼으니, 이제 내 글 목록을 긁어와 한 번에 지우는 마지막 조각입니다.
6. 목록 수집과 삭제 — parse_qsl와 요청 예절
마지막 조각입니다. 내가 쓴 글만 골라 모은 뒤, 되돌릴 수 없는 삭제를 신중하게 실행합니다. 여기서 URL의 뒷부분을 다루는 도구가 하나 등장해요.
query string (쿼리 스트링): URL의 ? 뒤에 붙는 key=value&key2=value2 부분 — 요청에 딸려 보내는 옵션들입니다(예: ?page=1&pageSize=15).
공식 문서는 이렇게 설명합니다. urllib.parse.parse_qsl은 쿼리 스트링을 파싱해 "이름, 값 쌍의 리스트"로 돌려줍니다. 실습은 이 리스트를 dict()로 감싸 곧바로 딕셔너리로 만들죠.
(출처: docs.python.org — urllib.parse.parse_qsl)
# 공식 문서는 예제 대신 설명 위주 — 동작을 보이는 최소 예시(직접 작성) from urllib.parse import parse_qsl parse_qsl("page=1&pageSize=15") # → [('page', '1'), ('pageSize', '15')] dict(parse_qsl("page=1&pageSize=15")) # → {'page': '1', 'pageSize': '15'}
강의 실습에서는 이렇게 썼습니다. 긴 URL을 ? 기준으로 잘라, 뒷부분을 dict(parse_qsl(...))로 파라미터 딕셔너리로 바꿉니다. 그러면 params["page"] = i처럼 페이지 번호만 갈아 끼우며 반복 수집할 수 있어요.
url, params = url.split("?") # 주소 / 쿼리 분리 params = dict(parse_qsl(params)) # 쿼리 → dict articleList = [] for i in range(1, 20): params["page"] = i # 페이지 번호만 교체 resp = request(url=url, params=params, cookies=cookies, method="GET") page_items = resp.json()["result"]["articleList"] # '내 닉네임' 글만 필터 — 남의 글은 절대 건드리지 않도록 articleList.extend(filter(lambda d: d["item"]["writerInfo"]["nick"] == MY_NICK, page_items)) if len(page_items) < 1: break # 더 없으면 종료
여기서 resp.json()은 3번에서 본 loads의 편의판입니다 — 응답 본문(JSON 문자열)을 곧바로 dict로 풀어 주죠. 공식 문서도 "JSON 응답엔 내장 디코더 r.json()이 있다"고 안내합니다. 삭제 단계는 모은 articleList를 돌며 요청을 보내는데, 두 가지 예절을 지킵니다.
for article in articleList: pid = article["item"]["articleId"] headers["referer"] = f"https://cafe.naver.com/ca-fe/cafes/{CAFE_ID}/articles/{pid}" # 실제 글 주소로 resp = request(url=url, data=params, headers=headers, cookies=cookies, method="POST") print(resp) # <Response [200]> 이면 삭제 성공 sleep(1) # 요청 간격 — 서버 부하/차단 방지
Rate limiting (요청 속도 제한): 서버가 짧은 시간에 몰리는 요청을 막는 장치 — 넘기면 일시 차단당합니다. 그래서 도배 작성엔 sleep(5), 삭제엔 sleep(1)로 간격을 두는 거예요. 또 referer(요청이 어느 페이지에서 출발했는지 알리는 헤더)를 실제 글 주소로 맞춰 줘야 서버가 정상 요청으로 받아들입니다.
filter(... == MY_NICK)로 정말 내 글만 담겼는지 articleList를 먼저 눈으로 확인한 뒤 루프를 돌려야 해요. 코드 순서가 "수집 → 확인 → 삭제"인 건 우연이 아닙니다.page만 바꿔가며 훑는 수집, 그리고 sleep·referer로 "사람처럼 예의 있게" 요청하는 크롤링 매너. 차단당하지 않는 자동화의 기본기입니다.치트시트 & 핵심 5가지
| 용도 | 패턴 |
|---|---|
| 로그인 후 쿠키 추출 | dict(map(lambda r:(r["name"],r["value"]), driver.get_cookies())) |
| 쿠키 붙여 요청 | request(url=u, cookies=cookies, method="GET") |
| 커스텀 헤더 | request(..., headers={"x-cafe-product":"pc"}) |
| JSON 문자열 편집 | d=json.loads(s) → 수정 → s=json.dumps(d) |
| 쿼리 → dict | dict(parse_qsl(url.split("?")[1])) |
| 응답 JSON 파싱 | resp.json()["result"][...] |
| 요청 예절 | sleep(n) + headers["referer"]=... |
1. 역할 분담 — 로그인은 Selenium, 반복 요청은 requests.
2. 쿠키가 신분증 —
get_cookies()로 뽑아 cookies=로 넘기면 "같은 사람".3. 내부 API는 F12로 관찰 — 공식 문서가 없으니 브라우저 Network 탭이 규격서, 헤더까지 그대로 재현.
4. JSON 안의 JSON — 편집 전
loads, 전송 전 dumps.5. 예의와 경계 —
sleep으로 간격, 내 계정·내 글에만, 삭제 전 필터 확인.참고 자료
'AI' 카테고리의 다른 글
| Tidy Data 제대로 이해하기 — 지저분한 표를 pandas로 정돈하기 (0) | 2026.08.31 |
|---|---|
| NumPy는 왜 빠른가 — ndarray 메모리 모델부터 einsum까지 (0) | 2026.08.27 |
| 케어노트AI 시스템 설계 v1 (0) | 2026.08.20 |
| 방문요양 실무자용 AI 업무 Copilot SaaS — 사업화 기획 정리 v2 (0) | 2026.08.20 |
| AI Agent 부트캠프 Day 14 - 브라우저를 코드로 운전하기 — Selenium 핵심 5개념 제대로 잡기 (0) | 2026.08.20 |