Wails로 데스크톱 앱 만들기 #5 실전 기능 — 설정 저장과 에러 처리

5 분 소요

“Wails로 데스크톱 앱 만들기 #4 시스템 통합 — 다이얼로그·메뉴·창 제어"까지 만든 할 일 앱에는 치명적인 공백이 하나 있습니다. 앱을 끄면 데이터가 사라집니다. 목록이 Go 구조체의 메모리에만 있기 때문입니다. 이번 글은 이 앱을 실제로 쓸 수 있는 수준으로 끌어올리는 실전 기능 세 가지, 곧 데이터 영속화, 에러 처리, 로그를 다룹니다.

총 8편(본편 6편 + 심화 2편)입니다.

  • #1 Wails란 — Go로 만드는 가벼운 데스크톱 앱
  • #2 프로젝트 구조와 개발 루프 — wails dev와 바인딩
  • #3 Go와 프론트엔드 연결 — 메서드 바인딩과 이벤트
  • #4 시스템 통합 — 다이얼로그·메뉴·창 제어
  • #5 실전 기능 — 설정 저장과 에러 처리 ← 이번 글
  • #6 빌드와 배포 — 플랫폼별 패키징

어디에 저장하는가 — os.UserConfigDir #

첫 질문은 저장 위치입니다. 실행 파일 옆에 파일을 쓰는 방식은 설치 경로에 쓰기 권한이 없는 경우(Windows의 Program Files, macOS의 Applications)가 많아 배포 후 곧바로 깨집니다. 각 OS가 앱 데이터용으로 정해 둔 경로를 써야 하고, Go 표준 라이브러리의 os.UserConfigDir가 그 경로를 돌려줍니다.

OSos.UserConfigDir() 반환 경로
macOS~/Library/Application Support
Windows%AppData% (C:\Users\<이름>\AppData\Roaming)
Linux~/.config (XDG_CONFIG_HOME)

이 아래에 앱 이름으로 디렉터리를 만들어 그 안에 저장합니다. 플랫폼 분기 코드 없이 세 OS 에서 같은 코드가 올바른 위치를 가리킵니다.

Store — JSON 저장과 로드 #

할 일 목록을 파일로 다루는 작은 저장소 타입을 만듭니다. 형식은 JSON이면 충분합니다.

store.go — JSON 파일 저장소
type Store struct {
	path string
}

func NewStore(appName string) (*Store, 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
	}
	return &Store{path: filepath.Join(dir, "todos.json")}, nil
}

func (s *Store) Load() ([]Todo, error) {
	data, err := os.ReadFile(s.path)
	if errors.Is(err, os.ErrNotExist) {
		return []Todo{}, nil // 첫 실행 — 빈 목록으로 시작
	}
	if err != nil {
		return nil, err
	}
	var todos []Todo
	if err := json.Unmarshal(data, &todos); err != nil {
		return nil, err
	}
	return todos, nil
}

func (s *Store) Save(todos []Todo) error {
	data, err := json.MarshalIndent(todos, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(s.path, data, 0o644)
}

첫 실행에서 파일이 없는 것은 오류가 아니라 정상 상태이므로, os.ErrNotExist를 구분해 빈 목록을 돌려줍니다. 이 구분이 없으면 첫 실행마다 오류 알림이 뜨는 앱이 됩니다.

startup과 shutdown — 수명 주기에 연결 #

저장소를 앱의 수명 주기에 연결합니다. #3에서 본 startup 훅에서 로드하고, 종료 훅에서 저장합니다.

app.go — 수명 주기 연결
func (a *App) startup(ctx context.Context) {
	a.ctx = ctx
	store, err := NewStore("wails-todo")
	if err == nil {
		a.store = store
		a.todos, _ = store.Load()
	}
}

func (a *App) shutdown(ctx context.Context) {
	if a.store != nil {
		a.store.Save(a.todos)
	}
}

shutdownoptions.AppOnShutdown에 등록합니다. 다만 강제 종료나 크래시에서는 shutdown 이 실행되지 않을 수 있으므로, 항목 추가·삭제처럼 데이터가 바뀌는 시점마다 저장하는 편이 실전에서는 더 안전합니다. 할 일 목록 수준의 데이터라면 매번 저장해도 비용이 체감되지 않습니다.

에러 처리 — Go의 error가 알림이 되기까지 #

바인딩된 Go 메서드가 error 를 반환하면, 프론트엔드에서는 거부된 Promise가 됩니다. 이 연결이 에러 처리 설계의 기반입니다.

app.go — error 반환
func (a *App) AddTodo(title string) ([]Todo, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return nil, fmt.Errorf("할 일 내용을 입력해 주세요")
	}
	a.todos = append(a.todos, Todo{Title: title})
	if err := a.store.Save(a.todos); err != nil {
		return nil, fmt.Errorf("저장에 실패했습니다: %w", err)
	}
	return a.todos, nil
}
frontend — catch 로 받기
try {
  const todos = await AddTodo(input.value);
  render(todos);
} catch (err) {
  showToast(String(err)); // Go 가 반환한 에러 메시지
}

여기서 오류를 두 부류로 나누는 기준을 세워 두면 코드가 정리됩니다.

  • 예상 가능한 오류 — 빈 입력, 잘못된 형식처럼 사용자가 고칠 수 있는 문제입니다. 사용자에게 보여 줄 문장으로 메시지를 만들어 반환합니다.
  • 예상 밖 오류 — 디스크 쓰기 실패, 손상된 파일처럼 사용자가 어쩔 수 없는 문제입니다. 사용자에게는 짧은 안내만 보여 주고, 상세 내용은 로그로 남깁니다.

로그 — 배포 후를 위한 준비 #

개발 중에는 터미널이 보이지만, 배포된 앱에는 터미널이 없습니다. 문제가 생겼을 때 들여다볼 기록을 파일로 남겨 둡니다. Wails 는 로거를 교체할 수 있는 옵션을 제공합니다.

main.go — 파일 로거
import "github.com/wailsapp/wails/v2/pkg/logger"

err := wails.Run(&options.App{
	Title:  "todo",
	Logger: logger.NewFileLogger(logPath), // 설정 디렉터리 아래 경로 권장
	// ...
})

앱 코드에서는 runtime.LogInfo(ctx, ...), runtime.LogError(ctx, ...) 계열 함수로 기록합니다. 예상 밖 오류를 만나는 지점마다 LogError 한 줄을 남겨 두면, 사용자에게 로그 파일 하나만 받아도 원인 파악이 가능해집니다.

외부 API 호출은 Go 쪽에서 #

날씨, 환율처럼 외부 API 를 부르는 기능이 필요하다면 프론트엔드의 fetch 대신 Go의 net/http 로 호출해 바인딩 메서드로 노출하는 구성을 권합니다. 이유는 두 가지입니다.

  • CORS 가 없습니다. WebView 의 fetch 는 브라우저와 같은 출처 제약을 받지만, Go 의 HTTP 클라이언트는 일반 프로그램의 네트워크 요청이라 제약이 없습니다.
  • 키가 숨겨집니다. API 키를 프론트엔드 코드에 두면 배포 파일에서 그대로 노출됩니다. Go 쪽에 두면 바이너리 안에 있고, 요청 조립도 서버 코드처럼 다룰 수 있습니다.
노트
바이너리에 포함된 키도 리버스 엔지니어링으로 추출될 수 있으므로, 과금이 걸린 중요한 키라면 데스크톱 앱에 직접 넣지 않고 중계 서버를 두는 것이 원칙입니다. Go 쪽 호출은 CORS 와 노출 문제를 줄여 줄 뿐 완전한 은닉은 아닙니다.

마무리 #

이번 글의 핵심은 세 가지입니다.

  • 데이터는 os.UserConfigDir 아래 앱 디렉터리에 저장합니다. 세 OS 에서 같은 코드가 올바른 경로를 가리킵니다.
  • Go 메서드의 error 는 프론트엔드의 거부된 Promise 가 됩니다. 사용자가 고칠 수 있는 오류는 문장으로, 예상 밖 오류는 로그로 보냅니다.
  • 외부 API 호출은 Go 쪽에 두면 CORS 와 키 노출 문제가 줄어듭니다.

이제 앱이 기능적으로 완성됐습니다. 다음 글인 “Wails로 데스크톱 앱 만들기 #6 빌드와 배포 — 플랫폼별 패키징"에서는 이 앱을 실행 파일로 만들어 다른 사람의 컴퓨터에 전달하는 마지막 단계를 다루고 시리즈를 마치겠습니다.

X