SELECT * FROM users WHERE id = 1;
users 테이블에서 id가 1인 행을 전부 가져오라는 뜻이다. 나는 이 한 줄이 DBMS(데이터베이스 프로그램) 안에서 정확히 어떻게 처리되는지 궁금했다.
“어차피 감춰진 계층이니까 몰라도 되겠지”라고 생각했지만, DB 내부 구조가 정말 궁금해서 MariaDB 소스 코드를 직접 디버거로 따라가 봤다.
이 글은 CLion(C/C++ 코드를 편집하고 디버깅하는 IDE)으로 실제 MariaDB 소스를 디버깅하며 배운 쿼리 실행 흐름을 적은 것이다. 이론이 아니라 코드에서 직접 확인한 것을 담았다. MariaDB는 C++로 쓰여 있어서 글에 C++ 코드가 많이 나오는데, 자바와 다른 문법은 나올 때마다 풀어 둔다.
왜 소스 코드까지 파고들었나?
처음엔 단순한 호기심이었다.
“쿼리가 어떻게 파싱되고(SQL 문자열을 토큰으로 자르고 문법 나무로 바꾸는 과정), 최적화되고, 실행되는지” 공식 문서만으로는 알 수 없는 구체적인 흐름이 궁금했다.
그래서 GitHub에 올라와 있는 MariaDB Server 저장소(저장소)의 소스를 받아 CLion으로 디버깅 환경을 만들었다. 브레이크포인트를 찍고, 변수를 지켜보고, 콜 스택을 따라가면서 “아, 이렇게 동작하는구나” 하는 순간들이 있었다.
가장 인상 깊었던 건 handler 추상화 계층이었다. InnoDB든 MyISAM이든 RocksDB든(셋 다 실제로 데이터를 디스크에 저장하고 꺼내는 스토리지 엔진이다), SQL을 해석하는 위쪽 코드(SQL 레이어)는 어느 엔진인지 신경 쓰지 않고 똑같은 인터페이스로 부른다.
전체 쿼리 실행 흐름 한눈에 보기
먼저 SELECT 쿼리가 실제로 거치는 전체 경로를 그림으로 본다. 상자 안의 영어 이름은 MariaDB 소스의 함수 이름이고, 화살표 옆 글씨는 그 사이에 하는 일이다.
mysql CLI / 앱] -->|MySQL Protocol
TCP| A[do_command] A -->|네트워크 패킷 수신
명령 종류 판별| B[dispatch_command] B -->|COM_QUERY| C[mysql_parse] C -->|① lex_start| C1[LEX 초기화] C -->|② query_cache 조회| C2{캐시 HIT?} C2 -->|YES| Return[바로 반환] C2 -->|NO| C3[parse_sql] C3 -->|Bison 파서| C4[MYSQLparse] C4 -->|파싱 결과| LEX[thd->lex 구조체] LEX --> D[mysql_execute_command] D -->|switch sql_command| E{명령 종류?} E -->|SQLCOM_SELECT| F[handle_select] E -->|SQLCOM_INSERT| G[mysql_insert] E -->|SQLCOM_UPDATE| H[mysql_update] F --> I[JOIN::optimize] I -->|통계 분석
비용 계산| I1[make_join_statistics] I -->|인덱스 vs 풀스캔| I2[best_access_path] I -->|ORDER BY / GROUP BY| I3[optimize_stage2] I1 & I2 & I3 --> J[JOIN::exec] J --> K[do_select] K --> L[sub_select] L --> M[rr_sequential
Full Table Scan] M --> N[handler::ha_rnd_next] N -->|virtual rnd_next| O{스토리지 엔진?} O -->|InnoDB| P1[ha_innobase::rnd_next] O -->|MyISAM| P2[ha_myisam::rnd_next] O -->|RocksDB| P3[ha_rocksdb::rnd_next] P1 --> Q[row_search_mvcc
MVCC 체크] Q -->|버퍼 풀 조회
디스크 I/O| R[행 데이터 반환] R -->|WHERE 조건 체크
evaluate_join_record| S[send_data_with_check] S -->|LIMIT 오프셋 체크| T[클라이언트로 전송] T --> U{더 읽을 행?} U -->|YES| M U -->|NO| V[완료] style A fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0 style D fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0 style I fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0 style N fill:#4a5f7f,stroke:#ffd966,stroke-width:3px,color:#e0e0e0 style O fill:#4a5f7f,stroke:#ffd966,stroke-width:3px,color:#e0e0e0 style Q fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0
복잡해 보이지만, 크게 5단계로 나뉜다. 괄호 안은 뒤의 「단계별 상세 분석」에서 그 단계를 다루는 소단계 번호다.
- 네트워크 수신 (
do_command→dispatch_command) — 상세 1단계 - 파싱 (
mysql_parse→parse_sql) — 상세 2단계, 그리고 파싱 결과를 보고 명령 종류를 가르는 상세 3단계 - 실행 계획 수립 (
JOIN::optimize) — 상세 4단계. 실행 계획은 「이 쿼리를 어떤 인덱스로, 어떤 테이블 순서로 읽을지」 정한 계획이고, 그 계획을 세우는 부분을 옵티마이저라고 부른다 - 데이터 읽기 (
handler→ 스토리지 엔진) — 상세 5~9단계 - 결과 전송 (
send_data_with_check) — 상세 10단계
그림에 나온 말 몇 개는 여기서 풀어 둔다.
- 클라이언트: 쿼리를 보내는 쪽이다. 터미널에서 쓰는
mysql명령(mysql CLI)이나, DB에 접속하는 자바 애플리케이션이 클라이언트다. 둘은 TCP 위에서 MySQL 프로토콜이라는 정해진 프로토콜로 패킷을 주고받는다 - COM_QUERY: 클라이언트가 보낸 패킷 맨 앞 1바이트가 명령 종류인데, 그중 「이 SQL을 실행해 달라」가 COM_QUERY다
- 쿼리 캐시: 똑같은 SELECT 문장의 결과를 통째로 저장해 두었다가, 같은 문장이 또 오면 파싱도 실행도 하지 않고 저장해 둔 결과를 바로 돌려주는 장치다. 결과가 저장돼 있으면 HIT, 없으면 MISS다. 기본값은 꺼져 있다(실무 4에서 다시 본다)
- sub_select: 테이블 하나를 맡아 「첫 행 읽기 → WHERE 조건 확인 → 다음 행 읽기」를 되풀이하는 함수다. 그림의 「더 읽을 행? YES」 화살표가 이 반복이다
- MVCC 체크: InnoDB가 「지금 이 트랜잭션에게 보여 줘야 하는 버전의 행」을 고르는 일이다. 9단계에서 본다
- 버퍼 풀 조회 / 디스크 I/O: InnoDB는 디스크에서 읽은 데이터를 메모리(버퍼 풀)에 담아 둔다. 메모리에 있으면 거기서 읽고, 없으면 디스크에서 읽어 온다(디스크 I/O)
- WHERE 조건 체크: 엔진은 행을 하나씩 넘겨줄 뿐이고,
WHERE조건에 맞는지는 SQL 레이어의evaluate_join_record가 행마다 확인한다. 조건에 맞는 행만send_data_with_check로 넘어간다
핵심 개념: 알아야 할 4가지 구조체
소스 코드를 따라가다 보면 반복해서 나오는 핵심 구조체들이 있다. 앞의 흐름도에 나온 thd->lex 구조체 같은 이름도 여기서 말하는 구조체 중 하나다. C++의 class와 struct는 자바의 클래스와 거의 같은 것으로 읽으면 된다. 아래 코드에서 LEX *lex;처럼 타입 뒤에 붙은 *는 그 객체를 가리키는 포인터라는 뜻이고, a->b는 자바의 a.b와 같다.
1. THD (Thread Handler)
class THD {
LEX *lex; // 파싱 결과
NET net; // 네트워크 연결
Security_context security_ctx; // 사용자 권한
Transaction_ctx transaction; // 트랜잭션 상태
// ...
};
한 줄씩 따라가기
lex: 방금 들어온 SQL이 파싱된 결과를 담아 둔다net: 이 연결이 쓰는 네트워크 소켓 정보를 담아 둔다security_ctx: 지금 연결한 사용자가 누구고 어떤 권한이 있는지 담아 둔다transaction: 지금 트랜잭션이 열려 있는지, 어떤 상태인지 담아 둔다
클라이언트 연결 하나 = THD 하나
모든 쿼리 실행의 중심이다. 현재 사용자, 트랜잭션 상태, 네트워크 연결 정보를 모두 담고 있다. 그래서 뒤에 나오는 함수들은 거의 다 THD *thd를 첫 인자로 받는다.
2. LEX (파싱 결과 바구니)
struct LEX {
enum_sql_command sql_command; // SQLCOM_SELECT 등
SELECT_LEX *select_lex; // SELECT 관련 정보
Item *where; // WHERE 조건
// ...
};
SQL 문자열이 파싱되면 구조화된 데이터로 바뀐다. 그 결과를 담는 것이 LEX다. enum_sql_command는 「SELECT인가 INSERT인가」 같은 명령 종류를 담는 enum이고, Item은 MariaDB가 식(age > 20 같은 조건이나 컬럼 이름) 하나를 나타내는 클래스다.
예를 들어 다음 쿼리가 있다.
SELECT name, age FROM users WHERE age > 20;
이 쿼리는 아래처럼 바뀐다. item_list와 table_list는 위 LEX 코드에는 없고, select_lex가 가리키는 SELECT_LEX 객체 안에 있는 필드다(위 코드는 // ...로 줄였다).
sql_command=SQLCOM_SELECTselect_lex->item_list=[name, age](가져올 컬럼 목록)select_lex->table_list=users(읽을 테이블 목록)where=age > 20
3. JOIN 객체
class JOIN {
TABLE_LIST *tables;
uint table_count;
JOIN_TAB *join_tab;
double best_read; // 예상 비용
// ...
};
SELECT 쿼리 하나 = JOIN 객체 하나
테이블 1개만 조회하는 쿼리도 내부에서는 JOIN 객체를 쓴다. “혼자 하는 JOIN”인 셈이다. SQL의 JOIN은 여러 테이블을 엮어 읽는 것인데, MariaDB는 테이블이 하나인 경우도 「테이블이 하나뿐인 JOIN」으로 똑같이 다룬다.
필드를 보면 tables는 읽을 테이블 목록, table_count는 테이블 개수, join_tab은 테이블마다 「어떻게 읽을지」를 적어 둔 칸들, best_read는 옵티마이저가 계산한 예상 비용이다. 비용은 여러 읽기 방법을 서로 견주려고 옵티마이저가 매기는 숫자이고, 작을수록 싸다. 정확한 단위는 이 글에서 확인하지 않았다. uint는 음수가 없는 정수 타입이다(뒤에 나오는 ulong, ulint도 음수가 없는 정수 타입이다).
이 객체에서 두 가지 핵심 작업이 일어난다.
optimize(): 실행 계획 수립 (어떤 인덱스를 쓸까, 어떤 순서로 읽을까)exec(): 그 계획대로 실제로 데이터를 읽는다
4. handler (추상화 계층)
class handler {
public:
virtual int rnd_next(uchar *buf) = 0; // 다음 행 읽기
virtual int index_read(uchar *buf, const uchar *key) = 0; // 인덱스로 읽기
// ...
};
class ha_innobase : public handler {
int rnd_next(uchar *buf) override; // InnoDB 구현
};
class ha_myisam : public handler {
int rnd_next(uchar *buf) override; // MyISAM 구현
};
한 줄씩 따라가기
virtual int rnd_next(uchar *buf) = 0;는 몸체가 없이= 0으로 끝나는, C++의 순수 가상 함수 선언이다. 자바로 치면interface의 추상 메서드와 같다 —handler를 상속받는 쪽이 반드시 자기 몸체를 채워야 한다.uchar *buf는 읽은 행을 채워 넣을 바이트 배열 자리다(uchar는 0~255 값을 담는 1바이트 타입)index_read도 같은 모양이다. 테이블을 처음부터 훑지 않고 인덱스로 찾아 읽는 쪽이다class ha_innobase : public handler는 자바의class HaInnobase extends Handler와 같은 뜻이다.ha_innobase,ha_myisam은handler를 상속해서 각자 자기 엔진에 맞는rnd_next를 채운 것이다.override는 자바의@Override와 같은 표시다- 그래서 SQL 레이어가 나중에
table->file->ha_rnd_next(buf)처럼 한 줄만 호출해도, 실제로 어느 엔진의rnd_next가 불릴지는 그 객체가 InnoDB인지 MyISAM인지에 따라 달라진다 — 자바의 다형성과 같은 원리다.ha_rnd_next는handler에 몸체가 있는 일반 함수이고, 그 안에서 엔진마다 다른rnd_next를 부른다(7단계에서 본다)
이것이 내가 가장 감탄한 부분이다.
SQL 레이어는 이렇게만 호출한다. table은 지금 읽는 테이블 객체, file은 그 테이블을 맡은 handler 객체다.
table->file->ha_rnd_next(buf);
이 한 줄이 InnoDB든 MyISAM이든 RocksDB든 모두 작동한다. 스토리지 엔진을 바꿔도 SQL 레이어 코드는 한 줄도 고칠 필요가 없다.
sql/sql_select.cc] -->|table->file->ha_rnd_next| Handler[handler 추상 클래스
sql/handler.h] Handler -->|virtual rnd_next| InnoDB[ha_innobase
InnoDB 엔진] Handler -->|virtual rnd_next| MyISAM[ha_myisam
MyISAM 엔진] Handler -->|virtual rnd_next| RocksDB[ha_rocksdb
RocksDB 엔진] InnoDB --> BufPool[버퍼 풀
메모리] InnoDB --> Disk1[디스크 .ibd] MyISAM --> Disk2[디스크 .MYD] RocksDB --> LSM[LSM Tree] style Handler fill:#4a5f7f,stroke:#ffd966,stroke-width:3px,color:#e0e0e0 style SQL fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0 style InnoDB fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0 style MyISAM fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0 style RocksDB fill:#2e4057,stroke:#66d9ff,stroke-width:2px,color:#e0e0e0
그림에서 .cc는 C++ 소스 파일, .h는 C++의 선언 파일이다. .ibd와 .MYD는 각각 InnoDB와 MyISAM이 테이블 데이터를 디스크에 저장하는 파일이고, LSM Tree는 RocksDB가 데이터를 쌓는 방식의 이름이다. 엔진마다 저장하는 모양은 다 다르지만 SQL 레이어는 그걸 모른다는 것이 이 그림의 요점이다.
단계별 상세 분석
이제 각 단계를 코드와 함께 자세히 본다. 앞의 5단계를 함수 단위로 더 잘게 나눠서 10단계로 따라간다. 코드는 실제 소스에서 흐름에 필요한 줄만 남기고 줄인 것이다. 위치의 파일:숫자는 그 함수가 있는 소스 파일과 줄 번호다(버전에 따라 줄 번호는 조금씩 달라진다).
코드 안의 이름을 전부 알 필요는 없다. 「한 줄씩 따라가기」에서 풀지 않은 인자 · 필드 · 상수(예: check_table_access의 FALSE, UINT_MAX, FALSE, best_access_path의 s, heap · mtr · vrow 같은 InnoDB 내부 변수)는 흐름을 따라가는 데 필요 없는 세부라 무시해도 된다. C++ 문법 두 가지만 미리 알아 두면 읽기 편하다.
- 변수 앞에 붙은
&(예:&conds)는 「그 변수가 놓인 자리(포인터)를 넘긴다」는 뜻이라, 받는 함수가 원래 변수를 직접 고칠 수 있다. 매개변수 선언에 붙은&(예:List<Item> &items)도 결과는 비슷하다 — 복사본이 아니라 원본을 다룬다 - 포인터는
if (ptr)처럼 조건에 바로 쓸 수 있다.NULL(자바의null)이면 거짓, 무언가를 가리키면 참이다
1단계: 네트워크 수신 (do_command)
위치: sql/sql_parse.cc:1220
dispatch_command_return do_command(THD *thd, bool blocking) {
char *packet;
ulong packet_length;
enum enum_server_command command;
// 이전에 중단된 명령이 있으면 복원 (비동기 재개)
if (thd->async_state.m_state == RESUMED) {
command = thd->async_state.m_command;
packet = thd->async_state.m_packet.str;
packet_length = thd->async_state.m_packet.length;
goto resume;
}
// 에러와 진단 정보 초기화
thd->clear_error(1);
// 네트워크 타임아웃 설정
my_net_set_read_timeout(net, thd->get_net_wait_timeout());
// TCP 소켓에서 패킷 수신
packet_length = my_net_read_packet(net, 1);
if (packet_length == packet_error) {
// 네트워크 에러 발생
return DISPATCH_COMMAND_CLOSE_CONNECTION;
}
packet = (char*) net->read_pos;
// 명령 종류 추출 (COM_QUERY, COM_PING, COM_QUIT 등)
command = fetch_command(thd, packet);
// 네트워크 타임아웃 복원
my_net_set_read_timeout(net, thd->variables.net_read_timeout);
resume: // ← 비동기 재개 시 여기서 시작
// 실제 명령 처리
return_value = dispatch_command(command, thd, packet+1,
(uint)(packet_length-1), blocking);
if (return_value == DISPATCH_COMMAND_WOULDBLOCK) {
// 비동기: 아직 안 끝남 (threadpool 모드)
// 현재 상태 저장하고 나중에 resume에서 재개
thd->async_state.m_command = command;
thd->async_state.m_packet = {packet, packet_length};
return return_value;
}
return return_value;
}
핵심 포인트:
my_net_read_packet(): TCP 소켓에서 바이트 덩어리(패킷)를 받는다. 두 번째 인자(1)는 읽기 방식을 바꾸는 내부 플래그값인데, 이 글의 핵심 흐름과는 무관해 더 깊이 다루지 않는다.net,return_value같은 변수의 선언은 줄인 코드에서 빠져 있다command: 패킷 맨 앞 1바이트에 든 명령 종류다. COM_QUERY (쿼리 실행), COM_PING (살아 있는지 확인), COM_QUIT (연결 종료) 등이 있다. 그래서dispatch_command에 넘길 때packet+1(첫 바이트를 건너뛴 자리부터)과packet_length-1(그만큼 줄인 길이)을 넘긴다. 남은 부분이 SQL 문자열이다goto resume;과resume:라벨: 자바에는 없는 문법이다.goto 이름;을 만나면이름:이 붙은 줄로 곧장 건너뛴다. 비동기로 중단됐던 명령을 다시 이어 갈 때, 패킷을 새로 받는 부분을 건너뛰고 바로 처리 부분으로 가려고 쓴다- 비동기 모드: threadpool 모드(연결마다 전용 스레드를 쓰지 않고, 정해진 개수의 스레드가 여러 연결을 돌아가며 처리하는 접속 처리 방식)에서는 한 연결이 스레드를 오래 붙잡으면(블로킹) 다른 연결이 기다려야 한다. 그래서 아직 못 끝낸 명령은
DISPATCH_COMMAND_WOULDBLOCK(「지금 끝내려면 기다려야 한다」는 뜻의 반환값)을 돌려주고, 그때의 명령과 패킷을async_state에 저장해 둔 뒤 스레드를 놓아준다. 나중에 다시 불리면 저장해 둔 것을 꺼내resume:부터 이어 간다
2단계: 파싱 (mysql_parse)
위치: sql/sql_parse.cc:7859
void mysql_parse(THD *thd, char *rawbuf, uint length,
Parser_state *parser_state) {
// ① LEX 초기화 (파싱 전에 반드시 필요)
lex_start(thd);
// ② THD 상태 리셋 (이전 명령 정보 정리)
thd->reset_for_next_command();
// ③ 쿼리 캐시 조회
// 주의: lex_start()를 먼저 호출해야 캐시가 제대로 작동
if (query_cache_send_result_to_client(thd, rawbuf, length) <= 0) {
// 캐시 미스(MISS) 또는 비활성화 → 파싱 필요
LEX *lex = thd->lex;
// ④ 실제 파싱 (Bison 문법 파서)
bool err = parse_sql(thd, parser_state, NULL, true);
if (likely(!err)) {
// 파싱 성공
// ⑤ 세미콜론 처리 (다중 쿼리 대응)
const char *found_semicolon = parser_state->m_lip.found_semicolon;
if (found_semicolon && (ulong)(found_semicolon - thd->query())) {
// 세미콜론 이전까지만 쿼리로 인식
thd->set_query(thd->query(),
(uint32)(found_semicolon - thd->query() - 1),
thd->charset());
lex->safe_to_cache_query = 0; // 다중 쿼리는 캐시 안 함
}
// ⑥ 실제 실행
int error = mysql_execute_command(thd);
} else {
// 파싱 에러 발생
query_cache_abort(thd, &thd->query_cache_tls);
}
// ⑦ 정리 작업
thd->end_statement();
thd->cleanup_after_query();
} else {
// 캐시 HIT! 파싱/실행 생략
thd->lex->sql_command = SQLCOM_SELECT;
thd->update_stats();
}
}
핵심 포인트:
- lex_start() 먼저 호출: 쿼리 캐시가
lex->safe_to_cache_query(이 쿼리를 캐시해도 되는지)와thd->server_status(서버가 이 연결에 대해 들고 있는 상태 값)를 보고 판단하므로, 그 값들을 먼저 초기화해야 한다 - 쿼리 캐시 체크:
query_cache_send_result_to_client()의 반환값이0이하이면 캐시 미스(또는 캐시가 꺼짐)라서 파싱으로 가고,0보다 크면 캐시에서 결과를 바로 보냈다는 뜻이다 - 세미콜론 처리: 클라이언트가
SELECT 1; SELECT 2;처럼 여러 문장을 한 번에 보내면(다중 쿼리) 첫 번째 문장만 이번에 처리한다.found_semicolon - thd->query()는 문장 시작 위치부터 세미콜론 위치까지의 거리, 즉 첫 문장의 길이다. 나머지 문장이 남았다는 표시(SERVER_MORE_RESULTS_EXISTS)를 세우는 줄은 위 코드에서 줄였고, 실제 소스에는 있다 - 캐시 정책: 다중 쿼리는
safe_to_cache_query = 0으로 표시해서 캐시하지 않는다 likely(!err)는 「err가 false일 가능성이 높다」고 컴파일러에게 알려 주는 표시일 뿐이고, 동작은 그냥if (!err)와 같다
파싱 과정 (parse_sql):
parse_sql() 안에서는 Bison 파서가 동작한다. Bison은 문법 규칙을 적어 두면 그 문법에 맞는 파서 코드를 자동으로 만들어 주는 도구고, 뒤에 나오는 LALR은 그렇게 만들어진 파서가 토큰을 왼쪽에서 오른쪽으로 읽으며 문법을 맞춰 가는 방식(알고리즘)의 이름이다.
위치: sql/sql_parse.cc:10313
bool parse_sql(THD *thd, Parser_state *parser_state,
Object_creation_ctx *creation_ctx, bool do_pfs_digest) {
// ① Parser state 설정
thd->m_parser_state = parser_state;
// ② Performance Schema Digest 설정 (쿼리 성능 모니터링용)
parser_state->m_digest_psi = NULL;
parser_state->m_lip.m_digest = NULL;
if (do_pfs_digest) {
// Digest 계산 시작 (쿼리 해시값 생성)
parser_state->m_digest_psi = MYSQL_DIGEST_START(thd->m_statement_psi);
if (parser_state->m_digest_psi != NULL) {
parser_state->m_lip.m_digest = thd->m_digest;
parser_state->m_lip.m_digest->m_digest_storage.m_charset_number =
thd->charset()->number;
}
}
// ③ 실제 파싱: SQL 모드에 따라 다른 파서 호출
bool mysql_parse_status = (thd->variables.sql_mode & MODE_ORACLE)
? ORAparse(thd) // Oracle 호환 모드
: MYSQLparse(thd); // 일반 MySQL 문법
if (mysql_parse_status) {
// 파싱 실패: LEX 정리 (side effect 제거)
LEX::cleanup_lex_after_parse_error(thd);
}
// ④ current_select 포인터 복원
thd->lex->current_select = thd->lex->first_select_lex();
// ⑤ Parser state 리셋
thd->m_parser_state = NULL;
// ⑥ 반환: 파싱 실패 || 치명적 에러
bool ret_value = mysql_parse_status || thd->is_fatal_error;
if (ret_value == 0 && parser_state->m_digest_psi != NULL) {
// 파싱 성공: Performance Schema에 Digest 기록
MYSQL_DIGEST_END(parser_state->m_digest_psi,
&thd->m_digest->m_digest_storage);
}
return ret_value;
}
핵심 포인트:
- SQL 모드별 파서: SQL 모드는 「어느 DB의 문법에 맞춰 SQL을 해석할지」 정하는 설정이다.
sql_mode & MODE_ORACLE은 그 설정 값에 Oracle 호환 모드가 켜져 있는지 비트로 확인하는 식이고, 켜져 있으면ORAparse(), 아니면MYSQLparse()를 부른다 - Bison 파서:
MYSQLparse()는 문법 규칙 파일sql/sql_yacc.yy를 Bison에 넣어 자동으로 만든 파서(LALR 파서)다 - Digest 계산: Performance Schema는 MariaDB가 쿼리별 실행 횟수와 시간 같은 통계를 모아 두는 기능이다. Digest는 그 통계를 「같은 모양의 쿼리」끼리 묶으려고 쿼리를 짧은 값으로 줄인 해시값이다
- 에러 처리: 파싱이 중간에 실패하면 반쯤 채워진 LEX가 남는다.
LEX::cleanup_lex_after_parse_error()가 그걸 치운다
파싱 결과는 thd->lex에 구조화된 형태로 저장된다.
3단계: 명령 분기 (mysql_execute_command)
위치: sql/sql_parse.cc:3489
int mysql_execute_command(THD *thd, bool is_called_from_prepared_stmt) {
int res = 0;
LEX *lex = thd->lex;
SELECT_LEX *select_lex = lex->first_select_lex();
TABLE_LIST *first_table = select_lex->table_list.first;
TABLE_LIST *all_tables = lex->query_tables;
// 보안, 권한, 트랜잭션 처리...
// 명령 타입에 따른 분기
switch (lex->sql_command) {
case SQLCOM_SELECT:
case SQLCOM_SHOW_STATUS:
case SQLCOM_SHOW_DATABASES:
case SQLCOM_SHOW_TABLES:
// 권한 체크
privilege_t privileges_requested = lex->exchange ?
SELECT_ACL | FILE_ACL : SELECT_ACL;
if (all_tables)
res = check_table_access(thd, privileges_requested,
all_tables, FALSE, UINT_MAX, FALSE);
if (!res)
res = execute_sqlcom_select(thd, all_tables);
break;
case SQLCOM_INSERT:
case SQLCOM_REPLACE:
res = mysql_insert(thd, all_tables, lex->field_list,
lex->many_values, ...);
break;
case SQLCOM_UPDATE:
case SQLCOM_UPDATE_MULTI:
DBUG_ASSERT(lex->m_sql_cmd != NULL);
res = lex->m_sql_cmd->execute(thd);
break;
case SQLCOM_DELETE:
case SQLCOM_DELETE_MULTI:
DBUG_ASSERT(lex->m_sql_cmd != NULL);
res = lex->m_sql_cmd->execute(thd);
break;
case SQLCOM_CREATE_TABLE:
case SQLCOM_ALTER_TABLE:
case SQLCOM_DROP_TABLE:
DBUG_ASSERT(lex->m_sql_cmd != NULL);
res = lex->m_sql_cmd->execute(thd);
break;
// ... 100개 이상의 case문 (SQLCOM_BEGIN, SQLCOM_COMMIT, etc.)
default:
DBUG_ASSERT(0); // 불가능한 경우
my_ok(thd);
break;
}
// 정리 작업
thd->update_stats();
return res || thd->is_error();
}
한 줄씩 따라가기(함수 맨 앞 네 줄)
LEX *lex = thd->lex;— 방금 파싱된 결과 전체를 가져온다SELECT_LEX *select_lex = lex->first_select_lex();—LEX하나는 세미콜론으로 구분된 쿼리 한 문장을 담고, 그 안에UNION(두 SELECT 결과를 이어 붙이기)이나 서브쿼리(SELECT 안의 SELECT)처럼SELECT블록이 여러 개 들어갈 수 있다.SELECT_LEX는 그중 하나의SELECT블록을 가리킨다TABLE_LIST *first_table = select_lex->table_list.first;— 이SELECT가 읽는 테이블 목록 중 첫 번째 테이블이다TABLE_LIST *all_tables = lex->query_tables;— 쿼리 전체(여러SELECT를 합친 경우 포함)가 참조하는 모든 테이블 목록이다
SELECT 갈래 따라가기
SHOW TABLES(테이블 목록 보기) 같은SHOW명령도 SELECT와 같은 갈래(case여러 개가 한 몸체를 같이 쓴다)에 들어간다privileges_requested는 이 쿼리에 필요한 권한이다. 기본은SELECT_ACL(읽기 권한)이고, 결과를 파일로 내보내는 쿼리(lex->exchange가 있을 때)면FILE_ACL(파일 권한)도 더한다.A | B는 두 권한을 함께 요구한다는 비트 표시다check_table_access(...)가 그 권한이 있는지 확인하고, 문제가 없으면(!res)execute_sqlcom_select()로 넘어간다
핵심 포인트:
- 100개 이상의
case문으로 모든 SQL 명령을 처리한다 - INSERT처럼 함수를 바로 부르는 명령도 있고, UPDATE · DELETE · CREATE TABLE처럼
lex->m_sql_cmd->execute()에 맡기는 명령도 있다. 어느 쪽을 쓰는지는 명령마다 다르고, 이 글에서는 SELECT 갈래만 따라간다 DBUG_ASSERT(...)는 디버그 빌드에서만 「이 조건이 참이어야 한다」를 확인하는 검사다. 자바의assert와 같은 자리다.default:의DBUG_ASSERT(0)은 「여기 오면 안 된다」는 표시이고, 실제 서비스용 빌드에서는 검사가 빠지므로 그 아래my_ok(thd)로 「성공」 응답만 보낸다- 마지막
return res || thd->is_error();는 둘 중 하나라도 에러면 1, 아니면 0을 돌려준다. C++에서는bool결과가int로 바뀌어 반환된다
SELECT가 JOIN 객체에 닿기까지: execute_sqlcom_select()는 handle_select()를 부르고, handle_select()는 mysql_select()를 부른다. mysql_select() 안에서 new JOIN(...)으로 JOIN 객체를 만들고 join->optimize()(4단계), join->exec()(5단계)를 차례로 부른다(sql/sql_select.cc의 mysql_select).
이제 이 SELECT 경로로 들어간다.
4단계: SELECT 최적화 (JOIN::optimize)
위치: sql/sql_select.cc:1988
int JOIN::optimize() {
int res = 0;
if (select_lex->pushdown_select) {
// 외부 스토리지 엔진으로 푸시다운 (예: Spider 엔진)
if (optimization_state == JOIN::OPTIMIZATION_DONE)
return 0;
fields = &select_lex->item_list;
if (!(select_options & SELECT_DESCRIBE)) {
res = select_lex->pushdown_select->prepare();
}
with_two_phase_optimization = false;
} else {
if (optimization_state != JOIN::NOT_OPTIMIZED)
return FALSE;
optimization_state = JOIN::OPTIMIZATION_IN_PROGRESS;
res = optimize_inner();
}
if (!with_two_phase_optimization) {
if (!res && have_query_plan != QEP_DELETED)
res = build_explain();
optimization_state = JOIN::OPTIMIZATION_DONE;
}
// 쿼리 비용을 사용자 변수에 저장
if (select_lex->select_number == 1)
thd->status_var.last_query_cost = best_read;
return res;
}
bool JOIN::optimize_inner() {
// ① 테이블 통계, 인덱스 분석
make_join_statistics(this, tables_list, &conds, &keyuse_array);
// ② 풀스캔 vs 인덱스 비용 계산
best_access_path(this, s, ...);
// ③ ORDER BY / GROUP BY 처리 방식 결정
if (optimize_stage2(this)) {
return true;
}
return false;
}
한 줄씩 따라가기
int JOIN::optimize() { ... }는JOIN클래스의optimize메서드 몸체를 클래스 밖에서 적는 C++ 문법이다.클래스::메서드로 읽으면 된다- 첫
if의pushdown_select는 쿼리를 MariaDB가 직접 풀지 않고 다른 서버에 통째로 맡기는(푸시다운) 특수한 엔진(예: Spider)일 때만 쓰는 갈래다. 보통의 InnoDB 테이블은else로 간다 else쪽은 이미 최적화했으면 바로 돌아가고, 아니면 「최적화 중」으로 표시한 뒤 실제 일을 하는optimize_inner()를 부른다build_explain()은EXPLAIN으로 보여 줄 실행 계획 정보를 만들어 두는 일이다- 마지막
if는 쿼리의 바깥쪽 첫 SELECT(select_number == 1)일 때 예상 비용best_read를 상태 변수last_query_cost에 저장한다(코드 주석의 「사용자 변수」는 이 상태 변수를 가리킨다). 상태 변수는 서버가 세어 두는 숫자들이고, 쿼리를 실행한 뒤SHOW STATUS LIKE 'Last_query_cost'(상태 변수 중 이 이름인 것을 보여 달라는 명령)로 이 값을 볼 수 있다 SELECT_DESCRIBE·with_two_phase_optimization·QEP_DELETED는 이 글의 SELECT 흐름을 따라가는 데는 필요 없는 표시라 무시해도 된다
optimize_inner()가 실제로 하는 일은 세 가지다(코드는 크게 줄였다).
make_join_statistics: 각 테이블의 행 수와 쓸 수 있는 인덱스를 모은다.conds는 WHERE 조건,keyuse_array는 「이 조건에 쓸 수 있는 인덱스」 목록이다best_access_path: 테이블을 처음부터 끝까지 훑을지(풀 스캔), 인덱스로 찾을지 비용을 비교해 고른다. 「최적화」란 결국 이 비용이 가장 작은 방법을 고르는 일이다optimize_stage2:ORDER BY(정렬)나GROUP BY(묶어서 집계)가 있으면 그걸 어떻게 처리할지 정한다
비용 계산 예시 (공식 문서 기준):
MariaDB 11.0부터 Optimizer Cost Model이 도입되었다. 아래는 SQL이 아니라 비용 공식을 적은 의사 코드다.
-- InnoDB Full Scan 비용
COST = ROW_COUNT * ROW_COPY_COST + TABLE_SCAN_COST
-- Index Range Scan 비용
COST = KEY_LOOKUP_COST * ESTIMATED_ROWS + KEY_COMPARE_COST * log2(ROWS)
- 풀 스캔 비용은 「행 수 × 행 하나를 복사하는 비용 + 테이블을 훑는 기본 비용」이다. 행이 많을수록 그대로 커진다
- Index Range Scan(인덱스로
age > 20같은 범위를 찾아 그 행들만 읽는 방식) 비용은 「조건에 맞을 것으로 예상한 행 수(ESTIMATED_ROWS) × 한 번 찾아가는 비용 + 인덱스 안에서 비교하는 비용」이다.ROWS는 테이블 전체 행 수이고,log2(ROWS)는 전체 행이 두 배가 되어도 비교 횟수는 한 번만 는다는 뜻이다.KEY는 인덱스를 말한다
best_read 변수에 최종 예상 비용이 저장된다.
5단계: 실행 (JOIN::exec)
위치: sql/sql_select.cc:4902
int JOIN::exec() {
int res;
DBUG_ASSERT(optimization_state == OPTIMIZATION_DONE);
// EXPLAIN을 위한 프로브 포인트
DBUG_EXECUTE_IF("show_explain_probe_join_exec_start",
if (dbug_user_var_equals_int(thd,
"show_explain_probe_select_id",
select_lex->select_number))
dbug_serve_apcs(thd, 1);
);
ANALYZE_START_TRACKING(thd, &explain->time_tracker);
res = exec_inner();
ANALYZE_STOP_TRACKING(thd, &explain->time_tracker);
return res;
}
int JOIN::exec_inner() {
// 컬럼 헤더 전송 (Field A, Field B 등)
result->send_result_set_metadata(...);
// 실제 데이터 읽기
do_select(this);
return 0;
}
한 줄씩 따라가기
exec()의DBUG_EXECUTE_IF(...)는 MariaDB 개발자가 테스트할 때만 켜는 디버그용 코드라 흐름과는 상관없다ANALYZE_START_TRACKING/ANALYZE_STOP_TRACKING은 그 사이exec_inner()에 걸린 시간을 재는 매크로다.ANALYZE문으로 실행 계획과 실제 걸린 시간을 같이 볼 때 이 값이 쓰인다exec_inner()의result는 결과를 받아 클라이언트로 보내는 객체다(10단계의send_data_with_check가 이 객체의 메서드다).send_result_set_metadata는 행보다 먼저 「컬럼 이름과 타입」을 보낸다do_select()가 실제 읽기를 시작한다.do_select()는 첫 테이블에 대해sub_select()를 부르고,sub_select()가 「첫 행 읽기 → WHERE 조건 확인(evaluate_join_record) → 다음 행 읽기」를 행이 떨어질 때까지 되풀이한다. 풀 스캔일 때 그 「행 읽기」 한 번이 바로 다음 6단계의rr_sequential()한 번이다
6단계: 행 읽기 (rr_sequential)
위치: sql/records.cc:506
int rr_sequential(READ_RECORD *info) {
int tmp;
// handler를 통해 다음 행 읽기
while ((tmp = info->table->file->ha_rnd_next(info->record()))) {
// 에러 처리 (삭제된 레코드, 잠금 등)
tmp = rr_handle_error(info, tmp);
break;
}
return tmp;
}
한 줄씩 따라가기
READ_RECORD *info는 「지금 어느 테이블을 어떤 방식으로 읽고 있는지」를 담은 객체다.info->table->file이 앞에서 본 그handler객체다while ((tmp = ...ha_rnd_next(...)))는 괄호 안에서ha_rnd_next를 부르고 그 반환값을tmp에 넣은 뒤, 그 값이 0이 아니면 몸체로 들어간다. C++에서는int를 조건에 바로 쓸 수 있고 0은 거짓, 0이 아닌 값은 참이다. 여기서 0은 「한 행을 잘 읽었다」, 0이 아니면 에러 코드다- 그래서 행을 잘 읽으면(0) 몸체에 들어가지 않고 바로
return 0이 된다. 에러가 나면 몸체에서rr_handle_error로 에러를 정리하고break로 빠져나간다. 모양은while이지만 실제로는 「한 번 읽고, 에러면 처리한다」는if처럼 동작한다 rr_sequential()은 한 번 불릴 때 한 행만 읽는다. 다음 행은 앞에서 본sub_select()의 반복이 다시 부른다
행 읽기 방식은 여러 가지다. 옵티마이저가 고른 방식에 따라 sub_select()가 부르는 함수가 달라진다. 아래 「EXPLAIN type」은 EXPLAIN을 실행했을 때 결과의 type 칸에 찍히는 값이다(실무 1에서 본다).
| 함수 | EXPLAIN type | 설명 |
|---|---|---|
rr_sequential() |
ALL (Full Table Scan) |
테이블의 모든 행을 처음부터 순서대로 읽는다 |
join_read_always_key() |
ref |
인덱스로 customer_id = 123처럼 같은 값을 가진 행들을 찾는다 |
join_read_key() |
eq_ref |
조인에서 앞 테이블의 한 행마다 Primary Key나 유니크 인덱스(같은 값이 두 번 들어갈 수 없는 인덱스)로 딱 한 행을 찾는다 |
join_read_const() |
const |
결과가 최대 1건으로 확정되는 테이블을 한 번만 읽는다 |
age > 20 같은 범위 검색(range)은 위 넷이 아니라 범위 전용 읽기 함수로 간다.
7단계: 스토리지 엔진 호출 (handler::ha_rnd_next)
위치: sql/handler.cc:4027
int handler::ha_rnd_next(uchar *buf) {
int result;
DBUG_ASSERT(table_share->tmp_table != NO_TMP_TABLE ||
m_lock_type != F_UNLCK);
DBUG_ASSERT(inited == RND);
do {
// ★ 핵심: 가상 함수 호출 (다형성)
TABLE_IO_WAIT(tracker, PSI_TABLE_FETCH_ROW, MAX_KEY, result,
{ result = rnd_next(buf); })
if (result != HA_ERR_RECORD_DELETED)
break;
status_var_increment(table->in_use->status_var.ha_read_rnd_deleted_count);
} while (!table->in_use->check_killed(1));
if (result == HA_ERR_RECORD_DELETED)
result = HA_ERR_ABORTED_BY_USER;
else {
if (!result) {
update_rows_read();
// 가상 컬럼 업데이트
if (table->vfield && buf == table->record[0])
table->update_virtual_fields(this, VCOL_UPDATE_FOR_READ);
}
increment_statistics(&SSV::ha_read_rnd_next_count);
}
table->status = result ? STATUS_NOT_FOUND : 0;
return result;
}
TABLE_IO_WAIT(tracker, PSI_TABLE_FETCH_ROW, MAX_KEY, result, { ... })는 매크로다. 괄호 안 { result = rnd_next(buf); }가 실제로 하는 일이고, 매크로는 그 실행 시간을 재서 Performance Schema(앞에서 본 모니터링용 통계)에 기록하는 코드를 앞뒤로 둘러씌운다. 즉 이 줄이 실제로 하는 일은 그냥 rnd_next(buf)를 호출하는 것과 같고, 걸린 시간을 재는 부분이 덧붙은 것뿐이다.
한 줄씩 따라가기
- 맨 앞 두
DBUG_ASSERT는 「이 테이블이 읽을 준비(잠금 · 순차 읽기 초기화)가 되어 있어야 한다」를 디버그 빌드에서 확인하는 검사다 do { ... } while (...)는 자바와 같다. 엔진이HA_ERR_RECORD_DELETED(삭제 표시만 된 행을 만났다)를 돌려주면 그 행은 건너뛰고 다시 읽는다. 다만 매번check_killed(1)로 「사용자가 이 쿼리를 중단(KILL)시켰나」를 보고, 중단됐으면 반복을 멈춘다- 그렇게 중단돼서 반복을 빠져나왔는데 결과가 여전히 「삭제된 행」이면, 그 값을
HA_ERR_ABORTED_BY_USER(사용자가 중단함)로 바꿔 돌려준다. 삭제된 행 때문이 아니라 중단 때문에 멈췄다는 뜻이다 - 잘 읽었으면(
!result) 읽은 행 수를 늘리고, 가상 컬럼이 있으면 채운다(바로 아래).increment_statistics는 「순차 읽기를 몇 번 했나」 통계 숫자를 하나 올린다
가상 컬럼은 테이블에 실제로 저장되지 않고, 다른 컬럼 값으로 계산해서 보여주는 컬럼이다(예: price * quantity를 total이라는 컬럼처럼 쓰는 것). update_virtual_fields는 행을 읽을 때마다 그 계산을 다시 해서 채워 넣는다.
rnd_next()는 순수 가상 함수다. 각 엔진이 구현한다.
8단계: InnoDB 엔진 (ha_innobase::rnd_next)
위치: storage/innobase/handler/ha_innodb.cc:9420
int ha_innobase::rnd_next(uchar* buf) {
int error;
if (m_start_of_scan) {
// 첫 번째 행: 인덱스 처음부터 시작
error = index_first(buf);
if (error == HA_ERR_KEY_NOT_FOUND) {
error = HA_ERR_END_OF_FILE;
}
m_start_of_scan = false;
} else {
// 이후 행들: 다음 행 가져오기
error = general_fetch(buf, ROW_SEL_NEXT, 0);
}
return error;
}
한 줄씩 따라가기
m_start_of_scan은 「이번 스캔에서 아직 한 행도 안 읽었나」 표시다- 첫 행이면
index_first(buf)로 인덱스의 맨 앞 행을 읽는다. 풀 스캔인데 인덱스부터 읽는 이유는, InnoDB가 테이블 데이터 자체를 Primary Key 순서로 정렬된 인덱스(클러스터 인덱스) 안에 저장하기 때문이다. 그 인덱스를 처음부터 끝까지 훑는 것이 곧 테이블 전체를 훑는 것이다 - 맨 앞 행이 없으면(
HA_ERR_KEY_NOT_FOUND, 빈 테이블) SQL 레이어가 알아듣는 「끝까지 다 읽었다」(HA_ERR_END_OF_FILE)로 바꿔 돌려준다 - 둘째 행부터는
general_fetch(buf, ROW_SEL_NEXT, 0)로 다음 행(ROW_SEL_NEXT)을 가져온다.general_fetch안에서 다음 9단계의row_search_mvcc()를 부른다(storage/innobase/handler/ha_innodb.cc의general_fetch)
9단계: MVCC 처리 (row_search_mvcc)
위치: storage/innobase/row/row0sel.cc (InnoDB 내부)
dberr_t row_search_mvcc(
byte* buf,
page_cur_mode_t mode,
row_prebuilt_t* prebuilt,
ulint match_mode,
ulint direction
) {
dict_index_t* index = prebuilt->index;
trx_t* trx = prebuilt->trx;
const rec_t* rec;
dberr_t err = DB_SUCCESS;
// ① 테이블 상태 확인
if (!prebuilt->table->space) {
return DB_TABLESPACE_DELETED;
} else if (!prebuilt->table->is_readable()) {
return DB_CORRUPTION;
} else if (prebuilt->index->is_corrupted()) {
return DB_CORRUPTION;
}
// ② 트랜잭션 격리 수준 확인 및 Read View 체크
if (trx->isolation_level == TRX_ISO_READ_UNCOMMITTED
|| !trx->read_view.is_open()) {
// READ UNCOMMITTED: 최신 버전 읽기
} else if (trx_id_t bulk_trx_id = index->table->bulk_trx_id) {
if (prebuilt->select_lock_type == LOCK_NONE
&& !trx->read_view.changes_visible(bulk_trx_id)) {
// 테이블이 비어있음
return DB_END_OF_INDEX;
}
}
rec_loop:
// ③ 레코드 가져오기
rec = btr_pcur_get_rec(pcur);
// ④ InnoDB 버퍼 풀에서 페이지 검색 (간략화)
offsets = rec_get_offsets(rec, index, offsets,
index->n_core_fields,
ULINT_UNDEFINED, &heap);
// ⑤ MVCC: 현재 트랜잭션이 볼 수 있는 버전인지 체크
if (index == clust_index) {
err = row_sel_clust_sees(rec, *index, offsets, trx->read_view);
if (err == DB_SUCCESS_LOCKED_REC) {
// 현재 버전이 보이지 않음 → 이전 버전 찾기 (UNDO 로그)
rec_t* old_vers;
err = row_sel_build_prev_vers_for_mysql(
prebuilt, clust_index,
rec, &offsets, &heap, &old_vers,
need_vrow ? &vrow : nullptr, &mtr);
if (err != DB_SUCCESS) {
goto lock_wait_or_error;
}
if (old_vers == NULL) {
// 행이 Read View에 존재하지 않음
goto next_rec;
}
rec = old_vers;
}
}
// ⑥ 삭제 마크된 레코드 스킵
if (rec_get_deleted_flag(rec, comp)) {
goto next_rec;
}
// ⑦ 행 데이터를 buf에 복사
rec_copy(buf, rec);
return DB_SUCCESS;
}
이 코드는 실제 함수(수천 줄)에서 흐름에 필요한 줄만 뽑은 것이라, goto가 가리키는 next_rec: · lock_wait_or_error: 라벨과 pcur · comp 같은 변수의 선언은 빠져 있다. rec_loop:도 원래는 next_rec:에서 다음 행으로 커서를 옮긴 뒤 되돌아오는 자리다. prebuilt는 「이 테이블을 어떤 인덱스로, 어떤 트랜잭션에서 읽는 중인지」를 담아 둔 InnoDB 쪽 객체다.
코드에 나온 trx->isolation_level(트랜잭션 객체의 필드)이 바로 격리 수준이다 — 여러 트랜잭션이 동시에 돌 때 서로 아직 커밋하지 않은 변경을 얼마나 볼 수 있는지 정해 둔 값이다. 그 트랜잭션이 들고 있는 read_view(Read View)는 「이 트랜잭션 기준으로 어디까지의 변경이 보이는지」를 적어 둔 스냅숏이다.
④번 줄의 버퍼 풀은 디스크에서 읽은 페이지(InnoDB가 디스크와 메모리 사이에서 데이터를 옮기는 단위인 16KB짜리 덩어리)를 담아 두는 버퍼들을 모아 둔 메모리 공간이다 — 매번 디스크를 다시 읽지 않고 이미 올라온 페이지를 재사용한다. 줄인 코드에서 ④번의 rec_get_offsets는 그렇게 찾은 행 안에서 컬럼마다 시작 위치를 계산하는 함수다.
⑤번은 이 행을 지금 트랜잭션에게 보여 줘도 되는지 확인한다. row_sel_clust_sees는 이 행을 마지막으로 바꾼 트랜잭션이 Read View 기준으로 보이면 DB_SUCCESS, 안 보이면 DB_SUCCESS_LOCKED_REC를 돌려준다(이름과 달리 이 함수에서는 「안 보인다」는 뜻이다 — 소스의 함수 주석이 그렇게 적고 있다). 안 보이면 UNDO 로그(행이 바뀌기 전 값을 적어 둔 기록)에서 이전 버전을 꺼내 온다. 이전 버전도 없으면 이 트랜잭션에게는 그 행이 없는 것이라 건너뛴다.
⑥번의 삭제 마크: InnoDB는 행을 지울 때 바로 지우지 않고 「지워졌다」는 표시만 해 둔다. 아직 지우기 전 버전을 봐야 하는 다른 트랜잭션이 있을 수 있어서, 실제로 치우는 일(purge)은 그런 트랜잭션이 없어진 뒤에 따로 한다. 지금 읽는 쪽은 표시된 행을 건너뛴다.
MVCC (Multi-Version Concurrency Control, 여러 버전으로 동시에 돌리기): 행 하나에 대해 옛 버전들을 UNDO 로그에 남겨 두고, 트랜잭션마다 자기 Read View에 맞는 버전을 골라 보여 주는 방식이다.
READ UNCOMMITTED: Read View를 보지 않고 가장 최신 버전을 그대로 읽는다. 아직 커밋하지 않은 변경까지 보인다(코드 ②번의 첫 갈래)READ COMMITTED: 문장(SELECT)을 실행할 때마다 Read View를 새로 만든다. 그래서 그 순간까지 커밋된 최신 데이터를 읽는다REPEATABLE READ: 그 트랜잭션에서 처음 읽을 때 만든 Read View를 트랜잭션이 끝날 때까지 쓴다. 그래서 같은 행을 다시 읽어도 처음 본 값이 나온다- 읽는 쪽은 옛 버전을 읽으면 되므로, 다른 트랜잭션이 그 행을 고치는 중이어도 기다리지(블로킹) 않는다
10단계: 결과 전송 (send_data_with_check)
위치: sql/sql_class.h:3299 → sql_class.cc
int select_result_sink::send_data_with_check(
List<Item> &items,
SELECT_LEX_UNIT *u,
ha_rows sent
) {
// LIMIT 오프셋 체크
if (u->lim.check_offset(sent))
return 0;
// 쿼리가 중단되었는지 체크
if (u->thd->killed == ABORT_QUERY)
return 0;
// 실제 데이터 전송
int rc = send_data(items);
// 복잡한 데이터 타입 정리
if (thd->stmt_arena->with_complex_data_types())
thd->stmt_arena->expr_event_handler_for_free_list(thd,
expr_event_t::DESTRUCT_RESULT_SET_ROW_FIELD);
return rc;
}
한 줄씩 따라가기
List<Item> &items는 이번 행의 컬럼 값 목록이다.<Item>은 자바 제네릭List<Item>과 같은 뜻이고,&는 복사하지 않고 원본을 그대로 넘긴다는 표시다.sent는 지금까지 보낸 행 수다u->lim.check_offset(sent):LIMIT 10 OFFSET 5처럼 「앞의 5행은 건너뛰라」는 조건이 있으면, 아직 건너뛸 차례인 행은 보내지 않고0(문제없음)을 돌려준다. WHERE는 이미 앞에서 걸렀고, 여기서는 LIMIT만 본다killed == ABORT_QUERY: 이 쿼리가 중단 신호를 받았으면 더 보내지 않는다send_data(items)가 실제로 보내는 일을 한다. 일반 SELECT에서 이 함수는select_send::send_data이고, 그 안에서 아래Protocol::send_result_set_row를 부른다(sql/sql_class.cc)- 마지막
if는 주석대로 복잡한 데이터 타입의 값을 정리하는 일이라 흐름에는 영향이 없다
실제 네트워크 전송은 Protocol 클래스를 통해 일어난다.
// sql/protocol.cc
bool Protocol::send_result_set_row(List<Item> *row_items) {
List_iterator_fast<Item> it(*row_items);
ValueBuffer<MAX_FIELD_WIDTH> value_buffer;
for (Item *item = it++; item; item = it++) {
value_buffer.reset_buffer();
// 각 컬럼 값 직렬화
if (item->send(this, &value_buffer)) {
// 메모리 부족 시 복구 시도
this->free();
return TRUE;
}
// 에러 발생 시 중단
if (unlikely(thd->is_error()))
return TRUE;
}
return FALSE;
}
이 함수는 한 행(row)의 각 컬럼 값을 차례로 꺼내 item->send()로 직렬화한다 — 메모리 안의 값 그대로는 네트워크로 보낼 수 없으니, 클라이언트가 이해할 수 있는 바이트 형태로 바꿔서 보낸다는 뜻이다.
한 줄씩 따라가기
List_iterator_fast<Item> it(*row_items);는 컬럼 목록을 하나씩 꺼내는 반복자(자바의Iterator)를 만든다.it++는 다음 항목을 돌려주고, 더 없으면NULL(자바의null)을 돌려준다- 그래서
for (Item *item = it++; item; item = it++)는 자바로 치면while (it.hasNext()) { Item item = it.next(); ... }와 같다 ValueBuffer<MAX_FIELD_WIDTH> value_buffer;는 값을 바이트로 바꿀 때 쓰는 임시 버퍼다. 자바 제네릭과 달리 C++ 템플릿은< >안에 타입뿐 아니라 숫자 상수도 넣을 수 있고, 여기서는 버퍼 크기를 정한다. 이 버퍼는reset_buffer()로 컬럼마다 비운다item->send(...)가 실패하면 지금까지 쓰던 것을 정리(free())하고TRUE(실패)를 돌려준다.unlikely(...)는 앞의likely와 반대로 「거의 거짓일 것」이라는 컴파일러용 표시일 뿐이다
실무에 어떻게 활용할까?
이런 내부 구조를 알면 실무에서 어떤 도움이 되는지 본다.
1. 쿼리 최적화 인사이트
EXPLAIN SELECT * FROM orders WHERE customer_id = 123;
orders(주문) 테이블에서 customer_id(주문한 고객 번호)가 123인 주문을 찾는 쿼리 앞에 EXPLAIN을 붙이면, 쿼리를 실행하지 않고 실행 계획만 표로 보여 준다. EXPLAIN 결과의 type 칸에 ALL이 나오면 Full Table Scan이다.
이제 우리는 안다.
rr_sequential()함수가 호출된다ha_rnd_next()가 모든 행을 하나씩 읽는다- 테이블이 클수록 느려진다
해결책: 인덱스 추가. 아래 문장은 orders 테이블의 customer_id 컬럼에 idx_customer라는 이름의 인덱스를 만든다.
CREATE INDEX idx_customer ON orders(customer_id);
이제 type이 ref가 되고 읽는 함수도 join_read_always_key()로 바뀐다. 인덱스가 B-tree 구조라 한 번 비교할 때마다 찾는 범위를 크게 줄여 가는 로그 시간 복잡도(O(log n))로 빠르게 찾는다.
2. 스토리지 엔진 선택 기준
handler 추상화 덕분에 테이블마다 엔진을 골라 쓸 수 있다. 아래 표의 OLTP는 주문 · 결제처럼 짧은 읽기 · 쓰기가 많이 몰리는 일반적인 서비스 업무를 말하고, 테이블 락은 쓰는 동안 테이블 전체를 잠가 다른 쓰기를 기다리게 하는 방식이다. Aria는 MariaDB에 들어 있는 또 하나의 엔진으로, 서버가 갑자기 꺼져도 테이블이 깨지지 않게(Crash-safe) 만든 MyISAM 계열이다. INSERT는 테이블에 새 행을 넣는 SQL 명령이다. 표의 특징과 용도는 내가 정리한 대략의 기준이고, 이 글에서 재 보지는 않았다.
| 엔진 | 특징 | 적합한 용도 |
|---|---|---|
| InnoDB | MVCC, 트랜잭션, 외래키 | 일반적인 OLTP |
| MyISAM | 테이블 락, INSERT 빠름 | 로그 테이블, 읽기 전용 |
| RocksDB | LSM Tree, 압축 우수 | 대용량 데이터, 쓰기 많음 |
| Aria | Crash-safe MyISAM | 임시 테이블 |
예를 들어 로그 수집 테이블이라면 아래처럼 만든다. CREATE TABLE은 테이블을 만드는 문장이고, 괄호 안은 컬럼 이름과 타입(DATETIME 날짜와 시각, INT 정수, VARCHAR(255) 최대 255자 문자열)이다. 맨 끝 ENGINE=MyISAM이 이 테이블만 MyISAM 엔진으로 저장하라는 지정이다.
CREATE TABLE access_logs (
log_time DATETIME,
user_id INT,
url VARCHAR(255)
) ENGINE=MyISAM; -- INSERT가 빠르고 트랜잭션 불필요
3. 트랜잭션 격리 수준 이해
row_search_mvcc()를 알면 격리 수준을 깊이 이해할 수 있다. 아래는 DB에 동시에 접속한 두 연결(세션 A, 세션 B)이 같은 계좌를 읽고 고치는 장면이다. START TRANSACTION은 트랜잭션을 시작하고, UPDATE ... SET balance = 200은 잔액을 200으로 고치며, COMMIT은 고친 것을 확정한다.
-- 세션 A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 100원
-- ... 시간 경과 ...
-- 세션 B (다른 커넥션)
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;
-- 세션 A (계속)
SELECT balance FROM accounts WHERE id = 1; -- ?????
격리 수준에 따라:
READ COMMITTED: 200원 (두 번째 SELECT를 할 때 Read View를 새로 만들어서, 그 사이 커밋된 값이 보인다)REPEATABLE READ(기본값): 100원 (세션 A가 처음 읽을 때 만든 Read View를 계속 쓴다)
이제 왜 그런지 안다. REPEATABLE READ에서는 세션 B의 변경이 세션 A의 Read View 기준으로 「안 보이는 변경」이라, row_search_mvcc()가 UNDO 로그에서 이전 버전(100원)을 꺼내 준다. READ COMMITTED에서는 새로 만든 Read View에 세션 B의 커밋이 들어 있어서 최신 버전(200원)이 그대로 보인다. 같은 장치에서 Read View를 언제 만드느냐만 다르다(InnoDB 소스 ha_innodb.cc에 「낮은 격리 수준에서는 읽을 때마다 자기 스냅숏을 만든다」는 주석이 있다).
4. 쿼리 캐시 활용
쿼리 캐시는 기본으로 꺼져 있다. 처음 이 글을 쓸 때는 「MariaDB 10.11부터 쿼리 캐시가 제거되었다」고 적었는데 틀린 말이었다. 이 블로그 저장소에 받아 둔 MariaDB 13.0 소스에도 쿼리 캐시 코드(sql/sql_cache.cc)와 query_cache_type 설정이 그대로 있고, 그 기본값이 OFF다(sql/sys_vars.cc).
켜서 쓰려면 아래처럼 한다. SET GLOBAL은 서버 전체 설정 값을 바꾸는 문장이고, 64M은 64MB다.
-- 캐시 활성화
SET GLOBAL query_cache_type = ON;
SET GLOBAL query_cache_size = 64M;
-- 캐시 무효화 (데이터 변경 시)
UPDATE users SET name = 'Alice'; -- users 테이블 관련 캐시 자동 삭제
마지막 UPDATE처럼 테이블 데이터가 바뀌면, 그 테이블을 읽었던 저장된 결과는 더 이상 맞지 않으므로 지워진다. mysql_parse()에서 캐시를 먼저 확인하므로, 캐시에 결과가 있으면 파싱조차 생략된다.
CLion 디버깅 실습 가이드
직접 따라 해 보려면 이렇게 시작한다.
환경 구축
# 1. 소스 다운로드
git clone https://github.com/mariadb/server.git
cd server
# 2. 빌드 (Ubuntu 기준)
sudo apt install build-essential cmake libncurses5-dev bison
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Debug
make -j$(nproc)
# 3. CLion에서 열기
# File → Open → server 폴더 선택
한 줄씩 따라가기
git clone ...: GitHub에 있는 MariaDB 소스 코드 전체를 내 컴퓨터로 복제해 온다.cd server는 받아 온server폴더로 들어간다sudo apt install ...: Ubuntu(리눅스 배포판 하나)에서 프로그램을 설치하는 명령이다.apt가 설치 도구이고,sudo는 관리자 권한으로 실행한다는 뜻이다.build-essential(C/C++ 컴파일러 모음),cmake(빌드 설정 도구),bison(앞에서 나온 그 파서 생성기),libncurses5-dev(터미널 화면을 다루는 라이브러리의 개발용 파일)를 설치한다mkdir build && cd build: 빌드 결과물을 소스 폴더와 섞이지 않게 따로 둘 폴더를 만들고 그 안으로 들어간다.&&는 앞 명령이 성공하면 뒤 명령을 이어서 실행한다cmake .. -DCMAKE_BUILD_TYPE=Debug: 한 단계 위(..)에 있는 소스를 보고 실제 빌드에 쓸 설정을 만든다.Debug모드로 만들어야 디버거로 중간에 멈춰 변수를 볼 수 있다make -j$(nproc): 방금 만든 설정대로 실제 컴파일을 한다.-j$(nproc)는 CPU 코어 수만큼 작업을 동시에 돌려 빌드 시간을 줄이는 옵션이다
브레이크포인트 추천 위치
아래 라인 번호는 앞의 위치(함수가 시작하는 줄)와 다르다. 함수 첫 줄이 아니라 그 함수 안에서 멈춰 보면 좋은 줄을 적었기 때문이다. 4번은 아예 다른 함수(JOIN::optimize가 부르는 JOIN::optimize_inner)다. 소스 버전에 따라 줄 번호가 밀리므로, 번호가 안 맞으면 함수 이름으로 찾아 들어가면 된다.
| 순서 | 파일 | 라인 | 함수 | 확인할 변수 |
|---|---|---|---|---|
| 1 | sql/sql_parse.cc |
1432 | do_command 내 resume: |
command, packet |
| 2 | sql/sql_parse.cc |
7882 | mysql_parse |
rawbuf (SQL 원본) |
| 3 | sql/sql_parse.cc |
3953 | case SQLCOM_SELECT: |
thd->lex->sql_command |
| 4 | sql/sql_select.cc |
2216 | JOIN::optimize_inner |
join->best_read |
| 5 | sql/records.cc |
508 | rr_sequential |
info->record() |
| 6 | sql/handler.cc |
4047 | ha_rnd_next |
buf (전후 비교) |
| 7 | storage/innobase/handler/ha_innodb.cc |
9420 | ha_innobase::rnd_next |
- |
실습 쿼리
-- 간단한 SELECT로 시작
SELECT * FROM mysql.user LIMIT 1;
-- WHERE 조건 추가
SELECT * FROM mysql.user WHERE user = 'root';
-- JOIN 쿼리
SELECT u.user, d.db
FROM mysql.user u
JOIN mysql.db d ON u.user = d.user
LIMIT 5;
mysql.user는 mysql 데이터베이스의 user 테이블(서버 계정 목록)이다. LIMIT 1은 결과를 1행만 받는다는 뜻이다. 세 번째 쿼리의 JOIN ... ON u.user = d.user는 두 테이블에서 user 값이 같은 행끼리 이어 붙이라는 뜻이고, u와 d는 두 테이블에 붙인 짧은 별명이다.
서버를 CLion 디버거 아래에서 띄우고 mysql 클라이언트로 접속해 이 쿼리들을 보내는 구체적인 설정은 이 글에 적지 않았다. 세 쿼리를 차례로 돌리면 브레이크포인트에 걸리는 횟수가 달라진다. 몇 번씩 걸리는지는 이 글에 기록해 두지 않았다. 예를 들어 6번 ha_rnd_next는 풀 스캔일 때 읽는 행마다 한 번씩 걸린다(앞의 6단계에서 본 대로 rr_sequential이 한 번에 한 행만 읽기 때문이다).
핵심 파일 정리
| 파일 | 역할 | 주요 함수 |
|---|---|---|
sql/sql_parse.cc |
네트워크 수신, 파싱, 명령 분기 | do_command, mysql_parse, mysql_execute_command |
sql/sql_select.cc |
SELECT 최적화 & 실행 | handle_select, JOIN::optimize, JOIN::exec |
sql/records.cc |
행 읽기 방식 구현 | rr_sequential 외 여러 읽기 함수 |
sql/handler.cc |
스토리지 엔진 추상화 | handler::ha_rnd_next |
sql/handler.h |
handler 인터페이스 정의 | 가상 함수 선언 |
storage/innobase/handler/ha_innodb.cc |
InnoDB 엔진 구현 | ha_innobase::rnd_next, general_fetch |
storage/innobase/row/row0sel.cc |
InnoDB 행 검색 & MVCC | row_search_mvcc |
빌드 규모 (참고):
- 전체
.cc파일: 1,402개 / 55.2 MB sql/폴더만: 249개 / 17.8 MB- 최초 빌드: 20~30분 / 수정 후 재빌드: 1~3분
마치며 — 블랙박스를 열어 보니 설계 이유가 보였다
처음엔 “소스 코드까지 봐야 하나?” 싶었다. 하지만 직접 디버거로 따라가면서 DB가 어떻게 동작하는지 깊이 이해하게 되었고, 이는 쿼리 최적화와 문제 해결에 큰 도움이 되었다.
특히 handler 추상화 계층처럼, 단순히 “이렇게 되어 있다”를 넘어서 “왜 이렇게 설계했는지”를 느낄 수 있었던 건 큰 수확이었다.
핵심 요약
- 쿼리 실행은 5단계: 네트워크 수신 → 파싱 → 최적화 → 데이터 읽기 → 결과 전송
- handler 추상화: SQL 레이어는 스토리지 엔진을 몰라도 된다 (다형성의 힘)
- MVCC: 읽는 쪽은 옛 버전을 읽으므로 고치는 쪽을 기다리지 않는다
- CLion 디버깅: 브레이크포인트로 실제 흐름을 확인할 수 있다
다음 단계는?
- 직접 디버깅 해보기: MariaDB GitHub에서 클론
- EXPLAIN 분석 연습: 실제 쿼리의 실행 계획 읽기
- 스토리지 엔진 비교: InnoDB vs MyISAM vs RocksDB 벤치마킹
참고 자료
- MariaDB Server GitHub Repository
- MariaDB ColumnStore Architecture
- MariaDB Optimizer Costs Documentation
자신만의 철학을 만들어가는 중입니다.
댓글남기기