Devin.KR

S4 와 참조 클래스 - 형식 있는 객체와 변하는 객체

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 S3 객체에 클래스 이름을 붙이고, 같은 제네릭 함수가 객체의 클래스에 따라 다른 메서드를 실행하도록 만들었다. 이 방식은 판매 요약처럼 구조가 단순하고 유연한 객체에 잘 맞는다. 그러나 판매 기록에 필요한 항목과 자료형을 분명하게 선언하고, 객체가 업무 규칙까지 만족하는지 검사하려면 별도의 설계가 필요하다. 또한 재고처럼 여러 작업이 같은 상태를 계속 바꾸어야 하는 대상은 일반적인 값 객체와 다르게 다루어야 한다.

이 장에서는 형식을 선언하는 S4 객체와 변경 가능한 상태를 가진 참조 클래스(reference class, RC)를 편의점 판매·재고 코드에 적용한다. S4 판매 기록은 거래가 성립했을 때의 값을 담고, 참조 클래스 재고는 판매가 일어날 때마다 남은 수량을 바꾼다. 두 방식을 함께 사용하면서 어떤 객체를 값으로 다루고 어떤 객체를 공유 상태로 다룰지 구분한다. 여기서 사용하는 methods는 R에 기본으로 포함되어 있으므로 외부 패키지를 설치하지 않는다.

  • setClass로 슬롯과 자료형을 선언하고 validity로 판매 기록의 업무 규칙을 검사한다.
  • setGeneric과 setMethod로 S4 객체에 적용할 계산을 정의한다.
  • setRefClass로 재고 상태와 상태를 바꾸는 메서드를 묶는다.
  • 일반 대입, 참조 객체의 copy(), 값으로 만든 스냅샷의 차이를 확인한다.
  • 데이터 구조와 변경 방식에 따라 S3·S4·RC를 선택한다.

문제 상황

동네 편의점 체인은 지점별 상품 판매와 재고를 작은 R 프로그램으로 관리한다. 지금은 한 상품의 판매 건을 리스트로 만들고 지점, 판매 수량, 단가를 넣는다. 입력 담당자가 판매 수량을 숫자 대신 문자로 넣거나 단가를 빠뜨려도 리스트 자체는 만들어진다. 문제는 판매 금액을 계산하는 시점에 드러난다. 어느 입력 단계에서 잘못된 값이 들어왔는지 추적하기도 어렵다.

기본서에서 배운 입력 검사 함수를 붙이면 이런 문제를 줄일 수 있다. 다만 판매 기록을 만드는 함수가 늘어나면 각 함수가 같은 검사를 빠짐없이 수행해야 한다. 객체를 만드는 규칙을 한곳에 모으고, 만들어진 객체가 그 규칙에 맞는지 다시 확인할 수 있으면 유지하기가 수월하다. 이 역할을 S4 클래스 정의와 유효성 검사에 맡긴다.

재고에서는 다른 문제가 생긴다. 매장 화면과 계산대 코드가 같은 재고 객체를 사용한다고 하자. 계산대에서 상품 여섯 개를 판매했으면 매장 화면도 줄어든 재고를 읽어야 한다. 반면 판매 전 수량을 감사용으로 저장했다면 그 값은 나중의 판매 때문에 바뀌면 안 된다. 두 요구를 모두 단순한 대입문으로 표현하면 객체의 변경 방식이 불분명해진다.

예제는 서쪽점에서 한 상품을 취급하는 상황으로 좁힌다. 시작 재고는 20개이고, 단가 3,000원으로 6개를 판매한다. 판매 기록에는 지점·수량·단가를 저장하며 재고 객체에는 지점·현재 수량을 저장한다. 상품 식별자와 거래 시각을 생략한 이유는 객체 시스템의 동작을 분리해서 보기 위해서다. 여러 상품으로 확장할 때도 값 기록과 변경 가능한 상태를 구분하는 원칙은 유지된다.

S4로 형식과 업무 규칙을 선언한다

슬롯은 객체의 구조를 정한다

S4 클래스는 setClass로 정의한다. 객체를 구성하는 이름 붙은 항목을 슬롯(slot)이라고 한다. 판매 기록 SaleRecord에는 branch, units, price라는 세 슬롯을 둔다. 각각의 클래스는 character, integer, numeric이다. 리스트처럼 임의의 항목을 추가하는 대신 클래스 정의가 허용하는 구조를 사용한다.

슬롯의 자료형을 정하는 일과 업무 규칙을 정하는 일은 다르다. character는 길이가 1이라는 뜻이 아니며, integer도 양수라는 뜻이 아니다. 길이가 0인 지점 이름이나 음수 판매 수량은 자료형만 보면 통과할 수 있다. 따라서 지점은 비어 있지 않은 문자열 하나, 판매 수량은 결측이 아닌 양의 정수 하나, 단가는 유한한 양수 하나라는 규칙을 추가한다.

R에서 6은 보통 double이고 6L은 integer다. integer 슬롯에 넣을 수량은 6L처럼 명시한다. numeric 단가는 3000처럼 작성한다. 외부 데이터를 가져오는 프로그램에서는 변환 전에 원래 값이 허용 범위에 있는지 검사해야 한다. 소수 수량에 as.integer를 적용하면 소수 부분이 사라지므로, 변환 성공을 입력의 적합성으로 해석하면 안 된다.

validity는 객체 전체의 관계를 검사한다

setClass의 validity 인수에는 객체를 검사하는 함수를 넣는다. 규칙을 모두 만족하면 TRUE를 반환하고, 만족하지 않으면 설명을 담은 문자 벡터를 반환한다. FALSE만 반환하는 검사 함수로 생각하지 않는 것이 좋다. 문자 벡터에는 사용자가 고쳐야 할 항목을 구체적으로 적는다.

예제의 검사는 길이부터 확인한다. 길이가 1이 아닐 때 바로 다음 검사를 피해야 is.na의 결과나 여러 원소의 비교 결과를 조건식 하나로 잘못 사용하는 일을 줄일 수 있다. 코드에서는 ||가 왼쪽 조건이 TRUE일 때 오른쪽 조건을 평가하지 않는 성질을 이용한다. 단가에는 is.finite도 적용하여 Inf와 NaN을 허용하지 않는다.

new로 S4 객체를 만들면 슬롯 자료형과 유효성을 확인한다. 그러나 객체 생성이 끝난 뒤 모든 슬롯 변경에서 사용자 validity가 자동으로 다시 실행된다고 생각해서는 안 된다. 슬롯의 자료형에 맞는 값이어도 업무 규칙을 어길 수 있다. 직접 슬롯을 고친다면 변경 뒤 validObject를 호출하고, 실무 코드에서는 변경을 맡는 함수를 통해 규칙을 일관되게 검사하는 편이 좋다.

S4 판매 기록은 슬롯 자료형과 업무 규칙을 모두 만족해야 생성된다
판매 기록의 자료형 검사와 업무 규칙 검사는 서로 다른 책임을 가진다
슬롯선언한 자료형추가 업무 규칙
branchcharacter길이 1, 결측 없음, 빈 문자열 아님
unitsinteger길이 1, 결측 없음, 0보다 큼
pricenumeric길이 1, 유한한 값, 0보다 큼

제네릭과 메서드는 계산의 인터페이스를 정한다

판매 금액을 구하는 함수 이름은 sales_amount로 정한다. setGeneric은 함수의 인터페이스와 메서드 선택 지점을 선언한다. 함수 본문의 standardGeneric은 해당 제네릭의 메서드 선택을 요청한다. setMethod는 SaleRecord를 받았을 때 실행할 계산을 등록한다. 예제에서는 units와 price를 곱한다.

S3에서는 함수 이름과 클래스 이름을 조합한 메서드 이름이 중요한 관례였다. S4에서는 메서드를 등록할 때 시그니처(signature)로 대상 인수의 클래스를 지정한다. 여러 인수의 클래스를 기준으로 선택하는 설계도 가능하지만, 여기서는 object 하나만 사용한다. 이 장의 목적은 메서드 선택을 복잡하게 만드는 것이 아니라 판매 금액 계산의 공통 진입점을 만드는 데 있다.

메서드 첫 줄은 validObject(object)다. 정상적인 생성 경로만 사용한다면 이 검사는 반복처럼 보일 수 있다. 그러나 슬롯을 직접 고친 객체까지 받을 수 있는 계산 경계에서는 현재 객체가 업무 규칙을 만족하는지 확인하는 의미가 있다. 객체가 많고 계산이 빈번하다면 검사를 어느 경계에 둘지 비용과 함께 판단한다. 예제는 동작의 명확성을 우선한다.

참조 클래스로 재고 상태를 바꾼다

setRefClass는 필드와 메서드를 가진 참조 클래스를 만든다. 필드(field)는 현재 상태를 담고, 메서드는 그 상태를 읽거나 변경한다. Inventory의 필드는 branch와 stock이다. 이 클래스의 객체를 다른 변수에 대입하면 두 변수가 같은 참조 객체를 가리킨다. 한쪽에서 판매 메서드를 호출하면 다른 쪽에서도 바뀐 재고를 읽는다.

참조 클래스는 R의 S4 기반 기능이지만, 일반적인 S4 값 객체와 변경 의미가 다르다. S4 판매 기록을 다른 변수에 대입한 뒤 복사본의 슬롯을 바꾸면 원래 기록의 값은 유지된다. 참조 클래스에서는 대입만으로 독립 상태가 생기지 않는다. 이 차이가 재고 공유에는 도움이 되지만, 과거 기록을 보관할 때는 주의가 필요하다.

상태 변경 전에 필요한 검사를 끝낸다

참조 클래스의 필드에도 자료형을 지정할 수 있다. 하지만 stock의 integer 선언만으로 음수나 결측 재고를 막지는 못한다. 이 예제에서는 check_state 메서드에 현재 상태의 규칙을 모으고, 생성 함수와 각 업무 메서드가 이를 호출한다. S4 판매 기록의 validity와 같은 목적을 가지지만 연결 방식은 다르다. 참조 클래스 필드 변경에 판매 기록의 validity가 자동으로 적용되는 것은 아니다.

sell은 현재 상태, 요청 수량, 재고 부족 여부를 순서대로 검사한다. 그다음 SaleRecord를 생성하여 단가까지 검증한다. 모든 검사가 끝난 뒤에만 stock을 줄인다. 단가가 잘못되어 판매 기록을 만들지 못했는데 재고부터 줄어드는 상황을 피하려는 순서다. 이 코드는 한 메서드 안의 변경 순서를 정한 것이며, 데이터베이스나 여러 프로세스 사이의 거래 제어를 구현한 것은 아니다.

메서드 내부의 stock <<-는 해당 참조 객체의 필드를 바꾸는 표현이다. 앞에서 환경과 스코프를 다루며 본 상위 환경 탐색과 관련되지만, 여기서는 참조 클래스가 제공하는 필드 바인딩을 통해 상태를 갱신한다. 일반 함수의 전역 변수 변경에 이 패턴을 그대로 넓혀 쓰지 않는다. 상태가 바뀌는 위치를 클래스의 업무 메서드로 모으는 것이 핵심이다.

공유, 복사, 스냅샷을 구분한다

alias <- inventory는 같은 재고를 함께 쓰기 위한 대입이다. independent <- inventory$copy()는 별도의 참조 객체를 만들기 위한 호출이다. 예제의 필드는 문자와 정수뿐이므로 복사본을 바꾸어도 원본의 필드 값은 바뀌지 않는다. 필드 안에 다른 참조 객체를 넣는다면 그 내부까지 독립적인지 별도로 검토해야 한다.

snapshot은 현재 지점과 재고를 일반 리스트로 반환한다. 예제의 스냅샷은 필드 값을 추출한 기록이므로 나중의 판매가 이전 스냅샷을 바꾸지 않는다. 화면이 실시간 재고를 보여 주어야 하면 참조 객체를 읽고, 보고서가 특정 시점의 재고를 보존해야 하면 스냅샷을 저장한다. 어떤 의미로 보관하는지 변수 이름에도 드러내는 편이 좋다.

일반 대입은 재고 상태를 공유하고 copy와 값 스냅샷은 독립된 수량을 보존한다

참조 객체의 필드는 외부에서 접근할 수 있다. 따라서 메서드를 정의했다고 해서 모든 직접 변경이 차단되는 것은 아니다. 팀의 코드에서는 stock을 직접 대입하지 않고 sell처럼 목적이 드러나는 메서드를 호출한다는 사용 규칙을 정해야 한다. 이 작은 프로그램은 check_state로 상태를 다시 확인하지만, 잘못된 직접 대입 자체를 숨기거나 되돌리지는 않는다.

S3·S4·RC를 선택하는 기준

세 방식은 일렬로 발전하는 단계가 아니다. 판매 요약을 간단한 리스트로 표현하고 출력 방식만 달리하려면 S3가 충분할 수 있다. 객체의 항목과 자료형을 명시하고 여러 생성 경로에서 같은 유효성 규칙을 적용하려면 S4가 어울린다. 여러 코드가 의도적으로 같은 상태를 읽고 바꿔야 한다면 참조 클래스를 검토한다.

선택할 때는 먼저 변경 의미를 묻는다. 객체가 어떤 시점의 사실을 담는가, 아니면 현재 상태를 대표하는가를 구분한다. 판매 기록을 참조 객체로 만들면 과거 거래를 다른 코드가 바꿀 여지가 생긴다. 반대로 현재 재고를 매번 값으로 전달하면 갱신된 값을 반환하고 호출자가 다시 저장하는 흐름을 명시해야 한다. 어느 쪽도 가능한 설계이지만 한 프로그램 안에서 의미를 섞지 않는 것이 중요하다.

객체 시스템은 필요한 구조와 상태 공유 방식에 따라 선택한다
방식구조와 검사변경 의미편의점 예시
S3관례와 생성 함수로 관리일반 값의 사용 방식간단한 판매 요약
S4슬롯 선언과 validity값을 중심으로 다룸형식이 정해진 판매 기록
RC필드 선언과 업무 메서드 검사대입한 변수들이 상태 공유현재 재고와 판매 처리

R에 포함된 기능만으로 이 설계를 실행할 수 있다. 공식 설명이 필요하면 S4 클래스 정의, 객체 유효성 검사, 참조 클래스의 안내를 확인한다. 문서의 예제를 가져오는 대신 아래에서는 편의점 상황에 맞는 독립적인 프로그램을 작성한다.

완성 코드

다음 내용을 main.R로 저장한다. 새 Rscript 프로세스에서 실행하는 것을 기준으로 하며, 입력 파일과 외부 패키지가 필요하지 않다. 모든 입력은 코드 안에서 만들고 난수도 사용하지 않는다. 클래스와 제네릭 등록의 반환값은 invisible로 감싸 출력에 섞이지 않게 한다.

library(methods)

invisible(setClass(
  "SaleRecord",
  slots = c(
    branch = "character",
    units = "integer",
    price = "numeric"
  ),
  validity = function(object) {
    problems <- character()

    if (length(object@branch) != 1L ||
        is.na(object@branch) ||
        !nzchar(trimws(object@branch))) {
      problems <- c(problems, "지점은 빈 값이 아닌 문자열 하나여야 한다.")
    }
    if (length(object@units) != 1L ||
        is.na(object@units) ||
        object@units <= 0L) {
      problems <- c(problems, "판매 수량은 양의 정수 하나여야 한다.")
    }
    if (length(object@price) != 1L ||
        !is.finite(object@price) ||
        object@price <= 0) {
      problems <- c(problems, "단가는 유한한 양수 하나여야 한다.")
    }

    if (length(problems) == 0L) TRUE else problems
  }
))

invisible(setGeneric(
  "sales_amount",
  function(object) standardGeneric("sales_amount")
))

invisible(setMethod(
  "sales_amount",
  signature(object = "SaleRecord"),
  function(object) {
    validObject(object)
    object@units * object@price
  }
))

is_positive_integer <- function(x) {
  is.integer(x) && length(x) == 1L &&
    !is.na(x) && x > 0L
}

Inventory <- setRefClass(
  "Inventory",
  fields = list(
    branch = "character",
    stock = "integer"
  ),
  methods = list(
    check_state = function() {
      if (length(branch) != 1L ||
          is.na(branch) ||
          !nzchar(trimws(branch))) {
        stop("지점은 빈 값이 아닌 문자열 하나여야 한다.")
      }
      if (length(stock) != 1L ||
          is.na(stock) ||
          stock < 0L) {
        stop("재고는 음수가 아닌 정수 하나여야 한다.")
      }
      invisible(TRUE)
    },
    sell = function(units, price) {
      .self$check_state()
      if (!is_positive_integer(units)) {
        stop("판매 수량은 양의 정수 하나여야 한다.")
      }
      if (units > stock) {
        stop("판매 수량이 현재 재고보다 많다.")
      }

      record <- new(
        "SaleRecord",
        branch = branch,
        units = units,
        price = price
      )
      stock <<- as.integer(stock - units)
      record
    },
    snapshot = function() {
      .self$check_state()
      list(branch = branch, stock = stock)
    }
  )
)

new_inventory <- function(branch, stock) {
  object <- Inventory$new(branch = branch, stock = stock)
  object$check_state()
  object
}

inventory <- new_inventory("서쪽점", 20L)
alias <- inventory
independent <- inventory$copy()
before <- inventory$snapshot()

record <- inventory$sell(6L, 3000)
after <- inventory$snapshot()

revised <- record
revised@units <- 2L
stopifnot(validObject(revised))

rejected_record <- tryCatch(
  {
    new("SaleRecord", branch = "서쪽점", units = 0L, price = 3000)
    FALSE
  },
  error = function(e) TRUE
)

stock_before_failure <- inventory$stock
rejected_price <- tryCatch(
  {
    inventory$sell(1L, -100)
    FALSE
  },
  error = function(e) TRUE
)

stopifnot(
  identical(before$stock, 20L),
  identical(after$stock, 14L),
  identical(alias$stock, 14L),
  identical(independent$stock, 20L),
  identical(record@units, 6L),
  identical(revised@units, 2L),
  sales_amount(record) == 18000,
  sales_amount(revised) == 6000,
  rejected_record,
  rejected_price,
  identical(inventory$stock, stock_before_failure)
)

cat("지점:", record@branch, "\n")
cat(sprintf("판매: %d개, 단가 %.0f원, 금액 %.0f원\n",
            record@units, record@price, sales_amount(record)))
cat(sprintf("재고: 판매 전 %d개, 판매 후 %d개\n",
            before$stock, after$stock))
cat(sprintf("공유 변수 재고: %d개\n", alias$stock))
cat(sprintf("독립 복사 재고: %d개\n", independent$stock))
cat(sprintf("판매 기록 수량: 원본 %d개, 수정본 %d개\n",
            record@units, revised@units))
cat("검사: 모두 통과\n")

줄별 해설

library(methods)는 S4와 참조 클래스 기능을 제공하는 기본 포함 패키지를 연결한다. 외부 패키지 설치를 뜻하지 않는다. 이어지는 setClass의 첫 인수는 클래스 이름이며, slots의 이름은 슬롯 이름이고 값은 허용하는 클래스다. 이 정의에서는 리스트를 슬롯에 넣어 항목을 자유롭게 늘리는 대신 판매 기록의 세 항목을 고정한다.

validity 함수의 problems <- character()는 문제가 없을 때 길이 0인 문자 벡터를 만든다. 각 if문은 실패한 규칙의 설명을 여기에 더한다. 마지막 줄은 문제가 없으면 TRUE, 있으면 설명 벡터를 돌려준다. 한 객체에 여러 문제가 있으면 검사가 발견한 설명을 함께 반환할 수 있다.

branch 검사에서 trimws는 양쪽 공백을 제거한 검사 대상 문자열을 만든다. 따라서 공백만 있는 지점도 거부한다. 이 표현은 저장된 branch를 정리해서 바꾸지는 않는다. 지점 이름의 공백을 실제로 제거해 저장하고 싶다면 생성 함수에서 별도의 정규화 단계를 둔다. 검사와 정규화를 같은 동작으로 혼동하지 않는다.

setGeneric의 function(object)는 사용자가 호출할 함수 형태다. standardGeneric의 문자열은 등록할 제네릭 이름과 같다. setMethod의 signature는 object가 SaleRecord일 때 적용할 구현을 지정한다. 메서드 본문에서는 validObject로 현재 기록을 확인한 뒤 슬롯 두 개를 곱한다. 슬롯 접근에는 @를 사용한다.

is_positive_integer는 판매 요청 수량을 확인한다. is.integer를 먼저 검사하므로 6L은 허용하지만 6과 "6"은 허용하지 않는다. 뒤의 &&는 앞 조건이 실패하면 나머지 조건 평가를 멈춘다. 이 프로그램은 호출자가 자료형을 명시하는 방식이며, 편의를 위해 숫자를 자동 변환하는 함수는 넣지 않았다.

setRefClass 결과를 Inventory에 저장하면 이 객체의 생성기를 사용할 수 있다. fields는 상태의 이름과 자료형을 선언하고, methods는 그 상태에 적용할 함수를 정의한다. 메서드 안의 branch와 stock은 해당 객체의 필드를 읽는다. 외부에서는 inventory$stock처럼 접근하므로 S4 판매 기록의 @와 표기가 다르다.

check_state는 지점과 재고가 현재 규칙에 맞는지 확인한다. 판매 수량은 양수여야 하지만 재고는 0이어도 된다. 같은 integer 자료형을 써도 역할에 따라 경계 조건이 다르다. 성공 시 invisible(TRUE)를 반환하여 확인 결과가 업무 출력에 나타나지 않게 한다.

sell의 .self는 현재 메서드가 실행되는 참조 객체다. .self$check_state()로 기존 재고부터 확인한다. 요청 수량이 정상인지 검사한 뒤 현재 재고보다 큰지 비교한다. 다음 new 호출은 판매 기록을 생성하면서 단가도 검사한다. record가 성공적으로 만들어진 뒤 stock <<-로 재고를 줄이고, 마지막 record 표현으로 판매 기록을 반환한다.

snapshot은 check_state를 거쳐 일반 리스트를 만든다. 리스트에는 참조 객체 자체가 아니라 현재 필드 값이 들어간다. new_inventory는 생성 직후 check_state를 호출하는 작은 생성 함수다. 사용자가 Inventory$new를 직접 호출하면 이 업무 검사를 건너뛸 수 있으므로, 이 코드 묶음에서는 new_inventory를 생성 진입점으로 삼는다.

alias 대입은 공유 상태를, copy 호출은 독립 재고를 만든다. before는 판매 전 값을 보존한다. sell 호출은 원본 재고와 alias가 읽는 재고를 14개로 바꾸고, independent는 20개를 유지한다. after는 판매가 끝난 시점의 값을 담는다. 이후 revised의 수량을 2L로 바꾸어도 record의 수량은 6L이다.

두 tryCatch 구문은 실패해야 하는 입력을 검사하기 위한 것이다. 예상된 오류가 발생하면 TRUE를 반환하고, 잘못된 입력이 통과하면 FALSE를 반환한다. 이 형태는 발생한 오류의 종류나 메시지까지 구별하지 않는다. 여기서는 해당 입력이 거부되는지와 거부 후 재고가 유지되는지를 확인하는 데 사용한다. 오류와 경고를 체계적으로 분류하는 설계는 다음 장에서 다룬다.

stopifnot은 공유 상태, 복사본, 판매 금액, 실패 후 상태를 한 번에 검증한다. identical은 수량의 값과 integer 자료형까지 확인한다. 검사 중 하나라도 실패하면 마지막 출력까지 도달하지 않는다. cat과 sprintf는 숫자 표시와 문장 형식을 고정하므로 정상 실행의 출력이 일정하다.

실행 결과

main.R을 저장한 디렉터리에서 다음 명령을 실행한다. 예상된 잘못된 입력은 검사 코드에서 처리하므로 오류 문장이 화면에 나오지 않는다. 정상 경로에는 경고를 발생시키는 연산이 없다.

Rscript main.R

예상 출력은 다음과 같다.

지점: 서쪽점 
판매: 6개, 단가 3000원, 금액 18000원
재고: 판매 전 20개, 판매 후 14개
공유 변수 재고: 14개
독립 복사 재고: 20개
판매 기록 수량: 원본 6개, 수정본 2개
검사: 모두 통과

첫 줄의 지점 이름 뒤에는 cat이 인수 사이에 넣는 공백 하나가 있다. 나머지 숫자 출력은 sprintf에서 형식을 정한다. 이 프로그램은 그래프나 파일을 만들지 않는다. 출력에서 확인할 핵심은 같은 판매 뒤에도 공유 변수는 14개, 독립 복사본은 20개를 읽는다는 점이다.

실무에서 자주 틀리는 것

integer 슬롯에 일반 숫자를 넣는다

수량이 정수처럼 보인다고 해서 R의 자료형도 integer인 것은 아니다. 다음 코드는 units 슬롯에 double인 6을 전달한다.

# 틀린 코드
new("SaleRecord", branch = "서쪽점", units = 6, price = 3000)

# 고친 코드
new("SaleRecord", branch = "서쪽점", units = 6L, price = 3000)

외부 입력은 더 신중하게 다룬다. 6.8을 as.integer로 바꾸면 6이 되므로, 변환 전에 소수 부분과 범위를 검사해야 한다. 이미 선언한 자료형에 맞춘다는 이유로 원래 입력의 의미를 지우지 않는다.

슬롯을 고친 뒤 업무 규칙이 유지된다고 가정한다

0L은 integer이므로 슬롯 자료형에는 맞지만 판매 수량 규칙에는 맞지 않는다. 직접 슬롯을 변경한 뒤 검증을 생략하면 유효하지 않은 객체가 다음 작업으로 전달될 수 있다.

# 틀린 코드
candidate <- record
candidate@units <- 0L

# 고친 코드
candidate <- record
candidate@units <- 2L
stopifnot(validObject(candidate))

호출자가 값을 입력하는 수정 함수라면 먼저 후보 객체를 만들고 validObject를 통과한 후보만 반환한다. 수정 함수가 성공했다는 사실을 업무 규칙을 만족한다는 의미로 정할 수 있다. 여러 슬롯 사이의 관계가 있다면 개별 대입이 아니라 후보 객체 전체를 검사해야 한다.

대입으로 재고 백업을 만들었다고 생각한다

backup이라는 이름만으로 참조 객체가 복사되지는 않는다. 다음 대입은 같은 재고를 가리키는 변수를 하나 더 만든다.

# 틀린 코드
backup <- inventory
inventory$sell(1L, 3000)
# backup도 변경된 재고를 읽는다.

# 고친 코드
backup <- inventory$copy()
saved <- inventory$snapshot()
inventory$sell(1L, 3000)
# backup은 독립 객체이고 saved는 당시 값이다.

독립 객체가 필요하면 copy를 사용하고, 과거 수량을 기록할 목적이면 snapshot을 사용한다. 위 코드는 완성 프로그램 실행 뒤 개별 동작을 비교하는 예시이며, 두 부분을 이어서 실행하면 판매가 각각 한 번씩 발생한다. 검증할 때는 같은 시작 상태에서 각 경우를 실행한다.

재고를 먼저 줄이고 판매 기록을 만든다

단가 검사에 실패하면 거래 기록은 없는데 재고만 줄어들 수 있다. 상태 변경은 필요한 검사를 통과한 뒤에 수행한다.

# 틀린 코드: 참조 클래스 메서드 내부
stock <<- as.integer(stock - units)
record <- new(
  "SaleRecord", branch = branch, units = units, price = price
)

# 고친 코드: 수량과 재고 검사를 마친 메서드 내부
record <- new(
  "SaleRecord", branch = branch, units = units, price = price
)
stock <<- as.integer(stock - units)

순서 변경만으로 모든 실패에 대비한 것은 아니다. 고친 코드도 요청 수량과 현재 재고 검사를 먼저 수행해야 한다. 상태 변경 뒤 파일 저장이나 다른 외부 작업이 추가된다면 실패 시 어떤 상태를 유지할지도 별도로 정해야 한다.

한눈에 보기

클래스 선언부터 상태 보존까지 각 도구가 맡는 역할을 연결한다
도구 또는 표현역할예제에서의 의미
setClassS4 구조 선언판매 기록의 세 슬롯 정의
validity업무 규칙 정의수량과 단가의 양수 조건
validObject현재 객체 검증수정한 기록과 계산 입력 확인
setGeneric·setMethod인터페이스와 구현 등록판매 금액 계산
setRefClass상태와 메서드 정의재고 감소 처리
alias <- inventory참조 공유같은 현재 재고 읽기
inventory$copy()별도 참조 객체 생성독립 재고 상태 만들기
inventory$snapshot()현재 값을 추출특정 시점의 재고 보존

S4에서는 선언한 자료형과 validity를 함께 읽어야 객체의 계약을 이해할 수 있다. 참조 클래스에서는 어떤 메서드가 필드를 바꾸는지까지 읽어야 사용 결과를 알 수 있다. 값을 반환하는 함수와 상태를 바꾸는 메서드를 구분하면, 코드 검토에서도 변경이 일어나는 위치를 좁혀 확인할 수 있다.

연습 문제

  1. 새 재고를 5L로 만들고 5L을 판매하라. 판매 금액과 남은 재고를 검사하라. 재고가 0이어도 유효해야 하는 이유를 설명하라.
  2. SaleRecord의 validity에 판매 수량은 1,000개 이하라는 규칙을 추가하라. 1000L은 허용되고 1001L은 거부되는지 검사하라.
  3. 판매 기록의 단가를 바꾸는 change_price 함수를 작성하라. 원본은 유지하고, 검증을 통과한 수정본을 반환하도록 하라.
  4. 재고 10L인 객체에서 일반 대입 변수, copy로 만든 객체, snapshot으로 만든 리스트를 준비하라. 원본에서 3L을 판매한 뒤 각각의 재고가 무엇인지 검사하라.

정답과 해설

재고를 모두 판매한다

small <- new_inventory("동쪽점", 5L)
last_sale <- small$sell(5L, 2000)

stopifnot(
  identical(small$stock, 0L),
  sales_amount(last_sale) == 10000
)
small$check_state()

판매 요청은 양수여야 하지만 남은 재고에는 0이 포함된다. 0은 상품이 소진된 정상 상태다. check_state에서 stock < 0L을 검사한 이유가 여기에 있다. 판매 수량의 규칙을 재고에 그대로 적용하면 소진된 상태를 잘못 거부한다.

판매 수량의 상한을 추가한다

기존 validity 함수에서 units 검사 다음에 아래 조건을 추가한다. 자료형은 슬롯 선언이 담당하고, 길이와 결측을 먼저 확인한 뒤 상한을 비교한다. 완성 코드의 클래스 정의를 수정하고 새 Rscript 프로세스에서 실행한다.

if (length(object@units) == 1L &&
    !is.na(object@units) &&
    object@units > 1000L) {
  problems <- c(problems, "한 판매 기록의 수량은 1000개 이하여야 한다.")
}

다음 검사를 추가하면 경계값을 확인할 수 있다. 상한 규칙은 재고의 크기와 별개이며, 거래 한 건에 허용하는 수량을 제한한다.

limit_sale <- new(
  "SaleRecord", branch = "서쪽점", units = 1000L, price = 1000
)
over_limit <- tryCatch(
  {
    new("SaleRecord", branch = "서쪽점", units = 1001L, price = 1000)
    FALSE
  },
  error = function(e) TRUE
)
stopifnot(validObject(limit_sale), over_limit)

검증한 수정본을 반환한다

change_price <- function(record, price) {
  stopifnot(is(record, "SaleRecord"))
  validObject(record)
  candidate <- record
  candidate@price <- price
  validObject(candidate)
  candidate
}

discounted <- change_price(record, 2500)
stopifnot(
  record@price == 3000,
  discounted@price == 2500,
  sales_amount(discounted) == 15000
)

함수는 원본이 유효한 판매 기록인지 확인한 뒤 후보 객체를 만든다. 변경된 단가의 자료형은 슬롯 대입에서 확인하고, 양수와 유한성은 validObject에서 확인한다. 실패하면 후보를 반환하지 않는다. 원본의 단가를 유지하는 것은 일반적인 S4 값 객체의 변경 방식에 따른 결과다.

세 가지 보관 방식의 결과를 비교한다

current <- new_inventory("북쪽점", 10L)
shared <- current
separate <- current$copy()
saved <- current$snapshot()

sale <- current$sell(3L, 1500)

stopifnot(
  identical(current$stock, 7L),
  identical(shared$stock, 7L),
  identical(separate$stock, 10L),
  identical(saved$stock, 10L),
  sales_amount(sale) == 4500
)

shared는 원본과 같은 상태를 읽어 7개를 확인한다. separate는 독립된 참조 객체이므로 10개를 유지한다. saved는 판매 전 필드 값을 담은 리스트여서 10개를 보존한다. 실시간 상태 공유, 독립 작업용 복사, 시점 기록이라는 세 목적을 구분해 변수와 인터페이스를 설계한다.

댓글 0

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

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