일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- docker
- pipex
- ecole42
- 42Seoul
- Spring
- django #ninja #django-ninja #장고
- Spring-Boot
- Daemon
- nestjs
- 42
- Born2beroot #42
- dockerd
- 데이터중심애플리케이션설계
- 네스트JS
- data-root
- Today
- Total
혼자 정리
[데이터 중심 애플리케이션 설계] Part 1 데이터 시스템의 기초 본문
데이터 중심 애플리케이션 설계
1. 신뢰할 수 있고 확장 가능하며 유지보수하기 쉬운 애플리케이션
- 계산 중심(compute-intensive) vs. 데이터 중심(data-intensive)
- 오늘날에는 보통 데이터 중심 애플리케이션
- 애플리케이션은 추상화된 데이터 시스템을 사용
- 데이터 시스템은 저마다 특성이 다르고 애플리케이션의 요구사항에 맞게 적합한 것을 골라야 함
- 세 가지 주요 관심사
- 신뢰성(Reliability) : 어떤 역경에도 시스템이 지속적으로 올바르게 동작
- 확장성(Scalability) : 시스템 데이터 양, 트래픽 양, 복잡도가 증가할 때 처리할 적절한 방법이 있어야 함.
- 유지보수성(Maintainability) : 모든 사용자가 시스템 상에서 생산적으로 작업할 수 있게 해야 함
신뢰성
- 결함(fault) : 잘못 될 수 있는 일
- 장애(failure)와 다른 점? : 결함은 사양에서 벗어난 시스템의 한 구성 요소지만, 장애는 사용자에게 필요한 서비스를 제공하지 못하고 시스템 전체가 멈춘 경우.
- 결함 확률을 0으로 줄이는 것은 불가능
- 신뢰할 수 없는 여러 부품으로 신뢰할 수 있는 시스템을 구축해야 함 → 내결함성(fault-tolerant)
여러 결함 유형
- 하드웨어 결함
- 하드웨어를 중복 추가하여 하드웨어 결함으로 인한 시스템 장애율을 줄일 수 있음
- 소프트웨어 오류
- 환경에 대한 잘못된 가정으로 인해 생기는 경우가 많음
- 가정이 맞는 것처럼 보이지만 최종적으로는 틀린 경우가 종종 있음
- 체계적 오류(systematic error)
- 신속한 해결책이 없다. 시스템 가정과 상호작용에 대해 숙고하기, 빈틈없는 테스트, 프로세스 격리, 모니터링 등 여러 방식을 사용
- 인적 오류
- 대규모 인터넷 서비스에 대한 연구에 따르면 운영자의 설정 오류가 시스템 중단의 주요 원인
- 여러 대응 방식
- 오류 가능성을 최소화하는 방향으로 시스템 설계
- 실수로 장애가 발생하기 쉬운 곳을 분리.
- 철저한 테스트
- 인적 오류를 빠르고 쉽게 복구할 수 있게 하기. 롤백, 롤아웃 등
- 성능 지표와 오류율 등 상세하고 명확한 모니터링
- 조직 교육과 실습
신뢰성의 중요성 예시
- 비즈니스 애플리케이션의 버그로 생산성 저하
- 전자 상거래 사이트 중단으로 인한 매출 손실
- 온라인 백업 서비스에 오류 발생
확장성
부하가 증가해도 시스템이 안정적으로 동작할 수 있는지에 대한 특성
단순히 확장성이 있다 없다의 측면이 아니라, 시스템 부하가 ~~한 방식으로 증가했을 때 어떤 옵션들을 통해 대처할 수 있는지와 같은 선택지가 중요
시스템의 현재 부하를 정의하는 것이 중요
- ex) 웹 서버의 초당 요청 수, 데이터베이스의 읽기 대 쓰기 비율, 대화방의 동시 활성 사용자, 캐시 적중률 등
- 평균값이 중요할 수도 있고, 극단적인 값이 중요할 수도 있고
- 트위터의 예시
- 홈 타임라인 조회가 users, follows, tweets 등 여러 테이블의 조인으로 큰 부하를 일으킴
- 평균적으로 트윗 게시 요청량(쓰기)이 홈 타임라인 읽기 요청량보다 수백 배 적음
- 개별 사용자의 홈 타임라인 캐시를 두고 트윗 작성(쓰기)에 대해 팔로워의 타임라인 캐시에 쓰기를 수행(팬 아웃)
- 쓰기가 읽기에 비해 적게 이루어지므로 쓰기 시점에 더 많은 일을 하도록 한 것.
- 평균적으로는 팔로워 수가 크지 않기 때문에 트윗 작성이 유발하는 홈 타임라인 작성은 견딜만 함
- 하지만 셀럽의 경우 팔로우가 수천 만 명에 달하므로 단일 트윗이 홈 타임라인에 그만큼의 작성을 유발할 수 있음 → 과도한 팬아웃 부하
- 최종적으로 트위터는 하이브리드 형태를 적용
- 일반적인 사용자의 트윗은 홈 타임라인에 팬 아웃
- 유명인의 트윗은 읽는 시점에 홈 타임라인에 합치기
부하를 정의하면 부하가 증가할 때 어떤 일이 생기는지 체크할 수 있음.
- 부하량을 늘리고 시스템 리소스는 변경하지 않으면 시스템 성능은 어떻게 영향을 받는지?
- 부하량을 늘리면서 성능을 유지하려면 리소스를 얼마나 늘려야 할지?
이 질문의 답을 알려면 성능을 정의할 수 있어야 함
어떤 시스템인지에 따라 중요하게 보는 성능 수치가 다름
하둡 같은 일괄 처리 시스템은 처리량(throughput)(초당 처리 가능한 양 or 주어진 양을 처리할 때 걸리는 시간)이 중요
온라인 서비스에서는 보통 응답 시간(response time)이 중요.
응답 시간의 경우 매 번 달라지므로 값의 분포로 생각해야 함
- 평균보다 백분위를 사용하는 것이 더 유용
- 사용자가 보통 얼마나 오랫동안 기다려야 하는지 알고 싶으면 중앙값(median)이 적절
- 특이값이 얼마나 좋지 않은지 알아보려면 상위 백분위가 적절(p95, p99, p999)
- 백분위는 서비스 수준 목표(service level objective, SLO)와 서비스 수준 협약서(service level agreement, SLA)에 자주 나옴
꼬리 지연 증폭(tail latency amplification) : 단일 사용자 요청이 병렬적으로 여러 백엔드에 호출한다면 최종 요청의 응답을 기다려야 함. 작은 비율의 응답이 느려져도 사용자 요청 응답 시간이 느려지게 되는 현상
- 상위 백분위를 살피는 것이 중요한 이유
부하가 증가하더라도 좋은 성능을 유지하려면?
- 리소스를 확장하는 방법이 존재
- 용량 확장(scaling up) vs. 규모 확장(scaling out)
- 고사양 장비는 매우 비싸므로 부하가 증가하다 보면 스케일링 아웃을 하는 것이 자연스러운 단계
- 그렇지만 적절한 사양의 장비를 선택해야 함에 유의. 너무 낮은 사양의 장비를 많이 사용하는 것보다 적절한 사양의 장비 몇 대를 사용하는 것이 더 간단하고 저렴
- 탄력적(elastic)인 리소스 사용도 가능
- 부하 증가할 때 컴퓨팅 자원을 자동으로 추가하는 식
- 부하가 예측하기 어려운 경우 유용하다는 장점. 하지만 시스템이 복잡해지고 운영상 예상치 못한 일이 더 생기기 쉽다는 단점
- 용량 확장(scaling up) vs. 규모 확장(scaling out)
- one-size-fits-all한 확장 아키텍처는 없다.
- 읽기 양, 쓰기 양, 저장할 데이터의 양, 데이터 복잡도, 응답 시간 요구사항, 접근 패턴 등 여러 조건에 따라 적합한 것이 달라진다.
- 결국 이러한 조건들이 필요한 확장성에 대한 가정이 된다. 이 가정이 틀린 경우, 확장에 대한 엔지니어링은 복잡성만 낫게 된다.
- 특정 애플리케이션에 적합하도록 확장성을 갖춘 아키텍처를 짜더라도, 보통 범용적인 구성 요소로 이러한 아키텍처를 구축하게 된다.
유지보수성
유지보수 비용을 줄이기 위한 소프트웨어 시스템 설계 원칙
- 운용성(operability) : 쉽게 운영할 수 있도록
- 동일 반복 태스크를 쉽게 할 수 있게 만들어서 고부가가치 활동에 노력을 집중할 수 있도록
- 단순성(simplicity) : 엔지니어가 이해하기 쉽도록
- 우발적 복잡도(accidental complexity) : 소프트웨어가 풀어야 할 문제와 상관 없이 구현으로 인해 발생하는 복잡도
- 추상화를 통해 우발적 복잡도를 줄일 수 있다.
- 발전성(evolability) : 시스템을 쉽게 변경할 수 있도록
2. 데이터 모델과 질의 언어
데이터 모델은 소프트웨어가 해결하려는 문제를 어떻게 생각해야 하는지에 대해 지대한 영향을 미친다.
보통의 애플리케이션은 하위 계층의 데이터 모델을 또 다른 데이터 모델로 추상화하는 식으로 구축된다.
- ex) 애플리케이션 → JSON/XML,… → byte level → 하드웨어
데이터 모델은 이를 활용하여 소프트웨어가 할 수 있는 일과 없는 일에 큰 영향을 주므로 적절하게 선택하는 것이 중요
관계형 모델과 문서 모델
관계형 데이텅베이스는 70년대에 등장하여 장기간 높은 점유율을 가져왔다.
기존 다른 데이터베이스와 달리 정리된 인터페이스 뒤로 구현 세부 사항을 숨겼고, 이로 인해 애플리케이션 개발자는 데이터베이스 세부 구현을 덜 신경써도 되었다.
2010년대부터 다양한 요구 사항에 맞추기 위해 NoSQL의 점유율도 올라오게 되었다.
앞으로는 다양한 데이터베이스를 함께 사용하는 polyglot persistence 개념이 널리 퍼질 것으로 보인다.
문서 데이터 모델의 장점 : 스키마 유연성, 로컬리티로 인한 성능, 일부 애플리케이션의 경우 사용되는 데이터 구조와 더 유사
관계형 데이터 모델의 장점 : 조인, 다대일, 다대다 관계를 더 잘 지원
어떤 기준으로 선택해야 할까?
일대다 관계 트리로 데이터가 문서와 비슷한 구조라면 문서 모델을 사용하는 것이 상대적으로 좋다.
다대다 관계를 사용한다면 관계형 모델이 유리하다. 우선 조인이 쉽지 않다. 비정규화를 통해 조인의 필요성을 줄일 수 있지만 데이터 일관성 유지를 위한 추가 작업이 늘어난다. 애플리케이션 조인은 데이터베이스 내 특화된 조인보다 보통 느리다.
이처럼 데이터 항목 간의 관계 유형에 따라 적합한(코드를 단순하게 만드는) 모델 선택은 달라질 수 있다.
스키마리스(schemaless) 용어에 대한 오해
문서 디비는 스키마리스로 불리지만 데이터를 읽는 코드는 구조에 대한 가정을 어느 정도 할 수 밖에 없다.
관계형 디비는 쓰기 스키마(schema-on-write)이고, 문서 디비는 읽기 스키마(schema-on-read)라 볼 수 있다.
문서 디비에서 새 필드를 추가하는 경우 앱 코드에 기존 문서와 호환성을 처리하는 코드만 두면 된다.
하지만 관계형 디비는 스키마 마이그레이션을 수행해야 하므로 보다 번거롭다.
흔히 말하는 스키마리스(읽기 스키마 접근 방식)는 컬렉션 내의 항목이 모두 동일한 구조가 아닐 때 유리하다. 더욱이 구조가 외부 시스템에 의해서 결정된다면 스키마리스의 편의가 더욱 커진다.
그렇지 않고 모든 레코드의 구조가 동일하고 예상 가능하다면 스키마를 통해 문서화와 구조를 강제하는 것이 유용하다.
질의를 위한 데이터 지역성
웹 페이지 문서를 보여주는 것처럼 앱이 전체 문서를 접근할 일이 많은 경우 저장소 지역성(storage locality)를 활용하면 성능 이점이 있다. 여러 테이블에서 전체 문서를 조합하는 것보다 디스크 탐색을 덜 하게 될 가능성이 있기 때문.
문서가 큰 경우 문서의 아주 일부분만 접근해도 전체 문서를 불러와야 하므로 자원 낭비일 수 있다.
문서 갱신 또한 인코딩 된 문서의 크기를 바꾼다면 전체 문서를 다시 써야 할 수 있다.
따라서 일반적으로 문서를 작게 유지하면서 문서 크기가 늘어나는 쓰기를 피하는 것을 문서 디비에서는 권장한다.
이러한 지역성을 문서 디비가 아닌 곳에서도 차용할 수 있다.
- 구글의 스패너(Spanner) 디비 : 부모 케이블 내에 (중첩되도록) 테이블의 로우를 교차 배치되게 선언하는 스키마를 허용
- 오라클 : 다중 테이블 색인 클러스터 테이블(multi-table index cluster table) 기능
- 빅테이블(Bigtable) : 칼럼 패밀리(column-family) 개념(카산드라, HBase에서 사용)
문서 디비와 관계형 디비의 융합
- 관계형 디비 : XML, JSON의 지원
- 문서형 디비
- 리싱크DB : 질의 언어에서 관계형 조인 지원
- 몽고DB : 드라이버가 자동으로 데이터베이스 참조를 확인(클라이언트 단에서 조인 수행)
선언형 쿼리 언어
SQL은 기존의 IMS, 코다실과 다르게 명령형(imperative)이 아닌 선언형(declarative) 언어
선언형 쿼리 언어는 사용하기에 간결하다는 장점이 있다. 하지만 더 중요한 건 상세 구현이 숨겨져 있으므로 질의를 수정하지 않고도 성능을 향상시킬 수 있다는 점이다.
또한 명령형처럼 명령어를 순차적으로 실행할 필요가 없으므로 병렬 처리에 더욱 유리하다.
그래프형 데이터 모델
데이터에서 다대다 관계가 일반적이라면 그래프 디비가 적절하다.
그래프 디비에는 두 가지 객체가 존재한다. 정점(vertex), 간선(edge)
다음과 같은 그래프 예시가 있다.
- 소셜 그래프 : 사람이 정점이고, 간선은 사람 사이의 관계
- 웹 그래프 : 웹 페이지가 정점이고, 간선은 다른 페이지에 대한 링크
- 도로나 철도 네트워크 : 교차로가 정점이고, 간선은 교차로 간 도로나 철로 선
pagerank는 웹 그래프를 사용해서 웹 페이지의 인기와 검색 결과 순위를 결정한다.
앞의 예시는 동일 유형의 객체를 다루지만, 페이스북처럼 서로 다른 유형의 정점인 사람, 장소, 이벤트, 체크인, 코멘트 등을 단일 그래프에서 다룰 수도 있다.
대표적인 그래프 모델은 다음과 같다.
- 속성 그래프(property graph) 모델 : Neo4j, Tita, InfiniteGraph 등의 구현
- 트리플 저장소(triple-store) 모델 : Datomic, Allegrograph 등의 구현
그래프용 선언형 질의 언어는 다음과 같은 것들이 있다.
- Cypher
- SPARQL
- Datalog
3. 저장소와 검색
이번 장은 디비가 데이터를 저장하는 방식과 조회하는 방식을 살펴본다.
인덱스는 디비에서 특정 키의 값을 효율적으로 찾기 위해 필요한 데이터 구조다.
인덱스는 기본 데이터(primary data)에서 파생된 추가적인 구조다. 그러니 인덱스를 추가하는 것은 인덱스가 없을 때보다 쓰기 작업에 오버헤드를 추가한다.
따라서 적절한 인덱스를 추가하는 것은 읽기와 쓰기의 트레이드오프를 적절하게 저울질하는 것이라고 볼 수 있다.
해시 인덱스
일단 파일에 순차적으로 키-밸류를 기록하는 log-structured storage를 가정한다. 이러한 각 파일(세그먼트)은 압축과 병합을 통해 너무 커지지 않게 관리되고, 이 과정은 백그라운드에서 진행된다.
인메모리 키-밸류 저장소를 이용해 인덱스를 생성할 수 있다.
만약 단순 파일에 append하는 방식으로 데이터 저장소를 구성한다 해보자. 파일에 키-밸류를 계속 추가하는 방식이다.
파일에 데이터를 추가할 때마다 인메모리 키-밸류 스토어에 (키, 파일에서 키가 위치하는 바이트 오프셋)쌍을 저장할 수 있다(append-only log). 키에 해당하는 값 데이터를 찾을 때 이를 이용해 위치를 파일 풀 스캔 없이 확인할 수 있다.
이러한 인덱스는 각 키의 값이 자주 갱신되는 상황에 적합하다.
하지만 해시 테이블 색인은 다음과 같은 단점이 있다.
- 키가 너무 많으면 메모리 자원을 많이 써야 한다. 무작위 접근이 많으므로 디스크에 해시 맵을 기록한다면 좋은 성능을 얻기 어렵다.
- Range query에 적합하지 않다
SS테이블과 LSM 트리
앞에서 본 log-structured 스토리지에서 키-밸류 쌍을 개별 세그먼트 내에서 중복 없이 키로 정렬한 것을 Sorted String Table(SSTables)라고 부른다.
이렇게 키를 중복 없이 정렬함으로써 다음 이점을 얻을 수 있다.
- mergesort와 유사한 방식을 활용해 세그먼트 병합의 효율성을 높일 수 있다.
- 인덱스로 인한 메모리 비용을 줄일 수 있다. 이것은 모든 키에 대한 인덱스를 유지하지 않더라도 인덱스를 활용한 조회를 할 수 있기 때문이다. ‘aaa’키와 ‘aba’키가 인덱스에 존재한다면 ‘aac’ 키는 (만약 존재한다면) 두 키를 통해 찾은 파일 오프셋 사이에 있다는 것을 알 수 있다.
- 그렇게 sparse한 인메모리 인덱스를 유지한다고 할 때 각 인덱스가 가리키는 범위(다음 인덱스 직전까지)를 블록으로 묶어서 압축할 수 있다. 그러면 디스크 공간도 절약하고 I/O 대역폭도 줄일 수 있다.
그런데 데이터를 키로 정렬하는 것은 어떻게 해야 할까?
디스크보다는 메모리 위에서 정렬된 구조를 유지하는 것이 훨씬 간편하므로 레드-블랙 트리나 AVL 트리 같은 자료 구조를 활용하여 정렬된 키 데이터 구조를 유지할 수 있다.
그러면 이러한 스토리지 엔진을 만들 수 있다.
- 쓰기 요청에는 인메모리 균형 트리 구조에 추가한다(memtable)
- Memtable이 임계치보다 커지면 SS테이블 파일로 디스크에 기록한다. 기록하는 동안 쓰기는 새 memtable에 기록한다.
- 읽기 요청이 들어오면 memtable, 디스크 상 최신 세그먼트, 그 다음 세그먼트, … 순서로 조회한다.
- 백그라운드에서 이따금씩 세그먼트 파일 병합, 컴팩션 과정을 수행한다.
만약 디비에 고장이 발생하면 memtable에만 있는 데이터는 손실된다. 이 문제를 피하기 위해 복구용 append-only 로그를 디스크 상에 유지할 수 있다. 이 로그는 연결된 memtable이 SSTable에 기록된 다음에는 지워도 무방하다.
⇒ 이와 유사하게 SSTable, Memtable을 기반으로 하는 구조를 Lot-Structured Merge-Tree (LSM Tree)라고 부른다.
Bigtable, Lucene 등이 LSM Tree와 유사한 방식을 사용하는 것으로 알려져 있다.
B-tree
B트리는 균형 트리의 일종이다. 이진 트리와 달리 하나의 노드가 가질 수 있는 자식 노드의 수가 두 개를 넘길 수 있다.
많은 데이터베이스에서 인덱스 구조로 B 트리를 활용한다.
이진 트리에서 B 트리로 자식 노드 수를 확장하면서, 트리 높이는 감소하고, 밸런싱 작업은 줄어들게 된다. 따라서 디스크와 같이 노드 접근 시간이 데이터 처리 시간보다 큰 경우에 유리하다.
브랜칭 팩터가 500인 4KB 페이지의 4레벨 트리는 256TB까지 저장할 수 있다 (500^4 * 4096B = 256TB)
Making B-trees reliable
B 트리의 페이지 쓰기는 LSM 트리와 다르게 기본적으로 덮어쓰기 방식이다. (LSM 트리 인덱스에서는 파일에 추가만 한다).
그렇지만 여러 페이지를 덮어써야 하는 동작의 경우 일부 페이지만 기록하고 크래시가 발생한다면 혼자 동떨어진 orphan 페이지가 발생할 수 있다.
그래서 크래시 상황에서도 복구가 가능하도록 보통 디스크에 쓰기 전에 redo log라고도 불리는 WAL(write-ahead log)에 기록한다.
또한 멀티 스레딩의 상황에서 동시에 B 트리에 접근하여 일관성이 깨지는 것을 막기 위해 보통 latch(가벼운 락)로 임계 지점을 보호한다.
B 트리 최적화
여러가지 최적화 방법이 존재한다.
- WAL 대신 copy-on-write scheme을 채택하여 변경된 페이지는 다른 위치에 기록하고 새 부모 페이지를 만들어서 가리키게 한다.
- 키를 축약해서 저장하여 한 페이지에 더 많은 키를 담을 수 있게 한다. 이렇게 하면 브랜칭 팩터를 높여서 트리 깊이를 줄일 수 있다.
- 리프 페이지가 디스크 상에 연속된 위치에 나타나게끔 배치하도록 노력해서 range 쿼리의 효율성을 높이려 한다.
- 리프 페이지에 형제 페이지에 대한 포인터를 추가해서 상위로 다시 가지 않고 순차적으로 스캔 가능하도록 할 수 있다.
OLTP와 OLAP
그동안 일반적인 앱은 데이터 접근 패턴이 전통적인 커머셜 트랜잭션 처리와 유사했다. 인덱스를 통해 일부 키에 대한 레코드를 찾고, 레코드는 사용자 입력을 기반으로 삽입되거나 업데이트된다.
이러한 접근 패턴을 OLTP(Online transaction processing)라 부른다.
그렇지만 점점 디비를 데이터 분석에도 많이 사용하게 되었다. 이는 트랜잭션과 접근 패턴이 상이하다. 분석 쿼리는 raw 데이터를 반환하지 않고 많은 레코드의 일부 칼럼만 읽어서 통계를 계산해야 한다.
이런 방식의 접근 패턴은 OLAP(Online analytic processing)라 부른다.
데이터 웨어하우싱
보통 OLTP 시스템은 비즈니스 운영에 중요하므로 OLTP 디비에 즉석 분석 쿼리를 날리는 것은 꺼려진다. (많은 스캔으로 인해 트랜잭션 성능 저하 우려)
그래서 데이터 웨어하우스를 마련하여 분석가들이 OLTP 작업에 영향을 주지 않고 쿼리를 날릴 수 있도록 한다.
- OLTP에서 웨어하우스로 데이터를 가져오는 과정은 Extract-Transform-Load의 형태를 따라 보통 ETL이라고 부른다.
분석용 스키마: star schema
많은 데이터 웨어하우스는 스타 스키마(dimensional modeling이라고도 함)를 사용한다.
스키마 중심에는 fact table이 있다.
- 팩트 테이블에는 개별 이벤트를 담는다. 이것은 분석의 유연성을 극대화하기 위한 것이다. 개별 이벤트를 담기 때문에 팩트 테이블은 매우 커질 수 있다.
- 팩트 테이블의 일부 칼럼은 속성(e.g., 제품 판매 가격, 공급자 구매 비용 등)이고 나머지는 차원 테이블(dimensional table)이라고 부르는 다른 테이블을 가리키는 FK다.
- 여기서 차원은 who, when, where, what, how, why를 나타낸다.
- 날짜조차 차원 테이블을 이용해 날짜에 대한 추가정보(e.g., 공휴일)를 담을 수 있다.
- 팩트 테이블을 중심으로 차원 테이블이 별 모양처럼 사방으로 뻗어나와 있다고 해서 스타 스키마라고 부른다.
Column-Oriented Storage(칼럼 지향 저장소)
팩트 테이블에는 보통 100개 이상의 칼럼이 있지만 데이터 웨어하우스 질의는 보통 한 번에 너덧 개의 칼럼만 접근한다.
그래서 OLAP에서는 칼럼 지향 방식으로 데이터를 배치한다. 각 칼럼 별로 모든 값을 함께 저장하는 것이다.
(OLTP에서는 한 로우 안의 값은 서로 인접하게 저장한다. 문서 디비처럼)
이렇게 함으로써 질의에 사용하는 칼럼 데이터만 읽음으로써 작업량을 줄일 수 있다.
또한 칼럼 내에서 많은 값이 반복되므로 압축을 적용하기에도 좋다. (e.g., 비트맵 인코딩)
4. Encoding and Evolution
데이터는 계속 변화한다. 새 기능을 위해서든 데이터가 투영하는 현실 세계의 변화에 의해서든.
데이터가 변화하면 애플리케이션 코드 또한 대응하기 위해 변화해야 한다.
하지만 서버 앱은 한 순간에 배포가 완료될 수 없기 때문에 여러 버전이 공존할 때가 존재하고, 클라이언트 앱 또한 사용자가 업데이트하지 않는다면 여러 버전이 공존할 수 밖에 없다.
데이터 또한 여러 유형이 한 시점에 공존할 수 있다.
이처럼 여러 코드, 데이터 타입이 공존하므로 시스템이 제대로 돌아가려면 양방향 호환성을 유지해야 한다.
하위 호환성(backward compatibility)은 새 코드가 예전 데이터 타입을 다룰 수 있도록 하는 것을, 상위 호환성(forward compatability)은 예전 코드가 새 데이터 타입을 다룰 수 있는 것을 의미한다.
새 코드는 예전 버전 데이터 타입을 알기 때문에 상위 호환성 대응은 어렵지 않다. 하지만 예전 코드가 새로운 데이터 타입에서 추가/변경된 것을 무시하는 것은 까다로울 수 있으므로 하위 호환성이 조금 더 어렵다.
데이터 인코딩 형식
프로그램은 메모리 상의 데이터와 파일/네트워크 상의 데이터 두 가지 형식으로 데이터를 다룬다.
메모리 상의 데이터를 네트워크로 전송하려면 바이트 시퀀스로 인코딩해야 하듯이 두 가지 형식 사이에는 전환이 필요하다.
- Encoding(부호화, 직렬화, 마셜링) : 인메모리 표현 → 바이트열 표현
- cf) 트랜잭션 컨텍스트에서의 직렬화는 전혀 다른 의미
- Decoding(파싱, 역직렬화, 언마셜링) : 바이트열 표현 → 인메모리 표현
사람이 읽을 수 있는 형식의 인코딩
JSON, XML, CSV는 텍스트 형식으로 읽기 편하고 대표적으로 많이 쓰이는 인코딩 방식이다.
이런 방식들에 다음과 같은 이슈가 있다는 것 정도는 알아두면 좋다.
- number 처리 : XML, CSV는 number와 digit로 구성된 문자열을 구분하지 못한다. JSON은 정수와 부동소수점을 구별하지 않고 정밀도를 지정하지 않는다. → JS에서 파싱할 때 정확도를 상실할 수 있다.
- JSON, XML은 바이너리 데이터를 지원하지 않는다 Base64 인코딩을 통해 우회할 수 있지만 효율적이진 않다.
- 스키마 이슈 : JSON, XML은 스키마가 있지만 익히기 어렵고 강제되지 않는다. CSV는 스키마가 없다. 스키마가 없거나 강제되지 않는다면 애플리케이션이 파싱 로직을 하드코딩해야 할 수 있다.
Thrift와 Protocol Buffers
Thrift IDL(Interface Definition Language)로 작성한 스키마
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
프로토버프 스키마
message Person {
required string user_name = 1;
optional int64 favorite_number = 2;
repeated string interests = 3;
}
이런 스키마를 가지고 인코딩을 진행할 수 있다.
위의 스키마를 따르는 데이터 중 userName을 인코딩하면 대략 다음 형태가 된다.
...
// 타입 11 (문자열)
0b
// 필드 태그 = 1
00 01
// 길이 6
00 00 00 06
// M a r t i n
4d 61 72 74 69 6a
...
이걸 보면 알 수 있듯이 필드명은 인코딩된 데이터에 포함되지 않고, 대신 필드 태그가 포함되는 것을 알 수 있다.
따라서 필드 태그가 데이터 인식에 있어서 중요하다.
필드 태그와 스키마 변경
필드를 추가할 때 호환성 대응은 다음과 같이 하면 된다.
- 상위 호환성 : 예전 코드는 인식할 수 없는 태그 번호를 가진 필드를 만난다면 길이만큼 바이트를 건너뛰면 된다.
- 하위 호환성 : 앱이 데이터를 읽는 것은 문제가 없다. 새롭게 스키마에 추가하는 필드는 required로 하면 해당 필드가 없는 예전 데이터를 읽는 작업은 실패하므로 optional이거나 기본값을 부여해야 한다.
필드를 삭제할 때는 반대로 하면 된다.
- 상위 호환성 : 없어진 필드는 데이터에서 나타나지 않으므로 데이터 읽기는 문제 없다. required 필드는 삭제하면 읽기에 실패하므로 삭제할 수 없다. 오직 optional 필드만 사용할 수 있다.
- 하위 호환성 : 예전 스키마에 있었지만 현재 스키마에는 없는 태그 번호라면 건너뛰면 된다.
데이터 타입과 스키마 변경
필드의 데이터 타입을 변경하는 건 불가능하진 않지만 값의 정확도가 손상될 가능성이 있다. (e.g., i32 → i64)
protobuf에는 목록이나 배열 데이터 타입이 없는 대신 repeated 표시자가 있다.
- repeated 필드를 부호화하면 같은 필드 태그가 여러 번 나타나는 식으로 기록된다.
- 만약 optional 필드를 repeated 필드로 바꿨다고 해보자. 새 코드는 해당 필드가 0 또는 1개의 엘리먼트를 가진 배열로 보고, 기존 코드는 배열의 마지막 엘리먼트만 보게 된다.
Apache Avro
Avro IDL로 작성한 아브로 스키마
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}
JSON으로 작성한 아브로 스키마
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
}
아브로 스키마에는 스리프트, 프로토콜 버퍼와 다르게 태그 번호가 없다.
바이너리 인코딩된 데이터에도 필드나 데이터 타입을 식별하기 위한 정보가 담기지 않는다.
대신 아브로는 인코딩된 데이터와 쓰기에 사용된 스키마를 연결지어야 한다. 스키마에 나타난 순서대로 필드를 살펴보고 이에 맞게 필드별 데이터타입을 파악해야 한다.
쓰기 스키마와 읽기 스키마
그러면 쓰기 당시에 쓰인 스키마와 현재 버전의 스키마가 다르다면 어떻게 해야 할까?
아브로는 쓰기 스키마와 읽기 스키마를 비교하여 데이터를 적절하게 변환한다.
필드는 이름을 기준으로 리졸브되기 때문에 순서가 달라도 무방하다.
스키마 발전 규칙
아브로에서 호환성의 의미는 다음으로 볼 수 있다.
- 상위 호환성 : 새 버전의 쓰기 스키마와 예전 버전의 읽기 스키마 조합이 문제 없이 동작한다.
- 하위 호환성 : 새 버전의 읽기 스키마와 예전 버전의 쓰기 스키마 조합이 문제 없이 동작한다.
호환성을 유지하기 위해서는 우선 기본값이 있는 필드만 추가/삭제할 수 있게 해야 한다.
- 기본값이 없는 필드를 추가한다면 새 버전의 읽기 스키마를 가지고 예전 데이터를 읽을 때 누락된 필드를 처리할 방법을 찾지 못하게 된다. → 하위 호환성이 깨짐
- 삭제한 경우 예전 버전의 읽기 스키마로 새 버전의 데이터를 읽지 못한다. → 상위 호환성이 깨짐
필드 이름 변경은 하위 호환성을 갖는 변경이 가능하다. 필드 이름에 별칭을 추가할 수 있는데 별칭에 예전 필드명을 추가하면 하위 호환성을 갖게 할 수 있다.
유니언 타입에 새 타입을 추가하는 것 또한 비슷한 원리로 하위 호환성을 갖는다.
쓰기 스키마는 어디에?
아브로에서 쓰기 스키마를 확인하는 방식은 상황에 따라 다르다.
- 하둡 같이 많은 레코드가 있는 대용량 파일에서는 파일의 시작 부분에 한 번만 포함시키면 된다.
- 하나의 디비에 서로 다른 쓰기 스키마를 가진 여러 레코드가 쓰여지는 경우가 있을 수 있다. 이 때 레코드에는 스키마 버전 번호만 추가하고 디비에서 스키마 버전 목록을 유지하면 된다.
- 두 프로세스가 네트워크를 통해 레코드를 주고 받을 때는 스키마 버전 합의를 할 수 있다. 아브로 RPC 프로토콜이 이런 식으로 동작한다.
동적 생성 스키마
아브로 스키마에 태그 번호가 포함되지 않는다는 점으로 인해 아브로는 스키마 동적 생성이 스리프트나 프로토콜 버프에 비해 편리하다.
파일로 덤프할 어떤 버전의 관계형 디비가 있을 때, 아브로를 사용하면 관계형 스키마에서 아브로 스키마를 쉽게 생성할 수 있다. 스리프트나 프로토콜 버프를 사용한다면 필드 태그를 수동으로 할당해야 한다.
코드 생성과 동적 타입 언어
스리프트나 프로토콜 버퍼는 스키마 정의 후 원하는 프로그래밍 언어로 스키마를 구현한 코드를 생성하고 이를 사용할 수 있다. 하지만 JS, Ruby, Python 같은 동적 타입 언어에서는 코드 생성이 크게 중요하지 않다.
하지만 아브로에서는 쓰기 스키마를 포함한 객체 컨테이너 파일이 있다면 마치 JSON 파일을 보는 것과 같이 코드 생성 없이 데이터를 볼 수 있다. 그래서 동적 타입 언어에서는 아브로를 통한 데이터 분석이 보다 유연하게 진행될 수 있다.
스키마의 장점
앞서 살펴본 스리프트, 프로토콜 버퍼, 아브로와 같이 스키마를 기반으로 한 바이너리 인코딩은 다음과 같은 장점이 있다.
- 인코딩된 데이터에서 필드 이름을 생략함으로써 바이너리 인코딩 된 JSON 파일보다 크기를 더욱 줄일 수 있다.
- 스키마 자체가 하나의 문서화가 될 수 있다.
- 스키마를 유지함으로써 스키마를 변경할 때 상위 호환성, 하위 호환성이 어떻게 될지 확인해볼 수 있다.
- 정적 타입 언어에서는 코드 생성을 통해 컴파일 타임 타입 체크를 할 수 있다.
프로세스 간 데이터 전달
데이터를 전달하는 방식은 여러 가지가 있지만 대표적으로는 다음과 같은 것들이 있다.
- 데이터베이스를 통한 전달
- REST나 RPC 같은 서비스 호출을 통한 전달
- 비동기 메시지 전달
데이터베이스를 통한 데이터 전달
데이터베이스에 기록을 하면 그 데이터를 확인할 사람은 미래의 자신이다. 그런 측면에서 데이터베이스에서는 하위 호환성이 필요하다.
또한 애플리케이션의 새 버전으로 업그레이드 되는 과정에서 예전 코드가 새 스키마의 데이터에 접근하는 일도 존재한다. 이로 인해 보통 상위 호환성도 필요하다.