항해 플러스 6기 6주차를 회고하며 - 레디스? 카프카? 불매합니다.(불티나게 매입)

1. 문제

6주차의 과제는 다음과 같다.

* 시나리오에서 발생할 수 있는 동시성 이슈 파악 후 가능한 동시성 제어 방식들을 도입 및 장단점 파악
    * 파악한 내용을 보고서로 작성해보기
* 이전 주차 때 적용한 동시성 제어 방식에서, 파악 후 적합하다고 판단한 방법으로 비즈니스 로직 개선해보기

지난 4주차 때 시나리오의 요구사항을 구현하면서 동시성 처리하려고 적용했던 DB 락에서, 더 다양한 방식들을 알아보며 분석해보고 실제로 적용하는 과제였다. 이번 주 과제하면서 배우게 된 동시성 제어 방식에는 다음과 같은 방식들이 있었다.

  1. DB 락 (비관적 락, 낙관적 락)
  2. Redis 분산락
  3. Kafka MQ 기능 사용

2. 시도

시나리오에서의 동시성 이슈와 가능한 동시성 제어 방식을 파악하고 비교해보는 보고서는 다음 문서 같이 작성했다.
(동시성 제어 시나리오 분석 보고서)

해당 보고서를 작성할 때 어려웠던 점은 각 방식들을 비교해보기 위해선 실제로 구현을 해봐야 했다는 점이다. 이번 주에 Redis와 Kafka를 직접 사용해보기 위해 로컬에 환경 구성을 진행했는데, Kafka 환경 구성에서 조금 애먹었다. Consumer의 value-deserializer를 설정해야 했는데, 이 설정은 메시지로 받는 데이터를 정의한 타입으로 역직렬화할 때 필요한 Deserializer에 대한 설정이다. 나의 시나리오에서는 주문 시 상품 재고 차감 이벤트에 대해 Kafka 이벤트로 처리하고자 했기에, Kafka 메시지로 전달할 데이터는 상품 재고 차감과 관련된 정보들을 담아주려 했다.

data class ProductMessage(
    val productDetailId: Long,  // 상품 세부 정보 ID
    val orderQuantity: Int  // 주문양
) {}

처음에는 이 데이터는 클래스 타입이기 때문에 이를 역직렬화해주기 위해 JsonDeserializer로 설정해줬다.

spring:
  kafka:
    // ...
    consumer:
      bootstrap-servers: localhost:9092
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      group-id: hhplus_ecommerce

하지만 이러한 설정을 해도 컨슈밍이 실패했는데, 알고 보니 위 ProductMessage 데이터는 직접 정의한 클래스이다보니 신뢰할 수 있는 패키지 경로임을 설정에 추가해줬어야 했다. 이 설정을 application.yml 에서 설정하려고 했지만 잘 적용이 안 되는 것 같아서 KafkaConfig에서 Consumer 설정에 추가해줬다.

그리고 실제로 Kafka를 사용하여 상품의 재고 차감 처리를 어떤 식으로 구현할지 고민해야 했다. 기존 주문 로직은 주문 정보 저장 -> 상품 재고 차감 -> 장바구니 저장된 상품 정보 삭제 였다. 여기서 상품 재고 차감 기능을 이벤트로 따로 처리하고자 했고, 이는 다른 주문 로직의 트랜잭션이 정상적으로 Commit 될 때 해줘야 올바른 이벤트 흐름이겠다고 생각했다.

kafka-order

3. 해결

위에서 언급한 KafkaConfig에서 Consumer 설정에 신뢰할 수 있는 패키지 설정은 다음과 같이 설정했다.

@Bean
fun consumerProps(): Map<String, Any> {
    val props = HashMap<String, Any>()
    props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = consumerBootstrapServers
    props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = keyDeserializer
    props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = valueDeserializer
    props[ConsumerConfig.GROUP_ID_CONFIG] = groupId
    props[JsonDeserializer.TRUSTED_PACKAGES] = "*"  // 해당 프로젝트의 모든 패키지 경로를 허용해주도록 설정
    return props
}

@Bean
fun consumerFactory(): ConsumerFactory<Int, Any> {
    return DefaultKafkaConsumerFactory<Int, Any>(consumerProps())
}

@Bean
fun kafkaListenerContainerFactory(): KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<Int, Any>> {
    val factory = ConcurrentKafkaListenerContainerFactory<Int, Any>()
    factory.consumerFactory = consumerFactory()
    factory.setConcurrency(concurrency.toInt())
    factory.containerProperties.pollTimeout = pollTimeout.toLong()
    return factory
}

그리고 Kafka를 사용한 상품 재고 차감 이벤트는 다음과 같이 구현했다.

먼저 주문 로직의 트랜잭션이 Commit 되면 상품 재고 차감 이벤트를 발행해주기 위해 Transactional Spring Event의 AFTER_COMMIT 이벤트로 발행해준다.

    // ...

    // 주문 정보 등록
    val (savedOrder, savedOrderItems) = orderService.doOrder(userId, orderItemDetailInfos)

    // 장바구니 삭제
    cartService.deleteCartByUser(userId)

    // 재고 차감 이벤트 발생
    eventPublisher.publishEvent(ProductOrderMessageEvent(orderItemInfos))
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun productOrderEventListen(event: ProductOrderMessageEvent) {
    for (orderItemInfo in event.orderItemInfos) {
        messageProducer.sendProductOrderMessage(
            ProductMessage(
                orderItemInfo.productDetailId,
                orderItemInfo.quantity
            )
        )
    }
}

Transactional Spring Event는 상품 주문 정보를 담은 Kafka 메시지를 토픽에 Producing 하도록 한다.

@Component
class KafkaProducer(
    private val kafkaTemplate: KafkaTemplate<String, Any>
) : MessageProducer {
    override fun sendProductOrderMessage(message: ProductMessage) {
        kafkaTemplate.send(PRODUCT_ORDER_TOPIC, message.productDetailId.toString(), message)
    }
}

메시지를 발행할 때 파티션 키를 상품의 ID 값으로 지정해줘서, 재고 차감 요청을 같은 상품에 대해서는 순서를 보장해주도록 해줬다. 해당 토픽에 메시지가 발행되면 해당 토픽의 Consumer는 실제로 상품의 재고를 차감하는 이벤트를 수행한다.

@KafkaListener(groupId = "\${spring.kafka.consumer.group-id}", topics = [PRODUCT_ORDER_TOPIC])
fun listenProductOrderEvent(@Payload message: ProductMessage) {
    productService.updateProductQuantityDecrease(message.productDetailId, message.orderQuantity)
}

4. 알게된 것

이번 과제는 동시성 제어 방식에 대해 더 자세히 배우고 알게 되는 기회였다. 특히 Redis 분산락의 종류와 특징, 그리고 Kafka를 통해 동시성 제어를 하는 방법까지 알아보았고, 실제로 구현도 해보면서 실습을 해볼 수 있었다.

이번주부터 본격적으로 Redis와 Kafka 사용을 시작해보면서 얼마나 더 새로운 것들을 배우게 될지 기대된다!💓


Keep : 현재 만족하고 계속 유지할 부분

이번 과제에서 만족한 부분은 짧은 시간 내에 이번 과제에서 소개된 모든 동시성 제어 방식을 직접 구현해보고 비교해봤다는 점이다. 처음에는 이게 될까? 의심하고 그냥 내가 필요하다고 생각되는 방식만 구현해볼까 했지만, 도전 끝에 성공할 수 있었다!

그리고 Kafka를 최초 환경 설정부터 해서 기능 구현까지 시간 안에 할 수 있었던 점도 굉장히 만족스럽다. 물론 최초 환경 구성은 힘든 과정이었기 때문에 며칠 간은 잠을 줄여가면서 할 수밖에 없었지만, 추후 본격적으로 Kafka를 다루는 과제가 주어졌을 때 할 일이 줄고 그 과제에 집중할 수 있을 것이라고 생각하기 때문에 만족한다!

Problem : 개선이 필요하다고 생각하는 문제점

코치님께 이번 과제 피드백으로 다음과 같이 받았다.

그렇다! 놓친 부분이었다… DB 락에서 낙관적 락에 대한 설명을 충분히 작성하지 못했다. 사실 DB 락 자체에 대한 설명이 꽤 부족했던 것 같다… 이미 이전 과제에서 다뤘던 내용이라고 은연중에 생각이 들어 중요하게 생각하지 않고 넘어갔던 것 같다. 이 부분은 다시 보완해서 정리해야겠다.

Try : 문제점을 해결하기 위해 시도해야 할 것

이번 과제부터 대용량 트래픽 및 데이터 처리 챕터가 시작되었다. 해당 챕터는 어떤 방식이 무조건 좋다! 이런 것이 없고, 각 상황마다 적절한 방식을 선택해야 하는 문제들이 대부분이기 때문에 각 방식 별 특징과 장단점을 파악하는 게 중요하다. 이런 지식을 제대로 습득해야 추후 실무에서 비슷한 상황에 놓여졌을 때 개발자가 적절한 선택을 할 수 있게 되고, 이 것이 곧 개발자의 역량이 된다.

즉 주어진 문제 상황 분석과 적절한 방식을 선택하여 적용하고 이를 해결하는 것이 이번 챕터의 가장 주요한 키 포인트로 생각하고, 과제에 임하도록 하겠다🫡


너무 감사하게도 이번 과제도 올패스를 받고, 드디어 퍼플 뱃지를 달성했다! 앞으로의 과제도 모두 헤쳐나가서 다음 단계 뱃지도 얻도록 힘내보자🔥


🤩 다음 수료생 추천 할인 혜택!

혹시라도 항해 플러스에 합류하고 싶은데 비싼 수강료 때문에 망설여진다면…? 🤔

수료생 추천 할인 혜택으로 20만 원을 할인받으실 수 있다는 사실! 💡

결제페이지 → 할인 코드 → 수료생 할인 코드에 tJQjYK 입력하면 추가로 20만 원을 할인받는 혜택 꼭 챙겨가시길 바란다🚀🚀🌟


#추천인: tJQjYK #항해플러스 #항해99