본문으로 건너뛰기

IPC 라우트 추가하기

이 가이드는 가상의 "즐겨찾기(Favorites)" 기능을 예시로 삼아, 타입 정의부터 프론트엔드 Hook까지 IPC 라우트 개발의 전체 흐름을 설명합니다.

통신 아키텍처 개요

Renderer Process (React)
| window.api.favorites.list()
v
Preload Script (contextBridge)
| ipcRenderer.invoke('favorites:list', authToken)
v
Main Process Router (BaseRouter.secureHandle)
| auth validation → param validation (Zod)
v
Main Process Service
| business logic → database operations
v
Return result → Preload → Renderer

모든 IPC 호출에는 자동으로 authToken이 포함되며, secureHandle이 중앙에서 이를 검증합니다 — 인증을 수동으로 처리할 필요가 없습니다.


Step 1: 공유 타입 정의

파일: packages/desktop/app/shared/contracts/favorites-types.ts

export interface Favorite {
id: string;
sessionId: string;
messageId: string;
note?: string;
createdAt: string;
}

export interface CreateFavoriteInput {
sessionId: string;
messageId: string;
note?: string;
}

export interface UpdateFavoriteInput {
note?: string;
}

공유 타입은 @shared/contracts/ 아래에 위치합니다. 프론트엔드와 백엔드 모두 @shared/contracts/favorites-types를 통해 임포트할 수 있어, 경계를 넘어 타입을 일관되게 유지합니다.


Step 2: 백엔드 서비스 생성

파일: packages/desktop/app/main/services/content/favorites/FavoritesService.ts

import type { DbRpc } from '../../workers/types';
import type {
CreateFavoriteInput,
Favorite,
UpdateFavoriteInput,
} from '@shared/contracts/favorites-types';

export class FavoritesService {
constructor(private readonly db: DbRpc) {}

async list(sessionId?: string): Promise<Favorite[]> {
return this.db.favorites_list({ sessionId });
}

async create(input: CreateFavoriteInput): Promise<Favorite> {
return this.db.favorites_create(input);
}

async update(id: string, input: UpdateFavoriteInput): Promise<Favorite> {
return this.db.favorites_update({ id, ...input });
}

async delete(id: string): Promise<void> {
return this.db.favorites_delete({ id });
}
}

:::warning 중요 외부 API 호출, 파일 시스템 작업, 데이터베이스 접근은 모두 서비스 계층에서 완료해야 합니다. 프론트엔드에서 이러한 작업에 직접 접근해서는 안 됩니다. :::


Step 3: 라우터 생성

파일: packages/desktop/app/main/services/routers/FavoritesRouter.ts

import { z } from 'zod';
import { secureHandle } from '../../ipc/safe-handle';
import type { AuthService } from '../platform/auth';
import type { FavoritesService } from '../../content/favorites/FavoritesService';

export class FavoritesRouter {
constructor(
private readonly auth: AuthService,
private readonly favorites: FavoritesService,
) {}

register(): void {
const validate = (token: string) => this.auth.validate(token);

secureHandle(
'favorites:list',
async (_event, params: unknown) => {
const { sessionId } = z.object({
sessionId: z.string().optional(),
}).parse(params ?? {});
return this.favorites.list(sessionId);
},
validate,
);

secureHandle(
'favorites:create',
async (_event, params: unknown) => {
const input = z.object({
sessionId: z.string(),
messageId: z.string(),
note: z.string().optional(),
}).parse(params);
return this.favorites.create(input);
},
validate,
);

secureHandle(
'favorites:update',
async (_event, params: unknown) => {
const { id, note } = z.object({
id: z.string(),
note: z.string().optional(),
}).parse(params);
return this.favorites.update(id, { note });
},
validate,
);

secureHandle(
'favorites:delete',
async (_event, params: unknown) => {
const { id } = z.object({
id: z.string(),
}).parse(params);
return this.favorites.delete(id);
},
validate,
);
}
}

핵심 패턴 설명:

  • secureHandle(channel, handler, validate) — 통합 보안 IPC 핸들러
    • channel — IPC 채널 이름 (형식: domain:action)
    • handler — 비동기 핸들러 함수; 첫 번째 인수는 event, 두 번째는 토큰이 제거된 params
    • validate — 토큰 검증 함수
  • Zod 검증: 악의적인 입력으로부터 보호하기 위해 모든 파라미터는 Zod 검증을 통과해야 합니다

Step 4: 라우터 등록

파일: packages/desktop/app/main/services/routers/index.ts

// 1. Router와 Service 임포트
import { FavoritesRouter } from './FavoritesRouter';
import { FavoritesService } from '../../content/favorites/FavoritesService';

// 2. registerAllRouters() 내부에서 인스턴스 생성 및 등록
export function registerAllRouters(deps: RouterDependencies) {
// ... 기존 라우터 등록 ...

// FavoritesService와 Router 생성
const favoritesService = new FavoritesService(deps.db);
const favoritesRouter = new FavoritesRouter(deps.auth, favoritesService);
favoritesRouter.register();

// ... 기타 코드 ...
}

서비스에 새로운 의존성이 필요한 경우 RouterDependencies 인터페이스도 함께 업데이트하세요.


Step 5: Preload를 통해 노출

파일: packages/desktop/app/preload/index.ts

api 객체에 새로운 네임스페이스를 추가합니다:

const api = {
// ... 기존 네임스페이스 ...

favorites: {
list: (sessionId?: string) =>
invoke('favorites:list', sessionId ? { sessionId } : undefined),
create: (input: { sessionId: string; messageId: string; note?: string }) =>
invoke('favorites:create', input),
update: (id: string, note?: string) =>
invoke('favorites:update', { id, note }),
delete: (id: string) =>
invoke('favorites:delete', { id }),
},
};

프론트엔드의 window.api가 올바른 타입 힌트를 얻을 수 있도록 @shared/contracts.tsDesktopApi 인터페이스에도 타입 선언을 추가하세요.


Step 6: 프론트엔드 Hook 생성

파일: packages/renderer/src/features/favorites/hooks/useFavorites.ts

import { useCallback, useEffect, useState } from 'react';

import type { Favorite } from '@shared/contracts/favorites-types';

export function useFavorites(sessionId?: string) {
const [favorites, setFavorites] = useState<Favorite[]>([]);
const [loading, setLoading] = useState(true);

const refresh = useCallback(async () => {
setLoading(true);
try {
const result = await window.api.favorites.list(sessionId);
setFavorites(result);
} finally {
setLoading(false);
}
}, [sessionId]);

useEffect(() => {
refresh();
}, [refresh]);

const addFavorite = useCallback(
async (messageId: string, note?: string) => {
if (!sessionId) return;
await window.api.favorites.create({ sessionId, messageId, note });
await refresh();
},
[sessionId, refresh],
);

const removeFavorite = useCallback(
async (id: string) => {
await window.api.favorites.delete(id);
await refresh();
},
[refresh],
);

return { favorites, loading, addFavorite, removeFavorite, refresh };
}

Step 7: UI 컴포넌트 생성

파일: packages/renderer/src/features/favorites/components/FavoritesList.tsx

import { Star, Trash2 } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { useFavorites } from '@/features/favorites/hooks/useFavorites';

interface FavoritesListProps {
sessionId: string;
}

export function FavoritesList({ sessionId }: FavoritesListProps) {
const { favorites, loading, removeFavorite } = useFavorites(sessionId);

if (loading) {
return <div className="animate-pulse p-4">Loading...</div>;
}

if (favorites.length === 0) {
return (
<div className="flex flex-col items-center gap-2 p-8 text-muted-foreground">
<Star className="h-8 w-8" />
<p>No favorites yet</p>
</div>
);
}

return (
<div className="space-y-2 p-4">
{favorites.map((fav) => (
<div
key={fav.id}
className="flex items-center justify-between rounded-lg bg-surface-1 p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{fav.note}</p>
<p className="text-xs text-muted-foreground">{fav.createdAt}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeFavorite(fav.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
);
}

Step 8: i18n 추가

en, zh, ja에 대한 번역 파일을 생성합니다:

파일: packages/renderer/src/locales/{en,zh,ja}/favorites.json

{
"favorites.title": "Favorites",
"favorites.empty": "No favorites yet",
"favorites.add": "Add to Favorites",
"favorites.remove": "Remove from Favorites",
"favorites.note": "Note"
}

체크리스트

PR을 제출하기 전에 다음 사항을 확인하세요:

  • 프론트엔드는 사용자 입력과 설정 파라미터만 전달하는가 (API 키 등 민감한 데이터 없음)
  • 백엔드가 모든 외부 호출(API, 파일 시스템, 데이터베이스)을 처리하는가
  • 프론트엔드에 반환되는 데이터는 최종 형태인가 (프론트엔드가 다시 처리하여 보낼 필요 없음)
  • base64 등 대용량 데이터를 프론트엔드에서 처리하여 백엔드로 다시 보내지 않는가
  • 라우터의 파라미터가 Zod로 검증되는가
  • 새로운 라우터가 registerAllRouters()에 등록되어 있는가
  • Preload API의 메서드 시그니처가 DesktopApi 타입 정의와 일치하는가
  • i18n 파일이 세 언어 모두 업데이트되었는가
  • npm run lint가 오류 없이 통과되는가

파일 빠른 참조

계층경로
공유 타입packages/desktop/app/shared/contracts/
백엔드 서비스packages/desktop/app/main/services/
IPC 라우터packages/desktop/app/main/services/routers/
라우터 등록packages/desktop/app/main/services/routers/index.ts
Preload 스크립트packages/desktop/app/preload/index.ts
프론트엔드 Hookpackages/renderer/src/features/<feature>/hooks/ (크로스 기능 Hook은 shared/hooks/에)
프론트엔드 컴포넌트packages/renderer/src/features/<feature>/components/ (공유 UI는 components/ui/에)
i18npackages/renderer/src/locales/