콘텐츠로 이동

19. R 객체와 클래스

R은 처음 설계될 때부터 객체지향(object-oriented) 개념을 내부적으로 활용해 온 언어입니다. print(), summary(), plot() 같은 함수를 벡터에 써도, 데이터프레임에 써도, 회귀모형 객체에 써도 항상 자연스럽게 동작하는 것은 우연이 아니라, R이 객체의 클래스(class) 에 따라 실제로 실행할 코드를 자동으로 바꿔주기 때문입니다.

19.1 객체와 클래스 (Object and Class)

아래처럼 서로 다른 세 객체에 각각 class()를 적용해 보면 완전히 다른 값이 나옵니다.

class(1:5)
#> [1] "integer"

class(iris)
#> [1] "data.frame"

fit <- lm(dist ~ speed, data = cars)
class(fit)
#> [1] "lm"

그런데 이 세 객체에 print(x)라는 똑같은 함수를 실행하면, 정수 벡터는 숫자들이 나열되고, 데이터프레임은 표 형태로, lm 객체는 회귀계수 요약으로 각각 전혀 다른 방식으로 출력됩니다. 사용자는 매번 "이 객체는 표로 출력해야지", "이 객체는 계수만 보여줘야지"라고 신경 쓸 필요 없이 그냥 print(x)라고만 쓰면 됩니다. 어떻게 하나의 함수 이름이 객체마다 다르게 동작할 수 있을까요?

팩터(factor)를 보면 이 원리가 더 뚜렷하게 드러납니다.

x <- factor(c("a", "b", "a"))
print(x)
#> [1] a b a
#> Levels: a b

unclass(x)
#> [1] 1 2 1
#> attr(,"levels")
#> [1] "a" "b"

unclass()로 class 속성을 떼어내고 보면, x의 실제 저장 형태는 그냥 정수 벡터 1 2 1이라는 것을 알 수 있습니다. 그런데도 print(x)는 "a b a"라는 문자로 보여줍니다. 그 이유는 x에 class = "factor"라는 속성(꼬리표)이 붙어 있고, print()가 이 꼬리표를 보고 "정수가 아니라 팩터니까 levels를 참고해서 문자로 바꿔 보여줘야겠다"고 판단하기 때문입니다.

"어떤 값에 class라는 속성을 붙이고, 함수는 이 class를 보고 실제 실행할 코드를 다르게 선택한다"는 것이 R 객체지향의 근본 원리입니다. 이렇게 클래스에 따라 실행 내용이 달라지는 함수를 제네릭 함수(generic function), 특정 클래스에 대해 실제로 실행되는 코드를 메서드(method), 그리고 어떤 메서드를 실행할지 결정하는 과정을 디스패치(dispatch) 라고 부릅니다. R은 이 원리를 구현하는 방식이 서로 다른 세 가지 시스템을 base 안에 내장하고 있습니다.

구분 S3 S4 Reference Class (R5)
도입 시점 R 최초 버전(S 언어를 계승) R 1.4.0(2001년) R 2.12.0(2010년)
클래스 정의 방식 비공식적 — 구조를 강제하는 선언이 없음 형식적(formal) — setClass()로 슬롯과 자료형을 명시 형식적 — setRefClass()로 필드와 메서드를 명시
객체 생성 structure() 등으로 자유롭게 new() 또는 setClass()가 반환하는 생성자 함수 $new()
필드(성분) 접근 $ @(at) $
값 전달 방식 값에 의한 복사(copy-on-modify) 값에 의한 복사 참조에 의한 전달(mutable, 객체지향 언어의 클래스와 유사)
자료형·값 검증 없음(직접 구현해야 함) setValidity()로 지정 가능 없음(직접 구현해야 함)
대표적인 사용처 lm, data.frame, factor 등 base R 대부분 Bioconductor 패키지 등 엄격한 검증이 필요한 코드 은행 계좌·연결(connection)처럼 상태가 계속 바뀌는 객체

이 장에서 다루는 "클래스"는 S3에서는 객체의 class 속성(문자형 벡터일 수 있음), S4·Reference Class에서는 setClass()/setRefClass()로 미리 등록해 둔 형식적 클래스 정의를 가리킵니다. "제네릭 함수"는 객체의 클래스에 따라 실행 내용이 달라지는 함수를 뜻하며, 세 시스템 모두 공통적으로 (1) 클래스를 정의하고 (2) 그 클래스의 객체를 생성하고 (3) 클래스별로 다르게 동작하는 함수(메서드)를 등록한다는 세 단계를 거칩니다.

structure()

structure(.Data, ...)는 어떤 값(.Data)에 속성(attribute)을 붙여 즉시 반환하는 함수입니다.

  • .Data : 속성을 붙일 원본 객체(벡터, 리스트 등).
  • ... : 이름 = 값 형태로 지정하는 속성들. class = "학생"처럼 지정하면 S3 객체를 만드는 가장 흔한 방법이 됩니다. 이 밖에 names, dim 등 다른 속성도 함께 지정할 수 있습니다.
obj <- structure(list(이름 = "홍길동", 나이 = 20), class = "학생")
class(obj)
#> [1] "학생"

attributes(obj)
#> $names
#> [1] "이름" "나이"
#> 
#> $class
#> [1] "학생"

obj는 리스트에 class = "학생"이라는 속성만 붙었을 뿐, 아직 "학생" 클래스에 맞는 전용 동작(예쁜 출력 형식 등)은 전혀 정의되어 있지 않습니다. 그래서 지금 print(obj)를 실행하면 클래스 이름이 붙은 평범한 리스트로만 보입니다. 이 객체가 원하는 대로 동작하도록 만드는 방법은 다음 절에서 이어서 다룹니다.

19.2 S3 클래스

19.1절에서 만든 obj(학생 객체)를 그냥 출력하면 "이름: 홍길동, 나이: 20"처럼 사람이 읽기 좋은 형태가 아니라, 클래스 이름만 붙은 평범한 리스트로 보입니다. print(obj)라는 똑같은 호출로 "학생" 클래스만의 출력 방식을 갖게 하려면 무엇이 필요할까요?

print.학생이라는 이름의 함수를 만들어 두기만 하면 됩니다.

print.학생 <- function(x, ...) {
  cat("이름:", x$이름, "/ 나이:", x$나이, "\n")
}
obj
#> 이름: 홍길동 / 나이: 20 

print(obj)(또는 콘솔에 obj만 입력했을 때 자동으로 호출되는 print(obj))를 실행하면, R은 class(obj)가 "학생"이라는 것을 확인하고 제네릭이름.클래스이름 규칙에 맞는 print.학생()을 자동으로 찾아 실행합니다.

이렇게 "제네릭이름.클래스이름"이라는 함수 이름 규칙(naming convention) 만으로 메서드를 등록하는 방식이 S3입니다. 별도의 클래스 선언 문법이나 컴파일 시점 검사 없이, 오직 이름 규칙과 다음에 볼 UseMethod() 호출만으로 동작하기 때문에 "비공식적(informal)" 시스템이라고 부릅니다. 문법이 이렇게 가벼운데도 print(), summary(), plot(), +(연산자) 등 base R 제네릭 함수 대부분이 바로 이 S3 방식으로 구현되어 있어서, R에서 실질적으로 가장 많이 마주치는 객체지향 시스템입니다.

S3 제네릭 함수는 제네릭이름 <- function(x, ...) UseMethod("제네릭이름") 형태로 정의되고, 메서드는 제네릭이름.클래스이름 <- function(x, ...) {...} 형태로 정의됩니다. 객체 x에 대해 제네릭이 호출되면 R은 class(x)의 원소들을 순서대로 확인하며 일치하는 메서드를 찾아 실행하고, 일치하는 것이 하나도 없으면 제네릭이름.default를 실행합니다.

UseMethod()

UseMethod(generic, object)는 현재 함수를 S3 제네릭 함수로 선언하고, 객체의 클래스에 맞는 메서드로 실행을 넘깁니다(디스패치).

  • generic : 제네릭 함수 이름(문자열). 보통 현재 함수 이름과 동일하게 지정합니다.
  • object : 디스패치 기준으로 삼을 객체. 생략하면 제네릭 함수의 첫 번째 인자가 사용됩니다.
소개 <- function(x) UseMethod("소개")
소개.학생 <- function(x) cat(x$이름, "입니다.\n")
소개.default <- function(x) cat("알 수 없는 객체입니다.\n")

소개(obj)
#> 홍길동 입니다.

소개(1:5)
#> 알 수 없는 객체입니다.

obj는 클래스가 "학생"이므로 소개.학생()이 실행되고, 정수 벡터 1:5는 "학생" 메서드가 없으므로 소개.default()로 넘어갑니다. .default 메서드는 반드시 만들어야 하는 것은 아니지만, 만들어 두지 않으면 일치하는 메서드가 없는 객체에 대해 오류가 발생하므로 공개용 함수를 만들 때는 함께 정의해 두는 것이 안전합니다.

NextMethod()

NextMethod()는 현재 실행 중인 메서드 안에서, 객체의 클래스 벡터 중 다음 클래스에 해당하는 메서드를 이어서 호출합니다. 대부분 인자 없이 NextMethod()로만 씁니다.

객체 하나는 class(x) <- c("A", "B")처럼 클래스를 여러 개(벡터) 가질 수 있습니다. 이때 앞쪽 클래스("A")의 메서드가 자신만의 처리를 약간 덧붙인 뒤, 나머지 처리는 뒤쪽 클래스("B")의 메서드에 그대로 맡기고 싶을 때 NextMethod()를 씁니다.

장학생 <- structure(list(이름 = "김철수", 나이 = 21, 장학금 = 100),
                    class = c("장학생", "학생"))

print.장학생 <- function(x, ...) {
  cat("[장학생] ")
  NextMethod()   # class(x)의 다음 원소인 "학생"에 대한 print.학생()을 이어서 실행
}

print(장학생)
#> [장학생] 이름: 김철수 / 나이: 21 

class(장학생)이 c("장학생", "학생")이므로, print()는 먼저 print.장학생()을 찾아 실행합니다. 그 안에서 NextMethod()를 호출하면 두 번째 클래스인 "학생"에 해당하는 print.학생()이 이어서 실행되어 "[장학생] " 뒤에 그대로 이어 붙습니다. print.학생()은 위에서 만들었기 때문에 다시 작성하지 않았습니다.

methods() / getS3method()

methods(generic.function, class)는 특정 제네릭 함수(또는 특정 클래스)에 어떤 메서드들이 등록되어 있는지 조회합니다. getS3method(f, class)는 그 메서드의 실제 코드(함수 본문)를 가져옵니다.

  • generic.function : 조회할 제네릭 함수 이름.
  • class : 조회할 클래스 이름. 특정 클래스에 등록된 모든 메서드를 한 번에 보고 싶을 때 사용합니다.
  • f(getS3method) : 제네릭 함수 이름(문자열).
methods(class = "학생")
#> [1] print 소개
#> see '?methods' for accessing help and source code

methods(class = "장학생")
#> [1] print
#> see '?methods' for accessing help and source code
getS3method("print", "학생")
#> function(x, ...) {
#>   cat("이름:", x$이름, "/ 나이:", x$나이, "\n")
#> }

주의: S3는 함수 이름 규칙만으로 동작하기 때문에, 이름 자체에 마침표(.)가 들어 있는 일반 함수(예: as.data.frame, read.csv)를 R이 "as.data 제네릭의 frame 메서드"처럼 착각할 여지가 이론적으로 있습니다. 실제로는 base R이 이런 이름들을 조심스럽게 관리하고 있어 큰 문제가 되는 경우는 드물지만, 이런 구조적 모호함은 S3의 대표적인 약점으로 꼽힙니다. 다음 절의 S4는 클래스와 슬롯을 형식적으로 선언해 이 모호함을 없앤 시스템입니다.

19.3 S4 클래스

S3는 문법이 매우 간단하지만, 객체의 성분이 정확히 몇 개이고 각각 어떤 자료형이어야 하는지를 강제하는 장치가 없습니다. 예를 들어 obj$나이에 "스무살"이라는 문자열을 넣어도 R은 아무 제지 없이 받아들입니다. 여러 사람이 함께 개발하는 큰 패키지에서 이런 실수가 조용히 넘어가면, 오류의 원인을 한참 뒤에야 발견하게 될 수 있습니다.

setClass()로 클래스를 정의하면서 각 슬롯의 자료형을 미리 지정해 두면, 그 형식에 맞지 않는 값을 넣었을 때 객체를 만드는 시점에 즉시 오류가 발생합니다.

Person <- setClass("Person", slots = c(name = "character", age = "numeric"))
p <- Person(name = "홍길동", age = 20)
p
#> An object of class "Person"
#> Slot "name":
#> [1] "홍길동"
#> 
#> Slot "age":
#> [1] 20
tryCatch(Person(name = "김", age = "스무살"),
         error = function(e) cat("에러:", conditionMessage(e), "\n"))
#> 에러: 잘못된 클래스 “Person” 객체입니다: invalid object for slot "age" in class "Person": got class "character", should be or extend class "numeric" 

S4는 클래스를 setClass()로 미리 "선언"하고, 이 클래스가 가질 슬롯(slot, S3의 성분에 대응)마다 자료형을 명시합니다. 필드 접근에는 $ 대신 @(at)을 씁니다. 제네릭 함수도 S3처럼 이름 규칙에 기대지 않고 setGeneric()·setMethod()로 명시적으로 등록하기 때문에, 앞서 본 S3의 이름 충돌 문제에서 자유롭습니다.

setClass(Class, representation, prototype, contains = character(), slots)로 클래스를 정의하고, new(Class, ...)(또는 setClass()가 반환하는 생성자 함수)로 객체를 생성합니다. 제네릭은 setGeneric(name, function(x, ...) standardGeneric(name)), 메서드는 setMethod(name, signature, definition)으로 등록합니다.

setClass() / new()

setClass(Class, ..., slots, contains = character(), prototype)는 새로운 S4 클래스를 정의하고, 결과를 변수에 저장해 두면 그 변수 자체를 객체 생성자 함수로 바로 쓸 수 있습니다. new(Class, ...)는 클래스 이름으로 직접 객체를 생성하는 좀 더 옛 방식의 함수입니다.

  • Class : 클래스 이름(문자열).
  • slots : 슬롯 이름과 자료형을 c(슬롯이름 = "자료형", ...) 형태로 지정합니다. 예전 문서에서는 representation(name = "character", age = "numeric") 형태를 많이 볼 수 있는데, 이는 이전 문법으로 지금도 동작은 하지만 R 공식 문서는 하위 호환을 위해 남겨 둔 것일 뿐이며 slots = 사용을 권장합니다.
  • contains : 상속받을 부모 클래스 이름(19.5절 참고).
  • prototype : 슬롯의 기본값을 list(슬롯이름 = 기본값, ...) 형태로 지정합니다.
  • new()의 ...(또는 생성자 함수의 ...) : 슬롯이름 = 값 형태로 초깃값을 지정합니다.
p@name
#> [1] "홍길동"

slotNames(p)
#> [1] "name" "age" 

isVirtualClass("Person")
#> [1] FALSE

setClass()를 Person <- setClass(...)처럼 변수에 저장해 두면, 이후 new("Person", ...) 대신 Person(...)처럼 함수처럼 바로 호출해 객체를 만들 수 있습니다. R 공식 도움말(?setClass)도 이 방식을 "전형적인 사용법(typical usage)"으로 안내하고 있어, 최근에는 new()를 직접 쓰기보다 이 생성자 함수 스타일이 더 권장됩니다. prototype으로 기본값을 지정해 두면 일부 슬롯을 생략해도 오류 없이 객체를 만들 수 있습니다.

Person2 <- setClass("Person2",
                     slots = c(name = "character", age = "numeric"),
                     prototype = list(age = 0))
p2 <- Person2(name = "무명")
p2@age    # age를 지정하지 않아도 prototype의 기본값 0이 사용됨
#> [1] 0

setValidity() / validObject()

setValidity(Class, method)는 이미 정의된 클래스에 유효성 검사 규칙을 추가로 등록합니다. validObject(object)는 이 규칙에 따라 객체가 여전히 유효한지 다시 검사합니다.

  • Class : 규칙을 적용할 클래스 이름.
  • method : 객체 하나(관례상 object)를 받아, 유효하면 TRUE를, 유효하지 않으면 문제를 설명하는 문자열을 반환하는 함수.
  • object(validObject) : 검사할 S4 객체.
invisible(setValidity("Person", function(object) {
  if (object@age < 0) "age는 음수가 될 수 없습니다" else TRUE
}))

tryCatch(Person(name = "김", age = -5),
         error = function(e) cat("에러:", conditionMessage(e), "\n"))
#> 에러: 잘못된 클래스 “Person” 객체입니다: age는 음수가 될 수 없습니다 

유효성 검사는 new()(또는 생성자 함수) 호출 시점에는 자동으로 실행되지만, 슬롯을 직접 바꾸는 @<- 대입에는 자동으로 실행되지 않는다는 점에 유의해야 합니다.

p2 <- Person(name = "김", age = 5)
p2@age <- -10                 # 직접 대입은 유효성 검사를 건너뜀
p2@age
#> [1] -10

tryCatch(validObject(p2), error = function(e) cat("에러:", conditionMessage(e), "\n"))
#> 에러: 잘못된 클래스 “Person” 객체입니다: age는 음수가 될 수 없습니다 

즉 p2@age <- -10은 아무 경고 없이 성공하지만, 나중에 validObject()를 명시적으로 호출하면 그제야 규칙 위반이 드러납니다. 슬롯을 자주 직접 대입하는 코드를 작성한다면, 중요한 시점마다 validObject()를 한 번씩 호출해 객체가 여전히 온전한지 확인하는 습관이 안전합니다.

setGeneric() / setMethod()

setGeneric(name, def)는 새로운 S4 제네릭 함수를 등록합니다. setMethod(f, signature, definition)은 그 제네릭이 특정 클래스(들)의 객체를 만났을 때 실행할 메서드를 등록합니다.

  • name/f : 제네릭 함수 이름(문자열).
  • def : 보통 function(x, ...) standardGeneric(name) 형태로, "이 함수는 실제 계산 없이 클래스에 맞는 메서드로 넘긴다"는 것만 선언합니다.
  • signature : 메서드를 적용할 클래스 이름(문자열). S4는 인자 여러 개의 클래스 조합으로 메서드를 나눌 수도 있어(다중 디스패치) signature에 클래스 이름을 여러 개 지정할 수도 있습니다.
  • definition : 실제로 실행할 함수 본문.
setGeneric("소개", function(x) standardGeneric("소개"))
setMethod("소개", "Person", function(x) cat(x@name, "입니다.\n"))
소개(p)
#> 홍길동 입니다.

S4 객체를 콘솔에서 그냥 출력했을 때의 형식("An object of class ...")을 바꾸고 싶다면, S3의 print()에 대응하는 show라는 이름의 제네릭에 메서드를 등록하면 됩니다.

setMethod("show", "Person", function(object) {
  cat("<Person>", object@name, "(", object@age, "세)\n")
})
p
#> <Person> 홍길동 ( 20 세)

이미 등록된 메서드를 조회할 때는 S3의 methods()에 대응하는 existsMethod()·hasMethod()를 사용합니다. 둘의 차이는 "그 클래스 자체에 직접 등록되었는가"와 "상속을 포함해 실제로 호출 가능한가"입니다(상속 관계는 19.5절 참고).

existsMethod("소개", "Person")   # Person에 직접 등록됨
#> [1] TRUE
existsMethod("소개", "Student")  # Student 전용으로 새로 등록한 적은 없음
#> [1] FALSE
hasMethod("소개", "Student")     # 그러나 부모(Person)로부터 물려받아 호출은 가능함
#> [1] TRUE

더 알아보기: 가상 클래스(virtual class)로 추상적인 상위 개념 표현하기

실제 객체를 만들 일은 없지만, 여러 하위 클래스가 공통으로 가져야 할 성질(인터페이스)만 정의해 두고 싶을 때가 있습니다. setClass()에서 슬롯을 지정하는 대신 representation("VIRTUAL")을 넘기면 가상 클래스가 됩니다. 가상 클래스는 new()(또는 생성자 함수)로 직접 객체를 만들 수 없고, 오직 다른 클래스가 contains =로 상속(19.5절)받는 용도로만 쓰입니다.

setClass("Shape", representation("VIRTUAL"))
isVirtualClass("Shape")
#> [1] TRUE
tryCatch(new("Shape"), error = function(e) cat("에러:", conditionMessage(e), "\n"))
#> 에러: trying to generate an object from a virtual class ("Shape") 
Circle <- setClass("Circle", contains = "Shape", slots = c(radius = "numeric"))
setGeneric("area", function(x) standardGeneric("area"))
setMethod("area", "Circle", function(x) pi * x@radius^2)

circ <- Circle(radius = 3)
area(circ)
#> [1] 28.27433

"Shape"는 그 자체로는 만들 수 없는 추상적인 개념이지만, area()라는 제네릭을 통해 "Shape를 상속하는 모든 도형은 area()를 계산할 수 있어야 한다"는 규약(인터페이스)의 역할을 합니다. 이런 설계는 여러 도형·여러 모형처럼 종류는 다양하지만 공통 동작(계산, 출력 등)이 필요한 패키지를 만들 때 자주 쓰입니다.

19.4 참조 클래스 (Reference Class, R5)

S3·S4 객체는 모두 R의 기본 원칙인 "값에 의한 복사(copy-on-modify)"를 따릅니다. 즉 y <- x 이후 y를 바꿔도 x는 전혀 영향을 받지 않습니다. 그런데 은행 계좌 잔액, 진행 중인 카운터, 네트워크 연결처럼 "여러 곳에서 같은 객체를 공유하며 계속 상태를 바꿔 나가야 하는" 경우에는 이 원칙이 오히려 불편합니다. 매번 함수가 새로운 값을 반환하도록 만들고 그 값을 다시 원래 변수에 대입해 주어야 하기 때문입니다.

setRefClass()로 만든 객체는 예외적으로 참조(reference) 로 전달됩니다. 즉 같은 객체를 가리키는 두 변수 중 하나를 바꾸면 다른 변수에도 그 변화가 그대로 반영됩니다.

Account <- setRefClass("Account",
  fields = list(balance = "numeric"),
  methods = list(
    deposit = function(amount) {
      balance <<- balance + amount
    },
    withdraw = function(amount) {
      if (amount > balance) stop("잔액 부족")
      balance <<- balance - amount
    }
  ))

acc <- Account$new(balance = 1000)
acc$deposit(500)
acc$balance
#> [1] 1500

acc$withdraw(200)
acc$balance
#> [1] 1300
acc2 <- acc              # acc2는 acc의 복사본이 아니라 같은 객체를 가리킴
acc2$deposit(1000)
acc$balance               # acc도 함께 1300 -> 2300으로 바뀜
#> [1] 2300

acc2 <- acc로 만든 acc2는 새로운 계좌가 아니라 acc와 완전히 같은 객체를 가리키는 또 다른 이름일 뿐입니다. 그래서 acc2로 입금하면 acc의 잔액도 함께 바뀝니다. 정말로 별개의 복사본이 필요하다면 $copy() 메서드를 명시적으로 호출해야 합니다.

acc3 <- acc$copy()        # 이제부터는 완전히 독립된 별개의 객체
acc3$deposit(9999)
acc$balance
#> [1] 2300
acc3$balance
#> [1] 12299

이렇게 필드(상태)를 직접 정의하고, 그 필드를 바꾸는 메서드까지 객체 안에 함께 묶어 두며, 대입해도 복사되지 않고 참조가 공유되는 방식을 R에서는 참조 클래스(Reference Class), 또는 R5라고 부릅니다. 다른 객체지향 언어(파이썬의 클래스, 자바의 클래스 등)에 익숙하다면 이 방식이 가장 친숙하게 느껴질 것입니다.

setRefClass(Class, fields, methods, contains = character())로 클래스를 정의하고, Class$new(...)로 객체를 생성합니다. 메서드 내부에서 자신의 필드 값을 바꿀 때는 일반 대입 연산자(<-) 대신 슈퍼대입 연산자 <<-를 사용해야 하며, 그래야 지역 변수가 아니라 객체 자신의 필드가 실제로 바뀝니다.

setRefClass()

setRefClass(Class, fields, methods, contains = character())는 새로운 참조 클래스를 정의하고, 그 결과를 변수에 저장해 두면 변수$new(...) 형태로 객체를 생성할 수 있는 생성자가 됩니다.

  • Class : 클래스 이름(문자열).
  • fields : 객체가 가질 필드(상태)를 list(필드이름 = "자료형", ...) 형태로 지정합니다. 자료형을 생략하고 필드 이름만 문자형 벡터로 나열할 수도 있습니다.
  • methods : 객체가 가질 메서드들을 list(메서드이름 = function(...) {...}, ...) 형태로 지정합니다. 메서드 본문 안에서는 필드 이름을 그대로 변수처럼 쓸 수 있고, 값을 바꿀 때는 <<-를 사용합니다.
  • contains : 상속받을 부모 참조 클래스 이름(19.5절 참고).

앞의 Account 예제에서 deposit()·withdraw() 메서드 안의 balance <<- balance + amount가 바로 이 규칙을 따른 것입니다. 만약 실수로 <-를 썼다면, balance는 함수 안에서만 바뀌는 지역 변수가 되어 객체의 실제 잔액은 전혀 바뀌지 않습니다.

S3의 print.클래스, S4의 show 메서드에 대응하는 출력 형식도 methods 목록 안에 show라는 이름으로 정의하면 됩니다.

Account <- setRefClass("Account",
  fields = list(balance = "numeric"),
  methods = list(
    deposit = function(amount) balance <<- balance + amount,
    show = function() cat("<Account> 잔액:", balance, "원\n")
  ))

acc <- Account$new(balance = 1000)
acc$deposit(500)
acc
#> <Account> 잔액: 1500 원

참고: 최신 동향 — R6 패키지

실무에서는 base R의 Reference Class보다 CRAN의 외부 패키지인 R6가 훨씬 널리 쓰입니다. R6는 Reference Class와 마찬가지로 참조(mutable) 방식의 객체를 만들지만, 문법이 더 간결하고 실행 속도도 더 빠르다는 평가를 받아 Shiny 등 여러 유명 패키지의 내부 구현에도 쓰이고 있습니다. 다만 R6는 base R이 아니라 별도로 설치해야 하는 패키지이므로, Base R 전용인 이 매뉴얼의 스코프를 벗어납니다.

19.5 상속 (Inheritance)

19.3절의 Person처럼 이름과 나이를 가진 클래스가 있을 때, 여기에 소속 학교 정보만 추가한 "Student" 클래스를 만들고 싶다고 해 봅시다. name·age 슬롯과 관련 메서드를 처음부터 전부 다시 작성한다면 Person과 Student 사이에 중복된 코드가 계속 늘어나고, 나중에 Person을 수정할 때마다 Student도 일일이 따라 고쳐야 하는 문제가 생깁니다.

세 클래스 시스템 모두 "이미 있는 클래스를 확장해 새로운 클래스를 만든다"는 상속 기능을 제공하며, 문법은 다르지만 개념은 같습니다.

## S4: contains = 로 Person을 상속받아 Student를 정의
Person  <- setClass("Person",  slots = c(name = "character", age = "numeric"))
Student <- setClass("Student", contains = "Person", slots = c(school = "character"))

s <- Student(name = "이영희", age = 22, school = "인천여대")
is(s, "Person")     # Student는 Person이기도 함
#> [1] TRUE
extends("Student")  # Student가 속한 클래스 계층 전체
#> [1] "Student" "Person" 

Student는 school이라는 슬롯만 새로 추가했을 뿐인데도, name·age 슬롯과 소개() 같은 Person의 메서드를 그대로 물려받아 사용할 수 있습니다.

setGeneric("소개", function(x) standardGeneric("소개"))
setMethod("소개", "Person", function(x) cat(x@name, "입니다.\n"))

소개(s)   # Student 전용 메서드가 없어도 Person의 메서드가 그대로 호출됨
#> 이영희 입니다.

부모의 메서드를 완전히 대체하지 않고, "부모의 동작을 실행한 뒤 내 것을 덧붙이고 싶다"면 S4에서는 callNextMethod()를 씁니다(S3의 NextMethod()와 같은 역할입니다).

setMethod("소개", "Student", function(x) {
  callNextMethod()             # 먼저 Person의 소개() 실행
  cat("(", x@school, "재학중)\n")
})
소개(s)
#> 이영희 입니다.
#> ( 인천여대 재학중)

Reference Class의 상속도 contains =로 지정하며, 부모 메서드를 이어서 호출할 때는 callSuper()를 씁니다.

Animal <- setRefClass("Animal",
  fields = list(name = "character"),
  methods = list(
    speak = function() cat(name, "이(가) 소리를 냅니다.\n")
  ))

Dog <- setRefClass("Dog", contains = "Animal",
  methods = list(
    speak = function() {
      callSuper()                              # 먼저 Animal의 speak() 실행
      cat(name, "이(가) 멍멍 짖습니다.\n")
    }
  ))

d <- Dog$new(name = "바둑이")
d$speak()
#> 바둑이 이(가) 소리를 냅니다.
#> 바둑이 이(가) 멍멍 짖습니다.

is(d, "Animal")
#> [1] TRUE

S3의 상속은 19.2절에서 이미 살펴본 것처럼 별도의 선언 없이 클래스 벡터의 순서로만 표현됩니다. class(x) <- c("장학생", "학생")이라고 지정하는 것 자체가 "장학생은 학생이기도 하다"는 상속 관계를 나타내며, NextMethod()가 그 순서를 따라 다음 메서드로 넘어갑니다. 반면 S4와 Reference Class는 contains = 인자로 상속 관계를 명시적으로 선언하고, is()로 그 관계를 조회하며, callNextMethod()/callSuper()로 부모의 동작을 이어서 호출합니다.

아래 표는 세 시스템의 상속 관련 문법을 나란히 정리한 것입니다.

구분 상속 선언 방법 클래스(부모) 관계 확인 부모의 메서드를 이어서 호출
S3 class(x) <- c("자식클래스", "부모클래스") (클래스 벡터에 순서대로 나열) inherits(x, "부모클래스") NextMethod()
S4 setClass("자식클래스", contains = "부모클래스") is(x, "부모클래스") callNextMethod()
Reference Class setRefClass("자식클래스", contains = "부모클래스") is(x, "부모클래스") callSuper()

inherits()

inherits(x, what, which = FALSE)는 객체 x의 클래스 벡터 안에 what으로 지정한 클래스 이름이 들어 있는지 확인합니다(S3 클래스 확인용).

  • x : 검사할 객체.
  • what : 확인하고 싶은 클래스 이름(문자형 벡터를 넣으면 여러 개를 한 번에 검사).
  • which : FALSE(기본값)이면 하나라도 포함되어 있는지 논리값 하나로 반환하고, TRUE이면 what의 각 클래스가 class(x)에서 몇 번째 위치에 있는지(없으면 0을) 정수 벡터로 반환합니다.
장학생 <- structure(list(이름 = "김철수", 나이 = 21, 장학금 = 100),
                    class = c("장학생", "학생"))
class(장학생)
#> [1] "장학생" "학생"  
inherits(장학생, "학생")
#> [1] TRUE
inherits(장학생, c("장학생", "대학원생"), which = TRUE)
#> [1] 1 0

which = TRUE로 확인해 보면 "장학생"은 클래스 벡터의 1번째, "대학원생"은 아예 없어(0) 있다는 것을 알 수 있습니다. S3는 클래스 사이의 관계를 별도로 등록하지 않으므로, inherits()는 어디까지나 "class 속성 벡터 안에 그 이름이 있는가"만 확인할 뿐, S4의 is()처럼 실제 클래스 계층 정보를 참조하는 것은 아니라는 점에 유의해야 합니다.

is()

is(object, class2)는 S4(또는 Reference Class) 객체 object가 class2 클래스이거나 그 클래스를 상속받은 하위 클래스인지 확인합니다.

  • object : 검사할 객체.
  • class2 : 확인하고 싶은 클래스 이름(생략하면 object가 상속받은 모든 클래스 이름을 반환).
is(s, "Person")
#> [1] TRUE
is(d, "Animal")
#> [1] TRUE

class2를 생략하면 object가 속한 클래스 계층 전체를 확인할 수 있어, extends()(위 예제 참고)와 비슷한 용도로도 쓰입니다.

더 알아보기: S7 — 차세대 통합 OOP 시스템

R Consortium(R Core·Bioconductor·tidyverse/Posit 관계자들이 함께 참여하는 협의체)은 S3의 간결함과 S4의 형식적 엄격함을 하나로 통합하려는 새 시스템인 S7을 개발하고 있습니다. S7은 장기적으로 base R에 병합하는 것을 목표로 하고 있지만, 이 장의 집필 시점(2026년 7월) 기준으로는 아직 CRAN의 별도 패키지(install.packages("S7"))로만 제공되는 실험적(experimental) 단계이며 base R에 포함되어 있지 않습니다. 이 매뉴얼은 Base R만을 다루는 것을 원칙으로 하므로 S7의 구체적인 문법은 다루지 않지만, S3·S4·Reference Class의 뒤를 이을 시스템으로 R 커뮤니티에서 주목받고 있다는 점은 최신 흐름으로 기억해 둘 만합니다.