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, 두 번째는 토큰이 제거된paramsvalidate— 토큰 검증 함수
- 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.ts의 DesktopApi 인터페이스에도 타입 선언을 추가하세요.
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 |
| 프론트엔드 Hook | packages/renderer/src/features/<feature>/hooks/ (크로스 기능 Hook은 shared/hooks/에) |
| 프론트엔드 컴포넌트 | packages/renderer/src/features/<feature>/components/ (공유 UI는 components/ui/에) |
| i18n | packages/renderer/src/locales/ |