지난 [올리브영 테크블로그 읽기] 올리브영 쿠폰 시스템의 성장기에 이어 두번째 성장기이다.
요즘 이커머스 도메인에서 자주 언급되는 개념은 옴니채널이다. 사용자가 온라인과 오프라인을 오가며 하나의 서비스처럼 이용하는 경험을 의미한다. 올리브영은 이 구조를 적극적으로 활용하고 있고, 그 중심에는 ‘오늘드림’이 있다.
오늘드림은 몇 시간 내에 배송을 받거나 근처 매장에서 픽업할 수 있는 기능이다. 이 과정에서 가장 중요한 요소는 재고이다. 배송은 근처 매장, MFC, 물류센터 중 어디에서 출고할지 결정해야 한다. 픽업 역시 실제 재고가 있는 매장을 기준으로 주문이 이루어진다.
결국 오늘드림의 사용자 경험은 재고 정확도에 의해 결정된다. 그래서 이 글에서는 올리브영 테크블로그에 올라온 글들을 살펴보며 재고 시스템이 어떤 문제를 겪었고 어떻게 발전했는지 살펴보려고 한다.
이 글은 발전 과정을 살펴보기 위해 작성된 글이다. 이러한 의도로, 상세 구현을 위한 코드들은 생략됐다. 상세 구현을 위한 코드가 궁금하다면 올리브영 테크블로그의 원본 게시글을 참고하길 바란다.
신규 재고 시스템 구축을 위한 개발 여정 - 올여우님 (2023.10.04)
올리브영은 오프라인 매장과 온라인몰을 동시에 운영하는 만큼, 재고 데이터를 얼마나 빠르고 정확하게 다루느냐가 서비스 품질에 영향을 끼친다.
그런데 기존에는 모든 데이터가 DB 하나에 집중되어 있었고, 여러 시스템이 동시에 재고 데이터를 필요로 하면서 부하 문제가 반복됐다.
이 문제를 DB 트래픽 분산과 MSA 구조로 전환하면서 안정적인 재고 시스템 구축을 목표로 다음 그림에 있는 아키텍처의 프로젝트를 시작했다고 한다.
[출처 - 올리브영 테크블로그의 그림을 재가공]
올리브영 팀은 신규 시스템의 1차 구축 목표를 오프라인 매장 재고 시스템 개선으로 설정했다. 그 이유는 오늘드림 서비스 때문이다.
오늘드림은 고객이 온라인으로 주문하면 근처 매장에서 바로 배송해 주는 서비스인데, 이게 동작하려면 온라인몰이 매장 재고를 실시간으로 알고 있어야 한다. 또한 오늘드림 픽업 또한 매장의 재고를 기준으로 이루어진다.
지금까지는 트래픽이 몰릴 때마다 Cache나 Batch로 임시방편을 써왔지만, 조금 더 안정적인 재고 시스템을 위해서 신규 시스템을 구축할 필요가 있었다.
이제부터는 안정적인 재고 시스템을 만들면서 만난 문제들을 어떻게 해결했는지 살펴볼 예정이다.
매장 재고는 전국의 매장 POS, 물류 시스템, 관리자 등 여러 클라이언트에서 동시에 호출이 발생한다. 따라서 데이터 동시성 제어는 필수다.
따라서 (1)어떤 전략으로 동시성을 제어할 건지, 그 전략을 구현하기 위해 (2)어떤 라이브러리를 쓸 건지, 라이브러리 안에서 (3)어떤 기능을 사용할 건지를 결정해야 한다.
올리브영 처럼 대규 트래픽이 발생하는 환경이라면, 동시성을 제어하는 방법은 크게 3가지라고 볼 수 있다.
첫 번째는 낙관적 락(Optimistic Lock) 이다. 충돌이 거의 없다고 가정하고, 데이터를 수정할 때 버전을 비교해 충돌이 감지되면 롤백하는 방식이다. 충돌이 드문 상황에서는 성능이 좋지만, 전국 매장에서 동시 요청이 빈번한 환경에서는 충돌 빈도가 높아 롤백과 재시도가 반복되는 문제가 있다.
두 번째는 비관적 락(Pessimistic Lock) 이다. 항상 충돌이 발생한다고 가정하고 DB 수준에서 락을 거는 방식이다. 데이터 정합성은 보장되지만, DB 커넥션을 점유한 채 대기하므로 트래픽이 몰리면 DB 부하가 커진다.
세 번째는 분산 락(Distributed Lock) 이다. DB가 아닌 별도의 공유 저장소(Redis)에서 락을 관리하는 방식이다. DB 부하 없이 여러 서버 간 동시 접근을 제어할 수 있고, 전국 매장처럼 다수의 클라이언트가 동시에 요청하는 환경에 적합하다.
이러한 이유로 Redis 기반의 분산 락이 적합 할 것이다.
올리브영 팀은 분산락 구현을 위해 Redisson 라이브러리를 선택했다고 한다.
Redisson은 Java의 표준 컬렉션 인터페이스 구현으로 간단하게 연동이 가능하고, 분산락 지원 및 Pub/Sub과 고가용성 기능을 지원한다는 장점이 있다.
동시성 제어를 위해 Redisson의 분산 락 인터페이스인 RLock을 활용했다. Pub/Sub 기반의 분산 락 메커니즘을 제공한다.
Redisson에는 락을 획득하는 다양한 메서드들이 있다. 크게 lock 메서드와 tryLock 메서드로 구분된다.
lock 메서드는 락을 얻을 때까지 대기하며, tryLock 메서드는 락 획득에 실패하면 추가 작업을 진행한다. 프로젝트에서는 락 상태를 바로 알 수 있는 tryLock으로 데이터 일관성과 성능 향상을 도모했다.
자세한 구현 방법은 올리브영 테크블로그를 참조하기 바란다.
신규 재고 시스템 구축을 위한 개발 여정
매장 재고는 물류 시스템과 관리자 등에 의해 바뀔 수도 있지만, 가장 자주 변경되는 이유는 고객의 주문/취소로 인한 것이다. 때문에 재고 변동을 실시간으로 반영하기 위해서는 고객의 주문과 취소로 인해 바뀌는 재고 데이터에 지연이 없게 만들어야 한다.
기존 레거시 시스템에서는 POS 주문 건마다 중계 서버를 거쳐 주문/취소와 관련된 데이터를 DB에 등록해서 재고를 업데이트했다고 한다.
하지만 주문/취소와 관련된 데이터는 재고뿐 아니라 상품, 쿠폰, 회원, 매출 등 전체 정보를 가지고 있다. 이러한 상황이라면 재고 데이터가 빠르게 변동되는 것을 다른 데이터가 막는 상황이 발생할 수 있다.
즉, 빠르고 안정적인 재고 데이터의 변동을 위해서 오프라인 매장 POS에서 발생하는 재고 변동만 따로 관리할 필요가 있다.
올리브영 팀은 이를 위해 Kafka Message Streaming을 채택했다.
Kafka Message Streaming은 대규모 데이터를 실시간으로 수집, 저장, 처리하기 위한 분산 이벤트 스트리밍 플랫폼이다. 쉽게 말해, 아주 많은 양의 데이터를 한 곳에서 다른 곳으로 빠르고 안전하게 옮겨주는 '데이터 고속도로'라고 생각하면 된다.
신규 아키텍처는 AWS에서 제공하는 Kafka 플랫폼인 MSK를 구축했고, POS에서 발생하는 재고 이벤트는 DB를 거치지 않고, 바로 신규 재고 시스템으로 적재가 가능하게 만들었다고 한다.
[출처 - 올리브영 테크블로그]
위 그림과 같은 파이프라인을 만드는 게 목표였지만, Producer 역할을 하는 POS Relay Server(중계 서버)을 만드는 데 문제가 있었다고 한다. 중계 서버는 한정된 레거시 시스템의 리소스를 사용해서 기능을 추가해야 했다.
그 때문에, 최소한의 네트워크 통신이 발생되게 하면서 안정적인 데이터 전송을 보장할 수 있게 만들어야 했고, 이를 위해 Kafka Producer에서 제공하는 비동기 호출 기반의 Batch 전송 방식을 사용했다고 한다.
[출처 - 올리브영 테크블로그]
한마디로 말하면 네트워크 통신을 최대한 줄이기 위해, 메시지를 묶어서 보내는 방식이다.
메시지가 올 때마다 보내면 1개의 메시지 당 1번의 네트워크 통신이 발생하고, 100개의 메시지가 오면 100번의 네트워크 통신이 발생한다. 하지만 메시지를 5개씩 묶어서 보낸다면 네트워크 통신은 20번으로 줄어들 것이다.
또한 메시지가 5개 쌓일 때까지 계속 기다린다면, 재고 데이터를 실시간으로 업데이트 할 수 없을 것이다. 이를 위해 최대 대기시간을 설정하는 것이다.
등장하는 용어들을 정리해 보면 다음과 같다.
buffer.memory : 버퍼의 크기
batch.size : 최대 적재 수 (ex. 5개)
linger.ms : 최대 대기시간 (ex. 10ms)
여기서 처리량을 높이려면 batch.size와 linger.ms 값을 크게 설정하고, 지연 없는 전송이 필요하면 작게 설정하면 된다.
필자는 Kafka를 사용해 본 적도 없고 공부해본 적도 없다. 그래서 파티션과 배치라는 개념이 생소했다.
1) 파티션이 뭘까?
파티션은 Kafka의 Consumer가 메시지를 병렬로 처리하기 위해 나눈 단위다. Kafka에서는 하나의 파티션을 하나의 Consumer만 담당할 수 있다. 즉, 파티션이 1개면 아무리 Consumer를 여러 개 붙여도 실제로 메시지를 읽는 건 1개뿐이다.
파티션을 나눠두면 Consumer들이 각자 맡은 파티션을 동시에 처리하므로 처리량이 올라간다.
2)그럼 파티션 안에 배치가 여러 개인 이유는 뭘까?
배치 하나가 batch.size에 가득 차면 Broker로 전송을 시작한다. 그런데 전송하는 동안에도 POS에서는 계속 새로운 재고 이벤트가 들어온다. 전송 중인 배치에는 새 메시지를 넣을 수 없으니, 새로 들어오는 메시지를 받아낼 다음 배치가 필요한 것이다.
종합 예시
파티션과 배치의 개념을 실생활의 은행을 예시로 생각해보자.
파티션은 은행원이고, 배치는 은행원의 뇌라고 생각하면 좋을 것 같다.
손님이 아무리 많이와도, 은행원이 동시에 여러 명을 상대할 순 없다. 때문에 처리 속도가 느려진다. 하지만, 은행원이 여러 명이라면 병렬로 처리해서 처리 속도가 빨라진다.
또한 은행원은 손님의 업무가 끝나면 관련된 내용이 정리된 이후에 다음 손님을 상대할 수 있다. 은행원이 정리하는 동안 다음 손님은 대기하거나, 은행을 떠난다. 만약 은행원의 뇌가 2개라면, 정리를 하는 동시에 다음 손님이 은행에 찾아온 이유를 들을 수가 있는 것이다.
매장 재고는 매장 코드와 상품 코드로 간단하게 조회가 가능하다. 하지만 매장의 수많은 상품의 재고를 한 번에 조회하는 API도 필요했다. 이 요청을 원활하게 처리하기 위해서는 조회할 상품의 종류만큼의 스레드가 필요했고, 그만큼의 스레드를 생성하는 건 쉽지 않은 일이다.
올리브영 매장에서 취급하는 상품의 종류는 6만 3,973개라고 한다.
https://www.ceomagazine.co.kr/ko-kr/articles/34179
따라서 스레드 수는 부족할 것이고 성능저하로 이어질 것이다. 하지만 트래픽 증가 때문이 아니라, 단순 요청 때문에 스케일 업이나 스케일 아웃을 하는건 맞지 않는 방법이다. 이를 개선하기 위해 올리브영 팀은 Reactive Programing을 고려했다.
Reactive Programing은 비동기 데이터 스트림을 다루는 패러다임이다.
데이터 스트림의 변화에 반응하여 연속적으로 데이터를 처리하고, 데이터 흐름을 선언적으로 정의하는 함수형 프로그래밍 기법이라고 한다.
스레드의 행동에서 가장 큰 차이점을 보인다.
val futures = keys.map { key ->
async { redis.get(key) }
}
val results = futures.map { it.await() }
단순 비동기 처리의 경우 위와 같이 코드를 작성하고, 스레드의 행동은 다음과 같다.
요청 스레드 : Redis 요청 -> 작업 스레드에게 위임 → 다른 일 함
작업 스레드 : Redis 응답 올 때까지 멈춰서 기다림 -> 응답 도착 → 콜백/결과 반환
반면 Reactive Programing은 다음과 같다.
Flux.fromIterable(keys)
.flatMap { redis(it) }
.filter { it.isNotEmpty() }
.map { toStock(it) }
스레드 : Redis 요청 등록 → 다른 일 함 (아무 스레드도 대기하지 않음)
응답 도착 : 선언된 작업 실행 → 다시 반환
즉, 기존의 비동기 처리는 응답이 왔을 때 작업을 수행할 작업 스레드가 호출한 수 만큼 필요하지만, Reactive Programing은 아무도 대기하지 않는다. 대신 응답이 왔을 때, 이벤트를 발생시켜서 작업을 수행시킨다. 이러한 방식으로 많은량의 스레드가 필요한 작업도 적은량의 스레드로 처리가 가능하다.
올리브영 팀은 Reactive Programing을 구현하기 위해 Redisson에서 제공하는 Reactive 전용 클라이언트를 사용했다고 한다.
자세한 구현은 블로그 내용을 참조
신규로 재고 DB를 구축했기 때문에 안정성 확보는 필수였다고 한다. 올리브영 팀이 사용하는 Amazon MemoryDB는 클러스터 구조로 안정성이 보장된 시스템이지만, 장애 상황에 대한 준비는 필요했다.
이를 위해 CircuitBreaker를 사용했다고 한다.
CircuitBreaker에 대한 내용은 다음 포스트를 참조하면 좋을 것 같다.
[올리브영 테크블로그 읽기] Circuitbreaker를 사용한 장애 전파 방지
마지막으로 DataDog을 통한 모니터링을 구축했다고 한다.
재고 데이터 연동 및 처리, 시스템 성능, API 호출 및 응답, Message Queue, CircuitBreaker, 오류 수집 등의 데이터를 모니터링 하면서 이상이 발생하면 슬랙을 통해 알림을 발생시키는 구조를 만들었다고 한다.
추가로 평일의 전국 매장 재고 데이터 처리 현황을 소개해 줬다.
전국 매장 POS의 주문/취소에 따른 재고 이벤트는 Kafka Message Streaming으로 연동되고, 이를 DataDog로 모니터링 하는 것이다.
[출처 - 올리브영 테크블로그]
데이터를 바탕으로 한적한 쇼핑을 원한다면 점심시간(12시 이후)과 퇴근 시간(18시 이후)은 피하라는 꿀팁까지 알려주셨다.
재고의 변동을 시계열 데이터로?! - 한첨지님 (2024.11.15)
올리브영에는 매장과 상품의 조합으로 만들어지는 SKU(Stock Keeping Unit)가 1,000만 개 이상 존재한다. SKU란 쉽게 말해 "강남점의 립스틱 A", "홍대점의 립스틱 A"처럼 매장과 상품을 묶은 단위이다.
올리브영 내부에는 검색 시스템, 추천 시스템, 물류 시스템 등 다양한 타 시스템이 존재하는데, 이 시스템들은 각자의 목적에 맞게 재고 데이터를 활용하기 위해 주기적으로 올리브영 전체 재고를 자신의 시스템에 복사해 저장한다. 이 과정을 색인이라고 한다.
기존 재고 시스템은 Redis Hash 구조로 구현되어 있었으며, 항상 현재 시점의 최신 재고 수량만 보관하고 있었다.
[출처 - 올리브영 테크블로그]
이 구조에서는 "어떤 상품이 언제 변동되었는지"에 대한 이력이 존재하지 않는다. 따라서 타 시스템이 재고를 동기화할 때 마지막으로 가져간 이후 어떤 상품이 변동되었는지 알 수 없기 때문에, 결국 1,000만 건에 달하는 전체 재고 데이터를 매번 가져가는 수밖에 없었다.
이로 인해 타 시스템이 주기적으로 전체 재고를 가져갈 때마다 Redis CPU가 급격히 증가하고 API 응답 속도가 느려지는 문제가 발생하였다. 즉, 문제의 근본 원인은 "변동된 것만 가져갈 수 없는 구조"에 있었고, 이를 구현하기 위해 올리브영 팀은 Redis Stream을 사용하게 됐다.
올리브영 팀은 다음과 같은 목표를 설정했다.
- 처리 순서 보장
- 빠른 조회 속도 보장
Redis Stream은 Redis 5.0에서 도입된 자료구조로, 이벤트나 로그처럼 시간 순서대로 데이터를 쌓아두는 용도로 사용된다. 데이터를 추가만 할 수 있고 중간에 삽입하거나 순서를 바꿀 수 없는 append-only 구조이며, 저장된 데이터는 자동으로 시간 순서가 보장된다.
[append-only 관련 그림]
Stream에 저장되는 데이터 하나하나를 Entry라고 부른다. 각 Entry는 고유한 ID를 가지는데, 별도로 지정하지 않으면 {밀리초 타임스탬프}-{시퀀스번호} 형태로 자동 생성된다.
ex) 1730271083140-0
시퀀스번호는 같은 밀리초 안에 여러 Entry가 생성될 경우 0, 1, 2, ... 순서로 증가한다. 이 ID 덕분에 Entry는 항상 생성된 시간 순서대로 정렬되어 있으며, 이 ID를 기준으로 특정 시간 범위의 데이터를 조회할 수 있다.
Redis Stream의 주요 명령어는 다음과 같다.
| 제목 셀1 | 제목 셀2 |
|---|---|
| XADD | Stream에 새 Entry를 추가한다. |
| XREAD | 특정 ID 이후의 Entry를 순서대로 읽는다. |
| XRANGE | 특정 ID 범위 사이의 Entry를 조회한다. |
| XLEN | Stream에 저장된 Entry의 개수를 반환한다. |
Redis Stream은 Entry의 ID를 Entry가 추가된 시점을 기준으로 자동으로 생성할 수 있다. 따라서 어떤 상황에서도 ID는 항상 이전 Entry보다 크거나 같은 값을 가지며, 이 덕분에 Stream에 쌓인 데이터는 항상 시간 순서대로 정렬되어 있다.
이 점과 XRANGE 명령어를 이용해서 재고 업데이트를 할 때, 이미 가져와서 업데이트한 부분은 제외하고 최신 데이터만 가져올 수 있다.
하지만 XRANGE 명령어를 사용함으로써 발생하는 문제가 있다.
Redis는 inmemory를 데이터 저장소로 사용하여 고성능 데이터 처리가 가능하도록 설계되어 있다. 내부적으로는 싱글 스레드로 돌아가기 때문에 한 명령어의 처리 시간이 길어지면 자칫 장애로 이어질 수 있다.
XRANGE 의 시간복잡도는 O(log(N)+M)으로, 데이터가 많을수록 한 번에 조회하는 것은 매우 위험하다.
N : 스트림에 저장된 Entry 개수
M : 반환될 Entry 개수
올리브영 팀은 이러한 문제를 해결해야 했다.
XRANGE와 비슷하게 KEYS, HGETALL라는 명령어도 시간복잡도가 높다. 하지만 KEYS, HGETALL는 SCAN, HSCAN로 대체해서 사용할 수 있다. SCAN, HSCAN는 스캔 범위(count)를 설정하여 해당 count만큼 끊어서 병렬로 조회한다.
이러한 방식을 참고해서 XRANGE를 사용할 때도 끊어서 병렬로 조회한다면 조회 속도를 개선할 수 있을 것이다.
[출처 - 올리브영 테크블로그]
이때 스캔 범위를 적절히 설정하는 것이 중요하다. 일반적으로는, 조회 범위가 길어 반환할 Entry 수가 많아지면 시간복잡도가 증가하여 한 명령어 당 조회 성능이 저하되지만, 조회 범위가 너무 짧으면 명령어 호출 횟수가 그만큼 증가하여 자원을 낭비할 수 있습니다.
시스템상 데이터가 시간의 흐름에 따라 어떤 분포도를 가지는지 파악하고 그에 맞는 조회 범위를 찾아야 합니다.
시간의 흐름에 따른 스트림에 적재하기 유리한 분포도를 그려보았습니다.
[출처 - 올리브영 테크블로그]
좌측처럼 고르게 분포되어 있는 경우에는 100ms 처럼 짧게 쪼개서 가져가는 게 이득일 것이다. 하지만, 오른쪽처럼 특정 구간에만 분포되어 있는 경우에 100ms 처럼 짧게 쪼개서 가져가면 대부분의 경우에는 조회된 Entry가 0개이기 때문에 무의미한 I/O 요청이 발생하게 되어 비효율적이다. 때문에 더 길게 잡는 게 효율 적일 것이다.
이처럼 분포도를 참고해서 최적의 범위를 찾아 조회 성능을 최적화하는 것이다.
[출처 - 올리브영 테크블로그의 내용을 재구성]
Kafka Streams 기반 EDA 구축 사례: 올리브영 품절 시스템 현대화 프로젝트 - 벙개맨님 (2025.12.15)
매 분기마다 올리브영에는 "올영세일"이라는 큰 행사가 열린다. 평소보다 몇 배는 많은 고객이 몰리고, 그만큼 주문도 폭발적으로 늘어난다.
그런데 이 기간마다 반복적으로 이상한 일이 생겼다고 한다.
"이 상품, 분명히 재고 있다고 표시됐는데 왜 품절이에요?"
"장바구니에 담았는데 갑자기 품절로 바뀌었어요."
고객 입장에서는 당연히 황당한 경험이다. 그런데 이건 단순한 버그가 아니었다. 트래픽이 몰릴수록 품절 정보 자체가 늦게 반영되고 있었고, 심한 경우엔 느려진 품절 조회가 온라인몰 전체 서비스 품질까지 영향을 준 것이다.
고객이 상품 페이지를 열거나 장바구니를 확인할 때마다, 시스템은 각 상품이 품절인지 아닌지를 조회해야 한다. 기존 올리브영은 이 질문을 매번 Oracle DB 안에 내장된 함수를 직접 호출해서 DB 내부에서 계산하는 방식이었다.
단순한 조회가 아니라 내부적으로 복잡한 쿼리를 돌리는 무거운 작업이었으며, 캐시도 없었다.
캐시가 있으면 100명이 동시에 "A 상품 품절이야?"를 물어봐도 DB에는 딱 한 번만 물어보고 나머지 99명에게는 저장해둔 답을 돌려줄 수 있다. 그런데 그게 없었으니, 100명 모두가 물어볼 때마다 DB가 직접 계산해야 했다.
평소에는 무난하게 조회됐을 결과도, 수백만 명의 고객이 동시에 요청하는 "올영세일"기간에는 DB에 과부하가 생겨서 결과가 느리게 계산됐던 것이다.
문제의 본질은 명확했다. 모든 것이 DB 하나에 집중된 구조. 이 단일 의존성이 전체 서비스의 발목을 잡고 있었다.
올리브영 팀이 선택한 해결 방향은 EDA(Event-Driven Architecture)라고 불리는 이벤트 기반 구조였다.
이해를 돕기 위해 기말고사 점수 내기 예시를 들어보자.
전교 회장과 부회장이 기말고사 점수로 치킨 내기를 했다는 소식이 퍼졌다. 이러한 소문 덕에 기말고사가 끝난 후에 누가 이겼을지는 전교생의 관심사이다.
기존 방식은 전교생이 한 명씩 교무실에 찾아가서 "누가 이겼어요?"라고 묻는 것과 같다. 선생님은 그때마다 회장과 부회장의 점수를 꺼내 비교하고 결과를 말해줘야 한다. 전교생이 1,000명이라면? 선생님은 같은 일을 1,000번 반복해야 한다.
반면, EDA 방식은 점수가 나오는 순간 선생님이 방송부를 통해 전교에 결과를 한 번에 알려주는 것과 같다. 이후에는 아무도 교무실에 찾아올 필요가 없다. 선생님(DB)은 딱 한 번만 일하면 된다.
이번엔 올리브영의 구조에 대입해 보자.
기존에는 고객이 상품 페이지를 열 때마다 DB가 품절 여부를 그때그때 계산했다.
EDA로 전환한다면 재고에 변화가 생기는 순간 한 번만 계산하고, 이후 조회는 미리 저장된 결과를 꺼내 오는 방식으로 바뀔 것이다. 즉, 가벼운 단순 조회로 바뀌는 것이다.
이 구조 전환으로 올리브영 팀이 기대한 효과는 크게 세 가지였다.
첫째, DB 단일 의존성 제거다. 기존에는 DB에 문제가 생기면 전체 서비스가 함께 흔들렸다. EDA로 전환하면서 품절 조회 요청이 DB로 직접 가지 않게 됐고, 한 곳의 장애가 전체로 번지는 구조에서 벗어날 수 있었다.
둘째, 실시간 처리와 낮은 결합도다. 기존에는 시스템끼리 데이터를 주고받아야 하면, 배치 방식은 일정 주기마다 데이터를 모아서 처리하다 보니 지연이 생길 수밖에 없었다. 이벤트 기반으로 바꾸면서 재고가 바뀌는 즉시 반영되고, 서비스끼리 직접 연결되지 않아도 되니 변경에도 유연해졌다
셋째, 확장성이다. 새로운 서비스가 필요할 때 기존 구조를 건드리지 않고, 해당 이벤트를 구독하기만 하면 된다. 서비스가 늘어날수록 이 장점은 더 두드러진다.
올리브영 팀은 Kafka CDC(OGG), AWS MSK, Kafka Streams 세 가지 기술을 조합해서 만들었다.
Kafka CDC(Change Data Capture)는 DB의 변경 사항을 실시간으로 감지해서 이벤트로 바꿔주는 기술이다.
1. DB에 변화 발생
2. CDC가 변화 감지
3. 변화가 발생했다는 이벤트 발행
4. Kafka로 전달
이해를 돕기 위해 앞선 기말고사 예시를 이어 가보자.
선생님이 방송부를 통해 전교에 결과를 알리려면, 먼저 점수가 나왔다는 사실을 누군가가 방송실에 전달해 줘야 한다.
점수는 교무실 성적 장부(DB)에 기록된다. 그런데 방송실은 장부가 언제 업데이트되는지 알 방법이 없다. 그래서 방송부원 한 명이 교무실 앞에서 대기하고 있다가, 선생님이 장부에 점수를 적는 순간 선생님께 전교회장과 부회장의 점수를 듣고, 바로 달려가 방송실에 알려준다.
이 방송부원의 역할이 바로 Kafka CDC가 하는 일이다.
CDC는 DB에 변화가 생기는 순간을 감지해서 즉시 Kafka에 이벤트를 전달한다. 장부를 주기적으로 들여다보는 게 아니라, 변화가 일어나는 그 순간을 포착하는 것이다.
Kafka Streams는 Kafka로 전달된 이벤트를 실시간으로 가공해서 의미 있는 결과로 만들어주는 기술이다.
1. Kafka에서 이벤트 수신
2. 비즈니스 조건 판단
3. 결과 이벤트 발행
4. 다음 목적지로 전달
앞선 기말고사 예시를 이어 가보자.
방송부원(CDC)이 선생님께 결과를 듣고 방송실로 달려왔다. 그런데 방송부원이 가져온 건 그냥 "전교 회장과 부회장의 점수가 각각 100점과 99점이래요!"라는 사실뿐이다.
방송실에서는 이 정보를 받아서 "그래서 누가 이긴 거야?"를 직접 판단해야 한다. 회장 점수와 부회장 점수를 비교해서 높은 사람을 가려내고, 최종적으로 "회장이 이겼습니다"라는 방송 원고를 만들어야 한다.
이 방송실에서 결과를 판단하고 원고를 만드는 역할이 바로 Kafka Streams가 하는 일이다.
Kafka Streams는 Kafka에서 이벤트를 받아 비즈니스 조건을 판단하고, 결과를 다음 목적지로 전달한다. 주기적으로 데이터를 모아서 처리하는 게 아니라, 이벤트가 들어오는 그 순간 즉시 판단하는 것이다.
그렇다면 왜 Kafka Streams를 선택했을까?
가장 먼저 올리브영의 상품들은 각각 관리 책임이 다르다는걸 이해해야한다.
올리브영의 상품은 크게 세 종류다.
직매입 상품은 이미 전담 시스템(Inventory Service)이 있어서 그 시스템이 알아서 Kafka에 이벤트를 발행해줬다.
문제는 위수탁 상품과 예약/한정 상품이었다. 이 두 유형은 전담 시스템이 없었기 때문에 재고 변경을 감지하고 이벤트로 바꿔줄 무언가가 필요했다.
이러한 이유와 다음 세가지 구체적인 이유를 바탕으로 Kafka Streams로 선택한 것이다.
첫째, 구현이 간결하다. Kafka Streams는 filter, map 같은 기본 함수를 제공한다. "재고가 0이면 품절"이라는 비즈니스 조건을 복잡한 설정 없이 짧은 코드로 표현할 수 있어 개발 속도가 빠르고 유지보수도 쉬워진다.
둘째, 애플리케이션 구조가 단순해진다. 기존에는 이벤트를 받는 Consumer와 결과를 내보내는 Producer를 별도 애플리케이션으로 분리해서 관리해야 했다. Kafka Streams는 이 둘을 하나의 애플리케이션 안에서 처리할 수 있어 불필요한 복잡도가 사라진다.
셋째, 별도 인프라가 필요 없다. Spark Streaming 같은 다른 스트림 처리 기술은 별도의 클러스터나 스케줄링 도구가 필요하다. 반면 Kafka Streams는 라이브러리 형태라 기존 애플리케이션 서버에 얹어서 바로 쓸 수 있다. 이미 Kafka를 운영 중이라면 추가 인프라 비용 없이 도입이 가능하다.
결국 Kafka Streams의 선택은 단순히 "좋은 기술이라서"가 아니었다. 이미 갖춰진 인프라 위에서 가장 빠르고 단순하게 문제를 해결할 수 있는 현실적인 선택이었다.
[출처 - 올리브영 테크블로그의 내용을 바탕으로 예상]
최종 구조에서는 품절 여부를 DB에서 계산하지 않고 Kafka Streams에서 계산한다. 이때 상품이 품절로 판정되면 결과를 OpenSearch에 저장한다. 이후 상품의 품절 상태는 DB가 아니라 OpenSearch에서 조회하게 된다.
1. 사용자의 주문 or 상품재고 추가
2. DB에 재고 수 반영
3. OGG가 DB 변동을 감지해서 Kafka에 이벤트 발행
4. 변동 이벤트가 발행되면 Kafka Streams가 해당 상품이 품절인지 계산
5. 계산결과 품절이라면 품절 Kafka에 품절 이벤트 발행
6. Worker는 품절 이벤트이가 발행되면 OpenSearch에 품절 데이터를 저장
1. 사용자가 상품을 조회
2. 재고 서비스가 OpenSearch에서 해당 상품의 품절 데이터를 조회
[출처 - 올리브영 테크블로그]
[출처 - 올리브영 테크블로그]
개선 후 처음 맞이한 올영세일에서 DB 호출 수가 86% 감소했다.
개선 전 일주일 동안 2.34G(23억 4천만) 건 이었던 호출이, 개선 후 237M(2억 3천7백만) 건으로 줄었다.
실제 올리브영 테크블로그 글에는 Kafka를 어떻게 구현했는지에 대한 코드와 이 문제를 해결하면서 탐구한 Kafka의 조인 기능과 코파티셔닝 기능, 그리고 Kafka Streams를 사용할 때의 주의점도 소개가 되고 있다.
궁금하다면 올리브영 테크블로그에 올라온 원본 글을 참고하길 바란다.
이렇게 올리브영 테크블로그에 올라와 있는 재고 관련된 개선 글들을 읽어보았다.
올리브영의 옴니채널에 중심에 있는 재고 도메인에 대해 알아보는 게 재밌었다. 그 중에서 지식이 없었던 Kafka를 어떻게 사용하는지를 배울 수 있어서 재밌다고 생각한다.
혹시 이 글을 읽으며 잘못 이해했거나 놓친 부분이 있다면 언제든지 짚어주시면 정말 환영이다. 그 과정 또한 또 하나의 배움이 될 것이고, 이 글을 더 나은 방향으로 다듬는 계기가 될 것이라고 생각한다.
성장 과정을 읽으면서 올리브영의 재고팀에서 일 할 수 있다면 재밌고 다양한 경험을 할 수 있을 것 같다는 생각이 들었다.
마지막으로 좋은 내용을 테크 블로그를 통해 공유해 주신 올리브영의 [올여우, 한첨지, 벙개맨]님께 감사하다고 전하고 싶다. 언젠가 이 글을 보시게 된다면, 덕분에 올리브영의 재고 시스템의 개선 과정을 재밌게 이해할 수 있었다고 꼭 전하고 싶다.
신규 재고 시스템 구축을 위한 개발 여정 (2023.10.04) - 올여우님
재고의 변동을 시계열 데이터로?! (2024.11.15) - 한첨지님
Kafka Streams 기반 EDA 구축 사례: 올리브영 품절 시스템 현대화 프로젝트 (2025.12.15) - 벙개맨님