Devin.KR

벡터화와 apply 계열 - 반복문을 줄이기

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 조건문, 반복문, 함수를 만들어 판매 기록을 한 줄씩 훑는 코드를 썼다. 그 방식은 정확하지만 코드가 길어지고, 자료가 커지면 느려진다. 이 장은 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 순서를 따른다.

tapply 는 값을 분류별로 나누고 각 그룹에 sum 을 적용한 뒤 결과를 하나로 모은다
apply 계열 함수가 돌려주는 결과의 모양
함수돌려주는 것결과 모양이 고정되는가언제 쓰는가
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 함수의 대응
base Rtidyverse비고
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값 하나 또는 누적 벡터

연습 문제

  1. 수량 벡터 qty <- c(5, 8, 6, 3, 4, 9, 7, 2, 6, 7, 5, 4) 에서 수량이 5 미만이면 "보충", 아니면 "충분"이라고 적은 벡터를 만들고, "보충"인 건수를 세어 보아라. 반복문은 쓰지 않는다.
  2. main.R 의 sales 에서 상품별 수량 합계를 vapply 로 구해 보아라.
  3. 같은 데이터에서 분류별 평균 거래액을 tapply 로 구해 보아라.
  4. 일별 매출이 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 이다. 인덱스를 어긋나게 잡아 벡터화 연산으로 바꾸는 방식은 이 장의 핵심 요령이다.

댓글 0

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

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