[Swift 입문] 28편 — SQLite와 데이터베이스

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [27편] FileManager와 파일 시스템

파일 저장의 한계와 데이터베이스

JSON 파일로 데이터를 저장하면 간단하지만, 데이터가 많아질수록 문제가 생깁니다.

  • 10,000개의 메모를 불러오려면 전체 JSON 파일을 읽어야 합니다
  • “2024년에 작성한 메모만 검색”하려면 모든 데이터를 메모리에 올려야 합니다
  • 여러 스레드에서 동시에 파일에 쓰면 데이터가 깨질 수 있습니다

이런 문제를 해결하는 것이 데이터베이스이고, macOS/iOS 앱에서 가장 널리 쓰이는 내장 데이터베이스가 SQLite입니다.


SQLite란?

SQLite는 서버가 필요 없는 파일 기반 관계형 데이터베이스입니다. 전 세계에서 가장 많이 배포된 데이터베이스로, 스마트폰 앱, 브라우저, 항공기 소프트웨어까지 다양한 곳에서 사용됩니다.

  • 단일 .db 파일에 모든 데이터 저장
  • SQL 쿼리로 빠른 검색·정렬·필터링
  • 트랜잭션으로 데이터 일관성 보장
  • macOS/iOS에 libsqlite3로 기본 내장

libsqlite3 — C API 직접 사용

Swift에서 SQLite를 사용하는 가장 기본적인 방법은 시스템에 내장된 C 라이브러리를 직접 호출하는 것입니다.

import Foundation
import SQLite3  // 또는 import CSQLite

class Database {
    private var db: OpaquePointer?

    init(path: String) throws {
        // 데이터베이스 열기 (없으면 생성)
        let result = sqlite3_open(path, &db)
        if result != SQLITE_OK {
            throw DatabaseError.openFailed(sqlite3_errmsg(db).map(String.init) ?? "알 수 없는 오류")
        }
    }

    deinit {
        sqlite3_close(db)
    }
}

enum DatabaseError: Error {
    case openFailed(String)
    case queryFailed(String)
}

테이블 생성

extension Database {
    func createTables() throws {
        let sql = """
            CREATE TABLE IF NOT EXISTS notes (
                id          TEXT PRIMARY KEY,
                title       TEXT NOT NULL,
                body        TEXT NOT NULL DEFAULT '',
                created_at  REAL NOT NULL,
                updated_at  REAL NOT NULL
            );
            CREATE INDEX IF NOT EXISTS notes_created_at ON notes(created_at DESC);
        """

        var errorMsg: UnsafeMutablePointer<CChar>?
        let result = sqlite3_exec(db, sql, nil, nil, &errorMsg)

        if result != SQLITE_OK {
            let msg = errorMsg.map(String.init) ?? "알 수 없는 오류"
            sqlite3_free(errorMsg)
            throw DatabaseError.queryFailed(msg)
        }
    }
}

데이터 삽입 — Prepared Statement

SQL 인젝션을 막고 성능을 높이기 위해 항상 prepared statement를 사용합니다.

struct Note {
    let id: String
    var title: String
    var body: String
    var createdAt: Date
    var updatedAt: Date
}

extension Database {
    func insert(_ note: Note) throws {
        let sql = """
            INSERT OR REPLACE INTO notes (id, title, body, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?);
        """

        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }  // 항상 해제

        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw DatabaseError.queryFailed("prepare 실패")
        }

        // ? 자리에 값 바인딩 (1-indexed)
        sqlite3_bind_text(stmt, 1, note.id, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 2, note.title, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 3, note.body, -1, SQLITE_TRANSIENT)
        sqlite3_bind_double(stmt, 4, note.createdAt.timeIntervalSince1970)
        sqlite3_bind_double(stmt, 5, note.updatedAt.timeIntervalSince1970)

        guard sqlite3_step(stmt) == SQLITE_DONE else {
            throw DatabaseError.queryFailed("insert 실패")
        }
    }
}

데이터 조회

extension Database {
    func fetchAll() throws -> [Note] {
        let sql = "SELECT id, title, body, created_at, updated_at FROM notes ORDER BY created_at DESC;"
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }

        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw DatabaseError.queryFailed("prepare 실패")
        }

        var notes: [Note] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            let id    = String(cString: sqlite3_column_text(stmt, 0))
            let title = String(cString: sqlite3_column_text(stmt, 1))
            let body  = String(cString: sqlite3_column_text(stmt, 2))
            let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 3))
            let updatedAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4))

            notes.append(Note(id: id, title: title, body: body,
                              createdAt: createdAt, updatedAt: updatedAt))
        }
        return notes
    }

    func search(query: String) throws -> [Note] {
        let sql = "SELECT id, title, body, created_at, updated_at FROM notes WHERE title LIKE ? OR body LIKE ? ORDER BY updated_at DESC LIMIT 50;"
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }

        sqlite3_prepare_v2(db, sql, -1, &stmt, nil)

        let pattern = "%\(query)%"
        sqlite3_bind_text(stmt, 1, pattern, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 2, pattern, -1, SQLITE_TRANSIENT)

        var results: [Note] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            // 위와 동일하게 행 읽기...
            let id    = String(cString: sqlite3_column_text(stmt, 0))
            let title = String(cString: sqlite3_column_text(stmt, 1))
            let body  = String(cString: sqlite3_column_text(stmt, 2))
            let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 3))
            let updatedAt = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4))
            results.append(Note(id: id, title: title, body: body,
                                createdAt: createdAt, updatedAt: updatedAt))
        }
        return results
    }
}

트랜잭션

여러 작업을 하나의 원자적 단위로 처리할 때 트랜잭션을 사용합니다. 중간에 실패하면 전부 취소(롤백)됩니다.

extension Database {
    func transaction(_ block: () throws -> Void) throws {
        sqlite3_exec(db, "BEGIN TRANSACTION;", nil, nil, nil)
        do {
            try block()
            sqlite3_exec(db, "COMMIT;", nil, nil, nil)
        } catch {
            sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
            throw error
        }
    }
}

// 사용
try db.transaction {
    try db.insert(note1)
    try db.insert(note2)
    try db.insert(note3)
    // 셋 중 하나라도 실패하면 전부 취소
}

WAL 모드 — 성능 향상

SQLite의 기본 저널 모드 대신 WAL(Write-Ahead Logging) 모드를 사용하면 읽기와 쓰기를 동시에 할 수 있어 성능이 크게 향상됩니다.

// 데이터베이스 열 때 한 번만 설정
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, nil)
sqlite3_exec(db, "PRAGMA synchronous=NORMAL;", nil, nil, nil)
sqlite3_exec(db, "PRAGMA cache_size=-4000;", nil, nil, nil)  // 4MB 캐시

SwiftData (iOS 17 / macOS 14+)

Apple이 2023년 발표한 SwiftData는 SQLite 위에 구축된 고수준 프레임워크로, Core Data를 대체합니다. 복잡한 SQL 없이 Swift 코드로 데이터를 모델링하고 저장할 수 있습니다.

import SwiftData

@Model  // SwiftData 모델 선언
class Note {
    var id: UUID
    var title: String
    var body: String
    var createdAt: Date

    init(title: String, body: String) {
        self.id = UUID()
        self.title = title
        self.body = body
        self.createdAt = .now
    }
}

// SwiftUI와 통합
struct NoteApp: App {
    var body: some Scene {
        WindowGroup {
            NoteListView()
        }
        .modelContainer(for: Note.self)  // 컨테이너 설정 한 줄로 끝
    }
}

struct NoteListView: View {
    @Query(sort: \Note.createdAt, order: .reverse) var notes: [Note]
    @Environment(\.modelContext) var context

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
        .toolbar {
            Button("추가") {
                context.insert(Note(title: "새 메모", body: ""))
            }
        }
    }
}

최신 macOS/iOS 앱을 새로 시작한다면 SwiftData를 사용하는 것이 권장됩니다. 구버전 지원이 필요하거나 성능을 세밀하게 제어해야 한다면 SQLite를 직접 사용합니다.


데이터 저장 방법 비교

방법 적합한 용도 용량
UserDefaults 앱 설정, 간단한 값 소량
Codable + JSON 파일 중소규모 데이터, 내보내기 중간
SQLite 대량 데이터, 검색·정렬 필요 대용량
SwiftData 관계형 모델, Swift-native API 대용량
Core Data 레거시 코드, 구버전 지원 대용량

핵심 요약

  • SQLite: 서버 불필요한 파일 기반 관계형 DB. macOS/iOS에 기본 내장
  • sqlite3_open: DB 열기/생성. sqlite3_close: 반드시 닫기
  • Prepared statement: SQL 인젝션 방지 + 성능 향상. ?로 바인딩
  • SQLITE_ROW / SQLITE_DONE: sqlite3_step 결과 코드
  • 트랜잭션: BEGIN → 작업 → COMMIT/ROLLBACK으로 원자성 보장
  • WAL 모드: 읽기·쓰기 동시 처리로 성능 향상
  • SwiftData: iOS 17+/macOS 14+ 신규 앱에 권장되는 고수준 API

5부 Foundation과 데이터 저장이 완결됩니다. 다음 편부터는 6부 네트워킹과 IPC로, TCP 소켓, Unix Domain Socket, 프로세스 간 통신을 다룹니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다