안녕하세요.
올리브영의 상품 운영 프로세스 개발 업무를 담당하고 있는 벙개맨⚡️ 입니다.
제가 소속된 상품 탐색 스쿼드는 보다 안정적이고 확장성 있는 상품 정보 관리를 위해 상품 통합 프로젝트를 진행하고 있습니다.
오늘은 위 프로젝트의 한 부분인 상품의 품절 여부 관리 프로세스를 어떻게 개선했는지, 그리고 개선 과정에서 실시간 데이터 처리를 위해 활용한 Kafka Streams에 대하여 소개해 드리겠습니다.
⛔️ 멈춰버린 데이터 고속도로: 기존 프로세스의 문제점
올리브영 온라인몰은 고객에게 정확한 상품의 품절 여부(Sold Out Status) 정보를 제공하기 위해, 이 데이터를 장바구니, 검색 결과, 상품 상세 페이지 등 다양한 영역에서 핵심적으로 활용하고 있습니다.
하지만 개선 이전에는 품절 여부를 조회하기 위해 Oracle 함수를 직접 호출하는 구조를 사용하고 있었고, 내부적으로 복잡한 쿼리를 수행해야 했기 때문에 응답 속도와 처리 효율이 떨어졌습니다.
또한 별도의 캐시 처리 없이 모든 트래픽이 Oracle 함수를 직접 호출했습니다. 이로 인해 데이터베이스에 지속적인 부하가 발생했습니다.
특히 올영세일과 같은 대규모 트래픽 상황에서는 이 부하가 심화되어 품절 정보 조회 지연은 물론, 온라인몰 전체 서비스 품질에도 영향을 주는 문제가 발생했습니다.
이러한 한계를 해소하고 보다 안정적이며 확장 가능한 서비스를 제공하기 위해 저희는 품절 여부 관리 프로세스 개선을 진행하게 되었습니다.
🚉 새로운 환승 시스템을 설계하다: 해결책 모색
저희는 기존 DB 내부 로직(Oracle 함수 등)에 의존하던 일방향식 처리 구조를 벗어나, OGG(Oracle GoldenGate), AWS MSK(Managed Streaming for Apache Kafka), Kafka Streams를 활용한 Event-Driven Architecture(EDA) 기반의 새로운 데이터 환승 시스템을 설계하였습니다. EDA를 도입한 주요 목적은 다음과 같습니다.
DB 단일 의존성 제거와 안정성 확보
기존에는 Oracle DB에 집중된 레거시 구조로 인해, DB 부하나 장애가 발생하면 전체 서비스가 영향을 받는 단일 장애 지점(Single Point of Failure) 문제가 있었습니다. 이에 저희는 DB 의존성을 줄이고, 서비스 단위로 독립적인 확장과 안정적인 장애 대응이 가능한 구조로 전환하였습니다.
실시간 데이터 처리와 낮은 결합도 구현
과거에는 배치 중심의 데이터 전달 방식이라 지연(latency) 이 발생하고, 서비스 간 결합도가 높아 변경이 어려웠습니다. 이를 이벤트 기반 구조로 전환하면서, 데이터 변경 시 즉시 이벤트를 발행하고 필요한 서비스가 이를 구독하여 실시간으로 처리할 수 있게 되었습니다. 결과적으로 서비스 간 결합도가 낮아지고, 변경 대응이 유연해졌습니다.
손쉬운 확장성과 유연한 비즈니스 대응
Kafka 기반의 이벤트 파이프라인은 새로운 서비스가 필요할 때 단순히 이벤트를 구독하는 것만으로 손쉽게 확장할 수 있습니다.
또한 Kafka Streams를 활용해 이벤트를 실시간으로 가공, 조합함으로써, 기존 DB 중심 처리 방식으로는 구현하기 어려웠던 다양한 비즈니스 요구사항에도 유연하게 대응할 수 있게 되었습니다.
🔄 실시간으로 흐르는 재고 데이터: 새로운 아키텍처의 동작 방식
새로 설계한 아키텍처에서는 상품 재고의 변경 이벤트가 발생하는 순간부터 해당 정보가 실시간으로 흐르는 구조로 변경되었습니다.
먼저, Event Data Producer 영역에서는 재고 변경이 감지되면 CDC(Change Data Capture)를 통해 변경 이벤트를 Kafka Topic으로 즉시 발행합니다. 이 메시지는 Stream Data Processing 영역으로 전달되어 Kafka Streams를 통해 필요한 로직이 실시간으로 처리되고, 최종적으로 OpenSearch에 상품의 품절 여부 정보가 저장됩니다.
상품 유형별 재고 관리 구조
이 아키텍처를 이해하려면 먼저 올리브영의 재고 관리 체계를 알아야 합니다. 추후 인벤토리 담당 스쿼드에서 통합 관리 예정이나, 현재 올리브영에서는 상품 유형에 따라 재고 데이터의 관리 주체가 다릅니다.
올리브영의 상품은 크게 세 가지 재고 관리 체계로 구분됩니다.
- 직매입 상품 (올리브영이 직접 재고 보유)
- 관리 주체: 인벤토리 스쿼드
- 처리 방식: Inventory Service 내부에서 변경된 재고 정보를 감지하여 Kafka Topic으로 발행
- 위수탁 상품 (협력사 재고)
- 관리 주체: 상품 탐색 스쿼드
- 처리 방식: Kafka Streams로 실시간 이벤트 처리
- 예약/한정 상품 (한시적 재고)
- 관리 주체: 상품 탐색 스쿼드
- 처리 방식: Kafka Streams로 실시간 이벤트 처리
Kafka Streams를 통한 실시간 품절 처리
특히 위수탁 및 예약/한정 상품의 경우, Kafka Streams는 다음과 같은 방식으로 품절 여부를 실시간 판단합니다.
- 이벤트 감지: 재고 변경 이벤트 중 "모든 재고 소진" 또는 "재고 입고로 판매 재개" 상태 전환을 감지
- 토픽 발행: 감지된 이벤트를
SoldOut토픽으로 발행
이러한 구조로 전환하면서 기존 DB 기반 배치 처리 방식에서 발생하던 시간 지연과 데이터 정합성 문제가 크게 개선되었습니다. 각 단계가 이벤트 기반으로 긴밀하게 연결되어, 재고 변경부터 검색 결과 반영까지의 전체 프로세스가 실시간으로 처리됩니다. 마치 잘 정비된 고속 환승 시스템처럼 매끄럽게 이어지는 거죠.
🚄 데이터 KTX를 소개합니다: Kafka Streams란?
위 아키텍처를 보면서 "재고 변동이 일어날 때마다 어떻게 그렇게 빠르게 처리될까?" 궁금하셨을 텐데요. 그 비밀은 바로 Kafka Streams에 있습니다. Kafka Streams란 Kafka에서 공식적으로 제공하는 Kafka 클라이언트 Java 라이브러리로, 토픽 데이터를 낮은 지연과 빠른 속도로 처리가 가능합니다. 토픽 데이터를 가공하여 다른 토픽으로 발행하거나 2개 이상의 토픽을 조인하여 데이터 집계 처리하는 등 다양하게 활용될 수 있으며, 간단한 코드로 구현이 가능한 장점이 있습니다.
- 카프카와 완벽한 호환
- Kafka에서 공식으로 제공되는 라이브러리로 Kafka 버전에 맞춰 호환성을 제공
- 딱 1번만(Exactly-once) 처리 보장
- 데이터가 유실되거나 중복 처리되지 않도록 한 번만 처리될 수 있는 기능 제공
- 별도 스케줄링 도구가 불필요
- Spark Streaming 등 별도의 스케줄링 도구가 필요 없이 Kafka Streams를 통하여 Stream Processing 처리 가능
- Streams DSL과 Processor API를 사용하여 간단한 구현 가능
- Streams DSL
- 미리 제공되는 함수들을 사용하여 간단하게 로직 구현 가능 (filter, map, join, window 등)
- KStream, Ktable, GlobalKtable 데이터 처리 구조 제공
- 스트림 데이터 처리뿐 아니라 key-value 저장소로 사용 가능
- Processor API
- Streams DSL을 통한 구현이 불가할 경우 사용
- Streams DSL 보다 복잡한 코드 구조를 가지지만, Lower-Level에서 복잡하고 정교한 로직 구현 가능
- Streams DSL
- 로컬 상태 저장소를 사용
- 상태 기반 처리를 위하여 RockDB를 사용하며, 장애가 발생하더라도 안전하게 복구 가능
🧩 Kafka Streams의 동작 방식
Kafka Streams는 내부적으로 Topology(토폴로지) 구조로 동작합니다. Topology는 데이터를 처리하는 일련의 흐름을 노드(Processor) 단위로 구성한 형태로, 각 노드가 데이터를 받아 가공하고 다음 단계로 전달하는 파이프라인 역할을 합니다.
Kafka Streams의 Topology는 크게 다음 세 가지 Processor로 구성됩니다.
Source Processor
Topology의 시작점으로, Kafka 토픽과 직접 연결되어 메시지를 가져오는 역할을 합니다. 즉, 스트림 처리를 시작하기 위해 가장 먼저 데이터를 받아오는 입구 노드입니다.
Stream Processor
가져온 메시지를 실제로 가공·처리하는 핵심 노드입니다. 여기서는 join, window 등의 연산이나 다양한 비즈니스 로직을 적용하여 데이터를 원하는 형태로 변환하거나 조합합니다.
Sink Processor
Topology의 마지막 노드로 가공, 집계된 데이터를 다른 Kafka 토픽으로 발행하는 역할을 합니다. 즉, 처리된 결과가 다음 시스템이나 서비스로 전달되는 출구 노드입니다.
🔑 어째서 Kafka Streams일까?
상품 재고 수량의 실시간 변동을 처리하기 위해 Kafka Streams를 선택한 이유는 다음과 같습니다.
1️⃣ Streams DSL을 활용해 구현이 간결합니다.
Kafka Streams는 map, filter 등 기본 함수를 제공하여 복잡한 로직 없이도 원하는 비즈니스 조건을 간단한 코드로 표현할 수 있습니다. 이를 통해 개발 효율성을 높이고, 유지 보수가 용이한 구조를 확보할 수 있었습니다.
2️⃣ 애플리케이션 구조가 단순해집니다.
기존 구조에서는 Consumer와 Producer 애플리케이션의 분리로 인해 불필요한 네트워크 통신이 발생하고 복잡도가 높았습니다. Kafka Streams는 Consumer와 Producer 로직을 단일 애플리케이션 내에서 통합 관리하여 구조를 간결하게 만들고, 유지 보수성을 크게 개선할 수 있었습니다.
3️⃣ 실시간 데이터 처리에 최적화된 아키텍처입니다.
Kafka Streams는 스트리밍 데이터의 특성에 맞춰 설계된 라이브러리로, 재고와 같이 지속적으로 변화하는 데이터를 낮은 지연과 높은 처리 안정성으로 다룰 수 있습니다.
결과적으로 Kafka Streams는 실시간성과 단순성을 모두 갖춘 구조로, 상품 재고 처리 프로세스의 안정성과 효율성을 한층 높이는 핵심 역할을 하게 되었습니다.
⌨️ Kafka Streams 예제 코드
Kafka Streams의 가장 큰 장점 중 하나는 복잡한 실시간 데이터 처리를 단 몇 줄의 코드로 구현할 수 있다는 점입니다. 아래 예제는 Streams DSL을 사용하여 품절에 대한 이벤트를 감지하고, 가공된 데이터를 다른 토픽으로 발행하는 간단한 구현 예제 코드입니다.
@Component
class SoldOutStreamsProcessor (
private val goodsInventoryService: GoodsInventoryService,
private val streamsBuilder: StreamsBuilder,
private val streamsProperty: StreamsProperty
) {
fun soldOutInventory() {
streamsBuilder.stream<String, String>(streamsProperty.optionInventory) // streamsBuilder를 통하여 스트림 데이터를 consume
.mapValues { value -> convertToMessage<SoldOutVo>(value) } // 비즈니스 형식에 맞게 Message Convert
.filter { _, value -> goodsInventoryService.validateSoldOut(value) } // 비즈니스 조건에 맞는 이벤트 감지
.map { _, value -> convertFromMessage(value?.afterInventory) } // produce topic 형식에 맞게 Message Convert
.filter { _, value -> !value.isNullOrEmpty() }
.to(streamsProperty.optionSoldOut) // topic produce
}
}이처럼 Kafka Streams는 토픽 데이터를 읽고(consume), 비즈니스 로직을 적용한 뒤, 다시 토픽으로 발행(produce) 하기까지의 과정을 하나의 애플리케이션 내에서 간결하게 처리할 수 있습니다.
복잡한 네트워크 구성이나 별도의 Consumer/Producer 관리 없이도 안정적인 스트리밍 처리가 가능하다는 점이 큰 장점입니다.
🚄 열차 운행의 비밀: 코드와 주의 사항
다만 Kafka Streams를 도입할 때는 몇 가지 주의해야 할 점이 있습니다. 스트리밍 처리의 장점을 극대화하기 위해서는 아래 항목들을 유념해야 합니다.
1️⃣ 네트워크 통신이 필요한 고비용 작업은 지양할 것
Kafka Streams는 메모리 기반의 실시간 데이터 처리에 최적화된 구조를 가지고 있습니다. 따라서 내부 프로세스에서 외부 API 호출이나 DB I/O 등 네트워크 통신이 필요한 작업을 수행하면 지연(latency)이 증가하고 전체 스트림 처리 효율이 저하될 수 있습니다. 이런 경우에는 별도의 비동기 처리나 외부 서비스 연동 구조로 분리하는 것이 좋습니다.
2️⃣ 토픽 간 조인(Join)에는 제약이 존재함
Kafka Streams는 토픽 간 조인을 지원하지만, 다음과 같은 조건을 만족해야 합니다.
- 동일한 Kafka Cluster 내에 토픽이 존재해야 합니다.
- 조인 대상 토픽의 키(Key) 가 동일해야 합니다.
- 토픽들이 동일한 파티션 수를 가지며 코파티셔닝(co-partitioning) 되어 있어야 합니다.
이 조건 중 하나라도 만족하지 않으면 조인 동작이 정상적으로 수행되지 않을 수 있습니다.
🙋 조인(Join)이란?
Kafka Streams에서 조인(Join) 이란, 두 개 이상의 스트림(토픽)을 공통된 키(key)를 기준으로 결합하는 기능을 의미합니다.
즉, 서로 다른 토픽 혹은 상태 저장소(State Store)에서 유입된 데이터를 같은 키값으로 묶어 새로운 이벤트나 상태를 만들어내는 방식입니다.
예를 들어, 토픽 A와 토픽 B가 동일한 키를 가진 데이터를 각각 발행하면, Stream Processor는 두 데이터를 하나로 결합하여 새로운 토픽으로 발행하게 됩니다.
이 과정을 통해 여러 소스에서 들어오는 실시간 데이터를 하나의 통합된 이벤트로 다룰 수 있습니다.
🙋 코파티셔닝(Co-partitioning)이란?
2개 이상의 스트림(토픽)이 동일한 파티셔닝 스키마를 따르는 것을 의미합니다.
즉, 서로 다른 두 개의 토픽이 파티션 개수가 같고 동일한 파티셔닝 전략(partition strategy)을 사용하면서, 조인이 되고자 하는 데이터가 메시지 키에 주입된 상태를 의미합니다.
아래 그림을 보면 각 토픽 및 파티션에 전달되는 데이터의 동일한 키를 가진 메시지는 동일한 파티션으로 할당되는 것을 볼 수 있습니다.
Kafka Streams에서는 각 파티션 별로 Task를 할당하여 데이터를 처리하기 때문에 데이터를 조인을 사용하기 위해서는 아래와 같이 코파티셔닝된 구조가 선행되어야 합니다.
아쉽지만 위에 설명드린 조인(Join) 기능에 대해서는 품절 시스템에 활용하지는 못했습니다. 초기 프로모션 정보까지 포함되어 재고 관리가 되도록 기획되었으나 복잡한 비즈니스 관계로 인해 간소화되었고, Kafka Streams를 통해서 상품 분류 별 재고 토픽의 필터링 처리하는 기능으로 사용하게 되었습니다. 하지만 Kafka Streams의 활용성은 무궁무진하니, 앞으로도 다양한 비즈니스에 적용해 볼 계획입니다!
🎯 러시아워에 본 개선 결과: 성공 그 자체
상품 품절 여부 관리 프로세스 개선을 진행한 후 첫 올영세일을 맞이했습니다! 모니터링 결과는 놀랍게도 함수 호출 양의 약 86%가 감소했습니다! 개선 전 일주일 동안 오라클 함수 호출 양은 2.34G (23억 4천만)이었으나, 개선 후 237M (2억 3천7백만)으로 함수 호출 양이 대폭 감소한 것을 확인할 수 있었습니다.👏🏻👏🏻👏🏻 그로 인해 올영 세일 기간에 보다 안정적인 서비스를 제공할 수 있게 되었습니다.
🏃 우리의 여정은 계속됩니다
이번 글에서 소개한 상품 품절 여부 관리 프로세스 개선은 현재 진행 중인 상품 통합 프로젝트의 한 부분입니다. 다양한 팀과 협업하는 큰 프로젝트를 처음 진행하다 보니 개인적으로 많은 도전과 성장의 기회를 얻었습니다. 앞으로 넘어야 할 산이 참 많지만, 능력 있는 팀원들과 함께라면 남은 여정도 성공적으로 헤쳐나갈 수 있을 것이라 확신합니다!
다음 포스트에는 더 많은 정보를 담아 풍성하게 돌아오겠습니다.
많은 관심 부탁드리며 마무리 하겠습니다. 긴 글 읽어주셔서 감사합니다.

