Reddit 데이터 스크래핑, 왜 다시 주목받고 있나?
2023년 7월, Reddit은 API 가격 정책을 대폭 변경했습니다. 이전까지 무료였던 데이터 액세스가 월 100건 요청당 $0.24 수준으로 책정되면서, 대규모 데이터를 수집하는 팀에게는 연간 수만 달러의 비용이 발생하게 되었습니다. 이를 계기로 비용에 민감한 데이터 팀, 시장 조사 분석가, 감성 분석 프로젝트에서 Reddit 데이터 스크래핑이 현실적인 대안으로 재조명되고 있습니다.
하지만 Reddit은 스크래핑에 대해 점진적으로 강력한 제한을 도입하고 있습니다. 속도 제한, IP 차단, 429→403 에스컬레이션 패턴까지 — 성공적인 데이터 수집을 위해서는 프록시 전략이 필수입니다.
⚠️ 법적 고지: 이 글은 공개적으로 접근 가능한 데이터에 한한 합법적 수집만을 다룹니다. Reddit의 이용약관(Terms of Service)을 준수하고, 미국 CFAA, EU GDPR 등 관할 법령을 존중하세요. 로그인이 필요한 비공개 콘텐츠는 수집하지 마십시오.
Reddit API 환경 변화: 2023년 이후
유료화의 구체적 영향
변경 전 Reddit API는 사실상 무료였습니다. OAuth 인증 후 1분당 60건, 분당 100건 수준의 요청이 무료로 가능했습니다. 2023년 신규 가격 정책은:
- 무료 티어: 앱당 100건/분 쿼리 (데이터 수집 목적에는 부적합)
- 유료 티어: 1,000건 요청당 약 $0.24 — 대량 수집 시 월 수천~수만 달러
- 데이터 파이프라인 접근: 별도 계약 필요, 일반 개발자에게는 사실상 불가
이로 인해 소규모 데이터 팀, 학술 연구자, 스타트업에서는 공식 API 대신 공개 웹 페이지 스크래핑으로 눈을 돌리게 되었습니다.
공식 API vs 스크래핑: 현재 상황 비교
| 기준 | 공식 Reddit API | 공개 페이지 스크래핑 |
|---|---|---|
| 비용 | 대량 시 $0.24/1K 요청 | 프록시 비용만 발생 |
| 속도 제한 | 100건/분 (무료) | IP당 수십 건/분 후 차단 |
| 데이터 형식 | 구조화된 JSON | HTML 파싱 필요 |
| 인증 필요 | OAuth 토큰 필수 | 불필요 (공개 데이터) |
| 비공개 서브레딧 | 접근 가능 (권한 시) | 불가 |
| 안정성 | 높음 (공식 지원) | HTML 구조 변경 시 대응 필요 |
접근 가능한 공개 데이터: 무엇을 수집할 수 있나?
Reddit에서 로그인 없이 공개적으로 접근할 수 있는 데이터는 다음과 같습니다:
- 서브레딧 피드:
https://www.reddit.com/r/subreddit/— 인기/최신 게시물 목록 - 개별 게시물:
https://www.reddit.com/r/subreddit/comments/abc123/post_title/— 제목, 본문, 점수, 타임스탬프 - 댓글 스레드: 동일 URL에
.json확장자로 JSON 데이터 접근 가능 (공개 게시물 한정) - 검색 결과:
https://www.reddit.com/search/?q=keyword&t=year - 사용자 공개 프로필:
https://www.reddit.com/user/username/
old.reddit.com: 스크래핑 친화적 대안
old.reddit.com은 레거시 인터페이스로, 현대적인 React 기반 UI 대신 서버 사이드 렌더링된 정적 HTML을 제공합니다. 스크래핑 관점에서 이점이 많습니다:
- JavaScript 렌더링 불필요 —
requests만으로 충분 - HTML 구조가 더 단순하고 안정적
- 속도 제한이
www.reddit.com과 별도로 관리될 수 있음 - 봇 감지 시스템이 상대적으로 덜 공격적
다만 old.reddit.com은 일부 최신 기능(예: 이미지 갤러리, 채팅)을 지원하지 않으므로, 수집 대상에 따라 적절히 선택해야 합니다.
로그인 없이 접근 가능 vs 로그인 필요 데이터
접근 가능 (공개): 공개 서브레딧의 게시물/댓글, 사용자 공개 프로필, 검색 결과
접근 불가 (로그인 필요): 비공개/초대 전용 서브레딧, 개인 메시지, 저장/숨김 목록, 채팅 기록
프록시 선택: 데이터센터 vs 주거 IP
데이터센터 프록시: 저용량에 적합
데이터센터 프록시는 빠르고 저렴하지만, IP 대역이 봇으로 식별되기 쉽습니다. 소규모 테스트, 1일 1,000건 이하의 경량 수집에는 적합할 수 있습니다.
주거용 프록시: 본격 수집에 필수
Reddit은 데이터센터 IP 대역을 적극적으로 차단합니다. Reddit 주거용 프록시를 사용하면 실제 가정용 IP에서 요청하는 것과 동일하게 보이므로, 대량 수집 시 차단 확률이 크게 낮아집니다.
모바일 프록시: 추가 우회 옵션
Reddit의 모바일 엔드포인트(m.reddit.com, API)는 별도의 속도 제한 정책을 가질 수 있습니다. 모바일 프록시는 이 경로로 접근할 때 자연스러운 지문을 제공합니다.
| 프록시 유형 | 속도 | 차단 위험 | 권장 사용량 | 가격대 |
|---|---|---|---|---|
| 데이터센터 | 매우 빠름 | 높음 | <1K 요청/일 | 저가 |
| 주거용 (회전) | 보통 | 낮음 | 10K~100K 요청/일 | 중가 |
| 주거용 (스티키) | 보통 | 낮음 | 세션 유지 필요 시 | 중가 |
| 모바일 | 보통~느림 | 매우 낮음 | 모바일 엔드포인트 | 고가 |
Python 실전 구현: old.reddit.com + 회전 주거용 프록시
다음 예제는 old.reddit.com에서 서브레딧의 게시물 목록을 수집하고, ProxyHat 주거용 프록시로 IP를 회전하는 방법을 보여줍니다.
기본 스크래퍼
import requests
from bs4 import BeautifulSoup
import time
import random
# ProxyHat 주거용 프록시 설정
PROXY_URL = "http://user-country-US:YOUR_PASSWORD@gate.proxyhat.com:8080"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
def fetch_subreddit(subreddit, sort="hot", limit=25):
"""old.reddit.com에서 서브레딧 게시물 목록 수집"""
url = f"https://old.reddit.com/r/{subreddit}/{sort}/?limit={limit}"
proxies = {"http": PROXY_URL, "https": PROXY_URL}
try:
resp = requests.get(url, headers=HEADERS, proxies=proxies, timeout=15)
resp.raise_for_status()
return parse_listings(resp.text)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
print(f"속도 제한 감지 — 요청 간격을 늘리세요")
elif e.response.status_code == 403:
print(f"IP 차단 — 프록시 회전 필요")
return []
def parse_listings(html):
"""old.reddit.com HTML에서 게시물 정보 추출"""
soup = BeautifulSoup(html, "html.parser")
posts = []
for thing in soup.select("div.thing"):
title_el = thing.select_one("a.title")
score_el = thing.select_one("div.score.unvoted")
time_el = thing.select_one("time")
author_el = thing.select_one("a.author")
if not title_el:
continue
posts.append({
"title": title_el.get_text(strip=True),
"url": title_el["href"],
"score": score_el.get("title", "0") if score_el else "0",
"author": author_el.get_text(strip=True) if author_el else "[deleted]",
"created_utc": time_el["datetime"] if time_el else None,
"id": thing.get("data-fullname", "").replace("t3_", ""),
})
return posts
# 실행 예시
if __name__ == "__main__":
results = fetch_subreddit("datascience", sort="hot", limit=25)
for post in results:
print(f"[{post['score']}] {post['title']}")IP 회전이 포함된 다중 서브레딧 수집
import requests
from bs4 import BeautifulSoup
import time
import random
SUBREDDITS = ["datascience", "machinelearning", "technology", "startups"]
BASE_PROXY = "http://user-country-US:YOUR_PASSWORD@gate.proxyhat.com:8080"
HEADERS_LIST = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
]
def scrape_with_backoff(subreddit, max_retries=3):
"""지수 백오프와 함께 서브레딧 수집"""
proxies = {"http": BASE_PROXY, "https": BASE_PROXY}
for attempt in range(max_retries):
headers = {
"User-Agent": random.choice(HEADERS_LIST),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
url = f"https://old.reddit.com/r/{subreddit}/hot/?limit=25"
try:
resp = requests.get(url, headers=headers, proxies=proxies, timeout=15)
if resp.status_code == 200:
return parse_listings(resp.text)
elif resp.status_code == 429:
wait = (2 ** attempt) + random.uniform(1, 5)
print(f" 429 감지 — {wait:.1f}초 대기 후 재시도")
time.sleep(wait)
elif resp.status_code == 403:
print(f" 403 차단 — 프록시 회전 필요")
return []
else:
print(f" 예상치 못한 상태 코드: {resp.status_code}")
return []
except requests.exceptions.RequestException as e:
print(f" 요청 오류: {e}")
time.sleep(2 ** attempt)
return []
def run_multi_subreddit():
"""여러 서브레딧을 순회하며 수집"""
all_data = {}
for sub in SUBREDDITS:
print(f"수집 중: r/{sub}")
posts = scrape_with_backoff(sub)
all_data[sub] = posts
print(f" → {len(posts)}개 게시물 수집")
# 서브레딧 간 3~6초 대기
time.sleep(random.uniform(3, 6))
return all_data
if __name__ == "__main__":
data = run_multi_subreddit()
for sub, posts in data.items():
print(f"\nr/{sub}: {len(posts)} posts")댓글 스레드 수집
def fetch_comments(subreddit, post_id, limit=50):
"""특정 게시물의 댓글 수집""n url = f"https://old.reddit.com/r/{subreddit}/comments/{post_id}/"
proxies = {"http": BASE_PROXY, "https": BASE_PROXY}
headers = {
"User-Agent": random.choice(HEADERS_LIST),
"Accept": "text/html,application/xhtml+xml",
}
resp = requests.get(url, headers=headers, proxies=proxies, timeout=20)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
comments = []
for comment in soup.select("div.comment"):
author_el = comment.select_one("a.author")
text_el = comment.select_one("div.usertext-body")
score_el = comment.select_one("span.score")
comments.append({
"author": author_el.get_text(strip=True) if author_el else "[deleted]",
"text": text_el.get_text(strip=True)[:500] if text_el else "",
"score": score_el.get_text(strip=True) if score_el else "0",
})
return comments[:limit]Node.js 구현 예제
const axios = require('axios');
const cheerio = require('cheerio');
const PROXY_URL = 'http://user-country-US:YOUR_PASSWORD@gate.proxyhat.com:8080';
async function scrapeSubreddit(subreddit) {
const url = `https://old.reddit.com/r/${subreddit}/hot/?limit=25`;
const resp = await axios.get(url, {
proxy: {
host: 'gate.proxyhat.com',
port: 8080,
auth: {
username: 'user-country-US',
password: 'YOUR_PASSWORD',
},
},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
+ 'AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36',
},
timeout: 15000,
});
const $ = cheerio.load(resp.data);
const posts = [];
$('div.thing').each((_, el) => {
const title = $(el).find('a.title').text().trim();
const score = $(el).find('div.score.unvoted').attr('title') || '0';
const author = $(el).find('a.author').text().trim() || '[deleted]';
if (title) {
posts.push({ title, score, author });
}
});
return posts;
}
// 실행
scrapeSubreddit('datascience')
.then(posts => console.log(posts))
.catch(err => console.error('오류:', err.message));속도 제한 대응: 429에서 403으로의 에스컬레이션
Reddit의 속도 제한은 단계적으로 강화됩니다. 이 패턴을 이해하는 것이 성공적인 수집의 핵심입니다.
속도 제한 계층 구조
- 1단계 — 429 Too Many Requests: 첫 번째 경고. 일정 시간 대기 후 재시도 가능.
- 2단계 — 403 Forbidden: 동일 IP에서 지속적 요청 시 발생. IP가 블랙리스트에 등록됨.
- 3단계 — CAPTCHA 챌린지: 브라우저에서는 시각적 챌린지, API에서는 차단 응답.
이 429→403 에스컬레이션 패턴은 매우 중요합니다. 429를 무시하고 계속 요청하면 403으로 승격되며, 한번 403을 받은 IP는 복구가 어렵습니다.
IP당 속도 제한 가이드라인
- 보수적 접근: IP당 1분에 30건 이하 요청
- 안전 마진: 429 수신 시 즉시 60초 이상 대기
- IP 회전: 주거용 회전 프록시 사용 시 요청마다 다른 IP 할당
- User-Agent 다양화: 동일 IP에서 동일 UA 조합이 반복되면 지문 추적 가능
class RedditScraper:
"""속도 제한 인식 Reddit 스크래퍼"""
def __init__(self, proxy_url, min_delay=2.0, max_delay=5.0):
self.proxy_url = proxy_url
self.min_delay = min_delay
self.max_delay = max_delay
self.consecutive_429s = 0
def request_with_rate_limit(self, url, max_retries=3):
proxies = {"http": self.proxy_url, "https": self.proxy_url}
headers = {
"User-Agent": random.choice(HEADERS_LIST),
"Accept": "text/html,application/xhtml+xml",
}
for attempt in range(max_retries):
try:
resp = requests.get(url, headers=headers, proxies=proxies, timeout=15)
if resp.status_code == 200:
self.consecutive_429s = 0
delay = random.uniform(self.min_delay, self.max_delay)
time.sleep(delay)
return resp
elif resp.status_code == 429:
self.consecutive_429s += 1
# 429가 연속으로 발생하면 대기 시간 기하급수적 증가
backoff = min(300, 60 * (2 ** self.consecutive_429s))
print(f"429 감지 ({self.consecutive_429s}회 연속) — {backoff}초 대기")
time.sleep(backoff)
elif resp.status_code == 403:
print("403 — IP 차단. 즉시 중단합니다.")
return None
else:
print(f"예상치 못한 상태: {resp.status_code}")
time.sleep(5)
except requests.exceptions.RequestException as e:
print(f"네트워크 오류: {e}")
time.sleep(10)
return None지리적 타겟팅과 세션 관리
Reddit은 일부 콘텐츠와 광고를 지역에 따라 다르게 제공합니다. 특정 국가의 사용자 관점에서 데이터를 수집하려면 주거용 프록시의 지리적 타겟팅이 유용합니다.
# 미국 IP로 수집
US_PROXY = "http://user-country-US:YOUR_PASSWORD@gate.proxyhat.com:8080"
# 독일 IP로 수집 (베를린)
DE_PROXY = "http://user-country-DE-city-berlin:YOUR_PASSWORD@gate.proxyhat.com:8080"
# 일본 IP로 수집
JP_PROXY = "http://user-country-JP:YOUR_PASSWORD@gate.proxyhat.com:8080"
# 세션 유지가 필요한 경우 (로그인 상태 시뮬레이션 등)
STICKY_PROXY = "http://user-country-US-session-abc123:YOUR_PASSWORD@gate.proxyhat.com:8080"세션 플래그(session-abc123)를 사용하면 설정된 시간 동안 동일한 IP가 유지되어, 여러 페이지를 탐색하는 자연스러운 패턴을 시뮬레이션할 수 있습니다.
핑거프린트 리스크와 대응
Reddit은 IP 주소 외에도 다음 지문 요소를 조합하여 봇을 감지합니다:
- User-Agent 일관성: 매 요청마다 다른 UA를 사용하면 의심스럽지만, 너무 단순한 UA도 감지 대상
- TLS 지문: Python
requests의 기본 TLS 지문은 브라우저와 다름 —curl_cffi또는tls_client라이브러리 고려 - 요청 패턴: 규칙적인 간격보다 무작위 지연이 자연스러움
- Referer 헤더: Reddit 내부 링크에서 온 것처럼 Referer 설정
- 쿠키 동작: 브라우저처럼 쿠키를 저장하고 전송하는지 여부
모범 사례: 안정적이고 윤리적인 수집
1. 현실적인 User-Agent 설정
Reddit은 python-requests/2.x 같은 기본 UA를 즉시 차단합니다. 항상 실제 브라우저 UA 문자열을 사용하고, 연구 목적이라면 Reddit의 권장 형식을 따르세요:
# Reddit이 권장하는 봇 UA 형식 (공개 데이터 연구용)
headers = {
"User-Agent": "ResearchBot/1.0 (contact: your@email.com) "
"Python/3.11 requests/2.31",
}2. 속도 제한 준수
- IP당 분당 30건 이하로 유지
- 429 응답 수신 시 최소 60초 대기
- 서브레딧 간 3~6초 무작위 지연 삽입
- 대량 수집 시 여러 IP로 분산
3. 공격적 캐싱
동일한 데이터를 반복 수집하지 마세요. 로컬 캐시를 구현하여:
- 이미 수집한 게시물 ID 건너뛰기
- 24시간 이내 데이터는 재수집하지 않기
- SQLite 또는 Redis에 수집 결과 저장
import hashlib
import sqlite3
from datetime import datetime, timedelta
class ScraperCache:
def __init__(self, db_path="reddit_cache.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
url_hash TEXT PRIMARY KEY,
fetched_at TIMESTAMP,
data TEXT
)
""")
def is_fresh(self, url, max_age_hours=24):
url_hash = hashlib.md5(url.encode()).hexdigest()
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
row = self.conn.execute(
"SELECT fetched_at FROM cache WHERE url_hash = ?",
(url_hash,)
).fetchone()
return row and row[0] > cutoff.isoformat()
def save(self, url, data):
url_hash = hashlib.md5(url.encode()).hexdigest()
self.conn.execute(
"INSERT OR REPLACE INTO cache VALUES (?, ?, ?)",
(url_hash, datetime.utcnow().isoformat(), data)
)
self.conn.commit()4. robots.txt 확인
reddit.com/robots.txt를 확인하여 허용되지 않은 경로는 수집하지 마세요. 현재 Reddit은 API 경로와 일부 내부 경로를 차단하고 있으며, 공개 서브레딧 페이지는 조건부 허용입니다.
5. 데이터 최소화
필요한 데이터만 수집하세요. 사용자 이름, 게시물 제목, 점수, 타임스탬프 등 분석에 직접 필요한 필드만 추출하고, 불필요한 개인정보는 저장하지 마세요.
언제 공식 API를 대신 사용해야 하나?
스크래핑이 항상 최선의 선택은 아닙니다. 다음 상황에서는 공식 API를 고려하세요:
- 소규모 수집: 월 1만 건 이하의 요청은 무료 티어로 충분할 수 있음
- 구조화된 데이터 필요: JSON 응답이 HTML 파싱보다 훨씬 안정적
- 실시간 스트리밍: WebSocket 기반 실시간 데이터는 API 전용
- 비공개 서브레딧: 스크래핑으로는 접근 불가
- 장기 프로젝트: HTML 구조 변경 시 유지보수 비용이 API 비용보다 클 수 있음
공식 API의 무료 티어(100건/분)로 충분한 프로젝트라면 굳이 스크래핑을 선택할 이유가 없습니다. 스크래핑은 비용 제약이 있고 대량의 공개 데이터가 필요한 경우에 합리적인 선택입니다.
핵심 요약
- 2023년 Reddit API 유료화 이후 공개 페이지 스크래핑이 비용 효율적 대안으로 부상
- old.reddit.com이 JavaScript 렌더링 없이 HTML 파싱 가능한 가장 스크래핑 친화적 경로
- 주거용 프록시는 대량 수집 시 데이터센터 IP 차단을 우회하는 핵심 도구
- 429→403 에스컬레이션 패턴을 이해하고, 429 수신 시 즉시 백오프해야 함
- 적절한 User-Agent, 속도 제한 준수, 공격적 캐싱이 장기적 수집 성공의 열쇠
- 소규모 수집은 공식 API를, 대규모 공개 데이터는 스크래핑을 선택하는 실용적 접근
Reddit 공개 데이터 스크래핑에 주거용 프록시가 필요하다면, ProxyHat의 주거용 프록시 플랜을 확인해 보세요. 190개 이상 국가의 실제 주거 IP로 안정적인 데이터 수집 환경을 제공합니다. 웹 스크래핑 유스케이스 페이지에서 더 많은 활용 사례를 확인할 수 있습니다.






