[MindSprout 2편]랭체인(LangChain)을 버렸다: Next.js + 로컬 LLM 초경량 연동 및 트러블슈팅

1. 서론: AI 프레임워크의 함정, 그리고 가벼움에 대한 갈망

최근 AI를 활용한 로컬 LLM 연동 프론트엔드 프로젝트를 시작할 때, 개발자들은 마치 공식처럼 터미널에 npm install langchain을 타이핑하곤 합니다. 저 역시 [지난 1편에서 소개했던 MindSprout의 초기 아키텍처] 자연스럽게 수많은 AI 프레임워크들을 검토했습니다.

하지만 실제로 데일리 리포트와 숙제 데이터를 입력받아 처리하는 파이프라인을 구축해 보며 치명적인 딜레마에 직면했습니다.

로컬 환경에서 Ollama(Llama 3 등)를 구동하는 것만으로도 하드웨어 리소스가 상당히 소모되는데, 여기에 무거운 AI 프레임워크의 의존성(Dependencies)까지 얹어지니 빌드 속도와 런타임 성능이 눈에 띄게 저하되었습니다. 특히 현장에서 매일 수시로 쌓이는 입력 데이터를 지연 없이 가볍게 처리해야 하는 ‘입력 도구(Input Tool)’의 본질적인 목적에 부합하지 않았습니다.

“어차피 로컬에 띄워진 Ollama 포트(11434)로 텍스트 페이로드를 던지고 JSON만 받아오면 되는데, 왜 이렇게 내부 구조를 알 수 없는 블랙박스 같은 프레임워크를 주렁주렁 달아야 할까?”

이 깊은 고민 끝에, 저는 과감히 모든 AI 프레임워크를 걷어내고 Next.js(App Router)의 내장 Fetch API만을 활용한 ‘초경량 통신 아키텍처’로 회귀하기로 결정했습니다.


2. 군더더기 없는 초경량 로컬 LLM 연동 파이프라인의 이점

복잡한 체인(Chain) 객체나 에이전트(Agent) 설정 없이, Next.js의 Server Actions를 활용해 클라이언트와 로컬 LLM, 그리고 데이터베이스(Supabase)를 최단 거리의 직선으로 연결했습니다.

로컬 LLM 연동

[그림: 프레임워크 의존성 없이 Next.js 내장 Fetch API만으로 구현한 초경량 파이프라인]

이러한 퓨어(Pure) 아키텍처가 로컬 LLM 연동 실무에서 강력한 3가지 이유:

  1. Zero-Dependency (의존성 제로): 서드파티 AI 라이브러리들의 잦은 버전 업데이트나 파괴적 변경(Breaking Changes)에 따른 예기치 않은 빌드 에러에서 완전히 해방됩니다.
  2. 보안성 극대화: 브라우저(Client Component)가 아닌 Next.js Server 단에서 로컬 포트로 직접 통신하므로, 외부 네트워크에서는 우리 시스템에 로컬 LLM이 존재하는지조차 알 수 없습니다.
  3. 압도적인 속도와 디버깅: 중간 미들웨어를 거치지 않으므로 요청과 응답이 즉각적입니다. 에러가 발생해도 스택 트레이스(Stack Trace)가 투명하여 원인을 즉시 파악할 수 있습니다.

3. 실전 코드: 로컬 LLM 연동을 위한 Ollama API 직접 호출의 마법

무거운 라이브러리 대신 순수 TypeScript와 Fetch만으로 작성한 로컬 LLM 연동 서비스 코드입니다. 여기서 주목해야 할 핵심 실무 꿀팁은 바로 format: 'json' 옵션과 프롬프트 제어입니다.

3.1. 수다스러운 LLM 입막음하기

LLM은 종종 “네, 알겠습니다! 요청하신 데이터를 JSON으로 정리해 드립니다:” 같은 불필요한 친절함(인삿말)을 출력하여 프론트엔드의 JSON.parse() 에러를 유발합니다. 이를 완벽하게 제어하는 코드를 살펴보겠습니다.

// src/services/ollama.ts
export interface OllamaResponse {
  model: string;
  response: string; // 순수 JSON String
  done: boolean;
}

export async function generateOllamaJSON(prompt: string, systemPrompt: string): Promise<string> {
  const OLLAMA_URL = process.env.OLLAMA_LOCAL_URL || 'http://localhost:11434/api/generate';

  try {
    const res = await fetch(OLLAMA_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: 'llama3', // 구동 중인 로컬 모델명
        system: systemPrompt,
        prompt: prompt,
        // 🔥 [실무 꿀팁] 이 옵션 하나가 수백 줄의 파싱 코드를 대체합니다.
        format: 'json', 
        stream: false,
      }),
    });

    if (!res.ok) {
      throw new Error(`Ollama 통신 실패: ${res.statusText}`);
    }

    const data: OllamaResponse = await res.json();
    return data.response; 

  } catch (error) {
    console.error('Local LLM Error:', error);
    // 1편의 Fail-Secure 철학: 에러 발생 시 즉시 상위로 던져 프로세스 차단
    throw error; 
  }
}

이 코드는 Vercel, 자체 VPS 등 어느 환경에 배포하더라도 번들 사이즈에 전혀 영향을 주지 않고 깃털처럼 가볍게 동작합니다.


4. 트러블슈팅: AI 에이전트와 TypeScript Strict 모드의 충돌

이 가벼운 로컬 LLM 연동 파이프라인을 구축하는 과정이 마냥 순탄치만은 않았습니다. 평소 개발 생산성을 높이기 위해 AI 코딩 에이전트(Cursor, GitHub Copilot 등)와 협업하는데, 여기서 아주 흥미롭고 피곤한 ‘타입스크립트 에러 루프’를 경험했습니다.

프로젝트 프론트엔드 환경의 안정성을 위해 TypeScript Strict 옵션을 강하게 걸어두었는데, AI 에이전트는 Ollama에서 반환된 동적인 텍스트를 객체로 변환할 때 자꾸만 안일한 코드를 제안했습니다.

[AI 에이전트가 빠진 함정: any 타입 남발]

// AI 에이전트가 반복적으로 제안한 잘못된 코드 (빌드 실패 유발)
const jsonString = await generateOllamaJSON(text, prompt);
const parsedData = JSON.parse(jsonString);

// Error: 'parsedData' is of type 'any'. 
// Strict 모드에서는 암묵적인 any를 절대 허용하지 않습니다.
const title = parsedData.title; 

에이전트는 전체 프로젝트의 컨텍스트를 잃고 계속해서 as any로 우회하려는 꼼수를 부렸습니다. 결국 제가 직접 개입하여, 에이전트에게 명시적인 인터페이스와 Zod 기반의 스키마 구조를 프롬프트에 쥐여주고 나서야 완벽한 Server Action 코드를 얻어낼 수 있었습니다. 인간 개발자의 ‘설계 철학’ 리드가 얼마나 중요한지 다시금 깨달은 순간이었습니다.

4.2. Supabase 데이터베이스 적재 (Server Actions)

에이전트의 실수를 바로잡고 완성한 최종 데이터 적재 로직입니다. 파싱 도중 발생할 수 있는 할루시네이션 및 JSON 규격 미준수 예외를 방지하기 위해 엄격한 타입 단언 가드를 장착했습니다.

// src/actions/ingest.ts
import { createClient } from '@/utils/supabase/server';
import { generateOllamaJSON } from '@/services/ollama';

// 💡 TypeScript Strict 모드를 통과하기 위한 명확한 타입 정의
interface ParsedReport {
  title: string;
  category: string;
  dueDate: string;
  studentId: string;
}

export async function processAndSaveSensitiveData(rawText: string) {
  const supabase = createClient();

  // 시스템 프롬프트에 정확한 JSON 키(Key) 값을 주입하여 할루시네이션 방지
  const systemPrompt = `당신은 정확한 데이터 추출기입니다. 주어진 텍스트에서 다음 키를 가진 JSON 객체만 반환하세요: { "title": string, "category": string, "dueDate": string, "studentId": string }`;

  const jsonString = await generateOllamaJSON(rawText, systemPrompt);

  let parsedData: ParsedReport;
  try {
    // 타입 단언(Type Assertion)과 파싱 가드를 통해 예외 상황 처리
    parsedData = JSON.parse(jsonString) as ParsedReport;
    if (!parsedData.title || !parsedData.category) {
      throw new Error('필수 데이터 필드 누락');
    }
  } catch (parseError) {
    throw new Error(`LLM 응답 JSON 파싱 실패: ${parseError instanceof Error ? parseError.message : 'Unknown'}`);
  }

  // Supabase Table에 실시간 Insert
  const { data, error } = await supabase
    .from('mindsprout_tasks')
    .insert([
      {
        title: parsedData.title,
        category: parsedData.category,
        due_date: parsedData.dueDate,
        student_id: parsedData.studentId,
        is_sensitive: true, // 로컬 파이프라인을 거친 보안 인증 데이터
      }
    ])
    .select();

  if (error) throw new Error(`Supabase 적재 실패: ${error.message}`);
  return data;
}

5. 결론 및 3편 예고

유행하는 기술이나 거창한 프레임워크를 맹목적으로 도입하기보다, 현재 내 서비스가 요구하는 ‘가벼움’과 ‘정확성’에 집중했을 때 오히려 코드는 우아해졌습니다. 로컬 LLM의 강력한 보안성과 Next.js Server Actions의 속도가 완벽한 시너지를 내면서 로컬 LLM 연동 데이터 입력 파이프라인이 아주 견고하게 완성되었습니다.

이제 안전하게 정제된 데이터들이 Supabase에 실시간으로 차곡차곡 쌓이기 시작했습니다.

다음 3편에서는 이렇게 성공적인 로컬 LLM 연동을 통해 적재된 데이터를 바탕으로, 수많은 내역 중 기밀 데이터는 감추고 학업 정보를 시각적으로 관리하기 위한 또 다른 프로젝트(관리자 대시보드) UI를 Tailwind CSS로 어떻게 최적화하여 구현했는지 다루어 보겠습니다. 두 개의 분리된 프로젝트가 하나의 Supabase를 바라보며 만들어내는 아키텍처적 시너지를 기대해 주세요.

AI 시대에 나만의 가벼운 무기를 만들며 고군분투하고 계신 프론트엔드 개발자분들의 다양한 의견과 트러블슈팅 경험을 댓글로 기다립니다!


댓글 남기기