포스팅에 사용된 그림은 책에서 제공하는 그림들 입니다.
검색어 자동완성 시스템을 설계해보자.
구글에 dinner를 검색하면 아래와 같은 검색어 자동 기능이 있다.
요구사항을 가정해 보자.
개략적으로 보면 시스템은 두 부분으로 나뉜다.
데이터 수집 서비스가 어떻게 동작하는지 간단한 예제를 통해 살펴보자. 질의문과 사용빈도를 저장하는 빈도 테이블이 있다고 가정하겠다. 처음에 이 테이블은 비어 있는데, 사용자가 ‘twitch’,’twitter’,’twitter’,’twillo’를 순서대로 검색하면 그 상태가 아래 그림처럼 바뀌어 간다
아래표 같은 빈도 테이블이 있는 상태라고 가정하면 두 개의 필드가 있음을 볼 수 있다.
이 상태에서 사용자가 “tw”를 검색창에 입력하면 아래의 “top 5” 자동완성 검색어가 표시 되어야 하는데, “top 5”는 빈도 필드에 기록된 수치를 사용한다고 가정해보자.
가장 많이 사용된 5개 검색어 “top 5”는 아래의 SQL 질의문을 사용해 계산할 수 있다.
SELECT * FROM frequency_table
WHERE query Like `prefix%`
ORDER BY frequency DESC
LIMIT 5
데이터 양이 적을 때는 나쁘지 않은 설계안이다. 하지만 데이터가 아주 많아지면 데이터베이스가 병목이 될 수 있다. 이 병목 현상을 해결할 방법은 상세 설계에서 알아보자
데이터 수집 서비스와 질의 서비스의 두 부분으로 구성된 개략적 설계안은 최적화된 결과물이라고 말하기엔 어렵지만 출발점으론 썩 괜찮은 방법 이었다. 상세 설계에선 컴포넌트를 몇 개 골라 보다 상세히 설계하고 최적화 방안을 논의해볼 것이다
개략적 설계안에서는 관계형 데이터베이스를 저장소로 사용했었다. 하지만 관계형 데이터베이스를 이용해 가장 인기 있었던 다섯 개 질의문을 골라내는 방법은 효율적이지 않다. 이 문제는 트라이를 사용해 해결할 것이다.
💡 트라이 자료구조란? 트라이의 이름은 “retrieval”이라는 단어에서 온것이며, 접두어 트리 (prefix tree)라고도 불린다트라이가 시스템의 핵심적 부분이 될 것이므로, 충분한 시간을 할애하여 주어진 요구사항에 딱 맞는 트라이를 만들도록 할 것이다.
일단, 트라이 자료구조에 대해 간단하게 살펴본 후 어떻게 최적화하면 응답 시간을 줄일 수 있을지에 집중해보겠다.
트라이는 문자열들을 간략하게 저장할 수 있는 자료구조다. 문자열을 꺼내는 연산에 초점을 맞추어 설계된 자료구조이며, 핵심 아이디어는 아래와 같다.
아래 그림은 질의어 ‘tree’, ‘try’, ‘true’, ‘toy’, ‘wish’, ‘win’이 보관된 트라이다 해당 질의어를 나타내는 노드는 굵은 외곽선으로 표시 되어 있다.
기본 트라이 자료구조는 노드에 문자열만 저장한다, 이용 빈도에 따라 정렬된 결과를 반환하기 위해서는 노드에 빈도 정보까지 저장해야할 필요가 있다
이 빈도 정보를 트라이 노드에 저장하게 되면 아래 그림과 같은 상태가 될 것이다
그러면 이 트라이 자료구조로 검색어 자동완성은 어떻게 구현할 수 있을까? 알고리즘을 살펴보기전, 용어 몇가지를 숙지하자
가장 많이 사용된 질의어 k개는 다음과 같이 찾을 수 있다
위 그림의 예제를 보자 k=2이고, 입력으로 ‘be’를 입력했다고 가정하면 아래와 같이 동작할 것이다
동작에 필요한 시간 복잡도는 O(p) + O(c) + O(clogc)다.
이 알고리즘은 직관적이지만 최악의 경우에는 k개 결과를 얻으려고 전체 트라이를 다 검색해야 하는 일이 생길 수도 있다. 이 문제를 해결하는 방법은 두가지가 있다.
이 두가지 최적화 방안을 순서대로 살펴보자
사용자가 검색창에 긴 검색어를 입력하는 일은 거의 없다. 따라서 p값은 정숫값(가령 50같은)이라고 가정해도 안전하다. 검색어의 최대 길이를 제한할 수 있다면 “접두어 노드를 찾는” 단계의 시간 복잡도는 O(p)에서 O(작은 상수값)=O(1)로 바뀔 것이다
각 노드에 k개의 인기 검색어를 저장해두면 전체 트라이를 검색하는 일을 방지할 수 있다.
5~10개 정도의 자동완성 제안을 표시하면 충분하므로, k는 작은 값이다. 미리 캐시해 두면 각 노드에 질의어를 저장할 공간이 많이 필요하게 된다는 단점도 있지만, 빠른 응답속도가 아주 중요할 때는 이 정도의 단점은 희생할 만한 가치가 있다.
아래 그림은 개선된 트라이 구조다. 각 노드에 가장 인기 있는 검색어 다섯가지를 저장하도록 했다.
앞의 두 가지 최적화 기법을 적용하면 시간 복잡도가 어떻게 달라지는지 알아보면 다음과 같다
각 단계의 시간 복잡도가 O(1)로 바뀐 덕분에, 최고 인기 검색어 k개를 찾는 전체 시간 복잡도도 O(1)로 바뀐다
지금까지 살펴본 설계안은 사용자가 입력을 할 때마다 실시간으로 데이터를 수정했다. 이 방법은 다음 두 가지 문제로 그다지 실용적이지 않다
규모 확장이 쉬운 데이터 수집 서비스를 만들려면 데이터가 어디서 오고 어떻게 이용되는지를 살펴야 한다. 트위터 같은 실시간 애플리케이션이면 제안되는 검색어를 항상 신선하게 유지할 필요가 잇지만 구글 검색 같은 애플리케이션이라면 그렇게 자주 바꿔줄 이유는 없을 것이다
용례가 달라지더라도 데이터 수집 서비스의 토대는 바뀌지 않을 것이다. 트라이를 만드는 데 쓰는 데이터는 보통 데이터 분석 서비스나 로깅 서비스로부터 올 것이기 때문이다
아래 그림은 데이터 분석 서비스의 수정된 설계안이다 각 컴포넌트를 차례로 살펴보자
데이터 분석 서비스 로그에는 검색창에 입력된 질의에 관한 원본 데이터가 보관된다. 새로운 데이터가 추가될 뿐 수정은 이루어지지 않으며 로그 데이터에는 인덱스를 걸지 않는다
데이터 분석 서비스로부터 나온 로그는 보통 그 양이 엄청나고 데이터 형식도 제각각인 경우가 많다. 따라서 이 데이터를 잘 취합하여 우리 시스템이 쉽게 소비할 수 있도록 해야한다.
애플리케이션의 종류에 따라 취합 방식은 달라질 수 있다. 트위터 같은 실시간 애플리케이션은 취합 주기를 보다 짧게 가져가야 하며, 대부분의 애플리케이션은 일주일에 한 번 정도로 로그를 취합해도 충분할 것이다.
아래표는 매주 취합한 데이터의 사례다. time 필드는 해당 주가 시작한 날짜를 나타낸다. frequency 필드는 해당 질의가 해당 주에 사용된 횟수의 합이다
작업 서버는 주기적으로 비동기적 작업을 실행하는 서버 집합이다. 트라이 자료구조를 만들고 트라이 데이터베이스에 저장하는 역할을 담당한다
트라이 캐시는 분산 캐시 시스템으로 트라이 데이터를 메모리에 유지하여 읽기 연산 성능을 높이는 구실을 한다. 매주 트라이 데이터베이스의 스냅샷을 떠서 갱신한다.
트라이 데이터베이스는 지속성 저장소다. 트라이 데이터베이스로 사용할 수 있는 선택지로는 다음의 두 가지가 있다.
아래 그림은 트라이를 해시 테이블로 어떻게 대응할 수 있는지 보여준다
위 그림에서 각 트라이 노드는 하나의 <키,값> 쌍으로 변환된다.
개략적 설계안에서 살펴본 질의 서비스는 데이터베이스를 활용하여 최고 인기 검색어 다섯 개를 골라냈다. 아래그림은 해당 설계안의 비효율성을 개선한 새 설계안이다.
질의 서비스는 빨라야 한다. 이를 위해 다음과 같은 최적화 방안을 생각해 보자.
트라이는 검색어 자동완성의 핵심 컴포넌트다. 지금부터 트라이 관련 연산들이 어떻게 동작하는지 살펴보자
트라이 생성은 작업 서버가 담당하며, 데이터 분석 서비스와 로그나 데이터베이스로부터 취함된 데이터를 이용한다.
트라이를 갱신하는 데는 두가지 방법이 있다.
‘beer’의 이용 빈도를 10에서 30으로 갱신해야 하는 상황이라면, 위 그림과 같이 해당 노드의 상위 노드들에 기록된 이용 빈도 수치도 전부 30으로 갱신될 것이다.
혐오성이 짙거나, 폭력적이거나, 성적으로 노골적이거나, 여러 가지로 위험한 질의어를 자동완성 결과에서 제거해야 한다. 이를 위한 방법으로는 필터 계층을 두어 부적절한 질의어가 반환되지 않도록 하는 것이다. 필터 계층을 두면 필터 규칙에 따라 검색 결과를 자유롭게 변경할 수 있다는 장점이 있따. 데이터베이스에서 해당 검색어를 물리적으로 삭제하는 것은 다음번 업데이트 사이클에 비동기적으로 진행하면 된다.
자동완성된 검색어를 사용자에게 제공하는 시스템의 설계를 마쳤으니, 트라이의 크기가 한 서버에 넣기엔 너무 큰 경우에도 대응할 수 있도록 규모 확장성 문제를 해결해보자
영어만 지원하면 되기 때문에, 간단하게는 첫 글자를 기준으로 샤딩하는 방법을 생각해 볼 수 있다.
이 방법을 사용하면 사용 가능한 최대 서버 개수는 영어 알파벳 개수인 26대로 제한된다. 이 이상으로 서버를 늘리려면 샤딩을 계층적으로 해야한다. 가령 검색어의 첫 번째 글자는 첫 번째 레벨의 샤딩에 쓰고, 두 번째 글자는 두 번째 레벨의 샤딩에 쓰는 것이다. 예를 들어 ‘a’로 시작하는 검색어를 네 대 서버에 나눠 보관하고 싶다고 하면, ‘aa’부터 ‘ag’까지는 첫 번째 서버에, ‘ah’부터’an’까지는 두 번째 서버에, ‘ao’부터’au’까지는 세 번째 서버에, 나머지는 네 번째 서버에 보관하면 될 것이다.
얼핏 생각하기에는 그럴싸해 보이지만, ‘c’로 시작하는 단어가 ‘x’로 시작하는 단어보다 많다는 점을 고려하면 데이터를 각 서버에 균등하게 배분하기가 불가능하다.
이 문제를 해결하기 위해, 본 설계안의 경우 과거 질의 데이터의 패턴을 분석하여 샤딩하는 아래와 같은 그림을 제안한다.
아래 그림에서 검색어 대응 샤드 관리자는 어떤 검색어가 어느 저장소 서버에 저장되는지에 대한 정보를 관리한다. 예를 들어 ‘s’로 시작하는 검색어의 양이 ‘u~z’로 시작하는 검색어를 전부 합친 것과 비슷하다면, ‘s’에 대한 샤드 하나와 ‘u~z’까지의 검색어를 위한 샤드 하나를 두어도 충분할 것이다.
상세 설계를마치면 다음과 같은 것을 생각해보아야 한다.
비영어권 국가에서 사용하는 언어를 지원하려면 유니코드 데이터를 저장해야 한다.
국가별로 다른 트라이를 사용하면 된다. 트라이를 CDN에 저장하여 응답속도를 높이는 방법도 생각해 볼 수 있다.
새로운 뉴스 이벤트가 생긴다든가 하는 이유로 특정 검색어의 인기가 갑자기 높아질 수 있다. 현 설계안은 그런 검색어를 지원하기에 적합하지 않다.
실시간 검색어 자동완성 시스템을 구축하는데에 도움될 만한 몇 가지 아이디어만 보자면 다음과 같다