Objective-C 래퍼를 통해 Swift에서 SQLite3를 사용하는 법

이 글에서는 Objective-C로 SQLite3 데이터베이스에 액세스하는 API를 래핑한 간단한 클래스를 작성해보겠다. 사실 Objective-C로 SQLite3를 사용하는 것은 C API를 그대로 사용하면 되는 부분인데, 이렇게 래퍼를 만들면 브릿징헤더만 작성해주면 래퍼 클래스를 Swift에서 그대로 사용할 수 있게 되기 때문에 좀 더 쉽게 사용할 수 있다.

Swift에서 C헤더를 바로 반입할 수 있기 때문에 이 방식은 오히려 번거로울 수 있다. (특히 스펙이 약간만 변경되어도 Objective-C 클래스를 수정해야 한다.) 선택은 각자가 알아서 하시면 되겠다.

기본적으로 SQLite3를 사용하는데 주로 사용되는 함수는 다음 7개이다. 자세한 사용법에 대해서는 SQLite3 공식문서를 참고하도록 한다. 대략의 코멘트만 덧붙였다.

  • sqlite3_open() – DB에 연결
  • sqlite3_prepare() / sqlite3_prepare_v2() – 쿼리 컴파일
  • sqlite3_bind_* () – 컴파일 된 쿼리에 변수 바인딩
  • sqlite3_step() – 쿼리를 실행한 결과 중 1개 Row를 가져옴
  • sqlite3_column_* () – 가져온 Row에서 칼럼값을 가져옴
  • sqlite3_finalize() – 컴파일된 쿼리 해제
  • sqlite3_close() – 연결닫기

만들고자 하는 래퍼는 간단한 테이블에서 저장된 레코드들로부터 텍스트 정보를 모두 반환한다. 데이터 베이스와 관련한 정보는 다음과 같다고 가정하겠다.

  • 데이터베이스 테이블은 아주 단순하게 id(INTEGER), text(TEXT)의 두 개 칼럼을 가진다.
  • 앱 내에 디폴트데이터를 담은 샘플 데이터베이스 파일을 포함하고 있어서 기본적으로는 이 파일의 사본을 만들어서 사용한다. (실제 많은 앱들이 사용하는 방법이기도 하다)

그리고 래퍼 컨테이너는 다음의 한가지 API만을 구현하겠다. 그외에 조건에 의한 검색이나, 데이터 추가/변경/삭제는 쿼리만 다를 뿐이고 여기서는 래퍼를 만들어서 Swift에서 사용하게끔 하는 부분만 참고하면 되겠다.

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

그외에 추가적인 프로퍼티가 있다. 데이터 베이스 파일의 경로와, 파일이 해당 경로에 없을 때 번들로부터 샘플 데이터베이스를 복사하도록 하는 것이다. 단 이 프로퍼티들은 모듈 외부에서 참고할 필요는 없으므로 내부 인터페이스로 작성한다.

  1. 실제로 액세스할 파일 시스템 상의 데이터베이스 파일 경로 (dbPath)
  2. isPrepared : 1의 경로에 파일이 준비되어 있는지 여부를 나타내는 프로퍼티.

헤더정의

이상의 내용을 바탕으로 먼저 헤더를 정의해보자. 별 것 없이 외부에 노출될 메소드 하나만 정의해주면 된다.

/// DBConnector.h

#import <Foundation/Foundation.h> 

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

Private 프로퍼티 정의

private한 프로퍼티는 .m파일에서 내부의 익명 인터페이스를 통해서 정의한다. 참고로 이 파일에서는 SQLite3의 인터페이스를 사용해야 하므로 sqlite.h 파일을 임포트해야 한다.

/// DBConnector.m

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

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

/// ... implementation ...

DB파일 경로

dbPath는 데이터베이스 파일의 경로를 의미하는데, 보통 앱에서 사용자가 내용을 수정할 수 있다면, 번들 내 경로가 아닌 앱의 사용자 문서 디렉토리에 생성해준다. 생성 부분은 isPrepared 에서 체크하면 되니까, 여기서는 경로만 만들어주겠다.

@implementation DBConnector 

- (NSString *)DBPath 
{ 
  if (!_DBPath) { 
    // 초기화된적이 없다면, 경로를 초기화한다.
    // 파일매니저를 통해서 사용자문서 디렉토리 URL을 얻고
    // 여기에 파일명을 덧붙여 문자열로 변환한다.
    NSFileManager *fileManager = [NSFileManager defaultManager]; 
    NSURL docDir = [[fileManager URLsForDirectory:NSDocumentDirectory     
                                     inDomainMask:NSUserDomainMask] 
                      lastObject];
    _DBPath = [[docDir URLByAppendingPathComponent:@"data.sqlite"] string]; 
  } 
  return _DBPath; 
}

DB 파일 검사하기

DB를 실제로 액세스하기 전에 isPrepared 프로퍼티를 체크할 것이다. 이 프로퍼티는 데이터베이스 파일이 해당 경로에 있는지를 검사한다. 체크과정에서 파일이 존재하지 않으면 번들로부터 복사하여 설치한다. 설치에 실패하면 NO를 리턴한다.

- (BOOL)isPrepared 
{ 
  // self.DBPath에 파일이 있는지 검사하고
  // 없는 경우 번들 내의 샘플 파일을 해당 경로로 복사
  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함수 콜이고 얻어온 데이터를 NSMutableArray에 쌓아서 리턴한다.

- (NSArray *)getDefaultList 
{ 
  // DB에 저장된 전체 텍스트를 가져온다.
  // 결과를 담을 배열
  NSMutableArray *list = [NSMutableArray array]; 

  if (self.isPrepared) { 
    sqlite3 *db; 
    const char* dbfile = [self.DBPath UTF8String]; 

    // DB에 연결
    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]; 
        } 
        // statement 객체 해제
        sqlite3_finalize(stmt) 
      } 
      // 연결해제
      sqlite3_close(db) 
    } 
  } 
  return list; 
}

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

Swift 로 작성하는 프로젝트에서는 Objective-C 클래스를 그대로 사용할 수 있다. 단, 이 때 필요한 것이 브릿징-헤더이다. Xcode의 프로젝트 빌드 세팅에서 브릿징 헤더 (Objective-C Bridging Header files) 항목에 해당 파일의 경로를 넣어주면 된다. 브릿징 헤더에는 Swift 프로젝트로 가져오고 싶은 Objecitve-C 헤더들 및 함수 선언등이 들어가면 된다.

브릿징 헤더를 만들기 전에, Objective-C 파일들과 번들에 포함할 sqlite 파일을 프로젝트에 미리 복사해두자.

Bridging-Header 만들기

File > New File > Source > header file을 선택하여 확장자가 .h 인 파일을 만든다. 이름을 적당히 Bridging-Header.h로 두자. 이 내용에는 DBController.h 파일을 다시 반입하는 한 줄만 있으면 되겠다.

#import "DBController.h"

그리고 프로젝트의 빌드 세팅에서 브릿징 헤더 키를 찾아서 해당 파일의 경로를 넣어준다. 이때 파일의 이름만 달랑 들어가는게 아니라 프로젝트명/파일명의 형식이 되어야 한다. 예를 들어 프로젝트명이 TestMemo 이면 TestMemo/Bridging-Header.h를 입력해주자.

이제 DBConnector 클래스는 마치 Swift의 클래스인 것처럼 그대로 쓰면 된다. 참고로 getDefaultList의 리턴타입을 NSArray*로 했기 때문에 Array<AnyObject>로 반입될 것인데, 이를 적절히 다운캐스팅하자.

func resetWordList() -> Void {
    let conn = DBConnector()
    DispatchQueue.global.async { 
        guard let contents = conn.getDefaultList() as? [String]
        else { return }
        self.wordList = contents
        DispatchQueue.main.sync {
            tableView.reloadData()
        }
    }
}

Xcode 7.0 부터 Objective-C 코드를 작성할 때 경량 제네릭이 지원된다. 즉 getDefaultList의 리턴타입이 NSArray<NSString *> * 이라면 [String] 타입으로 변환되어 반입될 것이다.