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

SQLite3는 경량 데이터베이스로 흔히 앱에서 데이터를 저장하는 용도로 많이 사용될 수 있다. 실제로 코어데이터를 사용할 때 스토리지로도 많이 사용된다. 만약 코어데이터를 사용하지 않고 직접 SQLite3를 제어하려면 어떻게해야할까? Xcode를 설치하면 SDK에 SQLite3가 포함되고, 실제로 Objective-C를 사용하면서도 간단히 이를 사용할 수 있다. SQLite3는 원래 C로 만들어졌고, Objective-C 역시 C와 동일하므로 Objective-C를 사용하여 코드를 작성할 때에는 SQLite3의 C API를 그대로 사용하면 되었다.

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

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

데이터베이스 연결하기

SQLite3를 사용하는 절차의 처음은 데이터베이스를 연결하는 것이다. SQLite3 데이터베이스는 기본적으로 단일 파일 1개를 데이터베이스 1개로 사용한다. 데이터 베이스 파일을 열고 데이터베이스 연결을 만들기 위해서는 sqlite3_open() 함수를 사용한다. 이 함수의 타입은 아래와 같이 정의되어 있다.

// sqlite3는 데이터베이스 핸들에 대한 포인터
typedef struct sqlite3 sqlite3;
int sqlite3_open(const char *filename, sqlite3 ** ppDb);
  • filename : 파일의 경로 문자열
  • ppDb : 데이터베이스 핸들

이 함수를 호출하기 위해서는 두 가지 인자가 필요하다. 먼저 filename은 C문자열로 파일의 위치를 나타내는 값이다. 두 번째로 전달되는 sqlite3 라는 타입은 일종의 디스크립터로 데이터베이스 핸들에 대한 정보를 담고 있다. 보통 C 함수들은 어떤 객체를 생성해서 리턴하는 방식이 아니라 이렇게 포인터를 받아서 그 내용을 변경하는 식으로 처리한다. 그리고 리턴 타입은 정수인데, 함수의 처리 결과가 성공인지 실패인지를 나타내는 식으로 디자인되어 있다.

이 함수를 C에서 사용한다면 다음과 같이 쓸 수 있다. 이중 포인터를 넘긴다는 의미는 sqlite3 * 타입의 포인터를 선언하고, 그 주소를 함수로 전달하여 사용한다는 의미이다.

sqlite3 * db_handle;
if( sqlite3_open("mydata.db", &db_handle) == SQLITE_OK ) {
  // 참고로 SQLITE_OK는 0으로 성공했을 때 함수들의 리턴코드이다.
  // 데이터 베이스 열기에 성공했을 때의 처리...
}

자 그러면 이 함수는 Swift로 어떤식으로 변환되어 반입될까?

먼저 sqlite3 * 라는 포인터 타입을 보면, sqlite3 는 원래 구조체인데, sqlite3.h 파일을 뒤져보아도 그 내용은 정의되지 않고 이름만 선언되어 있다. 이 말은 sqlite3 라는 구조체는 그 내부 구조를 알 수 없으며, sqlite3 * 타입은 “내부구조를 알 수 없는 불투명한 타입에 대한 포인터”이다. Swift에서 이런 구조체 포인터 타입들은 OpaquePointer 타입으로 변환되어 반입된다. 그리고 데이터베이스 파일 경로는 문자열인데, 이는 C 문자열이다. C에서 문자열은 char타입의 배열이고, char는 부호없는 1바이트 정수이므로 UInt8이나 Int8 타입으로 볼 수 있다.  따라서 C 문자열은 UnsafePointer<UInt8>! 쯤 될 것이다. 다음과 같이 대응된다.

  • const char * – UnsafePointer<UInt8>
  • sqlite3 * – UnsafeMutablePointer<OpaquePointer?>!
  • int – Int32

따라서, sqlite3_open() 함수는 swift에서는 다음과 같이 정의된 함수로 표현된다.

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

뭔가 인자에 엄청난 타입이 들어가버린 거 같은 기분이다. 그렇다면 이 함수를 어떻게 사용하지? 사실 생긴 것만 이렇지 원래 함수에서 파라미터의 의미를 생각하면 그리 어렵지 않다.

  1. UnsafePointer<T>, UnsafeMutablePointer<T> 타입의 인자를 위해서는 해당 T 타입에 대한 UnsafePointer를 가지고 있다면 그 포인터 값을 그대로 넘기면 된다.
  2. 그런데 물론 1과 같은 상황은 별로 없을 것이다. 따라서 보통 T 타입에 대해서 inout 표현으로 인자로 넘길 수 있다.
  3. 혹은 그 인자가 T타입의 배열을 받기 위한 포인터라면 [T] 타입의 배열 값을 그대로 전달할 수 있다. 이는 C에서의 동작과 유사하게 배열의 첫 주소값이 함수로 전달되게 된다. 단, Swift의 문자열은 C 문자열과 구조가 다르다. 인자로 전달되는 시점에 임시적인 포인터데이터가 생성되어서 전달된다고 보면 된다.
  4. T 타입이 Int8, UInt8인 경우에는 문자열을 그대로 넘기면, 해당 문자열의 UTF8 바이트 배열의 첫 주소가 넘어간다.

따라서 파일의 경로는 문자열을 그대로 넘겨주고, sqlite3 ** 타입은 OpaquePointer 타입 변수를 선언해서 &을 붙여서 넘겨준다. 참고로 sqlite3 * 포인터는 최초 값은 nil(-> NULL)일 것이기 때문에 옵셔널 타입으로 선언한다. (옵셔널이 아닌 경우 inout으로 넘겼을 때 immutable 하다는 에러가 나는 것을 보면, 반드시 옵셔널 타입이어야 하는 듯하다.)

let filepath: String = "data.db"
var db: OpaquePointer? = nil

if sqlite3_open(filepath, &db) == SQLITE_OK {
  // DB를 연 이후의 처리
}

쿼리 컴파일 하기

데이터 베이스를 여는데 성공했다면, 다음은 쿼리를 컴파일하는 것이다. 쿼리 컴파일은 sqlite3_prepare_v2()라는 함수를 사용한다. 이 함수는 다음과 같이 정의되어 있다.

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 * 라는 것으로 쿼리를 처리하는 일종의 커서의 개념으로 이해하면 된다. 타입은 sqlite3 *와 다르겠지만, 여전히 불투명 구조체 포인터이므로 UnsafeMutablePointer<OpaquePointer!> 타입이 될 것이다. 따라서 어렵지 않게 다음의 Swift 함수로 변환될 것임을 알 수 있다.

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

다음과 같이 쿼리를 컴파일 할 수 있다. 참고로 아래의 코드는 위의 open 코드의 if 블럭 안에 들어간다고 봐야 한다.

if sqlite3_open(dbpath, &db) == SQLITE_OK {
  let query = "CREATE TABLE IF NOT EXISTS test (num INTEGER)"
  var stmt: OpaquePointer? = nil
  if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE3_OK {
    // 쿼리 컴파일이 성공함
   }
}

쿼리 바인드와 실행

쿼리 컴파일에 성공했다면, 선택적으로 쿼리 바인딩을 수행하거나 컴파일된 쿼리를 실행할 수 있다. 쿼리 바인딩은 파라미터를 쿼리 문자열과 미리 결합하지 않고 컴파일한 후에 삽입하는 것인데, 이를 통해 SQL주입과 같은 공격을 방지할 수 있다. 쿼리 바인딩하는 방법은 다음과 같다.

  1. 쿼리 문자열에서 치환될 부분을 ? 로 표기한다.
  2. 쿼리 문자열을 컴파일한다. (sqlite3_prepare_v2())
  3. 각 ? 에 대해서 값을 바인딩한다. 이 때 사용하는 함수들은 sqlite3_bind_* 라는 이름이 붙은 함수들이다.

기본적인 바인드 함수들은 다음 인자를 받는다.

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

텍스트를 바인드 하는 경우는 몇 가지 인자가 더 붙는다.

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

대략 다음과 같은 식으로 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가 남아있다는 의미. 이 값이 리턴되면 sqlite3_column_* 함수들을 사용해서 현재 row의 각 칼럼 값들을 추출할 수 있다.
  • SQLITE_DONE : 쿼리의 실행이 완료되었음을 의미한다. SELECT 의 경우 더 이상 남아있는 row가 없음을 나타낸다.

값을 추출하는 함수들은 sqlite3_column_int(), sqlite3_column_double(), sqlite3_column_text() 등을 사용하며, 사용 규격은 모두 동일하다.

  • stmt : 쿼리 진행에 사용중인 statment 객체
  • iCol : 칼럼의 열 번호. 0부터 시작한다.

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

정리 작업

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

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

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

참고자료