Wails 실전 강좌 #2 SQLite 로컬 데이터베이스 — CGO 없이 순수 Go로
#1에서 노트 앱을 바인딩·서비스·저장소 세 층으로 나눴습니다. 이번 글은 그중 저장소 층을 SQLite로 구현해, 앱을 꺼도 노트가 남게 만듭니다. 파일 하나에 담기는 SQLite는 로컬 데스크톱 앱의 저장소로 이상적이지만, Wails에서는 한 가지 함정을 먼저 넘어야 합니다. 바로 CGO입니다.
총 10편(2부 구성) 중 두 번째 글입니다.
- #1 실전 프로젝트 설계 — 무엇을 만들고 어떻게 나눌까
- #2 SQLite 로컬 데이터베이스 — CGO 없이 순수 Go로 ← 이번 글
- #3 전문 검색과 데이터 흐름 — FTS5와 이벤트 기반 갱신
- #4 트레이 상주와 전역 단축키 — 백그라운드에서 빠르게 캡처
- #5 서명과 공증 — 배포된 앱이 신뢰받는 법
- #6 CI/CD 자동 릴리스 — GitHub Actions로 세 플랫폼 배포
함정 먼저: CGO가 크로스컴파일을 막는다 #
Go에서 SQLite를 쓸 때 가장 널리 알려진 드라이버는 mattn/go-sqlite3입니다. 그런데 이 드라이버는 C로 된 SQLite를 링크하므로 CGO가 켜져야 합니다. CGO가 켜지면 빌드에 C 컴파일러가 끼어들고, 그 순간 Go의 강점인 손쉬운 크로스컴파일이 무너집니다. macOS에서 Windows용 바이너리를 뽑으려면 Windows용 C 툴체인이 필요해지는 식입니다. 이 문제는 #6의 CI 배포에서 세 플랫폼 바이너리를 만들 때 정면으로 부딪힙니다.
해법은 순수 Go로 작성된 SQLite 드라이버 modernc.org/sqlite입니다. SQLite를 Go로 옮긴 구현이라 C 의존이 없고, CGO 없이 컴파일됩니다. 성능은 CGO판보다 약간 낮지만, 로컬 노트 앱 규모에서는 차이가 체감되지 않고, 크로스컴파일이 그냥 된다는 이점이 훨씬 큽니다.
import (
"database/sql"
_ "modernc.org/sqlite" // 드라이버 이름은 "sqlite"
)임포트에 붙은 언더스코어(_)는 패키지를 쓰지 않고 초기화만 하겠다는 뜻입니다. 이것으로 database/sql에 "sqlite"라는 드라이버가 등록됩니다. mattn판의 드라이버 이름이 "sqlite3"인 것과 다르므로 헷갈리지 않게 주의합니다.
저장소 층: 열기와 스키마 #
저장소는 DB 파일을 열고 스키마를 준비하는 것부터 시작합니다. 파일 위치는 입문 #5에서 다룬 os.UserConfigDir 아래로 잡아, 설치 경로 권한 문제를 피합니다.
type NoteRepository struct {
db *sql.DB
}
func OpenRepository(appName string) (*NoteRepository, error) {
base, err := os.UserConfigDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, appName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", filepath.Join(dir, "notes.db"))
if err != nil {
return nil, err
}
repo := &NoteRepository{db: db}
if err := repo.migrate(); err != nil {
return nil, err
}
return repo, nil
}마이그레이션: 버전으로 스키마를 관리 #
스키마를 CREATE TABLE IF NOT EXISTS로만 만들면 첫 버전은 되지만, 나중에 컬럼을 추가할 때 곤란해집니다. 실전에서는 스키마 버전을 DB 자신에 기록하고, 버전에 따라 단계별로 올립니다. SQLite의 PRAGMA user_version이 이 용도에 딱 맞습니다.
func (r *NoteRepository) migrate() error {
var version int
if err := r.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
return err
}
if version < 1 {
_, err := r.db.Exec(`
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
PRAGMA user_version = 1;
`)
if err != nil {
return err
}
}
// 이후 버전은 여기에 if version < 2 { ... } 로 이어 붙인다
return nil
}이 구조 덕분에 #3에서 전문 검색용 테이블을 추가할 때 기존 사용자의 DB도 자동으로 다음 버전으로 올라갑니다. 새 설치든 기존 설치든 같은 코드가 올바른 스키마에 도달합니다.
CRUD: 저장소가 SQL을 맡는다 #
저장소에는 순수하게 DB 접근만 둡니다. 검증 같은 규칙은 서비스 층 몫입니다.
func (r *NoteRepository) Insert(n Note) (Note, error) {
res, err := r.db.Exec(
`INSERT INTO notes (title, body, created_at, updated_at) VALUES (?, ?, ?, ?)`,
n.Title, n.Body, n.CreatedAt, n.UpdatedAt,
)
if err != nil {
return Note{}, err
}
n.ID, _ = res.LastInsertId()
return n, nil
}
func (r *NoteRepository) List() ([]Note, error) {
rows, err := r.db.Query(
`SELECT id, title, body, created_at, updated_at FROM notes ORDER BY updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
notes := []Note{} // nil이 아닌 빈 슬라이스 — 프론트엔드에서 [] 로 온다
for rows.Next() {
var n Note
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, err
}
notes = append(notes, n)
}
return notes, rows.Err()
}값을 SQL에 넣을 때는 항상 ? 플레이스홀더로 넘깁니다. 문자열을 직접 이어 붙이면 SQL 인젝션 위험이 생기는데, 로컬 앱이라도 노트 본문에 어떤 문자가 들어올지 모르므로 습관을 지킵니다. 빈 목록을 nil 대신 []Note{}로 돌려주는 것도 중요합니다. nil 슬라이스는 JSON에서 null이 되어 프론트엔드의 배열 순회를 깨뜨리기 때문입니다.
서비스 층: 검증을 여기서 #
서비스는 저장소를 감싸며 도메인 규칙을 겁니다. 시간 값도 여기서 채웁니다.
func (s *NoteService) Create(title, body string) (Note, error) {
title = strings.TrimSpace(title)
if title == "" {
return Note{}, errors.New("제목을 입력하세요")
}
now := time.Now()
return s.repo.Insert(Note{
Title: title,
Body: body,
CreatedAt: now,
UpdatedAt: now,
})
}여기서 반환한 error는 입문 #5에서 다룬 대로 프론트엔드에서 거부된 Promise가 됩니다. 제목이 비면 화면에 “제목을 입력하세요"가 그대로 뜨게 됩니다.
정리 #
- Wails에서 SQLite를 쓸 때
mattn/go-sqlite3는 CGO를 요구해 크로스컴파일을 막습니다. 순수 Go 드라이버modernc.org/sqlite(드라이버 이름"sqlite")로 이 문제를 없앱니다. - DB 파일은
os.UserConfigDir아래 앱 디렉터리에 둬서 설치 경로 권한 문제를 피합니다. - 스키마는
PRAGMA user_version으로 버전을 매겨 단계별 마이그레이션을 합니다. 새 설치와 기존 설치가 같은 코드로 최신 스키마에 도달합니다. - 저장소는 순수 DB 접근만, 검증 같은 규칙은 서비스 층에 둡니다. 값은 항상
?플레이스홀더로 넘기고, 빈 목록은[]Note{}로 돌려줍니다. - 다음 글에서 이 저장소 위에 FTS5 전문 검색을 얹고, 데이터 변경을 이벤트로 프론트엔드에 알립니다.