Skip to content

[fix] 상품 검색 인덱싱시 stale write방지#127

Open
Dongnyoung wants to merge 3 commits into
developfrom
test/#126
Open

[fix] 상품 검색 인덱싱시 stale write방지#127
Dongnyoung wants to merge 3 commits into
developfrom
test/#126

Conversation

@Dongnyoung

Copy link
Copy Markdown
Contributor

🚀 Summary

비동기 OpenSearch 색인 과정에서 오래된 상품 수정 이벤트가 최신 검색 문서를 덮어쓰지 못하도록 searchVersion 기반 stale write prevention을 추가했습니다.

Facebook Memcache Lease의 번호표 개념에서 착안해 상품 수정마다 단조 증가하는 fencing token을 발급하고, 이벤트와 OpenSearch 문서에 동일한 version을 저장하도록 했습니다.

Changes

  • Product.searchVersion 필드 추가
  • 상품 수정 시 searchVersion 단조 증가
  • ProductSearchIndexRequestedEventproductId, searchVersion 포함
  • SearchDocumentsearchVersion 저장
  • 비동기 이벤트 처리 시 DB 최신 searchVersion보다 낮은 이벤트는 skip
  • OpenSearch scripted update에서 현재 문서의 searchVersion보다 낮은 요청은 noop 처리
  • 신규 인덱스 mapping에 searchVersion long 타입 추가
  • 상품 searchVersion 컬럼 추가 Flyway migration 작성
  • stale event / idempotent retry 관련 테스트 추가

Why

상품 수정 API에서 OpenSearch 색인을 비동기로 분리한 이후, 이벤트 처리 순서가 뒤바뀌면 오래된 이벤트가 최신 OpenSearch 문서를 덮어쓸 수 있습니다.

예를 들어 version=11 이벤트가 먼저 색인된 뒤 version=10 이벤트가 늦게 도착하면, 기존 구조에서는 최신 문서가 과거 상태로 되돌아갈 위험이 있었습니다.

이번 변경으로 낮은 version 이벤트는 애플리케이션 레벨에서 skip되고, OpenSearch 레벨에서도 noop 처리되어 최신 문서를 덮어쓰지 못합니다.

Design Notes

OpenSearch external versioning도 고려했지만, 기존 문서들이 internal version으로 생성된 상태에서 external_gte를 바로 적용하면 version conflict 가능성이 있습니다.

따라서 기존 인덱스와 호환되는 방식으로 _source.searchVersion을 비교하는 scripted update를 사용했습니다.

현재 문서 searchVersion <= 요청 문서 searchVersion -> update
현재 문서 searchVersion > 요청 문서 searchVersion -> noop

🎲 Issue Number

close #126

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이번 풀 리퀘스트는 OpenSearch 색인 시 Stale Write를 방지하기 위해 searchVersion 메커니즘을 도입합니다. 주요 변경 사항으로는 Product 엔티티에 searchVersion 필드를 추가하고, 색인 이벤트 및 문서에 버전을 포함하며, OpenSearchClient에 버전 비교 업데이트 스크립트를 구현한 점이 있습니다. 리뷰에서는 세 가지 개선 사항이 지적되었습니다. 첫째, 경매(Auction) 도메인에 버전 관리 로직이 누락되어 발생할 수 있는 동시성 버그, 둘째, 데이터베이스 복제 지연 발생 시 과거 데이터가 영구적으로 색인될 수 있는 정합성 리스크, 셋째, OpenSearch 업데이트 시 refresh=true 옵션 사용으로 인한 성능 저하 우려입니다.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 80 to 84
auction.getEndsAt(),
product.getViewCount(),
product.getFavoriteCount(),
product.getSearchVersion(),
toSeoulOffsetDateTime(product.getCreatedAt())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

[Critical / Concurrency Bug]
현재 searchVersion을 통한 Stale Write 방지 로직이 경매(Auction) 도메인에는 적용되지 않는 치명적인 문제가 있습니다.

문제 분석:

  1. 경매 입찰(Bid) 등으로 인해 경매 정보(currentPrice 등)가 변경될 때, 연관된 Product 엔티티의 searchVersion은 증가하지 않습니다.
  2. 따라서 동일한 상품에 대해 여러 번의 경매 업데이트 이벤트가 거의 동시에 발생할 경우, 모든 이벤트의 SearchDocument는 **동일한 searchVersion**을 가지게 됩니다.
  3. 이 상태에서 다음과 같은 레이스 컨디션이 발생할 수 있습니다:
    • **Thread 2 (최신 입찰)**가 DB에서 최신 상태(예: 가격 120)를 읽어 OpenSearch에 searchVersion = 1로 반영합니다.
    • **Thread 1 (이전 입찰)**이 지연되어 DB에서 이전 상태(예: 가격 100)를 읽은 뒤, OpenSearch에 searchVersion = 1로 반영을 시도합니다.
    • OpenSearch의 스크립트 조건인 ctx._source.searchVersion <= params.document.searchVersion에 의해 1 <= 1이 참이 되므로, 최신 데이터(120)가 이전 데이터(100)로 덮어씌워집니다(Stale Write).

해당 문제를 해결하기 위한 제안:

  • 경매 상태가 변경될 때도 Product.searchVersion을 함께 증가시키거나,
  • SearchDocument에 경매 전용 버전 필드(예: auctionVersion 또는 경매 수정 시마다 증가하는 토큰)를 추가하고, OpenSearch 스크립트에서 이를 함께 비교하도록 개선해야 합니다.

Comment on lines +63 to 68
private void indexRegularProductIfCurrent(Product product, long eventSearchVersion) {
if (product.getSearchVersion() > eventSearchVersion) {
return;
}
indexRegularProductIfSearchable(product);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[Correctness / Replication Lag Risk]
현재 product.getSearchVersion() < eventSearchVersion인 경우에 대한 처리가 고려되어 있지 않습니다.
만약 데이터베이스 복제 지연(Replication Lag) 등으로 인해 읽기 전용 트랜잭션(@Transactional(readOnly = true))에서 최신 버전보다 낮은 과거의 Product 데이터를 조회하게 되면, product.getSearchVersion() > eventSearchVersion 조건은 거짓이 되어 그대로 색인을 진행하게 됩니다.
이 경우, 최신 이벤트(eventSearchVersion)가 처리되었음에도 불구하고 OpenSearch에는 과거 버전의 데이터가 저장되고, 더 이상 후속 이벤트가 없기 때문에 검색 엔진의 데이터가 영구적으로 최신 상태와 불일치할 위험이 있습니다.

해당 문제를 방지하기 위한 제안:

  1. SearchIndexService 또는 해당 메서드에서 마스터 데이터베이스(Primary DB)로부터 데이터를 조회하도록 강제하거나,
  2. product.getSearchVersion() < eventSearchVersion인 경우 예외를 발생시켜 이벤트 처리를 재시도(Retry)할 수 있는 메커니즘을 도입하는 것을 권장합니다.

Comment on lines +169 to +174
restClient.post()
.uri("/{index}/_update/{id}?refresh=true", properties.getIndexName(), document.id())
.contentType(MediaType.APPLICATION_JSON)
.body(toUtf8Bytes(toJson(body)))
.retrieve()
.toBodilessEntity();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[Performance / OpenSearch Anti-pattern]
_update API 호출 시 쿼리 스트링으로 refresh=true를 사용하고 있습니다.
모든 단일 색인/수정 요청마다 refresh=true를 강제하면 OpenSearch가 매번 새로운 세그먼트를 생성하고 디스크에 쓰기 때문에, 쓰기 성능이 심각하게 저하되고 CPU 및 I/O 부하가 급증할 수 있습니다.

개선 제안:
실시간 동기화가 반드시 필요한 특수한 테스트 환경이 아니라면, refresh=true 옵션을 제거하고 OpenSearch의 기본 백그라운드 리프레시 주기(기본 1초)에 의존하거나, 꼭 필요하다면 refresh=wait_for 옵션을 사용하는 것을 권장합니다.

References
  1. 성능 이슈가 생길 만한 부분은 반드시 지적할 것 (link)

@Dongnyoung

Copy link
Copy Markdown
Contributor Author

@gemini-code-assist review again

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

본 풀 리퀘스트는 OpenSearch 색인 시 순서가 뒤바뀌는 문제를 방지하기 위해 Product 및 Auction 엔티티에 searchVersion 필드를 도입하고, OpenSearch의 Painless 스크립트를 활용한 버전 비교 색인 기능(indexIfVersionNotOlder)을 추가하였습니다. 이에 대해 동시성 요청 시 버전 유실을 방지하기 위한 JPA @Version 낙관적 락 도입, OpenSearchClient 내 ObjectMapper 복사로 인한 성능 저하 개선, 그리고 복제 지연(Replica Lag) 상황에서 예외 발생 시 인덱스 불일치를 방지하기 위한 재시도 메커니즘 도입을 피드백으로 제시하였습니다.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +83 to +84
@Column(name = "search_version", nullable = false)
private long searchVersion = 1L;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 Product 엔티티에는 JPA의 @Version 어노테이션(낙관적 락)이 적용되어 있지 않습니다. 이로 인해 동시 요청이 발생할 경우, 두 트랜잭션이 동일한 searchVersion을 읽고 동일하게 증가시켜 저장하는 '두 번의 갱신 분실 문제(Lost Update)'가 발생할 수 있습니다.

동일한 searchVersion을 가진 이벤트가 생성되면, OpenSearch의 scripted update 조건(ctx._source.searchVersion <= params.document.searchVersion)에 의해 순서가 뒤바뀐 오래된 데이터가 최신 데이터를 덮어쓰는 stale write가 여전히 발생할 수 있습니다.

따라서 searchVersion을 안정적인 fencing token으로 사용하기 위해서는 엔티티에 @Version을 도입하여 동시성 제어를 하거나, 데이터베이스 수준에서 버전을 안전하게 증가시킬 수 있는 대책이 필요합니다.

restClient.post()
.uri("/{index}/_update/{id}", properties.getIndexName(), document.id())
.contentType(MediaType.APPLICATION_JSON)
.body(toUtf8Bytes(toJson(body)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 indexIfVersionNotOlder 메서드 내에서 toJson(body)를 호출하고 있습니다. toJson 메서드의 구현을 보면 매 호출마다 objectMapper.copy()를 수행하고 있는데, 이는 Jackson에서 매우 무거운 작업이며 성능 저하를 유발할 수 있습니다.

OpenSearchClient 생성자 시점에 objectMapper를 한 번만 복사하여 필드로 들고 있거나, 별도의 설정을 가진 ObjectMapper 빈을 주입받아 사용하도록 개선하는 것을 권장합니다.

Comment on lines +75 to +78
if (product.getSearchVersion() < eventSearchVersion) {
throw new IllegalStateException("Product search version is behind event version. productId=%d, current=%d, event=%d"
.formatted(product.getProductId(), product.getSearchVersion(), eventSearchVersion));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

데이터베이스의 searchVersion이 이벤트의 eventSearchVersion보다 작은 경우 IllegalStateException을 발생시키고 있습니다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하더라도, 만약 다중 데이터베이스 환경(Read-Replica)을 사용 중이고 복제 지연(Replica Lag)이 발생한다면 조회 시점에 아직 최신 데이터가 반영되지 않아 이 예외가 발생할 수 있습니다.

현재 이벤트 리스너(SearchIndexEventListener)에서는 예외 발생 시 단순히 로그만 남기고 이벤트를 유실하므로, 검색 인덱스가 영구적으로 최신 상태와 어긋날 위험이 있습니다.

따라서 복제 지연 가능성이 있는 환경이라면 일시적인 오류로 간주하고 재시도(Retry) 메커니즘(예: Spring Retry 또는 지연 큐)을 도입하는 것을 고려해 주세요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[test] 비동기 OpenSearch 인덱싱 시 오래된 이벤트가 최신 문서를 덮어쓸 수 있는 문제

1 participant