Elasticsearch를 사용해서 추천 결과를 저장하고 쉽게 볼 수 있도록 만들어보자

조현준조현준
3 min read

프로젝트의 특성상 3차원 빈 패킹 알고리즘을 통해 생성되는 결과에는 많은 복잡한 데이터들이 포함되어 있습니다. 각 아이템의 위치, 회전 정보, 선택된 박스, 그리고 무엇보다 중요한 제약 조건 위반 점수 등의 상세한 정보들을 효과적으로 저장하고 조회해야 했습니다.

왜 Elasticsearch를 선택했는가?

1. 복잡한 점수 구조의 저장 및 분석

프로젝트에서는 OptaPlanner의 BendableScore를 사용하여 하드/소프트 제약 조건을 구분하고 있습니다. 각 제약 조건별로 상세한 점수가 기록되는데, 이러한 중첩된 데이터 구조를 Elasticsearch의 Nested 타입으로 효과적으로 저장할 수 있습니다.

data class ScoreCoordinate(
    val level: Int,
    val isHard: Boolean,
    val score: Int,
    val constraintName: String,
    val constraintDescription: String
)

2. 시계열 데이터의 효율적 관리

빈 패킹 작업의 히스토리를 날짜별로 관리하고, 최적화 성능의 추이를 분석해야 했습니다. Elasticsearch는 날짜 기반 인덱싱과 Index Lifecycle Management를 통해 이러한 요구사항을 효과적으로 처리할 수 있습니다.

3. 실시간 분석과 시각화

Grafana 대시보드와 통합하여 빈 패킹 성능 지표를 실시간으로 모니터링할 수 있고, 다양한 제약 조건별 점수 분포를 시각화할 수 있습니다.

Elasticsearch 적용 과정

1. 프로젝트 의존성 설정

Spring Boot 3.4.4와 호환되는 Elasticsearch 의존성을 추가하고, 설정 클래스를 작성합니다.

ES, Kibana는 docker compose를 이용해서 컨테이너를 띄웠습니다

YAML

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    ports:
      - "9200:9200"

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.3
    ports:
      - "5601:5601"

Kotlin


dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
}

application.yml에서 프로필별 Elasticsearch 연결 설정을 구성합니다:

spring:
  elasticsearch:
    uris: ${ELASTICSEARCH_URI:http://localhost:9200}
    username: ${ELASTICSEARCH_USERNAME:elastic}
    password: ${ELASTICSEARCH_PASSWORD:changeme}

2. Document 객체 생성

빈 패킹 결과와 점수 상세 정보를 저장할 Document 클래스를 생성합니다:

@Document(indexName = "bin-pack-recommend-result")
data class BinPackRecommendResult(
    @Id val id: String? = null,
    @Field(type = FieldType.Long) val solutionId: Long,
    @Field(type = FieldType.Nested) val scoreCoordinates: List<ScoreCoordinate>,
    @Field(type = FieldType.Date) val createdAt: OffsetDateTime,
    @Field(type = FieldType.Nested) val assignments: List<AssignmentDetail>
)

3. 날짜별 인덱스 생성 구현

매일 새로운 인덱스가 생성되도록 커스텀 리포지토리를 구현합니다:

object IndexNameGenerator {
    private const val BASE_INDEX_NAME = "bin-pack-recommend-result"
    private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd")

    fun generateIndexName(date: LocalDate = LocalDate.now()): String {
        return "${BASE_INDEX_NAME}-${date.format(DATE_FORMATTER)}"
    }
}

class BinPackRecommendResultCustomRepositoryImpl(
    private val operations: ElasticsearchOperations
) : BinPackRecommendResultCustomRepository {

    override fun saveWithDateIndex(document: BinPackRecommendResult) {
        val indexName = IndexNameGenerator.generateIndexName()
        val query = IndexQueryBuilder()
            .withId(document.id.toString())
            .withObject(document)
            .build()

        operations.index(query, IndexCoordinates.of(indexName))
    }
}

4. ILM 설정을 통한 인덱스 TTL 설정

인덱스가 자동으로 순환되도록 설정합니다. 이 프로젝트에서는 Elasticsearch 설정파일을 통해 구현할 예정이며, 현재는 날짜별 인덱스 구조를 먼저 구축했습니다. ILM 설정은 다음과 같이 적용할 계획입니다:

{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_age": "30d",
            "max_size": "50GB"
          }
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

5. 적용 결과 확인

이벤트 기반 아키텍처를 통해 PostgreSQL에 먼저 저장된 후, 비동기로 Elasticsearch에도 저장합니다:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleRecommendResultSaved(event: RecommendResultSavedEvent) {
    try {
        val documentToSave = BinPackRecommendResult.from(
            result = event.recommendResult,
            skus = skuDocuments,
            scoreDescription = event.scoreDescription,
            scoreCoordinates = event.scoreCoordinates,
            assignments = event.assignments
        )

        binPackRecommendResultRepository.saveWithDateIndex(documentToSave)
        logger.info("Successfully saved recommend result ${event.recommendResult.id} to Elasticsearch")
    } catch (e: Exception) {
        logger.error("Failed to save recommend result ${event.recommendResult.id} to Elasticsearch", e)
    }
}

도커 환경에서 실행하면 다음과 같이 Elasticsearch와 Kibana가 함께 실행되어 데이터를 확인할 수 있습니다:

Kibana에서 추천 결과 확인

느낀점

  1. Elasticsearch의 유연성: JSON 형태의 복잡한 중첩 데이터 구조를 자연스럽게 저장하고 쿼리할 수 있어 OptaPlanner의 상세한 점수 정보를 효과적으로 관리할 수 있었습니다.

  2. 이벤트 기반 아키텍처의 장점: Spring의 @TransactionalEventListener를 활용하여 PostgreSQL 저장이 성공한 후에 Elasticsearch에 저장하는 구조로, 데이터 일관성을 보장하면서도 성능 저하를 최소화할 수 있었습니다.

  3. 인덱스 관리의 중요성: 날짜별 인덱스 분리를 통해 오래된 데이터의 효율적인 삭제와 검색 성능 향상을 동시에 달성할 수 있었습니다. 아직 ILM 설정은 구현 예정이지만, 장기적인 데이터 관리 전략이 중요함을 깨달았습니다.

이 과정을 통해 Elasticsearch가 단순한 검색 엔진이 아닌, 복잡한 분석 데이터의 저장소로서도 매우 효과적임을 확인할 수 있었습니다. 특히 최적화 알고리즘의 결과 분석에 필요한 다차원 데이터를 효과적으로 다룰 수 있는 강력한 도구임을 실감했습니다.

0
Subscribe to my newsletter

Read articles from 조현준 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

조현준
조현준