본문으로 건너뛰기

AiProvider 어댑터 레이어 설계

상태: Phase 0 Week 1 첫 PR 설계서 (코드 착수 전 참고문) 선행 ADR: 0004 AI 스택 전환 · 0005 서류 생성 아키텍처 관련 정책: AI 스택 Firebase AI Logic 고정 (§6.3) — 국산 소버린 AI 출시 전까지 Firebase AI Logic (Gemini) 단일 사용, 소버린 출시 시 전환


1. 목적

현재 apps/web/lib/ai/client.tsfirebase/ai SDK 에 강결합. Phase 2~3 에 소버린 AI (HyperCLOVA X·Upstage Solar) 로 전환이 예정되어 있어, 지금 코드에 얇은 어댑터 레이어를 끼워두면 전환 시점에 호출부 8개소 수정 없이 provider 만 스왑 가능.

당면 목표 (Phase 0 Week 1)

  • 호출부 무변경 리팩터: 기존 generateLegalDoc · generateDashboardBriefing · summarizeEvidence · generateStrategyReport · generateRelatedMemoriesAnalysis API 시그니처 100% 유지
  • provider 인터페이스 추출: 모든 생성 함수가 내부적으로 AiProvider 를 거치도록
  • 기본 구현 1종: GeminiProvider (firebase/ai 래퍼)
  • 싱글톤 배럴: getAiProvider() 가 환경변수 기반으로 현재 active provider 반환

범위 외 (Phase 0 에서는 하지 않음)

  • ❌ 실제 소버린 AI 구현체 (Phase 2 PoC 시점에)
  • ❌ Provider 다중화·failover 로직 (ADR 0004 §4 위반 — Firebase AI Logic 단일 유지)
  • ❌ 스트리밍 API 통일 (Phase 1 이후)
  • ❌ 멀티모달 경로 분기 (Gemini 고유 inline base64 경로는 당분간 직접 접근 유지)

2. 현재 구조 (Before)

apps/web/lib/ai/client.ts ← firebase/ai 직접 import
├── generateLegalDoc (systemInstruction + contents)
├── generateDashboardBriefing (systemInstruction + text)
├── summarizeEvidence (inlineData + text, 멀티모달)
├── generateStrategyReport (JSON schema + AbortSignal)
└── generateRelatedMemoriesAnalysis (systemInstruction + text)

호출 사이트 (grep 결과):
├── app/(workspace)/docs/**
├── app/(workspace)/cases/[caseId]/**
├── app/(workspace)/dashboard/**
└── components/** (부분)

공통 패턴

  • getGenerativeModel(ai, { model, systemInstruction, generationConfig })
  • model.generateContent(...) 또는 model.generateContent({ contents })
  • result.response.text() 결과 취출
  • 실패 시 throw new Error("...실패") 한국어 사용자 메시지

3. 설계 (After)

3.1 디렉터리

apps/web/lib/ai/
├── client.ts ← 공개 API (generate* 함수, 시그니처 불변)
├── provider.ts ← AiProvider 인터페이스 + getAiProvider() 싱글톤
├── providers/
│ ├── gemini.ts ← GeminiProvider (firebase/ai 래퍼)
│ └── (phase-2+) sovereign.ts ← HyperCLOVA·Solar 어댑터 자리 예약
├── prompts.ts ← (기존 유지)
├── execute-ai-action.ts ← (기존 유지)
└── __tests__/
├── client.test.ts ← (기존 유지, 회귀 방지)
└── provider.test.ts ← (신규) GeminiProvider 단위 테스트

3.2 인터페이스 초안

// apps/web/lib/ai/provider.ts

import type { StrategyReportResponse } from "@/types/strategy-report";

/**
* AI 생성 요청 공통 옵션.
* provider 는 이 중 지원 불가한 필드를 조용히 무시할 수 있으나,
* maxOutputTokens·temperature 는 전 provider 필수 지원.
*/
export interface AiGenerateOptions {
systemInstruction?: string;
maxOutputTokens?: number;
temperature?: number;
/** JSON 전용 — Gemini responseMimeType: "application/json" 대응 */
responseMimeType?: "text/plain" | "application/json";
/** 취소 지원 */
signal?: AbortSignal;
}

/** 단일 턴 텍스트 생성 (대부분 호출이 이 형태) */
export interface AiTextRequest {
prompt: string;
options?: AiGenerateOptions;
}

/** 멀티 턴 (system + user/assistant 교대) — generateLegalDoc 용 */
export interface AiMessage {
role: "user" | "assistant";
content: string;
}

export interface AiMessagesRequest {
messages: AiMessage[];
options?: AiGenerateOptions;
}

/** 멀티모달 (파일 + 텍스트) — summarizeEvidence 용 */
export interface AiMultimodalRequest {
parts: Array<
| { type: "text"; text: string }
| { type: "inline"; mimeType: string; base64: string }
>;
options?: AiGenerateOptions;
}

export interface AiGenerateResult {
/** 생성된 텍스트 (responseMimeType=application/json 인 경우에도 문자열) */
text: string;
/** 실제 호출된 모델 ID — auditLog 에 기록 */
model: string;
}

/**
* 공용 AI Provider 인터페이스.
*
* 기본 구현: GeminiProvider (firebase/ai)
* 향후 구현: HyperClovaProvider, SolarProvider (Phase 2 PoC 시 추가)
*
* 규약:
* - 실패 시 Error throw (한국어 사용자 메시지)
* - signal.aborted 시 즉시 throw signal.reason
* - responseMimeType="application/json" 인 경우 raw string 반환 (파싱은 호출자 책임)
*/
export interface AiProvider {
/** 단일 prompt 텍스트 생성 */
generateText(req: AiTextRequest): Promise<AiGenerateResult>;

/** 멀티 턴 메시지 생성 */
generateFromMessages(req: AiMessagesRequest): Promise<AiGenerateResult>;

/** 멀티모달 (inline base64 + text) 생성 */
generateMultimodal(req: AiMultimodalRequest): Promise<AiGenerateResult>;
}

/**
* 싱글톤 조회. 환경변수로 provider 선택 (Phase 0 에서는 "gemini" 고정).
* 소버린 전환 시 `NEXT_PUBLIC_AI_PROVIDER` 를 바꾸고 provider 구현만 추가.
*/
export function getAiProvider(): AiProvider;

3.3 GeminiProvider 구현 골격

// apps/web/lib/ai/providers/gemini.ts

import { getGenerativeModel } from "firebase/ai";
import { ai } from "@/lib/firebase/client";
import type {
AiProvider,
AiGenerateResult,
AiMessagesRequest,
AiMultimodalRequest,
AiTextRequest,
} from "@/lib/ai/provider";

const DEFAULT_MODEL = "gemini-2.5-flash";

export class GeminiProvider implements AiProvider {
async generateText(req: AiTextRequest): Promise<AiGenerateResult> {
this.throwIfAborted(req.options?.signal);

const model = getGenerativeModel(ai, {
model: DEFAULT_MODEL,
systemInstruction: req.options?.systemInstruction,
generationConfig: this.toGenerationConfig(req.options),
});

const result = await model.generateContent(req.prompt);
return this.extractResult(result);
}

async generateFromMessages(req: AiMessagesRequest): Promise<AiGenerateResult> {
this.throwIfAborted(req.options?.signal);

const model = getGenerativeModel(ai, {
model: DEFAULT_MODEL,
systemInstruction: req.options?.systemInstruction,
generationConfig: this.toGenerationConfig(req.options),
});

const contents = req.messages.map((m) => ({
role: (m.role === "assistant" ? "model" : "user") as "model" | "user",
parts: [{ text: m.content }],
}));

const result = await model.generateContent({ contents });
return this.extractResult(result);
}

async generateMultimodal(req: AiMultimodalRequest): Promise<AiGenerateResult> {
this.throwIfAborted(req.options?.signal);

const model = getGenerativeModel(ai, {
model: DEFAULT_MODEL,
generationConfig: this.toGenerationConfig(req.options),
});

const parts = req.parts.map((p) =>
p.type === "text"
? { text: p.text }
: { inlineData: { mimeType: p.mimeType, data: p.base64 } },
);

const result = await model.generateContent({
contents: [{ role: "user", parts }],
});
return this.extractResult(result);
}

private toGenerationConfig(options?: AiTextRequest["options"]) {
return {
maxOutputTokens: options?.maxOutputTokens ?? 4096,
temperature: options?.temperature ?? 0.3,
...(options?.responseMimeType === "application/json"
? { responseMimeType: "application/json" as const }
: {}),
};
}

private extractResult(result: { response: { text: () => string } }): AiGenerateResult {
const text = result.response.text();
if (!text) throw new Error("AI 응답이 비어 있습니다. 다시 시도해주세요.");
return { text, model: DEFAULT_MODEL };
}

private throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw signal.reason ?? new Error("요청이 취소되었습니다.");
}
}

3.4 client.ts 리팩터 (시그니처 불변)

// apps/web/lib/ai/client.ts (리팩터 후)

import { getAiProvider } from "@/lib/ai/provider";
import { DOC_SYSTEM_PROMPTS, buildLegalDocMessages, /* ... */ } from "@/lib/ai/prompts";

export const DEFAULT_MODEL = "gemini-2.5-flash";

export async function generateLegalDoc(
docType: string,
caseInput: LegalCaseInput,
ragContext?: string[],
): Promise<{ text: string; model: string }> {
const baseSystem = DOC_SYSTEM_PROMPTS[docType] ?? DOC_SYSTEM_PROMPTS["소장"];
const systemInstruction = ragContext?.length
? `${baseSystem}\n\n--- 사무실 기억 참고 자료 ---\n${ragContext.join("\n\n")}`
: baseSystem;

const messages = buildLegalDocMessages(docType, caseInput);

return getAiProvider().generateFromMessages({
messages,
options: { systemInstruction, maxOutputTokens: 4096, temperature: 0.7 },
});
}

export async function generateDashboardBriefing(context: string): Promise<string> {
const { text } = await getAiProvider().generateText({
prompt: context,
options: { systemInstruction: DASHBOARD_BRIEFING_PROMPT, maxOutputTokens: 256, temperature: 0.3 },
});
return text;
}

// ...(summarizeEvidence, generateStrategyReport, generateRelatedMemoriesAnalysis 유사)

4. 마이그레이션 플랜 (Phase 0 Week 1, 8 eng-day)

순서작업eng-day의존
1provider.ts 인터페이스·싱글톤 신설 (타입 only, 실구현 0)1
2providers/gemini.ts GeminiProvider 구현21
3client.ts generateLegalDoc 리팩터 + 기존 테스트 회귀 확인12
4client.ts generateDashboardBriefing 리팩터0.52
5client.ts summarizeEvidence 리팩터 (멀티모달)12
6client.ts generateStrategyReport 리팩터 (JSON·AbortSignal)1.52
7client.ts generateRelatedMemoriesAnalysis 리팩터0.52
8__tests__/provider.test.ts 신규 + 통합 스모크0.53~7

PR 전략

  • 단일 PR 로 진행 (기능 flag 불필요, 시그니처 불변이라 rollback 안전)
  • 커밋 단위는 마이그레이션 플랜 번호와 동일
  • CI: pnpm type-check + pnpm test --filter=@neohollo/web + 기존 Storybook 통과

검증 체크리스트

  • 호출부 8개소 (grep 결과) 중 변경 0개
  • 기존 client.test.ts 스위트 100% 통과
  • AiProvider 인터페이스에 provider 고유 필드 (Gemini tools·context cache 등) 누출 없음
  • responseMimeType: "application/json" 경로 generateStrategyReport 회귀 테스트
  • 멀티모달 summarizeEvidence base64 경로 회귀 테스트
  • AbortController 중간 취소 generateStrategyReport 회귀 테스트

5. 소버린 AI 전환 시 작업량 (Phase 2 PoC 예측)

어댑터 도입 후 소버린 전환 소요 (참고):

작업eng-day
providers/hyperclova.ts 구현 (네이버 Clova Studio OpenAPI)4
providers/solar.ts 구현 (Upstage API)3
getAiProvider() 환경변수 분기0.5
E2E 통합 (30문항 Ko-LegalQA 회귀)2
프롬프트 톤 미세조정 (provider 간 편차 대응)3~5
합계12~15

핵심: 어댑터가 없으면 호출부 8개소 × 2 provider = 16회 수정 + 프롬프트·옵션 변환 산만 → 20+ eng-day. 어댑터 도입으로 일회성 8 eng-day 투자 → 전환 단축 7~8 eng-day + 안정성 향상.


6. 테스트 전략

단위 테스트 (__tests__/provider.test.ts)

// 모사 (mock) 수준: firebase/ai 를 mock 해 네트워크 격리
import { vi, describe, it, expect } from "vitest";
import { GeminiProvider } from "@/lib/ai/providers/gemini";

vi.mock("firebase/ai", () => ({
getGenerativeModel: vi.fn(() => ({
generateContent: vi.fn(async () => ({
response: { text: () => "mocked" },
})),
})),
}));

describe("GeminiProvider", () => {
const p = new GeminiProvider();

it("generateText 가 text 를 반환", async () => {
const r = await p.generateText({ prompt: "안녕" });
expect(r.text).toBe("mocked");
expect(r.model).toBe("gemini-2.5-flash");
});

it("abortSignal 이 이미 중단된 경우 즉시 throw", async () => {
const controller = new AbortController();
controller.abort(new Error("취소"));
await expect(
p.generateText({ prompt: "x", options: { signal: controller.signal } }),
).rejects.toThrow("취소");
});

// messages / multimodal / responseMimeType 각각 회귀
});

회귀 (client.test.ts)

  • 기존 스위트 100% 통과 = 시그니처 불변 증명

통합 스모크

  • Phase 0 Week 2 Go/No-Go 직전 수동: dogfood 사무소 1곳에서 generateLegalDoc 1건 end-to-end 실행

7. 위험·완화

위험가능성대응
Gemini SDK 의 generateContent({ contents }) 내부 스펙 변경낮음Provider 안에서 wrap, 호출부 무영향. SDK 버전 lockfile 고정
responseMimeType Gemini 고유 기능이 소버린 provider 에 없음중간인터페이스 필드는 유지, 소버린 provider 는 JSON prompt injection 으로 폴백
멀티모달 inline base64 포맷이 소버린과 상이중간Phase 2 PoC 시 실제 스펙 확인 후 인터페이스 v2 도입 (breaking change 사전 공지)
어댑터 도입 자체의 회귀 리스크중간시그니처 불변 + 기존 테스트 회귀 + 1일 dogfood 관문

8. Chair 승인 게이트

본 문서는 설계서. 실제 코드 착수 전 Chair 승인 필요:

  • 인터페이스 시그니처 확정 (3.2)
  • 단일 PR 전략 수용 (4. PR 전략)
  • 마이그레이션 8 eng-day 할당 수용
  • Phase 0 Week 1 착수 시점 확정

승인 후 PR #1 로 착수.


9. 관련 문서