Backend · Search · Database

색인은 늦게 따라온다 - 방금 쓴 글이 안 보이는 이유

글을 저장하고 바로 검색했는데 안 나온다. 버그가 아니라 역색인이라는 구조가 원래 그렇게 생겼다. 왜 바로 못 넣는지, 그 지연을 어디까지 줄일 수 있는지 본다.

·검색이 빠른 이유 5편
목차
  1. 저장은 됐는데 검색에 안 뜬다
  2. 책을 고쳐 찍으면 찾아보기도 다시 만든다
  3. 문서 하나가 목록 수백 개를 건드린다
  4. 그래서 모아서 쓴다
  5. 실시간이 아니라 근실시간
  6. 지우는 일이 더 까다롭다
  7. 병합이 진짜 비용이다
  8. 이 블로그도 그렇다
  9. 실무에서: 지연을 전제로 화면을 짠다
  10. 정리

글을 쓰고 저장 버튼을 눌렀다. 목록에는 잘 뜨는데 검색창에 제목을 그대로 넣으면 안 나온다.

저장은 됐는데 검색에 안 뜬다

이 신고를 받으면 색인에 문서가 있는지, 토큰이 맞는지부터 짚게 된다. 그런데 삼십 초쯤 뒤에 다시 해보면 멀쩡히 나온다.

버그가 아니다. 저장과 검색 가능해지는 시점이 원래 다르다. 그리고 이건 특정 제품의 흠이 아니라 역색인이라는 구조에서 곧장 나오는 성질이다.

책을 고쳐 찍으면 찾아보기도 다시 만든다

찾아보기를 다시 떠올려 보자. 책 본문에 한 문단을 끼워 넣으면 무슨 일이 생기나.

그 문단에 든 낱말들의 항목에 새 쪽 번호를 넣어야 하고, 그 문단 뒤의 모든 내용이 한 쪽씩 밀리면서 찾아보기의 거의 모든 쪽 번호가 어긋난다. 그래서 실제 책은 문단 하나를 고쳤다고 찾아보기를 손보지 않는다. 개정판을 찍을 때 통째로 다시 만든다.

역색인도 사정이 비슷하다. 본문을 조금 고쳤다고 색인을 조금 고치는 게 생각만큼 싸지 않다.

문서 하나가 목록 수백 개를 건드린다

왜 안 싼지 세어 보면 바로 보인다.

글 하나에 서로 다른 낱말이 200개 들어 있다고 하자. 이 글을 색인에 넣는다는 건 포스팅 리스트 200개에 이 문서 번호를 끼워 넣는 일이다. 게다가 목록은 오름차순으로 정렬돼 있어야 하니 끝에 붙이는 게 아니라 중간에 끼워야 하는 경우도 생긴다.

디스크에 저장된 목록이라면 이건 200군데를 여기저기 찾아가 조금씩 고치는 일이 된다. B-tree와 LSM-tree 글에서 본 그 문제와 정확히 같다. 작은 변경 하나가 흩어진 자리를 여러 번 다시 쓰게 만든다.

글을 저장할 때마다 이 일을 즉시 다 하면, 검색은 빨라도 글쓰기가 느려진다. 그래서 아무도 그렇게 안 만든다.

그래서 모아서 쓴다

발상은 단순하다. 기존 색인을 고치지 말고, 새 문서들만 담은 작은 색인을 하나 더 만든다.

글 A
글 B
글 C
굽는다 굽는다
저장 0.9초 안 보임 검색에 보인다
저장 0.8초 안 보임 검색에 보인다
저장 검색에 보인다
0초 1초 2초 3초

저장은 셋 다 끝났다. 그런데 굽기 전까지는 검색에 없다. 안 보이는 시간은 굽는 시점까지 얼마나 남았느냐가 정한다.

새 글은 일단 메모리에 쌓아 둔다. 어느 정도 모이거나 정해진 시간이 지나면, 그 묶음을 한 덩어리의 작은 색인으로 통째로 굽는다. 이 덩어리를 보통 세그먼트라고 부른다.

세그먼트에는 성질이 하나 있다. 한 번 구우면 고치지 않는다. 고칠 일이 없으니 정렬해서 빽빽하게 눌러 담을 수 있고, 여러 요청이 동시에 읽어도 잠글 게 없다.

그러면 검색은 어떻게 되나. 세그먼트를 전부 뒤져 결과를 합친다. 색인이 하나가 아니라 여러 개가 되고, 검색은 그 여럿에 같은 질문을 던진 뒤 결과를 모은다.

여기서 지연이 나온다. 메모리에 쌓여 있고 아직 안 구워진 글은 어느 세그먼트에도 없다. 검색이 뒤지는 대상에 안 들어 있으니 안 나온다. 저장은 분명히 됐는데도 그렇다.

실시간이 아니라 근실시간

그래서 검색 시스템은 자기가 실시간이라고 말하지 않는다. 근실시간이라고 말한다.

굽는 주기를 짧게 하면 지연이 줄어든다. 그런데 짧게 할수록 작은 세그먼트가 우수수 생기고, 검색이 뒤져야 할 덩어리 수가 늘어 조회가 느려진다. 길게 하면 조회는 빠른데 새 글이 늦게 보인다.

굽는 주기새 글이 보이기까지세그먼트 수조회
짧게빠르다많아진다느려진다
길게느리다적다빠르다

이건 고르는 문제지 최적화하는 문제가 아니다. 로그 검색처럼 몇 초 늦어도 되는 데이터는 주기를 길게 잡고, 상품 재고처럼 바로 반영돼야 하는 데이터는 짧게 잡는다. 서비스가 견딜 수 있는 지연이 얼마인지를 먼저 정해야 이 값을 정할 수 있다.

지우는 일이 더 까다롭다

세그먼트를 안 고친다고 했는데, 그러면 문서를 지울 때는 어떻게 하나.

못 지운다. 지웠다고 표시만 한다. 검색은 결과를 모은 뒤 지움 표시가 붙은 문서를 걸러 내고 내보낸다. 실제로 사라지는 건 나중이다.

그래서 수정은 이렇게 처리된다. 옛 문서에 지움 표시를 붙이고, 고친 내용을 새 문서로 넣는다. 수정이 곧 삭제와 추가다.

이 구조는 MVCC와 닮았다. 제자리에서 덮어쓰는 대신 새 것을 만들고 옛 것에 표시를 남기는 방식이고, 그래서 같은 대가를 떠안는다. 죽은 문서가 쌓이고, 언젠가 치워야 한다.

자주 고치는 데이터일수록 이게 심하다. 문서 1만 건을 하루에 열 번씩 고치면 색인 안의 실제 덩어리는 10만 건에 가까워지고, 검색은 그중 9만 건을 걸러 내느라 일한다.

병합이 진짜 비용이다

그래서 주기적으로 세그먼트들을 합친다.

작은 것들을 모아 큰 것 하나로 다시 굽고, 그 과정에서 지움 표시가 붙은 문서를 진짜로 버린다. 합치고 나면 뒤져야 할 덩어리 수가 줄어 검색이 다시 빨라진다.

문제는 이게 통째로 다시 쓰는 일이라는 점이다. 10GB짜리 세그먼트 둘을 합치면 20GB를 읽고 20GB를 쓴다. 그동안 디스크와 CPU를 크게 먹는다.

주의

검색 지연이 평소엔 20ms인데 가끔 몇 초씩 튀는 일이 있다. 대개 병합이 도는 중이다. 평균만 보고 있으면 절대 안 보이고, 꼬리 쪽 응답 시간을 봐야 드러난다. 색인을 많이 넣는 시간대와 검색이 몰리는 시간대가 겹치면 특히 뚜렷하다.

분석 규칙을 바꿔 전체를 다시 색인해야 할 때도 같은 부담이 온다. 그래서 현장에서는 새 색인을 옆에 통째로 새로 굽고, 다 구워지면 서비스가 바라보는 이름표를 새 쪽으로 옮기는 방식을 쓴다. 쓰던 색인을 제자리에서 고치지 않는다는 점에서 세그먼트를 다루는 방식과 발상이 같다.

이 블로그도 그렇다

지금 읽고 있는 이 블로그가 그 실물이다. 이 사이트의 검색 색인은 빌드할 때 한 번 만들어져 결과물에 딸려 나가는 파일이라, 글을 고치고 저장하는 것만으로는 검색에 반영되지 않는다. 다시 빌드해야 반영된다. 그래서 개발 서버에서는 검색이 아예 동작하지 않고, 그게 고장이 아니라 정상이다. 굽는 주기를 극단까지 길게 잡은 셈인데, 하루에 몇 번 글이 올라오는 사이트에서는 이 선택이 아무것도 잃지 않는다. 지연을 얼마나 허용할지는 데이터가 얼마나 자주 바뀌는지가 정한다.

실무에서: 지연을 전제로 화면을 짠다

지연을 0으로 만들려고 애쓰는 대신, 지연이 있다는 전제로 화면과 흐름을 짜는 쪽이 훨씬 싸게 먹힌다.

방금 쓴 것은 검색으로 보여 주지 않는다. 글을 저장한 직후 이동하는 화면에서는 검색을 태우지 말고 원본 저장소에서 직접 읽어 보여 준다. “내가 쓴 글” 목록도 마찬가지다. 사용자가 지연을 느끼는 자리는 거의 전부 자기가 방금 한 일이라, 그 자리만 원본을 보게 해도 체감 문제가 사라진다.

이 처방은 복제 글에서 본 것과 판박이다. 거기서도 방금 쓴 걸 못 읽는 문제를 “쓴 직후에는 원본을 읽게 한다”로 풀었다. 읽는 곳과 쓰는 곳이 갈린 구조에서는 언제나 같은 처방이 나온다.

색인 실패를 조용히 넘기지 않는다. 원본에는 저장됐는데 색인에 못 들어가는 일이 반드시 생긴다. 이때 사용자에게는 저장 성공이라고 답한 상태라 아무도 모른다. 그래서 색인 작업은 실패하면 다시 시도할 수 있는 형태로 두고, 원본 건수와 색인 건수를 주기적으로 맞춰 보는 점검을 따로 돌린다.

전체 재색인을 언제든 돌릴 수 있게 해 둔다. 분석 규칙을 바꾸거나 색인이 깨지면 결국 이 길로 온다. 급할 때 처음 만들어 보게 되면 그때가 가장 나쁜 순간이다.

정리

  • 저장과 검색 가능해지는 시점이 다른 건 버그가 아니라 역색인의 구조에서 나오는 성질이다.
  • 문서 하나를 넣는다는 건 포스팅 리스트 수백 개를 건드리는 일이라, 즉시 반영하면 쓰기가 무너진다.
  • 그래서 새 문서를 모아 작은 색인 덩어리로 굽고, 검색은 여러 덩어리를 다 뒤져 합친다. 안 구워진 것은 안 보인다.
  • 굽는 주기는 새 글이 보이는 속도와 조회 속도를 맞바꾸는 손잡이다. 최적값이 아니라 서비스가 견딜 지연이 정한다.
  • 덩어리는 안 고치므로 삭제는 표시만, 수정은 삭제와 추가다. 그래서 죽은 문서가 쌓이고 병합으로 치운다.
  • 병합은 통째로 다시 쓰는 일이라 그때 지연이 튄다. 평균이 아니라 꼬리를 봐야 보인다.
  • 지연을 없애려 애쓰는 대신, 방금 쓴 것만 원본에서 보여 주는 쪽이 훨씬 싸다.
참고

여기까지 오면 검색엔진이 무엇을 해 주고 무엇을 요구하는지가 다 나왔다. 다음 글은 마지막 판단이다. 이 구조를 통째로 들일 것인가, 아니면 지금 쓰는 데이터베이스로 더 버틸 것인가.