Swift에서 SQLite3를 사용하는 방법 (Objective-C + Bridging)

sqlite3를 Objective-C에서 사용하는 것은 Objective-C가 C이기도 하므로, C/C++ API를 그대로 써서 사용하면 됐다. 여기서 필요한 것은 크게 7종류의 함수이다. (각 함수에 대해서는 이전 글들이나 레퍼런스 문서를 참고하자)

  • sqlite3_open()
  • sqlite3_prepare() / sqlite3_prepare_v2()
  • sqlite3_step()
  • sqlite3_column_* ()
  • sqlite3_bind_* ()
  • sqlite3_finalize()
  • sqlite3_close()

Swift에서 sqlite3를 사용하는 것에 대해 고민해보자. Objective-C는 C의 수퍼셋이었으므로 C API를 사용하는데 아무런 제약이 (그저 C문법만 알고 있다면) 없었다. 하지만 Swift는 C와는 다른 환경을 제공하고 있어서 이를 그대로 쓰는 게 어렵다.1 그래서 몇가지 방법을 강구해보도록 한다.

  1. Objective-C에서 Model-Controller에 해당하는 클래스를 작성하고 (이 클래스가 DB 액세스를 담당) 이 클래스를 Swift 프로젝트로 반입한다.
  2. Swift에서 sqlite3.h 파일을 통해 라이브러리 전체를 반입하여 Swifty하게 작성해본다. ➡😢 안타깝게도 현 시점에서는 어렵다.

Swift2에서부터 C API와 연동하기 위한 여러 새로운 타입들과 트랜드코딩/브릿징 API들이 추가되면서 브릿징헤더를 통해서 C 라이브러리를 가져와서 사용할 수 있게 되었다. 이와 관련한 내용을 별도의 포스팅으로 정리하였으니 참고하자.

C 라이브러리 연동에는 Objective-C를 사용하자

(사실 이 글을 발행한 이후 시점에는 상황이 나아져서, C API를 Swift 내로 갖고 들어올 수 있는 기능이 생기긴 했지만) Objective-C 문법을 알고 있다면 C API를 다시 Objective-C로 한 번 감싸는 래퍼를 작성하는 것만으로 Swift에서 활용할 수 있게 하는 모든 준비는 사실 끝난다고 보면 된다. Swift가 처음 개발되는 시점에서도 기존 코코아/코코아터치 프레임워크는 여전히 Objective-C로 쓰여져 있고, Objective-C 런타임 기능들을 사용하기 위해서라도 Objective-C로 쓰여진 모듈을 Swift에서 추가비용없이 그대로 사용할 수 있게 하는 것은 Swift의 목숨이 달린 매우 중요한 문제였다.

그래서 Objetive-C에서 SQLite3의 입출력을 담당할 수 있는 인터페이스 컨테이너를 만들어보자. 이 클래스는 SQLite3 데이터베이스 파일내의 특정한 테이블을 액세스한다. 가정은 이렇다.

  • 데이터베이스 테이블은 아주 단순하게 id(INTEGER), text(TEXT)의 두 개 칼럼을 가진다.
  • 앱 내에 디폴트데이터를 담은 샘플 데이터베이스 파일을 포함하고 있어서 기본적으로는 이 파일의 사본을 만들어서 사용한다. (실제 많은 앱들이 사용하는 방법이기도 하다)
  • 최근 추가된 레코드 전체를 보여주거나, 특정 키워드로 필터링한 키워드를 보여주는 기능이 요구된다.

먼저 이 클래스를 외부에서 봤을 때 필요한 기능은 다음과 같다.

  • 디폴트 메모 목록을 가져온다.🎛  - (NSArray *)getDefaultList;

그 외에 이 클래스는 내부적으로 다음에 관한 정보도 추가적으로 다뤄야 한다.

  1. 실제로 액세스할 파일 시스템 상의 데이터베이스 파일 경로 (dbPath)
  2. 번들 내의 샘플 데이터 파일의 이름 ( “data.sqlite”)
  3. isPrepared : 1의 경로에 파일이 준비되어 있는지 여부를 나타내는 프로퍼티. 액세스할 때마다 확인한다.

대략 이정도가 되겠다.

헤더 정의 ; DBController.h

#import <Foundation/Foundation.h>

@interface DBConnector : NSObject
- (NSArray *)getDefaultList;
@end

헤더 자체는 매우 간단하다.

Private 프로퍼티

private한 프로퍼티는 구현부 파일에서 내부의 익명 인터페이스를 통해서 정의한다.

#import "DBConnector.h"
#import <sqlite3.h>

@interface DBConnector ()
@property (readonly, nonatomic) NSString *DBPath;
@property (readonly, nonatomic) BOOL isPrepared;
@end

또한, sqlite3 라이브러리를 사용하는 부분도 이 부분이므로 헤당 라이브러리의 헤더를 반입해둔다.

DB파일 경로 정하기

dbPath는 데이터베이스 파일의 경로를 의미하는데, 번들 내 경로가 아닌 앱의 사용자 문서 디렉토리 내의 경로를 구한다. getter 접근자를 다음과 같이 느긋하게 초기화되도록 하는 기능을 포함하기 때문에 @synthesize를쓰지 않고 직접 구현한다.

DB파일의 경로는 보통 iOS 앱들이 하는 것처럼, 사용자 문서 폴더를 이용한다.

@implementation DBConnector

- (NSString *)DBPath {
    if (!_DBPath) {
        NSFileManager *fileManager = [NSFileManager defaultManager];
        NSURL docDir = [[fileManager URLsForDirectory:NSDocumentDirectory inDomainMask:NSUserDomainMask] 
                            lastObject];
        _DBPath = [[docDir URLByAppendingPathComponent:@"data.sqlite"] string]
    }
    return _DBPath;
}

DB 파일 검사하기

이번에는 DB 액세스를 하기 이전에 DB 파일이 준비되었는지 검사하는 과정이다. 이는 읽기 전용의 isPrepared 프로퍼티를 통해서 확인된다. 이 프로퍼티는 액세스할 때마다 dbPath에 파일이 잘 있나 검사하고, 그렇지 않으면 번들 내에서 샘플 데이터 베이스 파일을 여기로 복사해준다.

앱 설치후 최초 실행시에는 해당 DB 파일이 없기 때문에 이 파일을 번들로부터 복사하는 작업이 이곳에서 이루어질 것이다.(물론 isPrepared는 앱 실행 시 한 번만 호출하도록 하면 된다.)

- (BOOL)isPrepared
{
    NSFileManager *fileManager = [NSFileManager defaultManager];
    if ( ![fileManager fileExistsAtPath:self.DBPath]) {
        NSURL *predefinedDB = [[[NSBundle MainBundle] URLForResource:@"data" withExtension:@".sqlite"] path];
        NSError *error = nil;
        [fileManager copyItemAtPath:predefinedDB toPath:self.DBPath error:&error];
        if (error) {
            return NO;
        }
    } 
    return YES;
}

데이터 조회하기

다음은 실제로 데이터를 가져오는 액세스 부분이다. 사실 이 함수는 C함수를 호출하는 C함수나 마찬가지다.

- (NSArray *)getDefaultList
{
    NSMutableArray *list = [NSMutableArray array];
    if (self.isPrepared) {
        sqlite3 *db;
        const char* dbfile = [self.DBPath UTF8String];
        if (sqlite3_open(dbfile, &db) == SQLITE_OK) {
            sqlite3_stmt *stmt;
            const char* query_str = [@"SELECT text FROM tbl_memo ORDER BY id ASC LIMIT 50" UTF8String];
            if (sqlite3_prepare(db, query_str, -1, &stmt, NULL) == SQLITE_OK) {
                while (sqlite3_step(stmt) == SQLITE_ROW) {
                    NSString *col = [NSString stringWithUTF8String:sqlite3_column_text(stmt, 0)];
                    [list addObject:col];
                }
                sqlite3_finalize(stmt)
            }
            sqlite3_close(db)
        }
        return list;
    }
}

Swift에서 Objective-C 클래스를 사용하기

Swift 로 작성하는 프로젝트에서는 Objective-C 클래스를 그대로 사용할 수 있다. 이 때 필요한 것이 브릿징-헤더이다. 이는 시스템적으로 자동으로 생성하는 것은 아니고 빌드 세팅에서 이 브릿징 헤더 (Objective-C Bridging Header files) 항목에 해당 파일의 경로를(모듈/파일이름 식의 경로도 된다) 기술해둔다. 그리고 이 파일 내에서 Swift 상에서 참조해야 할 클래스나 라이브러리의 헤더 파일을 모두 반입하면 된다.

프로젝트가 Swift로 만들어지고, 위의 Objective-C 클래스 및 데이터 파일(data.sqlite)을 프로젝트로 복사했다고 가정하고 진행하겠다.

Bridging-Header 만들기

이는 간단하다. 만약 없다면 (만들지 않았다면 없다) File > New File > Source > header file을 선택하여 확장자가 .h 인 파일을 만든다. 이름을 적당히 Bridging-Header.h로 두자. 이 파일은 Swift 컴파일러가 읽어가게될 Objective-C 쪽의 헤더인 셈이다.

이 파일의 내용에 다음과 같이..

#import "DBController.h"

한줄만 넣어주면 된다.

그리고 프로젝트의 빌드 세팅에서 브릿징 헤더 키를 찾아서 해당 파일의 경로를 넣어준다. 프로젝트명이 TestMemo 이면 TestMemo/Bridging-Header.h를 입력해주자.

만약 정상적으로 처리되었다면 ViewController.swift 파일에서 다음과 같은 식으로 처리가 가능하다.

func resetWordList() -> Void {
    self.wordList = []
    let dbcon = DBController()
    let obj_list = dbcon.getDefaultList()
    for i in obj_list {
        self.wordList.append(i as String)
    }
    self.tableView.reloadData()
}

그냥 Swift의 클래스인 것처럼 그대로 쓰면 된다. 다만, 리턴타입이 NSArray *인데 이것은 Swift에서는 [AnyObject]로 변환되므로 이를 다시 Swift 타입으로 만들어주는 작업이 필요하다.2


  1. 이 글을 쓴 시점은 2014년말이며, 이 당시에는 Swift의 C/ObjC 브릿징이 그다지 원활하지 못했으며, C API를 Swfit API로 번역하는 기능도 미미한 수준이었다. (C 구조체 포인터 등을 다룰 수 있는 타입들이 정의되지 못했다.) 
  2. 이제는 Objective-C에서도 Generic이 도입되었기 때문에, 반드시 AnyObject를 상정할 필요도 없다.