🎧 ▶ 5분 브리핑으로 듣기
▶ 오디오북 재생 (Google Drive)
NotebookLM 오디오 개요 (AI 생성)
📕 전자책 전문 읽기 검색의 규율 · 31쪽
브라우저가 PDF를 바로 표시하지 못합니다. 새 탭에서 열기
이 글은 요약해 다시 쓴 것이고, PDF가 전문입니다.

이 글은 자신의 제품에 검색 기능을 직접 만들어야 하는 1인 개발자를 향한 것입니다. 끝까지 읽으면, ‘기록은 있는데 사용자가 못 찾는’ 고장이 왜 나는지를, 그리고 데이터베이스 안에서 그 고장을 가장 싼 가격에 고치는 방법을 얻게 됩니다.

‘기록은 있는데 못 찾는다’는 고장은 저장의 실패가 아닙니다. 거의 항상 번역의 실패입니다.

사용자는 흐릿한 기억을 문장으로 번역해 검색창에 쓰고, 데이터베이스는 저장된 형태 그대로의 문장 밖에는 답할 수 없습니다. 이 둘 사이를 잇는 일을 설계하지 않으면, 검색은 데이터가 쌓일수록 더 자주 실패합니다. 이 글은 그 번역의 문제를 하나의 주장으로 끝까지 풀어 보겠습니다.

검색은 매칭이 아니라 번역이다 개념을 형상화한 이미지 글의 핵심 개념을 형상화했습니다.

완전 일치는 키 조회이지, 찾기가 아니다

대부분의 첫 검색은 단순합니다. WHERE name = $1. 깔끔하고 빠르고 인덱스도 탑니다. 그리고 테스트에서 늘 통과합니다. 등록한 제목을 그대로 쳤기 때문입니다. 하지만 테스트는 개발자의 문장으로 쓰고 사용자는 사용자의 문장으로 칩니다. 이 두 어휘 사이의 간극이 검색 기능을 죽이는 자리입니다.

사용자의 쪽지를 조사하는 개발자는 두 갈래로 나뉩니다. 한 갈래는 데이터베이스 클라이언트를 열어, LIKE ‘%프로젝트%’ 같은 관대한 조건으로 직접 SELECT를 씁니다. 그리고 ‘있잖아, 내가 찾았는데?’라고 답합니다. 다른 갈래는 제품의 검색창을 열어 사용자가 친 그대로를 다시 치고, ‘버그 맞다’라고 답합니다. 둘이 다른 결과를 보는 이유는, 둘이 다른 문장을 쳤기 때문입니다.

개발자가 직접 SQL을 쓸 때는 답을 이미 알고 있습니다. 답을 아는 사람은 조건을 풀지 않습니다. ‘이것은 분명히 제목에 들어있다’는 확신으로 LIKE를 씁니다. 사용자는 답을 모릅니다. 답을 모르는 사람은 기억나는 문장을 칩니다. 검색 기능을 시험할 때 개발자가 답을 아는 상태에서 조건을 짜는 것은 시험이 아닙니다. 답을 모른다는 전제에서 기억나는 문장을 치는 것만이 시험입니다.

완전 일치는 사용자에게 아주 엄격한 약속을 합니다. 제목을 통째로, 글자 하나 다르지 않게, 앞뒤 공백 없이 치라는 것입니다. 데이터를 등록한 사람이 다시 검색하는 내부 도구라면 이 약속은 무리가 없습니다. 하지만 누구든 검색하는 제품에서는 깨집니다. 세 달 전에 이름을 지은 사람과 오늘 그 이름을 찾는 사람은 다른 사람일 수 있고, 같은 사람이라도 세 달 뒤의 기억은 원본 형식을 유지하지 않습니다.

결국 완전 일치는 키로 조회하는 기술이지, 찾는 기술이 아닙니다. 사용자가 검색창을 열 때, 그 사람은 키를 입력하려는 게 아니라 흐릿한 기억을 문장으로 번역하려는 것입니다. 그 번역을 받는 쪽이 데이터베이스인데, 완전 일치는 그중에서 가장 덜 관대한 엔진입니다.

핵심 개념 요약 인포그래픽 1 NotebookLM이 소스를 종합해 생성한 인포그래픽입니다.

사용자가 실제로 치는 다섯 가지

검색창을 제품에 넣고 일주일만 지켜봐도, 입력은 대략 다섯 가지 형태로 수렴합니다. 이 분류는 실제로 입력박스를 바라보고 적어 내려간 결과입니다.

첫째는 등록된 이름 그대로입니다. ‘3분기 성장 프로젝트’ 전체를 치는 경우로, 가장 단순하고 완전 일치만으로도 처리되는 유일한 유형입니다. 실제 제품에서 이 비율은 생각보다 낮습니다. [추정] 둘째는 이름의 일부입니다. ‘성장’이라고만 치는 경우로, 가장 빈번한 형태이고 부분일치를 전제로 합니다. 이 유형은 다시 앞부분, 중간, 뒷부분으로 갈리는데, B-tree 인덱스는 앞부분만 찾아줍니다.

셋째는 오타입니다. 자리가 바뀐 글자, 빠진 글자입니다. 넷째는 데이터에 없는 단어입니다. 데이터에는 ‘청구서’라고 되어 있는데, 사용자는 ‘인보이스’를 치는 경우입니다. 저장한 쪽의 어휘와 찾는 쪽의 어휘가 다른 격차 문제입니다. 이 유형은 매칭 알고리즘으로는 풀리지 않습니다. 아무리 관대해도, 저장된 글자에 없는 문장은 찾을 수 없기 때문입니다.

다섯째는 조건 섞임입니다. ‘진행중인 거, 지난 달의’처럼 검색과 필터링을 동시에 하려는 시도입니다. 검색창은 단어만 받기 때문에, 이 유형은 별도의 필터 입력으로 조건을 분리해야 풀립니다. 이 다섯 밖에는 빈 입력이 있습니다. 플레이스홀더를 보고 커서가 깜빡이게 하고 그냥 떠나는 것인데, 검색창에서 가장 빈번한 이벤트입니다. 빈 입력이 잦다면, 사용자가 뭘 쳐야 할지 몰랐거나, 쳐도 되는 줄 몰랐다는 뜻입니다.

이 다섯 유형은 풀이가 완전히 다릅니다. 부분일치, 유사도 계산, 어휘 지도, 필터가 각각 필요합니다. 하나의 완전 일치 구절로 전부 처리하면, 전부 실패합니다. 플레이스홀더 문장도 이 분류와 관련 있습니다. ‘이름을 검색하세요’라고 적으면 사용자는 이름 전체를 쳐야 한다고 믿고, ‘프로젝트 검색’이라고 적으면 이름도 설명도 치면 된다고 믿습니다. 플레이스홀더는 사용자가 어떤 사전으로 말해야 답이 나오는지, 한 줄로 미리 말해주는 기능 설명입니다.

the-search-discipline 슬라이드 1

검색은 두 사전 사이의 번역이다

검색 기능은 두 사전 사이를 통역하는 일입니다. 첫 번째 사전은 사용자의 것입니다. 그 사물이 사용자의 머릿속에서 어떤 문장으로 불리느냐. 두 번째 사전은 데이터의 것입니다. 데이터베이스에는 어떤 문장이 저장되어 있느냐. 데이터베이스는 저장이 충실할 뿐, 사용자의 사전에 대해서는 아무것도 모릅니다. 저장된 형태 그대로의 문장으로만 대답할 수 있습니다.

검색을 만드는 개발자의 일은, 쿼리를 쓰기 전에 이 두 사전을 가까워지게 만드는 것입니다. 손 쓸 수 있는 지렛대는 세 개입니다.

첫째, 저장을 관대하게 만듭니다. 등록할 때 동의어가 있으면 함께 저장하는 것이지요. ‘인보이스’라고 치면 ‘청구서’ 필드도 함께 쓰는 식입니다. 이건 데이터 모델의 문제입니다. 둘째, 검색을 관대하게 만듭니다. 부분일치, 유사도, 트라이그램입니다. 셋째, 사전 사이의 격차를 직접 잇습니다. 작은 동의어 지도와 ‘이거 찾으시는 거?’라는 제안입니다.

세 지렛대를 전부 당기지 않아도 됩니다. 하지만 최소한 둘째는 당겨야 합니다. 완전 일치는 출발점이지 답이 아닙니다. 저장을 관대하게 만드는 일이 가장 오래가고 격차를 잇는 일이 가장 사용자 가깝지만, 검색 자체를 관대하게 만드는 일이 빠르고 싼 효과를 줍니다. 그래서 이 글의 다음 부분은 둘째 지렛대의 구체적인 모양, 그리고 그 위에서 제품을 완성하는 결과 페이지와 증거의 규율로 갑니다.

the-search-discipline 슬라이드 2

가장 싼 관대는 부분일치와 트라이그램이다

검색이 문장을 다루기 전에는 먼저 자릅니다. 영어에서는 공백이 단어 경계이고 소문자로 내리면 끝입니다. 한국어는 다릅니다. 공백은 단어 경계가 아니어서 ‘검색기능’과 ‘검색 기능’은 같은 뜻인데 자르는 조각이 다릅니다. 더 근본적인 문제는 형태입니다. ‘검색하다’, ‘검색을’, ‘검색이’는 같은 뿌리인데 문자열로는 서로 다릅니다. 형태소 단위로 자르려면 전용 분석기가 필요합니다.

1인 규모에서 한국어 형태소 분석을 처음부터 제대로 세팅하는 일은, 검색 기능을 만드는 일보다 더 큰 프로젝트가 됩니다. 분석기를 고르고 등록할 때와 검색할 때 같은 도구를 쓰는지를 검증하고, 데이터가 쌓인 뒤에는 재분석까지 합니다. [추정] 이 일은 검색 기능의 두세 배를 먹습니다. 그래서 가장 싼 관대는 문장을 자르지 않고 ‘안에 들어 있다’는 조건으로 찾는 부분일치입니다.

부분일치의 가장 직관적인 작성법은 LIKE ‘%성장%’ 조건입니다. 문제는 인덱스가 안 탑니다. B-tree 인덱스는 정렬된 순서로 저장되므로, 앞에 ‘성장’이 오는 행은 빠르게 뛸 수 있습니다. 하지만 어디에나 ‘성장’이 들어가는 행을 찾으면, 정렬 순서는 도움이 안 됩니다. 한 행 한 행을 전부 읽어야 합니다. 이걸 순차 스캔이라고 부릅니다. 1천 행이면 아무 일도 일어나지 않습니다. 10만 행이면 검색 한 건마다 10만 행을 읽고 50만 행이면 ‘예전에는 빨랐는데?’라는 질문을 받습니다.

전부 읽는 것에서 벗어나는 탈출로는 트라이그램 인덱스입니다. Postgres의 pg_trgm 확장이 그것인데, 원리는 단순합니다. 문장을 3글자씩 겹쳐가며 썰어, 그 조각들을 인덱스에 올립니다. ‘성장추진’에는 ‘성장추’, ‘장추진’ 같은 조각이 들어 있고 사용자가 ‘성장추’를 찾으면 그 조각을 인덱스에서 찾아 후보 행만 검사합니다. 전부 읽는 대신, 후보만 읽는 것입니다. 장점은 두 가지입니다. 언어를 가리지 않고 한국어든 영어든 글자로 된 텍스트에 작동합니다. 그리고 같은 인덱스로 ‘성장으로 시작’과 ‘성장이 들어간’, 두 종류의 질문에 모두 탑니다.

트라이그램의 경계도 알아야 합니다. 1~2글자 검색은 만드는 조각이 흔해서, 인덱스로 골라도 후보가 표의 대부분이 됩니다. 그래서 검색창에서 최소 길이를 제한하는 것입니다. 또 트라이그램은 부분 문자열을 찾지, 비슷한 문자열을 찾지 못합니다. ‘crew’가 저장되어 있어도 ‘crewt’는 못 만납니다. 오타 관용은 전용 검색 엔진의 영역입니다. 아래 표는 매칭 방법 네 가지를, 잘 되는 자리와 막히는 자리로 나눈 것입니다.

방법 잘 되는 자리 막히는 자리
완전 일치 키 조회 부분, 오타
트라이그램 짧은 한국어 포함 2글자 이하
전문 인덱스 긴 본문, 영어 짧은 한국어
전용 엔진 오타, 다국어 운영 부담

표를 읽는 법은 간단합니다. 저장된 데이터가 긴 문서라면 전문 인덱스가, 짧은 이름이라면 트라이그램이 맞습니다. 사용자가 오타를 쳐서도 답을 기대한다면, 트라이그램은 거기까지이고 전용 엔진의 영역입니다. 다만 1인 규모에서 가장 흔히 저지르는 실수는 반대 방향입니다. 5천 행에 전용 엔진을 앞세우는 것은, 성능 문제를 운영 문제로 바꾸는 일입니다. 지금의 느림이 LIKE 때문인지, 행 수 때문인지, 페이지 구조 때문인지 모른 채 새 서비스를 더하는 것은, 증세 없이 약을 더하는 것과 같습니다. 원인을 확인한 다음에 고르는 것입니다.

the-search-discipline 슬라이드 3

결과 페이지가 검색을 완성한다

사용자의 목표는 쿼리가 아닙니다. 답입니다. 검색창은 입구일 뿐이고 결과 페이지가 제품입니다. 사용자가 제품을 ‘찾기 좋은’이라고 느끼는 순간은, 결과가 보이는 화면에서입니다. 그 화면은 세 질문에 답해야 합니다. 이건 뭔가, 어느 순서가 맞는가, 아직 안 나온 건 어떤 모양인가.

첫 번째 질문에는 미리보기가 답합니다. 행의 미리보기가 클릭 없이 ‘이게 맞다’를 판단하게 해야 합니다. 매 행에 세 가지를 놓습니다. 제목, 하나의 식별 맥락, 매칭 하이라이트입니다. 식별 맥락은 엔티티마다 다릅니다. 주문이면 금액과 날짜, 고객이면 회사명과 최근 활동, 문서면 작성자와 수정일입니다. 고르는 기준은 하나입니다. 비슷한 제목의 두 행을 보았을 때, 이 칸 하나로 열지 않고 구분할 수 있느냐입니다.

두 번째 질문에는 정렬이 답합니다. 정렬은 두 개의 정직한 답 사이에서 고르는 문제입니다. 관련도 순은 ‘친 것에 가장 가까운 것부터’이고 시간 순은 ‘최근에 변한 것부터’입니다. 둘 다 옳고, 둘 다 때로는 틀립니다. 실용선은 조건부입니다. 검색어가 없으면 시간 순, 있으면 관련도 순입니다. 그리고 동점 처리를 잊지 마십시오. 관련도 점수가 같은 행의 순서는 정의가 없으면, 페이지를 넘길 때마다 같은 행의 순서가 바뀌어, 사용자가 목록이 움직인다고 느낍니다. 불변의 id를 마지막 타이브레이커로 붙이면, 그 흔들림이 끝납니다.

세 번째 질문, 그리고 가장 소홀해지는 자리는 빈 결과 페이지입니다. 결과가 0개인 것은 정보입니다. 하지만 대부분의 제품은 그 자리에 아무것도 보여주지 않습니다. 빈 화면은 ‘없다’는 말하지만 ‘다음에 뭘 치나’는 말해주지 못합니다. 빈 결과 페이지에는 네 가지를 놓습니다. 무엇을 쳤는지의 복기, 조건을 지우라는 제안, 더 짧은 단어를 권하는 것, 그리고 유사한 단어 제안입니다.

여기서 가장 값진 것은 기록입니다. 0결과인 검색을 매번 쿼리, 결과 수, 시각과 함께 한 행씩 남깁니다. 이 로그가 쌓이면, 0결과가 자주 모이는 단어군이 보입니다. 사용자가 ‘인보이스’를 40번 쳤는데 데이터에는 ‘청구서’만 있다면, 동의어 지도에 한 줄 추가할 이유가 생긴 것입니다. 빈 결과 로그는 사용자가 원하는 것을 데이터가 모르는 어휘 격차의 보고서입니다.

the-search-discipline 슬라이드 4

번역의 각 단계를 증명으로 다뤄라

인덱스가 있는 쿼리는 인덱스를 쓰지 않을 수 있습니다. 플랜러는 비용을 계산해서, 전부 읽는 쪽이 싸다고 판단하면 순차 스캔을 고릅니다. 그래서 검색이 느리다고 불평이 온 첫날에 할 일은 EXPLAIN입니다. 검색 코드를 건드릴 때마다, 매번 실행하는 도구입니다. 성능 문제가 ‘느려졌다’에서 시작하면, 증명이 없어서 추측 싸움이 됩니다. EXPLAIN의 한 줄은 그 싸움을 끝냅니다.

두 번째 증거는 20개 쿼리입니다. 실제로 치겠더란 20개 검색어를 적는 연습입니다. 바쁠 때 정말로 치겠더란 20개입니다. 적다 보면, 절반은 완전 일치로는 못 찾고 일부는 부분일치로도 못 찾는다는 것이 드러납니다. 이 20개를 위에서 나눈 다섯 유형으로 분류하고, 지금의 검색창이 몇 개를 답하는지 세어 보십시오. 이 숫자가 개선의 진짜 측정입니다. 검색을 고쳤을 때, 아무 문장이나로 테스트하지 않고 같은 20개를 다시 돌려, 잡힌 수가 늘어나는지를 봅니다.

세 번째 증거는 검색 로그 전체입니다. 한 달치만, 세 가지를 봅니다. 0결과가 반복되는 단어, 많이 잡히지 않는 큰 쿼리, 상위 몇 개의 쿼리입니다. 0결과 단어는 어휘 격차의 증거이고 클릭이 한 번도 없는 큰 쿼리는 미리보기가 ‘이거다’를 말해 주지 못했다는 증거입니다. 결과는 많지만 아무 행도 클릭하지 않는 쿼리가 보인다면, 다시 결과 페이지로 돌아가야 합니다.

그리고 규모가 커지기 전에 기억해 두어야 할 것이 있습니다. SaaS라면, 모든 검색에 테넌트 필터가 처음부터 붙습니다. WHERE tenant_id = $1. 이걸 빼면, A 사용자는 B 사용자의 데이터를 찾게 됩니다. 이 필터는 전제입니다. 또 이주하는 순간에 대해 선을 둡니다. 지금 없는 성능을 위해 전용 엔진으로 이주하지는 않습니다. 오타 관용이 제품의 얼굴이 되거나, 다국어가 되거나, 행이 천만 단위가 되거나, 서비스를 하나 더 돌릴 사람이 생겼을 때, 그때 이주 논의를 시작합니다.

결국 이 글이 말하는 규율은 하나입니다. 번역의 각 단계를 증명으로 다루라. 추측으로는 안 된다. 20개 쿼리가 번역의 테스트 세트를, EXPLAIN이 인덱스의 증명을, 검색 로그가 다음 번역 개선의 자료가 됩니다. ‘사용자가 뭘 배워야 했나’를 검색을 고칠 때마다 한 번씩 물어 보십시오. 검색을 쓰기 전에 사용자가 배워야 한다면, 그 검색은 아직 완성되지 않았습니다. 데이터는 쌓이고 사용자는 바뀌고 말은 바뀝니다. 변하지 말아야 하는 쪽은 검색입니다.

참고 자료

본문의 B-tree 접두 검색, pg_trgm의 3글자 썰기, 전문 인덱스의 한계, EXPLAIN과 인덱스 사용 확인, 한국어 형태소 분석은 아래 자료와 대조해 보실 수 있습니다. 본문에 [추정]으로 표시한 비율과 배수는 저자의 운영 시작점입니다.

핵심 개념 요약 인포그래픽 2 NotebookLM이 소스를 종합해 생성한 인포그래픽입니다.

태그: database, full-text-search, postgres, saas, search, solo-developer, trigram

카테고리:

업데이트: