R · 기본
데이터로 시작하는 R
벡터화와 apply 계열 - 반복문을 줄이기
벡터화 연산이 빠른 이유, sapply·lapply·vapply·tapply, Map·Reduce
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 조건문, 반복문, 함수를 만들어 판매 기록을 한 줄씩 훑는 코드를 썼다. 그 방식은 정확하지만 코드가 길어지고, 자료가 커지면 느려진다. 이 장은 R 이 원래 잘하는 방식인 벡터화(vectorization)로 같은 일을 짧게 쓰는 법을 다룬다. 이어서 상품별·분류별·날짜별 집계처럼 "나누어 계산하고 다시 모으는" 작업을 apply 계열 함수로 처리한다.
- 벡터화 연산이 반복문보다 짧고 빠른 이유를 설명할 수 있다.
- lapply, sapply, vapply 가 돌려주는 결과의 모양을 구별하고 vapply 로 결과 형태를 고정할 수 있다.
- tapply 로 분류나 날짜별 합계를 한 줄에 구할 수 있다.
- Map 과 Reduce 로 여러 벡터를 짝지어 처리하고 값을 차례로 누적할 수 있다.
문제 상황
동네 편의점 사장님이 3일치 판매 기록을 주며 세 가지를 묻는다. 상품별 매출 합계, 음료와 식품의 매출 합계, 날짜별 매출이다. 엑셀이라면 피벗 테이블을 만들거나 SUMIF 를 채워 넣으면 된다. 앞 장까지 배운 방법으로는 상품 이름마다 빈 변수를 만들고, 12행을 for 문으로 돌면서 if 로 상품을 확인해 더하게 된다. 같은 코드를 분류용과 날짜용으로 두 번 더 쓴다.
기록이 12행이면 견딜 만하다. 하지만 1년치 판매 기록이 수만 행이고 집계 기준이 늘어나면 코드는 반복문 위에 반복문이 쌓인다. 실수할 자리도 늘어난다. 이 장의 목표는 "행을 하나씩 도는 코드"를 "벡터 전체에 한 번에 적용하는 코드"로 바꾸는 것이다.
벡터화: 벡터 전체를 한 번에 계산한다
원소별 연산은 반복문 없이 된다
R 의 산술·비교 연산자는 벡터를 받으면 같은 위치의 원소끼리 계산한다. 수량 벡터 qty 와 단가 벡터 price 가 있으면 qty * price 한 줄이 12건의 거래액을 모두 계산한다. 이렇게 한 번의 호출로 벡터 전체를 처리하도록 쓰는 방식을 벡터화라고 한다. 엑셀에서 한 칸에 수식을 쓰고 아래로 끌어내리는 일을, R 에서는 수식을 한 번 쓰는 것으로 대신한다고 보면 된다.
비교 연산도 마찬가지다. amount >= 10000 은 TRUE 와 FALSE 로 이루어진 벡터를 돌려준다. TRUE 는 1, FALSE 는 0 으로 계산되므로 sum() 에 넣으면 조건을 만족하는 건수가 나온다. 조건에 따라 값을 고르는 일에는 ifelse(조건, 참일 때, 거짓일 때) 를 쓴다. 이 함수는 조건 벡터의 각 원소를 보고 결과 벡터를 만든다.
왜 빠른가
R 은 코드를 한 줄씩 해석하며 실행하는 언어다. for 문 안에서 qty[i] * price[i] 를 계산하면 매 차례 인덱스를 꺼내고, 자료형을 확인하고, 곱셈 함수를 호출하고, 결과를 벡터의 i 번째 칸에 넣는 일이 반복된다. 이 부가 작업이 계산 자체보다 오래 걸린다.
벡터화된 qty * price 는 해석기가 곱셈 함수를 한 번만 호출한다. 그 안에서 원소를 도는 반복은 미리 컴파일된 C 코드가 맡는다. 자료형 확인도 벡터 전체에 대해 한 번만 한다. 그래서 원소가 수만 개를 넘어가면 차이가 눈에 띈다. 직접 재보고 싶다면 원소 100만 개짜리 벡터에 두 방식을 적용하고 system.time() 으로 비교해 보면 된다. 걸리는 시간은 컴퓨터마다 다르므로 이 책의 완성 코드에는 넣지 않았다.
정직하게 짚어 둘 점이 있다. for 문이 항상 느린 것은 아니다. 결과를 담을 벡터를 미리 numeric(n) 으로 만들어 두고 칸을 채우면 적당한 크기에서는 충분히 빠르다. 반대로 result <- c(result, 새값) 처럼 반복마다 벡터를 이어 붙이면 매번 벡터를 새로 복사하므로 크기가 커질수록 급격히 느려진다. 벡터화의 가장 큰 이득은 속도보다 코드가 짧고 의도가 바로 읽힌다는 점이다.
길이가 다른 벡터를 계산할 때
두 벡터의 길이가 다르면 R 은 짧은 쪽을 처음부터 되풀이해 맞춘다(재활용 규칙, recycling). 길이 1인 값과 벡터의 계산은 이 규칙 덕분에 자연스럽다. amount * 1.1 이 그런 예다. 그러나 길이가 배수 관계가 아니면 R 이 경고를 내므로, 경고가 보이면 두 벡터의 길이가 의도한 대로인지 먼저 확인해야 한다.
apply 계열: 나누어 계산하고 모은다
벡터화로 풀리지 않는 일이 있다. "상품마다 합계를 구한다"는 상품별로 자료를 나눈 뒤 그룹마다 sum 을 적용해야 한다. 이때 쓰는 것이 apply 계열 함수다. 함수를 값처럼 인자로 넘길 수 있다는 점(앞 장에서 만든 함수도 마찬가지다)을 이용해, "각 조각에 이 함수를 적용하라"고 지시한다.
lapply, sapply, vapply
세 함수는 모두 "벡터나 리스트의 각 원소에 함수를 적용한다"는 같은 일을 하고, 결과를 돌려주는 방식만 다르다.
lapply(X, FUN)은 항상 리스트를 돌려준다. 원소마다 결과의 길이나 종류가 달라도 안전하다.sapply(X, FUN)은 결과를 단순하게 만들어 준다. 결과가 모두 길이 1이면 벡터, 길이가 같은 2 이상이면 행렬, 그 외에는 리스트다. 편리하지만 어떤 모양이 나올지 입력에 따라 달라진다.vapply(X, FUN, FUN.VALUE)는 세 번째 인자로 "결과 하나의 형태"를 미리 적는다. 예를 들어numeric(1)은 "숫자 하나"라는 뜻이다. 형태가 다르면 조용히 넘어가지 않고 오류를 내므로 프로그램 안에서 쓰기에 가장 안전하다.
이름이 붙은 리스트를 넣으면 결과에도 같은 이름이 붙는다. 그래서 split(값, 기준) 으로 상품별 리스트를 만든 뒤 sapply(리스트, sum) 을 하면 상품 이름이 붙은 합계 벡터가 나온다. 이 장의 코드에서 상품과 분류는 등장 순서를 그대로 쓰려고 factor 의 levels 를 직접 지정했다. 그렇게 하면 실행 환경의 정렬 규칙과 상관없이 결과 순서가 같다.
tapply: 기준 열로 나누어 바로 계산한다
tapply(값, 기준, 함수) 는 위의 split 과 sapply 를 한 번에 하는 함수다. 값 벡터를 기준 벡터의 값별로 나누고, 그룹마다 함수를 적용해 이름 붙은 결과를 돌려준다. 엑셀의 SUMIF 나 피벗 테이블의 "행 기준 합계"와 같은 일이다. 결과의 이름 순서는 기준이 factor 일 때 levels 순서를 따른다.
| 함수 | 돌려주는 것 | 결과 모양이 고정되는가 | 언제 쓰는가 |
|---|---|---|---|
| lapply | 리스트 | 예 | 결과 길이가 제각각일 때 |
| sapply | 벡터, 행렬 또는 리스트 | 아니오 | 콘솔에서 빠르게 확인할 때 |
| vapply | 지정한 형태의 벡터나 행렬 | 예 | 스크립트와 함수 안 |
| tapply | 그룹 이름이 붙은 배열 | 기준이 factor 면 예 | 분류별 집계 |
Map 과 Reduce
Map(f, a, b) 는 여러 벡터를 같은 위치끼리 짝지어 함수에 넘긴다. f(a[1], b[1]), f(a[2], b[2]) 와 같은 식이며 결과는 항상 리스트다. 상품 이름과 합계를 짝지어 "생수: 15,000원" 같은 문장을 만드는 데 쓸 수 있다. 다만 곱셈처럼 이미 벡터화된 연산에는 Map 이 필요 없다. Map 은 paste 와 format 을 섞은 문장 만들기처럼 한 쌍씩 처리해야 하는 함수를 붙일 때 어울린다.
Reduce(f, x) 는 벡터를 왼쪽부터 하나씩 접어 값 하나로 만든다. Reduce(`+`, c(1, 2, 3)) 은 (1 + 2) + 3 을 계산한다. accumulate = TRUE 를 주면 중간 결과를 모두 돌려주므로 "일별 누적 매출"을 구할 수 있다. 이 예에서 `+` 처럼 백틱으로 감싼 이름은 연산자를 함수로 넘기는 표기다.
tidyverse 를 아는 독자를 위해 대응 관계를 표로 적는다. 이 책에서는 외부 패키지를 설치하지 않으므로 표는 참고용이다.
| base R | tidyverse | 비고 |
|---|---|---|
| lapply(x, f) | purrr::map(x, f) | 둘 다 리스트 |
| vapply(x, f, numeric(1)) | purrr::map_dbl(x, f) | 결과 형태가 고정 |
| Map(f, a, b) | purrr::map2(a, b, f) | 인자 순서가 다르다 |
| Reduce(f, x) | purrr::reduce(x, f) | 인자 순서가 다르다 |
| tapply(v, g, sum) | dplyr 의 group_by 와 summarise | 데이터 프레임 결과 |
각 함수의 세부 인자는 콘솔에서 ?lapply, ?tapply, ?Reduce 로 볼 수 있고, 온라인에서는 lapply 도움말 에서 확인할 수 있다.
완성 코드
아래 파일 하나가 전부다. 데이터는 코드 안에서 만들고, 난수는 쓰지 않으므로 실행할 때마다 출력이 같다.
main.R
# main.R - 벡터화와 apply 계열
# 실행: Rscript main.R
# 1. 판매 기록 만들기 (3일 x 상품 4개)
sales <- data.frame(
date = rep(c("2026-03-01", "2026-03-02", "2026-03-03"), each = 4),
item = rep(c("생수", "삼각김밥", "커피", "라면"), times = 3),
category = rep(c("음료", "식품", "음료", "식품"), times = 3),
qty = c(5, 8, 6, 3, 4, 9, 7, 2, 6, 7, 5, 4),
price = rep(c(1000, 1500, 2000, 1200), times = 3),
stringsAsFactors = FALSE
)
sales$item <- factor(sales$item, levels = c("생수", "삼각김밥", "커피", "라면"))
sales$category <- factor(sales$category, levels = c("음료", "식품"))
# 이름 붙은 결과를 한 줄씩 보여 주는 도우미 함수
print_named <- function(x, title) {
cat(title, "\n", sep = "")
cat(paste0(" ", names(x), " = ", x, "\n"), sep = "")
}
# 2. 반복문과 벡터화 비교
amount_loop <- numeric(nrow(sales))
for (i in seq_len(nrow(sales))) {
amount_loop[i] <- sales$qty[i] * sales$price[i]
}
sales$amount <- sales$qty * sales$price
cat(sprintf("반복문과 벡터화 결과가 같은가: %s\n",
identical(amount_loop, sales$amount)))
big <- sales$amount >= 10000
cat(sprintf("1만원 이상 판매 건수: %d\n", sum(big)))
sales$size <- ifelse(big, "큰거래", "작은거래")
cat(sprintf("작은 거래 건수: %d\n", sum(sales$size == "작은거래")))
# 3. lapply, sapply, vapply
by_item <- split(sales$amount, sales$item)
totals <- sapply(by_item, sum)
print_named(totals, "[sapply: 상품별 합계]")
ranges <- lapply(by_item, range)
cat(sprintf("lapply 결과: %s, 길이 %d\n", class(ranges), length(ranges)))
m <- sapply(by_item, range)
cat(sprintf("sapply(range) 결과: %s, %s\n",
class(m)[1], paste(dim(m), collapse = " x ")))
max_by_item <- vapply(by_item, max, numeric(1))
print_named(max_by_item, "[vapply: 상품별 최대 거래액]")
check <- tryCatch(
vapply(by_item, range, numeric(1)),
error = function(e) "길이가 다르다는 오류를 vapply 가 잡았다"
)
cat(check, "\n", sep = "")
# 4. tapply
by_category <- tapply(sales$amount, sales$category, sum)
print_named(by_category, "[tapply: 분류별 합계]")
by_date <- tapply(sales$amount, sales$date, sum)
print_named(by_date, "[tapply: 날짜별 합계]")
# 5. Map 과 Reduce
labels <- Map(
function(name, total) sprintf("%s: %s원", name, format(total, big.mark = ",")),
names(totals), totals
)
cat("[Map: 표시용 문장]\n")
cat(paste0(unlist(labels, use.names = FALSE), "\n"), sep = "")
running <- Reduce(`+`, as.numeric(by_date), accumulate = TRUE)
cat(sprintf("일별 누적 매출: %s\n", paste(running, collapse = ", ")))
cat(sprintf("마지막 누적값과 전체 합계가 같은가: %s\n",
running[length(running)] == sum(sales$amount)))
줄별 해설
데이터 프레임 만들기. rep(..., each = 4) 는 각 값을 4번씩 연달아 되풀이하고, times = 3 은 벡터 전체를 3번 되풀이한다. 그래서 날짜는 4행씩 묶이고 상품은 한 날짜 안에서 순서대로 나열된다. qty 는 날짜별로 4개씩 세 묶음을 적었다. 이어지는 두 줄은 item 과 category 를 factor 로 바꾸며 levels 를 직접 지정한다. 나중에 split 과 tapply 가 이 순서대로 그룹을 내놓는다.
print_named. 이름 붙은 벡터를 그대로 출력하면 한글 이름의 열 정렬이 환경에 따라 달라질 수 있다. 그래서 paste0 로 "이름 = 값" 문장을 벡터째로 만들고 cat 으로 이어 출력한다. 이 함수 안에도 반복문이 없다. paste0 가 벡터화되어 있기 때문이다. tapply 의 결과는 1차원 배열이지만 names() 가 그룹 이름을 돌려준다.
반복문과 벡터화. numeric(nrow(sales)) 는 결과를 담을 빈 벡터를 미리 만든다. for 문은 그 칸을 하나씩 채운다. sales$qty * sales$price 는 한 줄로 같은 결과를 만들고, identical 이 두 결과가 완전히 같은지 확인한다. sum(big) 은 TRUE 의 개수를 센다. %d 는 정수를 넣는 자리표시자인데 sum 이 논리 벡터에서 정수를 돌려주므로 맞는다.
lapply, sapply, vapply. split(sales$amount, sales$item) 은 거래액을 상품별로 나눈 리스트를 만든다. sapply(by_item, sum) 은 각 조각의 합을 구해 이름 붙은 벡터로 돌려준다. lapply(by_item, range) 는 각 조각의 최솟값과 최댓값을 리스트로 돌려주고, 같은 함수를 sapply 에 넣으면 길이 2인 결과 4개가 2행 4열 행렬로 합쳐진다. class(m) 은 값이 두 개("matrix", "array")라서 첫 번째만 골랐다. vapply(by_item, max, numeric(1)) 은 결과가 숫자 하나여야 한다고 못박는다. 같은 형태로 range 를 쓰면 결과가 길이 2이므로 오류가 나고, tryCatch 가 그 오류를 붙잡아 준비한 문장을 돌려준다.
tapply. tapply(sales$amount, sales$category, sum) 은 분류별 합계를 낸다. 날짜는 문자열 그대로 기준으로 넘겼다. 문자열은 내부적으로 factor 로 바뀌며, 이 날짜 표기는 글자 순서가 곧 날짜 순서다.
Map 과 Reduce. Map 은 상품 이름과 합계를 짝지어 함수에 넘기고, 결과 리스트를 unlist 로 문자 벡터로 풀었다. use.names = FALSE 는 이름표를 떼어 출력에 이름이 섞이지 않게 한다. format(total, big.mark = ",") 는 천 단위 쉼표를 넣는다. Reduce(`+`, ..., accumulate = TRUE) 는 날짜별 합계를 앞에서부터 더해 누적값 세 개를 만들고, 마지막 값이 전체 합계와 같은지 마지막 줄에서 확인한다. as.numeric 으로 tapply 결과의 배열 속성을 벗겨 일반 벡터로 만든 뒤 넘겼다.
실행 결과
$ Rscript main.R
반복문과 벡터화 결과가 같은가: TRUE
1만원 이상 판매 건수: 6
작은 거래 건수: 6
[sapply: 상품별 합계]
생수 = 15000
삼각김밥 = 36000
커피 = 36000
라면 = 10800
lapply 결과: list, 길이 4
sapply(range) 결과: matrix, 2 x 4
[vapply: 상품별 최대 거래액]
생수 = 6000
삼각김밥 = 13500
커피 = 14000
라면 = 4800
길이가 다르다는 오류를 vapply 가 잡았다
[tapply: 분류별 합계]
음료 = 51000
식품 = 46800
[tapply: 날짜별 합계]
2026-03-01 = 32600
2026-03-02 = 33900
2026-03-03 = 31300
[Map: 표시용 문장]
생수: 15,000원
삼각김밥: 36,000원
커피: 36,000원
라면: 10,800원
일별 누적 매출: 32600, 66500, 97800
마지막 누적값과 전체 합계가 같은가: TRUE
상품별 합계를 다 더하면 97,800원이고 분류별 합계도, 날짜별 합계도 같은 값이 된다. 서로 다른 방식으로 나눠도 총합이 같아야 하므로 집계 결과를 검산하는 좋은 방법이다.
실무에서 자주 틀리는 것
if 에 벡터 조건을 넣는다
if 는 TRUE 또는 FALSE 하나만 받는다. 벡터를 넣으면 R 4.2 이후로는 오류가 난다. 원소마다 판단하려면 ifelse 를 쓴다.
# 틀린 코드
qty <- c(5, 8, 6, 3)
if (qty > 5) "많이 팔림" else "보통" # 오류: 조건의 길이가 1이 아니다
# 고친 코드
ifelse(qty > 5, "많이 팔림", "보통")
sapply 결과의 모양을 믿는다
입력이 비어 있으면 sapply 는 벡터가 아니라 빈 리스트를 돌려준다. 이 결과를 sum 에 넣으면 오류가 난다. 자료가 비는 날이 있는 프로그램에서는 vapply 로 형태를 고정한다.
# 틀린 코드
empty <- character(0)
sum(sapply(empty, nchar)) # 오류: 리스트는 sum 할 수 없다
# 고친 코드
sum(vapply(empty, nchar, integer(1))) # 0
결측값이 있는데 na.rm 을 빠뜨린다
그룹 하나에 NA 가 있으면 그 그룹의 sum 은 NA 다. apply 계열 함수는 함수 뒤에 적은 인자를 그대로 FUN 에 전달하므로 na.rm = TRUE 를 뒤에 붙이면 된다. 단, NA 를 빼고 계산해도 되는지는 자료의 사정에 따라 정할 일이다. 결측값을 읽어 들이는 방법은 다음 장에서 다룬다.
# 틀린 코드
x <- list(a = c(1, NA, 3), b = c(4, 5))
sapply(x, sum) # a 는 NA, b 는 9
# 고친 코드
sapply(x, sum, na.rm = TRUE) # a 는 4, b 는 9
Map 의 결과를 벡터처럼 쓴다
Map 은 언제나 리스트를 돌려준다. 리스트에 sum 을 쓰면 오류가 난다. 그리고 이 경우에는 애초에 곱셈이 벡터화되어 있어 Map 이 필요 없다.
# 틀린 코드
qty <- c(5, 8, 6, 3)
price <- c(1000, 1500, 2000, 1200)
amounts <- Map(function(q, p) q * p, qty, price)
sum(amounts) # 오류: 리스트는 sum 할 수 없다
# 고친 코드
sum(qty * price) # 벡터화하면 Map 이 필요 없다
sum(unlist(amounts)) # Map 을 꼭 써야 한다면 unlist 로 푼다
한눈에 보기
| 하려는 일 | 쓸 것 | 결과 |
|---|---|---|
| 원소끼리 계산, 비교 | + - * / >= 등 연산자 | 같은 길이의 벡터 |
| 조건에 따라 값 고르기 | ifelse | 같은 길이의 벡터 |
| 조건을 만족하는 개수 | sum(조건) | 정수 하나 |
| 각 원소에 함수 적용, 형태 미정 | lapply | 리스트 |
| 각 원소에 함수 적용, 숫자 하나씩 | vapply(x, f, numeric(1)) | 숫자 벡터 |
| 기준 열별 집계 | tapply | 이름 붙은 배열 |
| 여러 벡터를 짝지어 처리 | Map | 리스트 |
| 앞에서부터 접기, 누적 | Reduce | 값 하나 또는 누적 벡터 |
연습 문제
- 수량 벡터
qty <- c(5, 8, 6, 3, 4, 9, 7, 2, 6, 7, 5, 4)에서 수량이 5 미만이면 "보충", 아니면 "충분"이라고 적은 벡터를 만들고, "보충"인 건수를 세어 보아라. 반복문은 쓰지 않는다. - main.R 의
sales에서 상품별 수량 합계를vapply로 구해 보아라. - 같은 데이터에서 분류별 평균 거래액을
tapply로 구해 보아라. - 일별 매출이
day <- c(32600, 33900, 31300)일 때, 전날 대비 증감을 구하는 코드를 반복문 없이 써 보아라. 결과는 길이 2인 벡터여야 한다.
정답과 해설
1번.
qty <- c(5, 8, 6, 3, 4, 9, 7, 2, 6, 7, 5, 4)
status <- ifelse(qty < 5, "보충", "충분")
sum(status == "보충") # 4
qty < 5 를 만족하는 값은 3, 4, 2, 4 로 네 건이다. 5 는 "미만"에 들지 않는다. status == "보충" 은 논리 벡터이고 sum 이 TRUE 의 수를 센다. 경계값(5)이 어느 쪽에 속하는지 비교 연산자로 정확히 적었는지를 확인하는 문제다.
2번.
vapply(split(sales$qty, sales$item), sum, numeric(1))
# 생수 15, 삼각김밥 24, 커피 18, 라면 9
수량 합계는 15, 24, 18, 9 이고 모두 더하면 66이다. 날짜별로 22개씩 세 번 팔렸으니 66과 일치한다. split 이 factor 의 levels 순서로 리스트를 만들어 주므로 이름 순서도 코드의 levels 와 같다.
3번.
tapply(sales$amount, sales$category, mean)
# 음료 8500, 식품 7800
음료의 합계 51,000원을 6건으로 나누면 8,500원이고, 식품의 합계 46,800원을 6건으로 나누면 7,800원이다. 함수 자리에 sum 대신 mean 만 바꿔 넣으면 된다. 평균이 항상 대표값이 되는 것은 아니라는 점은 기술 통계를 다루는 장에서 살펴본다.
4번.
day <- c(32600, 33900, 31300)
day[-1] - day[-length(day)] # 1300 -2600
day[-1] 은 첫 원소를 뺀 벡터(둘째 날부터)이고, day[-length(day)] 는 마지막 원소를 뺀 벡터(첫째 날부터)다. 길이가 같은 두 벡터를 빼면 원소별 차이가 나온다. 33,900 - 32,600 = 1,300 이고 31,300 - 33,900 = -2,600 이다. 인덱스를 어긋나게 잡아 벡터화 연산으로 바꾸는 방식은 이 장의 핵심 요령이다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.