
디지털청의 GenAI Web(`genai-web`)에서 Cloud 의존성을 제거하고 로컬 LLM으로 구동해 보았다
요약
디지털청의 GenAI Web 프론트엔드 프로젝트에서 AWS 의존성을 제거하고 로컬 LLM 환경에서 구동할 수 있도록 개조하는 방법을 소개합니다. Cognito, Lambda, DynamoDB 대신 SQLite와 OpenAI 호환 API를 사용하여 로컬 실행 환경을 구축합니다.
핵심 포인트
- AWS 의존성을 제거하여 로컬 LLM 구동 환경 구축
- Amplify/Cognito 인증 스킵 및 SQLite로 채팅 이력 저장 대체
- Lambda streaming을 로컬 HTTP API로 교체
- Ollama 등 OpenAI 호환 엔드포인트를 통한 모델 연동
GenAI(源内)는 디지털청이 개발 및 운용하는 생성 AI (Generative AI) 활용 기반입니다. 행정 직원이 업무 특화 생성 AI 애플리케이션을 신속하고 안전하며 간편하게 이용할 수 있는 환경을 제공합니다.
digital-go-jp/genai-web 은 그 「GenAI Web」의 프론트엔드 (Frontend)입니다.
AWS의 Generative AI Use Cases를 베이스로 하면서, 팀 관리, AI 앱 관리, 외부 AI 앱 실행, 디지털청 디자인 시스템 등이 추가되어 있습니다.
다만, 원래 구성은 AWS 전제입니다.
- Cognito / Amplify를 통한 인증
- Lambda response stream을 통한 추론
- DynamoDB를 통한 채팅 이력 저장
- S3 서명 URL을 통한 파일 업로드
- Bedrock / SageMaker의 모델 목록
- CDK / CloudFormation을 통한 배포
이번에는 이 genai-web을 AWS 없이 로컬에서 실행할 수 있도록 만들었습니다.
개정 후의 리포지토리 (Repository)는 여기입니다.
주의: 이 기사에서는 「CDK나 AWS 관련 코드를 모두 물리적으로 삭제하는 것」이 아니라, 로컬 실행 경로에서 Cloud 의존성을 제거하는 것을 목적으로 합니다. 기존 코드와의 차이를 최소화하기 위해, Cloud용 코드는 lazy import / route 제어로 남겨두었습니다.
로컬화 과정에서 수행한 작업은 주로 다음 5가지입니다.
VITE_APP_LOCAL_MODE=true를 추가하여 Cloud mode와 Local mode를 구분- Amplify / Cognito 인증을 로컬에서는 스킵 (Skip)
- Lambda streaming을 로컬 HTTP API의
/predict/stream으로 교체 - DynamoDB에 상응하는 저장소를 SQLite로 교체
- Bedrock / SageMaker 대신 OpenAI-compatible endpoint를 사용
구성(Configuration)은 다음과 같습니다.
Browser
|
|
Vite / React
...
- Node.js 22 계열
- OpenAI 호환의
/v1/chat/completionsAPI를 가진 로컬 모델 서버 - 예: Ollama
Ollama를 사용하는 경우, 예를 들어 다음과 같이 모델을 준비합니다.
ollama pull gemma4:e4b
ollama serve
다음으로, 별도의 터미널에서 genai-web-local을 기동합니다.
git clone https://github.com/engchina/genai-web-local.git
cd genai-web-local
npm install
...
기본값으로는 아래와 같이 기동됩니다.
| 항목 | 기본값 |
|---|---|
| Web | http://localhost:5173/ |
| ... |
모델이나 포트(Port)를 변경하는 경우에는 환경 변수 (Environment Variable)로 지정할 수 있습니다.
LOCAL_API_PORT=8787 \
LOCAL_OPENAI_BASE_URL=http://127.0.0.1:11434/v1 \
LOCAL_OPENAI_API_KEY=ollama \
...
먼저, 루트(Root)의 package.json에 로컬 기동용 스크립트 (Script)를 추가했습니다.
{
"scripts": {
"local:dev": "node scripts/local-dev.mjs",
...
scripts/local-dev.mjs에서는 Web과 Local API를 동시에 기동합니다.
여기서 중요한 점은 AWS용 환경 변수를 비우거나 로컬 값으로 교체했다는 점입니다.
const env = {
...process.env,
LOCAL_API_PORT: port,
...
즉, 기존 프론트엔드 입장에서 보면 「API 엔드포인트 (Endpoint)는 존재하지만」, 그 끝은 AWS가 아니라 localhost:8787이 됩니다.
프론트 측에는 로컬 모드 판정용 작은 유틸리티 (Utility)를 추가했습니다.
// packages/web/src/utils/localMode.ts
export const isLocalMode = import.meta.env.VITE_APP_LOCAL_MODE === 'true';
이 한 줄을 곳곳에서 사용하여, Cloud 의존성이 필요한 처리만을 회피합니다.
포인트는 각 컴포넌트에서 환경 변수 (Environment Variable)를 직접 읽지 않는 것입니다.
원래 코드에서는 앱 전체가 Amplify UI의 Authenticator.Provider로 감싸져 있었습니다.
로컬 버전에서는 Cloud mode일 때만 Amplify를 로드합니다.
// packages/web/src/main.tsx
const CloudAuthWrapper = lazy(async () => {
await import('@aws-amplify/ui-react/styles.css');
...
useAuth 또한 로컬에서는 Cognito로 향하지 않고, 최소한의 세션 형태 (Session Shape)만 반환합니다.
// packages/web/src/hooks/useAuth.ts
export const useAuth = () => {
return useSWR('user', async () => {
...
이 개수를 통해 로컬 기동 시 Cognito User Pool, Identity Pool, SAML 설정이 불필요해집니다.
API 호출 부분도 Local mode에서는 Authorization: Bearer ...를 붙이지 않도록 했습니다.
// packages/web/src/lib/fetcher.ts
const getAuthHeaders = async (hasBody: boolean): Promise<Record<string, string>> => {
const headers: Record<string, string> = {};
...
나아가 스트리밍 (Streaming) 용으로 postStream을 추가했습니다.
const rawRequest = async (
method: string,
path: string,
...
이로써 일반 API와 스트리밍 API 모두를 로컬 API로 향하게 할 수 있습니다.
원래의 predictStream은 AWS Lambda의 응답 스트림 (Response Stream)을 사용합니다.
로컬에서는 Lambda를 사용하지 않고, /predict/stream으로 POST 합니다.
// packages/web/src/lib/chatApi.ts
export async function* predictStream(req: PredictRequest) {
if (isLocalMode) {
...
여기서 중요한 점은 AWS SDK의 임포트 (Import)도 Cloud mode 쪽으로 몰아두었다는 것입니다.
Local mode에서는 Lambda Client도, Cognito 자격 증명 제공자 (Credential Provider)도 로드되지 않습니다.
타입 정의 (Type Definition)에는 새롭게 openai-compatible을 추가했습니다.
// packages/types/src/message.d.ts
export type Model = {
type: 'bedrock' | 'sagemaker' | 'openai-compatible';
...
프론트엔드의 모델 레지스트리 (Model Registry)도 Local mode에서는 VITE_APP_LOCAL_MODEL_IDS를 사용합니다.
// packages/web/src/models.ts
const localModelIds = parseStringArray(import.meta.env.VITE_APP_LOCAL_MODEL_IDS, [
'gemma4:e4b',
...
이로써 모델 선택 UI는 Bedrock이 아닌 로컬 모델을 표시할 수 있습니다.
새롭게 packages/local-api를 추가했습니다.
의존성을 늘리지 않기 위해 Node.js의 표준 http와 node:sqlite를 사용하고 있습니다.
설정은 환경 변수에서 읽습니다.
// packages/local-api/src/config.ts
export const loadConfig = (
env: NodeJS.ProcessEnv = process.env,
...
Local API는 프론트엔드에서 온 PredictRequest를 OpenAI와 호환되는 /chat/completions로 변환합니다.
// packages/local-api/src/openai.ts
const chatCompletionsUrl = (config: LocalApiConfig): string =>
`${config.openAIBaseUrl}/chat/completions`;
...
스트리밍(streaming)의 경우, OpenAI 호환 SSE를 읽고 프론트엔드가 기대하는 NDJSON으로 변환합니다.
export async function* parseOpenAICompatibleStream(
stream: ReadableStream<Uint8Array>,
):
AsyncGenerator<string> {
...
채팅 기록이나 시스템 프롬프트는 DynamoDB가 아닌 SQLite에 저장합니다. 다만, 프론트엔드 측으로 반환되는 형태(shape)는 기존과 유사하게 유지했습니다.
// packages/local-api/src/database.ts
this.db.exec(`
CREATE TABLE IF NOT EXISTS chats (
...
예를 들어, 채팅 생성은 이처럼 DynamoDB 같은 키 형식을 유지하고 있습니다.
createChat(userId: string, usecase = ''): Chat {
const createdDate = `${Date.now()}`;
const chat: ChatRow = {
...
이런 형태로 만들어 두면 프론트엔드 측의 변경 범위를 상당히 줄일 수 있습니다.
로컬 API 쪽에서는 우선 최소한으로 필요한 API만 구현했습니다.
// packages/local-api/src/http.ts
if (method(req) === 'POST' && url.pathname === '/predict/stream') {
await streamPredict(config, req, res);
...
채팅 기록도 같은 API 경로를 사용합니다.
if (segments[0] === 'chats') {
if (segments.length === 1 && method(req) === 'POST') {
const body = await readJson<JsonRecord>(req);
...
로컬 버전 v1에서는 텍스트 채팅을 중심으로 했습니다. 따라서 다음 기능들은 Local 모드에서는 표시하지 않습니다.
- AI 앱 관리
- 팀 관리
- 이미지 생성
- 음성 녹취(文字起こし)
- 파일 업로드
라우트(route) 측에서는 Local 모드일 때 Cloud 기반의 라우트를 제외했습니다.
// packages/web/src/routes.tsx
!isLocalMode
? {
...
헤더(Header)에서도 AI 앱으로 가는 링크를 숨겼습니다.
// packages/web/src/components/ui/Header.tsx
{!isLocalMode && (
<li >
...
파일 업로드도 Local 모드에서는 비활성화했습니다.
// packages/web/src/features/chat/hooks/useFileUploadable.ts
const accept = useMemo(() => {
if (isLocalMode) {
...
여기는 억지로 로컬 구현을 하지 않는 것이 안전했습니다.
S3 서명 URL (Presigned URL)을 전제로 하는 파일 흐름 (file flow)을 대충 교체하려고 하면, 저장, 삭제, 이력, 크기 제한, MIME 판정까지 한꺼번에 망가지기 쉽기 때문입니다.
이번 로컬 버전 v1은 다음과 같은 방침을 따르고 있습니다.
| 기능 | 로컬 버전 v1 |
|---|---|
| 텍스트 채팅 | 대응 |
| ... |
Local API 측은 아래에서 확인할 수 있습니다.
npm run local:api:test
이번 확인에서는 다음 항목들이 통과되었습니다.
Test Files 3 passed (3)
Tests 6 passed (6)
Web 측은 local mode와 관련된 최소한의 테스트를 확인했습니다.
npm -w packages/web run test -- tests/lib/fetcher.test.ts tests/features/chat/hooks/useFileUploadable.test.tsx
결과는 다음과 같습니다.
Test Files 2 passed (2)
Tests 3 passed (3)
node:sqlite를 사용하고 있기 때문에, Node.js 22 계열에서는 다음과 같은 경고 (warning)가 발생할 수 있습니다.
ExperimentalWarning: SQLite is an experimental feature and might change at any time
이는 현시점에서는 예상 범위 내에 있습니다.
이번 개수에서 가장 중요했던 것은, "Cloud 의존성을 한꺼번에 없애는 것"이 아니라, "Cloud 의존성이 필요한 경로를 local mode에서 통과하지 않도록 하는 것"입니다.
특히 효과적이었던 점은 다음과 같습니다.
VITE_APP_LOCAL_MODE로 분기점을 명확히 함- Amplify / AWS SDK는 정적 임포트 (static import) 하지 않고, Cloud mode 측에서 동적 임포트 (dynamic import) 함
- API 경로 (path)는 가급적 기존과 동일하게 유지하여 프론트엔드의 변경을 최소화함
- DynamoDB의 아이템 형태 (item shape)에 맞춰 SQLite에 저장함
- S3 / Teams / AI Apps 등, v1에서 안전하게 이식할 수 없는 것은 무리하게 활성화하지 않음
이러한 방침을 따르면, 기존 genai-web의 구조를 크게 해치지 않으면서 로컬 LLM으로 동작하는 개발용 Web UI를 만들 수 있습니다.
genai-web은 원래 AWS를 전제로 한 구성이지만, 프론트엔드의 API 경계 (boundary)를 잘 활용하면 로컬 LLM용으로도 구동할 수 있습니다.
이번 genai-web-local에서는 다음과 같은 형태로 구성했습니다.
- 인증은 local mode에서 스킵
- 추론은 Local API를 통해 OpenAI-compatible endpoint에 접속
- 이력은 SQLite에 저장
- Cloud 전용 UI는 라우트 (route) 및 네비게이션 (navigation)에서 제외
개인적으로는 이러한 방식의 로컬화에서는 "전체를 이식하는 것"보다, 우선 텍스트 채팅을 안정적으로 동작시키는 것이 더 중요하다고 생각합니다.
동작하는 중심선을 먼저 만든 다음, 파일 처리, 이미지 생성, AI 앱 연동 등을 순차적으로 되돌려 놓는 것이 나중에 망가지지 않는 방법입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기