Skip to main content

5장. 첫 번째 LLM API 서비스 구현

2부. LLM API의 공통 원리 이해하기 / 5장. 첫 번째 LLM API 서비스 구현

처음 한 번은 작고 단순하게 끝까지 간다

4장에서 우리는 LLM API의 공통 구조를 봤다. 모델 이름이 달라도 결국은 요청을 만들고, 인증을 붙이고, 메시지를 보내고, 응답을 받는 흐름이라는 점이 핵심이었다. 이제는 그 구조를 실제 서비스 코드로 옮길 차례다.

이 장의 목표는 멋진 데모를 만드는 것이 아니다. 가장 단순한 요청-응답형 웹 기능을 처음부터 끝까지 직접 구현하는 것이다. 이 경험이 중요한 이유는 분명하다. 한 번이라도 브라우저 입력부터 서버 호출, LLM 응답, 화면 출력까지 전부 연결해 봐야 이후의 추상화가 현실적으로 보이기 때문이다. 공식 Quickstart도 결국 이 기본 흐름, 즉 인증된 서버 코드가 모델에 요청을 보내고 응답을 받는 구조를 출발점으로 삼는다.1

이번 장에서 만들 기능은 아주 소박하다. 사용자가 문장을 입력하면, 서버가 LLM API를 호출해 더 자연스러운 업무용 문장으로 다시 써 준다. 예를 들어 "이거 계속 안 되는데 빨리 확인 부탁드려요"를 "해당 문제가 지속적으로 발생하고 있습니다. 빠른 확인 부탁드립니다" 같은 형태로 바꾸는 식이다. 작아 보여도 여기에는 실무의 기본기가 다 들어 있다. 입력 검증, API 키 보호, 프롬프트 작성, 응답 파싱, 오류 처리, 로그 기록이 모두 등장한다.

왜 브라우저가 아니라 서버에서 호출해야 할까

처음 구현할 때 가장 많이 하는 착각은 브라우저에서 LLM API를 바로 호출해도 된다고 생각하는 것이다. 눈앞의 코드만 보면 더 간단해 보인다. 하지만 실제 서비스에서는 거의 항상 서버를 거쳐야 한다.

가장 큰 이유는 보안이다. API 키를 프런트엔드 코드에 넣는 순간 그 키는 더 이상 비밀이 아니다. 환경 변수나 시크릿 저장소에 넣고 서버에서만 사용해야 한다는 점은 실무 가이드에서 반복해서 강조된다.2 여기에 더해 서버는 단순 중계기가 아니라 비용과 품질을 지키는 관문이 된다. 너무 긴 입력을 막고, 요청 빈도를 통제하고, 실패 로그를 남기고, 나중에 벤더를 바꿀 때도 프런트엔드는 그대로 두게 해 준다.

한마디로 말해, 사용자는 우리 앱을 호출하고 우리 앱이 LLM을 호출해야 한다. 이 순서를 지키면 서비스가 된다. 반대로 사용자가 곧바로 LLM 벤더를 때리기 시작하면 금방 데모 냄새가 난다.

이번에 만들 기능의 전체 흐름

먼저 머릿속에 흐름부터 그려 보자.

  • 브라우저에서 사용자가 문장을 입력한다.

  • 프런트엔드는 /api/rewrite로 POST 요청을 보낸다.

  • 서버는 입력값을 검사한다.

  • 서버는 환경 변수에 저장된 API 키로 LLM API를 호출한다.

  • 서버는 응답에서 결과 텍스트와 사용량 정보를 꺼낸다.

  • 서버는 JSON으로 다시 돌려준다.

  • 브라우저는 결과를 화면에 표시한다.

이 구조는 놀라울 만큼 오래 간다. 나중에 요약 기능을 붙여도, 분류 기능을 붙여도, 제목 생성 기능을 붙여도 기본 뼈대는 거의 같다. 그래서 첫 번째 기능은 가능한 한 단순해야 한다. 복잡한 대화 메모리, 스트리밍, 툴 호출은 아직 잠깐 밖에 서 있으라고 하자. 오늘은 문을 여는 날이다.

프로젝트를 아주 작게 시작하자

이번 장에서는 Node.js와 Express를 사용한다. 프레임워크가 중요한 것은 아니다. Python의 FastAPI로도 같은 구조를 만들 수 있다. 여기서는 웹 서비스의 요청-응답 감각을 빠르게 잡는 데 집중하자.

프로젝트 구조는 아래 정도면 충분하다.

first-llm-service/
├─ public/
│  └─ index.html
├─ .env
├─ package.json
└─ server.js

먼저 프로젝트를 만들고 필요한 패키지를 설치한다.

mkdir first-llm-service
cd first-llm-service
npm init -y
npm install express openai dotenv

package.json은 이렇게 정리하면 된다.

{
  "name": "first-llm-service",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node server.js"
  }
}

그리고 .env 파일을 만든다.

OPENAI_API_KEY=여기에_발급받은_API_키
OPENAI_MODEL=gpt-4.1-mini
PORT=3000

여기서 제일 중요한 줄은 모델명이 아니라 API 키를 환경 변수에 넣는다는 점이다. 작은 실습에서는 사소해 보여도, 이 습관 하나가 나중에 사고를 막는다.2

서버 구현: 입력을 받고, 모델을 호출하고, 결과를 돌려준다

이제 server.js를 작성해 보자.

import express from "express";
import OpenAI from "openai";
import dotenv from "dotenv";

dotenv.config();

const app = express();
const port = Number(process.env.PORT || 3000);
const model = process.env.OPENAI_MODEL || "gpt-4.1-mini";

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

app.use(express.json());
app.use(express.static("public"));

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.post("/api/rewrite", async (req, res) => {
  const startedAt = Date.now();

  try {
    const text = String(req.body?.text ?? "").trim();

    if (!text) {
      return res.status(400).json({ error: "text 값이 비어 있습니다." });
    }

    if (text.length > 4000) {
      return res.status(400).json({ error: "입력은 4000자 이하로 보내 주세요." });
    }

    const response = await client.chat.completions.create({
      model,
      temperature: 0.2,
      max_tokens: 200,
      messages: [
        {
          role: "system",
          content: "너는 한국어 업무 글쓰기 도우미다. 사용자의 문장을 더 명확하고 자연스러운 업무용 문장으로 다시 써라. 의미는 바꾸지 말고 결과 문장만 출력하라."
        },
        {
          role: "user",
          content: text
        }
      ]
    });

    const result = response.choices?.[0]?.message?.content?.trim() || "";
    const latencyMs = Date.now() - startedAt;

    console.log({
      route: "/api/rewrite",
      model: response.model,
      latencyMs,
      usage: response.usage
    });

    res.json({
      result,
      model: response.model,
      usage: response.usage,
      latencyMs
    });
  } catch (error) {
    const status = error?.status === 429 ? 429 : 502;

    res.status(status).json({
      error: status === 429
        ? "현재 요청이 많습니다. 잠시 후 다시 시도해 주세요."
        : "LLM 호출 중 오류가 발생했습니다."
    });
  }
});

app.listen(port, () => {
  console.log(`server started: http://localhost:${port}`);
});

코드가 길어 보여도 실제 역할은 단순하다. 입력을 읽고, 검증하고, 모델을 호출하고, 결과를 돌려준다. 나머지는 서비스 품질을 위한 장치다.

이 코드에서 꼭 봐야 하는 지점

첫째, express.json()은 브라우저가 보낸 JSON 본문을 읽게 해 준다. 없으면 req.body가 비어 보일 수 있다.

둘째, 입력 검증은 장식이 아니라 비용 방어선이다. 빈 문자열을 막는 것은 기본이고, 길이 제한은 더욱 중요하다. 입력이 길어질수록 비용과 지연 시간이 같이 올라가기 때문이다.

셋째, temperature: 0.2는 재작성 기능에 맞춘 선택이다. 이런 기능은 창의성보다 안정성이 중요하다. 즉흥적인 شاعر가 아니라 성실한 편집자를 부르는 셈이다.

넷째, max_tokens: 200은 출력 길이 제한이다. 처음 구현에서 이 값을 의식하지 않으면 모델이 친절하게 너무 많은 말을 해 버릴 수 있다. 비용도 늘고 UI도 지저분해진다.

다섯째, 응답에서 choices[0].message.content만 뽑지 말고 usage도 같이 보는 습관을 들여야 한다. 운영 단계에 들어가면 토큰 사용량이 곧 비용이고, 비용이 곧 기능 설계로 돌아오기 때문이다.3

프롬프트는 짧고 분명해야 한다

이번 장은 프롬프트 엔지니어링의 깊은 이론보다, 서비스 코드 안에서 프롬프트가 어떤 역할을 하는지 보는 장이다. 그래서 시스템 메시지도 일부러 짧게 잡았다.

너는 한국어 업무 글쓰기 도우미다. 사용자의 문장을 더 명확하고 자연스러운 업무용 문장으로 다시 써라. 의미는 바꾸지 말고 결과 문장만 출력하라.

이 정도면 첫 기능에는 충분하다. 초반에 자주 하는 실수는 시스템 프롬프트를 회의록처럼 길게 쓰는 것이다. 규칙이 많아지면 통제력이 커질 것 같지만, 실제로는 무엇이 효과를 냈는지 구분하기 어려워진다. 처음에는 짧고 검증 가능한 지시가 더 좋다.

프롬프트는 마법 주문이 아니라 제품 로직의 일부다. 그러니 멋있게 쓰는 것보다, 나중에 다시 읽고 고칠 수 있게 쓰는 편이 훨씬 중요하다.

프런트엔드 구현: 버튼 하나로 체감이 달라진다

이제 public/index.html을 만든다.

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>첫 번째 LLM 서비스</title>
    <style>
      body { font-family: sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
      textarea { width: 100%; min-height: 140px; padding: 12px; font-size: 16px; }
      button { margin-top: 12px; padding: 10px 16px; }
      .box { margin-top: 20px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; white-space: pre-wrap; }
      .muted { color: #666; font-size: 14px; }
    </style>
  </head>
  <body>
    <h1>업무 문장 다듬기</h1>
    <p class="muted">문장을 입력하면 더 자연스러운 업무용 문장으로 다시 써 줍니다.</p>

    <textarea id="inputText"></textarea>
    <button id="rewriteBtn">다듬기</button>

    <div id="status" class="muted"></div>
    <div id="output" class="box"></div>

    <script>
      const inputText = document.getElementById('inputText');
      const rewriteBtn = document.getElementById('rewriteBtn');
      const status = document.getElementById('status');
      const output = document.getElementById('output');

      rewriteBtn.addEventListener('click', async () => {
        status.textContent = '처리 중...';
        output.textContent = '';

        try {
          const response = await fetch('/api/rewrite', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ text: inputText.value })
          });

          const data = await response.json();

          if (!response.ok) {
            throw new Error(data.error || '요청 실패');
          }

          output.textContent = data.result;
          status.textContent = `완료 · ${data.latencyMs}ms`;
        } catch (error) {
          status.textContent = '오류 발생';
          output.textContent = error.message;
        }
      });
    </script>
  </body>
</html>

이 프런트엔드는 아주 단순하지만, 사용자가 실제로 가치를 체감하는 순간은 여기서 생긴다. 서버에서 모델을 호출하는 것만으로는 아직 반쪽짜리다. 화면에 결과가 떠야 비로소 기능이 된다.

실행해 보기

터미널에서 서버를 실행한다.

node server.js

브라우저에서 http://localhost:3000을 열고 문장을 넣어 보자. 잘 되면 입력한 문장이 업무용 톤으로 다듬겨 나온다. 실패한다면 보통 아래 네 가지 중 하나다.

  1. API 키가 잘못되었거나 .env가 로드되지 않았다.

  2. 모델명이 현재 계정에서 사용 불가하다.

  3. 입력이 너무 길다.

  4. 네트워크나 쿼터 문제로 요청이 거절되었다.

이때 중요한 것은 에러를 숨기지 않는 것이다. 초반에는 특히 서버 로그와 응답 메시지를 함께 보면서 원인을 좁혀야 한다.

운영 관점에서 이미 넣어 둔 습관들

이 예제는 작지만, 운영으로 가는 습관을 일부러 심어 두었다.

  • API 키는 코드가 아니라 환경 변수에 둔다.

  • 입력 길이를 제한한다.

  • 응답 시간과 사용량을 로그에 남긴다.

  • 429와 일반 오류를 나눠서 처리한다.

  • 프런트엔드와 LLM 벤더를 직접 연결하지 않는다.

이 다섯 가지만 지켜도 이후 확장이 훨씬 편해진다. 첫 기능은 완벽해야 하는 것이 아니라, 다음 기능을 붙이기 쉬워야 한다.

마무리

이 장에서 우리는 아주 작은 LLM API 서비스를 끝까지 만들어 봤다. 중요한 것은 코드 조각이 아니라 연결이었다. 브라우저 입력, 서버 검증, LLM 호출, 응답 반환, 화면 표시가 한 흐름으로 이어질 때 비로소 "서비스"가 된다.

다음 장에서는 이 구조 위에 더 실용적인 기능을 얹어 볼 수 있다. 예를 들어 여러 요청을 안정적으로 다루는 방법, 응답을 구조화하는 방법, 혹은 프롬프트를 버전 관리하는 방법 같은 것들이다. 하지만 그보다 먼저 기억할 것은 단순하다. 작게 만들고, 끝까지 연결하고, 그다음에 넓혀라.


1공식 Quickstart 계열 문서들은 대체로 인증된 서버 측 요청-응답 구조를 출발점으로 설명한다.

2API 키는 클라이언트에 두지 말고 서버 환경 변수나 시크릿 저장소에 보관하는 것이 기본 원칙이다.

3토큰 사용량과 응답 지연은 운영 비용과 사용자 경험에 직접 연결된다.

책 목록

실전 LLM 서비스 개발: ChatGPT·Claude·Gemini API로 만드는 AI 웹 서비스의 설계부터 운영까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기