앱에 SNA 내장하기
실제 앱 안에 SNA를 내장할 때 쓰는 제품 통합 패턴입니다.
SNA는 일회성 chat endpoint가 아니라 local/cloud agent runtime으로 앱 안에 내장할 수 있습니다. 제품이 project, tab, document, permission, background workflow를 agent 주변에 가지고 있을 때 아래 패턴이 유용합니다.
1. 앱 세션과 SNA 세션을 분리하기
제품에는 보통 자체 session 또는 project row가 있습니다. 그 row를 사용자에게 보이는 객체로 유지하고, 현재 붙어 있는 SNA session id를 별도 필드로 저장합니다.
type AppSession = {
// Product가 소유하는 안정적인 identity입니다. Rename/archive/fork는 이 id를 기준으로 처리합니다.
id: string;
// User-facing label입니다. Runtime process label과 분리해두면 migration이 쉬워집니다.
name: string;
// Local project가 없을 수도 있으므로 nullable로 둡니다.
projectPath: string | null;
// 현재 붙어 있는 SNA head입니다. Runtime switch나 fork 때 바뀔 수 있습니다.
snaSessionId: string | null;
// Local SNA와 cloud SNA instance를 같은 product model로 다루기 위한 field입니다.
snaInstanceId: "local" | string;
};이 분리 덕분에 앱은 runtime process를 제품 객체로 착각하지 않고 rename, archive, move, fork를 처리할 수 있습니다. 앱 세션의 identity를 유지한 채 local SNA에서 cloud SNA로 옮기는 것도 가능합니다.
한 앱 세션이 시간이 지나며 여러 SNA session head를 만들 수 있다면 link table을 두는 패턴이 다루기 쉽습니다.
CREATE TABLE session_sna_links (
session_id TEXT NOT NULL,
sna_session_id TEXT NOT NULL,
instance_id TEXT NOT NULL DEFAULT 'local',
source TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, sna_session_id)
);2. SNA instance마다 SnaClient 하나 사용하기
WebSocket state는 connection 단위입니다. 앱이 local SNA와 cloud SNA instance를 동시에 다룰 수 있다면 instance마다 client를 cache하고, event는 하나의 listener set으로 fan-in합니다.
import { SnaClient } from "@sna-sdk/client";
// Instance id마다 client를 하나만 만듭니다. WebSocket 중복 연결을 막는 역할입니다.
const clients = new Map<string, SnaClient>();
type SnaConnection = {
// Local embedded SNA라면 startSnaServer()가 반환한 값을 사용합니다.
baseUrl: string;
authToken: string;
};
// UI는 특정 client가 아니라 app-level listener set에 subscribe합니다.
const eventListeners = new Set<(event: unknown) => void>();
const permissionListeners = new Set<(event: unknown) => void>();
function installAggregator(client: SnaClient) {
// Runtime event를 하나의 app event bus로 모읍니다.
client.agent.onEvent((event) => {
for (const listener of eventListeners) listener(event);
});
// Permission request도 client별로 들어오므로 같은 방식으로 fan-in합니다.
client.agent.onPermissionRequest((event) => {
for (const listener of permissionListeners) listener(event);
});
}
export function snaFor(instanceId: string, connection: SnaConnection): SnaClient {
// 이미 연결된 instance라면 같은 client를 재사용합니다.
const existing = clients.get(instanceId);
if (existing) return existing;
const client = new SnaClient(connection);
clients.set(instanceId, client);
installAggregator(client);
// connect와 permission subscription은 client 생성 직후 한 번만 수행합니다.
client.connect();
client.agent.subscribePermissions().catch(() => {});
return client;
}
export function onAgentEventAll(listener: (event: unknown) => void) {
// React hook에서 cleanup으로 바로 쓸 수 있도록 unsubscribe 함수를 반환합니다.
eventListeners.add(listener);
return () => eventListeners.delete(listener);
}핵심은 UI component가 특정 WebSocket이 아니라 앱 레벨 aggregator에 subscribe한다는 점입니다. 나중에 cloud instance가 추가되어도 UI를 remount하지 않고 해당 client를 aggregator에 붙일 수 있습니다.
3. Command는 host를 통하고 event는 SNA로 직접 받기
제품 앱에서는 보통 경로를 이렇게 분리하면 다루기 쉽습니다.
| Path | Route | 이유 |
|---|---|---|
| Lifecycle command | renderer → app host HTTP → SNA HTTP | Host가 앱 DB에서 config를 만들고, cloud instance를 깨우고, model selection을 reconcile하고, 제품 context를 주입할 수 있습니다. |
| Event stream | renderer → SNA WebSocket | 모든 token을 앱 서버로 proxy하지 않고 UI가 낮은 latency로 runtime event를 받습니다. SNA auth token은 host의 신뢰할 수 있는 채널로 전달합니다. |
Renderer에는 raw SNA call보다 domain wrapper를 노출하는 식으로 경계를 잡습니다.
export async function ensureAgentAlive(snaSessionId: string, isNewSession = false) {
// Renderer는 SNA id를 받더라도 product session으로 다시 resolve합니다.
const appSession = sessionStore.findBySnaId(snaSessionId);
// Host endpoint가 cwd, model, cloud wake, context injection 같은 product logic을 소유합니다.
await appFetch("/agent/ensure", {
method: "POST",
body: JSON.stringify({
appSessionId: appSession.id,
snaSessionId,
isNewSession,
}),
});
}
export async function sendMessage(snaSessionId: string, message: string) {
// Send도 host를 거치면 pending model/runtime change를 user-turn 경계에서 적용할 수 있습니다.
return appFetch("/agent/send", {
method: "POST",
body: JSON.stringify({ snaSessionId, message }),
});
}
export function subscribeAgent(snaSessionId: string) {
// Event stream은 token latency를 줄이기 위해 SNA WebSocket에 직접 붙습니다.
return clientForSnaSession(snaSessionId).agent.subscribe(snaSessionId, { since: 0 });
}Component는 ensureAgentAlive, sendMessage, subscribeAgent 같은 제품 verb만 알면 됩니다.
4. /agent/ensure를 idempotent하게 만들기
Start-vs-resume 판단은 host가 소유하게 두면 흐름이 단순해집니다. UI가 runtime history 유무, cloud wake 필요 여부, remote container 안에서 유효한 cwd를 판단하지 않게 두면 안정적입니다.
async function ensureAgent({ appSessionId, requestedSnaId, isNewSession }) {
// App session을 기준으로 local/cloud SNA instance를 고릅니다.
// `instance`는 SnaClient connection을 감싼 host-owned wrapper입니다.
const instance = snaForAppSession(appSessionId);
await wakeIfCloudInstance(instance);
// 요청 id가 없으면 product DB에 저장된 SNA id를 쓰거나 새로 만듭니다.
const snaSessionId = requestedSnaId ?? await loadOrCreateSnaId(appSessionId);
if (await instance.isAlive(snaSessionId)) {
return { status: "already_alive", snaSessionId };
}
// Agent config는 host에서 조립합니다. UI가 model args나 cwd를 직접 만들지 않습니다.
const { config, cwd } = await buildAgentConfig(appSessionId, snaSessionId);
await instance.createSession({
id: snaSessionId,
label: config.label ?? appSessionId,
cwd,
meta: debugMode ? { langfuseTrace: true } : undefined,
});
const hasHistory = await instance.getMessages(snaSessionId, { limit: 1 })
.then((res) => res.ok && res.data.messages.length > 0)
.catch(() => false);
// 새 session이거나 history가 없으면 start, 기존 head라면 resume으로 복구합니다.
return isNewSession || !hasHistory
? instance.startAgent(snaSessionId, { ...config, force: true, cwd })
: instance.resumeAgent(snaSessionId, config);
}이 endpoint는 tab focus, reload recovery, send preflight에서 반복 호출되어도 안전해야 합니다.
5. 제품 context는 user-turn 경계에서 주입하기
Metadata를 별도 model turn으로 보내기보다, 사용자 message를 forwarding하기 직전에 context를 만들고 그 message에 제품 소유 tag를 prepend/append하면 runtime history가 깨끗합니다.
async function sendAgentMessage({ snaSessionId, message }) {
let finalMessage = message;
// Pending model/runtime 선택은 실제 user turn 직전에 reconcile합니다.
await reconcileSelectedModel(snaSessionId);
// 한 번만 소비되어야 하는 system metadata는 user turn에 붙여 보냅니다.
const pendingMeta = consumePendingSystemMeta(snaSessionId);
if (pendingMeta) finalMessage = `${pendingMeta}\n${finalMessage}`;
// Retrieval context도 현재 user message 기준으로 계산해야 stale context가 줄어듭니다.
const retrievalContext = await maybeBuildRetrievalContext(snaSessionId, finalMessage);
if (retrievalContext) finalMessage += `\n${retrievalContext}`;
// 최종적으로 agent가 보는 것은 context가 붙은 실제 user turn입니다.
return snaForSnaSession(snaSessionId).sendMessage(snaSessionId, finalMessage);
}이렇게 하면 runtime history가 깨끗하게 유지됩니다. Start/resume 이후 agent가 처음 보는 것은 앱의 현재 world로 보강된 실제 사용자 turn입니다.
6. 비용 큰 상태 변경은 send 시점까지 미루기
Context folding, model change, runtime switch 같은 pending operation은 다음 user-turn 경계에서 적용합니다. 사용자가 보낸 직후 model이 응답하기 전의 자연스러운 대기 시간이 kill/resume/replay 비용을 가려줍니다.
안전한 순서는 다음과 같습니다.
- Active SNA head를 resolve합니다.
- Pending fork/fold operation을 적용합니다.
- 선택된 model/runtime을 reconcile합니다.
- 최종 message context를 만듭니다.
sendMessage를 호출합니다.- User message가 persist된 뒤 session 변경을 broadcast합니다.
User message가 저장되기 전에 새 SNA head를 broadcast하면 renderer가 optimistic user message가 없는 timeline에 먼저 resubscribe할 수 있습니다.
7. Permission은 모든 client에서 subscribe하기
Permission subscription은 WebSocket별입니다. Permission dialog가 mount된 뒤 cloud client가 생성될 수 있다면, 새 client에도 subscribe해야 합니다.
export async function subscribePermissions() {
await Promise.all(
[...clients.values()].map((client) =>
client.agent.subscribePermissions().catch(() => {}),
),
);
}
export function respondPermission(snaSessionId: string, approved: boolean) {
return clientForSnaSession(snaSessionId)
.agent
.respondPermission(snaSessionId, approved);
}이 처리가 없으면 cloud instance의 agent가 tool decision을 기다리는데 UI는 local SNA에만 subscribe한 상태가 될 수 있습니다.
8. React subscription은 app root에 유지하기
SNA event bridge는 chat surface보다 위에 mount해두면 route unmount에 안전합니다. Chat pane, route, side panel은 agent가 streaming 중일 때도 unmount될 수 있습니다. Root-level hook이 WebSocket listener를 유지하고 event를 store로 전달하면 안정적입니다.
function AppShell() {
// App boot 때 product sessions를 먼저 hydrate합니다.
const hydrateSessions = useSessionStore((state) => state.hydrate);
const sessions = useSessionStore((state) => state.sessions);
// Permission dialog는 현재 route뿐 아니라 모든 known session을 볼 수 있어야 합니다.
const sessionIds = useMemo(
() => sessions.flatMap((session) => session.snaSessionId ? [session.snaSessionId] : []),
[sessions],
);
// 이 hook은 route보다 오래 살아야 하므로 app root에서 호출합니다.
useAgentEventStream();
const { pending, clearPending } = usePermissionRequests(sessionIds);
useEffect(() => {
// SNA connection lifecycle도 root component가 소유합니다.
hydrateSessions();
connectSna();
return () => disconnectSna();
}, [hydrateSessions]);
return (
<>
<AppRoutes />
{pending[0] && (
<PermissionDialog
request={pending[0]}
onDone={() => clearPending(pending[0].sessionId)}
/>
)}
</>
);
}Event hook은 한 번만 listener를 등록하고, session list가 바뀔 때 보이는/알려진 SNA session들을 subscribe해야 합니다.
function useAgentEventStream() {
const sessions = useSessionStore((state) => state.sessions);
const register = useMessageStore((state) => state.register);
const handleEvent = useMessageStore((state) => state.handleEvent);
useEffect(() => {
// Listener는 한 번만 설치하고, 들어오는 event는 store에 정규화해서 넣습니다.
return onAgentEvent(({ session, cursor, event, isHistory }) => {
handleEvent(session, { cursor, event, isHistory });
});
}, [handleEvent]);
useEffect(() => {
const cleanups = sessions
.filter((session) => session.snaSessionId)
.map((session) => {
// Product session id와 SNA session id의 mapping을 store에 알려줍니다.
register(session.snaSessionId!, session.id);
// Mount 시 최근 event를 tail로 복구하고 이후 stream을 이어받습니다.
subscribeAgent(session.snaSessionId!, { since: 0, tail: 200 }).catch(() => {});
// Session list가 바뀌면 이전 subscription을 정리합니다.
return () => unsubscribeAgent(session.snaSessionId!).catch(() => {});
});
return () => cleanups.forEach((cleanup) => cleanup());
}, [sessions, register]);
}Permission UI도 같은 이유로 root에 mount하는 편이 안전합니다. 이것은 현재 선택된 chat panel의 자식 상태가 아니라 제품 상태입니다.
언제 쓰나
| 상황 | 이 패턴 사용? |
|---|---|
| 제품 DB가 없는 단일 CLI wrapper | 보통은 필요 없습니다. SNA를 직접 호출하는 편이 단순합니다. |
| Local SNA와 renderer UI가 있는 Electron 앱 | 잘 맞습니다. Command와 event path를 분리하면 좋습니다. |
| Local + cloud SNA instance를 함께 쓰는 앱 | 잘 맞습니다. Client registry와 event aggregator가 필요합니다. |
| Retrieval, project, policy context를 주입하는 앱 | 잘 맞습니다. Send-time context building을 host에 모으면 다루기 쉽습니다. |