데이터 모델과 인코딩
관계·문서·그래프 모델 가운데 무엇을 고를지, 데이터를 바이트로 바꾸는 형식과 스키마가 바뀌어도 서로 읽히게 하는 법이다.
관계·문서·그래프 모델
데이터 모델은 "데이터를 어떤 모양으로 생각하느냐"다. 같은 사실(사용자, 사용자가 쓴 글, 누가 누구를 팔로하는지)도 표 여러 개로 나눠 담을 수 있고, 사람 한 명 몫을 문서 하나로 묶을 수도 있고, 사람과 글을 점으로, 관계를 선으로 그릴 수도 있다. 어느 모양이 옳은지는 데이터가 아니라 읽고 쓰는 방식(접근 패턴)이 정한다.
- 관계형: 사실을 한 번만 적고(정규화) 필요할 때 조인으로 모은다. 질의를 미리 몰라도 되고, 여러 행을 한 트랜잭션으로 고치기 쉽다. 엔진 내부는 관계형 데이터베이스 노트에 있다.
- 문서: 한 번에 통째로 읽고 쓰는 덩어리(주문 하나, 프로필 하나)가 분명할 때 맞다. 조인 없이 한 번에 읽혀 지역성이 좋지만, 여러 문서가 같은 값을 나눠 가지면(다대다) 복제된 값을 맞추는 일이 응용 코드로 넘어온다. PostgreSQL의 jsonb처럼 관계형 엔진이 문서 열을 지원하면서 경계는 흐려졌다. MongoDB 같은 엔진은 문서·와이드 컬럼 노트에 있다.
- 그래프: "친구의 친구가 좋아한 글"처럼 관계를 몇 단계든 따라가는 질의가 중심일 때 맞다. 관계형에서는 단계마다 조인이 하나씩 늘지만, 그래프 엔진은 점에서 이웃으로 바로 건너간다. Neo4j의 그래프 데이터베이스 개념과 질의 언어 openCypher가 대표적이다. 내부 구조는 시계열·그래프·벡터 노트에서 다룬다.
실무에서는 하나만 고르지 않는다. 주문은 관계형에, 상품 상세는 문서로, 추천용 관계는 그래프로 두고, 그 사이를 뒤에 나올 변경 데이터 캡처로 잇는 구성이 흔하다(배치와 스트림 처리 노트).
스키마는 언제 검사하나
"문서 데이터베이스는 스키마가 없다"는 말은 정확하지 않다. 읽는 코드는 어차피 어떤 필드가 있다고 가정하므로 스키마는 늘 있다. 다른 것은 검사하는 시점이다. 쓸 때 데이터베이스가 모양을 강제하면 schema-on-write, 무엇이든 받아 두고 읽는 코드가 해석하면 schema-on-read다.
- 쓸 때 검사: 틀린 데이터가 들어오지 않아 읽는 쪽이 단순하다. 대신 모양을 바꾸려면 마이그레이션(ALTER TABLE)이 필요하다. 요즘 엔진은 기본값이 있는 열 추가를 메타데이터만 고쳐 즉시 끝내지만, 열의 타입을 바꾸면 표 전체를 다시 쓸 수 있다.
- 읽을 때 해석: 항목마다 모양이 다른 데이터(외부에서 받은 이벤트, 사용자 정의 속성)를 담기 쉽고 배포가 빠르다. 대신 "옛 문서면 이렇게, 새 문서면 저렇게"라는 분기가 읽는 코드 곳곳에 쌓인다. 데이터베이스가 검사하지 않을 뿐 스키마를 관리할 책임은 그대로다.
텍스트 형식의 한계
메모리 안의 객체를 파일에 쓰거나 네트워크로 보내려면 바이트열로 바꿔야 한다. 이것이 인코딩(직렬화)이다. JSON·XML·CSV는 사람이 읽을 수 있고 거의 모든 언어가 지원해 조직 사이 데이터 교환에 표준처럼 쓰이지만, 타입을 느슨하게 다루는 데서 오는 함정이 있다.
- 숫자: JSON 명세 RFC 8259는 숫자의 범위와 정밀도를 구현에 맡기고, 상호 운용을 원하면 IEEE 754 배정밀도 범위 안에 머물라고 권한다. JavaScript처럼 모든 숫자를 배정밀도 실수로 읽는 쪽은 2⁵³을 넘는 정수의 끝자리를 조용히 바꾼다. 64비트 ID는 문자열로 보내는 것이 안전하다. 정수와 실수의 구분도 없다.
- 바이너리: JSON과 XML 문자열에는 임의의 바이트를 넣을 수 없어 Base64로 바꿔 싣는다. 3바이트가 4글자가 되므로 부피가 3분의 1 늘고, 받는 쪽은 어느 필드가 Base64인지 따로 알아야 한다.
- CSV: 열 이름 줄은 있어도 타입이 없어 07이 숫자인지 우편번호 같은 글자인지 모른다. 값 안의 쉼표와 따옴표는 따옴표로 감싸고 겹쳐 써야 한다. 이 규칙을 적은 RFC 4180도 표준이 아닌 정보 문서라, 도구마다 해석이 조금씩 다르다.
JSON Schema나 XML Schema로 타입을 적을 수는 있지만 강제력은 쓰는 쪽에 달려 있다. 그래서 같은 조직 안에서 대량으로 주고받는 데이터에는 스키마를 전제로 한 이진 형식을 쓰는 경우가 많다.
이진 인코딩: 태그와 스키마
Protocol Buffers(Google), Thrift(Facebook에서 시작해 Apache로 감), Avro(Hadoop 생태계)는 모두 스키마를 먼저 적고 그 스키마로 바이트를 만든다. 셋을 가르는 질문은 하나다. 바이트 안에 "이 값이 어느 필드인가"를 적느냐다.
- 필드 태그(Protocol Buffers·Thrift): 값마다 앞에 필드 번호와 와이어 타입을 담은 태그를 붙인다. Protocol Buffers 인코딩 문서에 따르면 태그는 (필드 번호 << 3) | 와이어 타입이고, 정수는 7비트씩 끊어 쓰는 varint라 작은 수는 한 바이트다. 필드 이름은 바이트에 없고 번호만 있다. 그래서 이름은 바꿔도 되지만 번호는 바꾸면 안 된다. Thrift도 2007년 백서에서 같은 이유로 필드마다 정수 식별자를 둔다고 설명한다.
- 쓴 쪽 스키마와 읽는 쪽 스키마(Avro): 태그가 없고 값만 스키마에 적힌 순서대로 이어 붙인다. 그래서 가장 작지만, 바이트만으로는 무엇이 어느 필드인지 모른다. Avro 명세는 데이터를 쓸 때 쓴 스키마(writer's schema)를 함께 알게 하고, 읽는 쪽은 자기 스키마(reader's schema)와 필드 이름으로 맞춰 읽는 "스키마 해석" 규칙을 정한다. 큰 파일은 머리에 스키마를 한 번 적고, 메시지마다 보낼 때는 스키마 레지스트리의 번호만 붙인다.
Avro처럼 태그가 없으면 스키마를 데이터베이스 표에서 자동으로 만들기 쉽다. 열이 늘거나 줄어도 새 스키마를 뽑으면 되고, 번호를 사람이 관리하지 않아도 된다. 반대로 Protocol Buffers는 번호만 지키면 스키마 없이도 모르는 필드를 건너뛸 수 있어 RPC처럼 양쪽이 따로 배포되는 곳에 편하다.
스키마 진화와 호환성
서버 여러 대를 한 대씩 새 버전으로 바꾸는 동안, 또 모바일 앱처럼 사용자가 업데이트를 미루는 동안에는 옛 코드와 새 코드가 함께 돈다. 데이터도 마찬가지로 옛 형식과 새 형식이 섞인다. 그래서 호환성을 두 방향으로 따진다.
- 필드 추가: 새 필드는 선택이거나 기본값이 있어야 한다. 새 코드가 옛 데이터를 읽을 때 그 값이 없으니 기본값으로 채우고, 옛 코드는 새 데이터의 모르는 필드를 건너뛴다. Protocol Buffers는 태그로 길이를 알아 건너뛰고, Avro는 쓴 쪽 스키마에 있고 읽는 쪽에 없는 필드를 무시한다.
- 필드 삭제: 선택 필드만 지울 수 있다. 지운 번호를 나중에 다른 뜻으로 다시 쓰면 옛 데이터가 엉뚱하게 해석된다. proto3 언어 안내는 지운 필드의 번호와 이름을 reserved로 묶어 다시 못 쓰게 하라고 권한다.
- 타입 변경: int32를 int64로 넓히는 것은 대개 되지만, 옛 코드가 큰 값을 읽으면 잘린다. 형식마다 허용 목록이 다르니 문서를 확인한다.
- 모르는 필드 보존: 옛 코드가 새 레코드를 읽어 객체로 만든 뒤 다시 쓸 때 모르는 필드를 버리면, 새 코드가 쓴 값이 사라진다. 읽은 그대로 다시 쓰는지 확인해야 한다.
Kafka 생태계의 스키마 레지스트리는 이 규칙을 자동으로 검사한다. Confluent 문서는 BACKWARD·FORWARD·FULL과 그 TRANSITIVE 변형으로 호환 수준을 고르고, 새 스키마가 규칙을 어기면 등록을 거절한다. 세 형식의 진화 규칙을 바이트 수준에서 나란히 비교한 글로는 Martin Kleppmann의 2012년 블로그 글 「Schema evolution in Avro, Protocol Buffers and Thrift」가 있다.
데이터가 흐르는 세 길
인코딩한 데이터가 한 프로세스에서 다른 프로세스로 가는 길은 크게 셋이다. 길마다 누가 언제 읽는지가 달라서 호환성을 지키는 방법도 다르다.
- 데이터베이스: 쓰는 프로세스가 미래의 읽는 프로세스에게 보내는 메시지로 볼 수 있다. 코드는 몇 분 만에 바뀌어도 5년 전에 쓴 행은 그대로 남는다. 그래서 대개 하위 호환이 필수이고, 새 코드가 옛 행을 다시 쓸 때 모르는 열을 지우지 않게 조심한다.
- 서비스 호출(REST·RPC): REST는 Roy Fielding의 2000년 박사 논문이 정리한 아키텍처 스타일로, HTTP의 메서드·상태 코드·캐시를 그대로 쓴다. RPC는 원격 호출을 함수 호출처럼 보이게 하지만, 네트워크 호출은 시간 초과·재시도·중복 실행이 있어 지역 함수와 같을 수 없다. gRPC는 Protocol Buffers로 서비스와 메시지를 정의하고, 호출마다 마감 시간(deadline)을 두어 네트워크 호출이라는 사실을 감추지 않는다. 보통 서버를 먼저 올리므로 요청은 하위 호환, 응답은 상위 호환이면 된다. 공개 API는 클라이언트를 강제로 올릴 수 없어 버전을 URL이나 헤더에 적어 오래 유지한다.
- 비동기 메시지: 보내는 쪽은 브로커에 넣기만 하고 받는 쪽을 모른다. 받는 쪽이 느리거나 죽어 있어도 메시지는 기다리고, 한 메시지를 여러 소비자가 읽을 수 있다. 소비자가 받은 메시지를 다른 토픽으로 다시 보낼 때 모르는 필드를 지우지 않아야 한다. 브로커의 종류는 배치와 스트림 처리 노트에서 다룬다.
서비스 호출 쪽에서 흔히 비교하는 세 방식을 표로 놓으면 이렇다. 어느 것이 낫다기보다 누가 부르는가에 따라 고른다. 대체로 공개 API에는 REST, 화면마다 필요한 데이터 모양이 크게 다른 앱에는 GraphQL, 내부 서비스끼리의 촘촘한 호출에는 gRPC가 잘 맞는 편이다.
| REST | GraphQL | gRPC | |
|---|---|---|---|
| 계약 | 자원 URL과 HTTP 메서드. 스키마는 OpenAPI 같은 별도 문서 | 타입 있는 스키마 하나. 클라이언트가 필요한 필드를 질의로 고른다 | Protocol Buffers로 서비스와 메시지를 정의하고 코드를 생성 |
| 주소 | 자원마다 여러 엔드포인트 | 보통 엔드포인트 하나 | 서비스의 메서드 |
| 캐시 | GET과 HTTP 캐시·CDN을 그대로 쓴다 | POST가 기본이라 그대로는 어렵다. 질의를 GET으로 보내거나 질의 해시만 보내는 persisted query로 캐시한다 | 호출 결과 캐시는 애플리케이션이 따로 맡는다 |
| 흔한 함정 | 화면 하나에 여러 번 부르거나(왕복), 필요 없는 필드까지 받는다 | 필드 리졸버가 목록의 항목마다 원천을 따로 부르는 N+1 문제, 너무 깊고 비싼 질의 | 브라우저에서 바로 부를 수 없어 gRPC-Web과 프록시가 필요 |
| 흐름 | 요청 하나에 응답 하나 | 질의·변경, 구독(subscription) | 단항과 서버·클라이언트·양방향 스트리밍 RPC |
GraphQL의 두 함정은 graphql.org의 성능 안내가 직접 다룬다. N+1은 짧은 시간 동안 모인 요청을 한 번에 원천으로 보내는 DataLoader 같은 배치로 줄이고, 비싼 질의는 목록 페이지 나누기, 질의 깊이·폭 제한, 복잡도 분석으로 막는다. gRPC의 스트리밍 종류는 핵심 개념 문서, 브라우저 쪽은 gRPC-Web 안내에 있다.
이 노트의 주제 순서는 Martin Kleppmann과 Chris Riccomini의 『Designing Data-Intensive Applications』 2판(2026)의 앞부분을 지도로 삼았다. 2판 소개는 저자 누리집의 책 페이지에 있다.
- Protocol Buffers Documentation — Google
- Designing Data-Intensive Applications, 2nd Edition — Martin Kleppmann·Chris Riccomini
- Protocol Buffers Encoding — Google
- Language Guide (proto 3) — Google
- Apache Avro Specification 1.12.0 — Apache Avro
- Thrift: Scalable Cross-Language Services Implementation — Slee·Agarwal·Kwiatkowski
- Schema evolution in Avro, Protocol Buffers and Thrift — Martin Kleppmann
- Schema Evolution and Compatibility — Confluent Docs
- RFC 8259: The JSON Data Interchange Format — IETF
- RFC 4180: Common Format for CSV Files — IETF
- JSON Types — PostgreSQL Documentation
- Graph database concepts — Neo4j
- openCypher — openCypher Project
- Architectural Styles and the Design of Network-based Software Architectures — Roy Fielding
- Introduction to gRPC — gRPC
- Deadlines — gRPC
- Performance — GraphQL
- Core concepts, architecture and lifecycle — gRPC
- gRPC-Web Basics tutorial — gRPC