SQLite3 를 Swift 에서 브릿징 없이 사용하는 법

Swift에서 C/C++ API를 이용해서 Sqlite3 를 쓰는 법

Swift에서 SQLite3를 사용하는 방법 중에서 가장 간단한 것은 Objective-C로 인터페이스하는 클래스를 만들어서 이를 Swift로 브릿징하여 사용하는 방법이다. 하지만, 테이블 스키마나 구조에 따라서 매번 Objective-C 클래스를 수정 변경하는 것이 좀 귀찮을 수도 있고, Objective-C 에서도 Swift를 위한 타입 어노테이션이 붙게됨에 따라서 이런 문법이 복잡하고 괜히 어렵게 느껴지는 사람도 있을 수 있다. 그래서 이번에는 Swift내에서 Objective-C 클래스 없이, C API를 반입하여 직접 사용하는 방법에 대해서 알아보자.

수정: 외부에서 제글을 링크하면서 Swift3에서 작동하지 않는다는 글을 보고 수정합니다. Swift3의 큰 변화로는 COpaquePointerOpaquePointer로 이름이 변경되고 그외의 몇몇 파운데이션 클래스들이 Swift 타입으로 전환되거나, Swift 클래스로 재작성되면서 NS 접두어가 빠진 것들이 있습니다. NSURL, NSFileManager등이 그것이죠. 특히 NSFileManager.defaultManager()는 더 이상 팩토리 메소드가 아닌 싱글턴 객체를 내놓는 타입 프로퍼티로 .default가 됩니다. 또한 쓸데없는 내부프로퍼티들을 썼었는데 그것들은 사실 별 필요없는 부분이기도 해서 대폭 정리한 형태로 변경했습니다. 이제 이 글은 Swift3 기준입니다.

브릿징헤더 만들기

어쨌거나 저쨌거나 SQLite3은 C로 만들어졌고, 우리는 프로젝트에 라이브러리를 추가하고, 헤더를 반입해야 한다. Xcode의 프로젝트 세팅값 중에서 Build Phrases 중에서 Link Binary With Libarary를 선택한 다음, +를 클릭해서 sqlite3 라이브러리를 추가한다. 검색창에 sqlite3 이라고 치면 sqlite3.tdb라는 파일이 뜨는데 이것을 클릭한다.

Xcode 7에서 부터 라이브러리를 직접 끌고오지 않고 .tbd 파일을 끌어오는 것으로 보인다.

Xcode를 사용하는 경우에는 앱이름-Bridging-Header.h 파일을 만든다. 만약 명령줄에서 바로 컴파일 하려는 경우에는 이름을 뭘줘도 상관없다. 이 파일 안에서 다음과 같이 sqlite3.h 파일을 반입한다.

팁: 브릿징헤더를 가장 쉽게 만드는 방법은 Swift로 된 프로젝트에 그냥 아무 Objective-C 파일이나 만드는 것이다. 그러면 Xcode는 이를 감지하고 브릿징헤더를 자동으로 만들어준다.

// TEST-Bridging-Header.h
#import <sqlite3.h>

브릿징헤더를 수동으로 만든 경우, 컴파일러에게 이걸 같이 읽으라고 알려줘야 한다. 이 옵션은 Build Settings에서 bridge로 검색해서 해당 키에 파일 이름을 지정해주면 된다.

데이터베이스에 연결하기

데이터베이스 핸들러와 쿼리 핸들러

sqlite3의 C/C++ 인터페이스를 이용할 때는 두 개의 객체 타입을 사용한다. 하나는 데이터베이스 커넥션 정보를 담고 있는 sqlite3_t 타입, 다른 하나는 컴파일된 쿼리 정보를 담는 sqlite3_stmt_t 타입이다. 이 두 타입은 내부를 숨긴 구조체 포인터를 다시 void* 타입으로 캐스팅하여 실제적인 내부 구조를 알 수 없는 Opqaue Pointer이다. 이러한 타입을 숨긴 구조체 포인터는 Swift 내에서 OpaquePointer 타입1이 된다.
Swift의 이전버전에서는 이중포인터를 인자로받는 C 함수에 대해 옵셔널 타입의 변수를 사용했지만, 현재는 그냥 COpaquePointer 타입이 nil 값으로 (NULL) 초기화 가능하기 때문에 굳이 옵셔널 타입을 쓰지 않아도 된다.

이를 염두에 두고 데이터베이스와 인터페이스하는 클래스를 만들어보자.

import Foundation

enum SQLError: Error { /// ErrorType 은 Error 로 변경됐다.
    /// 그리고 `case` 들은 이제 소문자로 시작한다.
    case connectionError
    case queryError
    case otherError
}

class SQLInterface {
    lazy var db: COpaquePointer = { /* ... */ }()
    var stmt: OpaquePointer? = nil /// 여러 메소드에서 재활용할 예정
    //....
}

db 프로퍼티는 데이터 베이스 연결을 관리하는 핸들러이다. 느긋하게 초기화될 것이며, 사용자의 문서 디렉토리에 db.sqlite라는 이름으로 된 파일에 액세스할 것이다. 연결에 실패하는 경우에는 앱 실행을 중단하기로 한다.

따라서 위에서 선언한 db 프로퍼티는 다음과 같이 정의할 수 있다.

    lazy var db: OpaquePointer = {
        let _db: OpaquePointer? = nil
        let path = FileManager.default  /// NSFileManager는 FileManager로 재작성되었다. 
        /// `+defaultManager()`는 타입속성 `default`로 변경됐다.
            .urls(for:.documentDirectory, in: .userDomainMask)
            .last!.appendingPathComponent("db.sqlite").path
        if sqlite3_open(path, &_db) == SQLITE_OK {
            return _db
        }
        print("Fail to connect database...")
        abort();
    }()

간단히 정수를 저장하는 테이블을 만들어서 DB를 준비해보도록 하자. 쿼리를 실행하는 절차는 다음과 같다. 그리고 여기서 언급된 함수 이름들은 C/C++ API와 같은 이름을 사용하지만, 인자 타입들은 모두 Swift로 브릿징되어 변환된 타입을 사용하게 될 것이다.

  1. sqlite3_open()을 이용해서 연결을 생성한다.
  2. sqlite3_prepare()를 이용해서 쿼리를 컴파일 한다.
  3. sqlite3_step()을 이용해서 쿼리를 실행한다.
  4. sqlite3_finalize()를 이용해서 컴파일된 쿼리 객체(stmt)를 해제한다.
  5. sqlite3_close()를 이용해서 연결을 닫고, sqlite3_t 객체를 해제한다.

이 중 1, 5번에 해당하는 내용은 인터페이스 객체 인스턴스를 생성하고 제거할 때 자동으로 이루어지게 하자.

초기화

아래 코드는 클래스를 초기화하고, 해제하는 과정에 대한 내용이다. 객체가 생성되면 자동으로 데이터베이스 파일에 대한 연결을 준비한다. 그리고 객체가 다 사용되고 파괴되기 직전에 만들어진 연결을 해제한다.


func prepare_database() throws { defer { sqlite3_finalize(stmt) } let query = "CREATE TABLE IF NOT EXISTS test (num INTERGER)" if sqlite3_prepare(db, query, -1, &stmt, nil) == SQLITE_OK { if sqlite3_step(stmt) == SQLITE_DONE { return } } throw SQLError.QueryError } override init() { super.init() do { try prepare_database() } catch { print("Fail to initialize database.") abort() } } deinit { if let db = db { sqlite3_close(db) } }

여기까지 했으면 다음과 같이 인터페이스 객체를 생성하게 되면, 자동으로 데이터베이스 파일이 생성되고, 내부에 테이블이 만들어진 것을 확인할 수 있다.

let sql = SQLInteface()

테이블에 값 삽입하기

이번에는 위에서 만든 테이블에 값을 삽입해보도록 하자. 변환된 몇 가지 객체의 타입만 알고 있다면, 다른 동작은 C 라이브러리를 사용하는 것과 동일한 코드를 사용한다.


func insertValue(value: Int32) throws { guard db != nil else { throw SQLError.ConnectionError } defer { sqlite3_finalize(stmt) } let query = "INSERT INTO test (num) VALUES (?)" if sqlite3_prepare_v2(db, query, -1, stmt, nil) == SQLITE_OK { if sqlite3_bind_int(stmt, 1, value) == SQLITE_OK { if sqlite3_step(stmt) == SQLITE_DONE { return } } } throw SQLError.QueryError }

테이블로부터 값 얻기

테이블로부터 값을 얻으려 할 때는 sqlite3_step() 함수의 리턴값이 SQLITE_ROW임을 확인한다. 이 경우 stmt 객체에는 현재 포커스된 결과 row에 대한 정보가 담겨있으므로, sqlite3_colum_* 함수들을 이용해서 각 칼럼에 담겨있는 값을 확인할 수 있다.

아래 함수는 test 테이블에 들어간 숫자값 전체를 읽어서 정수 배열로 리턴한다. 참고로 이때 넘겨받는 정수값은 Swift의 Int 타입이 아니라 C의 32비트 정수이므로 그 타입은 Swift 내에서 Int32가 되니 주의.

    func getValues() throws -> [Int32] {
        defer { sqlite3_finalize(stmt) }

        var result = [Int32]()
        let query = "SELECT num FROM test"
        if sqlite3_prepare(db, query, -1, stmt, nil) == SQLITE_OK {
            while sqlite3_step(stmt) == SQLITE_ROW {
                let i = sqlite3_column_int(stmt, 1)
                result.append(i)
            }
            return result
        }
        throw SQLError.QueryError
    }

이제 테스트를 해보자.

do {
    let db = SQLInteface()
    for i in 1...100 {
        try db.insertValue(Int32(i))
    }
    let result = try db.getValues()
    print(result)
} catch let e {
    print("oooops", e)
}

보너스: 커맨드라인으로부터 컴파일 하기

이렇게 작성한 클래스는 다음과 같이 컴파일 할 수 있다. --import-objc-header를 통해서 브릿징 헤더를 반입하고, 필요한 라이브러리는 gcc와 마찬가지로 -l 스위치를 통해서 명시해줄 수 있다.

$ swiftc --import-objc-header TEST-Bridging-Header.h -lsqlite3 main.swift -o test_sqlite

전체 코드는 아래와 같다.


  1. Swift3에서 기존의 COpaquePointerOpaquePointer로 이름이 변경됐다. 그외에 기존에는 암시적으로 nil 값을 가질 수 있었던 부분이 이제는 명시적으로 옵셔널 타입으로 정의해야 nil을 대입할 수 있다. 참고로 옵셔널타입인 경우에도 CAPI로 넘겨질 때는 따로 언래핑하지 않아도 된다. (C-API에서는 NULL 포인터를 받는 것도 가능하다는 점을 생각하면 된다.)