콘텐츠로 이동

13. 데이터 선택·가공 함수

💡 참고: 이 장에서 다루는 조합 중 상당수(조건 선택·정렬·중복 제거·병합·재구조화)는 실무에서 tidyverse의 dplyr(filter(), arrange(), distinct(), left_join() 등)나 tidyr(pivot_longer(), pivot_wider())로 대체되어 더 간결하게 쓰이는 경우가 많습니다. 다만 이 매뉴얼은 Base R 전용 범위(프로젝트 지침 참고)이므로 별도 패키지 문법은 다루지 않으며, Base R만으로 같은 작업을 어떻게 해낼 수 있는지에 집중합니다.

13.1 선택·정렬

데이터프레임에 수백~수천 개의 행이 있을 때, 그 전체를 한눈에 보는 것은 불가능합니다. 조건에 맞는 행만 골라내거나, 앞부분 몇 개만 미리 확인하거나, 특정 기준으로 정렬해서 상위·하위를 파악하거나, 연속된 값을 몇 개의 등급으로 나누어 보거나, 전체 중 일부를 무작위로 뽑아 표본으로 삼아야 하는 경우가 실무에서 끊임없이 발생합니다.

학생 5명의 학년·점수 데이터에서 "1학년 중 점수가 80점 이상인 학생"만 골라내는 상황을 생각해 보겠습니다.

df <- data.frame(
  이름 = c("김민준", "이서연", "박도윤", "최지우", "정하은"),
  학년 = c(2, 1, 2, 1, 3),
  점수 = c(85, 92, 78, 92, 63)
)
df[df$학년 == 1 & df$점수 >= 80, ]
#>     이름 학년 점수
#> 2 이서연    1   92
#> 4 최지우    1   92

df$학년 == 1 & df$점수 >= 80처럼 조건을 논리형 벡터로 직접 만들어 [ 안에 넣는 방법은 4장에서 이미 다루었습니다. 이 절에서는 이 조건을 더 명시적으로 다루는 which()·subset(), 조건 자체를 만드는 데 쓰이는 cut(), 그리고 정렬·무작위 추출 관련 함수들을 정리합니다.

이 절에서 다루는 함수는 "무엇을 기준으로 고르는가"에 따라 다음과 같이 나뉩니다.

함수 목적 반환 형태
which() 조건을 만족하는 원소의 위치(인덱스) 를 찾음 정수 벡터
head()/tail() 앞/뒤에서 지정한 개수만큼만 확인 원본과 같은 자료형
sort() 값 자체를 오름차순·내림차순으로 정렬 정렬된 벡터
order() 정렬했을 때의 순번(인덱스) 을 반환 정수 벡터
rev() 순서를 그대로 거꾸로 뒤집음 원본과 같은 자료형
cut() 연속형 값을 구간별 범주(factor)로 변환 factor
subset() 행 조건과 열 선택을 한 번에 지정(데이터프레임 전용) 데이터프레임
sample() 무작위로 일부를 추출 벡터

which()

which(x, arr.ind = FALSE)는 논리형 벡터 x에서 TRUE인 원소의 위치(인덱스) 를 정수 벡터로 반환합니다.

  • x : 논리형 벡터(또는 논리형으로 변환 가능한 값). 보통 x > 5처럼 조건식의 결과를 그대로 넣습니다.
  • arr.ind : x가 행렬·배열일 때, FALSE(기본값)이면 전체를 1차원으로 취급한 위치를, TRUE이면 행·열 인덱스를 함께 담은 행렬을 반환합니다.

7.2절(논리 함수 — 위치 탐색)에서 which()의 기본 동작을 다루었으므로, 여기서는 데이터프레임에서 조건에 맞는 행을 실제로 골라내는 실전 활용에 초점을 둡니다. 이름이 붙은 벡터에 적용하면 위치뿐 아니라 이름까지 함께 반환되어, 어떤 원소가 뽑혔는지 바로 확인할 수 있습니다.

x <- c(85, 92, 47, 63, 78, 55, 91)
names(x) <- paste0("학생", 1:7)
x
#> 학생1 학생2 학생3 학생4 학생5 학생6 학생7 
#>    85    92    47    63    78    55    91 

which(x >= 80)          # 조건을 만족하는 위치(이름 포함)
#> 학생1 학생2 학생7 
#>     1     2     7 
x[which(x >= 80)]       # 그 위치의 값
#> 학생1 학생2 학생7 
#>    85    92    91 

데이터프레임에서는 df[조건, ] 형태로 바로 행을 추출할 수 있어 which()를 반드시 거칠 필요는 없지만, 조건 결과에 NA가 섞여 있을 때는 이야기가 다릅니다. 논리형 벡터를 직접 인덱스로 쓰면 NA인 위치까지 결과에 포함되어 값이 전부 NA인 행이 끼어들지만, which()는 NA를 TRUE로도 FALSE로도 취급하지 않고 아예 결과에서 제외하므로 더 안전합니다.

df <- data.frame(이름 = c("김민준", "이서연", "박도윤"),
                  점수 = c(85, NA, 47))

df[df$점수 >= 80, ]       # NA인 행이 그대로 포함되어 전부 NA로 채워짐
#>     이름 점수
#> 1 김민준   85
#> NA   <NA>   NA
#> 3 박도윤   NA

df[which(df$점수 >= 80), ]  # NA인 행은 조건을 만족하지 않는 것으로 보고 제외
#>     이름 점수
#> 1 김민준   85

head() / tail()

head(x, n = 6L)와 tail(x, n = 6L)은 각각 x의 앞부분과 뒷부분에서 n개만 잘라서 보여줍니다.

  • x : 벡터, 리스트, 데이터프레임, 행렬 등 대부분의 R 객체.
  • n : 확인할 개수(기본값 6). 데이터프레임·행렬에 대해서는 행 개수를 의미합니다. 음수를 지정하면 "끝에서 |n|개를 뺀 나머지 전부"라는 의미가 되어, head(x, -2)는 "마지막 2개를 제외한 전부"를 반환합니다.

목차에는 없지만, 처음 불러온 데이터가 예상한 모양대로 되어 있는지 확인하거나, 정렬·필터링 결과의 상위/하위 몇 개만 빠르게 훑어볼 때 가장 먼저 쓰게 되는 실무 필수 함수이므로 이 절에 포함했습니다.

head(iris, 3)
#>   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
#> 1          5.1         3.5          1.4         0.2  setosa
#> 2          4.9         3.0          1.4         0.2  setosa
#> 3          4.7         3.2          1.3         0.2  setosa

tail(iris, 3)
#>     Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
#> 148          6.5         3.0          5.2         2.0 virginica
#> 149          6.2         3.4          5.4         2.3 virginica
#> 150          5.9         3.0          5.1         1.8 virginica

sort()·order()와 함께 쓰면 "상위 n개"나 "하위 n개"를 곧바로 확인할 수 있어 자주 조합됩니다.

iris$Sepal.Length |> sort(decreasing = TRUE) |> head(5)   # 꽃받침 길이 상위 5개 값
#> [1] 7.9 7.7 7.7 7.7 7.7

sort()

sort(x, decreasing = FALSE, na.last = NA, method = c("auto", "shell", "radix"), ...)는 벡터 x를 오름차순(기본값) 또는 내림차순으로 정렬한 값 자체를 반환합니다.

  • x : 정렬할 벡터(숫자형·문자형·팩터 등).
  • decreasing : FALSE(기본값)이면 오름차순, TRUE이면 내림차순으로 정렬합니다.
  • na.last : 결측치(NA) 처리 방법. 기본값 NA는 결측치를 아예 결과에서 제외하고, TRUE는 맨 뒤로, FALSE는 맨 앞으로 보냅니다.
  • method : 내부적으로 사용할 정렬 알고리즘입니다. "auto"(기본값)는 데이터 종류에 따라 적절한 방식을 자동으로 고르며, 보통은 신경 쓸 필요가 없습니다.

문자형 벡터를 정렬할 때는 현재 세션의 로캘(locale) 설정에 따라 정렬 순서(특히 한글·특수문자의 순서)가 달라질 수 있다는 점에 유의해야 합니다.

v <- c(3, 1, NA, 4, 1, 5, NA, 9)
sort(v)                    # 기본값: NA 제외, 오름차순
#> [1] 1 1 3 4 5 9
sort(v, decreasing = TRUE) # 내림차순(NA는 여전히 제외)
#> [1] 9 5 4 3 1 1
sort(v, na.last = TRUE)    # NA를 제외하지 않고 맨 뒤로
#> [1]  1  1  3  4  5  9 NA NA
sort(c("바나나", "사과", "체리"))
#> [1] "바나나" "사과"   "체리"  

order()

order(..., na.last = TRUE, decreasing = FALSE, method = c("auto", "shell", "radix"))는 벡터(들)를 정렬했을 때 각 원소가 와야 할 위치(인덱스) 를 반환합니다.

  • ... : 정렬 기준이 되는 벡터(들). 여러 개를 넣으면 첫 번째 기준으로 먼저 정렬하고, 값이 같은 경우에만 두 번째 기준으로, 그래도 같으면 세 번째 기준으로 순서를 정하는 식으로 동작합니다(SQL의 ORDER BY 열1, 열2와 같은 개념).
  • na.last : sort()와 달리 기본값이 TRUE라서, 결측치를 제외하지 않고 맨 뒤로 보냅니다. FALSE이면 맨 앞으로, NA이면 아예 제외합니다.
  • decreasing : TRUE이면 내림차순으로 정렬합니다. 정렬 기준을 여러 개 넣을 때, 기준마다 다르게 지정하고 싶으면 decreasing에 각 기준과 길이가 같은 논리형 벡터를 넣으면 됩니다(method = "radix"일 때만 지원).

sort()가 값 자체를 정렬하는 데 그치는 반면, order()는 위치(인덱스)만 알려주므로 그 인덱스를 df[order(...), ]처럼 활용해 데이터프레임 전체를 원하는 기준으로 재배열할 수 있습니다. 이것이 데이터 가공에서 order()를 sort()보다 훨씬 더 자주 쓰게 되는 이유입니다.

df <- data.frame(
  이름 = c("김민준", "이서연", "박도윤", "최지우", "정하은"),
  학년 = c(2, 1, 2, 1, 3),
  점수 = c(85, 92, 78, 92, 63)
)

df[order(df$학년), ]              # 학년 오름차순
#>     이름 학년 점수
#> 2 이서연    1   92
#> 4 최지우    1   92
#> 1 김민준    2   85
#> 3 박도윤    2   78
#> 5 정하은    3   63

학년이 같은 학생끼리는 점수 내림차순으로 다시 정렬하고 싶다면, 두 번째 기준 앞에 마이너스(-)를 붙이거나 decreasing 인자를 벡터로 넘기면 됩니다. 다만 마이너스 부호는 숫자형 벡터에서만 통하므로, 문자형 벡터를 내림차순으로 두고 싶다면 decreasing 벡터 지정 방식을 써야 합니다.

df[order(df$학년, -df$점수), ]                              # 학년 오름차순, 점수 내림차순
df[order(df$학년, df$점수, decreasing = c(FALSE, TRUE)), ]  # 위와 동일한 결과, 벡터로 지정
#>     이름 학년 점수
#> 2 이서연    1   92
#> 4 최지우    1   92
#> 1 김민준    2   85
#> 3 박도윤    2   78
#> 5 정하은    3   63

rev()

rev(x)는 x의 원소 순서를 그대로 거꾸로 뒤집습니다.

  • x : 벡터 또는 리스트. 값의 크기와 무관하게 위치만 뒤집을 뿐, 정렬(대소 비교)은 하지 않습니다.

sort(x, decreasing = TRUE)와 rev(sort(x))가 종종 같은 결과처럼 보이지만 목적이 다릅니다. 전자는 "크기 순으로 다시 정렬"하는 것이고, 후자는 "이미 있는 순서를 그대로 뒤집는" 것입니다. 예를 들어 정렬되지 않은 벡터를 rev()로 뒤집으면 크기 순서와 무관한 결과가 나옵니다.

rev(1:5)
#> [1] 5 4 3 2 1
rev(c("a", "b", "c"))
#> [1] "c" "b" "a"

cut()

cut(x, breaks, labels = NULL, right = TRUE, include.lowest = FALSE, dig.lab = 3)는 연속형 숫자 벡터 x를 구간별로 나누어 범주형(factor) 으로 변환합니다.

  • x : 구간으로 나눌 숫자형 벡터.
  • breaks : 구간을 나눌 기준입니다. 경계값을 직접 벡터로 지정(c(0, 60, 70, 80, 90, 100))할 수도 있고, 단순히 나눌 구간의 개수를 숫자 하나로 지정(breaks = 4)해 x의 범위를 균등한 폭으로 자동 분할할 수도 있습니다.
  • labels : 각 구간에 붙일 이름. 지정하지 않으면(기본값 NULL) "(0,60]"처럼 구간 자체를 이름으로 씁니다. FALSE를 지정하면 이름 대신 1, 2, 3, ...처럼 구간 순서를 나타내는 정수 코드를 반환합니다.
  • right : TRUE(기본값)이면 각 구간이 오른쪽 경계를 포함하는 (a, b] 형태가 되고, FALSE이면 왼쪽 경계를 포함하는 [a, b) 형태가 됩니다.
  • include.lowest : 전체 범위의 가장 바깥쪽 경계값(right = TRUE이면 최솟값, right = FALSE이면 최댓값)을 구간에 포함시킬지 여부입니다. 기본값 FALSE에서는 이 경계값이 어느 구간에도 속하지 못해 결측치(NA)로 처리되므로 주의해야 합니다.
  • dig.lab : 구간 이름을 자동으로 만들 때 사용할 숫자의 유효자릿수입니다.

💡 참고: 구간(interval) 표기 (a, b]는 실수의 범위를 나타내는 구간 표기입니다. 괄호의 모양은 해당 경계값을 포함하는지를 나타냅니다. (와 )는 경계값을 포함하지 않음을, [와 ]는 경계값을 포함함을 의미합니다. - (a, b) : (a<x<b) — 양쪽 경계값(a, b) 모두 제외 - [a, b] : (a≤x≤b) — 양쪽 경계값(a, b) 모두 포함 - (a, b] : (a<x≤b) — a 제외, b 포함 - [a, b) : (a≤x<b) — a 포함, b 제외

성적을 등급으로 나누는 상황을 생각해 보면 cut()의 쓰임새를 바로 이해할 수 있습니다.

scores <- c(55, 62, 74, 81, 93, 45, 88, 67)
grp <- cut(scores, breaks = c(0, 60, 70, 80, 90, 100),
           labels = c("F", "D", "C", "B", "A"))
grp
#> [1] F D C B A F B D
#> Levels: F D C B A
table(grp)
#> grp
#> F D C B A 
#> 2 2 1 2 1 

breaks에 벡터 대신 정수 하나만 지정하면, x의 최솟값~최댓값 범위를 그 개수만큼 균등한 폭으로 나누어 줍니다. 특정 기준 없이 데이터의 분포를 대략적인 몇 개 구간으로 훑어보고 싶을 때 유용합니다.

set.seed(10)
x <- round(runif(10, 0, 100))
x
#>  [1] 51 31 43 69  9 23 27 27 62 43
table(cut(x, breaks = 4))
#> 
#> (8.94,24]   (24,39]   (39,54] (54,69.1] 
#>         2         3         3         2 

right와 include.lowest의 관계는 직접 짧은 예제로 비교해 보는 것이 가장 이해하기 쉽습니다. 경계값이 정확히 어느 쪽 구간에도 들어가지 못하면 결측치가 발생한다는 점을 확인할 수 있습니다.

cut(c(0, 5, 10), breaks = c(0, 5, 10))          # 0은 어느 구간에도 못 들어가 NA
#> [1] <NA>   (0,5]  (5,10]
#> Levels: (0,5] (5,10]

# 최솟값(0)을 첫 구간에 포함
cut(c(0, 5, 10), breaks = c(0, 5, 10), include.lowest = TRUE)  
#> [1] [0,5]  [0,5]  (5,10]
#> Levels: [0,5] (5,10]

# 왼쪽 포함 + 최댓값(10) 포함
cut(c(0, 5, 10), breaks = c(0, 5, 10), right = FALSE, include.lowest = TRUE)  
#> [1] [0,5)  [5,10] [5,10]
#> Levels: [0,5) [5,10]

subset()

subset(x, subset, select, drop = FALSE)는 데이터프레임(또는 행렬) x에서 행 조건과 열 선택을 한 번에 지정해 그 결과를 돌려줍니다.

  • x : 대상 데이터프레임(또는 행렬, 벡터).
  • subset : 남길 행의 조건을 논리식으로 지정합니다. df$ 를 반복해서 붙이지 않고 열 이름을 변수처럼 바로 쓸 수 있다는 점이 df[조건, ] 방식과의 가장 큰 차이입니다.
  • select : 남길 열을 지정합니다. 열 이름을 문자열이 아니라 c(이름, 점수)처럼 그대로(따옴표 없이) 나열할 수 있고, -열이름으로 특정 열만 제외할 수도 있습니다.
  • drop : 결과가 열 하나로 줄어들 때, FALSE(기본값)이면 데이터프레임 형태를 유지하고 TRUE이면 벡터로 단순화합니다.
df <- data.frame(
  이름 = c("김민준", "이서연", "박도윤", "최지우", "정하은"),
  학년 = c(2, 1, 2, 1, 3),
  점수 = c(85, 92, 78, 92, 63)
)

subset(df, 학년 == 1)                        # df$ 없이 열 이름을 바로 조건에 사용
#>     이름 학년 점수
#> 2 이서연    1   92
#> 4 최지우    1   92

subset(df, 학년 == 1, select = c(이름, 점수))  # 조건 + 열 선택을 동시에
#>     이름 점수
#> 2 이서연   92
#> 4 최지우   92

subset(df, 점수 >= 80 & 학년 <= 2)            # 여러 조건 결합
#>     이름 학년 점수
#> 1 김민준    2   85
#> 2 이서연    1   92
#> 4 최지우    1   92

⚠️ 주의: subset()은 대화형(interactive) 사용을 위한 함수입니다

subset()의 R 공식 도움말(?subset)에는 "이 함수는 대화형으로 사용하기 편리하도록 만들어진 것이며, 함수를 작성하는 등 프로그래밍 목적에는 [ 같은 표준 인덱싱 방법을 쓰는 것이 더 낫다"는 안내가 명시되어 있습니다. subset·select 인자가 열 이름을 따옴표 없이 그대로 받아들이는 편리함(비표준평가, non-standard evaluation) 자체가, 사용자 입력값을 변수로 전달받아 동작해야 하는 함수나 패키지 내부 코드에서는 오히려 예기치 않은 부작용의 원인이 될 수 있기 때문입니다. 즉 콘솔에서 데이터를 직접 훑어볼 때는 subset()이 간편하지만, 다른 함수 안에서 재사용할 코드라면 df[df$학년 == 1, ]처럼 4장에서 다룬 표준 인덱싱을 쓰는 편이 안전합니다.

sample()

sample(x, size, replace = FALSE, prob = NULL)는 x에서 무작위로 표본을 추출합니다.

  • x : 표본을 뽑을 대상. 벡터를 직접 넣거나, 길이가 1인 양의 정수를 넣으면 1:x 중에서 뽑습니다.
  • size : 뽑을 개수. 생략하면 x의 길이만큼(즉 x 전체를 무작위로 섞은 순서)을 뽑습니다.
  • replace : FALSE(기본값)이면 한 번 뽑은 원소는 다시 뽑지 않는 비복원추출, TRUE이면 같은 원소가 여러 번 뽑힐 수 있는 복원추출입니다.
  • prob : 각 원소가 뽑힐 확률(가중치)을 x와 같은 길이의 벡터로 지정합니다. 생략하면 모든 원소가 동일한 확률을 갖습니다.

무작위 추출은 실행할 때마다 결과가 달라지므로, 같은 결과를 재현하려면 sample() 실행 직전에 set.seed(정수)로 난수 생성기의 초깃값을 고정해야 합니다.

set.seed(1)
sample(1:10, 5)                 # 1~10 중 5개를 비복원추출
#> [1] 9 4 7 1 2

replace = TRUE를 지정하면 같은 값이 여러 번 나올 수 있으며, size가 모집단 크기(x의 길이)보다 커도 표본을 뽑을 수 있습니다.

set.seed(1)
sample(1:10, 15, replace = TRUE)  # 모집단(10개)보다 많은 15개를 복원추출
#>  [1]  9  4  7  1  2  7  2  3  1  5  5 10  6 10  7

prob으로 각 값이 뽑힐 확률을 다르게 줄 수도 있습니다. 아래 예제는 "A"가 뽑힐 확률을 60%, "B"를 30%, "C"를 10%로 지정한 것입니다.

set.seed(1)
sample(c("A", "B", "C"), 10, replace = TRUE, prob = c(0.6, 0.3, 0.1))
#>  [1] "A" "A" "A" "C" "A" "B" "C" "B" "B" "A"

데이터프레임의 행을 무작위로 뽑고 싶을 때는 sample()에 데이터프레임을 직접 넣는 대신, 행 번호를 뽑아 인덱싱하는 방식을 씁니다.

df <- data.frame(이름 = c("김민준", "이서연", "박도윤", "최지우", "정하은"))
set.seed(42)
df[sample(nrow(df), 3), , drop = FALSE]   # 5명 중 3명의 행을 무작위로 추출

💡 더 알아보기: R 3.6.0의 sample() 알고리즘 변경

R 3.6.0(2019년 4월 배포)부터 sample()이 정수를 뽑을 때 사용하는 기본 알고리즘이 "Rounding"에서 "Rejection"으로 바뀌었습니다. 이전 방식은 모집단이 클 때 특정 값이 미세하게 더 자주 뽑히는 편향이 있었는데, 이를 바로잡은 것입니다. 이 매뉴얼이 기준으로 하는 R 4.1 이상에서는 모두 새 방식("Rejection")이 기본값이므로 신경 쓸 필요가 없지만, 2019년 이전에 출간된 통계학 교재나 예전 코드의 set.seed() 결과를 그대로 재현하려 할 때는 다음과 같이 옛 방식을 명시적으로 지정해야 값이 일치합니다.

RNGkind(sample.kind = "Rounding")  # 2019년 이전(R 3.5.x 이하)과 동일한 결과를 재현
set.seed(1); sample(1:10, 5)
#> [1] 3 4 5 7 2
RNGkind(sample.kind = "default")  # 다시 기본값(Rejection)으로 되돌림

13.2 집합·중복 처리

설문 응답이나 로그 데이터에는 같은 항목이 여러 번 중복되어 기록되거나, 응답을 하지 않아 결측치(NA)로 남는 경우가 흔합니다. 중복을 제거하지 않고 집계하면 특정 값의 비중이 부풀려지고, 결측치를 그대로 두고 계산하면 NA가 전염되어 평균·합계 같은 통계량 자체가 NA가 되어 버립니다.

설문에서 응답자의 소속 학과를 중복 입력받은 데이터를 정리하는 상황을 생각해 보겠습니다.

major <- c("컴공", "전자", "컴공", "경영", "전자", "컴공")
unique(major)              # 어떤 학과들이 있었는지(중복 제거)
#> [1] "컴공" "전자" "경영"
table(major)               # 학과별 응답 인원(빈도표)
#> major
#> 경영 전자 컴공 
#>    1    2    3 

이 절의 함수는 "중복을 어떻게 다루는가"와 "결측을 어떻게 다루는가"라는 두 갈래로 나뉩니다.

함수 목적 반환 형태
unique() 중복을 제거한 값만 남김 원본과 같은 자료형
duplicated() 각 원소가 이전에 이미 나온 값인지 표시 논리형 벡터
table() 값(또는 값 조합)별 빈도를 집계 table 객체
na.omit() 결측치가 있는 행·원소를 제거 원본과 같은 자료형(제거 위치를 속성으로 기록)
na.fail() 결측치가 하나라도 있으면 즉시 오류 발생 원본 그대로, 또는 오류
complete.cases() 행 단위로 결측이 하나도 없는지 표시 논리형 벡터

unique()

unique(x, incomparables = FALSE, fromLast = FALSE)는 x에서 중복된 값을 제거하고 처음 등장한 값만 원래 순서대로 남깁니다.

  • x : 벡터, 데이터프레임, 리스트 등.
  • incomparables : 비교에서 제외할 값을 지정합니다(고급 옵션으로, 기본값 FALSE는 모든 값을 비교 대상으로 삼습니다).
  • fromLast : FALSE(기본값)이면 각 값이 처음 등장한 자리를 남기고, TRUE이면 마지막에 등장한 자리를 남깁니다.
x <- c(3, 1, 2, 3, 1, 5, 2)
unique(x)
#> [1] 3 1 2 5
unique(x, fromLast = TRUE)   # 각 값이 마지막으로 등장한 순서를 기준으로 남김
#> [1] 3 1 5 2

데이터프레임에 적용하면 행 전체가 완전히 같은 경우만 중복으로 보고 제거합니다.

df <- data.frame(반 = c("A", "A", "B", "B", "A"),
                   이름 = c("김민준", "이서연", "김민준", "박도윤", "김민준"))
unique(df)
#>   반   이름
#> 1  A 김민준
#> 2  A 이서연
#> 3  B 김민준
#> 4  B 박도윤

duplicated()

duplicated(x, incomparables = FALSE, fromLast = FALSE)는 x의 각 원소가 그 이전에 이미 나온 값과 같은지를 논리형 벡터로 알려줍니다. unique()가 중복을 걸러낸 결과 자체를 돌려주는 것과 달리, duplicated()는 "어느 위치가 중복인지"를 표시만 해 준다는 점이 다릅니다.

  • x, incomparables, fromLast : unique()와 동일합니다.
x <- c(3, 1, 2, 3, 1, 5, 2)
duplicated(x)
#> [1] FALSE FALSE FALSE  TRUE  TRUE FALSE  TRUE
x[!duplicated(x)]     # unique(x)와 동일한 결과
#> [1] 3 1 2 5

데이터프레임에서 "중복된 행만 확인하고 싶을 때"나 "특정 열을 기준으로 처음 등장한 행만 남기고 싶을 때"처럼, unique()보다 더 세밀한 제어가 필요할 때 유용합니다.

df <- data.frame(반 = c("A", "A", "B", "B", "A"),
                   이름 = c("김민준", "이서연", "김민준", "박도윤", "김민준"))

df[duplicated(df), ]              # 완전히 중복된 행만 확인
#>   반   이름
#> 5  A 김민준

df[!duplicated(df$이름), ]        # "이름" 열 기준으로 처음 등장한 행만 남김
#>   반   이름
#> 1  A 김민준
#> 2  A 이서연
#> 4  B 박도윤

table()

table(..., useNA = c("no", "ifany", "always"), dnn = list.names(...))는 하나 이상의 벡터(주로 팩터·문자형)에 대해 값(또는 값 조합)별 빈도표를 만듭니다.

  • ... : 빈도를 셀 벡터(들). 하나만 넣으면 1차원 도수분포표, 두 개를 넣으면 교차표(cross table) 가 만들어집니다.
  • useNA : 결측치(NA)를 표에 포함할지 여부입니다. "no"(기본값)는 NA를 제외하고, "ifany"는 NA가 실제로 있을 때만 별도 항목으로 포함하며, "always"는 NA가 하나도 없어도 항상 항목을 만듭니다.
  • dnn : 표의 차원 이름(dimension names)을 직접 지정합니다. 생략하면 넘긴 인자의 이름을 그대로 씁니다.
grade <- c("A", "B", "A", "C", "B", "A", "B")
table(grade)
#> grade
#> A B C 
#> 3 3 1 

table()에 기본값(useNA = "no")을 쓰면 결측치가 있어도 조용히 빠져 버려 응답률 자체를 놓치기 쉽습니다. 결측치 개수까지 함께 확인하려면 useNA를 지정해야 합니다.

x <- c("A", "B", NA, "A", NA, "B", "A")
table(x)                       # 기본값: NA는 표에서 아예 빠짐
#> x
#> A B 
#> 3 2 
table(x, useNA = "ifany")      # NA도 하나의 항목으로 포함
#> x
#>    A    B <NA> 
#>    3    2    2 

두 개 이상의 변수를 넣으면 각 조합별 빈도를 담은 교차표가 만들어지며, prop.table()로 비율로, addmargins()로 합계까지 함께 확인할 수 있습니다.

major <- c("컴공", "컴공", "전자", "전자", "컴공", "전자")
grade2 <- c("A", "B", "A", "B", "B", "A")

t2 <- table(major, grade2)
t2
#>       grade2
#> major  A B
#>   전자 2 1
#>   컴공 1 2

prop.table(t2)            # 전체를 1로 놓은 비율
#>       grade2
#> major          A         B
#>   전자 0.3333333 0.1666667
#>   컴공 0.1666667 0.3333333

prop.table(t2, margin = 1)  # margin = 1: 행(전공)별 비율(각 행의 합이 1)
#>       grade2
#> major          A         B
#>   전자 0.6666667 0.3333333
#>   컴공 0.3333333 0.6666667

addmargins(t2)             # 행·열 합계를 함께 표시
#>       grade2
#> major  A B Sum
#>   전자 2 1   3
#>   컴공 1 2   3
#>   Sum  3 3   6

cut()(13.1절)으로 연속형 변수를 구간별 범주로 나눈 뒤 table()로 각 구간의 개수를 세는 조합은 실무에서 매우 자주 쓰입니다.

na.omit() / na.fail()

na.omit(object)는 결측치가 포함된 행(또는 원소)을 제거합니다. na.fail(object)는 반대로, 결측치가 하나라도 있으면 아무것도 반환하지 않고 즉시 오류를 발생시킵니다.

  • object : 벡터, 데이터프레임, 행렬 등.

na.omit()이 반환한 결과에는 어떤 행(위치)이 제거되었는지가 "na.action"이라는 속성(attribute)에 그대로 기록되어 있어, 나중에 원본과 대조하거나 되돌릴 때 참고할 수 있습니다.

v <- c(1, 2, NA, 4, NA, 6)
na.omit(v)
#> [1] 1 2 4 6
#> attr(,"na.action")
#> [1] 3 5
#> attr(,"class")
#> [1] "omit"

데이터프레임에 적용하면 어느 한 열에라도 결측치가 있는 행 전체가 제거됩니다.

df3 <- data.frame(x = c(1, NA, 3, 4), y = c(NA, 2, 3, NA))
df3
#>    x  y
#> 1  1 NA
#> 2 NA  2
#> 3  3  3
#> 4  4 NA

na.omit(df3)
#>   x y
#> 3 3 3

na.fail()은 "결측치가 있으면 계산을 아예 멈추고 알려 달라"는 의도로 방어적인 코드를 짤 때 씁니다. 예를 들어 함수 내부에서 입력 데이터에 결측치가 섞여 있는지 미리 검증하는 용도로 쓸 수 있습니다.

tryCatch(na.fail(v), error = function(e) cat("에러:", conditionMessage(e), "\n"))
#> 에러: 객체안에 결측값들이 있습니다 

na.fail(c(1, 2, 3))    # 결측치가 없으면 원본을 그대로 반환
#> [1] 1 2 3

complete.cases()

complete.cases(...)는 데이터프레임(또는 여러 벡터)에서 행 단위로 결측치가 하나도 없는지를 논리형 벡터로 반환합니다.

  • ... : 검사할 데이터프레임 또는 벡터(들). 여러 개를 넣으면 길이가 같아야 하며, 같은 위치의 값들을 하나의 "행"처럼 취급해 검사합니다.

na.omit()이 결측치가 있는 행을 곧바로 제거해 버리는 것과 달리, complete.cases()는 제거하지 않고 "결측 여부"만 논리값으로 알려줍니다. 그래서 sum()으로 결측이 있는 행이 몇 개인지 세거나, 원본은 그대로 둔 채 결측이 없는 행만 골라 별도로 확인하는 등 더 유연하게 활용할 수 있습니다.

df3 <- data.frame(x = c(1, NA, 3, 4), y = c(NA, 2, 3, NA))

complete.cases(df3)
#> [1] FALSE FALSE  TRUE FALSE

df3[complete.cases(df3), ]   # 결측 없는 행만 추출 (na.omit(df3)과 결과는 같음)
#>   x y
#> 3 3 3

sum(!complete.cases(df3))    # 결측이 있는 행이 몇 개인지 개수 확인
#> [1] 3

13.3 그룹 연산

그룹별로 요약 통계를 내는 것(by(), aggregate())은 8장에서 이미 다루었습니다. 그런데 데이터 가공 실무에서는 그룹별 요약 자체보다, 서로 다른 표에 나뉘어 있는 정보를 공통된 키(key)로 합치거나, 원자료를 바로 교차표로 바꾸어야 하는 경우가 더 자주 발생합니다. 예를 들어 학생 명단과 성적표가 학번을 기준으로 서로 다른 파일에 나뉘어 있다면, 분석을 시작하기 전에 먼저 두 표를 하나로 합쳐야 합니다.

학생 명단(학번, 이름)과 성적표(학번, 점수)가 별도의 표로 있고, 두 표의 학번이 완전히 일치하지도 않는 상황을 생각해 보겠습니다.

students <- data.frame(학번 = c(1, 2, 3, 4),
                         이름 = c("김민준", "이서연", "박도윤", "최지우"))
scores   <- data.frame(학번 = c(1, 2, 3, 5),
                         점수 = c(85, 92, 78, 88))

merge(students, scores, by = "학번")   # 학번이 양쪽에 모두 있는 경우만 결합
#>   학번   이름 점수
#> 1    1 김민준   85
#> 2    2 이서연   92
#> 3    3 박도윤   78

8장에서 다룬 by()·aggregate()가 하나의 데이터프레임을 그룹별로 나누어 요약하는 함수였다면, 이 절에서 새로 다루는 merge()는 서로 다른 두 데이터프레임을 공통 키로 옆으로 이어 붙이는(join) 함수이고, xtabs()는 원자료를 formula 표기로 바로 교차표(table 객체)로 바꾸는 함수입니다.

merge()

merge(x, y, by = intersect(names(x), names(y)), by.x, by.y, all = FALSE, all.x = all, all.y = all, sort = TRUE, suffixes = c(".x", ".y"))는 두 데이터프레임 x, y를 공통된 열(키)을 기준으로 결합합니다. SQL의 JOIN과 정확히 같은 개념입니다.

  • x, y : 결합할 두 데이터프레임.
  • by : 결합 기준이 되는 열 이름. 생략하면 두 데이터프레임에 공통으로 있는 열 이름을 자동으로 찾아 사용합니다. 열이 여러 개면 c("열1", "열2")처럼 벡터로 지정할 수 있습니다.
  • by.x, by.y : 두 데이터프레임에서 키 열의 이름 자체가 다를 때, 각각 무엇을 기준으로 삼을지 따로 지정합니다.
  • all : FALSE(기본값)이면 양쪽 모두에 키 값이 존재하는 행만 남기는 내부 결합(inner join), TRUE이면 어느 한쪽에만 있어도 남기고 반대쪽 값은 NA로 채우는 완전 외부 결합(full outer join) 이 됩니다.
  • all.x : TRUE이면 x의 모든 행을 남기고, y에 대응 값이 없으면 NA로 채웁니다(왼쪽 결합, left join).
  • all.y : TRUE이면 y의 모든 행을 남기고, x에 대응 값이 없으면 NA로 채웁니다(오른쪽 결합, right join).
  • sort : TRUE(기본값)이면 결과를 키 기준으로 정렬하고, FALSE이면 정렬하지 않습니다(대용량 데이터에서는 정렬을 생략하면 속도에 도움이 됩니다).
  • suffixes : 두 데이터프레임에 이름이 같은(키가 아닌) 열이 있을 때, 구분을 위해 각각 뒤에 붙일 접미사입니다(기본값 ".x", ".y").

all·all.x·all.y의 조합에 따라 결과가 어떻게 달라지는지, 위 예제(students, scores)로 비교해 보면 다음과 같습니다. scores에는 학번 4가 없고, students에는 학번 5가 없다는 점에 주목하면 각 결합 방식의 차이가 뚜렷하게 드러납니다.

# 완전 외부 결합: 양쪽에 있는 학번을 모두 포함
merge(students, scores, by = "학번", all = TRUE)     
#>   학번   이름 점수
#> 1    1 김민준   85
#> 2    2 이서연   92
#> 3    3 박도윤   78
#> 4    4 최지우   NA
#> 5    5   <NA>   88

# 왼쪽 결합: students의 4명을 모두 유지
merge(students, scores, by = "학번", all.x = TRUE)   
#>   학번   이름 점수
#> 1    1 김민준   85
#> 2    2 이서연   92
#> 3    3 박도윤   78
#> 4    4 최지우   NA

# 오른쪽 결합: scores의 학번을 모두 유지
merge(students, scores, by = "학번", all.y = TRUE)   
#>   학번   이름 점수
#> 1    1 김민준   85
#> 2    2 이서연   92
#> 3    3 박도윤   78
#> 4    5   <NA>   88

키 열의 이름이 두 데이터프레임에서 서로 다르면 by.x·by.y로 각각 지정합니다.

scores2 <- data.frame(id = c(1, 2, 3, 5), 점수 = c(85, 92, 78, 88))
merge(students, scores2, by.x = "학번", by.y = "id")
#>   학번   이름 점수
#> 1    1 김민준   85
#> 2    2 이서연   92
#> 3    3 박도윤   78

⚠️ 주의: 키가 중복되면 결합 결과의 행 수가 늘어날 수 있습니다

merge()는 키 값이 한쪽(또는 양쪽)에 중복되어 있으면, 가능한 모든 조합을 만들어 냅니다(이른바 카티전 곱, Cartesian product). 아래 예제처럼 "반"이라는 키가 양쪽에 각각 2번씩 중복되어 있으면, 결과에는 2 × 2 = 4개의 행이 만들어집니다.

a <- data.frame(반 = c("A", "A", "B"), 이름 = c("김민준", "이서연", "박도윤"))
b <- data.frame(반 = c("A", "A", "B"), 담당 = c("김쌤", "이쌤", "박쌤"))
merge(a, b, by = "반")
#>   반   이름 담당
#> 1  A 김민준 김쌤
#> 2  A 김민준 이쌤
#> 3  A 이서연 김쌤
#> 4  A 이서연 이쌤
#> 5  B 박도윤 박쌤

학번·주민등록번호처럼 값이 유일해야 할 키에 뜻하지 않게 중복이 있으면 이렇게 행이 예상보다 훨씬 불어난 결과를 얻게 되므로, merge() 이전에 duplicated()로 키 값의 중복 여부를 미리 확인해 두는 습관이 안전합니다.

xtabs()

xtabs(formula, data, subset, sparse = FALSE, na.action, addNA = FALSE)는 formula 표기를 이용해 데이터프레임으로부터 곧바로 교차표(table 객체) 를 만듭니다.

  • formula : 빈도변수 ~ 그룹변수1 + 그룹변수2 형태로 지정합니다. 왼쪽(빈도변수)을 생략하고 ~ 그룹변수1 + 그룹변수2만 쓰면, 이미 집계된 빈도 열 없이 원자료의 행 개수 자체를 세어 줍니다.
  • data : 대상 데이터프레임.
  • subset : 집계에 포함할 행을 조건으로 제한합니다.
  • sparse : TRUE로 지정하면 결과를 (범주 조합이 아주 많아 대부분이 0인 경우에 유리한) 희소행렬(sparse matrix, Matrix 패키지의 클래스)로 반환합니다. 데이터의 범주 조합이 수만 개를 넘는 대용량 상황에서 메모리를 아낄 때 유용합니다.
  • addNA : TRUE로 지정하면 그룹변수에 결측치가 있을 때 이를 별도의 범주로 포함시킵니다.

table()이 이미 개수를 센 벡터 여러 개를 인자로 나열해야 하는 것과 달리, xtabs()는 원자료 데이터프레임을 그대로 넣고 formula로 무엇을 기준으로 집계할지 지정할 수 있어, 이미 "인원수"처럼 집계된 열이 데이터에 포함되어 있을 때 특히 편리합니다.

df <- data.frame(
  성별 = c("남", "여", "남", "여", "남", "여", "남", "여"),
  전공 = c("공학", "공학", "인문", "인문", "공학", "인문", "인문", "공학"),
  인원 = c(10, 8, 5, 12, 7, 6, 4, 9)
)

xtabs(인원 ~ 성별 + 전공, data = df)   # "인원" 열의 값을 성별×전공 조합별로 합산
#>     전공
#> 성별 공학 인문
#>   남   17    9
#>   여   17   18

formula 왼쪽을 생략하면(~ 성별 + 전공) "인원" 열의 값을 더하는 대신, 그 조합이 데이터프레임에 몇 행 등장했는지(이 예제에서는 각 조합이 정확히 2행씩 있으므로 전부 2)를 세게 됩니다. 이미 집계된 데이터인지, 아직 집계되지 않은 원자료인지에 따라 어느 쪽을 쓸지가 갈립니다.

xtabs(~ 성별 + 전공, data = df)
#>     전공
#> 성별 공학 인문
#>   남    2    2
#>   여    2    2

xtabs()의 결과는 table 클래스를 상속하므로, table()의 결과와 마찬가지로 prop.table()·addmargins()를 그대로 적용할 수 있고, summary()를 적용하면 두 범주 변수가 서로 독립적인지에 대한 카이제곱 검정 결과까지 간단히 확인할 수 있습니다.

summary(xtabs(인원 ~ 성별 + 전공, data = df))
#> Call: xtabs(formula = 인원 ~ 성별 + 전공, data = df)
#> Number of cases in table: 61 
#> Number of factors: 2 
#> Test for independence of all factors:
#>  Chisq = 1.7093, df = 1, p-value = 0.1911

13.4 재구조화

같은 정보라도 표의 "모양"은 분석 목적에 따라 다르게 요구됩니다. 사람이 보기에는 학생 한 명이 한 행을 차지하고 과목별 점수가 각각의 열로 펼쳐진 형태(wide, 넓은 형태)가 편하지만, aggregate()나 그래프를 그릴 때는 오히려 "학번, 과목, 점수"처럼 관측값 하나가 한 행을 차지하는 형태(long, 긴 형태)가 다루기 쉬운 경우가 많습니다. 이처럼 값 자체는 바꾸지 않고 표의 구조만 wide ↔ long으로 바꾸는 작업을 재구조화(reshaping) 라고 합니다.

학생별로 국어·수학·영어 점수가 각각의 열에 나뉘어 있는 wide 형태의 표를, "학번·과목·점수" 세 열만 있는 long 형태로 바꾸는 상황을 생각해 보겠습니다.

wide <- data.frame(
  id = 1:3, 이름 = c("김민준", "이서연", "박도윤"),
  국어 = c(85, 92, 78), 수학 = c(90, 88, 95), 영어 = c(75, 80, 82)
)

wide
#>   id   이름 국어 수학 영어
#> 1  1 김민준   85   90   75
#> 2  2 이서연   92   88   80
#> 3  3 박도윤   78   95   82

reshape(wide, direction = "long",
        varying = c("국어", "수학", "영어"),
        v.names = "점수", timevar = "과목",
        times = c("국어", "수학", "영어"), idvar = "id") |>
  (\(df) { row.names(df) <- NULL; df })()
#>   id   이름 과목 점수
#> 1  1 김민준 국어   85
#> 2  2 이서연 국어   92
#> 3  3 박도윤 국어   78
#> 4  1 김민준 수학   90
#> 5  2 이서연 수학   88
#> 6  3 박도윤 수학   95
#> 7  1 김민준 영어   75
#> 8  2 이서연 영어   80
#> 9  3 박도윤 영어   82

stack()/unstack()은 여러 개의 벡터(또는 데이터프레임의 여러 열)를 두 열(값, 구분)짜리 표로 합치거나 그 반대로 되돌리는, 비교적 단순한 재구조화를 담당합니다. reshape()는 wide ↔ long 양방향 변환을 모두 지원하는 훨씬 더 범용적인(그리고 그만큼 인자 구성이 복잡한) 함수입니다.

함수 방향 목적
stack() wide → long 여러 벡터·열의 값을 values(값) + ind(원래 열 이름) 두 열로 결합
unstack() long → wide stack()의 역변환
reshape(direction = "long") wide → long 여러 열을 하나의 값 열 + 시점(구분) 변수로
reshape(direction = "wide") long → wide 시점별 값을 각각의 열로 펼침

stack() / unstack()

stack(x, ...)는 여러 열(또는 리스트의 여러 원소)로 나뉘어 있는 값들을 하나의 values(값) 열과 ind(어느 열에서 왔는지 나타내는 factor) 열, 단 두 개의 열로 쌓아 올립니다. unstack(x, form, ...)은 그 반대로, 이렇게 쌓인 두 열짜리 표를 다시 원래의 여러 열 형태로 되돌립니다.

  • x : stack()에는 여러 열을 가진 데이터프레임(또는 벡터의 리스트)을, unstack()에는 stack()이 만든 것과 같은 형태(값 열 + 구분 열)의 데이터프레임을 넣습니다.
  • form : unstack()에서 어느 열이 값이고 어느 열이 구분 기준인지를 값열 ~ 구분열 형태의 formula로 지정합니다. 생략하면 관례적인 이름(values, ind)을 그대로 사용합니다.

과목별 열로 나뉜 성적표를 stack()으로 쌓아 올리면, 마치 여러 열을 옆으로 이어 붙이는 대신 위아래로 쌓는 것과 같은 결과가 됩니다.

scores <- data.frame(
  국어 = c(85, 92, 78),
  수학 = c(90, 88, 95),
  영어 = c(75, 80, 82)
)
rownames(scores) <- c("김민준", "이서연", "박도윤")

stacked <- stack(scores)
stacked
#>   values  ind
#> 1     85 국어
#> 2     92 국어
#> 3     78 국어
#> 4     90 수학
#> 5     88 수학
#> 6     95 수학
#> 7     75 영어
#> 8     80 영어
#> 9     82 영어

unstack()은 이 결과를 다시 원래의 wide 형태로 되돌립니다. form 인자를 생략하면 stack()이 만든 기본 이름(values, ind)을 그대로 인식합니다.

unstack(stacked)                  # form 생략 시 values ~ ind로 자동 인식
unstack(stacked, values ~ ind)    # 위와 동일, 명시적으로 지정
#>   국어 수학 영어
#> 1   85   90   75
#> 2   92   88   80
#> 3   78   95   82

stack()은 여러 그룹의 값을 나란히 비교하는 그래프(예: 상자그림)를 그리기 전, 데이터를 그래프 함수가 기대하는 long 형태로 빠르게 바꿀 때 특히 자주 쓰입니다. 다만 원래의 행 이름(위 예제의 학생 이름)은 결과에 남지 않는다는 한계가 있어, 개체 식별자까지 함께 유지해야 한다면 아래의 reshape()가 더 적합합니다.

reshape()

reshape(data, direction = c("wide", "long"), varying, v.names, timevar, idvar, sep = ".", ...)는 데이터프레임을 wide 형태와 long 형태 사이에서 양방향으로 변환합니다. Base R 함수 중 인자 구성이 까다롭기로 잘 알려진 함수이지만, 핵심 인자 몇 개의 역할만 정확히 이해하면 충분히 다룰 수 있습니다.

  • data : 변환할 데이터프레임.
  • direction : "long"이면 wide → long으로, "wide"이면 long → wide로 변환합니다.
  • varying : (wide → long 변환 시) 하나로 합쳐질, 즉 "같은 성격이지만 시점·항목만 다른" 열 이름들을 벡터로 지정합니다.
  • v.names : 합쳐진 값을 담을 새 열의 이름(위 예제의 "점수"처럼)을 지정합니다.
  • timevar : 각 값이 원래 어느 열(시점·항목)에서 왔는지를 표시할 새 열의 이름을 지정합니다(위 예제의 "과목").
  • idvar : 개체(행)를 구분하는 식별자 열의 이름을 지정합니다. 이 열의 값이 같은 행들은 원래 하나의 개체(wide 형태에서 하나의 행)였다는 뜻입니다.
  • times : timevar 열에 채워질 값을 직접 지정합니다(생략하면 varying에 나열한 열 이름이 그대로 쓰입니다).
  • sep : (long → wide 변환 시) 새로 만들어질 열 이름을 v.names와 timevar 값을 이어 붙여 지을 때 사용할 구분자입니다(기본값 ".", 예: "점수.국어").

wide → long 변환(넓은 형태를 긴 형태로 풀어내기)의 기본 예제는 위 "구체적 사례"에서 이미 살펴보았습니다. 그 결과(long)를 다시 long → wide로 되돌리려면 direction = "wide"와 idvar·timevar·v.names를 지정합니다.

long <- reshape(wide, direction = "long",
                 varying = c("국어", "수학", "영어"),
                 v.names = "점수", timevar = "과목",
                 times = c("국어", "수학", "영어"), idvar = "id")

back_wide <- reshape(long, direction = "wide",
                      idvar = "id", timevar = "과목", v.names = "점수")
back_wide
#>        id   이름 점수.국어 점수.수학 점수.영어
#> 1.국어  1 김민준        85        90        75
#> 2.국어  2 이서연        92        88        80
#> 3.국어  3 박도윤        78        95        82

long → wide 변환에서는 새로 만들어지는 열 이름이 v.names와 timevar 값을 sep(기본값 ".")으로 이어 붙인 형태("점수.국어")가 된다는 점, 그리고 행 이름이 reshape() 내부적으로 자동 생성된 값("1.국어" 등)을 그대로 물려받는다는 점에 유의해야 합니다. 행 이름이 거슬린다면 row.names(back_wide) <- NULL로 초기화하면 됩니다.

row.names(back_wide) <- NULL
back_wide
#>   id   이름 점수.국어 점수.수학 점수.영어
#> 1  1 김민준        85        90        75
#> 2  2 이서연        92        88        80
#> 3  3 박도윤        78        95        82

💡 참고: reshape()는 인자 이름과 역할을 외우기 쉽지 않아 실무에서는 tidyr 패키지의 pivot_longer()/pivot_wider()로 대체되는 경우가 많습니다. 다만 이 매뉴얼은 Base R 전용 범위이므로, wide ↔ long 변환이 필요할 때 별도 패키지 설치 없이 쓸 수 있는 표준 도구로 reshape()를 소개했습니다.