정현닷넷 | | 이력서 | 플레이리스트


데이터베이스는 어떻게 동작하는가

SQL을 작성하는 것과 SQL이 어떻게 실행되는지 이해하는 것은 완전히 다른 영역이다. SELECT * FROM users WHERE id = 1이 결과를 반환하기까지 내부에서 일어나는 일을 알면, 느린 쿼리를 보는 눈이 달라진다.

왜 파일 시스템이 아니라 데이터베이스인가

데이터를 저장하는 가장 원시적인 방법은 파일이다. JSON 파일 하나에 사용자 목록을 넣고, 읽고, 수정하고, 다시 저장한다. 소규모 프로젝트에서는 이것으로 충분하다.

문제는 규모가 커질 때 발생한다. 사용자가 100만 명이면 JSON 파일은 수백 MB가 된다. 한 명의 정보를 찾으려면 파일 전체를 읽어야 한다. 두 명의 사용자가 동시에 같은 파일을 수정하면 누군가의 변경이 유실된다. 서버가 데이터를 쓰는 도중 꺼지면 파일이 깨진다.

DBMS(Database Management System)는 이 문제들을 해결하기 위해 존재한다:

  • 동시성 제어: 여러 클라이언트가 같은 데이터에 동시에 접근해도 충돌 없이 처리한다.
  • 장애 복구: 서버가 비정상 종료되어도 데이터를 일관된 상태로 복원한다.
  • 효율적 검색: 인덱스를 사용하여 수억 건의 데이터에서 특정 행을 밀리초 안에 찾는다.
  • 데이터 무결성: 제약 조건(constraint)으로 잘못된 데이터가 들어오는 것을 방지한다.

파일 시스템 위에 이 모든 것을 직접 구현할 수도 있다. 하지만 그 결과물이 결국 데이터베이스다. DBMS는 수십 년간 축적된 동시성, 내구성, 성능 문제의 해법을 하나의 소프트웨어로 패키징한 것이다.

클라이언트-서버 모델

MySQL은 클라이언트-서버 아키텍처를 따른다. 서버 프로세스(mysqld)가 항상 실행 중이고, 클라이언트가 TCP/IP 또는 Unix socket을 통해 접속한다.

클라이언트 (mysql CLI, 애플리케이션)
        │
        │  TCP/IP (3306) 또는 Unix socket
        ▼
서버 프로세스 (mysqld)
        │
        ▼
데이터 파일 (디스크)

하나의 MySQL 서버에 수백 개의 클라이언트가 동시에 접속할 수 있다. 각 클라이언트 연결은 서버 내부에서 하나의 thread로 처리된다. 클라이언트가 SQL을 보내면 해당 thread가 쿼리를 실행하고 결과를 반환한다.

# mysql CLI로 접속
mysql -h 127.0.0.1 -u root -p

# 현재 연결된 클라이언트 목록 확인
SHOW PROCESSLIST;
+----+------+-----------+------+---------+------+-------+------------------+
| Id | User | Host      | db   | Command | Time | State | Info             |
+----+------+-----------+------+---------+------+-------+------------------+
|  5 | root | localhost | mydb | Query   |    0 | init  | SHOW PROCESSLIST |
|  8 | app  | 10.0.0.5  | mydb | Sleep   |   12 |       | NULL             |
+----+------+-----------+------+---------+------+-------+------------------+

Id는 connection ID, Command는 현재 상태를 나타낸다. Sleep은 연결은 유지되지만 쿼리를 실행하지 않는 상태다. connection pool을 사용하는 애플리케이션에서 흔히 볼 수 있다.

SQL이 실행되기까지의 큰 그림

클라이언트가 다음 쿼리를 보냈다고 가정한다:

SELECT name, email FROM users WHERE age > 25 ORDER BY name;

이 SQL 문자열이 결과 집합으로 바뀌기까지 네 단계를 거친다.

1단계: 파서 (Parser)

SQL 문자열을 파싱하여 내부 자료 구조로 변환한다. 프로그래밍 언어의 컴파일러가 소스 코드를 AST(Abstract Syntax Tree)로 변환하는 것과 같은 원리다.

파서는 두 가지를 검사한다:

  • 문법 검사: SELEC처럼 키워드가 틀리면 syntax error를 반환한다.
  • 의미 검사: users 테이블이 존재하는지, age 컬럼이 해당 테이블에 있는지 확인한다.

문법이나 의미에 문제가 있으면 여기서 에러가 발생하고 실행이 중단된다. 흔히 보는 ERROR 1064 (42000): You have an error in your SQL syntax는 이 단계에서 나온다.

2단계: 옵티마이저 (Optimizer)

파서가 만든 내부 구조를 받아서 실행 계획(execution plan)을 생성한다. 같은 결과를 만드는 방법은 여러 가지가 있다. 옵티마이저는 그중 가장 비용이 낮은 방법을 선택한다.

위 쿼리에서 옵티마이저가 판단하는 것들:

  • age > 25 조건에 인덱스를 사용할 수 있는가?
  • 인덱스를 사용하는 것이 전체 테이블을 스캔하는 것보다 빠른가?
  • ORDER BY name을 위해 별도의 정렬이 필요한가, 아니면 인덱스 순서를 활용할 수 있는가?

옵티마이저는 테이블의 통계 정보(행 수, 인덱스의 카디널리티 등)를 기반으로 비용을 추정한다. 이 추정이 실제와 다르면 비효율적인 실행 계획이 선택될 수 있다. 쿼리 튜닝의 상당 부분은 옵티마이저가 올바른 판단을 하도록 돕는 작업이다.

실행 계획은 EXPLAIN 명령으로 확인할 수 있다:

EXPLAIN SELECT name, email FROM users WHERE age > 25 ORDER BY name;
+----+-------------+-------+------+---------------+------+---------+------+------+-----------------------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows | Extra                       |
+----+-------------+-------+------+---------------+------+---------+------+------+-----------------------------+
|  1 | SIMPLE      | users | ALL  | idx_age       | NULL | NULL    | NULL | 1000 | Using where; Using filesort |
+----+-------------+-------+------+---------------+------+---------+------+------+-----------------------------+

type: ALL은 전체 테이블 스캔, key: NULL은 인덱스를 사용하지 않았다는 뜻이다. Using filesort는 결과를 정렬하기 위해 추가 작업이 필요하다는 의미다. 이 출력의 각 항목이 무엇을 의미하는지는 18편에서 다룬다.

3단계: 실행 엔진 (Execution Engine)

옵티마이저가 결정한 실행 계획을 실제로 수행하는 단계다. 실행 엔진은 MySQL의 서버 레이어에 속하며, 스토리지 엔진에게 데이터를 요청하고 받아서 처리한다.

실행 엔진이 하는 일:

  • 스토리지 엔진에게 "이 조건에 맞는 행을 달라"고 요청
  • 반환된 행에 대해 WHERE 조건을 재검증 (스토리지 엔진이 걸러내지 못한 부분)
  • ORDER BY 정렬 수행
  • LIMIT 적용
  • 결과를 클라이언트에게 전송

실행 엔진과 스토리지 엔진 사이에는 명확한 인터페이스(handler API)가 존재한다. 이 인터페이스 덕분에 MySQL은 여러 스토리지 엔진을 교체하며 사용할 수 있다.

4단계: 스토리지 엔진 (Storage Engine)

디스크에 저장된 데이터를 실제로 읽고 쓰는 계층이다. 테이블에 데이터를 어떤 형태로 저장할지, 인덱스를 어떤 자료 구조로 관리할지, 트랜잭션을 어떻게 보장할지 — 이 모든 것이 스토리지 엔진의 영역이다.

MySQL의 기본 스토리지 엔진은 InnoDB다. MySQL 5.5 이전에는 MyISAM이 기본이었지만, 트랜잭션과 crash recovery를 지원하지 않아 InnoDB로 대체되었다.

MySQL 아키텍처: 두 개의 레이어

MySQL의 내부 구조는 크게 두 레이어로 나뉜다. 이 구분을 이해하는 것이 MySQL을 이해하는 출발점이다.

서버 레이어

모든 스토리지 엔진이 공유하는 상위 계층이다. 스토리지 엔진에 관계없이 동일하게 동작한다.

  • 연결 관리: 클라이언트 인증, thread 할당, connection pool 관리
  • 파서: SQL 구문 분석, syntax 검증
  • 옵티마이저: 실행 계획 수립, 비용 기반 최적화
  • 실행 엔진: 실행 계획 수행, 결과 반환
  • 캐시: 쿼리 캐시(MySQL 8.0에서 제거됨), 메타데이터 캐시
  • 내장 함수: NOW(), CONCAT(), COUNT() 등의 함수 처리
  • 뷰, 트리거, 스토어드 프로시저: 서버 레이어에서 관리

스토리지 엔진 레이어

데이터의 물리적 저장과 검색을 담당하는 하위 계층이다. MySQL의 스토리지 엔진은 pluggable 아키텍처다. 같은 MySQL 서버에서 테이블마다 다른 스토리지 엔진을 사용할 수 있다.

-- 테이블 생성 시 스토리지 엔진 지정
CREATE TABLE logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    message TEXT,
    created_at DATETIME
) ENGINE=InnoDB;

-- 현재 사용 가능한 스토리지 엔진 목록
SHOW ENGINES;
+--------------------+---------+------------------------------------------------------------+
| Engine             | Support | Comment                                                    |
+--------------------+---------+------------------------------------------------------------+
| InnoDB             | DEFAULT | Supports transactions, row-level locking, and foreign keys |
| MyISAM             | YES     | Non-transactional engine with full-text search             |
| MEMORY             | YES     | Hash based, stored in memory, useful for temporary tables  |
| CSV                | YES     | Stores tables as CSV files                                 |
| ARCHIVE            | YES     | Compressed, supports only INSERT and SELECT                |
+--------------------+---------+------------------------------------------------------------+

InnoDBDEFAULT 표시는 테이블 생성 시 ENGINE을 명시하지 않으면 InnoDB가 사용된다는 뜻이다.

서버 레이어와 스토리지 엔진 레이어 사이의 통신은 handler API라는 인터페이스를 통해 이루어진다. 서버 레이어가 "다음 행을 읽어라", "이 행을 삽입하라" 같은 추상적인 요청을 보내면, 스토리지 엔진이 자신만의 방식으로 이를 처리한다.

┌─────────────────────────────────────────┐
│           서버 레이어                     │
│                                         │
│  연결 관리 → 파서 → 옵티마이저 → 실행 엔진  │
│                                         │
├────────────── handler API ──────────────┤
│                                         │
│         스토리지 엔진 레이어               │
│                                         │
│  ┌─────────┐ ┌────────┐ ┌────────┐     │
│  │ InnoDB  │ │ MyISAM │ │ Memory │ ... │
│  └─────────┘ └────────┘ └────────┘     │
│                                         │
│              디스크 / 메모리              │
└─────────────────────────────────────────┘

이 설계가 가져오는 결과는 명확하다. 옵티마이저는 InnoDB든 MyISAM이든 신경 쓰지 않는다. "이 테이블에서 id = 1인 행을 찾아라"라는 요청만 보낸다. 인덱스를 B-tree로 탐색할지, 해시로 탐색할지는 스토리지 엔진이 결정한다.

반대로, 스토리지 엔진은 SQL을 모른다. SELECT, JOIN, GROUP BY 같은 개념은 서버 레이어의 영역이다. 스토리지 엔진은 "행을 읽어라", "행을 써라", "인덱스에서 찾아라" 수준의 저수준 연산만 수행한다.

각 계층이 하는 일: 하나의 쿼리를 따라가며

다시 처음의 쿼리로 돌아간다:

SELECT name, email FROM users WHERE age > 25 ORDER BY name;

이 쿼리가 결과를 반환하기까지의 전체 흐름을 정리한다.

  1. 클라이언트가 MySQL 서버에 TCP 연결을 맺고, 인증을 통과한다.
  2. 서버 레이어의 파서가 SQL 문자열을 파싱하여 users 테이블의 name, email 컬럼을 읽되, age > 25 조건으로 필터링하고, name으로 정렬하라는 내부 구조를 생성한다.
  3. 옵티마이저가 테이블 통계를 확인한다. users 테이블에 1,000행이 있고, age 컬럼에 인덱스가 있지만 age > 25에 해당하는 행이 800개라면, 인덱스보다 전체 스캔이 낫다고 판단한다.
  4. 실행 엔진이 스토리지 엔진에게 handler API를 통해 "users 테이블의 행을 순서대로 달라"고 요청한다.
  5. InnoDB(스토리지 엔진)가 디스크에서 데이터 페이지를 buffer pool로 읽어온다. 이미 buffer pool에 있으면 디스크 I/O 없이 메모리에서 바로 반환한다.
  6. 실행 엔진이 반환된 각 행에 대해 age > 25 조건을 검사한다. 조건을 만족하는 행의 name, email만 추출한다.
  7. 조건을 통과한 행들을 name 기준으로 정렬한다. 메모리에서 정렬이 가능하면 메모리에서, 데이터가 크면 임시 파일을 사용한다.
  8. 정렬된 결과를 클라이언트에게 전송한다.

이 과정에서 핵심적인 비용 발생 지점은 두 곳이다:

  • 디스크 I/O: 5단계에서 데이터가 buffer pool에 없으면 디스크에서 읽어야 한다. 디스크는 메모리보다 수만 배 느리다.
  • 정렬: 7단계에서 인덱스 순서와 정렬 순서가 다르면 별도의 정렬 작업이 필요하다. 데이터가 크면 디스크 기반 정렬(filesort)이 발생한다.

쿼리 최적화란 결국 이 두 비용을 줄이는 작업이다. 인덱스를 적절히 설계하면 디스크에서 필요한 데이터만 읽을 수 있고, 정렬도 인덱스 순서를 활용하여 생략할 수 있다.

정리

  • 데이터베이스는 파일 시스템 위에 동시성 제어, 장애 복구, 효율적 검색을 얹은 시스템이다.
  • MySQL은 서버 레이어와 스토리지 엔진 레이어로 나뉘며, 두 레이어가 handler API로 통신한다.
  • SQL 한 줄이 파싱, 최적화, 실행, 저장소 접근이라는 네 단계를 거쳐 결과가 된다.
  • 쿼리 최적화의 핵심 비용 지점은 디스크 I/O와 정렬이며, 인덱스 설계로 두 비용을 줄일 수 있다.

스토리지 엔진과 InnoDB