Wails로 데스크톱 앱 만들기 #3 Go와 프론트엔드 연결 — 메서드 바인딩과 이벤트
지난 글에서 app.go의 메서드가 wailsjs의 자바스크립트 함수로 노출되는 흐름을 확인했습니다. 이번 글은 그 연결을 본격적으로 씁니다. 바인딩의 규칙을 정확히 정리하고, 할 일 목록 백엔드를 만들어 React 프론트엔드와 연결한 뒤, 반대 방향 통신인 이벤트까지 다룹니다. 예제는 react-ts 템플릿 기준입니다.
총 8편(본편 6편 + 심화 2편)입니다.
- #1 Wails란 — Go로 만드는 가벼운 데스크톱 앱
- #2 프로젝트 구조와 개발 루프 — wails dev와 바인딩
- #3 Go와 프론트엔드 연결 — 메서드 바인딩과 이벤트 ← 이번 글
- #4 시스템 통합 — 다이얼로그·메뉴·창 제어
- #5 실전 기능 — 설정 저장과 에러 처리
- #6 빌드와 배포 — 플랫폼별 패키징
바인딩 규칙 — 무엇이 어떻게 노출되는가 #
Bind에 등록한 구조체에서 프론트엔드로 노출되는 것은 공개 메서드(대문자로 시작)뿐입니다. Go 쪽 시그니처가 자바스크립트 쪽에서 어떻게 보이는지 규칙을 표로 정리합니다.
| Go 메서드 | 자바스크립트에서 |
|---|---|
func (a *App) Do() | Do() — Promise<void> |
func (a *App) Do() string | Do() — Promise<string> 로 resolve |
func (a *App) Do() (string, error) | error 가 nil 이면 resolve, 아니면 reject |
func (a *App) Do(n int, s string) | Do(n, s) — 인자 순서 그대로 |
| 소문자 시작 메서드 | 노출되지 않음 |
핵심은 error의 매핑입니다. Go의 관례대로 마지막 반환값을 error로 두면, 프론트엔드에서는 Promise의 reject로 넘어와 try/catch로 처리할 수 있습니다. 양쪽 언어의 에러 처리 관례가 자연스럽게 이어지는 설계입니다.
실습 — 할 일 목록 백엔드 #
메모리에 할 일을 보관하는 백엔드를 만듭니다. app.go에 타입과 메서드를 추가합니다.
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type App struct {
ctx context.Context
todos []Todo
nextID int
mu sync.Mutex
}
func (a *App) AddTodo(title string) (Todo, error) {
if strings.TrimSpace(title) == "" {
return Todo{}, errors.New("제목이 비어 있습니다")
}
a.mu.Lock()
defer a.mu.Unlock()
a.nextID++
todo := Todo{ID: a.nextID, Title: title}
a.todos = append(a.todos, todo)
return todo, nil
}
func (a *App) ListTodos() []Todo {
a.mu.Lock()
defer a.mu.Unlock()
return a.todos
}
func (a *App) DeleteTodo(id int) {
a.mu.Lock()
defer a.mu.Unlock()
a.todos = slices.DeleteFunc(a.todos, func(t Todo) bool {
return t.ID == id
})
}일반적인 Go 코드입니다. 데스크톱 앱이라고 특별한 것은 없고, 고루틴에서 접근될 수 있으므로 뮤텍스로 보호한 정도가 전부입니다. 저장이 메모리라 앱을 끄면 사라지는데, 파일로 남기는 것은 #5에서 해결합니다.
프론트엔드에서 호출 #
wails dev를 실행하면 wailsjs에 세 함수가 생성됩니다. React 컴포넌트에서 그대로 가져다 씁니다.
import { useEffect, useState } from 'react';
import { AddTodo, ListTodos, DeleteTodo } from '../wailsjs/go/main/App';
import { main } from '../wailsjs/go/models';
function App() {
const [todos, setTodos] = useState<main.Todo[]>([]);
const [title, setTitle] = useState('');
useEffect(() => {
ListTodos().then(setTodos);
}, []);
async function handleAdd() {
try {
await AddTodo(title);
setTitle('');
setTodos(await ListTodos());
} catch (err) {
alert(err); // Go 가 돌려준 error 메시지
}
}
// ...입력란과 목록 렌더링
}AddTodo가 빈 제목으로 호출되면 Go 쪽에서 error를 반환하고, 프론트엔드에서는 catch로 잡힙니다. 서버 없이도 백엔드와 프론트엔드가 한 프로세스 안에서 함수 호출로 이어지는 것이 Wails 개발의 감각입니다.
구조체와 TypeScript 모델 #
위 코드에서 main.Todo 타입을 import 한 부분이 눈에 띕니다. Wails는 바인딩된 메서드가 주고받는 구조체를 분석해 TypeScript 모델을 자동 생성합니다(wailsjs/go/models.ts). Go 구조체의 json 태그가 필드 이름을 결정하므로, 태그를 붙여 두면 프론트엔드에서 소문자 필드로 자연스럽게 다룰 수 있습니다. Go 타입을 바꾸면 모델도 다음 빌드에서 갱신되어, 양쪽 타입이 어긋나는 사고를 컴파일 단계에서 잡아 줍니다.
이벤트 — Go에서 프론트엔드로 밀어 보내기 #
바인딩 호출은 항상 프론트엔드가 시작합니다. 반대로 Go 쪽에서 먼저 알려야 하는 상황이 있습니다. 오래 걸리는 작업의 진행률이 대표적입니다. 이때 이벤트 시스템을 씁니다.
import "github.com/wailsapp/wails/v2/pkg/runtime"
func (a *App) ProcessFiles(paths []string) {
go func() {
for i, path := range paths {
process(path)
runtime.EventsEmit(a.ctx, "progress", map[string]any{
"done": i + 1,
"total": len(paths),
})
}
}()
}import { EventsOn } from '../wailsjs/runtime/runtime';
useEffect(() => {
const off = EventsOn('progress', (data) => {
setProgress(data);
});
return off; // 컴포넌트 정리 시 구독 해제
}, []);EventsEmit의 첫 인자로 쓰인 a.ctx가 #2에서 본 startup의 context입니다. Wails 런타임 함수들은 이 context를 통해 앱과 연결되므로, startup에서 context를 보관하는 관례는 앞으로 계속 등장합니다. 바인딩 호출이 고루틴 안에서 EventsEmit을 부르는 위 구조는 무거운 작업으로 UI가 멈추지 않게 하는 기본 패턴입니다.
마무리 #
이번 글의 핵심은 세 가지입니다.
- 바인딩은 공개 메서드만 노출하고, 반환값은 Promise resolve로, error는 reject로 매핑됩니다.
- 구조체는 json 태그 기준으로 TypeScript 모델이 자동 생성되어 양쪽 타입이 동기화됩니다.
- Go에서 먼저 알려야 하는 정보는 EventsEmit과 EventsOn의 이벤트로 전달하고, 무거운 작업은 고루틴에서 돌리며 진행률을 이벤트로 보고합니다.
다음 글인 “Wails로 데스크톱 앱 만들기 #4 시스템 통합 — 다이얼로그·메뉴·창 제어"에서는 데스크톱 앱다운 기능들을 붙입니다. 파일 열기 다이얼로그, 네이티브 메뉴, 창 제어처럼 브라우저에서는 못 하던 일들을 Wails 런타임으로 처리하겠습니다.