[새로 작성된] iOS에서 SQLite3 사용방법

예전에 쓴 글이 있기는 하지만, 그냥 요리법처럼 쓴 글이기도 하거니와 소스코드에서 뭔가 글자가 빠지는 등(syntax highlighter를 안써야 겠지만 기존 글 고치기가 귀찮아…) 문제가 많아 내용을 보충해서 다시 작성.

애플은 SQLite3를 직접 인터페이스하는 것보다는 코어데이터를 사용하라고 권장하고 있고, (실제로 있다가 빠진 것인지는 알 수 없으나 그런 주장을 하는 사람들이 종종 있다) 애플 개발자 문서에서도 관련 내용을 내렸다고 한다. (하지만 이는 사실이 아닐 거라 생각한다. 왜냐면 iOS에서 SQLite3를 인터페이스 하는 부분은 전적으로 libsqlite3를 사용하는 것이고, 이에 대한 문서는 SQLite3 홈페이지에 가면 있기 때문이다)

SQLite3 사용하기

SQLite3를 사용하기 위해서는 libsqlit3 라이브러리를 프로젝트에 포함시켜줘야 한다. 아마 애플이 따로 변경하지 않았다면 libsqlite3를 추가해주면 된다. (그냥 sqlite3로 라이브러리 이름이 잡힐 것임)

SQLite DB는 데이터의 영구저장소가 되는 부분이므로, 이 DB 객체에게 질의를 보내서 자료를 불러오거나 저장하는 객체를 하나 만들어 보는 형태로 이전 버전의 글과 같은 개념의 코드를 하나 작성해 보고자 한다. 그러나 그 전에, 단순히 코드만 수정할 거면 글을 수정하고 말았을테지만 (아마 syntax highlighter의 문제인지 몇 몇 글자가 안나오는 문제도 있고) 해가 두 번이나 넘어갔으니, 그냥 간단하게 sqlite3 라이브러리에 대한 설명을 좀 하고 넘어가자. 그래야 코드가 이해될 것 같다.

SQLite3 라이브러리

iOS에서 SQLite3를 쓸 수 있는 건 단순히 해당 라이브러리가 시스템이 포함되어 있고, 이를 가져다가 쓰면 되는 것이기 때문이고, 해당 라이브러라는 애플이 제공하는 것도 아니다. Objective-C는 C언어 위에 몇 가지 기능을 추가한 언어이므로, 기존의 표준 C로 작성한 코드가 얼마든지 돌아갈 수 있고, 이 말은 C용 라이브러리들도 얼마든지 쓸 수 있다는 것이다. 맞다. libsqlite3는 SQLite3의 C 언어 API 라이브러리이다.

sqlite3에는 많은 함수들이 있지만, 그 중에 중요한 핵심은 2개의 객체와 6개의 함수이다. 여기에 대해 살짝 설명하고 넘어가자.

핵심 객체

sqlite3의 핵심객체는 sqlite3sqlite3_stmt 두 가지가 있다. sqlite3는 데이터베이스 커넥션 정보를 갖고 있는 객체이고, sqlite3_stmt는 데이터베이스에 보낼 질의를 컴파일한 객체라 보면 된다. sqlite3 객체는 sqlite3_open() 함수를 호출하여 생성하며, sqlite3_close() 함수를 통해 연결을 닫으면서 해제된다. 그리고 sqlite3_stmt 객체는 sqlite3_prepare() 함수를 통해 질의를 컴파일하면서 생성되고, sqlite3_finalize() 함수를 통해 해제된다.

핵심 함수 6종

앞서 두 핵심 객체를 설명하면서 벌써 4개의 함수를 소개했다. 전체 함수는 다음과 같다.

  • sqlite3_open : 연결 생성
  • sqlite3_prepare : 쿼리 컴파일
  • sqlite3_step : 쿼리 실행, 각 row를 fetch 함
  • sqlite3_column : fetch 한 row에 대해 각 칼럼의 데이터를 리턴한다.
  • sqlite3_finalize : 질의를 완료한다. 데이터베이스가 커밋되고 prepared_statmenet(sqlite3_stmt)객체가 해제된다.
  • sqlite3_close : 연결을 종료한다. 데이터베이스의 lock이 해제되고, 연결 객체 또한 파괴된다.

그런데 sqlite3_column 함수는 몇 가지 함수 세트를 대표하는 이름일 뿐이다. 특정 순서의 칼럼을 가져오기 위해서는 해당하는 데이터타입으로 받아와야 한다. 따라서 sqlite3_column_int() 이런식의 함수를 사용해야 한다.

실제로 sqlite3를 사용할 때는 위의 함수들을 순서대로 호출해서 사이클을 끝내면 된다.

또한 사용하려는 함수에서는 V2, V16 이런 접미사가 붙는 함수가 종종 있는데, V16은 UTF-16인코딩을 사용한다는 의미이다. V2는 주로 sqlite3_prepare_v2()로 많이 쓰는데, 이 버전으로 생성한 stmt 객체에는 추후에 쿼리에 바인딩을 붙일 수 있다. 이 때 sqlite3_bind 를 사용한다. (바인딩시에는 인덱스가 1부터 시작한다)

데이터 베이스 인터페이스 클래스

이전 글에서도 만들었던 DBInterface 클래스를 새로 작성할 것이다. 약간의 변경 사항이 있다면

  1. 초기화 함수를 새로 만든다. 초기화 함수는 파일 이름을 받고 (확장자는 “.sqlite”로 한다.) 파일의 존재 여부를 체크하고, 없으면 번들에 포함된 미리 준비된 파일을 복사하는 것으로 한다. (여느 책들에서 흔히 쓰는 방법이다.)
  2. 테이블은 이름이 test이고 id(integer, primary key), name(text), description(text)의 세 칼럼으로 구성되어 있다고 가정한다.
  3. NSDictionary와 관련하여서는 박스 리터럴을 사용한다. 코드가 그나마 좀 짧아진다.

요 정도가 되겠다.

먼저 인터페이스를 만들어보자.

#import <Foundation/Foundation.h>

@interface DBInterface : NSObject
-(id)initWithDataBaseFilename:(NSString*)databaseFilename;
-(NSArray *)searchWithKeyword:(NSString *)keyword;
-(void)updateRecordWithName:(NSString *)name description:(NSString*)description atID:(int)recordID;
@end

초기화 메소드가 들어간 것 외에는 별다른 차이가 없다.

내부 인터페이스

이번에는 .m 파일의 상단, 내부 인터페이스 부분이다.

#import "DBInterface.h"
#import <sqlite3/sqlite3.h>

@interface DBInterface ()
@property (copy, nonatomic) NSString *givenFilename;
@property (copy, nonatomic) NSString *dbPath;
@end

내부적으로 사용할 두 개의 프로퍼티를 추가로 선언했다. 단순히 초기화 메소드에서 받은 파일 이름을 사용할 용도로 프로퍼티를 하나 더 쓸 뿐이다.

초기화 메소드

이번에는 초기화 메소드. 초기화 메소드는 단순히 객체를 생성하고, 받은 파일이름을 프로퍼티에 저장만 해 놓는다.

@implementation DBInterface
@synthesize dbPath=_dbPath;

-(id)initWithDataBaseFilename:(NSString*)databaseFilename
{
    self = [super init];
    if(self){
        self.givenFilename = databaseFilename;
    }
    return self;
}

데이터베이스 프로퍼티 접근자

다음은 데이터 베이스 파일의 경로를 얻는 부분이다. 코드가 좀 바꼈다. NSFileManagerURLsForDirectory:inDomains: 를 쓰는 게 좀 더 유행(?)인 듯 하여 바꿨다.

-(NSString *)dbPath
{
    if(!_dbPath)
    {
        NSFileManager *fileman = [NSFileManager generalManager];
        NSURL *documentPathURL = [[fileman URLsForDirecory:NSDocumentDirectory
                                inDomain:NSUserDomainMask] lastObject];
        NSString *databaseFilename = [self.givenFilename stringByAppendingString:@".sqlite"];

        _dbPath = [[documentPathURL URLByAppendingPathComponent:databaseFilename] path];

        if(![fileman fileExistsAtPath:_dbPath])
        {
            NSString *dbSource = [[NSBundle mainBundle] pathForResource:@"source" ofType:@"sqlite"];
            [fileman copyItemAtPath:dbSource toPath:_dbPath error:nil];
        }
    }
    return _dbPath;
}

이런 식으로 프로퍼티 접근자를 만들어 놓으면, 인터페이스 객체를 생성하더라도 실제로 파일 이름이 필요해지는 시점이 되기 전까지는 실제 데이터 베이스 파일이 있는지 확인조차 안하게 되어 가능한 리소스를 아낄 수 있다.

SELECT 하기

다음은 검색 메소드. 바인딩하기 귀찮아서 그냥 NSString 상태에서 합침.

-(NSArray *)searchWithKeyword:(NSString *)keyword
{
    NSMutableArray *result = [NSMutableArray array];
    sqlite3 *db;
    const char *dbfile = [self.dbPath UTF8String];

    //1
    if ( sqlite3_open(dbfile, &db) == SQLITE_OK) {
        const char *sql = [[NSString stringWithFormat:@"SELECT id, name, description FROM test WHERE keyword LIKE "%%%@%%";",keyword] UTF8String];

        sqlite3_stmt *stmt;
        // 2
        if( sqlite3_prepare(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
            while(sqlite3_step(stmt)==SQLITE_ROW) {
                NSNumber *index = @(sqlite3_column_int(stmt, 0));
                NSString *name = [NSString stringWithUTF8String:sqlite3_column_text(stmt, 1)];
                NSString *description = [NSString stringWithUTF8String:sqlite3_column_text(stmt, 2)];
                NSDictionary *anItem = @{@"index":index, @"name":name, @"description":description};
                [result addObject:anItem];
            }
            sqlite3_finalize(stmt);
        }
        sqlite3_close(db);
    }
    if([result count] == 0) return nil;
    return result;
}

sqlite3_open 함수는 에러없이 끝날 때 0을 리턴하는데, SQLITE_OK 상수가 0으로 정의돼 있다.

int sqlite3_open(const char* filename, sqlite3** );

그리고 prepare 함수는 다음과 같은 식으로 원형이 선언돼 있다.

int sqlite3_prepare(sqlite3* , const char* sql, int length, sqlite3_stmt**, const char**);

세 번째 -1은 SQL 구문의 길이인데, -1을 쓰면 첫번째 NUL종료문자까지의 길이를 사용한다. 이 함수 역시 문제 없이 끝날 때 0을 반환한다. 또한 sqlite3_step 함수는 fetch 된 결과가 있으면 SQLITE_ROW를, 실행 결과의 끝에 도달하면 SQLITE_DONE을 리턴한다. (각각 100, 101의 값)

UPDATE 메소드

끝으로 DB 항목을 업데이트하는 코드는 다음과 같다. sqlite3_prepare_v2()를 사용한 이유는 쿼리 문자열에 바인딩을 하기 위해서이다. 앞서 검색 메소드에서는 NSString 문자열을 포맷팅하면서 미리 합쳤기에 sqlite3_prepare()를 사용했다.

-(void)updateRecordWithName:(NSString *)name description:(NSString*)description atID:(int)recordID
{
    sqlite3 *db;
    if(sqlite3_open([self.dbPath UTF8String], &db) == SQLITE_OK) {
        const char *sql = "UPDATE test SET name=?, description=? WHERE id=?";
        sqlite3_stmt *stmt;
        if( sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
            sqlite3_bind_text(stmt, 1, [name UTF8String], -1, NULL);
            sqlite3_bind_text(stmt, 2, [description UTF8String], -1, NULL);
            sqlite3_bind_int(stmt, 3, recordID);
            if(sqlite3_step(stmt) != SQLITE_DONE) {
                /* Process Error */
            }
            sqlite3_finalize(stmt);
        }
        sqlite3_close(db);
    }
}

정리

iOS에서 SQLite3를 사용하는 것은 그냥 C 라이브러리를 사용해야해서 되려 낯설게 느껴질 수는 있다. SQLite는 데이터 셋이 그리 크지 않은 경우에는 이런 식으로 구현해서 쓰는 것이 좋을 수 있는데, 데이터의 양이 많아지면 코어데이터로의 이전을 고려해보아야 한다. 코어데이터는 약간의 오버헤드가 있기는 하지만, 보다 지능적으로 데이터를 fetching 하며, 특히 fetching 한 객체를 직접 액세스하기 전까지는 데이터를 가져오지 않기 때문에 메모리 관리나 성능 관리면에서 상당한 장점을 보인다. 특히 두 개 이상의 테이블을 JOIN 해야 하는 경우, 이는 관리객체의 relationship으로 매우 쉽게 사용할 수 있다. 어쨌든 코어데이터 쓰자는 그런 이야기.

아래는 이 글을 쓰면서 작성해 본 두 파일의 전체 코드이다.

https://gist.github.com/sooop/7019754