복잡한 장바구니 로직 읽기 — 분기·외부 API·배송비 계산 패턴
레거시 e-commerce 코드베이스에서 흔히 마주치는 유형이 있다. 서비스 메서드 하나가 150줄을 넘기며, 조건문이 중첩되고, 외부 API 호출이 루프 안에 박혀 있고, 변수가 어디서 초기화됐는지 위아래를 뒤져야 하는 코드. 이번 글에서는 그런 장바구니 DTO 조립 메서드를 해부하며, 어떤 로직이 어떤 이유로 그렇게 작성됐는지 단계별로 읽어낸다.
메서드가 하는 일
한 문장으로 정리하면: 장바구니 상품 목록을 받아, 각 상품의 가격·배송·결제 정보를 외부에서 조회해 조합한 뒤, 최종 응답 DTO를 만들어 반환한다.
입력은 List<CartProduct>이고 출력은 CartDto. 그 사이에서 일어나는 일이 복잡할 뿐이다.
전체 흐름은 다섯 단계로 나뉜다.
1. 특수 상품 타입 선처리 (루프 밖)
2. 전체 상품 순회 - 결제 방식 조회
3. 전체 상품 순회 - 상품 정보 조회 및 가격 계산
4. 배송비 계산 (외부 계산기 위임)
5. 공통 결제 수단 필터링 및 DTO 조립
1단계: 메인 루프 전 선처리
메서드 맨 앞에서 특이한 분기가 하나 있다. 일반 상품과 다른 방식으로 가격을 조회해야 하는 특수 유형(예: 외부 기관 연동 견적 상품)이 존재하기 때문이다.
cartProductList.get(0)의 특정 필드가 있는지 먼저 확인하고, 있으면 일반 루프와는 별도로 전용 API를 호출해 단품 정보를 업데이트한다.
// 특수 유형 선처리 — 루프 진입 전
if (!CollectionUtils.isEmpty(cartProductList)
&& !StringUtils.isEmpty(cartProductList.get(0).getSpecialProdNo())) {
// Case A: 단품 수준에 식별자가 있는 경우 → 개별 단품 순회
if (!StringUtils.isEmpty(items.get(0).getExternalId())) {
for (CartProductItem cartItem : items) {
ProductItem prodItem = externalService.fetchItem(
cartItem.getProductItem().getExternalId(), ...
);
cartItem.setProductItem(prodItem);
// 수량 × 단가로 금액 계산
}
}
// Case B: 식별자가 상위 객체에 있는 경우 → 첫 번째 단품에만 적용
else {
ProductItem prodItem = externalService.fetchItem(
cartProductList.get(0).getExternalId(), ...
);
items.get(0).setProductItem(prodItem);
}
}
이 패턴이 루프 밖에 있는 이유는 해당 타입이 항상 리스트 첫 번째 항목에만 오는 비즈니스 제약 때문으로 추정된다. 구조적으로 이상하지만, 도메인 제약이 코드에 그대로 박힌 케이스다.
잠재적 버그:
items.get(0)호출 전 리스트가 비어있는지 확인하는 가드가 없다. 런타임에서IndexOutOfBoundsException이 발생할 수 있는 지점이다.
2단계: 상품 순회 — 결제 방식 처리
메인 루프 안으로 들어오면 첫 번째 작업은 결제 방식 조회다. 전시 카테고리 번호(dispCatNo)가 유효한 경우에만 실행된다.
간편결제 중복 제거
여러 간편결제 수단이 시스템에 등록되어 있지만 사용자에게는 하나의 "간편결제" 옵션으로만 노출해야 하는 요구사항이 있다. 이를 플래그 하나로 처리한다.
boolean simplePayChk = false;
for (PayWay payWay : payWayList) {
if (isSimplePayType(payWay.getCode())) {
if (!simplePayChk) { // 처음 만난 간편결제만 추가
realPayWayList.add(payWay);
simplePayChk = true;
}
// 이후 간편결제 코드는 모두 skip
} else {
realPayWayList.add(payWay); // 일반 결제 수단은 모두 추가
}
}
결제 수단별 카운트 집계
결제 방식마다 "몇 개 상품에 이 수단이 가능한가"를 집계한다. 이 카운트는 5단계 필터링에서 사용된다.
// 특정 결제 수단(적립금, 포인트)은 카운트 제외
if (!isExcludedPayType(payWay.getCode())) {
payWayCntMap.merge(payWay.getCode(), 1, Integer::sum);
}
외부 제휴 지원 내역 조회
특정 제휴 결제 방식이 있는 경우, 해당 장바구니에 연결된 지원 내역(지원율, 지원 금액)을 추가로 조회한다.
if (hasPartnerPayType && cartProduct.getCartNo() != 0) {
SupportInfo support = supportService.fetchSupport(cartNo);
if (support != null) {
// 특정 상태 코드 → 지원 금액 0으로 초기화
// 승인 상태 → totalSprtPrc에 합산
}
}
3단계: 상품 순회 — 상품 타입별 정보 조회
루프 안에서 두 번째 분기는 일반 상품 vs 견적 상품이다.
| 구분 | 기준 | 조회 방식 |
|---|---|---|
| 일반 상품 | qttnProdNo 없음 | 상품 기본 + 배송 + 이미지 + 단품 각각 조회 |
| 견적 상품 | qttnProdNo 있음 | 견적 전용 API 단일 호출 |
일반 상품 처리
N+1 구조가 여기 있다. 상품마다 4개의 서비스 호출이 발생한다.
// 상품 기본 정보
ProductBase prodDto = productService.fetchBase(prodParam);
if (prodDto == null) { continue; } // null이면 이 상품 전체 skip
// 배송비 상세 + 배송 정책
DlprcDtl dlprcDtl = deliveryService.fetchDeliveryFee(prodNo);
DlvPolcDtl dlvPolcDtl = deliveryService.fetchDeliveryPolicy(prodNo);
// 상품 이미지
ProductImage image = productService.fetchImage(prodNo);
그 다음 단품 목록을 다시 순회한다.
for (CartProductItem cartItem : cartProduct.getCartProductItems()) {
ProductItem prodItem = productItemService.fetchItem(itemParam);
cartItem.setProductItem(prodItem);
Long price = prodItem.getRealPrice();
// 옵션 미사용 상품은 상위 기본가 사용
if ("N".equals(cartProduct.getProductBase().getOptUseYn())) {
price = cartProduct.getProductBase().getRealPrice();
}
cartItem.setProdPrc(cartItem.getOrdQty() * price);
}
설계 포인트: 옵션 사용 여부에 따라 가격 기준이 달라진다. 이 분기를 놓치면 옵션 없는 상품의 금액이 틀린다.
견적 상품 처리
전용 API 하나로 끝나며 배송비는 계산기에 위임(quoteMode = true).
ProductBase prodDto = productService.fetchQuote(qttnProdNo, qttnReqNo, bidProdNo);
cartProduct.setProductBase(prodDto);
cartProduct.setProdPrc(prodDto.getUnitBuyQty() * prodDto.getUnitPrice());
quoteMode = true;
공통 그룹핑 패턴 (중복 코드)
두 분기 모두 마지막에 연관 업체 번호(relcopNo) 기준으로 productMap에 그룹핑한다. 코드가 동일하게 두 번 존재한다. Java 8 이상에서는 아래로 합칠 수 있다.
// 현재 코드 (중복)
if (productMap.get(relcopNo) != null && productMap.get(relcopNo).size() > 0) {
productMap.get(relcopNo).add(cartProduct);
} else {
List<CartProduct> list = new ArrayList<>();
list.add(cartProduct);
productMap.put(relcopNo, list);
}
// 개선 버전
productMap.computeIfAbsent(relcopNo, k -> new ArrayList<>()).add(cartProduct);
4단계: 배송비 계산 위임
루프가 끝나면 배송비 계산은 전담 클래스에 위임한다. Strategy 패턴으로 어댑터를 주입하는 구조다.
DlvrPriceCalculator calc = new DlvrPriceCalculator();
DlvrCalcResult<CartProduct> result = calc.calculate(
cartProductList,
new CartProductDlvrAdapter(), // Strategy: 도메인 객체 → 계산기 인터페이스 매핑
DlvrCalcContext.builder()
.memberType(mbrTpCd)
.quoteMode(quoteMode) // 견적 모드 여부에 따라 내부 로직 분기
.build()
);
totalDeliveryFee = result.getBaseDlprcTotal();
totalJejuFee = result.getJejuDlprcTotal();
totalRemoteAreaFee = result.getRemtpDlprcTotal();
prodByRelcopList = result.getProdByRelcopListSafe();
기본 배송비, 제주 추가 배송비, 도서산간 추가 배송비가 분리된다는 점이 중요하다. UI에서 각각을 따로 표시해야 하기 때문이다.
5단계: 공통 결제 수단 필터링 및 DTO 조립
2단계에서 집계한 payWayCntMap을 이제 사용한다. 결제 수단이 모든 상품에 공통으로 존재할 때만 최종 목록에 포함시킨다.
List<String> finalPayWayList = new ArrayList<>();
for (Map.Entry<String, Integer> entry : payWayCntMap.entrySet()) {
// 등장 횟수 == 전체 상품 수 → 모든 상품에 가능한 결제 수단
if (entry.getValue() == prodByRelcopList.size()) {
finalPayWayList.add(entry.getKey());
}
}
이 로직의 의도는 명확하다. 상품 A는 신용카드+계좌이체, 상품 B는 신용카드만 가능하다면, 최종적으로 신용카드만 노출해야 한다.
주의: 비교 기준이
prodByRelcopList.size()다. 이는 배송비 계산 후 재구성된 리스트 기준으로, 원본cartProductList와 크기가 다를 수 있다. 또한 3단계에서prodDto == null로continue된 상품은 payWayCntMap에 집계가 안 되므로 필터 결과가 달라질 수 있다.
마지막으로 DTO를 조립한다.
cartDto.setPayWayCdList(finalPayWayList);
cartDto.setCartProductList(prodByRelcopList);
cartDto.setTotalProdPrc(totalProdPrc);
cartDto.setTotalDlvPrc(totalDeliveryFee);
cartDto.setTotalPayPrc(totalProdPrc + totalDeliveryFee - totalSupportPrc); // 지원금 차감
cartDto.setTotalJejuDlvPrc(totalJejuFee);
cartDto.setTotalRemtpDlvPrc(totalRemoteAreaFee);
코드에서 배울 점
이 메서드는 기능은 동작하지만 몇 가지 구조적 취약점을 가진다.
방어 코드 부재: get(0) 접근 전 isEmpty() 가드가 없는 지점이 있다. 실제로 비어있는 리스트가 들어오면 런타임 예외가 발생한다.
N+1 호출: 상품 루프 안에서 상품마다 4~5회의 서비스 호출이 일어난다. 카트에 상품이 많을수록 지연이 선형으로 증가한다. 성능 문제가 생기면 배치 조회로 전환하는 것이 우선 개선 포인트다.
중복 코드: productMap 그룹핑 블록이 두 분기에 동일하게 존재한다. computeIfAbsent로 통합할 수 있다.
필터 기준의 미묘함: 결제 수단 필터링이 prodByRelcopList.size() 기준인데, 이 값이 어디서 결정되는지(배송 계산기 내부)를 모르면 버그를 추적하기 어렵다. 이런 암묵적 의존 관계는 주석이나 명시적 검증으로 보완하는 것이 좋다.
레거시 코드를 읽는 가장 좋은 방법은 "왜 이렇게 썼을까"를 복원하는 것이다. 대부분의 이상한 코드는 이상한 비즈니스 요구사항에서 출발한다. 코드를 비판하기 전에 그 배경을 먼저 이해하는 것이 실력이다.