카트 개발 연대기

컬리 카트는 어떤 모습으로 성장하고 있을까?

인사말

마켓컬리에서 김진실이라는 개발자를 수식하는 단어가 있습니다. 바로 카트, 카트입니다. profile.png

다른 많은 업무 중에도 유독, 이 도메인만큼은 아직도 저와 함께하고 있고, 저에게 늘 연구의 대상인데, 이 도메인을 볼 때마다 항상 생각나는 구절이 있습니다.

“나는 알파와 오메가요. 처음이자 마지막이요. 시작과 마침이라.”

아시나요? 모든 주문의 시작은 카트에서 계산대로 상품을 내려놓는 것부터 시작합니다. 라라벨 프레임워크로 제공되는 이 도메인은 작년 총 4회에 거쳐 지금의 모습이 되었습니다. 그래서 한번 이야기해볼까 합니다. 지금, 이 순간의 카트를 말입니다.

20210721_1.png (카트 이미지는 현재 컬리에서 판매 중인 컬리 퍼플 박스로 사용했습니다.)

[용어 사전집]

  • TAM : 주소지 기반 권역 정보 조회를 위한 시스템
  • 쇼룸 : 상품 정보를 취합하여 고객에게 바로 제공할 수 있도록 준비해두는 서비스
  • cluster center code : 물류 센터 코드
  • vsms : 가상 재고 관리 시스템
  • dsms : 상품 전시 관리 시스템 (판매, 진열 등을 관리)
  • eSCM : 물류 관리 파트너 포털

카트가 반드시 해결해야 하는 목표

2020 카트가 가장 먼저 목표로 했던 것은, 바로 비즈니스 관점의 서비스 확장이었습니다. 당시 컬리는 김포물류센터를 공사 중이었고, 그 과정에서 사용자가 지정한 주소지 기반으로 물류센터의 재고를 알아야만 했습니다. 우리는 도깨비방망이가 아니기에, 카트의 설계 및 개발을 몇 가지 큰 목표를 지정해 가져가기로 했습니다.

비즈니스 측면: 유저의 주소에 기반한 상품 노출

  • 이전에는 물류센터가 하나라 지역 상관없이 한 개의 물류센터를 봤다면, 이제는 물류센터 담당 권역에 맞춰 지역 기반으로 출고지를 확인할 것

  • 주소지에 포함되는 물류센터 코드를 기반으로 재고, 전시, 할인 API를 조회해 상품 데이터를 생성할 것

  • 주문서로 이동하기 전에 고객에게 현재 카트 아이템의 구매 가능 여부를 확인할 수 있도록 할 것

개발 측면: 기술부채 상환과 장애가능성 감소

  • 구 컬리몰(레거시 쇼핑몰) 기반의 시스템에서 벗어날 것

  • 기존의 테이블을 개선하여, 비효율적인 상품 구조를 끊어낼 것

  • DDD 컨셉으로 카트가 조회하는 상품, 할인 도메인 등을 분리할 것

  • 카트의 데드락을 해결할 것

  • 카트의 응답속도를 개선할 것

컬리 카트의 성장기

각각의 카트를 개발할 때, 컨셉에 맞춰 카트의 이름을 지었습니다.

1세대 Legacy Cart

1세대 카트라 불리는 Legacy Cart는 기존 카트 코드의 Legacy 정책을 계승한 채로, 라라벨 프레임워크에 맞춰 코드 리팩토링을 진행했습니다. Legacy Cart는 신규 카트 설계와 리팩토링을 시작하기 위한 전 초석 같은 것이었습니다.

Controller에 약 3,000줄가량 밀집되어 있던 코드를 분해해, 다음 세대의 카트가 매끄럽게 등장 할 수 있도록 Interface를 만들고, 불필요한 코드와 사용하지 않는 로직을 걷어냈습니다. 또 실제 동작하는 function들을 Service 단위의 method로 분해하고, 정리하는 과정이었습니다. 이 과정에서 카트는 Eager Loading을 도입하고 카트를 만들 때 필요한 데이터를 foreach 통해 마다마다 조회하던 내용을 relation 구조를 통해 코드 개선을 진행한 것입니다.

이렇게 만들어진 카트는 Cart Response 객체를 통해, 카트를 구성하는 상품구조를 관리하는 기반이 됩니다.

20210721_2.png ??? : 정책을 계승 중입니다. 아버지

코드 리팩토링이 핵심이다!

  • Cart Interface 정의

  • Cart Controller의 코드를 Service 단위로 분리.

  • Cart라는 도메인이 몇 세대에 거쳐 리팩토링 될 것을 고려해 Manager Pattern을 도입

  • 응답 정보를 Cart Response 라는 객체를 통해 상품구조를 관리

  • 가상재고 도메인과 Cart의 연동

2세대 Stack Cart

Dual write를 고안하기 위해 설계된 이 카트는 가장 긴 시간을 투자해 가장 빠르게 사라진 불운한 카트입니다. Stack Cart가 추구하는 방향성은 뒤에 등장할 Modern Cart로 가는 과정에서 충격을 완화하기 위한 역할이었습니다. 바로 데이터의 마이그레이션입니다. 고객의 상품 정보는 기존의 담고 있던 것 그대로 노출 시켜주어야 하는데, 트리구조의 Legacy는 선형 구조의 Modern과 그 형태부터가 달랐습니다. 당연히 로직을 구성하고 있는 데이터 역시도 큰 차이가 있어, table migration이 쉽지 않았습니다. 그래서 생각합니다. 동일한 데이터를 순간마다 동시에 적재한다면, 일정 시간이 지난 후에 자연스럽게 Modern Cart로 넘어갈 준비를 하지 않을까 ?

하지만 Stack Cart는 실패하고 맙니다. 왜일까요?

20210721_3.png (??? : 너흰 아직 준비가 안 됐다. )

원인은 바로 앞서 카트가 해결해야 한다고 언급했던 데드락 때문입니다. 1세대 카트 이전부터 카트는 데드락이라는 원인만 아는 불치병을 앓고 있었습니다. 원래도 데드락이 많은 기존 로직과 (데드락이 해결되지 않은) 신규 로직이 Dual write하는 과정에서 크게 앓아누워 버린 것입니다. 특히나 주문 골드타임이 오면 저는 카트 모니터링으로 광증을 앓았는데, 당시엔 오늘 무사히 보내도 잠이 들면 꿈에 카트 장애가 생길 정도로 담당 개발자로 죄책감에 많이 시달렸습니다.

두 카트를 함께 운영하기 위해 Dual Write를 지원하자!

  • 신규 테이블과 이전 테이블 사이의 원활한 데이터 이동 -> 실패

  • 신규 카트 테이블은 유저의 주소지를 기반 물류센터 조회

  • 구 Table 구조는 Modern 구조로 출력을, 신규 Table 구조는 Legacy 구조로 출력을 …

새로운 문제: 롤백할 수 없는데 데드락이 발생한다

2세대 카트 배포 후 2주일, 저는 개발 리더님께 불려가게 됩니다. 카트가 너무 느려서, 서비스 운영이 너무 힘들다. 1세대 카트로 돌아가거나, 3세대 카트로 가야 한다. 이미 앱이 배포된 상태로, 1세대 카트로 돌아가게 되면 넘어야 할 산이 너무 많았습니다. Endpoint 규격이 바뀌어서, 1세대 카트로 온전히 롤백이 불가능한 상황이었기에 저는 결정을 해야만 했습니다… 아니 강력하게 주장했습니다. 가즈아 3세대 Modern Cart

20210721_8.png

카트는 왜 데드락을 유발하죠?
  • row는 1개인데 그 row를 차지하고 싶어하는 세션은 n개. 서로 머리채 잡고 쥐어뜯기의 연속이었습니다.
  • 카트 cleansing은 내가 가지고 있는 최신 카트 한 개만 남기고, 나머지 카트는 삭제하는 작업입니다.
  • 그 과정에서 만약 동일 계정을 사용하고 있는 두 개의 플랫폼이 동시 접속하면, 서로의 카트를 뺏겠다고 경합을 벌입니다. (승부의 세계는 냉혹하기 때문에 서비스에 장애를 줍니다)
카트는 어떻게 장애 지표를 확인하나요?
  • 컬리는 데이터독(APM)을 통해 개발자도 지표를 볼 수 있었는데 매일 밤 10~11시면 API의 성능 지표들이 일정 트래픽을 넘기지 못하고 사망했습니다.
왜 롤백을 할 수 없는 상황인가?
  • 비즈니스 측면에서 이미 앱이 배포되었고, 카트는 앱 내의 네이티브를 사용하고 있었기 때문에, 바뀐 API 규격을 다시 롤백하기 위해서는 강제 업데이트가 필요한 상황이었습니다. 강제 업데이트는 리스크가 크다고 여겼기 때문에 지양하고자 했습니다.

3세대 Modern Cart

위의 Stack Cart가 가장 긴 시간 개발했지만, 가장 빠르게 사라진 불운의 카트라면, Modern Cart는 이미 Stack Cart 배포 시 어느 정도 기반은 완성된 상태였기 때문에, 약간의 리팩토링을 통해 배포가 가능했습니다.

( 리팩토링했던 이유는, Stack Cart 배포 당시 Strict 한 마이그레이션 구조로 갑작스레 코드를 바꿔야 했는데, 이 부분 때문에 동작이 부자연스러워졌기 때문입니다. )

Modern Cart의 가장 큰 고민거리는 바로 주문서였습니다. Stack 카트를 2세대로 준비했던 것도 이 이슈 때문이었는데, 당시 주문서는 개편 전 단계였기 때문에, Modern Cart의 데이터 구조를 받을 수 없는 상태였습니다. 다른 것보다도, 주문서로 진입 시 상품 리스트 구조가 구 카트를 보고 있기 때문에, 이 부분을 조율할 방법을 생각해내야 했습니다. 당시 제가 준비한 방법은 주문하려는 상품 리스트를 주문서 진입 전에 구 카트에 부분 마이그레이션 하는 방식을 채택했습니다.

(마지막_최종.jpg) 데이터 마이그레이션으로 레거시 카트와 작별하자

  • 구 카트의 데이터를 보고 있는 주문서와 카트를 어떻게 연결할 것인가

  • 구 카트가 가지고 있는 데이터에 대한 마이그레이션

데드락을 아직 완벽히 해결하지 못했다?!

사실 우리가 궁극적으로 원했던 비즈니스 모델의 종착지였기 때문에, 저는 Modern Cart만 배포된다면, 모든 것이 해결될 거로 생각했습니다. 저의 착각이었습니다. 이전보다 Deadlock은 덜했지만, 특정 트래픽이 오면 Cart가 트래픽을 견디지 못하고 휘청거렸습니다.

이제는 데드락을 궁극적으로 해결해야 할 때가 왔습니다.

20210721_9.png

20210721_4.png

4세대 Multi Cart

저는 Stack Cart 구현 당시부터 Cart Item 구조를 sharing 구조로 가야 한다고 생각했습니다.

왜냐하면, 현재 카트는 비회원의 경우 1 session 1 cart, 회원의 경우 n Session 1 cart의 구조였는데, 이 n개의 세션을 1개의 카트로만 관리하려고 하니 지속해서 데드락이 생겼습니다. 회원의 경우, 자신의 계정을 여러 개의 플랫폼에서 동시에 로그인 할 수 있는 문제가 있었고, 이 경우 카트는 1개의 카트만 유지하기 위해, n개 세션을 억지로 한 개의 카트로 유지하려고 하는 Clean Up 정책이 있었습니다.

(**유저에게는 노출되지 않지만, 카트 내부에서 필수적으로 운영되고 있던 정책은 한 계정 내에서 생성될 수 있는 각각의 카트끼리 경합을 벌이도록 하는 구조였습니다)

결론은 어떻게 경합을 피할 것인 가의 문제였습니다. 원래 싸움도 양보하는 쪽이 이기는 거라고 합니다. 각각의 카트가 서로의 카트 아이템을 뺏으려 하는 것이 아닌, 서로의 것을 공유한다면 어떨까요? 쿼리는 좀 더 복잡해질지도 모르겠습니다.

내가 가진 카트를 검색하기 위해 카트 id와 카트 item 사이의 관계를 알아야 하니까요. 추가로 Multi Cart에서는 그전에 가지고 있던 100원딜 상품에 대한 검증과 그 검증을 위한 쿼리를 리팩토링하는 작업을 진행했습니다. 이 역시도 장애 지표 중의 하나였고, 3세대 카트 이전부터 벼르고 있던 작업이었습니다.

데드락의 원인은 카트 cleansing

  • 아이템 구조를 어떻게 공유할 것인가

  • 카트 cleansing 제거하기 ( 1개 세션 유지 → n개 세션 유지 ) : 이전 카트는 지우고 오직 최신 카트만 남겨주지 않겠니?

20210721_10.png

가족끼리 같은 계정으로 컬리몰을 사용하는 경우

언니 카트 : 야, 너 담은 거 다 내놔.

동생 카트 : 언니나 가진 거 다 내놔.

언니 카트 : 이게! 넌 위아래도 없냐?

동생 카트 : 고작 생성일 좀 몇 시간 빠르다고 유세 부리지 마라! 최신 업데이트 일은 내가 제일 우선순위거든 ?!

(멀티 카트 등장)

??? : 싸우지 마. 그냥 너네 가진 거 같이 서로 알고 공유하면 되잖아?

20210721_6.png

겸사겸사 기술부채도 하나 더 상환하자

  • 카트 100원딜 상품 검증 로직 리팩토링 (더 빠른 카트로 가자)

  • 내 카트 아이템 개수를 표현할 때 캐시 레이어를 넣자

결론: Modern Cart와 Multi Cart 비교

A 카트 ( 현재 내 카트 ) . B 카트 (동일 계정의 다른기기 카트 )

  Modern Cart Multi Cart
추가 1,2 1,3
수량 변경 2,2 2,3
삭제 3,2 3,3
리스트 4,2 4,3
체크아웃 5,2 5,3
  1. B 카트에 있는 모든 cart item을 A 카트로 강제 Update
  2. A 카트가 해당 상품을 가졌는지 확인
  3. 없으면 추가
  4. 있으면 수량만 update 한다.
  1. A와 B 카트 중에 해당 상품을 가지고 있는지 확인
  2. 없으면 A 카트 아래로 추가
  3. 둘중에 하나라도 있다면, 그 카트 아래 상품에 상품 수량을 더함
  1. B 카트에 있는 모든 cart item 을 A 카트로 강제 Update
  2. 수량 업데이트
  1. A와 B 카트가 가지고 있는 해당 상품을 확인
  2. 총 수량 정보 확인
  3. 카트_아이템.id 기반으로 총수량 정보를 반영하여 수정 (누구의 카트든 상관 없음)
  1. B 카트에 있는 모든 cart item 을 A 카트로 강제 Update
  2. 대상 item.id 를 기반으로 삭제
  1. 카트_아이템.id 기반으로 넘어온 상품들을 모두 삭제
  2. id로 삭제한 상품 리스트 중 A와 B 에 남아있는 동일한 상품이 있는지 확인 후 같이 삭제
  1. B 카트에 있는 모든 cart item 을 A 카트로 강제 Update
  2. 카트_아이템 테이블에서 cart_id 로 list

A와 B 카트 에 담긴 모든 상품을 list (group by product, parent_product, 수량은 sum(quantity))

  1. B 카트에 있는 모든 cart item 을 A 카트로 강제 Update
  2. 카트_아이템 테이블에서 cart_id 로 list
  3. 해당 상품들을 주문 중으로 변경
  1. 카트_아이템.id 기반으로 넘어온 상품번호를 A와 B 카트에서 전부 추출
  2. 대상이 되는 product_no + parent_product 기반의 상품을 list화
  3. 해당 상품들을 주문 중으로 변경

앞으로 등장할 5세대 카트의 목표

(부제 : 카트는 다시 한번 도약을 꿈꾼다)

4세대 카트에서 카트의 진화가 끝이라면 얼마나 좋을까요. 현재 Multi Cart가 이전 세대들보다 오랫동안 관리되고 있는 것은 사실입니다. 하지만, 아직 주요 장애 요소로 지목되는 것 역시 외면하지 못하는 현실입니다. 그래서 저는 5세대 카트를 설계하고 있습니다.

20210721_11.png

  • 카트가 오직 카트의 기능으로, 다른 도메인의 장애 지표가 되지 않도록.

  • 불필요한 DML을 줄여서, Write DB 리소스를 너무 많이 할당하지 않도록.

  • 대규모 트래픽이 발생하더라도 원활한 처리는 물론이거니와 빠른 처리가 가능해지도록.

올해 말에 5세대 카트로 이 글을 읽으신 분들과 한 번 더 만나고 싶은 게 저의 바람입니다.

20210721_5.png

** 본문에 등장하는 너굴희 캐릭터는 글쓴이의 창착캐릭터임을 밝힙니다.