로그인 전후에도 끊기지 않는 장바구니 경험 설계

이번 글에서는 로그인 전후에도 장바구니 경험이 자연스럽게 이어지도록, 저장 구조와 병합 방식을 설계한 과정을 다룹니다.

설계 배경

로그인이 쇼핑 흐름을 끊으면 안 된다

커머스 서비스에서는 로그인 여부와 상관없이 장바구니가 그대로 유지돼야 사용자가 끊기지 않고 쇼핑을 이어갈 수 있다고 생각했습니다. 사용자가 로그인하기 전 담아둔 상품이 로그인 순간 사라지면, 다시 탐색 → 재선택 → 재담기 과정을 거치게 되고 이 구간에서 이탈 가능성이 높아집니다.

처음에는 로그인 사용자에게만 장바구니 기능을 제공하는 방식도 고민했지만, 비회원 주문이나 나중의 기능 확장까지 고려하면 비로그인 상태에서도 장바구니를 유지할 수 있어야 했습니다.

그래서 이번 프로젝트에서는 로그인 여부와 상관없이 장바구니 경험이 이어지도록 저장소를 분리하고, 로그인 순간 두 장바구니를 합치는 구조를 목표로 설계 했습니다.


장바구니 저장소 분리 전략 (localStorage ↔ Server)

로그인 상태에 따라 장바구니 저장 위치를 다르게 구성하였습니다.


비로그인 사용자
  • 비로그인 사용자는 서버 식별 정보가 없으므로 로컬 스토리지(Redux Persist) 저장
로그인 사용자
  • 여러 기기에서 동일한 장바구니를 유지해야 하므로 서버(Supabase DB) 저장


저장 방식이 달라지더라도 UI 코드가 영향을 받지 않도록, 두 저장소를 하나의 인터페이스로 다룰 수 있는 훅을 구현했습니다. 컴포넌트는 장바구니의 저장 위치를 몰라도 되도록, 동일한 형태로 데이터를 사용할 수 있게 하였습니다.

// 컴포넌트는 저장소가 로컬인지 서버인지 몰라도 됩니다
const { cart, addToCart, updateQuantity, removeItem } = useCartSource();

로그인 시 장바구니 병합 설계

로그인 순간, 로컬 장바구니와 서버 장바구니를 합쳐야 합니다. 이 과정에서 몇 가지 결정이 필요했습니다.

왜 서버 장바구니를 먼저 조회하는가?

병합 로직의 첫 단계는 서버 장바구니를 조회하는 것입니다.

const { data: existingCart } = await supabase
  .from("cart")
  .select("product_id, size, quantity")
  .eq("user_id", userId);
  • 서버에 이미 있는 상품 → 수량을 더해야 함
  • 서버에 없는 상품 → 새로 추가해야 함

즉, 서버 장바구니는 병합의 기준 데이터 역할을 합니다. 로컬 장바구니는 이 기준에 맞춰서 합치는 구조입니다.

배열 비교 방식은 왜 문제가 되는가?

처음에는 로컬 아이템마다 서버 장바구니 배열을 순회하며 동일 상품을 찾는 방식을 떠올렸습니다.

serverCart.find(
  (item) =>
    item.product_id === localItem.id && item.size === localItem.selectedSize
);

하지만 이 방식은 로컬 아이템(N) × 서버 아이템(M)을 매번 비교해야 하므로, 아이템 수가 늘어날수록 병합 비용이 빠르게 증가하는 문제가 있습니다. 동작은 하지만, 확장성 측면에서 적합하지 않은 구조였습니다.

Map 구조를 사용한 병합 방식

그래서 서버 장바구니를 먼저 Map 구조로 변환했습니다. Map을 사용하면 동일 상품 존재 여부를 즉시(O(1)) 확인할 수 있고, 서버 데이터는 한 번만 순회하면 되기 때문입니다.

// 서버 장바구니 → Map 변환
const cartMap = new Map();

existingCart?.forEach((item) => {
  const key = `${item.product_id}:${item.size}`; // 서버 키
  cartMap.set(key, item.quantity);
});

이 Map의 역할은 하나입니다. "이 상품이 서버에 이미 있나?"를 답해주는 역할입니다.

왜 key를 product_id:size로 설계했는가?

장바구니에서 같은 상품이라도 사이즈가 다르면 다른 항목으로 취급해야 합니다.

"shoe123:M"; // 신발 M 사이즈
"shoe123:L"; // 신발 L 사이즈

이 기준을 분리하지 않으면 사이즈가 다른 상품의 수량이 하나로 합쳐지는 문제가 생길 수 있습니다. 그래서 여러 속성을 매번 따로 비교하는 대신, 상품과 옵션을 하나의 값으로 묶어 product_id:size 형태로 장바구니 항목을 구분했습니다.

로컬 장바구니 순회 및 병합

이제 로컬 장바구니를 순회하며 Map에서 서버 수량을 조회한 뒤, 서버 수량 + 로컬 수량을 합산하여 반영하도록 구성했습니다.

const key = `${item.id}:${item.selectedSize}`; // 로컬 키

const serverQuantity = cartMap.get(key) ?? 0;
const mergedQuantity = serverQuantity + item.quantity;

여기서 ?? 0 사용하는 이유는, Map에 key가 없으면 undefined가 반환되기 때문입니다. 서버에 없는 상품은 수량을 0으로 처리해서 로컬 수량만 반영하도록 했습니다.

로그인 시 로컬 → 서버 병합 및 중복 방지

로그인 순간 로컬 장바구니를 서버로 병합하는 과정에서 동일 상품이 서로 다른 저장소에 존재할 경우 중복 레코드가 생성되는 문제가 있었습니다.

이 문제를 해결하기 위해 다음 두 가지를 기반으로 병합 로직을 구성했습니다.


1. DB UNIQUE 제약조건 적용

Supabase에서 (user_id, product_id, size) 조합을 UNIQUE로 설정했습니다.

-- 같은 사용자가 같은 상품의 같은 사이즈를 중복으로 가질 수 없음
UNIQUE(user_id, product_id, size)

2. Upsert로 중복 없이 병합

  • 이미 서버에 존재하는 상품 → 수량만 업데이트(update)
  • 서버에 없는 상품 → 새로 추가(insert)


구현 결과

  • 로그인 시 로컬 장바구니가 서버 장바구니와 합쳐지도록 설계하여 쇼핑 흐름이 끊기지 않도록 했습니다.
  • UNIQUE 제약조건과 Upsert를 적용해 동일한 상품이 여러 번 생성되는 상황을 방지했습니다.

러닝 포인트

장바구니는 단순해 보이지만, 실제로는 로그인 전환 흐름까지 고려한 상태 설계가 필요하다는 점을 배웠습니다. 비로그인에서 로그인으로 넘어가도 사용 흐름이 자연스럽게 유지되도록 하기 위해, 내부적으로 다양한 처리 과정을 정리해야 했습니다.

이번 경험 덕분에, 기능을 바로 만들기보다 사용자가 어떤 흐름으로 움직이는지 먼저 그려보고 구조를 잡는 과정이 훨씬 중요하다는 점을 다시 생각하게 되었습니다.