저번에 작성했던 글에서 Elasticsearch에 대해서 학습하였으며 이번엔 현재 진행중인 팀프로젝트에 적용하여 어떠한 방식으로 사용했고 실제 성능개선은 어떤 식으로 되었는지 작성을 해보려 합니다.
도입하게 된 배경
- INDEX만을 사용해 데이터 조회 성능 개선을 할 수 없는 부분에 대한 개선 (Like 문법을 포함한 조회)
- 유사 실서비스의 기능을 생각했을 때, 가장 많이 이루어지는 기능은 조회이며, 그에 따른 처리속도 개선을 Elasticsearch 사용을 통해 더욱 개선할 수 있습니다.
우선 사용방법에 대해서 작성하기 전,
그래서 실제로 성능이 좋아졌는지에 대해서 먼저 말씀드리려 합니다. 우선 네트워크 속도 및 제 개인 맥북으로 테스트를 하는 것이기 때문에 성능개선에 대해서 오차는 좀 있으며 데이터 총 수량에 따라 오차가 있음을 말씀드립니다.
우선 이전에 진행했던 RDB(Mysql)에서 인덱스를 걸기 전과 후로 나눈 성능 개선입니다.
Average | 1443 ms | 732 ms | 약 49% 감소 |
Median | 1246 ms | 665 ms | 약 47% 감소 |
90% Line | 1894 ms | 1029 ms | 약 46% 감소 |
95% Line | 2402 ms | 1250 ms | 약 48% 감소 |
99% Line | 5814 ms | 2404 ms | 약 59% 감소 |
Maximum | 6425 ms | 3153 ms | 약 50% 감소 |
Error % | 4.80% | 2.28% | 약 50% 감소 |
Throughput | 669.5/sec | 1341.1/sec | 2배 증가 |
데이터 총 수량이 100개에 필터는 4개(복합인덱스)를 걸었을 뿐인데 거의 2배 가까이 모든 성능이 개선되는 것을 확인 할 수 있었습니다.
그렇다면 위 복합인덱스(4)를 걸어놓은 것과 Elasticsearch를 사용하여 성능 테스트 결과를 확인해보겠습니다.
인덱스 30초 10건

Elasticsearch 30초 10건

인덱스 1분 50000건

Elasticsearch 1분 50000건

인덱스 30초 50000건

Elasticsearch 30초 50000건

인덱스 1분 50000건

Elasticsearch 1분 50000건

표로 확인해보겠습니다.
- 30초 기준, 초당 500번 반복 요청, 100건
- MySQL 인덱스 (1차) Elasticsearch (2차) 결과표
# Samples | 13,146 | 12,807 | 거의 동일 |
Average (ms) | 2,306 ms | 2,305 ms | 거의 동일 |
Median (ms) | 1,976 ms | 1,817 ms | ES가 약 8% 더 빠름 |
90% Line (ms) | 3,324 ms | 4,462 ms | MySQL이 더 안정적 (33%) |
95% Line (ms) | 5,691 ms | 5,206 ms | ES가 약 8.5% 더 빠름 |
99% Line (ms) | 6,401 ms | 6,044 ms | ES가 약 5.6% 더 빠름 |
Min (ms) | 2 ms | 1 ms | ES 더 빠름 |
Max (ms) | 7,338 ms | 6,723 ms | ES 더 빠름 (약 8.4%) |
Error % | 7.61% | 7.81% | MySQL 쪽이 더 안정적 |
Throughput | 418.7/sec | 411.6/sec | MySQL 쪽이 조금 더 높음 |
Received KB/sec | 2,088.01 | 2,013.13 | MySQL 쪽이 높음 |
Sent KB/sec | 135.63 | 134.88 | 거의 동일 |
데이터가 적을 때는 차이가 별로 없습니다. 애초에 데이터가 많은 경우에 역색인의 장점이 드러나기 때문일거라 생각하고, 50000건을 임시로 만들어두고 다시 테스트해보겠습니다.
- 1분 기준, 초당 500번 반복 요청, 50000건
- MySQL 인덱스 (1차) Elasticsearch (2차) 결과표 - 1
# Samples | 14,879 | 25,940 | ✅ ES가 74% 더 많은 요청 처리 |
Average (ms) | 4,013 | 2,485 | ✅ ES가 38.1% 더 빠름 |
Median (ms) | 3,706 | 2,352 | ✅ ES가 36.5% 더 빠름 |
90% Line (ms) | 6,683 | 3,661 | ✅ ES가 45.2% 더 빠름 |
95% Line (ms) | 7,490 | 4,154 | ✅ ES가 44.5% 더 빠름 |
99% Line (ms) | 8,393 | 6,507 | ✅ ES가 22.5% 더 빠름 |
Min (ms) | 15 | 5 | ✅ ES가 더 빠름 |
Max (ms) | 10,829 | 6,882 | ✅ ES가 더 빠름 (36% 낮음) |
Error % | 6.72% | 3.85% | ✅ ES가 더 안정적 (오류율 낮음) |
Throughput | 244.5/sec | 395.1/sec | ✅ ES가 61.6% 더 높은 처리량 |
Received KB/sec | 1,207.24 | 1,942.63 | ✅ ES가 더 많은 데이터 수신 |
Sent KB/sec | 103.55 | 174.36 | ✅ ES가 더 많은 데이터 송신 |
- 1분 기준, 초당 500번 반복 요청, 50000건
- MySQL 인덱스 (1차) Elasticsearch (2차) 결과표 - 2
# Samples | 24,200 | 31,466 | ✅ ES가 30% 더 많은 요청 처리 |
Average (ms) | 2,476 | 1,983 | ✅ ES가 20% 더 빠름 |
Median (ms) | 2,306 | 1,941 | ✅ ES가 15.8%더 빠름 |
90% Line (ms) | 3,195 | 2,528 | ✅ ES가 21% 더 빠름 |
95% Line (ms) | 3,661 | 2,753 | ✅ ES가 24.8%더 빠름 |
99% Line (ms) | 5,742 | 2,941 | ✅ ES가 50%더 빠름 |
Min (ms) | 17 | 1 | ✅ ES가 더 빠름 |
Max (ms) | 7,267 | 3,145 | ✅ ES가 더 빠름 (56% 낮음) |
Error % | 4.13% | 3.18% | ✅ ES (더 안정적) |
Throughput | 399.2/sec | 497.1/sec | ✅ ES가 24.5% 더 높은 처리량 |
Received KB/sec | 1,997.29 | 2,452.10 | ✅ ES 22.7% 더 많은 데이터 수신 |
Sent KB/sec | 173.05 | 219.99 | ✅ ES 27.1% 더 많은 데이터 송신 |
차이는 균일하지 않지만 50000건 조회에서 전체적으로 성능개선이 유의미하게 일어나는 것을 확인 할 수 있습니다.
이는 실사용자가 많은 환경에서 더욱 사용자 경험을 뛰어나게 할 것입니다.
물론 Es를 사용함으로써 발생할 수 있는 데이터 정합성 문제는 해결해야할 문제입니다. 현재는 동기적으로 작동하기에 만약 Es 서버에서 문제가 일어난 경우 따로 해결할 수 있는 방법이 없습니다.
하지만 현재 팀에서 도입중인 RabbitMq를 통해 보상로직(아마 재시도 로직)을 구성함으로써 DB와 ES 서버의 데이터의 동기화를 이룰 수 있고 성능개선을 안전하게 할 수 있을 것으로 판단중에 있습니다.
Elasticsearch 연결 툴 선택
SpringBoot에서 어떤식으로 Elasticsearch를 사용했는지 확인해보겠습니다.
사실 기본적인 RestTemplate을 사용해도 되고, 현재 DDD 환경을 구성해놓은 저희 프로젝트에서 사용중인 WebClient를 사용해서 도커를 통해 띄워놓은 ES를 사용해도 됩니다.
그러나 조건문을 사용하고 컴파일 시 에러를 잡을 수 있는 툴이 있는 것으로 알고 있기 때문에 특히나 익숙한 queryDsl처럼 구성하면 가독성 또한 올라갈 것이라 판단하여 Mvn Repository에서 우선 어떤 툴이 있는지 찾아봤습니다.
그리고 키워드를 얻어 공식문서들을 들어가 보고 알아본 후 스프링 생태계에서 구성하기 편하고, queryDsl과 유사한 형식이기에 가독성 및 컴파일 단계에서 오류 체크, queryDsl 형식이기 때문에 텍스트로 쿼리를 관리하지 않아도 된다는 점에서 선택하게 되었습니다.
Elasticsearch 컨테이너 실행
docker run -d \
--name elasticsearch \
-p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
docker.elastic.co/elasticsearch/elasticsearch:8.18.1
도커를 통해 실행이 되었다면 이제 스프링부트 코드를 작성해보겠습니다.
의존성 추가
// elasticsearch
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
implementation 'org.elasticsearch:elasticsearch:8.18.1'
Elasticsearch Config 설정
@Configuration
public class ElasticsearchClientConfig extends ElasticsearchConfiguration {
@Override
public ClientConfiguration clientConfiguration() {
return ClientConfiguration.builder()
.connectedTo("localhost:9200")
.build();
}
}
ElasticsearchRepository
public interface MeetingElasticRepository extends ElasticsearchRepository<MeetingDocument, String> {
}
기존 JpaRepository를 통해 기본적인 기능이 구현되어있는 레포지토리가 생각나실 것 같습니다. 동일하게 Elasticsearch에서 기본적인 기능을 사용할 수 있는 레포지토리입니다. 다른점은 완전한 Jpa가 아니기 때문에 추가적인 Document를 만들어야 합니다.
MeetingDocument
@Document(indexName = "meetings")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MeetingDocument {
...
}
@Document 어노테이션을 통해 ES에 저장할 인덱스 명을 정하고 내부 데이터 형식을 저장해줍니다.
이전 ElasticRepository를 상속받은 레포지토리를 하나 더 만든 이유는 데이터의 변경이 일어났을 때 ES와 동기화를 해줘야하기 때문에 필요하여 사용중에 있습니다.
(ex) repository.save(meetingDocument)
Elasticsearch를 도입한 이유이기도 한 조회로직을 이전 queryDsl 로직과 비교해보겠습니다.
queryDsl을 통한 모임 목록 조회 로직
@Repository
@RequiredArgsConstructor
public class MeetingQueryRepositoryImpl implements MeetingQueryRepository {
private final JPAQueryFactory queryFactory;
@Override
public Page<Meeting> findMeetings(String title, LocalDateTime meetingDate, MeetingStatus status, Integer categoryId,
Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
if (!title.trim().isEmpty()) {
builder.and(meeting.title.like("%" + title.trim() + "%"));
}
if (meetingDate != null) {
LocalDate date = meetingDate.toLocalDate();
LocalDateTime start = date.atStartOfDay();
LocalDateTime end = start.plusDays(1);
builder.and(meeting.meetingDate.goe(start).and(meeting.meetingDate.lt(end)));
}
if (status != null) {
builder.and(meeting.status.eq(status));
}
if (categoryId != null) {
builder.and(meeting.categoryId.eq(categoryId));
}
builder.and(meeting.isDeleted.eq(false));
List<Meeting> meetingContent = queryFactory
.selectFrom(meeting)
.where(builder)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(meeting.meetingDate.desc())
.fetch();
Long total = Optional.ofNullable(queryFactory
.select(meeting.count())
.from(meeting)
.where(builder)
.fetchOne()).orElse(0L);
return new PageImpl<>(meetingContent, pageable, total);
}
ElasticsearchCustomRepository 생성 후 모임 목록 조회 로직 (ES)
@Override
public Page<MeetingDocument> getMeetings(String title, LocalDateTime meetingDate, MeetingStatus status,
Integer categoryId, Pageable pageable) {
Criteria criteria = new Criteria();
if (title != null && !title.isBlank()) {
criteria = criteria.and("title").contains(title);
}
if (meetingDate != null) {
LocalDateTime start = meetingDate.toLocalDate().atStartOfDay();
LocalDateTime end = start.plusDays(1);
criteria = criteria.and("meetingDate").between(start, end);
}
if (status != null) {
criteria = criteria.and("status").is(status.name());
}
if (categoryId != null) {
criteria = criteria.and("categoryId").is(categoryId);
}
CriteriaQuery query = new CriteriaQuery(criteria, pageable);
SearchHits<MeetingDocument> searchHits =
elasticsearchOperations.search(query, MeetingDocument.class);
List<MeetingDocument> content = searchHits.getSearchHits()
.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
return new PageImpl<>(content, pageable, searchHits.getTotalHits());
}
criteria는 기존 booleanbuilder와 비슷한 역할을 한다고 생각하면 됩니다. 기존에 말씀드린 queryDsl과 비슷한 형식이기에 가독성 및 컴파일 단계에서의 에러 체크등의 장점이 있음을 알 수 있습니다.
여기까지 조건이 필요한 목록 조회에서의 성능 개선과, 작성한 코드를 통해 Elasticsearch를 확인해봤습니다.
'SpringBoot' 카테고리의 다른 글
조인과 서브쿼리 (0) | 2025.07.04 |
---|---|
SpringBoot 대용량 데이터 조회 (Index) (1) | 2025.07.03 |
AOP 탐구 (0) | 2025.07.01 |
JPA 영속성 파헤치기 (0) | 2025.06.28 |
SpringEvent / EDA (2) | 2025.06.24 |