Devin.KR

R · 심화

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

메타프로그래밍 - 식을 값으로 다루기

quote·bquote·substitute·deparse·eval, 비표준 평가와 subset·with 의 원리, 식 만들기의 위험

개발자KR · 원고 갱신

이 장에서 배우는 것

편의점 체인의 판매 자료를 분석하다 보면 계산 결과뿐 아니라 계산 방법 자체를 보관해야 할 때가 있다. “판매량이 기준 이상이고 재고가 기준 이하인 상품”이라는 조건을 여러 지점에 반복해서 적용하거나, 보고서에 실제로 적용한 조건을 함께 남기는 경우다. 이때 조건을 문자열로 조립하기보다 R의 식을 값으로 다루면 계산 구조를 유지하면서 필요한 부분만 바꿀 수 있다.

메타프로그래밍(metaprogramming)은 코드의 구조를 프로그램 안에서 다루는 방식이다. 이 장에서는 거대한 코드 생성기를 만들지 않는다. 편의점 자료에 적용할 작은 조건식을 만들고, 그 식이 사용할 이름과 평가 환경을 정한 뒤, 결과를 검사하는 도구를 만든다. 앞 장에서 다룬 조건 처리의 관점은 여기에도 이어진다. 잘못된 식을 조용히 받아들이기보다 허용 범위를 정하고 문제가 발견되는 지점에서 멈추는 것이 중요하다.

  • quote로 계산을 보류하고, 식·호출·이름의 차이를 설명한다.
  • bquote와 substitute가 식의 일부를 바꾸거나 인수의 표현을 포착하는 방식을 구분한다.
  • eval의 평가 환경을 선택하고 subset과 with의 이름 탐색 원리를 이해한다.
  • deparse를 기록용으로 사용하고 문자열을 실행 코드로 바꿀 때의 위험을 설명한다.
  • 허용한 문법만 사용하는 판매·재고 조건식을 만들고 결과를 검사한다.

문제 상황

동네 편의점 체인은 매일 지점별 상품 판매량과 남은 재고를 확인한다. 담당자는 판매량이 10개 이상인데 재고가 6개 이하인 상품을 골라 추가 입고 목록을 만든다. 다음 주에는 행사 때문에 판매량 기준을 12개로 바꾸고, 다른 보고서에서는 재고 기준을 4개로 바꾼다. 자료의 열 이름은 sales와 stock으로 고정되어 있지만 기준값은 자주 달라진다.

처음에는 sales >= 10 & stock <= 6을 직접 쓰면 충분하다. 그러나 같은 조건을 여러 자료에 적용하고, 적용한 조건을 보고서에 남기려면 조건을 독립적인 값으로 보관하는 편이 편리하다. 조건을 만드는 함수와 조건을 실행하는 함수를 나누면 어느 단계에서 기준값을 정했는지, 어느 자료에서 열을 찾았는지도 드러난다.

문자열을 이어 붙이는 방법도 떠오른다. 하지만 문자열에는 R의 계산 구조가 없다. 따옴표와 괄호를 잘못 붙일 수 있고, 외부에서 받은 문자열을 실행하면 의도하지 않은 함수 호출까지 받아들일 수 있다. 이 장의 도구는 사용자가 임의의 R 코드를 입력하는 방식 대신 숫자 기준 두 개를 받아 정해진 구조의 식을 만든다. 실행 함수에는 이름과 연산자의 허용 목록도 둔다.

계산을 보류한 식은 어떤 값인가

R은 함수를 호출하기 전에 모든 인수를 곧바로 계산하지 않는다. 이 성질을 이용하면 함수가 인수의 결과 대신 인수로 전달된 표현을 다룰 수 있다. quote는 전달된 표현을 계산하지 않고 반환한다. 아래 코드에서 sales라는 객체가 아직 없어도 첫 줄은 실행할 수 있다. sales의 값을 찾아 비교하는 일은 나중에 eval을 호출할 때 일어난다.

condition <- quote(sales >= 10)
typeof(condition)
typeof(quote(sales))
typeof(quote(10))

세 결과는 각각 "language", "symbol", "double"이다. 첫 값은 함수 호출 구조를 가진 언어 객체다. 비교 연산자도 R에서는 호출의 머리에 놓인다. 두 번째 값은 이름을 나타내는 심벌(symbol)이고, 세 번째 값은 숫자 상수다. quote를 썼다고 해서 모든 결과가 같은 자료형이 되는 것은 아니다.

sales >= 10이라는 호출은 머리인 >=, 첫 번째 인수인 sales, 두 번째 인수인 10으로 이루어진다. condition[[1L]]을 확인하면 호출의 머리를 볼 수 있다. 여기서 [[는 계산 결과의 원소를 꺼내는 것이 아니라 식의 구성 요소에 접근한다. 익숙한 벡터 인덱싱과 비슷하게 보이지만 다루는 대상은 계산 구조다.

expression은 여러 표현을 담을 수 있는 표현식 벡터를 만든다. quote(sales >= 10)은 하나의 호출이고 expression(sales >= 10)은 그 호출을 원소로 가진 표현식 벡터다. eval은 둘 다 평가할 수 있지만 구조를 검사하는 코드에서는 이 차이가 중요하다. 이 장의 검사 함수는 표현식 벡터를 받지 않고 개별 호출·이름·상수만 다룬다.

quote는 식의 구조를 보관하고 eval은 지정한 환경에서 그 식을 계산한다

quote는 보관하고 bquote는 일부를 채운다

quote 안의 이름은 그대로 남는다. min_sales에 10을 저장한 뒤 quote(sales >= min_sales)를 만들면 오른쪽에는 숫자 10이 아니라 min_sales라는 이름이 들어간다. 그 이름의 값을 언제 어디에서 찾을지는 평가 시점에 결정된다. 조건을 만든 순간의 기준을 고정하고 싶다면 값을 식 안에 넣어야 한다.

bquote는 기본적으로 표현을 보관하되 .()로 표시한 부분을 현재 환경에서 평가하여 끼워 넣는다. 아래 식에서는 sales와 stock은 이름으로 남고 min_sales와 max_stock의 값만 숫자로 들어간다. 따라서 식을 만든 뒤 min_sales를 바꾸더라도 이미 만들어진 식의 기준은 바뀌지 않는다.

min_sales <- 10
max_stock <- 6
condition <- bquote(
  sales >= .(min_sales) & stock <= .(max_stock)
)
min_sales <- 99
condition

마지막 줄에 표시되는 식은 sales >= 10 & stock <= 6이다. .()는 여기서 bquote가 해석하는 표시다. 일반 계산 코드에서 같은 모양을 쓴다고 값 삽입이 일어나는 것은 아니다. 무엇을 이름으로 남기고 무엇을 값으로 고정할지 먼저 결정한 뒤 표시해야 한다.

substitute는 인수의 표현을 포착한다

substitute는 주어진 표현에서 이름을 대체한다. 특히 함수 안에서 형식 인수에 substitute를 적용하면 호출자가 그 인수 자리에 쓴 표현을 얻을 수 있다. 이는 인수가 지연되어 전달되는 방식과 연결된다. 아래 함수는 비교 결과를 미리 계산해 받는 대신 sales >= 10이라는 표현을 포착한다.

capture_condition <- function(condition) {
  substitute(condition)
}

capture_condition(sales >= 10)

sales 객체가 없는 상태에서도 이 호출은 식을 반환한다. 함수가 인수의 값을 요구하지 않고 표현을 꺼내기 때문이다. 다만 substitute가 언제나 호출자의 원문을 돌려주는 것은 아니다. 환경이나 대체 목록을 명시하면 그 안의 값으로 이름을 바꿀 수 있고, 기본 환경에 있는 이름에 대해서도 정해진 대체 규칙을 따른다.

substitute(
  sales >= limit,
  list(limit = 12)
)

이 코드는 sales >= 12를 만든다. 따라서 substitute를 “인수의 표현을 포착하는 도구”로만 외우기보다 “환경이나 목록의 바인딩을 이용해 이름을 대체하는 도구”로 이해하는 편이 정확하다. 완성 코드에서는 bquote로 조건을 만들고, substitute로 호출자가 직접 쓴 조건을 포착하여 두 결과가 같은지 검사한다.

식을 다루는 함수는 보관·대체·표시·평가의 역할이 다르다
함수핵심 역할이 장의 사용
quote표현을 계산하지 않고 반환한다기준값을 직접 적은 식과 비교한다
bquote.() 부분의 값을 식에 넣는다숫자 기준을 고정한 조건식을 만든다
substitute이름을 대체하고 인수의 표현을 포착한다호출자가 쓴 조건을 받아 보관한다
deparse객체를 코드 형태의 문자 벡터로 표시한다보고서용 조건 설명을 만든다
eval지정한 환경에서 식을 평가한다판매·재고 열로 조건을 계산한다

비표준 평가와 이름을 찾는 순서

비표준 평가(non-standard evaluation)는 함수가 인수의 표현을 포착하거나 평소와 다른 환경에서 평가하는 방식을 가리킨다. subset(data, sales >= 10)에서 sales를 따로 data$sales로 쓰지 않아도 되는 것은 함수가 조건의 표현을 받아 자료의 열 이름을 먼저 찾도록 평가하기 때문이다. 열 이름이 함수 호출자의 작업 공간에 자동으로 만들어진다는 뜻은 아니다.

데이터 프레임에 대한 subset의 동작은 조건 부분만 놓고 보면 substitute로 조건을 포착하고 eval로 자료 안에서 평가하는 방식으로 이해할 수 있다. 실제 구현에는 선택할 열의 처리와 행 선택 규칙 등이 더 들어 있다. 아래 함수는 그 전체를 복제하지 않고 포착과 평가라는 핵심만 보여 준다.

select_rows <- function(data, condition) {
  expr <- substitute(condition)
  keep <- eval(expr, envir = data, enclos = parent.frame())

  if (!is.logical(keep) || length(keep) != nrow(data)) {
    stop("조건은 행 수와 같은 길이의 논리 벡터여야 한다.")
  }

  keep[is.na(keep)] <- FALSE
  data[keep, , drop = FALSE]
}

envir에 데이터 프레임처럼 환경이 아닌 목록형 자료를 넘기면 eval은 그 자료의 이름을 사용할 평가 환경을 구성한다. enclos는 그 환경에서 찾지 못한 이름을 이어서 찾을 환경이다. 여기서는 함수 호출자의 환경을 지정했다. 자료에 sales가 있으면 그 열을 사용하고, 자료에 없는 limit은 호출자의 환경에서 찾을 수 있다. envir에 실제 환경 객체를 넘기는 경우에는 그 환경의 부모 관계가 쓰이며 enclos는 사용되지 않는다.

이 방식은 대화형 분석에서는 간결하지만 재사용 함수에서는 이름 충돌을 만들 수 있다. 자료에 limit 열이 새로 추가되면 이전까지 외부 기준을 뜻하던 limit이 자료의 열을 뜻하게 될 수 있다. 같은 코드가 자료의 열 구성에 따라 다른 계산을 하는 것이다. 완성 코드에서는 기준을 숫자로 식에 넣고, 자료에 없는 임의의 이름은 허용하지 않는다.

자료 안에서 이름을 먼저 찾고 없는 이름만 바깥 환경에서 찾으므로 열 이름과 외부 변수는 충돌할 수 있다

with도 자료 안에서 표현을 평가하는 도구다. 데이터 프레임을 대상으로 with(data, sales * price)를 쓰면 sales와 price를 자료에서 찾는다. 데이터 프레임에 대한 기본 동작의 핵심은 substitute(expr)로 표현을 포착하고 eval을 통해 자료와 호출자의 환경에서 평가하는 것이다. with는 평가 결과를 돌려주며 원래 데이터 프레임에 새 열을 추가하는 도구는 아니다.

revenue <- with(data, sales * price)
data$revenue <- revenue

첫 줄은 매출액 벡터를 만들고, 두 번째 줄은 그 값을 열로 저장한다. 함수 내부에서 열 이름을 문자열 인수로 받는다면 data[[column]]처럼 명시적으로 꺼내는 방법이 더 단순하다. 비표준 평가가 항상 더 좋은 인터페이스는 아니다. 직접 입력할 표현을 받고 싶은지, 열 이름이라는 데이터를 받고 싶은지에 따라 선택한다.

식 만들기는 실행 권한을 설계하는 일이다

deparse는 식을 사람이 읽을 수 있는 문자 벡터로 바꾼다. 긴 식이면 여러 문자열로 나뉠 수 있으므로 보고서의 한 줄로 만들 때는 paste(deparse(expr), collapse = " ")를 사용한다. 이 결과는 기록에 적합하지만 원래 입력의 공백이나 줄바꿈을 그대로 보존하지 않는다. 주석도 일반적인 식 구조에는 남지 않는다. 실행할 식은 별도로 보관하고 표시용 문자열은 그 식에서 만들어야 한다.

문자열에서 식을 얻는 parse와 식을 실행하는 eval을 연결하면 문자열에 담긴 함수 호출까지 실행된다. 판매·재고 조건만 받으려던 자리에 파일을 쓰는 호출이나 다른 상태를 바꾸는 호출이 들어갈 수 있다. 코드처럼 보이는 문자열을 받아 실행한다는 결정은 단순한 자료형 변환보다 큰 의미를 가진다.

평가 환경의 부모를 baseenv()로 지정해도 임의의 코드를 안전하게 실행하는 격리 공간이 되지는 않는다. 기본 환경에는 여러 기능을 가진 함수가 있다. 반대로 부모를 emptyenv()로 지정하면 비교 연산자까지 찾지 못할 수 있다. 부모 환경을 바꾸는 일과 허용할 문법을 정하는 일은 별개다.

완성 코드의 검사 함수는 식의 각 부분을 재귀적으로 확인한다. sales와 stock이라는 이름, 유한한 숫자 상수, &·>=·<=·괄호 호출만 허용한다. 이것은 좁은 조건식에 맞춘 문법 제한이며 범용 코드 실행용 보안 장치가 아니다. 별도의 신뢰 경계가 필요한 시스템에서 이 함수만으로 임의의 R 코드를 받아 실행해서는 안 된다.

평가 뒤에도 검사가 필요하다. 문법이 허용되어도 결과가 숫자 하나일 수 있고, 자료에 결측값이 있으면 논리 결과에 NA가 섞일 수 있다. 이 도구는 행 수와 같은 길이의 논리 벡터만 받아들이고, NA인 행은 선택하지 않는다. 자료의 열이 유한한 숫자인지도 먼저 검사하여 입력 계약을 분명히 한다.

함수의 세부 규칙을 확인할 때는 R 공식 문서의 quote와 substitute 설명, bquote 설명, eval 설명을 참고할 수 있다. 문서는 동작을 확인하는 근거로 사용하고, 분석 도구의 인터페이스와 예제는 업무에 맞게 설계한다.

완성 코드

다음 내용을 main.R로 저장한다. 외부 패키지와 별도 자료 파일은 필요하지 않다. 실행하면 조건식을 만들고, 조건에 맞는 상품을 골라 지점별 판매량을 합산한다. 기본 함수 subset과 같은 행을 선택하는지, 지원하지 않는 호출을 거부하는지도 검사한다. 그래프를 만들지 않으므로 생성되는 이미지 파일은 없다.

# 01. 예제 자료
sales_data <- data.frame(
  branch = rep(c("A", "B"), each = 4L),
  item = rep(c("water", "milk", "bread", "rice"), times = 2L),
  sales = c(12, 7, 15, 4, 9, 11, 6, 14),
  stock = c(5, 8, 3, 9, 7, 4, 10, 2),
  price = rep(c(1000, 2000, 1000, 2000), times = 2L),
  stringsAsFactors = FALSE
)

# 02. 식의 포착과 표시
capture_condition <- function(condition) {
  substitute(condition)
}

expression_label <- function(expr) {
  paste(deparse(expr, width.cutoff = 60L), collapse = " ")
}

# 03. 숫자 기준을 식 안에 넣기
make_condition <- function(min_sales, max_stock) {
  valid_limit <- function(x) {
    is.numeric(x) && length(x) == 1L &&
      !is.na(x) && is.finite(x) && x >= 0
  }

  if (!valid_limit(min_sales) || !valid_limit(max_stock)) {
    stop("기준은 유한한 비음수 숫자 하나여야 한다.")
  }

  bquote(sales >= .(min_sales) & stock <= .(max_stock))
}

# 04. 허용할 식의 구조 검사
check_condition <- function(expr) {
  allowed_names <- c("sales", "stock")
  allowed_calls <- c("&", ">=", "<=", "(")

  visit <- function(node) {
    if (is.numeric(node) && length(node) == 1L &&
        !is.na(node) && is.finite(node)) {
      return(invisible(TRUE))
    }

    if (is.symbol(node) &&
        as.character(node) %in% allowed_names) {
      return(invisible(TRUE))
    }

    if (is.call(node) && is.symbol(node[[1L]])) {
      head <- as.character(node[[1L]])
      if (head %in% allowed_calls) {
        expected_length <- if (head == "(") 2L else 3L
        if (length(node) != expected_length) {
          stop("연산자의 인수 개수가 맞지 않는다.")
        }
        for (part in as.list(node)[-1L]) {
          visit(part)
        }
        return(invisible(TRUE))
      }
    }

    stop("허용하지 않은 이름 또는 연산이다.")
  }

  visit(expr)
  invisible(TRUE)
}

# 05. 자료 검사와 식 평가
apply_condition <- function(data, expr) {
  needed <- c("sales", "stock")
  if (!is.data.frame(data) || anyDuplicated(names(data)) > 0L ||
      !all(needed %in% names(data))) {
    stop("자료에는 중복 없는 sales와 stock 열이 필요하다.")
  }

  valid_columns <- vapply(
    data[needed],
    function(x) is.numeric(x) && all(is.finite(x)),
    logical(1)
  )
  if (!all(valid_columns)) {
    stop("sales와 stock은 유한한 숫자 열이어야 한다.")
  }

  check_condition(expr)
  data_env <- list2env(as.list(data[needed]), parent = baseenv())
  keep <- eval(expr, envir = data_env)

  if (!is.logical(keep) || length(keep) != nrow(data)) {
    stop("조건 결과는 행 수와 같은 길이의 논리 벡터여야 한다.")
  }

  keep[is.na(keep)] <- FALSE
  data[keep, , drop = FALSE]
}

# 06. 오류 발생 여부를 확인하는 검사
expect_error <- function(code) {
  failed <- tryCatch(
    {
      force(code)
      FALSE
    },
    error = function(e) TRUE
  )
  stopifnot(failed)
  invisible(TRUE)
}

# 07. 분석과 검증
condition <- make_condition(10, 6)
captured <- capture_condition(sales >= 10 & stock <= 6)
chosen <- apply_condition(sales_data, condition)
reference <- subset(sales_data, sales >= 10 & stock <= 6)

stopifnot(
  identical(condition, quote(sales >= 10 & stock <= 6)),
  identical(condition, captured),
  identical(chosen, reference),
  identical(rownames(chosen), c("1", "3", "6", "8"))
)
expect_error(apply_condition(sales_data, quote(sum(sales) >= 10)))
expect_error(apply_condition(sales_data, quote(1)))
expect_error(make_condition(-1, 6))

chosen_sales <- vapply(
  c("A", "B"),
  function(branch_name) {
    sum(chosen$sales[chosen$branch == branch_name])
  },
  numeric(1)
)
revenue <- with(sales_data, sales * price)
stopifnot(identical(unname(chosen_sales), c(27, 25)))
stopifnot(sum(revenue) == 114000)

# 08. 결정적인 출력
cat("조건: ", expression_label(condition), "\n", sep = "")
cat(sprintf("선택 행 수: %d\n", nrow(chosen)))
cat("지점별 선택 상품 판매량\n")
for (branch_name in names(chosen_sales)) {
  cat(sprintf("%s: %.0f\n", branch_name, chosen_sales[[branch_name]]))
}
cat(sprintf("전체 매출액: %.0f원\n", sum(revenue)))
cat("검사: 모두 통과\n")

줄별 해설

01의 자료 생성 줄. 각 지점에 네 상품을 둔다. 판매량과 재고는 조건 선택에 쓰이고 가격은 with의 계산 예제에 쓰인다. 문자 열은 명시적으로 문자열로 저장한다. 숫자 기준과 숫자 열은 같은 비교 연산으로 평가할 수 있다. 행 이름은 기본값을 유지하여 마지막 검사에서 실제로 어떤 행이 선택되었는지 확인한다.

02의 substitute(condition) 줄. capture_condition은 인수의 값을 계산하지 않는다. 호출자가 인수 자리에 적은 표현을 포착하여 반환한다. expression_label의 deparse는 그 식을 표시 가능한 문자 벡터로 바꾸고, paste는 여러 줄로 나뉜 결과를 한 문자열로 합친다. 이 문자열은 출력에만 사용하며 다시 실행하지 않는다.

03의 valid_limit 줄. 기준값은 숫자 하나여야 한다. &&는 앞의 검사 결과가 거짓이면 뒤의 검사를 생략하므로 길이가 맞지 않는 입력에 숫자 하나용 검사를 계속 적용하지 않는다. NA와 무한값, 음수도 거부한다. bquote에서는 두 기준값만 평가하여 끼워 넣고 sales와 stock은 평가 대상 자료에서 찾을 이름으로 남긴다.

04의 visit 줄. 검사 함수는 식의 구성 요소를 하나씩 방문한다. 숫자 상수와 허용된 이름이면 검사를 마친다. 호출이면 머리가 허용된 연산자인지 확인하고 인수 개수도 검사한다. 괄호 호출에는 인수가 하나이고, 나머지 세 연산자에는 인수가 둘이다. 호출의 길이는 머리를 포함하므로 각각 2와 3으로 검사한다.

04의 for 줄. as.list(node)[-1L]은 호출의 머리를 제외한 인수들이다. 각 인수에 visit을 다시 적용하여 안쪽에 숨은 호출도 확인한다. 예를 들어 sales >= sum(stock)은 바깥 비교 연산자가 허용되어도 안쪽의 sum이 허용 목록에 없으므로 거부된다. 마지막 stop은 어느 허용 형태에도 맞지 않는 값을 처리한다.

05의 자료 검사 줄. 자료가 데이터 프레임인지 먼저 확인한다. ||의 단락 평가 덕분에 데이터 프레임이 아닌 입력에는 뒤의 열 검사를 계속하지 않는다. 중복 이름과 필수 열 누락을 거부한 뒤 vapply로 두 열의 숫자 여부와 유한성을 검사한다. 검사 결과는 열마다 논리값 하나이므로 logical(1)을 반환 형식으로 지정한다.

05의 list2env와 eval 줄. 평가 환경에는 sales와 stock 두 열만 넣는다. 비교 연산자는 부모인 기본 환경에서 찾는다. 기준값은 이미 식 안에 숫자로 들어 있으므로 호출자의 작업 공간을 탐색할 필요가 없다. 식을 만든 단계와 자료를 선택하는 단계의 역할이 이 지점에서 분리된다.

05의 keep 검사 줄. 문법 검사를 통과했다고 결과 형식까지 보장되는 것은 아니다. quote(1)은 허용되는 숫자 상수지만 행 선택에 필요한 논리 벡터가 아니므로 여기서 거부된다. 데이터 프레임 인덱싱이 짧은 논리 벡터를 재활용하게 두지 않고 길이를 확인한다. drop = FALSE는 선택 결과를 데이터 프레임으로 유지한다.

06의 force(code) 줄. expect_error에 전달된 코드는 지연된 상태로 들어온다. tryCatch 안에서 force를 호출해야 검사하려는 코드의 오류를 그 안에서 받을 수 있다. 오류가 발생하면 TRUE, 정상 종료하면 FALSE를 얻는다. stopifnot은 오류가 예상대로 발생했는지 확인한다. 오류의 종류나 메시지까지 검사하는 도구는 아니므로 이 예제의 좁은 검증에만 사용한다.

07의 identical 줄. 생성한 식을 직접 적은 식과 비교하여 값 삽입이 의도대로 이루어졌는지 검사한다. 포착한 식과도 비교한다. 선택 결과는 subset의 결과와 전체 데이터 프레임 단위로 비교하고, 예상 행 번호도 따로 검사한다. 두 함수가 같은 실수를 하더라도 예상 행 검사가 추가 근거를 제공한다.

07의 합산 줄과 08의 출력 줄. A와 B의 순서를 직접 지정해 지점별 합계를 만든다. with는 전체 자료의 매출액 벡터를 계산한다. 출력은 데이터 프레임의 자동 인쇄 형식에 의존하지 않고 cat과 sprintf로 고정한다. 앞선 검사에서 문제가 있으면 마지막 성공 문구까지 도달하지 않는다.

실행 결과

main.R을 저장한 디렉터리에서 다음 명령을 실행한다. 프로그램은 인터프리터로 실행되므로 별도 컴파일 단계는 없다. R 4.5 이상에서 외부 패키지 설치 없이 실행되며, 정상 입력에서는 경고가 발생하지 않는다.

Rscript main.R

예상 표준 출력은 다음과 같다.

조건: sales >= 10 & stock <= 6
선택 행 수: 4
지점별 선택 상품 판매량
A: 27
B: 25
전체 매출액: 114000원
검사: 모두 통과

A에서는 water와 bread가 선택되어 판매량이 12 + 15 = 27이다. B에서는 milk와 rice가 선택되어 11 + 14 = 25다. 전체 매출액은 선택한 네 행만의 매출액이 아니라 원래 여덟 행의 매출액 합계다. 출력의 대상 범위를 구분해야 조건 선택 결과와 전체 매출액을 혼동하지 않는다.

실무에서 자주 틀리는 것

기준값을 넣었다고 생각했지만 이름만 남긴다

다음 코드의 식에는 limit이라는 이름이 남는다. 식을 만든 뒤 limit을 바꾸면 평가할 때 바뀐 값을 찾을 수 있다. 조건을 생성한 시점의 기준을 기록하려는 목적과 맞지 않는다.

# 틀린 코드: 생성 시점의 기준을 고정하지 않는다.
limit <- 10
expr <- quote(sales >= limit)
limit <- 99
eval(expr, envir = sales_data, enclos = environment())

bquote의 삽입 부분에 기준을 넣으면 식 자체에 숫자가 남는다. 기준 변경과 이미 만들어진 조건의 의미를 분리할 수 있다.

# 고친 코드
limit <- 10
expr <- bquote(sales >= .(limit))
limit <- 99
eval(expr, envir = sales_data)

subset 안에서 외부 변수와 열 이름을 혼동한다

아래 코드는 외부 limit인 10을 사용하려는 의도다. 하지만 자료에 limit 열이 있으므로 각 행의 100과 비교한다. 오류가 나지 않아 결과가 잘못되었다는 사실을 놓치기 쉽다.

# 틀린 코드: 외부 limit을 쓰려는 의도와 다르게 계산된다.
data2 <- sales_data
data2$limit <- rep(100, nrow(data2))
limit <- 10
subset(data2, sales >= limit)

외부 기준으로 비교하려는 함수 코드에서는 열과 기준을 명시적으로 분리한다. 입력 이름을 문자열로 받는 경우에도 같은 원칙을 적용할 수 있다.

# 고친 코드
keep <- data2[["sales"]] >= limit
data2[keep, , drop = FALSE]

외부 문자열을 그대로 실행한다

다음 방식은 조건 문자열이 기대한 비교식인지 확인하지 않는다. 문자열이 여러 표현을 담으면 eval은 그 표현들을 차례로 평가할 수도 있다. 화면에서 입력받은 텍스트라는 이유만으로 실행할 권한을 주어서는 안 된다.

# 틀린 코드: 검증되지 않은 문자열을 코드로 실행한다.
text_condition <- "sales >= 10 & stock <= 6"
keep <- eval(parse(text = text_condition), envir = sales_data)

필요한 입력이 숫자 기준 두 개라면 그 두 개만 받는다. 이 방식은 문자열 문법을 받아들일 필요를 없애고, 허용된 식 구조를 생성 함수에서 결정한다.

# 고친 코드
expr <- make_condition(min_sales = 10, max_stock = 6)
chosen <- apply_condition(sales_data, expr)

논리 결과의 길이와 결측값 처리를 생략한다

자료가 평가 환경이라고 해서 모든 표현이 행별 조건이 되는 것은 아니다. sum(sales) > 0은 논리값 하나를 만든다. 이를 행 선택에 사용하면 재활용되어 전체 행이 선택될 수 있다. 결측값이 섞인 논리 벡터도 원하는 행 선택 정책과 다르게 처리될 수 있다.

# 틀린 코드
expr <- quote(sum(sales) > 0)
keep <- eval(expr, envir = sales_data)
sales_data[keep, , drop = FALSE]

행별 비교식을 사용하고 결과 길이를 확인한다. NA인 행을 제외할지 오류로 처리할지는 업무 규칙으로 정한다. 다음 코드는 제외 정책을 명시한다.

# 고친 코드
expr <- quote(sales >= 10)
keep <- eval(expr, envir = sales_data)
stopifnot(is.logical(keep), length(keep) == nrow(sales_data))
keep[is.na(keep)] <- FALSE
sales_data[keep, , drop = FALSE]

한눈에 보기

식을 만들 때는 고정할 값과 평가할 이름을 구분한다
질문선택확인할 점
표현을 그대로 보관하는가quote이름의 값은 아직 찾지 않는다
기준값을 생성 시점에 고정하는가bquote와 .()삽입한 값이 식 안에 남는다
호출자가 쓴 표현을 받는가substitute함수 인수와 대체 환경을 구분한다
조건을 사람이 읽게 남기는가deparse와 paste여러 줄 반환과 원문 손실을 고려한다
어느 자료에서 계산하는가eval의 envir열 이름과 부모 환경을 확인한다
행 선택으로 써도 되는가결과 형식과 길이 검사논리형·행 수·NA 정책을 확인한다

다른 분석 코드를 읽을 때는 문법의 닮은꼴보다 목적을 먼저 비교한다. 다음 표는 외부 패키지를 설치하거나 실행하기 위한 안내가 아니라 같은 목적의 코드를 알아보기 위한 대응표다. 패키지의 표현 포착과 환경 관리 규칙을 base R의 함수와 동일하다고 가정해서는 안 된다.

base R과 tidyverse 계열은 비슷한 목적에 서로 다른 평가 규칙을 사용한다
목적이 장의 base Rtidyverse 계열의 대응
조건으로 행 선택subset 또는 명시적 행 인덱싱dplyr::filter
열로 계산with와 열 대입dplyr::mutate
식의 일부에 값 삽입bquote의 .()rlang의 주입 문법
표현과 평가 환경 관리substitute와 별도 환경 지정rlang의 표현 포착과 quosure

이 장의 도구는 작은 식을 대상으로 생성·검사·평가를 분리했다. 분석이 느려졌다면 식을 조립하는 부분이 원인이라고 추측하기 전에 실제 시간을 측정해야 한다. 다음 장에서는 계산 결과가 유지되는지 확인하면서 실행 시간과 메모리 사용을 살펴본다.

연습 문제

  1. make_condition의 판매량 기준을 12, 재고 기준을 4로 바꾸어 선택된 지점과 상품, 판매량 합계를 구한다. 출력 문자열을 비교하지 말고 선택된 행으로 결과를 검사한다.
  2. substitute의 대체 목록을 사용해 sales >= minimum & stock <= maximum에서 minimum과 maximum을 각각 10과 6으로 바꾼다. make_condition(10, 6)이 만든 식과 동일한지 검사한다.
  3. 자료에 limit 열이 있고 외부에도 limit 객체가 있을 때 eval이 어느 값을 사용하는지 확인한다. 외부 limit을 생성 시점에 고정하는 식을 만들고 선택된 행 번호를 검사한다.
  4. 재고에 NA가 있는 행을 선택 대상에서 제외하도록 apply_condition의 자료 검사 정책을 바꾼다. 무한값과 숫자가 아닌 열은 계속 거부하고, 첫 행의 stock을 NA로 바꾸었을 때 남는 행 번호를 검사한다.

정답과 해설

판매량 12 이상, 재고 4 이하

expr2 <- make_condition(12, 4)
chosen2 <- apply_condition(sales_data, expr2)

stopifnot(
  identical(rownames(chosen2), c("3", "8")),
  identical(chosen2$branch, c("A", "B")),
  identical(chosen2$item, c("bread", "rice")),
  sum(chosen2$sales) == 29
)

A의 bread와 B의 rice가 선택된다. A의 water는 판매량 기준을 만족하지만 재고가 5이므로 제외된다. 조건의 두 부분을 함께 확인해야 한다. 판매량 합계는 15 + 14 = 29다.

대체 목록으로 기준 넣기

expr3 <- substitute(
  sales >= minimum & stock <= maximum,
  list(minimum = 10, maximum = 6)
)
stopifnot(identical(expr3, make_condition(10, 6)))

목록에 들어 있는 두 이름만 숫자로 바뀌고 sales와 stock은 남는다. 여기서는 단순한 숫자 상수를 대체하므로 bquote의 결과와 같은 구조를 얻는다. 문자열로 바꾼 뒤 다시 해석하는 과정은 필요하지 않다.

열 이름과 외부 이름의 충돌 확인

data3 <- sales_data
data3$limit <- rep(100, nrow(data3))
limit <- 10

dynamic <- quote(sales >= limit)
fixed <- bquote(sales >= .(limit))

dynamic_keep <- eval(dynamic, envir = data3, enclos = environment())
fixed_keep <- eval(fixed, envir = data3)

stopifnot(
  !any(dynamic_keep),
  identical(which(fixed_keep), c(1L, 3L, 6L, 8L))
)

dynamic은 자료의 limit 열을 사용하여 모든 행에서 판매량을 100과 비교한다. fixed에는 숫자 10이 들어 있으므로 자료의 limit 열과 관계없이 같은 기준을 사용한다. 평가 환경의 탐색 순서를 이해하면 이런 차이를 실행 전에 설명할 수 있다.

결측 재고를 제외하는 정책

apply_condition 안의 valid_columns 계산과 그 직후 오류 문구를 아래 내용으로 바꾼다. sales는 계속 유한한 숫자만 허용하고 stock만 NA 또는 NaN 같은 결측값을 허용한다. is.na는 두 종류의 결측값을 모두 참으로 판정한다. 무한값은 두 검사 모두 거짓이므로 거부된다.

valid_columns <- c(
  sales = is.numeric(data$sales) && all(is.finite(data$sales)),
  stock = is.numeric(data$stock) &&
    all(is.na(data$stock) | is.finite(data$stock))
)
if (!all(valid_columns)) {
  stop("sales는 유한한 숫자, stock은 유한한 숫자 또는 결측값이어야 한다.")
}

수정한 함수를 사용하여 다음 검사를 실행한다. 조건 결과의 NA를 FALSE로 바꾸는 기존 줄이 첫 행을 제외한다. 자료 검사와 행 선택의 결측값 정책이 서로 맞아야 한다.

data4 <- sales_data
data4$stock[1L] <- NA_real_
chosen4 <- apply_condition(data4, make_condition(10, 6))

stopifnot(identical(rownames(chosen4), c("3", "6", "8")))

data4$stock[2L] <- Inf
expect_error(apply_condition(data4, make_condition(10, 6)))

첫 행이 빠지고 나머지 세 행만 선택된다. 이어서 넣은 Inf는 자료 검사에서 거부된다. 결측값을 허용한다는 규칙을 유한하지 않은 모든 값을 허용한다는 규칙으로 넓히지 않는 것이 핵심이다.

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

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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