Wireframe

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부터 시작한다는 점이다.

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

대략 다음과 같은 식으로 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개로 나뉜다.

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

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

정리 작업

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

이들 작업은 놓치는 경우가 많으므로, 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
    }
}

참고자료

Exit mobile version