Wails 실전 강좌 #2 SQLite 로컬 데이터베이스 — CGO 없이 순수 Go로

5 분 소요

#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 아래로 잡아, 설치 경로 권한 문제를 피합니다.

repository.go — 저장소 열기
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이 이 용도에 딱 맞습니다.

migrate — 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 접근만 둡니다. 검증 같은 규칙은 서비스 층 몫입니다.

repository.go — 생성과 조회
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이 되어 프론트엔드의 배열 순회를 깨뜨리기 때문입니다.

서비스 층: 검증을 여기서 #

서비스는 저장소를 감싸며 도메인 규칙을 겁니다. 시간 값도 여기서 채웁니다.

service.go — 생성 시 검증
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 전문 검색을 얹고, 데이터 변경을 이벤트로 프론트엔드에 알립니다.
X