Skip to main content

Command Palette

Search for a command to run...

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

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

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ