Search

[Data Engineering] Row vs Column - 데이터는 왜 구간마다 형태를 바꾸는가

Python으로 웹 스크래핑 하기
2024/09/08
Python
Data Engineering
BeautifulSoup
Python으로 웹 스크래핑 하기
2024/09/08
Python
Data Engineering
BeautifulSoup
Load more
지난 글에서는 Protobuf의 Wire Format을 바이트 단위로 뜯어보며, 필드 이름 대신 숫자 태그를 쓰고 값을 가변 길이 정수(Varint)로 인코딩하는 방식이 JSON 대비 얼마나 효율적인지 확인했습니다. 그리고 Protobuf를 행(Row) 기반 처리의 표준이라고 정리했습니다.
그런데 실제로 데이터 파이프라인을 운영하다 보면, 수집 구간에서는 Protobuf를 쓰면서도 분석 구간에서는 Apache Arrow(인메모리 연산)나 Parquet(디스크 저장) 같은 열 기반 포맷을 쓰는 경우가 많습니다. Protobuf가 효율적이라면 왜 모든 곳에서 Protobuf만 쓰지 않는 걸까요?
이번 글에서는 그 이유를 데이터가 물리적으로 메모리에 배치되는 방식에서 알아보려고 합니다. 행(Row) 기반열(Column) 기반, 이 두 가지 구조가 만들어내는 성능 차이를 알아보겠습니다.

1. 데이터가 메모리에 놓이는 두 가지 방식

다음과 같은 사용자 데이터 3건이 있다고 가정해 보겠습니다.
user_id
name
age
1
Alice
30
2
Bob
25
3
Carol
28
같은 데이터지만, 메모리에 배치하는 방식은 크게 두 가지입니다.

1-1. Row-oriented (행 기반)

한 레코드의 모든 필드를 연속으로 배치합니다.
메모리 레이아웃: [1, Alice, 30] → [2, Bob, 25] → [3, Carol, 28] ────────────── ────────────── ────────────── 레코드 1 레코드 2 레코드 3
Plain Text
복사
한 사람의 정보가 한 덩어리로 모여 있습니다. "user_id=2인 사람의 모든 정보를 가져와라"라는 요청에 유리한 구조입니다. 한 번의 연속 읽기로 해당 레코드의 모든 필드를 가져올 수 있기 때문입니다.

1-2. Column-oriented (열 기반)

같은 필드의 값들을 연속으로 배치합니다.
메모리 레이아웃: [1, 2, 3] → user_id 컬럼 [Alice, Bob, Carol] → name 컬럼 [30, 25, 28] → age 컬럼
Plain Text
복사
같은 종류의 값이 한 덩어리로 모여 있습니다. "전체 사용자의 평균 나이를 구해라"라는 요청에 유리한 구조입니다. age 컬럼만 연속으로 읽으면 되고, name 같은 불필요한 필드는 아예 건드리지 않습니다.

1-3. 익숙한 예: RDBMS는 왜 행 기반인가

사실 행 기반 저장은 이미 익숙한 구조입니다. MySQL(InnoDB)은 데이터를 16KB 페이지 안에 행 단위로 저장하고, 이 페이지들을 B+ Tree로 관리합니다. PostgreSQL도 8KB 슬롯 페이지에 행(tuple)을 채우는 방식입니다.
InnoDB 페이지 (16KB): ┌──────────────────────────────┐ │ [1, Alice, 30] │ │ [2, Bob, 25] │ │ [3, Carol, 28] │ │ ... │ └──────────────────────────────┘ ↑ B+ Tree 리프 노드
Plain Text
복사
RDBMS는 INSERT, UPDATE, DELETE가 빈번한 OLTP 워크로드에 맞춰 설계되었고, 이런 연산은 특정 레코드 하나를 빠르게 찾아서 읽고 쓰는 작업입니다. 행 기반이 아니면 레코드 하나를 수정할 때마다 여러 컬럼 버퍼를 각각 건드려야 하니, 트랜잭션 처리에 맞지 않습니다.
반면 BigQuery, Redshift 같은 분석용 데이터베이스(OLAP)는 처음부터 열 기반으로 설계되어 있습니다. SELECT AVG(age) FROM users처럼 특정 컬럼을 대량으로 스캔하는 쿼리가 대부분이기 때문입니다.
관점
Row 기반
Column 기반
메모리/디스크 배치
레코드 단위로 연속
컬럼 단위로 연속
강점
특정 레코드 전체 읽기/쓰기
특정 컬럼만 대량 스캔
약점
컬럼 하나만 필요해도 전체 레코드를 읽음
한 레코드를 조립하려면 여러 컬럼을 합쳐야 함
대표 시스템
MySQL, PostgreSQL, Protobuf, Kafka
BigQuery, Redshift, Arrow, Parquet

2. Row 기반과 직렬화 포맷: Protobuf

Protobuf는 메시지 직렬화 포맷이지만, 하나의 레코드를 연속 바이트로 담으므로 행 기반의 특성을 가집니다. RDBMS가 디스크에서 행 기반으로 저장한다면, Protobuf는 네트워크 전송 구간에서 행 기반을 담당합니다.

2-1. 구조 복습

Protobuf는 하나의 메시지(레코드)를 직렬화할 때, 모든 필드를 순서대로 하나의 바이트 스트림에 담습니다.
message User { int32 user_id = 1; string name = 2; int32 age = 3; }
Protobuf
복사
직렬화된 바이트:
[tag=1, varint=1] [tag=2, len=5, "Alice"] [tag=3, varint=30] ───────────────────────────────────────────────────────────── 한 레코드가 하나의 연속 바이트
Plain Text
복사
InnoDB가 페이지 안에 행을 나열하는 것처럼, Protobuf도 하나의 레코드를 연속된 바이트 덩어리로 만듭니다. 다만 용도가 다릅니다. RDBMS는 디스크 I/O에, Protobuf는 네트워크 전송과 메시지 교환에 초점을 맞추고 있습니다.

2-2. 행 기반이 유리한 시나리오

행 기반은 레코드 단위로 데이터가 움직이는 구간에서 강점을 가집니다.
Kafka 메시지: 프로듀서가 보내는 단위는 "이벤트 1건"입니다. 레코드가 하나의 바이트 스트림에 담겨 있으니, 직렬화 한 번으로 토픽에 올릴 수 있습니다.
gRPC 요청/응답: 클라이언트와 서버가 주고받는 단위도 "메시지 1건"입니다. Protobuf가 gRPC의 기본 직렬화 포맷인 이유이기도 합니다.
CDC 이벤트: INSERT, UPDATE, DELETE는 모두 특정 레코드에 대한 조작입니다. 변경분을 캡처해서 전송할 때, 행 단위로 묶여 있어야 빠르게 식별할 수 있습니다.
전부 레코드 1건 단위의 쓰기/전송 작업입니다.

2-3. 한계: 분석에는 비효율적

반면 "전체 사용자의 평균 나이"를 구하려면 상황이 달라집니다.
[1, Alice, 30] → age=30 추출 [2, Bob, 25] → age=25 추출 [3, Carol, 28] → age=28 추출
Plain Text
복사
모든 레코드를 처음부터 끝까지 읽으면서, 각 레코드에서 age 필드만 골라내야 합니다. user_idname은 필요 없는데도 읽기를 피할 수 없습니다. 컬럼이 수십 개이고 레코드가 수억 건이라면 이 낭비가 누적됩니다.

3. Column 기반의 대표: Apache Arrow

Apache Arrow인메모리(In-Memory) 열 기반 포맷입니다. 분석 워크로드에서 발생하는 위와 같은 비효율을 해결하는 데 초점을 맞추고 있습니다.

3-1. 메모리 레이아웃

Arrow는 데이터를 컬럼별로 분리해서, 각 컬럼을 연속된 고정 크기 버퍼에 저장합니다.
Arrow RecordBatch (3 rows × 3 columns): user_id buffer: [1] [2] [3] ← int32 × 3 = 12 bytes 연속 name buffer: [Alice] [Bob] [Carol] ← 가변 길이, offset 배열로 관리 age buffer: [30] [25] [28] ← int32 × 3 = 12 bytes 연속
Plain Text
복사
age의 평균을 구하려면? age 버퍼 12바이트만 읽으면 끝입니다. 나머지 컬럼은 건드리지도 않습니다.

3-2. CPU 캐시와 SIMD

CPU는 메모리에서 데이터를 읽을 때 요청한 값만 가져오는 것이 아니라, 주변 데이터를 캐시 라인(Cache Line, 보통 64바이트) 단위로 함께 가져옵니다. 이 구조에서 행 기반과 열 기반의 차이가 드러납니다.
행 기반에서 age를 읽으면, 같은 캐시 라인에 user_idname이 딸려 들어옵니다. 레코드 전체를 볼 때는 유리하지만, age만 집계할 때는 불필요한 필드가 캐시를 차지하는 낭비(Cache Pollution)가 발생합니다. 열 기반에서는 age 배열을 읽으면 다음 age, 그 다음 age가 캐시에 함께 올라오므로, CPU가 메모리를 기다리는 시간(Stall)이 줄어듭니다.
같은 타입의 값이 연속으로 놓여 있으면 CPU의 SIMD(Single Instruction, Multiple Data) 명령어를 활용할 수 있습니다.
일반 처리: 30 → 더하기 25 → 더하기 28 → 더하기 (3번의 연산) SIMD 처리: [30, 25, 28] → 한 번에 더하기 (1번의 연산)
Plain Text
복사
행 기반에서는 값들이 메모리 곳곳에 흩어져 있어 SIMD를 적용하기 어렵습니다. 열 기반에서는 같은 타입의 값이 연속으로 붙어 있으므로 캐시 적중률과 SIMD 효율이 모두 높아집니다.

3-3. Zero-Copy

Arrow의 또 다른 강점은 언어 간 데이터 교환에서 나타납니다.
기존 방식에서는 Python의 DataFrame을 Java 엔진에 넘기려면
Python 객체 → 직렬화 → 바이트 → 역직렬화 → Java 객체
Plain Text
복사
이 과정에서 데이터 복사가 일반적으로 2번 이상 발생합니다.
Arrow를 쓰면
Python (Arrow 버퍼) → 메모리 주소만 전달 → Java (같은 Arrow 버퍼)
Plain Text
복사
데이터 자체는 복사하지 않고, 메모리 주소만 넘깁니다. 양쪽 언어가 동일한 Arrow 레이아웃을 이해하기 때문에 가능한 구조입니다. 이 방식을 Zero-Copy라고 부르며, Pandas, Spark, DuckDB, Polars 등이 Arrow를 데이터 교환 표준으로 채택한 배경이기도 합니다.

3-4. 한계: 쓰기/전송에는 비효율적

Arrow는 분석에 최적화되어 있지만, 레코드 단위 쓰기에는 맞지 않습니다.
새 레코드 {user_id: 4, name: "Dave", age: 32}를 추가하려면
user_id 버퍼 끝에 4를 추가 name 버퍼 끝에 "Dave"를 추가 age 버퍼 끝에 32를 추가
Plain Text
복사
3개의 서로 다른 메모리 위치에 쓰기가 발생합니다. Row 기반이라면 한 번의 연속 쓰기로 끝날 일을, 컬럼 수만큼 분산해서 처리해야 합니다. 실시간으로 레코드가 1건씩 들어오는 CDC나 메시지 스트림에서는 이 오버헤드가 누적됩니다.

마무리

데이터가 어떤 작업을 위해 움직이느냐에 따라 물리적으로 유리한 배치가 달라집니다. 레코드 단위의 쓰기/전송에는 행 기반(Protobuf)이, 컬럼 단위의 스캔/집계에는 열 기반(Arrow)이 적합합니다. MySQL이 행 기반인 것과 BigQuery가 열 기반인 것은 같은 원리입니다. 실무에서 포맷을 선택할 때 이 구간이 수집인지 분석인지만 먼저 판단하면 대부분 답이 나옵니다.
다음 글에서는 열 기반 포맷이 내부적으로 어떻게 생겼는지, Apache Parquet의 구조를 뜯어보겠습니다.