출고준수율 개발기: 정의부터 구현 및 성능 개선까지

Youngsik Yoon
29CM TEAM
Published in
16 min readJul 15, 2024

안녕하세요, 29CM 주문배송팀에서 일하고 있는 무신사 백엔드 엔지니어 윤영식 입니다. 저희 팀은 여러분께서 주문하신 상품을 더 빨리 받아볼 수 있도록 출고 속도를 높이기 위한 여러 기능을 개발하고 있습니다. 오늘은 그중에서도 출고 일정이 잘 지켜지고 있는지 측정할 수 있는 지표인 출고준수율을 정의하고, 이를 계산하는 기능을 개발하며 발생한 문제를 해결한 내용을 공유하고자 합니다.

출고준수율이란?

상품의 출고 속도를 높이는 방법의 하나로 주문된 상품에 대하여 계획된 일정대로 출고가 잘 되고 있는지 먼저 파악하고자 했습니다. 따라서 저희 팀은 출고 일정이 잘 지켜지고 있는지를 측정할 수 있는 지표인 출고준수율을 아래와 같이 정의하였습니다.

출고준수율 정의

출고준수율은 간단히 말해 출고기한(상품의 출고를 완료해야 하는 날짜) 내에 얼마나 출고가 되었는가에 대한 비율입니다. 따라서 출고준수율이 100%에 가까우면 가까울수록 출고 일정이 잘 지켜지는 상품으로 판단할 수 있습니다. 이렇게 계산된 출고준수율은 파트너가 사용하는 관리메뉴 곳곳에서 사용하고 있습니다. 이를 통해 저희 개발팀은 파트너가 출고 일정이 잘 지켜지지 못하는 상품에 대해 인지하고 이에 대응할 수 있도록 다양한 정보를 제공하고자 노력하고 있습니다.

출고준수율 정보 제공 화면 예시 (예시 데이터로 실제 정보와 무관)

개발 초기 단계에서 마주한 소통 문제

출고준수율 개발 초기 단계에서는 이해관계자 간의 계산 정책 소통이 원활하지 않았습니다. 위에서는 출고준수율을 단순한 공식으로 설명했지만 실제로는 좀 더 복잡한 정책이 포함되어 있었기 때문입니다. 특히 주문 품목의 상태에 따라 집계 대상 포함 여부가 달라지고, 출고기한을 넘긴 출고 지연과 관련된 계산 정책이 복잡하여 이를 정의하고 이해하는 데 어려움이 있었습니다. 이에 따라 이해관계자마다 계산 정책을 서로 다르게 이해하는 문제가 발생했습니다.

우리 같은 방법으로 계산한게 맞지?… 😂

정책 소통을 위한 엑셀 시뮬레이션 모형 작성

이러한 문제를 해결하고자 엑셀 기반의 계산 시뮬레이션 모형을 작성했습니다. 특정 기간 집계 데이터로 가상의 주문 데이터를 생성하고, 시간이 흐름에 따라 날짜별로 달라지는 출고준수율을 셀 수식을 통해 계산할 수 있도록 문서를 작성했습니다. 이를 통해 계산 정책이 실제로 어떻게 적용되는지 확인할 수 있었으며, 모두가 동일한 방식으로 출고준수율을 이해할 수 있게 되었습니다.

출고준수율 계산 정책 소통을 위해 작성한 엑셀 시뮬레이션 모형 일부

첫 배포 그리고 찾아온 조회 성능 이슈

출고준수율 계산 정책이 확정된 후 개발은 순조롭게 진행되었고, 출고준수율을 포함한 출고 관련 정보를 파트너 대시보드에서 조회할 수 있는 기능을 문제없이 배포했습니다.

초기 배포 제품에서 제공한 출고준수율 정보 (QA 데이터로 실제 데이터와 무관)

그러나 주문량이 많은 브랜드나 특정 기간 이벤트로 주문량이 증가한 브랜드의 경우, 출고준수율 조회 응답 시간이 최대 5초를 넘는 이슈가 발생했습니다. 이에 따라 출고준수율 조회 API의 성능 개선이 필요했습니다.

첫번째 개선 방법: 캐시 적용을 통한 조회 속도 개선

처음으로 적용한 조회 속도 개선 방법으로 캐시를 사용했습니다. 캐시를 사용한 이유는 아래와 같습니다.

  • 출고준수율 계산 시 분모로 사용되는 출고 예정 상품 수는 쿼리 속도가 느리고 자주 변경되지 않는 값이었습니다. 따라서 캐시 퍼지 로직 없이 적절한 TTL을 설정하여 캐시를 적용하면 데이터 정합성 문제없이 조회 속도를 개선할 수 있다고 판단했습니다.
  • 29CM에서는 글로벌 캐시로 레디스를 사용하고 있어, 간단한 어노테이션 코드 추가로 출고준수율 계산 결과를 쉽게 캐싱할 수 있었습니다.
// 출고 예정 상품 수 조회 캐시 적용 코드

/**
* 출고준수율 - 출고 예정 상품 수 조회
*/
@Cacheable(
cacheManager = "cacheManagerWith3HourTtl",
cacheNames = "deliveryComplianceOnTimeShippingCount:V1",
key = """
#condition.getItemId()
.concat('::')
.concat(#condition.getComplianceBaseAt().toString())
.concat('::')
"""
)
@Override
public Long getDeliveryComplianceCountOnTimeShippingCount(
final DeliveryCompliancePredicateCondition condition
) {
return deliveryComplianceRepository
.getDeliveryComplianceCountOnTimeShippingCount(condition);
}
  • 출고준수율 API는 파트너가 관리자 기능을 사용하기 위한 메인 화면인 대시보드에서 호출되었습니다. 관리자 기능 사용을 위한 진입점으로 반복 호출되는 경우가 많을 것으로 판단하여 캐시를 적용했습니다.

이와 같은 이유로 출고준수율 계산 시 일부 값에 캐시를 적용했고 주문량이 많은 브랜드에 대해서 전체적인 조회 속도를 기존 5초에서 1.5초로 개선할 수 있었습니다.

이때까지만 해도 쉽게 해결된 줄 알았습니다… 😭

지속되는 데이터 정합성 문제

캐시를 통해 출고준수율 조회 성능을 개선했지만 데이터 정합성 이슈가 지속적으로 제기되었습니다. 저희의 예상과 달리 출고 예정 상품 수는 빈번하게 변경되었고, 많은 파트너 분들이 출고준수율 조회 기능을 사용해 상품 출고나 주문 상태 변경 시마다 출고준수율을 확인하는 경우가 많았습니다.
(저희 팀이 개발한 기능에 대해 파트너께서 많은 관심을 가져주셔서 너무 감사했습니다. 🙏)

따라서 TTL 시간을 점차 짧게 설정하는 방식으로 대응했지만 데이터 정합성 이슈는 지속되어 제기되었고, 캐시 퍼지 로직을 구현해 대응하기에는 제약사항이 존재하여 결국 캐시를 제거하고 조회 속도 개선을 위한 근본적인 문제 해결을 모색하기로 했습니다.

부적절한 캐싱은 데이터 정합성 문제를 야기할 수 있습니다. 😂

두 번째 개선 방법: 쿼리 병렬 호출을 통한 속도 개선

두 번째 개선 방법으로는 출고준수율을 조회하기 위해 순차적으로 실행되는 쿼리를 병렬 호출하여 전체 호출시간을 줄이고자 했습니다. 출고준수율을 계산하기 위해서는 네 종류의 Count 쿼리를 통해 얻은 값이 필요했습니다. 기존 코드는 아래와 같이 각 4종류의 쿼리를 순차적으로 호출하여 값을 가져와 최종 결과를 계산했습니다.

// 출고준수율 조회 병렬 처리 전 코드
private DeliveryComplianceRate getDeliveryComplianceDashboardCountInfo(
final DeliveryCompliancePredicateCondition condition
) {
final var onTimeShippingCount = deliveryComplianceDashboardReader.
getDeliveryComplianceCountOnTimeShippingCount(condition);

final var onTimeShippedCount = deliveryComplianceDashboardReader
.getDeliveryComplianceOnTimeShippedCount(condition);

final var delayedShippingCount = deliveryComplianceDashboardReader
.getDeliveryComplianceCountDelayedShippingCount(condition);

final var delayedShippedCount = deliveryComplianceDashboardReader
.getDeliveryComplianceDelayedShippedCount(condition);

return DeliveryComplianceRate.of(onTimeShippingCount, onTimeShippedCount,
delayedShippingCount, delayedShippedCount);
}

네 종류의 쿼리는 서로 독립적으로 실행될 수 있었기에 다른 쿼리의 응답을 기다리는 시간은 낭비라고 생각했고 이를 병렬로 호출하도록 변경했습니다.

// 출고준수율 조회 병렬 처리 후 코드
private DeliveryComplianceRate getDeliveryComplianceDashboardCountInfo(
final DeliveryCompliancePredicateCondition condition
) {
final var onTimeShippingCountFuture = CompletableFuture.supplyAsync(
() -> deliveryComplianceDashboardReader.getDeliveryComplianceCountOnTimeShippingCount(condition), executor);

final var onTimeShippedCountFuture = CompletableFuture.supplyAsync(
() -> deliveryComplianceDashboardReader.getDeliveryComplianceOnTimeShippedCount(condition), executor);

final var delayedShippingCountFuture = CompletableFuture.supplyAsync(
() -> deliveryComplianceDashboardReader.getDeliveryComplianceCountDelayedShippingCount(condition), executor);

final var delayedShippedCountFuture = CompletableFuture.supplyAsync(
() -> deliveryComplianceDashboardReader.getDeliveryComplianceDelayedShippedCount(condition), executor);

// 비동기 병렬 호출 값 대기
final var onTimeShippingCount = onTimeShippingCountFuture.join();
final var onTimeShippedCount = onTimeShippedCountFuture.join();
final var delayedShippingCount = delayedShippingCountFuture.join();
final var delayedShippedCount = delayedShippedCountFuture.join();

return DeliveryComplianceRate.of(onTimeShippingCount, onTimeShippedCount,
delayedShippingCount, delayedShippedCount);
}

병렬 호출 처리 후 이전 대비 단기간에 더 많은 쿼리가 호출되어 데이터베이스에 부하를 줄 수 있을 것으로 판단했고, 따라서 QA 환경에서 테스트를 진행하였습니다. 다행히 병렬 처리 후에도 그 전과 데이터베이스 부하에는 큰 차이가 없었습니다.

병렬 호출 처리 전/후 DB Load 모니터링(QA 환경) 빨간색 박스가 병렬 처리 적용 후의 측정값

이를 통해 주문수가 많은 상위 브랜드를 대상으로 평균 1,000ms~1,500ms 소요되었던 조회 시간을 600ms 이내로 감소시킬 수 있었습니다.

쿼리 병렬 호출의 한계

쿼리 병렬 호출을 통해 출고준수율 조회 속도를 개선했지만 결국 주문량이 증가한다면 무거운 쿼리를 동시에 호출하는 상황이 발생하여 조회 속도가 느려질 것으로 판단했습니다. 따라서 추가로 MaterializedView 테이블을 구성하여 쿼리 속도를 개선하고자 했습니다.

MaterializedView 란?

MaterializedView(이하 MV)란 뷰의 일종으로 쿼리의 결과를 물리적으로 저장합니다. 일반 뷰와는 달리 쿼리 결과를 테이블에 저장하기 때문에 매번 쿼리를 실행할 필요 없이 데이터를 빠르게 조회할 수 있습니다. 주로 여러 테이블을 조인하는 복잡한 쿼리를 자주 실행해야 할 때, 해당 테이블의 결과를 MV에 미리 저장하여 쿼리 성능을 향상할 수 있습니다. 단, 일반 뷰와는 달리 MV는 데이터를 저장해야 하므로 정기적으로 갱신하여 최신 데이터를 유지해야 합니다.

세 번째 개선 방법: MaterializedView를 통한 쿼리 성능 개선

세 번째 개선 방법으로는 MV를 활용했습니다. 출고준수율 계산 시 사용하는 값을 조회하는 쿼리에는 주문 시 상품 정보를 저장하는 테이블과 출고 정보를 저장하는 테이블을 조인하는 조건이 있습니다.

  • table_item: 주문 시 상품의 스냅샷 정보를 저장하는 테이블로 저장된 데이터가 많아 조인이 오래 걸린다.
  • table_delivery: 상품의 출고 관련 정보를 저장하는 테이블로 데이터가 많고 table_main과 일대다 관계 맺고 있어 조인이 오래 걸린다.
-- 출고준수율 쿼리 일부
select count(table_main.main_no)
from table_main
inner join orders.table_item on table_main.item_no = table_item.item_no
-- ... 추가 조인 쿼리 생략 ...
where
-- 상품 정보 조인
table_item.insert_timestamp >= '2024-06-01T00:00+09:00'
and table_item.brand_no in (12345)
and table_item.type = 1

-- 출고 정보 조인
and not (exists (select 1
from orders.table_delivery table_delivery
where table_delivery.main_no = table_main.main_no
and table_delivery.is_return = 'F'
and (table_main.status in (1,2,3,4,5))
and table_delivery.insert_timestamp <= '2024-06-30T23:59:59.999999+09:00'))
-- 추가 조건 생략
;

실행계획 분석 시, 해당 두 테이블을 조인하는 과정에서 많은 비용(Cost)가 발생하는 것을 알 수 있습니다.

출고준수율 쿼리 실행계획 시각화

두 테이블에는 이미 많은 인덱스가 생성되어 있었기에 인덱스를 통한 쿼리 튜닝은 한계가 있었고 따라서, 두 테이블의 조인 결과를 미리 저장하는 MV 테이블을 만들어 무거운 조인으로 발생하는 비용을 줄여 쿼리 속도를 개선하고자 했습니다.

MV 테이블 스키마 예시

MV 테이블에 데이터를 적재하고 갱신하는 로직은 그 복잡성을 낮추기 위해 다음과 같은 전제를 만족하게끔 개발했습니다.

  • MV 테이블의 컬럼은 출고준수율 외의 기능에서도 사용할 수 있도록 범용성을 갖되 필요한 컬럼만 최소한으로 갖도록 설계한다.
  • MV 테이블의 데이터는 주 테이블과 일대일 관계가 되도록 설계한다.
    (일대다 관계의 경우, 출고준수율 계산에 사용할 대표 행 하나를 저장)
  • MV 데이터의 생성과 갱신 로직은 가능하다면 출고준수율을 담당하는 서비스에만 존재하도록 한다.
  • MV 데이터의 생성과 갱신은 데이터 정합성을 위해 원천 데이터가 갱신되는 비즈니스 로직의 동일 트랜잭션에서 진행되도록 한다.

이를 통해 최소한의 코드 구현으로 복잡성을 낮추어 MV를 구성할 수 있었고 쿼리 조인 비용도 낮추어 기존 대비 출고준수율 조회 API의 응답속도를 50% 정도 개선할 수 있었습니다.

  • 주문 수 상위 5개 브랜드 대상 평균 응답속도를 500ms에서 250ms로 약 50% 개선
  • 당시 주문량의 2.5배를 상정한 데이터를 기준으로 평균 응답속도 6,239ms에서 2,879ms로 약 50% 개선

마치며

현재 출고준수율 조회 API는 전체 요청의 90% 이상(p90)을 200ms 이내로 응답하고 있습니다. 😄

출고준수율 조회 API 응답속도 분포도

이번 프로젝트는 동료들과 협업하는 방법부터 API 조회 속도를 줄이기 위한 기술적인 방법들까지 경험해 볼 수 있는 의미 있는 시간이었습니다. 미리 문제를 예측하고 한 번에 해결하지 못한 점이 아쉬움으로 남았지만, 동료들과 함께 단계적으로 문제를 개선하는 과정에서 많은 것을 배울 수 있었습니다. (어려움을 마주할 때마다 함께 고민하고 도움을 준 주문배송팀 모두에게 감사드립니다. 🙏)

반복적으로 동일한 값을 응답하는 API나 무거운 조인 조건을 사용하는 쿼리가 있다면 캐시 적용, 병렬 호출, 그리고 Materialized View 테이블 구성을 통해 속도를 개선해 보시는 건 어떠실까요?

이상으로 글을 마치도록 하겠습니다. 지금까지 읽어주셔서 감사합니다! 🙇‍♂️

29CM CAREER

함께할 동료를 찾습니다.

29CM는 ‘고객의 더 나은 선택을 돕는다’라는 미션으로 출발했습니다. 우리는 우리만의 방식으로 콘텐츠를 제공하며, 브랜드와 고객 모두에게 대체 불가능한 커머스 플랫폼을 만들어가고 있습니다. 이 미션을 이루기 위해 우리는 흥미로우면서도 복잡한 문제들을 해결하고 있습니다. 만약 우리와 함께 이 문제들을 해결해 보고 싶다면, 주저하지 말고 29CM에 합류하세요!

🚀 29CM 채용 페이지 : https://corp.musinsa.com/ko/

📌채용이 완료되면 공고가 닫힐 수 있으니 빠르게 지원해 주세요!

--

--