코드를 묶기 - 패키지 구조와 테스트
이 장에서 배우는 것
앞 장에서 만든 시뮬레이션과 재표집 코드는 계산 방법을 확인하는 데 적합했다. 같은 계산을 여러 지점에 반복해서 적용하기 시작하면 관심이 조금 달라진다. 함수가 어느 파일에 있는지, 어떤 함수를 외부에서 사용해도 되는지, 코드를 고친 뒤에도 결과가 유지되는지를 알아야 한다. 이 장에서는 편의점 체인의 판매·재고 계산을 작은 파일 묶음으로 정리하고, 그 묶음을 패키지로 옮길 때 필요한 정보를 살펴본다.
실행의 출발점은 여전히 main.R이다. 여러 파일을 같은 프로젝트 폴더에 저장한 뒤 그 폴더에서 Rscript main.R을 실행한다. 패키지를 설치하거나 외부 도구를 추가할 필요는 없다. R 코드는 별도의 컴파일 명령 없이 실행하며, 이번 예제의 정상 실행에서는 오류와 경고가 발생하지 않는다.
- 함수 정의, 실행 절차, 테스트를 서로 다른 파일에 배치한다.
- source()가 파일을 읽는 위치와 정의를 저장하는 환경을 구분한다.
- DESCRIPTION과 NAMESPACE가 각각 무엇을 기록하고 통제하는지 설명한다.
- 문서화 주석과 stopifnot()을 이용해 함수의 사용 조건과 결과를 확인한다.
- 회귀 테스트로 기존 결과를 보존하면서 함수의 내부 구현을 바꾼다.
문제 상황
동네 편의점 체인에는 가람점과 나루점이 있다. 담당자는 하루 판매 기록에서 지점별 판매 수량과 매출을 계산하고, 기초 재고에서 판매 수량을 빼서 잔여 재고를 구한다. 처음에는 데이터 생성, 계산, 출력이 하나의 긴 스크립트에 들어 있었다. 계산식을 수정할 때마다 파일 전체를 실행해야 했고, 출력 형식을 고치다가 계산 코드까지 바꾸는 일도 생겼다.
다른 담당자가 같은 계산을 사용하려면 긴 스크립트에서 필요한 부분을 복사해야 했다. 복사한 함수 중 어느 것이 최신인지 확인하기도 어려웠다. 더구나 판매 기록의 행 순서가 바뀌면 지점의 출력 순서까지 바뀌었다. 숫자는 같아도 보고서 비교에서는 차이로 나타났다.
이번에는 계산 함수를 R 폴더에 모으고, 검사를 tests 폴더에 둔다. main.R은 파일을 읽고 데이터를 만든 다음, 테스트를 통과한 함수로 결과를 출력한다. 예제의 재고 수량은 품목별 재고가 아니라 지점의 총수량이다. 서로 다른 가격의 상품을 합산하는 단순한 상황이며, 반품이나 입고는 포함하지 않는다. 이 범위를 주석과 검사에 함께 기록해야 함수의 결과를 올바르게 해석할 수 있다.
목표는 파일 수를 늘리는 데 있지 않다. 계산 규칙을 한곳에서 관리하고, 변경할 때 확인할 근거를 남기는 데 있다. 작은 프로젝트에서도 실행하는 코드와 불러올 코드를 구분하면 이 두 가지를 함께 얻을 수 있다.
파일의 책임과 source()의 실행 위치
파일을 나눌 때는 먼저 책임을 나눈다. 계산 파일은 함수만 정의한다. 실행 파일은 데이터를 준비하고 함수를 호출한다. 테스트 파일은 입력과 기대 결과를 연결한다. 함수 파일을 읽었다는 이유만으로 보고서가 출력되거나 데이터가 저장되지 않도록 구성한다.
| 파일 | 담당하는 일 | 읽는 시점 |
|---|---|---|
| main.R | 데이터 생성, 파일 로딩, 검사와 출력 | Rscript 실행 시 |
| R/sales.R | 입력 검사와 판매·재고 계산 | main.R이 실행될 때 |
| tests/test-sales.R | 기대 결과와 회귀 검사 | 계산 함수를 읽은 뒤 |
| DESCRIPTION, NAMESPACE | 패키지 정보와 공개 함수 선언 | 패키지 도구가 처리할 때 |
source()는 파일에 적힌 식을 읽고 평가한다. 파일을 단순히 연결하는 작업이 아니다. 파일 안에 cat()이 있으면 출력이 발생하고, 대입문이 있으면 지정된 환경에 이름이 생긴다. 따라서 함수 정의만 들어 있는 파일도 source()를 통해 평가해야 함수를 사용할 수 있다.
이번 코드에서는 app이라는 환경을 만들고 source()의 local 인수에 넘긴다. 계산 함수들은 app에 저장된다. 테스트용 환경은 app을 부모로 삼으므로 계산 함수를 찾을 수 있지만, 테스트 파일에서 정의한 이름은 app에 추가되지 않는다. 이는 앞서 다룬 환경의 이름 탐색 규칙을 파일 구성에 적용한 것이다.
상대 경로는 파일이 놓인 곳이 아니라 현재 작업 폴더를 기준으로 해석한다. 이번 실행 계약은 프로젝트 폴더에서 Rscript main.R을 실행하는 것이다. main.R은 필요한 두 파일이 그 위치에 있는지 먼저 확인한다. 어디서나 실행할 수 있도록 경로 탐색 코드를 늘리는 대신, 실행 위치를 분명하게 정한다.
app의 부모로 baseenv()를 사용하므로 계산 파일은 기본 함수만 이용한다. main.R에서 만든 sales가 자동으로 계산 함수 안에서 보이는 구조도 아니다. 필요한 데이터는 함수 인수로 전달한다. 파일 분리와 함께 입력 경계까지 드러내면, 계산 함수가 우연히 실행 파일의 변수에 의존하는 일을 줄일 수 있다.
패키지 정보와 문서화 주석
패키지(package)는 관련 함수와 사용 설명, 배포 정보를 일정한 구조로 묶은 것이다. 이번 프로젝트는 패키지에서 사용하는 R 폴더와 메타데이터 파일을 갖추지만, 실행 확인은 설치 없이 source()로 한다. 이 두 실행 방식은 구분해야 한다. source()는 DESCRIPTION이나 NAMESPACE를 읽어서 함수의 공개 여부를 결정하지 않는다.
DESCRIPTION은 패키지의 이름, 버전, 설명, 저자, 라이선스와 의존 조건을 기록한다. Package 값은 패키지를 식별하는 이름이며, Title은 사람이 읽는 짧은 제목이다. Description은 제공하는 기능을 설명한다. Depends에 적은 R 버전은 이번 코드의 실행 조건을 나타낸다. 여러 줄 값을 쓸 때 이어지는 줄 앞에는 공백을 둔다.
NAMESPACE는 이름공간(namespace)의 공개 이름과 가져올 이름 등을 선언한다. 이번에는 두 계산 함수만 export()로 공개하고, 입력 검사 함수는 내부에 둔다. 내부 함수도 패키지 안에서는 호출할 수 있다. 공개하지 않는다는 뜻은 패키지 사용자에게 일반적인 호출 지점으로 제공하지 않는다는 뜻이다.
단, app$validate_sales처럼 환경에서 직접 이름을 꺼내는 것은 이번 source() 실행에서도 가능하다. NAMESPACE의 선언이 이 접근을 막지는 않는다. 환경에 파일을 읽는 방식은 패키지 로딩을 흉내 내는 일부 구성일 뿐이며, 실제 패키지의 이름공간 처리를 대신하지 않는다.
문서화 주석은 함수의 목적, 인수, 반환값과 제한을 함수 가까이에 남긴다. 코드에서 사용하는 #' 표시는 R에게 일반 주석이다. @param과 @return 같은 표기는 문서를 읽는 사람에게 구조를 제공하며, 별도 도구를 사용하면 도움말 생성의 재료로도 쓸 수 있다. 이번에는 외부 도구를 설치하지 않으므로 주석에서 도움말 파일이 자동으로 만들어지지 않는다.
배포 가능한 패키지로 마무리하려면 공개 함수의 도움말 등을 man 폴더에 준비하고 추가 검사를 해야 한다. R CMD build 프로젝트폴더는 소스 묶음을 만드는 명령이며, 완전한 품질 검사를 대신하지 않는다. 이번 장에서는 명령을 실행하지 않는다. 패키지 형식의 세부 규칙을 확인할 때는 R 확장 작성 공식 안내를 사실 확인 자료로 사용할 수 있다.
테스트가 보존하는 것은 함수의 계약이다
테스트(test)는 정해진 입력에 대해 프로그램이 기대한 조건을 만족하는지 실행으로 확인하는 절차다. 이 예제의 계약은 지점을 정렬해 반환하고, 수량과 매출을 합산하며, 재고표와 연결해 잔여 수량을 계산하는 것이다. 판매 수량과 단가에는 음수나 결측값을 허용하지 않는다. 판매가 없는 재고 지점은 결과에 포함하지 않는다.
stopifnot()은 조건이 참이 아닐 때 오류를 낸다. 모든 값이 같은지를 비교할 때는 all.equal()의 반환 형식에 주의한다. 비교가 성공하면 TRUE를 반환하지만, 차이가 있으면 설명 문자열을 반환할 수 있다. 따라서 isTRUE(all.equal(actual, expected))처럼 논리값으로 바꾸어 검사한다.
이번 검사 함수는 성공한 검사 수를 세고, 실패하면 검사 이름을 포함한 오류를 낸다. 이름이 붙어 있으면 여러 검사를 실행할 때 어느 계약이 깨졌는지 바로 알 수 있다. 정상 결과뿐 아니라 잘못된 입력이 오류로 거절되는지도 확인한다. 다만 오류 발생 검사 하나만으로 정확한 원인까지 확인한 것은 아니다. 여러 오류 원인을 구별해야 하는 프로그램에서는 메시지나 조건의 종류까지 검사 범위를 넓혀야 한다.
회귀 테스트(regression test)는 변경 이후에도 보존해야 할 동작을 확인한다. 여기서는 반복문으로 작성한 기준 구현과 파일로 분리한 구현을 비교한다. 기준 구현은 작은 고정 데이터에 적용하며, 비교 결과가 같다는 사실과 별개로 판매 수량과 매출의 기대값도 직접 검사한다. 두 구현이 같은 실수를 해도 비교만으로는 찾지 못하기 때문이다.
리팩터링(refactoring)은 외부에서 관찰하는 동작을 유지하면서 내부 구성을 바꾸는 작업이다. 반복문을 지점별 vapply() 호출로 바꾸거나 함수를 다른 파일로 이동하는 일이 여기에 해당한다. 결과의 열 이름, 행 순서, 자료형도 호출자가 사용하는 동작에 포함될 수 있다. 숫자가 같다는 이유만으로 이런 차이를 무시하지 않는다.
테스트 데이터는 많은 행보다 구별되는 조건이 중요하다. 이번 여섯 행에는 같은 지점의 반복 판매와 서로 다른 단가를 넣었다. 입력 행 순서를 뒤집어도 지점 순서가 유지되는지 별도로 확인한다. 이런 검사는 구현을 조금 바꾸었을 때 발생하기 쉬운 변화를 짧은 코드로 드러낸다.
완성 코드
아래 다섯 파일을 한 프로젝트 폴더에 저장한다. R과 tests는 그 폴더 바로 아래의 하위 폴더다. 파일은 UTF-8로 저장한다. 데이터는 main.R 안에서 생성하며 난수를 사용하지 않는다. 따라서 같은 코드의 출력은 실행할 때마다 같다. 그래프를 만들지 않으므로 이미지 출력 파일도 없다.
main.R
required_files <- c("R/sales.R", "tests/test-sales.R")
if (!all(file.exists(required_files))) {
stop("프로젝트 폴더에서 실행해야 한다.", call. = FALSE)
}
app <- new.env(parent = baseenv())
source("R/sales.R", local = app, encoding = "UTF-8")
sales <- data.frame(
branch = c("가람점", "가람점", "가람점",
"나루점", "나루점", "나루점"),
qty = c(3, 2, 4, 1, 5, 2),
price = c(1000, 2000, 1000, 2000, 1000, 2000),
stringsAsFactors = FALSE
)
inventory <- data.frame(
branch = c("가람점", "나루점"),
opening = c(20, 18),
stringsAsFactors = FALSE
)
test_env <- new.env(parent = app)
source("tests/test-sales.R", local = test_env,
encoding = "UTF-8")
test_count <- test_env$run_tests(sales, inventory)
result <- app$analyze_branches(sales, inventory)
cat(sprintf("검사 통과: %d개\n", test_count))
cat("지점 | 판매 수량 | 매출 | 잔여 재고\n")
for (i in seq_len(nrow(result))) {
cat(sprintf("%s | %.0f | %.0f | %.0f\n",
result$branch[i], result$units[i],
result$revenue[i], result$remaining[i]))
}
R/sales.R
validate_sales <- function(sales) {
stopifnot(
is.data.frame(sales),
all(c("branch", "qty", "price") %in% names(sales)),
is.character(sales$branch),
is.numeric(sales$qty),
is.numeric(sales$price),
nrow(sales) > 0L,
!anyNA(sales$branch),
all(nzchar(sales$branch)),
all(is.finite(sales$qty)),
all(is.finite(sales$price)),
all(sales$qty >= 0),
all(sales$qty == floor(sales$qty)),
all(sales$price >= 0)
)
invisible(TRUE)
}
#' 지점별 판매 수량과 매출을 합산한다.
#' @param sales branch, qty, price 열을 가진 데이터 프레임.
#' @return branch, units, revenue 열을 가진 데이터 프레임.
#' @details 수량은 음이 아닌 정수이며 단가는 음이 아닌 수다.
#' 지점명은 문자이며 결과를 문자 바이트 순서로 정렬한다.
summarize_sales <- function(sales) {
validate_sales(sales)
branches <- sort(unique(sales$branch), method = "radix")
units <- vapply(branches, function(branch) {
sum(sales$qty[sales$branch == branch])
}, numeric(1))
revenue <- vapply(branches, function(branch) {
rows <- sales$branch == branch
sum(sales$qty[rows] * sales$price[rows])
}, numeric(1))
stopifnot(all(is.finite(units)), all(is.finite(revenue)))
data.frame(
branch = branches,
units = unname(units),
revenue = unname(revenue),
stringsAsFactors = FALSE
)
}
#' 판매 요약에 기초 재고와 잔여 재고를 연결한다.
#' @param sales summarize_sales()가 받는 판매 데이터.
#' @param inventory branch, opening 열을 가진 재고 데이터.
#' @return 판매 요약에 opening, remaining 열을 더한 데이터.
#' @details 판매가 있는 지점만 반환한다. 입고와 반품은 없다.
#' 기초 재고가 판매 수량보다 작으면 오류를 낸다.
analyze_branches <- function(sales, inventory) {
summary <- summarize_sales(sales)
stopifnot(
is.data.frame(inventory),
all(c("branch", "opening") %in% names(inventory)),
is.character(inventory$branch),
is.numeric(inventory$opening),
!anyNA(inventory$branch),
all(nzchar(inventory$branch)),
anyDuplicated(inventory$branch) == 0L,
all(is.finite(inventory$opening)),
all(inventory$opening >= 0),
all(inventory$opening == floor(inventory$opening))
)
index <- match(summary$branch, inventory$branch)
stopifnot(!anyNA(index))
summary$opening <- inventory$opening[index]
stopifnot(all(summary$opening >= summary$units))
summary$remaining <- summary$opening - summary$units
summary
}
tests/test-sales.R
run_tests <- function(sales, inventory) {
count <- 0L
check <- function(label, condition) {
if (!isTRUE(condition)) {
stop(paste("검사 실패:", label), call. = FALSE)
}
count <<- count + 1L
invisible(TRUE)
}
check_equal <- function(label, actual, expected) {
check(label, isTRUE(all.equal(actual, expected)))
}
check_error <- function(label, expression) {
failed <- tryCatch({
force(expression)
FALSE
}, error = function(e) TRUE)
check(label, failed)
}
reference_summary <- function(x) {
branches <- sort(unique(x$branch), method = "radix")
result <- data.frame(
branch = branches,
units = numeric(length(branches)),
revenue = numeric(length(branches)),
stringsAsFactors = FALSE
)
for (i in seq_len(nrow(x))) {
j <- match(x$branch[i], branches)
result$units[j] <- result$units[j] + x$qty[i]
result$revenue[j] <- result$revenue[j] +
x$qty[i] * x$price[i]
}
result
}
result <- analyze_branches(sales, inventory)
check_equal("결과 열",
names(result),
c("branch", "units", "revenue",
"opening", "remaining"))
check_equal("지점 순서", result$branch,
c("가람점", "나루점"))
check_equal("판매 수량", result$units, c(9, 8))
check_equal("매출", result$revenue, c(11000, 11000))
check_equal("잔여 재고", result$remaining, c(11, 10))
check_equal("기준 구현과 일치",
summarize_sales(sales),
reference_summary(sales))
reversed <- sales[rev(seq_len(nrow(sales))), ,
drop = FALSE]
check_equal("입력 행 순서와 무관",
analyze_branches(reversed, inventory),
result)
bad_sales <- sales
bad_sales$qty[1] <- -1
check_error("음수 판매 거절", summarize_sales(bad_sales))
count
}
DESCRIPTION
Package: cornerledger
Type: Package
Title: Summarize Branch Sales and Opening Inventory
Version: 0.1.0
Authors@R: person("Mina", "Kim", role = c("aut", "cre"),
email = "mina@example.invalid")
Description: Computes branch totals from sales records and
compares sold quantities with opening inventory.
License: GPL-3
Encoding: UTF-8
Depends: R (>= 4.5.0)
NAMESPACE
export(summarize_sales)
export(analyze_branches)
줄별 해설
main.R의 첫 부분은 실행에 필요한 파일 두 개를 확인한다. DESCRIPTION과 NAMESPACE는 source() 실행에 필요하지 않으므로 이 검사에 넣지 않는다. 필요한 파일이 없으면 계산을 시작하기 전에 실행 위치에 관한 메시지를 낸다. 파일을 찾는 데 실패한 것과 판매 데이터가 잘못된 것을 구분할 수 있다.
new.env(parent = baseenv())는 계산 함수를 보관할 환경을 만든다. 이어지는 source()의 local = app은 함수 정의가 저장될 위치를 지정한다. encoding = "UTF-8"은 파일을 읽을 때 사용할 인코딩을 지정한다. 한글 지점명과 오류 메시지를 포함한 파일도 같은 규칙으로 저장한다.
sales의 각 행은 한 판매 기록이다. qty와 price를 곱하면 그 행의 매출이 된다. inventory는 지점마다 한 행을 가져야 한다. 서로 다른 자료를 하나의 위치에 맞춰 놓고 계산하지 않고, branch 값을 이용해 연결한다.
test_env의 부모는 app이다. test_env에 정의된 run_tests()가 analyze_branches()를 호출하면 이름 탐색이 app으로 이어진다. main.R에서는 테스트 함수와 업무 함수를 환경 이름으로 구분해 호출한다. 모든 파일을 전역 환경에 읽는 것보다 이름의 출처가 드러난다.
validate_sales()의 stopifnot()은 자료의 구조부터 값의 범위까지 차례로 확인한다. is.finite()는 결측값뿐 아니라 무한대도 거절한다. floor()와 비교하는 조건은 소수 수량을 거절하되, R의 numeric 벡터에 저장된 정수 값은 허용한다. 추가 열이 있어도 필요한 열을 갖추면 계산할 수 있다.
summarize_sales()는 중복을 제거한 지점명을 정렬한다. method = "radix"는 여기서 문자 바이트 순서로 정렬해 로케일에 따른 출력 순서 차이를 줄인다. 이 순서는 사람이 기대하는 모든 언어의 사전 순서를 뜻하지 않는다. 예제에서는 출력의 재현성을 위한 규칙으로 사용한다.
두 vapply() 호출은 지점마다 길이 1의 숫자를 반환하도록 요구한다. 첫 호출은 수량을 더하고, 둘째 호출은 행별 매출을 더한다. 결과에 붙을 수 있는 이름은 unname()으로 제거한다. 지점명은 branch 열에서 관리하므로 행 이름에 같은 정보를 중복해 저장할 필요가 없다.
analyze_branches()는 재고 지점의 중복을 거절하고 match()로 판매 요약에 대응하는 재고 행을 찾는다. 재고표의 행 순서가 달라도 연결은 유지된다. 대응하는 재고가 없거나 판매 수량이 기초 재고를 넘으면 오류를 낸다. 잔여 재고를 음수로 계산한 뒤 정상 결과처럼 출력하지 않는다.
테스트의 count는 run_tests() 호출 안에서 만들어진다. 내부 check()는 <<-로 이 값을 증가시킨다. 따라서 run_tests()를 다시 호출하면 검사 수는 다시 0에서 시작한다. 전역 변수로 검사 수를 관리하지 않는다.
check_error()는 전달받은 식을 force()로 평가하고, 오류가 발생하면 TRUE를 얻는다. 이 오류는 예상된 검사 대상이므로 tryCatch()가 처리한다. 콘솔에는 오류 메시지가 출력되지 않는다. 마지막 count가 반환된 뒤 main.R은 검사 수와 계산 결과를 정해진 형식으로 출력한다.
실행 결과
프로젝트 폴더에서 다음 명령을 실행한다. 통화의 천 단위 구분 기호와 자동 행 번호를 사용하지 않아 출력 형식이 일정하다. 여덟 검사를 모두 통과해야 판매 요약이 출력된다.
Rscript main.R
검사 통과: 8개
지점 | 판매 수량 | 매출 | 잔여 재고
가람점 | 9 | 11000 | 11
나루점 | 8 | 11000 | 10
가람점의 매출은 3×1000 + 2×2000 + 4×1000으로 11000이다. 판매 수량은 9이므로 기초 재고 20에서 9를 뺀 11이 남는다. 나루점은 판매 수량 8, 매출 11000, 잔여 재고 10이다. 수작업으로 확인할 수 있는 크기의 데이터를 사용하면 테스트의 기대값도 검토하기 쉽다.
검사 통과는 이 여덟 조건을 만족했다는 뜻이다. 모든 가능한 데이터를 확인했다는 뜻은 아니다. 지점이 추가되거나 반품을 허용하는 등 계약이 바뀌면 데이터와 기대값을 함께 확장해야 한다.
실무에서 자주 틀리는 것
현재 작업 폴더를 확인하지 않고 상대 경로를 사용한다
다른 폴더에서 실행하면 R/sales.R을 찾지 못할 수 있다. source()가 main.R의 위치를 자동으로 기준 삼는다고 생각하면 실행 방식에 따라 문제가 나타난다.
# 틀린 코드: 실행 위치에 관한 안내가 없다.
source("R/sales.R")
# 고친 코드: 실행 계약을 검사한다.
if (!file.exists("R/sales.R")) {
stop("프로젝트 폴더에서 실행해야 한다.", call. = FALSE)
}
source("R/sales.R", local = app, encoding = "UTF-8")
이 수정은 작업 폴더를 자동으로 바꾸지 않는다. 사용자는 프로젝트 폴더로 이동해서 실행한다. 실행 위치를 넓게 지원해야 한다면 별도의 경로 설계가 필요하다.
파일을 읽는 순간 업무 계산까지 실행한다
함수 파일 끝에 실제 계산 호출을 넣으면, 테스트를 준비하려고 파일을 읽는 순간에도 데이터가 필요해진다. 파일을 불러오는 행위와 함수를 사용하는 행위를 분리한다.
# 틀린 코드: R/sales.R 끝에서 실행한다.
print(analyze_branches(sales, inventory))
# 고친 코드: main.R에서 호출한다.
result <- app$analyze_branches(sales, inventory)
함수 파일에는 정의를 남기고, 입력 준비와 호출은 실행 파일에 둔다. 이 구분은 다른 데이터로 함수를 재사용할 때도 도움이 된다.
all.equal()의 결과를 논리값으로 가정한다
비교가 실패하면 all.equal()은 차이를 설명하는 문자열을 반환할 수 있다. 이 값을 if 조건으로 직접 넣으면 의도한 검사 실패 메시지에 도달하지 못할 수 있다.
# 틀린 코드
if (!all.equal(actual, expected)) {
stop("결과가 다르다.")
}
# 고친 코드
if (!isTRUE(all.equal(actual, expected))) {
stop("결과가 다르다.", call. = FALSE)
}
숫자의 작은 오차를 허용하는 비교인지, 자료형까지 정확히 같아야 하는 비교인지도 정해야 한다. 정확한 동일성이 계약이라면 identical()을 사용할 수 있다. 이번 코드의 all.equal()은 기본 허용 오차를 사용한다.
관찰한 결과로 기대값을 매번 다시 만든다
새 구현의 출력으로 기대값을 계산하면 새 구현이 틀려도 검사가 통과할 수 있다. 리팩터링 전에 검토한 기대값을 고정하고, 독립적으로 읽을 수 있는 기준 구현을 보조 근거로 남긴다.
# 틀린 코드
actual <- summarize_sales(sales)$revenue
expected <- summarize_sales(sales)$revenue
stopifnot(isTRUE(all.equal(actual, expected)))
# 고친 코드: 이 고정 예제의 기대 매출이다.
actual <- summarize_sales(sales)$revenue
expected <- c(11000, 11000)
stopifnot(isTRUE(all.equal(actual, expected)))
업무 규칙을 의도적으로 바꾸었다면 기대값도 바뀔 수 있다. 그때는 변경 이유를 기록하고 새 규칙으로 직접 계산한 값과 대조한다. 검사 실패를 없애기 위해 기대값만 바꾸는 것은 변경의 근거가 되지 않는다.
한눈에 보기
| 요소 | 핵심 역할 | 이번 장의 사용 |
|---|---|---|
| source() | 파일의 식을 지정 환경에서 평가 | 계산 환경과 테스트 환경에 각각 로딩 |
| DESCRIPTION | 이름, 버전, 저자, 의존 조건 기록 | R 4.5 이상과 패키지 정보 선언 |
| NAMESPACE | 패키지의 공개 이름 등 선언 | 계산 함수 두 개를 export |
| 문서화 주석 | 입력·출력·제한을 설명 | 함수 바로 위에 계약 기록 |
| stopifnot() | 조건 위반 시 실행 중단 | 입력 구조와 값의 범위 검사 |
| 회귀 테스트 | 변경 뒤 보존할 동작 확인 | 고정 기대값과 기준 구현 비교 |
| 이번 코드 | 대응하는 외부 도구 | 차이 |
|---|---|---|
| vapply()로 지점별 합산 | tidyverse의 dplyr 요약 | 그룹 계산을 표현하는 문법이 다르다 |
| match()로 재고 연결 | dplyr의 조인 함수 | 중복 키와 미일치 처리 규칙을 확인해야 한다 |
| 직접 만든 check_equal() | testthat의 expect_equal() | 검사 보고와 실패 설명 기능이 더해진다 |
| 직접 만든 check_error() | testthat의 expect_error() | 오류 메시지나 조건을 세밀하게 검사할 수 있다 |
대응표의 도구는 이번 실행에 사용하지 않는다. 표현 방법을 바꾸더라도 공개 함수의 계약과 기대값은 먼저 정해야 한다. 도구의 선택이 그 결정을 대신해 주지는 않는다.
연습 문제
- 기존 판매 데이터 끝에 가람점의 수량 1, 단가 2000인 행을 추가한다. 재고는 그대로 둔다. 가람점의 판매 수량, 매출, 잔여 재고를 구하고, 고정 기대값 검사에서 바꿀 부분을 설명한다.
- 재고표의 행 순서를 뒤집어도 결과가 같은지 검사 한 개를 추가한다. 기존 예제 데이터로 실행했을 때 검사 통과 수가 얼마가 되는지도 적는다.
- 판매 수량이 기초 재고보다 많은 경우를 check_error()로 검사한다. 판매 데이터는 그대로 두고 가람점의 기초 재고만 8로 바꾼다.
- NAMESPACE에서 export(analyze_branches)를 지운 상태로 Rscript main.R을 실행하면 어떤 일이 생기는지 설명한다. 실제 패키지로 설치했을 때 일반적인 공개 호출과도 비교한다.
정답과 해설
판매 행 추가와 기대값 변경
가람점의 판매 수량은 10, 매출은 13000, 잔여 재고는 10이다. 기존 데이터에 행을 더하는 코드는 다음과 같다.
sales <- rbind(
sales,
data.frame(branch = "가람점", qty = 1, price = 2000,
stringsAsFactors = FALSE)
)
run_tests()의 판매 수량 기대값은 c(10, 8), 매출 기대값은 c(13000, 11000), 잔여 재고 기대값은 c(10, 10)으로 바꾼다. 지점과 열 이름의 기대값은 유지된다. 기준 구현과 순서 변경 검사는 전달받은 데이터로 계산하므로 그대로 사용할 수 있다. main.R의 출력도 새 데이터에 맞춰 달라진다.
재고 행 순서 검사
run_tests() 안에서 result를 만든 뒤 다음 검사를 추가한다. 판매 지점에 맞는 재고를 match()로 찾으므로 결과가 유지된다.
reversed_inventory <- inventory[
rev(seq_len(nrow(inventory))), , drop = FALSE
]
check_equal("재고 행 순서와 무관",
analyze_branches(sales, reversed_inventory),
result)
이 문제의 검사만 추가하면 통과 수는 9개다. 행 위치에 기대어 재고를 붙이도록 구현을 바꾸면 이 검사가 실패할 수 있다.
재고 부족 검사
run_tests() 안에 다음 코드를 추가한다. 지점명으로 수정 대상을 찾으므로 재고표의 행 순서를 가정하지 않는다.
short_inventory <- inventory
short_inventory$opening[
short_inventory$branch == "가람점"
] <- 8
check_error("재고 부족 거절",
analyze_branches(sales, short_inventory))
원래 가람점 판매 수량은 9다. 기초 재고 8은 판매 수량보다 작으므로 analyze_branches()의 조건 검사가 오류를 낸다. 이 문제의 검사만 추가하면 통과 수는 9개이며, 앞 문제의 검사도 함께 추가하면 10개다.
공개 선언과 source()의 차이
이번 main.R의 실행 결과는 바뀌지 않는다. source()는 R/sales.R을 app에 읽고, main.R은 app$analyze_branches로 함수를 꺼내 호출한다. NAMESPACE는 이 경로에서 처리되지 않는다.
실제 패키지로 설치하고 로딩하면 공개 선언이 적용된다. export를 제거한 함수는 cornerledger::analyze_branches라는 일반적인 공개 접근으로 호출할 수 없다. 다만 패키지 내부 함수의 정의가 사라지는 것은 아니다. 파일 로딩 검사와 실제 패키지 검사가 서로 다른 일을 확인한다는 점을 이 차이로 구분할 수 있다.