오늘은 지금까지 배웠던 엘라스틱서치를 Spring Data Elasticsearch
로 활용하는 법을 적는다.
Spring Data Elasticsearch
는 Elasticsearch를 쉽게 사용할 수 있도록 도와주는 Spring Data 프로젝트의 한 부분이다.Spring Data
의 리포지토리 패턴을 활용해 간편하게 문서를 저장하고 검색할 수 있다.🔹 주요 기능
1️⃣ 자동 CRUD 리포지토리 제공
2️⃣ Elasticsearch 쿼리 지원 (Query DSL, Native Query, Criteria API)
3️⃣ 페이징 및 정렬 기능 제공
4️⃣ 비동기(Async) 처리 가능
5️⃣ Elasticsearch의 기본 RestClient 및 Spring Integration 지원
예시 코드들은 간단하게 만든 테스트 코드들이다.
Maven의 경우
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
Gradle의 경우
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
}
spring:
elasticsearch:
uris: http://localhost:9200
9200 port에서 엘라스틱서치가 실행중임을 가정한다.
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
@Document(indexName = "products") // Elasticsearch의 인덱스명 지정
public class Product {
@Id
private String id;
@Field(type = FieldType.Text)
private String name;
@Field(type = FieldType.Text)
private String description;
@Field(type = FieldType.Double)
private double price;
public Product(String id, String name, String description, double price) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
}
// Getter와 Setter는 이곳에서 생략되나, 실제로는 작성하거나 lombok을 import하여 사용해야한다
}
🔹 @Document(indexName = "products")
🔹 @Field(type = FieldType.Text)
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import java.util.List;
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
List<Product> findByName(String name); // 자동으로 쿼리 생성
}
🔹 ElasticsearchRepository를 확장하면 CRUD 기능이 자동 생성됨
findByName(String name)
을 호출하면 name 필드에 해당하는 데이터를 Elasticsearch에서 자동 검색import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// 물품을 저장하는 메서드
public Product save(Product product) {
return productRepository.save(product);
}
// 이름으로 물품을 조회하는 메서드
public List<Product> findByName(String name) {
return productRepository.findByName(name);
}
// 모든 물품들을 조회하는 메서드
public Iterable<Product> findAll() {
return productRepository.findAll();
}
}
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public Product save(@RequestBody Product product) {
return productService.save(product);
}
@GetMapping("/{name}")
public List<Product> getByName(@PathVariable String name) {
return productService.findByName(name);
}
@GetMapping
public Iterable<Product> getAll() {
return productService.findAll();
}
}
# 데이터 저장
curl -X POST "http://localhost:8080/products" -H "Content-Type: application/json" -d '{
"id": "1",
"name": "걘역시99",
"description": "샘성의 역작",
"price": 999.99
}'
# 데이터 검색
curl -X GET "http://localhost:8080/products/걘역시99"
💡 네이티브 쿼리는 물론, QuaryDSL을 활용한 복잡한 검색도 가능하다.
@Query("{\"match\": {\"description\": \"?0\"}}") List<Product> searchByDescription(String keyword);
💡 페이징 및 정렬 역시 제공하니 익혀두자.
public interface ProductRepository extends ElasticsearchRepository<Product, String> { Page<Product> findByName(String name, Pageable pageable); }
❗ 보안상 실제 사용했던 모든 코드를 보여줄 수는 없어서, 일부만 보여드리겠습니다..
🔷 엘라스틱서치 특성상 일반적인 데이터를 저장해놓기 보다는 사용자의 로그 발생 시 이를 담아 놓는 역할로 자주 쓰인다.
🔷 그래서 필자가 담당했던 주문 통계 api 제작을 위해 주문 로그를 기록하여 이를 저장하는데 사용하였다.
public interface OrderLogRepository extends ElasticsearchRepository<OrderLog, String> {
// 상태, 부모 카테고리, 서브 카테고리, 타임스탬프 범위를 기준으로 문서 조회
List<OrderLog> findByStatusAndParentCategoryAndSubCategoryAndTimestampBetween(
String status,
String parentCategory,
String subCategory,
LocalDateTime startDate,
LocalDateTime endDate
);
// 상태, 부모 카테고리, 타임스탬프 범위를 기준으로 문서 조회
List<OrderLog> findByStatusAndParentCategoryAndTimestampBetween(
String status,
String parentCategory,
LocalDateTime startDate,
LocalDateTime endDate
);
// 상태, 타임스탬프 범위를 기준으로 문서 조회
List<OrderLog> findByStatusAndTimestampBetween(
String status,
LocalDateTime startOfMonth,
LocalDateTime endOfMonth);
}
💻 주문 취소 시 로그 수집 메서드
public void sendCreated(Order order) {
for (OrderProduct product : order.getOrderProducts()) {
try {
ProductCollection productCollection = productCollectionRepository.findByIdAdmin(product.getProductId())
.orElseThrow(() -> new ProfileApplicationException(ErrorCode.PRODUCT_NOT_FOUND));
SubCategory subCategory = getSubCategory(productCollection.getCategory().getCategoryId());
OrderLog orderLog = OrderLog.builder()
.id(order.getOrderUid())
.status(order.getStatus().name())
.userId(order.getUser().getUserId().toString())
.productId(product.getProductId())
.productName(product.getProductName())
.subCategory(subCategory.getCategoryName())
.parentCategory(subCategory.getParentCategory().getParentCategoryName())
.size(product.getSize())
.color(product.getColor())
.quantity(product.getQuantity())
.price(product.getPrice().doubleValue())
.totalPrice(product.getPrice().multiply(BigDecimal.valueOf(product.getQuantity())).doubleValue())
.timestamp(LocalDateTime.now())
.details("Order created successfully")
.build();
orderLogRepository.save(orderLog);
log.info("Order log created for product: {}", orderLog.getProductId());
} catch (Exception e) {
log.error("Failed to create log for product: {}", product.getProductId(), e);
}
}
}
💻 주문 취소 시 로그 수집
private void sendOrderCancellationToElasticSearch(Order order) {
try {
logService.sendCancellation(order);
} catch (Exception e) {
log.error("엘라스틱 서치 요청 중 오류 발생: {}", e.getMessage());
}
}
/**
이번 달의 카테고리 별 판매량을 정렬해서 집계
*/
public StatsResponseDto<List<CategoryStatisticsDto>> getSubCategoryStatistics(String parentCategoryName, int month) {
// 유저 검증
validateAdminUser();
// 월 단위 시작일과 종료일 계산
LocalDate now = LocalDate.now();
int year = now.getYear();
LocalDateTime startOfMonth = LocalDate.of(year, month, 1).atStartOfDay();
LocalDateTime endOfMonth = startOfMonth.plusMonths(1).minusSeconds(1);
log.info("Fetching statistics for parentCategory: {} from {} to {}", parentCategoryName, startOfMonth, endOfMonth);
// Elasticsearch 레포지토리 호출
List<OrderLog> logs = orderLogRepository.findByStatusAndParentCategoryAndTimestampBetween("COMPLETED", parentCategoryName, startOfMonth, endOfMonth);
// 하위 카테고리별로 그룹화 및 집계
Map<String, Integer> subCategoryCounts = logs.stream()
.collect(Collectors.groupingBy(OrderLog::getSubCategory, Collectors.summingInt(OrderLog::getQuantity)));
// 결과 매핑
List<CategoryStatisticsDto> statistics = subCategoryCounts.entrySet().stream()
.map(entry -> CategoryStatisticsDto.builder()
.category(entry.getKey())
.count(entry.getValue())
.build())
.collect(Collectors.toList());
return StatsResponseDto.<List<CategoryStatisticsDto>>builder()
.resultCode(200)
.data(statistics)
.build();
}
💡 이쯤되면 눈치챈 사람도 있겠지만, 거의 모든 응답 Dto는 응답 코드와 함께 객체 데이터를 담아 빌드하여 리턴시킨다. 빌더 패턴에 익숙해지면 데이터의 갯수 등을 추가로 더 빌드할 수 있게 분리하는 등 여러모로 편하고 빠른 작업이 가능해진다.
더 보여주기에는 곤란한 것들이 많아서 여기까지만 보여드리리다...
여기까지 실무를 통해 접하고 정리했던 엘라스틱서치에 관한 내용들이었다.
나 말고도 많은 사람들에게 도움이 되길 바라며, 일단은 여기까지.
하지만 ELK에 짙은 호기심이 생겨 강의 구매를 저질렀으니... 엘라스틱서치는 다시 돌아온다.