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로 공개데이터 받기
그림 1. 원본 표를 filter·mutate·group_by+summarise로 한 겹씩 정리해 결과로 잇는 dplyr의 흐름.

이 글에서 할 것

분석 코드를 짜다 보면 정작 DESeq2ggplot2를 부르기 전에, 표 모양을 맞추는 데 대부분의 시간을 씁니다. 발현행렬은 유전자가 행·샘플이 열인데 그림을 그리려면 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 (넓은 형식)로 되돌립니다.

그림 2. pivot_longer로 wide 발현행렬을 long으로 눕히고, pivot_wider로 되돌리는 형식 변환.
# 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_longernames_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_naNA가 있는 행을 버리고, replace_naNA를 특정 값으로 채웁니다.

# 일부러 결측을 넣은 표
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 치트시트

자주 쓰는 함수를 한 표로 정리했습니다.

패키지 함수 하는 일
dplyrfilter()조건에 맞는 고르기
dplyrselect() 고르기·순서 바꾸기
dplyrmutate()기존 열로 새 열 만들기
dplyrarrange()정렬 (desc()로 내림차순)
dplyrgroup_by() + summarise()그룹별 집계
dplyrcount() · distinct()개수 세기 · 고유값
dplyrslice_max() · slice_min()상·하위 n
dplyracross()여러 열에 같은 변환
dplyrleft_join() · inner_join()결합 (by=)
tidyrpivot_longer()wide → long
tidyrpivot_wider()long → wide
tidyrseparate() · unite()열 분리 · 합치기
tidyrdrop_na() · replace_na()결측 제거 · 대치
그림 3. sample 키를 기준으로 발현행렬과 메타데이터를 left_join으로 붙여 그룹·배치를 발현값에 잇는 결합.

9. 자주 발생하는 에러 & 해결

  • filter에서 =를 썼다filter(group = "treated")는 에러입니다. 비교는 반드시 ==를 씁니다 (filter(group == "treated")). =는 인자 대입, ==는 같은지 비교라는 점을 늘 구분하세요.
  • filter가 NA 행을 말없이 버린다filter(adj.P.Val < 0.05)adj.P.ValNA인 행을 조건 불만족으로 보고 제외합니다. 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

  1. Wickham, H. (2014). Tidy Data. Journal of Statistical Software, 59(10), 1–23. (tidy data 3원칙)
  2. Wickham, H., Çetinkaya-Rundel, M., Grolemund, G. (2023). R for Data Science (2nd ed.). O'Reilly. (dplyr·tidyr·파이프 표준 교재, r4ds.hadley.nz)
  3. dplyr 1.1+ documentation (tidyverse). filter·select·mutate·arrange·summarise·group_by·across·join·.by.
  4. tidyr 1.3+ documentation (tidyverse). pivot_longer·pivot_wider·separate·unite·drop_na·replace_na.

Pipette & Pipeline · A bio portfolio journal

이 글을 쓴 사람 Yumingming

생명융합공학과 박사과정.
Microbiome · Cosmetics · RNA Therapeutics · Bioinformatics를 공부하며,
실험(Wet Lab)과 데이터(Dry Lab)를 잇는 글을 논문(article) 기반으로 씁니다.

About · 더 알아보기 →

⚕️ 이 글은 학습·정보 제공 목적이며, 의학적 진단·치료·조언을 대체하지 않습니다. 건강·질병·치료에 관한 결정은 반드시 의사 등 전문가와 상의하세요. 자세한 내용은 면책조항을 참고해 주세요.