콘텐츠로 이동

11. 객체 함수

11.1 클래스·타입 조사

R로 작업하다 보면 함수가 갑자기 오류를 내거나, 분명 숫자를 다루고 있다고 생각했는데 계산이 안 되는 상황을 자주 만나게 됩니다. 이런 문제의 상당수는 객체의 정체 — R 내부적으로 어떤 방식으로 저장되어 있는지(typeof), R이 그것을 어떤 유형으로 다루는지(class) — 를 확인하지 않고 넘어갔기 때문에 생깁니다.

설문 응답을 엑셀에서 옮겨 적다 보면 숫자 열이 문자형으로 들어오는 일이 흔합니다. 겉보기에는 숫자와 똑같아 보이지만, mean() 같은 함수는 이를 받아들이지 못합니다.

설문 <- c("1", "2", "3", "5")   # 외부에서 문자형으로 읽어온 상황을 가정
mean(설문)
#> [1] NA
#> 경고메시지(들):
#> mean.default(설문)에서:
#>   인자가 수치형 또는 논리형이 아니므로 NA를 반환합니다

class(설문)   # 원인 확인
#> [1] "character"

typeof(설문)
#> [1] "character"

설문2 <- as.numeric(설문)   # 11.4절에서 다룰 형 변환으로 해결
mean(설문2)
#> [1] 2.75

이처럼 객체의 정체를 확인하는 함수는 한 가지가 아니라 class(), typeof(), mode() 세 가지가 있으며, 서로 미묘하게 다른 관점에서 답을 줍니다.

함수 무엇을 알려주는가 1:5(정수 벡터) matrix(1:6, 2) data.frame(a=1)
class() R이 객체를 어떤 "유형"으로 취급하는가(S3 메서드를 고를 때 이 값을 봅니다) "integer" "matrix" "array" "data.frame"
typeof() 메모리에 실제로 어떤 방식(C 수준 내부 자료형)으로 저장되어 있는가 "integer" "integer" "list"
mode() typeof()를 좀 더 단순하게 묶은, S 언어 시절부터 내려온 전통적 분류 "numeric" "numeric" "list"

class(x)·typeof(x)·mode(x)는 모두 인자 하나(x)를 받아 문자열(또는 문자 벡터)을 반환하는 조회 함수이며, class(x) <- value처럼 좌변에 놓아 값을 바꾸는 치환 함수(replacement function) 형태로도 쓸 수 있습니다.

class()

class(x)는 객체에 부여된 클래스(class)를 확인합니다. class(x) <- value처럼 치환 함수 형태로 쓰면 클래스를 새로 지정하거나 바꿀 수 있습니다.

R의 많은 함수(print(), summary(), plot() 등)는 인자로 받은 객체의 class를 보고 실제로 어떤 세부 동작을 할지 결정합니다. class()는 바로 이 판단 기준이 되는 값을 확인하는 함수입니다.

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

y <- matrix(1:6, nrow = 2)
class(y)                        # R 4.0.0부터 "matrix"와 "array" 두 값을 함께 반환
#> [1] "matrix" "array" 

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

class(x) <- value로 리스트에 이름을 부여하면 나만의 객체 유형을 만들 수 있습니다.

obj <- list(이름 = "홍길동", 점수 = 95)
class(obj) <- "성적표"
class(obj)
#> [1] "성적표"

typeof()

typeof(x)는 객체가 R 내부적으로 어떤 방식(C 수준 자료형)으로 저장되어 있는지를 확인합니다.

class()가 "R이 이 객체를 어떻게 취급하는가"라는 다소 추상적인 유형이라면, typeof()는 "메모리에 실제로 어떤 형태로 들어 있는가"라는 더 근본적인 질문에 답합니다. 예를 들어 data.frame은 class()로는 "data.frame"이지만, typeof()로 보면 사실 "list"(리스트)를 기반으로 만들어진 구조입니다.

typeof(1L)        # 정수 리터럴(L 접미사)
#> [1] "integer"

typeof(1)         # 접미사 없는 숫자는 기본적으로 double(실수)
#> [1] "double"

typeof(1:5)       # : 연산자는 정수를 만듦
#> [1] "integer"

typeof(TRUE)
#> [1] "logical"

typeof("a")
#> [1] "character"

typeof(list(1, 2))
#> [1] "list"

typeof(mean)      # 사용자가 R 코드로 정의한 함수
#> [1] "closure"

typeof(sum)       # C로 구현된 내장 함수
#> [1] "builtin"

typeof(NULL)
#> [1] "NULL"

mode()

mode(x)는 typeof()의 세분화된 결과를 좀 더 단순한 몇 가지 범주("numeric", "character", "logical", "list", "function" 등)로 묶어서 보여줍니다. S 언어 시절부터 있던 함수라 오래된 R 코드에서 종종 보이며, 오늘날에는 typeof()나 class()로 대체 가능한 경우가 대부분입니다.

class(y)   # y는 위에서 만든 행렬 (matrix(1:6, nrow = 2))
#> [1] "matrix" "array" 

typeof(y)
#> [1] "integer"

mode(y)      
#> [1] "numeric"

typeof()가 "integer"와 "double"을 구분하는 반면, mode()는 둘 다 "numeric"으로 묶어 버립니다. 이 차이가 실무에서 의미를 가지는 대표적인 경우가 메모리 사용량입니다. 정수(integer)는 원소당 4바이트, 실수(double)는 원소당 8바이트를 차지하므로, 아주 큰 벡터를 다룰 때는 storage.mode()(또는 mode())를 이용해 저장 방식을 명시적으로 바꿔 메모리를 절약할 수 있습니다.

x <- c(1, 2, 3)             # 기본적으로 double로 저장됨
typeof(x)
#> [1] "double"

object.size(x)
#> 80 bytes

storage.mode(x) <- "integer"   # 정수로만 이루어진 값이라면 integer로 바꿔 메모리 절약
typeof(x)
#> [1] "integer"

object.size(x)
#> 64 bytes

str()

str(object)는 객체의 내부 구조(자료형, 크기, 앞부분 값 몇 개)를 한 화면에 압축해서 보여줍니다. 이름 그대로 "구조(structure)"를 보여주는 함수로, 처음 보는 데이터를 파악할 때 가장 먼저 실행해 볼 만한 함수입니다.

str(iris)
#> 'data.frame':    150 obs. of  5 variables:
#>  $ Sepal.Length: num  5.1 4.9 4.7 4.6 5 5.4 4.6 5 4.4 4.9 ...
#>  $ Sepal.Width : num  3.5 3 3.2 3.1 3.6 3.9 3.4 3.4 2.9 3.1 ...
#>  $ Petal.Length: num  1.4 1.4 1.3 1.5 1.4 1.7 1.4 1.5 1.4 1.5 ...
#>  $ Petal.Width : num  0.2 0.2 0.2 0.2 0.2 0.4 0.3 0.2 0.2 0.1 ...
#>  $ Species     : Factor w/ 3 levels "setosa","versicolor",..: 1 1 1 1 1 1 1 1 1 1 ...

리스트처럼 서로 다른 자료형이 섞여 있는 객체에서 특히 유용합니다. View()(RStudio 환경 창)로 일일이 펼쳐 보는 대신, str() 한 줄이면 전체 구조가 몇 줄로 요약됩니다.

lst <- list(이름 = "홍길동", 점수 = c(90, 85, 95), 통과 = TRUE)
str(lst)
#> List of 3
#>  $ 이름: chr "홍길동"
#>  $ 점수: num [1:3] 90 85 95
#>  $ 통과: logi TRUE

attributes() / attr()

attributes(x)는 객체에 붙어 있는 부가 정보(속성, attribute)를 리스트 전체로 반환합니다. attr(x, which)는 그중 이름이 which인 속성 하나만 콕 집어 확인하거나(조회), 치환 함수 형태(attr(x, which) <- value)로 새 속성을 붙일 때 사용합니다.

속성은 벡터의 이름(names), 행렬의 차원(dim), 팩터의 수준(levels), 객체의 클래스(class)처럼 데이터 자체는 아니지만 데이터에 딸려 다니는 "꼬리표"입니다. 앞서 다룬 class(), 뒤에서 다룰 dim()·dimnames()·names() 같은 함수들은 사실 모두 특정 속성 하나를 편하게 다루기 위한 전용 창구인 셈입니다.

v <- c(a = 1, b = 2, c = 3)
str(v)
#> Named num [1:3] 1 2 3
#> - attr(*, "names")= chr [1:3] "a" "b" "c"

attributes(v)
#> $names
#> [1] "a" "b" "c"

attr(v, "names")
#> [1] "a" "b" "c"

attr()에 임의의 이름을 지정하면 R이 미리 정해 두지 않은, 사용자만의 메타데이터도 자유롭게 붙일 수 있습니다.

attr(v, "단위") <- "점수"
attributes(v)
#> $names
#> [1] "a" "b" "c"
#> 
#> $단위
#> [1] "점수"

주의: 속성은 대부분의 연산에서 조용히 사라집니다. 예를 들어 v + 1을 계산하면 names 속성은 유지되지만, sum(v)처럼 벡터를 하나의 값으로 축약하는 연산에서는 대부분의 속성이 사라집니다. "분명 붙여 두었던 정보가 없어졌다"는 문제를 겪는다면 어느 단계에서 속성이 사라졌는지 attributes()로 중간중간 확인해 보는 것이 좋습니다.

unclass()

unclass(x)는 객체에서 class 속성만 제거하고, 그 아래에 있던 "본모습"을 그대로 드러냅니다.

class()가 겉으로 보이는 포장지를 확인하는 함수라면, unclass()는 그 포장지를 벗겨 내는 함수입니다. 팩터(factor)가 사실은 정수 벡터에 levels라는 속성표를 붙여 둔 것뿐이라는 점을 눈으로 직접 확인할 때 특히 유용합니다.

f <- factor(c("낮음", "높음", "중간"), levels = c("낮음", "중간", "높음"))
f
#> [1] 낮음 높음 중간
#> Levels: 낮음 중간 높음

unclass(f)
#> [1] 1 3 2
#> attr(,"levels")
#> [1] "낮음" "중간" "높음"

unclass(f)의 결과를 보면 "낮음"이 1, "중간"이 2, "높음"이 3이라는 내부 정수 코드가 그대로 드러납니다. lm()처럼 복잡해 보이는 모형 객체도 결국 unclass()로 벗겨 보면 평범한 리스트입니다.

is.list(unclass(fit))
#> [1] TRUE

structure()

structure(.Data, ...)는 객체(.Data)를 만들면서 동시에 속성(...에 이름 = 값 형태로 나열)들을 한 번에 부여합니다.

앞서 class(obj) <- "성적표"처럼 객체를 만든 뒤 속성을 나중에 붙였던 것을, structure()를 쓰면 한 문장으로 줄일 수 있습니다.

obj2 <- structure(list(이름 = "홍길동", 점수 = 95), class = "성적표")
identical(obj, obj2)   # 앞서 class(obj) <- "성적표"로 만든 obj와 완전히 동일
#> [1] TRUE

str(obj2)
#> List of 2
#>  $ 이름: chr "홍길동"
#>  $ 점수: num 95
#>  - attr(*, "class")= chr "성적표"

inherits()

inherits(x, what)는 x가 what에 지정한 클래스에 속하는지를 TRUE/FALSE로 검사합니다.

class(x) == "lm"처럼 ==로 직접 비교하고 싶어질 수 있지만, 이 방식은 위험합니다. class()가 값 하나가 아니라 여러 클래스로 이루어진 벡터를 반환하는 객체가 많기 때문입니다. 예를 들어 glm()으로 적합한 모형은 class()가 c("glm", "lm")이라 "lm"의 성질도 함께 가지고 있지만, ==로 비교하면 이 사실을 놓칩니다.

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

class(gfit) == "lm"    # 벡터라서 TRUE/FALSE가 각각 나옴 - 그대로 조건문에 쓰기 위험
#> [1] FALSE  TRUE

if (class(gfit) == "lm") cat("lm 객체입니다\n")   # R 4.2.0부터 즉시 오류
#> if (class(gfit) == "lm") cat("lm 객체입니다\n")에서 다음과 같은 에러가 발생했습니다:
#>   the condition has length > 1 

inherits()는 클래스 벡터 전체를 검사해 하나라도 일치하면 TRUE를 반환하므로 이런 함정에서 안전합니다.

inherits(gfit, "lm")     
#> [1] TRUE

inherits(gfit, "glm")    # glm 객체도 lm의 성질을 상속받았으므로 TRUE
#> [1] TRUE

더 알아보기: S4 객체의 클래스 검사, is()

inherits()는 S3 클래스 체계를 기준으로 동작합니다. R에는 이보다 더 형식을 갖춘 S4 클래스 체계도 있는데, S4 객체의 상속 관계까지 정확히 검사하려면 is(object, class2)를 사용해야 합니다.

11.2 차원·길이 조사

객체의 "크기"를 확인해야 할 때, R 초보자가 자주 헷갈리는 지점이 있습니다. 벡터는 원소가 몇 개인지(length())만 있으면 충분하지만, 행렬이나 데이터프레임처럼 2차원 이상인 자료구조는 "전체 원소 개수"와 "행·열 개수"가 서로 다른 질문이라는 점입니다.

데이터프레임에 length()를 적용하면 무엇이 나올지 예측해 봅시다.

df <- data.frame(x = 1:5, y = 6:10, z = 11:15)   # 5행 3열
length(df)    # "행이 5개니까 5?"라고 예상하기 쉽지만...
#> [1] 3

nrow(df)
#> [1] 5

length(df)는 5가 아니라 3이 나옵니다. 데이터프레임은 typeof()로 보면 사실 리스트이고, 리스트의 length()는 "원소(=열)가 몇 개인가"를 뜻하기 때문입니다. 행 개수가 필요하다면 nrow()를 써야 합니다.

이처럼 "크기"를 묻는 함수는 어떤 단위로 크기를 셀 것인가에 따라 여러 개로 나뉩니다.

함수 묻는 질문 벡터 행렬 데이터프레임 리스트
length() 원소가 총 몇 개인가 원소 개수 전체 셀 개수 열 개수 원소(하위 리스트) 개수
dim() 몇 차원이고 각 차원 크기는 얼마인가 NULL c(행, 열) c(행, 열) NULL
nrow()/ncol() 행/열이 몇 개인가 NULL/NULL 행/열 개수 행/열 개수 NULL/NULL

length()

length(x)는 객체를 이루는 원소의 개수를 반환합니다. 치환 함수 형태(length(x) <- n)로 쓰면 벡터의 길이를 강제로 늘리거나 줄일 수 있습니다.

v <- c(10, 20, 30, 40)
length(v)
#> [1] 4

lst <- list(a = 1:3, b = "문자", c = TRUE)
length(lst)     # 리스트는 하위 원소 개수(3개: a, b, c)
#> [1] 3

length(x) <- n으로 벡터 길이를 원래보다 늘리면 모자란 자리는 NA로 채워지고, 줄이면 뒤쪽 값이 잘려 나갑니다.

v
#> [1] 10 20 30 40

length(v) <- 6      # 6개로 늘림 - 모자란 자리는 NA
v
#> [1] 10 20 30 40   NA   NA

length(v) <- 2      # 2개로 줄임 - 뒤쪽이 잘림
v
#> [1] 10 20

lengths()

lengths(x, use.names = TRUE)는 리스트 x에 들어 있는 각 원소의 길이를 한 번에 정수 벡터로 반환합니다(R 3.2.0부터 제공).

리스트의 원소마다 길이가 몇인지 알고 싶을 때 sapply(x, length)를 흔히 쓰는데, lengths()는 이와 결과가 같으면서도 내부적으로 더 효율적으로 계산되도록 최적화되어 있어 원소가 아주 많은 리스트에서 특히 빠릅니다.

lst2 <- list(a = 1:3, b = 1:5, c = 1)
sapply(lst2, length)   # 목적은 같지만
#> a b c 
#> 3 5 1 

lengths(lst2)           # lengths()가 더 빠르고 간결함
#> a b c 
#> 3 5 1 

dim()

dim(x)는 객체의 각 차원별 크기를 정수 벡터로 반환합니다. 벡터처럼 차원 개념이 없는 1차원 객체에는 NULL을 반환합니다. 치환 함수 형태(dim(x) <- c(...))로 쓰면 벡터를 행렬·배열로 바꿀 수 있습니다.

v <- 1:5
dim(v)         # 벡터는 차원이 없음
#> NULL

m <- matrix(1:6, nrow = 2)
dim(m)
#> [1] 2 3

dim(df)         # data.frame도 dim() 지원(행, 열)
#> [1] 5 3

벡터에 dim<-()으로 차원을 부여하면 그 자체로 행렬이 됩니다. 이는 4.2절에서 다룬 matrix()가 내부적으로 하는 일과 같습니다.

x <- 1:6
dim(x) <- c(2, 3)
x
#>      [,1] [,2] [,3]
#> [1,]    1    3    5
#> [2,]    2    4    6

class(x)
#> [1] "matrix" "array" 

nrow() / ncol()

nrow(x)·ncol(x)는 행렬·데이터프레임의 행 개수·열 개수를 각각 반환합니다. 차원이 없는 벡터에 사용하면 NULL을 반환합니다.

nrow(m)
#> [1] 2

ncol(m)
#> [1] 3

nrow(v)     # 벡터는 행 개념이 없어 NULL
#> NULL

더 알아보기: 벡터에도 안전한 NROW()·NCOL()

nrow(v)가 NULL을 반환하는 것이 불편할 때가 있습니다. 예를 들어 함수 하나가 입력을 벡터로 받을 수도, 데이터프레임으로 받을 수도 있게 만들고 싶다면 매번 NULL 여부를 따로 처리해야 합니다. NROW()·NCOL()(대문자)는 이런 상황을 위한 안전한 버전으로, 벡터를 "열이 1개인 행렬"처럼 취급해 NULL 대신 항상 숫자를 돌려줍니다.

NROW(v); NCOL(v)   # 벡터를 길이 5, 열 1개인 것처럼 취급
#> [1] 5
#> [1] 1

dimnames()

dimnames(x)는 행렬·데이터프레임 각 차원(행·열)에 붙은 이름을 리스트로 확인하거나 지정합니다.

m2 <- matrix(1:6, nrow = 2)
dimnames(m2)     # 이름이 없으면 NULL
#> NULL

dimnames(m2) <- list(c("행1", "행2"), c("열1", "열2", "열3"))
m2
#>     열1 열2 열3
#> 행1   1   3   5
#> 행2   2   4   6

행 이름·열 이름만 각각 따로 다루고 싶다면 dimnames()가 반환하는 리스트의 첫 번째·두 번째 원소를 그대로 감싼 rownames()·colnames()가 더 간편합니다.

rownames(m2)
#> [1] "행1" "행2"

colnames(m2)
#> [1] "열1" "열2" "열3"

11.3 논리 검사 함수

조건문(if())이나 필터링 작업에서 결측치(NA)나 특정 자료형 여부를 확인해야 하는 경우가 매우 흔합니다. 그런데 ==로 NA를 직접 비교하면 원하는 결과를 얻지 못합니다.

"값이 결측치인지" 확인하고 싶을 때 x == NA를 쓰면 어떻게 될까요?

x <- c(10, NA, 30)
x == NA          # NA와의 비교는 항상 NA(알 수 없음)를 반환
#> [1] NA NA NA

is.na(x)          # 결측치 여부는 반드시 is.na()로 확인해야 함
#> [1] FALSE  TRUE FALSE

NA는 "알 수 없는 값"이기 때문에 무엇 == NA의 결과도 "알 수 없음(NA)"이 되어 버립니다. 이런 이유로 R은 결측치나 자료형·자료구조 여부를 판정하는 전용 함수들을 is. 접두사로 통일해서 제공합니다.

is.xxx() 계열 함수는 검사 대상에 따라 다음과 같이 나눌 수 있습니다.

분류 함수 확인하는 것
결측·공백 확인 is.na(), anyNA(), is.null() 결측치(NA) 또는 빈 객체(NULL) 여부
기본 자료형 확인 is.numeric(), is.character(), is.logical(), is.integer(), is.double(), is.function() 값의 자료형
자료구조 확인 is.vector(), is.list(), is.matrix(), is.array(), is.data.frame(), is.factor() 값이 담긴 그릇(구조)의 종류
특수값 확인 is.finite(), is.infinite(), is.nan() 유한한 수인지, 무한대인지, 계산 불가능한 값인지

이 함수들은 모두 인자 하나(x)를 받아, x와 같은 길이의 TRUE/FALSE 논리 벡터(또는 객체 전체에 대한 TRUE/FALSE 하나)를 반환하는 술어 함수(predicate function)라는 공통점을 가집니다.

is.na() / anyNA()

is.na(x)는 x의 각 원소가 결측치(NA)인지를 TRUE/FALSE로 반환합니다. 치환 함수 형태(is.na(x) <- 위치)로 쓰면 특정 위치의 값을 NA로 바꿀 수 있습니다. anyNA(x)는 "x 안에 NA가 하나라도 있는가"만 빠르게 확인합니다.

x <- c(10, NA, 30, NA, 50)
is.na(x)
#> [1] FALSE  TRUE FALSE  TRUE FALSE

which(is.na(x))     # 결측치의 위치
#> [1] 2 4

anyNA(x)            # NA가 하나라도 있는지만 빠르게 확인
#> [1] TRUE

is.na(x) <- 위치는 특정 원소를 결측치로 강제 지정할 때 사용합니다. 이상치를 결측치로 처리해야 하는 데이터 정제 작업에서 자주 쓰이는 패턴입니다.

y <- c(1, 2, 3, 4)
is.na(y) <- c(2, 4)   # 2번째, 4번째 원소를 NA로 지정
y
#> [1]  1 NA  3 NA

더 알아보기: any(is.na(x)) 대신 anyNA(x)를 쓰는 이유

any(is.na(x))도 anyNA(x)와 결과는 같습니다. 다만 any(is.na(x))는 x와 길이가 같은 논리 벡터(is.na(x))를 메모리에 통째로 만든 뒤에야 any()로 검사하는 반면, anyNA(x)는 원소를 하나씩 살펴보다가 NA를 발견하는 즉시 계산을 멈춥니다. 관측치가 수백만 개인 벡터에서는 이 차이가 체감될 만큼 커질 수 있으므로, 단순히 "NA가 있는지 없는지"만 궁금하다면 anyNA()를 우선 고려하는 것이 좋습니다.

is.null()

is.null(x)는 x가 NULL인지 확인합니다.

NULL과 NA는 둘 다 "값이 없다"는 인상을 주지만 서로 다른 개념입니다(2.5.3절). NA는 "자리는 있지만 그 값을 모른다"는 뜻이고, NULL은 "그 자리 자체가 아예 없다"는 뜻입니다. 그래서 길이가 있는 벡터의 한 원소는 NA가 될 수 있어도 NULL이 될 수는 없으며, NULL 자체는 길이가 0입니다.

z <- NULL
is.null(z)
#> [1] TRUE

length(NULL)
#> [1] 0

w <- c()          # c()에 아무것도 안 넣으면 NULL이 만들어짐
is.null(w)
#> [1] TRUE

기본 자료형 확인

is.numeric(), is.character(), is.logical(), is.integer(), is.double(), is.function()은 각각 x가 숫자형·문자형·논리형·정수형·실수형·함수인지를 확인합니다.

is.numeric(1L)      # 정수(integer)도 numeric으로 취급됨에 주의
#> [1] TRUE

is.numeric(1.5)
#> [1] TRUE

is.numeric("1")      # 숫자처럼 생긴 문자열은 numeric이 아님
#> [1] FALSE

is.character("a")
#> [1] TRUE

is.logical(TRUE)
#> [1] TRUE

is.function(mean)
#> [1] TRUE

주의: is.numeric(1L)이 TRUE라는 점을 기억해 두어야 합니다. typeof(1L)은 "integer"이지만, is.numeric()은 정수형과 실수형을 모두 "수치형"으로 인정합니다. 정수형인지 실수형인지 정확히 구분해야 한다면 is.integer()·is.double()을 따로 사용해야 합니다.

자료구조 확인

is.vector(), is.list(), is.matrix(), is.array(), is.data.frame(), is.factor()는 x가 각각 벡터·리스트·행렬·배열·데이터프레임·팩터라는 "그릇"에 담겨 있는지 확인합니다.

is.vector(1:3)
#> [1] TRUE

is.matrix(matrix(1:4, 2))
#> [1] TRUE

is.list(list(1, 2))
#> [1] TRUE

is.data.frame(data.frame(x = 1))
#> [1] TRUE

is.factor(factor("a"))
#> [1] TRUE

is.array(array(1:8, dim = c(2, 2, 2)))
#> [1] TRUE

주의: is.vector()는 생각보다 엄격합니다

is.vector()는 원소의 나열 여부가 아니라 "names 외의 다른 속성이 전혀 없는가"까지 함께 확인합니다. 그래서 이름(names)만 붙어 있는 벡터는 여전히 TRUE지만, dim 속성이 붙어 있는 행렬은 원소가 나열되어 있는데도 is.vector()가 FALSE를 반환합니다.

is.vector(c(a = 1, b = 2))     # names 속성만 있으면 여전히 TRUE
is.vector(matrix(1:4, 2))      # dim 속성이 있으면 FALSE
#> [1] TRUE
#> [1] FALSE

"행렬도 결국 원소들의 나열 아닌가?"라고 생각하면 헷갈리기 쉬운 부분이므로, is.vector()는 "속성이 거의 없는 순수한 벡터인가"를 묻는 함수라고 이해하는 편이 정확합니다.

특수값 확인

수치 연산 중에는 결측치(NA) 외에도 계산이 불가능하거나(NaN, Not a Number) 무한대로 발산한(Inf, -Inf) 값이 나올 수 있습니다. is.finite()·is.infinite()·is.nan()은 이런 특수한 수치 상태를 구분해서 확인합니다.

vals <- c(5, NA, NaN, Inf, -Inf)
data.frame(
  값 = c("5", "NA", "NaN", "Inf", "-Inf"),
  is.na = is.na(vals),
  is.nan = is.nan(vals),
  is.finite = is.finite(vals),
  is.infinite = is.infinite(vals)
)
#>     값 is.na is.nan is.finite is.infinite
#> 1    5 FALSE  FALSE      TRUE       FALSE
#> 2   NA  TRUE  FALSE     FALSE       FALSE
#> 3  NaN  TRUE   TRUE     FALSE       FALSE
#> 4  Inf FALSE  FALSE     FALSE        TRUE
#> 5 -Inf FALSE  FALSE     FALSE        TRUE

이 표에서 눈여겨볼 점은 is.na(NaN)도 TRUE라는 사실입니다. NaN(0/0처럼 정의될 수 없는 계산 결과)은 NA(값을 모름)의 특수한 경우로 취급되어, is.na()로도 함께 잡힙니다. 반대로 is.nan()은 NA를 NaN으로 인정하지 않으므로 is.na()보다 더 좁은 범위만 검사합니다. Inf·-Inf는 1/0처럼 무한대로 발산한 결과로, 결측치는 아니지만(is.na FALSE) 유한하지도 않습니다(is.finite FALSE).

11.4 형 변환 함수

데이터를 불러오거나 사용자 입력을 받으면 원하는 자료형이 아닌 경우가 흔합니다. 11.3절의 is.xxx()로 문제를 확인했다면, 이제 as.xxx()로 원하는 형태로 바꿔야 합니다.

11.1절 도입부에서 본 문자형 설문 응답을 다시 살펴보면, as.numeric()으로 숫자형으로 바꾼 뒤에야 평균을 구할 수 있었습니다. 그런데 형 변환이 항상 깔끔하게 성공하는 것은 아닙니다.

as.numeric("3.14")     # 성공적으로 변환됨
#> [1] 3.14

as.numeric("삼")        # 숫자로 해석할 수 없으면 NA + 경고
#> [1] NA
#> 경고메시지(들): 
#> 강제형변환에 의해 생성된 NA 입니다

is.xxx()와 as.xxx()는 대부분 짝을 이루며, R의 기본 자료형·자료구조 각각에 대응하는 변환 함수가 마련되어 있습니다.

대상 확인 함수(11.3절) 변환 함수
수치형 is.numeric() as.numeric()(as.double()와 동일)
정수형 is.integer() as.integer()
문자형 is.character() as.character()
논리형 is.logical() as.logical()
팩터 is.factor() as.factor()
벡터 is.vector() as.vector()
리스트 is.list() as.list()
행렬 is.matrix() as.matrix()
데이터프레임 is.data.frame() as.data.frame()

as.xxx(x, ...) 형태의 함수들은 공통적으로 객체 x를 받아 다른 자료형·자료구조로 강제 변환한 결과를 반환하며, 변환이 불가능한 원소는 결측치(NA)로 대체하고 경고를 발생시킵니다.

as.numeric() / as.integer() / as.character() / as.logical()

가장 기본적인 형 변환 함수들입니다. as.numeric(x)(as.double(x)와 동일)·as.integer(x)는 x를 실수형·정수형으로, as.character(x)는 문자형으로, as.logical(x)는 논리형으로 바꿉니다.

as.numeric("3.14")
#> [1] 3.14

as.integer(3.9)          # 소수점 이하는 버림(반올림 아님)
#> [1] 3

as.character(123)
#> [1] "123"

as.character(TRUE)
#> [1] "TRUE"

as.logical()은 문자열 "TRUE"/"T"/"FALSE"/"F"(대소문자 무관)를 인식하며, 그 외의 문자열은 NA가 됩니다. 숫자는 0이면 FALSE, 0이 아니면 모두 TRUE로 취급합니다.

as.logical("TRUE")
#> [1] TRUE

as.logical("T")
#> [1] TRUE

as.logical("yes")
#> [1] NA

as.logical(0)
#> [1] FALSE

as.logical(5)
#> [1] TRUE

팩터를 숫자로 바꿀 때의 함정

as.numeric()을 팩터(factor)에 그대로 적용하면 원하는 값이 아니라 엉뚱한 값이 나옵니다. R 사용자가 가장 자주 빠지는 함정 중 하나입니다.

f <- factor(c("10", "20", "30"))
f
#> [1] 10 20 30
#> Levels: 10 20 30

as.numeric(f)     # 10, 20, 30이 아니라 1, 2, 3이 나옴!
#> [1] 1 2 3

11.1절 unclass()에서 확인했듯, 팩터는 내부적으로 "정수 코드 + levels 이름표"로 저장됩니다. as.numeric()을 팩터에 바로 적용하면 levels가 아니라 그 내부 정수 코드를 그대로 숫자로 바꿔 버립니다. 원래 라벨이 우연히 숫자처럼 생겼더라도 R은 이를 알지 못합니다.

반드시 as.character()를 한 번 거친 뒤 as.numeric()을 적용해야 합니다.

as.numeric(as.character(f))    # 문자로 바꾼 뒤 숫자로: 올바른 결과
#> [1] 10 20 30

as.numeric(levels(f))[f]        # 대용량 팩터에서 더 효율적인 대안(levels 개수만큼만 변환)
#> [1] 10 20 30

as.numeric(as.character(f))는 원소 개수만큼 문자→숫자 변환을 반복하는 반면, as.numeric(levels(f))[f]는 고유한 levels 개수만큼만 변환한 뒤 인덱싱으로 값을 채웁니다. 데이터가 아주 크고 levels 개수가 적을 때는 후자가 더 빠릅니다.

as.vector() / as.list() / as.matrix() / as.data.frame()

자료구조를 바꾸는 변환 함수들입니다. as.vector(x)는 dim 등의 구조적 속성을 제거해 평범한 벡터로, as.list(x)는 리스트로, as.matrix(x)는 행렬로, as.data.frame(x)는 데이터프레임으로 바꿉니다.

m <- matrix(1:6, nrow = 2)
m
#>      [,1] [,2] [,3]
#> [1,]    1    3    5
#> [2,]    2    4    6

as.vector(m)     # dim 속성이 사라지고 평범한 벡터가 됨(열 방향으로 풀림)
#> [1] 1 2 3 4 5 6
v <- c(a = 1, b = 2, c = 3)
as.list(v)         # 원소마다 별도의 리스트 항목이 됨(이름은 유지)
#> $a
#> [1] 1
#> 
#> $b
#> [1] 2
#> 
#> $c
#> [1] 3
mm <- matrix(1:6, nrow = 2, dimnames = list(NULL, c("x", "y", "z")))
as.data.frame(mm)   # 열 이름이 그대로 데이터프레임의 변수 이름이 됨
#>   x y z
#> 1 1 3 5
#> 2 2 4 6

주의: as.list(v)와 unlist()(4.6.5절)는 정반대 방향입니다. as.list()는 벡터를 리스트로 "풀어서" 원소마다 개별 항목으로 만들고, unlist()는 리스트를 벡터로 "합쳐서" 하나의 벡터로 되돌립니다.

as.factor()

as.factor(x)는 벡터 x(주로 문자형)를 팩터로 변환합니다.

as.factor(c("낮음", "높음", "낮음"))
#> [1] 낮음 높음 낮음
#> Levels: 낮음 높음

R 4.0.0부터 read.csv() 등 데이터를 불러오는 함수들의 stringsAsFactors 기본값이 TRUE에서 FALSE로 바뀌면서, 오늘날에는 문자형 열을 자동으로 팩터로 바꿔 주지 않습니다. 그래서 범주형으로 다뤄야 할 열이 있다면 as.factor()(또는 factor())로 직접 변환해 주어야 하는 경우가 예전보다 늘었습니다.

type.convert()

type.convert(x, na.strings = "NA", as.is, ...)는 문자형 벡터 x의 내용을 살펴보고, 숫자로 해석할 수 있으면 숫자형으로, 아니면 문자형(또는 팩터)으로 자동으로 적절한 자료형을 판단해 변환합니다. read.table()·read.csv()가 파일을 읽어 각 열의 자료형을 정할 때 내부적으로 사용하는 함수이기도 합니다.

여러 열을 하나씩 as.numeric()·as.character()로 판단해 바꾸는 대신, "이 문자열 벡터가 숫자로 보이면 숫자로, 아니면 그대로 둬라"는 판단 자체를 R에 맡기고 싶을 때 유용합니다.

raw <- c("1", "2", "3")
str(type.convert(raw, as.is = TRUE))    # 모두 숫자로 해석 가능 -> integer로 변환
#>  int [1:3] 1 2 3

raw2 <- c("1", "2", "가")
str(type.convert(raw2, as.is = TRUE))   # 숫자로 해석 안 되는 값이 섞여 있으면 문자형 유지
#>  chr [1:3] "1" "2" "가"

주의: as.is 인자를 생략하면 R이 "명시적으로 지정해 달라"는 경고를 내며, 사실상 as.is = TRUE(문자형 유지)로 동작합니다. 오래된 코드에서는 as.is = FALSE(문자형을 팩터로 자동 변환)가 기본값이었지만, 위 as.factor() 설명에서 언급한 stringsAsFactors 기본값 변경과 같은 흐름으로 이해하면 됩니다. 따라서 type.convert()를 쓸 때는 as.is = TRUE를 항상 명시하는 것이 안전합니다.