Devin.KR

R · 심화

함수·객체·모형으로 깊어지는 R

종합 실습 - 지점별 판매·재고 분석 도구

앞 장의 S3 객체·함수형 파이프라인·모형·시뮬레이션을 묶어 지점 비교 보고서와 발주 제안을 출력

개발자KR · 원고 갱신

이 장에서 배우는 것

지점별 판매량을 모으는 것과 발주 판단에 쓸 수 있는 도구를 만드는 것은 다른 작업이다. 분석 도구는 입력 자료의 의미를 확인하고, 같은 기준으로 지점을 비교하며, 계산 결과를 사람이 읽을 수 있는 형태로 내보내야 한다. 판매 예측이 발주 수량으로 바뀌는 과정도 설명할 수 있어야 한다.

이 장에서는 세 지점의 샌드위치 판매 자료로 다음 영업일의 지점 비교 보고서와 발주 제안을 만든다. 앞 장에서 다룬 코드와 검사의 구분을 유지하되, 실행할 파일은 main.R 하나로 모은다. 이미 배운 객체, 함수형 처리, 모형, 재표집을 연결하는 데 집중한다. 각각의 기능을 다시 구현하는 것보다 기능 사이에서 어떤 값이 오가고 어떤 가정을 공유하는지가 중요하다.

  • S3 객체에 분석 결과와 판단 기준을 함께 담는다.
  • 지점별 자료를 나누고 Map으로 같은 분석 함수를 적용한다.
  • 선형 모형의 예측과 잔차 재표집을 발주 목표 재고로 연결한다.
  • 지점 비교표와 PNG 그래프를 저장하고, 직접 만든 검사로 실행 결과를 확인한다.

문제 상황

동네 편의점 체인에 중앙점, 강변점, 주택점이 있다. 담당자는 매일 각 지점의 샌드위치 판매량과 마감 재고를 확인한다. 지점마다 판매 규모가 달라서 판매량이 가장 많은 지점에 무조건 가장 많이 발주하는 방식은 적절하지 않다. 많이 팔리는 지점이라도 남은 재고가 충분하면 추가 발주가 적을 수 있다.

이번 도구는 여섯 영업일의 판매 자료와 마지막 날의 재고를 입력으로 받는다. 다음 영업일의 예상 판매량을 계산하고, 잔차로 만든 수요 시나리오의 90%를 충족하는 목표 재고를 정한다. 목표 재고에서 현재 재고를 뺀 양을 발주 제안으로 출력한다. 재고가 목표보다 많으면 제안 수량은 0이다.

계산을 단순하게 유지하기 위해 몇 가지 운영 조건을 고정한다. 발주품은 다음 영업일 판매가 시작되기 전에 도착하며, 마감 재고는 모두 다음 날 판매할 수 있다고 가정한다. 포장 단위, 폐기, 입고 지연은 고려하지 않는다. 예제 자료에서는 모든 날에 재고가 남으므로 관측 판매량을 수요로 취급한다. 실제로 품절이 있었던 날의 판매량은 수요의 상한을 보여 주지 못하므로 별도 표시와 보정이 필요하다.

출력에는 지점별 평균 판매량, 다음 날 예측, 현재 재고, 목표 재고, 발주 수량이 들어간다. 담당자는 화면에서 요약을 읽고, CSV를 다른 업무에 넘기며, 그래프로 지점 간 차이를 확인한다. 같은 파일을 다시 실행하면 같은 보고서를 얻도록 자료 순서와 난수 상태를 코드에서 정한다.

분석 결과를 객체로 묶기

보고서의 재료를 따로 흩어 놓으면 지점 이름과 모형, 재고가 어긋날 수 있다. 한 지점의 분석 결과를 리스트로 묶고 store_plan이라는 클래스를 붙인다. 객체에는 지점 이름, 평균 판매량, 적합한 모형, 다음 날 예측, 재표집 수요, 목표 재고, 발주 수량이 들어간다. 숫자를 출력할 때마다 원자료를 다시 계산하지 않고, 이미 만든 객체를 읽는다.

생성 함수는 계산이 끝난 객체를 반환한다. print.store_plan은 그 객체를 짧게 보여 주는 역할만 맡는다. 출력 메서드 안에서 모형을 다시 적합하거나 난수를 생성하면 단순히 객체를 보는 행동이 분석 결과를 바꿀 수 있다. 여기서는 계산과 표현을 분리한다.

입력 자료와 지점 분석 객체가 맡는 역할
값주요 내용역할
sales지점, 영업일 번호, 판매량모형에 사용할 관측 자료
inventory지점, 마지막 마감 재고발주에서 차감할 현재 재고
store_plan모형, 예측, 수요 시나리오, 발주한 지점의 분석 결과 보관
report지점별 비교 열화면, CSV, 그래프의 공통 자료

입력 확인도 객체 생성 전에 수행한다. 지점과 영업일 번호의 조합은 중복되면 안 되며, 각 지점에는 여섯 날의 판매 자료가 있어야 한다. 재고표에는 지점당 한 행만 있어야 한다. 재고표의 행 순서가 판매표와 같다는 가정은 하지 않는다. 지점 이름으로 위치를 찾은 뒤 재고를 연결한다.

검사는 작은 예제의 입력 계약을 명시한다. 이 도구는 임의의 기간을 받는 범용 시스템이 아니라, 여섯 날의 자료로 다음 한 날을 제안하는 프로그램이다. 기간을 늘리려면 숫자 하나만 바꾸는 것이 아니라 입력 검사, 예측 시점, 보고서 설명을 함께 바꿔야 한다.

지점별 계산을 하나의 흐름으로 연결하기

함수형 파이프라인(functional pipeline)은 이 장에서 자료를 나누고, 같은 함수를 적용하고, 결과를 모으는 흐름을 뜻한다. split은 지점별 데이터 프레임을 만들고, Map은 각 데이터 프레임과 해당 재고를 분석 함수에 전달한다. 마지막에는 객체마다 보고서 한 행을 만든 뒤 rbind로 합친다.

판매 자료를 지점별로 나누어 같은 함수로 분석하고 결과를 비교표로 모은다

Map에 넘기는 두 목록은 길이만 같다고 충분하지 않다. 같은 위치에 있는 판매 자료와 재고가 같은 지점을 가리켜야 한다. 코드에서는 stores를 기준 순서로 삼고, 판매 목록과 재고 벡터를 모두 그 순서로 정렬한다. 보고서도 같은 순서를 따른다. 우연히 맞는 행 순서에 의존하지 않는 연결이다.

중간 결과인 plans에는 분석 객체가 남는다. 보고서에는 사람이 비교할 열만 옮기지만, 모형과 수요 시나리오는 객체에서 다시 확인할 수 있다. 예측을 검토하려고 원자료부터 모든 작업을 반복할 필요가 없다. 또한 보고서 작성 함수는 객체 하나를 한 행으로 바꾸므로 지점 수가 달라져도 같은 구조를 사용할 수 있다.

이 예제는 Map으로 여러 입력을 함께 전달한다. 입력이 지점 자료 하나뿐이라면 lapply도 자연스럽다. 모든 작업을 한 종류의 함수로 통일하는 것보다 입력과 출력의 구조에 맞는 함수를 선택하는 편이 읽기 쉽다.

예측을 목표 재고와 발주로 바꾸기

각 지점에 sold ~ day 모형을 적합한다. day는 달력 날짜가 아니라 1부터 6까지의 영업일 번호다. predict로 day가 7인 날의 판매량을 구한다. 이 자료에서는 세 지점의 추세가 같고 판매 수준만 다르다. 따라서 예측값은 중앙점 14.2개, 강변점 18.2개, 주택점 12.2개가 된다.

예측값은 평균적인 수준을 나타내므로 그 값만큼 재고를 준비하면 변동을 충분히 반영하지 못한다. 여기서는 모형의 잔차를 다음 날 예측에 더해 가능한 수요를 만든다. 음수 수요는 0으로 제한한다. 이 방식에는 과거 잔차의 분포가 다음 날에도 비슷하다는 가정이 들어간다.

출력을 재현하기 쉽게 만들기 위해 균형 잔차 재표집(balanced residual resampling)을 사용한다. 여섯 잔차를 각각 100번 반복한 600개 목록을 만든 뒤, sample로 그 순서만 섞는다. 잔차마다 같은 비중을 주므로 전체 수요 분포와 분위수는 난수 순서에 영향을 받지 않는다. set.seed는 객체에 저장되는 시나리오의 순서까지 재현하기 위해 지정한다.

이것은 모형을 반복 적합하는 부트스트랩이 아니다. 적합한 모형을 고정하고 잔차를 다음 날의 변동 시나리오로 옮긴다. 잔차를 100번 반복했다고 새로운 관측 정보가 늘어나는 것도 아니다. 반복은 동일한 비중의 시나리오 목록을 만들기 위한 장치다. 예측계수의 추정 오차와 미래 추세 변화는 이 계산에 들어 있지 않다.

목표 재고는 수요 시나리오의 90% 분위수를 올림한 정수다. quantile의 type = 1을 지정해 관측 시나리오 값 중 하나를 분위수로 선택한다. 발주량은 목표 재고와 현재 재고의 차이를 0 이상으로 제한한다. 실제 판매 수량은 정수지만, 모형 예측과 잔차 시나리오는 연속값으로 유지하고 재고 결정 단계에서 올림한다.

예측에 잔차를 더한 수요 분포에서 목표 재고를 정하고 현재 재고를 빼 발주량을 구한다

시나리오 충족률은 준비한 재고가 수요 시나리오 이상인 비율이다. 결과가 90% 이상인지 검사하지만, 이를 실제 운영의 서비스 수준 보장으로 해석하면 안 된다. 이 예제에서는 잔차 값이 여섯 종류뿐이고 정수로 올림하므로 모든 시나리오를 충족한다. 목표 비율보다 높은 결과가 나오는 이유도 함께 읽어야 한다.

모형과 정책은 분리해 둔다. 모형은 수요를 예측하고, 정책은 어떤 분위수를 재고 목표로 삼을지 결정한다. 같은 모형에도 비용과 품절 허용 수준에 따라 다른 목표 비율을 적용할 수 있다. 여기서는 비교를 위해 세 지점에 같은 0.90을 적용한다.

완성 코드

다음 내용을 UTF-8로 저장한 main.R에서 실행한다. 외부 패키지는 사용하지 않는다. 실행한 디렉터리에 지점 비교 CSV와 PNG가 생성된다. 같은 이름의 기존 파일은 덮어쓴다. 그래프 장치의 한글 글꼴 차이를 줄이기 위해 그림 안의 지점 표시는 A, B, C로 쓰며, 화면과 CSV에는 한국어 지점 이름을 유지한다.

options(warn = 2)
set.seed(20261007)

stores <- c("중앙점", "강변점", "주택점")
base_sales <- c(10, 12, 11, 13, 12, 14)
offsets <- c(0, 4, -2)

sales <- data.frame(
  store = rep(stores, each = 6L),
  day = rep(seq_len(6L), times = 3L),
  sold = unlist(lapply(offsets, function(x) base_sales + x)),
  stringsAsFactors = FALSE
)
inventory <- data.frame(
  store = c("주택점", "중앙점", "강변점"),
  stock = c(4L, 7L, 15L),
  stringsAsFactors = FALSE
)

check <- function(ok, message) {
  if (!isTRUE(ok)) stop(message, call. = FALSE)
  invisible(TRUE)
}

validate_inputs <- function(sales, inventory, stores) {
  check(all(c("store", "day", "sold") %in% names(sales)),
        "판매 자료의 열을 확인한다.")
  check(all(c("store", "stock") %in% names(inventory)),
        "재고 자료의 열을 확인한다.")
  check(!anyNA(sales) && !anyNA(inventory),
        "입력에 결측값이 있다.")
  check(is.numeric(sales$day) && is.numeric(sales$sold) &&
          is.numeric(inventory$stock), "수량과 영업일은 숫자여야 한다.")
  check(all(is.finite(sales$day)) &&
          all(is.finite(sales$sold)) &&
          all(is.finite(inventory$stock)), "유한한 값만 허용한다.")
  check(all(sales$sold >= 0 & sales$sold == floor(sales$sold)) &&
          all(inventory$stock >= 0 &
                inventory$stock == floor(inventory$stock)),
        "판매량과 재고는 0 이상의 정수여야 한다.")
  check(setequal(sales$store, stores) &&
          setequal(inventory$store, stores), "지점 목록이 다르다.")
  check(!anyDuplicated(sales[c("store", "day")]),
        "지점과 영업일이 중복된다.")
  check(!anyDuplicated(inventory$store),
        "재고표에 중복 지점이 있다.")
  days <- split(sales$day, sales$store)
  check(all(vapply(days, function(x) {
    identical(sort(as.integer(x)), seq_len(6L)) &&
      all(x == floor(x))
  }, logical(1))), "각 지점에 1부터 6까지의 영업일이 필요하다.")
  invisible(TRUE)
}

new_store_plan <- function(data, stock, service = 0.90) {
  check(is.numeric(service) && length(service) == 1L &&
          is.finite(service) && service > 0 && service < 1,
        "목표 비율은 0과 1 사이여야 한다.")
  data <- data[order(data$day), ]
  fit <- lm(sold ~ day, data = data)
  forecast <- max(0, as.numeric(predict(
    fit, newdata = data.frame(day = 7L)
  )))
  residual_pool <- rep(as.numeric(residuals(fit)), times = 100L)
  demand <- pmax(0, forecast + sample(
    residual_pool, size = length(residual_pool), replace = FALSE
  ))
  target <- as.integer(ceiling(unname(quantile(
    demand, probs = service, type = 1
  ))))
  structure(list(
    store = as.character(data$store[1L]),
    mean_sales = mean(data$sold),
    model = fit,
    forecast = forecast,
    demand = demand,
    service = service,
    stock = as.integer(stock),
    target = target,
    order = max(0L, target - as.integer(stock)),
    coverage = mean(demand <= target)
  ), class = "store_plan")
}

print.store_plan <- function(x, ...) {
  cat(sprintf("%s: 목표 %d개, 재고 %d개, 발주 %d개\n",
              x$store, x$target, x$stock, x$order))
  invisible(x)
}

report_row <- function(x) {
  data.frame(
    store = x$store,
    mean_sales = x$mean_sales,
    forecast = x$forecast,
    stock = x$stock,
    target = x$target,
    order = x$order,
    coverage = x$coverage,
    stringsAsFactors = FALSE
  )
}

expect_error <- function(f) {
  caught <- tryCatch({
    f()
    FALSE
  }, error = function(e) TRUE)
  check(caught, "예상한 오류가 발생하지 않았다.")
}

validate_inputs(sales, inventory, stores)
parts <- split(sales, sales$store)[stores]
stocks <- inventory$stock[match(stores, inventory$store)]
plans <- Map(new_store_plan, parts, stocks)
report <- do.call(rbind, lapply(plans, report_row))
rownames(report) <- NULL

stopifnot(all(vapply(plans, inherits, logical(1), "store_plan")))
stopifnot(isTRUE(all.equal(report$forecast, c(14.2, 18.2, 12.2))))
stopifnot(identical(report$order, c(9L, 5L, 10L)))
stopifnot(all(report$coverage >= 0.90))

bad_inventory <- inventory
bad_inventory$stock[1L] <- -1L
expect_error(function() {
  validate_inputs(sales, bad_inventory, stores)
})

cat("지점 비교 보고서: 다음 영업일 7\n")
cat("지점 | 평균 판매 | 예측 판매 | 재고 | 목표 | 발주 | 시나리오 충족률\n")
for (i in seq_len(nrow(report))) {
  cat(sprintf("%s | %.1f | %.1f | %d | %d | %d | %.1f%%\n",
              report$store[i], report$mean_sales[i],
              report$forecast[i], report$stock[i],
              report$target[i], report$order[i],
              100 * report$coverage[i]))
}
cat("\n발주 제안\n")
invisible(lapply(plans, print))
cat("검사: 5개 통과\n")

write.csv(report, "store_report.csv",
          row.names = FALSE, fileEncoding = "UTF-8")

save_plot <- function(report, filename) {
  png(filename, width = 960, height = 600)
  on.exit(invisible(dev.off()), add = TRUE)
  values <- rbind(report$forecast, report$stock, report$target)
  barplot(
    values, beside = TRUE,
    names.arg = c("A", "B", "C"),
    col = c("#e1edf7", "#315675", "#c0392b"),
    border = NA,
    ylim = c(0, max(values) * 1.3),
    main = "Next-day demand and inventory",
    xlab = "Store", ylab = "Units",
    legend.text = c("Forecast", "Stock", "Target"),
    args.legend = list(x = "topleft", bty = "n")
  )
  invisible(filename)
}

save_plot(report, "store_plan.png")
cat("CSV 저장: store_report.csv\n")
cat("그래프 저장: store_plan.png\n")
cat("그래프 지점: A=중앙점, B=강변점, C=주택점\n")

줄별 해설

options(warn = 2)는 경고를 오류로 바꾼다. 예제 실행에서는 경고가 발생하지 않으며, 예상하지 못한 경고가 생기면 결과 파일을 정상 산출물로 취급하지 않도록 실행을 멈춘다. 모든 경고를 무시하는 설정과는 목적이 다르다. set.seed는 뒤에서 잔차 목록을 섞을 때 사용하는 난수 상태를 고정한다.

stores는 지점 순서의 기준이다. base_sales에 지점별 offsets를 더해 판매 자료를 만든다. 중앙점의 판매량은 10, 12, 11, 13, 12, 14이고, 강변점은 각각 4개 많으며, 주택점은 각각 2개 적다. inventory는 일부러 다른 순서로 작성했다. 이름으로 연결하는 코드가 필요한 상황을 자료 안에 넣은 것이다.

check는 조건 하나와 설명 문장을 받는다. isTRUE를 사용하므로 조건이 TRUE 하나일 때만 통과한다. NULL이나 NA를 정상으로 취급하지 않는다. validate_inputs는 열 이름을 먼저 확인하고, 그다음 결측값과 숫자 여부, 값의 범위, 지점과 날짜의 중복을 확인한다. 앞선 검사에 실패하면 뒤의 계산을 수행하지 않는다.

영업일 검사에서는 지점별 번호를 정렬해 1부터 6까지와 비교한다. 정수 변환 결과만 비교하면 1.5 같은 값이 1로 바뀌어 통과할 수 있으므로, 원래 값이 정수인지도 함께 확인한다. 지점별 여섯 날이라는 입력 계약은 이 부분에서 실행 가능한 조건이 된다.

new_store_plan은 이미 검증한 지점 자료와 재고를 받는 내부 생성 함수다. 공개 입력 경계의 검사는 validate_inputs가 맡고, 생성 함수는 정책 매개변수인 service를 추가로 확인한다. 함수 안에서 자료를 영업일 순서로 정렬하므로 입력 행이 섞여 있어도 계산과 저장 순서가 일정하다.

lm은 각 지점의 판매량과 영업일 번호 사이의 선형 관계를 적합한다. predict에는 새 데이터 프레임을 전달한다. 모형이 사용한 변수 이름이 day이므로 새 자료도 그 이름을 가져야 한다. max(0, ...)는 음수 판매 예측을 제한하는 운영 규칙이다. 선형 모형 자체가 음수를 막아 주는 것은 아니다.

residual_pool은 여섯 잔차를 같은 횟수로 반복한 벡터다. sample은 비복원 방식으로 전체 벡터를 섞는다. 값의 구성은 유지하고 배치 순서만 바뀐다. demand는 그 잔차를 forecast에 더한 결과이며, pmax는 각 시나리오의 음수 값을 개별적으로 0으로 바꾼다.

quantile은 목표 비율에 해당하는 수요를 고르고, ceiling은 그 값을 올림한다. unname은 분위수에 붙는 이름을 제거한다. 구조를 만드는 마지막 표현식은 계산한 값들을 리스트에 넣고 클래스를 지정한다. 함수가 끝나면 이 객체가 반환된다.

print.store_plan의 sprintf는 값의 표시 형식을 정한다. 발주량에는 정수 형식을 사용한다. invisible(x)를 반환하므로 출력한 객체를 다시 전달할 수 있으면서 불필요한 추가 표시를 막는다. report_row는 같은 객체를 데이터 프레임 한 행으로 바꾸며 모형과 시나리오 벡터는 비교표에서 제외한다.

split 뒤의 [stores]는 지점별 목록을 기준 순서로 다시 배치한다. match는 각 지점 이름이 재고표의 어느 위치에 있는지 찾는다. 이 두 결과를 Map에 전달하면 같은 위치의 자료와 재고가 함께 new_store_plan에 들어간다. rbind는 세 보고서 행을 합치고, 행 이름은 CSV에 의미 없는 식별자로 남지 않도록 제거한다.

네 stopifnot은 객체 클래스, 예측값, 발주값, 시나리오 충족률을 확인한다. 실수 예측에는 all.equal을 사용하고 정수 발주에는 identical을 사용한다. 다섯 번째 검사는 음수 재고를 넣었을 때 오류가 발생하는지 확인한다. expect_error가 해당 오류를 받아 처리하므로 정상 실행의 화면에 오류 문장이 나타나지 않는다.

화면 출력은 데이터 프레임 기본 출력 대신 cat과 sprintf로 작성한다. 콘솔 폭에 따라 열이 나뉘거나 행 이름이 추가되는 영향을 피하고, 예상 출력과 일치하는 형식을 유지한다. CSV에는 반올림 전 숫자가 기록되고, 화면에는 예측과 평균을 소수점 한 자리로 보여 준다.

save_plot은 그래프 장치를 연 뒤 on.exit로 닫는 작업을 예약한다. 그림을 그리다 오류가 나도 장치를 닫는다. dev.off의 반환값은 invisible로 감싸 화면에 장치 번호가 나타나지 않게 한다. 그래프는 예측, 현재 재고, 목표 재고를 나란히 보여 주며 발주량은 표에서 확인한다.

실행 결과

터미널에서 main.R을 저장한 디렉터리로 이동한 뒤 실행한다. R 4.5 이상과 PNG 출력을 지원하는 R 환경을 기준으로 한다.

Rscript main.R

표준 출력은 다음과 같다.

지점 비교 보고서: 다음 영업일 7
지점 | 평균 판매 | 예측 판매 | 재고 | 목표 | 발주 | 시나리오 충족률
중앙점 | 12.0 | 14.2 | 7 | 16 | 9 | 100.0%
강변점 | 16.0 | 18.2 | 15 | 20 | 5 | 100.0%
주택점 | 10.0 | 12.2 | 4 | 14 | 10 | 100.0%

발주 제안
중앙점: 목표 16개, 재고 7개, 발주 9개
강변점: 목표 20개, 재고 15개, 발주 5개
주택점: 목표 14개, 재고 4개, 발주 10개
검사: 5개 통과
CSV 저장: store_report.csv
그래프 저장: store_plan.png
그래프 지점: A=중앙점, B=강변점, C=주택점

강변점은 예측 판매량이 가장 크지만 발주 제안은 가장 작다. 이미 재고가 15개 있기 때문이다. 주택점은 판매 규모가 가장 작지만 남은 재고가 4개여서 10개를 발주한다. 이 결과는 판매 규모와 추가 입고 필요량을 구분해서 읽어야 함을 보여 준다.

세 지점의 충족률이 모두 100%인 것은 실제 품절 가능성이 없다는 뜻이 아니다. 현재 시나리오에서 가장 큰 수요도 올림한 목표 재고보다 작다는 뜻이다. 여섯 날의 잔차로 만든 시나리오가 행사, 날씨, 공급 지연까지 대표하지는 않는다. 보고서가 어떤 자료와 가정에서 나온 것인지 함께 전달해야 한다.

store_report.csv에는 영어 열 이름과 한국어 지점 이름이 저장된다. store_plan.png에는 A, B, C 순서로 각 지점의 막대가 나타난다. 숫자와 화면 출력은 결정적이지만 PNG의 글꼴과 세부 렌더링은 운영체제와 그래프 장치에 따라 조금 달라질 수 있다.

실무에서 자주 틀리는 것

재고를 행 순서로 연결하기

판매 목록과 재고표의 순서가 다르면 길이가 같아도 다른 지점의 재고가 들어간다. 다음 코드는 이 예제에서 중앙점에 주택점 재고를 전달한다.

# 틀린 코드
stocks <- inventory$stock
plans <- Map(new_store_plan, parts, stocks)

# 고친 코드
stocks <- inventory$stock[match(stores, inventory$store)]
plans <- Map(new_store_plan, parts, stocks)

이름으로 연결하기 전에는 재고표의 지점 중복과 누락을 확인해야 한다. match는 중복 이름 중 첫 위치를 사용하므로 연결 함수만으로 자료의 일관성이 보장되지는 않는다.

목표 재고를 그대로 발주량으로 쓰기

목표 재고는 판매 시작 전에 확보할 총량이다. 이미 남은 재고를 빼지 않으면 필요한 양보다 많이 주문한다. 목표보다 재고가 많을 때 음수 발주가 나오지 않도록 제한도 필요하다.

# 틀린 코드
order <- target

# 고친 코드
order <- max(0L, target - stock)

이 식은 재고가 다음 날에도 모두 판매 가능하다는 가정에서 성립한다. 유통기한 때문에 일부 재고를 사용할 수 없다면 그 수량을 먼저 제외한 재고를 전달해야 한다.

반복 시나리오 수를 관측 자료 수로 해석하기

600개 시나리오를 만들었어도 원래 관측 자료는 여섯 날이다. 반복 횟수로 표본 크기를 부풀려 정밀도가 높아졌다고 설명하면 안 된다. 객체에 두 수를 남기려면 의미를 구분한다.

# 틀린 코드
observations <- length(demand)

# 고친 코드
observations <- nrow(data)
scenarios <- length(demand)

이 수정은 새 모형 계산을 추가하지 않는다. 보고서의 숫자가 무엇을 세는지 바로잡는다. 예측 불확실성을 더 넓게 평가하려면 추정 오차와 운영 변동을 포함하는 별도 설계가 필요하다.

실수 계산 결과를 정확한 일치로 검사하기

출력에는 14.2가 보여도 내부 계산값은 부동소수점 표현의 작은 차이를 가질 수 있다. 숫자 계산의 의도는 허용 오차 안에서 확인한다.

# 틀린 코드
stopifnot(identical(report$forecast, c(14.2, 18.2, 12.2)))

# 고친 코드
stopifnot(isTRUE(all.equal(
  report$forecast, c(14.2, 18.2, 12.2)
)))

정수 발주처럼 자료형까지 계약에 포함되는 값에는 identical이 적합하다. 모든 비교를 느슨하게 바꾸는 것이 아니라 값의 성격에 따라 검사 방식을 고른다.

한눈에 보기

판매 자료가 발주 보고서가 되는 단계와 확인할 조건
단계사용한 도구확인할 조건결과
입력 검증check, 중복 검사지점, 날짜, 수량의 일관성계산 가능한 자료
지점별 처리split, match, Map판매와 재고의 지점 일치분석 객체 목록
판매 예측lm, predict예측 시점과 변수 이름다음 날 평균 수준
수요 시나리오residuals, rep, sample잔차 변동을 옮길 수 있는가균형 잔차 시나리오
발주 결정quantile, ceiling, max목표 비율과 가용 재고목표 재고와 추가 발주
전달과 검증S3 출력, CSV, PNG, 검사표현과 계산의 일치재현 가능한 보고서

다른 도구를 사용하는 코드와 비교할 때도 먼저 역할을 대응시키면 된다. 아래 도구는 이 프로그램에서 설치하거나 실행하지 않는다.

base R 구현을 다른 작업 방식과 비교할 때의 대응 관계
작업이 장의 구현대응하는 도구
지점별 함수 적용split과 Maptidyverse의 purrr::map2
여러 보고서 행 결합do.call과 rbindtidyverse의 dplyr::bind_rows
계산 결과 검사stopifnot과 all.equaltestthat::expect_equal
예상 오류 검사직접 만든 expect_errortestthat::expect_error

완성된 도구의 핵심은 특정 함수 이름의 조합보다 연결 규칙에 있다. 입력 검사는 자료의 의미를 고정하고, 객체는 한 지점의 결과를 보관하며, 보고서는 같은 결과를 비교 가능한 형태로 내보낸다. 모형과 재표집은 발주 정책의 근거를 제공하지만 운영 가정을 대신 결정하지는 않는다.

연습 문제

  1. inventory의 세 행을 역순으로 바꿔도 보고서의 재고와 발주량이 같음을 검사하라. 난수를 다시 생성하지 않고 기존 parts와 새 재고 연결을 사용해도 된다.
  2. 중앙점의 재고를 30개로 바꾸고 다시 분석하라. 목표 재고와 발주량이 각각 얼마인지 설명하고, 기존 발주값 검사를 어떻게 수정할지 작성하라.
  3. 목표 비율을 0.90에서 0.50으로 바꿔 세 지점을 다시 분석하라. 목표 재고와 발주량을 구하고, 목표 비율을 낮춰도 모든 지점에 같은 양을 발주하지 않는 이유를 설명하라.
  4. 보고서에 excess라는 열을 추가해 현재 재고가 목표 재고보다 많은 양을 표시하라. 기본 자료와 중앙점 재고 30개 자료에서 값을 확인하라. 이 열을 폐기량으로 해석할 수 있는지도 설명하라.

정답과 해설

첫 번째 문제는 연결 기준을 확인하는 검사다. 재고표를 뒤집은 뒤에도 이름으로 찾으면 stocks와 같은 벡터를 얻는다.

reversed_inventory <- inventory[rev(seq_len(nrow(inventory))), ]
reversed_stocks <- reversed_inventory$stock[
  match(stores, reversed_inventory$store)
]
stopifnot(identical(stocks, reversed_stocks))
reversed_plans <- Map(new_store_plan, parts, reversed_stocks)
stopifnot(identical(
  vapply(reversed_plans, function(x) x$order, integer(1)),
  vapply(plans, function(x) x$order, integer(1))
))

새로 분석하면 난수 상태가 진행되어 시나리오 순서는 달라질 수 있다. 그러나 이 방식은 잔차의 구성과 비중을 고정하므로 목표 재고와 발주량은 같다. 객체 전체의 동일성을 검사하는 대신 문제에서 요구한 결과를 검사한다.

두 번째 문제에서 중앙점의 목표 재고는 16개로 유지되고 발주량은 0개다. 재고는 판매 모형이나 수요 시나리오에 들어가지 않으므로 목표를 바꾸지 않는다.

more_inventory <- inventory
more_inventory$stock[more_inventory$store == "중앙점"] <- 30L
validate_inputs(sales, more_inventory, stores)
more_stocks <- more_inventory$stock[
  match(stores, more_inventory$store)
]
more_plans <- Map(new_store_plan, parts, more_stocks)
more_report <- do.call(rbind, lapply(more_plans, report_row))
stopifnot(identical(more_report$order, c(0L, 5L, 10L)))
stopifnot(more_report$target[1L] == 16L)

세 번째 문제에서는 type = 1의 50% 분위수가 정렬된 여섯 잔차 중 세 번째 값에 대응한다. 잔차는 약 −0.943, −0.686, −0.429, 0.429, 0.686, 0.943이다. 중앙점은 약 13.771을 올림해 목표가 14개가 된다. 같은 방식으로 강변점은 18개, 주택점은 12개다.

half_plans <- Map(function(data, stock) {
  new_store_plan(data, stock, service = 0.50)
}, parts, stocks)
half_report <- do.call(rbind, lapply(half_plans, report_row))
stopifnot(identical(half_report$target, c(14L, 18L, 12L)))
stopifnot(identical(half_report$order, c(7L, 3L, 8L)))

목표 비율이 같아도 판매 수준과 현재 재고가 다르므로 발주량은 다르다. 목표 비율을 낮추는 것은 작은 재고로 더 많은 수요 시나리오를 감수하는 정책 변경이다. 실제로 적용할 때는 품절과 잔여 재고의 비용을 함께 검토해야 한다.

네 번째 문제는 여러 행을 한 번에 계산하므로 max가 아니라 pmax를 사용한다. max는 벡터 전체의 최댓값 하나를 반환한다.

report$excess <- pmax(0L, report$stock - report$target)
more_report$excess <- pmax(
  0L, more_report$stock - more_report$target
)
stopifnot(identical(report$excess, c(0L, 0L, 0L)))
stopifnot(identical(more_report$excess, c(14L, 0L, 0L)))

기본 자료의 초과량은 모두 0개이며, 재고를 바꾼 중앙점은 14개다. 초과량은 이번 정책의 목표보다 많은 재고일 뿐 폐기량은 아니다. 유통기한과 다음 날 이후의 판매 가능성을 알아야 폐기를 판단할 수 있다.

이 도구는 입력부터 발주 제안까지 실행 가능한 한 흐름을 갖는다. 다음에 실제 자료를 연결할 때는 먼저 입력 계약과 운영 가정을 확인한다. 그 기준이 유지되면 자료를 바꾸어도 객체 생성, 지점 비교, 파일 저장, 검사라는 구조를 이어 사용할 수 있다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.