장바구니
- 실측
- 테스트
- 병목은 프로시저가 아닌 커넥션 풀(리틀의 법칙 교차검증)
- 프로시저 기반 가격 동기화
- 기업별 계약가
- 재고 연동
개요
B2B 전용몰 장바구니 기능. 단순 상품 담기가 아닌, 기업별 계약가·재고·행사를 실시간 반영하는 동적 장바구니를 설계했다.
장바구니 진입 시 DB 프로시저가 모든 상품의 가격/재고를 일괄 갱신하여, 사용자에게 항상 최신 정보를 보여준다.
왜 이 설계가 필요했는가 (Problem)
B2C 장바구니와 다른 B2B의 3가지 핵심 난제 — 단순 FK 참조 모델·애플리케이션 갱신으로는 해결 불가능했다.
이 3가지를 동시에 해결하기 위해 DB 프로시저 + 장바구니 스냅샷 컬럼 의 조합으로 결정했다.
테이블 설계
설계 배경 — 왜 5개 컬럼으로는 부족했나
초기 장바구니 테이블은 단순한 FK 참조 테이블이었다.
-- 초기 설계: 단순 FK 참조
장바구니(장바구니 ID, 기업, 사용자, 상품, 수량)
B2B 전용몰의 장바구니는 B2C와 다른 요구사항이 있었다.
| B2B 요구사항 | 문제 | 해결 → 추가된 컬럼 |
|---|---|---|
| 기업마다 계약 가격이 다름 | 상품의 판매가를 그대로 쓸 수 없음 | 판매단가 · 할인가 · 기업 계약가 |
| 주문 시점의 가격을 확정해야 함 | 장바구니 → 주문 전환 시 가격이 변동될 수 있음 | 정가 · 공급가 · 부가세 · 소비자가 |
| 행사/쿠폰 할인액을 분리 추적 | 할인 출처별 금액을 구분해야 정산 가능 | 행사 할인 · 자동 쿠폰 · 수동 쿠폰 금액 |
| 공급사별 묶음배송 그룹핑 | 장바구니 화면에서 공급사별로 상품을 그룹핑 표시 | 공급사 · 공급사명 · 묶음배송 그룹 |
| 프로시저 계산 결과 저장 | 프로시저가 계산한 가격/수량/상태를 장바구니에 기록 | 스냅샷 컬럼 전체 (가격/상품/공급사) |
이 요구사항들을 반영하면서 5개 컬럼짜리 참조 테이블이 가격 · 상품 · 공급사 스냅샷을 담는 수십 개 컬럼으로 확장되었다.
장바구니 최종 구조
| 분류 | 담는 것 | 역할 |
|---|---|---|
| 기본 식별 | 기업 · 사용자 · 상품 | 기업+사용자+상품 조합으로 장바구니 항목 식별 |
| 가격 스냅샷 | 판매단가 · 할인가 · 정가 · 공급가 · 부가세 | 프로시저가 갱신한 최신 가격 저장, 주문 생성 시 가격 확정용 |
| 상품 스냅샷 | 상품명 · 이미지 · 판매처 상품코드 | 상품명·이미지를 장바구니 자체에서 렌더링 |
| 공급사 스냅샷 | 공급사 · 공급사명 · 사업자번호 | 공급사별 그룹핑 표시에 활용 |
| 옵션 | 옵션 조합(JSON) · 옵션 · 옵션명 · 옵션 금액 | JSON으로 다중 옵션 조합 저장, 정규화된 참조도 병행 |
| 할인/쿠폰 | 행사 할인 · 자동 쿠폰 · 수동 쿠폰 | 행사할인과 쿠폰을 분리하여 금액 추적 |
| 그룹핑 | 묶음배송 그룹 · 선택형 그룹 | 묶음배송 그룹, 선택형 그룹 지원 |
| 사용자 상호작용 | 선택 여부 · 수량 · 메모 | 선택 상태, 수량, 메모를 개별 관리 |
왜 스냅샷 컬럼이 필요했나
스냅샷 컬럼 없이도 기능 자체는 구현할 수 있다. 장바구니 조회 시 상품 · 기업별 상품(계약가) · 기업 테이블을 매번 JOIN하거나, 주문 생성 시 그 시점에 직접 상품/가격을 조회하면 된다.
그러나 스냅샷 컬럼과 프로시저는 세트다.
| 스냅샷 없이 (애플리케이션에서 직접) | 스냅샷 + 프로시저 (현재 설계) |
|---|---|
| 갱신 로직을 애플리케이션에서 구현 → 상품당 3회 쿼리 (N+1 문제) |
프로시저가 DB 내부에서 일괄 갱신 → 1회 CALL로 완결 |
| 프로시저가 계산 결과를 저장할 곳이 없음 → 임시 테이블 또는 OUT 파라미터로 반환 |
프로시저가 장바구니 스냅샷 컬럼에 직접 UPDATE → 별도 반환 로직 불필요 |
| 주문 생성 시 가격을 다시 조회해야 함 → 장바구니와 주문 사이 가격 불일치 가능 |
주문 생성 시 장바구니 스냅샷을 그대로 사용 → 장바구니에서 본 가격 = 주문 가격 보장 |
인덱스 설계
| 인덱스 기준 | 용도 |
|---|---|
| 기업 | 기업별 장바구니 조회 |
| 사용자 | 사용자별 장바구니 조회 |
| 상품 | 상품 기준 장바구니 존재 확인 |
핵심 설계 결정 — 장바구니 갱신 프로시저
검토했던 대안들 (Analyze)
“장바구니 진입 시 모든 상품의 최신 가격·재고 동기화”를 위한 5가지 방법을 비교했다.
| 대안 | 장점 | 기각 사유 |
|---|---|---|
| 1. 매번 조회 시 JOIN (실시간 계산) | 스냅샷 불필요, 항상 최신 | 장바구니 조회 빈도 높음 → 매번 상품 · 기업별 상품 JOIN 수행 비용. 주문 생성 시점과 가격 불일치 위험 여전히 존재 |
| 2. Redis 가격 캐싱 (TTL 5분) | 빠른 조회, 캐시 적중률 높음 | 가격 변동 시점과 캐시 만료 사이 정합성 깨짐. 기업별 계약가 캐시 키 폭증(M기업 × N상품). Redis SPOF |
| 3. 비동기 스케줄러 갱신 (5분 주기) | 장바구니 조회 부하 0 | 사용자가 본 시점과 갱신 시점 차이 → 가격 불일치 동일 발생. “왜 어떤 사람만 이전 가격?” 디버깅 난해 |
| 4. 애플리케이션 + 배치 로딩 (Batch Fetch) | JPA 유지하면서 N+1 회피 | 여전히 외부 호출 발생. 가격 계산·재고 조정 로직이 Java에 분산 → 주문 생성 프로시저와 로직 중복 |
| 5. DB 프로시저 + 장바구니 스냅샷 | 1회 RT, 가격·재고·계약가 DB 내부 완결, 주문 생성 시 스냅샷 재사용 | DB 종속성, 프로시저 디버깅 비용 (수용 가능) |
비교: 애플리케이션 레벨 vs DB 프로시저
❌ 애플리케이션 레벨로 구현했다면
장바구니 아이템 N개를 순회하며 상품당 3회 쿼리(상품 조회 → 계약가 조회 → UPDATE)가 발생한다.
// 애플리케이션 레벨로 갱신한다면 (가정한 의사코드)
장바구니 = 사용자 · 기업의 장바구니 조회 // 1회
for 항목 in 장바구니: // N개 순회
상품 = 상품 최신 정보 조회(항목) // N회
if 상품 없음 또는 판매중지: 항목 삭제 표시, 다음 항목으로
계약가 = 기업별 계약가 조회(항목, 기업) // N회
할인가 = 할인 계산(정가, 할인율) // 메모리에서 계산
수량 = 재고 · 최대 주문수량에 맞춰 조정
장바구니 스냅샷 갱신(할인가, 계약가, 수량) // N회
// 총 쿼리: 1 + 3N (상품 20개 → 61회)
✅ DB 프로시저로 구현한 결과
단 1회 CALL로 DB 내부에서 커서 기반 일괄 처리한다.
-- 장바구니 갱신 프로시저 (요지)
FOR 항목 IN (사용자 · 기업의 장바구니) LOOP -- 커서로 DB 안에서 순회
상품 정보 조회 · 판매 가능 검증 -- DB 내부 호출, 네트워크 왕복 없음
기업별 계약가 조회
할인가 = FLOOR(정가 × (1 − 할인율 / 100))
수량 = 재고 · 최대 주문수량에 맞춰 조정
UPDATE 장바구니 SET 판매단가 · 할인가 · 계약가 · 수량 …
END LOOP
성능 비교 (장바구니 상품 20개 기준)
| 항목 | 애플리케이션 레벨 | DB 프로시저 | 개선 효과 | 측정 방법 |
|---|---|---|---|---|
| 네트워크 라운드트립 | 30회 (IN 절 배치)167회 (상품당 조회) |
4회 | 배치 대비 26회 절감 | ✅ 실측 완료 — MySQL 전역 카운터 델타 |
| DB 커넥션 점유 | 쿼리마다 획득/반환 반복 | 단일 커넥션으로 완결 | 커넥션 풀 경합 제거 | ✅ 실측 완료 — 부하 시 활성 20/20 · 대기 75 |
| SQL 파싱 오버헤드 | 61회 개별 파싱 | 프로시저 1회 (사전 컴파일) | 파싱 비용 제거 | ⚠️ 미측정 — 파싱 비용만 분리해 재지 않았다 |
| 응답 시간 | 반복 RT 누적 | p95 78ms (무경합) / 600ms (동시 100 VU) | — | ✅ 실측 완료 (k6, 아래 Result) |
| 상품 50개 시 | 왕복은 라인 수와 무관한 상수 ( IN 절로 묶으면 30회 고정) |
4회 고정 | 격차는 일정하게 유지 | ✅ 실측 — 제대로 짜면 앱도 N+1 이 아니다 |
프로시저 동작 흐름
프로시저 핵심 로직
| 단계 | 처리 내용 | 참조 |
|---|---|---|
| 1. 상품 검증 | 삭제·판매중지 상품 → 장바구니에서 삭제 표시 | 상품 |
| 2. 계약가 조회 | 기업별 계약 판매단가 조회 | 기업별 상품(계약가) |
| 3. 할인 계산 | FLOOR(정가 × (1 − 할인율 / 100)) 할인율 범위 0~100 보정 |
— |
| 4. 재고 조정 | 재고관리 상품: 재고 초과 시 재고 수량으로 조정 최대 주문수량 초과 시 조정 |
상품 |
| 5. 결과 반환 | 갱신 · 제거 · 수량 조정 건수 | — |
설계가 만든 결과 (Result)
측정 조건
| 항목 | 값 |
|---|---|
| 대상 | 장바구니 조회 API — 요청마다 장바구니 갱신 프로시저 실행 |
| 환경 | 로컬 단일 인스턴스 · MySQL 8.4 컨테이너 · HikariCP 최대 20 |
| 데이터 | 사용자 120명이 각자 장바구니 20개 |
| 부하 | 2 VU 워밍업 → 10 → 50 → 100 (2분 45초), 생각시간 없음 |
| 계측 | Micrometer → Prometheus (SLO 버킷 100ms~3s) + Grafana |
실측 결과
| 지표 | 실측 | 조건 |
|---|---|---|
| 응답시간 p95 | 78 ms | 무경합 (2 VU), 장바구니 20개 |
| 응답시간 p50 | 57 ms | 동일 |
| p95 | 600 ms | 동시 100 VU |
| p99 | 943 ms | 동시 100 VU |
| 처리량 | 146 req/s | 동시 100 VU |
| 실패율 | 0 % | 24,043 요청 |
장바구니 20개의 가격·기업계약가·재고를 1회 CALL 로 갱신하는 데 p95 78ms.
병목은 프로시저가 아니라 커넥션 풀이었다
부하 구간의 서버측 지표가 병목 위치를 특정했다.
| 지표 | 값 | 해석 |
|---|---|---|
| HikariCP 활성 | 20 / 20 | 풀 포화 |
| HikariCP 대기 | 75 | 동시 100 요청 중 75개가 커넥션 대기 |
| MySQL CPU | 1.3 % | DB 는 놀고 있음 |
| 행 잠금 대기 증가 | 53건 | 잠금 경합 아님 |
트랙 순도 가드 검증
재측정 — 풀 크기만 바꿔 A/B (2026-08-30)
| 지표 | 풀 5 | 풀 20 | 배수 |
|---|---|---|---|
| 처리량 | 115.1 req/s | 280.5 req/s | 2.44배 |
| 총 요청 | 18,989 | 46,285 | — |
| p50 | 191.6 ms | 83.6 ms | 2.29배 |
| p95 | 813.3 ms | 291.3 ms | 2.79배 |
| p99 | 1,129.3 ms | 429.0 ms | 2.63배 |
| 실패율 | 0.00 % | 0.00 % | — |