Wails 실전 강좌 #9 자동 업데이트 구현 — 새 버전을 안전하게 전달

5 분 소요

#6에서 자동 업데이트는 전략만 이야기하고 구현은 미뤘습니다. 이번 글이 그 미룬 것을 실제 코드로 만듭니다. 배포한 앱은 언젠가 새 버전이 나오는데, 사용자가 그걸 알 방법이 없으면 대부분 옛 버전에 머뭅니다. 다만 자동 업데이트는 잘못 만들면 #5에서 힘들게 쌓은 서명의 신뢰를 스스로 깨뜨리므로, 안전한 선을 지키며 구현합니다.

이 시리즈는 총 10편, 2부 구성입니다.

먼저 버전을 심는다 #

업데이트 확인의 출발점은 “지금 실행 중인 앱이 몇 버전인가"입니다. 버전 문자열을 소스에 상수로 두는 방법도 있지만, #6의 CI에서 태그로 릴리스를 만드는 흐름과 맞추려면 빌드 시점에 태그를 버전으로 주입하는 편이 깔끔합니다. Go의 -ldflags가 빌드 때 변수 값을 채워 줍니다.

version.go — 빌드 시 주입되는 버전
package main

// 빌드 시 -ldflags "-X main.Version=v1.2.0" 로 채운다
var Version = "dev"

CI에서는 git tag가 곧 버전이므로, 워크플로가 태그 값을 -ldflags로 넘겨 빌드하면 릴리스와 앱 안의 버전이 항상 일치합니다. 개발 중에는 기본값 dev로 남아 업데이트 확인을 건너뛰게 할 수 있습니다.

최신 버전 조회 #

GitHub Releases는 최신 릴리스를 알려주는 API를 제공합니다. 앱이 시작할 때(또는 사용자가 “업데이트 확인"을 누를 때) 이 API를 불러 최신 태그를 받고, 지금 버전과 비교합니다.

update.go — 최신 릴리스 태그 조회
type release struct {
	TagName string `json:"tag_name"`
	HTMLURL string `json:"html_url"`
}

func latestRelease(ctx context.Context) (release, error) {
	url := "https://api.github.com/repos/OWNER/REPO/releases/latest"
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return release{}, err
	}
	defer resp.Body.Close()

	var r release
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return release{}, err
	}
	return r, nil
}

버전 비교는 문자열 비교로 하면 안 됩니다. v1.10.0v1.9.0보다 작다고 잘못 판단하기 때문입니다. semver(유의적 버전) 규칙으로 비교해야 하며, golang.org/x/mod/semver 같은 표준 계열 라이브러리를 쓰면 안전합니다.

현재 버전보다 높을 때만 업데이트 알림
func (a *App) CheckForUpdate() {
	if Version == "dev" {
		return // 개발 빌드는 확인하지 않는다
	}
	r, err := latestRelease(a.ctx)
	if err != nil {
		runtime.LogWarning(a.ctx, "업데이트 확인 실패: "+err.Error())
		return // 실패는 조용히 넘긴다 — 앱 사용을 막지 않는다
	}
	if semver.Compare(r.TagName, Version) > 0 {
		runtime.EventsEmit(a.ctx, "update:available", r) // 프론트에 알림
	}
}

업데이트 확인 실패는 앱을 막지 않습니다. 네트워크가 없거나 GitHub이 응답하지 않아도 노트 앱 자체는 오프라인으로 멀쩡히 돌아야 하므로, 실패는 로그만 남기고 조용히 넘깁니다.

알림형이 안전한 기본값이다 #

새 버전이 있으면 무엇을 할까요. #6에서 두 갈래를 봤습니다. 개인·소규모의 안전한 기본값은 알림형입니다. 앱 안에 “새 버전 v1.2.0이 있습니다 — 릴리스 열기” 같은 배너를 띄우고, 사용자가 누르면 서명·공증된 설치 파일이 올라간 릴리스 페이지를 브라우저로 엽니다.

frontend — 업데이트 배너
import { BrowserOpenURL } from "../wailsjs/runtime/runtime";

EventsOn("update:available", (r) => {
  showBanner(`새 버전 ${r.tag_name}이 있습니다`, () => {
    BrowserOpenURL(r.html_url); // 릴리스 페이지를 브라우저로
  });
});

이 방식의 장점은 #5의 서명 흐름과 충돌하지 않는다는 것입니다. 사용자는 언제나처럼 서명된 설치 파일을 내려받아 설치하므로, OS의 검증이 그대로 작동합니다. 구현이 단순하고 깨질 구석이 적습니다.

완전 자동 교체가 위험한 이유 #

새 바이너리를 앱이 스스로 내려받아 교체하는 완전 자동 방식은 매끄러워 보이지만, 실전에서는 지켜야 할 것이 많습니다.

  • 서명 검증: 내려받은 바이너리가 진짜 내 서명이 맞는지 검증하지 않으면, 중간자 공격으로 악성 바이너리가 끼어들 수 있습니다. 자동 교체는 #5의 신뢰를 오히려 깎을 위험을 안습니다.
  • 권한과 위치: 설치 경로(Program Files, Applications)는 쓰기 권한이 없는 경우가 많아, 자기 파일을 자기가 덮어쓰기가 플랫폼마다 까다롭습니다.
  • 롤백: 교체 도중 실패하면 앱이 실행 불능이 될 수 있어, 실패 시 이전 버전으로 되돌리는 안전장치가 필요합니다.

그래서 완전 자동은 검증·권한·롤백을 모두 감당할 준비가 됐을 때만, 그리고 사용자 규모가 커서 수동 안내가 부담이 될 때만 갑니다. 그 전까지는 알림형이 정답입니다. 직접 만들기보다 검증된 업데이트 프레임워크를 붙이는 편이 안전한 경우가 많다는 것도 함께 기억합니다.

버전 흐름 한눈에 #

지금까지가 하나로 이어집니다. #6에서 git tag v1.2.0을 밀면 CI가 그 태그를 -ldflags로 앱에 주입해 빌드하고, 서명·공증해 릴리스에 올립니다. 사용자의 앱은 시작할 때 자기 버전(v1.1.0)과 릴리스의 최신 태그(v1.2.0)를 semver로 비교해, 높으면 배너로 알립니다. 태그 하나가 빌드·버전·업데이트 알림을 관통하는 이 일관성이, 앞의 편들을 따로 만들지 않고 엮어 온 이유입니다.

정리 #

  • 업데이트 확인의 출발점은 앱의 현재 버전입니다. CI의 태그를 -ldflags로 빌드 때 주입해 릴리스와 앱 버전을 일치시킵니다.
  • GitHub Releases API로 최신 태그를 조회하고, 문자열이 아니라 semver 규칙으로 비교합니다. 확인 실패는 앱을 막지 않고 조용히 넘깁니다.
  • 개인·소규모의 안전한 기본값은 알림형입니다. 배너로 알리고 서명된 릴리스 페이지를 열어, #5의 서명 검증을 그대로 살립니다.
  • 완전 자동 교체는 서명 검증·권한·롤백을 모두 감당해야 하며, 잘못하면 서명의 신뢰를 깎습니다. 규모가 커질 때만 검토합니다.
  • 태그 하나가 빌드·버전 주입·업데이트 알림을 관통합니다. 다음 글에서 설정·다크모드·데이터 백업으로 앱을 마감합니다.
X