Devin.KR

함수형 프로그래밍 - Map·Filter·Reduce

개발자KR 조회 0

이 장에서 배우는 것

편의점 지점이 늘어나면 같은 분석을 여러 자료에 적용하는 일이 잦아진다. 지점마다 결측값을 정리하고 매출을 계산한 뒤, 조건을 만족하는 지점만 골라 전체 실적을 집계한다. 이 작업을 하나의 긴 반복문으로 작성할 수도 있다. 그러나 처리 규칙을 작은 함수로 나누면 어떤 계산을 반복하고 어떤 결과를 선택하는지 더 분명하게 드러난다.

앞 장에서 함수가 자신을 둘러싼 환경의 값을 찾는 방법을 살펴보았다. 여기서는 함수를 다른 함수의 입력으로 전달하여 분석의 흐름을 구성한다. 이미 익숙한 벡터화와 apply 계열에서 한 걸음 더 나아가, 여러 입력을 함께 처리하는 Map, 조건으로 원소를 고르는 Filter, 여러 결과를 하나로 접는 Reduce를 사용한다. 목적은 반복문을 모두 없애는 것이 아니라 반복의 역할을 코드에 드러내는 데 있다.

  • 고차 함수와 익명 함수를 이용하여 처리 규칙을 값처럼 전달한다.
  • Map·Filter·Reduce의 입력과 반환값을 구분하고 빈 입력의 결과를 설계한다.
  • do.call로 리스트에 담긴 인자를 함수 호출로 연결한다.
  • 함수 합성과 순수 함수로 지점별 판매 분석을 작은 단계로 나눈다.
  • 한 파일로 실행되는 분석 프로그램을 만들고 계산 규칙을 검사한다.

문제 상황

동네 편의점 체인에 가람점, 나루점, 다온점이 있다. 각 지점은 우유, 도시락, 빵의 하루 판매수량과 단가, 마감 재고를 전달한다. 자료의 열은 같지만 판매수량에 결측값이 들어올 수 있다. 본사에서는 지점별 판매수량, 매출, 보충이 필요한 품목 수를 확인하고, 하루 매출이 10,000원 이상인 지점의 합계도 계산하려 한다.

이 예제에서는 집계 전에 확인을 마쳤다는 가정 아래 판매수량의 결측값을 0으로 바꾼다. 실제 업무에서 결측값은 미보고와 판매 없음 중 어느 쪽인지 확인해야 한다. 함수형으로 코드를 작성해도 이러한 업무 판단이 자동으로 해결되지는 않는다. 여기서는 규칙을 명시적으로 정하고 그 규칙이 적용되는 위치를 한 함수에 모은다.

보충 대상은 마감 재고가 3개 미만인 품목이다. 판매수량과 재고를 비교하여 보충 여부를 정하지 않는다. 판매는 당일 흐름이고 마감 재고는 집계 시점의 상태이기 때문이다. 또한 서로 다른 지점의 같은 상품은 별개의 보충 대상으로 센다. 두 지점에서 우유를 보충해야 한다면 보충품목 합계는 2다.

지점 이름을 바꿔 가며 코드를 복사하면 어느 한 지점에만 예전 기준이 남기 쉽다. 하나의 반복문 안에 정리, 계산, 선택, 집계, 출력을 모두 넣으면 수정할 부분을 찾기도 어렵다. 따라서 먼저 지점 하나를 처리하는 함수를 만들고, 그 함수를 지점 목록 전체에 적용한다. 지점별 결과가 만들어진 뒤 선택과 합산을 수행한다.

함수를 입력으로 받는 함수

고차 함수(higher-order function)는 함수를 인자로 받거나 함수를 반환하는 함수다. 함수형 프로그래밍(functional programming)은 이러한 함수를 이용하여 계산 규칙과 계산의 흐름을 조합하는 방식이다. R의 함수는 변수에 저장하거나 리스트에 담을 수 있는 값이다. 함수 이름을 다른 함수에 전달하면 그 함수는 필요한 시점에 전달받은 규칙을 실행할 수 있다.

함수 이름과 함수 호출 결과는 구분해야 한다. clean_sales는 함수 자체를 가리키고, clean_sales(x)는 x를 처리한 결과를 가리킨다. Map에는 원소를 처리할 함수가 필요하므로 첫 번째 인자로 함수 자체를 전달한다. 각 원소에 대한 호출은 Map이 수행한다.

double_quantity <- function(x) x * 2
Map(double_quantity, list(2, 5, 7))

이 호출은 2, 5, 7을 각각 함수에 전달하고 결과를 리스트에 담는다. 반환값은 세 원소를 가진 리스트이지 하나의 숫자 벡터가 아니다. 결과를 벡터로 단순화하지 않는다는 점은 이후에 각 결과가 데이터 프레임이나 복잡한 목록이 되어도 같은 흐름을 유지할 수 있게 한다.

익명 함수(anonymous function)는 별도의 이름을 붙이지 않고 필요한 자리에 작성한 함수다. R 4.5에서는 function(x) x * 2를 \(x) x * 2로 쓸 수 있다. 두 표기는 같은 종류의 함수를 만든다. 본문이 길면 중괄호를 사용한다. 짧은 변환이나 조건은 익명 함수가 읽기 좋지만 업무 규칙이 여러 줄로 늘어나면 이름 있는 함수로 분리하는 편이 낫다.

Map(\(x) x * 2, list(2, 5, 7))

Map(\(x) {
  adjusted <- x + 1
  adjusted * 2
}, list(2, 5, 7))

Map은 여러 입력도 함께 순회한다. 지점 이름의 첫 원소와 자료 목록의 첫 원소를 함수에 함께 전달하고, 다음 위치에서도 같은 일을 한다. 지점 이름과 지점 자료를 연결하는 데 적합하다. 다만 짧은 입력은 재활용될 수 있다. 업무상 일대일 대응이어야 한다면 호출 전에 길이가 같은지 검사해야 한다.

익명 함수 안에서 외부의 기준값을 참조할 수도 있다. 이때 함수는 앞 장에서 다룬 환경의 규칙에 따라 값을 찾는다. 이번 프로그램에서는 매출 선택 기준을 min_revenue에 저장한다. 지점 요약 함수에는 재고 기준을 인자로 전달하여 같은 자료에 다른 기준을 적용하기 쉽게 만든다.

분석의 각 역할에 맞는 base R 함수
함수입력의 역할반환값편의점 예제
Map함수와 대응하는 입력 원소들각 호출 결과의 리스트지점마다 요약 만들기
Filter조건 함수와 원소 목록조건을 만족하는 원소들매출 기준을 넘는 요약 선택
Reduce결합 함수와 원소 목록반복 결합한 결과선택된 지점의 합계 계산
do.call호출할 함수와 인자 목록해당 함수의 호출 결과요약 행들을 한 표로 결합

변환하고, 고르고, 하나로 접기

Map은 입력 원소마다 처리 결과를 만든다. Filter는 원소를 바꾸지 않고 남길 원소를 고른다. Reduce는 누적 결과와 다음 원소를 계속 결합한다. 세 함수는 모두 함수를 입력으로 받지만 그 함수에 요구하는 역할은 다르다. 같은 익명 함수라도 어느 위치에 전달하는지에 따라 입력과 반환값의 계약이 달라진다.

Filter에 전달하는 조건 함수는 원소 하나를 받아 길이 1의 TRUE 또는 FALSE를 반환하도록 작성한다. 이번 예제의 원소는 지점별 한 행짜리 요약표다. 따라서 row$revenue[[1]]을 꺼내 기준과 비교한다. Filter가 남기는 것은 매출 숫자만이 아니라 조건을 만족한 요약표 전체다.

Map은 지점별 결과를 만들고 Filter는 결과를 고르며 Reduce는 선택된 결과를 하나로 합친다

Reduce의 결합 함수는 일반적으로 두 인자를 받는다. 첫 인자는 현재 누적값이고 두 번째 인자는 다음 원소다. 왼쪽부터 누적하는 기본 동작에서 init을 주면 먼저 init과 첫 원소를 결합하고, 그 결과를 두 번째 원소와 결합한다. 이때 결합 함수의 반환값은 다음 호출의 누적값으로 다시 사용된다.

합계를 구할 때 init은 이름 있는 0 벡터로 정한다. 판매수량, 매출, 보충품목 수를 같은 순서로 배치한 벡터다. 누적값과 다음 요약을 더하면 다시 같은 형태의 벡터가 나온다. 누적값의 형태를 일정하게 유지하면 첫 호출과 이후 호출을 따로 처리할 필요가 줄어든다.

초깃값은 빈 입력에서도 의미가 있다. 매출 기준을 높여 모든 지점이 제외되면 선택 결과는 빈 리스트가 된다. init을 지정한 Reduce는 이 경우 초깃값을 그대로 반환한다. 따라서 지점이 하나도 선택되지 않았을 때 합계가 0이라는 업무 규칙을 코드에 담을 수 있다. 초깃값 없이 빈 목록을 처리하여 NULL을 얻는 흐름과는 결과의 형태가 다르다.

모든 연산에 0이 적절한 것은 아니다. 곱셈을 누적한다면 1이 자연스럽고, 문자열을 모은다면 빈 문자열이나 빈 벡터를 고려해야 한다. 평균도 지점 평균을 계속 더하는 방식으로 계산해서는 안 된다. 전체 평균이 필요하면 분자에 해당하는 합계와 분모에 해당하는 개수를 함께 누적한 뒤 마지막에 나누어야 한다.

do.call은 반복 함수가 아니라 호출을 구성하는 함수다. do.call(rbind, summaries)는 summaries의 각 원소를 별개의 인자로 펼쳐 rbind를 호출한다. 지점 요약 세 개가 들어 있다면 rbind(first, second, third)와 같은 형태의 호출을 만든다. rbind(summaries)처럼 리스트 자체를 한 인자로 전달하는 것과 구분해야 한다.

인자 목록의 이름도 중요하다. do.call은 이름 있는 원소를 이름 있는 인자로 전달한다. 이번 코드에서는 표를 결합하기 전에 unname(summaries)로 지점 이름을 제거한다. 지점 이름은 이미 branch 열에 담겨 있으므로 호출 인자의 이름으로도 사용할 필요가 없다. 결과의 행 이름은 별도로 지워 표의 행 위치와 지점 정보를 구분한다.

순수 함수로 단계를 나누고 합성하기

순수 함수(pure function)는 같은 입력에 같은 결과를 내고 함수 밖의 상태를 바꾸지 않는 함수다. 분석 과정에서 파일을 쓰거나 화면에 출력하는 일도 필요하지만, 계산 함수마다 이런 일을 섞으면 결과를 재사용하거나 검사하기 어려워진다. 이번 프로그램은 정리, 계산, 요약, 합산을 값의 변환으로 작성하고 화면 출력은 마지막에 모은다.

clean_sales는 판매수량의 결측값만 정리한다. add_revenue는 행별 매출 열을 추가한다. summarize_branch는 한 지점의 합계를 한 행짜리 데이터 프레임으로 만든다. 각 함수는 전달받은 자료를 지역 변수로 다루고 결과를 반환한다. 일반 데이터 프레임을 이 방식으로 수정하면 호출한 쪽의 원본 자료는 그대로 유지된다. 원본을 별도로 보관하여 실행 후 이를 검사한다.

함수 합성(function composition)은 한 함수의 결과를 다음 함수의 입력으로 연결하는 것이다. compose(f, g)가 \(x) f(g(x))를 반환하도록 만들면, 합성된 함수는 먼저 g를 실행하고 이어서 f를 실행한다. compose(add_revenue, clean_sales)는 결측값을 정리한 후 매출을 계산한다. 괄호 안 오른쪽 함수가 먼저 실행된다는 점을 이름과 설명으로 명확히 해야 한다.

합성 함수는 자료를 정리한 결과를 매출 계산의 입력으로 전달한다

이번 compose는 인자 하나를 받는 함수끼리 연결하는 작은 도구다. 여러 인자를 자동으로 전달하거나 자료 구조를 변환하는 범용 도구는 아니다. 연결하려는 함수의 출력 형태와 다음 함수의 입력 형태가 맞아야 한다. 합성은 이 연결을 간결하게 표현할 뿐, 잘못된 자료 형태를 고쳐 주지 않는다.

또한 함수형으로 작성했다고 실행 속도가 자동으로 빨라지지는 않는다. Map 안에서 지점마다 함수를 호출하는 비용은 남아 있다. 반면 각 지점 내부의 판매수량과 단가 곱셈은 이미 벡터 연산이다. 여기서는 지점 사이의 반복을 Map으로 표현하고 지점 안의 열 계산은 벡터화하여 두 수준의 역할을 나눈다.

작은 함수는 검사 단위이기도 하다. 결측값이 사라졌는지, 요약표의 행 수가 지점 수와 같은지, 합계가 손으로 계산한 값과 일치하는지 따로 확인할 수 있다. 검사는 계산식 자체를 그대로 복사하기보다 업무상 기대값과 결과의 형태를 확인해야 한다. 예제에서는 빈 선택 결과와 원본 보존도 함께 검사한다.

완성 코드

다음 코드를 main.R로 저장한다. 외부 파일이나 패키지가 필요하지 않다. 자료는 코드 안에서 만들며 난수를 사용하지 않는다. 판매수량과 재고는 음수가 아닌 수, 단가는 음수가 아닌 수라는 예제의 입력 조건을 검사한다. 실제 시스템의 모든 입력 검사를 구현하는 대신 이번 분석에서 필요한 계약만 확인한다.

clean_sales <- function(data) {
  required <- c("sku", "quantity", "price", "stock")
  stopifnot(is.data.frame(data))
  stopifnot(all(required %in% names(data)))
  stopifnot(
    is.numeric(data$quantity),
    is.numeric(data$price),
    is.numeric(data$stock)
  )

  data$quantity[is.na(data$quantity)] <- 0

  stopifnot(
    all(is.finite(data$quantity)),
    all(is.finite(data$price)),
    all(is.finite(data$stock)),
    all(data$quantity >= 0),
    all(data$price >= 0),
    all(data$stock >= 0)
  )
  data
}

add_revenue <- function(data) {
  data$revenue <- data$quantity * data$price
  data
}

compose <- function(f, g) {
  \(x) f(g(x))
}

summarize_branch <- function(branch, data, reorder_level) {
  data.frame(
    branch = branch,
    quantity = sum(data$quantity),
    revenue = sum(data$revenue),
    reorder = sum(data$stock < reorder_level),
    stringsAsFactors = FALSE
  )
}

to_totals <- function(row) {
  c(
    quantity = row$quantity[[1]],
    revenue = row$revenue[[1]],
    reorder = row$reorder[[1]]
  )
}

add_summary <- function(acc, row) {
  acc + to_totals(row)
}

zero_totals <- c(quantity = 0, revenue = 0, reorder = 0)

sales <- list(
  "가람점" = data.frame(
    sku = c("우유", "도시락", "빵"),
    quantity = c(8, 3, NA_real_),
    price = c(1500, 2000, 1200),
    stock = c(4, 10, 2)
  ),
  "나루점" = data.frame(
    sku = c("우유", "도시락", "빵"),
    quantity = c(5, 6, 2),
    price = c(1500, 2000, 1200),
    stock = c(7, 2, 5)
  ),
  "다온점" = data.frame(
    sku = c("우유", "도시락", "빵"),
    quantity = c(0, 1, 1),
    price = c(1500, 2000, 1200),
    stock = c(8, 6, 4)
  )
)

original_sales <- sales
prepare <- compose(add_revenue, clean_sales)
prepared <- Map(prepare, sales)

branches <- names(prepared)
stopifnot(length(branches) == length(prepared))

summaries <- Map(
  \(branch, data) summarize_branch(
    branch, data, reorder_level = 3
  ),
  branches,
  prepared
)

summary_table <- do.call(rbind, unname(summaries))
rownames(summary_table) <- NULL

min_revenue <- 10000
selected <- Filter(
  \(row) row$revenue[[1]] >= min_revenue,
  summaries
)
totals <- Reduce(add_summary, selected, init = zero_totals)

none_selected <- Filter(
  \(row) row$revenue[[1]] >= 1000000,
  summaries
)
empty_totals <- Reduce(
  add_summary, none_selected, init = zero_totals
)

stopifnot(
  identical(sales, original_sales),
  !anyNA(prepared[[1]]$quantity),
  nrow(summary_table) == 3L,
  identical(summary_table$branch, names(sales)),
  identical(summary_table$revenue, c(18000, 21900, 3200)),
  length(selected) == 2L,
  totals[["quantity"]] == 24,
  totals[["revenue"]] == 39900,
  totals[["reorder"]] == 2,
  length(none_selected) == 0L,
  identical(empty_totals, zero_totals)
)

cat("지점별 요약\n")
lines <- Map(
  \(branch, quantity, revenue, reorder) sprintf(
    "%s | 판매수량=%d | 매출=%d | 보충품목=%d",
    branch, quantity, revenue, reorder
  ),
  summary_table$branch,
  summary_table$quantity,
  summary_table$revenue,
  summary_table$reorder
)
cat(unlist(lines, use.names = FALSE), sep = "\n")

selected_names <- vapply(
  selected, \(row) row$branch[[1]], character(1)
)
cat(sprintf(
  "선택 지점: %s\n",
  paste(selected_names, collapse = ", ")
))
cat(sprintf(
  "선택 합계 | 판매수량=%d | 매출=%d | 보충품목=%d\n",
  totals[["quantity"]],
  totals[["revenue"]],
  totals[["reorder"]]
))
cat(sprintf(
  "빈 선택 합계 | 판매수량=%d | 매출=%d | 보충품목=%d\n",
  empty_totals[["quantity"]],
  empty_totals[["revenue"]],
  empty_totals[["reorder"]]
))
cat("검사 통과\n")

줄별 해설

clean_sales의 required는 필요한 열 이름을 모은 벡터다. %in%으로 이 이름들이 자료에 모두 있는지 확인한다. 추가 열이 있는 자료도 허용하지만 필요한 열이 빠지면 검사가 멈춘다. 수량, 단가, 재고가 숫자형인지 확인한 다음 수량의 결측값을 0으로 바꾼다. 이후 유한한 값인지와 음수가 아닌지를 검사한다. 단가나 재고의 결측값은 이 규칙으로 보정하지 않으므로 검사를 통과하지 못한다.

add_revenue의 곱셈은 같은 행의 수량과 단가를 연결한다. clean_sales를 먼저 실행하므로 가람점 빵의 매출은 결측값이 아니라 0이 된다. sum에 na.rm = TRUE를 덧붙여 결측값을 숨기는 방식과 달리, 보정 규칙을 정리 단계에서 한 번 적용한다. 함수 마지막의 data가 반환값이다.

compose는 즉시 자료를 계산하지 않는다. f와 g를 기억하고 나중에 x를 받을 새 함수를 반환한다. prepare에 저장한 함수가 지점 자료를 받으면 clean_sales와 add_revenue가 순서대로 실행된다. Map(prepare, sales)의 결과는 원래 지점 목록과 같은 순서의 리스트다.

summarize_branch는 판매수량과 매출을 합하고 재고 비교 결과를 합한다. 논리 벡터를 sum에 전달하면 TRUE를 1, FALSE를 0으로 센다. 재고 3개 미만인 품목 수가 reorder에 들어간다. sku별 행이 서로 다른 품목이라는 예제 조건이 있으므로 행 수를 세는 것으로 품목 수를 구할 수 있다. 같은 품목이 여러 행에 나뉘어 있다면 먼저 집계 단위를 정해야 한다.

두 번째 Map에서는 branches와 prepared를 같은 위치끼리 연결한다. 익명 함수가 재고 기준 3을 고정하여 summarize_branch에 전달한다. summaries의 각 원소는 열 이름과 열 순서가 같은 한 행짜리 데이터 프레임이다. 이 구조가 정해져 있으므로 do.call과 rbind로 전체 요약표를 만들 수 있다.

summary_table은 모든 지점의 보고용 표이고 selected는 매출 조건을 만족하는 요약들의 리스트다. 선택 조건을 표를 만드는 과정과 분리했으므로 보고서에는 전체 지점을 표시하면서 특정 지점의 합계를 따로 계산할 수 있다. Filter는 원래 순서를 유지하여 가람점 다음에 나루점이 남는다.

to_totals는 한 행짜리 표에서 숫자 세 개를 꺼내 이름 있는 벡터를 만든다. [[1]]은 열 안의 첫 값을 꺼낸다. add_summary는 그 벡터를 누적값에 더한다. R의 벡터 덧셈은 이름을 기준으로 항목을 찾아 맞추는 연산이 아니므로 두 벡터의 순서를 맞추는 일이 중요하다. 여기서는 zero_totals와 to_totals가 같은 순서를 사용한다.

none_selected는 모든 지점의 매출보다 높은 기준을 적용한 결과다. 이 결과를 Reduce에 넣어도 초기 벡터가 그대로 돌아오는지 검사한다. stopifnot은 조건을 모두 만족하면 아무것도 출력하지 않는다. 마지막의 검사 통과 문구는 앞의 검사가 모두 끝난 뒤에만 출력된다.

화면 출력에서는 데이터 프레임의 자동 인쇄 대신 sprintf로 행별 문장을 만든다. 열 너비나 출력 설정에 따른 차이를 줄이고 예상 출력을 직접 확인하기 위해서다. vapply는 선택된 지점 이름이 각각 길이 1의 문자열이라는 조건을 명시한다. Map의 리스트 반환과 달리 여기서는 문자 벡터가 필요하므로 기존에 배운 도구를 함께 사용한다.

실행 결과

터미널에서 main.R을 저장한 디렉터리로 이동하여 실행한다. 파일은 UTF-8로 저장하고 한글을 표시할 수 있는 터미널을 사용한다.

Rscript main.R

예상 출력은 다음과 같다.

지점별 요약
가람점 | 판매수량=11 | 매출=18000 | 보충품목=1
나루점 | 판매수량=13 | 매출=21900 | 보충품목=1
다온점 | 판매수량=2 | 매출=3200 | 보충품목=0
선택 지점: 가람점, 나루점
선택 합계 | 판매수량=24 | 매출=39900 | 보충품목=2
빈 선택 합계 | 판매수량=0 | 매출=0 | 보충품목=0
검사 통과

가람점 매출은 우유 12,000원과 도시락 6,000원의 합이다. 빵의 결측 판매수량은 정해 둔 규칙에 따라 0이 된다. 가람점에서는 빵, 나루점에서는 도시락의 재고가 3개 미만이므로 선택 지점의 보충품목 합계는 2다. 다온점은 전체 요약에는 표시되지만 매출 기준에 미달하여 선택 합계에는 포함되지 않는다.

실무에서 자주 틀리는 것

Map의 반환값을 표로 생각하기

Map으로 요약을 만들었다고 결과 전체가 데이터 프레임이 되는 것은 아니다. 다음 코드는 모든 지점 매출을 구하려는 의도와 달리 리스트의 revenue라는 원소를 찾는다. 그런 원소가 없으면 NULL을 얻고 sum은 0을 반환하므로 오류 없이 잘못된 결과가 나올 수 있다.

# 틀린 코드
sum(summaries$revenue)

# 고친 코드
table_all <- do.call(rbind, unname(summaries))
sum(table_all$revenue)

리스트를 유지하려면 Reduce로 합산해도 된다. 이후 작업이 열 중심인지 원소 중심인지에 따라 표로 결합할지 리스트를 유지할지 결정한다. unlist로 모든 값을 펼치면 지점 이름과 숫자가 섞여 숫자까지 문자로 바뀔 수 있으므로 자료 구조를 먼저 확인해야 한다.

여러 입력의 길이 차이를 놓치기

다음 호출에서는 지점 이름이 하나뿐이어서 같은 이름이 모든 자료에 사용된다. 길이가 배수 관계이면 경고 없이 재활용될 수 있다. 실행이 끝났다는 사실만으로 지점 대응이 올바르다고 판단해서는 안 된다.

# 틀린 코드
Map(
  \(branch, data) summarize_branch(branch, data, 3),
  "가람점",
  prepared
)

# 고친 코드
branch_names <- names(prepared)
stopifnot(length(branch_names) == length(prepared))
Map(
  \(branch, data) summarize_branch(branch, data, 3),
  branch_names,
  prepared
)

길이 검사는 서로 다른 위치의 자료가 잘못 연결된 문제까지 발견하지는 못한다. 이번 코드처럼 이름을 같은 자료 목록에서 직접 가져오면 대응 관계를 유지하기 쉽다. 이름과 자료를 따로 정렬하는 경우에는 같은 순서인지도 확인해야 한다.

Reduce의 첫 누적값과 빈 입력을 설계하지 않기

add_summary는 누적값으로 숫자 벡터를 받도록 작성했다. init을 생략하면 첫 지점의 요약표가 누적값이 되어 함수의 계약과 맞지 않는다. 입력이 비었을 때의 결과도 숫자 벡터로 유지되지 않는다.

# 틀린 코드
Reduce(add_summary, selected)

# 고친 코드
Reduce(add_summary, selected, init = zero_totals)

초깃값의 자료형과 길이는 다음 호출에서 반환할 누적값과 맞춰야 한다. 숫자 0 하나만 넣어도 벡터 재활용으로 계산이 진행될 수 있지만, 항목 이름과 빈 입력 결과의 형태가 달라진다. 이번 분석에서는 세 항목을 모두 가진 초기 벡터가 계약이다.

do.call에 함수 대신 호출 결과를 전달하기

do.call의 첫 인자는 함수 또는 함수 이름 문자열이다. 괄호를 붙이면 do.call이 실행되기 전에 호출이 일어난다. 다음 틀린 코드는 rbind()의 결과를 첫 인자로 전달하므로 의도한 표 결합을 수행하지 못한다.

# 틀린 코드
do.call(rbind(), unname(summaries))

# 고친 코드
do.call(rbind, unname(summaries))

인자 목록은 함수 호출을 구성하는 재료다. 이 예제에서는 각 데이터 프레임이 rbind의 인자 하나가 된다. 서로 다른 열을 가진 자료를 그대로 결합하는 문제는 do.call이 해결하지 않으므로 각 요약이 같은 열 구조를 반환하도록 먼저 설계한다.

한눈에 보기

단계별 입력과 출력이 다음 계산의 계약을 만든다
단계입력출력확인할 점
정리와 매출 계산지점별 원자료매출 열을 가진 자료결측값 처리와 원본 보존
Map으로 요약지점 이름과 자료 목록한 행짜리 표들의 리스트입력 길이와 대응 순서
Filter로 선택요약 리스트선택된 요약 리스트조건이 논리값 하나인지
Reduce로 합산선택 리스트와 초기 벡터이름 있는 합계 벡터누적값 형태와 빈 입력
do.call로 결합같은 구조의 요약표 목록전체 지점 요약표호출 인자와 열 구조

base R만으로 이번 분석을 끝낼 수 있다. 다른 코드에서 tidyverse 계열의 함수 이름을 만났을 때는 다음 대응표를 읽는 안내로 사용한다. 이름이 비슷하더라도 반환값, 재활용, 인자 전달 규칙까지 같다는 뜻은 아니다. 이 장에서는 해당 패키지를 설치하거나 실행하지 않는다.

다른 코드에서 만날 수 있는 함수 이름의 역할 대응
이 장의 도구관련 도구공통 역할읽을 때 주의할 점
Mappurrr::map, map2, pmap원소별 함수 적용입력 개수에 따라 도구가 나뉜다.
Filterpurrr::keep조건을 만족하는 원소 유지조건 함수의 반환 규칙을 확인한다.
Reducepurrr::reduce누적 결합초깃값 인자의 이름이 다르다.
do.callrlang::exec여러 인자로 함수 호출 구성리스트를 전달하는 문법이 다르다.

함수형 분석의 핵심은 각 단계가 무엇을 받아 무엇을 반환하는지 명확히 하는 데 있다. 지점 목록을 처리하는 흐름과 지점 하나의 업무 계산을 나누면 기준을 바꾸거나 새 지점을 추가할 때 수정할 위치가 드러난다. 모든 처리를 하나의 긴 합성식으로 압축하기보다 중간 결과에 이름을 붙여 확인할 수 있게 두는 편이 실무 분석에 유용하다.

연습 문제

  1. 완성 코드의 summaries에서 매출이 20,000원 이상인 지점을 Filter로 선택하고 Reduce로 합계를 구한다. 선택 지점과 세 합계 항목을 예상한 뒤 검사한다.
  2. 보충 기준을 재고 3개 미만에서 5개 미만으로 바꾼다. prepared는 다시 만들지 않고 새 요약 목록을 만든다. 모든 지점의 보충품목 수와 전체 합계를 확인한다.
  3. 매출 계산을 먼저 하고 결측값 정리를 나중에 하도록 합성 순서를 뒤집으면 가람점 빵의 quantity와 revenue는 각각 어떤 값이 되는가. 원래 순서의 결과와 비교하는 코드를 작성한다.
  4. 합계 함수가 빈 목록과 한 지점짜리 목록에서도 같은 항목 이름을 반환하는지 검사한다. 나루점 하나만 넣었을 때의 판매수량, 매출, 보충품목 수도 확인한다.

정답과 해설

매출 기준을 바꾸어 선택하기

selected_20000 <- Filter(
  \(row) row$revenue[[1]] >= 20000,
  summaries
)
totals_20000 <- Reduce(
  add_summary, selected_20000, init = zero_totals
)
stopifnot(
  length(selected_20000) == 1L,
  selected_20000[[1]]$branch[[1]] == "나루점",
  totals_20000[["quantity"]] == 13,
  totals_20000[["revenue"]] == 21900,
  totals_20000[["reorder"]] == 1
)

나루점만 남는다. Filter가 선택한 원소의 구조를 유지하므로 기존 합산 함수를 그대로 사용할 수 있다. 매출 기준 변경은 자료 정리나 행별 매출 계산에 영향을 주지 않아 그 단계를 다시 실행할 필요가 없다.

재고 기준을 바꾸어 요약하기

summaries_5 <- Map(
  \(branch, data) summarize_branch(branch, data, 5),
  names(prepared),
  prepared
)
reorder_counts <- vapply(
  summaries_5, \(row) row$reorder[[1]], numeric(1)
)
totals_5 <- Reduce(
  add_summary, summaries_5, init = zero_totals
)
stopifnot(
  identical(unname(reorder_counts), c(2, 1, 1)),
  totals_5[["reorder"]] == 4
)

가람점은 우유와 빵, 나루점은 도시락, 다온점은 빵이 대상이다. 재고가 정확히 5개인 나루점 빵은 포함되지 않는다. 기준은 5개 이하가 아니라 5개 미만이다. 기준이 요약 함수의 인자로 드러나 있어 정리된 자료를 재사용할 수 있다.

합성 순서가 결과에 미치는 영향 확인하기

correct <- prepare(sales[[1]])
reverse_prepare <- compose(clean_sales, add_revenue)
reversed <- reverse_prepare(sales[[1]])

stopifnot(
  correct$quantity[[3]] == 0,
  correct$revenue[[3]] == 0,
  reversed$quantity[[3]] == 0,
  is.na(reversed$revenue[[3]])
)

뒤집힌 순서에서는 NA인 판매수량으로 매출을 먼저 계산하여 revenue에 NA가 생긴다. 이후 clean_sales는 quantity만 고치므로 이미 만들어진 매출의 결측값은 남는다. 같은 두 함수를 사용해도 순서가 달라지면 결과가 달라질 수 있다. 함수 합성에서는 연결 순서도 분석 규칙의 일부다.

빈 목록과 한 지점 목록 검사하기

empty_result <- Reduce(
  add_summary, list(), init = zero_totals
)
one_result <- Reduce(
  add_summary, summaries[2], init = zero_totals
)

stopifnot(
  identical(names(empty_result), names(zero_totals)),
  identical(names(one_result), names(zero_totals)),
  identical(empty_result, zero_totals),
  one_result[["quantity"]] == 13,
  one_result[["revenue"]] == 21900,
  one_result[["reorder"]] == 1
)

summaries[2]는 두 번째 원소를 담은 리스트다. summaries[[2]]는 그 안의 데이터 프레임이므로 Reduce에 전달하면 표의 열들이 순회 대상이 된다. 목록에서 지점 하나를 선택하여 같은 처리 흐름을 유지하려면 단일 대괄호를 사용한다. 초깃값을 지정하면 빈 목록에서도 후속 코드가 기대하는 합계 구조를 유지한다.

댓글 0

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

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