22  Arrow

22.1 소개

CSV 파일은 사람이 읽기 쉽게 설계되었습니다. 매우 간단하고 모든 도구에서 읽을 수 있기 때문에 좋은 교환 형식입니다. 하지만 CSV 파일은 그리 효율적이지 않습니다. 데이터를 R로 읽어들이기 위해 꽤 많은 작업을 수행해야 하기 때문입니다. 이 장에서는 강력한 대안인 파켓(parquet) 형식에 대해 배울 것입니다. 이는 빅 데이터 시스템에서 널리 사용되는 오픈 표준 기반 형식입니다.

우리는 파켓 파일을 대규모 데이터셋의 효율적인 분석 및 전송을 위해 설계된 다국어 도구 상자인 Apache Arrow와 결합할 것입니다. 익숙한 dplyr 구문을 사용하여 메모리보다 큰 데이터셋을 분석할 수 있는 dplyr 백엔드를 제공하는 arrow 패키지를 통해 Apache Arrow를 사용할 것입니다. 추가적인 이점으로 arrow는 매우 빠릅니다. 이 장의 뒷부분에서 몇 가지 예를 보게 될 것입니다.

arrow와 dbplyr 모두 dplyr 백엔드를 제공하므로 언제 각각을 사용해야 하는지 궁금할 수 있습니다. 많은 경우 데이터가 이미 데이터베이스나 파켓 파일에 있으므로 있는 그대로 작업하고 싶을 것이므로 선택은 이미 결정되어 있습니다. 하지만 자신의 데이터(아마도 CSV 파일)로 시작하는 경우 데이터베이스에 로드하거나 파켓으로 변환할 수 있습니다. 일반적으로 무엇이 가장 잘 작동할지 알기 어려우므로 분석 초기 단계에서는 두 가지를 모두 시도해보고 자신에게 가장 잘 맞는 것을 선택하는 것이 좋습니다.

(이 장의 초기 버전을 기여해 준 Danielle Navarro에게 큰 감사를 드립니다.)

22.1.1 선수 지식

이 장에서도 tidyverse, 특히 dplyr를 계속 사용하겠지만, 대용량 데이터 작업을 위해 특별히 설계된 arrow 패키지와 결합할 것입니다.

이 장의 뒷부분에서는 arrow와 duckdb 사이의 몇 가지 연결 고리도 살펴볼 것이므로 dbplyr과 duckdb도 필요합니다.

library(dbplyr, warn.conflicts = FALSE)
library(duckdb)
#> Warning: package 'duckdb' was built under R version 4.5.2
#> Loading required package: DBI

22.2 데이터 가져오기

이러한 도구에 걸맞은 데이터셋인 시애틀 공공 도서관의 아이템 대출 데이터셋을 가져오는 것으로 시작합니다. 이 데이터셋은 data.seattle.gov/Community/Checkouts-by-Title/tmmm-ytt6에서 온라인으로 이용 가능합니다. 이 데이터셋에는 2005년 4월부터 2022년 10월까지 매달 각 책이 몇 번 대출되었는지를 알려주는 41,389,465개의 행이 포함되어 있습니다.

다음 코드는 데이터의 캐시된 복사본을 가져옵니다. 데이터는 9GB CSV 파일이므로 다운로드하는 데 시간이 좀 걸립니다. 매우 큰 파일을 가져올 때는 이 목적을 위해 특별히 제작된 curl::multi_download()를 사용하는 것을 적극 추천합니다. 진행률 표시줄을 제공하고 중단된 경우 다운로드를 재개할 수 있기 때문입니다.

dir.create("data", showWarnings = FALSE)

curl::multi_download(
  "https://r4ds.s3.us-west-2.amazonaws.com/seattle-library-checkouts.csv",
  "data/seattle-library-checkouts.csv",
  resume = TRUE
)
#> # A tibble: 1 × 10
#>   success status_code resumefrom url                    destfile        error
#>   <lgl>         <dbl>      <dbl> <chr>                  <chr>           <chr>
#> 1 TRUE            200          0 https://r4ds.s3.us-we… /Users/jinhwan… <NA> 
#> # ℹ 4 more variables: type <chr>, modified <dttm>, time <dbl>,
#> #   headers <list>

22.3 데이터셋 열기

데이터를 살펴보는 것부터 시작하겠습니다. 9GB인 이 파일은 메모리에 전체를 로드하고 싶지 않을 만큼 큽니다. 일반적인 경험 법칙으로 데이터 크기의 최소 두 배 이상의 메모리가 필요한데, 많은 노트북은 16GB가 한계입니다. 즉, read_csv()를 피하고 대신 arrow::open_dataset()을 사용하고 싶습니다:

seattle_csv <- open_dataset(
  sources = "data/seattle-library-checkouts.csv", 
  col_types = schema(ISBN = string()),
  format = "csv"
)

이 코드가 실행되면 어떻게 될까요? open_dataset()은 수천 개의 행을 스캔하여 데이터셋의 구조를 파악합니다. ISBN 열은 처음 80,000행 동안 빈 값을 포함하므로 arrow가 데이터 구조를 파악하는 데 도움이 되도록 열 유형을 지정해야 합니다. open_dataset()에 의해 데이터가 스캔되면 발견된 내용을 기록하고 멈춥니다. 사용자가 구체적으로 요청할 때만 추가 행을 읽습니다. 이 메타데이터는 seattle_csv를 인쇄하면 볼 수 있는 내용입니다:

seattle_csv
#> FileSystemDataset with 1 csv file
#> 12 columns
#> UsageClass: string
#> CheckoutType: string
#> MaterialType: string
#> CheckoutYear: int64
#> CheckoutMonth: int64
#> Checkouts: int64
#> Title: string
#> ISBN: string
#> Creator: string
#> Subjects: string
#> Publisher: string
#> PublicationYear: string

출력의 첫 번째 줄은 seattle_csv가 로컬 디스크에 단일 CSV 파일로 저장되어 있음을 알려줍니다. 필요할 때만 메모리에 로드됩니다. 나머지 출력은 각 열에 대해 arrow가 추정한 열 유형을 알려줍니다.

glimpse()로 실제로 무엇이 들어있는지 볼 수 있습니다. 약 4,100만 개의 행과 12개의 열이 있음을 보여주고 몇 가지 값을 보여줍니다.

seattle_csv |> glimpse()
#> FileSystemDataset with 1 csv file
#> 41,389,465 rows x 12 columns
#> $ UsageClass      <string> "Physical", "Physical", "Digital", "Physical", "Ph…
#> $ CheckoutType    <string> "Horizon", "Horizon", "OverDrive", "Horizon", "Hor…
#> $ MaterialType    <string> "BOOK", "BOOK", "EBOOK", "BOOK", "SOUNDDISC", "BOO…
#> $ CheckoutYear     <int64> 2016, 2016, 2016, 2016, 2016, 2016, 2016, 2016, 20…
#> $ CheckoutMonth    <int64> 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,…
#> $ Checkouts        <int64> 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 2, 3, 2, 1, 3, 2,…
#> $ Title           <string> "Super rich : a guide to having it all / Russell S…
#> $ ISBN            <string> "", "", "", "", "", "", "", "", "", "", "", "", ""…
#> $ Creator         <string> "Simmons, Russell", "Barclay, James, 1965-", "Tim …
#> $ Subjects        <string> "Self realization, Conduct of life, Attitude Psych…
#> $ Publisher       <string> "Gotham Books,", "Pyr,", "Random House, Inc.", "Di…
#> $ PublicationYear <string> "c2011.", "2010.", "2015", "2005.", "c2004.", "c20…

dplyr 동사를 사용하여 이 데이터셋을 사용할 수 있으며, collect()를 사용하여 arrow가 계산을 수행하고 일부 데이터를 반환하도록 강제할 수 있습니다. 예를 들어, 이 코드는 연도별 총 대출 횟수를 알려줍니다:

seattle_csv |> 
  group_by(CheckoutYear) |> 
  summarise(Checkouts = sum(Checkouts)) |> 
  arrange(CheckoutYear) |> 
  collect()
#> # A tibble: 18 × 2
#>   CheckoutYear Checkouts
#>          <int>     <int>
#> 1         2005   3798685
#> 2         2006   6599318
#> 3         2007   7126627
#> 4         2008   8438486
#> 5         2009   9135167
#> 6         2010   8608966
#> # ℹ 12 more rows

arrow 덕분에 이 코드는 기본 데이터셋이 아무리 크더라도 작동합니다. 하지만 현재는 다소 느립니다. 해들리의 컴퓨터에서 실행하는 데 약 10초가 걸렸습니다. 데이터 양을 고려하면 나쁘지 않지만 더 나은 형식으로 전환하여 훨씬 더 빠르게 만들 수 있습니다.

22.4 파켓(Parquet) 형식

이 데이터를 더 쉽게 다루기 위해 파켓 파일 형식으로 전환하고 여러 파일로 나누어 보겠습니다. 다음 섹션에서는 먼저 파켓과 파티셔닝(partitioning)을 소개한 다음 배운 내용을 시애틀 도서관 데이터에 적용해 보겠습니다.

22.4.1 파켓의 장점

CSV와 마찬가지로 파켓은 사각형 데이터에 사용되지만, 모든 파일 에디터로 읽을 수 있는 텍스트 형식이 아니라 빅 데이터의 요구 사항에 맞춰 특별히 설계된 맞춤형 이진 형식입니다. 이것은 다음을 의미합니다:

  • 파켓 파일은 일반적으로 동일한 CSV 파일보다 작습니다. 파켓은 파일 크기를 낮게 유지하기 위해 효율적인 인코딩에 의존하며 파일 압축을 지원합니다. 디스크에서 메모리로 이동할 데이터가 적기 때문에 파켓 파일을 빠르게 만드는 데 도움이 됩니다.

  • 파켓 파일은 풍부한 유형 시스템을 가지고 있습니다. Section 7.3 에서 이야기했듯이 CSV 파일은 열 유형에 대한 정보를 제공하지 않습니다. 예를 들어 CSV 리더는 "08-10-2022"를 문자열로 파싱해야 할지 날짜로 파싱해야 할지 추측해야 합니다. 반면 파켓 파일은 데이터와 함께 유형을 기록하는 방식으로 데이터를 저장합니다.

  • 파켓 파일은 “열 지향(column-oriented)”입니다. 이는 R의 데이터 프레임과 마찬가지로 열별로 구성되어 있음을 의미합니다. 이것은 행별로 구성된 CSV 파일에 비해 데이터 분석 작업에서 일반적으로 더 나은 성능을 제공합니다.

  • 파켓 파일은 “청크(chunked)”되어 있어 파일의 다른 부분을 동시에 작업할 수 있고, 운이 좋으면 일부 청크를 완전히 건너뛸 수도 있습니다.

파켓 파일에는 한 가지 주요 단점이 있습니다. 더 이상 “사람이 읽을 수 없다”는 것입니다. 즉, readr::read_file()을 사용하여 파켓 파일을 보면 횡설수설하는 무더기만 보이게 됩니다.

22.4.2 파티셔닝(Partitioning)

데이터셋이 점점 더 커짐에 따라 모든 데이터를 단일 파일에 저장하는 것은 점점 더 고통스러워지며, 대규모 데이터셋을 여러 파일에 나누어 저장하는 것이 종종 유용합니다. 이 구조화가 지능적으로 수행되면 많은 분석에 파일의 일부만 필요하기 때문에 성능이 크게 향상될 수 있습니다.

데이터셋을 파티셔닝하는 방법에 대한 확고한 규칙은 없습니다. 결과는 데이터, 액세스 패턴 및 데이터를 읽는 시스템에 따라 달라집니다. 자신의 상황에 가장 적합한 파티셔닝을 찾기 전에 약간의 실험이 필요할 수 있습니다. 대략적인 지침으로 arrow는 20MB보다 작고 2GB보다 큰 파일을 피하고 10,000개 이상의 파일을 생성하는 파티션을 피할 것을 권장합니다. 또한 필터링의 기준이 되는 변수로 파티셔닝해야 합니다. 곧 보게 되겠지만, 그렇게 하면 arrow가 관련 파일만 읽음으로써 많은 작업을 건너뛸 수 있습니다.

22.4.3 시애틀 도서관 데이터 다시 쓰기

실제 상황에서 어떻게 작동하는지 보기 위해 이러한 아이디어를 시애틀 도서관 데이터에 적용해 보겠습니다. 일부 분석에서는 최근 데이터만 보고 싶어할 가능성이 높고 연도별로 파티셔닝하면 적당한 크기의 18개 청크가 생성되므로 CheckoutYear로 파티셔닝하겠습니다.

데이터를 다시 쓰기 위해 dplyr::group_by()를 사용하여 파티션을 정의한 다음 arrow::write_dataset()을 사용하여 파티션을 디렉터리에 저장합니다. write_dataset()에는 두 가지 중요한 인수가 있습니다: 파일을 생성할 디렉터리와 사용할 형식입니다.

pq_path <- "data/seattle-library-checkouts"
seattle_csv |>
  group_by(CheckoutYear) |>
  write_dataset(path = pq_path, format = "parquet")

이 작업은 실행하는 데 약 1분 정도 걸립니다. 곧 보게 되겠지만 이는 향후 작업을 훨씬 더 빠르게 만들어 보상받는 초기 투자입니다.

방금 생성한 내용을 살펴보겠습니다:

tibble(
  files = list.files(pq_path, recursive = TRUE),
  size_MB = file.size(file.path(pq_path, files)) / 1024^2
)
#> # A tibble: 18 × 2
#>   files                            size_MB
#>   <chr>                              <dbl>
#> 1 CheckoutYear=2005/part-0.parquet    108.
#> 2 CheckoutYear=2006/part-0.parquet    161.
#> 3 CheckoutYear=2007/part-0.parquet    175.
#> 4 CheckoutYear=2008/part-0.parquet    192.
#> 5 CheckoutYear=2009/part-0.parquet    211.
#> 6 CheckoutYear=2010/part-0.parquet    219.
#> # ℹ 12 more rows

우리의 단일 9GB CSV 파일이 18개의 파켓 파일로 다시 쓰여졌습니다. 파일 이름은 Apache Hive 프로젝트에서 사용하는 “자기 설명적(self-describing)” 관례를 사용합니다. Hive 스타일 파티션은 폴더 이름을 “key=value” 관례로 지정하므로 짐작하셨겠지만 CheckoutYear=2005 디렉터리에는 CheckoutYear가 2005년인 모든 데이터가 포함되어 있습니다. 각 파일은 100MB에서 300MB 사이이며 총 크기는 이제 약 4GB로 원래 CSV 파일 크기의 절반보다 약간 큽니다. 파켓이 훨씬 더 효율적인 형식이므로 예상한 결과입니다.

22.5 arrow와 함께 dplyr 사용하기

이제 이 파켓 파일들을 만들었으므로 다시 읽어 들여야 합니다. 다시 open_dataset()을 사용하지만 이번에는 디렉터리를 제공합니다:

seattle_pq <- open_dataset(pq_path)

이제 dplyr 파이프라인을 작성할 수 있습니다. 예를 들어 지난 5년 동안 매달 대출된 총 도서 수를 계산할 수 있습니다:

query <- seattle_pq |> 
  filter(CheckoutYear >= 2018, MaterialType == "BOOK") |>
  group_by(CheckoutYear, CheckoutMonth) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(CheckoutYear, CheckoutMonth)

arrow 데이터에 대한 dplyr 코드를 작성하는 것은 개념적으로 Chapter 21 의 dbplyr과 유사합니다. dplyr 코드를 작성하면 Apache Arrow C++ 라이브러리가 이해하는 쿼리로 자동으로 변환되고 collect()를 호출할 때 실행됩니다. query 객체를 인쇄하면 실행 시 Arrow가 반환할 것으로 예상되는 내용에 대한 약간의 정보를 볼 수 있습니다:

query
#> FileSystemDataset (query)
#> CheckoutYear: int32
#> CheckoutMonth: int64
#> TotalCheckouts: int64
#> 
#> * Grouped by CheckoutYear
#> * Sorted by CheckoutYear [asc], CheckoutMonth [asc]
#> See $.data for the source Arrow object

그리고 collect()를 호출하여 결과를 얻을 수 있습니다:

query |> collect()
#> # A tibble: 58 × 3
#> # Groups:   CheckoutYear [5]
#>   CheckoutYear CheckoutMonth TotalCheckouts
#>          <int>         <int>          <int>
#> 1         2018             1         355101
#> 2         2018             2         309813
#> 3         2018             3         344487
#> 4         2018             4         330988
#> 5         2018             5         318049
#> 6         2018             6         341825
#> # ℹ 52 more rows

dbplyr과 마찬가지로 arrow는 일부 R 표현식만 이해하므로 평소 작성하던 것과 똑같은 코드를 작성하지 못할 수도 있습니다. 하지만 지원되는 연산 및 함수 목록은 상당히 광범위하며 계속 늘어나고 있습니다. 현재 지원되는 함수의 전체 목록은 ?acero에서 확인할 수 있습니다.

22.5.1 성능

CSV에서 파켓으로 전환할 때의 성능 영향을 빠르게 살펴보겠습니다. 먼저 데이터가 단일 대용량 CSV로 저장되어 있을 때 2021년 각 달에 대출된 도서 수를 계산하는 데 걸리는 시간을 측정해 보겠습니다:

seattle_csv |> 
  filter(CheckoutYear == 2021, MaterialType == "BOOK") |>
  group_by(CheckoutMonth) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(desc(CheckoutMonth)) |>
  collect() |> 
  system.time()
#>    user  system elapsed 
#>  12.321   1.902  12.132

이제 시애틀 도서관 대출 데이터가 18개의 작은 파켓 파일로 파티셔닝된 새 버전의 데이터셋을 사용해 보겠습니다:

seattle_pq |> 
  filter(CheckoutYear == 2021, MaterialType == "BOOK") |>
  group_by(CheckoutMonth) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(desc(CheckoutMonth)) |>
  collect() |> 
  system.time()
#>    user  system elapsed 
#>   0.236   0.062   0.079

약 100배의 성능 향상은 두 가지 요인, 즉 다중 파일 파티셔닝과 개별 파일의 형식에 기인합니다:

  • 파티셔닝은 성능을 향상시킵니다. 이 쿼리는 데이터를 필터링하기 위해 CheckoutYear == 2021을 사용하며, arrow는 18개의 파켓 파일 중 1개만 읽으면 된다는 것을 인식할 만큼 똑똑하기 때문입니다.
  • 파켓 형식은 데이터를 메모리에 더 직접적으로 읽을 수 있는 이진 형식으로 저장하여 성능을 향상시킵니다. 열 기반 형식과 풍부한 메타데이터는 arrow가 쿼리에 실제로 사용된 4개의 열(CheckoutYear, MaterialType, CheckoutMonth, Checkouts)만 읽으면 된다는 것을 의미합니다.

이 엄청난 성능 차이가 대용량 CSV를 파켓으로 변환할 가치가 있는 이유입니다!

22.5.2 arrow와 함께 duckdb 사용하기

파켓과 arrow의 마지막 장점 하나는 arrow::to_duckdb()를 호출하여 arrow 데이터셋을 DuckDB 데이터베이스(Chapter 21)로 매우 쉽게 전환할 수 있다는 것입니다:

seattle_pq |> 
  to_duckdb() |>
  filter(CheckoutYear >= 2018, MaterialType == "BOOK") |>
  group_by(CheckoutYear) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(desc(CheckoutYear)) |>
  collect()
#> Warning: Missing values are always removed in SQL aggregation functions.
#> Use `na.rm = TRUE` to silence this warning
#> This warning is displayed once every 8 hours.
#> # A tibble: 5 × 2
#>   CheckoutYear TotalCheckouts
#>          <int>          <dbl>
#> 1         2022        2431502
#> 2         2021        2266438
#> 3         2020        1241999
#> 4         2019        3931688
#> 5         2018        3987569

to_duckdb()의 멋진 점은 전송 과정에서 메모리 복사가 발생하지 않는다는 것이며, 이는 한 컴퓨팅 환경에서 다른 환경으로의 원활한 전환을 가능하게 하는 arrow 생태계의 목표를 잘 보여줍니다.

22.5.3 연습문제

  1. 매년 가장 인기 있는 책을 찾아보세요.
  2. 시애틀 도서관 시스템에서 가장 많은 책을 보유한 작가는 누구입니까?
  3. 지난 10년 동안 종이책 대 전자책의 대출은 어떻게 변했습니까?

22.6 요약

이 장에서는 디스크의 대규모 데이터셋 작업을 위한 dplyr 백엔드를 제공하는 arrow 패키지를 맛보았습니다. CSV 파일과 함께 작업할 수 있으며, 데이터를 파켓으로 변환하면 훨씬 더 빠릅니다. 파켓은 현대 컴퓨터에서의 데이터 분석을 위해 특별히 설계된 이진 데이터 형식입니다. CSV에 비해 파켓 파일을 작업할 수 있는 도구는 훨씬 적지만, 파티셔닝, 압축 및 열 구조 덕분에 분석이 훨씬 더 효율적입니다.

다음으로 tidyr 패키지에서 제공하는 도구를 사용하여 처리할 첫 번째 비정형 데이터 소스에 대해 배울 것입니다. JSON 파일에서 가져온 데이터에 초점을 맞추겠지만, 일반적인 원리는 소스에 관계없이 트리 구조의 데이터에 적용됩니다.