[Spring] Spring Data Elasticsearch

Bzeromo·2025년 3월 13일
0

Spring

목록 보기
15/15
post-thumbnail

⚡ Spring Data Elasticsearch


🍀 Elasticsearch (1)
🍀 Elasticsearch (2)

오늘은 지금까지 배웠던 엘라스틱서치를 Spring Data Elasticsearch로 활용하는 법을 적는다.


📌 그게 뭐고?

  • Spring Data Elasticsearch는 Elasticsearch를 쉽게 사용할 수 있도록 도와주는 Spring Data 프로젝트의 한 부분이다.
  • REST API를 직접 호출하지 않고도 Spring Data의 리포지토리 패턴을 활용해 간편하게 문서를 저장하고 검색할 수 있다.

🔹 주요 기능

1️⃣ 자동 CRUD 리포지토리 제공
2️⃣ Elasticsearch 쿼리 지원 (Query DSL, Native Query, Criteria API)
3️⃣ 페이징 및 정렬 기능 제공
4️⃣ 비동기(Async) 처리 가능
5️⃣ Elasticsearch의 기본 RestClient 및 Spring Integration 지원


📌 활용법

예시 코드들은 간단하게 만든 테스트 코드들이다.

1. 의존성 추가

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'
}

2. application.yml 설정

spring:
  elasticsearch:
    uris: http://localhost:9200

9200 port에서 엘라스틱서치가 실행중임을 가정한다.

3. 문서(Entity) 매핑

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")

  • Elasticsearch에서 products라는 인덱스를 생성
  • MySQL에서 테이블에 해당하는 개념

🔹 @Field(type = FieldType.Text)

  • 필드 유형을 지정 (Text, Keyword, Integer, Double, Date 등을 지원)

4. 리포지토리 인터페이스 생성

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에서 자동 검색

5. 서비스(Service) 구현

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();
    }
}

6. 컨트롤러(Controller) 구현

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();
    }
}

7. 사용해보기

# 데이터 저장
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 제작을 위해 주문 로그를 기록하여 이를 저장하는데 사용하였다.

OrderLogRepository

  • 통계를 위해 필요한 정보들이 복잡한 쿼리를 요구하진 않아서 간략하게 구현한 모습
  • 상태는 주문 확정, 환불, 반품 등이 반영되어 있다.
  • 사실 이것 말고 더 있지만 여기까지만...
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);
}

LogService

  • 주문 확정, 주문 취소 시에 로그를 수집하기 위한 용도

💻 주문 취소 시 로그 수집 메서드

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);
        }
    }
}

PaymentService에서의 LogService 사용 예시

💻 주문 취소 시 로그 수집

private void sendOrderCancellationToElasticSearch(Order order) {
        try {
            logService.sendCancellation(order);
        } catch (Exception e) {
            log.error("엘라스틱 서치 요청 중 오류 발생: {}", e.getMessage());
        }
}

StatisticsService

  • 수집된 로그를 이용해 통계를 집계하여 전달하는 역할
  • 검증, 계산, 조회, 집계, 매핑 등 다양한 로직들이 흩어져 있으나 일부러 보기 쉽게 몇 개는 모았다. 실제는 훨씬 복잡하다...
/**
 이번 달의 카테고리 별 판매량을 정렬해서 집계
 */
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에 짙은 호기심이 생겨 강의 구매를 저질렀으니... 엘라스틱서치는 다시 돌아온다.

profile
Hodie mihi, Cras tibi

0개의 댓글