인덱스를 설계했으니, 이제 "어떻게 검색하고, 데이터를 어떻게 동기화할지" 설계할 차례다. ES의 쿼리 구조와 이 프로젝트의 이벤트 기반 동기화 전략을 다룬다.
Bool Query 구조
ES에서 검색 조건을 조합할 때 가장 많이 쓰는 쿼리가 Bool Query다. 하나의 bool 쿼리 안에 네 가지 절을 조합해서 복잡한 검색 조건을 표현한다.
각 절의 역할을 정리하면 이렇다.
- must — "이 조건은 반드시 만족해야 한다." 검색 점수에 영향을 준다. "반팔"이라는 단어가 본문에 여러 번 등장하면 점수가 높아지고, 결과 상단에 노출된다. 텍스트 검색에 사용한다.
- filter — "이 조건은 반드시 만족해야 한다." 하지만 점수 계산 없이 Yes/No 필터링만 한다. "맑음인지 아닌지"처럼 이분법적 조건이다. 점수를 계산하지 않으니 성능이 더 좋고, ES가 결과를 캐시할 수 있다.
- should — "있으면 좋고, 없어도 된다." 조건을 만족하면 점수를 부스팅하는 용도로 쓴다. 필수가 아니라 가점이다.
- must_not — "이 조건에 해당하면 제외한다." filter처럼 점수 계산 없이 동작한다.
네 가지 절이 Bool Query 안에서 어떤 역할을 하는지 한눈에 보면 구조가 명확하다.
초록 영역인 must는 점수를 계산하고, 주황 영역인 filter는 점수 없이 필터링만 한다는 차이가 핵심이다.
핵심을 한 줄로 정리하면, 텍스트 검색은 must, 조건 필터링은 filter로 분리하는 게 성능의 핵심이다. 점수가 필요 없는 조건까지 must에 넣으면, ES가 쓸데없이 점수를 계산하느라 느려지고 캐시도 못 탄다.
실제 쿼리를 보면 구조가 명확해진다.
{
"query": {
"bool": {
"must": [
{ "match": { "content": "반팔" } }
],
"filter": [
{ "term": { "skyStatus": "CLEAR" } },
{ "term": { "precipitationType": "NONE" } }
]
}
}
}
content 필드는 텍스트 검색이니 must에, skyStatus와 precipitationType은 정확히 일치하는지만 보면 되니 filter에 넣었다. 이 분리가 ES 쿼리 설계의 기본 원칙이다.
이 프로젝트의 검색 쿼리 설계
WeatherFit의 FeedGetRequest에는 네 가지 필터가 있다. 이걸 ES 쿼리로 어떻게 매핑하는지가 설계의 핵심이다.
어떤 필터가 어떤 절로 매핑되는지 흐름을 보면 직관적이다.
초록 흐름은 텍스트 검색이라 must로 가고, 주황 흐름은 정확 일치라 filter로 간다. 이 구분이 앞서 말한 "텍스트는 must, 필터는 filter" 원칙 그대로다.
각 매핑을 구체적으로 보면 이렇다.
- keywordLike →
must(match("content", keyword))— 본문 내용을 nori 분석기로 형태소 분석한 뒤 토큰 매칭한다. LIKE 검색 대체. - skyStatusEqual →
filter(term("skyStatus", value))— "맑음", "구름많음" 같은 enum 값의 정확 일치. - precipitationTypeEqual →
filter(term("precipitationType", value))— "없음", "비", "눈" 같은 enum 값의 정확 일치. - authorIdEqual →
filter(term("authorId", value))— 특정 작성자의 피드만 필터링.
정렬
- 기본 정렬은
createdAt DESC다. - 좋아요순 정렬은
likeCount DESC로 ES에서 직접 처리한다. 기존 DB에서 서브쿼리로 좋아요 수를 계산하던 방식을 대체할 수 있다.
페이지네이션
커서 기반 페이지네이션을 쓴다. ES에서는 이걸 search_after 방식이라 부른다. 이전 페이지의 마지막 문서가 가진 정렬 값을 기준으로 다음 페이지를 가져온다. offset 방식보다 깊은 페이지에서 성능이 훨씬 좋다.
검색 흐름 — "검색은 ES, 데이터는 DB"
ES를 도입한다고 DB를 버리는 게 아니다. 검색은 ES가, 데이터 조회는 DB가 담당하는 역할 분리가 핵심이다.
실제 검색 요청이 들어왔을 때 서비스 내부에서 어떤 흐름으로 처리되는지 보자.
feedId 목록 추출 ES-->>S: feedId 목록 반환 end rect rgb(240, 248, 255) Note over S, DB: 데이터 조회 단계 S->>DB: feedId IN (...) 조회 Note right of DB: Feed 엔티티 +
연관 데이터 로딩 DB-->>S: Feed 엔티티 목록 end S-->>C: 피드 목록 응답
초록 영역에서 ES는 "어떤 피드가 조건에 맞는지" feedId만 빠르게 찾아준다. 파란 영역에서 DB는 그 feedId들로 실제 엔티티와 연관 데이터를 조회한다.
왜 이렇게 나누는 걸까? ES는 검색에 최적화된 엔진이지, 관계형 데이터를 다루는 도구가 아니다. 트랜잭션도 없고, 외래키 관계도 없고, JOIN도 안 된다. 원본 데이터는 항상 DB에 있어야 하고, ES는 "검색 인덱스" 역할만 하는 거다.
feedId가 수백 개라면 IN 절이 길어지는데, PostgreSQL은 이 정도는 문제없이 처리한다. 필요하면 배치로 나눠 조회하면 된다.
이벤트 기반 동기화 전략
DB에 피드가 저장되면, ES에도 같은 데이터를 색인해야 한다. 이 동기화를 어떻게 할 것인가?
기존 패턴 재활용
이 프로젝트에는 이미 S3 업로드에서 @TransactionalEventListener + @Async 패턴을 쓰고 있다.
@Async("s3UploadTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Retryable(maxAttempts = 5, backoff = @Backoff(delay = 1000, multiplier = 2))
public void handleS3PutEvent(S3ClothesPutEvent event) {
s3Service.put(event.fileName(), event.contentType(), event.bytes());
}
S3 이벤트 리스너의 핵심 구조는 세 가지다.
- 트랜잭션 커밋 후 실행 — DB 저장이 확정된 다음에야 외부 시스템과 통신한다
- 비동기 — 메인 스레드를 블로킹하지 않는다
- 재시도 — 실패 시 최대 5회, 지수 백오프로 재시도한다
ES 동기화도 이 패턴을 그대로 재활용한다. FeedIndexEvent를 발행하고, ESEventListener가 비동기로 ES에 색인하는 구조다.
동기화 흐름을 보면 S3 패턴과 구조가 똑같다.
초록 영역은 트랜잭션 안에서 일어나는 일이다. DB에 피드를 저장하고, 이벤트를 발행한다. 주황 영역은 트랜잭션이 커밋된 후 비동기로 실행되는 부분이다. 이벤트 리스너가 ES에 문서를 색인한다.
트랜잭션 커밋 후에 실행하는 이유가 중요하다. 만약 DB 저장이 롤백됐는데 ES에는 이미 색인했다면, 존재하지 않는 피드가 검색 결과에 나오는 정합성 문제가 생긴다.
재시도 전략
@Retryable로 S3와 같은 패턴을 적용한다. ES가 일시적으로 응답하지 않아도 1초, 2초, 4초, 8초, 16초 간격으로 최대 5회 재시도한다. 모든 재시도가 실패하면 @Recover 메서드에서 로그를 남긴다.
동기화 지연 허용
비동기 처리이므로 DB에 저장된 피드가 ES 검색에 반영되기까지 수백ms에서 1초 정도 지연이 생길 수 있다. 이건 ES의 near-realtime 특성과도 맞닿아 있다. ES는 문서를 색인하면 바로 검색되지 않고, refresh interval 이후에 검색 가능해진다. 기본값이 1초다.
전체 재색인 배치
이벤트 기반 동기화만으로는 100% 정합성을 보장할 수 없다. ES 장애로 이벤트가 유실되거나, 재시도까지 전부 실패하는 경우가 있기 때문이다. 이걸 보완하기 위해 주기적으로 DB 전체를 ES에 재색인하는 배치를 돌린다. 야간 시간대에 실행하면 서비스 영향도 최소화된다.
면접 대비 Q&A
둘 다 "반드시 만족해야 하는 조건"이라는 점은 같다. 차이는 점수 계산 여부다. must는 검색 점수를 계산해서 관련도 정렬에 영향을 주고, filter는 점수 계산 없이 Yes/No만 판단한다. filter는 점수를 계산하지 않으니 성능이 더 좋고, ES가 결과를 내부적으로 캐시할 수 있다. 따라서 텍스트 검색처럼 관련도가 중요한 조건은 must에, 정확 일치나 범위 필터는 filter에 넣는 게 기본 원칙이다.
[!QUESTION] ES를 유일한 저장소로 쓸 수 있나?
기술적으로는 가능하지만, 실무에서는 거의 하지 않는다. ES는 트랜잭션을 지원하지 않고, 외래키 제약조건도 없고, 데이터 유실 가능성이 RDB보다 높다. 문서를 색인하면 바로 검색 가능한 것도 아니라 near-realtime이라 1초 정도 지연이 있다. 그래서 원본 데이터는 RDB에 두고, ES는 검색 전용 인덱스로 활용하는 패턴이 표준이다.
[!QUESTION] 동기화가 실패하면 어떻게 되나?
재시도 전략으로 일시적 장애를 커버하고, 모든 재시도가 실패하면 로그를 남겨서 모니터링한다. 하지만 이것만으로는 부족하다. ES가 장시간 다운되면 이벤트가 쌓이거나 유실될 수 있기 때문이다. 이걸 보완하기 위해 주기적으로 DB 전체를 ES에 재색인하는 배치를 돌려서 정합성을 맞춘다. Dead Letter Queue를 도입해서 실패한 이벤트를 별도 저장하고 나중에 재처리하는 방법도 있다.
[!QUESTION] ES의 near-realtime 검색이란?
ES는 문서를 색인하면 즉시 검색 가능한 게 아니다. 내부적으로 in-memory buffer에 쌓아뒀다가, refresh 시점에 segment로 만들어야 검색할 수 있다. 이 refresh interval 기본값이 1초라서 "거의 실시간"이라는 뜻의 near-realtime이라 부른다. 실시간 검색이 꼭 필요하면 refresh interval을 줄일 수 있지만, 색인 성능이 떨어지는 트레이드오프가 있다.