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

프로젝트의 특성상 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에서 추천 결과 확인
느낀점
Elasticsearch의 유연성: JSON 형태의 복잡한 중첩 데이터 구조를 자연스럽게 저장하고 쿼리할 수 있어 OptaPlanner의 상세한 점수 정보를 효과적으로 관리할 수 있었습니다.
이벤트 기반 아키텍처의 장점: Spring의
@TransactionalEventListener
를 활용하여 PostgreSQL 저장이 성공한 후에 Elasticsearch에 저장하는 구조로, 데이터 일관성을 보장하면서도 성능 저하를 최소화할 수 있었습니다.인덱스 관리의 중요성: 날짜별 인덱스 분리를 통해 오래된 데이터의 효율적인 삭제와 검색 성능 향상을 동시에 달성할 수 있었습니다. 아직 ILM 설정은 구현 예정이지만, 장기적인 데이터 관리 전략이 중요함을 깨달았습니다.
이 과정을 통해 Elasticsearch가 단순한 검색 엔진이 아닌, 복잡한 분석 데이터의 저장소로서도 매우 효과적임을 확인할 수 있었습니다. 특히 최적화 알고리즘의 결과 분석에 필요한 다차원 데이터를 효과적으로 다룰 수 있는 강력한 도구임을 실감했습니다.
Subscribe to my newsletter
Read articles from 조현준 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
