색인 중인데 검색은 왜 안 막힐까?

2026년 07월 23일

opensearch

시리즈OpenSearch 내부 구조#4
# OpenSearch# Lucene# 세그먼트# 동시성# MVCC
목차

들어가며

관계형 DB를 다뤄봤다면 이런 상황이 익숙할 겁니다.

대량 UPDATE가 도는 동안 조회 쿼리가 느려지거나 대기한다.

그런데 OpenSearch는 초당 수만 건을 색인하는 중에도 검색이 멀쩡히 나갑니다. 색인과 검색이 서로를 거의 방해하지 않아요.

비결은 정교한 락 설계가 아닙니다. 정반대예요.

애초에 락을 걸 일이 없게 만들었습니다.

ℹ️

이번 글의 범위

  • 다루는 것: 불변성과 락의 관계, tombstone 삭제, merge의 진짜 역할, 스냅샷 읽기
  • 다루지 않는 것: refresh 주기와 NRT(5편), 복제 시의 동시성(6편)

락은 왜 필요한가

먼저 락이 왜 필요한지부터 봅시다. 이유는 하나입니다.

누군가 데이터를 바꾸는 중인데 다른 사람이 그걸 읽으면, 반쯤 바뀐 상태를 볼 수 있으니까.

칠판에 적힌 글씨를 고치는 상황을 떠올려 보세요. 지우고 다시 쓰는 중간에 누가 보면 뜻이 깨진 문장을 읽게 됩니다. 그래서 “고치는 동안 보지 마세요”라고 막아야 하죠.

그런데 아무도 안 고친다면? 막을 이유가 없습니다.

예아니오데이터가 변경되는가?락이 필요하다(반쯤 바뀐 상태를 막아야 함)락이 필요 없다(항상 온전한 상태)

Lucene은 오른쪽 길을 골랐습니다. 세그먼트를 절대 수정하지 않기로 한 겁니다.


변경이 아니라 추가

2편에서 봤듯, 역색인은 사전순 정렬을 유지해야 해서 중간 삽입이 매우 비쌉니다. 그래서 기존 세그먼트를 고치는 대신 새 세그먼트를 하나 더 만듭니다.

색인 전:  _0 [doc1, doc2]
                    ↓ 새 문서 doc3 추가
색인 후:  _0 [doc1, doc2]   ← 그대로. 한 글자도 안 바뀜
          _1 [doc3]         ← 새로 생김

_0은 한 글자도 바뀌지 않았습니다. 그저 _1이 새로 생겼을 뿐이죠.

이것이 결정적입니다. 지금 _0을 읽고 있는 검색이 있어도 아무 문제가 없어요. 읽는 대상이 변하지 않으니까요. 락 없이 동시에 읽어도 안전합니다.

💡

“추가”는 왜 안전한가?

새 세그먼트가 만들어지는 동안, 그 파일은 아직 “유효한 세그먼트 목록”에 등록되지 않은 상태입니다. 완성된 뒤에야 목록에 원자적으로 추가되죠.

그래서 검색은 “완성 안 된 세그먼트”를 볼 일이 없습니다. 보이거나(완성 후) 안 보이거나(완성 전) 둘 중 하나이지, 반쯤 만들어진 걸 보는 경우는 없어요.


그럼 삭제와 수정은 어떻게 하나

여기서 자연스러운 의문이 생깁니다.

추가는 그렇다 치고, 삭제는? 파일을 못 고치는데?

정답은 “안 지웁니다” 입니다.

삭제 = 죽었다고 표시만 하기

문서를 삭제하면 세그먼트 파일은 그대로 두고, 옆에 “이 문서는 죽었다”는 표시만 남깁니다. 이 표시를 tombstone(묘비) 라고 부릅니다.

_0.cfs   [doc1, doc2, doc3]   ← 파일은 그대로
_0_1.liv  doc2 = 삭제됨        ← 묘비 파일

검색할 때는 세그먼트를 읽으면서 묘비에 적힌 문서를 결과에서 빼버립니다. 사용자 눈에는 사라진 것처럼 보이지만, 디스크에는 그대로 남아있어요.

수정 = 삭제 + 추가

사실 Lucene에는 “수정”이라는 개념이 없습니다.

문서 수정 = 기존 문서에 묘비를 세운다 + 수정된 내용을 새 문서로 추가한다

OpenSearch의 _update API도 내부적으로는 이걸 합니다. 겉보기엔 수정이지만 실제로는 삭제 후 재색인이에요.

⚠️

그래서 문서를 지워도 디스크 사용량이 즉시 줄지 않습니다.

“1000만 건 삭제했는데 왜 용량이 그대로죠?”라는 질문의 답이 여기 있습니다. 묘비만 세워졌을 뿐 실제 데이터는 남아있으니까요.


merge의 진짜 역할

그럼 삭제된 문서는 언제 진짜로 사라질까요? merge 때입니다.

3편에서 merge를 “세그먼트를 합쳐서 개수를 줄이는 작업”이라고 했는데, 그게 전부가 아니었습니다. merge는 살아있는 문서만 골라서 새 세그먼트를 만듭니다.

살아있는 문서만 복사merge 후_2 [doc1, doc3, doc4]merge 전_0 [doc1, doc2❌, doc3]_1 [doc4, doc5❌]

묘비가 세워진 doc2와 doc5는 새 세그먼트에 아예 복사되지 않습니다. 옛 세그먼트 파일은 통째로 삭제되고요. 이때 비로소 디스크가 회수됩니다.

정리하면 merge는 두 가지 일을 동시에 합니다.

역할효과
세그먼트 개수 줄이기검색 시 뒤질 파일이 줄어 빨라짐
삭제 문서 실제 제거디스크 회수, 검색 대상 감소

직접 확인해보기

시뮬레이터에서 문서를 클릭하면 삭제됩니다. 묘비가 어떻게 동작하는지 눈으로 보세요.

🔬삭제와 merge 시뮬레이터
📝In-memory Buffer검색 불가
비어 있음
📜Translogflush 완료
비어 있음
💾세그먼트검색 가능 · 한번 만들면 수정 불가
아직 세그먼트가 없습니다
🔍 지금 검색되는 문서
하나도 없습니다
💡 문서를 색인해보세요.

실습 1 — 삭제는 표시일 뿐

  1. 문서 색인 3번 → refresh → 세그먼트 _0에 문서 3개
  2. 세그먼트 안의 문서 하나를 클릭합니다
  3. 취소선이 그어지고 (삭제됨) 이 붙습니다. 하지만 세그먼트에는 그대로 남아있죠
  4. 아래 “지금 검색되는 문서”에서는 사라졌습니다

실습 2 — merge가 진짜로 지운다

  1. 문서 색인 2번 → refresh → 세그먼트가 2개
  2. merge 를 누릅니다
  3. 삭제 표시됐던 문서가 완전히 사라졌습니다. 힌트 메시지에도 몇 건이 진짜로 지워졌는지 나옵니다

검색은 “그 순간의 사진”을 본다

락이 없는 이유가 하나 더 있습니다.

검색이 시작될 때, Lucene은 그 시점에 유효한 세그먼트 목록을 잡아둡니다. 검색이 끝날 때까지 그 목록으로만 작업해요.

영향 없음검색 시작세그먼트 목록 [_0, _1] 확보_0 검색 중..._1 검색 중...결과 반환이 사이에 refresh 발생_2 생성

검색 도중에 refresh가 일어나 _2가 새로 생겨도, 진행 중인 검색은 그걸 보지 않습니다. 시작 시점의 사진으로만 답을 만들죠. 그래서 결과가 일관됩니다.

💡

진행 중인 검색이 보는 세그먼트는 merge로 지워지지 않습니다.

merge가 옛 세그먼트를 삭제하려 해도, 아직 그 파일을 읽고 있는 검색이 있으면 기다립니다. 참조하는 쪽이 모두 끝난 뒤에 지워요. 그래서 “검색 도중에 파일이 사라져서 에러”가 나지 않습니다.

DB의 MVCC와 비슷한가

익숙하다면 이렇게 연결하면 좋습니다.

RDB의 MVCCLucene
목표읽기가 쓰기를 기다리지 않게동일
방법변경 전 버전을 undo log에 보관세그먼트를 아예 안 고침
읽기가 보는 것트랜잭션 시작 시점의 스냅샷검색 시작 시점의 세그먼트 목록
옛 버전 정리undo log 정리merge

“읽는 쪽에 일관된 스냅샷을 주고 락을 피한다” 는 아이디어가 같습니다. 다만 MVCC는 옛 버전을 따로 보관하는 방식이고, Lucene은 애초에 안 고쳐서 옛 버전이 곧 현재 파일이라는 점이 다릅니다.

⚠️

“OpenSearch에는 락이 전혀 없다”는 건 과장입니다.

정확히는 읽기(검색) 경로에 락이 없다는 뜻입니다. 같은 문서를 동시에 수정하는 경우까지 자유로운 건 아니에요. 이때는 문서마다 붙은 버전 번호로 충돌을 감지합니다(if_seq_no / if_primary_term을 이용한 낙관적 동시성 제어).

정리하면 읽기는 락 없이 자유롭고, 같은 문서에 대한 동시 쓰기만 버전으로 조율합니다.


불변성의 대가

공짜는 아닙니다. 얻은 것과 잃은 것이 명확합니다.

얻은 것잃은 것
검색에 락 불필요 → 색인/검색 동시 처리삭제해도 디스크가 바로 안 줄어듦
일관된 스냅샷 읽기가 공짜merge가 CPU·디스크 I/O를 계속 소모
세그먼트 파일 자체를 복사·캐싱 가능수정이 잦으면 묘비가 쌓여 검색 효율 저하

마지막 줄이 실무에서 중요합니다. 자주 바뀌는 데이터를 OpenSearch에 넣으면 손해예요. 수정 한 번에 묘비 하나가 쌓이고, 그걸 정리하려 merge가 계속 돌면서 자원을 먹습니다.

OpenSearch는 한번 쓰고 여러 번 읽는 데이터에 최적화돼 있습니다.

ℹ️

세 번째 줄이 6편의 복선입니다.

“세그먼트 파일 자체를 복사할 수 있다”는 성질 덕분에, 복제할 때 문서를 다시 색인하는 대신 완성된 세그먼트 파일을 그대로 보내는 방식이 가능해집니다. 이게 6편의 주제예요.


정리

  1. 락은 “변경”이 있어야 필요하다. Lucene은 세그먼트를 안 고치기로 해서 읽기 락을 없앴다.
  2. 새 문서는 기존 세그먼트를 건드리지 않고 새 세그먼트로 추가된다.
  3. 삭제는 묘비(tombstone)만 세운다. 파일에서 지우지 않으므로 디스크가 즉시 줄지 않는다.
  4. 수정은 없다. 삭제 + 추가일 뿐이다.
  5. merge가 진짜 삭제를 수행한다. 살아있는 문서만 새 세그먼트로 복사하고 옛 파일을 버린다.
  6. 검색은 시작 시점의 세그먼트 목록으로만 작업해 일관된 결과를 낸다.

다음 글에서는 이 불변성이 만들어내는 또 하나의 결과, “왜 방금 넣은 문서가 바로 안 보이는가” 를 다룹니다.

🤔문서 100만 건을 삭제했는데 디스크 사용량이 거의 그대로입니다. 이유는?

참고자료

© 2026, 미나리와 함께 만들었음