웹사이트에서 정보를 수정했는데 검색 결과에는 이전 내용이 그대로 표시되는 경우가 있다.
관리자는 제목을 변경했고, API는 정상적으로 응답했으며, 데이터베이스에도 새로운 내용이 저장되어 있다. 그런데 검색창에 키워드를 입력하면 수정하기 전의 제목이나 설명이 나타난다.
이 문제는 단순히 검색 기능이 느리기 때문에 발생하는 것이 아니다.
데이터베이스와 검색 인덱스가 서로 다른 시점의 정보를 가지고 있기 때문에 발생할 수 있다.
특히 웹사이트 디렉터리, 상품 검색 서비스, 구인구직 플랫폼, 문서 검색 시스템처럼 데이터가 지속적으로 변경되는 서비스에서는 중요한 문제다.
이번 글에서는 데이터 변경부터 검색 결과 반영까지의 과정을 살펴보고, 실무 환경에서 데이터 불일치를 방지하기 위한 시스템 설계 방법을 정리한다.
1. 데이터베이스와 검색 인덱스는 서로 다른 시스템이다
검색 기능을 제공하는 웹서비스는 일반적으로 두 가지 데이터 저장 구조를 사용한다.
첫 번째는 원본 데이터를 관리하는 데이터베이스다.
두 번째는 검색에 적합하도록 데이터를 구성한 검색 인덱스다.
두 시스템은 역할이 다르다.
| 구분 | 데이터베이스 | 검색 인덱스 |
|---|---|---|
| 주요 목적 | 원본 데이터 저장 및 관리 | 검색과 조회 최적화 |
| 대표 기술 | PostgreSQL, MySQL | Elasticsearch, OpenSearch |
| 데이터 변경 | 트랜잭션 중심 | 색인 작업 중심 |
| 검색 방식 | SQL 쿼리 등 | 역색인 및 검색 엔진 쿼리 |
| 데이터 일관성 | 트랜잭션 범위의 일관성 보장 | 별도 동기화 설계 필요 |
예를 들어 웹사이트 정보를 저장하는 서비스를 생각해 보자.
데이터베이스에는 다음과 같은 정보가 저장되어 있다.
{
"id": 1042,
"title": "개발자 자료 모음",
"category": "기술",
"status": "active",
"version": 8
}
그런데 검색 인덱스에는 이전 버전이 남아 있을 수 있다.
{
"id": 1042,
"title": "프로그래밍 링크 모음",
"category": "기술",
"status": "active",
"version": 6
}
두 시스템 모두 정상적으로 작동하고 있지만, 데이터는 일치하지 않는다.
이것이 검색 데이터 동기화 문제의 출발점이다.
기본적인 데이터 흐름
[사용자 데이터 수정]
|
v
[Application API]
|
v
[Primary Database]
|
v
[이벤트 전달]
|
v
[Index Worker]
|
v
[Search Index]
|
v
[검색 결과]
이 구조에서는 데이터가 여러 단계를 거쳐 이동한다.
그리고 각 단계에서 장애가 발생할 수 있다.
- 데이터베이스 저장은 성공했지만 검색 인덱스 갱신이 실패할 수 있다.
- 동일한 이벤트가 중복으로 전달될 수 있다.
- 이벤트가 생성된 순서와 처리되는 순서가 달라질 수 있다.
- 검색 인덱스에 변경 사항이 저장됐지만 아직 검색 결과에는 나타나지 않을 수 있다.
따라서 안정적인 검색 시스템을 만들려면 단순한 데이터 저장 기능뿐 아니라 동기화 과정 전체를 설계해야 한다.
2. 데이터베이스와 검색 인덱스를 동시에 수정하면 왜 위험할까?
가장 단순한 방법은 API 요청이 들어올 때 데이터베이스와 검색 인덱스를 연속으로 갱신하는 것이다.
예를 들어 Python으로 다음과 같이 구현할 수 있다.
def update_document(document_id, data):
database.update(document_id, data)
search_index.update(document_id, data)
이 방식은 구현이 간단하다.
그러나 두 시스템의 작업이 하나의 트랜잭션으로 묶여 있지 않다는 문제가 있다.
실패 시나리오
다음 상황을 가정해 보자.
- 사용자가 웹사이트 제목을 수정한다.
- 데이터베이스 업데이트가 성공한다.
- 검색 서버에 일시적인 장애가 발생한다.
- 검색 인덱스 업데이트가 실패한다.
- 데이터베이스에는 새 제목이 저장되지만 검색 결과에는 이전 제목이 남는다.
검색 서버를 다시 정상화하더라도 변경 사항이 자동으로 복구된다는 보장은 없다.
더 중요한 문제는 애플리케이션이 데이터 불일치 자체를 인식하지 못할 수 있다는 것이다.
해결 방법: Transactional Outbox Pattern
Transactional Outbox는 데이터베이스의 변경 사항과 이벤트 기록을 하나의 트랜잭션으로 저장하는 패턴이다.
[Application API]
|
v
[DB Transaction]
/ \
v v
[데이터 변경] [Outbox 이벤트 저장]
\ /
v v
[Transaction Commit]
|
v
[Outbox Relay]
|
v
[Event Queue]
|
v
[Index Worker]
|
v
[Search Index]
핵심은 데이터베이스 변경과 이벤트 생성 기록이 함께 커밋된다는 점이다.
트랜잭션이 실패하면 둘 다 저장되지 않는다.
반대로 트랜잭션이 성공하면 검색 인덱스에 반영해야 할 변경 사항이 Outbox에 남아 있게 된다.
검색 서버에 장애가 발생하더라도 이벤트 기록을 기반으로 재처리할 수 있다.
다만 Transactional Outbox 자체가 이벤트의 정확히 한 번 처리를 보장하는 것은 아니다.
중복 전달과 순서 문제는 별도로 해결해야 한다.
3. 이벤트 순서가 바뀌면 최신 데이터가 사라질 수 있다
이벤트 기반 시스템에서는 데이터가 변경된 순서와 실제 처리되는 순서가 반드시 같지는 않다.
다음 상황을 생각해 보자.
10:00:01 -> 문서 버전 11 생성
10:00:02 -> 문서 버전 12 생성
정상적인 최종 상태는 버전 12다.
그러나 네트워크 지연이나 여러 Worker의 동시 처리로 인해 이벤트가 다음 순서로 처리될 수 있다.
버전 12 처리 -> 최신 데이터 반영
버전 11 처리 -> 이전 데이터로 덮어쓰기
결과적으로 검색 인덱스가 과거 상태로 돌아간다.
이러한 문제를 방지하려면 문서마다 버전 번호를 관리하는 방법을 고려할 수 있다.
이벤트에 버전 정보 추가하기
{
"event_id": "evt-93f",
"document_id": 1042,
"version": 12,
"operation": "upsert"
}
검색 인덱스에 이벤트를 적용할 때 버전 번호를 비교한다.
기존 인덱스가 더 높은 버전을 가지고 있다면 오래된 이벤트를 거부해야 한다.
단, 버전을 읽은 다음 비교하고 다시 저장하는 방식은 동시 실행 환경에서 경쟁 조건이 발생할 수 있다.
따라서 버전 검증과 갱신은 검색 엔진에서 원자적으로 처리되어야 한다.
Elasticsearch 외부 버전 관리 예시
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
def apply_event(event, document):
es.index(
index="directory",
id=event["document_id"],
document=document,
version=event["version"],
version_type="external",
)
이 예시는 문서별 버전 번호가 양의 정수이며 변경할 때마다 증가한다는 전제를 가진다.
Elasticsearch의 외부 버전 관리 기능을 이용하면 현재 저장된 버전보다 오래된 이벤트가 문서를 덮어쓰지 못하도록 할 수 있다.
같은 버전이 다시 전달된 경우에도 충돌이 발생할 수 있으므로, Worker는 충돌 원인을 분류해야 한다.
중요한 것은 단순히 이벤트를 처리했다는 사실이 아니다.
최신 버전이 이전 버전으로 덮어써지지 않았는지 확인하는 것이 핵심이다.
4. 이벤트 중복 전달을 고려해야 한다
메시지 큐를 사용하는 시스템에서는 하나의 이벤트가 여러 번 전달될 수 있다.
예를 들어 다음 상황이 발생할 수 있다.
- Worker가 이벤트를 수신한다.
- 검색 인덱스 업데이트에 성공한다.
- Worker가 이벤트 처리 완료를 알리기 전에 종료된다.
- 메시지 큐가 같은 이벤트를 다시 전달한다.
이것은 적어도 한 번 전달하는 방식(At-Least-Once Delivery)에서 발생할 수 있는 정상적인 상황이다.
따라서 동일한 이벤트를 반복 처리해도 최종 데이터가 잘못되지 않도록 설계해야 한다.
이를 멱등성(Idempotency)이라고 한다.
[버전 12 이벤트 수신]
|
v
[검색 인덱스 반영]
|
v
[Worker 비정상 종료]
|
v
[버전 12 재전달]
|
v
[이미 적용된 버전 확인]
|
v
[중복 이벤트 처리 완료]
멱등성을 구현하는 방법은 시스템에 따라 다르다.
대표적으로 다음 정보를 사용할 수 있다.
- 고유한 이벤트 ID
- 문서별 버전 번호
- 마지막 처리 이벤트 기록
- 검색 엔진의 조건부 갱신 기능
다만 중복 처리 방지를 위해 이벤트 ID만 저장하는 방식은 운영 규모와 보관 기간까지 고려해야 한다.
검색 인덱스 갱신 외에 다른 부수 효과가 존재한다면 별도의 처리 이력 관리가 필요할 수도 있다.
5. 삭제된 데이터가 검색 결과에 남는 문제
검색 동기화에서 놓치기 쉬운 부분은 삭제 처리다.
데이터베이스에서 특정 문서를 삭제했지만 검색 인덱스에 삭제 요청이 반영되지 않으면, 검색 결과에 존재하지 않는 문서가 표시될 수 있다.
이러한 결과를 흔히 Ghost Result라고 부른다.
삭제 이벤트 예시
{
"event_id": "evt-94a",
"document_id": 1042,
"version": 13,
"operation": "delete"
}
하지만 검색 인덱스에서 문서를 즉시 제거하는 것만으로 충분하지 않을 수 있다.
삭제 이벤트 이후 과거의 수정 이벤트가 늦게 도착하면 삭제된 문서가 다시 생성될 위험이 있기 때문이다.
Tombstone을 이용한 삭제 관리
Tombstone은 삭제된 문서의 상태와 버전 정보를 남겨두는 방식이다.
{
"id": 1042,
"version": 13,
"deleted": true
}
검색 쿼리에서는 deleted: true인 문서를 제외한다.
동시에 버전 13이라는 정보를 보존하므로 버전 12의 오래된 수정 이벤트를 거부할 수 있다.
다만 Tombstone은 저장 공간을 사용하며 검색 시 필터링이 필요하다.
실제 구현에서는 다음 조건을 함께 판단해야 한다.
- 삭제된 문서의 버전을 얼마나 오래 보관할 것인가?
- 과거 이벤트는 최대 얼마나 늦게 도착할 수 있는가?
- 삭제된 데이터의 복원 가능성이 필요한가?
- 개인정보 삭제 및 보존 정책과 충돌하지 않는가?
삭제는 단순한 데이터 제거 작업이 아니라 이벤트 순서와 데이터 일관성의 문제이기도 하다.
6. 인덱스 업데이트 성공과 검색 노출은 다르다
Elasticsearch와 같은 검색 엔진에서는 문서를 저장했다고 해서 반드시 즉시 검색 결과에 나타나는 것은 아니다.
Elasticsearch는 Near Real-Time Search 구조를 사용한다.
인덱스에 반영된 변경 사항이 검색 가능해지는 시점은 Refresh 동작과 관련이 있다.
[DB Commit]
|
v
[Outbox Event]
|
v
[Index Write]
|
v
[Search Refresh]
|
v
[검색 결과 반영]
따라서 다음 상태를 구분해야 한다.
- 데이터베이스 저장 완료
- 검색 인덱스 쓰기 완료
- 검색 결과에서 조회 가능
세 단계는 동일한 시점에 완료된다고 가정하면 안 된다.
Refresh 정책 설계
Elasticsearch에서는 검색 가시성에 대한 요구 사항에 따라 Refresh 관련 옵션을 사용할 수 있다.
- 기본 비동기 Refresh: 일반적인 색인 작업에 적합
-
refresh=wait_for: 다음 Refresh까지 기다린 뒤 응답 -
refresh=true: 관련 샤드의 Refresh를 강제로 수행
모든 쓰기 요청마다 강제 Refresh를 수행하는 방식은 성능 부담을 증가시킬 수 있다.
관리자가 콘텐츠를 수정했다고 해서 모든 상황에서 즉시 검색 결과에 반영할 필요가 있는지도 검토해야 한다.
예를 들어 문서 관리 화면에는 데이터베이스의 최신 내용을 표시하고, 전체 검색에는 일정한 반영 지연을 허용하는 구조도 가능하다.
이때 사용자에게 어떤 일관성 수준을 제공할지 명확히 정의하는 것이 중요하다.
7. 검색 인덱스 지연을 측정하는 방법
검색 시스템의 문제는 검색 결과가 틀렸다는 사용자 신고를 받은 후에야 발견되는 경우가 있다.
이를 방지하려면 동기화 지연과 데이터 불일치를 측정해야 한다.
수집해야 할 주요 시간 정보
- 데이터베이스 변경 완료 시점
- 이벤트 생성 시점
- Worker가 이벤트를 수신한 시점
- 검색 인덱스 쓰기 완료 시점
- 실제 검색 가시성 확인 시점
이 정보를 이용하면 두 가지 지표를 구분할 수 있다.
Processing Lag
데이터 변경부터 인덱스 쓰기 완료까지 걸린 시간이다.
Search Visibility Lag
데이터 변경부터 실제 검색 결과에 최신 내용이 나타날 때까지 걸린 시간이다.
두 지표는 동일하지 않을 수 있다.
모니터링 지표 예시
index_processing_lag_seconds
outbox_oldest_pending_age_seconds
index_events_failed_total
index_events_duplicate_total
index_version_conflicts_total
index_reconciliation_mismatches_total
이런 지표를 Prometheus 등의 모니터링 시스템으로 수집할 수 있다.
평균 처리 시간만 측정하기보다 P95, P99처럼 지연 분포를 확인하는 것도 중요하다.
대부분의 문서가 빠르게 반영되더라도 일부 문서가 장시간 오래된 상태로 남을 수 있기 때문이다.
운영 목표 예시
인덱스 갱신 99%를 30초 이내 처리
5분 이상 대기한 Outbox 이벤트 감지
지속적인 인덱싱 실패 시 알림 발생
위 수치는 설명을 위한 예시다.
실제 기준은 서비스의 데이터 변경 빈도, 검색 중요도, 사용자 요구 사항에 따라 결정해야 한다.
8. 이벤트 처리 외에 데이터 검증 작업도 필요하다
이벤트 큐가 비어 있다고 해서 데이터베이스와 검색 인덱스가 완전히 일치한다는 뜻은 아니다.
다음과 같은 문제가 남아 있을 수 있다.
- 배포 과정에서 일부 이벤트가 누락됨
- 검색 인덱스 매핑 변경으로 일부 필드가 잘못 저장됨
- 오래된 Worker가 잘못된 문서를 생성함
- 인덱스 재구축 과정에서 데이터가 빠짐
- 운영 중 수동 수정으로 두 시스템의 상태가 달라짐
이러한 문제를 감지하기 위한 방법이 Reconciliation이다.
Reconciliation은 원본 데이터와 검색 인덱스를 비교하고 차이를 확인하는 검증 과정이다.
기본적인 검증 흐름
[Primary Database]
|
v
[데이터 배치 조회]
|
v
[Search Index 조회]
|
v
[상태 비교]
/ \
v v
[일치] [불일치]
| |
v v
[종료] [복구 작업 등록]
|
v
[최신 원본 재색인]
비교 기준으로는 다음 항목을 활용할 수 있다.
- 문서 ID
- 데이터 버전
- 삭제 여부
- 인덱싱 대상 필드의 해시값
복구 예시
Primary Database Version: 18
Search Index Version: 16
결과:
검색 인덱스가 이전 버전임
조치:
데이터베이스의 최신 내용을 다시 색인
검증:
검색 인덱스 버전이 18인지 확인
대량 데이터를 처리하는 시스템이라면 모든 문서를 한 번에 메모리에 불러오는 방식은 피해야 한다.
배치 처리와 점진적인 비교 작업을 활용하고, 필요하면 정기적인 전체 검증을 수행하는 것이 좋다.
9. 실제 운영 환경에서 발생할 수 있는 장애 시나리오
여러 웹사이트 정보를 검색할 수 있는 디렉터리 서비스를 가정해 보자.
관리자가 한 문서의 카테고리를 수정한다.
이때 데이터 흐름은 다음과 같다.
- 데이터베이스에 버전 21이 저장된다.
- Outbox에 변경 이벤트가 기록된다.
- 이벤트가 메시지 큐로 전달된다.
- Worker가 검색 인덱스 업데이트를 시도한다.
- 검색 서버가 일시적으로 쓰기를 거부한다.
- Worker는 처리 완료를 승인하지 못한다.
- 그 사이 새로운 변경인 버전 22가 검색 인덱스에 반영된다.
- 이전 이벤트인 버전 21이 다시 전달된다.
- 버전 검증에서 오래된 이벤트로 판정되어 적용되지 않는다.
최종 검색 결과에는 버전 22가 유지된다.
버전 검증이 없다면?
버전 21이 버전 22를 덮어쓸 수 있다.
이 경우 이벤트 큐는 정상적으로 작동하고 모든 메시지가 처리되었어도 검색 결과에는 이전 정보가 표시된다.
따라서 시스템 안정성을 판단할 때는 이벤트 전달 성공 여부만 보는 것으로 충분하지 않다.
최종 데이터의 정확성까지 검증해야 한다.
10. 프로덕션 적용 전 체크리스트
검색 인덱스 동기화 시스템을 운영 환경에 적용하기 전 다음 항목을 점검할 필요가 있다.
데이터 일관성
- [ ] 원본 데이터의 기준 시스템이 명확한가?
- [ ] 데이터 변경과 Outbox 이벤트를 하나의 트랜잭션으로 저장하는가?
- [ ] 문서별 버전 번호를 관리하는가?
- [ ] 오래된 이벤트가 최신 정보를 덮어쓸 수 없는가?
- [ ] 중복 이벤트를 안전하게 처리하는가?
- [ ] 삭제된 문서가 과거 이벤트에 의해 복원되지 않는가?
검색 인덱스 운영
- [ ] Worker가 비정상 종료된 후 작업을 재개할 수 있는가?
- [ ] 실패한 이벤트를 추적하고 재처리할 수 있는가?
- [ ] 인덱스 스키마 변경에 대한 절차가 있는가?
- [ ] 검색 가시성 지연을 고려했는가?
- [ ] 전체 인덱스를 재구축할 수 있는가?
모니터링과 복구
- [ ] 동기화 지연을 측정하는가?
- [ ] 오래 대기하는 이벤트를 감지할 수 있는가?
- [ ] 버전 충돌과 일반 장애를 구분하는가?
- [ ] 데이터베이스와 인덱스의 차이를 검증하는가?
- [ ] 복구 작업 이후 결과를 확인하는가?
11. 모든 서비스에 복잡한 동기화 구조가 필요할까?
그렇지는 않다.
데이터 변경이 드물고 규모가 작은 서비스라면 정기적으로 검색 인덱스를 재구축하는 방식만으로 충분할 수 있다.
반대로 다음 조건에 해당한다면 이벤트 기반 동기화 구조를 검토할 가치가 있다.
- 검색이 서비스의 핵심 기능이다.
- 데이터 변경이 빈번하다.
- 여러 시스템에서 동일한 데이터를 수정한다.
- 오래된 정보가 노출되면 서비스 신뢰도에 영향을 준다.
- 검색 서버 장애가 원본 데이터 저장에 영향을 주면 안 된다.
- 검색 결과의 정확성을 독립적으로 검증해야 한다.
중요한 것은 최신 기술을 많이 사용하는 것이 아니다.
서비스에 필요한 데이터 일관성 수준을 정의하고 그 요구 사항에 맞춰 설계하는 것이다.
결론
검색 시스템의 안정성은 빠른 검색 속도만으로 결정되지 않는다.
데이터베이스에 저장된 최신 정보가 검색 인덱스에도 정확하게 반영되는지가 중요하다.
이를 위해서는 데이터 변경 기록의 안정적인 전달, 이벤트 중복 처리, 버전 관리, 삭제 상태 보존, 동기화 지연 측정, 데이터 재검증과 복구 절차가 필요하다.
특히 다음 세 가지 상태를 구분해야 한다.
데이터베이스 저장 성공, 검색 인덱스 갱신 성공, 실제 검색 결과 반영은 서로 다른 단계다.
이 차이를 이해하고 설계하는 것이 단순한 검색 기능 구현과 안정적인 검색 인프라 구축을 구분하는 기준이 된다.
참고 자료
- AWS 공식 문서: Transactional Outbox Pattern
- Elastic 공식 문서: Near Real-Time Search
- Elastic 공식 문서: Refresh Parameter
※ 이 글은 웹서비스 내부의 검색 인덱스 동기화를 설명한다. Google 검색의 크롤링과 색인 반영은 별도의 시스템이며, 내부 검색 인덱스를 갱신했다고 Google 검색 결과가 즉시 변경되는 것은 아니다.
Top comments (0)