16. 사용자 정의 함수¶
R에서 사용자 정의 함수를 능숙하게 다루는 일은 단순히 코드 중복을 줄이는 것 이상의 의미가 있습니다. R은 함수를 벡터·숫자·문자열과 마찬가지로 하나의 값으로 취급하는 함수형 프로그래밍 언어이기 때문입니다.
16.1 함수 정의 기본 (function())¶
똑같은 계산을 대상만 바꾸어 가며 여러 번 반복해서 작성해야 하는 경우가 많습니다. 코드를 복사·붙여넣기로 늘려 가면 당장은 동작하지만, 계산 로직에 오류가 있거나 나중에 수정할 일이 생기면 복사된 곳을 전부 찾아 일일이 고쳐야 하고, 그 과정에서 한두 곳을 빠뜨리는 실수가 생기기 쉽습니다.
섭씨(Celsius) 온도 벡터를 화씨(Fahrenheit)로 변환하는 계산을 생각해 보겠습니다. 계산식 자체는 벡터 연산이라 한 줄이면 되지만, 이 변환을 코드 여러 곳에서 반복해서 써야 한다면 함수로 감싸 두는 편이 안전합니다.
c_temp <- c(0, 20, 36.5, 100)
# 계산식을 그대로 사용
c_temp * 9/5 + 32
#> [1] 32.0 68.0 97.7 212.0
# 함수로 정의해 두면 이름으로 재사용 가능
c_to_f <- function(celsius) {
fahrenheit <- celsius * 9/5 + 32
return(fahrenheit)
}
c_to_f(c_temp)
#> [1] 32.0 68.0 97.7 212.0
계산 로직을 함수로 감싸 이름을 붙이면 (1) 같은 코드를 여러 번 베껴 쓰지 않아도 되고(재사용성), (2) c_to_f(c_temp)처럼 함수 이름 자체가 "이 코드가 무슨 일을 하는지"를 설명해 주며(가독성), (3) 로직을 고칠 일이 생기면 함수 정의 한 곳만 수정하면 됩니다(유지보수성). 이런 원칙을 흔히 "같은 것을 반복하지 말라(DRY, Don't Repeat Yourself)"고 부릅니다.
R에서 함수는 다음 세 가지 요소로 구성됩니다.
- 인자 목록(formals): 함수가 입력으로 받을 값들의 이름
- 본문(body): 중괄호
{}안에 들어가는, 실제로 실행되는 코드 - 환경(environment): 함수가 정의될 당시의 주변 환경
이 세 요소를 갖춘 함수 객체를 <-로 이름에 할당하는 것이 R에서 함수를 만드는 기본 형태입니다.
function()¶
function(인자, ...) {본문}은 새로운 함수 객체를 만들어 반환하는 R의 예약어(키워드)입니다.
인자: 함수가 받을 입력값의 이름을 쉼표로 나열합니다. 개수 제한은 없으며, 인자가 하나도 없는 함수(function() {...})도 가능합니다.{본문}: 인자를 받아 실제로 수행할 코드입니다. 본문이 한 줄이면 중괄호를 생략할 수 있지만(function(x) x^2), 여러 줄이라면 중괄호로 묶어야 합니다.
함수를 만든 뒤 formals()·body()·args()로 각 구성 요소를 따로 확인할 수 있습니다.
formals(c_to_f) # 인자 목록
#> $celsius
#>
body(c_to_f) # 본문
#> {
#> fahrenheit <- celsius * 9/5 + 32
#> return(fahrenheit)
#> }
args(c_to_f) # 인자 목록 + 반환값 형식(항상 NULL로 표시됨)
#> function (celsius)
#> NULL
함수 이름을 괄호 없이 콘솔에 입력하면(예: c_to_f만 입력) R은 그 함수의 소스 코드 전체를 그대로 출력해 줍니다. 이는 R이 함수를 코드 텍스트가 아니라 하나의 데이터(객체) 로 다루기 때문이며, 이 성질 덕분에 함수를 변수에 저장하거나 다른 함수의 인자로 전달하는 일(8장의 apply 계열·함수형 도구가 바로 이 원리 위에서 동작합니다)이 자연스럽게 가능해집니다.
is.function(c_to_f)
class(c_to_f)
#> [1] TRUE
#> [1] "function"
# 함수를 리스트에 담아 여러 함수를 한꺼번에 다룰 수도 있음
f_list <- list(c_to_f, mean, sd)
sapply(f_list, is.function)
#> [1] TRUE TRUE TRUE
익명 함수와 람다 축약 \(x)¶
이름을 붙이지 않고 그 자리에서 바로 만들어 한 번만 쓰는 함수를 익명 함수(anonymous function) 라고 합니다. 8장에서 apply()·sapply() 등에 \(x) x^2 + 1처럼 넘겼던 함수들이 모두 익명 함수입니다.
- 이름 붙은 함수(named function)는 여러 번 재사용하거나, 함수의 목적을 이름으로 문서화하고 싶을 때 적합합니다.
- 익명 함수는 apply 계열·함수형 도구(8장)처럼 함수를 딱 한 번, 그 자리에서만 쓰고 버릴 때 적합합니다. 굳이 이름을 붙이면 전역 환경(또는 스크립트)에 다시는 안 쓸 이름만 늘어나게 됩니다.
R 4.1부터는 function(x) {...} 대신 \(x) {...}라는 축약 문법을 쓸 수 있으며, 둘은 완전히 동일하게 동작합니다.
함수를 정의하자마자 괄호로 감싸 바로 호출하는 방식을 즉시실행함수(IIFE, Immediately Invoked Function Expression) 라고 합니다. 결과 값만 한 번 필요하고 함수 자체를 남겨 둘 필요가 없을 때 씁니다.
더 알아보기: 함수 이름 짓기 관행
Base R 자체는 함수 이름 표기 방식이 통일되어 있지 않습니다.
read.csv()처럼 마침표로 단어를 구분한 이름도 있고,colMeans()처럼 낙타 표기법(camelCase)을 쓴 이름도 있습니다. 이는 R이 30년 넘게 여러 개발자의 손을 거치며 발전해 온 역사적 결과입니다. 새로 작성하는 함수라면 마침표(.)는 S3 클래스 메서드와 혼동될 수 있으므로 피하고,celsius_to_fahrenheit()처럼 밑줄(_)로 단어를 구분하는 스타일(snake_case)을 권장합니다. 이 표기법은 tidyverse 스타일 가이드에서 널리 채택하면서 최근 R 사용자들 사이에서도 사실상의 표준처럼 자리 잡았습니다.
16.2 인자·기본값·...¶
함수를 쓸 때마다 모든 인자를 일일이 지정해야 한다면, 대부분의 경우 똑같이 넣는 옵션값까지 매번 타이핑해야 해서 번거롭습니다. 반대로 어떤 함수는 몇 개의 값을 받게 될지 함수를 정의하는 시점에는 알 수 없는 경우도 있습니다(예: 벡터를 몇 개든 넘기면 전부 합산하는 함수).
mean(x, na.rm = FALSE)처럼 Base R 함수 대부분은 자주 바꾸지 않는 인자에 기본값을 지정해 두어, 필요할 때만 명시적으로 값을 넘기도록 설계되어 있습니다. 또한 sum(1, 2, 3, 4)처럼 인자 개수가 정해져 있지 않은 함수도 있습니다.
R은 이 두 가지 요구를 각각 기본값 인자(default argument)와 ...(ellipsis, 가변 인자)로 지원합니다. 또한 인자를 어떤 순서로, 어떤 이름으로 넘기느냐에 따라 R이 그 값을 함수의 어느 인자에 대응시킬지를 정하는 규칙(인자 매칭)도 함께 알아야 합니다.
function(x, y = 기본값, ...)처럼 기본값이 없는 인자·기본값이 있는 인자·...를 섞어 정의할 수 있으며, 인자는 (1) 이름 완전 일치 → (2) 이름 부분 일치 → (3) 남은 인자를 순서대로 위치 매칭, 이 세 단계 순서로 값에 대응됩니다.
기본값 인자 (default argument)¶
인자 이름 뒤에 = 값을 붙이면, 함수를 호출할 때 그 인자를 생략해도 지정한 기본값이 대신 사용됩니다.
greet <- function(name, greeting = "안녕하세요") {
paste0(greeting, ", ", name, "!")
}
greet("철수") # greeting 생략 -> 기본값 사용
greet("영희", "반갑습니다") # greeting을 직접 지정
#> [1] "안녕하세요, 철수!"
#> [1] "반갑습니다, 영희!"
기본값에는 상수뿐 아니라 다른 인자를 활용한 표현식도 넣을 수 있습니다. 예를 들어 function(x, n = length(x))처럼 n의 기본값을 x로부터 계산하도록 만들 수 있습니다. 다만 기본값이 없는 인자를 기본값이 있는 인자보다 뒤에 두면 호출할 때 혼란을 줄 수 있으므로, 관례적으로 기본값이 없는 필수 인자를 앞쪽에, 기본값이 있는 선택적 인자를 뒤쪽에 배치합니다.
인자 매칭 규칙 (위치 · 이름 · 부분매칭)¶
함수를 호출할 때 인자를 넘기는 방법은 세 가지입니다.
- 위치 매칭(positional matching): 정의된 순서대로 값을 나열
- 이름 매칭(exact name matching):
인자이름 = 값형태로, 순서와 무관하게 지정 - 부분매칭(partial matching): 인자 이름의 앞부분만 겹치지 않게 줄여서 지정(R 콘솔·스크립트에서는 되지만, 가독성이 떨어져 패키지 코드에서는 권장되지 않습니다)
introduce <- function(name, age, job) {
sprintf("%s(%d세, %s)", name, age, job)
}
introduce("지민", 23, "학생") # 위치 매칭
#> [1] "지민(23세, 학생)"
introduce(age = 23, job = "학생", name = "지민") # 이름 매칭(순서 무관)
#> [1] "지민(23세, 학생)"
introduce("지민", jo = "학생", age = 23) # 부분매칭(jo -> job)
#> [1] "지민(23세, 학생)"
R은 인자를 이 세 방식이 섞여 있어도 다음 순서로 처리합니다. 먼저 이름이 정확히 일치하는 인자를 매칭하고, 그다음 부분적으로만 일치하는 이름을 매칭하며, 마지막으로 아직 매칭되지 않은 인자에 남은 값들을 순서대로 배정합니다.
주의: 부분매칭은 인자 이름이 겹치지 않을 때만 안전합니다. 아래처럼 여러 인자의 앞부분이 같으면 R이 어느 인자를 가리키는지 판단하지 못해 오류를 냅니다. 짧은 스크립트에서는 편리하지만, 여러 사람이 함께 보는 코드나 패키지에서는 이름을 완전하게 쓰는 편이 안전합니다.
... (ellipsis, 가변 인자)¶
...은 함수를 정의할 때 인자 개수를 미리 정하지 않고, 호출하는 쪽에서 원하는 만큼(0개 이상)의 인자를 이름 있는/없는 형태로 자유롭게 넘길 수 있게 해 주는 특수한 인자입니다.
- 함수 본문에서
...에 담긴 값 전체를 다른 함수에 그대로 넘기려면 그냥...이라고 쓰면 됩니다. list(...)로 감싸면...에 담긴 값들을 이름이 붙은 리스트로 꺼내 볼 수 있습니다....length()는...에 담긴 인자 개수를,..1·..2처럼 순번으로 개별 값에 접근할 수도 있습니다.
my_sum <- function(...) {
args <- list(...)
cat("전달된 인자 개수:", length(args), "\n")
sum(...)
}
my_sum(1, 2, 3, 4)
#> 전달된 인자 개수: 4
#> [1] 10
show_dots <- function(...) {
cat("개수:", ...length(), "\n")
cat("첫 번째:", ..1, "\n")
print(names(list(...)))
}
show_dots(a = 1, b = 2, c = 3)
#> 개수: 3
#> 첫 번째: 1
#> [1] "a" "b" "c"
...이 가장 진가를 발휘하는 상황은 내가 만든 함수가 다른 함수를 감싸면서(wrapping), 자신이 직접 처리하지 않는 나머지 옵션들을 그 함수에 그대로 넘겨줄 때입니다. 예를 들어 plot()을 감싸는 함수를 만들되 plot()이 지원하는 col, pch, main 같은 옵션은 일일이 다시 정의하지 않고 그대로 통과시키고 싶다면 다음과 같이 작성합니다.
my_plot_title <- function(x, y, ...) {
plot(x, y, ...) # col, pch, main 등은 이 자리에서 plot()으로 그대로 전달됨
}
missing()¶
missing(인자이름)는 함수를 호출할 때 특정 인자가 생략되었는지(값이 넘어오지 않았는지) 논리값으로 확인합니다.
인자이름: 확인하려는 형식 인자의 이름을 따옴표 없이 그대로 씁니다.
기본값을 두지 않고, 인자 유무에 따라 아예 다른 계산을 하고 싶을 때 유용합니다.
check_args <- function(x, y) {
if (missing(y)) {
cat("y가 생략되었습니다. x만 사용합니다.\n")
return(x)
}
x + y
}
check_args(10)
#> y가 생략되었습니다. x만 사용합니다.
#> [1] 10
check_args(10, 5)
#> [1] 15
match.arg()¶
match.arg(arg, choices)는 인자의 기본값을 "선택지 벡터"로 지정해 두고, 사용자가 그중 하나만 고르도록(또는 아무것도 지정하지 않으면 첫 번째 값을 기본으로 쓰도록) 강제하는 함수입니다.
arg: 함수 인자로 받은 값(선택지 벡터 자체 또는 그중 하나의 문자열).choices: 허용할 값의 목록. 생략하면arg가 정의된 함수의 기본값(선택지 벡터)에서 자동으로 가져옵니다.
옵션 문자열을 switch()와 함께 검증할 때 특히 자주 쓰입니다.
summarize_stat <- function(x, type = c("mean", "median", "sd")) {
type <- match.arg(type)
switch(type,
mean = mean(x),
median = median(x),
sd = sd(x))
}
summarize_stat(c(1, 2, 3, 4, 100)) # type 생략 -> 선택지의 첫 번째("mean")가 기본값
#> [1] 22
summarize_stat(c(1, 2, 3, 4, 100), "median")
#> [1] 3
choices에 없는 값을 넘기면 즉시 구체적인 오류 메시지와 함께 실행이 중단되므로, 오타나 잘못된 옵션이 함수 내부 깊숙한 곳까지 전달되어 엉뚱한 곳에서 오류가 나는 상황을 미리 막아 줍니다.
summarize_stat(c(1, 2, 3), "min")
#> match.arg(type)에서 다음과 같은 에러가 발생했습니다:
#> 'arg' should be one of "mean", "median", "sd"
16.3 스코프와 반환값 (return())¶
함수 안에서 만든 변수가 함수 밖에서도 그대로 보이거나 영향을 준다면, 함수를 다른 곳에서 재사용할 때 그 함수가 바깥의 다른 변수를 실수로 덮어써 버리는 부작용(side effect)이 생길 수 있습니다. 반대로, 함수를 여러 번 호출하는 동안 어떤 값을 계속 "기억"해 두어야 하는 경우(예: 호출할 때마다 1씩 증가하는 카운터)도 있습니다.
아래 예제에서 함수 f() 안에서 만든 x는 함수 밖의 전역 변수 x와 이름은 같지만 완전히 별개의 변수이며, 함수가 끝나면 사라집니다.
x <- 10
f <- function() {
x <- 20 # 함수 안에서 만든 x는 지역변수
x
}
f()
#> [1] 20
x # 전역 x는 영향을 받지 않음
#> [1] 10
반면 함수 안에서 아직 정의되지 않은 변수를 "읽기"만 하는 경우에는, R이 함수가 정의된 환경까지 거슬러 올라가 그 변수를 찾아 사용합니다.
R은 함수가 호출된 위치가 아니라 정의된 위치를 기준으로 변수를 찾는 어휘적 범위(lexical scoping) 를 사용합니다. 함수가 호출될 때마다 그 함수만을 위한 새로운 실행 환경이 만들어지고, 그 안에서 만든 변수(지역변수)는 함수가 끝나면 함께 사라집니다. 함수 안에서 변수를 찾지 못하면 R은 그 함수가 정의된 환경(부모 환경)으로 거슬러 올라가며 찾는데, 이 부모 환경은 함수가 호출된 위치와는 무관하다는 점이 핵심입니다. 이런 규칙 덕분에 함수는 예측 가능한 독립적인 단위로 동작할 수 있습니다.
함수의 반환값은 기본적으로 함수 본문에서 마지막으로 평가된 표현식의 값이며(암묵적 반환), return(값)으로 언제든 그 자리에서 즉시 함수를 종료하며 값을 반환할 수 있습니다(명시적 반환). 함수 밖의 변수를 함수 안에서 직접 수정하려면 일반 할당(<-) 대신 슈퍼 할당(<<-)을 사용해야 합니다.
return()과 암묵적 반환¶
함수의 본문이 끝까지 실행되면, 마지막으로 평가된 표현식의 값이 자동으로 함수의 반환값이 됩니다. 이를 암묵적 반환(implicit return) 이라고 합니다.
return(값)을 쓰면 본문이 끝나기 전이라도 그 자리에서 즉시 함수를 종료하고 지정한 값을 반환합니다. return() 뒤에 남은 코드는 실행되지 않습니다.
값 하나만 계산해서 돌려주는 짧은 함수라면 암묵적 반환만으로 충분하지만, 조건에 따라 함수를 도중에 여러 갈래로 빠져나가야 할 때는 return()이 코드를 훨씬 명확하게 만듭니다.
classify <- function(x) {
if (x < 0) {
return("음수")
}
if (x == 0) {
return("영")
}
"양수" # 마지막 경우는 암묵적 반환으로 처리
}
sapply(c(-5, 0, 7), classify)
#> [1] "음수" "영" "양수"
invisible()¶
invisible(값)은 return()과 마찬가지로 값을 반환하지만, 그 값이 콘솔에 자동으로 출력되지 않도록 표시만 해 둡니다. 함수를 변수에 저장하거나 다른 함수의 인자로 쓸 때는 여전히 정상적인 값으로 사용할 수 있습니다.
make_report <- function(x) {
cat("평균:", mean(x), "\n")
invisible(mean(x)) # 값은 반환하되 자동 출력만 생략
}
make_report(1:10) # cat()의 출력만 보이고, 반환값은 화면에 안 나타남
#> 평균: 5.5
result <- make_report(1:10) # 반환값 자체는 정상적으로 result에 저장됨
#> 평균: 5.5
result
#> [1] 5.5
plot()이나 print() 메서드처럼 화면에 무언가를 이미 보여준 함수가 뒤이어 같은 값을 또 출력해 화면을 어지럽히지 않도록 할 때 자주 쓰입니다.
<<-와 클로저(closure)¶
일반 할당 연산자 <-는 항상 현재 함수(또는 현재 환경)에 새 지역변수를 만듭니다. 이와 달리 슈퍼 할당 연산자 <<-는 현재 환경에서 변수를 만드는 대신, 함수를 감싸고 있는 부모 환경들을 거슬러 올라가며 같은 이름의 변수를 찾아 그 값을 수정합니다(끝까지 찾지 못하면 전역 환경에 새로 만듭니다).
이 성질을 이용하면 함수가 자신이 "태어난" 환경의 변수를 스스로 기억하고 수정하는 함수, 즉 클로저(closure) 를 만들 수 있습니다. 함수를 호출할 때마다 값이 1씩 증가하는 카운터가 대표적인 예입니다.
# `counter()`함수는 함수를 반환하는 함수임
counter <- function() {
n <- 0
function() {
n <<- n + 1 # counter() 호출 당시 만들어진 환경의 n을 찾아 수정
n
}
}
tick <- counter()
tick()
tick()
tick()
#> [1] 1
#> [1] 2
#> [1] 3
counter()를 다시 호출하면 n이 0부터 시작하는 완전히 새로운 환경이 만들어지므로, 여러 개의 독립된 카운터를 동시에 가질 수 있습니다.
environment()로 클로저가 기억하고 있는 환경과 그 안의 변수를 직접 들여다볼 수도 있습니다.
주의:
<<-는 함수 밖의 상태를 바꾸는 부작용을 만들기 때문에, 남용하면 "이 변수가 코드의 어디에서 바뀌었는지" 추적하기 어려워집니다. 위 예제처럼 클로저 내부에 상태를 캡슐화해 그 함수를 통해서만 값을 바꾸도록 제한하는 용도로 쓰는 것이 안전하며, 일반적인 계산 로직에서 결과를 함수 밖으로 내보낼 때는<<-대신return()을 쓰는 것이 원칙입니다.
local()¶
local(expr)은 표현식 expr을 그 자리에서만 쓰이는 임시 환경 안에서 실행하고, 계산에 사용한 중간 변수는 바깥으로 노출하지 않은 채 최종 결괏값만 돌려줍니다.
expr: 중괄호{}로 묶은 코드 블록. 여러 줄의 중간 계산을 거쳐 하나의 결과를 만들 때 사용합니다.
이름 붙은 함수를 따로 정의할 만큼 복잡하지는 않지만, 중간 변수(a, b 등)가 전역 환경을 어지럽히는 것은 피하고 싶은 일회성 계산에 적합합니다.
result <- local({
a <- 5
b <- 10
a + b # 마지막 표현식의 값이 result에 저장됨
})
result
#> [1] 15
exists("a") # local() 안에서 만든 a는 바깥에 노출되지 않음
#> [1] FALSE
on.exit()¶
on.exit(expr, add = FALSE, after = TRUE)는 함수가 (정상적으로 끝나든, return()으로 도중에 빠져나가든, 오류로 중단되든) 어떤 경로로 종료되더라도 마지막에 반드시 실행할 코드를 등록해 둡니다.
expr: 함수 종료 시점에 실행할 코드.add:FALSE(기본값)이면 새로 등록하는 코드가 이전에 등록해 둔on.exit()을 덮어쓰고,TRUE이면 기존 등록에 이어서 추가합니다.after:add = TRUE일 때, 새 코드를 기존 코드 뒤(TRUE, 기본값)에 붙일지 앞에 붙일지 정합니다.
파일을 열었다가 닫거나, 옵션을 잠시 바꿨다가 원래대로 되돌리는 등 "함수가 끝나면 반드시 뒷정리를 해야 하는" 상황에 자주 쓰입니다.
with_timer <- function(x) {
start <- Sys.time()
on.exit(cat("경과 시간:", round(as.numeric(Sys.time() - start), 4), "초\n"))
Sys.sleep(0.2)
sum(x)
}
with_timer(1:1000000)
#> 경과 시간: 0.2007 초
#> [1] 500000500000
함수가 오류로 중단되는 경우에도 on.exit()에 등록해 둔 코드는 실행됩니다. 아래 예제는 add = TRUE로 여러 개의 뒷정리 코드를 순서대로 쌓아 둔 모습입니다.
safe_divide <- function(x, y) {
on.exit(cat("1단계 정리\n"))
on.exit(cat("2단계 정리\n"), add = TRUE)
if (y == 0) stop("0으로 나눌 수 없습니다")
x / y
}
tryCatch(safe_divide(10, 0), error = function(e) cat("에러 처리:", conditionMessage(e), "\n"))
#> 1단계 정리
#> 2단계 정리
#> 에러 처리: 0으로 나눌 수 없습니다
safe_divide(10, 2)
#> 1단계 정리
#> 2단계 정리
#> [1] 5
16.4 재귀 함수¶
어떤 문제는 "더 작은 같은 형태의 문제로 쪼갠 뒤 그 결과를 조합한다"는 방식으로 정의하는 것이 가장 자연스럽습니다. 팩토리얼(계승)이나 트리 구조 탐색이 대표적입니다. 이런 문제를 억지로 for()·while() 반복문만으로 표현하면, 문제의 정의 자체는 명료한데도 코드는 오히려 부자연스러워지는 경우가 많습니다.
팩토리얼 \(n! = n \times (n-1) \times \cdots \times 1\)은 "\(n!\)은 \(n \times (n-1)!\)이고, \(1!\)은 1이다"라는 자기 자신을 이용한 정의로도 표현할 수 있습니다. 이 정의를 그대로 옮기면 함수가 자기 자신을 호출하는 형태가 됩니다.
fact_recursive <- function(n) {
if (n <= 1) return(1) # 기저 조건(base case)
n * fact_recursive(n - 1) # 재귀 조건(recursive case): 더 작은 문제(n-1)를 자기 자신에게 위임
}
fact_recursive(5)
#> [1] 120
# 반복문으로 작성한 동일한 결과
fact_iterative <- function(n) {
result <- 1
for (i in seq_len(n)) result <- result * i
result
}
identical(fact_recursive(10), fact_iterative(10))
#> [1] TRUE
자기 자신을 호출하는 함수를 재귀 함수(recursive function) 라고 합니다. 모든 재귀 함수는 반드시 다음 두 부분으로 이루어져야 합니다.
- 기저 조건(base case): 더 이상 쪼개지 않고 즉시 답을 낼 수 있는 가장 단순한 경우(위 예제의
n <= 1). 재귀 호출이 여기서 멈춥니다. - 재귀 조건(recursive case): 문제를 더 작은 같은 형태의 문제로 줄여 자기 자신에게 위임하는 부분(위 예제의
n * fact_recursive(n - 1)).
재귀 함수는 수학적 점화식과 사실상 같은 형태를 가집니다.
여기서 \(g\)는 재귀 호출 결과 \(f(n')\)을 현재 단계의 값과 결합하는 방법이고, \(n'\)이 매 호출마다 기저 조건에 조금씩 더 가까워져야 유한한 횟수 안에 계산이 끝난다는 점이 핵심입니다.
재귀 함수의 기본 구조¶
재귀 함수를 작성할 때는 다음 순서로 접근하면 실수를 줄일 수 있습니다.
- 기저 조건을 가장 먼저 정한다: "더 쪼갤 수 없는 가장 단순한 입력이 무엇인가"를 먼저 답합니다(
fact_recursive()에서는n <= 1). - 재귀 조건을 정한다: "현재 문제를, 기저 조건에 한 걸음 더 가까운 더 작은 문제로 어떻게 줄일 것인가"를 정합니다(
n - 1을 자기 자신에게 넘기는 부분). - 줄어드는 방향을 확인한다: 재귀 호출마다 인자가 기저 조건 쪽으로 반드시 좁혀지는지 확인합니다. 그렇지 않으면 다음 "주의"에서 설명하는 무한 재귀에 빠집니다.
if (기저 조건) return(기저값)을 함수의 맨 앞줄에 두는 것이 관례입니다. 기저 조건을 나중에 검사하도록 작성하면, 그 전에 실행되는 코드가 잘못된 입력(예: 음수 n)에 대해 먼저 오류를 낼 수 있기 때문입니다.
주의: 기저 조건 누락과 재귀 깊이 제한
기저 조건을 빠뜨리거나 잘못 작성하면 함수가 자기 자신을 무한히 호출하게 됩니다. R은 이런 상황을 무한정 방치하지 않고, 중첩 호출 횟수가
options("expressions")로 정해진 한도(기본값 5000)를 넘으면"evaluation nested too deeply"오류를 내며 멈춥니다.이 한도는 무한 재귀를 막는 안전장치일 뿐, 재귀 자체가 5000단계 이상 필요한 정상적인 문제(예: 아주 긴 리스트를 재귀로 순회)라면 한도를 넘기 전에 R 프로세스가 사용하는 C 스택이 먼저 바닥나 세션이 강제 종료될 수도 있습니다. 재귀의 깊이가 데이터 크기에 비례해 커지는 경우에는 재귀 대신
for()(3.4절)나 8장의 apply 계열 함수로 반복문 형태로 바꾸어 쓰는 것이 더 안전합니다.
Recall()¶
Recall(...)은 현재 실행 중인 함수 자기 자신을 다시 호출합니다. 이름 붙은 함수 안에서는 함수 이름을 직접 써도 되지만, 함수를 담은 변수의 이름이 바뀔 수 있는 상황이나 익명 함수 안에서 재귀 호출을 해야 할 때는 함수 이름을 하드코딩할 수 없으므로 Recall()이 유용합니다.
fact_anon <- function(n) {
if (n <= 1) return(1)
n * Recall(n - 1) # fact_anon이라는 이름 대신 "현재 함수 자신"을 가리킴
}
fact_anon(6)
#> [1] 720
메모이제이션(memoization)을 이용한 재귀 최적화¶
재귀는 문제를 직관적으로 표현하게 해 주지만, 잘못 설계하면 같은 계산을 몇 번이고 반복하게 될 수 있습니다. 피보나치 수열(첫째·둘째 항이 1이고, 이후 각 항이 바로 앞 두 항의 합인 수열)을 정의 그대로 재귀로 구현하면 이 문제가 뚜렷하게 드러납니다.
fib_naive <- function(n) {
if (n <= 2) return(1)
fib_naive(n - 1) + fib_naive(n - 2)
}
sapply(1:10, fib_naive)
#> [1] 1 1 2 3 5 8 13 21 34 55
fib_naive(n)을 계산하려면 fib_naive(n-1)과 fib_naive(n-2)를 각각 다시 처음부터 계산하고, 그 과정에서 fib_naive(n-3)같은 작은 값들이 여러 경로를 통해 똑같이 반복 계산됩니다. n이 커질수록 이 중복이 기하급수적으로 늘어나 계산 시간이 급격히 길어집니다.
이 문제는 한 번 계산한 값을 어딘가에 저장해 두었다가, 같은 계산이 다시 필요할 때는 재계산하지 않고 저장된 값을 바로 꺼내 쓴다는 아이디어로 해결할 수 있으며, 이를 메모이제이션(memoization) 이라고 합니다. 16.3절의 클로저와 <<-를 조합하면 Base R만으로도 간단히 구현할 수 있습니다.
make_fib_memo <- function() {
cache <- c(1, 1) # 1항, 2항은 이미 알고 있는 값으로 미리 채워 둠
function(n) {
if (n <= length(cache)) return(cache[n]) # 이미 계산된 값이면 캐시에서 바로 반환
result <- Recall(n - 1) + Recall(n - 2)
cache[n] <<- result # 새로 계산한 값을 캐시에 저장(클로저가 기억하는 cache를 수정)
result
}
}
fib_memo <- make_fib_memo()
sapply(1:10, fib_memo)
#> [1] 1 1 2 3 5 8 13 21 34 55
system.time(fib_memo(28))
#> user system elapsed
#> 0 0 0
같은 결과를 계산하는 데 fib_naive(28)은 약 0.3초가 걸린 반면, 캐시를 활용한 fib_memo(28)은 측정 오차 수준(0초)으로 끝났습니다. 재귀 자체의 명료함은 유지하면서도, 중복 계산이라는 재귀의 대표적인 약점을 실행 환경(environment)에 결과를 저장해 두는 방식으로 보완한 것입니다.
실무에서 광범위한 함수에 메모이제이션을 적용해야 한다면, 매번 이런 클로저를 직접 작성하는 대신
memoise패키지처럼 이 패턴을 함수 하나로 감싸 주는 도구를 쓰는 경우가 많습니다. 다만 이 매뉴얼은 Base R 범위로 한정하므로, 여기서는 Base R만으로 원리를 구현하는 방법까지만 다룹니다.