dplyr·tidyr로 생물 데이터 정리하기 — 분석 전 90%는 데이터 정리 (R 실전)

TL;DR — 발현행렬·메타데이터·DEG 표를 다룰 때 실제 시간의 대부분은 통계가 아니라 데이터 정리에 들어갑니다. tidyverse의 dplyr (행·열 조작)과 tidyr (형식 변환)이 그 일을 맡죠. dplyr의 다섯 동사 —
filter(행)·select(열)·mutate(파생열)·arrange(정렬)·summarise+group_by(집계) — 와 tidyr의pivot_longer/pivot_wider(wide↔long), 파이프|>만 손에 익으면 대부분의 정리가 한 흐름으로 이어집니다.이 글은 더미 발현 데이터를 코드로 만들어 결합·집계·필터·형식 변환까지 복붙하면 그대로 도는 예시로 따라갑니다. ggplot2 글과 함께 'tidyverse 기초'를 이루는 짝입니다. (코드는 dplyr 1.1+ / tidyr 1.3+ 기준으로 실제 실행해 확인했습니다.)
🔗 관련 글 · 정리한 데이터를 그림·분석으로: ggplot2 기초 · DESeq2로 차등발현 분석 · GEOquery로 공개데이터 받기

이 글에서 할 것
분석 코드를 짜다 보면 정작 DESeq2나 ggplot2를 부르기 전에, 표 모양을 맞추는 데 대부분의 시간을 씁니다. 발현행렬은 유전자가 행·샘플이 열인데 그림을 그리려면 long (긴 형식)으로 바꿔야 하고, 메타데이터의 그룹 정보는 발현값과 다른 표에 들어 있어 결합(join)해야 하죠. DEG 결과에서는 유의한 유전자만 골라 정렬해야 합니다.
이 글은 그 정리 과정을 dplyr·tidyr로 처리합니다. 유전자 × 샘플 발현행렬과 샘플 메타데이터를 코드로 만든 뒤 — ① 두 표를 결합하고 ② 그룹별 평균 발현을 구하고 ③ DEG 표에서 유의 유전자를 추리고 ④ wide 발현행렬을 long으로 바꿔 ggplot2에 넘길 준비까지, 한 단계씩 따라갑니다.
# 이 글이 반복해서 쓰는 한 패턴 — 데이터를 파이프로 흘려보낸다
expr |>
pivot_longer(-gene, names_to = "sample", values_to = "expr") |> # wide → long
left_join(meta, by = "sample") |> # 메타 결합
summarise(mean_expr = mean(expr), .by = c(gene, group)) # 그룹별 평균
0. tidy data — 정리의 목표가 뭔가
dplyr·tidyr가 전제하는 데이터 모양이 tidy data (정돈된 데이터)입니다. 규칙은 세 가지뿐입니다 — 한 변수는 한 열, 한 관측은 한 행, 한 종류의 관측 단위는 한 표. 발현 데이터로 말하면 "어떤 유전자가 어떤 샘플에서 얼마로 발현됐다"가 한 관측이니, tidy 형태에서는 gene·sample·expr 세 열에 한 측정이 한 행으로 들어갑니다 (Wickham 2014).
그런데 우리가 흔히 받는 발현행렬은 샘플이 열로 펼쳐진 wide (넓은 형식)입니다. 사람이 보기엔 편하지만 ggplot2·통계 함수는 tidy long을 좋아하죠. 그래서 tidyr의 pivot_longer로 wide를 long으로 눕히는 작업이 거의 항상 첫 단계가 됩니다. 정리의 목표는 결국 "지금 표를 tidy 형태로 만드는 것"이라고 보면 됩니다.
1. 사전 준비 — 설치와 예시 데이터
dplyr·tidyr는 둘 다 tidyverse 묶음에 들어 있습니다. tidyverse를 깔면 dplyr (데이터 조작)·tidyr (형식 변환)·ggplot2 (시각화) 등이 한 번에 들어와 편합니다.
install.packages("tidyverse") # dplyr + tidyr + ggplot2 ... 한 번에 (최초 1회)
library(dplyr) # 행·열 조작: filter·select·mutate·arrange·summarise·group_by
library(tidyr) # 형식 변환: pivot_longer·pivot_wider·separate·drop_na
이제 예시 데이터를 코드로 만듭니다. 유전자 6개 × 샘플 6개 발현행렬, 샘플별 그룹을 담은 메타데이터, 그리고 DEG 결과 표 — 세 개를 더미로 생성합니다. set.seed로 난수를 고정해 누가 돌려도 같은 값이 나오게 합니다.
set.seed(1)
# 발현행렬: 유전자(행) × 샘플(열), 첫 열은 gene 이름 (wide 형식)
genes <- paste0("Gene", 1:6)
expr <- data.frame(
gene = genes,
S1 = round(rnorm(6, 8, 2), 2), S2 = round(rnorm(6, 8, 2), 2),
S3 = round(rnorm(6, 8, 2), 2), S4 = round(rnorm(6, 9, 2), 2),
S5 = round(rnorm(6, 9, 2), 2), S6 = round(rnorm(6, 9, 2), 2)
)
# 샘플 메타데이터: 샘플마다 그룹·배치 (별도 표)
meta <- data.frame(
sample = paste0("S", 1:6),
group = rep(c("control", "treated"), each = 3),
batch = rep(c("b1", "b2"), times = 3)
)
# DEG 결과 표: logFC와 보정 p값 (DESeq2/limma 출력을 흉내)
deg <- data.frame(
gene = genes,
logFC = c(2.4, -1.8, 0.3, 1.2, -0.5, 3.1),
adj.P.Val = c(0.001, 0.004, 0.62, 0.03, 0.41, 0.0008)
)
head(expr, 3)
gene S1 S2 S3 S4 S5 S6
1 Gene1 6.75 9.84 9.62 11.36 8.39 9.39
2 Gene2 8.37 8.16 9.74 8.88 9.78 11.93
3 Gene3 6.33 9.49 6.85 9.07 9.59 9.94
2. 파이프 — |> vs %>%
본격적으로 들어가기 전에 파이프 (pipe)부터 짚겠습니다. 파이프는 왼쪽 결과를 오른쪽 함수의 첫 인자로 넘기는 연산자라, f(g(x)) 같은 중첩을 x |> g() |> f()처럼 읽는 순서대로 펼 수 있습니다.
R에는 파이프가 둘 있습니다. base R에 내장된 |> (R 4.1+)와, magrittr 패키지의 %>% (tidyverse가 오래 써 온 것)입니다. 일상적인 정리 작업에서는 거의 똑같이 동작하니, 새로 배운다면 의존성이 없는 |>를 권합니다.
# 아래 셋은 같은 일을 한다 — head(arrange(deg, adj.P.Val), 3)
deg |> arrange(adj.P.Val) |> head(3) # base 파이프 (권장)
deg %>% arrange(adj.P.Val) %>% head(3) # magrittr 파이프 (tidyverse 전통)
3. dplyr 다섯 동사 — filter·select·mutate·arrange
dplyr는 데이터 조작을 다섯 개의 동사 (verb) 함수로 정리합니다. 전부 "데이터프레임을 받아 데이터프레임을 돌려준다"는 규칙이 같아, 파이프로 줄줄이 이을 수 있습니다.
deg |>
filter(adj.P.Val < 0.05, abs(logFC) > 1) |> # 행 고르기: 유의 + 효과크기 큰 DEG
mutate(direction = if_else(logFC > 0, "up", "down")) |> # 파생열 추가
select(gene, logFC, direction) |> # 열 고르기 (순서도 정렬)
arrange(desc(abs(logFC))) # |logFC| 내림차순 정렬
gene logFC direction
1 Gene6 3.1 up
2 Gene1 2.4 up
3 Gene2 -1.8 down
4 Gene4 1.2 up
한 줄씩 뜯어보면 — filter는 조건을 만족하는 행만 남기고 (쉼표로 나열하면 AND), mutate는 기존 열로 새 열을 계산하며, select는 필요한 열만 골라 순서까지 정합니다. arrange는 정렬인데 desc()로 감싸면 내림차순입니다. 여기서 adj.P.Val < 0.05 & abs(logFC) > 1은 DEG를 추릴 때 가장 흔히 쓰는 조건이죠.
여기에 더해 자주 쓰는 보조 동사도 있습니다.
deg |> count(adj.P.Val < 0.05) # 조건별 개수 세기
meta |> distinct(group) # 중복 제거한 고유값
deg |> slice_max(logFC, n = 2) # logFC 상위 2개 행
4. group_by + summarise — 그룹별 집계
데이터를 그룹으로 묶어 요약 (aggregate)하는 건 정리에서 가장 자주 하는 일입니다. group_by로 묶고 summarise로 집계하면 "그룹마다 한 줄"로 줄어듭니다. 먼저 발현행렬을 long으로 바꿔야 그룹 집계가 자연스러운데, long 변환은 6절에서 자세히 보고 여기서는 결과만 씁니다.
# expr를 long으로 바꾼 뒤 메타 결합 (자세한 변환은 6절)
long <- expr |>
pivot_longer(-gene, names_to = "sample", values_to = "expr") |>
left_join(meta, by = "sample")
# 유전자 × 그룹별 평균·표준편차 발현
long |>
group_by(gene, group) |>
summarise(mean_expr = mean(expr),
sd_expr = sd(expr),
n = n(), # 그룹 내 샘플 수
.groups = "drop") # 집계 후 그룹 해제
# A tibble: 12 × 5
gene group mean_expr sd_expr n
<chr> <chr> <dbl> <dbl> <int>
1 Gene1 control 8.74 1.69 3
2 Gene1 treated 9.71 1.49 3
3 Gene2 control 8.76 0.860 3
...
summarise 안에서 mean·sd·n 여러 통계를 한 번에 계산했습니다. 집계가 끝나면 .groups = "drop"으로 그룹을 풀어 줘야 뒤 단계에서 의도치 않은 그룹 연산을 막을 수 있습니다 (예전 코드의 ungroup()과 같은 역할). dplyr 1.1+에서는 summarise(..., .by = c(gene, group))처럼 .by 인자로 그룹을 그 자리에서만 적용할 수도 있어, group_by + ungroup을 한 줄로 대체합니다.
여러 열에 같은 요약을 한꺼번에 적용할 때는 across가 깔끔합니다.
# 모든 숫자형 샘플 열을 z-점수로 정규화 (mutate + across)
expr |>
mutate(across(where(is.numeric), ~ (.x - mean(.x)) / sd(.x))) # 열마다 평균 0·분산 1
5. join — 발현값과 메타데이터 결합
발현값과 그룹 정보가 다른 표에 있을 때, 공통 열을 기준으로 붙이는 게 join (결합)입니다. dplyr의 left_join은 왼쪽 표를 다 남기고 오른쪽에서 짝을 찾아 붙이고, inner_join은 양쪽에 다 있는 행만 남깁니다. by로 기준 열을 지정합니다.
long <- expr |>
pivot_longer(-gene, names_to = "sample", values_to = "expr")
long |> left_join(meta, by = "sample") |> head(4) # sample 기준으로 group·batch 붙이기
# A tibble: 4 × 5
gene sample expr group batch
<chr> <chr> <dbl> <chr> <chr>
1 Gene1 S1 6.75 control b1
2 Gene1 S2 9.84 control b2
3 Gene1 S3 9.62 control b1
4 Gene1 S4 11.4 treated b2
기준 열의 이름이 양쪽에서 다르면 by = c("sample" = "sample_id")처럼 짝을 명시합니다. join 뒤에는 행 수가 예상대로인지 꼭 확인하세요 — 오른쪽 표에 같은 키가 여러 번 있으면 행이 의도치 않게 불어날 수 있습니다.
6. pivot_longer / pivot_wider — wide ↔ long
tidyr의 핵심은 표의 형식을 바꾸는 두 함수입니다. pivot_longer는 여러 열에 펼쳐진 값을 한 열로 눕혀 long (긴 형식)으로 만들고, pivot_wider는 거꾸로 한 열을 여러 열로 펼쳐 wide (넓은 형식)로 되돌립니다.

# wide → long: 샘플 열(S1~S6)을 sample·expr 두 열로 눕히기
long <- expr |>
pivot_longer(
cols = -gene, # gene만 빼고 나머지 열을 모은다
names_to = "sample", # 옛 열 이름이 들어갈 새 열
values_to = "expr") # 옛 셀 값이 들어갈 새 열
head(long, 3)
# A tibble: 3 × 3
gene sample expr
<chr> <chr> <dbl>
1 Gene1 S1 6.75
2 Gene1 S2 9.84
3 Gene1 S3 9.62
이제 36행짜리 long이 됐고, ggplot2·통계 함수에 바로 넘길 수 있는 tidy 형태입니다. 반대로 long을 다시 발현행렬 모양으로 펴려면 pivot_wider를 씁니다.
# long → wide: 원래 발현행렬 모양으로 복원
long |>
pivot_wider(names_from = sample, values_from = expr) # sample 값이 열 이름이 된다
names_from은 어떤 열의 값을 새 열 이름으로 쓸지, values_from은 어떤 열의 값을 셀에 채울지를 가리킵니다. pivot_longer의 names_to/values_to와 방향이 반대라고 기억하면 헷갈리지 않습니다.
문자열이 한 열에 뭉쳐 있을 때는 separate로 쪼개고 unite로 합칩니다.
# "Gene1_control" 같은 합성 ID를 두 열로 분리
deg |>
mutate(label = paste(gene, c("up","dn","ns","up","ns","up"), sep = "_")) |>
separate(label, into = c("g", "dir"), sep = "_") # _ 기준으로 g·dir 분리
7. 결측값 — drop_na / replace_na
실제 데이터에는 NA (결측값)가 섞이기 마련입니다. tidyr의 drop_na는 NA가 있는 행을 버리고, replace_na는 NA를 특정 값으로 채웁니다.
# 일부러 결측을 넣은 표
long_na <- long
long_na$expr[c(2, 5)] <- NA
long_na |> drop_na(expr) # expr이 NA인 행 제거
long_na |> mutate(expr = replace_na(expr, 0)) # NA를 0으로 채우기
drop_na()를 열 지정 없이 부르면 아무 열에든 NA가 있는 행을 모두 버리니, 보통은 drop_na(expr)처럼 대상 열을 명시하는 게 안전합니다.
8. dplyr · tidyr 치트시트
자주 쓰는 함수를 한 표로 정리했습니다.
| 패키지 | 함수 | 하는 일 |
|---|---|---|
| dplyr | filter() | 조건에 맞는 행 고르기 |
| dplyr | select() | 열 고르기·순서 바꾸기 |
| dplyr | mutate() | 기존 열로 새 열 만들기 |
| dplyr | arrange() | 행 정렬 (desc()로 내림차순) |
| dplyr | group_by() + summarise() | 그룹별 집계 |
| dplyr | count() · distinct() | 개수 세기 · 고유값 |
| dplyr | slice_max() · slice_min() | 상·하위 n행 |
| dplyr | across() | 여러 열에 같은 변환 |
| dplyr | left_join() · inner_join() | 표 결합 (by=) |
| tidyr | pivot_longer() | wide → long |
| tidyr | pivot_wider() | long → wide |
| tidyr | separate() · unite() | 열 분리 · 합치기 |
| tidyr | drop_na() · replace_na() | 결측 제거 · 대치 |

9. 자주 발생하는 에러 & 해결
filter에서=를 썼다 —filter(group = "treated")는 에러입니다. 비교는 반드시==를 씁니다 (filter(group == "treated")).=는 인자 대입,==는 같은지 비교라는 점을 늘 구분하세요.filter가 NA 행을 말없이 버린다 —filter(adj.P.Val < 0.05)는adj.P.Val이NA인 행을 조건 불만족으로 보고 제외합니다. NA도 남기려면filter(adj.P.Val < 0.05 | is.na(adj.P.Val))처럼 명시합니다.group_by후 그룹을 안 풀었다 —summarise뒤에도 그룹이 남아 있으면 이어지는mutate가 그룹 단위로 계산돼 엉뚱한 결과가 납니다..groups = "drop"이나ungroup()으로 풀어 주세요.- pivot에서
names_to/values_to를 빠뜨렸다 —pivot_longer는 새로 생길 두 열의 이름을 지정해야 합니다. 빠뜨리면 기본 이름 (name·value)이 붙어 뒤 코드와 어긋납니다. - join 후 행이 불어났다 — 오른쪽 표의 키가 유일하지 않으면 (한
sample이 여러 번 등장) 행이 곱으로 늘어납니다. join 전에meta |> distinct(sample)또는count(sample)로 키 중복을 확인하세요.
• (바로 다음) ggplot2 기초 — 여기서 만든 long 데이터를 그대로
ggplot()에 넣어 그림 그리기• (분석 본론) DESeq2로 차등발현 분석 — 정리한 카운트 행렬로 DEG 찾기 · Volcano plot 시각화
• (데이터 확보) GEOquery로 공개데이터 받기 · RNA-seq 정규화란?
References
- Wickham, H. (2014). Tidy Data. Journal of Statistical Software, 59(10), 1–23. (tidy data 3원칙)
- Wickham, H., Çetinkaya-Rundel, M., Grolemund, G. (2023). R for Data Science (2nd ed.). O'Reilly. (dplyr·tidyr·파이프 표준 교재, r4ds.hadley.nz)
- dplyr 1.1+ documentation (tidyverse). filter·select·mutate·arrange·summarise·group_by·across·join·
.by. - tidyr 1.3+ documentation (tidyverse). pivot_longer·pivot_wider·separate·unite·drop_na·replace_na.
Pipette & Pipeline · A bio portfolio journal
'Code Bench > R Snippets' 카테고리의 다른 글
| R로 기초 통계 검정하기 — t-검정·ANOVA·상관·비모수 (R 실전) (0) | 2026.06.18 |
|---|---|
| pheatmap으로 발현 히트맵 빠르게 그리기 — z-score·클러스터링·주석 (R 실전) (0) | 2026.06.18 |
| tximport로 salmon·kallisto 정량 불러오기 — RNA-seq 카운트의 출발점 (R 실전) (0) | 2026.06.17 |
| ggplot2 기초 — 바이오 데이터 그래프 그리기 (산점도·막대·박스플롯, R 실전) (0) | 2026.06.17 |
| limma-voom으로 RNA-seq 차등발현(DEG) 분석하기 — DESeq2·edgeR과 무엇이 다른가 (R 실전) (0) | 2026.06.17 |