SQLite3 C API를 Swift에서 사용하는 방법

Swift에서 SQLite3를 사용하는 방법은 크게 두 가지이다. 하나는 Objective-C에서 SQLite3를 액세스하는 래퍼 클래스를 작성하고, 이 것을 Swift 프로젝트에 포함시켜서 컴파일하는 것이다. Swift는 Objective-C와 자연스럽게 상호호환되기 때문에 Objective-C에 친숙하다면 이 방법도 나쁘지 않다.

관련글 : Objective-C 래퍼를 통해 Swift에서 SQLite3를 사용하는 법

다른 한가지 방법으로는 sqlite3.h 헤더를 Swift에서 반입하여 Swift에서 SQLite3 C API를 바로 사용하는 것이다. 결국 업어치나 메치나 똑같은 것이기는 한데, Swift에서 C API를 직접 사용하는 것은 Swift와 C의 연계 방식에 대한 이해가 필요하다. 이 글에서는 Swift에서 Sqlite3를 사용하기 위해 필요한 배경 지식들에 대해서 살펴볼 예정이다. SQLite3 API에 대한 자세한 설명이 필요할 수 있는데, 이 내용은 Objective-C에서 SQLite3를 사용하는 법을 다룬 위 링크에서 대략 소개하고 있으며, 이 글에서는 최소한 SQLite3 API에 대해 알고 있다고 전제하겠다.

SQLite3 C API를 사용하여 쿼리를 실행하는 결과에 대해서는 다음의 과정을 거친다.

  1. 데이터베이스 연결
  2. 쿼리 컴파일 및 쿼리 바인딩
  3. 쿼리 실행 및 각 Row의 데이터 획득
  4. 연결닫기

데이터베이스 연결하기

SQLite3 데이터베이스를 연결하려면 sqlite3_open() 함수를 사용한다. 이 함수의 원형은 다음과 같이 정의되어 있다.

// sqlite3는 데이터베이스 핸들에 대한 포인터
typedef struct sqlite3 sqlite3;
int sqlite3_open(const char *filename, sqlite3 ** ppDb);

// - filename : 파일의 경로 문자열
// - ppDb : 데이터베이스 핸들

연결에 성공하면 정수 0을 리턴하는데, 이 값은 SQLITE_OK 라는 상수로 정의되어 있다. 이 함수는 Swift로 반입되면서 다음의 시그니처를 갖게 된다.

func sqlite3_open(_ filename: UnsafePointer<UInt8>, _ ppDb: UnsafeMutablePointer<OpaquePointer!>) -> Int32
// C code

sqlite3* db;
const char* dbpath = " .... ";

if(sqlite3_open(filename, &db) == SQLITE_OK) {
  // .. DB 연결 완료
  ...
}

ppDb의 경우, struct sqlite3 구조체의 정의 부분은 헤더에 공개되지 않으므로 struct sqlite3 * 포인터는 OpaquePointer 형태로 반입되었다. 불투명포인터의 경우, 특별한 생성이나 메소드 호출은 불가능하고 사실, 그럴필요조차 없다. 따라서 다음과 같이 정의하고 inout 표현으로 넘겨주면 된다.

// swift

var db: OpaquePointer?
var stmt: OpaquePointer?
let filename = " .... "

if sqlite3_open(filename, &db) == SQLITE_OK {
  ...
}

쿼리 컴파일 하기

데이터 베이스를 여는데 성공했다면, 다음 할일은 쿼리를 컴파일하는 것이다. 쿼리 컴파일은 sqlite3_prepare_v2()라는 함수를 사용한다. 이 함수를 사용하면 쿼리 문자열을 조합하는 대신, ? 으로 대체한 후 sqlite3_bind~() 함수를 사용해서 안전하게 쿼리를 합성할 수 있다. 이 함수의 원형은 다음과 같이 정의되어 있다.

int sqlite3_prepare_v2(
  sqlite3 *db,
  const char *zSql,
  int nByte,
  sqlite3_stmt **ppStmt,
  const char **pzTail
);


/*
sqlite3 *db : 데이터베이스 핸들. sqlite3_open()을 통해 획득한 것을 쓴다.
zSql : 쿼리 문자열
nByte : 쿼리 문자열의 길이. -1을 넣으면 자동으로 계산한다.
ppStmt : 컴파일된 쿼리, 즉 스테이트먼트에 대한 포인터.
pzTail : 사용하지 않는 파라미터.
*/

여기서 또 새로운 타입이 등장한다. sqlite3_stmt * 라는 것으로 쿼리를 처리하는 일종의 커서의 개념으로 이해하면 된다. 하지만 역시나 알 수 없는 타입이므로 OpaquePointer로 취급된다. Swift로 반입된 함수는 다음의 시그니처를 갖는다.

func sqlite3_prepare_v2(_ db:OpaquePointer!,
                        _ zSql: UnsafePointer<UInt8>,
                        _ nByte: Int32,
                        _ ppStmt: UnsafeMutablePointer<OpaquePointer!>
                        - pzTail: UnsafeMutablePointer<UnsafePointer<UInt8>!>? )
-> Int32

시그니처 자체는 엄청 복잡해보이는데, 사용하는 코드는 어렵지 않다. 문자열은 그래도 전달하면 되고, stmt는 inout 표현으로 넘겨준다. 사용하지 않는 파라미터는 nil로 전달하면 된다. 위 코드의 if 문 이하에 다음 코드가 들어간다.

// ---- if sqlite3_open(dbpath, &db) == SQLITE_OK {

let query = "CREATE TABLE IF NOT EXISTS test (num INTEGER)"
if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE3_OK {
    // 쿼리 바인딩 및 실행 코드
}
// ---- }

쿼리 바인드와 실행

쿼리 컴파일에 성공했다면, 선택적으로 쿼리 바인딩을 수행하거나 컴파일된 쿼리를 실행할 수 있다. 쿼리 바인딩은 파라미터를 쿼리 문자열과 미리 결합하지 않고 컴파일한 후에 삽입하는 것인데, 이를 통해 SQL주입과 같은 공격을 방지할 수 있다. 쿼리 바인딩에는 sqlite3_bind_타입() 함수가 쓰인다. 바인드 함수들은 공통적으로 세 개의 인자를 받는다. 이 때 주의할 것은 치환자의 인덱스가 0이 아닌 1부터 시작한다는 점이다.

  • stmt : 컴파일된 쿼리에 대한 포인터
  • index : 치환자의 순서. 1부터 시작한다.
  • value : 치환할 값

C에서는 함수에 문자열을 전달할 때, 보통 그 길이를 같이 받는 경향이 있어서 다음 인자가 추가된다.

  • nByte : 문자열의 길이. -1을 쓰면 자동 계산한다.
  • distructor : void (*)(void*) 타입의 함수 포인터로, 바인딩하고나서 해당 문자열을 해제하는데 쓰이는 함수. Swift 에서는 실제 문자열의 포인터가 전달되는 것이 아니라, 임시로 변환된 포인터가 생성되어 전달되기 때문에 그냥 nil을 쓰면 된다.

대략 다음과 같은 식으로 Swift 에서 바인딩처리를 할 수 있다.

var stmt: OpaquePointer? = nil
let query = "INSERT INTO test (num, name) VALUES (?, ?)"
if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK {
   sqlite3_bind_int(stmt, 1, 10)
   sqlite3_bind_text(stmt, 2, "hello", -1, nil)
...
}

쿼리를 실행하는 함수는 sqlite3_step() 이다. 이 함수의 리턴값은 2개로 나뉜다.

  • SQLITE_ROW : SELECT 쿼리에 대해 결과 row가 남아있다는 의미. SELECT 쿼리를 실행했을 때, 이 값을 얻는다. 이 값이 리턴되면 sqlite3_column_* 함수들을 사용해서 현재 row의 각 칼럼 값들을 추출할 수 있다.
  • SQLITE_DONE : 쿼리의 실행이 완료되었음을 의미한다. INSERT, UPDATE, DELETE의 결과로 받게되며, SELECT 의 경우 더 이상 남아있는 row가 없음을 나타낸다.

SELECT 쿼리를 사용하여 값을 가져올 때에는 sqlite3_step()의 결과가 SQLITE_ROW 인 경우, sqlite3_column_타입() 함수들을 사용할 수 있다. 이 함수들은 모두 stmt 객체 포인터와 칼럼 번호를 나타내는 정수값을 인자로 받는다. 이 때 칼럼번호는 0부터 시작한다.

DB에 저장된 문자열을 가져오는 경우, sqlite3_column_text()의 리턴타입은 const char* 이고 이는 함수에 파라미터를 전달할 때와 달리 String으로 변환되어 Swift로 돌아오지 않는다. UnsafePointer<UInt8>? 타입의 포인터를 받게 되며, 이는 String.init?(utf8String:) 을 사용해서 문자열로 변환해야 한다.

정리 작업

데이터베이스를 열고, 쿼리를 컴파일하여 stmt 객체를 생성하였으므로, 모든 작업이 완료되었으면 이 리소스들을 정리해야 한다. 여기서 두 가지 함수가 사용된다.

  • sqlite3_finalize() : stmt 객체를 해제한다.
  • sqlite3_close() : 데이터베이스 파일을 닫고 db 객체를 해제한다.

이들 작업은 놓치는 경우가 많으므로, DB를 액세스하는 함수의 초입 부분에 defer { ... } 블럭에 사용한다. 참고로 nil 을 넘겨주어도 별 탈이 생기지 않는 함수들이라고 한다.

전체 예시

다음은 위에서 소개한 코드를 조립하여 만들어본 코드이다.

import Foundation
import SQLite3

let filename: String = "..."
var pDb: OpaquePointer?
var stmt: OpaquePointer?

let query = "INSERT INTO test (name, number) VALUES (?, ?);"
let vName = "ORANGE"
let vNumber: Int32 = 34

main: do {
    defer {
        sqlite3_finalize(stmt)
        sqlite3_close(pDb)
    }

    guard sqlite3_open(filename, &pDb) == SQLITE_OK,
        sqlite3_prepare_v2(pDB, query, -1, &stmt, nil) == SQLITE_OK
    else {
        print("Fail to open database")
        break main
    }

    // bind
    // 각 바인드함수의 결과가 SQLITE_OK 인지 체크하는것이 정석임
    guard sqlite3_bind_text(stmt, 1, vName, -1, nil) == SQLTIE_OK,
        sqlite3_bind_int(stmt, 2, vNumber) == SQLITE_OK 
    else {
        print("Fail to binding query")
        break main
    }

    guard sqlite3_step(stmt) == SQLITE3 else {
        print("Fail to insert data")
        break main
    }
}

참고자료