Devin.KR

R · 기본

데이터로 시작하는 R

리스트·행렬·데이터 프레임

list·matrix, data.frame 만들기, 행·열 선택, str·summary·head

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장까지는 값 하나 또는 같은 종류의 값을 나란히 놓은 벡터를 다뤘다. 그런데 실제 판매 기록은 날짜, 상품 이름, 분류, 수량, 금액처럼 종류가 다른 값이 한 줄에 모여 있다. 엑셀에서는 열마다 서식이 달라도 표 하나로 다룰 수 있다. R 에서 이 역할을 하는 것이 데이터 프레임(data frame)이다. 이 장에서는 데이터 프레임에 이르는 길목에 있는 리스트(list)와 행렬(matrix)을 먼저 보고, 데이터 프레임을 만들고, 원하는 행과 열을 고르고, 내용을 훑어보는 방법까지 익힌다.

  • 종류가 다른 값을 리스트로 묶고, [ ], [[ ]], $ 의 차이를 구분한다.
  • 행렬을 만들고 [행, 열] 형태로 원하는 칸을 고른다.
  • data.frame() 으로 판매 기록 표를 만들고 행과 열을 고른다.
  • str(), summary(), head() 로 표의 생김새와 내용을 빠르게 확인한다.

문제 상황

편의점 사장이 3월 첫 주의 판매 기록을 R 로 옮기려 한다. 지금까지 배운 방법대로 하면 날짜 벡터, 상품 벡터, 수량 벡터, 금액 벡터를 따로 만든다. 처음에는 문제가 없어 보인다. 그런데 금액이 큰 순서로 보려고 금액 벡터만 정렬하면 나머지 벡터와 줄이 어긋난다. 어느 상품이 5,200원이었는지 알 수 없게 된다. 엑셀이라면 표 전체를 선택해 정렬하므로 이런 일이 생기지 않는다.

필요한 것은 "길이가 같은 벡터 여러 개를 한 덩어리로 묶고, 같은 줄에 있는 값은 항상 함께 움직이게 하는 구조"다. 또 가게 이름, 개점 연도, 취급 분류 목록처럼 길이도 종류도 제각각인 정보를 한곳에 모아 두고 싶을 때도 있다. 이 장은 두 요구를 각각 데이터 프레임과 리스트로 해결한다.

리스트와 행렬

데이터 프레임을 이해하려면 먼저 두 가지 구조를 알아야 한다. 리스트는 종류와 길이가 다른 값을 칸마다 담는 상자이고, 행렬은 같은 종류의 값을 직사각형으로 늘어놓은 구조다. 그림 1 이 세 구조의 차이를 한 장으로 보여 준다.

리스트는 칸마다 종류가 달라도 되고, 행렬은 모든 칸이 같은 종류이며, 데이터 프레임은 종류가 다른 열을 같은 길이로 나란히 놓은 구조다.

리스트: 종류가 다른 값을 담는 상자

벡터에 문자와 숫자를 섞어 넣으면 앞 장에서 본 것처럼 모두 문자로 바뀐다. 리스트는 그런 변환 없이 각 칸이 자기 종류를 그대로 유지한다. list() 에 이름을 붙여 값을 넣으면 된다.

store <- list(
  name = "가온 편의점",
  opened = 2019L,
  categories = c("음료", "간편식", "라면", "과자")
)

칸을 꺼내는 방법은 세 가지이고, 결과가 다르다.

  • store$categories 와 store[["categories"]] 는 칸 안에 들어 있는 값 자체를 꺼낸다. 여기서는 문자 벡터가 나온다.
  • store["categories"] 는 그 칸만 남긴 작은 리스트를 돌려준다. 상자에서 물건을 꺼내는 것이 아니라 상자째 옮기는 셈이다.

그래서 꺼낸 값으로 계산하려면 $ 나 [[ ]] 를 써야 한다. 칸을 지우려면 앞 장에서 배운 NULL 을 대입한다. store$opened <- NULL 처럼 쓰면 해당 칸이 사라진다.

행렬: 같은 종류의 직사각형

행렬은 값이 모두 같은 종류인 표다. matrix() 는 벡터를 받아 nrow 만큼의 행으로 접는다. 기본 채우기 순서는 열 방향이다. 첫 열을 위에서 아래로 채운 뒤 다음 열로 넘어간다. 우리가 글을 읽는 순서, 즉 행 방향으로 채우려면 byrow = TRUE 를 준다. 행과 열에 이름을 붙이려면 dimnames 에 이름 벡터 두 개를 리스트로 묶어 넘긴다.

칸은 [행, 열] 로 고른다. 번호 대신 이름을 써도 된다. 행이나 열 자리를 비워 두면 그 방향은 전부라는 뜻이다. rowSums() 는 행마다 합을 구해 주는 함수다. 행렬은 수를 계산하는 데 알맞지만, 문자와 숫자를 한 표에 섞을 수는 없다. 그래서 판매 기록처럼 열마다 종류가 다른 표에는 데이터 프레임을 쓴다.

리스트, 행렬, 데이터 프레임은 담는 값과 모양이 다르다
구조담을 수 있는 값모양만드는 함수
리스트칸마다 종류와 길이가 자유롭다일렬list()
행렬전부 같은 종류행과 열matrix()
데이터 프레임열마다 종류가 다르고 열 길이는 같다행과 열data.frame()

데이터 프레임 만들기

데이터 프레임은 길이가 같은 벡터를 열로 나란히 세운 리스트다. 실제로 typeof() 로 확인하면 "list" 가 나온다. 리스트라서 열 하나를 $ 로 꺼낼 수 있고, 모든 열의 길이가 같다는 조건이 붙어 있어서 표처럼 행 단위로도 다룰 수 있다. 각 열은 벡터이므로 앞 장에서 배운 자료형과 결측값 규칙이 그대로 적용된다.

data.frame() 에 열이름 = 벡터 를 나열하면 된다. R 4 이후로 문자 열은 문자 그대로 저장되고 factor 로 자동 변환되지 않는다. 열 길이가 하나라도 다르면 오류가 난다.

sales <- data.frame(
  date = c("2025-03-03", "2025-03-03"),
  item = c("생수", "삼각김밥"),
  qty  = c(3L, 2L)
)

날짜를 지금은 문자열로 둔다. 날짜 자료형은 문자열과 날짜를 다루는 장에서 따로 배운다. 이 장의 완성 코드에서는 8건의 판매 기록을 이렇게 만든다.

행과 열 고르기, 그리고 살펴보기

[행, 열] 로 고르기

데이터 프레임도 [행, 열] 형태로 고른다. 행렬과 같은 문법이지만 열에는 이름 벡터를 자주 쓴다. 자리를 비우면 그 방향은 전부 선택한다.

sales[2, ] 는 한 행을 데이터 프레임으로, sales[, "qty"] 는 한 열을 벡터로, sales[2, "qty"] 는 값 하나를 돌려준다.
  • sales[2, ] 는 2행 전체다. 결과는 행 하나짜리 데이터 프레임이다.
  • sales[, "qty"] 는 열 하나다. 결과는 벡터로 바뀐다. sales$qty 도 같은 결과다.
  • sales["qty"] 처럼 쉼표 없이 열 이름만 쓰면 결과가 데이터 프레임으로 남는다. 쉼표가 있느냐 없느냐로 결과의 종류가 달라진다.
  • sales[sales$category == "음료", ] 처럼 행 자리에 참과 거짓으로 된 벡터를 넣으면 참인 행만 남는다. 조건식이 벡터를 돌려주는 방식은 앞 장에서 본 벡터 비교와 같다.
  • subset(sales, qty >= 4, select = c(item, qty)) 는 같은 일을 열 이름만으로 쓰는 편한 함수다. 대화형으로 훑어볼 때 좋고, 스크립트가 길어지면 [ ] 형태가 예측하기 쉽다.

조건 벡터에 NA 가 섞여 있으면 [ ] 는 값이 전부 NA 인 빈 행을 만들어 낸다. 비교 결과가 NA 인 행을 어떻게 처리할지는 결측값을 다룬 앞 장의 규칙을 따라 먼저 정해 두어야 한다.

str, summary, head 로 살펴보기

표를 처음 받으면 내용을 전부 출력하기보다 세 함수를 먼저 쓴다.

  • str() 은 행과 열의 개수, 열 이름, 열마다의 자료형과 앞부분 값을 한 줄씩 보여 준다. 숫자로 읽혀야 할 열이 문자로 들어와 있는지 여기서 알 수 있다.
  • summary() 는 열의 성격에 맞는 요약을 낸다. 숫자 열이라면 최솟값, 사분위수, 중앙값, 평균, 최댓값이 나온다. 데이터 프레임 전체를 넣으면 열마다 이 요약이 나란히 붙는다. 이 장에서는 열 하나에 적용해서 읽기 쉬운 출력을 확인한다. 각 통계의 의미는 기술 통계를 다루는 장에서 자세히 본다.
  • head(x, n) 은 앞의 n 행만 보여 준다. 기본값은 6행이다. 뒤쪽은 tail() 로 본다.

새 열은 sales$unit_price <- sales$amount / sales$qty 처럼 없는 이름에 대입하면 만들어진다. 엑셀에서 옆 칸에 수식을 채우는 일과 같다. 다만 R 에서는 수식이 아니라 계산된 값이 저장된다.

완성 코드

아래 코드를 main.R 하나로 저장한다. 외부 패키지가 필요 없고 데이터는 코드 안에서 만든다. 난수를 쓰지 않으므로 실행할 때마다 출력이 같다. 터미널의 문자 인코딩은 UTF-8 이어야 한국어가 깨지지 않는다.

# main.R - 리스트, 행렬, 데이터 프레임

cat("== 1. 리스트 ==\n")
store <- list(
  name = "가온 편의점",
  opened = 2019L,
  categories = c("음료", "간편식", "라면", "과자")
)
str(store)
cat("분류 수: ", length(store$categories), "\n", sep = "")
cat("[ ] 결과의 클래스: ", class(store["opened"]), "\n", sep = "")
cat("[[ ]] 결과의 클래스: ", class(store[["opened"]]), "\n", sep = "")

cat("\n== 2. 행렬 ==\n")
qty_mat <- matrix(c(5, 4, 6, 2, 3, 1), nrow = 2, byrow = TRUE,
                  dimnames = list(c("음료", "라면"), c("월", "화", "수")))
print(qty_mat)
print(dim(qty_mat))
print(qty_mat["음료", "화"])
print(rowSums(qty_mat))
print(qty_mat[, "수"])

cat("\n== 3. 데이터 프레임 ==\n")
sales <- data.frame(
  date = c("2025-03-03", "2025-03-03", "2025-03-04", "2025-03-04",
           "2025-03-05", "2025-03-05", "2025-03-06", "2025-03-06"),
  item = c("생수", "삼각김밥", "봉지라면", "콜라",
           "생수", "컵라면", "콜라", "과자"),
  category = c("음료", "간편식", "라면", "음료",
               "음료", "라면", "음료", "과자"),
  qty = c(3L, 2L, 4L, 2L, 5L, 3L, 1L, 2L),
  amount = c(3000L, 3000L, 5200L, 3600L, 5000L, 4200L, 1800L, 3000L)
)
print(sales)
str(sales)
print(head(sales, 3))
print(summary(sales$amount))
cat("행 ", nrow(sales), ", 열 ", ncol(sales), "\n", sep = "")
cat("typeof: ", typeof(sales), ", class: ", class(sales), "\n", sep = "")

cat("\n== 4. 행과 열 고르기 ==\n")
print(sales$qty)
print(sales[2, "item"])
print(sales[1:3, c("item", "amount")])
print(sales[sales$category == "음료", c("date", "item", "qty")])
print(subset(sales, qty >= 4, select = c(item, qty)))
print(class(sales["item"]))
print(class(sales[, "item"]))
print(class(sales[, "item", drop = FALSE]))

cat("\n== 5. 새 열과 합계 ==\n")
sales$unit_price <- sales$amount / sales$qty
print(sales$unit_price)
cat(paste(names(sales), collapse = ", "), "\n", sep = "")
drink_total <- sum(sales$amount[sales$category == "음료"])
cat("음료 매출 합계: ", drink_total, "\n", sep = "")

줄별 해설

리스트 부분

  • list(name = ..., opened = 2019L, categories = ...): 문자 하나, 정수 하나, 문자 4개짜리 벡터를 이름 붙은 칸으로 묶는다. 2019L 의 L 은 정수형이라는 표시다.
  • str(store): 칸마다 이름, 자료형, 값을 한 줄로 보여 준다. [1:4] 는 길이 4인 벡터라는 뜻이다.
  • length(store$categories): $ 로 꺼낸 벡터의 길이다.
  • class(store["opened"]) 와 class(store[["opened"]]): 한 겹 대괄호는 리스트가, 두 겹 대괄호는 안에 든 정수가 나온다. 출력 문구 안의 [ ] 는 단순한 글자다.

행렬 부분

  • matrix(c(5, 4, 6, 2, 3, 1), nrow = 2, byrow = TRUE, ...): 여섯 개의 값을 2행으로 접되 행 방향으로 채운다. 첫 행이 5, 4, 6 이고 둘째 행이 2, 3, 1 이다.
  • dimnames = list(행이름, 열이름): 행과 열에 이름을 붙인다.
  • dim(qty_mat): 행 수와 열 수를 벡터로 돌려준다.
  • qty_mat["음료", "화"]: 음료 행, 화 열의 한 칸이다.
  • rowSums(qty_mat): 행마다 합을 구하고 행 이름을 결과의 이름으로 남긴다.
  • qty_mat[, "수"]: 한 열만 꺼내면 벡터가 되고, 행 이름이 벡터의 이름으로 남는다.

데이터 프레임 부분

  • data.frame(...): 열 다섯 개를 이름과 함께 넘긴다. 열마다 길이가 8로 같다. 3L 같은 정수와 문자열이 한 표에 공존한다.
  • print(sales): 표 전체를 출력한다. 왼쪽 숫자는 행 이름이고 자동으로 1부터 붙는다.
  • str(sales): 8개 관측(행)과 5개 변수(열)라는 요약과 열별 자료형을 보여 준다. 문자 열은 앞의 4개만 나오고 나머지는 ... 로 줄인다.
  • head(sales, 3): 앞 3행만 뽑는다.
  • summary(sales$amount): 금액 열의 여섯 가지 요약 값이다. 중앙값이 3,300 이고 평균이 3,600 이라 평균이 중앙값보다 약간 크다.
  • nrow(), ncol(): 행 수와 열 수. typeof(sales) 가 list 이고 class(sales) 가 data.frame 이다.

고르기와 새 열

  • sales[2, "item"]: 2행의 상품 이름 하나다. 결과는 문자열 하나다.
  • sales[1:3, c("item", "amount")]: 앞 3행에서 두 열만 남긴다.
  • sales[sales$category == "음료", c(...)]: 분류가 음료인 행만 남긴다. 원래 표의 행 번호(1, 4, 5, 7)가 그대로 행 이름으로 남는다는 점을 눈여겨본다.
  • subset(sales, qty >= 4, select = c(item, qty)): 수량이 4 이상인 행에서 두 열을 고른다.
  • 세 개의 class(...) 줄: 쉼표 없는 ["item"] 은 데이터 프레임, 쉼표가 있는 [, "item"] 은 문자 벡터, drop = FALSE 를 주면 쉼표가 있어도 데이터 프레임이 남는다.
  • sales$unit_price <- sales$amount / sales$qty: 두 열을 원소별로 나눠 새 열을 붙인다. 열 이름이 하나 늘어 여섯 개가 된다.
  • paste(names(sales), collapse = ", "): 열 이름을 쉼표로 이어 한 줄로 만든다.
  • sum(sales$amount[sales$category == "음료"]): 음료 행의 금액만 골라 더한다. 3000 + 3600 + 5000 + 1800 = 13400 이다.

실행 결과

터미널에서 main.R 가 있는 폴더로 이동해 실행한다. 한글은 화면에서 영문보다 두 배 넓게 그려지므로, 고정폭 글꼴이 아닌 환경에서는 열이 어긋나 보일 수 있다.

$ Rscript main.R
== 1. 리스트 ==
List of 3
 $ name      : chr "가온 편의점"
 $ opened    : int 2019
 $ categories: chr [1:4] "음료" "간편식" "라면" "과자"
분류 수: 4
[ ] 결과의 클래스: list
[[ ]] 결과의 클래스: integer

== 2. 행렬 ==
     월 화 수
음료  5  4  6
라면  2  3  1
[1] 2 3
[1] 4
음료 라면 
  15    6 
음료 라면 
   6    1 

== 3. 데이터 프레임 ==
        date     item category qty amount
1 2025-03-03     생수     음료   3   3000
2 2025-03-03 삼각김밥   간편식   2   3000
3 2025-03-04 봉지라면     라면   4   5200
4 2025-03-04     콜라     음료   2   3600
5 2025-03-05     생수     음료   5   5000
6 2025-03-05   컵라면     라면   3   4200
7 2025-03-06     콜라     음료   1   1800
8 2025-03-06     과자     과자   2   3000
'data.frame':	8 obs. of  5 variables:
 $ date    : chr  "2025-03-03" "2025-03-03" "2025-03-04" "2025-03-04" ...
 $ item    : chr  "생수" "삼각김밥" "봉지라면" "콜라" ...
 $ category: chr  "음료" "간편식" "라면" "음료" ...
 $ qty     : int  3 2 4 2 5 3 1 2
 $ amount  : int  3000 3000 5200 3600 5000 4200 1800 3000
        date     item category qty amount
1 2025-03-03     생수     음료   3   3000
2 2025-03-03 삼각김밥   간편식   2   3000
3 2025-03-04 봉지라면     라면   4   5200
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
   1800    3000    3300    3600    4400    5200 
행 8, 열 5
typeof: list, class: data.frame

== 4. 행과 열 고르기 ==
[1] 3 2 4 2 5 3 1 2
[1] "삼각김밥"
      item amount
1     생수   3000
2 삼각김밥   3000
3 봉지라면   5200
        date item qty
1 2025-03-03 생수   3
4 2025-03-04 콜라   2
5 2025-03-05 생수   5
7 2025-03-06 콜라   1
      item qty
3 봉지라면   4
5     생수   5
[1] "data.frame"
[1] "character"
[1] "data.frame"

== 5. 새 열과 합계 ==
[1] 1000 1500 1300 1800 1000 1400 1800 1500
date, item, category, qty, amount, unit_price
음료 매출 합계: 13400

실무에서 자주 틀리는 것

행을 고르면서 쉼표를 빼먹는다

행 조건만 쓰고 쉼표를 빠뜨리면 R 은 조건 벡터를 열 선택으로 해석한다. 조건 벡터의 길이는 8이고 열은 5개라서 오류가 난다.

# 틀린 코드
sales[sales$qty >= 4]
# Error ... undefined columns selected

# 고친 코드: 행 자리에 조건, 열 자리는 비워 둔다
sales[sales$qty >= 4, ]

열 하나를 골랐더니 벡터가 되어 버린다

열을 하나만 고르면 데이터 프레임이 벡터로 바뀐다. 뒤에서 nrow() 나 열 이름 접근을 기대하는 코드가 이어지면 오류가 난다.

# 틀린 코드: 결과가 문자 벡터라 ncol() 이 NULL 을 돌려준다
one <- sales[, "item"]
ncol(one)

# 고친 코드: drop = FALSE 로 표의 모양을 유지한다
one <- sales[, "item", drop = FALSE]
ncol(one)

리스트에서 [ ] 로 꺼낸 값으로 계산한다

한 겹 대괄호는 리스트를 돌려주므로 산술 연산이 되지 않는다.

# 틀린 코드
store["opened"] + 1
# Error ... non-numeric argument to binary operator

# 고친 코드
store[["opened"]] + 1

열의 길이를 맞추지 않는다

데이터 프레임의 모든 열은 길이가 같아야 한다. 상품은 3개인데 수량을 2개만 넣으면 오류가 난다. 짧은 열이 긴 열의 길이에 나누어떨어지면 R 이 값을 반복해서 채우므로, 의도하지 않은 값이 조용히 들어갈 수도 있다.

# 틀린 코드
data.frame(item = c("생수", "콜라", "과자"), qty = c(3L, 2L))
# Error ... arguments imply differing number of rows: 3, 2

# 고친 코드: 열마다 길이를 확인한다
items <- c("생수", "콜라", "과자")
qty <- c(3L, 2L, 2L)
stopifnot(length(items) == length(qty))
data.frame(item = items, qty = qty)

한눈에 보기

대괄호와 달러 기호는 결과의 종류가 다르다
식대상결과
x[i]리스트칸이 i 개 남은 리스트
x[[i]], x$이름리스트, 데이터 프레임칸 안의 값
m[i, j]행렬칸, 행, 열 (한 줄이면 벡터)
df[i, j]데이터 프레임열이 하나면 벡터, 아니면 데이터 프레임
df["열"]데이터 프레임열이 남은 데이터 프레임
base R 작업은 tidyverse 에서 이렇게 부른다
작업base Rtidyverse (dplyr)
열 고르기sales[c("item", "qty")]select(sales, item, qty)
행 걸러내기subset(sales, qty >= 4)filter(sales, qty >= 4)
새 열 만들기sales$unit_price <- ...mutate(sales, unit_price = amount / qty)
앞 3행 보기head(sales, 3)slice_head(sales, n = 3)

tidyverse 는 이 책에서 설치하지 않으므로 대응 관계만 알아 두면 된다. 자세한 사용법은 dplyr 공식 사이트에서 확인한다. base R 의 함수 설명은 콘솔에서 ?subset, ?data.frame 으로 볼 수 있다.

연습 문제

  1. 완성 코드의 sales 에서 금액이 4000 이상인 행의 item 과 amount 두 열만 뽑는 코드를 [ ] 문법으로 쓰고, 어떤 행이 나오는지 말해 보자.
  2. matrix(1:6, nrow = 2) 로 만든 행렬에서 [1, 2] 의 값은 얼마인가. byrow = TRUE 를 붙이면 값이 어떻게 달라지는가.
  3. sales$qty 의 합계와 평균을 cat() 으로 출력하는 코드를 쓰고 값을 계산해 보자.
  4. 리스트 store 에 phone = "02-000-0000" 칸을 추가하고 categories 칸을 지운 뒤 length(store) 가 얼마인지 말해 보자.

정답과 해설

  1. sales[sales$amount >= 4000, c("item", "amount")]

    금액이 4000 이상인 행은 3행(5200), 5행(5000), 6행(4200)이다. 그래서 봉지라면, 생수, 컵라면이 나온다. 쉼표 앞이 행 조건이고 뒤가 열 선택이다.

  2. 기본은 열 방향 채우기이므로 1:6 이 첫 열에 1, 2, 둘째 열에 3, 4, 셋째 열에 5, 6 으로 들어간다. [1, 2] 는 3 이다. byrow = TRUE 를 붙이면 첫 행이 1, 2, 3 이 되어 [1, 2] 는 2 가 된다.

  3. cat("합계: ", sum(sales$qty), ", 평균: ", mean(sales$qty), "\n", sep = "")

    수량은 3, 2, 4, 2, 5, 3, 1, 2 이므로 합계가 22, 평균이 22 / 8 = 2.75 다. 출력은 합계: 22, 평균: 2.75 가 된다.

  4. store$phone <- "02-000-0000"
    store$categories <- NULL
    length(store)

    처음에 칸이 3개였고 하나를 더하면 4개, NULL 을 대입해 하나를 지우면 3개가 된다. 답은 3 이다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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