개요
최근 블로그에 OpenAI 의 LLM API 를 이용해 블로그 글을 요약해 표현하는 기능을 도입했습니다. 이번 글에서는 LLM의 API 를 도입하기 위해 제가 작업했던 과정을 복기하고 배운 점을 정리하려고 합니다. LLM API 는 사용량 마다 비용이 들기 때문에, 현재 제 블로그의 문제점 중 하나였던 빌드와 ISR 시 중복 API 호출 문제를 먼저 해결 후 비용을 최소화 하며 사용자에게도 함께 도움이 될 수 있는 방식을 고민했었습니다. 이 글에서는 앞선 중복 API 호출 문제를 제외하고, ollama 를 이용해 LLM API dev 세팅 과정을 만들고, OpenAI 를 이용해 production 에 반영한 과정을 정리해보려고 합니다. 글은 다음과 같은 순서로 이어집니다.
- 기능 기획
- LLM API 호출을 위한 로컬 세팅
- LLM API production 반영
기능 기획
기획적인 문제

제 블로그의 디자인은 너무 투박하고, 허전한 느낌이 강했습니다. 변경하기 전 메인화면이었던 블로그 포스트 카드들은 정말 의미 없이 노션의 커버 이미지와 제목, 태그, 배포날짜만 보여주고 있었습니다. 블로그의 메인 컨텐츠를 다루는 화면의 정보를 좀 더 의미있게 하면서, 이 기회에 디자인 적으로도 보강하는 것이 필요했습니다.
기획적인 개선안
그래서, 블로그 포스트의 카드에 좀 더 의미있는 정보를 주고자 했습니다. 간단하게 해당 글이 어떤 내용인지를 요약해주고, 카드에 표현하고 싶었습니다.

기술적인 문제
그런데, 이 기능을 도입할 때 걱정되는 것이 있었습니다. 바로, API 호출 비용이었는데요. 노션 API 는 무료로 제공해주지만, OpenAI 의 API 는 사용량(토큰)에 따라 비용이 차감됩니다. 제가 블로그를 배포한 Vercel 을 통해 외부 API 호출 수를 보면, 이번 기능을 도입하기 전엔 12시간동안 노션 API 호출 수가 600회 이상으로 상당히 많은 양을 호출하고 있었습니다. 물론, ISR 주기가 짧은 (3분) 것도 원인이 있었겠지만, 그래도 방문자 수에 비해 불필요한 호출이 많은 것 같았습니다. 그래서 잘못하면 OpenAI 의 요약 API 도 너무 많이 호출하게 될 수도 있겠다라는 결론에 도달했습니다. 그래서 제가 파악한 문제점은
- 현재 ISR 로 인한 외부 API 호출 수가 너무 많음.
- dev 환경에서 개발 시, 잘못하면 무수히 많은 횟수의 API 를 호출하게 될 수 있음.
이 두가지 경우를 개선해야 된다고 생각했습니다.
기술적인 개선안
첫번째 문제는 현재 ISR 로 인한 외부 API 호출 수를 ISR 주기를 짧게 가져가는 중에도 줄이려면, 역시나 API 호출을 캐시하는 것이 중요하다고 생각했습니다. 이 개선안을 구체적으로 어떻게 구성했는지는 다른 글에 정리해두도록 하겠습니다.
두번째 문제는 혹시 모를 비용 낭비 방지를 위해 로컬 LLM 을 활용해 dev 환경에서는 로컬 LLM 에게 요약 요청을 할 수 있도록 환경 세팅을 하는 것 이었습니다. 그리고, 요약 기능 또한 매번 요약 요청을 보내는 것이 아닌, 노션에 요약을 한번 저장하고 그 요약 내용을 제공하는 것으로 변경하였습니다.
LLM API 호출을 위한 로컬 세팅
로컬 dev 모드에서는 어떤 실수로 인해 API 를 짧은 시간에 무수히 많이 호출 할 때가 있습니다. LLM API 는 input 토큰과 output 토큰 양에 따른 비용이 나갑니다. (여기서 토큰은 LLM 에 요청하거나 LLM 이 응답할 때 사용되는 문자열의 길이에 따라 비례하는 값이라고 보시면 됩니다.) 그리고 저는 이번 블로그 글 요약 기능은 블로그 글의 전문을 LLM 에게 제공하고 요약하는 기능이기 때문에 input 토큰 양은 꽤나 큽니다. 한순간 dev 개발 실수로 API 토큰 소모를 크게 할 수 있는 상황인 것 입니다. 그래서 저는 ollama 를 이용해 로컬 세팅을 먼저 하기로 했습니다.
Ollama 란?
Ollama 는 공개된 LLM 모델을 받을 수 있는 LLM 레지스트리처럼(Harbor 나 npm 처럼) 이해하고 사용했습니다. 좀 더 찾아보니, Ollama 는 npm 레지스트리 처럼 LLM 모델 레지스트리이면서, 레지스트리에서 받은 모델들을 돌릴 수 있는 런타임을 제공해줍니다. ollama 를 통해 로컬 PC 에서 사용할만한 작은 파라미터의 모델부터 거대한 파라미터를 가진 모델까지 공개된 LLM 모델들을 받아서 사용해볼 수 있습니다. 1년 전까지만 해도 로컬 PC 에서 사용할만한 작은 파라미터를 가진 모델은 한국어도 잘 못했는데, 최신 모델들은 더 적은 파라미터로도 훨씬 좋은 결과를 내더라구요. 그래서 이제는 로컬에서 활용할 수 있겠다고 생각해 Ollama 에서 로컬에서 돌릴만한 모델을 설치해 로컬 dev 환경을 만들기로 했습니다.
Ollama 를 통해 모델을 실행시켜 서버 형태로 동작하게 하는 것은 매우 쉽습니다. 원하는 모델을 pull 받아와 실행하면 끝입니다.
ollama pull gemma3:1b
ollama serve
ollama -v

ollama run gemma3:1b
개발할 때에는 run 명령어를 통해 cli 로 직접 대화하는 것이 아니라, API 를 통해 서버에 요청할 예정이므로 원하는 모델을 pull 만 받고, 서버만 serve 상태로 변경해주면 됩니다. 프론트엔드에서 API 를 통해 모델을 직접 지정하여 프롬프트를 넣기 때문에 run 으로 대화 모드를 켤 필요는 없습니다.
프로젝트 로컬 환경 세팅
제가 원하는 프로젝트 로컬 환경 세팅 시나리오는 이랬습니다.
- 프로젝트를 vscode 에서 열면 로컬 LLM 서버 자동 실행
- dev 모드 일 때는 로컬 LLM, production 모드 일 때는 OpenAI LLM API 를 사용
프로젝트 열면 바로 ollama serve 동작시키기
Ollama 를 통해 모델을 실행만 해두면 곧바로 서버처럼 사용할 수 있지만, 백엔드 서버를 켜두는 것 처럼 프로젝트 실행 시 항상 켜기 귀찮을 것 같았습니다. 그래서 프로젝트에 들어갈 때만 모델을 실행할 수 있는 방법이 있을까 싶어 찾아보았습니다.
.nvmrc
같은 방법이 있을 것 같아 찾아보았는데, 다행히도 방법이 있었습니다. vscode 를 통해 특정 프로젝트를 실행 할 때, .vscode
디렉터리 하위에 task.json
파일로 프로젝트를 시작하거나 할 때 실행할 것들을 설정 할 수 있는데요. 아래와 같은 실행 태스크를 등록해두면 ollama 를 자동으로 실행하게 됩니다. 아래 쉘 스크립트는 ollama 라는 프로세스가 없다면, ollama serve 를 실행하라는 스크립트 입니다.{ "version": "2.0.0", "tasks": [ { "label": "Ollama Serve", "type": "shell", "command": "pgrep -x ollama >/dev/null || ollama serve", "presentation": { "reveal": "always", "panel": "dedicated" }, "runOptions": { "runOn": "folderOpen" } } ] }

이 프로세스는 세션에 물려있어, 해당 터미널 세션이 닫히면 ollama 서버는 닫히게 됩니다. 즉, 프로젝트를 끄면 ollama 프로세스도 자동으로 죽게됩니다. 기본 포트는 11434 를 사용합니다.
‼️pgrep 은 리눅스 기반에서만 동작합니다!
LLM 모델 선택
로컬에서 모델 선택하는 것은 크게 중요한 것은 아니었습니다. 실제 제공할 데이터를 만들어 주는 것은 아니니까요. 그래서 output 의 성능보다는 로컬에서 돌리기 좋은 저사양이면서도 성능이 어느정도 나와주는 모델을 선택했습니다. 저는 구글에서 만든 gemma3:1b 모델을 사용해 테스트 용도로 사용하기로 정했습니다. gemma 는 지향하는 것 자체가 가볍고 성능 좋은 모델이어서 선택하였습니다. 실제로 사용해 보았을 때는 응답은 빠르지만, system 프롬프트가 잘 안 먹히는지 2문장으로 요약을 요청해도 길게 대답하는 경우가 상당히 많았습니다.
production 의 모델은 gpt-4o-mini 를 사용하였습니다. 이전부터 많이 사용해와서 output 성능을 잘 알고있었고, 최신 모델에 비해 가격도 저렴했기 때문입니다. 제가 원하는 시스템 프롬프트에 맞게 결과물을 잘 도출해주었습니다.
프로젝트에서 로컬 LLM API 활용하기
프로젝트는 production 에서는 OpenAI 의 API 를 활용해야하고, dev 에서는 로컬 LLM 의 API 를 활용해야하기 위해 코드 상에서 두 모델에 대한 세팅을 두고 분기해 사용할 수 있도록 합니다. 또 하나 주목할 점은 Ollama 와 OpenAI API 사용방식과 인터페이스가 굉장히 유사하다는 점 입니다. 요청에 모델을 선택하는 방식이 같고, 프롬프트 메시지를 전달하는 인터페이스도 유사해서 모델의 프롬프트 메시지 템플릿을 공유할 수 있었습니다. 하지만, 응답은 달라서 함수 자체를 나누어 환경에 따라 선택하도록 하였습니다.
import OpenAI from "openai"; import { modelConfig } from "../config"; const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // 블로그 포스트 길이 제한 function safeSlice(text: string, tokenLikeLimit = 8000) { const words = text.split(/\s+/); return words.slice(0, tokenLikeLimit).join(" "); } // 모델 정보 리턴 함수 // role 은 openai API 사용 시 타입 에러가 남 // 원인은 openai client 에서 제공하는 role 타입이 user | system 등 특정 문자열 유니온으로 되어있기 때문 const getModelInfo = (title: string, content: string) => { const system = { role: "system" as const, content: "블로그 글이 어떤 내용을 담고 있는지 2문장 이내로 간단히 알려주는 역할. 과장/추측 금지. 부가설명 금지. 마크다운 표현 금지. 정중한 표현 사용.", }; const user = { role: "user" as const, content: [`제목: ${title}`, "본문:", content].join("\n"), }; return { system, user }; }; async function _getAISummary(postTitle: string, plainText: string) { const content = safeSlice(plainText, 8000); const { system, user } = getModelInfo(postTitle, content); const res = await client.chat.completions.create({ model: "gpt-4o-mini", temperature: 0.2, messages: [system, user], }); return res.choices[0]?.message?.content?.trim() ?? ""; } async function _getAISummaryLocal(postTitle: string, plain_text: string) { const content = safeSlice(plain_text, 8000); const { system, user } = getModelInfo(postTitle, content); const response = await fetch(`${process.env.LOCAL_AI_ENDPOINT}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ messages: [system, user], stream: false, ...modelConfig.local, }), }); if (!response.ok) { throw new Error("Failed to fetch summary from local AI"); } const data = await response.json(); return data.message?.content; } export const getAISummary = process.env.NODE_ENV === "development" ? _getAISummaryLocal : _getAISummary;
dev 모드일 때와 아닐때의 사용 함수를 다르게 두고 API 호출을 분기합니다. model 관련 설정은 공유하기 위해 따로 설정 객체를 공유할 수 있도록 함수를 만들어
return
합니다.LLM API production 반영
production 에 반영하기 위해 API 를 어떻게 활용할 지 고민을 많이 했었습니다. 이 API 를 CSR 을 통해 렌더링 하게되면 우려되는 문제점은 두가지가 있었습니다.
- 매번 API 를 호출하면 메인 화면에 접속 시 글 1개당 8000 토큰 정도 되는 API 요청을 모든 글에 하게됨
- LLM 은 같은 요청을 하더라도 거의 다른 응답을 하기 때문에, 매번 사용자들은 통일되지 않은 요약본을 보게됨
그래서 저는 요약을 저장하도록 노션 데이터베이스에 컬럼을 추가하고 API 는 버튼을 활용해 요청할 수 있도록 기능 구현 설계를 하였습니다.


이 기능은 현재 블로그 포스트 리스트를 보여주는 페이지가 서버 컴포넌트이지만, 요약 요청 버튼은 클라이언트 컴포넌트이기 때문에 next.js 의 API route 를 통해 API 를 호출합니다. 왜냐하면 제가 사용하고 있는 notion API 클라이언트는 node.js 환경에서만 사용 가능하기 때문입니다.
import { revalidatePath, revalidateTag } from "next/cache"; import { type NextRequest, NextResponse } from "next/server"; import { getNotionPostContentForSummary, patchNotionPostSummary, } from "@/entities/notion/model"; import { getAISummary } from "@/entities/openai/model"; export async function PATCH( _: NextRequest, { params }: { params: { postId: string } }, ) { const { postId } = params; try { // 1. 포스트 내용 가져오기 const { title, content, isSummarized } = await getNotionPostContentForSummary(postId); if (isSummarized) { throw new Error("이미 요약이 생성된 포스트입니다."); } // 2. AI 요약 생성 const newSummary = await getAISummary(title, content); // 3. Notion 업데이트 await patchNotionPostSummary(postId, newSummary); // 4. 캐시 무효화 (API Route에서 직접 호출) revalidateTag("posts"); revalidatePath("/posts"); revalidatePath("/"); const result = { success: true, summary: newSummary, message: "AI 요약이 성공적으로 생성되었습니다.", }; return new NextResponse(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json", }, }); } catch (error) { console.error(`❌ [API Route] AI 요약 업데이트 실패 (${postId}):`, error); let errorMessage = "AI 요약 생성에 실패했습니다."; if (error instanceof Error) { if (error.message.includes("unauthorized")) { errorMessage = "Notion API 권한이 부족합니다."; } else if (error.message.includes("not found")) { errorMessage = "포스트를 찾을 수 없습니다."; } else if (error.message.includes("rate limit")) { errorMessage = "요청 제한에 걸렸습니다. 잠시 후 다시 시도해주세요."; } else { errorMessage = error.message; } } return new NextResponse( JSON.stringify({ success: false, error: errorMessage, }), { status: 500 }, ); } }
정리
이번 글에서는 dev 환경에서 Ollama(로컬 LLM)로 비용을 통제하며 기능을 검증하고, production 에서는 OpenAI API로 안정 운영하는 실전 흐름을 정리했습니다. 이번 LLM 도입을 통해 ollama 를 로컬 개발 환경에 도입해보고, OpenAI API 를 블로그에 직접 적용해 추가적인 기능을 제공할 수 있었습니다. 마지막으로 이번에 제가 배운점을 한번 더 정리하려고 합니다.
- Ollama 를 통해 클라이언트에서 LLM 에게 요청하는 것은 다른 상용 LLM 서비스들과 방식이 유사하다.
- 그래서 Ollama 를 통해 로컬 LLM API 테스트를 할 수 있다.
- VSCode Tasks 기능을 통해 로컬 LLM API 서버를 자동으로 활성화 할 수 있다.(커서도 가능)
- 타입스크립트: 리터럴 문자열 쪽에서 타입 에러가 날 시, 리터럴 문자열 유니온 타입 쪽 문제가 아닐지 의심해볼만 하다.
감사합니다.