이미지 로딩 중...

재고 및 수요 예측 지표 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 16. · 7 Views

재고 및 수요 예측 지표 완벽 가이드

재고 관리와 수요 예측에 필요한 핵심 지표들을 Python과 Polars로 계산하고 분석하는 방법을 배웁니다. 실무에서 바로 활용 가능한 회전율, 재고일수, 예측 정확도 등의 지표 계산법을 다룹니다.


목차

  1. 재고회전율(Inventory Turnover) - 재고가 얼마나 빨리 판매되는지 측정
  2. 재고일수(Days Inventory Outstanding) - 재고가 판매되기까지 걸리는 평균 일수
  3. 평균 예측 오차(MAE - Mean Absolute Error) - 수요 예측의 정확도 측정
  4. 예측 정확도(Forecast Accuracy) - 예측이 얼마나 맞았는지 백분율로 표현
  5. 안전재고(Safety Stock) - 수요 변동과 리드타임을 고려한 최소 재고량
  6. 재주문점(Reorder Point) - 언제 발주해야 하는지 결정
  7. 수요 변동성(Coefficient of Variation) - 수요 예측의 난이도 측정
  8. 재고 회전일수(Days Sales of Inventory) - 현재 재고가 판매되는 데 걸리는 일수
  9. 채찍 효과 지표(Bullwhip Effect Metric) - 공급망 전체의 수요 증폭 현상 측정
  10. 서비스 레벨 달성률(Service Level Achievement) - 품절 없이 고객 수요를 충족한 비율

1. 재고회전율(Inventory Turnover) - 재고가 얼마나 빨리 판매되는지 측정

시작하며

여러분이 이커머스 회사에서 재고 데이터를 분석하는 업무를 맡았을 때 이런 질문을 받아본 적 있나요? "우리 창고에 쌓여있는 재고가 적정 수준인가요?

너무 많이 쌓여있는 건 아닌가요?" 경영진은 항상 재고가 돈으로 묶여있다는 것을 걱정합니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.

재고가 너무 많으면 자금이 묶이고 창고 비용이 증가하며, 재고가 너무 적으면 품절로 인한 매출 손실이 발생합니다. 특히 유통기한이 있는 상품이나 트렌드에 민감한 제품의 경우 재고 회전율이 낮으면 큰 손실로 이어집니다.

바로 이럴 때 필요한 것이 재고회전율입니다. 이 지표는 일정 기간 동안 재고가 몇 번 판매되고 보충되었는지를 측정하여, 재고 효율성을 한눈에 파악할 수 있게 해줍니다.

개요

간단히 말해서, 재고회전율은 매출원가를 평균재고금액으로 나눈 값입니다. 이 값이 높을수록 재고가 빠르게 회전하여 효율적으로 운영되고 있다는 의미입니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, CFO는 운영자금 효율성을 알고 싶어하고, 물류팀장은 창고 공간 최적화가 필요하며, 상품기획팀은 어떤 상품이 잘 팔리는지 파악하고 싶어합니다. 예를 들어, 회전율이 12인 상품은 월 1회 재고가 교체되는 반면, 회전율이 2인 상품은 6개월에 한 번만 팔린다는 의미입니다.

전통적인 방법으로는 엑셀에서 수동으로 계산했다면, 이제는 Polars로 대규모 거래 데이터를 빠르게 처리하여 실시간으로 재고회전율을 모니터링할 수 있습니다. 재고회전율의 핵심 특징은 첫째, 업종마다 적정 수준이 다르다는 점(식품은 높고, 명품은 낮음), 둘째, 시즌성을 고려해야 한다는 점, 셋째, 카테고리별로 분석해야 의미 있다는 점입니다.

이러한 특징들을 이해하면 단순히 숫자를 계산하는 것이 아니라 비즈니스 인사이트를 도출할 수 있습니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 판매 데이터 로드
sales_df = pl.read_csv("sales_data.csv")

# 기간별 매출원가 계산 (Cost of Goods Sold)
cogs = sales_df.filter(
    pl.col("sale_date").is_between(datetime(2024, 1, 1), datetime(2024, 12, 31))
).select(pl.col("cost_price").sum()).item()

# 평균 재고금액 계산 (기초재고 + 기말재고) / 2
inventory_df = pl.read_csv("inventory_data.csv")
avg_inventory = (inventory_df.filter(pl.col("date") == "2024-01-01").select(pl.col("inventory_value").sum()).item() +
                 inventory_df.filter(pl.col("date") == "2024-12-31").select(pl.col("inventory_value").sum()).item()) / 2

# 재고회전율 계산
turnover_ratio = cogs / avg_inventory
print(f"재고회전율: {turnover_ratio:.2f}회")

설명

이것이 하는 일: 이 코드는 1년간의 판매 데이터와 재고 데이터를 분석하여 재고회전율을 계산합니다. 재고회전율이 높을수록 재고가 효율적으로 관리되고 있다는 의미입니다.

첫 번째로, sales_df에서 특정 기간의 매출원가(COGS)를 집계합니다. is_between 함수로 2024년 전체 판매 데이터를 필터링하고, cost_price 컬럼을 합산합니다.

매출원가는 판매된 상품을 구매하거나 제조하는 데 든 비용으로, 판매가격이 아닌 원가를 사용해야 정확한 재고 효율성을 측정할 수 있습니다. 그 다음으로, inventory_df에서 연초와 연말의 재고금액을 가져와 평균을 계산합니다.

왜 평균을 사용할까요? 재고는 시간에 따라 변동하기 때문에 특정 시점의 값만 사용하면 왜곡될 수 있습니다.

더 정확하게는 매월 재고를 측정해서 12개월 평균을 사용하는 것이 좋지만, 간단히는 기초재고와 기말재고의 평균을 사용합니다. 마지막으로, COGS를 평균재고로 나누어 재고회전율을 계산합니다.

예를 들어 매출원가가 1억 원이고 평균재고가 2천만 원이라면, 재고회전율은 5회입니다. 이는 1년에 재고가 5번 완전히 교체되었다는 의미로, 평균적으로 약 73일마다 재고가 새로 채워진다는 뜻입니다.

여러분이 이 코드를 사용하면 SKU별, 카테고리별, 매장별로 재고회전율을 계산하여 어떤 상품이 효율적으로 관리되고 있는지 파악할 수 있습니다. 회전율이 낮은 상품은 프로모션 대상으로 선정하거나 발주량을 줄이는 등의 액션을 취할 수 있고, 회전율이 높은 상품은 재고 부족을 방지하기 위해 안전재고를 늘릴 수 있습니다.

실무에서는 이 지표를 대시보드에 시각화하여 경영진에게 보고하고, 이상치가 발견되면 알림을 보내는 시스템을 구축할 수 있습니다. 또한 시계열 분석을 통해 재고회전율의 추세를 파악하고, 계절성 패턴을 발견하여 재고 계획에 반영할 수 있습니다.

실전 팁

💡 업종별 벤치마크와 비교하세요. 식품업은 20-30회, 의류는 4-6회, 가전은 6-8회 정도가 평균입니다. 단순히 높다/낮다가 아니라 업종 특성을 고려한 해석이 필요합니다.

💡 카테고리별로 세분화해서 분석하세요. 전체 평균만 보면 잘 팔리는 상품과 안 팔리는 상품이 평균화되어 문제를 놓칠 수 있습니다. group_by("category")를 활용하세요.

💡 재고회전율이 너무 높은 것도 문제입니다. 품절이 자주 발생할 수 있고, 긴급 발주로 인한 비용 증가가 발생합니다. 적정 수준을 유지하는 것이 중요합니다.

💡 시즌성을 고려하세요. 크리스마스 시즌과 비수기의 재고회전율은 당연히 다릅니다. 전년 동기 대비(YoY) 비교가 더 의미 있습니다.

💡 재고평가 방법(FIFO, LIFO, 가중평균)에 따라 결과가 달라집니다. 회계팀과 협의하여 일관된 방법을 사용하세요.


2. 재고일수(Days Inventory Outstanding) - 재고가 판매되기까지 걸리는 평균 일수

시작하며

여러분이 물류센터 관리자에게 "이 상품은 얼마나 오래 창고에 있었나요?"라는 질문을 받았을 때, 재고회전율로는 직관적으로 답하기 어렵습니다. "회전율이 6회입니다"보다는 "평균 60일 정도 창고에 있습니다"가 훨씬 이해하기 쉽습니다.

이런 문제는 비전문가와 커뮤니케이션할 때 자주 발생합니다. 재고회전율은 좋은 지표지만, 일수로 표현하면 훨씬 직관적입니다.

특히 유통기한 관리나 재고 부담을 논의할 때 일수 단위가 더 실용적입니다. 바로 이럴 때 필요한 것이 재고일수입니다.

이 지표는 재고가 판매되기까지 평균 며칠이 걸리는지를 보여주어, 누구나 쉽게 재고 상태를 이해할 수 있게 해줍니다.

개요

간단히 말해서, 재고일수는 365일을 재고회전율로 나눈 값입니다. 또는 평균재고를 일평균 매출원가로 나눈 값으로도 계산할 수 있습니다.

이 값이 낮을수록 재고가 빠르게 소진됩니다. 왜 이 지표가 필요한지 실무 관점에서 설명하면, 유통기한이 있는 제품의 경우 재고일수가 유통기한의 30%를 넘으면 위험 신호입니다.

예를 들어, 유통기한이 90일인 식품의 재고일수가 30일을 넘으면 폐기 위험이 높아집니다. 또한 자금 관리 측면에서도 재고일수가 길면 현금이 재고에 묶여있는 기간이 길다는 의미입니다.

전통적인 방법으로는 재고회전율만 사용했다면, 이제는 재고일수를 함께 보고하여 경영진과 현장 관리자 모두가 이해하기 쉬운 리포트를 만들 수 있습니다. 재고일수의 핵심 특징은 첫째, 직관적이라는 점(60일이면 2개월), 둘째, 유통기한과 직접 비교 가능하다는 점, 셋째, 캐시 컨버전 사이클(Cash Conversion Cycle) 계산에 사용된다는 점입니다.

이러한 특징들을 활용하면 재무팀과 운영팀 간의 커뮤니케이션이 훨씬 원활해집니다.

코드 예제

import polars as pl

# 일별 매출원가 계산
daily_sales = pl.read_csv("sales_data.csv").group_by("sale_date").agg(
    pl.col("cost_price").sum().alias("daily_cogs")
)

# 일평균 매출원가 계산
avg_daily_cogs = daily_sales.select(pl.col("daily_cogs").mean()).item()

# 특정 날짜의 재고금액
current_inventory = pl.read_csv("inventory_data.csv").filter(
    pl.col("date") == "2024-12-31"
).select(pl.col("inventory_value").sum()).item()

# 재고일수 계산 (DIO = Days Inventory Outstanding)
dio = current_inventory / avg_daily_cogs
print(f"재고일수: {dio:.1f}일")

# 또는 재고회전율로부터 계산
turnover_ratio = 6.0  # 앞서 계산한 값
dio_from_turnover = 365 / turnover_ratio
print(f"재고일수 (회전율 기준): {dio_from_turnover:.1f}일")

설명

이것이 하는 일: 이 코드는 일평균 매출원가와 현재 재고금액을 기반으로 재고가 소진되기까지 며칠이 걸리는지 계산합니다. 두 가지 방법을 제시하여 상황에 맞게 선택할 수 있습니다.

첫 번째로, daily_sales에서 일별 매출원가를 집계합니다. group_by("sale_date")로 날짜별로 그룹화하고 agg 함수로 각 날의 매출원가 합계를 계산합니다.

이를 통해 일평균 매출원가를 구할 수 있습니다. 일평균을 사용하는 이유는 주말이나 공휴일 등 비영업일을 고려한 실질적인 평균을 얻기 위함입니다.

그 다음으로, 현재(또는 특정 시점)의 재고금액을 가져옵니다. 예를 들어 2024년 12월 31일의 재고가 2천만 원이고, 일평균 매출원가가 50만 원이라면, 현재 재고는 40일치라는 의미입니다.

즉, 현재 판매 속도로 판매하면 40일 후에 재고가 소진됩니다. 세 번째로, 재고회전율을 이미 알고 있다면 더 간단하게 365일을 회전율로 나누어 계산할 수 있습니다.

회전율이 6회라면 365 ÷ 6 = 60.8일입니다. 두 방법 모두 같은 결과를 주지만, 첫 번째 방법은 더 정확한 일수를 제공하고, 두 번째 방법은 계산이 간단합니다.

여러분이 이 코드를 사용하면 SKU별 재고일수를 계산하여 "이 상품은 창고에 평균 몇 일 있는가?"를 즉시 파악할 수 있습니다. 재고일수가 90일을 넘는 상품은 "슬로우 무빙(Slow Moving)" 재고로 분류하여 특별 관리 대상이 됩니다.

반대로 7일 이하인 상품은 "패스트 무빙(Fast Moving)" 재고로 품절 위험을 모니터링해야 합니다. 실무에서는 재고일수를 ABC 분석과 결합합니다.

A등급 상품(매출 상위 20%)의 재고일수는 짧게, C등급 상품(매출 하위 50%)의 재고일수는 길게 유지하는 것이 일반적입니다. 또한 시계열 차트로 시각화하면 재고 증가 추세를 조기에 발견할 수 있습니다.

실전 팁

💡 업종별 적정 재고일수를 알아두세요. 슈퍼마켓은 10-20일, 패션은 60-90일, 전자제품은 30-45일이 일반적입니다. 자사 데이터로 벤치마크를 설정하세요.

💡 재고일수와 유통기한을 비교하여 리스크를 관리하세요. 재고일수 / 유통기한 > 0.3이면 경고, > 0.5이면 긴급으로 분류합니다.

💡 신제품의 경우 론칭 초기 재고일수가 길어도 정상입니다. 최소 3개월 데이터가 쌓인 후 평가하세요.

💡 계절 상품은 시즌 종료 전 재고일수를 집중 모니터링하세요. 시즌 종료 30일 전부터 주간 단위로 체크합니다.

💡 재고일수가 음수나 비정상적으로 큰 값이 나오면 데이터 오류입니다. filter로 이상치를 제거하고, 데이터 품질을 먼저 점검하세요.


3. 평균 예측 오차(MAE - Mean Absolute Error) - 수요 예측의 정확도 측정

시작하며

여러분이 수요 예측 모델을 만들어서 다음 달 판매량을 예측했는데, 실제 판매량과 얼마나 차이가 나는지 어떻게 평가하시겠습니까? "대충 비슷한 것 같은데요"라는 답변은 데이터 분석가에게 어울리지 않습니다.

이런 문제는 머신러닝 모델을 평가할 때 항상 발생합니다. 예측값과 실제값의 차이를 정량적으로 측정하지 않으면 모델의 성능을 개선할 수 없고, 어느 모델이 더 나은지 비교할 수도 없습니다.

특히 재고 관리에서 예측 오차는 곧 과잉재고 또는 품절로 이어집니다. 바로 이럴 때 필요한 것이 평균 예측 오차(MAE)입니다.

이 지표는 예측값과 실제값의 차이를 절대값으로 평균내어, 모델이 평균적으로 얼마나 틀리는지를 알려줍니다.

개요

간단히 말해서, MAE는 |예측값 - 실제값|의 평균입니다. 예를 들어 MAE가 10이라면, 평균적으로 예측이 실제값과 10만큼 차이난다는 의미입니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 데이터 사이언티스트는 모델 성능을 비교하고 개선하기 위해 필요하고, 재고 관리자는 안전재고를 얼마나 확보해야 할지 결정하는 데 사용하며, 경영진은 예측 기반 의사결정의 신뢰도를 판단하는 데 활용합니다. 예를 들어, MAE가 작은 모델을 사용하면 과잉재고를 줄이고 재고 비용을 절감할 수 있습니다.

전통적인 방법으로는 엑셀에서 수작업으로 계산했다면, 이제는 Polars로 수천 개의 SKU에 대한 예측 오차를 동시에 계산하고 분석할 수 있습니다. MAE의 핵심 특징은 첫째, 해석이 쉽다는 점(원래 단위 그대로), 둘째, 이상치에 덜 민감하다는 점(RMSE보다), 셋째, 모든 오차를 동일하게 취급한다는 점입니다.

이러한 특징들 때문에 MAE는 비즈니스 리포트에 자주 사용되며, 기술팀과 비즈니스팀 간의 공통 언어가 됩니다.

코드 예제

import polars as pl
import numpy as np

# 실제 판매량과 예측값 로드
forecast_df = pl.read_csv("forecast_results.csv")

# MAE 계산
mae_df = forecast_df.select([
    pl.col("product_id"),
    (pl.col("actual_sales") - pl.col("predicted_sales")).abs().alias("absolute_error")
]).group_by("product_id").agg(
    pl.col("absolute_error").mean().alias("mae")
)

# 전체 MAE
overall_mae = forecast_df.select(
    (pl.col("actual_sales") - pl.col("predicted_sales")).abs().mean()
).item()

print(f"전체 평균 예측 오차: {overall_mae:.2f} units")
print(mae_df)

설명

이것이 하는 일: 이 코드는 제품별 수요 예측 오차를 계산하여 어떤 제품의 예측이 정확하고 어떤 제품이 예측하기 어려운지 파악합니다. 첫 번째로, forecast_df에서 실제 판매량(actual_sales)과 예측값(predicted_sales)의 차이를 계산합니다.

.abs()로 절대값을 취하는 이유는 과대예측(+)과 과소예측(-)을 모두 오차로 취급하기 위함입니다. 만약 절대값을 취하지 않으면 +10과 -10이 상쇄되어 오차가 0으로 보이는 문제가 발생합니다.

그 다음으로, group_by("product_id")로 제품별로 그룹화하고 각 제품의 평균 절대 오차를 계산합니다. 예를 들어 제품 A의 MAE가 5이고 제품 B의 MAE가 50이라면, 제품 B가 예측하기 훨씬 어렵다는 의미입니다.

이는 제품 B의 수요 패턴이 불규칙하거나, 외부 요인에 민감하거나, 신제품이라 과거 데이터가 부족한 경우일 수 있습니다. 세 번째로, 전체 데이터셋의 MAE를 계산하여 모델의 전반적인 성능을 평가합니다.

이 값은 모델 버전 간 비교나 다른 예측 알고리즘과의 벤치마킹에 사용됩니다. 예를 들어 이번 달 모델의 MAE가 15이고 저번 달 모델의 MAE가 20이었다면, 모델이 개선되었다고 할 수 있습니다.

여러분이 이 코드를 사용하면 어떤 제품군이 예측하기 어려운지 파악하여 해당 제품에는 더 정교한 모델을 적용하거나, 안전재고를 더 많이 확보하는 등의 차별화된 전략을 수립할 수 있습니다. 또한 MAE를 시계열로 추적하면 모델의 성능이 시간에 따라 저하되는지(모델 드리프트) 모니터링할 수 있습니다.

실무에서는 MAE를 평균 판매량으로 나눈 MAPE(Mean Absolute Percentage Error)도 함께 계산합니다. MAE는 절대적 오차를, MAPE는 상대적 오차를 보여주므로 두 지표를 함께 보면 더 정확한 평가가 가능합니다.

실전 팁

💡 MAE만 보지 말고 편향(Bias)도 체크하세요. (predicted - actual).mean()으로 계산하며, 양수면 과대예측, 음수면 과소예측 경향이 있다는 의미입니다.

💡 제품별 MAE를 매출액 가중 평균하면 더 의미 있는 전체 지표를 얻을 수 있습니다. 매출이 큰 제품의 오차가 비즈니스에 더 큰 영향을 미치기 때문입니다.

💡 예측 기간에 따라 MAE가 달라집니다. 1주 예측과 4주 예측의 MAE를 직접 비교하지 마세요. 기간별로 따로 계산하세요.

💡 이상치(프로모션, 결품 등)가 포함된 데이터는 필터링하세요. 정상 상황의 예측 성능과 특수 상황의 성능을 분리해서 평가해야 합니다.

💡 MAE 임계값을 설정하여 알림을 받으세요. 예를 들어 "MAE가 전월 대비 20% 이상 증가하면 알림"으로 설정하면 모델 문제를 조기에 발견할 수 있습니다.


4. 예측 정확도(Forecast Accuracy) - 예측이 얼마나 맞았는지 백분율로 표현

시작하며

여러분이 경영진에게 "우리 수요 예측 모델의 MAE가 15입니다"라고 보고했을 때, "그래서 그게 좋은 건가요, 나쁜 건가요?"라는 질문을 받았다면 어떻게 답하시겠습니까? MAE는 절대값이라 직관적으로 이해하기 어렵습니다.

이런 문제는 비즈니스 커뮤니케이션에서 자주 발생합니다. 기술팀은 MAE, RMSE 같은 지표에 익숙하지만, 경영진은 "정확도 85%"처럼 백분율로 표현된 지표를 선호합니다.

또한 업종이 다른 회사와 비교할 때도 절대값보다는 백분율이 유용합니다. 바로 이럴 때 필요한 것이 예측 정확도입니다.

이 지표는 예측이 얼마나 정확한지를 백분율로 표현하여, 누구나 쉽게 이해하고 비교할 수 있게 해줍니다.

개요

간단히 말해서, 예측 정확도는 1 - (|예측값 - 실제값| / 실제값)의 평균을 백분율로 표현한 것입니다. 100%에 가까울수록 예측이 정확합니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, CEO는 "우리 예측 시스템에 얼마나 의존할 수 있나?"를 알고 싶어하고, 영업팀은 "예측 기반으로 고객에게 납기를 약속해도 되나?"를 판단해야 하며, 재무팀은 "예측 기반 재고 투자가 안전한가?"를 검토해야 합니다. 예를 들어, 정확도가 90%라면 대부분의 의사결정을 예측에 기반해도 된다는 뜻입니다.

전통적인 방법으로는 주관적인 느낌으로 "괜찮은 것 같다"고 말했다면, 이제는 정량적으로 "정확도 87%로 업계 평균인 80%를 상회합니다"라고 보고할 수 있습니다. 예측 정확도의 핵심 특징은 첫째, 백분율이라 직관적이라는 점, 둘째, 벤치마킹이 쉽다는 점, 셋째, KPI로 설정하기 좋다는 점입니다.

이러한 특징들 덕분에 예측 정확도는 성과 평가나 보너스 산정의 기준으로도 자주 사용됩니다.

코드 예제

import polars as pl

# 예측 정확도 계산 (MAPE 기반)
forecast_df = pl.read_csv("forecast_results.csv")

# 실제값이 0인 경우 제외 (division by zero 방지)
accuracy_df = forecast_df.filter(pl.col("actual_sales") > 0).select([
    pl.col("product_id"),
    pl.col("actual_sales"),
    pl.col("predicted_sales"),
    (
        1 - (pl.col("actual_sales") - pl.col("predicted_sales")).abs() / pl.col("actual_sales")
    ).alias("accuracy")
])

# 음수 정확도를 0으로 처리 (예측이 실제보다 2배 이상 틀린 경우)
accuracy_df = accuracy_df.with_columns(
    pl.when(pl.col("accuracy") < 0).then(0).otherwise(pl.col("accuracy")).alias("accuracy")
)

# 평균 예측 정확도
avg_accuracy = accuracy_df.select(pl.col("accuracy").mean()).item()
print(f"평균 예측 정확도: {avg_accuracy * 100:.1f}%")

# 제품별 정확도
product_accuracy = accuracy_df.group_by("product_id").agg(
    pl.col("accuracy").mean().alias("avg_accuracy")
).sort("avg_accuracy", descending=True)
print(product_accuracy)

설명

이것이 하는 일: 이 코드는 제품별 예측 정확도를 계산하여 어떤 제품이 예측하기 쉽고 어떤 제품이 어려운지 파악하고, 전체 예측 시스템의 신뢰도를 백분율로 제시합니다. 첫 번째로, actual_sales > 0 필터로 실제 판매량이 0인 케이스를 제외합니다.

0으로 나누면 에러가 발생하기 때문입니다. 실제 판매량이 0인 경우는 신제품 출시 전이나 단종된 제품일 수 있으므로, 별도로 처리하거나 분석에서 제외하는 것이 일반적입니다.

그 다음으로, 각 행의 정확도를 계산합니다. 예를 들어 실제 판매량이 100이고 예측값이 90이라면, 오차는 10이고, 오차율은 10%, 정확도는 90%입니다.

이 계산을 모든 제품의 모든 날짜에 대해 수행합니다. .abs()를 사용하는 이유는 과대예측과 과소예측을 동일하게 취급하기 위함입니다.

세 번째로, 음수 정확도를 0으로 처리합니다. 예를 들어 실제 판매량이 10인데 예측값이 30이라면 오차가 20으로 실제값보다 크므로, 정확도가 -100%가 됩니다.

이는 의미 없는 값이므로 0%로 처리합니다. 이런 경우는 프로모션 효과를 놓쳤거나, 갑작스러운 수요 급증이 있었을 때 발생합니다.

네 번째로, 전체 평균 정확도와 제품별 정확도를 계산합니다. 전체 평균은 모델의 종합 성능을, 제품별 정확도는 어떤 카테고리나 제품이 예측하기 어려운지를 보여줍니다.

sort로 정확도가 낮은 제품을 찾아내어 중점 관리 대상으로 지정할 수 있습니다. 여러분이 이 코드를 사용하면 "우리 예측 시스템은 평균 85% 정확합니다"라고 자신 있게 보고할 수 있고, 정확도가 낮은 제품에 대해서는 "제품 X는 정확도가 60%에 불과하므로 안전재고를 더 확보하겠습니다"라는 액션 플랜을 제시할 수 있습니다.

또한 월별 정확도 추이를 대시보드에 표시하여 예측 시스템의 성능을 지속적으로 모니터링할 수 있습니다. 실무에서는 정확도 목표를 KPI로 설정합니다.

예를 들어 "2024년 Q4까지 예측 정확도 85% 달성"과 같이 설정하고, 월별로 진척도를 추적합니다. 목표 미달 시 모델 재학습, 피처 추가, 이상치 제거 등의 개선 작업을 진행합니다.

실전 팁

💡 정확도는 제품별, 카테고리별, 기간별로 세분화하세요. 전체 평균만 보면 문제가 있는 세그먼트를 놓칠 수 있습니다.

💡 정확도가 너무 높게 나온다면(95% 이상) 데이터 누수를 의심하세요. 미래 정보가 학습 데이터에 포함되었을 가능성이 있습니다.

💡 가중 평균 정확도를 계산하세요. 판매량이 큰 제품의 정확도에 더 높은 가중치를 부여하면 비즈니스 임팩트를 반영할 수 있습니다.

💡 정확도와 MAE를 함께 보세요. 정확도가 높아도 MAE가 크다면 소수의 큰 오차가 있다는 의미입니다. 두 지표는 상호 보완적입니다.

💡 계절 상품은 시즌별 정확도를 따로 계산하세요. 비수기의 낮은 정확도가 전체 평균을 낮출 수 있습니다.


5. 안전재고(Safety Stock) - 수요 변동과 리드타임을 고려한 최소 재고량

시작하며

여러분이 재고 관리 시스템을 운영하면서 "재고가 떨어져서 판매 기회를 놓쳤습니다"라는 보고를 받았을 때, 이는 단순히 예측이 틀렸다는 문제가 아닙니다. 수요는 항상 변동하고, 공급업체의 납기도 일정하지 않기 때문에 평균 예측량만 발주하면 품절이 발생합니다.

이런 문제는 모든 재고 관리 시스템에서 발생하는 근본적인 이슈입니다. 수요가 정확히 예측대로 발생하는 경우는 거의 없으며, 리드타임(발주부터 입고까지 걸리는 시간)도 변동합니다.

특히 온라인 커머스에서 품절은 곧 고객을 경쟁사로 잃는 것을 의미합니다. 바로 이럴 때 필요한 것이 안전재고입니다.

이 지표는 수요 변동성과 리드타임을 고려하여 품절을 방지하기 위해 추가로 보유해야 할 재고량을 계산해줍니다.

개요

간단히 말해서, 안전재고는 Z-score × 리드타임 동안의 수요 표준편차로 계산됩니다. Z-score는 원하는 서비스 레벨(품절 방지율)에 따라 결정됩니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 물류팀은 얼마만큼의 재고를 항상 유지해야 할지 알아야 하고, 구매팀은 발주량을 결정할 때 안전재고를 고려해야 하며, 재무팀은 안전재고에 묶일 자금을 예산에 반영해야 합니다. 예를 들어, 서비스 레벨 95%를 목표로 하면 Z-score는 1.65이며, 이는 100번 중 95번은 품절이 발생하지 않는다는 의미입니다.

전통적인 방법으로는 "평균 판매량의 2주치" 같은 경험적 규칙을 사용했다면, 이제는 실제 수요 변동성과 원하는 서비스 레벨을 기반으로 과학적으로 계산할 수 있습니다. 안전재고의 핵심 특징은 첫째, 통계적 근거가 있다는 점, 둘째, 서비스 레벨과 재고 비용 간의 트레이드오프를 정량화한다는 점, 셋째, 제품별로 차별화할 수 있다는 점입니다.

이러한 특징들로 인해 안전재고는 재고 최적화의 핵심 도구입니다.

코드 예제

import polars as pl
import numpy as np
from scipy import stats

# 일별 수요 데이터 로드
demand_df = pl.read_csv("daily_demand.csv")

# 제품별 수요 통계 계산
demand_stats = demand_df.group_by("product_id").agg([
    pl.col("daily_demand").mean().alias("avg_demand"),
    pl.col("daily_demand").std().alias("std_demand")
])

# 리드타임 설정 (예: 7일)
lead_time_days = 7

# 서비스 레벨 설정 (예: 95% = Z-score 1.65)
service_level = 0.95
z_score = stats.norm.ppf(service_level)

# 안전재고 계산: Z × σ × √L
safety_stock = demand_stats.with_columns([
    (z_score * pl.col("std_demand") * np.sqrt(lead_time_days)).alias("safety_stock")
]).select(["product_id", "avg_demand", "safety_stock"])

print(f"서비스 레벨 {service_level*100}% 기준 안전재고:")
print(safety_stock)

설명

이것이 하는 일: 이 코드는 과거 수요 데이터의 변동성을 분석하여 제품별로 얼마만큼의 안전재고를 유지해야 원하는 서비스 레벨을 달성할 수 있는지 계산합니다. 첫 번째로, demand_df에서 제품별 일평균 수요와 표준편차를 계산합니다.

표준편차는 수요의 변동성을 나타내는데, 표준편차가 클수록 수요 예측이 어렵고 안전재고가 더 많이 필요합니다. 예를 들어 제품 A는 일평균 100개, 표준편차 10개이고, 제품 B는 일평균 100개, 표준편차 30개라면, 제품 B가 훨씬 불안정하므로 더 많은 안전재고가 필요합니다.

그 다음으로, 리드타임과 서비스 레벨을 설정합니다. 리드타임은 발주부터 입고까지 걸리는 시간으로, 이 기간 동안은 재고를 보충할 수 없으므로 리드타임이 길수록 안전재고가 더 필요합니다.

서비스 레벨은 품절을 허용하지 않을 확률로, 95%는 "100번 중 5번은 품절을 감수한다"는 의미입니다. Z-score는 정규분포에서 해당 확률에 대응하는 값으로, 95%는 1.65, 99%는 2.33입니다.

세 번째로, 안전재고 공식을 적용합니다: Z × σ × √L. 여기서 σ는 일간 수요 표준편차, L은 리드타임입니다.

√L을 곱하는 이유는 리드타임 동안의 수요 변동성이 √L에 비례하여 증가하기 때문입니다(통계학의 분산 가산성). 예를 들어 일간 표준편차가 10이고 리드타임이 4일이라면, 4일간 표준편차는 10 × √4 = 20입니다.

여러분이 이 코드를 사용하면 각 제품의 최소 재고 수준을 과학적으로 결정할 수 있습니다. 재고가 안전재고 수준 아래로 떨어지면 자동으로 발주하는 ROP(Reorder Point) 시스템을 구축할 수 있고, A/B/C 등급에 따라 서비스 레벨을 차별화(A등급 99%, C등급 90%)하여 재고 효율성을 높일 수 있습니다.

실무에서는 안전재고를 정기적으로 재계산합니다. 수요 패턴이 변하거나, 리드타임이 달라지거나, 회사의 서비스 레벨 정책이 바뀌면 안전재고도 조정해야 합니다.

또한 계절성이 있는 제품은 성수기와 비수기의 안전재고를 따로 계산합니다.

실전 팁

💡 리드타임도 변동한다면 √(σ_demand² × L + μ_demand² × σ_leadtime²) 공식을 사용하세요. 더 정확하지만 복잡합니다.

💡 서비스 레벨을 제품 등급별로 차별화하세요. 모든 제품에 99%를 적용하면 재고 비용이 과도하게 증가합니다.

💡 안전재고 비용을 계산하세요. safety_stock × unit_cost × holding_cost_rate로 얼마나 자금이 묶이는지 파악하세요.

💡 실제 품절률을 모니터링하여 모델을 검증하세요. 서비스 레벨 95%로 설정했는데 품절률이 10%라면 모델 가정이 틀렸다는 의미입니다.

💡 수요가 정규분포를 따르지 않는 제품(예: 간헐적 수요)은 다른 방법론을 사용하세요. 정규분포 가정이 맞지 않으면 안전재고가 부정확합니다.


6. 재주문점(Reorder Point) - 언제 발주해야 하는지 결정

시작하며

여러분이 안전재고를 계산했지만, "그래서 언제 발주해야 하나요?"라는 질문에는 답하지 못했다면, 아직 재고 관리 시스템이 완성되지 않은 것입니다. 안전재고는 최소 유지 수량이지, 발주 시점은 아닙니다.

이런 문제는 재고 관리 자동화에서 핵심적인 과제입니다. 사람이 매일 재고를 확인하고 "이제 발주해야겠다"고 판단하는 것은 비효율적이고 실수가 발생하기 쉽습니다.

특히 수천 개의 SKU를 관리하는 경우 자동화된 발주 시스템이 필수입니다. 바로 이럴 때 필요한 것이 재주문점입니다.

이 지표는 재고가 이 수준 이하로 떨어지면 자동으로 발주하도록 설정하는 임계값으로, 리드타임 동안의 예상 수요와 안전재고를 합친 값입니다.

개요

간단히 말해서, 재주문점(ROP)은 (리드타임 × 일평균 수요) + 안전재고입니다. 현재 재고가 이 값 이하로 떨어지면 발주 신호가 발생합니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 구매팀은 언제 발주해야 할지 명확한 기준이 필요하고, IT팀은 자동 발주 시스템을 구축해야 하며, 경영진은 재고 관리가 체계적으로 이루어지는지 확인해야 합니다. 예를 들어, 리드타임이 7일이고 일평균 수요가 100개, 안전재고가 200개라면, 재고가 900개 이하로 떨어질 때 발주해야 합니다.

전통적인 방법으로는 "재고가 절반 이하로 떨어지면 발주" 같은 임의적 규칙을 사용했다면, 이제는 수요 예측과 통계적 안전재고를 결합하여 최적의 발주 시점을 계산할 수 있습니다. 재주문점의 핵심 특징은 첫째, 완전 자동화가 가능하다는 점, 둘째, 품절과 과잉재고를 동시에 방지한다는 점, 셋째, 실시간 재고 모니터링과 결합하면 즉각적인 대응이 가능하다는 점입니다.

이러한 특징들로 인해 ROP는 ERP, WMS 시스템의 핵심 로직입니다.

코드 예제

import polars as pl
import numpy as np
from scipy import stats

# 제품별 수요 통계 로드
demand_stats = pl.read_csv("demand_stats.csv")

# 제품별 리드타임 (공급업체마다 다를 수 있음)
leadtime_df = pl.read_csv("supplier_leadtime.csv")

# 데이터 조인
rop_df = demand_stats.join(leadtime_df, on="product_id")

# 서비스 레벨 95%
z_score = stats.norm.ppf(0.95)

# ROP 계산
rop_df = rop_df.with_columns([
    # 안전재고 계산
    (z_score * pl.col("std_demand") * pl.col("lead_time_days").sqrt()).alias("safety_stock"),
    # 리드타임 수요
    (pl.col("avg_daily_demand") * pl.col("lead_time_days")).alias("lead_time_demand")
]).with_columns([
    # 재주문점 = 리드타임 수요 + 안전재고
    (pl.col("lead_time_demand") + pl.col("safety_stock")).alias("reorder_point")
])

print("제품별 재주문점:")
print(rop_df.select(["product_id", "reorder_point", "safety_stock"]))

설명

이것이 하는 일: 이 코드는 제품별로 언제 발주해야 하는지를 나타내는 재주문점을 계산하여, 재고 관리 시스템에서 자동 발주 로직을 구현할 수 있게 합니다. 첫 번째로, 수요 통계와 리드타임 데이터를 조인합니다.

리드타임은 공급업체마다, 제품마다 다를 수 있으므로 별도 테이블로 관리하는 것이 일반적입니다. 해외 직수입 제품은 30일, 국내 제조 제품은 7일 등으로 다양합니다.

정확한 리드타임 데이터가 없다면 과거 발주 이력에서 입고일 - 발주일의 평균을 계산하여 추정할 수 있습니다. 그 다음으로, 안전재고와 리드타임 수요를 각각 계산합니다.

안전재고는 앞서 배운 공식으로 계산하고, 리드타임 수요는 단순히 일평균 수요에 리드타임을 곱한 값입니다. 예를 들어 일평균 100개 팔리고 리드타임이 7일이라면, 발주 후 도착할 때까지 700개가 판매될 것으로 예상됩니다.

세 번째로, 리드타임 수요와 안전재고를 합쳐 재주문점을 계산합니다. 위 예시에서 리드타임 수요 700개 + 안전재고 200개 = 재주문점 900개입니다.

즉, 현재 재고가 900개 이하로 떨어지면 즉시 발주해야 합니다. 그래야 리드타임 7일 동안 700개가 팔려도 안전재고 200개가 남아 있어 품절을 방지할 수 있습니다.

여러분이 이 코드를 사용하면 재고 관리 시스템에 "IF 현재재고 <= 재주문점 THEN 자동발주"라는 로직을 구현할 수 있습니다. 매일 밤 배치 작업으로 모든 SKU의 현재 재고와 재주문점을 비교하여 발주 대상 목록을 생성하고, 구매팀에 이메일이나 대시보드로 알림을 보낼 수 있습니다.

더 나아가 공급업체 API와 연동하면 완전 자동 발주도 가능합니다. 실무에서는 재주문점을 동적으로 업데이트합니다.

프로모션 기간에는 수요가 급증하므로 재주문점을 임시로 높이고, 수요 예측 모델이 재학습될 때마다 재주문점도 재계산합니다. 또한 "재주문점 - 현재재고"를 계산하여 얼마나 발주해야 하는지(발주량)도 함께 계산할 수 있습니다.

실전 팁

💡 재주문점과 최대재고를 함께 설정하세요. 재주문점에서 EOQ(경제적 발주량)만큼 발주하여 최대재고에 도달하도록 설계하면 최적화됩니다.

💡 리드타임이 불안정한 공급업체는 평균이 아닌 95 percentile을 사용하세요. 최악의 경우를 대비하는 것이 안전합니다.

💡 재주문점 도달 시 즉시 발주하지 말고, 일정 기간(예: 3일) 동안 추세를 관찰하세요. 일시적 급증일 수 있습니다.

💡 계절 상품은 시즌별 재주문점을 설정하세요. 여름 에어컨과 겨울 에어컨의 수요가 완전히 다릅니다.

💡 재주문점 위반 빈도를 모니터링하세요. 너무 자주 발주되거나(재주문점이 너무 높음) 품절이 발생하면(재주문점이 너무 낮음) 재조정이 필요합니다.


7. 수요 변동성(Coefficient of Variation) - 수요 예측의 난이도 측정

시작하며

여러분이 두 제품을 비교할 때, 제품 A는 평균 100개 팔리고 표준편차 10개, 제품 B는 평균 1000개 팔리고 표준편차 50개라면, 어느 제품이 예측하기 더 어려울까요? 단순히 표준편차만 보면 제품 B가 변동성이 더 크지만, 평균 대비 변동성을 보면 다른 결론이 나옵니다.

이런 문제는 서로 다른 규모의 제품을 비교할 때 항상 발생합니다. 표준편차는 절대적 변동성이므로, 판매량 규모가 다른 제품 간 비교가 어렵습니다.

특히 재고 정책을 결정할 때 "어떤 제품이 더 예측하기 어려운가"를 파악하는 것이 중요합니다. 바로 이럴 때 필요한 것이 변동계수(CV, Coefficient of Variation)입니다.

이 지표는 표준편차를 평균으로 나눈 값으로, 규모와 무관하게 상대적 변동성을 측정합니다.

개요

간단히 말해서, 변동계수는 (표준편차 / 평균) × 100%입니다. CV가 높을수록 수요가 불안정하고 예측하기 어렵습니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 상품기획팀은 어떤 제품이 관리하기 어려운지 알아야 하고, 데이터 사이언스팀은 어떤 제품에 더 정교한 모델을 적용해야 하는지 판단해야 하며, 재고팀은 높은 CV 제품에 더 많은 안전재고를 할당해야 합니다. 예를 들어, CV가 10%인 제품은 매우 안정적이고, CV가 100%인 제품은 매우 불안정합니다.

전통적인 방법으로는 "이 제품은 판매량이 들쭉날쭉하다"는 주관적 판단을 했다면, 이제는 CV로 정량화하여 "CV 80% 이상 제품은 고위험군으로 분류"같은 객관적 기준을 만들 수 있습니다. 변동계수의 핵심 특징은 첫째, 규모에 독립적이라는 점(무차원 수), 둘째, 제품 간 비교가 쉽다는 점, 셋째, 재고 정책 세분화의 기준이 된다는 점입니다.

이러한 특징들로 인해 CV는 ABC-XYZ 분석의 XYZ 축을 구성하는 핵심 지표입니다.

코드 예제

import polars as pl

# 제품별 수요 데이터 로드
demand_df = pl.read_csv("daily_demand.csv")

# 제품별 평균과 표준편차 계산
cv_df = demand_df.group_by("product_id").agg([
    pl.col("daily_demand").mean().alias("avg_demand"),
    pl.col("daily_demand").std().alias("std_demand"),
    pl.col("daily_demand").count().alias("data_points")
]).filter(
    # 데이터 포인트가 충분한 제품만 (최소 30일)
    pl.col("data_points") >= 30
).with_columns([
    # 변동계수 계산 (%)
    ((pl.col("std_demand") / pl.col("avg_demand")) * 100).alias("cv_percent")
]).with_columns([
    # 변동성 등급 분류
    pl.when(pl.col("cv_percent") < 25).then(pl.lit("Low - 안정"))
    .when(pl.col("cv_percent") < 50).then(pl.lit("Medium - 보통"))
    .when(pl.col("cv_percent") < 100).then(pl.lit("High - 불안정"))
    .otherwise(pl.lit("Very High - 매우 불안정")).alias("variability_class")
])

print("제품별 수요 변동성 분석:")
print(cv_df.select(["product_id", "avg_demand", "cv_percent", "variability_class"]))

# 변동성 등급별 통계
class_summary = cv_df.group_by("variability_class").agg([
    pl.col("product_id").count().alias("product_count"),
    pl.col("cv_percent").mean().alias("avg_cv")
])
print("\n변동성 등급별 요약:")
print(class_summary)

설명

이것이 하는 일: 이 코드는 제품별 수요 변동성을 계산하고 등급으로 분류하여, 어떤 제품이 예측하기 어렵고 더 많은 안전재고가 필요한지 파악합니다. 첫 번째로, 제품별 평균 수요와 표준편차를 계산합니다.

동시에 데이터 포인트 개수도 계산하여 통계적으로 의미 있는 샘플 크기(최소 30개)를 확보한 제품만 분석합니다. 신제품이나 최근 출시된 제품은 데이터가 부족하여 CV가 왜곡될 수 있으므로 필터링합니다.

그 다음으로, 변동계수를 계산합니다. 표준편차를 평균으로 나누고 100을 곱하여 백분율로 표현합니다.

예를 들어 평균이 100이고 표준편차가 30이라면 CV는 30%입니다. 이는 평균 대비 30%의 변동이 있다는 의미로, 일반적으로 중간 정도의 변동성으로 분류됩니다.

세 번째로, CV 값에 따라 변동성 등급을 분류합니다. 업계 표준은 없지만, 일반적으로 CV < 25%는 낮음(안정적), 25-50%는 보통, 50-100%는 높음(불안정), 100% 이상은 매우 높음으로 분류합니다.

이 등급에 따라 차별화된 재고 정책을 적용할 수 있습니다. 네 번째로, 변동성 등급별 제품 수와 평균 CV를 집계합니다.

예를 들어 "Very High" 등급에 전체 제품의 20%가 속한다면, 이 제품들이 재고 관리의 핵심 리스크 요인이라는 것을 알 수 있습니다. 이 제품들에는 높은 안전재고, 빈번한 리뷰, 정교한 예측 모델이 필요합니다.

여러분이 이 코드를 사용하면 ABC-XYZ 매트릭스를 만들 수 있습니다. ABC는 매출 기여도, XYZ는 변동성을 나타내며, AX(고매출, 저변동성)는 최우선 관리, CZ(저매출, 고변동성)는 재고 최소화 대상이 됩니다.

이를 통해 수천 개 SKU를 9개 그룹으로 세분화하여 효율적으로 관리할 수 있습니다. 실무에서는 CV를 시계열로 추적합니다.

제품의 CV가 시간에 따라 증가한다면 시장 환경이 변했거나 경쟁이 심화된 것일 수 있습니다. 또한 신제품은 초기에 CV가 높다가 시장에 안착하면서 낮아지는 패턴을 보입니다.

실전 팁

💡 평균이 매우 작은 제품(간헐적 수요)은 CV가 왜곡됩니다. 평균 < 1인 경우 다른 지표(예: ADI, CV²)를 사용하세요.

💡 계절성이 강한 제품은 시즌 내 CV를 계산하세요. 전체 기간 CV는 계절성 때문에 부풀려집니다.

💡 CV와 ABC 등급을 결합하여 9-box 매트릭스를 만드세요. 각 셀에 맞는 재고 정책을 수립하면 효율이 극대화됩니다.

💡 프로모션 기간 데이터를 제외하고 베이스라인 CV를 계산하세요. 프로모션은 일시적 변동이므로 정상 변동성과 구분해야 합니다.

💡 CV > 100%인 제품은 통계적 방법보다 시뮬레이션 기반 접근이 더 적합할 수 있습니다. 정규분포 가정이 깨지기 때문입니다.


8. 재고 회전일수(Days Sales of Inventory) - 현재 재고가 판매되는 데 걸리는 일수

시작하며

여러분이 월말 재고 실사 후 "현재 창고에 있는 재고가 언제쯤 다 팔릴까요?"라는 질문을 받았을 때, 재고회전율(연간)로는 즉답하기 어렵습니다. 경영진은 "지금 이 순간의 재고 상태"를 알고 싶어합니다.

이런 문제는 재무 보고나 재고 리뷰 회의에서 자주 발생합니다. 재고회전율은 과거 1년간의 평균이므로, 최근 수요 변화나 계절성을 반영하지 못합니다.

특히 이커머스에서는 재고 상태가 빠르게 변하므로 현재 시점의 지표가 중요합니다. 바로 이럴 때 필요한 것이 재고 회전일수(DSI, Days Sales of Inventory)입니다.

이 지표는 현재 재고를 최근 판매 속도로 판매하면 며칠이 걸리는지 보여줍니다.

개요

간단히 말해서, DSI는 현재 재고금액을 최근 일평균 매출원가로 나눈 값입니다. 이는 재고일수(DIO)와 유사하지만, 더 최근 데이터를 사용하여 현재 상태를 반영합니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 재무팀은 월말 재무제표 작성 시 재고 자산의 유동성을 평가해야 하고, 재고팀은 과잉재고 여부를 즉시 판단해야 하며, 경영진은 현재 재고 수준이 적정한지 빠르게 파악해야 합니다. 예를 들어, DSI가 90일이라면 현재 판매 속도로 3개월치 재고를 보유하고 있다는 의미입니다.

전통적인 방법으로는 연간 재고회전율만 보고했다면, 이제는 실시간에 가까운 DSI를 대시보드에 표시하여 재고 상태를 동적으로 모니터링할 수 있습니다. DSI의 핵심 특징은 첫째, 현재 시점의 재고 상태를 반영한다는 점, 둘째, 계산이 간단하여 실시간 대시보드에 적합하다는 점, 셋째, 캐시 컨버전 사이클의 구성 요소라는 점입니다.

이러한 특징들로 인해 DSI는 일일 재고 리포트의 핵심 KPI입니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 현재 재고 데이터 (예: 오늘 기준)
current_date = datetime(2024, 12, 31)
inventory_df = pl.read_csv("inventory_snapshot.csv").filter(
    pl.col("snapshot_date") == current_date.strftime("%Y-%m-%d")
)

# 최근 30일 판매 데이터
sales_df = pl.read_csv("sales_data.csv").filter(
    pl.col("sale_date").is_between(
        (current_date - timedelta(days=30)).strftime("%Y-%m-%d"),
        current_date.strftime("%Y-%m-%d")
    )
)

# 제품별 현재 재고금액
current_inventory = inventory_df.group_by("product_id").agg(
    pl.col("inventory_value").sum().alias("current_inventory_value")
)

# 제품별 최근 30일 일평균 매출원가
recent_cogs = sales_df.group_by("product_id").agg(
    pl.col("cost_price").sum().alias("total_cogs_30d")
).with_columns(
    (pl.col("total_cogs_30d") / 30).alias("avg_daily_cogs")
)

# DSI 계산
dsi_df = current_inventory.join(recent_cogs, on="product_id").with_columns(
    (pl.col("current_inventory_value") / pl.col("avg_daily_cogs")).alias("dsi")
)

print("제품별 재고 회전일수 (DSI):")
print(dsi_df.select(["product_id", "current_inventory_value", "dsi"]))

# DSI 기준 분류
dsi_classified = dsi_df.with_columns([
    pl.when(pl.col("dsi") < 30).then(pl.lit("Fast Moving"))
    .when(pl.col("dsi") < 60).then(pl.lit("Normal"))
    .when(pl.col("dsi") < 90).then(pl.lit("Slow Moving"))
    .otherwise(pl.lit("Dead Stock Risk")).alias("inventory_status")
])
print("\n재고 상태 분류:")
print(dsi_classified.select(["product_id", "dsi", "inventory_status"]))

설명

이것이 하는 일: 이 코드는 현재 시점의 재고 수준을 최근 판매 추세와 비교하여, 재고가 과다한지 부족한지 즉시 판단할 수 있게 합니다. 첫 번째로, 특정 시점(예: 월말)의 재고 스냅샷을 가져옵니다.

재고는 매일 변하므로 특정 시점을 기준으로 해야 일관성 있는 분석이 가능합니다. 대부분의 회사는 매일 자정 또는 매월 말일에 재고 스냅샷을 저장합니다.

재고금액은 재고수량 × 단가로 계산되며, 단가는 구매원가를 사용합니다. 그 다음으로, 최근 30일간의 판매 데이터를 가져와 일평균 매출원가를 계산합니다.

왜 30일일까요? 너무 짧으면(예: 7일) 최근의 일시적 변동에 과도하게 영향을 받고, 너무 길면(예: 90일) 오래된 트렌드를 반영하게 됩니다.

30일은 적당한 균형점이지만, 비즈니스 특성에 따라 조정할 수 있습니다. 패스트패션은 14일, 가전제품은 60일이 적절할 수 있습니다.

세 번째로, 현재 재고금액을 일평균 매출원가로 나누어 DSI를 계산합니다. 예를 들어 현재 재고가 300만 원이고 일평균 매출원가가 10만 원이라면, DSI는 30일입니다.

즉, 현재 판매 속도로 한 달이면 재고가 소진된다는 의미입니다. 네 번째로, DSI 값에 따라 재고 상태를 분류합니다.

DSI < 30일은 패스트 무빙(빠른 회전), 30-60일은 정상, 60-90일은 슬로우 무빙(느린 회전), 90일 이상은 데드 스톡 위험으로 분류할 수 있습니다. 이 기준은 업종마다 다르므로 자사 데이터로 벤치마크를 설정하세요.

여러분이 이 코드를 사용하면 매주 또는 매일 DSI 리포트를 생성하여 재고 상태를 모니터링할 수 있습니다. DSI가 급증하는 제품은 수요 감소 신호일 수 있으므로 프로모션을 계획하거나 발주를 중단할 수 있습니다.

반대로 DSI가 급감하는 제품은 인기 급증 신호이므로 긴급 발주나 가격 인상을 고려할 수 있습니다. 실무에서는 DSI를 카테고리별, 브랜드별, 매장별로 집계하여 다양한 관점에서 분석합니다.

또한 DSI 히트맵을 만들어 어떤 카테고리가 과잉재고 위험이 있는지 시각적으로 파악할 수 있습니다.

실전 팁

💡 계절 상품은 시즌 종료일까지 남은 일수와 DSI를 비교하세요. DSI > 남은 일수이면 시즌 내 소진이 불가능하므로 긴급 할인이 필요합니다.

💡 DSI가 음수나 무한대로 나오면 데이터 오류입니다. 재고는 있는데 최근 판매가 없거나, 판매는 있는데 재고가 음수인 경우 데이터를 점검하세요.

💡 프로모션 직후에는 DSI가 일시적으로 증가합니다. 정상 판매 기간의 DSI와 구분하여 평가하세요.

💡 신제품은 출시 초기 DSI가 매우 높게 나옵니다. 최소 1개월 판매 데이터가 쌓인 후 평가하세요.

💡 DSI 추이를 시계열 차트로 만들어 트렌드를 파악하세요. 지속적으로 증가하면 수요 감소 또는 과다 발주 문제가 있다는 신호입니다.


9. 채찍 효과 지표(Bullwhip Effect Metric) - 공급망 전체의 수요 증폭 현상 측정

시작하며

여러분이 소매점의 수요가 10% 증가했는데, 도매상은 20% 증가를 예상하고, 제조사는 30% 증가에 대비해 생산을 늘렸다가 나중에 과잉재고로 고통받는 상황을 본 적 있나요? 이것이 바로 채찍 효과(Bullwhip Effect)입니다.

이런 문제는 다단계 공급망에서 필연적으로 발생합니다. 각 단계에서 약간의 불확실성이 누적되면서 상류로 갈수록 수요 변동이 증폭됩니다.

특히 정보 공유가 부족하거나, 발주 주기가 길거나, 가격 할인이 빈번한 경우 채찍 효과가 심화됩니다. 바로 이럴 때 필요한 것이 채찍 효과 지표입니다.

이 지표는 공급망 각 단계의 수요 변동성을 비교하여 증폭 정도를 정량화합니다.

개요

간단히 말해서, 채찍 효과 지표는 상류 단계의 수요 변동계수(CV)를 하류 단계의 CV로 나눈 비율입니다. 1보다 크면 채찍 효과가 있다는 의미입니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 공급망 관리자는 어느 단계에서 변동성이 증폭되는지 파악해야 하고, 제조팀은 생산 계획의 안정성을 평가해야 하며, 경영진은 공급망 효율화 노력의 성과를 측정해야 합니다. 예를 들어, 채찍 효과가 2라면 소매 수요 변동의 2배가 제조 단계로 전달된다는 의미입니다.

전통적인 방법으로는 "공급망이 불안정하다"는 주관적 평가만 했다면, 이제는 채찍 효과 지표로 정량화하여 "정보 공유 시스템 도입 후 채찍 효과가 2.5에서 1.3으로 감소했습니다"라고 보고할 수 있습니다. 채찍 효과 지표의 핵심 특징은 첫째, 공급망 전체를 평가한다는 점, 둘째, 정보 공유나 협업의 효과를 측정한다는 점, 셋째, 감소 방향이 목표라는 점(낮을수록 좋음)입니다.

이러한 특징들로 인해 채찍 효과는 SCM(Supply Chain Management) 혁신의 핵심 지표입니다.

코드 예제

import polars as pl

# 공급망 각 단계의 발주/수요 데이터
retail_demand = pl.read_csv("retail_demand.csv")  # 소매 실제 판매
warehouse_orders = pl.read_csv("warehouse_orders.csv")  # 창고가 소매로부터 받은 주문
manufacturer_orders = pl.read_csv("manufacturer_orders.csv")  # 제조사가 받은 주문

# 각 단계의 변동계수(CV) 계산
def calculate_cv(df, value_col):
    stats = df.select([
        pl.col(value_col).mean().alias("mean"),
        pl.col(value_col).std().alias("std")
    ])
    mean_val = stats.select("mean").item()
    std_val = stats.select("std").item()
    return (std_val / mean_val) if mean_val > 0 else 0

retail_cv = calculate_cv(retail_demand, "demand")
warehouse_cv = calculate_cv(warehouse_orders, "order_quantity")
manufacturer_cv = calculate_cv(manufacturer_orders, "order_quantity")

# 채찍 효과 지표 계산
bullwhip_warehouse = warehouse_cv / retail_cv if retail_cv > 0 else 0
bullwhip_manufacturer = manufacturer_cv / warehouse_cv if warehouse_cv > 0 else 0

print(f"소매 수요 CV: {retail_cv:.2%}")
print(f"창고 발주 CV: {warehouse_cv:.2%}")
print(f"제조 발주 CV: {manufacturer_cv:.2%}")
print(f"\n채찍 효과 (창고/소매): {bullwhip_warehouse:.2f}")
print(f"채찍 효과 (제조/창고): {bullwhip_manufacturer:.2f}")

# 해석
if bullwhip_warehouse > 1.5:
    print("\n⚠️ 경고: 소매-창고 간 심각한 채찍 효과 발생")
elif bullwhip_warehouse > 1.2:
    print("\n⚠️ 주의: 소매-창고 간 채찍 효과 있음")
else:
    print("\n✅ 양호: 소매-창고 간 채찍 효과 낮음")

설명

이것이 하는 일: 이 코드는 공급망의 각 단계(소매, 창고, 제조)에서 수요/발주의 변동성을 계산하고 비교하여, 정보 왜곡이나 과잉 반응이 얼마나 발생하는지 측정합니다. 첫 번째로, 각 단계의 데이터를 로드합니다.

소매 수요는 실제 고객에게 판매된 수량, 창고 발주는 소매점이 창고에 주문한 수량, 제조 발주는 창고가 제조사에 주문한 수량입니다. 이상적으로는 세 데이터가 같아야 하지만, 실제로는 각 단계에서 안전재고, 배치 발주, 리드타임 등을 고려하면서 차이가 발생합니다.

그 다음으로, 각 단계의 변동계수(CV)를 계산합니다. calculate_cv 함수는 평균과 표준편차를 계산한 후 CV = (표준편차 / 평균)을 반환합니다.

예를 들어 소매 수요의 CV가 20%라는 것은 평균 대비 20%의 변동이 있다는 의미입니다. 세 번째로, 채찍 효과 비율을 계산합니다.

창고 발주 CV를 소매 수요 CV로 나누면 소매-창고 간 증폭 비율을 얻습니다. 예를 들어 소매 CV가 20%이고 창고 CV가 40%라면, 채찍 효과는 2.0입니다.

즉, 소매의 변동이 창고 단계에서 2배로 증폭되었다는 의미입니다. 네 번째로, 결과를 해석하고 액션을 제안합니다.

채찍 효과가 1에 가까우면 이상적(변동 증폭 없음), 1.2-1.5는 주의, 1.5 이상은 심각한 수준입니다. 채찍 효과가 큰 경우 정보 공유 부족, 긴 발주 주기, 배치 발주, 가격 변동 등이 원인일 수 있습니다.

여러분이 이 코드를 사용하면 공급망 개선 프로젝트의 효과를 정량적으로 측정할 수 있습니다. 예를 들어 VMI(Vendor Managed Inventory)나 EDI(Electronic Data Interchange)를 도입하면 채찍 효과가 감소하는지 추적할 수 있습니다.

또한 어느 단계에서 가장 큰 증폭이 발생하는지 파악하여 집중적으로 개선할 수 있습니다. 실무에서는 채찍 효과를 제품 카테고리별로 분석합니다.

프로모션이 빈번한 카테고리일수록 채찍 효과가 크게 나타나므로, EDLP(Everyday Low Price) 정책을 고려할 수 있습니다. 또한 시계열로 추적하여 개선 추이를 모니터링합니다.

실전 팁

💡 채찍 효과를 줄이려면 정보 공유가 핵심입니다. POS 데이터를 공급망 전체와 공유하면 각 단계가 실제 수요를 알 수 있습니다.

💡 발주 주기를 짧게 하세요. 월 1회 발주보다 주 1회 발주가 채찍 효과를 줄입니다. 다만 운송비와 트레이드오프가 있습니다.

💡 가격 프로모션을 줄이세요. 할인 기간에 대량 발주했다가 정상가 기간에 발주하지 않으면 제조사는 큰 변동을 겪습니다.

💡 배치 사이즈를 줄이세요. "100개 단위로만 발주 가능" 같은 제약이 채찍 효과를 증폭시킵니다.

💡 VMI(공급업체 관리 재고)를 도입하세요. 공급업체가 직접 재고를 관리하면 채찍 효과가 크게 감소합니다.


10. 서비스 레벨 달성률(Service Level Achievement) - 품절 없이 고객 수요를 충족한 비율

시작하며

여러분이 재고 최적화 프로젝트를 완료한 후 "재고는 20% 줄였는데, 고객 만족도는 어떻게 되었나요?"라는 질문을 받았을 때, 단순히 "품절이 별로 없었습니다"라고 답할 수는 없습니다. 정량적인 지표가 필요합니다.

이런 문제는 재고 효율화와 고객 서비스 간의 균형을 평가할 때 항상 발생합니다. 재고를 줄이는 것은 쉽지만, 품절이 증가하면 고객을 잃습니다.

반대로 재고를 과도하게 쌓으면 비용이 증가합니다. 최적 균형점을 찾기 위해서는 서비스 레벨을 측정해야 합니다.

바로 이럴 때 필요한 것이 서비스 레벨 달성률입니다. 이 지표는 고객 주문을 재고로 즉시 충족할 수 있었던 비율을 측정하여, 재고 정책의 적절성을 평가합니다.

개요

간단히 말해서, 서비스 레벨 달성률은 (충족된 주문 수 / 전체 주문 수) × 100%입니다. 또는 제품 단위로 (충족된 수량 / 요청된 수량) × 100%로 계산할 수도 있습니다.

왜 이 지표가 필요한지 실무 관점에서 설명하면, 영업팀은 고객 만족도를 알아야 하고, 재고팀은 안전재고가 충분한지 평가해야 하며, 경영진은 재고 투자 대비 서비스 품질을 판단해야 합니다. 예를 들어, 서비스 레벨 95%는 100건의 주문 중 95건은 즉시 충족했고 5건은 품절이었다는 의미입니다.

전통적인 방법으로는 품절 건수만 세었다면, 이제는 서비스 레벨을 목표로 설정(예: 98%)하고 달성 여부를 추적하여 KPI로 관리할 수 있습니다. 서비스 레벨 달성률의 핵심 특징은 첫째, 고객 관점의 지표라는 점, 둘째, 재고 투자의 효과를 측정한다는 점, 셋째, 카테고리별 차별화가 가능하다는 점입니다.

이러한 특징들로 인해 서비스 레벨은 재고 관리의 북극성 지표(North Star Metric)입니다.

코드 예제

import polars as pl

# 주문 데이터 (요청 수량과 실제 배송 수량)
orders_df = pl.read_csv("customer_orders.csv")

# 방법 1: 주문 건수 기준 서비스 레벨 (Order Fill Rate)
order_fill_rate = orders_df.with_columns([
    # 주문이 완전히 충족되었는지 (수량이 일치하는지)
    (pl.col("shipped_quantity") >= pl.col("ordered_quantity")).alias("is_fulfilled")
]).select([
    pl.col("is_fulfilled").mean().alias("order_fill_rate")
]).item()

print(f"주문 충족률 (Order Fill Rate): {order_fill_rate * 100:.2f}%")

# 방법 2: 수량 기준 서비스 레벨 (Item Fill Rate)
item_fill_rate = orders_df.select([
    pl.col("shipped_quantity").sum().alias("total_shipped"),
    pl.col("ordered_quantity").sum().alias("total_ordered")
]).with_columns([
    (pl.col("total_shipped") / pl.col("total_ordered")).alias("item_fill_rate")
]).select("item_fill_rate").item()

print(f"품목 충족률 (Item Fill Rate): {item_fill_rate * 100:.2f}%")

# 제품별 서비스 레벨
product_service_level = orders_df.group_by("product_id").agg([
    pl.col("ordered_quantity").sum().alias("total_ordered"),
    pl.col("shipped_quantity").sum().alias("total_shipped"),
    (pl.col("shipped_quantity") >= pl.col("ordered_quantity")).mean().alias("order_fill_rate")
]).with_columns([
    (pl.col("total_shipped") / pl.col("total_ordered")).alias("item_fill_rate")
]).sort("item_fill_rate")

print("\n제품별 서비스 레벨 (하위 10개):")
print(product_service_level.head(10))

# 목표 대비 달성률
target_service_level = 0.95
achievement = "달성 ✅" if item_fill_rate >= target_service_level else "미달 ❌"
print(f"\n목표 서비스 레벨: {target_service_level * 100:.1f}%")
print(f"실제 서비스 레벨: {item_fill_rate * 100:.2f}% - {achievement}")

설명

이것이 하는 일: 이 코드는 고객 주문 데이터를 분석하여 얼마나 많은 주문이 품절 없이 충족되었는지 측정하고, 서비스 레벨 목표 대비 달성 여부를 평가합니다. 첫 번째로, 주문 건수 기준 서비스 레벨(Order Fill Rate)을 계산합니다.

이는 주문이 완전히 충족된 비율로, 부분 충족도 미충족으로 간주합니다. 예를 들어 고객이 10개를 주문했는데 9개만 배송되었다면 이 주문은 미충족입니다.

이 방식은 고객 관점에서 엄격한 기준입니다. 그 다음으로, 수량 기준 서비스 레벨(Item Fill Rate)을 계산합니다.

이는 요청된 전체 수량 중 배송된 비율로, 부분 충족도 인정합니다. 위 예시에서 10개 중 9개가 배송되었다면 90%의 서비스 레벨입니다.

이 방식이 일반적으로 더 많이 사용됩니다. 세 번째로, 제품별 서비스 레벨을 계산합니다.

전체 평균만 보면 어떤 제품이 문제인지 알 수 없으므로, 제품별로 분석하여 서비스 레벨이 낮은 제품을 찾아냅니다. 이 제품들은 안전재고를 늘리거나, 리드타임을 단축하거나, 예측 모델을 개선해야 할 대상입니다.

네 번째로, 목표 서비스 레벨과 비교합니다. 예를 들어 회사 정책이 95%인데 실제가 92%라면 개선이 필요합니다.

반대로 99%를 달성했다면 재고가 과도할 수 있으므로, 재고를 줄이면서도 95%를 유지할 수 있는지 테스트할 수 있습니다. 여러분이 이 코드를 사용하면 재고 최적화 프로젝트의 영향을 측정할 수 있습니다.

"안전재고 공식 변경 후 재고는 15% 감소했지만 서비스 레벨은 97%에서 96%로 1%p만 하락했습니다"라고 보고할 수 있습니다. 또한 서비스 레벨을 제품 등급별로 차별화할 수 있습니다(A등급 99%, B등급 95%, C등급 90%).

실무에서는 서비스 레벨을 일별로 추적합니다. 월말에만 측정하면 문제 발견이 늦어집니다.

일일 대시보드에 서비스 레벨을 표시하고, 목표 미달 시 알림을 보내도록 설정합니다. 또한 품절 이유를 분류(예측 오류, 공급 지연, 시스템 오류 등)하여 근본 원인을 파악합니다.

실전 팁

💡 서비스 레벨 목표를 제품 등급별로 차별화하세요. 모든 제품에 99%를 적용하면 재고 비용이 과도합니다. A등급 고매출 제품만 높은 서비스 레벨을 유지하세요.

💡 Perfect Order Rate도 함께 측정하세요. 이는 정량, 정시, 무결함 배송을 모두 충족한 주문의 비율로, 더 엄격한 기준입니다.

💡 품절 기간(Stockout Duration)도 추적하세요. 서비스 레벨이 같아도 품절이 1일인 것과 7일인 것은 영향이 다릅니다.

💡 백오더(Backorder)를 구분하세요. 품절이지만 고객이 대기한 경우와 주문 취소된 경우는 다르게 평가해야 합니다.

💡 계절성을 고려하세요. 성수기와 비수기의 서비스 레벨 목표를 다르게 설정할 수 있습니다. 크리스마스 시즌에는 더 높은 서비스 레벨이 필요합니다.


#Python#Polars#데이터분석#재고관리#수요예측#데이터분석,Python,Polars

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.