Kakao Kanana-2 LLM 서비스 Spring Boot와 React 연동

궁금하면 500원·2026년 2월 11일

AI 미생지능

목록 보기
82/90

오픈소스 대규모 언어 모델을 서비스에 붙이려면 모델 카탈로그, 벤치마크 정보, 서빙 설정, 그리고 실제 인퍼런스 API가 필요합니다.
카카오 오픈소스 LLM 정리 문서를 기반으로 한 Kanana-2를 다룰 때, 이를 REST API와 웹 UI로 제공하는 스택이 kanana-apikanana-web입니다.

이 글에서는 Kanana-2 LLM 카탈로그·벤치마크·서빙·인퍼런스 흐름을 서술하고, kanana-apikanana-web 프로젝트의 백엔드·프론트 코드를 정리합니다.


1. Kanana-2와 API·웹이 맞는 이유

Kanana-2는 카탈로그, 벤치마크, 서빙 프리셋, 인퍼런스를 한 번에 다루기 좋은 도메인입니다.
단순히 vLLM만 띄워 두고 호출하는 것도 가능하지만, “어떤 모델이 있는지”, “성능은 어떤지”, “어떤 옵션으로 서빙하면 되는지”를 한 곳에서 조회·시각화하고, 웹에서 바로 완성 요청을 보내려면 백엔드 API프론트엔드 SPA 조합이 유리합니다.

실무에서는 내부 LLM 포털, 벤치마크 대시보드, 프로토타입 채팅/완성 UI 등에 활용할 수 있습니다.
여기서는 “모델 조회·벤치마크·서빙 정보·POST 완성”까지를 kanana-api와 kanana-web으로 구현한다고 가정합니다.


2. Spring Boot와 설정

kanana-api는 Java 17, Spring Boot 3.2를 사용하며, vLLM 연동을 위해 WebClient를 사용합니다.
설정은 application.yml에서 포트와 LLM 인퍼런스 URL·기본 모델만 두면 됩니다.

# kanana-api/src/main/resources/application.yml
spring:
  application:
    name: kanana-api

server:
  port: 8081

app:
  llm:
    inference-base-url: ${LLM_INFERENCE_URL:http://localhost:8000}
    default-model-id: kanana-2-30b-a3b-instruct-2601
  • app.llm.inference-base-url: vLLM 서버 URL
  • app.llm.default-model-id: modelId를 생략했을 때 사용할 기본 모델 ID

vLLM 서버가 없으면 POST /api/v1/complete 호출 시 연결 오류가 발생합니다.
모델 카탈로그·벤치마크·서빙 API는 vLLM 없이 동작합니다.


3. 도메인과 포트

백엔드 스타일을 적용하면, 도메인에는 모델, 벤치마크, 서빙 프리셋만 두고, 인퍼런스는 인터페이스 로 추상화합니다.

3.1 도메인 모델

도메인에는 저장소나 HTTP에 대한 의존이 없습니다.

// kanana-api/.../domain/model/KananaModel.java (요약)
public class KananaModel {

    private final String id;
    private final String name;
    private final ModelVariant variant;
    private final ModelSpec spec;
    private final String huggingFaceUrl;
    private final List<String> supportedLanguages;

    // Builder 생략

    public String getId() { return id; }
    public String getName() { return name; }
    public ModelVariant getVariant() { return variant; }
    public ModelSpec getSpec() { return spec; }
    public String getHuggingFaceUrl() { return huggingFaceUrl; }
    public List<String> getSupportedLanguages() { return supportedLanguages; }

    public boolean supportsToolCalling() {
        return variant == ModelVariant.INSTRUCT || variant == ModelVariant.THINKING;
    }
}
// kanana-api/.../domain/serving/ServingPreset.java (요약)
public final class ServingPreset {

    public enum Backend { VLLM, SGLANG }

    private final String modelId;
    private final Backend backend;
    private final String commandTemplate;
    private final String reasoningParser;
    private final String toolCallParser;
    private final long minGpuMemoryMb;

    // Builder, getter 생략
}

3.2 인퍼런스 포트

인퍼런스는 “모델 ID + 메시지 → 완성 텍스트”만 알면 되므로, 포트는 다음과 같이 단순하게 둡니다.

// kanana-api/.../application/port/LlmInferencePort.java
public interface LlmInferencePort {

    String complete(String modelId, String message);
}

구현체는 인프라 레이어의 VllmLlmInferenceAdapter에서 WebClient로 vLLM /v1/chat/completions를 호출합니다.


4. 인퍼런스 유스케이스와 REST API

4.1 유스케이스

modelId가 비어 있으면 기본 모델을 사용하고, 포트를 통해 한 번만 호출합니다.

// kanana-api/.../application/usecase/LlmCompleteUseCase.java
public class LlmCompleteUseCase {

    private final LlmInferencePort llmInferencePort;

    public LlmCompleteUseCase(LlmInferencePort llmInferencePort) {
        this.llmInferencePort = llmInferencePort;
    }

    public String execute(String modelId, String message) {
        return llmInferencePort.complete(modelId, message);
    }
}

4.2 요청 DTO와 Complete API

요청은 선택, 필수만 받습니다.

// kanana-api/.../interfaces/dto/CompleteRequestDto.java
public record CompleteRequestDto(
    String modelId,
    @NotBlank(message = "메시지는 필수입니다.") String message
) {}
// kanana-api/.../interfaces/web/CompleteController.java
@RestController
@RequestMapping("/api/v1/complete")
public class CompleteController {

    private final LlmCompleteUseCase llmCompleteUseCase;

    @PostMapping
    public ResponseEntity<Map<String, String>> complete(@Valid @RequestBody CompleteRequestDto dto) {
        String modelId = dto.modelId() != null && !dto.modelId().isBlank() ? dto.modelId() : null;
        String text = llmCompleteUseCase.execute(modelId, dto.message());
        return ResponseEntity.ok(Map.of("content", text != null ? text : ""));
    }
}

4.3 vLLM 어댑터

인프라에서는 WebClient로 vLLM 서버에 POST 요청을 보내고, 응답의 choices[0].message.content를 반환합니다.

// kanana-api/.../infrastructure/gateway/VllmLlmInferenceAdapter.java (핵심 부분)
@Override
public String complete(String modelId, String message) {
    String effectiveModel = modelId != null && !modelId.isBlank() ? modelId : defaultModelId;
    var body = Map.of(
        "model", effectiveModel,
        "messages", List.of(Map.of("role", "user", "content", message)),
        "max_tokens", 512
    );
    VllmChatResponse response = webClient.post()
        .uri("/v1/chat/completions")
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(body)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, ...)
        .onStatus(HttpStatusCode::is5xxServerError, ...)
        .bodyToMono(VllmChatResponse.class)
        .block();
    // ... null 체크 후 choice.getMessage().getContent() 반환
}

5. 모델 카탈로그 API와 DTO

모델 목록·상세는 유스케이스가 포트를 통해 도메인 KananaModel을 반환하고, 컨트롤러에서 DTO로 변환합니다.

// kanana-api/.../interfaces/web/ModelController.java
@RestController
@RequestMapping("/api/v1/models")
public class ModelController {

    private final GetModelCatalogUseCase getModelCatalogUseCase;
    private final GetModelByIdUseCase getModelByIdUseCase;

    @GetMapping
    public List<ModelDto> list() {
        return getModelCatalogUseCase.execute().stream()
            .map(ModelDto::from)
            .toList();
    }

    @GetMapping("/{id}")
    public ResponseEntity<ModelDto> getById(@PathVariable String id) {
        return getModelByIdUseCase.execute(id)
            .map(ModelDto::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}
// kanana-api/.../interfaces/dto/ModelDto.java (요약)
public record ModelDto(
    String id,
    String name,
    String variant,
    ModelSpecDto spec,
    String huggingFaceUrl,
    List<String> supportedLanguages,
    boolean supportsToolCalling
) {
    public static ModelDto from(KananaModel m) {
        return new ModelDto(
            m.getId(),
            m.getName(),
            m.getVariant().name(),
            ModelSpecDto.from(m.getSpec()),
            m.getHuggingFaceUrl(),
            m.getSupportedLanguages(),
            m.supportsToolCalling()
        );
    }

    public record ModelSpecDto(
        String totalParameters,
        String activatedParameters,
        int layers,
        int experts,
        int selectedExperts,
        String attentionMechanism,
        int vocabularySize,
        int contextLength
    ) { ... }
}

6. API 클라이언트와 Repository

kanana-web은 Vite + React + TypeScript이며, 개발 시 /api 요청을 Vite proxy로 http://localhost:8081로 넘깁니다. API 레이어는 clientkananaRepository로 나뉩니다.

6.1 API 클라이언트

// kanana-web/src/api/client.ts (요약)
const BASE = '/api/v1';

async function request<T>(path: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...options,
    headers: { 'Content-Type': 'application/json', ...options?.headers },
  });
  if (!res.ok) {
    // 에러 메시지 파싱 후 throw new Error(message)
  }
  if (res.status === 204 || res.headers.get('content-length') === '0') {
    return undefined as T;
  }
  return res.json() as Promise<T>;
}

export const apiClient = {
  get: <T>(path: string) => request<T>(path, { method: 'GET' }),
  post: <T>(path: string, body: unknown) =>
    request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
};

6.2 Repository와 타입

Repository가 백엔드 API를 추상화하여, 페이지와 훅은 URL·HTTP 메서드를 알 필요 없이 메서드만 호출합니다.

// kanana-web/src/api/kananaRepository.ts
import type { ModelDto, CompleteRequestDto, CompleteResponseDto, ... } from '@/types/model';
import { apiClient } from './client';

export const kananaRepository = {
  getModels: (): Promise<ModelDto[]> =>
    apiClient.get<ModelDto[]>('/models'),

  getModelById: (id: string): Promise<ModelDto | null> =>
    apiClient.get<ModelDto>(`/models/${encodeURIComponent(id)}`).catch(() => null),

  getBenchmarks: (modelId: string, category?: string): Promise<BenchmarkDto[]> => {
    const q = category ? `?category=${encodeURIComponent(category)}` : '';
    return apiClient.get<BenchmarkDto[]>(`/models/${encodeURIComponent(modelId)}/benchmarks${q}`);
  },

  getServingPreset: (modelId: string): Promise<ServingPresetDto | null> =>
    apiClient.get<ServingPresetDto>(`/models/${encodeURIComponent(modelId)}/serving`).catch(() => null),

  complete: (body: CompleteRequestDto): Promise<CompleteResponseDto> =>
    apiClient.post<CompleteResponseDto>('/complete', body),
};
// kanana-web/src/types/model.ts (요약)
export interface CompleteRequestDto {
  modelId?: string;
  message: string;
}

export interface CompleteResponseDto {
  content: string;
}

7. 인퍼런스 페이지와 useComplete 훅

7.1 useComplete 훅

제출·로딩·에러·응답을 한 곳에서 관리합니다.

// kanana-web/src/hooks/useComplete.ts
import type { CompleteRequestDto } from '@/types/model';
import { kananaRepository } from '@/api/kananaRepository';

export function useComplete() {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const submit = useCallback(async (payload: CompleteRequestDto) => {
    setLoading(true);
    setError(null);
    setContent('');
    try {
      const res = await kananaRepository.complete(payload);
      setContent(res.content ?? '');
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      setLoading(false);
    }
  }, []);

  const reset = useCallback(() => {
    setContent('');
    setError(null);
  }, []);

  return { content, loading, error, submit, reset };
}

7.2 CompletePage

모델 목록은 useModels로 가져오고, INSTRUCT/THINKING만 필터해 셀렉트에 넣습니다.
전송은 useComplete().submit으로 처리합니다.

// kanana-web/src/pages/CompletePage.tsx
export function CompletePage() {
  const { models, loading: modelsLoading, error: modelsError } = useModels();
  const { content, loading: submitLoading, error: submitError, submit, reset } = useComplete();
  const [modelId, setModelId] = useState('');
  const [message, setMessage] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!message.trim()) return;
    submit({ modelId: modelId || undefined, message: message.trim() });
  };

  const instructModels = models.filter(
    (m) => m.variant === 'INSTRUCT' || m.variant === 'THINKING'
  );

  return (
    <div className="page complete-page">
      <h1>인퍼런스</h1>
      <p className="page-desc">Kanana-2 모델에 질문을 보냅니다. (vLLM 서버 연동)</p>
      {modelsLoading && <Loading />}
      {modelsError && <ErrorAlert error={modelsError} />}
      <form onSubmit={handleSubmit} className="complete-form">
        <div className="form-group">
          <label htmlFor="model">모델 (선택)</label>
          <select
            id="model"
            value={modelId}
            onChange={(e) => setModelId(e.target.value)}
            disabled={modelsLoading}
          >
            <option value="">기본 모델</option>
            {instructModels.map((m) => (
              <option key={m.id} value={m.id}>{m.name}</option>
            ))}
          </select>
        </div>
        <div className="form-group">
          <label htmlFor="message">메시지</label>
          <textarea
            id="message"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            placeholder="질문을 입력하세요..."
            rows={3}
            required
          />
        </div>
        <div className="form-actions">
          <button type="submit" disabled={submitLoading || !message.trim()}>
            {submitLoading ? '전송 중...' : '전송'}
          </button>
          <button type="button" onClick={reset}>초기화</button>
        </div>
      </form>
      {submitError && <ErrorAlert error={submitError} />}
      {content && (
        <section className="complete-result">
          <h2>응답</h2>
          <pre className="result-content">{content}</pre>
        </section>
      )}
    </div>
  );
}

8. 홈 페이지와 요약·차트

홈에서는 모델 목록을 한 번 조회해 공개 모델 수, Tool Calling 지원 수, 변형별 분포 막대 차트를 보여줍니다.

// kanana-web/src/pages/HomePage.tsx (요약)
export function HomePage() {
  const { models, loading, error, refetch } = useModels();

  if (loading) return <Loading />;
  if (error) return <ErrorAlert error={error} onRetry={refetch} />;

  const variantCounts = models.reduce<Record<string, number>>((acc, m) => {
    acc[m.variant] = (acc[m.variant] ?? 0) + 1;
    return acc;
  }, {});
  const chartData = Object.entries(variantCounts).map(([name, count]) => ({ name, count }));

  return (
    <div className="page home-page">
      <h1>Kanana-2</h1>
      <p className="page-desc">카카오 오픈소스 LLM 카탈로그 · 벤치마크 · 인퍼런스</p>
      <section className="summary-cards">
        <div className="summary-card">
          <span className="number">{models.length}</span>
          <span>공개 모델</span>
        </div>
        <div className="summary-card">
          <span className="number">{models.filter((m) => m.supportsToolCalling).length}</span>
          <span>Tool Calling 지원</span>
        </div>
      </section>
      <section className="home-chart">
        <h2>모델 변형 분포</h2>
        <ResponsiveContainer width="100%" height={240}>
          <BarChart data={chartData} ...>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="name" />
            <YAxis allowDecimals={false} />
            <Tooltip />
            <Bar dataKey="count" fill="var(--color-primary)" ... />
          </BarChart>
        </ResponsiveContainer>
      </section>
      ...
    </div>
  );
}

Recharts의 BarChart로 변형별 개수를 시각화하며, 모델 목록·인퍼런스 페이지로 이동하는 링크를 둡니다.


9. API 명세와 사용 방법

메서드경로설명
GET/api/v1/models모델 목록
GET/api/v1/models/{id}모델 상세
GET/api/v1/models/{id}/benchmarks벤치마크 (쿼리: ?category=MATHEMATICS 등)
GET/api/v1/models/{id}/serving서빙 프리셋 (vLLM 명령 등)
POST/api/v1/completeLLM 완료 요청

POST /api/v1/complete 요청 본문 예시

{
  "modelId": "kanana-2-30b-a3b-instruct-2601",
  "message": "안녕하세요, 간단한 질문이에요."
}
  • modelId: 생략 시 설정된 기본 모델을 사용합니다.

실행 순서

  1. kanana-api: ./gradlew bootRun (기본 포트 8081)
  2. kanana-web: pnpm installpnpm dev (기본 포트 5173, /api → 8081 proxy)
  3. 브라우저에서 http://localhost:5173 접속 후 홈·모델 목록·인퍼런스 페이지를 사용하시면 됩니다.

10. 정리

  • Kanana-2는 카탈로그·벤치마크·서빙·인퍼런스를 한 번에 다루기 좋은 도메인이며, kanana-api는 Spring Boot + DDD + 포트/어댑터로 REST API를 제공합니다.
  • 인퍼런스는 LlmInferencePort로 추상화하고, VllmLlmInferenceAdapter에서 WebClient로 vLLM /v1/chat/completions를 호출합니다. 모델·벤치마크·서빙은 vLLM 없이 동작합니다.
  • kanana-web은 Repository 패턴으로 API를 추상화하고, useModels, useComplete 등 훅으로 로딩·에러·제출을 처리합니다. Recharts로 홈·모델 상세에서 차트를 그립니다.

Api 보면서 느낀점

LLM 모델 서빙부터 백엔드 API, 프론트엔드 UI까지 전체적인 흐름을 직접 구현해보며 대규모 언어 모델을 실제 서비스에 어떻게 녹여낼 수 있는지 깊이 있게 이해할 수 있었습니다.
특히, 백엔드에 적용하여 LLM 인퍼런스 로직을 추상화함으로써 코드의 유연성과 유지보수성을 높이는 방법을 실습할 수 있어 유익했습니다.
프론트엔드에서는 Repository 패턴과 custom hook을 활용하여 API 호출과 상태 관리를 효율적으로 처리하는 방법을 익혔고, Recharts를 이용해 모델 데이터를 시각화하는 과정도 흥미로웠습니다.
LLM 포털이나 프로토타입을 준비하는 분들에게 훌륭한 참고 자료가 될 것입니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글