
"그냥 LIKE 쓰면 되는 거 아냐?"
데이터베이스에서 검색하는 방법은 다들 압니다.
SELECT * FROM reports WHERE title LIKE '%저출산%';
문제는 이게 딱 저 글자만 찾는다는 겁니다.
- "저출생 대응 방안" → 못 찾음
- "출산율 하락 분석" → 못 찾음
- 4,684건 정도면 괜찮지만, 수백만 건이면 느려짐
- 어떤 결과가 더 관련 있는지 순서를 못 매김
우리가 네이버나 구글에서 기대하는 검색은 이런 게 아니죠. Elasticsearch(이하 ES) 는 바로 이 간극을 메우는 도구입니다.
이 글은 국회 발간물 4,684건을 수집해 검색 API를 만든 과정입니다. 코드보다는 "왜 이렇게 해야 하는가" 와 "어디서 넘어지는가" 에 초점을 맞췄습니다.
전체 그림: 4단계
[1. 크롤링] → [2. MySQL] → [3. ES 색인] → [4. 검색 API]
데이터 수집 원본 보관 검색용 가공 FastAPI
여기서 입문자들이 가장 많이 하는 질문이 있습니다.
"ES에도 데이터를 넣는데, 왜 MySQL에 또 저장해요? 중복 아닌가요?"
역할이 완전히 다르기 때문입니다.
| 역할 | 원본 보관소 | 검색 전용 사본 |
| 성격 | 절대 잃으면 안 됨 | 언제든 지우고 다시 만들어도 됨 |
이게 왜 중요하냐면 — 저는 이 프로젝트에서 ES 인덱스를 다섯 번 만들었다 지웠습니다. 설정을 바꿀 때마다요.
MySQL에 원본이 있었기 때문에 python bulk_index.py 한 줄이면 복구됐습니다. 만약 ES에만 데이터가 있었다면? 매번 크롤링을 다시 돌려야 했겠죠. 몇 시간짜리 작업입니다.
💡 원칙: 실험할 수 있는 구조를 먼저 만들어라. 되돌릴 수 있으면 겁 없이 시도하게 됩니다.
1단계: 데이터 모으기
첫 번째 함정 — 브라우저부터 켜지 마세요
처음엔 Selenium이나 Playwright(브라우저를 자동으로 조작하는 도구)를 쓰려고 했습니다. 4,684건이면 2~4시간짜리 작업입니다.
그런데 코드를 짜기 전에 사이트를 먼저 뜯어봤더니:
./list.do?page=2&categoryId=&pageSize=100
주소창에 페이지 번호가 그대로 있었습니다. 브라우저 없이 그냥 요청만 보내면 되는 구조였죠. 게다가 pageSize를 100으로 올릴 수 있어서 469페이지가 47페이지로 줄었습니다.
결과: 2~4시간 → 10분.
💡 교훈: 추측하지 말고 관찰하라. 크롬 개발자도구(F12) → Network 탭을 열고 페이지를 넘겨보세요. 10분 투자로 몇 시간을 아낍니다.
두 번째 함정 — 수집과 파싱을 분리하세요
초보자가 흔히 하는 실수가 이겁니다.
for page in range(1, 470):
html = 요청(page)
데이터 = 파싱(html) # ← 여기가 문제
DB에_저장(데이터)
400페이지쯤 긁었는데 파싱 규칙 하나가 틀렸다는 걸 발견하면? 처음부터 다시 긁어야 합니다.
이렇게 나누세요.
# 1단계: 원본 HTML을 그냥 파일로 저장
for page in range(1, 48):
html = 요청(page)
파일로_저장(f"raw/page_{page}.html")
# 2단계: 저장된 파일에서 파싱 (인터넷 불필요, 몇 번이든 재시도)
for 파일 in raw폴더:
데이터 = 파싱(파일 읽기)
HTML 47개는 다 합쳐도 몇십 MB입니다. 저장 비용은 사실상 0인데, 파서를 20번 고쳐도 재수집이 필요 없습니다.
세 번째 함정 — "고유 번호"라고 다 같은 게 아닙니다
목록에 이런 값들이 있었습니다.
| 4684 | 화면 표시용 번호 — 새 글 올라오면 전부 밀림 ❌ |
| 1P-iU0ZDsFz | 문서 ID — PDF 없는 글엔 아예 없음 ❌ |
| 49518 | 게시물 번호 — 모든 글에 존재 ✅ |
1P-iU0ZDsFz를 키로 쓸 뻔했는데, 검증하다 보니 PDF가 없는 문서 3건에는 그 값이 아예 없더군요. 그대로 진행했으면 3건이 조용히 사라졌을 겁니다.
💡 키를 고를 때 물어볼 것: "모든 데이터에 이 값이 반드시 있는가?"
2단계: 검증이 8할입니다
수집이 끝나면 반드시 숫자를 맞춰봐야 합니다.
전체 목록: 4,684건
주제별 합계: 4,375건
주제 없는 문서: 309건
─────────────────────────
4,375 + 309 = 4,684 ✅
이게 안 맞으면 어딘가 빠졌거나 중복된 겁니다. 실제로 저는 이 검증에서 카테고리 하나가 통째로 누락된 것을 발견했습니다.
이런 것들을 습관적으로 확인하세요.
- 총 건수가 사이트 표기와 같은가?
- 중복은 없는가?
- 필수 항목(제목, 날짜)이 비어 있는 게 몇 건인가?
수집 직후 30분 투자하면, 나중에 며칠을 아낍니다.
3단계: 한국어 검색의 진짜 장벽
여기가 이 글의 핵심입니다. 영어 튜토리얼만 보고 따라 하면 반드시 막히는 지점이거든요.
형태소 분석기가 필요한 이유
영어는 띄어쓰기만 해도 단어가 나뉩니다. "search engine" → search, engine. 끝.
한국어는 다릅니다.
"저출산 대응을 위한 정책"
→ 저출산 / 대응 / 을 / 위한 / 정책
을, 위한 같은 건 검색에 방해만 됩니다. 그래서 ES에 nori라는 한국어 분석기를 붙입니다. 조사와 어미를 떼어내고 의미 있는 단어만 남기는 도구예요.
함정 ① — 동의어가 조용히 사라졌습니다
"저출산"으로 검색해도 "저출생"이나 "출산율" 문서가 나오게 하려고 동의어를 등록했습니다.
저출산, 저출생, 출산율
그런데 결과가 이상했습니다.
"저출산" 검색 → 저출산 ✅, 저출생 ✅, 출산율 ❌
셋 중 하나만 빠졌습니다. 에러도 없이요.
원인은 이랬습니다. nori가 출산율을 이렇게 쪼갠 겁니다.
출산율 → "출산" + "율"
율은 접미사라 필터에 걸려 사라지고, 단어 하나가 조각나면서 동의어 규칙이 깨졌습니다. 반면 저출생은 제가 사전에 등록해둔 단어라 안 쪼개졌고, 그래서 살아남았던 거죠.
해결은 간단했습니다. 사전에 출산율을 추가하는 것.
💡 한국어 ES의 철칙: 동의어에 쓸 단어는 사전에 먼저 등록하라. 단어가 쪼개지면 동의어는 작동하지 않습니다.
더 무서운 건 이 실패가 에러를 안 낸다는 점입니다. 설정은 성공하고 결과만 이상하죠. 그래서 검색 서비스는 "일단 돌아가니까 됐다"가 통하지 않습니다. 직접 검색해보고 확인해야 합니다.
함정 ② — 자동완성에 형태소 분석기를 쓰면 안 됩니다
검색창에 "저출"까지 쳤을 때 추천을 띄우려고 했더니, 이런 결과가 나왔습니다.
"저출" → 저, 제, 출, 추, ᆯ
당황스럽죠. 원인은 이겁니다. 저출은 한국어 단어가 아닙니다. 타이핑 중인 조각일 뿐이죠. nori는 이걸 어떻게든 형태소로 해석하려다 이상한 결과를 냈습니다.
역할을 나눠서 해결했습니다.
| 의미 검색 | nori (형태소 분석 O) |
| 자동완성 | 글자 그대로 자르기 (형태소 분석 X) |
💡 자동완성은 "사용자가 보는 글자 그대로" 매칭해야 합니다.
함정 ③ — 사람 이름에 형태소 분석기 금지
유지영이라는 저자를 nori에 넣으면 유지 + 영이 됩니다. 그러면 "유지보수" 관련 문서가 저자 검색에 딸려 나오죠.
인명, 회사명, 고유명사는 쪼개지 말고 통째로 저장해야 합니다.
4단계: 검색 API
Python의 FastAPI로 만들었습니다. 핵심 기능은 넷.
GET /api/search 검색 + 필터 + 하이라이트
GET /api/suggest 자동완성
GET /api/facets 분류별 개수 (좌측 필터 메뉴용)
GET /api/explain 이 문서가 왜 상위에 떴는지
쿼리에서 꼭 알아야 할 구분
{
"must": [ "저출산" ], // 점수 계산 O
"filter": [ "2024년 이후" ] // 점수 계산 X, 캐시 O
}
"저출산과 얼마나 관련 있는가"는 점수를 매겨야 합니다. 하지만 "2024년 이후인가"는 예/아니오일 뿐이죠. 점수와 무관합니다.
filter에 넣으면 계산을 건너뛰고 결과를 재활용해서 훨씬 빠릅니다. 이 구분만 알아도 검색 성능이 달라집니다.
인덱스를 5번 갈아엎어도 서비스가 안 멈춘 비결
설정을 바꿀 때마다 인덱스를 새로 만들어야 했습니다. 그런데 만드는 동안 검색이 멈추면 안 되겠죠.
별명(alias) 을 씁니다.
앱은 "reports_search"만 호출
↓
[별명] → reports_v3 (실제 인덱스)
앱은 별명만 압니다. 뒤에 뭐가 연결됐는지는 모르죠.
작업 순서는 이렇습니다.
1. reports_v4 새로 만들기 ← v3는 계속 서비스 중
2. v4에 데이터 넣기 ← v3는 계속 서비스 중
3. v4가 잘 되는지 확인
4. 별명을 v4로 옮기기 ← 여기서 순간 전환
5. v3 삭제
4번은 한 번의 요청으로 원자적으로 처리됩니다. "둘 다 없는" 순간이 존재하지 않아서 검색이 한 건도 실패하지 않아요. 코드 수정도, 서버 재시작도 없습니다.
문제가 생기면? 별명을 되돌리면 1초 만에 롤백됩니다.
💡 별명의 진짜 가치는 "되돌릴 수 있다"는 것입니다. 롤백이 쉬우면 과감하게 실험할 수 있습니다. 이건 ES뿐 아니라 배포 전략 전반에 통하는 원리예요.
정리하며
작은 프로젝트였지만 얻은 게 많았습니다.
설계
- 원본과 사본을 분리하면 실험이 자유로워진다
- 키는 "모든 데이터에 반드시 있는" 값으로
- 되돌릴 수 있는 구조를 먼저 만들어라
한국어 검색
- 형태소 분석기는 만능이 아니다 — 인명, 미완성 입력에는 독이 된다
- 동의어보다 사전이 먼저다
- 조용한 실패를 의심하라. 직접 검색해봐야 안다
태도
- 추측하지 말고 관찰하라 (개발자도구 10분 = 몇 시간)
- 수집과 파싱을 분리하라
- 숫자로 검증하라
가장 크게 남은 건 이겁니다. 검색 서비스는 "돌아간다"와 "제대로 된다" 사이가 아주 멀다는 것. 에러 없이 잘못된 결과를 내놓는 경우가 너무 많아서, 직접 검색어를 넣어보고 결과를 눈으로 확인하는 과정을 건너뛸 수 없습니다.
혹시 검색 기능을 만들어보려는 분이라면, 작은 데이터로 먼저 끝까지 한 바퀴 돌려보시길 권합니다. 1,000건이든 100건이든요. 전체 흐름을 한 번 겪고 나면 그다음부터는 데이터가 늘어나도 같은 구조로 확장됩니다.
'강의&프로젝트' 카테고리의 다른 글
| 따라가는 공부는 남지 않는다 — 실무 기획자가 AI를 통해 성장하는 방식 (3) | 2026.08.05 |
|---|---|
| 하네스는 언젠가 짐이 된다 — 그런데 A4 한 장은 끝까지 남는다 (0) | 2026.08.04 |
| 하이브리드 RAG 검색 시스템, 설계하면서 부딪힌 4가지 갈림길 (1) | 2026.07.13 |
| [데이터 기획 실무] RDB의 한계를 넘는 온톨로지(Ontology) 활용법과 아키텍처 설계 (0) | 2026.05.01 |
| DB는 알지만 온톨로지는 이해하는 것: 왜 우리에게 '시맨틱 레이어'가 필요할까? 평범한 직장인 검색전문가 되기(1탄) (0) | 2026.04.29 |