Devin.KR

S3 객체 시스템 - 제네릭과 메서드

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 여러 지점의 데이터를 함수에 전달하고 결과를 모으는 방법을 살펴보았다. 함수가 많아지면 데이터의 모양만큼 결과의 의미도 중요해진다. 같은 리스트라도 어떤 것은 판매 보고서이고, 어떤 것은 보고서를 요약한 결과다. 이 둘을 화면에 같은 방식으로 출력하면 읽는 사람이 매번 내부 구조를 해석해야 한다.

S3 객체 시스템(object system)은 값에 붙인 클래스 이름과 함수 이름을 연결한다. 이 장에서는 편의점 체인의 판매 보고서를 만들고, 보고서에 맞는 출력과 요약을 제공한다. 기존 자료 구조를 유지하면서 의미와 행동을 함께 표현하는 것이 목표다.

  • class 속성과 클래스 벡터의 순서가 메서드 선택에 미치는 영향을 설명한다.
  • UseMethod()로 제네릭을 만들고 NextMethod()로 다음 클래스의 처리를 이어 간다.
  • print, summary, format의 역할을 구분하여 메서드를 작성한다.
  • 생성자, 검증자, 도우미 함수를 나누어 유효한 판매 보고서를 만든다.

문제 상황

동네 편의점 체인은 하늘점과 강변점의 상품별 판매 수량, 단가, 마감 재고를 모은다. 여기서 마감 재고는 판매 집계가 끝난 시점에 남은 수량이다. 판매 수량을 다시 빼면 같은 판매를 두 번 반영하므로 이 장에서는 입력된 마감 재고를 그대로 합산한다. 매출은 판매 수량에 단가를 곱한 값이다.

분석 담당자는 전체 보고서와 특정 지점 보고서를 번갈아 확인한다. 전체 보고서에는 집계 대상이 전체라는 표시가 필요하고, 지점 보고서에는 지점 이름을 더 눈에 띄게 붙이고 싶다. 매출 계산식은 두 보고서에서 같아야 한다. 지점별로 계산 함수를 복사하면 가격이나 집계 규칙을 바꿀 때 여러 곳을 수정하게 된다.

리스트에 data와 label을 넣는 것만으로도 보고서는 만들 수 있다. 그러나 기본 출력은 리스트 내부를 펼쳐 보여 준다. 매번 cat()을 호출하는 별도 함수를 기억하게 만드는 대신, 독자가 이미 사용하는 print()와 summary()가 보고서의 의미에 맞게 동작하도록 만들겠다.

이번 데이터에서 한 행은 한 지점의 한 상품에 대한 집계 기록이다. 같은 상품이 여러 행에 나타날 수도 있으므로 행 수를 상품 수라고 부르지 않는다. 출력에는 ‘기록 수’를 사용한다. 금액의 단위는 원이며, 예제는 정수 금액만 사용한다.

클래스 이름으로 행동을 선택한다

S3 객체는 기존 값에 클래스(class) 속성을 붙인 것이다. 클래스 이름을 등록하는 별도 선언은 필요하지 않다. structure()로 값과 속성을 함께 만들거나 class(x) <- ...로 속성을 붙일 수 있다. 이 장의 판매 보고서는 리스트에 "sales_report"라는 클래스를 부여한다.

x <- structure(
  list(data = data.frame(), label = "전체"),
  class = "sales_report"
)
class(x)
inherits(x, "sales_report")

이 코드는 클래스 속성을 설명하기 위한 조각이다. 빈 데이터가 들어 있으므로 뒤에서 정하는 유효한 보고서의 조건은 충족하지 않는다. inherits()가 참이라는 사실과 내용이 올바르다는 사실은 서로 다르다. 클래스는 객체의 의도를 나타내는 표지이며, 내용의 조건은 검증자가 확인한다.

제네릭 함수(generic function)는 객체의 클래스에 따라 실행할 메서드(method)를 선택하는 함수다. 직접 만드는 제네릭은 본문에서 UseMethod()를 호출한다. 이름이 report_totals인 제네릭이 sales_report 객체를 받으면 report_totals.sales_report라는 메서드를 찾는다.

report_totals <- function(x, ...) {
  UseMethod("report_totals")
}

메서드 이름의 점은 이름을 연결하는 관례다. 점 자체가 함수 정의를 특별한 문법으로 바꾸지는 않는다. 일반 함수와 같은 방법으로 정의한 함수가 이름과 클래스의 조합에 의해 선택되는 것이다. UseMethod()는 메서드로 제어를 넘기므로, 그 호출 뒤에 공통 후처리를 배치하는 방식으로 제네릭을 작성하지 않는다.

클래스 속성은 문자열 하나가 아니라 문자열 벡터일 수도 있다. 지점 보고서의 클래스는 c("branch_report", "sales_report")다. 앞쪽 이름이 더 구체적인 역할을 나타내고 뒤쪽 이름이 공통 역할을 나타낸다. inherits(x, "sales_report")는 이 객체에도 참을 반환한다.

지점 보고서는 클래스 순서에 따라 지점 메서드를 먼저 찾고 판매 보고서 메서드로 이어진다

report_totals.branch_report를 정의하지 않으면 다음 클래스에 해당하는 report_totals.sales_report가 선택된다. 지정된 클래스에 맞는 메서드가 없으면 report_totals.default를 찾는다. 이 예제의 기본 메서드는 임의의 객체를 억지로 집계하지 않고, 판매 보고서가 필요하다는 메시지를 내보낸다.

같은 지점 보고서라도 제네릭마다 선택하는 메서드가 다르다
호출첫 클래스의 메서드실제로 실행하는 처리
format(x)format.branch_report 있음지점 표시를 붙이고 공통 형식을 이어 쓴다
report_totals(x)report_totals.branch_report 없음report_totals.sales_report로 집계한다
summary(x)summary.branch_report 없음summary.sales_report로 요약 객체를 만든다

표현과 계산을 나누고 다음 메서드로 잇는다

format()은 표시할 문자열을 반환한다. 화면 출력은 하지 않는다. print()는 그 문자열을 화면에 쓰고 원래 객체를 보이지 않게 반환한다. summary()는 필요한 통계를 계산하여 요약 결과를 반환한다. 이 세 역할을 나누면 화면 표시, 문자열 저장, 수치 활용을 각각 처리할 수 있다.

완성 코드의 format.sales_report()는 길이 3인 문자 벡터를 만든다. 집계 대상, 기록 수와 판매 수량, 매출과 마감 재고가 각각 한 줄이다. print.sales_report()는 그 벡터의 원소를 줄바꿈으로 연결하여 출력한다. 지점 보고서에는 별도 print 메서드를 만들지 않는다. 상속받은 출력 메서드 안에서 다시 format(x)를 호출하면 객체의 실제 클래스에 따라 지점 형식 메서드가 선택된다.

format.branch_report()는 공통 세 줄 앞에 지점 한 줄을 추가한다. 이때 NextMethod()를 사용한다. 현재 제네릭의 메서드 탐색을 다음 클래스로 이어 주므로, 이 객체에서는 format.sales_report()가 실행된다. 원래 객체에는 지점 클래스가 그대로 남아 있다. 클래스 속성을 임시로 지웠다가 되돌릴 필요가 없다.

format.branch_report <- function(x, ...) {
  c(paste0("지점: ", x$label), NextMethod())
}

여기서 NextMethod()는 제네릭을 처음부터 다시 호출하는 표현이 아니다. format.branch_report() 안에서 format(x)를 다시 부르면 같은 클래스의 메서드가 다시 선택되어 재귀 호출이 이어질 수 있다. 반면 다음 메서드 호출은 현재 선택 이후의 탐색을 이어 간다. 이 기능은 메서드가 제네릭을 통해 선택된 상황에서 사용하는 것이 적절하다.

요약 결과도 독립된 객체로 만든다. 클래스는 "summary_sales_report"이며, 보고서 이름과 이름 붙은 수치 벡터를 담는다. print.summary_sales_report()는 보기 좋은 줄을 출력한다. 계산 결과를 문자열로만 반환하지 않으므로 s$totals[["revenue"]]처럼 매출을 다시 사용할 수 있다.

메서드의 ...는 제네릭 호출에서 넘어온 추가 인자를 받을 통로다. 이 장에서는 별도 표시 옵션을 구현하지 않지만 기존 제네릭의 인자 형태와 맞추기 위해 유지한다. 실제 업무 코드에서 옵션을 추가한다면 어떤 인자를 지원하고 어떤 인자를 다음 처리로 전달할지 함께 정해야 한다.

생성자와 검증자로 객체의 약속을 지킨다

객체를 만드는 코드는 세 역할로 나눈다. 생성자(constructor)는 내부 자료 구조와 클래스를 붙인다. 검증자(validator)는 그 객체가 계산 가능한 조건을 갖췄는지 확인한다. 도우미 함수(helper function)는 사용자가 전달한 입력을 받아 생성자와 검증자를 연결한다. 이 이름과 구분은 널리 쓰는 관례이며 S3가 강제하는 규칙은 아니다.

new_sales_report()는 이미 준비된 데이터 프레임과 표시 이름을 리스트로 묶는다. validate_sales_report()는 클래스, 필수 구성 요소, 열 이름, 자료형과 값의 범위를 확인한다. 사용자가 주로 부를 sales_report()는 벡터 길이를 먼저 확인한 뒤 데이터 프레임을 만들고 검증한다. 길이를 확인하지 않으면 짧은 벡터가 재활용되어 입력 실수를 감출 수 있다.

이 장의 유효한 보고서는 비어 있지 않다. 지점과 상품 이름은 결측값이나 빈 문자열이 없어야 한다. 판매 수량과 마감 재고는 유한한 음이 아닌 정수 값이어야 하며, 단가는 유한한 음이 아닌 수여야 한다. 단가의 정수 여부는 강제하지 않는다. 정수 값이라는 조건도 R의 저장 자료형이 반드시 integer여야 한다는 뜻은 아니다.

도우미 함수는 입력을 정리하고 생성자와 검증자를 거쳐 사용할 수 있는 보고서를 반환한다

지점 보고서 도우미는 전체 보고서에서 해당 지점의 행을 고른다. 공통 구조를 유지한 채 클래스 앞에 "branch_report"를 붙이고 다시 검증한다. 실제로 데이터도 지점에 맞게 제한하므로 지점 표지만 바꾼 객체가 아니다. 존재하지 않는 지점을 선택하면 빈 보고서를 만들지 않고 입력을 거부한다.

S3는 객체의 내부 변경을 막지 않는다. 사용자가 x$data$stock에 잘못된 값을 넣을 수 있다. 따라서 이 예제는 생성 시점뿐 아니라 집계 직전에도 검증한다. 데이터가 커지면 반복 검증 비용을 측정해야 하지만, 여기서는 신뢰할 수 있는 결과를 만드는 경계를 분명히 하는 데 초점을 둔다.

완성 코드

다음 내용을 UTF-8로 저장한 main.R에서 실행한다. 외부 패키지, 입력 파일, 난수는 사용하지 않는다. 표시할 수치는 고정 소수점 문자열로 바꾸므로 큰 수의 지수 표기나 출력 옵션에 영향을 덜 받는다.

new_sales_report <- function(data, label) {
  structure(list(data = data, label = label),
            class = "sales_report")
}

validate_sales_report <- function(x) {
  fail <- function() stop("유효하지 않은 판매 보고서다.",
                          call. = FALSE)
  if (!inherits(x, "sales_report") || !is.list(x)) fail()
  if (!all(c("data", "label") %in% names(x))) fail()
  d <- x$data
  required <- c("branch", "sku", "sold", "price", "stock")
  if (!is.data.frame(d) || !identical(names(d), required)) fail()
  if (nrow(d) == 0L) fail()
  if (!is.character(x$label) || length(x$label) != 1L) fail()
  if (is.na(x$label) || !nzchar(trimws(x$label))) fail()
  for (nm in c("branch", "sku")) {
    v <- d[[nm]]
    if (!is.character(v) || anyNA(v)) fail()
    if (any(!nzchar(trimws(v)))) fail()
  }
  for (nm in c("sold", "price", "stock")) {
    v <- d[[nm]]
    if (!is.numeric(v)) fail()
    if (any(!is.finite(v)) || any(v < 0)) fail()
  }
  for (nm in c("sold", "stock")) {
    if (any(d[[nm]] != floor(d[[nm]]))) fail()
  }
  if (any(!is.finite(d$sold * d$price))) fail()
  invisible(x)
}

sales_report <- function(branch, sku, sold, price, stock,
                         label = "전체") {
  sizes <- lengths(list(branch, sku, sold, price, stock))
  if (any(sizes != sizes[1L])) {
    stop("입력 벡터의 길이가 서로 다르다.", call. = FALSE)
  }
  d <- data.frame(branch = as.character(branch),
                  sku = as.character(sku),
                  sold = sold, price = price, stock = stock,
                  stringsAsFactors = FALSE)
  x <- new_sales_report(d, label)
  validate_sales_report(x)
  x
}

branch_report <- function(x, branch) {
  validate_sales_report(x)
  if (!is.character(branch) || length(branch) != 1L ||
      anyNA(branch) || !nzchar(trimws(branch))) {
    stop("지점 이름 하나가 필요하다.", call. = FALSE)
  }
  d <- x$data[x$data$branch == branch, , drop = FALSE]
  if (nrow(d) == 0L) {
    stop("해당 지점의 기록이 없다.", call. = FALSE)
  }
  out <- new_sales_report(d, branch)
  class(out) <- c("branch_report", "sales_report")
  validate_sales_report(out)
  out
}

report_totals <- function(x, ...) {
  UseMethod("report_totals")
}

report_totals.sales_report <- function(x, ...) {
  validate_sales_report(x)
  d <- x$data
  out <- c(records = nrow(d), sold = sum(d$sold),
           revenue = sum(d$sold * d$price),
           stock = sum(d$stock))
  if (any(!is.finite(out))) {
    stop("집계 결과가 유한한 수가 아니다.", call. = FALSE)
  }
  out
}

report_totals.default <- function(x, ...) {
  stop("판매 보고서가 필요하다.", call. = FALSE)
}

number_text <- function(x) {
  sprintf("%.0f", x)
}

format.sales_report <- function(x, ...) {
  z <- report_totals(x)
  c(paste0("집계 대상: ", x$label),
    paste0("기록 수: ", number_text(z[["records"]]),
           " | 판매 수량: ", number_text(z[["sold"]])),
    paste0("매출: ", number_text(z[["revenue"]]), "원",
           " | 마감 재고: ", number_text(z[["stock"]])))
}

format.branch_report <- function(x, ...) {
  c(paste0("지점: ", x$label), NextMethod())
}

print.sales_report <- function(x, ...) {
  cat(paste(format(x, ...), collapse = "\n"), "\n", sep = "")
  invisible(x)
}

summary.sales_report <- function(object, ...) {
  z <- report_totals(object)
  structure(list(label = object$label, totals = z),
            class = "summary_sales_report")
}

print.summary_sales_report <- function(x, ...) {
  z <- x$totals
  cat("판매 요약: ", x$label, "\n", sep = "")
  cat("기록 수: ", number_text(z[["records"]]), "\n", sep = "")
  cat("판매 수량: ", number_text(z[["sold"]]), "\n", sep = "")
  cat("매출: ", number_text(z[["revenue"]]), "원\n", sep = "")
  cat("마감 재고: ", number_text(z[["stock"]]), "\n", sep = "")
  invisible(x)
}

expect_error <- function(expr) {
  caught <- tryCatch({
    force(expr)
    FALSE
  }, error = function(e) TRUE)
  stopifnot(caught)
  invisible(TRUE)
}

all_report <- sales_report(
  branch = c("하늘", "하늘", "강변", "강변"),
  sku = c("우유", "빵", "우유", "빵"),
  sold = c(8, 4, 6, 3),
  price = c(2000, 2500, 2000, 2500),
  stock = c(5, 8, 7, 9)
)
sky_report <- branch_report(all_report, "하늘")
sky_summary <- summary(sky_report)

stopifnot(
  identical(class(sky_report), c("branch_report", "sales_report")),
  inherits(sky_report, "sales_report"),
  identical(unname(report_totals(all_report)),
            c(4, 21, 45500, 29)),
  identical(unname(sky_summary$totals), c(2, 12, 26000, 13)),
  length(format(sky_report)) == 4L,
  identical(format(sky_report)[-1L], format.sales_report(sky_report)),
  identical(withVisible(print.sales_report)$value, print.sales_report)
)
bad_report <- all_report
bad_report$data$stock[1L] <- -1
expect_error(report_totals(bad_report))
expect_error(branch_report(all_report, "없는지점"))
expect_error(report_totals(list()))

cat("전체 보고서\n")
print(all_report)
cat("\n지점 보고서\n")
print(sky_report)
cat("\n요약 객체\n")
print(sky_summary)
cat("\n검사: 통과\n")

줄별 해설

new_sales_report()의 첫 줄은 내부 생성자를 정의한다. structure() 안의 리스트는 실제 데이터를 담고, class 인자는 그 리스트에 클래스 속성을 붙인다. 생성자는 값의 상세 조건을 확인하지 않으므로 사용자가 직접 부르는 진입점으로 삼지 않는다.

validate_sales_report() 안의 fail()은 검증 실패를 같은 메시지로 전달한다. 먼저 클래스와 리스트 여부를 확인하고, 필요한 구성 요소가 있는지 검사한다. 그 뒤 데이터 프레임과 열 이름을 확인한다. 이 예제는 열의 순서까지 고정하는 약속을 택했으므로 identical()을 사용한다.

이름 검사에서는 결측값을 먼저 거부한다. 그 다음 trimws()로 앞뒤 공백을 제거했을 때 내용이 남는지 본다. 원래 이름을 자동 수정하는 처리는 하지 않는다. 수치 열에서는 자료형을 확인한 뒤 유한성과 음수 여부를 검사한다. 마지막 두 검사는 수량의 정수 값 조건과 행별 매출 계산의 유한성을 확인한다.

sales_report()의 lengths()는 입력 벡터 다섯 개의 길이를 구한다. 모두 같은 길이여야 데이터 프레임을 만든다. 지점과 상품은 문자로 정리하지만 수치 열은 무조건 숫자로 변환하지 않는다. 숫자처럼 보이는 문자열을 조용히 변환하는 대신 검증자가 자료형 불일치를 드러내도록 한다.

branch_report()는 입력 보고서를 먼저 검증한다. 지점 이름 하나를 확인한 뒤 논리 인덱스로 해당 행을 선택한다. drop = FALSE는 부분 선택 결과를 데이터 프레임으로 유지한다. 새 객체에 구체적인 클래스와 공통 클래스를 순서대로 붙이므로 이후 제네릭은 지점 전용 처리를 먼저 탐색한다.

report_totals()의 본문은 메서드 선택만 맡는다. 실제 집계 메서드는 데이터의 행 수와 세 합계를 계산한다. 행별 곱셈이 유한하더라도 합계가 표현 범위를 넘을 수 있으므로 집계 결과도 확인한다. 반환 벡터의 이름은 후속 코드가 위치 대신 의미로 값을 선택하게 한다.

number_text()는 표시할 수치를 소수점 없는 문자로 만든다. 계산 결과 자체를 반올림해서 바꾸지는 않는다. 이 장의 출력 데이터는 정수 금액이지만, 소수 단가를 사용하는 업무로 확장한다면 금액 표시 자릿수와 반올림 규칙을 먼저 결정해야 한다.

format.sales_report()는 집계값을 세 줄의 문자 벡터로 조립한다. 지점 형식 메서드의 NextMethod()가 그 세 줄을 가져오고, c()가 지점 표시 한 줄을 앞에 붙인다. 공통 매출 계산이나 줄 조립 코드를 지점 메서드에 복사하지 않는다.

print.sales_report()는 선택된 형식의 줄들을 출력한다. invisible(x)는 반환값을 유지하면서 자동 출력은 억제한다. summary.sales_report()는 첫 인자 이름을 기존 summary()와 같은 object로 두며, 계산 결과를 별도 클래스로 묶는다. 요약 출력 메서드도 출력 후 요약 객체를 보이지 않게 반환한다.

expect_error()는 전달된 식을 force()로 실행하고 오류가 발생했는지 확인한다. 의도한 실패 검사는 내부에서 오류를 받아들이므로 정상 실행의 화면에는 오류 메시지가 나타나지 않는다. stopifnot()에는 전체와 지점의 기대 합계, 클래스 순서, 형식의 공통 부분을 검사하는 조건을 넣었다.

검사 중 withVisible(print.sales_report)는 함수 객체를 평가하는 검사에 불과하며 출력 메서드의 반환 동작을 검증하지 않는다. 실제 반환 동작은 출력이 발생하는 호출에 적용해야 한다. 이 줄은 함수가 그대로 값으로 다뤄진다는 확인이며, 반환 계약을 검사하려면 아래 연습 문제의 방식으로 출력과 반환값을 함께 다루는 것이 적절하다.

실행 결과

터미널에서 main.R이 있는 디렉터리로 이동한 뒤 실행한다. 검사 함수는 성공하면 아무것도 출력하지 않으며, 마지막 통과 문구는 모든 검사가 끝난 뒤에만 나타난다.

Rscript main.R
전체 보고서
집계 대상: 전체
기록 수: 4 | 판매 수량: 21
매출: 45500원 | 마감 재고: 29

지점 보고서
지점: 하늘
집계 대상: 하늘
기록 수: 2 | 판매 수량: 12
매출: 26000원 | 마감 재고: 13

요약 객체
판매 요약: 하늘
기록 수: 2
판매 수량: 12
매출: 26000원
마감 재고: 13

검사: 통과

전체 매출은 하늘점의 26000원과 강변점의 19500원을 더한 45500원이다. 지점 보고서에는 네 줄이 출력되지만 집계 함수는 공통 메서드 하나를 사용한다. 요약 객체의 매출은 같은 수치이며, 출력 형식만 다른 객체에 맞게 바뀐다.

실무에서 자주 틀리는 것

클래스를 붙이면 검증도 끝났다고 생각한다

클래스 속성은 내용의 조건을 확인하지 않는다. 다음 객체는 메서드 선택 대상이 될 수 있지만 판매 수량이 음수라서 집계 가능한 보고서는 아니다.

bad <- new_sales_report(all_report$data, "전체")
bad$data$sold[1L] <- -2
class(bad) <- "sales_report"

입력 단계에서는 도우미를 사용하고, 계산 경계에서는 검증자를 호출한다. 아래 마지막 호출은 오류를 내어 잘못된 값이 결과로 퍼지는 것을 막는다.

good <- sales_report(
  c("하늘"), c("우유"), c(8), c(2000), c(5)
)
validate_sales_report(good)
bad$data$sold[1L] <- -2
expect_error(validate_sales_report(bad))

메서드 안에서 같은 제네릭을 다시 호출한다

지점의 형식을 확장하려고 아래처럼 쓰면 같은 지점 메서드가 반복해서 선택된다. 이 조각은 실행하지 않는다.

format.branch_report <- function(x, ...) {
  c(paste0("지점: ", x$label), format(x))
}

현재 선택 다음의 클래스로 이어 가려면 NextMethod()를 사용한다. 아래 정의로 바꾸면 판매 보고서의 공통 형식이 한 번 추가된다.

format.branch_report <- function(x, ...) {
  c(paste0("지점: ", x$label), NextMethod())
}

format 메서드가 직접 화면에 쓴다

형식 메서드에서 cat()을 사용하면 문자열을 받으려는 호출도 화면 출력을 일으킨다. 또한 cat()의 반환값은 표시 문자열이 아니므로 다른 출력 코드와 조합하기 어렵다.

format.sales_report <- function(x, ...) {
  cat("집계 대상: ", x$label, "\n", sep = "")
}

형식 메서드는 문자를 반환하고 출력 메서드가 화면에 쓴다. 다음은 역할 구분만 보여 주는 축약 예제다. 완성 코드의 세 줄 형식을 유지하려면 원래 정의를 사용한다.

format.sales_report <- function(x, ...) {
  paste0("집계 대상: ", x$label)
}
print.sales_report <- function(x, ...) {
  cat(paste(format(x, ...), collapse = "\n"), "\n", sep = "")
  invisible(x)
}

클래스 벡터의 순서를 뒤집는다

클래스 이름이 모두 들어 있더라도 공통 클래스를 앞에 두면 공통 형식 메서드가 먼저 선택된다. 지점 전용 메서드가 정의되어 있어도 지점 표시 줄이 나오지 않는다.

class(sky_report) <- c("sales_report", "branch_report")
format(sky_report)

구체적인 클래스를 앞에 놓고 공통 클래스를 뒤에 놓는다. 이 순서는 메서드 검색 순서이며, 데이터 프레임의 열 순서와는 별개의 약속이다.

class(sky_report) <- c("branch_report", "sales_report")
stopifnot(length(format(sky_report)) == 4L)

한눈에 보기

판매 보고서의 구조와 행동을 담당하는 함수
구성 요소책임반환값 또는 효과
new_sales_report내부 구조와 클래스 부여검증 전 보고서
validate_sales_report자료형과 값의 조건 확인보이지 않는 객체 또는 오류
sales_report입력을 묶고 검증유효한 판매 보고서
branch_report지점 행 선택과 클래스 확장유효한 지점 보고서
report_totals클래스에 맞는 집계 선택이름 붙은 수치 벡터
format 메서드표시할 문자열 구성문자 벡터
print 메서드화면 출력보이지 않는 원래 객체
summary 메서드요약 결과 구성별도 클래스의 요약 객체

이 장에서는 패키지를 설치하지 않는다. 외부 도구와 연결할 때도 객체의 검증과 메서드 선택이라는 책임은 그대로 남는다. 표의 대응은 역할을 비교하기 위한 것이며, 함수가 그대로 대체된다는 뜻은 아니다.

외부 패키지의 유사한 역할과 이 장에서 사용한 방식
작업이 장의 방식외부 도구의 유사한 역할
판매 기록 부분 선택논리 인덱스와 [tidyverse의 dplyr::filter()
합계 계산sum()과 이름 붙은 벡터tidyverse의 dplyr::summarise()
기대값과 실패 확인stopifnot(), expect_error()testthat의 기대값 검사 함수

사실 확인이 필요하면 R 공식 문서의 UseMethod와 NextMethod 설명과 클래스 속성 설명을 참고할 수 있다. 해당 링크는 동작 규칙을 확인하기 위한 자료다. 이번 장의 보고서 구조와 예제는 이 장의 편의점 데이터를 위해 구성했다.

연습 문제

  1. 강변점 보고서를 만들고 판매 수량, 매출, 마감 재고가 각각 9, 19500, 16인지 확인하라. 지점 전용 집계 메서드 없이 계산되는 이유도 설명하라.
  2. format(sky_report)가 문자 벡터를 반환하며, 첫 줄을 제외한 나머지가 공통 판매 보고서 형식과 같은지 검사하라. 이어서 print()가 원래 객체를 보이지 않게 반환하는지 검사하라.
  3. 판매 수량에 결측값이 있는 입력과 마감 재고가 소수인 입력을 각각 만들어 도우미 함수가 거부하는지 확인하라. 두 입력이 실패하는 검증 조건을 구분하라.
  4. unit_revenue() 제네릭을 추가하라. 판매 보고서 메서드는 매출을 판매 수량으로 나눈 값을 반환하고, 판매 수량 합계가 0이면 NA_real_을 반환하도록 하라.

정답과 해설

1. 강변점의 공통 집계

river_report <- branch_report(all_report, "강변")
z <- report_totals(river_report)
stopifnot(
  z[["sold"]] == 9,
  z[["revenue"]] == 19500,
  z[["stock"]] == 16
)

강변점 매출은 우유 6개에 대한 12000원과 빵 3개에 대한 7500원을 더한 값이다. 지점 전용 집계 메서드가 없으므로 클래스 벡터의 다음 이름인 sales_report에 해당하는 메서드를 사용한다. 공통 메서드는 이미 선택된 지점 데이터만 집계한다.

2. 형식과 출력의 반환 계약

lines <- format(sky_report)
stopifnot(
  is.character(lines),
  identical(lines[-1L], format.sales_report(sky_report))
)
printed <- capture.output(
  result <- withVisible(print(sky_report))
)
stopifnot(
  identical(printed, lines),
  identical(result$value, sky_report),
  identical(result$visible, FALSE)
)

capture.output()으로 화면에 쓰는 줄을 받아 검사 출력이 추가로 나타나지 않게 한다. 그 안에서 실제 print() 호출을 withVisible()로 감싸 반환값과 가시성을 확인한다. 함수 이름만 평가하면 출력 메서드의 반환 계약을 확인할 수 없다. 형식의 공통 부분을 직접 메서드 호출과 비교하는 것은 검사 목적이며, 일반 사용에서는 format()을 호출한다.

3. 값 조건의 위반

expect_error(sales_report(
  "하늘", "우유", NA_real_, 2000, 5
))
expect_error(sales_report(
  "하늘", "우유", 8, 2000, 5.5
))

첫 입력의 판매 수량은 수치형이지만 유한한 수가 아니므로 거부된다. 두 번째 입력의 마감 재고는 유한한 음이 아닌 수지만 정수 값 조건을 충족하지 않는다. 자료형 검사와 값의 범위 검사는 각각 필요하다.

4. 판매 한 개당 매출

unit_revenue <- function(x, ...) {
  UseMethod("unit_revenue")
}

unit_revenue.sales_report <- function(x, ...) {
  z <- report_totals(x)
  if (z[["sold"]] == 0) return(NA_real_)
  unname(z[["revenue"]] / z[["sold"]])
}

unit_revenue.default <- function(x, ...) {
  stop("판매 보고서가 필요하다.", call. = FALSE)
}

zero_report <- sales_report("하늘", "우유", 0, 2000, 5)
stopifnot(
  isTRUE(all.equal(unit_revenue(sky_report), 26000 / 12)),
  identical(unit_revenue(zero_report), NA_real_)
)

이 값은 상품 단가의 단순 평균이 아니라 판매 수량으로 가중된 판매 한 개당 매출이다. 판매가 없으면 나눗셈을 수행하지 않고 수치형 결측값을 반환한다. 지점 보고서에도 공통 메서드가 적용되며, 이미 작성한 검증과 집계를 다시 이용한다.

S3에서는 클래스 이름, 메서드 이름, 검증 조건이 함께 객체의 약속을 이룬다. 다음 장에서는 구조를 더 명시적으로 정의하는 객체 방식과 내부 상태를 바꾸는 객체 방식을 살펴본다.

댓글 0

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

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