Devin.KR

데이터 변형 심화 - 길게·넓게·나눠 묶기

개발자KR 조회 0

이 장에서 배우는 것

판매 자료는 분석 목적에 따라 다른 모양을 요구한다. 담당자가 입력하기 편한 표는 상품마다 열을 두지만, 상품별 판매량을 계산할 때는 상품 이름과 판매량을 각각 한 열에 두는 편이 편리하다. 지점 이름은 지점 관리표에 있고 재고는 별도 표에 있을 수도 있다. 표의 모양을 바꾸고, 필요한 정보를 연결하고, 같은 기준의 행을 묶어 요약하는 작업이 이어져야 지점별 집계표가 만들어진다.

앞 장에서 객체의 크기와 복사 비용을 살펴보았다. 이 장에서는 표를 변형하는 과정에서 행의 의미와 개수를 확인하는 데 초점을 둔다. 변형이 빠르게 끝났더라도 판매량이 중복 합산되거나 관측하지 않은 값을 0으로 바꾸었다면 결과를 사용할 수 없다. 각 단계에 작은 검사를 붙여 자료의 의미가 유지되는지 확인한다.

  • reshape()로 넓은 형식과 긴 형식을 오가며 행을 식별하는 기준을 정한다.
  • merge()의 네 가지 조인 결과를 설명하고, 결합 키의 중복을 검사한다.
  • 나누기·적용하기·합치기 흐름을 split()과 집계 함수로 구현한다.
  • aggregate(), tapply(), by()의 입력과 결과 구조를 구분한다.
  • 판매 기록이 없는 지점과 일부 판매량이 누락된 지점을 구별하는 집계표를 만든다.

문제 상황

동네 편의점 체인이 물과 과자의 판매량을 매일 모은다고 하자. 입력 파일에는 지점 코드, 날짜, 물 판매량, 과자 판매량이 들어 있다. 두 상품의 판매량이 서로 다른 열에 있으므로 사람이 하루 기록을 확인하기는 쉽다. 그러나 상품별 재고표와 연결하려면 판매 자료에도 상품을 나타내는 열이 필요하다.

이번 자료에는 A 지점의 이틀 기록과 B, C 지점의 하루 기록이 있다. C 지점은 물 판매량을 0으로 보고했지만 과자 판매량은 빠뜨렸다. 지점 관리표에는 D 지점도 등록되어 있으나 이번 기간의 판매 기록은 없다. 이 세 상태를 구별해야 한다. 판매량 0은 관측된 값이고, 누락된 판매량은 알 수 없는 값이며, 판매 기록이 없는 지점은 집계할 원본 행 자체가 없는 경우다.

최종 결과는 두 가지다. 첫째는 지점 이름, 기록 일수, 확인된 판매량 합계, 누락 건수를 가진 지점별 집계표다. 둘째는 지점·상품별 기초 재고에 확인된 판매량 합계를 붙인 표다. 재고표의 수치는 기간 시작 시점의 값이다. 입고와 반품 자료가 없으므로 여기서는 잔여 재고를 계산하지 않는다. 판매와 재고를 같은 키로 연결하는 과정만 다룬다.

집계의 의미도 먼저 정한다. 확인된 판매량 합계는 누락값을 제외한 합이다. 따라서 C 지점의 확인된 합계는 0이지만 기간 전체 판매량이 0이라고 결론 내리지는 않는다. D 지점의 합계는 결합 후에도 결측값으로 남긴다. 보고서에는 두 경우의 상태를 따로 표시한다.

표의 모양과 행의 기준

넓게 저장하고 길게 계산한다

넓은 형식(wide format)은 같은 종류의 측정값을 여러 열에 배치한다. 예제의 units.water와 units.chips가 여기에 해당한다. 긴 형식(long format)은 측정 대상을 한 열로 모으고 측정값을 다른 열에 둔다. 변형 후에는 item이 상품을, units가 판매량을 나타낸다.

긴 형식이 모든 작업에 더 적합한 것은 아니다. 사람에게 지점별 상품 판매량을 나란히 보여 주려면 넓은 표가 읽기 쉽다. 반면 상품별로 같은 계산을 반복하거나 상품 재고표를 연결할 때는 긴 표가 다루기 쉽다. 표의 모양은 저장 규칙이 아니라 작업에 맞추어 선택하는 표현이다.

상품별 열을 상품과 판매량 열로 모으면 한 날짜의 기록이 상품 수만큼 늘어난다

reshape()는 어느 열이 반복 측정값인지, 반복 측정 대상을 무엇이라고 부를지, 원래 행을 무엇으로 식별할지를 알아야 한다. 이 장에서는 varying에 판매량 열을 직접 지정한다. v.names는 모인 판매량 열의 이름이고, timevar는 상품을 담을 열의 이름이다. 함수의 인자 이름에 시간이 들어가지만 반복 대상을 반드시 시간으로 제한하지는 않는다.

times에는 water, chips를 지정한다. 열 이름을 자동으로 해석하게 맡기는 대신, 반복 대상의 이름과 순서를 코드에 드러낸다. idvar에는 지점과 날짜를 함께 지정한다. 원래 표에서 한 행은 한 지점의 하루 기록이므로 두 열의 조합이 식별 기준이다.

긴 표에서는 지점·날짜·상품의 조합이 한 행을 식별한다. 같은 조합이 두 번 나오면 넓은 표의 한 칸에 무엇을 넣어야 하는지 모호해진다. reshape()는 합계나 평균을 정하는 집계 함수가 아니다. 여러 거래 행을 하루 판매량으로 만들 필요가 있다면 먼저 집계하고, 그 결과를 변형해야 한다.

변형 전후의 행 수는 중요한 검사 대상이다. 원래 네 행에 상품이 두 개 있으므로 긴 표는 여덟 행이어야 한다. 누락된 판매량도 행으로 남는다. 결측값이 들어 있는 행을 없애면 해당 상품을 보고하지 않았다는 정보까지 사라진다.

예제에서 표마다 한 행을 식별하는 기준이 다르다
표한 행의 의미식별 기준
넓은 판매표한 지점의 하루 기록지점·날짜
긴 판매표한 지점의 하루 상품 기록지점·날짜·상품
지점 집계표한 지점의 기간 요약지점
기초 재고표한 지점의 상품별 기초 재고지점·상품

다시 넓힐 때 확인할 것

긴 표를 다시 넓히면 units.water와 units.chips가 생긴다. 이때 변형으로 생성된 행 이름이나 기본 행 순서에 의존하지 않는다. 필요한 열을 선택하고 지점·날짜순으로 정렬한 뒤 행 이름을 초기화한다. 완성 코드에서는 이렇게 정리한 복원 결과를 원본과 비교한다.

왕복 검사는 열과 값이 유지되었는지 확인하는 데 유용하다. 다만 모든 변형이 왕복 가능한 것은 아니다. 집계는 여러 행을 한 행으로 줄이므로 원래의 개별 기록을 복구할 수 없다. 표의 모양만 바꾸는 단계와 정보를 요약하는 단계를 구분해야 검사 기준도 분명해진다.

키로 연결하고 필요한 행을 남긴다

조인(join)은 키 값이 같은 행을 연결하는 작업이다. merge()에서는 by로 키 열을 지정한다. 지점 이름을 붙일 때는 지점 코드 하나가 키이고, 상품별 판매 합계를 재고표에 붙일 때는 지점 코드와 상품 코드 두 개가 키다. 같은 이름의 열이 여러 개 있어도 결합 기준을 명시하면 의도를 읽기 쉽다.

여기서는 왼쪽 표를 x, 오른쪽 표를 y라고 부른다. 내부 조인(inner join)은 양쪽에 있는 키만 남긴다. 왼쪽 조인(left join)은 왼쪽의 모든 행을 남기며, 오른쪽에서 찾지 못한 열에는 결측값을 넣는다. 오른쪽 조인(right join)은 반대로 오른쪽 행을 모두 남긴다. 전체 조인(full join)은 양쪽의 키를 모두 남긴다.

merge의 all.x와 all.y가 남길 행의 범위를 결정한다
종류지정남기는 키예제 행 수
내부all.x = FALSE, all.y = FALSE양쪽에 있는 키3
왼쪽all.x = TRUE, all.y = FALSE왼쪽의 키3
오른쪽all.x = FALSE, all.y = TRUE오른쪽의 키4
전체all = TRUE양쪽의 모든 키4

표의 행 수는 왼쪽에 A, B, C의 지점 요약을 놓고 오른쪽에 A, B, C, D의 지점 관리표를 놓았을 때의 값이다. 양쪽에 공통인 키는 세 개다. 오른쪽에만 있는 D는 오른쪽 조인과 전체 조인에서 남는다. 같은 두 표라도 좌우를 바꾸면 왼쪽 조인의 의미가 달라진다.

최종 지점 보고서에서는 지점 관리표를 왼쪽에 둔다. 등록된 지점을 빠짐없이 보여 주려는 목적 때문이다. all.x = TRUE로 지점 요약을 붙이면 D도 결과에 남는다. 판매 기록만 왼쪽에 두고 기본 조인을 사용하면 D가 사라져 보고 대상에서 누락된다.

행의 개수는 조인 종류만으로 정해지지 않는다. 같은 키가 왼쪽에 두 행, 오른쪽에 세 행 있으면 그 키의 결합 결과는 여섯 행이다. 한쪽의 지점 이름이 중복 등록되면 판매 기록도 반복될 수 있다. all.x = TRUE는 왼쪽 행을 남긴다는 뜻이지, 결과 행 수를 왼쪽과 같게 보장한다는 뜻은 아니다.

결측 키에도 주의한다. 기본 설정의 merge()는 결측 키끼리도 대응시킬 수 있다. 누락된 지점 코드 두 개가 같은 지점을 뜻한다고 해석해서는 안 된다. 이 예제는 지점·날짜·상품 키에 결측값을 허용하지 않고, 변형과 결합 전에 검사한다. 복합 키에서는 각 열의 결측 여부를 함께 확인하는 방식이 명확하다.

merge()는 기본적으로 키에 따라 결과를 정렬한다. sort = FALSE를 지정해도 원하는 원본 순서가 유지된다고 가정하지 않는다. 출력 순서가 계약의 일부라면 결합 후 order()로 직접 정한다. 완성 코드도 보고서와 재고 연결표를 각각 정렬한다.

나누고 계산한 뒤 다시 묶는다

나누기·적용하기·합치기(split-apply-combine)는 집계 작업을 세 단계로 보는 방법이다. 먼저 그룹 기준에 따라 행을 나눈다. 각 조각에 같은 함수를 적용한다. 마지막으로 작은 결과를 하나의 구조로 묶는다. 지점별 총판매량, 상품별 평균 판매량, 날짜별 누락 건수는 모두 이 흐름으로 설명할 수 있다.

지점별로 나눈 자료에 같은 요약 함수를 적용하면 지점당 한 행의 집계표가 된다

결과 구조에 맞는 함수를 고른다

aggregate()는 그룹별 요약을 데이터 프레임으로 받기 편하다. 여러 그룹 열을 지정할 수 있으므로 지점·상품별 판매 합계처럼 다음 결합에 사용할 표를 만들기에 적합하다. 완성 코드에서는 데이터 프레임 방식으로 판매량 열과 그룹 열을 전달한다. 이를 통해 누락값을 제외한다는 정책을 요약 함수 안에 명시한다.

tapply()는 벡터를 그룹별로 나누어 계산한다. 지점 하나를 기준으로 합계를 구하면 이름이 붙은 일차원 배열이 나온다. 지점과 상품을 함께 기준으로 삼으면 각 기준을 차원으로 가지는 배열을 얻을 수 있다. 값만 빠르게 확인하거나 그룹 조합을 격자 형태로 비교할 때 유용하다. 결합할 표가 필요하면 배열의 차원과 이름을 해석해 표로 바꾸어야 한다.

by()는 데이터 프레임의 조각을 함수에 전달하기 편하다. 한 그룹에서 판매량과 날짜 등 여러 열을 함께 사용해 계산할 수 있다. 결과는 by 클래스를 가진 객체이며, 그룹별 함수 결과를 담는다. 각 함수가 길이 세 개의 벡터를 반환한다고 해서 최종 결과가 곧바로 세 열짜리 데이터 프레임이 되는 것은 아니다.

split()과 lapply()를 직접 사용하면 단계별 객체를 확인하기 쉽다. split()의 결과는 지점 이름이 붙은 리스트다. 각 조각을 요약 함수에 보내 한 행짜리 데이터 프레임을 만들고, do.call(rbind, ...)로 행을 붙인다. 요약 함수가 매번 같은 열 이름과 자료형을 반환하도록 설계하는 것이 핵심이다.

누락값을 제외하는 것과 채우는 것은 다르다

sum(x, na.rm = TRUE)는 관측된 값만 더한다. 모든 값이 결측인 경우에도 결과는 0이므로 합계만으로 관측 여부를 판단할 수 없다. 완성 코드의 sum_known()은 관측값이 하나도 없으면 NA_real_을 반환한다. 적어도 하나의 관측값이 있을 때만 확인된 합계를 계산한다.

이 함수도 부분 누락 문제를 해결하지는 않는다. C 지점처럼 0과 결측값이 함께 있으면 확인된 합계는 0이다. 따라서 누락 건수를 함께 계산하고 상태를 표시해야 한다. 수치 열은 계산에 쓰고 상태 열은 해석을 돕도록 역할을 나눈다.

관측 행이 없는 지점은 다른 방식으로 처리된다. split()으로 실제 판매 행을 나누면 문자형 지점 코드 D의 조각은 만들어지지 않는다. 지점 관리표와 결합한 뒤 D의 기록 일수와 누락 건수를 0으로 채운다. 이는 판매량을 0으로 채우는 작업과 의미가 다르다. 판매 기록이 없어서 확인한 누락 건수도 없다는 뜻이며, 판매 합계는 그대로 결측값으로 둔다.

집계 결과의 불변 조건도 정할 수 있다. 긴 표의 전체 확인된 합계와 지점별 확인된 합계의 합은 같아야 한다. 이 검사는 누락된 그룹이나 중복 합산을 찾는 데 도움이 된다. 다만 합계가 같다는 사실만으로 모든 행이 올바르다고 증명되지는 않는다. 키의 유일성, 행 수, 복원 결과를 함께 검사한다.

완성 코드

다음 내용을 main.R로 저장한다. 외부 파일이나 패키지 없이 실행되며 난수를 사용하지 않는다. 출력은 함수의 기본 인쇄 형식에 맡기지 않고 행별 문자열로 만든다. 실행 환경의 출력 폭이 달라도 같은 내용이 나오게 하기 위해서다.

options(warn = 2)

check_key <- function(data, keys) {
  key <- data[keys]
  stopifnot(!anyNA(key), anyDuplicated(key) == 0L)
  invisible(TRUE)
}

sum_known <- function(x) {
  if (all(is.na(x))) return(NA_real_)
  sum(x, na.rm = TRUE)
}

wide <- data.frame(
  branch = c("A", "A", "B", "C"),
  day = as.Date(c(
    "2026-09-01", "2026-09-02",
    "2026-09-01", "2026-09-01"
  )),
  units.water = c(10, 12, 8, 0),
  units.chips = c(6, 4, 5, NA_real_),
  stringsAsFactors = FALSE
)

branches <- data.frame(
  branch = c("A", "B", "C", "D"),
  branch_name = c("골목점", "역앞점", "공원점", "학교점"),
  stringsAsFactors = FALSE
)

stock <- data.frame(
  branch = rep(c("A", "B", "C", "D"), each = 2),
  item = rep(c("water", "chips"), times = 4),
  opening_stock = c(20, 9, 15, 8, 5, 4, 12, 6),
  stringsAsFactors = FALSE
)

check_key(wide, c("branch", "day"))
check_key(branches, "branch")
check_key(stock, c("branch", "item"))

long <- reshape(
  wide,
  varying = list(c("units.water", "units.chips")),
  v.names = "units",
  timevar = "item",
  times = c("water", "chips"),
  idvar = c("branch", "day"),
  direction = "long"
)
long <- long[order(long$branch, long$day, long$item),
             c("branch", "day", "item", "units")]
rownames(long) <- NULL
check_key(long, c("branch", "day", "item"))
stopifnot(nrow(long) == 2L * nrow(wide))

restored <- reshape(
  long,
  v.names = "units",
  timevar = "item",
  idvar = c("branch", "day"),
  direction = "wide"
)
restored <- restored[order(restored$branch, restored$day),
                     names(wide)]
rownames(restored) <- NULL
stopifnot(isTRUE(all.equal(
  wide, restored, check.attributes = FALSE
)))

item_totals <- aggregate(
  x = long["units"],
  by = long[c("branch", "item")],
  FUN = sum_known
)
names(item_totals)[3] <- "known_units"
check_key(item_totals, c("branch", "item"))

totals_array <- tapply(long$units, long$branch, sum_known)

parts <- split(long, long$branch)
summarize_branch <- function(data) {
  data.frame(
    branch = data$branch[1],
    days = length(unique(data$day)),
    known_units = sum_known(data$units),
    missing_units = sum(is.na(data$units)),
    stringsAsFactors = FALSE
  )
}
summary_table <- do.call(rbind, lapply(parts, summarize_branch))
rownames(summary_table) <- NULL
check_key(summary_table, "branch")

by_summary <- by(long, long$branch, function(data) {
  c(
    days = length(unique(data$day)),
    known_units = sum_known(data$units),
    missing_units = sum(is.na(data$units))
  )
})
stopifnot(
  identical(as.numeric(totals_array), c(32, 13, 0)),
  isTRUE(all.equal(
    unname(by_summary[["C"]]), c(1, 0, 1)
  )),
  sum(summary_table$known_units, na.rm = TRUE) ==
    sum(long$units, na.rm = TRUE)
)

inner <- merge(summary_table, branches, by = "branch")
left <- merge(summary_table, branches, by = "branch",
              all.x = TRUE)
right <- merge(summary_table, branches, by = "branch",
               all.y = TRUE)
full <- merge(summary_table, branches, by = "branch",
              all = TRUE)
stopifnot(identical(
  c(nrow(inner), nrow(left), nrow(right), nrow(full)),
  c(3L, 3L, 4L, 4L)
))

report <- merge(branches, summary_table, by = "branch",
                all.x = TRUE)
report <- report[order(report$branch), ]
no_record <- is.na(report$days)
report$status <- ifelse(
  no_record, "기록 없음",
  ifelse(report$missing_units > 0, "일부 누락", "누락 없음")
)
report$days[no_record] <- 0L
report$missing_units[no_record] <- 0L

stock_report <- merge(
  stock, item_totals,
  by = c("branch", "item"), all.x = TRUE
)
stock_report <- stock_report[
  order(stock_report$branch, stock_report$item), ]
check_key(stock_report, c("branch", "item"))
stopifnot(
  nrow(report) == nrow(branches),
  nrow(stock_report) == nrow(stock),
  is.na(report$known_units[report$branch == "D"]),
  is.na(stock_report$known_units[
    stock_report$branch == "C" &
      stock_report$item == "chips"
  ])
)

format_count <- function(x) {
  ifelse(is.na(x), "NA", sprintf("%.0f", x))
}

cat("변형 검사: 4행 -> 8행 -> 4행\n")
cat(sprintf(
  "조인 행 수: 내부=%d, 왼쪽=%d, 오른쪽=%d, 전체=%d\n",
  nrow(inner), nrow(left), nrow(right), nrow(full)
))
cat("지점별 확인된 합계: ")
cat(paste(
  paste0(names(totals_array), "=", format_count(totals_array)),
  collapse = ", "
), "\n", sep = "")

cat("\n지점 집계표\n")
cat("지점 | 이름 | 기록일수 | 확인합계 | 누락건수 | 상태\n")
for (i in seq_len(nrow(report))) {
  cat(sprintf(
    "%s | %s | %d | %s | %d | %s\n",
    report$branch[i], report$branch_name[i], report$days[i],
    format_count(report$known_units[i]),
    report$missing_units[i], report$status[i]
  ))
}

cat("\n상품별 재고 연결표\n")
cat("지점 | 상품 | 기초재고 | 확인합계\n")
for (i in seq_len(nrow(stock_report))) {
  cat(sprintf(
    "%s | %s | %d | %s\n",
    stock_report$branch[i], stock_report$item[i],
    stock_report$opening_stock[i],
    format_count(stock_report$known_units[i])
  ))
}
cat("\n검사 통과\n")

줄별 해설

첫 줄의 options(warn = 2)는 경고가 발생하면 실행을 오류로 멈추게 한다. 이 예제는 경고가 없는 실행을 기준으로 작성했다. 경고를 숨기는 대신 자료형이나 중복 키 문제를 발견했을 때 그 지점에서 확인하도록 한다.

check_key()는 선택한 키 열을 작은 데이터 프레임으로 만든다. anyNA()는 키에 결측값이 있는지 검사하고, anyDuplicated()는 같은 키 조합이 반복되는지 검사한다. 여러 열을 넘겨도 열별 중복을 따로 보는 것이 아니라 행의 조합을 검사한다. 예를 들어 지점 A가 두 번 나오는 것은 허용되지만 A의 같은 날짜가 두 번 나오는 것은 허용하지 않는다.

sum_known()은 모든 값이 누락되었는지 먼저 확인한다. 이 경우 숫자형 결측값을 반환한다. 그렇지 않으면 관측된 값의 합을 반환한다. 그룹마다 반환값의 길이가 하나이고 자료형도 숫자형이므로 집계 함수에 전달하기 편하다.

wide의 날짜는 as.Date()로 만든다. 날짜의 문자열 표현과 날짜 객체를 섞지 않으려는 선택이다. 판매량은 모두 숫자형이며 누락값도 NA_real_로 지정한다. branches는 등록 지점의 목록이고 stock은 지점마다 두 상품의 기초 재고를 가진다.

첫 세 번의 check_key() 호출은 자료의 기본 계약을 확인한다. 지점 관리표에 같은 지점이 두 번 있거나 재고표에 같은 지점·상품이 두 번 있으면 이후의 조인에서 행이 늘어날 수 있다. 문제가 결과에 나타난 뒤 추적하는 것보다 결합 직전에 검사하는 편이 원인을 좁히기 쉽다.

첫 reshape() 호출에서 varying은 판매량 열 두 개를 하나의 목록 요소로 묶는다. 판매량이라는 측정 종류가 하나이고 반복 대상이 두 상품이기 때문이다. times의 첫 값은 첫 판매량 열에, 두 번째 값은 두 번째 판매량 열에 대응한다. 이 순서를 바꾸면 상품 이름과 판매량의 연결도 바뀐다.

변형 직후에는 지점·날짜·상품순으로 정렬하고 필요한 네 열을 선택한다. 생성된 행 이름을 제거해 이후 비교와 출력이 원본 행 이름에 의존하지 않게 한다. 키 검사와 여덟 행 검사는 서로 다른 문제를 확인한다. 행 수가 맞아도 중복 키가 있을 수 있고, 키가 유일해도 일부 행이 빠졌을 수 있다.

두 번째 reshape() 호출은 상품 값을 다시 열 이름에 반영한다. 복원 표의 열 순서를 names(wide)에 맞추므로 먼저 등장한 상품에 따라 열 순서가 달라져도 비교 기준은 일정하다. all.equal()에는 check.attributes = FALSE를 지정해 변형 과정의 부가 속성을 비교에서 제외한다. 이 검사는 값의 복원을 확인하며 모든 속성의 보존을 확인하는 검사는 아니다.

item_totals는 지점·상품당 한 행이다. aggregate()의 x에 한 열짜리 데이터 프레임을 전달하므로 결과 판매량 열도 units라는 이름을 가진다. 이를 known_units로 바꾸어 원본 판매량과 집계한 판매량을 구별한다. C의 과자는 그룹 자체는 존재하지만 관측값이 없어 합계가 결측이다.

totals_array는 지점별 합계를 배열로 받는 예다. 여기서는 지점 이름이 A, B, C인 일차원 결과를 얻는다. parts는 같은 지점 기준을 사용하지만 값 벡터가 아니라 데이터 프레임 전체를 나눈다. 따라서 요약 함수에서 날짜의 고유 개수와 판매량의 누락 개수를 함께 계산할 수 있다.

summarize_branch()의 기록 일수는 긴 표의 행 수가 아니라 서로 다른 날짜의 개수다. 긴 표에는 하루마다 상품 두 행이 있으므로 행 수를 그대로 일수로 쓰면 두 배가 된다. 함수가 반환하는 한 행짜리 표를 rbind로 합친 후 지점 키가 유일한지 다시 확인한다.

by_summary는 같은 요약을 벡터로 반환하는 비교 예다. C 그룹의 값이 기록 일수 1, 확인된 합계 0, 누락 건수 1인지 검사한다. tapply() 결과의 숫자 부분도 예상값과 비교한다. 서로 다른 함수가 같은 정책을 사용했는지 확인할 수 있다.

네 조인 객체는 조인 종류에 따른 행 수를 보여 주기 위해 만든다. 실무에서 네 종류를 항상 함께 실행할 필요는 없다. 최종 report는 지점 관리표를 왼쪽에 둔 왼쪽 조인으로 만든다. 결합 직후의 days가 결측인 행은 이 예제에서 판매 기록이 없는 지점이다. 원본 요약 함수는 기록이 있는 그룹에 대해 기록 일수를 항상 계산하기 때문이다.

상태를 먼저 정한 뒤 기록 없는 지점의 일수와 누락 건수를 0으로 채운다. 순서를 반대로 하면 결합으로 생긴 결측값이라는 단서를 잃는다. known_units에는 값을 채우지 않는다. D의 합계가 결측으로 남아 있는지 별도 검사도 둔다.

stock_report는 재고표의 모든 지점·상품을 남긴다. C의 과자는 판매 행이 있으나 값이 누락되었고, D의 두 상품은 판매 행이 없지만 출력 합계는 모두 결측이다. 이 표만으로 그 원인을 구별할 수는 없다. 원인을 보여 주어야 한다면 상품별 관측 건수와 누락 건수를 추가해야 한다.

마지막 출력 함수는 숫자형 결측값을 NA로 표시하고 나머지는 소수점 없이 출력한다. 이 예제의 판매량은 개수이므로 이런 표현을 사용한다. 금액이나 평균처럼 소수점이 의미를 가진 수치에는 별도의 출력 규칙이 필요하다.

실행 결과

터미널에서 파일이 있는 디렉터리로 이동해 실행한다. 이 프로그램은 텍스트 집계표를 출력하며 그래프 파일은 만들지 않는다.

Rscript main.R

예상 출력은 다음과 같다.

변형 검사: 4행 -> 8행 -> 4행
조인 행 수: 내부=3, 왼쪽=3, 오른쪽=4, 전체=4
지점별 확인된 합계: A=32, B=13, C=0

지점 집계표
지점 | 이름 | 기록일수 | 확인합계 | 누락건수 | 상태
A | 골목점 | 2 | 32 | 0 | 누락 없음
B | 역앞점 | 1 | 13 | 0 | 누락 없음
C | 공원점 | 1 | 0 | 1 | 일부 누락
D | 학교점 | 0 | NA | 0 | 기록 없음

상품별 재고 연결표
지점 | 상품 | 기초재고 | 확인합계
A | chips | 9 | 10
A | water | 20 | 22
B | chips | 8 | 5
B | water | 15 | 8
C | chips | 4 | NA
C | water | 5 | 0
D | chips | 6 | NA
D | water | 12 | NA

검사 통과

A의 확인된 합계는 물 22개와 과자 10개를 더한 32개다. B는 13개다. C는 관측된 물 판매량 0만 합계에 반영되어 0이지만 상태에 일부 누락이 표시된다. D의 합계는 결측으로 남는다. 마지막의 검사 통과 문구는 앞에 배치한 모든 stopifnot() 검사를 통과했을 때만 출력된다.

실무에서 자주 틀리는 것

넓힐 때 반복 대상을 키에서 빠뜨린다

다음 코드는 지점만으로 원래 행을 식별한다. A에는 서로 다른 날짜가 있으므로 지점·상품만으로는 한 칸의 값을 정할 수 없다. reshape()가 이 중복을 합산해 줄 것이라고 기대해서는 안 된다.

# 틀린 코드
bad_wide <- reshape(
  long, idvar = "branch", timevar = "item",
  v.names = "units", direction = "wide"
)

# 고친 코드
good_wide <- reshape(
  long, idvar = c("branch", "day"), timevar = "item",
  v.names = "units", direction = "wide"
)

기간 전체 상품 합계를 넓히려는 목적이라면 날짜를 빼는 대신, 먼저 지점·상품별로 집계해야 한다. 행의 의미를 정한 다음 그 의미에 맞는 키를 선택한다.

등록표의 중복을 허용한 채 판매표에 붙인다

지점 이름이 두 번 등록되면 같은 판매 행이 두 번 연결된다. 아래의 잘못된 코드는 결과를 다시 합산할 경우 A 판매량을 중복해서 셀 수 있다. 중복을 무조건 지우기보다 지점 등록표의 유일성부터 검사한다.

# 틀린 코드
bad_branches <- rbind(branches, branches[1, ])
bad_join <- merge(long, bad_branches, by = "branch")

# 고친 코드
check_key(branches, "branch")
good_join <- merge(
  long, branches, by = "branch", all.x = TRUE
)
stopifnot(nrow(good_join) == nrow(long))

실제 등록표가 변경 이력을 저장한다면 중복 행이 오류가 아닐 수도 있다. 그때는 적용 날짜 등 추가 키를 정하거나 해당 기간에 유효한 행을 먼저 선택해야 한다. 이 예제에서는 지점당 이름 하나라는 규칙을 사용한다.

집계식이 누락된 판매 행을 먼저 제거한다

aggregate()의 수식 방식은 기본 결측 처리에 따라 계산 전에 행을 제외할 수 있다. 누락 여부를 세려는 계산에서는 함수가 누락값을 전달받아야 한다. 다음의 첫 코드는 C의 누락 건수를 기대한 대로 계산하지 못한다.

# 틀린 코드
bad_missing <- aggregate(
  units ~ branch, data = long,
  FUN = function(x) sum(is.na(x))
)

# 고친 코드
good_missing <- aggregate(
  x = long["units"],
  by = long["branch"],
  FUN = function(x) sum(is.na(x))
)

수식 방식을 유지하려면 na.action = na.pass를 명시할 수도 있다. 중요한 점은 요약 함수 내부의 결측 처리와 함수 호출 전에 수행되는 행 제외를 구별하는 것이다.

결합 후 생긴 모든 결측값을 0으로 바꾼다

일괄 대입은 열마다 다른 의미를 무시한다. D의 확인된 판매 합계까지 0으로 바꾸면 판매 기록이 없다는 상태를 관측된 0으로 표현하게 된다. 채워도 되는 열과 그대로 둘 열을 구분한다.

# 틀린 코드
bad_report <- merge(
  branches, summary_table, by = "branch", all.x = TRUE
)
bad_report[is.na(bad_report)] <- 0

# 고친 코드
good_report <- merge(
  branches, summary_table, by = "branch", all.x = TRUE
)
absent <- is.na(good_report$days)
good_report$days[absent] <- 0L
good_report$missing_units[absent] <- 0L
stopifnot(is.na(
  good_report$known_units[good_report$branch == "D"]
))

결측값을 채우는 규칙은 출력 모양보다 자료의 의미에서 결정한다. 보고서에 빈칸이 불편하다면 표시 문자열을 바꿀 수 있지만, 계산에 쓰는 숫자 열까지 바꿀 필요는 없다.

한눈에 보기

변형과 집계 함수는 필요한 결과 구조에 따라 선택한다
작업base R결과와 확인점tidyverse 대응
길게·넓게 변형reshape()데이터 프레임, 행 식별 기준 확인pivot_longer(), pivot_wider()
키로 결합merge()데이터 프레임, 중복 키와 행 수 확인inner_join(), left_join() 등
그룹별 표 집계aggregate()데이터 프레임, 결측 처리 방식 확인group_by()와 summarise()
벡터의 그룹 집계tapply()배열 또는 리스트 배열, 차원 확인group_by()와 summarise()
자료 조각별 계산by()그룹별 결과 객체, 결합 방식 확인group_modify()
단계를 직접 구성split(), lapply(), rbind()리스트에서 표로 결합, 반환 열 확인group_split()과 목록 처리 함수

대응표는 비슷한 작업을 찾기 위한 안내다. 함수마다 결측 키의 처리, 정렬, 결과 자료형, 그룹 유지 방식이 다를 수 있으므로 단순한 이름 교체로 같은 결과를 보장하지는 않는다. 이 장의 프로그램은 표에 적힌 base R 함수만 사용한다.

집계 전에 한 행의 의미를 정하고, 변형 뒤에는 키와 행 수를 검사한다. 결합할 때는 어느 표의 행을 보존할지 정한다. 마지막으로 합계와 함께 관측 여부를 남긴다. 이렇게 만든 표는 다음 장에서 판매량을 설명하는 변수와 반응값을 준비할 때도 출발점이 된다. 다만 일부 누락된 합계를 그대로 완전한 기간 판매량으로 사용해서는 안 된다.

함수 인자와 결측 처리의 세부 규칙은 공식 도움말에서 확인할 수 있다. reshape 도움말, merge 도움말, aggregate 도움말은 함수 동작을 확인하기 위한 참고 자료다.

연습 문제

  1. long에서 상품별 확인된 판매 합계와 누락 건수를 구하라. 두 결과를 상품 키로 연결하고, 물 합계 30개, 과자 합계 15개, 과자 누락 건수 1건인지 검사하라.
  2. 지점 관리표에 branch = "E", branch_name = "시장점"인 행을 추가하라. 판매 자료는 바꾸지 않고 지점 보고서를 다시 만들라. 결과가 다섯 행이며 E의 기록 일수는 0, 판매 합계는 결측인지 검사하라.
  3. A 지점의 2026년 9월 1일 물 판매량에 3개짜리 추가 거래 행이 들어왔다고 하자. 이 경우 기존 긴 표의 키가 왜 유일하지 않게 되는지 설명하고, 지점·날짜·상품별로 합친 뒤 넓은 표를 만들라. 해당 날짜의 물 판매량이 13개인지 검사하라.
  4. 지점별 집계표에 observed_units 열을 추가하라. 이 열은 판매량이 보고된 행의 수다. A, B, C의 값이 각각 4, 2, 1인지 검사하고, 판매 합계만으로 관측 여부를 판단할 수 없는 이유를 설명하라.

정답과 해설

상품별 합계와 누락 건수

product_sum <- aggregate(
  long["units"], long["item"], sum_known
)
names(product_sum)[2] <- "known_units"

product_missing <- aggregate(
  long["units"], long["item"],
  function(x) sum(is.na(x))
)
names(product_missing)[2] <- "missing_units"

product_report <- merge(
  product_sum, product_missing, by = "item"
)
stopifnot(
  product_report$known_units[
    product_report$item == "water"
  ] == 30,
  product_report$known_units[
    product_report$item == "chips"
  ] == 15,
  product_report$missing_units[
    product_report$item == "chips"
  ] == 1
)

물은 10, 12, 8, 0을 더해 30개다. 과자는 6, 4, 5를 더해 확인된 합계 15개다. 과자 전체 판매량에는 C의 누락분이 포함되지 않았으므로 누락 건수도 함께 보여 준다. 두 집계표는 상품당 한 행이므로 상품 키로 결합한다.

판매 기록이 없는 지점 추가

branches2 <- rbind(
  branches,
  data.frame(
    branch = "E", branch_name = "시장점",
    stringsAsFactors = FALSE
  )
)
check_key(branches2, "branch")

report2 <- merge(
  branches2, summary_table, by = "branch", all.x = TRUE
)
absent <- is.na(report2$days)
report2$status <- ifelse(
  absent, "기록 없음",
  ifelse(report2$missing_units > 0, "일부 누락", "누락 없음")
)
report2$days[absent] <- 0L
report2$missing_units[absent] <- 0L

stopifnot(
  nrow(report2) == 5L,
  report2$days[report2$branch == "E"] == 0,
  is.na(report2$known_units[report2$branch == "E"]),
  report2$status[report2$branch == "E"] == "기록 없음"
)

左側に相当する 지점 관리표의 행을 모두 보존하므로 E가 결과에 남는다. E의 판매 행을 임의로 생성하지 않는다. 기록 일수를 0으로 표현하면서 판매 합계를 결측으로 두면 이번 기간의 보고가 없다는 의미를 유지할 수 있다.

추가 거래를 먼저 집계한 뒤 넓히기

extra <- data.frame(
  branch = "A",
  day = as.Date("2026-09-01"),
  item = "water",
  units = 3,
  stringsAsFactors = FALSE
)
transactions <- rbind(long, extra)
stopifnot(anyDuplicated(
  transactions[c("branch", "day", "item")]
) > 0L)

daily <- aggregate(
  transactions["units"],
  transactions[c("branch", "day", "item")],
  sum_known
)
check_key(daily, c("branch", "day", "item"))

daily_wide <- reshape(
  daily, idvar = c("branch", "day"), timevar = "item",
  v.names = "units", direction = "wide"
)
target <- daily_wide$branch == "A" &
  daily_wide$day == as.Date("2026-09-01")
stopifnot(daily_wide$units.water[target] == 13)

추가 거래가 들어오면 A·2026년 9월 1일·물 조합이 두 번 나타난다. 거래표에서는 자연스러운 상황이지만 하루 상품 집계표의 키로는 중복이다. 먼저 합계를 구해 조합당 한 행을 만든 뒤 넓힌다. 이 문제는 기존 행의 값과 추가 거래량을 더한다는 가정을 사용한다.

관측 건수 추가

summary_with_observed <- do.call(
  rbind,
  lapply(parts, function(data) {
    result <- summarize_branch(data)
    result$observed_units <- sum(!is.na(data$units))
    result
  })
)
rownames(summary_with_observed) <- NULL
summary_with_observed <- summary_with_observed[
  order(summary_with_observed$branch), ]
stopifnot(identical(
  summary_with_observed$observed_units, c(4L, 2L, 1L)
))

관측 건수는 값이 0이어도 한 건으로 센다. C의 물 판매량 0은 관측값이므로 C의 관측 건수는 1이다. 같은 합계 0이라도 관측된 0만 있는 경우, 일부 누락이 있는 경우, 아예 관측값이 없는 경우는 해석이 다르다. 합계와 관측 건수를 함께 저장하면 이러한 차이를 다음 계산 단계까지 전달할 수 있다.

댓글 0

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

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