Wails 앱이 프로덕션에서 터지는 이유와 저수준 제어법
July 26, 2026
0
컴퓨터/소프트웨어Comments (0)
Log in to leave a comment
No posts yet
Log in to leave a comment
No posts yet
Go로 데스크톱 앱을 만들 때 Wails는 매력적인 선택지다. 일렉트론처럼 Chromium을 통째로 패키징하지 않으니 가볍고 빠르다. 하지만 튜토리얼을 벗어나 진짜 서비스를 만들려고 하면 바로 벽에 부딪힌다. CGo 메모리가 새기 시작하고, 윈도우와 맥에서 웹뷰가 다르게 움직인다.
C/C++나 Objective-C 연동 경험이 없는 백엔드 개발자에게 이 지점은 통곡의 벽이다. 공식 문서의 화려한 예제 뒤에 숨겨진 메모리 누수와 OS별 웹뷰 파편화를 잡지 못하면 프로덕션 배포는 불가능하다.
CGo를 쓸 때 가장 많이 하는 착각이 Go의 가비지 컬렉터가 C 영역까지 챙겨줄 거라는 기대다. 당연히 안 챙겨준다. C.CString이나 C.malloc으로 할당한 메모리는 C 영역에 남아서 애플리케이션이 죽을 때까지 메모리를 먹어치운다.
Go 슬라이스를 C 함수로 넘길 때도 주의해야 한다. 슬라이스 헤더 자체의 주소를 넘기면 메모리 오염이 일어난다. 첫 번째 요소의 실제 주소인 unsafe.Pointer(&slice[0])를 넘겨야 안전하다. macOS의 Objective-C 코드를 호출할 때는 ARC를 믿으면 안 된다. CGo 스레드 루프 안에서 생성된 객체가 NSAutoreleasePool에 계속 쌓인다. Explicit하게 @autoreleasepool { ... } 블록으로 감싸서 바로바로 털어줘야 한다.
윈도우 환경이라면 굳이 CGo 컴파일러(MinGW)를 꼬리표처럼 달고 다닐 필요가 없다. CGo 오버헤드 없이 syscall 패키지로 DLL을 직접 때리면 그만이다. dwmapi.dll을 불러와서 다크 모드를 켜는 코드는 생각보다 간단하다.
// system_windows.go
//go:build windows
package native
import (
"syscall"
"unsafe"
)
var (
modDwmApi = syscall.NewLazyDLL("dwmapi.dll")
procDwmSetWindowAttribute = modDwmApi.NewProc("DwmSetWindowAttribute")
)
const DWMWA_USE_IMMERSIVE_DARK_MODE = 20
func SetWindowsDarkMode(hwnd uintptr, enable bool) error {
var val int32
if enable {
val = 1
}
ret, _, err := procDwmSetWindowAttribute.Call(
hwnd,
uintptr(DWMWA_USE_IMMERSIVE_DARK_MODE),
uintptr(unsafe.Pointer(&val)),
uintptr(unsafe.Sizeof(val)),
)
if ret != 0 {
return err
}
return nil
}
네이티브 제어 모듈을 만들 때는 먼저 공통 인터페이스(system_interface.go)를 정의한다. 그리고 macOS 구현체(system_darwin.go)에는 //go:build darwin 지시어와 함께 Objective-C 로직을 넣고, 윈도우 구현체에는 //go:build windows와 함께 Pure-Go Syscall을 작성해 분리한다. CGo 할당 직후 defer C.free를 붙이는 습관만 들여도 메모리 누수로 앱이 꺼지는 일은 없다.
Wails는 OS에 이미 설치된 웹뷰를 가져다 쓴다. macOS는 WebKit(Safari), Windows는 WebView2(Chromium)다. 바이너리 용량이 15MB 안팎으로 줄어드는 대가로, 브라우저 엔진 간의 파편화를 직접 감당해야 한다.
예를 들어 타이틀바가 없는 프레임리스 창에서 드래그 영역을 설정할 때 WebView2는 --wails-draggable: drag만 주면 작동하지만, WebKit은 -webkit-app-region: drag를 같이 명시하지 않으면 창이 움직이지 않는다.
이벤트 처리에서도 사고가 난다. Go 고루틴에서 runtime.EventsEmit을 초당 수천 번 던지면 웹뷰의 단일 UI 스레드가 비명을 지르며 화면이 먹통이 된다. 백엔드에서 60fps(약 16ms) 주기로 이벤트를 스로틀링하는 버퍼를 둬야 한다. 프론트엔드에서 사용자가 F5를 눌러 상태를 날려버리거나 우클릭 메뉴가 뜨는 불상사도 전역 패치로 막아야 한다.
// eventPatch.ts
export function applyGlobalUIFixes() {
window.addEventListener('contextmenu', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
e.preventDefault();
}
});
window.addEventListener('keydown', (e: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (e.key === 'F5' || (modifier && e.key.toLowerCase() === 'r')) {
e.preventDefault();
e.stopPropagation();
}
});
}
CSS에는 두 엔진용 드래그 속성을 동시에 적어두고, 앱 진입점(main.ts나 App.tsx)에서 applyGlobalUIFixes()를 실행한다. 백엔드 이벤트 발행부에는 스로틀링 타이머를 걸어둔다. OS별 웹뷰 특성 때문에 생기는 이상 동작은 이 정도 장치만 갖춰도 대부분 정돈된다.
Wails 앱은 평소 RAM을 35MB~50MB 정도 쓴다. 200MB 넘게 먹는 일렉트론에 비하면 양반이다. 문제는 대용량 파일이나 바이너리 데이터를 프론트엔드로 넘길 때 발생한다.
기본 제공되는 JSON RPC 바인딩으로 50MB짜리 데이터를 주고받으면 JSON 직렬화 과정에서 순간적으로 RAM 사용량이 180MB 이상으로 치솟는다. 이 현상을 피하려면 AssetServer.AssetsHandler 옵션을 써서 커스텀 HTTP 스트리밍을 구현해야 한다. 메모리 복사 없는 Zero-copy 방식으로 데이터를 넘기면 유휴 및 작업 메모리를 22MB~30MB 선에서 묶어둘 수 있다.
윈도우 사용자 환경 대응도 필요하다. WebView2 런타임이 안 깔린 클라이언트를 위해 빌드할 때 -webview2 download 플래그를 넣어 부트스트래퍼를 함께 묶어준다.
name: Multiplatform Release Build
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
platform: darwin/universal
output_name: OptimizedApp-macOS-Universal
- os: windows-latest
platform: windows/amd64
output_name: OptimizedApp-Windows-Installer
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build macOS Universal Binary
if: runner.os == 'macOS'
run: wails build -platform darwin/universal -clean
- name: Build Windows Installer
if: runner.os == 'Windows'
run: |
choco install nsis -y
wails build -platform windows/amd64 -nsis -webview2 download -clean
main.go에서 AssetServer.AssetsHandler에 custom http.Handler를 연결해 대용량 메모리 튀는 현상을 잡는다. 그리고 .github/workflows/release.yml에 위의 파이프라인을 넣어둔다. 태그를 올릴 때마다 macOS 유니버설 바이너리와 윈도우 NSIS 설치 파일이 생성되어 GitHub Releases에 올라가는 구조다.
저수준 영역의 메모리 관리를 직접 챙기고 웹뷰 엔진 차이를 코드로 흡수하면 Wails로도 단단한 데스크톱 앱을 만들 수 있다.