Python으로 Goodreads 스크래핑하는 방법 (빈 결과는 이제 그만)

최종 업데이트: July 2, 2026
Python으로 Goodreads 스크래핑하는 방법 (빈 결과는 이제 그만)

Python으로 Goodreads 스크래퍼를 짰는데 30줄을 다 돌린 끝에 돌아온 게 [] 하나뿐일 때. 그 허무함은 겪어본 사람만 알아요. 빈 리스트, 아무 데이터도 없는 화면, 깜빡이는 커서.

이런 호소를 정말 많이 들었어요. Thunderbit 내부 테스트에서도, 개발자 포럼에서도, 멈춰버린 스크래퍼 저장소의 이슈 목록에서도 똑같았죠. 레퍼토리도 거의 정해져 있어요. "상단 리뷰가 통째로 비어요. []만 떨어져요", "페이지 번호를 바꿔도 1페이지만 반복돼요", "작년엔 멀쩡했는데 지금은 안 돌아가요." 게다가 Goodreads API는 2020년 12월에 닫혔어요. 그래서 옛날 튜토리얼이 단골로 말하는 "API 쓰면 됩니다"는 이제 통하지 않는 조언이에요.

제목, 저자, 평점, 리뷰, 장르, ISBN. 이런 구조화된 도서 데이터를 지금 Goodreads에서 얻으려면 사실상 스크래핑밖에 길이 없어요. 이 글에서는 Python으로 Goodreads를 제대로 긁어오는 방법을 처음부터 짚어드릴게요. JS 렌더링, 페이지네이션, 차단 회피, 내보내기까지 다 들어가요. 그리고 Python이 부담스러운 분을 위해 클릭 두 번으로 끝나는 노코드 방식도 마지막에 같이 보여드려요.

Goodreads 스크래핑이란? 그리고 왜 하필 Python일까?

Goodreads 스크래핑은, 손으로 일일이 복사하는 대신 코드로 도서 데이터를 자동 수집하는 작업이에요. 제목, 저자, 평점, 리뷰 수, 장르, ISBN, 페이지 수, 출간일 같은 항목을 한꺼번에 뽑아오죠.

Goodreads는 세계에서 손꼽히는 도서 데이터베이스예요. 회원이 1억 5천만 명을 넘고, 사용자 리뷰도 약 9천만 건에 달해요. "읽고 싶어요" 추가는 매달 1,800만 권씩 쌓여요. 이렇게 끊임없이 갱신되는 구조화 데이터가 있으니, 출판사와 데이터 과학자, 서점 운영자, 연구자가 계속 이 플랫폼을 들여다보는 거예요.

이런 작업에 가장 많이 쓰이는 언어가 Python이에요. 전체 스크래핑 프로젝트의 약 69.6%가 Python으로 진행돼요. requests, BeautifulSoup, Selenium, Playwright, pandas 같은 라이브러리가 잘 갖춰져 있고, 문법도 입문자에게 친숙한 편이에요. 커뮤니티 규모도 압도적이죠.

웹 스크래핑이 처음이라면, 첫 언어로 Python만큼 무난한 선택도 없어요.

왜 Goodreads를 Python으로 긁을까? 실제 활용 사례

코드부터 보기 전에 한 가지 짚고 갈게요. 이 데이터, 누가 왜 필요한 걸까요?

활용 사례주요 사용자수집 데이터
출판 시장 조사출판사, 문학 에이전트인기 장르, 고평점 도서, 떠오르는 작가, 경쟁작 평점
도서 추천 시스템데이터 과학자, 취미 개발자, 앱 제작자평점, 장르, 사용자 서가, 리뷰 감성
가격 및 재고 모니터링이커머스 서점 운영자인기 도서, 리뷰 수, "읽고 싶어요" 수
학술 연구연구자, 학생리뷰 텍스트, 평점 분포, 장르 분류
독서 분석북 블로거, 개인 프로젝트개인 서가 데이터, 독서 기록, 연말 통계

실제 사례도 적지 않아요. 추천 연구에서 가장 자주 인용되는 학술 데이터셋인 UCSD Book Graph만 봐도, 고유 도서 236만 권과 사용자-도서 상호작용 2억 2,800만 건이 담겨 있어요. 전부 공개된 Goodreads 서가에서 수집한 거예요. Kaggle의 goodbooks-10k, Best Books Ever 같은 데이터셋도 출발점은 Goodreads 스크래핑이었어요. 2025년 Big Data and Society 연구는 후원 리뷰가 플랫폼에 미치는 영향을 보려고 인센티브 기반 도서 리뷰 33만 1,211건을 모아 분석하기도 했죠.

상업적 수요도 분명해요. Bright Data는 미리 수집한 Goodreads 데이터를 1,000건당 $0.50(약 700원)부터 팔고 있어요. 이 데이터에 실제 시장 가치가 있다는 신호예요.

Goodreads API는 사라졌어요 — 지금은 뭘 써야 할까요?

"Goodreads API"를 최근에 검색해 봤다면, 십중팔구 철 지난 튜토리얼을 만났을 거예요. 2020년 12월 8일, Goodreads는 새 개발자 API 키 발급을 조용히 끊었어요. 블로그 공지도, 단체 이메일도 없었어요. 문서 페이지에 작은 배너 하나 붙고, 당황한 개발자들만 남았죠.

goodreads-data-access-tools.webp

후폭풍은 곧바로 왔어요. 책 추천 공유용 Discord 봇을 만들던 Kyle K는 *"어느 날 갑자기 그냥 멈춰버렸다"*고 했어요. Matthew Jones는 Reddit r/Fantasy Stabby Awards 투표를 일주일 앞두고 API 접근 권한이 끊겨 Google Forms로 돌아가야 했고요. 대학원생 Elena Neacsu는 석사 논문 프로젝트가 개발 중에 그대로 멈췄어요.

그럼 지금 남은 카드는 뭘까요? 현실적인 선택지를 정리하면 이렇습니다.

방법사용 가능한 데이터사용 난이도속도 제한상태
Goodreads API전체 메타데이터, 리뷰쉬웠음초당 1 요청폐기됨(2020년 12월) — 새 키 발급 없음
Open Library API제목, 저자, ISBN, 표지(~3천만 권)쉬움초당 1~3 요청활성화, 무료, 인증 불필요
Google Books API메타데이터, 미리보기쉬움하루 1,000건 무료활성화됨(비영어 ISBN은 일부 누락)
Python 스크래핑(requests + BS4)초기 HTML에 있는 모든 것중간직접 관리정적 콘텐츠에 적합
Python 스크래핑(Selenium/Playwright)JS 렌더링 콘텐츠까지더 어려움직접 관리리뷰, 일부 목록 수집에 필요
Thunderbit (노코드 Chrome 확장)화면에 보이는 모든 페이지 데이터매우 쉬움(클릭 2번)크레딧 기반활성화 — Python 불필요

Open Library는 ISBN 조회나 기본 메타데이터를 채울 때 꽤 쓸 만한 보완재예요. 단, 평점이나 리뷰, 장르 태그, "읽고 싶어요" 수가 필요하면 결국 Goodreads를 직접 긁어야 해요. Python으로 하든 Thunderbit 같은 도구로 하든 결론은 같죠. Thunderbit는 AI가 제안한 필드로 Goodreads 목록 페이지는 물론 책 상세 하위 페이지까지 수집하고, Google Sheets, Notion, Airtable로 바로 내보낼 수 있어요.

왜 Goodreads 스크래퍼는 빈 결과를 뱉을까? 그리고 어떻게 고칠까?

Goodreads 데이터를 처음 만지던 시절, 누가 이걸 미리 알려줬다면 며칠은 아꼈을 거예요. "빈 결과" 문제는 개발자 포럼 단골 불만이고, 원인이 한둘이 아니에요. 그래서 해결책도 원인별로 따로 있어요.

증상원인해결책
리뷰/평점이 []로 반환됨JS 렌더링 콘텐츠(React/지연 로딩)requests 대신 Selenium 또는 Playwright 사용
항상 1페이지만 수집됨페이지네이션 파라미터 무시 또는 JS 기반?page=N을 정확히 전달하고, 무한 스크롤은 브라우저 자동화 사용
작년엔 됐는데 지금은 실패함Goodreads HTML 클래스명이 바뀜취약한 클래스명 대신 JSON-LD, data-testid 속성 사용
몇 번 요청 후 403/차단 발생헤더 누락 / 요청 속도가 너무 빠름User-Agent 추가, time.sleep() 적용, 프록시 로테이션
서가/목록 페이지에 로그인 벽이 있음쿠키/세션 필요쿠키가 포함된 requests.Session() 또는 브라우저 스크래핑 사용

JS로 렌더링되는 콘텐츠: 리뷰와 평점이 비어 보이는 이유

Goodreads 프런트엔드는 React 기반이에요. 책 페이지에 requests.get()을 날리면 초기 HTML만 손에 들어와요. 리뷰나 평점 분포, "추가 정보" 섹션은 그 뒤에 JavaScript로 비동기 로딩되거든요. 스크래퍼 입장에서는 그 내용이 아예 안 보이는 거죠.

답은 간단해요. JS 렌더링이 필요한 페이지에서는 Selenium이나 Playwright로 갈아타세요. 새로 시작하는 프로젝트라면 Playwright를 권해요. WebSocket 기반 프로토콜이라 Selenium보다 35~45% 빠르고, 기본 스텔스와 비동기 지원도 한 수 위예요.

troubleshooting-empty-array-causes.webp

페이지 1만 계속 가져오는 페이지네이션

이건 꽤 얄궂은 문제예요. 루프 돌리며 ?page=N을 올리는데 매번 같은 결과만 나오는 상황이죠. Goodreads는 인증 안 된 상태면 ?page= 값을 바꿔도 서가 페이지를 슬쩍 1페이지만 돌려줄 때가 있어요. 에러도 리다이렉트도 없이, 첫 페이지가 그냥 반복돼요.

해결책은 인증된 세션 쿠키, 특히 _session_id2를 넣어주는 거예요. 자세한 건 아래 페이지네이션 섹션에서 다룰게요.

작년엔 돌던 코드가 지금은 깨지는 이유

Goodreads는 HTML 클래스명과 페이지 구조를 주기적으로 바꿔요. GitHub의 유명한 maria-antoniak/goodreads-scraper 저장소에는 이제 영구 공지가 박혀 있어요. "이 프로젝트는 유지보수되지 않으며 더 이상 작동하지 않습니다." 답은 더 단단한 선택자를 쓰는 거예요. 쉽게 깨지는 클래스명 말고, schema.org 표준을 따라 거의 안 바뀌는 JSON-LD 구조화 데이터나 data-testid 속성을 쓰세요.

403 오류나 차단

Python requests 라이브러리는 Chrome과 TLS 지문이 달라요. Chrome User-Agent를 끼워 넣어도, Goodreads가 쓰는 Amazon 계열 AWS WAF 같은 봇 탐지 시스템은 이 불일치를 잡아내요. 답은 현실적인 브라우저 헤더를 추가하고, 요청 사이에 3~8초 time.sleep()을 넣고, 대량 작업이라면 TLS 지문을 맞추는 curl_cffi까지 고려하는 거예요.

서가와 목록 페이지의 로그인 벽

일부 Goodreads 서가/목록 페이지는 전체 내용을 보려면 인증이 필요해요. 특히 5페이지를 넘어가면 이 현상이 잦아져요. 브라우저에서 내보낸 쿠키를 requests.Session()에 넣거나, 로그인된 프로필로 Selenium/Playwright를 돌리세요. Thunderbit는 사용자가 로그인한 Chrome 안에서 동작하니 이런 부분을 자연스럽게 넘겨요.

시작하기 전에

  • 난이도: 중급 (기본 Python 지식 필요)
  • 소요 시간: 전체 과정 약 20~30분
  • 준비물:
    • Python 3.8 이상
    • Chrome 브라우저(DevTools 확인 및 Selenium/Playwright용)
    • 라이브러리: requests, beautifulsoup4, selenium 또는 playwright, pandas
    • (선택) Google Sheets 내보내기용 gspread
    • (선택) 노코드 대안용 Thunderbit Chrome 확장 프로그램

goodreads-scraping-flow.webp

1단계: Python 환경 설정하기

먼저 필요한 라이브러리부터 깔아요. 터미널을 열고 아래를 실행하세요.

pip install requests beautifulsoup4 selenium pandas lxml

Playwright가 더 끌린다면(새 프로젝트엔 이쪽 추천):

pip install playwright
playwright install chromium

Google Sheets로 내보낼 거라면(선택):

pip install gspread oauth2client

Python 버전이 3.8 이상인지 python --version으로 확인하세요.

설치가 끝났으면 모든 라이브러리가 오류 없이 import돼야 정상이에요. python -c "import requests, bs4, pandas; print('Ready')"로 점검해 보세요.

2단계: 올바른 헤더로 첫 요청 보내기

브라우저로 Goodreads의 장르 서가나 목록 페이지를 열어보세요. https://www.goodreads.com/list/show/1.Best_Books_Ever 같은 곳이요. 이제 Python으로 그 페이지를 받아옵니다.

import requests
from bs4 import BeautifulSoup

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}

url = "https://www.goodreads.com/list/show/1.Best_Books_Ever"
response = requests.get(url, headers=headers, timeout=15)
print(f"Status: {response.status_code}")

Status: 200이 떠야 해요. 403이 나오면 헤더부터 다시 보세요. Goodreads AWS WAF는 현실적인 User-Agent를 따지고, 너무 헐렁한 요청은 막아요. 위 헤더는 실제 Chrome 세션을 흉내 낸 거예요.

3단계: 페이지 구조 확인하고 올바른 선택자 찾기

Goodreads 목록 페이지에서 Chrome DevTools(F12)를 열어보세요. 책 제목을 우클릭하고 "검사"를 누르면 각 책 항목의 DOM 구조가 보여요.

목록 페이지의 책은 보통 itemtype="http://schema.org/Book"가 붙은 <tr>로 감싸져 있어요. 그 안에 이런 항목이 들어 있죠.

  • 제목: a.bookTitle (링크 텍스트가 제목, href에 책 URL)
  • 저자: a.authorName
  • 평점: span.minirating (평균 평점과 평점 수)
  • 표지 이미지: 책 행 안의 img

개별 책 상세 페이지에서는 CSS 선택자를 억지로 찾지 말고 곧장 JSON-LD를 보세요. Goodreads는 <script type="application/ld+json"> 안에 schema.org Book 형식의 구조화 데이터를 넣어둬요. 수시로 바뀌는 클래스명보다 훨씬 안정적이에요.

4단계: 단일 목록 페이지에서 책 데이터 뽑기

이제 목록 페이지를 파싱해 책마다 기본 정보를 뽑아봅시다.

import requests
from bs4 import BeautifulSoup

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
}

url = "https://www.goodreads.com/list/show/1.Best_Books_Ever"
response = requests.get(url, headers=headers, timeout=15)
soup = BeautifulSoup(response.text, "lxml")

books = []
rows = soup.select('tr[itemtype="http://schema.org/Book"]')

for row in rows:
    title_tag = row.select_one("a.bookTitle")
    author_tag = row.select_one("a.authorName")
    rating_tag = row.select_one("span.minirating")

    title = title_tag.get_text(strip=True) if title_tag else ""
    book_url = "https://www.goodreads.com" + title_tag["href"] if title_tag else ""
    author = author_tag.get_text(strip=True) if author_tag else ""
    rating_text = rating_tag.get_text(strip=True) if rating_tag else ""

    books.append({
        "title": title,
        "author": author,
        "rating_info": rating_text,
        "book_url": book_url,
    })

print(f"페이지 1에서 {len(books)}권을 찾았습니다")
for b in books[:3]:
    print(b)

목록 페이지 하나당 대략 100권이 나와요. 항목마다 제목, 저자, "4.28 avg rating — 9,031,257 ratings" 같은 평점 문자열, 그리고 책 상세 페이지 URL이 담겨 있어요.

5단계: 하위 페이지에서 상세 책 정보 긁기

목록 페이지는 기본 정보만 줘요. 진짜 알짜 데이터—ISBN, 전체 설명, 장르 태그, 페이지 수, 출간일—는 책 상세 페이지에 있어요. 여기서 JSON-LD가 진가를 발휘하죠.

import json
import time

def scrape_book_detail(book_url, headers):
    """단일 책 페이지에 접속해 JSON-LD로 상세 메타데이터를 추출합니다."""
    resp = requests.get(book_url, headers=headers, timeout=15)
    if resp.status_code != 200:
        return {}

    soup = BeautifulSoup(resp.text, "lxml")
    script = soup.find("script", {"type": "application/ld+json"})
    if not script:
        return {}

    data = json.loads(script.string)
    agg = data.get("aggregateRating", {})

    # 장르 태그는 JSON-LD에 없으므로 HTML에서 보완 추출
    genres = [g.get_text(strip=True) for g in soup.select('span.BookPageMetadataSection__genreButton a span')]

    return {
        "isbn": data.get("isbn", ""),
        "pages": data.get("numberOfPages", ""),
        "language": data.get("inLanguage", ""),
        "format": data.get("bookFormat", ""),
        "avg_rating": agg.get("ratingValue", ""),
        "rating_count": agg.get("ratingCount", ""),
        "review_count": agg.get("reviewCount", ""),
        "description": data.get("description", "")[:200],  # 미리보기용으로 자름
        "genres": ", ".join(genres[:5]),
    }

# 예시: 처음 3권 보강하기
for book in books[:3]:
    details = scrape_book_detail(book["book_url"], headers)
    book.update(details)
    print(f"수집 완료: {book['title']} — ISBN: {book.get('isbn', 'N/A')}")
    time.sleep(4)  # 속도 제한을 고려

요청 사이에는 time.sleep()으로 38초씩 쉬어주세요. Goodreads는 한 IP에서 분당 2030회쯤부터 속도 제한을 걸기 시작해요. 너무 몰아치면 403이나 CAPTCHA가 떨어질 수 있어요.

이렇게 목록 페이지에서 책 URL을 먼저 다 모은 다음 상세 페이지를 도는 2단계 방식은 훨씬 안정적이에요. 중간에 멈춰도 재개하기 쉽고요. 잘 돌아가는 Goodreads 스크래퍼가 대체로 쓰는 전략이에요.

참고: Thunderbit는 하위 페이지 스크래핑을 알아서 처리해요. AI가 책 상세 페이지마다 들어가 ISBN, 설명, 장르를 표에 채워줘요. 코드도 루프도 sleep 타이머도 필요 없죠.

6단계: Selenium으로 JavaScript 렌더링 콘텐츠 다루기

리뷰, 평점 분포, "더 많은 정보" 섹션처럼 JavaScript로 뜨는 콘텐츠가 필요하면 브라우저 자동화 도구가 있어야 해요. 아래는 Selenium 예시예요.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                     "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")

driver = webdriver.Chrome(options=options)
driver.get("https://www.goodreads.com/book/show/5907.The_Hobbit")

# 리뷰가 로드될 때까지 대기
try:
    WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "article.ReviewCard"))
    )
except:
    print("리뷰가 로드되지 않았습니다 — 로그인 필요 또는 JS 타임아웃일 수 있습니다")

# 이제 완전히 렌더링된 페이지 파싱
page_source = driver.page_source
soup = BeautifulSoup(page_source, "lxml")

reviews = soup.select("article.ReviewCard")
for rev in reviews[:3]:
    text = rev.select_one("span.Formatted")
    stars = rev.select_one("span.RatingStars")
    print(f"평점: {stars.get_text(strip=True) if stars else 'N/A'}")
    print(f"리뷰: {text.get_text(strip=True)[:150] if text else 'N/A'}...")
    print()

driver.quit()

requests와 Selenium, 언제 뭘 써야 하나요?

  • 책 메타데이터(JSON-LD), 목록 페이지, 서가 페이지(1페이지), Choice Awards 데이터는 requests + BeautifulSoup
  • 리뷰, 평점 분포, 원시 HTML에 없는 콘텐츠는 Selenium 또는 Playwright

새 프로젝트라면 대체로 Playwright가 나아요. 더 빠르고, 메모리도 덜 먹고, 기본 스텔스도 좋아요. 다만 Goodreads 관련 예제는 Selenium 쪽 커뮤니티가 더 크고 기존 코드도 많아요.

진짜로 작동하는 페이지네이션: Goodreads 전체 목록 긁기

페이지네이션은 Goodreads 스크래퍼가 가장 자주 자빠지는 지점이에요. 제대로 설명한 튜토리얼도 거의 못 봤고요. 여기서 똑바로 처리하는 법을 알려드릴게요.

Goodreads 페이지네이션 URL 구조

Goodreads는 대부분의 페이지네이션에 단순한 ?page=N 파라미터를 써요.

  • 목록: https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2
  • 서가: https://www.goodreads.com/shelf/show/thriller?page=2
  • 검색: https://www.goodreads.com/search?q=fantasy&page=2

목록 페이지는 보통 100권, 서가 페이지는 50권씩 보여줘요.

멈출 때를 아는 페이지네이션 루프 작성하기

import time

all_books = []
base_url = "https://www.goodreads.com/list/show/1.Best_Books_Ever"

for page_num in range(1, 50):  # 안전을 위해 최대 50페이지로 제한
    url = f"{base_url}?page={page_num}"
    resp = requests.get(url, headers=headers, timeout=15)

    if resp.status_code != 200:
        print(f"{page_num}페이지: 상태 코드 {resp.status_code}를 받았습니다. 중단합니다.")
        break

    soup = BeautifulSoup(resp.text, "lxml")
    rows = soup.select('tr[itemtype="http://schema.org/Book"]')

    if not rows:
        print(f"{page_num}페이지: 책을 찾지 못했습니다. 마지막 페이지에 도달했습니다.")
        break

    for row in rows:
        title_tag = row.select_one("a.bookTitle")
        author_tag = row.select_one("a.authorName")
        title = title_tag.get_text(strip=True) if title_tag else ""
        book_url = "https://www.goodreads.com" + title_tag["href"] if title_tag else ""
        author = author_tag.get_text(strip=True) if author_tag else ""
        all_books.append({"title": title, "author": author, "book_url": book_url})

    print(f"{page_num}페이지: {len(rows)}권 수집 (누적: {len(all_books)}권)")
    time.sleep(5)  # 페이지 간 5초 대기

print(f"\n완료. 총 {len(all_books)}권 수집")

마지막 페이지는 결과 목록이 비었는지(tr[itemtype="http://schema.org/Book"] 요소가 없는지), 아니면 "next" 링크(a.next_page)가 없는지로 가려낼 수 있어요.

예외 케이스: 5페이지 이후 로그인 필요

거의 다들 여기서 막혀요. 일부 Goodreads 서가/목록 페이지는 인증 없이 6페이지 이상을 요청하면 슬그머니 1페이지 내용을 다시 줘요. 에러도 리다이렉트도 없이 같은 데이터가 반복되죠.

이걸 풀려면 브라우저에서 _session_id2 쿠키를 내보내(쿠키 export 확장 프로그램이나 Chrome DevTools > Application > Cookies 사용) 요청에 넣으세요.

session = requests.Session()
session.headers.update(headers)
session.cookies.set("_session_id2", "YOUR_SESSION_COOKIE_VALUE_HERE", domain=".goodreads.com")

# 이제 requests.get() 대신 session.get() 사용
resp = session.get(f"{base_url}?page=6", timeout=15)

Thunderbit는 클릭형 페이지네이션과 무한 스크롤을 둘 다 기본으로 처리해요. 코드도 쿠키 관리도 필요 없죠. 페이지네이션 로직이 자꾸 깨진다면 한 번 검토해볼 만해요.

복붙해서 바로 쓰는 완성형 Python 스크립트

아래는 헤더, 페이지네이션, JSON-LD 하위 페이지 수집, 속도 제한, CSV 내보내기를 한데 묶은 통합 스크립트예요. 2025년 중반 기준 실제 Goodreads 페이지에서 테스트했어요.

"""
goodreads_scraper.py — Goodreads 목록을 페이지네이션과 상세 페이지 보강까지 포함해 스크래핑합니다.
사용법: python goodreads_scraper.py
출력: goodreads_books.csv
"""

import csv, json, time, requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
BASE_URL = "https://www.goodreads.com/list/show/1.Best_Books_Ever"
MAX_PAGES = 3          # 필요에 따라 조정
DELAY_LISTING = 5      # 목록 페이지 간 대기 시간(초)
DELAY_DETAIL = 4       # 상세 페이지 간 대기 시간(초)
OUTPUT_FILE = "goodreads_books.csv"


def scrape_listing_page(url):
    """한 목록 페이지에서 title, author, book_url을 딕셔너리 리스트로 반환합니다."""
    resp = requests.get(url, headers=HEADERS, timeout=15)
    if resp.status_code != 200:
        return []
    soup = BeautifulSoup(resp.text, "lxml")
    rows = soup.select('tr[itemtype="http://schema.org/Book"]')
    books = []
    for row in rows:
        t = row.select_one("a.bookTitle")
        a = row.select_one("a.authorName")
        if t:
            books.append({
                "title": t.get_text(strip=True),
                "author": a.get_text(strip=True) if a else "",
                "book_url": "https://www.goodreads.com" + t["href"],
            })
    return books


def scrape_book_detail(book_url):
    """책 페이지에 접속해 JSON-LD + HTML 보완 정보로 메타데이터를 추출합니다."""
    resp = requests.get(book_url, headers=HEADERS, timeout=15)
    if resp.status_code != 200:
        return {}
    soup = BeautifulSoup(resp.text, "lxml")
    script = soup.find("script", {"type": "application/ld+json"})
    if not script:
        return {}
    data = json.loads(script.string)
    agg = data.get("aggregateRating", {})
    genres = [g.get_text(strip=True)
              for g in soup.select("span.BookPageMetadataSection__genreButton a span")]
    return {
        "isbn": data.get("isbn", ""),
        "pages": data.get("numberOfPages", ""),
        "avg_rating": agg.get("ratingValue", ""),
        "rating_count": agg.get("ratingCount", ""),
        "review_count": agg.get("reviewCount", ""),
        "description": (data.get("description", "") or "")[:300],
        "genres": ", ".join(genres[:5]),
        "language": data.get("inLanguage", ""),
        "format": data.get("bookFormat", ""),
        "published": data.get("datePublished", ""),
    }


def main():
    all_books = []

    # --- 1차: 목록 페이지에서 책 URL 수집 ---
    for page in range(1, MAX_PAGES + 1):
        url = f"{BASE_URL}?page={page}"
        page_books = scrape_listing_page(url)
        if not page_books:
            print(f"{page}페이지: 비어 있음 — 페이지네이션 중단.")
            break
        all_books.extend(page_books)
        print(f"{page}페이지: {len(page_books)}권 (누적: {len(all_books)}권)")
        time.sleep(DELAY_LISTING)

    # --- 2차: 각 책 상세 정보 보강 ---
    for i, book in enumerate(all_books):
        details = scrape_book_detail(book["book_url"])
        book.update(details)
        print(f"[{i+1}/{len(all_books)}] {book['title']} — ISBN: {book.get('isbn', 'N/A')}")
        time.sleep(DELAY_DETAIL)

    # --- CSV 내보내기 ---
    if all_books:
        fieldnames = list(all_books[0].keys())
        with open(OUTPUT_FILE, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            writer.writerows(all_books)
        print(f"\n총 {len(all_books)}권을 {OUTPUT_FILE}에 저장했습니다")
    else:
        print("수집된 책이 없습니다.")


if __name__ == "__main__":
    main()

MAX_PAGES = 3 설정이면 이 스크립트는 "Best Books Ever" 목록에서 약 300권을 모으고, 책마다 상세 페이지를 돈 뒤 CSV로 저장해요. 제 환경에서는 약 25분이 걸렸어요(대부분 상세 페이지 요청 사이의 4초 지연 탓이에요). 출력 CSV에는 title, author, book_url, isbn, pages, avg_rating, rating_count, review_count, description, genres, language, format, published 같은 열이 들어가요.

CSV를 넘어 Google Sheets로: gspread 쓰기

CSV 대신, 또는 CSV와 함께 Google Sheets로 보내고 싶다면 CSV 저장 뒤에 다음 코드를 붙이세요.

import gspread
from oauth2client.service_account import ServiceAccountCredentials

scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"]
creds = ServiceAccountCredentials.from_json_keyfile_name("credentials.json", scope)
client = gspread.authorize(creds)

sheet = client.open("Goodreads Scrape").sheet1
header = list(all_books[0].keys())
sheet.append_row(header)
for book in all_books:
    sheet.append_row([str(book.get(k, "")) for k in header])
print("데이터가 Google Sheets로 전송되었습니다.")

Sheets와 Drive API가 켜진 Google Cloud 서비스 계정이 필요해요. gspread 문서대로 따라가면 5분이면 끝나요. 수백 행 넘게 보낼 땐 append_rows()처럼 배치 작업을 쓰세요. Google 요청 한도는 프로젝트당 60초에 300건이에요.

물론 이 설정 자체가 번거롭다면, Thunderbit는 클릭 한 번으로 Google Sheets, Airtable, Notion, Excel, CSV, JSON으로 내보내요. 라이브러리 설치도, 자격 증명 파일도, API 쿼터도 필요 없죠.

노코드 대안: Thunderbit로 Goodreads 긁기

누구나 Python 스크립트를 손수 관리하고 싶은 건 아니에요. 한 번 하고 말 시장 조사일 수도 있고, 올해 베스트셀러 목록 하나만 필요할 수도 있죠. 바로 그런 순간을 위해 Thunderbit를 만들었어요.

Thunderbit로 Goodreads 긁는 방법

  1. Chrome Web Store에서 Thunderbit Chrome 확장 프로그램을 설치하고 Goodreads 목록, 서가, 검색 결과 페이지로 이동하세요.
  2. Thunderbit 사이드바에서 **"AI 필드 제안"**을 클릭해요. AI가 페이지를 읽고 보통 제목, 저자, 평점, 표지 이미지 URL, 책 링크 같은 열을 추천해요.
  3. **"스크래핑"**을 누르면 몇 초 만에 구조화된 테이블로 데이터가 떨어져요.
  4. Google Sheets, Excel, Airtable, Notion, CSV, JSON으로 내보내요.

ISBN, 설명, 장르, 페이지 수 같은 상세 데이터가 필요하면, Thunderbit의 하위 페이지 스크래핑이 책 상세 페이지마다 들어가 표를 알아서 채워줘요. 루프도, sleep 타이머도, 디버깅도 없어요.

여러 페이지로 나뉜 목록도 Thunderbit가 자연스럽게 다뤄요. "다음"을 누르거나 스크롤하라고 일러두면, 코드 없이 모든 페이지 데이터를 모아줘요.

트레이드오프는 단순해요. Python 스크립트는 완전한 제어와 무료 사용(시간은 빼고)을 주지만, Thunderbit는 약간의 유연성을 내주는 대신 엄청난 시간 절약과 유지보수 제로를 줘요. 300권짜리 목록이면 Python 스크립트는 실행만 약 25분이고, 작성과 디버깅 시간은 별도예요. Thunderbit는 클릭 두 번으로 같은 데이터를 약 3분이면 받아요.

Goodreads 스크래핑용 Thunderbit 사용해 보기

책임감 있게 Goodreads 긁기: robots.txt, 이용약관, 윤리

이건 대충 넘길 면책 문구가 아니라 똑바로 짚어야 할 부분이에요.

Goodreads robots.txt가 실제로 말하는 것

Goodreads 최신 robots.txt는 의외로 구체적이에요. 책 상세 페이지(/book/show/), 공개 목록(/list/show/), 공개 서가(/shelf/show/), 저자 페이지(/author/show/)는 막혀 있지 않아요. 반대로 /api, /book/reviews/, /review/list, /review/show, /search는 차단돼요. GPTBot과 CCBot(Common Crawl)은 Disallow: /로 전면 차단이고요. bingbot에는 Crawl-delay: 5가 있지만, 전역 지연 설정은 없어요.

Goodreads 이용약관을 쉽게 풀면

이용약관(2021년 4월 28일 최종 수정)은 "데이터 마이닝, 로봇, 또는 유사한 데이터 수집·추출 도구의 사용"을 금지해요. 표현이 워낙 넓어서 가볍게 볼 일은 아니에요. 다만 법원은 이용약관 위반만으로 형사상 "무단 접근"이 성립한다고 본 적이 없어요. Sandvig v. Barr 판결은 "이용약관 위반을 형사처벌로 보면 모든 웹사이트가 제각각 형사 관할이 돼버릴 위험이 있다"고 짚었죠.

권장 사항

  • 요청 사이 지연 두기: 3~8초 간격(robots.txt 봇 기준은 5초)
  • 하루 5,000 요청 이하 유지: 단일 IP 기준
  • 공개 페이지만 수집: 로그인 전용 데이터를 대량으로 긁지 않기
  • 리뷰 원문을 상업적으로 재배포하지 않기: 리뷰는 저작권 있는 창작물
  • 필요한 데이터만 저장하고 보관 정책 세우기: 무분별한 저장은 피하기
  • 개인 연구 vs 상업적 이용: 공개 데이터를 개인 분석이나 학술 연구로 쓰는 건 대체로 용인돼요. 법적 리스크가 커지는 지점은 상업적 재배포예요.

Thunderbit처럼 사용자의 브라우저 세션으로 수집하는 도구를 써도, 화면상으로는 일반 브라우징과 크게 다르지 않아요. 단, 어떤 도구를 쓰든 윤리 원칙은 똑같아요. 웹 스크래핑의 법적 의미는 따로 정리해 뒀으니 궁금하면 참고하세요.

팁과 흔한 함정

팁: 항상 JSON-LD부터 보세요. 복잡한 CSS 선택자를 짜기 전에, 필요한 데이터가 <script type="application/ld+json"> 안에 있는지 먼저 확인하세요. 더 안정적이고, 파싱도 쉽고, Goodreads가 프런트엔드를 바꿔도 덜 깨져요.

팁: 2단계 전략을 쓰세요. 목록 페이지에서 책 URL을 다 모은 뒤에 상세 페이지를 도세요. 중간에 스크래퍼가 죽어도 재개하기 쉽고, URL 목록을 디스크에 체크포인트로 저장할 수도 있어요.

함정: 누락 필드를 안 챙기기. 모든 책 페이지에 ISBN, 장르 태그, 설명이 다 있는 건 아니에요. 항상 기본값과 함께 .get()을 쓰거나 선택자를 if로 감싸세요. NoneType 오류 하나로 3시간짜리 수집이 통째로 날아갈 수 있어요.

함정: 너무 빨리 돌리기. time.sleep(0.5)로 후딱 끝내고 싶은 마음은 알아요. 그런데 Goodreads는 빠른 요청 2030번쯤부터 403을 내기 시작하고, 한 번 걸리면 몇 시간을 기다리거나 IP를 바꿔야 할 수도 있어요. 45초 지연이 가장 무난해요.

함정: 오래된 튜토리얼 믿기. Goodreads API를 언급하거나 .field.value, #bookTitle 같은 클래스명을 쓰는 가이드는 낡았을 가능성이 커요. 스크래퍼를 짜기 전에 항상 실제 페이지에서 선택자를 다시 확인하세요.

알맞은 스크래핑 도구와 프레임워크를 고르는 법이 궁금하면, 최고의 Python 웹 스크래핑 도구Python으로 웹 스크래핑하는 방법 가이드도 같이 보세요.

결론 및 핵심 정리

Python으로 Goodreads를 긁는 건 충분히 할 만해요. 어디에 함정이 있는지만 알면 되거든요. 짧게 정리하면 이래요.

  • Goodreads API는 사라졌어요(2020년 12월 이후). 구조화된 책 데이터를 얻는 주된 길은 스크래핑이에요.
  • 빈 결과는 코드가 틀려서가 아니라, JS 렌더링 콘텐츠, 낡은 선택자, 헤더 누락, 페이지네이션 인증 문제 탓인 경우가 많아요.
  • JSON-LD는 책 메타데이터의 가장 든든한 친구예요. 안정적이고 구조화돼 있으며 거의 안 바뀌어요.
  • 페이지네이션은 많은 서가/목록 페이지에서 5페이지 이후 인증이 필요해요. _session_id2 쿠키를 넣으세요.
  • 속도 제한은 실제로 있어요. 3~8초 지연을 쓰고 하루 5,000 요청 이하로 유지하세요.
  • 2단계 전략(URL 먼저 모으고, 그다음 상세 페이지)이 더 안정적이고 재개도 쉬워요.
  • 코딩이 부담스러운 분(또는 오후 시간을 아끼고 싶은 분)이라면, Thunderbit가 JS 렌더링, 페이지네이션, 하위 페이지 보강, 내보내기를 클릭 두 번으로 끝내줘요.

책 데이터는 책임감 있게 모으고, robots.txt를 존중하세요. 그리고 여러분 결과가 더는 []가 아니길 바라요.

자주 묻는 질문(FAQs)

아직 Goodreads API를 쓸 수 있나요?

아니요. Goodreads는 2020년 12월에 공개 API를 폐기했고, 새 개발자 키도 더는 발급하지 않아요. 30일간 안 쓴 기존 키도 자동으로 비활성화됐고요. 지금 책 데이터에 프로그래밍으로 접근하려면 웹 스크래핑이나 Open Library, Google Books 같은 대체 API를 써야 해요.

왜 Goodreads 스크래퍼가 빈 결과를 주나요?

가장 흔한 원인은 JavaScript 렌더링 콘텐츠예요. Goodreads는 리뷰, 평점 분포, 상세 섹션을 React/JavaScript로 불러오는데, 단순한 requests.get()으로는 이게 안 보여요. 이런 페이지엔 Selenium이나 Playwright를 쓰세요. 그 밖에 낡은 CSS 선택자(Goodreads가 HTML을 바꾼 경우), User-Agent 헤더 누락(403 차단), 인증 안 된 페이지네이션 서가 페이지도 원인일 수 있어요.

Goodreads를 스크래핑하는 건 합법인가요?

공개 데이터를 개인용·연구용으로 긁는 건 현재 판례(hiQ v. LinkedIn, Meta v. Bright Data)상 대체로 허용되는 편이에요. 다만 Goodreads 이용약관은 자동 수집을 금지하고, robots.txt도 꼭 확인해야 해요. 저작권 있는 리뷰 텍스트의 상업적 재배포는 피하고, 사이트 자원에 부담을 주지 않게 요청량을 제한하세요.

Goodreads에서 여러 페이지를 어떻게 긁나요?

서가나 목록 URL에 ?page=N을 붙여 페이지 번호를 반복하면 돼요. 마지막 페이지는 결과가 비었거나 "next" 링크가 없는지로 감지하고요. 다만 일부 서가 페이지는 5페이지 이후 결과를 받으려면 인증이 필요해요. _session_id2 쿠키가 없으면 페이지 1 데이터가 슬쩍 반복돼요.

코딩 없이 Goodreads를 긁을 수 있나요?

네. Thunderbit는 Goodreads를 클릭 두 번으로 긁는 Chrome 확장 프로그램이에요. AI가 데이터 필드를 제안하고, 사용자는 "스크래핑"만 누르면 Google Sheets, Excel, Airtable, Notion으로 바로 내보낼 수 있어요. JS 렌더링, 페이지네이션, 하위 페이지 보강까지 알아서 처리하고, Python이나 코딩은 전혀 필요 없어요.

더 알아보기

Shuai Guan
Shuai Guan
Thunderbit CEO | AI 데이터 자동화 전문가 Shuai Guan은 Thunderbit의 CEO이자 University of Michigan 공학과 출신입니다. 10년 가까운 기술 및 SaaS 아키텍처 경험을 바탕으로, 복잡한 AI 모델을 누구나 바로 활용할 수 있는 노코드 데이터 추출 도구로 바꾸는 데 전문성을 갖고 있습니다. 이 블로그에서는 웹 스크래핑과 자동화 전략에 대한 필터링 없는 실전 경험과 검증된 인사이트를 공유하며, 더 똑똑하고 데이터 중심적인 워크플로를 만드는 데 도움을 드립니다. 데이터 워크플로를 최적화하지 않을 때는, 같은 꼼꼼함으로 사진이라는 취미에 몰두합니다.
목차

말 한마디로 웹페이지를 스크래핑하세요

원하는 걸 평범한 영어로 말하면 됩니다. 아니면 아무 말도 안 해도 괜찮아요.

Thunderbit 사용해보기 무료
AI로 데이터 추출하기
Google Sheets, Airtable, Notion으로 데이터를 손쉽게 منتقل하세요
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week