SQLite 바이트코드 엔진

SQLite 바이트코드 엔진 (The SQLite Bytecode Engine)

SQLite는 SQL 문을 바이트코드로 변환한 다음 그 바이트코드를 가상 머신에서 실행해 동작해요. 이 문서는 바이트코드 엔진이 어떻게 동작하는지 설명해요.

출처: The SQLite Bytecode Engine 문서

본문

1. 요약 (Executive Summary)

SQLite는 SQL 문을 바이트코드로 변환한 다음 그 바이트코드를 가상 머신에서 실행해 동작해요. 이 문서는 바이트코드 엔진이 어떻게 동작하는지 설명해요.

이 문서는 SQLite 내부 동작을 설명해요. 여기 제공된 정보는 SQLite를 사용한 일상적인 애플리케이션 개발에는 필요하지 않아요. 이 문서는 SQLite의 내부 동작을 더 깊이 파고들고 싶은 사람들을 위한 것이에요.

바이트코드 엔진은 SQLite의 API가 아니에요. 바이트코드 엔진의 세부사항은 SQLite 릴리스마다 바뀌어요. SQLite를 사용하는 애플리케이션은 이 문서에서 찾을 수 있는 어떤 세부사항에도 의존해서는 안 돼요.

SQLite가 SQL을 구현하기 위해 바이트코드를 선호하는 몇 가지 이유는 "Why SQLite Uses Bytecode" 문서를 참조하세요.

2. 소개

SQLite는 각 SQL 문을 바이트코드로 변환한 다음 그 바이트코드를 실행해 동작해요. SQLite의 prepared statement는 대부분 해당 SQL을 구현하는 데 필요한 바이트코드예요. sqlite3_prepare_v2() 인터페이스는 SQL을 바이트코드로 변환하는 컴파일러예요. sqlite3_step() 인터페이스는 prepared statement 안에 포함된 바이트코드를 실행하는 가상 머신이에요.

바이트코드 가상 머신은 SQLite의 핵심이에요. SQLite가 내부적으로 어떻게 동작하는지 이해하려는 프로그래머는 바이트코드 엔진을 잘 알아야 해요.

역사적으로 SQLite의 바이트코드 엔진은 "Virtual DataBase Engine" 또는 "VDBE"라고 불러요. 이 웹사이트는 "바이트코드 엔진", "VDBE", "가상 머신", "바이트코드 가상 머신"이라는 용어를 서로 바꿔 사용하며, 모두 같은 것을 의미해요.

이 문서는 또한 "바이트코드 프로그램"과 "prepared statement"라는 용어를 서로 바꿔 사용하는데, 그것들은 대부분 같은 것이기 때문이에요.

2.1. VDBE 소스 코드

바이트코드 엔진의 소스 코드는 vdbe.c 소스 파일에 있어요. 이 문서의 opcode 정의는 그 소스 파일의 주석에서 파생됐어요. 소스 코드 주석은 바이트코드 엔진에 대한 정보의 정식 소스예요. 의심이 들면 소스 코드를 참조하세요.

주요 vdbe.c 소스 코드 파일 외에도 소스 트리에는 "VDBE"의 줄임말인 "vdbe"로 시작하는 이름을 가진 다른 헬퍼 코드 파일들이 있어요.

opcode의 이름과 의미는 SQLite 릴리스마다 자주 바뀐다는 점을 기억하세요. SQLite의 EXPLAIN 출력을 연구한다면, EXPLAIN을 실행한 SQLite 버전과 일치하는 이 문서 버전(또는 vdbe.c 소스 코드)을 참조해야 해요. 그렇지 않으면 opcode의 설명이 정확하지 않을 수 있어요. 이 문서는 2026-07-24 날짜의 SQLite 버전 3.53.4 체크인 bf7c7f3003188에서 파생됐어요.

2.2. 명령 형식 (Instruction Format)

SQLite의 바이트코드 프로그램은 하나 이상의 명령(instruction)으로 구성되며, 각 명령은 opcode와 P1, P2, P3, P4, P5라는 다섯 개의 피연산자를 가져요. P1, P2, P3 피연산자는 32비트 부호 정수이며 종종 레지스터를 가리켜요. b-트리 커서로 작동하는 명령의 경우 P1은 보통 커서 번호예요. 점프 명령의 경우 P2는 보통 점프 대상이에요. P4는 32비트 부호 정수, 64비트 부호 정수, 64비트 부동 소수점 값, 문자열 리터럴, Blob 리터럴, 정렬 순서 비교 함수 포인터, 애플리케이션 정의 SQL 함수 구현 포인터 등 다양한 것일 수 있어요. P5는 보통 플래그를 담는 데 사용되는 16비트 부호 없는 정수예요. P5 플래그의 비트는 때때로 opcode에 미묘한 영향을 줄 수 있어요. 예를 들어 Eq opcode에 SQLITE_NULLEQ(0x0080) 비트가 설정되면 NULL 값들이 서로 동등하게 비교돼요. 그렇지 않으면 NULL 값들은 서로 다르게 비교돼요.

어떤 opcode는 다섯 피연산자를 모두 사용하고, 어떤 것은 하나나 둘을 사용하며, 어떤 것은 피연산자를 전혀 사용하지 않아요.

바이트코드 엔진은 명령 번호 0에서 실행을 시작해요. Halt 명령을 만나거나, 프로그램 카운터가 마지막 명령의 주소보다 커지거나, 오류가 있을 때까지 실행이 계속돼요. 바이트코드 엔진이 멈추면 할당한 모든 메모리가 해제되고 열었던 모든 데이터베이스 커서가 닫혀요. 실행이 오류로 인해 중단되면 보류 중인 트랜잭션이 종료되고 데이터베이스에 대한 변경 사항이 롤백돼요.

ResultRow opcode는 바이트코드 엔진을 일시 중지시키고 대응하는 sqlite3_step() 호출이 SQLITE_ROW를 반환하게 해요. ResultRow를 호출하기 전에 바이트코드 프로그램은 질의의 단일 행에 대한 결과를 일련의 레지스터에 로드했을 거예요. sqlite3_column_int()sqlite3_column_text() 같은 C 언어 API가 그 레지스터들에서 질의 결과를 추출해요. 바이트코드 엔진은 다음 sqlite3_step() 호출에서 ResultRow 다음 명령으로 재개해요.

2.3. 레지스터 (Registers)

모든 바이트코드 프로그램은 고정된(하지만 잠재적으로 큰) 수의 레지스터를 가져요. 단일 레지스터는 다양한 객체를 담을 수 있어요.

레지스터는 값을 전혀 담지 않음을 의미하는 "정의되지 않음(Undefined)"일 수도 있어요. Undefined는 NULL과 달라요. 컴파일 옵션에 따라 정의되지 않은 레지스터를 읽으려는 시도는 보통 런타임 오류를 일으켜요. 코드 생성기(sqlite3_prepare_v2())가 Undefined 레지스터를 읽는 prepared statement를 생성한다면 그것은 코드 생성기의 버그예요.

레지스터는 0부터 번호가 매겨져요. 대부분의 opcode는 적어도 하나의 레지스터를 참조해요.

단일 prepared statement의 레지스터 수는 컴파일 시 고정돼요. 모든 레지스터의 내용은 prepared statement가 reset되거나 finalized될 때 지워져요.

내부 Mem 객체는 단일 레지스터에 대한 값을 저장해요. API에 노출되는 추상 sqlite3_value 객체는 사실상 Mem 객체 또는 레지스터예요.

2.4. B-트리 커서 (B-Tree Cursors)

prepared statement는 0개 이상의 열린 커서를 가질 수 있어요. 각 커서는 작은 정수로 식별되며, 보통 커서를 사용하는 opcode의 P1 매개변수예요. 같은 인덱스나 테이블에 여러 커서가 열려 있을 수 있어요. 모든 커서는 같은 인덱스나 테이블을 가리키더라도 독립적으로 동작해요. 가상 머신이 데이터베이스 파일과 상호작용하는 유일한 방법은 커서를 통해서예요. 가상 머신의 명령은 새 커서를 만들고(OpenRead 또는 OpenWrite 등), 커서에서 데이터를 읽고(Column), 커서를 테이블의 다음 항목으로 전진시킬 수 있어요(Next 또는 Prev 등). 모든 커서는 prepared statement가 reset되거나 finalized될 때 자동으로 닫혀요.

2.5. 서브루틴, 코루틴, 서브프로그램 (Subroutines, Coroutines, and Subprograms)

바이트코드 엔진에는 서브루틴의 반환 주소를 저장할 스택이 없어요. 반환 주소는 레지스터에 저장되어야 해요. 따라서 바이트코드 서브루틴은 재진입(reentrant)이 아니에요.

Gosub opcode는 현재 프로그램 카운터를 레지스터 P1에 저장한 다음 주소 P2로 점프해요. Return opcode는 주소 P1+1로 점프해요. 따라서 모든 서브루틴은 두 개의 정수와 연관돼요: 서브루틴의 진입점 주소와 반환 주소를 담는 레지스터 번호.

Yield opcode는 프로그램 카운터의 값을 레지스터 P1의 정수 값과 교환해요. 이 opcode는 코루틴을 구현하는 데 사용돼요. 코루틴은 내용을 필요에 따라 끌어오는 서브쿼리를 구현하는 데 자주 사용돼요.

트리거는 재진입이 가능해야 해요. 바이트코드 서브루틴은 재진입이 아니므로 트리거를 구현하려면 다른 메커니즘을 사용해야 해요. 각 트리거는 자체 opcode, 프로그램 카운터, 레지스터 집합을 가진 별도의 바이트코드 프로그램으로 구현돼요. Program opcode가 트리거 서브프로그램을 호출해요. Program 명령은 서브프로그램의 각 호출마다 새로운 레지스터 집합을 할당하고 초기화하므로 서브프로그램은 재진입이 가능하고 재귀적일 수 있어요. Param opcode는 서브프로그램이 호출하는 바이트코드 프로그램의 레지스터 내용에 접근하는 데 사용돼요.

2.6. 자기 변경 코드 (Self-Altering Code)

어떤 opcode는 자기 변경적이에요. 예를 들어 Init opcode(모든 바이트코드 프로그램의 첫 번째 opcode)는 자신의 P1 피연산자를 증가시켜요. 이후의 Once opcode들은 그 뒤의 일회성 초기화 코드를 건너뛸지 결정하기 위해 자신의 P1 피연산자를 Init opcode의 P1 값과 비교해요. 또 다른 예는 String8 opcode로, P4 피연산자를 UTF-8에서 올바른 데이터베이스 문자열 인코딩으로 변환한 다음 자신을 String opcode로 변환해요.

3. 바이트코드 보기 (Viewing The Bytecode)

SQLite가 해석하는 모든 SQL 문은 가상 머신을 위한 프로그램으로 이어져요. 하지만 SQL 문이 EXPLAIN 키워드로 시작하면 가상 머신은 프로그램을 실행하지 않아요. 대신 프로그램의 명령들이 행당 하나씩 질의 결과처럼 반환돼요. 이 기능은 디버깅과 가상 머신이 어떻게 동작하는지 배우는 데 유용해요. 예:

$ sqlite3 ex1.db
sqlite> explain delete from tbl1 where two<20;
addr  opcode         p1    p2    p3    p4             p5  comment      
----  -------------  ----  ----  ----  -------------  --  -------------
0     Init           0     12    0                    00  Start at 12  
1     Null           0     1     0                    00  r[1]=NULL    
2     OpenWrite      0     2     0     3              00  root=2 iDb=0; tbl1
3     Rewind         0     10    0                    00               
4       Column        0     1     2                    00  r[2]=tbl1.two
5       Ge            3     9     2     (BINARY)       51  if r[2]>=r[3] goto 9
6       Rowid         0     4     0                    00  r[4]=rowid   
7       Once          0     8     0                    00               
8       Delete        0     1     0     tbl1           02               
9     Next           0     4     0                    01               
10    Noop           0     0     0                    00               
11    Halt           0     0     0                    00               
12    Transaction    0     1     1     0              01  usesStmtJournal=0
13    TableLock      0     2     1     tbl1           00  iDb=0 root=2 write=1
14    Integer        20    3     0                    00  r[3]=20      
15    Goto           0     1     0                    00

어떤 애플리케이션도 EXPLAIN 질의를 실행해 위와 유사한 출력을 얻을 수 있어요. 하지만 루프 구조를 보여주는 들여쓰기는 SQLite 코어가 생성하지 않아요. 명령줄 셸에는 루프를 들여쓰는 추가 로직이 포함돼 있어요. 또한 EXPLAIN 출력의 "comment" 열은 SQLite가 -DSQLITE_ENABLE_EXPLAIN_COMMENTS 옵션으로 컴파일된 경우에만 제공돼요.

SQLite가 SQLITE_DEBUG 컴파일 옵션으로 컴파일되면 디버깅과 VDBE 동작 탐구에 유용한 추가 PRAGMA 명령이 제공돼요. 예를 들어 vdbe_trace pragma를 활성화하면 각 VDBE opcode가 실행될 때마다 그 분해(disassembly)가 표준 출력에 인쇄되도록 할 수 있어요. 이러한 디버깅 pragma에는 다음이 포함돼요.

4. Opcode 목록

현재 가상 머신은 192개의 opcode를 정의해요. 현재 정의된 모든 opcode는 아래 표에 설명되어 있어요. 이 표는 vdbe.c 파일의 소스 코드를 스캔해 자동으로 생성됐어요.

기억하세요: VDBE opcode는 SQLite용 인터페이스 정의의 일부가 아니에요. opcode의 수와 이름, 의미는 SQLite 릴리스마다 바뀌어요. 아래 표에 표시된 opcode는 2026-07-24 날짜의 SQLite 버전 3.53.4 체크인 bf7c7f3003188에 유효해요.

각 opcode의 이름과 동작에 대한 설명은 원본 문서의 The Opcodes 표에 상세히 나와 있어요. 이 표의 처음 몇 항목은 다음과 같아요.

  • Abort — Abort가 발생할 수 있음을 검증해요. 이 시점에서 Abort가 데이터베이스 손상을 일으킬 수 있으면 assert해요. 이 opcode는 디버깅 빌드에서만 나타나요.
  • Add — 레지스터 P1의 값을 레지스터 P2의 값에 더해 결과를 레지스터 P3에 저장해요. 두 입력 중 하나가 NULL이면 결과는 NULL이에요.
  • AddImm — 상수 P2를 레지스터 P1의 값에 더해요. 결과는 항상 정수예요. 레지스터를 정수로 강제하려면 0을 더하면 돼요.
  • Affinity — P1에서 시작하는 P2개 레지스터 범위에 선호도(affinity)를 적용해요. P4는 P2 문자 길이의 문자열이며, 그 문자열의 N번째 문자는 범위의 N번째 메모리 셀에 사용할 열 선호도를 나타내요.
  • AggFinal — 집계 또는 창 함수의 누적기인 메모리 위치 P1에서 집계의 파이널라이저(finalizer) 함수를 실행하고 결과를 P1에 저장해요.
  • AggInverse — 집계에 대해 xInverse 함수를 실행해요. 함수는 P5개 인수를 가지며, P4는 함수를 지정하는 FuncDef 구조체에 대한 포인터예요. 레지스터 P3가 누적기예요.
  • AggStep — 집계에 대해 xStep 함수를 실행해요. 함수는 P5개 인수를 가지며, P4는 함수를 지정하는 FuncDef 구조체에 대한 포인터예요. 레지스터 P3가 누적기예요.

opcode의 전체 목록과 각각의 상세 설명은 원본 문서의 The Opcodes 표를 참조하세요. 각 opcode의 의미는 SQLite 버전에 따라 달라질 수 있으므로, 자신이 실행하는 SQLite 버전과 일치하는 문서를 확인하는 것이 중요해요.

더 알아보기 (Learn more)