데이터 다루기 - 걸러내고 묶고 합치기
이 장에서 배우는 것
편의점 판매 기록은 한 번 읽어 들이면 끝나는 자료가 아니다. 수량이 많은 판매만 골라 보고, 매출이 큰 날부터 늘어놓고, 분류별로 합계를 내고, 상품 가격표와 붙여서 금액을 계산하는 일이 반복된다. 엑셀에서는 필터, 정렬, 피벗 테이블, VLOOKUP 이 이 일을 맡는다. R 에서는 base R 함수 몇 개로 같은 일을 하며, 이 장에서는 그 함수들을 하나의 흐름으로 연결한다.
subset()과 대괄호로 조건에 맞는 행과 필요한 열만 골라낸다.order()로 여러 기준을 겹쳐 행을 정렬한다.merge()로 두 표를 키(key) 열 기준으로 합치고, 짝이 없는 행이 어떻게 되는지 확인한다.- 파생 열을 만들고
aggregate()로 그룹별 합계를 낸다. - 같은 작업을 tidyverse 의 dplyr 로 쓰면 어떤 함수에 대응하는지 표로 읽는다.
문제 상황
동네 편의점 사장님이 3월 첫 주의 판매 기록을 넘겨주었다. 기록에는 날짜, 상품 이름, 수량만 있고 금액과 분류가 없다. 가격과 분류는 따로 관리하는 상품표에 들어 있다. 사장님이 알고 싶은 것은 세 가지다.
- 한 번에 3개 이상 팔린 판매는 무엇인가.
- 분류(음료, 식품, 과자)별 매출은 얼마인가.
- 매출이 가장 큰 날은 언제인가.
금액을 구하려면 판매 기록과 상품표를 먼저 붙여야 한다. 붙이다 보면 상품표에 없는 상품이 섞여 있는 경우가 생긴다. 이런 행이 어디서 조용히 사라지는지 모르면 합계가 실제와 달라진다. 이 장의 예제는 일부러 상품표에 없는 상품 하나(battery)를 판매 기록에 넣어 두고 이 문제를 직접 확인한다.
이 장의 예제에서는 상품 이름과 분류를 영어 단어로 적는다. 한글이 섞인 표는 콘솔에서 열 폭이 어긋나 보일 수 있어서, 출력을 정확히 비교하기 위해 고른 선택이다.
행을 고르고 정렬하기
subset 으로 행과 열 고르기
데이터 프레임에서 조건에 맞는 행을 고르는 방법은 두 가지다. 대괄호를 쓰는 방법은 sales[sales$qty >= 3, ] 처럼 조건을 쉼표 앞에 쓴다. subset() 은 subset(sales, qty >= 3) 처럼 열 이름을 데이터 프레임 이름 없이 바로 쓸 수 있어서 읽기 쉽다. 세 번째 인자 select 로 남길 열도 함께 지정한다.
둘의 차이는 결측값(NA)을 만났을 때 드러난다. 조건이 NA 가 되는 행을 subset() 은 버리지만 대괄호는 NA 로 채운 행을 돌려준다. 이 내용은 뒤의 "실무에서 자주 틀리는 것"에서 다시 본다.
order 로 순서 정하기
order() 는 행을 정렬한 결과가 아니라 정렬했을 때의 행 번호를 돌려준다. 그 번호를 대괄호의 행 자리에 넣어야 실제 정렬이 된다. 수량이 많은 순서라면 숫자 앞에 마이너스를 붙여 order(-sales$qty) 로 쓴다. 기준을 쉼표로 이어 쓰면 앞 기준이 같은 행끼리 뒤 기준으로 순서를 정한다. 엑셀의 "정렬 기준 추가"와 같은 동작이다.
마이너스는 숫자에만 쓸 수 있다. 문자열 열을 내림차순으로 정렬하려면 order(x, decreasing = TRUE) 를 쓴다.
합치기와 파생 열
merge 로 두 표 붙이기
merge(x, y, by = "item") 은 두 표에서 item 값이 같은 행끼리 이어 붙인다. 엑셀의 VLOOKUP 이 한쪽 표에서 값을 가져오는 방식이라면, merge() 는 두 표를 양쪽에서 맞춰 하나의 표로 만든다. 두 표의 키 열 이름이 다르면 by.x 와 by.y 로 각각 지정한다.
기본값은 양쪽에 모두 있는 키만 남긴다. 짝이 없는 행을 어떻게 다룰지는 all 계열 인자가 정한다. 데이터베이스에서 쓰는 조인(join) 이름과 함께 정리하면 다음과 같다.
| 인자 | 조인 이름 | 결과 |
|---|---|---|
| 기본값(all = FALSE) | 내부 조인 | 양쪽에 모두 있는 키만 남는다 |
| all.x = TRUE | 왼쪽 조인 | 첫 번째 표의 행은 모두 남고, 짝이 없으면 두 번째 표의 열이 NA 가 된다 |
| all.y = TRUE | 오른쪽 조인 | 두 번째 표의 행이 모두 남는다 |
| all = TRUE | 완전 조인 | 양쪽 행이 모두 남는다 |
왼쪽 조인을 쓸 때는 결과를 곧바로 계산에 넣지 말고, 짝이 없는 행이 몇 개인지 먼저 세어 본다. 그 행의 price 가 NA 이므로 금액도 NA 가 된다.
또 하나 조심할 점이 있다. 키가 한쪽 표에서 중복되면 그 키를 가진 행이 중복 개수만큼 늘어난다. 상품표에서는 상품 하나가 한 행이어야 하므로, 붙이기 전에 anyDuplicated() 로 확인하는 습관이 좋다. 자세한 인자는 merge 도움말에서 확인할 수 있다.
파생 열 만들기
기존 열로 계산한 값을 새 열로 붙이는 일은 데이터프레임$새열 <- 계산식 한 줄이다. 열 전체가 한 번에 계산되므로 반복문이 필요 없다. 이 방식은 앞에서 다룬 벡터화가 데이터 프레임에서 그대로 쓰이는 모습이다. 이 장에서는 두 가지 파생 열을 만든다.
- 금액:
qty * price. 두 열의 같은 위치끼리 곱한다. - 등급:
cut()으로 숫자를 구간으로 나눠 이름을 붙인다. 결과는 범주형인 factor 다.
cut() 의 구간은 기본적으로 오른쪽이 닫힌다. breaks = c(0, 2000, 5000, Inf) 는 (0, 2000], (2000, 5000], (5000, Inf] 세 구간이므로, 정확히 5000 인 금액은 가운데 구간에 들어간다. 경계값이 어느 쪽에 속하는지는 실행 결과로 확인해 두는 것이 안전하다.
묶어서 집계하기와 dplyr 대응표
aggregate 의 formula 형식
aggregate(amount ~ category, data = merged, FUN = sum) 은 "category 별로 amount 에 sum 을 적용하라"로 읽는다. 물결표(~) 왼쪽이 계산할 열, 오른쪽이 묶는 기준이다. 열이 여럿이면 cbind(qty, amount) 처럼 감싸서 왼쪽에 적는다. 엑셀 피벗 테이블에서 "행" 자리에 category 를, "값" 자리에 amount 를 끌어다 놓은 것과 같다.
결과는 묶는 기준 열과 계산 결과 열을 가진 새 데이터 프레임이며, 기준 값 순서(문자열은 알파벳순)로 정렬되어 나온다. 이 결과를 다시 order() 에 넣으면 매출 순위표가 된다. 인자 전체는 aggregate 도움말에 있다.
이 장에서는 외부 패키지를 설치하지 않지만, 다른 자료에서 tidyverse 의 dplyr 로 쓴 코드를 만날 수 있다. 같은 작업을 다르게 쓴 것뿐이므로 대응 관계만 알아 두면 읽을 수 있다.
| 작업 | base R | dplyr |
|---|---|---|
| 행 걸러내기 | subset(), 대괄호 | filter() |
| 열 고르기 | subset(select = ), 대괄호 | select() |
| 정렬 | x[order(...), ] | arrange() |
| 파생 열 | x$새열 <- 식, transform() | mutate() |
| 그룹별 요약 | aggregate() | group_by() 와 summarise() |
| 표 합치기 | merge(all.x = TRUE) | left_join() |
완성 코드
파일 이름은 main.R 이다. CSV 는 코드가 임시 파일에 직접 써서 읽으므로 따로 준비할 파일이 없다.
# main.R - 편의점 판매 기록 걸러내고, 묶고, 합치기
# 실행: Rscript main.R
# 1. 코드가 직접 CSV 를 써 두고 읽는다
csv_path <- tempfile(fileext = ".csv")
writeLines(c(
"date,item,qty",
"2025-03-01,coffee,2",
"2025-03-01,ramen,1",
"2025-03-02,milk,3",
"2025-03-02,gimbap,2",
"2025-03-03,coffee,1",
"2025-03-03,chips,2",
"2025-03-04,ramen,4",
"2025-03-04,battery,1",
"2025-03-05,gum,5",
"2025-03-05,coffee,3"
), csv_path)
sales <- read.csv(csv_path)
products <- data.frame(
item = c("coffee", "milk", "ramen", "gimbap", "gum", "chips"),
category = c("drink", "drink", "food", "food", "snack", "snack"),
price = c(1500, 1200, 1300, 2500, 1000, 1800)
)
cat(sprintf("판매 기록 %d행, 상품 마스터 %d행\n", nrow(sales), nrow(products)))
# 2. 걸러내기
many <- subset(sales, qty >= 3, select = c(item, qty))
cat("\n[수량 3 이상]\n")
print(many)
# 3. 정렬
ranked <- sales[order(-sales$qty, sales$item), ]
cat("\n[수량 많은 순 상위 3건]\n")
print(head(ranked, 3))
# 4. 합치기
merged <- merge(sales, products, by = "item")
cat(sprintf("\n일치한 행 %d / 전체 %d\n", nrow(merged), nrow(sales)))
cat(sprintf("마스터에 없는 상품: %s\n",
paste(setdiff(sales$item, products$item), collapse = ", ")))
cat(sprintf("마스터 상품 중복 여부: %s\n", anyDuplicated(products$item) > 0))
# 5. 파생 열
merged$amount <- merged$qty * merged$price
merged$grade <- cut(merged$amount,
breaks = c(0, 2000, 5000, Inf),
labels = c("low", "mid", "high"))
cat("\n[금액 등급별 건수]\n")
print(table(merged$grade))
# 6. 묶어서 집계
by_category <- aggregate(cbind(qty, amount) ~ category, data = merged, FUN = sum)
cat("\n[분류별 합계]\n")
print(by_category)
daily <- aggregate(amount ~ date, data = merged, FUN = sum)
daily <- daily[order(-daily$amount), ]
cat("\n[매출 많은 날 상위 2일]\n")
print(head(daily, 2))
cat(sprintf("\n총 매출: %.0f원\n", sum(merged$amount)))
# 7. 왼쪽 기준 합치기와 결측
full <- merge(sales, products, by = "item", all.x = TRUE)
full$amount <- full$qty * full$price
cat(sprintf("\n[all.x = TRUE] 행 %d, amount 결측 %d\n",
nrow(full), sum(is.na(full$amount))))
kept <- aggregate(qty ~ category, data = full, FUN = sum)
cat(sprintf("집계에 반영된 수량 %d / 전체 수량 %d\n", sum(kept$qty), sum(full$qty)))
줄별 해설
1. 데이터 준비
tempfile(fileext = ".csv") 는 실행할 때마다 다른 임시 파일 경로를 만든다. 그 경로에 writeLines() 로 CSV 텍스트를 쓰고 read.csv() 로 읽는다. 앞 장에서 다룬 읽기와 같은 동작이다. 날짜 열은 문자열로 읽히고, 날짜 자료형으로 바꾸는 방법은 다음 장에서 다룬다. 경로는 출력하지 않으므로 실행 결과는 항상 같다. products 는 코드 안에서 data.frame() 으로 만든다.
sprintf() 의 %d 는 정수, %s 는 문자열, %.0f 는 소수점 없는 실수 자리다. nrow() 는 정수를 돌려주므로 %d 에 맞는다.
2. 걸러내기
subset(sales, qty >= 3, select = c(item, qty)) 는 수량이 3 이상인 행만 남기고 열은 item 과 qty 만 둔다. 출력의 왼쪽 번호는 원래 표의 행 번호를 그대로 유지한다. 3, 7, 9, 10 번 행이 남았다는 뜻이다.
3. 정렬
order(-sales$qty, sales$item) 은 수량 내림차순으로 정하고, 수량이 같으면 상품 이름 알파벳순으로 정한다. 그 번호를 sales[ , ] 의 쉼표 앞에 넣어 행을 재배열하고, head(ranked, 3) 으로 앞 세 행만 본다. 수량 3 인 행은 coffee(10번)와 milk(3번)인데, 알파벳순으로 coffee 가 앞선다.
4. 합치기
merge(sales, products, by = "item") 결과는 9행이다. battery 는 products 에 없어 탈락했다. setdiff(a, b) 는 a 에는 있고 b 에는 없는 값을 돌려주므로, 어떤 상품이 탈락했는지 이름으로 확인할 수 있다. anyDuplicated() 는 중복 값이 없으면 0 을 돌려주므로 > 0 비교는 FALSE 가 된다.
5. 파생 열
merged$qty * merged$price 는 9개 값이 한 번에 계산된다. cut() 의 labels 는 구간 이름이다. table() 은 각 등급이 몇 번 나왔는지 센다. 결과가 factor 이므로 low, mid, high 순서로 출력된다. 결과의 첫 줄이 비어 있는 것은 table() 출력의 기본 모양이다.
6. 묶어서 집계
cbind(qty, amount) ~ category 는 두 열을 분류별로 함께 합한다. 날짜별 합계는 aggregate(amount ~ date, ...) 로 구한다. 결과는 날짜 순서라서, 다시 order(-daily$amount) 로 금액 큰 순서로 바꾼다. 출력의 왼쪽 번호 5, 2 는 날짜순 결과에서의 원래 행 번호다.
7. 왼쪽 기준 합치기
all.x = TRUE 로 붙이면 10행이 모두 남고, battery 행의 category 와 price 는 NA 다. qty * price 도 NA 가 되어 is.na() 로 세면 1 이다. 마지막 두 줄이 이 장의 핵심 경고다. aggregate() 의 formula 형식은 사용하는 열에 NA 가 있는 행을 기본으로 제외한다. 그래서 분류별 수량 합은 23 이고 전체 수량은 24 이다. battery 1개가 집계에서 말없이 빠졌다.
실행 결과
$ Rscript main.R
판매 기록 10행, 상품 마스터 6행
[수량 3 이상]
item qty
3 milk 3
7 ramen 4
9 gum 5
10 coffee 3
[수량 많은 순 상위 3건]
date item qty
9 2025-03-05 gum 5
7 2025-03-04 ramen 4
10 2025-03-05 coffee 3
일치한 행 9 / 전체 10
마스터에 없는 상품: battery
마스터 상품 중복 여부: FALSE
[금액 등급별 건수]
low mid high
2 6 1
[분류별 합계]
category qty amount
1 drink 9 12600
2 food 7 11500
3 snack 7 8600
[매출 많은 날 상위 2일]
date amount
5 2025-03-05 9500
2 2025-03-02 8600
총 매출: 32700원
[all.x = TRUE] 행 10, amount 결측 1
집계에 반영된 수량 23 / 전체 수량 24
실무에서 자주 틀리는 것
1. 대괄호 필터에 NA 가 섞이면 이상한 행이 생긴다
틀린 코드:
x <- data.frame(item = c("a", "b", "c"), qty = c(3, NA, 5))
x[x$qty >= 3, ]
x$qty >= 3 의 결과가 TRUE, NA, TRUE 이므로, 대괄호는 NA 자리에 모든 열이 NA 인 행을 만들어 돌려준다. 조건에 맞지 않는 것도 아니고 맞는 것도 아닌 행이 결과에 끼어든다. 고친 코드:
subset(x, qty >= 3)
x[which(x$qty >= 3), ]
subset() 과 which() 는 조건이 TRUE 인 행만 남긴다. NA 행을 남겨야 한다면 그 이유를 코드에 주석으로 적는다.
2. order 뒤의 쉼표를 빼먹는다
틀린 코드:
ranked <- sales[order(-sales$qty)]
쉼표가 없으면 R 은 order() 결과를 열 번호로 해석한다. 열이 3개뿐인데 1부터 10 까지의 번호를 요구하므로 undefined columns selected 오류가 난다. 고친 코드:
ranked <- sales[order(-sales$qty), ]
행을 다루는 자리는 쉼표 앞이다. 쉼표 뒤를 비워 두면 모든 열을 가져온다는 뜻이다.
3. aggregate 가 NA 행을 말없이 버린다
틀린 코드:
full <- merge(sales, products, by = "item", all.x = TRUE)
aggregate(qty ~ category, data = full, FUN = sum)
이 코드는 오류도 경고도 내지 않는다. 그런데 category 가 NA 인 battery 행은 집계에서 빠져, 분류별 수량 합계가 전체 수량보다 작아진다. 고친 코드는 집계 전에 NA 를 세고, 합계를 원래 표와 대조한다.
sum(is.na(full$category))
sum(kept$qty) == sum(full$qty)
첫 줄이 0 이 아니면 짝이 없는 행이 있다는 뜻이다. 그 행을 "미분류" 같은 값으로 채운 뒤 집계하거나, 상품표를 고치는 쪽이 낫다. 결측 처리는 앞 장에서 본 방법과 같은 원리다.
4. 키가 중복된 표를 그대로 merge 한다
틀린 코드:
products_bad <- rbind(products,
data.frame(item = "ramen", category = "food", price = 1350))
nrow(merge(sales, products_bad, by = "item"))
ramen 이 상품표에 두 번 있으면, 판매 기록의 ramen 2건이 각각 두 행으로 늘어나 9행이 아닌 11행이 된다. 합계 매출도 그만큼 부풀려진다. 고친 코드:
anyDuplicated(products_bad$item)
products_ok <- products_bad[!duplicated(products_bad$item), ]
첫 줄은 중복된 위치를 알려 주고, 둘째 줄은 먼저 나온 행만 남긴다. 어느 가격이 맞는지는 코드가 아니라 사람이 정해야 하므로, 중복을 발견하면 원본 표를 고치는 것이 우선이다.
한눈에 보기
| 함수 | 하는 일 | 주의할 점 |
|---|---|---|
| subset(x, 조건, select = ) | 조건에 맞는 행과 지정한 열을 남긴다 | 조건이 NA 인 행은 버린다 |
| x[order(...), ] | 여러 기준으로 행을 정렬한다 | 쉼표를 빼먹으면 열 선택이 된다. 마이너스는 숫자에만 쓴다 |
| merge(x, y, by = ) | 키 열이 같은 행끼리 붙인다 | 기본은 짝이 있는 행만 남고, 키가 중복되면 행이 늘어난다 |
| x$새열 <- 식, cut() | 파생 열을 만들고 숫자를 구간으로 나눈다 | cut 의 구간은 기본적으로 오른쪽이 닫힌다 |
| aggregate(y ~ g, data, FUN) | g 별로 y 에 함수를 적용한다 | NA 가 있는 행은 기본으로 제외된다 |
연습 문제
sales에서 상품이 coffee 인 행만 골라date와qty열만 출력하는 코드를 쓰시오.merged에서 상품(item)별 수량 합계를aggregate()로 구하고, 합계가 큰 순서로 정렬하시오. 합계가 같으면 상품 이름 알파벳순으로 한다. 정렬된 결과의 상품 순서는 어떻게 되는가.merged에amount가 4000 이상이면 "big", 아니면 "small" 인size열을 추가하고, big 인 판매가 몇 건인지 구하시오.- 상품표에 milk 가 가격 1200 과 1300 으로 두 번 들어 있다고 하자. 이 표와
sales를item기준으로 기본merge()하면 몇 행이 되는가. 이유도 설명하시오.
정답과 해설
-
subset(sales, item == "coffee", select = c(date, qty))3개 행(1, 5, 10번)이 나오며 날짜와 수량은 각각 2025-03-01 과 2, 2025-03-03 과 1, 2025-03-05 와 3 이다. 같음 비교는 등호 두 개(
==)이며, 등호 하나(=)는 인자 지정이라 조건이 되지 않는다. -
by_item <- aggregate(qty ~ item, data = merged, FUN = sum) by_item[order(-by_item$qty, by_item$item), ]상품별 합계는 coffee 6, gum 5, ramen 5, milk 3, chips 2, gimbap 2 이다. gum 과 ramen, chips 와 gimbap 은 합계가 같아서 알파벳순으로 정해진다. 그래서 순서는 coffee, gum, ramen, milk, chips, gimbap 이다.
-
merged$size <- ifelse(merged$amount >= 4000, "big", "small") sum(merged$size == "big")답은 4건이다. 금액이 4000 이상인 판매는 4500(coffee 3개), 5200(ramen 4개), 5000(gimbap 2개), 5000(gum 5개)이다.
ifelse()는 조건을 벡터 전체에 적용해 값을 고른다. TRUE 는 1 로 계산되므로sum()으로 개수를 셀 수 있다. -
10행이 된다. 판매 기록의 milk 는 1건인데, 상품표에서 milk 키가 2행이라 그 1건이 2행으로 늘어난다. 원래 9행에서 1행이 늘어난 것이다. 이 경우 milk 의 금액은 3600 과 3900 두 값으로 계산된다. 합치기 전에
anyDuplicated(products$item)로 확인하면 미리 발견할 수 있다.