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.ts 는 firebase/ai SDK 에 강결합. Phase 2~3 에 소버린 AI (HyperCLOVA X·Upstage Solar) 로 전환이 예정되어 있어, 지금 코드에 얇은 어댑터 레이어를 끼워두면 전환 시점에 호출부 8개소 수정 없이 provider 만 스왑 가능.
당면 목표 (Phase 0 Week 1)
- 호출부 무변경 리팩터: 기존
generateLegalDoc·generateDashboardBriefing·summarizeEvidence·generateStrategyReport·generateRelatedMemoriesAnalysisAPI 시그니처 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 | 의존 |
|---|---|---|---|
| 1 | provider.ts 인터페이스·싱글톤 신설 (타입 only, 실구현 0) | 1 | — |
| 2 | providers/gemini.ts GeminiProvider 구현 | 2 | 1 |
| 3 | client.ts generateLegalDoc 리팩터 + 기존 테스트 회귀 확인 | 1 | 2 |
| 4 | client.ts generateDashboardBriefing 리팩터 | 0.5 | 2 |
| 5 | client.ts summarizeEvidence 리팩터 (멀티모달) | 1 | 2 |
| 6 | client.ts generateStrategyReport 리팩터 (JSON·AbortSignal) | 1.5 | 2 |
| 7 | client.ts generateRelatedMemoriesAnalysis 리팩터 | 0.5 | 2 |
| 8 | __tests__/provider.test.ts 신규 + 통합 스모크 | 0.5 | 3~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회귀 테스트 - 멀티모달
summarizeEvidencebase64 경로 회귀 테스트 -
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. 관련 문서
- ADR 0004 §4 AI 스택 전환 — Firebase AI Logic 단일
- AI 파이프라인 (기존)
- Pack 1 Phase 0 체크리스트 — Week 1 AI 어댑터 세부 작업
- 소스:
apps/web/lib/ai/client.ts(현행)