앱 제작중에 있어서 서버로 보내기엔 너무 간단하지만, 앱이 종료되도 저장이 가능한 저장소를 이용했었다.
주로 UserDefaults를 이용했었다.
UserDefaults:
https://developer.apple.com/documentation/foundation/userdefaults
이는 Key-Value 값이 한쌍으로 저장되는 기본 데이터베이스인데, 해당하는 키를 부르면 쉽게 데이터에 접근할 수 있어서 자주 사용했었지만, 다양한 구조체를 저장하고, 부르기에는 좀 무거워진 느낌이 들었다. 또한 Key를 통해 접근하다보니 여러가지 데이터를 받는데 헷갈렸다.
(이런식으로 저장하고자 하는 구조체마다 싱글톤을 제작하여 데이터 저장 및 출력을 했다.)
class StudyListUserDefaults {
var data :[Study] = {
var arr = [Study]()
if let data = UserDefaults.standard.value(forKey: "studyList") as? Data {
arr = try! PropertyListDecoder().decode(Array<Study>.self, from: data)
}
return arr
}() {
didSet {
UserDefaults.standard.setValue(try? PropertyListEncoder().encode(data), forKey: "studyList")
}
}
static let shared = StudyListUserDefaults()
private init(){}
func add(new: Study) {
data.append(new)
}
func remove(index: Int) {
data.remove(at: index)
}
func set(new: [Study]) {
data = new
}
func removeAll() {
data.removeAll()
}
}
앱에 있던 모든 데이터를 싱글톤인 유저디폴트로 저장하다보니까 한계가 느껴지는듯 해서 다른 로컬저장소를 찾아보았다.
이번 게시물은 유저디폴트에서 SQLite로 바꾸면서 느꼈던 점과 SQLite의 코드들을 살펴보려고 한다.
일단 코드를 보기전에 SQLite로 만들면서 좋았던점은
1. 구조체를 하나로 통일할 수 있었다.
-> 이전에 사용했던 유저디폴트를 마구마구 쓰지않게되었다.
2. 위 장점과 이어지는건데, 코드의 가독성이 좋아졌다.
3. 각 뷰마다 뷰모델에서 데이터의 접근이 매우 좋아졌다.
-> SQLite 싱글톤 하나로 어디든 데이터를 접근할 수 있었고, 뷰모델의 데이터로 UI그리기 용이했다.
4. SQL 기반의 오픈소스다.
-> import 하여 쉽게 접근할 수 있는점과, 익숙한 쿼리가 보인다. 익숙한면에서 점수 먹고들어간다
내가 느낀 단점도 역시 있다.
1. 아무래도 데이터를 직관적으로 보기 어렵다.
-> print를 이용해서 데이터를 확인해보지만, 단번에 파악하기는 좀 어려운듯하다.
2. 코드 구현 난이도가 있는편이다.
-> 아무래도 유저디폴트로 간편하게 저장했다보니까 비교적 어려웠다. 구현만 하면 편하다.
3. 데이터 가공이 좀 힘들다?
-> 내 코드실력도 한 몫했지만 데이터 저장방식과 입출력 파라미터 구현하는데 애먹었고, 복잡한 데이터베이스는 이와 맞지않는걸 느꼈다.
그밖에도 더있었지만.. 다시 로컬저장소를 이용한다면 또 이용해도 될 정도의 메리트는 있는것 같다.
SQLite
https://github.com/stephencelis/SQLite.swift
위 링크는 SQLite고 나는 SQLite3를 이용했다.
차이점은 아래에서 확인할 수 있다.
https://linuxhint.com/what-are-sqlite-and-sqlite3/
코드로 구현하기
코드로 작성하기 앞서 SQLite를 추가해준다.
import SQLite3
클래스 이름과 데이터베이스 이름은 아무렇게나 만들어도 상관이 없다.
class DB {
// 싱글톤
static let shared = DB()
// db를 가리키는 포인터
var db: OpaquePointer? = nil
// 데이터베이스 이름 형식: name.sqlite 로 만들것
let databaseName = "database.sqlite"
init() {
self.db = createDB()
}
deinit {
sqlite3_close(db)
}
}
createDB(): 데이터베이스 생성
func createDB() -> OpaquePointer? {
//파일 경로
let filePath = try! FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false).appendingPathExtension(databaseName).path
var db: OpaquePointer? = nil
// 해당 경로에 DB가 성공적으로 만들어지면 DB포인터 반환
if sqlite3_open(filePath, &db) != SQLITE_OK {
print("There is error in creating DB")
return nil
} else {
print("Database is been created with path \(databaseName)")
return db
}
}
createTable(): 테이블 생성
func createTable(tableName: String, stringColumn: [String]) {
"""
CREATE TABLE IF NOT EXISTS tablename(id INTEGER PRIMARY KEY AUTOINCREMENT, name: TEXT, done: TEXT NOT NULL, date: TEXT);
이는 tablename이란 테이블이 존재하지 않으면 생성한다는 뜻입니다.
id는 고유키가 되고, 데이터가 추가될때마다 자동으로 증가하는값을 가집니다.
여기서 TEXT는 문자열을 받는것이고, NOT NULL은 NULL은 저장하지않는단얘기입니다. Swift에선 옵셔널이 아닌형식이 되겠네요
저는 유연하게 테이블을 만들고자 테이블 이름과 저장할 데이터이름(column에 위치한)을 입력받습니다.
물론 데이터의 형식도 위와같이 입력받아 더 유연하게 만들 수 있지만 복잡해지므로 문자열만 받겠습니다.
"""
// 입력받은 데이터이름을 형식에 맞춰서 구성
var column: String = {
var str = "id INTEGER PRIMARY KEY AUTOINCREMENT"
for col in stringColumn {
str += ", \(col) TEXT"
}
return str
}()
// 쿼리 작성
let query = "CREATE TABLE IF NOT EXISTS" + " \(tableName)" + "(\(column));"
var createTable: OpaquePointer? = nil
// 작성한 쿼리를 실행
if sqlite3_prepare_v2(self.db, query, -1, &createTable, nil) == SQLITE_OK {
if sqlite3_step(createTable) == SQLITE_DONE {
print("Table creation success \(String(describing: self.db))")
} else {
print("Table creation fail")
}
} else {
print("Prepation fail")
}
sqlite3_finalize(createTable)
insertData(): 데이터 삽입
func insertData(tableName: String, columns: [String], insertData: [String]) {
"""
데이터도 역시 유연하게 받기 위해 테이블이름, 데이터이름, 넣을 데이터를 입력 받습니다.
이것도 역시 쿼리를 구성하고 실행하면 됩니다. 쿼리는 아래와 같이 구성합니다.
insert into tablename (id, name, done, date) values (?, ?, ?, ?);
tablename이란 테이블에 데이터를 집어넣는다. values 뒤에 ?는 넣을 데이터수에 맞춰 구성합니다. id포함
"""
// 입력받은 데이터이름을 형식에 맞춰 구성
let column: String = {
var column = "id"
for col in columns {
column += ", \(col)"
}
return column
}()
// 입력받은 데이터에따라 인자 구성
var value: String = {
var value = "?"
for val in 0..<insertData.count {
value += ", ?"
}
return value
}()
// 쿼리작성
let insertQuery = "insert into \(tableName) (\(column)) values (\(value));"
var statement: OpaquePointer? = nil
// 작성한 쿼리로 실행
if sqlite3_prepare_v2(self.db, insertQuery, -1, &statement, nil) == SQLITE_OK {
// 데이터 삽입
for i in 0..<insertData.count {
// 여기서 두번째 인자가 위 values에서 몇번째 ?에넣는가를 의미합니다.
// 세번째 인자는 넣을 데이터를 형식에 맞춰 변환하여 넣습니다.
sqlite3_bind_text(statement, Int32(i)+2, NSString(string: insertData[i]).utf8String, -1, nil)
}
if sqlite3_step(statement) == SQLITE_DONE {
print("insert success")
} else {
print("step fail")
}
} else {
print("bind fail")
}
데이터를 넣을때 데이터 형식에 맞게 넣어야합니다.
SQLite에서 지원하는 데이터형식: https://www.sqlite.org/datatype3.html
readData(): 데이터 읽기
데이터를 불러오기에 앞서 알맞은 형식을 받기위해 구조체 하나를 선언합니다.
struct StudyModel: Equatable, Codable {
var id: Int?
var name: String?
var done: String?
var date: String?
// 아래는 없어도됨
static func ==(lhs: StudyModel, rhs: StudyModel) -> Bool {
return lhs.name == rhs.name
}
}
데이터를 읽습니다.
func readData(tableName: String, column: [String]) -> [StudyModel] {
"""
데이터를 읽는것도 역시나 쿼리작성후 해당 쿼리를 실행하는것 입니다.
데이터도 역시 유연하게 읽기위해 tablename과 받을 데이터이름이 담긴 배열을 입력받습니다.
쿼리구성: select * from tablename;
"""
let query: String = "select * from \(tableName);"
var statement: OpaquePointer? = nil
// 앞서 선언했던 데이터받을 구조체의 배열
var result: [StudyModel] = []
// 구성한 쿼리로 실행
if sqlite3_prepare_v2(self.db, query, -1, &statement, nil) != SQLITE_OK {
let error = String(cString: sqlite3_errmsg(db)!)
print("error while prepare: \(error)")
return []
}
// Column에 값이 존재하지않을때 까지 모두 읽어옵니다.
// 저는 입력받은 데이터를 순회하면서 리턴형식에 맞게 만들고 추가하는 방법을 사용했습니다.
while sqlite3_step(statement) == SQLITE_ROW {
// id 고유키는 따로 읽어옵니다.
let id = sqlite3_column_int(statement, 0)
// 출력형식을 만들어주고, 이미 읽어온 id만 초기화
var d = StudyModel(id: Int(id), name: nil, done: nil, date: nil)
// 받아온데이터를 data 딕셔너리에 먼저 넣고, 저장변수이름과 해당하는 값을 매칭시켜서 d에 저장합니다.
var data = Dictionary<String, String>()
for i in 0..<column.count {
data[column[i]] = String(cString: sqlite3_column_text(statement, Int32(i+1)))
let load = String(cString: sqlite3_column_text(statement, Int32(i+1)))
switch column[i] {
case column[0]: d.name = load
case column[1]: d.done = load
case column[2]: d.date = load
default: continue
}
}
// 저장된 d를 출력배열 result에 넣습니다.
result.append(d)
}
sqlite3_finalize(statement)
return result
}
데이터를 읽고 쓰는데는 SQLite에서 지원하는 데이터형식을 고려하여 변환해야합니다.
updateData(): 데이터 수정
// 데이터 수정오류메세지
private func onSQLErrorPrintErrorMessage(_ db: OpaquePointer?) {
let errorMessage = String(cString: sqlite3_errmsg(db))
print("Error preparing Update \(errorMessage)")
return
}
func updateData(tableName: String, id: Int, done: String, date: String) {
"""
데이터 수정도 역시나 쿼리를 구성한 후 실행하는 과정을 거칩니다.
쿼리구성: UPDATE tablename SET name='변경값',date='변경값' WHERE id==2
TEXT형식은 반드시 변경값에 ''를 감싸줘야합니다.
WHERE뒤에는 조건부가 나옵니다.
변경하고자 하는 값을 입력받아 유연하게 데이터를 변경합니다.
"""
var statement: OpaquePointer? = nil
let query = "UPDATE \(tableName) SET done='\(done)',date='\(date)' WHERE id==\(id)"
if sqlite3_prepare(db, query, -1, &statement, nil) != SQLITE_OK {
onSQLErrorPrintErrorMessage(db)
return
}
if sqlite3_step(statement) != SQLITE_DONE {
onSQLErrorPrintErrorMessage(db)
return
}
print("Update has been successfully done")
}
deleteData(): 데이터 삭제
func deleteData(tableName: String, id: Int) {
"""
값 삭제도 쿼리를 구성후 실행합니다.
데이터 변경과 비슷하게 조건부가 필요합니다.
쿼리구성: delete from tablename where id == 2
tablename이란 테이블에서 id(고유키)가 2인 데이터를 삭제한다.
"""
let query = "delete from \(tableName) where id == \(id)"
print(query)
var statement: OpaquePointer? = nil
if sqlite3_prepare_v2(self.db, query, -1, &statement, nil) == SQLITE_OK {
if sqlite3_step(statement) == SQLITE_DONE {
print("delete success")
} else {
print("delete fail")
}
} else {
print("delete prepare fail")
}
sqlite3_finalize(statement)
}
이렇게 여러 메서드로 SQLite를 구현했습니다.
입력 파라미터 없이 메서드를 구현한다면 좀 딱딱한? 데이터베이스가 됩니다.
SQLite가 복잡한 데이터베이스구조와는 맞지않아서 간단하게 저장할 용도로 보통 사용하지만,
데이터를 조금씩 늘리고 테이블을 여러개 두고, 데이터형식도 좀 늘어난다면 이에 대응하기 위해 입력파라미터를 구성해놓는게 좋습니다.
아래 참고사이트와 전체코드를 두고 마무리합니다.
Reference:
https://42kchoi.tistory.com/387
https://www.sqlite.org/docs.html
전체코드:
import SQLite3
struct StudyModel: Equatable, Codable {
var id: Int?
var name: String?
var done: String?
var date: String?
static func ==(lhs: StudyModel, rhs: StudyModel) -> Bool {
return lhs.name == rhs.name
}
}
class DB {
// 싱글톤
static let shared = DB()
// db를 가리키는 포인터
var db: OpaquePointer? = nil
// 데이터베이스 이름 형식: name.sqlite 로 만들것
let databaseName = "database.sqlite"
init() {
self.db = createDB()
}
deinit {
sqlite3_close(db)
}
func createDB() -> OpaquePointer? {
let filePath = try! FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false).appendingPathExtension(databaseName).path
var db: OpaquePointer? = nil
if sqlite3_open(filePath, &db) != SQLITE_OK {
print("There is error in creating DB")
return nil
} else {
print("Database is been created with path \(databaseName)")
return db
}
}
func createTable(tableName: String, stringColumn: [String]) {
"""
CREATE TABLE IF NOT EXISTS tablename(id INTEGER PRIMARY KEY AUTOINCREMENT, name: TEXT, done: TEXT NOT NULL, date: TEXT);
이는 tablename이란 테이블이 존재하지 않으면 생성한다는 뜻입니다.
id는 고유키가 되고, 데이터가 추가될때마다 자동으로 증가하는값을 가집니다.
여기서 TEXT는 문자열을 받는것이고, NOT NULL은 NULL은 저장하지않는단얘기입니다. Swift에선 옵셔널이 아닌형식이 되겠네요
저는 유연하게 테이블을 만들고자 테이블 이름과 저장할 데이터이름(column에 위치한)을 입력받습니다.
물론 데이터의 형식도 위와같이 입력받아 더 유연하게 만들 수 있지만 복잡해지므로 문자열만 받겠습니다.
"""
// 입력받은 데이터이름을 형식에 맞춰서 구성
var column: String = {
var str = "id INTEGER PRIMARY KEY AUTOINCREMENT"
for col in stringColumn {
str += ", \(col) TEXT"
}
return str
}()
// 쿼리 작성
let query = "CREATE TABLE IF NOT EXISTS" + " \(tableName)" + "(\(column));"
var createTable: OpaquePointer? = nil
// 작성한 쿼리를 실행
if sqlite3_prepare_v2(self.db, query, -1, &createTable, nil) == SQLITE_OK {
if sqlite3_step(createTable) == SQLITE_DONE {
print("Table creation success \(String(describing: self.db))")
} else {
print("Table creation fail")
}
} else {
print("Prepation fail")
}
sqlite3_finalize(createTable)
}
func insertData(tableName: String, columns: [String], insertData: [String]) {
"""
데이터도 역시 유연하게 받기 위해 테이블이름, 데이터이름, 넣을 데이터를 입력 받습니다.
이것도 역시 쿼리를 구성하고 실행하면 됩니다. 쿼리는 아래와 같이 구성합니다.
insert into tablename (id, name, done, date) values (?, ?, ?, ?);
tablename이란 테이블에 데이터를 집어넣는다. values 뒤에 ?는 넣을 데이터수에 맞춰 구성합니다. id포함
"""
// 입력받은 데이터이름을 형식에 맞춰 구성
let column: String = {
var column = "id"
for col in columns {
column += ", \(col)"
}
return column
}()
// 입력받은 데이터에따라 인자 구성
var value: String = {
var value = "?"
for val in 0..<insertData.count {
value += ", ?"
}
return value
}()
// 쿼리작성
let insertQuery = "insert into \(tableName) (\(column)) values (\(value));"
var statement: OpaquePointer? = nil
// 작성한 쿼리로 실행
if sqlite3_prepare_v2(self.db, insertQuery, -1, &statement, nil) == SQLITE_OK {
// 데이터 삽입
for i in 0..<insertData.count {
// 여기서 두번째 인자가 위 values에서 몇번째 ?에넣는가를 의미합니다.
// 세번째 인자는 넣을 데이터를 형식에 맞춰 변환하여 넣습니다.
sqlite3_bind_text(statement, Int32(i)+2, NSString(string: insertData[i]).utf8String, -1, nil)
}
if sqlite3_step(statement) == SQLITE_DONE {
print("insert success")
} else {
print("step fail")
}
} else {
print("bind fail")
}
}
func readData(tableName: String, column: [String]) -> [StudyModel] {
"""
데이터를 읽는것도 역시나 쿼리작성후 해당 쿼리를 실행하는것 입니다.
데이터도 역시 유연하게 읽기위해 tablename과 받을 데이터이름이 담긴 배열을 입력받습니다.
쿼리구성: select * from tablename;
"""
let query: String = "select * from \(tableName);"
var statement: OpaquePointer? = nil
// 앞서 선언했던 데이터받을 구조체의 배열
var result: [StudyModel] = []
// 구성한 쿼리로 실행
if sqlite3_prepare_v2(self.db, query, -1, &statement, nil) != SQLITE_OK {
let error = String(cString: sqlite3_errmsg(db)!)
print("error while prepare: \(error)")
return []
}
// Column에 값이 존재하지않을때 까지 모두 읽어옵니다.
// 저는 입력받은 데이터를 순회하면서 리턴형식에 맞게 만들고 추가하는 방법을 사용했습니다.
while sqlite3_step(statement) == SQLITE_ROW {
// id 고유키는 따로 읽어옵니다.
let id = sqlite3_column_int(statement, 0)
// 출력형식을 만들어주고, 이미 읽어온 id만 초기화
var d = StudyModel(id: Int(id), name: nil, done: nil, date: nil)
// 받아온데이터를 data 딕셔너리에 먼저 넣고, 저장변수이름과 해당하는 값을 매칭시켜서 d에 저장합니다.
var data = Dictionary<String, String>()
for i in 0..<column.count {
data[column[i]] = String(cString: sqlite3_column_text(statement, Int32(i+1)))
let load = String(cString: sqlite3_column_text(statement, Int32(i+1)))
switch column[i] {
case column[0]: d.name = load
case column[1]: d.done = load
case column[2]: d.date = load
default: continue
}
}
// 저장된 d를 출력배열 result에 넣습니다.
result.append(d)
}
sqlite3_finalize(statement)
return result
}
// 데이터 수정오류메세지
private func onSQLErrorPrintErrorMessage(_ db: OpaquePointer?) {
let errorMessage = String(cString: sqlite3_errmsg(db))
print("Error preparing Update \(errorMessage)")
return
}
func updateData(tableName: String, id: Int, done: String, date: String) {
"""
데이터 수정도 역시나 쿼리를 구성한 후 실행하는 과정을 거칩니다.
쿼리구성: UPDATE tablename SET name='변경값',date='변경값' WHERE id==2
TEXT형식은 반드시 변경값에 ''를 감싸줘야합니다.
WHERE뒤에는 조건부가 나옵니다.
"""
var statement: OpaquePointer? = nil
let query = "UPDATE \(tableName) SET done='\(done)',date='\(date)' WHERE id==\(id)"
if sqlite3_prepare(db, query, -1, &statement, nil) != SQLITE_OK {
onSQLErrorPrintErrorMessage(db)
return
}
if sqlite3_step(statement) != SQLITE_DONE {
onSQLErrorPrintErrorMessage(db)
return
}
print("Update has been successfully done")
}
func deleteData(tableName: String, id: Int) {
"""
값 삭제도 쿼리를 구성후 실행합니다.
데이터 변경과 비슷하게 조건부가 필요합니다.
쿼리구성: delete from tablename where id == 2
tablename이란 테이블에서 id(고유키)가 2인 데이터를 삭제한다.
"""
let query = "delete from \(tableName) where id == \(id)"
print(query)
var statement: OpaquePointer? = nil
if sqlite3_prepare_v2(self.db, query, -1, &statement, nil) == SQLITE_OK {
if sqlite3_step(statement) == SQLITE_DONE {
print("delete success")
} else {
print("delete fail")
}
} else {
print("delete prepare fail")
}
sqlite3_finalize(statement)
}
}
'iOS' 카테고리의 다른 글
iOS 테이블셀 원하는 색이 안나오는경우 (0) | 2024.04.25 |
---|---|
present로 다른 뷰로 이동후 돌아올때, viewWillApear 메서드가 호출되지않는점 (0) | 2024.04.22 |
Delegate 패턴 구현중에 delgate = nil 오류 (0) | 2024.04.22 |
아이폰에 넣은 앱이 SQLite의 DB를 불러오지 못한 에러 (0) | 2024.04.14 |
iOS 레이아웃 이해하기 (1) | 2024.04.06 |