(KO) CUBRID 설계 철학 — 코드베이스가 지금의 모습이 된 이유
목차
- 이 문서를 읽는 법
- 결정 → 결과 지도
- 1. OODB 혈통
- 2. ARIES 복구, 논문 그대로
- 3. 읽기는 MVCC, 쓰기는 락
- 4. XASL 노드마다 하나의 이터레이터를 두는 Volcano 식 실행기
- 5. 쿼리 그래프를 갖춘 Selinger 식 비용 옵티마이저
- 6. WAL 커밋 파이프 — lock-free prior list
- 7. 페이지 찢김 방지를 위한 더블 라이트 버퍼
- 8. 절차적 언어를 위한 별도 JVM
- 9. cub_server 앞에 두는 broker 프로세스 풀
- 10. 로컬에서만 내리는 생존 판정
- 11. 로그 구조화가 아닌 페이지 기반
- 12. 오프라인 작업을 위한 SA 모드 유틸
- 13. CUBRID가 일부러 되지 않기로 한 것
이 문서를 읽는 법
섹션 제목: “이 문서를 읽는 법”이 디렉터리의 세부 문서들은 각 서브시스템이 무엇이고 어떻게 도는지를 답한다. 시스템 전체의 모양 자체를 정당화하지는 않는다. MVCC가 있는데 락 매니저는 왜 남아 있는가? WAL을 추가하는 경로는 왜 로그 버퍼로 곧장 쓰지 않고 단방향 리스트에 잠시 줄을 서는가? 절차적 코드는 왜 일부러 다른 프로세스로 빼냈는가? 이런 물음에 대한 답은 코드 위에 있는 한 층 — 역사적 압력, 원저자들이 염두에 두었던 학술 참고문헌, 그리고 어느 길은 닫고 어느 길은 열어 둔 공학적 결단 — 에 있다.
이 문서가 바로 그 한 층이다. 절마다 결정 하나를 짚고, 팀이 택할 수 있었던 다른 길을 보이고, 그들이 참고했거나 피하려 했던 것을 적고, 소스 코드 안의 결과를 가리킨다. 어느 절의 무엇이 다른 문서에서 이미 다뤄지고 있으면 그쪽으로 안내하고, 여기서는 왜에 머문다. 결정들은 가장 낮은 자리의 스토리지 약정에서 출발해 바깥쪽 운영 표층으로 올라가도록 줄을 세웠다.
결정 → 결과 지도
섹션 제목: “결정 → 결과 지도”flowchart LR
subgraph HIST["역사적 / 학술적 입력"]
UNISQL["UniSQL OODB 혈통<br/>(Won Kim, 1992)"]
ARIES["ARIES<br/>(Mohan TODS 1992)"]
SELINGER["System R / Selinger<br/>(SIGMOD 1979)"]
VOLCANO["Volcano<br/>(Graefe TKDE 1994)"]
PG["PostgreSQL XLogInsert<br/>(중앙 래치)"]
ORA["Oracle 인프로세스 JVM<br/>반면교사"]
end
subgraph DEC["CUBRID 결정"]
D1["1) OODB 혈통"]
D2["2) ARIES 복구"]
D3["3) MVCC + 락"]
D4["4) Volcano 실행기"]
D5["5) Selinger 옵티마이저"]
D6["6) lock-free prior list"]
D7["7) DWB"]
D8["8) 별도 JVM"]
D9["9) broker + CAS 풀"]
D10["10) 로컬 전용 HA"]
D11["11) 페이지 기반"]
D12["12) SA + CS 두 갈래 빌드"]
end
subgraph CONS["소스 결과"]
C1["class_object.c +<br/>locator 워크스페이스"]
C2["log_recovery_∗.c +<br/>fuzzy 체크포인트"]
C3["mvcc.h + lock_manager.c +<br/>vacuum.c 가 나란히"]
C4["qexec_∗ 트리 워크"]
C5["query_graph.c +<br/>query_planner.c"]
C6["log_append.cpp +<br/>logpb_prior_lsa_∗"]
C7["double_write_buffer.cpp"]
C8["pl_engine/ + pl_connection"]
C9["broker.c + cas.c +<br/>SCM_RIGHTS 핸드오프"]
C10["heartbeat.c 로컬 정원"]
C11["heap, btree, page_buffer<br/>제자리 변경"]
C12["libcubridsa.so /<br/>libcubridcs.so"]
end
UNISQL --> D1
ARIES --> D2 & D3 & D6 & D11
SELINGER --> D5
VOLCANO --> D4
PG --> D6
ORA --> D8
D1 --> C1
D2 --> C2
D3 --> C3
D4 --> C4
D5 --> C5
D6 --> C6
D7 --> C7
D8 --> C8
D9 --> C9
D10 --> C10
D11 --> C11
D12 --> C12
ARIES 한 줄이 여러 결정(복구, MVCC와 vacuum의 결합, WAL 파이프라인, 페이지 기반 스토리지)으로 갈라져 나가는 까닭은 단순하다 — 엔진 자체가 ARIES를 중심에 놓고 지어졌기 때문이다.
1. OODB 혈통
섹션 제목: “1. OODB 혈통”모든 테이블은 클래스이고, 모든 행은 영속 OID를 가진 인스턴스다. 인메모리 스키마 그래프가 속성과 제약뿐 아니라 메서드, 상속, 파티션 트리, 그리고 해소(resolution)까지 함께 들고 다닌다. 클라이언트 쪽은 워크스페이스를 굴린다 — OID를 키로 들고 가는 인메모리 MOP(memory object pointer) 핸들의 해시 표다. 영속 객체에 대한 모든 접근이 이 워크스페이스를 거치고, 더티 객체들은 LC_COPYAREA 버퍼에 묶여 서버 쪽 locator_*_force 가족으로 부쳐진다. 이 추상은 얇은 베니어가 아니다. 엔진이 그 위에서 자라난 등뼈다.
CUBRID 는 UniSQL/X 로 시작했다. 1990년대 초반, MCC의 Won Kim 그룹이 설계한 객체지향 데이터베이스이고, 초창기 ORDBMS 가운데 하나로 상용화됐다. 이 OODB 혈통이 class_object.c, 메타클래스 / 루트클래스 구분, 그리고 모든 fetch와 모든 인덱스 잎이 들고 다니는 OID-물리주소 (volid, pageid, slotid) 세 값짜리 묶음의 직접 조상이다. 학술적 선조는 EXODUS 스토리지 매니저(Carey & DeWitt, 1986)와 Stonebraker의 POSTGRES(1986)다. 이 유산이 그대로 살아남은 이유는 단순하다 — 지운다는 건 스토리지 계층 전체를 다시 쓴다는 뜻이다. 모든 페이지 포맷, 모든 인덱스 잎, 모든 카탈로그 행이 OID를 끌어다 쓴다. 그래서 관계형으로의 진화는 객체 발판 위에 관계형 기능을 덧대는 길을 택했다 — 행은 자기 클래스가 테이블인 객체이고, 외래 키는 객체 참조 타입의 속성이며, 파티션은 부모 클래스 링크를 단 클래스다.
class_object.c가 220 KB 가까이 되는 까닭은 그것이 그래프를 담기 때문이다 — SM_CLASS에 SM_ATTRIBUTE 목록, SM_METHOD 목록, 상속의 모호함을 풀기 위한 SM_RESOLUTION 목록, SM_PARTITION 서브트리, 진행 중 DDL을 위한 SM_TEMPLATE, 그리고 클래스가 거쳐 온 모든 레이아웃을 적어 두는 SM_REPRESENTATION 사슬이 들어간다. 로케이터(locator_cl.c / locator_sr.c)는 대량 fetch / flush 의 다리다 — 클라이언트 쪽엔 MOP 워크스페이스, 서버 쪽엔 heap, btree, lock, log, FK, 복제로 갈라지는 LC_COPYAREA. PL 계열은 OODB 시대의 메서드 콜백 추상을 그대로 물려받았다. 지금의 cub_pl은 본래 인프로세스 클래스-메서드 호출이었던 그 형태를 재정비한 결과다.
상호 참조: cubrid-class-object.md, cubrid-locator.md, cubrid-pl-javasp.md.
2. ARIES 복구, 논문 그대로
섹션 제목: “2. ARIES 복구, 논문 그대로”복구는 가장 최근의 fuzzy 체크포인트를 닻으로 삼아 WAL을 세 번 훑는 재실행이다 — 더티 페이지 표와 트랜잭션 표를 다시 짜는 분석 패스, 대상 페이지의 LSN이 레코드의 LSN보다 오래된 모든 레코드를 다시 적용하는 redo 패스, 패자 트랜잭션 하나하나를 거꾸로 따라가며 보상 로그 레코드(CLR)를 적어 두는 undo 패스. 로그는 heap 데이터에는 physiological, 인덱스 작업에는 logical이고, 체크포인트는 fuzzy다(일관성 체크포인트는 엔진을 멈춰 세운다).
Mohan 등의 ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging (TODS 17.1, 1992) 은 디스크 기반 관계형 복구의 표준 알고리즘이다. CUBRID 초기 커밋이 작성될 무렵에도 이미 표준이었다. 논문이 함께 보장하려는 속성이 몇 가지 있는데 — 역사를 그대로 다시 굴리기(패자 트랜잭션의 레코드까지 전부 redo한 뒤에야 undo로 들어간다), undo를 다시 시작 가능하게 만드는 CLR, 그리고 엔진을 막아 세우지 않는 fuzzy 체크포인트 — 한 세대의 엔진(DB2, SQL Server, InnoDB, CUBRID)이 이를 거의 글자 그대로 옮겨 구현했다. CUBRID에는 굳이 비껴갈 이유가 없었다. 알고리즘은 정확하고, 대안인 shadow paging이나 로그 구조화는 각각 엔진이 의존하는 다른 속성을 깨뜨린다.
log_recovery.c와 log_recovery_redo.cpp는 그 세 패스 모양 그대로 짜여 있다. redo는 LOG_RECTYPE enum을 인덱스로 쓰는 RV_fun[] 배열로 레코드 종류별로 갈리기 때문에, 새 레코드 종류를 더하는 일이 루프를 다시 짜는 일이 아니라 표 한 줄을 늘리는 일로 끝난다. fuzzy 체크포인트는 LOG_START_CHKPT / LOG_END_CHKPT 한 쌍이고, 복구는 LOG_END_CHKPT에 닻을 내린다 — DPT/TT 스냅샷이 그 레코드에 들어가 있기 때문이다. redo는 페이지 단위로 병렬화 가능하다(log_recovery_redo_parallel.cpp가 VPID로 샤딩한다). CLR은 LOG_COMPENSATE 종류이고, undo 중인 레코드를 지나 가리키는 undo_nxlsa를 들고 있어 undo 도중 크래시가 나도 같은 undo가 두 번 적용되지 않는다. 전체 메커니즘은 “ARIES, 논문 그대로”다. CUBRID 고유의 부분은 그저 어떤 서브시스템을 어떤 레코드 종류가 표현하느냐 하는 점뿐이다.
상호 참조: cubrid-recovery-manager.md, cubrid-checkpoint.md, cubrid-log-manager.md.
3. 읽기는 MVCC, 쓰기는 락
섹션 제목: “3. 읽기는 MVCC, 쓰기는 락”읽기는 트랜잭션(또는 문장) 시작 때 한 번 만들어 둔 스냅샷을 본다. 쓰기는 행을 고치기 전에 X-락을 잡는다. 둘은 양자택일이 아니라 함께 공존한다 — 스냅샷 격리가 빠른 읽기 경로를 깔아 주고, 락 매니저가 쓰기-쓰기 충돌을 붙들어 획득 순서대로 줄을 세우고, vacuum이 가장 오래된 가시(MVCCID) 워터마크보다 더 옛 버전을 회수해 간다.
순수한 2PL은 읽기 위주 워크로드에 한 가지 큰 비용을 안긴다 — 모든 읽기가 S-락을 잡고, 그 S-락이 동시 X-락을 막고, 오래 도는 스캔 하나가 쓰기 쪽 전체를 멈춰 세운다. 정반대 — 순수 MVCC — 는 읽기가 쓰기를 절대 막지 않는다는 점에서 매력적이지만, 그 비용은 쓰기-쓰기 충돌 감지에 그대로 떨어진다. 락 매니저가 없으면, 같은 행에 대한 두 갱신이 각자 별도로 실체화된 뒤 커밋 직전에서야 충돌을 발견해 한쪽을 롤백해야 한다. PostgreSQL의 직렬화 가능 스냅샷 격리(SSI; Cahill 등 SIGMOD 2008)가 완전 MVCC의 프로덕션 설계인데, 비싸다 — 술어 락, 의존 그래프, 사이클 감지가 함께 동작해야 한다. CUBRID는 절충을 골랐다 — 읽기는 MVCC, 쓰기는 락. SERIALIZABLE에서의 write skew는 SSI 대신 락 기반 직렬화로 격상하여 처리하고, 그 덕분에 읽기 경로는 가볍게 유지된다.
MVCC 구현(mvcc.c, mvcc_table.cpp, mvcc_active_tran.cpp)은 엔진의 본래 2PL 시대보다 한참 뒤인 2014년에 들어왔다. 결정은 명시적이었다 — 읽기 위주 OLTP가 주된 워크로드이고, 락 매니저를 그대로 두는 편이 쓰기 경로를 다시 짜서 MVCC만으로 충돌 감지를 처리하게 만드는 것보다 훨씬 싸다는 판단이다.
코드베이스에는 하나의 어휘(MVCCID)를 공유하면서 세 지점(레코드별 헤더, 트랜잭션별 스냅샷, 전역 oldest-visible 워터마크)에서 만나는 세 자매 서브시스템이 자리 잡고 있다. mvcc.h::MVCC_REC_HEADER가 모든 heap·인덱스 레코드에 mvcc_ins_id, mvcc_del_id, prev_version_lsa를 박아 둔다. lock_manager.c는 MVCC 아래가 아니라 옆에 자리 잡는다. vacuum.c는 m_oldest_visible을 키로 들고 백그라운드에서 돈다. 교과서가 말하는 비용 — 오래 도는 쓰기 트랜잭션 하나가 워터마크를 붙들어 두어 vacuum을 멈춰 세우는 — 은 구조에 그대로 새겨져 있고, 테이블 bloat에 관한 버그 리포트로 자주 모습을 드러낸다.
상호 참조: cubrid-mvcc.md, cubrid-lock-manager.md, cubrid-vacuum.md.
4. XASL 노드마다 하나의 이터레이터를 두는 Volcano 식 실행기
섹션 제목: “4. XASL 노드마다 하나의 이터레이터를 두는 Volcano 식 실행기”쿼리 플랜은 XASL 노드 트리다. 노드마다 하나의 연산자(heap scan, index scan, list scan, sort, group-by, hash join, …)가 들어 있고, 그 연산자는 open / next / close 계약을 따른다. 실행기는 트리를 재귀로 따라가며 필요한 만큼 튜플을 끌어 올린다. 연산자는 클래스가 아니라 함수다 — xasl->type에 따른 디스패치는 qexec_execute_mainblock_internal에서 일어나고, 튜플 단위 작업은 scan_next_scan → scan_next_scan_local → scan_next_<type>_scan에서 처리된다. 플랜은 통째로 직렬화 가능하다 — XASL 트리는 평탄한 바이트 버퍼로 묶여 캐시되고, 프로세스 사이를 옮겨 다닌다.
Goetz Graefe 의 Volcano — An Extensible and Parallel Query Evaluation System (TKDE 1994) 이 이터레이터 모델의 표준 참고문헌이다. CUBRID가 겨냥하는 OLTP + 가벼운 OLAP 워크로드에는 이 설계를 능가하는 답이 아직 없다. 이 모델에는 코드베이스가 눈에 띄게 활용하는 구조적 강점이 셋 있다 — 비차단 연산자에서는 파이프라이닝이 자동이고(인덱스 위 스캔 위 필터에 중간 실체화가 끼어들지 않는다), 조립이 트리 한 번으로 끝나며(실행기는 옵티마이저가 만든 그 트리를 그대로 따라간다), 차단 연산자(sort, hash build, group-by, top-N)는 list file에 실체화되어 그다음 연산자가 그 list file을 평범한 list scan으로 받는다. 비용 — 튜플 단위의 가상 호출 — 은 튜플 수가 적은 OLTP에서는 받아들일 만하다. 수십억 행을 훑는 분석 스캔에는 받아들이기 어렵고, 그게 바로 CUBRID가 OLAP 대신 OLTP 쪽에 자리를 잡은 이유 가운데 하나다(결정 13).
XASL 직렬화 가능성은 부수적인 이득이다. 연산자 상태가 네이티브 포인터가 아니라 XASL 바이트 버퍼 안의 오프셋을 들고 있는 구조체이기 때문에, 플랜 전체를 그대로 캐시하고 broker / CAS / cub_server 경계를 가로질러 버퍼 복사로 옮길 수 있다.
query_executor.c가 ~600 KB 분량의 접합 코드인 까닭은, 연산자 종류마다 자기 qexec_<op>_proc / scan_next_<type>_scan 한 쌍을 갖기 때문이다. 다만 그 접합 코드는 한결같은 모양으로 짜여 있다. 벡터화 대안(MonetDB/X100, HyPer 식 emit-batches push)은 모든 연산자 인터페이스를 다시 짜야 했을 텐데, 그러한 작업은 이루어지지 않았다. 병렬 쿼리는 연산자 내부 병렬화로 모델을 확장했지만 이터레이터 계약 자체는 건드리지 않았다 — 병렬 스캔도 결국 next()가 스레드 간에 멀티플렉스되는 SCAN_ID일 뿐이다.
상호 참조: cubrid-query-executor.md, cubrid-xasl-generator.md, cubrid-xasl-cache.md.
5. 쿼리 그래프를 갖춘 Selinger 식 비용 옵티마이저
섹션 제목: “5. 쿼리 그래프를 갖춘 Selinger 식 비용 옵티마이저”최적화는 네 단계 파이프라인이다. 의미 검사를 마친 파스 트리(PT_NODE)가 QO_NODE(테이블 / 파생 관계), QO_SEGMENT(컬럼 참조), QO_TERM(술어)으로 짜인 QO_ENV 쿼리 그래프로 변환된다. join 열거는 2^N 크기의 join_info 벡터 위에서 수행되는 동적 계획법이다 — 크기 k-1짜리 부분집합의 최선 플랜에서 출발해 크기 k짜리 부분집합의 최선 플랜을 차례로 쌓아 올린다. 비용은 xstats_update_statistics가 만들어 둔 통계를 받아 쓰는 System R 식 고정-cpu/io + 변동-cpu/io 모델이다. 살아남은 QO_PLAN 트리가 XASL 트리로 최종 변환된다.
Selinger 등의 Access Path Selection in a Relational Database Management System (SIGMOD 1979) 이 관계형 최적화의 토대 논문이다. 좌-편향 동적 계획법은 초지수 크기의 플랜 공간을 다루기 위한 가장 잘 정착된 근사로, O(2^N · N) 시간과 O(2^N) 공간으로 동작한다 — CUBRID의 OLTP 워크로드가 만나는 join 카디널리티에서는 다룰 만하다. 다른 길인 Cascades 식 bushy 플랜 탐색(Graefe 1995)은 SQL Server와 CockroachDB가 가는 길이고, 그 길의 강점(변환 규칙, 그룹 메모이제이션, 적응형 재최적화)은 CUBRID가 쫓지 않는 분석형 워크로드에서 가장 잘 드러난다.
쿼리 그래프 단계는 일부러 둔 중간 표현이다. 파스 트리는 표면 SQL에 너무 가깝고, XASL은 실행기에 너무 가깝다. QO_NODE / QO_TERM은 비용 기반 열거가 굴러갈 수 있는 가장 작은 정규형이다.
optimizer/query_graph.c가 그래프를 짜고, optimizer/query_planner.c가 DP를 수행하고, optimizer/plan_generation.c가 XASL을 내놓는다. 비용 모델은 query_planner.c 안에 인라인으로, 접근 방식마다의 고정/변동 cpu·io를 계산하는 qo_*_cost 함수들로 자리 잡는다. 상수는 CUBRID 스토리지 계층에 맞춰 빌드 시점에 보정되고, 운영자가 직접 조정하지는 않는다. 통계는 xstats_update_statistics가 만들어 두면 최적화 시점에 카탈로그에서 곧장 받아 간다. 구조가 1979년 논문을 충실히 따라가서, 그 논문을 읽은 독자라면 함수 이름만 보고도 자기 자리를 짚을 수 있을 정도다.
상호 참조: cubrid-query-optimizer.md, cubrid-statistics.md.
6. WAL 커밋 파이프 — lock-free prior list
섹션 제목: “6. WAL 커밋 파이프 — lock-free prior list”생산자 트랜잭션은 정식 로그 버퍼에 직접 쓰지 않는다. 전역 뮤텍스 바깥에서(undo / redo 페이로드의 zlib 압축까지 포함해서) LOG_PRIOR_NODE 하나를 만들어 둔다. 그 다음 prior_lsa_mutex를 잠깐만 잡고, 다음 단조 LSN을 할당하고 노드를 단방향 리스트의 꼬리에 매다는 O(1) 작업만 처리한다. 로그 flush 데몬이 래치로 보호되는 드레인을 수행한다 — 뮤텍스 안에서 리스트를 떼어 내고, 해제하고, 떼어 둔 노드들에서 정식 LOG_PAGE 버퍼로 바이트를 옮기고, 디스크에 쓰고, nxio_lsa를 앞으로 옮기고, group-commit condition variable을 깨운다. 커밋한 생산자들은 자기 LSN이 영속화될 때까지 그 CV에 대기한다.
PostgreSQL의 XLogInsert는 의도적으로 정반대를 택한 설계다. Postgres의 작성 측은 WALInsertLock(요즘 버전에서는 8조 stripe)을 잡고, 직전에 받은 LSN 위치에 곧장 WAL 버퍼 페이지로 바이트를 복사한 뒤 락을 푼다. 그 락은 레코드 길이만큼의 memcpy 동안 유지된다. 동시성이 높을 때 그 락 자체가 병목이 된다. Mohan과 DeWitt의 1992년 commit-pipe 연구가 이미 구조적 처방을 제시했다 — 생산자는 래치 바깥에서 레코드를 만들어 두고, 단일 드레인 스레드가 정식 버퍼로 바이트를 옮기는 래치 보호 작업을 도맡는다. CUBRID의 prior list가 정확히 이 파이프다.
이득은 락 경합 완화만이 아니다. 생산자의 비용이 큰 작업 (레코드 포맷팅, MVCC 스탬핑, zlib 압축) 이 뮤텍스 바깥에서 수행되므로, N 개의 생산자가 동시에 압축을 수행할 수 있다. 뮤텍스를 잡고 있는 시간은 꼬리에 매다는 O(1) 작업으로 한정된다. group commit 도 이 구조에서 자연스럽게 도출된다. lock-free 생산자와 일괄 소비자 구조에서, 같은 CV 에서 잠들어 있던 커미터들이 한 번의 fsync 전진에 함께 깨어난다. 이 설계는 또한 cubrid-thread-manager-ng.md 가 추적하는 CBRD-26177 고동시성 재설계의 토대이기도 하다.
prior_lsa_alloc_and_copy_data와 _crumbs가 log_append.cpp의 생산자 쪽 할당자다. prior_lsa_next_record_internal이 매다는 동작이고, log_page_buffer.c의 logpb_prior_lsa_append_all_list가 드레인이다. 떼어 내는 자리는 가능한 한 단순하다 — 뮤텍스 안에서 prior_list_header를 NULL로, prior_list_tail을 NULL로 바꾸고, list_size를 0으로 하고, 해제한다. 뮤텍스가 잡히는 시간은 정확히 포인터 세 번 + INT64 한 번의 저장이다 — 잡혀 있는 동안 단 한 바이트도 움직이지 않는다. 역압은 부드러운 상한이다 — list_size >= logpb_get_memsize()가 되면 생산자가 데몬을 깨우고 1 ms를 양보한다.
상호 참조: cubrid-prior-list.md, cubrid-log-manager.md, cubrid-thread-manager-ng.md.
7. 페이지 찢김 방지를 위한 더블 라이트 버퍼
섹션 제목: “7. 페이지 찢김 방지를 위한 더블 라이트 버퍼”모든 더티 데이터 페이지는, 홈 위치에 쓰기를 발행하기 전에, 순차 더블 라이트 볼륨을 한 번 거치고 거기서 fsync된다. 복구 시점에는 분석 패스보다 먼저 DWB 스캔이 한 번 돈다 — 홈 페이지가 찢겨 있는 DWB 슬롯이 있으면 해당 페이지를 DWB 사본으로 교체한다. DWB는 슬롯 할당이 결정적인 고정 크기 원형 파일이라, 복구 스캔은 한정된 양만 본다. 평상시 비용은 홈 쓰기 한 번당 순차 쓰기가 한 번 더 붙는 것뿐이다.
DBMS 페이지 크기와 파일시스템·디바이스의 원자 쓰기 단위가 어긋나 있다는 점이 페이지 찢김의 근원이다. CUBRID는 설정에 따라 4–16 KiB의 IO_PAGESIZE로 데이터 페이지를 쓰는데, 요즘의 리눅스 파일시스템은 하드웨어 섹터 경계(보통 512 B 또는 4 KiB)에서만 원자 쓰기를 보장한다. 쓰기 도중 충돌이 발생하면, 홈 자리에 앞쪽 절반은 새 이미지, 뒤쪽 절반은 옛 이미지인 프랑켄슈타인 페이지가 남는다. 이 문제에 대한 프로덕션의 답은 셋이다 — 변경이 일어난 직후 한 번 전체 페이지 이미지를 WAL에 기록하기(PostgreSQL의 full_page_writes), 더블 라이트 버퍼(MySQL InnoDB, CUBRID), copy-on-write 파일시스템에 의지(ZFS / Btrfs). PostgreSQL 쪽 해법은 체크포인트 경계마다 WAL 양을 두 배로 늘린다. InnoDB / CUBRID 쪽 해법은 더티 페이지 flush 한 번에 순차 쓰기 한 번이 더해질 뿐 — 디스크 위에 고정 크기 DWB 구조를 두는 비용이 따라오긴 하지만, 훨씬 싸다.
CUBRID가 둔 베팅은 WAL 처리량이 더 귀한 자원이라는 판단이었다. CUBRID의 WAL 파이프라인(결정 6)이 엔진에서 가장 정밀하게 다듬어진 부분이라, 전체 페이지를 WAL에 끼워 넣어 양을 두 배로 늘렸다면 그 여유가 그대로 사라졌을 것이다.
double_write_buffer.cpp가 페이지 버퍼 flush 핫패스 위에 자리 잡는다. 데이터 페이지의 모든 pgbuf_flush가 dwb_set_data_on_next_slot → WAL force → dwb_add_page → 홈 쓰기, 이 순서로 흐른다. WAL force는 결정 6에서 다룬 prior list group-commit 대기다. 더티 페이지는 자기 로그 LSN이 prior list에서 빠져 나와 fsync되기 전에는 홈에 쓰일 수 없다. 복구의 첫 행동은 — 분석 직전에 — 찢긴 홈 페이지를 온전한 DWB 슬롯으로 교체하는 DWB 스캔이다. 이 구조가 스토리지 서브시스템 전체에 단단한 순서를 강제한다 — 그 순서를 나머지 엔진이 어겨선 안 된다.
상호 참조: cubrid-double-write-buffer.md, cubrid-page-buffer-manager.md.
8. 절차적 언어를 위한 별도 JVM
섹션 제목: “8. 절차적 언어를 위한 별도 JVM”PL/CSQL 과 JavaSP 는 데이터베이스 서버 프로세스 안에서 실행되지 않는다. 별도로 fork 된 JVM 호스트 프로세스 cub_pl 안에서 실행된다. cub_pl 과 cub_server 는 Unix 도메인 소켓 (또는 TCP) 으로 통신한다. 카탈로그 행은 cub_server 가 보유하고 있고, 와이어 프로토콜은 pl_connection.cpp / pl_comm.c 이며, 실행기 쪽 C++ cubpl::executor 는 모든 호출을 cub_pl 로 전송하고 같은 소켓에서 JDBC 백채널 콜백을 받는다. 두 PL 언어는 같은 JVM 을 공유한다. PL/CSQL 이 Java 바이트코드로 컴파일되어, JavaSP 의 리플렉티브 디스패치를 처리하는 같은 ExecuteThread 로 동일하게 진입하기 때문이다.
사용자 코드를 인프로세스로 실행하는 일은 위험하다. 결함이 있는 저장 프로시저가 힙을 망치고, 파일 디스크립터를 고갈시키고, 스레드를 누수시키고, 최악의 경우 System.exit() 를 호출하여 데이터베이스 서버 전체를 다운시킬 수 있다. Oracle 도 같은 교훈을 얻은 적이 있다. Oracle 초기의 인프로세스 JVM (“Aurora JVM”, oracle.exe 에 임베드되어 있던) 이 운영상의 고통을 야기하여, 결국 일부를 외부 JVM 프로세스로 옮기게 만든 이유였다. CUBRID 도 예전에는 JNI 로 JVM 을 cub_server 안에 직접 임베드해 두었고 (pl_sr_jvm.cpp 가 그 시절의 흔적이다), 외부에서 fork 되는 cub_pl 로 옮긴 까닭도 같은 격리 논거였다.
JVM 시동 비용은 cub_pl 을 장기 구동 사이드카로 띄워 두면서 분담된다. 클래스로더 계층은 사용자 JAR 이 JVM 을 다시 띄우지 않고도 재로드될 수 있도록 짜여 있고, 보안 매니저 (SpSecurityManager) 가 사용자 클래스로더에서 System.exit() 호출과 네이티브 라이브러리 로딩을 막는다. PL/CSQL 과 JavaSP 가 같은 JVM 을 공유한다는 두 번째 베팅도 자연스럽게 따라붙는다. PL/CSQL 이 Java 바이트코드로 컴파일되니, 그것을 실행할 자연스러운 자리가 이미 JavaSP 가 동작하던 그 JVM 이기 때문이다.
pl_engine/ 은 pl_server.jar 를 빌드하는 별도 Gradle 프로젝트로, C/C++ CMake 빌드와 분리되어 있다. pl_connection.cpp 가 cub_server 한 대당 10 개의 연결 풀을 관리한다. server_monitor_task (1 초 주기 데몬) 가 cub_pl 이 죽으면 정리하고 다시 fork 한다. JDBC 백채널은 같은 소켓 위에서 동작한다.
CUBRIDServerSidePreparedStatement.execute() 가
METHOD_CALLBACK_QUERY_PREPARE 요청을 직렬화해 보내면,
cub_server 의 executor::response_callback_command() 가 이를
받아 쿼리 도구로 전달한다. 그래서 SQL 을 발행하는 JavaSP 가
자기 자신에게 새 TCP 연결을 다시 열 필요가 없다.
상호 참조: cubrid-pl-javasp.md, cubrid-pl-plcsql.md.
9. cub_server 앞에 두는 broker 프로세스 풀
섹션 제목: “9. cub_server 앞에 두는 broker 프로세스 풀”클라이언트 SQL 트래픽은 cub_server 로 곧장 가지 않는다.
cub_broker 부모 프로세스가 listening TCP 소켓을 보유하고, 고정
풀의 cub_cas 워커 프로세스를 미리 fork 해 둔다. accept 가
일어나면 부모는 클라이언트의 파일 디스크립터를 Unix 도메인
랑데부 채널로 유휴 CAS 에 SCM_RIGHTS 로 넘긴다. CAS 워커가
클라이언트를 인증하고 CSS 프레이밍을 파싱하고 SQL 트래픽을
cub_server 로 프록시한다. ACL 상태, SQL 로그, 모니터링 카운터,
broker 운영 인터페이스가 모두 SysV 공유 메모리 한 덩어리에서
함께 운용된다.
관계형 엔진의 인프로세스 상태 (파서, XASL 캐시, prepared statement 레지스트리, 인증된 트랜잭션 디스크립터) 는 구축하고 해제하는 데 비용이 큰 것들이다. 드라이버는 가장 단순한 모델 (소켓을 열고 SQL 을 보내고 행을 받고 닫는) 을 원한다. 운영자는 이 무거운 컨텍스트를 호스트가 실제로 몇 개나 감당할 수 있느냐에 대한 수평적 상한을 원한다. CUBRID 가 broker 매개 모델을 고른 까닭이 셋이다.
- 격리. CAS 하나가 죽어도 broker 도, 옆 CAS 워커도,
cub_server도 함께 다운되지 않는다. 부모가 죽은 CAS 를 정리하고 새것을 fork 하여 교체한다. - 운영 표면. CAS 가 각자 한 프로세스이기 때문에 CAS 별 SQL
로그와 에러 로그가 의미 있게 분리된다.
cub_server가 클라이언트 스레드별로 로그 버퍼를 다중화해야 했다면 훨씬 어려웠을 것이다. - 프런트엔드를 교체할 수 있다. CAS 추상 덕분에 ODBC 와
JDBC 브리지 (
cas_cgw.c) 가cub_server를 건드리지 않고도 추가될 수 있다.
이 결정 한 번이 SQL 요청을 네 프로세스 (JDBC 클라이언트 →
broker 부모 → CAS 워커 → cub_server) 에 걸쳐 분산시킨다.
broker 단계는 SQL 바이트에 손도 대지 않는 순수 파일 디스크립터
핸드오프다. 지연 비용은 마이크로초 단위이고, 운영상의 이득이
그 비용을 메워 준다.
broker.c 가 부모 역할이다. SysV 공유 메모리를 할당하고,
CAS 워커들을 fork 하고, listening 소켓을 열고, 공유 메모리의 작업
큐에서 다음 유휴 CAS 를 골라낸다. cas.c 가 워커 역할이다.
FD 를 recvmsg 로 받고, CSS 핸드셰이크를 수행하고, cub_server
로 가는 업스트림 연결을 열고, 트래픽을 프록시한다.
broker_send_fd.c 와 broker_recv_fd.c 가 SCM_RIGHTS 를
감싸고, broker_acl.c 가 부모 쪽에서 IP 기반 ACL 을 FD 핸드오프
직전에 심사한다. 계층마다 자기 디버그 로그를 갖추고 있어,
SQL 요청 한 줄을 끝까지 따라가려면 클라이언트 로그 → broker 로그
→ CAS 로그 → 서버 로그를 차례로 읽어야 한다.
상호 참조: cubrid-broker.md, cubrid-network-protocol.md.
10. 로컬에서만 내리는 생존 판정
섹션 제목: “10. 로컬에서만 내리는 생존 판정”CUBRID HA 클러스터의 각 cub_master 동료는 다른 동료가 살아
있는지를 독자적으로 결정한다. 보는 정보는 UDP 하트비트와 로컬
타임아웃뿐이다. 전역 합의 프로토콜이 없다. Raft 도, ZAB 도,
Paxos 도 쓰지 않는다. 선출은 로컬 시야 선출이다. 노드마다
자기와 동료의 점수를 매겨, 자신의 시야에서 누가 master 가 되어야
하는지를 판단한다. ha_ping_hosts 파라미터는 노드마다 ping 을
보낼 외부 주소를 적어 두는데, 이는 자기 자신이 네트워크에서
단절되었는지를 판별하는 데 쓰여 split-brain 의 최악의 시나리오
를 막아준다.
합의 프로토콜은 비싸다. 다회전 메시지 교환, 영속 상태 기계, leader lease, 로그 복제까지 모두 함께 운용해야 한다. Chandra & Toueg 의 Unreliable Failure Detectors for Reliable Distributed Systems (PODC 1996) 가 형식적 토대를 다룬 참고문헌이다. CUBRID 의 HA 가 겨냥하는 형태는 다섯 노드짜리 quorum 기반 쓰기 클러스터가 아니다. 훨씬 단순한 primary / standby 쌍에, 옵션으로 읽기 전용 복제가 붙는 정도다. 그 토폴로지에서 전역 합의는 과하다. 결과는 두 가지뿐이고 (primary 가 살아 있다, 또는 사라져서 standby 가 올라간다), 최악의 경우가 split-brain 이다. 로컬-시야 선출에 ha_ping_hosts 외부 증인을 더한 구조가 split-brain 을 보완해 주지만, 그 비용은 운영자가 ping 대상을 직접 지정해야 한다는 점에 있다.
베팅은 단순함과 운영 명료함이 이론적 우아함을 이긴다는 쪽이었고, 적어도 CUBRID 가 노리는 워크로드에서는 그렇다. 합의 프로토콜은 모든 노드에 영속 상태를 두고, 엔진이 이미 보유한 WAL 과 어긋나는 별도의 로그 복제 단계를 더하고, 더 복잡한 페일오버 핸드셰이크를 구축하라고 요구했을 것이다. 로컬-시야 설계는 수천 줄짜리 UDP 가십과 작업 큐 FSM, 그리고 워커 스레드 넷이면 충분하다. 비용은 실재한다. 비대칭 파티션 시나리오, 즉 노드 A 가 B 에 도달할 수 있는데 B 는 A 에 못 닿는 경우, 둘 다 master 로 올라갈 수 있다. ha_ping_hosts 가 그 위험을 줄여 주기는 해도 완전히 없애지는 못한다. 처방은 운영자가 그 사실을 알고 있다는 것 자체다.
master_heartbeat.c와 heartbeat.c가 가십과 FSM을 구동한다. 상태 전이 — slave → to-be-master → master, master → slave — 는 워커 스레드 넷이 처리하는 작업 큐로 진행된다. 합의 로그가 없다는 말은 페일오버가 빠르지만 형식적으로 안전하지는 않다는 뜻이다. HA의 사용자 대상 설명은 cubrid-ha-replication.md에 있고, 이 로컬-전용 설계가 바로 그 문서의 “운영자가 ping 호스트를 반드시 설정해야 한다”는 안내가 빠질 수 없는 이유다.
상호 참조: cubrid-heartbeat.md, cubrid-ha-replication.md.
11. 로그 구조화가 아닌 페이지 기반
섹션 제목: “11. 로그 구조화가 아닌 페이지 기반”CUBRID의 모든 스토리지 서브시스템은 페이지가 그 자리에서 갱신된다는 가정 위에 짜여 있다. heap 페이지, B+Tree 페이지, 카탈로그 페이지, 오버플로 페이지 — 모두 직접 갱신을 지원한다. WAL이 변경을 적고, WAL 불변식이 더티 페이지가 자기 홈으로 쓰이기 전에 로그가 영속화되도록 보장하며, 홈 자리가 유일한 자리다. 로그 구조화 재기록도, 컴팩션 기반 회수도, LSM 트리 레벨도 없다.
페이지 기반 + 제자리 갱신은 ARIES가 본래 겨냥해 짜인 설계다. Mohan의 알고리즘은 엔진이 페이지 한 장을 가져다, 그 LSN을 보고, redo가 필요한지를 정하고, redo 함수를 페이지 위에 그 자리에서 적용할 수 있다고 가정한다. 스토리지 설계의 두 갈래는 정반대 트레이드오프다 — B-tree는 비싼 제자리 갱신과 페이지 찢김이라는 비용을 치르고 싼 포인트 읽기를 얻는다. LSM 트리는 비싼 읽기와 끊임없는 컴팩션이라는 비용을 치르고 싼 쓰기를 얻는다.
CUBRID는 LSM이 기본값이 아니던 시절에 짜였다 — 로그 구조화 스토리지가 연구 아이디어로 자리 잡고는 있었지만(Rosenblum & Ousterhout, SOSP 1991), 트랜잭션 워크로드 위에 그것을 올린 주요 관계형 엔진은 아직 없었다. SQL Server, DB2, Oracle, PostgreSQL, MySQL이 모두 페이지 기반이었다. CUBRID가 노리는 OLTP 워크로드 — 작은 읽기 많이, 작은 쓰기 많이, primary key로 들어오는 포인트 쿼리 — 가 정확히 페이지 기반 스토리지가 다듬어진 영역이다.
이 결정 하나가 모든 스토리지 서브시스템에 잇따른 영향을 준다.
vacuum (결정 3) 은 페이지를 다시 쓰는 게 아니라 죽은 행 버전을
제자리에서 회수한다. DWB (결정 7) 는 홈 주소에서 일어나는 다중
섹터 쓰기를 보호한다. B+Tree 는 페이지를 split / merge 하면서도
그 홈 주소를 그대로 둔다. 페이지 버퍼는
(vfid, pageid) → 버퍼 프레임 의 고정 크기 해시 표인데, 페이지가
갱신마다 자리를 옮긴다면 안정된 키 자체가 사라져 해시가
동작하지 않는다. src/storage/ 아래의 모든 스토리지 경로가
제자리 갱신을 전제한다. 별도의 컴팩션 스케줄러도 없다. CUBRID 에서
컴팩션에 가장 가까운 도구는 cub_compactdb (SA 모드 유틸,
결정 12) 지만, 이마저도 백그라운드에서 지속적으로 동작하는 것이
아니라 운영자가 일부러 실행하는 명시적 조작이다. 제자리 갱신의
비용 (페이지 찢김, vacuum bloat, B+Tree 단편화) 은 그것을
만들어내는 서브시스템들이 분담하여 부담하며, 쓰기 경로 자체에
분담되지는 않는다.
12. 오프라인 작업을 위한 SA 모드 유틸
섹션 제목: “12. 오프라인 작업을 위한 SA 모드 유틸”운영 유틸 (cub_compactdb, cub_unloaddb, cub_loaddb,
cub_restoredb) 은 서버 엔진 전체를 자기 프로세스에 링크해 두고
디스크 위 데이터베이스에 직접 접근한다. broker 도, CAS 풀도,
cub_server 도 거치지 않는다. 유틸마다 CMake 그래프 안에서
두 번 빌드된다. 한 번은 <utility>_sa 로 libcubridsa.so
(인프로세스 서버) 를 링크하고, 다른 한 번은 <utility>_cs 로
libcubridcs.so (클라이언트 쪽 stub) 를 링크한다. 시작할 때
런타임 분류 표 (SA_ONLY, CS_ONLY, SA_CS) 와 그에 맞춰
dlopen 하는 라이브러리가 활성 모드를 정한다.
어떤 작업은 데이터베이스 파일을 독점적으로 점유하고자 한다.
네트워크 IPC 가 개입해서 이로울 게 없다. cub_compactdb 는
모든 레코드를 훑어 죽은 것을 구분하고 살아 있는 것을 물리적으로
옮기는 일을 한다. 이를 데몬을 거쳐 한다는 것은 데몬이 모든
클래스에 배타 락을 잡도록 강제해야 한다는 뜻이다. cub_unloaddb
는 데이터베이스 전체를 평탄한 파일로, 디스크가 받아낼 수 있는
만큼 빠르게 쏟아내고자 한다. 와이어 프로토콜의 CSS 프레이밍과
행마다의 마샬링은 그저 군더더기다. SA 모드의 cub_loaddb 는
락 매니저를 우회하고 (정의상 동시 접근이 없다) WAL 도 우회
한다 (데이터베이스가 새로 짜이는 중이라 복구 자체가 의미 없다).
그 결과 적재 속도가 자릿수 단위로 빨라진다.
두 갈래 빌드는 이 설계가 지불하는 공학적 비용이다. 같은 소스
파일이 세 번 컴파일된다. cub_server 용 SERVER_MODE,
libcubridsa.so 용 SA_MODE, libcubridcs.so 용 CS_MODE
세 가지다. #ifdef 블록이 어느 네트워킹 계층이 활성화될지를
결정한다. 엔진 코드 대부분은 세 빌드가 동일하고, 갈라지는 부분은
네트워크 인터페이스 근방에 모여 있다.
sa/CMakeLists.txt 와 cs/CMakeLists.txt 가 자매 공유
라이브러리 libcubridsa.so 와 libcubridcs.so 를 만든다.
util_admin.c, util_sa.c, util_cs.c 가 함께 디스패치 계층을
이루고, util_admin.c 가 유틸별 분류를 읽어 알맞은 라이브러리를
dlopen 한다. 분류 표는 어떤 유틸은 SA-only (cub_compactdb,
cub_unloaddb) 로, 어떤 유틸은 CS-only (이미 가동 중인 서버에
붙는 대화형 csql) 로, 어떤 유틸은 둘 다 (cub_loaddb 는 SA
쪽이 빠르지만 둘 다 지원한다) 로 분류한다. 엔진 곳곳에 흩어진
#if defined(SERVER_MODE) / #if defined(SA_MODE) /
#if defined(CS_MODE) 블록이 그 세 갈래를 소스 차원에 새겨 둔다.
상호 참조: cubrid-sa-cs-runtime.md.
13. CUBRID가 일부러 되지 않기로 한 것
섹션 제목: “13. CUBRID가 일부러 되지 않기로 한 것”위 결정들은 CUBRID 가 무엇인지 를 설명한다. 하지 않은 선택 들, 즉 엔진이 일부러 택하지 않은 모양들도 설계 철학에서 같은 무게의 한 부분이다. 코드베이스가 다른 모양이 아니라 지금의 모양이 된 까닭이 그쪽에 있기 때문이다.
CUBRID 는 분산 데이터베이스가 아니다. 2-phase commit을 넘어서는 노드 간 트랜잭션 코디네이터, 전역 쿼리 옵티마이저, 샤드를 인지 하는 플래너 어느 것도 없다. HA 는 읽기 전용 복제가 곁들여진 primary / standby (결정 10) 이지, Spanner / CockroachDB / TiDB 식 멀티 리전 클러스터가 아니다.
CUBRID 는 로그 구조화가 아니다. 스토리지는 페이지 기반과 제자리 갱신을 쓴다 (결정 11). SSTable 컴팩션도, LSM 레벨 스레딩도, write-amplification 분석도 없다. vacuum 은 MVCC 버전을 그 자리 에서 회수할 뿐, 컴팩션 스케줄러가 아니다.
CUBRID 는 벡터화되어 있지 않다. 실행기는 튜플을 한 개씩 처리하는 이터레이터다 (결정 4). MonetDB/X100 식 batch 실행도, HyPer 식 코드 생성도, LLVM-JIT 로 짜인 쿼리 파이프라인도 없다. 비용은 분석 스캔 처리량으로 치르고, 이득은 OLTP 의 단순함이다.
CUBRID 는 MPP 가 아니다. 병렬 쿼리 작업은 같은 머신 안에서의 노드 내 병렬화다. 한 연산자의 튜플 스트림이 같은 호스트 위 워커 스레드 여럿으로 분배되는 형태다. 노드 사이의 exchange 연산자, shuffle 단계, 분산 비용 모델은 없다.
그래서 CUBRID 가 어디에 자리 잡는가 하면, HA standby 를 곁에 둔 단일 primary 위의 OLTP 와 가벼운 OLAP 다. 그 표적 안에서는 엔진이 경쟁력이 있다. 읽기 지연 (락 없는 스냅샷 격리 읽기), 쓰기 처리량 (prior list 파이프가 고동시성 커밋을 흡수한다), 운영 예측 가능성 (ARIES 복구, fuzzy 체크포인트, DWB 로 보호되는 쓰기), 그리고 좁고 잘 다듬어진 운영 유틸 표면이 그렇다. 비목표 가 배제한 워크로드에서는 경쟁이 안 된다. 그렇게 만들어지지 않았기 때문이다. CUBRID 를 그 배제된 영역으로 밀어 넣을 만한 기능을 검토하는 독자라면, 엔진이 그 방향으로는 순순히 따라주지 않을 것이라고 예상하는 편이 옳다. 코드베이스는 될 수 있는 모습 이 아니라 지금의 모습 에 맞춰 다듬어져 있다.
이 13 개의 결정이 이 문서가 처음에 던진 물음 — CUBRID 는 왜 지금의 모습인가 — 에 대한 답이다. 이것들이 모양 아래의 모양 이다. 이 디렉터리의 세부 문서들이 그 모양을 따라 걷고, 이 문서는 그 모양을 빚어낸 손길을 가리킨다.