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

카프카 입문 시리즈 2편: 토픽, 파티션, 오프셋

이 글은 Apache Kafka 입문 시리즈의 두 번째 글입니다. 1편에서 살펴본 구성 요소들 위에서, 메시지가 실제로 어떤 구조로 저장되고 관리되는지 알아보겠습니다. 1편을 마치며 세 가지 질문을 남겼습니다. 메시지는 브로커 안에서 어떤 구조로 저장될까? 토픽과 파티션은 정확히 무엇이고, 왜 필요할까? 컨슈머의 오프셋은 어떻게 동작할까? 이번 편에서 이 질문들에 하나씩 답하겠습니다. Topic: 메시지의 논리적 분류 토픽(Topic)은...

Mar 19, 202612 min read7

Java GC의 진화 — Serial에서 Generational ZGC까지

Java가 약속한 것 중 하나는 "메모리는 내가 관리할게"였다. C/C++ 개발자들이 malloc과 free로 메모리와 씨름하던 시절, Java는 Garbage Collector(GC)라는 자동 메모리 관리자를 들고 나왔다. 개발자는 객체를 만들기만 하면 되고, 치우는 건 GC가 알아서 한다. 하지만 "알아서"라는 말에는 대가가 있었다. GC가 동작하는 동안 애플리케이션이 멈추는 것이다. 이 멈춤을 Stop-The-World(STW) 일시 정지...

Mar 16, 20269 min read1

Spring의 3대 철학 — DI, AOP, PSA가 만드는 코드의 품격

Spring을 처음 배울 때, 나는 어노테이션 수집가였다. @Autowired를 붙이면 객체가 알아서 들어오고, @Transactional을 붙이면 트랜잭션이 알아서 관리되고, @Cacheable을 붙이면 캐시가 알아서 동작했다. "알아서"라는 말 뒤에 숨은 원리를 몰랐다. 그냥 마법이라고 생각했다. 그러다 문제가 생겼다. @Transactional을 붙였는데 롤백이 안 됐다. 같은 클래스 안에서 메서드를 호출했기 때문이었다. 원인을 찾는 데 ...

Mar 16, 202611 min read9

Spring Boot Docker 이미지, 한 줄 한 줄에 담긴 고민

처음 Spring Boot 애플리케이션을 Docker로 배포했을 때, Dockerfile은 딱 세 줄이었다. FROM openjdk:17 COPY build/libs/app.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"] 동작은 했다. 하지만 이미지 크기는 700MB를 넘겼고, 코드 한 줄 고칠 때마다 전체 JAR를 다시 빌드해야 했다. 프로덕션에 올릴 때는 root 권한으로 실행되고 있었다. "동작...

Mar 16, 202610 min read4

끄적끄적 테크 블로그

32 posts

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