지난 글에서 Circuit Breaker와 다중 제공자 Fallback으로 LLM API 장애를 견디는 방법을 살펴봤다. 운영을 아무리 촘촘히 짜도 모델이 할 줄 아는 것은 결국 학습 때 본 데이터가 정한다. 「Garbage in, garbage out」이라는 오래된 말이 LLM에서 유난히 무겁게 들리는 까닭은, 학습 데이터가 수조 토큰이라 사람이 한 줄씩 볼 수 없고 그래서 무엇이 들어갔는지를 절차로만 보장할 수 있기 때문이다.
이 글은 그 절차를 앞에서부터 따라간다. 어디서 데이터를 가져오는지, 가져와도 되는지를 어떻게 가리는지, 언어와 도메인을 어떤 비율로 섞는지, 품질 필터의 문턱을 어떻게 정하는지, 평가 문항과 개인정보를 어느 단계에서 걷어 내는지, 그리고 반년 뒤에도 같은 데이터를 다시 만들 수 있게 무엇을 남기는지다.
데이터 소스
학습 데이터의 출처는 크게 셋으로 갈린다. 누구나 받을 수 있는 웹 크롤, 모델이 만들어 낸 합성 데이터, 그리고 출처와 품질이 이미 관리된 큐레이션 코퍼스다. 셋은 양과 품질이 거꾸로 움직인다. 웹 크롤은 양이 사실상 무한하지만 품질이 가장 들쭉날쭉하고, 큐레이션 코퍼스는 품질이 높은 대신 양이 적으며, 합성 데이터는 그 사이에서 원하는 모양으로 찍어 낼 수 있지만 만든 모델의 버릇을 그대로 물려받는다.
오픈 웹 데이터
Common Crawl은 비영리 단체가 대략 한 달에 한 번 수십억 페이지를 긁어 공개하는 웹 크롤 아카이브다. 공개된 대형 언어 모델 대부분이 이것을 뼈대로 쓴다. 그러나 원본에는 광고·메뉴·스팸·기계 번역 페이지가 그대로 섞여 있어서 그대로 학습에 넣는 곳은 없다. 실제로 쓰는 것은 누군가 필터링과 중복 제거를 거친 정제본이다.
Hugging Face가 공개한 FineWeb은 Common Crawl 수십 회차를 정제해 약 15조 토큰으로 만든 데이터셋이다. 직접 원본을 정제하기 전에 이런 정제본을 먼저 받아 보는 편이 낫다 — 정제 규칙이 문서로 남아 있어서, 우리 목적에 맞지 않는 부분만 골라 다시 걸러 쓸 수 있다.
import datasets
# FineWeb: Common Crawl 정제본. 전체를 받지 말고 샘플이나 스트리밍으로 연다
fineweb = datasets.load_dataset(
"HuggingFaceFW/fineweb",
name="sample-10BT", # 10B 토큰 샘플
split="train",
streaming=True,
)
for example in fineweb:
print(example["url"], example["text"][:100])
break
정제본을 쓸 때도 레코드마다 url과 크롤 회차 같은 메타데이터가 붙어 있는지를 먼저 본다. 뒤에서 라이선스를 가리고 특정 도메인을 빼고 오염 문항을 추적할 때 기댈 것이 그 메타데이터뿐이다.
합성 데이터
합성 데이터는 LLM이 다른 모델의 학습용으로 만들어 낸 텍스트다. 웹에 드문 형식 — 단계별 풀이, 특정 도메인의 질의응답, 도구 호출 대화 — 을 원하는 만큼 찍어 낼 수 있다는 것이 장점이다.
import json
from anthropic import Anthropic
client = Anthropic()
def generate_synthetic_qa(topic: str, n_pairs: int = 10):
"""특정 주제에 대한 합성 Q&A 쌍 생성"""
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=2000,
messages=[{
"role": "user",
"content": f"""다음 주제에 대해 Q&A 쌍 {n_pairs}개를 JSON 배열로만 답하세요.
주제: {topic}
형식: [{{"question": "질문", "answer": "답변"}}]"""
}]
)
return json.loads(response.content[0].text)
qa_pairs = generate_synthetic_qa("파이썬 비동기 프로그래밍", n_pairs=20)
Microsoft의 Phi-1은 「Textbooks Are All You Need」라는 논문 제목 그대로, 교과서처럼 쓰인 웹 데이터와 GPT-3.5로 생성한 교과서 문체의 합성 데이터로 13억 파라미터 모델을 학습해 훨씬 큰 모델과 겨루는 코딩 점수를 냈다. 다만 합성 데이터에는 두 가지 대가가 붙는다. 하나는 생성 모델의 약관이다 — 벤더마다 출력으로 경쟁 모델을 학습하는 것을 제한하는 조항이 있을 수 있으니 쓰기 전에 읽는다. 다른 하나는 다양성이다. 같은 프롬프트로 만 번 찍으면 만 개가 비슷한 문장 구조를 갖기 쉬워서, 주제·말투·난이도를 프롬프트마다 바꿔 주고 나중에 중복 제거를 한 번 더 건다.
큐레이션 코퍼스
위키백과, 공개 도서, 논문 아카이브, 공개 코드 저장소, 정부 공공데이터처럼 출처가 분명하고 품질이 이미 어느 정도 걸러진 묶음을 큐레이션 코퍼스라 부른다. 양은 웹의 몇 퍼센트에 불과하지만 사실 밀도가 높아서 대개 학습 중에 여러 번 되풀이해 보여 준다. 이 비율 이야기는 뒤의 「구성비」 절에서 숫자로 다시 본다.
라이선스 확인
모아도 되는지를 가리는 일은 수집 코드를 짜기 전에 한다. 이미 받아 버린 뒤에 빼는 것은 훨씬 비싸다 — 어느 샤드에 그 출처가 흩어져 있는지부터 찾아야 하기 때문이다. 확인할 것은 세 겹이고, 겹마다 뜻이 다르다.
robots.txt
robots.txt는 사이트가 크롤러에게 「여기는 긁지 마라」를 알리는 파일이다. 2022년 RFC 9309로 표준 문서가 됐지만, 표준이 정한 것은 파일의 문법이지 법적 효력이 아니다. 그래서 robots.txt가 허락한다고 저작권 문제가 풀리는 것은 아니다. 반대로 막혀 있는데 긁는 것은 거의 모든 데이터셋 정책이 금지한다.
AI 학습용 크롤러는 따로 이름을 갖는 경우가 많다. OpenAI의 GPTBot, Anthropic의 ClaudeBot, Common Crawl의 CCBot이 그렇고, Google은 검색 크롤러와 별개로 Google-Extended라는 토큰을 두어 학습 사용만 따로 거부할 수 있게 했다. 그래서 수집기는 자기 User-Agent로 허용 여부를 묻는 것에 더해, 이 학습용 토큰들이 막혀 있는 사이트를 「학습 사용을 거부한 사이트」로 표시해 두는 편이 안전하다.
import requests
import trafilatura
from urllib.robotparser import RobotFileParser
def ethical_crawl(url: str, user_agent: str = "ResearchBot/1.0") -> str | None:
"""robots.txt를 확인하고 본문만 추출한다"""
base_url = "/".join(url.split("/")[:3])
rp = RobotFileParser()
rp.set_url(f"{base_url}/robots.txt")
rp.read()
if not rp.can_fetch(user_agent, url):
return None
# 학습 전용 토큰이 막혀 있으면 학습 거부 의사로 기록해 둔다
if not rp.can_fetch("GPTBot", url) or not rp.can_fetch("CCBot", url):
log_optout(base_url)
response = requests.get(url, headers={"User-Agent": user_agent}, timeout=10)
return trafilatura.extract(response.text) # 광고·메뉴를 걷고 본문만
이용약관
robots.txt가 기술적인 신호라면 이용약관은 계약이다. 로그인 뒤의 페이지, API로만 받는 데이터, 커뮤니티 게시물은 약관이 수집과 재사용을 따로 정해 두는 경우가 많다. 특히 사용자 생성 콘텐츠를 모을 때는 그 서비스의 약관이 AI 학습 목적의 재사용을 허락하는지를 본다. 자사 서비스의 데이터를 쓸 때도 같다 — 우리 약관에 그 목적이 적혀 있지 않으면 우리 데이터라도 동의 범위 밖이다.
약관은 사이트마다 달라서 기계로 판정할 수 없다. 현실적인 방법은 도메인 단위로 허용 목록을 두는 것이다. 수집 대상 상위 도메인 몇백 개를 사람이 한 번 읽고 「허용 · 학습 불가 · 확인 필요」로 표시해 두고, 목록에 없는 도메인은 일반 웹 규칙을 따르게 한다.
CC 조건
위키백과나 공개 도서처럼 CC 라이선스가 붙은 자료는 조건 넷의 조합으로 읽는다. BY는 출처 표시, SA는 같은 조건으로 재배포, NC는 비영리, ND는 변경 금지다. CC0은 이 조건을 모두 포기한 사실상 공유 영역이다.
| 조건 | 학습 데이터에서 걸리는 자리 |
|---|---|
| BY | 출처 목록을 데이터 카드에 남겨야 한다 |
| SA | 데이터셋을 재배포할 때 같은 라이선스로 내야 한다 |
| NC | 상업 모델의 학습이 「비영리」에 드는지 해석이 갈린다 |
| ND | 학습이 「변경」인지 해석이 갈린다 |
NC와 ND는 해석이 정리되지 않았으므로 상업 모델에서는 빼 두는 쪽이 흔하다. 어느 쪽으로 정하든 핵심은 레코드마다 라이선스 필드를 붙여 두는 것이다. 나중에 정책이 바뀌면 그 필드로 걸러 다시 만들 수 있다.
EU AI Act는 범용 AI 모델 제공자에게 학습 데이터의 요약을 공개하고 저작권 준수 정책을 갖추라고 요구하며, 그 의무는 2025년 8월부터 적용됐다. 요약을 쓰려면 무엇을 어디서 가져왔는지가 남아 있어야 하므로, 위의 필드는 윤리 문제이기 전에 문서화 문제다.
구성비
데이터를 다 모았다고 끝나는 것이 아니다. 학습은 정해진 토큰 예산 안에서 돌기 때문에, 어느 출처를 몇 퍼센트 섞을지가 곧 모델이 무엇을 잘하게 될지를 정한다. 이 비율을 데이터 믹스, 곧 구성비라 부른다.
도메인 구성비
공개된 예 가운데 가장 자세한 것이 LLaMA 1 논문이다. 약 1.4조 토큰 가운데 Common Crawl이 67%, C4가 15%, GitHub·위키백과·도서가 각각 4.5%, arXiv가 2.5%, Stack Exchange가 2%였다. 흥미로운 것은 같은 표에 적힌 에폭 수다. 웹 데이터는 한 번 남짓 보고 지나가는데 위키백과는 약 2.45번, 도서는 약 2.23번 되풀이해 보여 줬다. 적지만 질이 좋은 출처를 여러 번 보여 주는 방식으로 비율을 끌어올린 것이다.
비율은 가설이다. 코드를 10% 더 넣으면 추론 과제가 좋아지는지, 수학 문서를 올리면 대화가 딱딱해지는지는 해 봐야 안다. 그래서 큰 학습 전에 작은 모델로 비율 몇 가지를 돌려 보는 것이 보통이고, 그 방법은 아래 「필터 임계값」 소절의 실험과 같은 틀이다.
언어 구성비
한국어 모델을 만든다면 언어 비율이 가장 먼저 걸린다. 웹 전체에서 한국어가 차지하는 몫은 몇 퍼센트에 그치므로 가만두면 한국어는 소수 언어가 된다. 한국어를 억지로 끌어올리면 반대로 영어로만 있는 기술 문서와 코드에서 얻는 능력이 줄어든다. 흔한 절충은 한국어를 자연 비율보다 몇 배 올려 잡되 영어·코드를 절반 이상 남기는 것이다.
언어 판정도 문턱이 있다. 언어 식별기가 「한국어 0.65」라고 답하는 문서는 한국어와 영어가 섞였거나 표·코드가 많은 문서일 수 있다. 문턱을 높이면 순수한 한국어만 남지만 기술 문서처럼 원래 영어가 섞이는 글이 빠진다.
구성비의 흔적
구성비는 학습이 끝난 뒤에도 모델에 흔적으로 남는다. 가장 눈에 띄는 것이 토크나이저다. 토크나이저를 학습할 때 한국어가 적으면 한국어 낱말이 잘게 쪼개져, 같은 뜻을 전하는 데 토큰이 더 든다. 추론 비용이 토큰 수에 비례하므로 이것은 곧 한국어 사용자가 더 비싸게 쓴다는 뜻이다.
말투와 지식에도 흔적이 남는다. 포럼 비중이 높으면 구어체가, 논문 비중이 높으면 문어체가 기본값이 된다. 특정 시기의 크롤을 많이 쓰면 그 시기의 사건이 과대 대표된다. 모델의 이상한 버릇을 추적할 때 가장 먼저 여는 것이 이 구성비 표다.
수집 파이프라인
소스와 비율을 정했으면 실제로 데이터를 흘린다. 파이프라인은 대개 크롤링 → 본문 추출 → 언어·품질 필터 → 중복 제거 → 안전 필터 순이다. 순서에 이유가 있다. 싼 검사를 앞에 두어 비싼 검사가 볼 양을 줄인다. 본문 추출과 언어 판정은 문서 하나에 몇 밀리초지만 임베딩 기반 검사는 GPU를 쓴다.
품질 필터
규칙 기반 품질 필터의 원조는 DeepMind가 Gopher를 학습할 때 쓴 규칙이다. 문서 길이가 50100,000 단어 사이인지, 평균 단어 길이가 310자인지, 기호 비율이 너무 높지 않은지, 줄 대부분이 글머리표이거나 말줄임표로 끝나지 않는지를 본다. 광고 페이지와 목차, 자동 생성된 목록이 이 규칙에 걸린다.
from datatrove.pipeline.filters import GopherQualityFilter, LanguageFilter
quality_filter = GopherQualityFilter(
min_doc_words=50,
max_doc_words=100_000,
min_avg_word_length=3,
max_avg_word_length=10,
max_symbol_word_ratio=0.1,
max_bullet_lines_ratio=0.9,
max_ellipsis_lines_ratio=0.3,
)
lang_filter = LanguageFilter(languages=["ko", "en"], language_threshold=0.65)
규칙 필터 다음에는 흔히 품질 분류기가 선다. 좋은 문서(교과서·위키백과 같은 것)와 나쁜 문서의 예로 작은 분류기를 학습해 모든 문서에 0~1 점수를 매기고, 문턱 아래를 버린다. FineWeb-Edu가 이렇게 LLM이 매긴 「교육적 가치」 점수로 분류기를 학습해 걸렀다. 규칙은 「명백히 쓰레기인 것」을, 분류기는 「멀쩡하지만 배울 것이 적은 것」을 거른다.
영어 기준으로 만든 규칙은 한국어에서 그대로 안 맞는다. 한국어는 띄어쓰기 단위가 영어 단어보다 길어서 평균 단어 길이 상한 10자에 조사 붙은 낱말이 걸리기도 한다. 다른 언어에 옮길 때는 문턱을 그 언어의 분포에서 다시 잰다.
필터 임계값
분류기 문턱을 몇으로 잡을지는 감으로 정하지 않고 실험으로 정한다. 문턱을 올릴수록 남는 양은 줄고 평균 품질은 오르는데, 너무 올리면 다양성이 사라져 오히려 모델이 나빠진다. 그래서 둘을 한 그림에 그려 봉우리를 찾는다.
절차는 이렇다. 문턱 후보를 몇 개 정하고, 후보마다 걸러진 데이터로 같은 크기의 작은 모델을 같은 토큰 수만큼 학습한 뒤, 같은 벤치마크 묶음으로 잰다. FineWeb 팀은 설정 하나마다 20억 파라미터가 안 되는 모델을 수백억 토큰으로 학습해 이 비교를 했다. 위 그림은 그 모양을 보이려고 만든 가상의 숫자다 — 문턱 0.0에서 100%가 남고 지표가 41.0, 0.25에서 62%와 43.2, 0.5에서 38%와 44.1, 0.75에서 21%와 43.6, 0.9에서 9%와 41.8이다.
이 예에서 봉우리는 0.5다. 그런데 고르는 값이 꼭 봉우리인 것은 아니다. 목표 학습 토큰이 원본의 38%보다 많으면 0.5로 거른 데이터를 여러 번 되풀이해야 하고, 되풀이가 네다섯 번을 넘으면 새 데이터만큼의 효과가 안 난다는 보고가 있다. 그래서 문턱은 「지표가 가장 높은 값」과 「목표 토큰을 채울 수 있는 값」 가운데 낮은 쪽으로 정한다.
중복 제거
웹에는 같은 글이 수없이 복사돼 있다. 뉴스 통신사 기사, 약관, 템플릿 페이지가 그렇고, 크롤 회차가 달라도 같은 페이지가 다시 잡힌다. 중복을 그대로 두면 모델이 그 문장을 외우고, 토큰 예산이 같은 글에 여러 번 쓰인다.
from datasketch import MinHash, MinHashLSH
def minhash_dedup(texts: list[str], threshold=0.8, num_perm=128):
"""MinHash LSH를 이용한 퍼지 중복 제거"""
lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
unique_texts = []
for i, text in enumerate(texts):
mh = MinHash(num_perm=num_perm)
tokens = text.split()
for j in range(len(tokens) - 4): # 5-gram
mh.update(" ".join(tokens[j:j+5]).encode("utf-8"))
if not lsh.query(mh):
lsh.insert(f"doc_{i}", mh)
unique_texts.append(text)
return unique_texts
MinHash는 문서를 짧은 서명으로 줄여 두 문서의 겹침 정도를 빠르게 어림하는 방법이고, LSH는 비슷한 서명끼리 같은 통에 모아 모든 쌍을 비교하지 않게 해 주는 색인이다. 두 장치의 계산과 문턱 설정은 뒤의 중복 제거 글에서 따로 다룬다. 여기서 기억할 것은 순서다. 중복 제거는 품질 필터 뒤에 둔다 — 버릴 문서끼리 비교하는 데 계산을 쓰지 않기 위해서다.
오염 검사와 PII
품질과 중복을 거른 뒤에도 빼야 할 것이 둘 남는다. 평가에 쓸 문항, 그리고 개인정보다. 둘 다 학습이 끝난 뒤에는 걷어 낼 수 없어서 수집 단계에서 막아야 한다.
벤치마크 오염
벤치마크 오염은 평가 문항이나 그 정답이 학습 데이터에 섞여 들어가는 일이다. 웹에는 유명한 벤치마크 문항이 해설 블로그나 저장소 형태로 흩어져 있어서, 가만히 긁기만 해도 섞인다. 오염된 모델은 문항을 풀지 않고 기억해 답하므로 점수가 실력보다 높게 나온다.
수집 단계에서 할 일은 겹침 검사다. 쓸 벤치마크 문항을 전부 n-gram으로 쪼개 색인을 만들고, 학습 문서마다 긴 n-gram이 그 색인과 겹치는지를 본다. GPT-3 논문은 13-gram 겹침을 기준으로 삼았다. 겹친 문서는 버리거나 겹친 구간만 지우고, 몇 건을 지웠는지 벤치마크별로 기록해 둔다. 평가 쪽에서 오염을 어떻게 재고 보고하는지는 평가 데이터 오염에서 다룬다.
주의할 점은 앞으로 쓸 벤치마크다. 학습을 시작한 뒤에 새 벤치마크를 도입하면 그 문항은 검사되지 않은 채 섞여 있을 수 있다. 그래서 스냅숏마다 「어느 벤치마크 목록으로 오염 검사를 했는가」를 함께 남긴다.
PII 제거
PII(개인 식별 정보)는 이메일, 전화번호, 주민등록번호, 계좌번호처럼 한 사람을 가리킬 수 있는 정보다. 모델은 학습 데이터의 드문 문자열을 그대로 외워 뱉을 수 있으므로, 학습 전에 찾아서 지우거나 <EMAIL> 같은 자리표시로 바꾼다.
import re
PATTERNS = {
"EMAIL": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
"PHONE": re.compile(r"01[016789]-?\d{3,4}-?\d{4}"),
"RRN": re.compile(r"\d{6}-?[1-4]\d{6}"), # 주민등록번호
}
def redact(text: str) -> str:
for label, pat in PATTERNS.items():
text = pat.sub(f"<{label}>", text)
return text
정규식은 모양이 정해진 것만 잡는다. 「공일공 일이삼사」처럼 한글로 적은 번호, 띄어쓰기로 끊은 번호, 이름·주소처럼 모양이 없는 정보는 놓친다. 그래서 정규식 다음에 개체명 인식 모델을 한 겹 더 두는 것이 보통이다. Microsoft Presidio 같은 도구가 두 겹을 함께 제공한다. 반대로 정규식은 헛잡기도 한다 — 주문번호나 날짜가 주민등록번호 모양과 겹칠 수 있다. 헛잡은 자리는 문맥이 조금 망가질 뿐이지만 놓친 자리는 모델에 남으므로, 문턱은 놓치는 쪽을 줄이는 방향으로 기운다.
표본 검수
자동 제거가 얼마나 놓치는지는 사람이 표본을 봐야 안다. 전체에서 무작위로 문서를 뽑아 사람이 PII를 찾아보는 것이다. 이때 쓰는 계산이 「3의 규칙」이다. 표본 n건에서 하나도 못 찾았다면 실제 누락률의 95% 신뢰 상한은 대략 3/n이다. 500건을 봐서 0건이면 「누락률이 0.6%보다 낮다고 95% 확신할 수 있다」까지만 말할 수 있다.
0.6%는 문서 1억 건이면 60만 건이다. 그래서 표본은 무작위 하나로 끝내지 않고 층화한다. 포럼·이력서 사이트·쇼핑몰 후기처럼 PII가 많을 법한 도메인에서 따로 더 뽑아 보고, 거기서 찾은 누락 패턴을 규칙으로 되먹인다.
스냅숏
수집의 마지막 일은 지금 만든 데이터를 반년 뒤에도 똑같이 다시 가리킬 수 있게 고정하는 것이다. 모델이 이상하게 굴 때 「그때 무엇을 학습했나」에 답하지 못하면 원인을 찾을 길이 없다.
저장 포맷과 샤딩
학습 데이터는 보통 Parquet이나 압축한 JSONL로 저장하고 수백 MB~1GB 단위의 샤드로 나눈다. 샤드로 나누는 이유는 둘이다. 여러 작업자가 동시에 읽을 수 있고, 한 샤드가 깨져도 그 샤드만 다시 만들면 된다. 레코드마다 텍스트와 함께 출처 URL, 크롤 회차, 라이선스, 언어 점수, 품질 점수, 본문 해시를 붙인다. 점수를 남겨 두면 문턱을 바꿀 때 전체를 다시 계산하지 않고 이미 있는 열로 거를 수 있다.
수집 시점 고정
웹은 계속 바뀌므로 「같은 URL을 다시 긁는다」는 재현이 아니다. 재현은 입력을 고정하는 것이다. Common Crawl을 쓴다면 회차 이름(CC-MAIN-2024-10 같은 것)을, Hugging Face 데이터셋을 쓴다면 브랜치가 아니라 커밋 해시를 적는다. 여기에 파이프라인 코드의 커밋, 필터 문턱, 오염 검사에 쓴 벤치마크 목록, 샤드마다의 체크섬을 한 매니페스트 파일로 묶는다.
{
"snapshot": "ko-web-2026-05",
"sources": [{"name": "fineweb", "revision": "<commit-sha>"}],
"pipeline_commit": "<git-sha>",
"filters": {"quality_threshold": 0.5, "language_threshold": 0.65},
"contamination_benchmarks": ["kmmlu", "hae-rae"],
"shards": [{"path": "shard-00000.parquet", "sha256": "..."}]
}
이 파일 하나가 있으면 모델 카드의 「학습 데이터」 칸을 채울 수 있고, 삭제 요청이 들어왔을 때 어느 스냅숏의 어느 샤드를 다시 만들어야 하는지도 찾을 수 있다.
데이터 수집은 모델 개발의 절반이라는 말이 과장이 아니다. 다만 여기서 모은 것은 아직 원재료다. 웹 텍스트로 언어를 배우는 사전 학습에는 이대로 쓸 수 있지만, 「이 문장은 불만이다」처럼 정답이 필요한 지도 학습에는 그 정답을 누군가 붙여 줘야 한다. 다음 글은 그 정답, 곧 레이블을 만드는 과정을 다룬다.
읽어주셔서 감사합니다. 😊

