문서 읽는 데 145분 · day02

Day 2. 모델 선택 가이드 & 프로바이더 추상화 — "키 하나로 모델이 바뀌는 그 마법의 정체"

전체 26강 중 2강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.

지난 시간에 우리 정말 많이 달렸죠. 20일 마라톤의 첫 칸, Day 1 day01-setup 을 다들 무사히 끊으셨기를 진심으로 바랍니다.

지난 시간 마지막에 제가 이렇게 마무리했던 거 기억나시나요?

"환경변수 한 줄. 그게 답이었습니다. 코드는 그대로, 프로파일만 바꾸면 모델이 바뀌는 그 경험."

그리고 과제 2에서 여러분 중 일부는 실제로 세 번째 프로파일인 groq 까지 직접 손으로 붙여보셨을 거예요. .envGROQ_API_KEY 한 줄, application.ymlgroq 프로파일 블록 한 덩어리. 자바 코드는 단 한 줄도 건드리지 않았습니다. 그런데 응답은 멀쩡하게 나왔죠.

🙋 "튜터님, 솔직히 과제 풀면서 좀 이상했어요. 자바 코드를 진짜 하나도 안 건드렸는데 왜 Groq이 붙죠? 프로파일에 spring.ai.model.chat=openai 라고 적어놨는데 실제로는 Groq으로 호출이 나가잖아요. 이거 마법 같은데… 어떻게 되는 거예요?"

네, 바로 그 지점입니다. 오늘 우리가 벗겨볼 첫 번째 베일이 그거예요. 그 마법이 왜 통하는지, Spring AI의 내부를 한 번 열어 보고 갈 겁니다. 한 번 열어 보고 나면 이후 19일 내내 "아, 이 추상화가 여기서도 같은 원리로 작동하는구나" 라는 감각이 손끝에 남아요.

💡 오늘 수업의 핵심 "모델을 고르는 눈프로바이더를 갈아끼우는 손"

오늘 수업은 딱 두 가지 축으로 굴러갑니다.

  1. 눈 — 모델 선택 가이드: 2026년 5월 기준 무료·저가·상용 모델 라인업을 머릿속 지도로 그려두고, 비용·지연·프라이버시·품질·한국어 5축으로 트레이드오프를 판단할 수 있게 된다.
  2. 손 — 프로바이더 추상화: 그 모델들을 spring.ai.model.chat 프로퍼티 한 줄 로 갈아끼우는 Spring AI의 내부 구조를 이해하고, 실제로 세 프로바이더를 시연으로 돌려본다.

눈만 있으면 탁상공론이 되고, 손만 있으면 아무 모델이나 집어 쓰는 무뎌진 엔지니어가 됩니다. 둘이 쌍으로 움직여야 해요.

그리고 하나 더.

오늘은 진짜 실무 감각이 크게 올라가는 날이에요.

제가 특히 강조하고 싶은 부분은 Step 4의 요금 시뮬레이션Step 6의 PII 체크리스트 입니다.

이 둘은 면접이든 실무든 무조건 나옵니다. "튜터님 저 한 달에 LLM 비용 얼마 나올지 감도 안 와요" / "저희 회사 고객 정보 LLM에 넣어도 되는지 물어보는데 뭐라고 답해야 해요?" — 오늘 끝나면 두 질문에 1분 안에 답할 수 있게 될 거예요.

🎯 학습 목표

  • Spring AI의 ChatModel 추상화 레이어가 어떻게 작동하는지 내부 구조로 이해합니다. (spring.ai.model.chat 프로퍼티가 어떤 AutoConfiguration의 운명을 바꾸는지)
  • 2026년 5월 기준 무료/저가/상용 LLM 라인업을 지도처럼 외워둡니다. (Ollama · Gemini 2.5 Flash · Groq · OpenRouter · Claude Opus 4.7 · Sonnet 4.6 · GPT-5.5 · GPT-5.4-mini · Gemini 3.1 Pro)
  • 비용 · 지연 · 프라이버시 · 품질 · 한국어 5축 트레이드오프 매트릭스로 "우리 서비스엔 어떤 모델을 붙일 것인가"를 판단하는 기준을 손에 넣습니다.
  • 실제 요금 시뮬레이션 으로 "DAU 100 / 10K / 1M" 세 시나리오의 한 달 비용 차이를 수치로 체감합니다.
  • PII · 민감정보를 클라우드 LLM에 보내면 안 되는 이유 와 실무 체크리스트를 정리합니다.
  • Day 1 에서 붙인 세 프로파일(ollama · gemini · groq) 을 실제로 돌려가며 "프로퍼티 한 줄로 모델이 바뀌는" 경험을 다시 한 번 손에 새깁니다.

Step 1: "그게 왜 됐을까?" — `ChatModel` 추상화의 해부

여러분, 지난 시간 과제 2 하면서 제일 이상했던 순간 을 한번 같이 떠올려봅시다. 과제 2의 6번 요구사항이 이랬어요.

"과제 1에서 만든 /api/hello-ai/v2groq 프로파일로 호출해서 응답이 정상적으로 오는지 확인"

그리고 힌트 마지막 줄에 이런 문장이 있었죠.

"한 프로파일 추가에 코드를 한 줄도 안 바꾼다" 가 이 과제의 진짜 목표입니다. HelloAiController 자바 코드는 손도 대지 않고 응답이 나와야 정답이에요.

네, 진짜로 그랬어요.

.envGROQ_API_KEY 한 줄, application.ymlgroq 프로파일 블록 하나.

그리고 SPRING_PROFILES_ACTIVE=docker,groq 로 바꿔서 ./run.sh 로 재기동.

끝.

자바 코드는 진짜로 하나도 안 건드렸습니다.

그런데 응답은 Groq 서버에서 정상적으로 돌아왔죠.

이게 대체 왜 되는 걸까요? 지난 시간 저는 마무리에서 "환경변수 한 줄이 답이었다" 라고만 얘기하고 넘어갔어요. 오늘은 그 뚜껑을 열어봅니다. 뚜껑을 열고 나면 Day 3 ~ Day 20 내내 반복될 같은 패턴 이 손끝에 새겨질 거예요.

1. 우선 HelloAiController 를 다시 펼쳐봅시다

Day 1 Step 7 에서 우리가 손으로 짠 바로 그 컨트롤러예요. 변경 없이 한 번 더 보면서 포인트를 짚겠습니다.

// src/main/java/kr/spartaclub/aifriends/hello/HelloAiController.java
@RestController
public class HelloAiController {

    private final ChatClient chatClient;
    private final ProviderInfo providerInfo;

    public HelloAiController(ChatClient.Builder builder, ProviderInfo providerInfo) {
        this.chatClient = builder.build();   // ← 여기가 핵심
        this.providerInfo = providerInfo;
    }

    @GetMapping("/api/hello-ai")
    public String hello(
            @RequestParam(defaultValue = "Hello, AI! 한 줄로 자기소개 부탁해.") String message
    ) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
    // ... /api/hello-ai/v2 생략
}

자, 여기서 눈여겨볼 게 두 가지예요.

  1. 우리가 주입받는 건 ChatClient.Builder 입니다. 구체적인 프로바이더 타입(예: OpenAiChatModel, OllamaChatModel)은 아무 데도 없어요.
  2. 우리가 쓰는 건 ChatClient 입니다. .prompt().user(...).call().content() 라는 fluent API만 호출하면 끝.

여기까지가 우리 애플리케이션이 Spring AI와 맞닿는 표면 입니다. 이 표면 아래에서 실제로 LLM 서버로 HTTP 요청을 쏘는 놈은 누굴까요? 그 아래를 벗겨볼 시간이에요.

2. ChatClient 를 분해해보자 — 내부에 ChatModel 이 산다

Spring AI 1.1.x 의 ChatClientfluent DSL의 껍데기 역할을 해요. 내부적으로는 ChatModel 이라는 인터페이스 타입의 엔진 을 품고 있습니다. 개념적으로 이렇게 생겼어요.

// Spring AI 내부 개념도 (실제 소스와 100% 일치하진 않고 이해용 요약)
public interface ChatClient {
    // 우리가 호출하는 메서드
    ChatClient.CallSpec prompt();

    // Builder 는 ChatModel 을 받아서 ChatClient 를 만든다
    interface Builder {
        Builder defaultSystem(String system);
        Builder defaultAdvisors(Advisor... advisors);
        ChatClient build();
    }
}

// 실제 LLM 호출을 수행하는 엔진
public interface ChatModel {
    ChatResponse call(Prompt prompt);
    // (스트리밍 등 다른 메서드들은 Day 6 에서 다룹니다)
}

즉, ChatClient = fluent DSL + 미들웨어 체인(Advisor) 이고, 그 밑에서 HTTP 요청을 실제로 쏘는 엔진은 ChatModel 이에요. 비유하자면 이렇습니다.

  • ChatClient = 자동차의 운전대와 페달 (운전자가 만지는 인터페이스)
  • ChatModel = 자동차의 엔진 (실제로 바퀴를 굴리는 부품)

운전대는 어떤 엔진이 달려 있든 똑같이 돌아갑니다. 엔진이 가솔린이든 전기든, 우린 운전대만 돌려도 차가 움직이죠. Spring AI 가 우리에게 주는 추상화도 정확히 이겁니다. ChatClient(운전대) 로만 소통하면, 밑에 어떤 엔진(ChatModel 구현체)이 달려 있든 상관이 없어요.

🙋 학생 질문 — "튜터님, 그럼 ChatModel 구현체는 누가 있어요?"

프로바이더마다 하나씩 있습니다. Spring AI 1.1.x 기준 대표적으로:

  • OpenAiChatModel — OpenAI 공식 API 그리고 OpenAI 호환 엔드포인트 전반(Gemini · Groq · OpenRouter · Together 등)
  • OllamaChatModel — 로컬/리모트 Ollama 서버
  • AnthropicChatModel — Claude API
  • VertexAiGeminiChatModel — Google Vertex AI 네이티브 Gemini
  • AzureOpenAiChatModel, MistralAiChatModel, BedrockConverseChatModel 등등 이십수 개

우리가 지난 시간 Ollama · Gemini · Groq 세 프로바이더에 접속할 때 OpenAiChatModelOllamaChatModel 딱 두 구현체 만 번갈아 썼어요.

Gemini 와 Groq 둘 다 OpenAI 호환 엔드포인트니까 OpenAiChatModel 하나가 base-url 만 바꿔서 커버해준 거죠.

이건 Step 2~3 에서 더 정리합니다.

3. 그럼 ChatModel 구현체는 누가 빈으로 꽂아주나? — 스타터 + AutoConfiguration

자, 그러면 누군가 OllamaChatModel 이든 OpenAiChatModel 이든 스프링 빈으로 등록 해줘야 ChatClient.Builder 가 집어서 쓸 수 있겠죠? 그 등록을 해주는 주인공이 바로 Spring AI 스타터 의존성 이에요.

지난 시간 Day 1 Step 3 에서 우리가 build.gradle 에 추가한 두 줄, 기억나시나요?

// build.gradle (Day 1 Step 3 발췌)
dependencies {
    implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'
}

이 두 스타터가 각자 가져오는 건 딱 이겁니다.

스타터 가져오는 AutoConfiguration 등록되는 빈
spring-ai-starter-model-ollama OllamaChatAutoConfiguration OllamaChatModel
spring-ai-starter-model-openai OpenAiChatAutoConfiguration OpenAiChatModel

Spring Boot의 오래된 친구인 AutoConfiguration 패턴이에요. 스타터만 넣어두면 조건에 맞을 때 해당 빈이 자동으로 등록됩니다. 지난 시간 Day 1 Step 3 에서 제가 "스타터만 넣으면 대부분 알아서 된다" 라고 한 게 바로 이거예요.

그런데 여기서 자연스러운 질문이 따라옵니다. "두 스타터를 다 넣었으면, OllamaChatModel 이랑 OpenAiChatModel 이 동시에 등록되는 거 아닌가요?" 그럼 ChatClient.Builder 가 둘 중 뭘 골라 써야 할지 헷갈리지 않나요?

정답은 "동시에 등록되지 않는다" 입니다. 그 이유가 오늘 Step 1 의 진짜 하이라이트예요.

4. @ConditionalOnProperty — 프로퍼티 한 줄이 빈의 운명을 정한다

Spring AI 의 각 ChatAutoConfiguration 클래스 위에는 아주 특별한 어노테이션이 하나 붙어 있어요. Spring Boot 세계에서 "조건부 빈 등록" 이라고 불리는 @ConditionalOnProperty 입니다.

개념적으로 요약하면 이렇게 생겼어요.

// Spring AI 내부 (이해용 요약 — 실제 클래스명은 버전에 따라 조금씩 다름)

@AutoConfiguration
@ConditionalOnClass(OllamaApi.class)
@ConditionalOnProperty(
    prefix = "spring.ai.model",
    name = "chat",
    havingValue = "ollama"          // ← 프로퍼티가 "ollama" 일 때만 살아난다
)
public class OllamaChatAutoConfiguration {
    @Bean
    public OllamaChatModel ollamaChatModel(...) { ... }
}

@AutoConfiguration
@ConditionalOnClass(OpenAiApi.class)
@ConditionalOnProperty(
    prefix = "spring.ai.model",
    name = "chat",
    havingValue = "openai"          // ← 프로퍼티가 "openai" 일 때만 살아난다
)
public class OpenAiChatAutoConfiguration {
    @Bean
    public OpenAiChatModel openAiChatModel(...) { ... }
}

이게 바로 지난 시간 여러분이 경험한 "마법" 의 정체입니다. 자바 코드를 한 줄도 안 건드렸는데 실제 호출 대상이 바뀐 이유가 여기 있어요.

  • spring.ai.model.chat=ollamaOllamaChatAutoConfiguration 이 살아남 → OllamaChatModel 빈만 등록 → ChatClient.Builder 가 얘를 씀
  • spring.ai.model.chat=openaiOpenAiChatAutoConfiguration 이 살아남 → OpenAiChatModel 빈만 등록 → ChatClient.Builder 가 얘를 씀
  • spring.ai.model.chat=none → 둘 다 등록되지 않음 → ChatClient.Builder 빈 자체가 만들어지지 않음

application.yml 의 프로바이더별 프로파일 설정을 다시 펼쳐볼까요? 지난 시간 우리가 손으로 쓴 건데, 이제 다르게 보일 거예요.

# application.yml (Day 1 에서 확정된 상태 발췌)
---
spring:
  config:
    activate:
      on-profile: ollama
  ai:
    model:
      chat: ollama        # ← 이 한 줄이 OllamaChatAutoConfiguration 을 살린다

---
spring:
  config:
    activate:
      on-profile: gemini
  ai:
    model:
      chat: openai        # ← 이 한 줄이 OpenAiChatAutoConfiguration 을 살린다
                          #    (Gemini 는 OpenAI 호환 엔드포인트라 openai 구현체를 재활용)

---
spring:
  config:
    activate:
      on-profile: groq
  ai:
    model:
      chat: openai        # ← 지난 시간 과제 2 에서 추가한 세 번째 프로파일도 똑같은 openai
    openai:
      api-key: ${GROQ_API_KEY:}
      base-url: https://api.groq.com/openai/v1
      chat:
        completions-path: /chat/completions
        options:
          model: ${GROQ_MODEL:llama-3.3-70b-versatile}

보이시죠? "프로바이더가 다르다" 는 게 실제로는 "프로퍼티 한 줄이 다르다" 는 말과 같은 뜻이었던 거예요. 자바 코드가 안 바뀐 게 아니라, 바꿀 이유 자체가 없었던 겁니다. 그 프로퍼티 한 줄이 빈 등록의 운명을 바꿨고, 빈이 바뀌었으니 호출 대상이 바뀐 거죠.

🙋 학생 질문 — "튜터님, 그럼 한 앱에서 Ollama랑 OpenAI 모델 둘 다 동시에 쓰고 싶으면 어떻게 해요?"

좋은 질문입니다.

실제로 A/B 테스트나 Failover 시나리오에서 필요해요.

기본 설정으로는 한 번에 하나만 활성화되지만, 각 스타터별로 활성화 프로퍼티를 따로 명시 (spring.ai.ollama.chat.enabled=true + spring.ai.openai.chat.enabled=true) 하면 두 구현체를 동시에 빈으로 등록하고 @Qualifier 로 구분해서 주입받을 수 있습니다.

이 패턴은 오늘 과제 2 심화 로 빼둘게요.

오늘 본문은 "프로파일 스위칭 한 가지" 에 집중합니다.

5. 정리: 우리 손에 쥔 건 이 그림이다

자, 뚜껑을 다 열어봤으니 머릿속 그림을 마지막으로 한 번 정리하고 갑시다.

[우리 코드 — 변하지 않음]
    HelloAiController
       └─ ChatClient.Builder 주입 → .build() → ChatClient 사용

                   ↓ 내부 의존

[Spring AI 가 만들어주는 층 — Builder 가 꽂아 쓴다]
    ChatClient  ─ 내부에 ─▶  ChatModel (인터페이스)

                   ↑ 실제 구현체

[프로파일별로 딱 하나만 등록됨]
    spring.ai.model.chat = ollama  →  OllamaChatModel
    spring.ai.model.chat = openai  →  OpenAiChatModel
    spring.ai.model.chat = none    →  (빈 등록 안 됨)

여기서 제가 진짜진짜진짜 강조하고 싶은 한 문장 이 있어요. 이 한 문장을 오늘 머릿속에 박아두시면 남은 16일이 편합니다.

"우리 애플리케이션 코드는 ChatModel 인터페이스에만 의존한다. 구현체는 절대 타입으로 고정하지 않는다."

무슨 뜻이냐면, 앞으로 혹시라도 이런 코드를 보면 본능적으로 경보가 울려야 해요.

// ❌ 절대 하지 말 것 — Day 2 이후 교안 내내 금지
@Autowired
private OpenAiChatModel openAiChatModel;     // ← 구현체 타입으로 고정!

반대로 아래처럼 쓰는 게 정답입니다.

// ✅ 이게 정답
@Autowired
private ChatModel chatModel;                 // 인터페이스 타입으로만!

// ✅ 혹은 우리가 이미 쓰는 스타일 (ChatClient.Builder 주입)
public HelloAiController(ChatClient.Builder builder) { ... }

왜 이게 중요하냐면, 구현체 타입을 붙잡는 순간 그 수업(Day)의 교훈이 무너져요. 오늘 우리가 열어본 "프로바이더 추상화"의 본질은 바로 "구현체에 묶이지 않는 것" 이거든요. 이건 Day 20 까지 쭉 이어지는 원칙이에요.

🙋 날카로운 질문 타임

"튜터님, OpenAI 호환 어댑터로 Gemini·Groq을 부르는 게 정석 맞나요? Gemini 는 VertexAiGeminiChatModel 이라는 전용 구현체도 있잖아요."

날카로운 지적이에요! 둘 다 존재하는 건 맞습니다. 그런데 우리가 "OpenAI 호환 엔드포인트 + OpenAiChatModel 재사용" 을 택한 이유가 있어요.

비교 OpenAI 호환 어댑터 VertexAiGeminiChatModel 전용
키 발급 AI Studio 무료 키 (간편) GCP 프로젝트 + 결제 계정 + 서비스 계정 JSON
환경 부담 학생 개인 맥북에서도 10초 GCP 콘솔 세팅에만 30분
기능 커버리지 Chat · Tool · Embedding (충분) Chat · Tool · Embedding · Grounding · Safety Attributes 등 전체
학습 목적 적합성 🎯 학습·프로토타입 최적 본격 엔터프라이즈 전용

"GCP 인프라까지 쓸 생각이면 VertexAi 전용, 그렇지 않으면 OpenAI 호환이 사실상 업계 표준" 이에요.

우리는 학생 실습 환경을 최소화하기 위해 호환 어댑터를 썼습니다.

운영에서 Grounding 이나 Safety Attributes 같은 GCP 전용 기능이 꼭 필요하면 전용 구현체로 갈아끼우면 되고, 그때도 ChatModel 인터페이스에 대고만 호출했기 때문에 우리 비즈니스 로직 코드는 거의 손대지 않고 전환 가능 합니다.

이게 추상화의 복리 이자예요.

"튜터님, 그럼 spring.ai.model.chat=none 일 때는 앱이 아예 안 뜨나요?"

좋은 호기심입니다. 답은 "앱은 뜨는데 ChatClient.Builder 빈이 없어서 HelloAiController 생성자 주입이 실패해 기동이 터진다" 입니다. Day 1 application.yml 의 기본값이 chat: none 이었던 이유를 기억하시죠?

  • 기본값 none 을 박아둔 이유: ollama / gemini 둘 중 어느 프로파일도 활성화 안 하고 앱을 띄우더라도 OpenAiChatAutoConfiguration 이 API 키 없이 혼자 살아나서 기동 실패하는 걸 막기 위함.
  • 프로파일을 씌우는 순간: 프로파일의 chat: ollama 또는 chat: openai 가 기본값 none 을 덮어써서 해당 AutoConfiguration 이 살아나고 ChatClient.Builder 빈이 생긴다.

none"아무 것도 안 켜진 안전 모드" 예요. 프로파일 하나를 꼭 씌워줘야 실제로 ChatClient 가 만들어집니다. Day 1 과제 부팅 로그에서 The following 2 profiles are active: docker, ollama 같은 줄을 확인했던 이유가 여기 있어요.

좋습니다. 지난 시간 경험한 "마법" 의 정체가 이제 머릿속에 손에 잡히는 그림으로 남으셨을 거예요. 프로퍼티 한 줄 = AutoConfiguration 하나의 생사 = ChatModel 구현체 하나의 빈 등록 — 이 삼단 논법이 Spring AI 전체를 관통하는 열쇠입니다.

자, 그럼 이제 머릿속의 그 "운전대" 아래에 어떤 엔진들을 꽂을 수 있는지 구체적인 2026년 5월 시점의 라인업 을 펼쳐볼 시간이에요. Step 2 에서는 학생 실습의 든든한 동반자가 될 무료·저가 프로바이더 4형제 부터 들여다보겠습니다.


Step 2: 2026년 5월 기준 무료/저가 프로바이더 지도

먼저 제가 하고 싶은 말부터 드릴게요. 학습이나 사이드 프로젝트에선 지금 소개할 4형제만 있어도 충분합니다. 20일짜리 이 강의 전체를 이 4형제로 완주할 수 있어요. 솔직히 말해서, 본인 카드 긁어서 유료 모델 붙이는 건 진짜 필요할 때 하는 거지, 학습 단계에서는 낭비예요.

오늘 지도 그릴 4형제는 이겁니다.

이름 한 줄 요약
① Ollama 로컬 내 맥북 안에서 도는 LLM. 돈 안 들고 데이터도 안 나간다
② Gemini 2.5 Flash 무료 티어 클라우드 기본 선택. 무료 쿼터 넉넉, 한국어 잘 됨
③ Groq 무료 티어 초고속 이 특기. 같은 Llama 인데 체감 속도가 다르다
④ OpenRouter 무료 모델 한 키로 수십 개 모델 — DeepSeek R1 · Llama · Qwen 등

이 네 개만 손에 쥐면 "모델이 없어서 실습 못 하는" 일은 강의 20일 내내 한 번도 없습니다. 하나씩 들여다볼게요.

① Ollama 로컬 — "내 맥북 안에 사는 LLM"

Day 1 Step 4 에서 이미 친해진 친구입니다.

brew install ollama + ollama pull gemma3:4b 로 모델을 내 기기에 다운로드해서 인터넷 없이도 돌릴 수 있죠.

Spring AI 입장에서는 spring-ai-starter-model-ollama 가 HTTP 로 localhost:11434 로 요청을 쏘면 그걸 Ollama 데몬이 받아서 내 CPU/GPU 를 갈아서 응답을 만들어주는 구조예요.

강점

  • 비용 제로. 하드웨어 값 외에는 돈이 전혀 안 듭니다. 수만 번 호출해도 0원.
  • 프라이버시. 데이터가 절대로 내 기기 밖으로 나가지 않아요. 민감정보가 포함된 실험을 맘 놓고 할 수 있습니다. (Step 6 에서 왜 이게 중요한지 본격 다룸)
  • 오프라인. 비행기 안에서도, 사내 폐쇄망에서도 돕니다.
  • 학습 속도. 프로바이더 쿼터 걱정 없이 for (int i = 0; i < 1000; i++) 같은 실험도 자유롭게.

약점

  • 응답 품질이 플래그십 모델보다 떨어집니다. 3B~8B 파라미터 규모의 "경량 모델" 들이라, Claude Opus 4.7 이나 GPT-5.5 같은 프론티어 모델과는 같은 리그가 아니에요.
  • 한국어가 조금 어색할 수 있어요. 특히 짧은 한국어 입력엔 영어로 답하기도 합니다. Day 1 에서 qwen3:4b 얘기가 나왔던 이유가 이거예요.
  • 응답 속도는 내 기기 성능이 좌우합니다. 맥 M2 면 쾌적한데 CPU 전용 노트북이면 한 단어씩 똑딱똑딱 나올 수 있어요.

추천 용도

  • 강의 실습·프로토타입·개인 실험 전반
  • PII 가 섞인 실험무조건 Ollama (Step 6 에서 다시 강조)
  • "무슨 모델 쓰는지 외부 노출 자체를 싫어하는" 사내 PoC

② Gemini 2.5 Flash 무료 티어 — "클라우드 기본 선택"

Google AI Studio (aistudio.google.com) 에서 5초면 발급되는 무료 API 키로 Gemini 모델을 호출할 수 있어요.

Day 1 Step 5 에서 이미 키 받고 .env 에 꽂아보셨을 거예요.

우리가 쓰는 기본 모델은 gemini-2.5-flash-lite — 가볍고 빠르고, 한국어 응답이 꽤 자연스럽다는 게 가장 큰 장점입니다.

강점

  • 무료 쿼터가 꽤 넉넉. 2026년 5월 기준 AI Studio 무료 티어에서 분당 요청 수 · 일일 요청 수 제한 안쪽이면 전액 무료. (정확한 숫자는 바뀔 수 있어서, 정답은 ai.google.dev/gemini-api/docs/rate-limits 에서 늘 최신으로 확인하세요.)
  • 한국어 품질이 양호. 우리 ai-friends 처럼 캐릭터 대화·미연시 게임처럼 한국어 톤이 중요한 서비스에 자연스럽게 쓰입니다.
  • 멀티모달 지원. Flash 계열도 이미지·오디오 입력을 받아요. Day 8 Vision, Day 9 음성에서 다시 등장합니다.
  • OpenAI 호환 엔드포인트 제공. 우리가 spring-ai-starter-model-openaiOpenAiChatModel재활용 해서 부를 수 있는 이유가 이거예요.

약점

  • 네트워크 왕복이 있다. 내 기기 → 구글 서버 → 응답. 대륙 넘어가는 지연이 포함돼요 (대체로 수백 ms 수준).
  • 무료 티어에 학습 데이터 사용 정책이 붙을 수 있다. 무료로 쓰는 대가로 구글이 품질 개선에 내 프롬프트를 활용할 수 있는 케이스. 민감 데이터 절대 금지. (Step 6 에서 자세히)
  • 쿼터 초과 시 429. 분당/일일 리밋을 넘으면 "Resource exhausted" 에러가 돌아옵니다.

추천 용도

  • 비민감 데이터로 하는 일반 챗봇·콘텐츠 생성 실험
  • 한국어 UX 가 중요한 학생 프로젝트
  • 이미지/오디오가 섞인 멀티모달 실험 (Day 7~9)

③ Groq 무료 티어 — " 초고속이 특기"

Groq 은 전용 LPU(Language Processing Unit) 가속 칩 을 만든 회사예요.

같은 Llama 70B 라도 GPU 에서 돌릴 때보다 체감 5~10배 빠릅니다.

Day 1 과제 2 에서 직접 세 번째 프로파일로 붙여보신 분들은 이미 몸으로 느끼셨을 텐데, 응답이 "쏴아" 하고 쏟아져요.

스트리밍 UI 에서 특히 인상적입니다. console.groq.com 에서 회원가입 + 키 발급만 하면 카드 등록 없이 무료로 씁니다.

강점

  • 압도적인 추론 속도. 토큰 생성률이 분당 수백~천 토큰. 대화형 UX 에서 응답 대기 체감이 거의 없어집니다.
  • 무료 티어 제공. 분당 요청 수 · 분당 토큰 수 리밋이 걸려있지만 학습용으론 충분. (최신 리밋은 console.groq.com/docs/rate-limits 에서 확인)
  • OpenAI 호환 엔드포인트. 이것도 OpenAI 스타터 재사용 가능.
  • 오픈소스 모델 친화. llama-3.3-70b-versatile, deepseek-r1-distill-llama-70b 같은 최신 오픈 모델을 빠르게 돌려볼 수 있어요.

약점

  • 모델 카탈로그가 제한적. Groq 이 호스팅하는 오픈소스 모델만 지원. 프론티어 모델(Claude Opus, GPT-5.5 등)은 여기서 못 돌립니다.
  • 가용성 기복. 인기 모델은 트래픽이 몰리면 "Service Unavailable" 이 종종 뜹니다. 학습에는 무해, 운영엔 폴백 필요.
  • 한국어 품질은 모델 자체에 달림. Groq 자체가 한국어를 잘 하는 게 아니라 Llama·DeepSeek 의 한국어 능력을 그대로 승계.

추천 용도

  • 스트리밍 응답이 중요한 실습 (Day 6 에서 특히 유용)
  • "같은 모델을 Groq 에서 돌리면 얼마나 빨라지나" 같은 속도 감각 실험
  • 부하 테스트로 다건 호출을 빠르게 뿌려야 할 때

④ OpenRouter 무료 모델 — "한 키로 수십 개 모델"

openrouter.ai여러 프로바이더를 하나의 API 로 묶은 메타 라우터 예요. OpenRouter 에 키 하나만 등록하면 OpenAI · Anthropic · Google · DeepSeek · Meta · Mistral · Qwen 등 수십 개 프로바이더의 수백 개 모델 을 통일된 OpenAI 호환 API 로 부를 수 있습니다. 그 중에서 :free 접미사가 붙은 모델들은 완전 무료로 호출 가능해요.

2026년 5월 시점에 무료로 돌릴 만한 대표 모델들(카탈로그는 수시로 바뀌니 openrouter.ai/models?free=1 에서 최신 확인):

  • deepseek/deepseek-r1:free — 추론(reasoning) 특화 오픈 모델
  • meta-llama/llama-3.3-70b-instruct:free — 범용 오픈 모델
  • qwen/qwen-2.5-72b-instruct:free — 한국어·중국어 강점
  • google/gemma-3-27b-it:free — Google 경량 모델

강점

  • 키 하나로 수십 개 모델. "이 기능엔 어떤 모델이 맞을까?" 를 고민할 때 실험용으로 최고.
  • 무료 모델 카탈로그가 생각보다 두껍다. DeepSeek R1 급 추론 모델도 무료로 돌릴 수 있어요.
  • OpenAI 호환. 역시 스타터 재사용 가능.
  • 유료 전환이 쉽다. 나중에 Claude 4 나 GPT-5.5 를 붙이고 싶으면 OpenRouter 키에 크레딧 충전하면 끝. 자바 코드는 그대로.

약점

  • 무료 모델은 공급자가 트래픽 조절을 겁니다. 분당 요청 수 · 일일 호출 수 제한이 빡빡하고 타이밍에 따라 Rate Limit 가 자주 걸려요.
  • 동일 모델이 여러 백엔드에서 제공 될 수 있고, 어느 백엔드로 라우팅될지 제어가 어려워요. 응답 품질이 호출마다 미묘하게 다를 수 있습니다.
  • 요청에 OpenRouter 헤더 한 겹이 더 추가되는 작은 오버헤드.

추천 용도

  • 모델 탐색 단계 — "여러 모델을 한 번에 비교해보고 싶다"
  • 특정 프로바이더에 묶이고 싶지 않은 사이드 프로젝트
  • DeepSeek R1 같은 reasoning 특화 오픈 모델 을 무료로 시험해볼 때

4형제 한 장 요약표

비교 축 ① Ollama 로컬 ② Gemini Flash 무료 ③ Groq 무료 ④ OpenRouter 무료
비용 0원 쿼터 내 0원 쿼터 내 0원 :free 모델 0원
지연 기기 성능 의존 중간 (대륙간 왕복) 압도적 빠름 중간 ~ 느림 (라우팅 포함)
프라이버시 최고 (로컬) ⚠️ 무료 티어는 학습 데이터 사용 가능 ⚠️ 프로바이더 정책 확인 ⚠️ 백엔드별 상이
한국어 모델 의존 (qwen3 추천) 🟢 양호 모델 의존 (Llama 보통) 모델 의존 (Qwen 추천)
모델 카탈로그 오픈소스 경량 위주 Gemini 계열만 Groq 호스팅 오픈 모델 수십 개 프로바이더
쿼터 걱정 ❌ 없음 있음 (넉넉) 있음 (빡셈) 있음 (빡빡)
주 용도 민감 실험·오프라인·완전 무료 범용 실습·한국어 서비스 스트리밍·속도 실험 모델 탐색·Reasoning 모델

💡 튜터의 핵심 포인트 — "왜 4형제를 같이 알아야 하나?"

솔직히 말하면, 학생들 중에는 "아니 튜터님, 그냥 Gemini 하나면 되는 거 아니에요? 왜 굳이 4개를 다 알아야 해요?" 하시는 분도 있을 거예요. 이유가 있습니다.

  1. 쿼터가 터졌을 때의 폴백. Gemini 무료 쿼터를 다 썼는데 실습을 계속해야 할 때, OpenRouter 로 갈아끼우면 끝. 프로퍼티 한 줄이면 됩니다.
  2. 상황별 최적 모델이 다름. 스트리밍 UX 체험하려면 Groq, 한국어 자연스러운 대화는 Gemini, 데이터가 민감하면 Ollama. 한 가지만 쓰면 항상 어느 축에서는 불리합니다.
  3. 운영 관점에서의 다중 프로바이더 감각. 실무에서는 거의 반드시 Primary + Fallback 최소 2개를 구성해요. 학습 단계에서부터 "여러 프로바이더를 돌려가며 쓰는 리듬" 에 익숙해지는 게 자산이에요.

"튜터님, 실무에서 실제로 Primary + Fallback 을 굴리는 회사가 있나요?"

아주 흔합니다. 대표적인 패턴 세 가지 예시.

  • Provider 장애 시 자동 폴백: Anthropic API 장애 → Claude Opus 4.7 호출 실패 → OpenAI GPT-5.5 로 재시도. 사용자는 장애를 모름.
  • 비용 구간별 라우팅: 일반 쿼리는 저가 모델(Flash), 중요 의사결정 쿼리만 플래그십(Opus/GPT-5.5).
  • A/B 테스트: 50% 유저는 Claude, 50% 유저는 Gemini 로 받게 해 품질 비교.

이런 패턴을 실제로 Spring AI 위에서 깔끔하게 구현 하려면 오늘 배운 "프로바이더 추상화" 가 필수 인프라예요. 이 감각을 학습 때부터 들여놓는 것의 진짜 가치가 여기 있습니다. (패턴 코드는 오늘 과제 2 심화 에서 힌트를 드릴게요.)

자, 무료/저가 4형제 지도가 머릿속에 그려지셨죠? 여기까지는 학습·사이드 프로젝트용 세계의 이야기예요. 그런데 우리가 이걸 실제 서비스에 붙여야 하는 순간 이 언젠가 옵니다. 그때가 되면 "무료 모델만으로는 품질이 안 나와요, 고객이 불만을 표해요" 라는 현실이 기다리고 있어요. 그럼 자연스럽게 다음 질문이 따라옵니다.

"2026년 5월 현재, 유료 플래그십 모델들의 라인업은 어떻게 생겼고, 각자 뭘 잘하나요?"

Step 3 에서 Claude Opus 4.7 · Sonnet 4.6 · GPT-5.5 · Gemini 3.1 Pro 같은 최신 프론티어 모델들을 한 장에 정리하겠습니다.


Step 3: 상용 프로바이더 최신 플래그십 라인업

이번 Step 시작하기 전에 분명히 한 번 짚고 갈게요.

⚠️ 여기서 소개하는 모델들은 본 강의 실습에서 돌리지 않습니다. 전부 유료이고, 본인 카드 등록해서 직접 호출하면 한 번의 실수로 예상외 청구가 나올 수 있어요. Step 3 는 "라인업을 머릿속에 지도로 그려둔다" 가 목표입니다. 실습은 Step 2 의 무료 4형제로만 갑니다.

그럼에도 이 모델들을 알고 있어야 하는 이유 가 있어요.

  • 실무에서 "우리 서비스에 어떤 모델 붙일까?" 논의가 시작되면 거의 반드시 얘네가 후보에 오릅니다.
  • 면접에서 "최근 LLM 지형도 어떻게 보시나요?" 라는 질문이 나오면 얘네를 입에 올릴 수 있어야 해요.
  • 무료 모델의 한계에 부딪혔을 때 뭐로 업그레이드할지 결정할 수 있어야 합니다.

그래서 2026년 5월 기준, 3대 프로바이더의 최신 플래그십 라인업을 각자의 특기 중심 으로 지도에 표시해둡시다.

1. Anthropic — Claude Opus 4.7 / Sonnet 4.6

Anthropic 의 Claude 계열은 코드 품질과 장시간 에이전트 작업 에서 현재 업계 최상위권으로 평가받는 라인업이에요. 특히 긴 컨텍스트에서 지시를 지키는 충실도 가 강점입니다.

  • Claude Opus 4.7플래그십. 최고 품질. 복잡한 코드 생성, 긴 에이전틱 워크플로(수십 ~ 수백 분 동안 혼자 태스크 수행), 심층 추론, 긴 글쓰기에 강합니다. 가격은 프론티어 중에서도 상위권.
  • Claude Sonnet 4.6밸런스 타입. Opus 대비 수 배 저렴 하면서도 코드/글쓰기 품질이 매우 좋아서 실무에서 "Default" 로 쓰이는 경우가 많아요. 일반적인 챗봇·코드 어시스턴트·RAG 응답 생성의 8할은 Sonnet 으로 커버됩니다.

특기 분야

  • 코드 생성·리팩토링·리뷰 (코드 품질이 전체적으로 가장 안정적이라는 평가)
  • 에이전트 작업 — Tool 호출을 여러 단계 연쇄하는 긴 태스크에서 중간에 길을 잃지 않음 (Day 11 Tool Calling 맥락에서 중요)
  • 긴 문서 요약·작성
  • 지시 충실도 (System prompt 에 넣은 제약을 끝까지 지킴)

Spring AI 연동

  • 전용 스타터: spring-ai-starter-model-anthropic
  • 구현체: AnthropicChatModel
  • 프로퍼티: spring.ai.anthropic.api-key=..., spring.ai.anthropic.chat.options.model=claude-opus-4-7
  • 프로바이더 추상화 덕분에 우리 자바 코드는 한 줄도 안 바뀜 (Step 1 복습)
🙋 학생 질문 — "튜터님, Opus 와 Sonnet 중에 뭘 기본으로 쓰나요?"

실무 패턴은 대체로 "Sonnet 을 기본, 복잡한 케이스만 Opus 로 에스컬레이션" 이에요.

이유는 가격 차이가 꽤 큽니다(보통 4~5배).

그러니 "쉬운 요청 → Sonnet, 어려운 요청 → Opus 라우팅" 전략이 자리잡았어요.

이걸 Spring AI 에서 구현하는 건 Day 19 (Harness · Rate Limit · cost guardrail) 에서 다룹니다.

2. OpenAI — GPT-5.5 / GPT-5.4-mini

GPT-5.5 는 OpenAI 가 지향하는 "통합 추론" 모델 의 현재 플래그십이에요(2026-04-23 출시). 단일 모델 안에서 빠른 응답과 깊은 reasoning 을 자동으로 전환하는 게 특징입니다. 경량 라인은 한 세대 뒤인 GPT-5.4-mini 가 현재 최신이에요.

  • GPT-5.5최고 추론력 + 멀티모달. 복잡한 수학·코드·리서치 태스크, 멀티모달 입력(이미지·오디오) 모두 상위권. "요청이 간단하면 빠르게 답하고, 복잡하면 자동으로 길게 생각한다"는 라우팅이 내장돼 있어요. 컨텍스트도 1M+ 로 넓어졌습니다.
  • GPT-5.4-mini경량·저가. 플래그십 대비 응답 속도가 훨씬 빠르고 가격도 낮아요. 품질은 이전 세대 최상위 모델과 비슷한 수준. 실무에서 "기본 챗봇 응답용"으로 즐겨 쓰입니다.

💡 잠깐 — 왜 mini 만 5.4 예요? 5.5-mini 가 아니라?

OpenAI 는 보통 풀 모델을 먼저 출시하고, mini/nano 는 한두 분기 뒤에 따라 내리는 리듬 이에요. 그래서 플래그십이 5.5 라도 mini 라인은 직전 세대(5.4) 가 한동안 최신으로 남아있는 게 정상입니다. "풀 모델 세대 = mini 세대" 가 절대 보장 안 된다는 점, 학생 여러분이 기억해두시면 가격표 볼 때 헷갈릴 일이 줄어요. 보통 "mini 의 다음 세대 출시일" 은 OpenAI 공식 모델 페이지에서 추적합니다.

특기 분야

  • 범용 품질의 안정성 — 거의 모든 태스크에서 상위권이라 "뭘 선택해야 할지 모르겠으면 일단 GPT" 라는 농담이 괜히 있는 게 아님
  • 멀티모달 (Day 8 Vision, Day 9 음성 맥락)
  • Tool/Function Calling 생태계가 가장 성숙 (Day 11 맥락)
  • OpenAI Assistants API / Structured Outputs / JSON Mode 같은 주변 기능이 풍부

Spring AI 연동

  • 전용 스타터: spring-ai-starter-model-openai (우리가 이미 쓰는 그 스타터!)
  • 구현체: OpenAiChatModel
  • 프로퍼티: spring.ai.openai.api-key=..., 모델 ID 는 spring.ai.openai.chat.options.model=gpt-5.5 (플래그십) 또는 gpt-5.4-mini (경량)
  • 공식 OpenAI 로 붙일 땐 base-url 기본값 그대로 두면 됨 — Gemini 호환 어댑터로 썼던 걸 원래 용도로 돌리는 거예요
🙋 학생 질문 — "튜터님, GPT-5.5 랑 GPT-5.4-mini 의 한국어 품질 차이는 많이 나요?"

둘 다 한국어는 매우 자연스럽습니다.

다만 복잡한 지시·법률/의료 같은 전문 영역 에서는 GPT-5.5 가 더 안정적이에요.

일반적인 대화·요약·분류는 mini 로도 충분합니다.

가격 차이(input 약 6.7배 · output 약 6.7배)를 생각하면 mini 를 기본으로, 필요할 때만 GPT-5.5 승격 이 실무 정석이에요.

Claude 의 Sonnet/Opus 라우팅 전략과 사실상 같은 구조죠.

3. Google — Gemini 3.1 Pro

Gemini 3.1 Pro 는 Google 의 현재 플래그십이에요. 우리가 Step 2 에서 다룬 Gemini 2.5 Flash 의 "상위 버전 세대 + 고품질 티어" 정도로 이해하시면 됩니다. 이 모델의 킬러 기능은 두 가지예요.

  • 초대용량 컨텍스트 윈도우 — 한 번의 호출에 1M 토큰 수준의 문서를 밀어넣을 수 있어요. 책 한 권, 긴 기술 문서 전체를 통째로 컨텍스트로 쓰는 시나리오가 현실적으로 가능해집니다. 다른 프론티어 모델들이 대체로 200K 토큰대임을 생각하면 자릿수 차이 예요.
  • 네이티브 멀티모달 — 이미지·오디오·비디오 입력을 자체 포맷으로 직접 처리. 특히 비디오 이해는 현재 Gemini 가 가장 완성도 높은 것으로 평가됩니다.

특기 분야

  • 초장문 컨텍스트 RAG (Day 15~16 맥락에서 중요)
  • 비디오/오디오/이미지가 섞인 멀티모달 추론
  • 한국어 품질 — 한국어 UX 중심 서비스에서 경쟁력 있음
  • 기업 고객은 Vertex AI 를 통한 GCP 통합(Grounding / Safety Attributes / IAM)

Spring AI 연동 — 두 갈래

접근 방식 구현체 특징
OpenAI 호환 어댑터 OpenAiChatModel (우리가 이미 쓰는 방식) 무료 티어 AI Studio 키로 바로. 학습·프로토타입 최적
Vertex AI 전용 VertexAiGeminiChatModel GCP 프로젝트/서비스 계정 필요. 엔터프라이즈 기능 풀 커버

운영에서 Grounding 이나 Safety Attributes 같은 GCP 전용 기능이 필요하면 전용 구현체로 갈아끼웁니다 (Step 1 끝에서 잠깐 짚었죠).

3대 프로바이더 플래그십 한 장 요약

플래그십 강점 특히 쓸 곳 Spring AI 스타터
Claude Opus 4.7 지시 충실도·에이전트·코드 품질 긴 에이전트 태스크, 코드 생성, 심층 리서치 spring-ai-starter-model-anthropic
Claude Sonnet 4.6 가성비·코드·글쓰기 실무 기본값, 챗봇, RAG 응답 생성 spring-ai-starter-model-anthropic
GPT-5.5 범용 최상위·멀티모달·도구 생태계 뭘 쓸지 모르겠을 때의 디폴트 spring-ai-starter-model-openai
GPT-5.4-mini 빠름·저렴·범용 품질 안정 기본 챗봇 응답, 분류/요약 spring-ai-starter-model-openai
Gemini 3.1 Pro 1M 컨텍스트·비디오 이해·한국어 초장문 RAG, 비디오 분석, 한국어 UX spring-ai-starter-model-openai (호환) 또는 spring-ai-starter-model-vertex-ai-gemini

💡 튜터의 핵심 포인트 — "플래그십 = 만능" 은 오해입니다

자, 여기까지 봤으면 "와 최고의 모델들이네, 이걸 다 쓰면 최고의 서비스가 되겠네?" 하실 수 있는데, 실무 감각으로는 정확히 반대 예요.

"플래그십을 전부 쓰는 서비스는 거의 없다. 대부분 중저가 모델을 기본으로 돌리고, 진짜 필요할 때만 플래그십으로 승격시킨다."

이유는 단순합니다. 플래그십과 중저가 모델의 가격 차이가 5~20배 예요. 그리고 서비스에서 오가는 요청의 상당수는 사실 중저가 모델로도 충분한 난이도 입니다. 전부 Opus 로 돌리면 한 달 청구서가 10배가 되는데 유저 만족도는 20~30% 나아지는 정도. 합리적인 ROI 가 나오지 않죠.

실무에서는 이런 식으로 조합합니다.

요청 종류 고르는 모델
간단한 분류·태깅·요약 GPT-5.4-mini 또는 Gemini Flash (가장 저렴)
일반 챗봇 응답·RAG 답변 Claude Sonnet 4.6 또는 GPT-5.4-mini
복잡한 추론·장문 분석·에이전트 Claude Opus 4.7 또는 GPT-5.5
1M 토큰급 컨텍스트가 필요한 문서 Gemini 3.1 Pro

이걸 "모델 라우팅" 이라고 해요.

요청의 특성에 따라 다른 모델로 라우팅시키는 패턴이에요.

Spring AI 에서 이걸 구현하는 건 오늘의 영역은 아니지만 (Day 11 Tool Calling 부터 조금씩 등장하고, Day 19 Harness 엔지니어링에서 본격 운영 레벨로 다룹니다), 개념만큼은 오늘 확실히 잡고 가야 해요.

"모델은 하나를 고르는 게 아니라 여러 개를 조합하는 것이다" 라는 감각입니다.

자, 그럼 이 플래그십들이 구체적으로 얼마나 비싼지 가 바로 다음 질문이죠? "GPT-5.5 가 비싸다 비싸다 하는데 내 서비스에 붙이면 한 달에 대체 얼마가 나오는 거야?" 이 막연한 불안을 Step 4 에서 숫자로 녹여줄 겁니다. DAU 100 / DAU 10K / DAU 1M 세 시나리오를 들고 실제로 계산기를 두드려봐요.


Step 4 — 실제 요금 시뮬레이션 워크샵 (20분)

자, 이번 Step 은 숫자 놀이 입니다. 계산기 켜시고 따라와주세요. 여러분이 지금 하는 계산이, 실무 나가서 기획자·대표님이 "이 AI 기능 붙이면 한 달에 얼마 들어요?" 물었을 때 5초 안에 답할 수 있는 근육 이 됩니다.

⚠️ 가격 면책 조항 아래 단가는 2026년 5월 기준 공식가 근사치 입니다. LLM 프로바이더들은 3~6개월 주기로 가격을 내리는 경향이 있어요 (모델이 최적화되고 경쟁이 붙어서). 실제 프로젝트에 붙이기 전에는 반드시 각 프로바이더 공식 Pricing 페이지로 최신가 재확인 하세요. 오늘 수업의 숫자는 "감각 잡는 용" 입니다.

1) 먼저 — 토큰은 LLM 의 기름값

LLM 요금은 토큰(Token) 단위로 과금 됩니다. 토큰은 대략 "단어 조각" 이라고 보시면 돼요. 영어 기준으로는 단어 하나가 보통 1~2 토큰, 한국어는 조사·어미가 많아서 한 글자가 1~2 토큰 정도로 잡힙니다.

한 번의 LLM 호출은 두 가지 토큰으로 쪼개져 청구됩니다.

구분 가격 비유
Input 토큰 여러분이 LLM 에게 보낸 프롬프트 + 시스템 프롬프트 + 과거 대화 이력 전부 싸다 (기준가) 주문 내용 읽는 비용
Output 토큰 LLM 이 만들어낸 응답 Input 의 3~5배 비쌈 요리 만드는 비용

왜 Output 이 몇 배나 비쌀까요? LLM 이 토큰을 하나 생성 할 때마다 전체 모델을 한 번씩 돌려야 합니다 (자기회귀 생성). 반면 Input 은 한 번에 통째로 읽고 끝이라 연산량이 훨씬 적어요. "읽기는 빠르고, 쓰기는 느리다" — 여러분 자신의 경험이랑도 일치하죠?

2) 2026년 5월 기준 토큰 단가표

공식 Pricing 페이지에서 긁어온 M(백만) 토큰당 USD 기준입니다.

모델 Input ($/1M) Output ($/1M) Input:Output 비율
Claude Opus 4.7 $15 $75 1 : 5
Claude Sonnet 4.6 $3 $15 1 : 5
GPT-5.5 $5 $30 1 : 6
GPT-5.4-mini $0.75 $4.5 1 : 6
Gemini 3.1 Pro (≤200K 컨텍스트) $3.5 $14 1 : 4
Gemini 2.5 Flash $0.1 $0.4 1 : 4
Ollama 로컬 (Gemma 3 / Llama 3) $0 $0 전기세만

이 표만 봐도 벌써 "Opus 4.7 Output 은 Flash 의 187.5배" 라는 게 보이죠? 그런데 아직 이 숫자가 여러분에게 안 꽂힐 거예요. 실제 서비스 시나리오에 붙여봐야 숨이 멎는 순간 이 옵니다. 바로 다음에 해볼게요.

3) 시나리오 공통 전제 조건

계산 단순화를 위해 다음 가정을 둡니다 (실무 챗봇 기준 평균적인 수치예요).

  • 한 요청당: Input 500 토큰 + Output 300 토큰 = 합산 800 토큰
  • 유저 1인당 하루 사용: 평균 5회 호출 (가볍게 쓰는 챗봇 기준)
  • 한 달 = 30일
  • 사용자는 Active Day 기준 (그날 안 들어온 사람은 계산 안 함)

이 전제로 DAU × 5 × 30 × 800 = 월간 총 토큰 이 계산됩니다.

4) 시나리오 A — DAU 100 (사이드 프로젝트)

"퇴근하고 취미로 돌려보는 AI 친구 앱" 정도의 규모예요.

  • 월간 요청 수: 100 × 5 × 30 = 15,000 회
  • 월간 Input 토큰: 15,000 × 500 = 7.5M
  • 월간 Output 토큰: 15,000 × 300 = 4.5M
모델 Input 비용 Output 비용 월 합계 (USD) 월 합계 (KRW, 1$=1,400원)
Claude Opus 4.7 $112.5 $337.5 $450 약 63만원
Claude Sonnet 4.6 $22.5 $67.5 $90 약 12.6만원
GPT-5.5 $37.5 $135 $172.5 약 24만원
GPT-5.4-mini $5.6 $20.3 $25.9 약 3.6만원
Gemini 3.1 Pro $26.25 $63 $89 약 12.5만원
Gemini 2.5 Flash $0.75 $1.8 $2.55 약 3,600원
Ollama 로컬 $0 $0 $0 전기세 + 본인 시간

튜터 관전 포인트: 사이드 프로젝트에서 Opus 를 풀가동 하면 월 63만원. 월세 내고 Opus 붙이고 나면 점심값 남지 않는 수준이에요. 반대로 Flash 나 GPT-5.4-mini 로 돌리면 치킨 한두 마리값 이면 끝납니다. "사이드에서 굳이 플래그십 안 써도 된다" — 오늘의 교훈 하나.

5) 시나리오 B — DAU 10K (스타트업 초기 PoC)

"시드 투자 받고 MAU 30만 찍은 스타트업의 AI 기능" 정도예요. 수치는 DAU 100 시나리오의 100배 입니다.

모델 월 합계 (USD) 월 합계 (KRW) 연간 (KRW)
Claude Opus 4.7 $45,000 약 6,300만원 7.5억원
Claude Sonnet 4.6 $9,000 약 1,260만원 약 1.5억원
GPT-5.5 $17,250 약 2,415만원 약 2.9억원
GPT-5.4-mini $2,588 약 362만원 약 4,350만원
Gemini 3.1 Pro $8,925 약 1,250만원 약 1.5억원
Gemini 2.5 Flash $255 약 35.7만원 약 430만원

튜터 관전 포인트: DAU 10K 에서 Opus 를 풀가동하면 연 7.5억 입니다. 개발자 5명 연봉이 날아가요. 이 단계에서 CTO 가 가장 먼저 하는 일은 "요청의 성격을 분류해서 mini 로 돌릴 수 있는 건 mini 로 보내고, 진짜 어려운 요청만 플래그십으로 승격" 하는 라우팅 설계입니다. Step 3 에서 깔아둔 복선 기억나시죠?

6) 시나리오 C — DAU 1M (스케일업)

"인스타그램이 모든 유저에게 AI 캡션 추천을 준다" 정도의 규모예요. DAU 100 시나리오의 10,000배.

모델 월 합계 (USD) 연간 (USD) 연간 (KRW)
Claude Opus 4.7 $4,500,000 $54M 756억원
Claude Sonnet 4.6 $900,000 $10.8M 약 151억원
GPT-5.5 $1,725,000 $20.7M 약 290억원
GPT-5.4-mini $258,750 $3.1M 약 43.5억원
Gemini 3.1 Pro $892,500 $10.7M 약 150억원
Gemini 2.5 Flash $25,500 $306K 약 4.3억원

튜터 관전 포인트: DAU 1M 에서 Opus 풀가동은 연 756억 — 중견 IT 회사 전체 개발 인건비와 맞먹습니다. 그래서 실무에서는 이 규모에서 "플래그십 풀가동" 하는 회사는 거의 없어요. 대부분은 이렇게 조합합니다.

유저 요청 들어옴
  ├─ 간단한 분류/태깅 요청 (전체의 70%) ──→ GPT-5.4-mini
  ├─ 일반 챗봇 응답       (전체의 25%) ──→ Sonnet 4.6
  └─ 진짜 어려운 추론     (전체의 5%) ──→ Opus 4.7 / GPT-5.5

이렇게 돌리면 "전부 Opus" 대비 비용 1/10 로 떨어지고, 체감 품질은 거의 동일 하게 유지됩니다. 그리고 답 중복·같은 질문 반복은 캐싱 으로 더 줄이고요.

7) 💡 튜터의 한마디 — "계산기가 아키텍처를 바꾼다"

오늘 계산한 숫자 보고 느끼신 거 있으시죠? LLM 아키텍처에서 비용은 기능만큼이나 중요한 설계 변수 입니다. 그래서 실무에서 LLM 을 붙일 때 가장 먼저 하는 작업이 두 가지예요.

  1. 토큰 계측(Observability): 지금 어느 엔드포인트가 Input 500 을 쓰는지, Output 2000 을 뱉는지 전부 집계
  2. 모델 라우팅(Routing): 요청 난이도별로 다른 모델로 분기 (+ 캐싱)

"LLM 비용 관리 운영 기법"Day 19 Harness 엔지니어링 + Day 20 Observability 에서 Spring AI + Micrometer + 캐시 조합으로 본격적으로 다룹니다. 오늘은 "이렇게까지 관리해야 하는 이유" 를 숫자로 각인시키는 단계였어요.

그리고 다시 한번 — "Ollama 로컬 $0" 의 매력 보이시죠? 학습·프로토타입·개인 프로젝트에서는 여러분이 Day 1 에 띄운 바로 그 Gemma 3 한 대가 가장 강력한 무기 입니다.

자, 이제 비용 감각이 잡혔으니 다음은 "그래서 내 서비스엔 어떤 모델을 골라야 해?" 의 판단 프레임워크를 드릴 차례입니다. Step 5 에서 비용 / 지연 / 프라이버시 / 품질 / 한국어 5축 트레이드오프 매트릭스를 들고 대표 모델 10개를 한판에 비교합니다. 실무에서 의사결정할 때 바로 꺼내 쓸 수 있는 치트시트 가 나와요.


Step 5 — 트레이드오프 매트릭스 5축 (25분)

여러분, LLM 모델 고르는 일은 "어떤 게 제일 좋아요?" 라는 질문이 성립 안 되는 영역이에요. 자동차에서 "어떤 차가 제일 좋아요?" 와 똑같아요. 출퇴근용? 오프로드? 레이싱? 패밀리? 용도마다 정답이 다르잖아요.

LLM 도 마찬가지입니다. 그래서 실무에서는 아래 5개 축(Axis) 으로 후보들을 비교하고, 내 서비스가 어떤 축에 민감한지 를 먼저 결정합니다. 그러면 모델은 자동으로 정해집니다.

1) 5축 정의

실무에서 모델을 판단할 때 사용하는 5개 축이에요. 각 축이 왜 중요한지 감만 잡고 가시면 됩니다.

언제 중요한가
비용 요청당 과금액 (토큰 단가) DAU 가 큰 B2C 서비스, 장문 응답이 많은 경우
지연(Latency) 첫 토큰까지의 시간 + 전체 응답 시간 실시간 챗봇, 음성 어시스턴트, 검색 자동완성
프라이버시 데이터가 외부 회사 서버를 거치는가 의료·금융·법률 등 규제 산업, 사내 문서 RAG
품질 복잡한 추론·코드·지시 충실도 에이전트 태스크, 코드 생성, 심층 분석
한국어 한국어 이해·생성 자연스러움 한국 유저 대상 챗봇, 한국어 문서 요약/검색

다섯 개 축을 동시에 만족시키는 모델은 없습니다. 이 지점이 가장 중요해요. 어떤 축을 희생하고 어떤 축을 살릴지 — 그게 아키텍처 의사결정 의 핵심입니다.

2) 대표 모델 10개 × 5축 매트릭스

별 1개(⭐)=나쁨, 별 5개(⭐⭐⭐⭐⭐)=최고 기준입니다. 2026년 5월 기준 실무 감각 으로 매겼어요.

모델 비용 지연 프라이버시 품질 한국어 한 줄 특징
Claude Opus 4.7 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 품질 깡패, 지갑 깡패
Claude Sonnet 4.6 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 실무 기본값
Claude Haiku 4.5 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 저가 Claude
GPT-5.5 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 범용 최상위
GPT-5.4-mini ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 챗봇 기본기
Gemini 3.1 Pro ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 한국어·장문의 왕
Gemini 2.5 Flash ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 싸고 빠른 치트키
Groq — Llama 3.3 70B ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 압도적 지연 1위
Ollama — Gemma 3 4B ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ 프라이버시 1위
OpenRouter — 무료 모델 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐ ⭐⭐ ⭐⭐⭐ 학습·실험 전용

왜 프라이버시는 대부분 ⭐⭐⭐ 일까? 클라우드 API 를 쓰는 이상 "우리 서버가 아닌 곳으로 데이터가 한 번 나간다" 는 사실은 바꿀 수 없어요. 다만 엔터프라이즈 계약 (Anthropic AWS Bedrock, Azure OpenAI, Vertex AI Gemini 등) 에서는 학습 사용 금지 / 리전 고정 / 데이터 거주(Data Residency) 같은 보증을 받을 수 있어서 금융·의료에서도 점점 쓰입니다. 그 이야기는 바로 Step 6 PII 체크리스트 에서 본격적으로 다룰게요.

3) 각 축별 "1등" 이 누구인지 보이시죠?

같은 10개 모델인데, 축마다 우승자가 다릅니다. 이 사실 하나가 오늘 Step 의 핵심 메시지예요.

1등 2등 꼴찌
비용 Ollama Gemma 3 / Flash Haiku / GPT-5.4-mini Opus 4.7
지연 Groq Llama 3.3 Haiku / Flash / mini Ollama (CPU/GPU 성능 따름)
프라이버시 Ollama 로컬 (독보적) 엔터프라이즈 Bedrock/Azure OpenRouter 무료
품질 Opus 4.7 / GPT-5.5 (공동) Sonnet / Gemini 3.1 Pro Gemma 3 4B / OpenRouter 무료
한국어 Gemini 3.1 Pro Claude / GPT 시리즈 Llama / Gemma (영어 편향)

관전 포인트: Opus 4.7 은 품질 1등이지만 비용은 꼴찌예요. Ollama 로컬은 비용·프라이버시 1등이지만 품질은 뒤쪽이고요. "한 모델이 모든 축에서 이기지 않는다" — 이걸 몸으로 느끼시는 게 Step 5 의 전부입니다.

4) 판단 프레임워크 — 이 세 질문만 하세요

자, 매트릭스를 봤으니 이제 써먹을 차례 예요. 실무에서 어떤 모델을 써야 할지 결정할 때 저는 항상 이 세 가지 질문 만 던져봅니다. 순서대로 답해나가면 모델이 거의 자동으로 정해져요.

질문 1 — 이 데이터, 외부로 내보낼 수 있나요?

  • NoOllama 로컬 (또는 Bedrock/Vertex 엔터프라이즈 계약). 여기서 끝.
  • Yes → 질문 2 로

질문 2 — 응답까지 몇 초까지 기다려줄 수 있어요?

  • 1초 미만 (실시간 UX) → Groq / Flash / mini / Haiku 중에서 품질 최상위
  • 2~5초 OK (일반 챗봇) → 질문 3 으로
  • 10초 이상도 OK (비동기 배치) → 품질 축으로 자유롭게 선택

질문 3 — 월 예산이 얼마예요?

  • 월 $100 미만 (사이드/취미) → Flash / GPT-5.4-mini / Ollama
  • 월 $1K~$10K (스타트업 초기) → Sonnet 4.6 / Gemini 3.1 Pro 기본 + 일부 플래그십
  • 월 $10K 이상 (규모 있는 서비스) → 라우팅 필수. mini 70% + Sonnet 25% + Opus 5%

세 질문만 해봐도 후보가 2~3개로 압축 됩니다. 그다음 한국어 품질 로 최종 간추리면 끝이에요.

5) ai-friends 에 대입해보면?

우리 프로젝트에 이 프레임워크를 적용해볼까요?

질문 ai-friends 답
데이터 외부 반출 OK? 강의용이라 괜찮지만, 실제 출시한다면 유저의 감정 대화 이력이 민감 → 엔터프라이즈 계약 필요
응답 시간? 미연시 게임 채팅 흐름 → 2~5초 OK
예산? 강의 프로젝트 → $0 기반 학습, 졸업 후 사이드 프로젝트면 월 $100 미만
한국어? 필수 — 유저가 한국인 기준

종합하면 학습용은 Ollama (Gemma 3) + Gemini 무료 티어 조합, 서비스 전환 시 Claude Sonnet 또는 Gemini Flash 로 승격 이 적절합니다. 우리가 Day 1 에 깔아둔 프로파일 구성이 의도적으로 이 판단을 실습 하도록 설계된 거예요.

6) 💡 튜터의 한마디 — 매트릭스는 스프레드시트로 관리하세요

실무에서 신규 LLM 프로젝트 PoC 시작하면, 1주일 안에 이 5축 매트릭스를 구글 스프레드시트로 만드는 것 을 강력 추천해요. 왜냐하면:

  1. 모델들이 6개월마다 갈아엎어집니다. 오늘의 표는 6개월 뒤 절반 이상 갱신됩니다.
  2. 여러분 서비스의 데이터 민감도 / SLA / 예산도 분기마다 바뀝니다.
  3. 의사결정 히스토리가 남아야 "그때 왜 Sonnet 골랐더라?" 를 6개월 뒤 확인할 수 있어요.

팀에서 이런 스프레드시트 하나 유지하는 게 "LLM 잘 아는 팀" 의 신호입니다. 나가서 회사 들어가면 바로 만드세요. 기획자·PM 이 여러분 눈빛이 달라집니다.

자, 여기까지 판단 프레임까지 잡았는데 — 세 질문 중 가장 앞에 있던 "데이터 외부 반출 OK?" 이거 여러분 쉽게 답하기 어렵죠? "뭐가 민감한 건데?" "시스템 프롬프트에 실명 박으면 안 되나?" "유저가 입력한 걸 Gemini 한테 넘기면 법적으로 문제 있나?" — 이 감각을 Step 6 에서 PII · 민감정보 체크리스트 로 구체화합니다.

ai-friends 맥락의 실제 사례로요.


Step 6 — ⚠️ PII · 민감정보 체크리스트 (20분)

자, 이번 Step 은 "법무팀 감각" 을 한 꼬집 얹는 시간이에요. 놀라실 텐데, 현업에서 LLM 도입 프로젝트가 기술 이슈로 엎어지는 경우보다 "법적 이슈로 퇴짜 맞는 경우" 가 더 많아요. 대표님·CTO 가 개발자에게 가장 먼저 던지는 질문이 "우리 유저 데이터, 이 AI 한테 보내도 법적으로 괜찮아?" 입니다.

오늘 이 감각만큼은 "나는 백엔드 개발자지 법무 담당자 아니야" 하고 넘기지 마세요. LLM 시대 백엔드 개발자는 데이터가 어디로 흐르는지 설명할 줄 아는 게 최소 자격 입니다.

1) 먼저 — PII 가 뭐예요?

PII (Personally Identifiable Information, 개인식별정보) — "이 정보 하나만으로, 혹은 다른 정보와 조합했을 때 특정 개인을 식별할 수 있는 정보" 를 말해요.

한국 개인정보보호법 · GDPR · 미국 CCPA 가 전부 비슷한 정의를 씁니다. 실무에서 자주 마주치는 PII 예시는 이렇게 정리할 수 있어요.

분류 예시
직접 식별자 실명, 주민번호, 이메일, 전화번호, 주소
준식별자 생년월일 + 성별 + 우편번호 (이 조합만으로도 87% 개인 특정 가능)
민감정보(Sensitive) 건강·의료 정보, 성적 지향, 정치·종교 성향, 생체정보
간접 식별자 IP 주소, 쿠키 ID, 기기 ID, 로그인 히스토리

ai-friends 는 챗봇 이잖아요? 유저가 대화 중에 "저 오늘 우울해요, 우리 아빠 이름은 OOO 이고…" 이런 얘기를 자연스럽게 흘려요. 이 대화가 그대로 LLM API 에 전송 된다면 — 이게 바로 PII 유출 시작점입니다.

2) LLM 으로 PII 가 새는 3가지 경로

교재에서 이 대목이 가장 중요합니다. 여러분이 Spring AI 로 코드를 짤 때 "어디서 PII 가 흘러나가는지" 세 지점을 콕 짚어드릴게요. 각 지점마다 ai-friends 맥락의 실제 사례 도 같이 보죠.

경로 ① — 시스템 프롬프트(System Prompt) 내부에 박힌 PII

개발자가 무의식적으로 시스템 프롬프트에 직접 때려박는 경우예요. 가장 흔한 실수 TOP 1.

나쁜 예 (절대 하지 마세요)

System: "너는 홍길동(hsg9984@gmail.com, 010-1234-5678)의 AI 친구야.
        홍길동은 30대 개발자이고 서울 강남구 OO동에 살아.
        따뜻하게 반말로 대화해."

User: "오늘 기분 안 좋아."

여기서 일어나는 일: 유저 대화 한 번마다 실명·이메일·전화번호·주소가 Gemini/OpenAI 서버로 전송 됩니다. 시스템 프롬프트는 요청마다 같이 보내져요. 1만 번 대화 = 1만 번 PII 전송.

올바른 패턴: 식별자를 마스킹/익명 ID 로 치환합니다.

System: "너는 user_7f3a 의 AI 친구야.
        상대는 30대 개발자이고 수도권에 살아.
        따뜻하게 반말로 대화해."

실명 대신 내부 해시 ID, 주소는 광역 단위까지만, 이메일·전화번호는 애초에 시스템 프롬프트에 없어야 해요. LLM 한테는 "말투와 맥락" 만 주면 충분합니다.

경로 ② — 과거 대화 이력의 무제한 누적 전송

ChatMemory (Day 5 에서 본격 다룸) 를 적당히 자르지 않으면, "유저가 3달 전 흘린 PII 가 오늘 요청에도 여전히 같이 전송" 됩니다.

나쁜 시나리오 ❌:

  • 3월: 유저가 "저 병원에서 우울증 진단 받았어요" 흘림 → 대화 이력 저장
  • 4월: 유저가 "오늘 날씨 어때?" 만 물어봄 → 하지만 3월 대화가 그대로 context 로 따라붙어 LLM 서버로 다시 전송

올바른 패턴:

  • ChatMemory 크기 제한 (최근 N턴만 유지) — Day 5 에서 구현합니다
  • 민감 토큰 포함 대화는 마스킹 후 저장 또는 아예 저장 안 함
  • 유저가 "대화 초기화" 버튼 누르면 실제로 DB에서 삭제 (개인정보 삭제권)

경로 ③ — 업로드 이미지의 EXIF 메타데이터

Day 8 (이미지 입력) 에서 본격적으로 쓰게 될 멀티모달 기능의 함정 이에요. 유저가 올린 JPEG 파일의 EXIF 메타데이터에는 GPS 좌표, 촬영 기기, 촬영 시각 이 박혀 있습니다.

나쁜 시나리오 ❌: 유저가 자기 방 사진 한 장 올리면서 "이거 어때?" 물었더니 — EXIF 에 집 주소 GPS 좌표 그대로 박혀서 LLM 서버로 전송. 만약 LLM 이 학습에 쓴다면 "사진 주인 집 주소" 가 모델에 녹아들 수도 있어요.

올바른 패턴:

  • 업로드 시 EXIF 스트립(Strip) — Java 진영에서는 metadata-extractor / commons-imaging 라이브러리로 처리
  • 이미지는 썸네일 수준으로 리사이즈 후 전송 (고해상도 불필요)
  • 실제 구현은 Day 8 에서 다루지만 — 오늘은 "EXIF 가 위험하다" 는 감각만 확보

3) ai-friends 3종 체크리스트

우리 프로젝트를 실제 출시한다고 가정 하고, 꼭 체크해야 할 세 가지를 정리해드릴게요.

# 체크 항목 현재 상태 Day 5 이후 목표
1 시스템 프롬프트에 실명/이메일/전화 포함 여부 Day 1 때 우리는 프롬프트 만지기 전 단계 → 아직 안전 Day 3 PromptTemplate 에서 익명 ID 치환 패턴 의도적으로 연습
2 ChatMemory 크기 제한 아직 ChatMemory 도입 전 Day 5 에서 최근 10~20 턴 제한 + 민감토큰 필터링 로직
3 이미지 업로드 시 EXIF 제거 이미지 업로드 없음 Day 8 에서 업로드 파이프라인 만들 때 EXIF strip 기본 적용

"지금 당장 고칠 건 없다" 가 결론이지만 — 각 단계를 올라갈 때마다 체크리스트가 딸려온다는 걸 기억해두세요.

4) 해결 패턴 3종 — 실무에서 쓰는 도구들

① 마스킹 (Masking) — 가장 현실적, 가장 자주 씀

LLM 에 보내기 전에 PII 를 플레이스홀더로 치환 하는 방식입니다. 한국어 맥락에서는 이런 식.

유저 원문 : "내 전화 010-1234-5678 이야"
마스킹 후 : "내 전화 [PHONE_1] 이야"
            ↓
         LLM 으로 전송
            ↓
LLM 응답  : "[PHONE_1] 로 전화드릴게요"
            ↓
         역치환
            ↓
유저에게  : "010-1234-5678 로 전화드릴게요"

Java 에서는 정규식 기반 마스커(자체 구현 또는 Presidio 같은 OSS 인스피레이션) 를 Spring AI 의 Advisor (Day 5 ChatMemory 와 함께 본격 등장) 에 꽂아 요청·응답 양방향 파이프라인으로 적용합니다. 오늘은 "이런 도구를 쓰는구나" 정도만 기억하면 돼요.

② 엔터프라이즈 LLM 계약 — 학습 금지 + 리전 고정

Claude (AWS Bedrock) · GPT (Azure OpenAI) · Gemini (Google Vertex AI) 전부 엔터프라이즈 계약이 있어요. 공통 보장 사항이 이겁니다.

  • 학습에 사용 금지 (No-Train) — 우리 대화가 모델 학습에 흘러가지 않음
  • 데이터 거주(Data Residency) — 서울/도쿄/버지니아 등 리전 고정
  • 감사 로그 제공 — 법적 분쟁 시 근거 자료 금융·의료·공공 프로젝트는 반드시 엔터프라이즈 계약 라인 을 씁니다. 일반 API 키로는 절대 들어갈 수 없어요.

③ 로컬 LLM + 클라우드 LLM 하이브리드

가장 민감한 데이터(실명, 의료 기록 등) 는 Ollama 로컬 에서 1차 처리하고, 민감하지 않은 나머지는 클라우드 LLM 으로. 이걸 하이브리드 LLM 아키텍처 라고 해요.

[유저 질문]
     │
     ├─ 민감 토큰 감지 → Ollama 로컬 (외부 전송 X)
     └─ 일반 질문     → Gemini / GPT (클라우드)

우리가 Day 1 에 띄운 Ollama 로컬 이 이 하이브리드 아키텍처의 출발점입니다. 오늘은 "왜 Ollama 를 무조건 한 장 깔고 가는지" 의 이유 하나를 더 얹는 시간이에요.

5) 실제로 터진 사례 TOP 3 — 교훈용

사례 1 (2023, 삼성전자) — 개발자가 회사 소스코드를 ChatGPT 에 그대로 붙여넣어 디버깅 요청 → 내부 코드가 OpenAI 서버로 업로드됨. 삼성은 이후 ChatGPT 사용 금지령 발동.

사례 2 (2023, 이탈리아 정부) — 이탈리아 데이터 보호청이 GDPR 위반 으로 ChatGPT 를 일시 차단. 개인정보 수집·처리 근거 부실 판정.

사례 3 (2024~, 각종 챗봇 서비스) — 시스템 프롬프트 내부에 실명·이메일 박아두고 프롬프트 인젝션 공격으로 유출 된 사고가 주기적으로 터짐 (유저가 특정 문구로 유도하면 시스템 프롬프트를 뱉어내는 취약점).

6) 💡 튜터의 한마디 — "데이터가 어디로 흘러가는지 한 장의 다이어그램으로"

실무에서 LLM 프로젝트의 보안 리뷰법무 리뷰 가 들어오면, 가장 먼저 요구받는 문서가 뭘까요? 데이터 플로우 다이어그램 입니다.

[유저 입력] → [우리 서버] → [마스킹 Advisor] → [Gemini API]
                                  ↓
                           [ChatMemory DB — AES-256 암호화]

이런 한 장의 다이어그램을 LLM 파이프라인 초안 단계에서 미리 그려두는 습관 만 들여도 여러분 법무팀한테서 "오 제대로 한다" 평가 받습니다. 그림 한 장이 한국 개인정보보호법 제29조(안전조치 의무) 충족 근거가 되기도 해요.

오늘 체크리스트는 Day 5 ChatMemory / Day 8 이미지 입력 / Day 19~20 LLM Ops 에서 하나씩 실제 코드로 연결됩니다. 오늘은 "감각" 만 확실히 잡고 가시면 됩니다.

자, 개념 얘기 길었죠? 이제 Step 7 에서는 손으로 직접 돌려보는 시간 입니다.

Day 1 에 띄워둔 ai-friends 에 Ollama / Gemini / Groq 프로파일을 번갈아 활성화 하면서 같은 질문 을 세 모델에 던져 응답·지연·한국어 품질을 비교해볼게요.

오늘 배운 5축 매트릭스가 실제로 내 눈에서 검증 되는 순간입니다.


Step 7 — 프로바이더 전환 시연 워크샵 (25분)

자, 마지막 Step 입니다. 여기부턴 손을 움직이는 시간 이에요. 오늘 배운 5축 매트릭스 · 비용 감각 · PII 가이드는 "실제로 모델을 바꿔봤더니 체감이 다르더라" 라는 경험 없이는 머릿속에 안 박혀요. 그래서 같은 질문 3개3개 프로바이더 에 던져서 표 하나 를 직접 채워보는 게 오늘의 마지막 과업입니다.

💡 핵심 포인트 — Step 7 에서 여러분이 주목해야 할 한 가지는, 자바 코드는 단 한 줄도 바꾸지 않는다 는 겁니다. 오늘 Step 1 에서 깔아둔 프로바이더 추상화.env 한 줄 수정만으로 완전히 다른 AI 엔진 으로 스위칭해주는 걸 직접 확인하게 돼요.

1) 실습 준비 — Day 1 에서 이미 거의 다 해놨어요 ✅

Day 1 Step 8 과 과제 2 를 완주하신 분이라면, 현재 ai-friends 에는 3개 프로파일 이 모두 살아있습니다.

프로파일 모델 필요한 설정
ollama gemma3:4b (로컬) Ollama 데몬 실행 중 (ollama serve)
gemini gemini-2.5-flash-lite .envGEMINI_API_KEY
groq llama-3.3-70b-versatile .envGROQ_API_KEY

⏩ 중간 합류 학생 가이드 Day 1 과제 2 (Groq 프로파일) 를 아직 안 해두셨으면, 먼저 git checkout day01-setup 로 Day 1 마친 시점으로 맞추거나 — 강사 안내에 따라 Groq 콘솔(https://console.groq.com)에서 카드 등록 없이 무료 API 키를 발급받고 .envGROQ_API_KEY= 줄에 붙여넣으세요.

2) 워크샵 질문 3종 — 의도적으로 고른 문항

다음 3개 질문을 준비했어요. 각 질문은 특정 축을 노리고 고른 겁니다.

# 질문 노리는 축 기대 관찰
Q1 자기소개 한 줄로 해줘 지연 + 한국어 모델별 첫 응답 속도, 문체 차이
Q2 대한민국 최신 대통령 이름 한 명만 말해 품질 (환각 유발) 학습 컷오프 따라 엉뚱한 이름 뱉을 수 있음 — 환각 체감
Q3 EUV 리소그래피가 왜 7nm 이하 공정에 필수적인지 3문장 설명 품질 (전문 지식) Gemma 3 4B 와 Llama 70B / Gemini Flash 의 깊이 차이 체감

💡 Q2 는 일부러 환각(Hallucination) 을 유발 하도록 설계한 질문이에요. 모델의 학습 컷오프가 과거라면 틀린 답을 자신만만하게 뱉습니다. 이 경험이 Day 15 RAG 학습의 동기 가 됩니다 — "LLM 혼자서는 최신 정보를 모른다" 는 걸 몸으로 느끼는 순간.

3) 실습 ① — Ollama 로 먼저

Ollama 데몬이 호스트에서 돌고 있는지 먼저 확인합니다.

# 터미널에서 (호스트)
ollama list
# 결과에 gemma3:4b 가 없으면:
ollama pull gemma3:4b

이제 .env 를 열어서 프로파일을 ollama 로 바꾸고 앱을 재기동합니다.

# ai-friends/.env 에서 이 줄을 수정
SPRING_PROFILES_ACTIVE=docker,ollama

# 재기동
./run.sh

앱이 올라오면 Postman 또는 curl 로 질문 3개를 던져봅니다.

curl -s "http://localhost:8080/api/hello-ai/v2?message=자기소개 한 줄로 해줘" | jq
curl -s "http://localhost:8080/api/hello-ai/v2?message=대한민국 최신 대통령 이름 한 명만 말해" | jq
curl -s "http://localhost:8080/api/hello-ai/v2?message=EUV 리소그래피가 왜 7nm 이하 공정에 필수적인지 3문장 설명" | jq

응답은 이런 구조로 돌아옵니다 (Day 1 과제 1 에서 설계한 HelloResponse 기억나시죠?).

{
  "provider": "ollama-gemma3:4b",
  "message": "자기소개 한 줄로 해줘",
  "reply": "...",
  "latencyMs": 1234
}

여기서 체크할 것provider 라벨이 ollama-gemma3:4b 로 찍혔는지. 자바 코드 건드리지 않고 YAML + 환경변수 만으로 모델이 정확히 바뀐 증거입니다.

4) 실습 ② — Gemini 로 전환 ️

다시 .env 로 돌아가서 한 줄만 수정합니다.

# ai-friends/.env
SPRING_PROFILES_ACTIVE=docker,gemini

# 재기동
./run.sh

같은 질문 3개를 다시 던집니다. 이번엔 providergemini-2.5-flash-lite 로 찍혀야 해요.

curl -s "http://localhost:8080/api/hello-ai/v2?message=자기소개 한 줄로 해줘" | jq
# ... Q2, Q3 도 동일

여기서 체크할 것 — Ollama 대비 응답 속도(latencyMs) 가 얼마나 다른가? 한국어 문체는 어떻게 다른가?

5) 실습 ③ — Groq 로 전환

Groq 은 LPU 기반 초저지연 추론 이 특기라고 Step 2 에서 배웠죠. 그게 사실인지 직접 확인합니다.

# ai-friends/.env
SPRING_PROFILES_ACTIVE=docker,groq

# 재기동
./run.sh

질문 3개 재호출.

curl -s "http://localhost:8080/api/hello-ai/v2?message=자기소개 한 줄로 해줘" | jq
# ... Q2, Q3 도 동일

여기서 체크할 것latencyMs 가 얼마나 줄었나? Llama 3.3 70B 는 Gemma 3 4B 보다 17배 큰 파라미터 를 가진 모델인데, Groq 위에서는 더 빠르게 응답이 돌아올 수 있어요. 이게 하드웨어(LPU) 가 만드는 속도 마법 입니다.

6) 관찰 표 — 직접 채워보세요

실습하면서 아래 표를 종이나 노션에 옮겨서 숫자·문체 메모 를 남겨두세요. 오늘의 학습 기록이 됩니다.

질문 Ollama (gemma3:4b) Gemini Flash Groq Llama 70B
Q1 지연(ms) ?ms ?ms ?ms
Q1 문체 예: 어색한 반말 예: 매끄러운 존댓말 예: 영어 섞임
Q2 정확성 맞음 / 틀림 / 모름 맞음 / 틀림 / 모름 맞음 / 틀림 / 모름
Q3 깊이(1~5) ? ? ?

7) 예상되는 관찰 결과

튜터 PC 에서 미리 돌려본 일반적인 결과를 풀어드려요 (여러분 결과는 다를 수 있습니다 — 모델은 계속 업데이트되니까).

  • Q1 지연: Groq(~100~300ms) < Gemini(~500~1000ms) < Ollama(~1~3s, 하드웨어 따라 편차 큼)
  • Q1 문체: Gemini 가 한국어 가장 자연스러움 / Ollama 4B 는 어색한 반말·번역투 섞임 / Groq Llama 는 가끔 영어 단어 튀어나옴
  • Q2 정확성: 세 모델 모두 학습 컷오프 기준으로 답함 → 최신 대통령 정보를 틀릴 수 있음. 이게 RAG(Day 15) 가 필요한 이유의 실증
  • Q3 깊이: Gemini · Groq Llama 70B 는 3문장 깊이 있게 설명 / Ollama Gemma 3 4B 는 정보 부족·혼동이 보일 수 있음

8) 💡 튜터의 결정적 한마디 — 이 실습의 진짜 교훈

자, 이 실습으로 여러분이 얻는 세 가지 체감이 있어요.

  1. 프로바이더 추상화가 실제로 작동한다.env 한 줄 수정 = 모델 완전 교체
  2. 모델마다 강한 축이 진짜 다르다 — 5축 매트릭스가 숫자로 확인됨
  3. "좋은 모델" 은 용도 따라 다르다 — 속도·비용·품질·한국어 어디에 가중치를 둘지가 설계

모든 AI 엔지니어링의 핵심 마인드셋 은 사실 여기서 출발합니다 — "모델은 교체 가능한 부품이다" 라는 감각. 오늘 우리가 깔아둔 프로바이더 추상화 레이어가 6개월 뒤 새로운 플래그십이 나왔을 때 도 여러분 서비스를 빠르게 업그레이드할 수 있게 해줍니다.

자, 여기까지 오늘의 7개 Step 모두 마무리했습니다! 오늘 손에 박은 프로바이더 추상화 · 5축 매트릭스 · PII 감각 은 앞으로 18일 내내 반복해서 쓰이는 기초 근육 이에요.

이 근육 없이 Spring AI 를 깊게 쓰는 건 불가능합니다.

그만큼 오늘 내용은 개념적으로는 단순해 보이지만 실무 영향력이 가장 큰 수업 중 하나였어요.


마무리

오늘의 한 문장 요약

"LLM 은 기능이 아니라 선택이다. 그리고 그 선택은 .env 한 줄로 교체 가능해야 한다."

이 한 문장을 기억하면 오늘 3시간이 평생 무기가 돼요.

오늘 확실히 짚은 5가지 ✅

# 핵심 개념 어디서 다뤘나
1 ChatModel 추상화와 프로바이더 스위칭 메커니즘 Step 1
2 무료/저가 프로바이더 지도 (Ollama / Gemini Flash / Groq / OpenRouter) Step 2
3 상용 플래그십 라인업과 라우팅 필요성 Step 3
4 토큰 과금 구조와 DAU 100/10K/1M 실제 비용 Step 4
5 5축 트레이드오프 매트릭스 + PII 3경로 + 프로바이더 전환 시연 Step 5~7

오늘 깔아둔 복선 (앞으로 회수됩니다)

  • Day 3 — PromptTemplate: Step 6 에서 본 "시스템 프롬프트에 실명 박지 말자" → 다음 시간 {userName} 플레이스홀더로 구체화
  • Day 5 — ChatMemory + Advisor: Step 6 "과거 대화 누수" 가 ChatMemory 크기 제한으로 실구현되고, 마스킹 패턴이 MessageChatMemoryAdvisor 같은 Advisor 체인에 꽂힘 (요청·응답 양방향 파이프)
  • Day 8 — 이미지 입력: Step 6 EXIF Strip 이 업로드 파이프라인에 기본 적용
  • Day 15 — RAG: Step 7 Q2 환각 사례가 "외부 지식 주입" 으로 해결
  • Day 19 — Harness 엔지니어링: Step 4 요금 시뮬레이션의 캐싱·라우팅 가드레일이 이날 본격 등장

Day 3 예고 — PromptTemplate 과 시스템 프롬프트의 기술

오늘까지 우리는 "LLM 에게 질문을 날리면 답이 온다" 까지 다뤘어요. 그런데 여기엔 진짜 중요한 걸 빼놨습니다.

질문을 어떻게 조립하느냐가 답의 품질을 결정한다.

똑같은 GPT-5.5 에게 "요약해줘" 라고 던지는 것과, 시스템 프롬프트에 페르소나·제약조건·출력 포맷을 정교하게 박아두고 질문만 날리는 것 — 응답 품질이 3배 이상 갈립니다. 이걸 다루는 도구가 바로 PromptTemplate 이에요.

Day 3 에서 다룰 것들을 살짝 흘려드리면:

  • {userName} 같은 플레이스홀더 기반 동적 프롬프트 조립 — 오늘 Step 6 에서 말한 "실명 대신 익명 ID 치환" 패턴의 정체
  • SystemPromptTemplate + UserPromptTemplate 분리 — 역할별로 프롬프트 레이어 구성
  • ai-friends 의 "AI 친구 페르소나" 본격 설계 — 말투·금기사항·응답 형식을 프롬프트로 박아두기
  • Prompt Engineering 핵심 패턴 5종 — Few-shot / Chain-of-Thought / Role / Output Format / Delimiter 다음 시간엔 오늘 배운 "모델 선택" 위에 "프롬프트 설계" 라는 한 층을 얹습니다. 이 두 레이어가 모이면 "진짜 쓸 만한 챗봇" 의 뼈대가 완성돼요.

과제 발제

오늘은 필수 1개 + 심화 1개 입니다. 둘 다 돌려보시면 가장 좋고, 시간이 빠듯하면 과제 1 만이라도 반드시 완주해주세요. 심화는 Spring AI 의 AutoConfiguration 내부 를 들여다봐야 하는 난이도라 시간 있으신 분만 도전하시면 됩니다.

🎯 과제 1 (필수) — 프로바이더 성능 벤치마크 엔드포인트

배경 Step 7 실습에서 여러분은 같은 질문을 3개 프로파일에서 한 번씩 돌려봤죠. 그런데 네트워크는 요동치는 놈 이에요. 같은 모델·같은 질문이어도 첫 호출 1,500ms, 두 번째 500ms, 세 번째 2,000ms 같은 식으로 편차가 생깁니다. 그래서 단일 호출로 성능 평가하는 건 위험 해요. 통계적 감각이 필요합니다.

요구사항

/api/benchmark 엔드포인트를 새로 만들어주세요. 아래 명세를 따릅니다.

  • HTTP: GET /api/benchmark?message=...&iterations=5
  • iterations 는 기본값 3, 최대 10 으로 제한 (너무 많이 돌리면 API 크레딧 다 씁니다)
  • 동작: 같은 질문을 iterations 횟수만큼 순차 호출 하여 각 호출의 latency 를 기록
  • 응답 포맷 (제안):
{
  "provider": "gemini-2.5-flash-lite",
  "message": "자기소개 해줘",
  "iterations": 5,
  "latencyStats": {
    "minMs": 512,
    "maxMs": 1834,
    "avgMs": 921,
    "allMs": [1834, 612, 823, 825, 512]
  },
  "sampleReply": "안녕하세요! 저는 AI 어시스턴트..."
}

제약

  • 자바 코드는 kr.spartaclub.aifriends.hello 패키지 안에 새 컨트롤러 또는 기존 HelloAiController 에 메서드 추가
  • ChatClient 또는 ChatModel 인터페이스 주입 — 특정 프로바이더 타입으로 고정 금지
  • iterations 가 범위를 벗어나면 400 Bad Request + 에러 메시지 반환

힌트

  • System.currentTimeMillis() 로 호출 전후 측정 (Day 1 Step 7 /api/hello-ai/v2 참고)
  • ChatClient 재사용 가능 — 새로 만들 필요 없음
  • 통계 계산은 Java Stream API (IntStream.of(arr).summaryStatistics()) 로 한 줄 가능
  • 400 응답은 IllegalArgumentException · ResponseStatusException 으로 직접 던지지 말고, 프로젝트에 이미 박혀 있는 공통 예외 체계 (kr.spartaclub.aifriends.common.exception.BusinessException + ErrorCode + GlobalExceptionHandler) 에 합류시켜 응답이 ApiResponse.fail(ErrorResponse) 6필드로 통일되게 만드세요. 슬라이스 테스트(@WebMvcTest) 에서는 @Import(GlobalExceptionHandler.class) 한 줄을 잊지 말고 (advice 가 자동 로드되지 않습니다)

테스트 예시

# Gemini 프로파일로 기동 후
curl -s "http://localhost:8080/api/benchmark?message=자기소개&iterations=5" | jq

과제 2 (심화) — 멀티 프로바이더 동시 주입 + 병렬 비교 엔드포인트

배경 과제 1 까지는 현재 활성 프로파일 1개 만 돌릴 수 있었어요. 그런데 실무에서는 "같은 질문을 Ollama 와 Gemini 에 동시에 날려서 두 응답을 한 화면에 나란히 보여주고 싶다" 같은 요구가 생깁니다 (플레이그라운드 UI 를 떠올려보세요). 이걸 구현하려면 두 ChatModel 을 동시에 스프링 컨텍스트에 등록 해야 합니다.

하지만 Spring AI 의 기본 AutoConfiguration 은 spring.ai.model.chat 프로퍼티 값에 따라 1개만 활성화 합니다. 그래서 수동으로 두 번째 ChatModel 빈을 등록하는 설정 클래스 를 작성해야 해요.

요구사항

  1. 새 프로파일 compare 추가application.ymlon-profile: compare 블록을 만들고, Ollama 와 Gemini(OpenAI 호환) 의 base-url·모델명을 둘 다 정의
  2. 수동 빈 등록 설정 클래스ChatModelCompareConfig.java 같은 이름으로 작성
    • @Configuration + @Profile("compare") 로 프로파일 한정
    • Ollama 용 ChatModel 빈 (@Bean("ollamaChatModel")) — 수동 생성
    • Gemini(OpenAI 호환) 용 ChatModel 빈 (@Bean("geminiChatModel")) — 수동 생성
  3. 새 엔드포인트 /api/compare?message=...
    • 두 ChatModel 에 병렬로 같은 질문 호출 (CompletableFuture 활용)
    • 응답 포맷:
{
  "message": "자기소개 해줘",
  "results": [
    { "provider": "ollama-gemma3:4b",        "reply": "...", "latencyMs": 1823 },
    { "provider": "gemini-2.5-flash-lite",   "reply": "...", "latencyMs": 612  }
  ]
}
  1. 기동 방법.envSPRING_PROFILES_ACTIVE=docker,compare 설정 후 ./run.sh

제약

  • 컨트롤러에서 두 ChatModel 을 주입받을 때 반드시 @Qualifier 로 구분 (타입은 ChatModel 인터페이스 유지)
  • 병렬 호출은 CompletableFuture.supplyAsync(...) + join() 또는 thenCombine 패턴 사용
  • 한 프로바이더가 실패해도 다른 프로바이더 응답은 반환돼야 함 — 에러 메시지를 reply 에 담아 반환

힌트

  • Spring AI OllamaApi / OpenAiApi 를 수동으로 new 해서 OllamaChatModel.builder() / OpenAiChatModel.builder() 로 조립 (이 빌더 안에서는 구체 타입을 쓰지만, 최종 주입 타입은 ChatModel 인터페이스)
  • 빌더 시그니처는 Spring AI 1.1.x 공식 문서 "OllamaChatModel auto-configuration" / "OpenAiChatModel auto-configuration" 섹션 참고
  • new Thread(Runnable) 말고 CompletableFuture 로 작성 — Java 21 기준 권장
  • 트레이드오프 힌트 — 수동 빈 등록은 AutoConfiguration 의 편의 기능(옵저버빌리티, Retry 등) 을 일부 포기하는 거예요. 과제 작성 후 "생각해볼 주제 2" 에서 이 트레이드오프를 다시 짚습니다.

테스트 예시

# .env 에 SPRING_PROFILES_ACTIVE=docker,compare 설정 후
./run.sh

# 병렬 호출
curl -s "http://localhost:8080/api/compare?message=자기소개" | jq

채점 포인트

  1. ChatModel 이 모두 스프링 컨텍스트에 동시 등록 되어 있는지
  2. @Qualifier 로 각각 주입되어 한 컨트롤러가 둘 다 쓸 수 있는지
  3. 병렬 호출로 실제 latency 합이 두 호출 중 더 느린 쪽 + α 로 나오는지 (순차였다면 두 latency 합 = 병렬 안 된 증거)
  4. 한쪽 프로바이더 실패 시에도 응답이 돌아오는지
  5. ChatModel 인터페이스 사용 원칙 준수 (컨트롤러에서는 구체 타입 금지)

💡 생각해볼 주제

코드 말고 머리로 고민해볼 주제 3개 드릴게요. 다음 시간 수업 도입부에 간단히 의견 나눌 예정이니 각자 3~5분씩만 생각해보시고 오세요. 정답은 없어요.

주제 1 — "플래그십 vs 중저가 모델 라우팅의 경계선"

오늘 Step 3~4 에서 "플래그십을 전부 쓰면 비용이 터지니 라우팅이 필수" 라고 배웠죠. 그런데 실무에서 가장 어려운 질문이 "어떤 기준으로 mini 로 보내고, 어떤 기준으로 Opus 로 보낼 것인가" 예요.

질문: ai-friends 같은 감성 챗봇 서비스에서, 유저의 한 메시지 를 받았을 때 "이건 mini 로 충분" 인지 "이건 Opus 로 승격시켜야" 하는지 자동 판단 하고 싶다면, 어떤 판단 기준과 기술적 구현 전략을 생각해볼 수 있을까요? 여러분이 설계자라면 1차 간단 분류기 → 2차 메인 LLM 같은 파이프라인을 어떻게 짜시겠어요?

배경: 메시지 길이·특정 키워드·과거 감정 라벨 등 여러 시그널이 있을 거예요. 정답보단 설계 아이디어 를 자유롭게.

주제 2 — "AutoConfiguration 의 편의 vs 수동 빈 등록의 투명성"

과제 2 에서 여러분은 수동으로 ChatModel 빈을 등록 했어요. Spring AI 의 기본 AutoConfiguration 은 spring-ai-starter-model-* 만 추가하면 알아서 등록해주는데, 왜 우리는 그걸 우회해야 했을까요? 그리고 이렇게 우회하는 순간 우리가 잃는 것우리가 얻는 것 이 각각 있습니다.

질문: Spring AI AutoConfiguration 을 통째로 우회하는 대신 "AutoConfig 로 1개를 등록하고, 나머지 1개만 수동으로 추가" 같은 하이브리드 전략은 가능할까요? 만약 가능하다면, 여러분이 프로덕션 LLM 서비스를 만들 때 AutoConfig / 수동 등록의 비율을 어느 쪽으로 가져가는 게 유지보수에 유리 하다고 판단하시겠어요?

배경: AutoConfig 는 버전 업 시 자동으로 최적화가 따라오지만, 설정이 블랙박스화돼요. 수동은 투명하지만 버전 업을 놓치기 쉽죠.

주제 3 — "LLM 호출 로그, 어디까지 남겨야 법무팀이 안 울까"

Step 6 에서 PII 3경로를 배웠죠. 그런데 LLM 호출 로그 는 사실 그 자체로 PII 의 종합 선물세트일 수 있어요. 요청 시간·유저 ID·시스템 프롬프트·유저 메시지·LLM 응답 — 전부 로그에 남기고 싶어지죠. 디버깅·비용 분석·품질 평가 전부에 필요하니까.

질문: 여러분이 ai-friends 의 로그 설계자라면, "남겨야 하는 정보""절대 남기면 안 되는 정보" 를 각각 어떻게 분류하시겠어요? 그리고 유저 메시지처럼 양쪽 성격을 동시에 가진 정보(디버깅엔 필요하지만 PII 위험)는 어떤 장치로 타협점을 찾으시겠어요?

배경: "유저 메시지 전문 저장 vs 해시만 저장 vs 요약만 저장" — 각 선택지가 디버깅 깊이와 법적 리스크를 어떻게 바꾸는지 생각해보세요.

✅ 예시 답안정답 보기

Step 7 실습에서 우리는 눈으로 한 번씩 돌려본 뒤 감각으로 빠르다·느리다를 판단했어요. 그런데 엔지니어링에서 "감각" 은 엄격한 근거가 못 되잖아요? 같은 질문을 N번 반복해서 latency 분포(min / max / avg) 를 통계로 뽑는 엔드포인트가 있으면 훨씬 객관적입니다. 오늘 과제가 그거예요.

이 과제의 진짜 학습 포인트는 "Day 1 의 ChatClientProviderInfo 를 그대로 재사용" 하는 감각이에요. 컨트롤러만 새로 만들면, 호출은 몇 번을 하든 설정·프로바이더 추상화가 공짜로 따라옵니다. 프로바이더 추상화의 실전 혜택을 한 번 더 체감하는 과제입니다.

Step 1. 응답 DTO — `BenchmarkResponse` record 만들기

먼저 응답 포맷을 잡습니다. Day 1 의 HelloResponse 와 같은 스타일로 record 한 장.

package kr.spartaclub.aifriends.hello;

import java.util.List;

/**
 * /api/benchmark 의 응답 페이로드.
 *
 * <p>같은 질문을 N회 반복 호출한 뒤 각 호출의 latency 통계를 함께 반환한다.
 *
 * <ul>
 *   <li>provider       : 현재 활성 프로파일의 모델/프로바이더 라벨</li>
 *   <li>message        : 사용자 질문 원문</li>
 *   <li>iterations     : 실제 반복 호출 횟수</li>
 *   <li>latencyStats   : min/max/avg + 각 호출별 latency 배열</li>
 *   <li>sampleReply    : 마지막 호출의 응답 본문 (품질 확인용 샘플)</li>
 * </ul>
 */
public record BenchmarkResponse(
        String provider,
        String message,
        int iterations,
        LatencyStats latencyStats,
        String sampleReply
) {
    public record LatencyStats(
            long minMs,
            long maxMs,
            double avgMs,
            List<Long> allMs
    ) {}
}

🙋 날카로운 질문 타임!

🙋 학생 질문 — "왜 `LatencyStats` 를 `record` 안에 `record` 로 중첩했나요? 별도 파일로 분리하는 게 맞지 않나요?"

둘 다 맞습니다.

다만 LatencyStats오직 BenchmarkResponse 의 부속 으로만 쓰이고 재사용 여지가 없기 때문에, 같은 파일 안에 중첩 record 로 넣는 게 더 응집도가 높습니다.

Java 17+ 의 record 중첩 패턴은 이런 "한 컨텍스트에서만 쓰이는 작은 값 객체" 를 파일 폭발 없이 표현하는 데 적합해요.

팀 룰에 따라 달라질 수 있는 스타일 선택입니다.

🙋 학생 질문 — "`avgMs` 를 왜 `double` 로 두셨어요? latency 는 정수(ms)니까 `long` 이 자연스러워 보이는데요."

LongSummaryStatistics#getAverage()반환 타입이 double 이에요.

평균은 정수 나눗셈이 딱 떨어지지 않는 경우가 더 흔하니까(예: 3회 호출 latency 합이 1000ms 이면 평균 333.33ms), 표준 API 가 double 을 주는 거죠.

여기서 억지로 (long) 캐스팅해 버리면 "333.8ms 인데 333ms 로 찍히는" 미세한 정보 손실이 생깁니다.

소수점 한 자리까지 살리는 게 벤치마크 정밀도 측면에서 더 맞아요.

allMslong[] 대신 List<Long> 으로 둔 것도 같은 이유 — 레코드 필드로 배열을 쓰면 equals/hashCode 가 참조 비교로 떨어져서 테스트에서 예상 밖의 실패가 납니다.

불변 컬렉션이 더 안전해요.

Step 2. `BenchmarkController` — 별도 컨트롤러 파일로 분리

Day 1 에서 만든 HelloAiController 에 메서드를 얹어도 되지만, 책임을 나눠 별도 파일로 빼두는 게 더 깔끔 합니다. /api/hello-ai 는 "한 번 호출" 용, /api/benchmark 는 "N회 반복 + 통계" 용이라 의도가 다르거든요.

ChatClient.BuilderProviderInfo 를 같은 방식으로 주입받으면 됩니다 — Day 1 의 프로바이더 추상화 덕분에 컨트롤러가 몇 개 늘어도 구현체에 묶이지 않아요.

package kr.spartaclub.aifriends.hello;

import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;
import java.util.LongSummaryStatistics;

/**
 * Day 2 과제 1 — 프로바이더 성능 벤치마크 엔드포인트.
 *
 * <p>같은 질문을 iterations 횟수만큼 순차 호출하여 latency 통계를 반환한다.
 * 단일 호출은 네트워크 편차가 커서 모델 비교에 부적합하기 때문에
 * 통계적 감각을 잡기 위함.</p>
 *
 * <p>{@link ChatClient} 만 주입하므로 활성 프로파일이 ollama/gemini/groq 어느 쪽이든
 * 컨트롤러 코드는 변하지 않는다 — Day 2 Step 1 에서 다룬 프로바이더 추상화의 실증.</p>
 */
@RestController
public class BenchmarkController {

    private static final int MIN_ITERATIONS = 1;
    private static final int MAX_ITERATIONS = 10;

    private final ChatClient chatClient;
    private final ProviderInfo providerInfo;

    public BenchmarkController(ChatClient.Builder builder, ProviderInfo providerInfo) {
        this.chatClient = builder.build();
        this.providerInfo = providerInfo;
    }

    @GetMapping("/api/benchmark")
    public BenchmarkResponse benchmark(
            @RequestParam(defaultValue = "Hello, AI! 한 줄로 자기소개 부탁해.") String message,
            @RequestParam(defaultValue = "3") int iterations
    ) {
        // ── Step 1: iterations 범위 검증 ────────────────────────────────
        // 유료 API 를 10회 이상 연타하면 학습 크레딧이 순식간에 증발한다.
        // 너무 작은 값(0 이하)도 의미가 없으므로 함께 막는다.
        if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS) {
            // 프로젝트 공통 예외 체계(BusinessException + ErrorCode) 합류.
            // 예외 처리의 세부 원리는 뒤 Day 에서 다시 다루니, 지금은
            // "IllegalArgumentException 을 직접 던지지 않고 도메인 베이스에 태워 보낸다"
            // 정도만 눈도장 찍고 넘어가도 충분합니다.
            throw new BusinessException(ErrorCode.BAD_REQUEST);
        }

        // ── Step 2: N회 반복 호출 + latency 기록 ───────────────────────
        List<Long> allMs = new ArrayList<>(iterations);
        String lastReply = null;

        for (int i = 0; i < iterations; i++) {
            long start = System.currentTimeMillis();
            lastReply = chatClient.prompt().user(message).call().content();
            allMs.add(System.currentTimeMillis() - start);
        }

        // ── Step 3: 통계 계산 ─────────────────────────────────────────
        // LongSummaryStatistics 한 번으로 min/max/avg 동시 산출.
        LongSummaryStatistics stats = allMs.stream()
                .mapToLong(Long::longValue)
                .summaryStatistics();

        return new BenchmarkResponse(
                providerInfo.currentLabel(),
                message,
                iterations,
                new BenchmarkResponse.LatencyStats(
                        stats.getMin(),
                        stats.getMax(),
                        stats.getAverage(),
                        allMs
                ),
                lastReply
        );
    }
}

잠깐, BusinessException 이 뭐죠? 프로젝트에 이미 만들어져 있는 공통 예외 베이스에요 (kr.spartaclub.aifriends.common.exception). BusinessExceptionErrorCode enum 을 가지고 있어서 전역 GlobalExceptionHandler 가 HTTP 상태·에러코드·메시지를 알아서 응답으로 변환해줍니다. Day 2 시점에는 "공통 베이스가 있고 거기에 태운다" 정도로만 이해하고, 왜 이렇게 쓰는지·어떻게 확장하는지는 뒤 Day 에서 따로 다뤄요. 슬라이스 테스트에서도 이 advice 를 쓰려면 @Import(GlobalExceptionHandler.class) 한 줄을 추가해주면 됩니다.

🙋 날카로운 질문 타임!

🙋 학생 질문 — "iterations 검증을 `@Valid` + `@Min/@Max` 로 하는 게 더 깔끔하지 않나요?"

맞아요, 실무 프로덕션 코드라면 jakarta.validation 애노테이션이 더 선언적입니다.

다만 이 과제는 학습 목적 이라 "검증 로직이 명시적으로 보이는" 방식을 택했어요.

@Min(1) @Max(10) int iterations + @Validated + ConstraintViolationException 핸들러까지 한 세트로 세팅하는 건 뒤 Day 의 Production Readiness 맥락에서 다시 다룹니다.

오늘은 "if + throw BusinessException" 로 본질만 먼저 잡아두세요.

🙋 학생 질문 — "왜 `ResponseStatusException(BAD_REQUEST, ...)` 가 아니라 `BusinessException(ErrorCode.BAD_REQUEST)` 를 던지셨어요? 전자가 더 짧잖아요."

정답은 "프로젝트 공통 응답 표준에 일관되게 합류시키기 위함" 이에요.

ai-friends 코드베이스에는 이미 GlobalExceptionHandler@RestControllerAdvice 로 등록돼 있고, 모든 에러를 ApiResponse.fail(ErrorResponse) 로 감싸서 6필드(timestamp/status/error/code/message/path) 로 정규화 합니다.

여기에 ResponseStatusException 을 직접 던지면 — ErrorMvcAutoConfiguration/error 디폴트 매핑이 만든 평면 JSON 이 돌아와서, 같은 앱의 다른 엔드포인트와 응답 구조가 비대칭 이 됩니다.

BusinessException 으로 던져두면 핸들러가 ErrorCode.BAD_REQUEST enum 의 코드/메시지/HTTP 상태를 단일 출처로 채워서 ApiResponse 형태로 통일된 응답을 만들어줘요.

컨트롤러는 "어떤 에러인지" 만 가리키고, 본문 조립은 ErrorCode + 핸들러가 책임 지는 분업이 깔끔합니다.

⚠️ 슬라이스 테스트 추가 한 줄@WebMvcTest(BenchmarkController.class) 는 컨트롤러 빈만 로드하고 @RestControllerAdvice 는 자동으로 안 끌어옵니다. 그래서 advice 가 안 붙은 채로 BusinessException 이 그대로 새서 500 으로 떨어집니다. @Import(GlobalExceptionHandler.class) 한 줄을 테스트 클래스에 박아주면 슬라이스 안에서도 advice 가 같이 등록돼 400 응답이 정상적으로 만들어져요. 이번 답안의 BenchmarkControllerTest 가 그 패턴을 그대로 보여줍니다.

🙋 학생 질문 — "순차 호출하는데 왜 `for` 루프를 썼나요? `IntStream.range` 로 더 함수형 스타일이 되지 않나요?"

IntStream 으로도 쓸 수 있고 스타일 선호 차이예요.

다만 chatClient.call() 은 side-effect (네트워크 I/O) 를 포함 하기 때문에, IntStream.parallel() 로 잘못 바꾸면 "병렬 호출" 이 되어 API rate limit 을 건드릴 수 있어요.

for 루프는 이런 실수를 원천 차단합니다.

병렬 호출은 과제 2 에서 의도적으로 하는 거고, 과제 1 은 순수 순차 벤치마크 라는 걸 코드로 박아둔 거예요.

Step 3. 동작 확인

Gemini 프로파일로 기동한 뒤 테스트.

# .env 에 SPRING_PROFILES_ACTIVE=docker,gemini
./run.sh

# 벤치마크 호출 (5회 반복)
curl -s "http://localhost:8080/api/benchmark?message=자기소개%20한%20줄로%20해줘&iterations=5" | jq

기대 응답 (값은 매번 다릅니다).

{
  "provider": "gemini-2.5-flash-lite",
  "message": "자기소개 한 줄로 해줘",
  "iterations": 5,
  "latencyStats": {
    "minMs": 512,
    "maxMs": 1834,
    "avgMs": 921.2,
    "allMs": [1834, 612, 823, 825, 512]
  },
  "sampleReply": "안녕하세요! 저는 Google 의 대규모 언어 모델이에요."
}

avgMs921.2 처럼 소수점으로 찍히는 게 정상입니다. LongSummaryStatistics#getAverage()double 을 반환하기 때문이에요. (Step 1 날카로운 질문 참고)

범위 벗어나는 값 테스트.

# iterations=15 → 400 + ApiResponse 로 감싼 ErrorResponse
curl -i "http://localhost:8080/api/benchmark?message=test&iterations=15"
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "success": false,
  "data": null,
  "error": {
    "timestamp": "2026-04-22T21:30:11.482",
    "status": 400,
    "error": "BAD_REQUEST",
    "code": "E001",
    "message": "잘못된 요청입니다.",
    "path": "/api/benchmark"
  }
}

응답 바디가 평면 {"error":"..."} 가 아니라 ApiResponse<Void> 로 감싼 ErrorResponse 6필드 로 돌아오는 점에 주목하세요. 우리가 던진 BusinessException(ErrorCode.BAD_REQUEST)GlobalExceptionHandler 가 잡아서 ApiResponse.fail(ErrorResponse) 로 변환합니다. 메시지 본문("잘못된 요청입니다.")·코드("E001") 도 컨트롤러가 직접 박은 게 아니라 ErrorCode.BAD_REQUEST enum 정의를 그대로 따라간 결과예요. 컨트롤러는 "어떤 에러인지" 만 enum 으로 가리키고, 사용자에게 보여줄 본문은 ErrorCode 가 단일 출처로 책임집니다 — 이 패턴은 Day 3 이후에도 그대로 이어집니다.

🎯 채점 포인트
# 채점 항목 가중치
1 /api/benchmark 엔드포인트가 200 OK 로 기대 JSON 포맷 반환 30%
2 iterations 가 1~10 이외일 때 400 Bad Request + 메시지 20%
3 latency 통계의 min/max/avg 가 수학적으로 정확 (allMs 배열 기준) 20%
4 ChatClient 또는 ChatModel 인터페이스 주입 (구체 타입 금지) 15%
5 Day 1 의 HelloResponse 와 일관된 record 스타일 10%
6 sampleReply 필드로 품질 육안 확인 가능 5%
실무 개선 포인트 (심화)

학습 과제에서 한 발 더 나아가려면 이런 개선을 생각해볼 수 있어요.

  • 토큰 사용량 계측 — Spring AI 1.1.x 의 ChatResponse.getMetadata().getUsage() 로 실제 Input/Output 토큰 수를 집계. chatClient.prompt().user(...).call().chatResponse() 로 ChatResponse 자체를 받아 metadata 추출
  • Micrometer 메트릭 연동 — latency 를 Timer 로 기록하면 Prometheus/Grafana 에서 P50/P95/P99 분위수로 관찰 가능 (Day 20 Observability)
  • 병렬 호출 옵션parallel=true 쿼리 파라미터로 순차/병렬 전환 (단 rate limit 체크 필요)
  • 결과 CSV 익스포트Accept: text/csv 요청 시 latency 배열을 CSV 로 반환, 엑셀로 박스플롯 그리기
  • Warm-up 분리 — 첫 호출은 보통 연결 수립·JIT 컴파일 등으로 오래 걸리므로, 첫 1~2회는 warm-up 으로 분리하고 통계에서 제외 이 중 토큰 사용량 계측 만큼은 Day 20 Observability 에서 정식으로 다룰 예정이니, 오늘 과제에서는 안 해도 됩니다. 미리 해본 사람은 Day 20 에서 훨씬 편해집니다

🎯 [과제 2 예시 답안] /api/compare — 멀티 프로바이더 동시 주입 + 병렬 비교

과제 1 이 "한 모델의 신뢰 구간" 이었다면, 과제 2 는 "두 모델을 같은 질문에 동시에 노출" 하는 설계입니다. LLM 플레이그라운드 UI 를 본 적 있으시죠? OpenAI Playground, Claude Workbench 에서 좌우로 모델 두 개 놓고 같은 프롬프트를 비교하는 그 기능이에요.

구현의 핵심은 "Spring AI AutoConfiguration 의 단일 활성화 제약을 우회하는 것" 입니다. 이 과제가 여러분에게 선물해주는 것은 다음 세 가지.

  1. Spring AI @ConditionalOnProperty 기반 AutoConfig 의 내부 동작 이해
  2. 수동 @Bean 등록 + @Qualifier 주입 Spring 패턴
  3. CompletableFuture 기반 비동기 병렬 I/O 설계 감각
Step 1. 새 프로파일 `compare` — `application.yml` 에 블록 추가

프로파일을 먼저 깝니다.

이 블록 하나로 두 프로바이더가 동시에 활성화 되는 시나리오를 명시적으로 만듭니다.

수동으로 빈을 등록할 거라 AutoConfig 는 chat: none 으로 우회하고, 두 프로바이더에 필요한 설정값은 app.compare.* 전용 네임스페이스로 빼둡니다.

(기존 spring.ai.ollama.* / spring.ai.openai.* 를 재활용해도 되지만, AutoConfig 가 해석하는 공식 네임스페이스와 섞이면 디버깅이 어려워져서 분리해 둡니다.)

---
# =========================================================
# compare 프로파일 (Day 2 과제 2): Ollama + Gemini 두 프로바이더를 동시에 주입
# 사용 예 (도커): .env 에 SPRING_PROFILES_ACTIVE=docker,compare
# 사용 예 (IDE) : SPRING_PROFILES_ACTIVE=local,compare
#
# 핵심:
#   - AutoConfig 는 `chat: none` 으로 우회 (한 번에 하나만 활성화되는 제약 때문)
#   - ChatModelCompareConfig 에서 두 ChatModel 을 @Bean 으로 수동 등록
#   - /api/compare 에서 @Qualifier 로 각각 주입받아 병렬 호출
# =========================================================
spring:
  config:
    activate:
      on-profile: compare

  ai:
    # 수동 빈 등록을 쓰기 위해 AutoConfig 를 명시적으로 우회 (Day 2 Step 1 복습)
    model:
      chat: none

# compare 프로파일 전용 커스텀 프로퍼티 — ChatModelCompareConfig 가 @Value 로 읽어 쓴다.
app:
  compare:
    ollama:
      base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
      model: ${OLLAMA_MODEL:gemma3:4b}
    gemini:
      api-key: ${GEMINI_API_KEY:}
      base-url: https://generativelanguage.googleapis.com/v1beta/openai
      completions-path: /chat/completions
      model: ${GEMINI_MODEL:gemini-2.5-flash-lite}

🙋 날카로운 질문 타임!

🙋 학생 질문 — "`spring.ai.model.chat: none` 으로 두면서 수동 빈을 등록하면, AutoConfig 가 뜨는 기본 스타터 기능(Observability, Retry 등) 은 어떻게 되나요?"

아주 좋은 질문이에요. AutoConfig 로 뜨는 OpenAiChatModel 은 내부적으로 ObservationRegistry, ToolCallingManager, RetryTemplate 등을 기본 주입받아 조립됩니다. 수동 빈을 등록하는 순간 이 주입 책임이 여러분에게로 넘어옵니다.

그래서 실무에서는 "AutoConfig 로 1개 + 수동 등록으로 1개" 하이브리드 패턴을 쓰거나, 수동 등록 시에도 ObservationRegistry 같은 핵심 빈은 생성자 주입으로 받아서 builder 에 꽂아주는 게 정답에 가까워요. 이 트레이드오프는 "생각해볼 주제 2" 에서 다시 다룹니다.

🙋 학생 질문 — "왜 설정값을 `spring.ai.ollama.*` / `spring.ai.openai.*` 에 안 쓰고 `app.compare.*` 라는 새 네임스페이스를 만드셨어요?"

"누가 이 프로퍼티를 해석하는지" 가 한눈에 보이게 하려는 의도예요. spring.ai.* 는 Spring AI 의 AutoConfiguration 이 권위 있게 해석하는 공식 네임스페이스입니다. 우리는 chat: none 으로 AutoConfig 를 우회해 놓고도 spring.ai.ollama.* 값을 그냥 재활용 할 수는 있어요. 하지만 그렇게 하면 "이 값이 AutoConfig 것인지, 수동 Config 것인지" 가 YAML 만 봐서는 구분이 안 됩니다. 네임스페이스를 app.compare.* 로 분리해두면 "이 블록은 우리 수동 Config 전용" 이라는 게 YAML 레벨에서 선언적이 돼서 버전 업 시 Spring AI 쪽 프로퍼티 이름이 바뀌어도 우리 compare 프로파일은 영향 없어요.

Step 2. `ChatModelCompareConfig` — 두 ChatModel 을 수동 등록

과제 2 의 핵심 파일입니다. @Profile("compare") 로 프로파일 한정이 걸려 있어서, 일반 개발/기동에는 영향이 없어요. 이 패턴은 Spring 에서 "특정 시나리오에서만 활성화되는 설정" 을 만들 때 자주 씁니다.

package kr.spartaclub.aifriends.hello;

import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;

/**
 * Day 2 과제 2 — compare 프로파일 전용: 두 개의 {@link ChatModel} 을 수동으로 빈 등록한다.
 *
 * <p>Spring AI 의 기본 AutoConfiguration 은 {@code spring.ai.model.chat} 프로퍼티 값에 따라
 * 한 시점에 한 종류의 ChatModel 만 활성화한다. 하지만 /api/compare 에서는 두 프로바이더에
 * 같은 질문을 동시에 던져야 하므로, AutoConfig 를 우회하고 Ollama · OpenAI 호환(Gemini)
 * 구현체를 각각 {@code @Bean("ollamaChatModel")} / {@code @Bean("geminiChatModel")} 으로 등록한다.</p>
 *
 * <p>반환 타입은 구체 구현체가 아닌 {@link ChatModel} 인터페이스로 노출한다 —
 * Day 2 Step 1 원칙("애플리케이션 코드는 ChatModel 인터페이스에만 의존") 을 유지하기 위함.</p>
 */
@Configuration
@Profile("compare")
public class ChatModelCompareConfig {

    @Bean("ollamaChatModel")
    public ChatModel ollamaChatModel(
            @Value("${app.compare.ollama.base-url}") String baseUrl,
            @Value("${app.compare.ollama.model}") String model
    ) {
        OllamaApi ollamaApi = OllamaApi.builder()
                .baseUrl(baseUrl)
                .build();

        return OllamaChatModel.builder()
                .ollamaApi(ollamaApi)
                .defaultOptions(OllamaChatOptions.builder().model(model).build())
                .build();
    }

    /**
     * compare 프로파일에서 기본 {@link org.springframework.ai.chat.client.ChatClient.Builder}
     * 가 단일 ChatModel 을 찾도록 Gemini 쪽을 {@code @Primary} 로 노출한다.
     * (HelloAiController · BenchmarkController 는 @Qualifier 없이 ChatClient.Builder 만 쓰므로
     *  이 기본값이 있어야 compare 프로파일에서도 기동된다.)
     */
    @Bean("geminiChatModel")
    @Primary
    public ChatModel geminiChatModel(
            @Value("${app.compare.gemini.api-key}") String apiKey,
            @Value("${app.compare.gemini.base-url}") String baseUrl,
            @Value("${app.compare.gemini.completions-path}") String completionsPath,
            @Value("${app.compare.gemini.model}") String model
    ) {
        OpenAiApi openAiApi = OpenAiApi.builder()
                .apiKey(apiKey)
                .baseUrl(baseUrl)
                .completionsPath(completionsPath)
                .build();

        return OpenAiChatModel.builder()
                .openAiApi(openAiApi)
                .defaultOptions(OpenAiChatOptions.builder().model(model).build())
                .build();
    }
}

🙋 날카로운 질문 타임!

🙋 학생 질문 — "`@Bean` 메서드 반환 타입을 `ChatModel` 인터페이스로 선언한 이유가 있나요? `OllamaChatModel` 로 반환해도 주입은 되잖아요?"

핵심 질문이에요. 두 가지 이유가 있습니다.

  1. 스프링 컨텍스트의 빈 타입 해석@Bean 메서드의 반환 타입이 스프링 빈의 public 타입 이 됩니다. ChatModel 로 선언하면 다른 구현 디테일 (예: OpenAiChatModel 내부 필드) 이 외부에서 접근 불가 해지고, 교체가 자유로워져요.
  2. 우리 프로젝트 코딩 규칙 — 본 강의의 프로바이더 추상화 규약 "ChatModel 인터페이스 주입, 구체 타입 고정 금지". 빌더 내부에서는 구체 타입을 쓰지만, 빈으로 등록하는 최종 타입은 인터페이스 로 맞추는 게 일관된 설계입니다.
🙋 학생 질문 — "Gemini 쪽 Bean 에 `@Primary` 가 붙어 있네요. 이거 왜 필요해요? 컨트롤러는 `@Qualifier` 로 명시적으로 구분하잖아요."

compare 프로파일에서도 /api/hello-ai, /api/benchmark 가 기동되기 위해 필요해요. Spring AI 의 ChatClientAutoConfigurationChatClient.Builder 를 만들 때 단일 ChatModel 빈을 autowire 하는데, compare 프로파일에서는 우리가 2개를 등록했으니 "어느 것을 쓸지 모른다" 고 기동이 터집니다. 실제로 초기 구현 때 이 이유로 NoUniqueBeanDefinitionException 을 맞았어요.

해결 방법은 셋 중 하나입니다:

  1. 둘 중 하나에 @Primary가장 단순 (지금 방식)
  2. HelloAiController·BenchmarkController@Profile("!compare") 를 붙여 compare 에서 제외 — 깔끔하지만 기존 컨트롤러를 침범
  3. compare 프로파일 전용 ChatClient.Builder 를 별도 @Bean 으로 직접 등록

학습 목적에선 (1) 이 가장 명확하고, @Primary 는 "auto-wire 의 fallback 이 나 야" 라는 의미로 읽히면 됩니다. @Qualifier 로 구체적 이름을 주는 곳에선 여전히 @Qualifier 가 우선하므로 CompareController 의 동작에는 영향 없어요.

🙋 학생 질문 — "만약 기존 AutoConfig 를 살려두고 Ollama 만 수동으로 추가하는 하이브리드도 가능한가요?"

네, 가능해요.

spring.ai.model.chat=openai 로 두면 AutoConfig 가 Gemini(OpenAI 호환) 를 자동 등록하고, 별도 @Bean("ollamaChatModel") 만 수동 추가하면 됩니다.

다만 AutoConfig 이 등록하는 빈의 이름은 openAiChatModel 이라 @Qualifier 쪽만 문자열로 맞춰주면 돼요.

이 패턴은 "생각해볼 주제 2" 에서 토론합니다.

Step 3. `CompareResponse` + `/api/compare` 컨트롤러 — `@Qualifier` + `CompletableFuture`

응답 DTO 를 먼저 record 로 잡고, 그 다음 컨트롤러. 두 ChatModel 을 동시에 주입받아 병렬 호출 하고, 한쪽이 실패해도 다른쪽은 결과를 돌려주는 게 포인트입니다.

3-1. CompareResponse — 응답 페이로드

package kr.spartaclub.aifriends.hello;

import java.util.List;

/**
 * /api/compare 의 응답 페이로드.
 *
 * <p>여러 프로바이더에 같은 질문을 병렬로 던진 결과를 한 배열에 담아
 * "같은 질문에 대한 서로 다른 모델의 반응" 을 한눈에 비교할 수 있게 한다.</p>
 *
 * <ul>
 *   <li>message : 요청에 썼던 질문 그대로</li>
 *   <li>results : 각 프로바이더별 응답 레코드. 한 쪽이 실패해도 에러 메시지를 {@code reply} 에 담아 반환.</li>
 * </ul>
 */
public record CompareResponse(
        String message,
        List<ProviderResult> results
) {
    public record ProviderResult(
            String provider,
            String reply,
            long latencyMs
    ) {
    }
}

3-2. CompareController — 병렬 호출과 장애 격리

package kr.spartaclub.aifriends.hello;

import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.concurrent.CompletableFuture;

/**
 * Day 2 과제 2 — 두 프로바이더에 같은 질문을 병렬로 던져 응답을 한 번에 비교하는 엔드포인트.
 *
 * <p>{@code @Qualifier} 로 "ollamaChatModel" / "geminiChatModel" 두 개의 {@link ChatModel}
 * 빈을 주입받고, 각각 {@link CompletableFuture#supplyAsync} 로 독립 쓰레드에서 호출한다.
 * 둘의 latency 합이 순차 호출 대비 "둘 중 더 느린 쪽 + α" 로 줄어드는 게 병렬의 증거.</p>
 *
 * <p>한 프로바이더가 예외를 던져도 다른 쪽 응답은 그대로 반환되도록
 * {@link #callSafely} 에서 per-provider 로 예외를 포획하고 에러 메시지를 {@code reply} 에 담는다.
 * (플레이그라운드 UX 에서 한 쪽이 장애여도 다른 쪽은 보여야 하는 패턴)</p>
 */
@RestController
@Profile("compare")
public class CompareController {

    private final ChatModel ollamaChatModel;
    private final ChatModel geminiChatModel;
    private final String ollamaLabel;
    private final String geminiLabel;

    public CompareController(
            @Qualifier("ollamaChatModel") ChatModel ollamaChatModel,
            @Qualifier("geminiChatModel") ChatModel geminiChatModel,
            @Value("${app.compare.ollama.model}") String ollamaModel,
            @Value("${app.compare.gemini.model}") String geminiModel
    ) {
        this.ollamaChatModel = ollamaChatModel;
        this.geminiChatModel = geminiChatModel;
        this.ollamaLabel = prefix("ollama-", ollamaModel);
        this.geminiLabel = prefix("gemini-", geminiModel);
    }

    /** 모델명이 이미 프로바이더 접두사로 시작하면(예: "gemini-2.5-flash-lite") 중복을 피한다. */
    private static String prefix(String tag, String model) {
        return model.startsWith(tag) ? model : tag + model;
    }

    @GetMapping("/api/compare")
    public CompareResponse compare(
            @RequestParam(defaultValue = "Hello, AI! 한 줄로 자기소개 부탁해.") String message
    ) {
        // 두 호출을 비동기로 시작 — ForkJoinPool 공용 스레드에서 실행
        CompletableFuture<CompareResponse.ProviderResult> ollamaFuture =
                CompletableFuture.supplyAsync(() -> callSafely(ollamaChatModel, message, ollamaLabel));
        CompletableFuture<CompareResponse.ProviderResult> geminiFuture =
                CompletableFuture.supplyAsync(() -> callSafely(geminiChatModel, message, geminiLabel));

        // 각 Future 를 join() — 두 호출이 병렬로 돌았으니 총 시간은 "느린 쪽 + α"
        return new CompareResponse(
                message,
                List.of(ollamaFuture.join(), geminiFuture.join())
        );
    }

    /**
     * ChatModel 호출을 try-catch 로 감싸 "한쪽 실패가 전체 응답을 막지 않도록" 한다.
     * 실패한 쪽의 reply 에 에러 메시지를 담아 반환.
     */
    private CompareResponse.ProviderResult callSafely(ChatModel model, String message, String label) {
        long start = System.currentTimeMillis();
        try {
            ChatResponse response = model.call(new Prompt(message));
            String reply = response.getResult().getOutput().getText();
            return new CompareResponse.ProviderResult(
                    label, reply, System.currentTimeMillis() - start);
        } catch (Exception ex) {
            return new CompareResponse.ProviderResult(
                    label,
                    "ERROR: " + ex.getClass().getSimpleName() + " - " + ex.getMessage(),
                    System.currentTimeMillis() - start
            );
        }
    }
}

🙋 날카로운 질문 타임!

🙋 학생 질문 — "라벨을 왜 `@Value` 로 프로퍼티에서 받아 `prefix()` 로 조립하셨어요? `"ollama-gemma3:4b"` 처럼 하드코딩하면 훨씬 간단하지 않아요?"

.env 만 바꿔도 모델이 바뀌는 유연성을 응답 라벨도 따라가게 한 거예요. OLLAMA_MODEL=qwen3:4b 로 바꾸고 재기동하면 라벨도 자동으로 ollama-qwen3:4b 로 찍혀 나옵니다. 하드코딩하면 응답에 찍힌 모델명이 실제 호출 대상과 어긋나게 돼서 디버깅이 지옥이에요.

prefix() 헬퍼의 존재 이유도 같은 흐름입니다.

Gemini 모델명은 대부분 이미 "gemini-" 로 시작해서(gemini-2.5-flash-lite), 그냥 앞에 "gemini-" 를 또 붙이면 "gemini-gemini-2.5-flash-lite" 같은 우스운 중복 이 생깁니다.

Day 1 의 ProviderInfo#prefix 와 같은 패턴이에요.

🙋 학생 질문 — "`CompletableFuture.supplyAsync` 로 병렬을 만들면 어느 스레드 풀에서 돌아가나요?"

파라미터 없이 supplyAsync(Supplier) 를 부르면 JVM 공용 ForkJoinPool.commonPool() 에서 실행됩니다. 이 풀은 CPU 코어 수 만큼의 스레드 로 제한돼 있어요 (보통 4~8개). I/O 바운드인 LLM 호출에는 사실 작은 편 이에요.

그래서 실무에서는 명시적으로 Executor 를 넘겨줍니다.

// 권장 패턴 (실무)
private static final Executor LLM_EXECUTOR =
        Executors.newFixedThreadPool(20, Thread.ofVirtual().factory());

CompletableFuture.supplyAsync(() -> callSafely(...), LLM_EXECUTOR);

Java 21 가상 스레드(Virtual Thread) 와 결합하면 I/O 대기 중 OS 스레드를 점유하지 않아 훨씬 효율적이에요. 오늘 과제에서는 학습 목적상 공용 풀로도 충분하지만, 실제 트래픽 받는 서비스에서는 가상 스레드 기반 전용 풀을 꼭 따로 둬야 합니다.

🙋 학생 질문 — "`ollamaFuture.join(); geminiFuture.join();` 를 순서대로 두 번 호출했는데, 이게 순차 호출로 바뀌는 건 아닌가요?"

아니에요. supplyAsync 가 호출된 그 순간 두 호출은 이미 비동기로 시작 됐어요. 그 시점부터 두 작업이 각자 다른 스레드에서 동시에 진행되죠. .join() 은 단지 "결과를 현재 스레드로 끌어오는 대기 액션" 일 뿐이에요. 예컨대:

  • 시점 0ms : 두 supplyAsync 호출 → Ollama 와 Gemini 가 동시에 출발
  • 시점 600ms : Gemini 먼저 응답 완료
  • 시점 1800ms : Ollama 응답 완료
  • ollamaFuture.join() 은 1800ms 시점에 바로 반환, geminiFuture.join() 도 이미 완료된 상태라 거의 즉시 반환

총 대기 시간 ≈ 1800ms (둘 중 느린 쪽) 이 됩니다. 순차였다면 600 + 1800 = 2400ms 이 나왔을 테고요. thenCombine 으로도 똑같이 쓸 수 있지만, 2개짜리 단순 조합에는 join() 두 번이 더 읽기 쉽다는 판단입니다.

🙋 학생 질문 — "`thenCombine` 과 `allOf` 의 차이가 뭐예요? 3개 이상으로 확장한다면요?"
  • thenCombine(other, (a,b) -> ...) : 딱 2개 의 Future 를 조합. 결과가 자연스럽게 타입 있는 튜플로 들어옴
  • allOf(f1, f2, f3, ...) : N개 의 Future 를 조합. 반환값이 CompletableFuture<Void> 라 결과를 꺼내려면 개별 Future 에 다시 .join() 필요

3개 이상 프로바이더 동시 비교로 확장한다면, Future 를 List<CompletableFuture<ProviderResult>> 에 모아두고 allOf(futures.toArray(...)).thenApply(v -> futures.stream().map(CompletableFuture::join).toList()) 같은 패턴으로 확장하는 게 정석이에요.

Step 4. `.env` + `./run.sh` 기동

도커 환경 흐름 그대로.

# ai-friends/.env
SPRING_PROFILES_ACTIVE=docker,compare
GEMINI_API_KEY=실제_키
OLLAMA_BASE_URL=http://host.docker.internal:11434

# 재기동
./run.sh

기동 시 로그에서 두 ChatModel 빈이 등록되었는지 확인 (둘 다 보여야 함).

Creating shared instance of singleton bean 'ollamaChatModel'
Creating shared instance of singleton bean 'geminiChatModel'
Step 5. 동작 확인
curl -s "http://localhost:8080/api/compare?message=자기소개%20한%20줄로%20해줘" | jq

기대 응답 (값은 매번 다릅니다).

{
  "message": "자기소개 한 줄로 해줘",
  "results": [
    {
      "provider": "ollama-gemma3:4b",
      "reply": "안녕, 나는 너의 AI 친구야.",
      "latencyMs": 1823
    },
    {
      "provider": "gemini-2.5-flash-lite",
      "reply": "반가워요! Google Gemini 기반의 AI 어시스턴트예요.",
      "latencyMs": 612
    }
  ]
}

여기서 체크 — Ollama 가 1800ms 걸렸고 Gemini 가 600ms 걸렸지만, 전체 응답 시간은 약 1900ms 근처입니다 (1800 + α). 만약 순차 호출이었다면 1800 + 600 = 2400ms 이 나올 거예요. 이 차이가 병렬이 작동한 증거 입니다.

실패 시나리오 테스트 (Ollama 데몬을 꺼둔 상태).

# Ollama 쪽만 실패, Gemini 는 정상 응답
curl -s "http://localhost:8080/api/compare?message=test" | jq
{
  "message": "test",
  "results": [
    {
      "provider": "ollama-gemma3:4b",
      "reply": "ERROR: ResourceAccessException - I/O error on POST request for \"http://localhost:11434/api/chat\": Connection refused",
      "latencyMs": 45
    },
    {
      "provider": "gemini-2.5-flash-lite",
      "reply": "정상 응답입니다.",
      "latencyMs": 587
    }
  ]
}

한쪽이 죽어도 다른 쪽은 살아남죠. 이게 Resilient Design 의 초보적인 형태예요 (Day 19 Harness 엔지니어링에서 cost guardrail · 폴백 패턴으로 본격 등장).

🎯 채점 포인트
# 채점 항목 가중치
1 compare 프로파일 추가 + ChatModelCompareConfigChatModel 동시 등록 25%
2 컨트롤러에서 @Qualifier 로 두 빈을 정확히 구분 주입 20%
3 CompletableFuture 기반 병렬 호출 — 순차였으면 안 되는 latency 25%
4 한 프로바이더 실패 시에도 다른 프로바이더 응답 반환 15%
5 ChatModel 인터페이스 사용 원칙 (컨트롤러에서 구체 타입 금지) 10%
6 ./run.sh + .env 플로우로 기동 성공 5%
실무 개선 포인트 (심화)
  • 전용 Executor 주입 — 공용 ForkJoinPool 대신 가상 스레드 기반 Executor 빈을 만들어 supplyAsync 에 주입. Thread.ofVirtual().factory() + Executors.newThreadPerTaskExecutor(...) 조합
  • ChatClient.Builder 를 각 모델별로 — 이번 예시답안은 저수준 ChatModel.call(Prompt) 로 호출했어요. 더 실무적인 건 ChatClient.Builder 2개를 각각 다른 ChatModel 로 빌드 해서 ChatClient 2개를 주입받는 패턴. Day 3 PromptTemplate 학습 후 시도해보면 좋습니다
  • 타임아웃 설정 — 한쪽이 30초 이상 안 끝나면 orTimeout(30, SECONDS) 로 Future 자체 타임아웃 처리
  • 응답 스트리밍 — 두 모델을 SSE (Server-Sent Events) 로 동시 스트리밍하면 UI 에서 타이핑 효과로 좌우 창에 띄울 수 있음 (Day 6 Streaming 응답 복선)
  • Micrometer Timer — 각 프로바이더별 latency 를 Timer 태그로 기록해 Grafana 에서 비교 대시보드 구성 (Day 20 Observability)
  • N개 프로바이더 확장Map<String, ChatModel> 주입으로 바꾸고 allOf + stream 으로 N개 동시 호출 — 신규 프로바이더 추가가 YAML 한 줄이 됨 과제 2 를 완수했다면 여러분은 이미 "Spring AI 의 AutoConfig 를 이해하고 우회할 수 있는 엔지니어" 입니다. 이 감각은 앞으로 Day 11 Tool Calling, Day 17~18 MCP 에서 각각 다른 방식의 "확장 포인트 개입" 을 할 때마다 기저 패턴 으로 계속 등장합니다.

💡 [생각해볼 거리] LLM 엔지니어링 판단력 훈련

혼자 생각해봐도 좋고, 팀원들과 토론해도 좋습니다. 아래 3개 주제는 실무 면접의 고정 출제 범위 이기도 해요. "LLM 을 그냥 쓸 줄 아는 개발자""LLM 시스템을 설계할 줄 아는 엔지니어" 를 가르는 기준이 여기에 있습니다.

주제 1. "플래그십 vs 중저가 모델 라우팅의 경계선"

[문제 상황 요약]

Step 3~4 에서 우리는 "플래그십 풀가동은 비용이 터지니 라우팅이 필수" 라고 배웠습니다.

그런데 막상 설계하려 하면 가장 어려운 게 "그래서 어떤 기준으로 mini 로 보내고, 어떤 기준으로 Opus 로 보낼 것인가" 예요.

ai-friends 같은 감성 챗봇 서비스에서 유저 메시지 한 건 이 들어왔을 때, 자동으로 모델 난이도를 판단하고 라우팅 시키려면 어떤 전략을 쓸까요?

💡 [튜터의 가이드 및 해설]

이 문제는 사실 "분류 문제를 LLM 으로 풀기 vs 규칙 기반으로 풀기" 의 선택 질문이기도 해요. 실무에서 쓰이는 패턴을 세 갈래로 정리하면 이렇습니다.

1) 규칙 기반 1차 분류 (Rule-based Gate) — 가장 단순, 가장 빠름

유저 메시지
  ├─ 길이 < 50자 AND 키워드 '고마워|안녕|잘자' 매칭 → GPT-5.4-mini
  ├─ 키워드 '우울|죽고싶어|도와줘' 매칭           → 플래그십 (민감 주제)
  └─ 그 외                                        → Sonnet 4.6 (기본값)

장점: 레이턴시 0ms 추가, 비용 0원. 단점: 규칙 유지보수 지옥, 신조어·변형·의도 파악 불가.

2) Classifier LLM 이 1차 판단 (2-Stage Cascade) — 실무 표준 패턴

Stage 1: Gemini Flash 같은 초저가 모델에게 "이 메시지는 [간단|중간|복잡] 중 뭐야?" 분류 질문
Stage 2: 분류 결과에 따라 mini / Sonnet / Opus 로 라우팅

장점: 신조어·의도·맥락 반영. 단점: 호출이 2번으로 늘어 레이턴시 + 약간의 비용. Classifier 자체 품질이 낮으면 라우팅이 엉뚱해짐.

3) 임베딩 유사도 + 라벨 매칭 (Embedding Router) — 중간 비용, 확장성 좋음

미리 "간단 질문 예시 10개" / "복잡 질문 예시 10개" 를 임베딩으로 저장
유저 메시지를 임베딩 → 코사인 유사도로 가장 가까운 라벨 선택

장점: Stage 1 대비 빠르고 저렴. 단점: 학습 데이터(예시 10개 + 10개) 를 잘 만들어야 함 — 이게 곧 Day 15~16 RAG 에서 다룰 스킬과 연결.

4) 하이브리드 — 진짜 실무 정답

위 세 가지를 레이어로 쌓아 씁니다. 규칙으로 커트할 수 있는 건 먼저 커트 (90% 비용 절약), 애매한 것만 임베딩 Router 태우고, 그래도 애매하면 Classifier LLM. 실제 프로덕션 LLM 라우터는 거의 다 이 구조예요.

실무 tip — 라우팅 결정 로그를 무조건 남기세요 (요청 ID + 선택된 모델 + 판단 근거). 나중에 "왜 이 메시지가 Opus 로 갔지?" 디버깅할 때 이 로그 없으면 지옥입니다.

🎯 면접관을 홀리는 핵심 멘트

"LLM 라우팅 설계는 '얼마나 정확하게 분류하느냐' 보다 '얼마나 싸고 빠르게 분류하느냐' 가 본질입니다. 규칙 기반 1차 게이트로 명확한 케이스를 0ms 에 처리하고, 애매한 영역만 임베딩 Router 나 Classifier LLM 으로 2차 판단하는 계단식 캐스케이드 가 실무 표준입니다. 판단 근거를 로그로 남겨 사후 분석이 가능하게 해두는 것까지가 세트입니다. 중요한 건 라우팅 자체의 비용이 라우팅으로 아끼는 비용보다 크면 안 된다 는 자명한 경제 원칙이죠."


주제 2. "AutoConfiguration 의 편의 vs 수동 빈 등록의 투명성"

[문제 상황 요약]

과제 2 에서 여러분은 Spring AI 의 기본 AutoConfiguration 을 통째로 우회 하고 수동 @Bean 으로 두 ChatModel 을 등록했습니다.

그런데 이 방식은 AutoConfig 가 기본 제공하는 Observability · Retry · Metrics · ToolCallingManager 같은 장치들을 전부 직접 챙겨야 한다는 뜻이기도 해요.

여러분이 프로덕션 LLM 서비스를 만든다면 "AutoConfig 올인 vs 수동 올인 vs 하이브리드" 중 어떤 비율이 유지보수에 유리할까요?

💡 [튜터의 가이드 및 해설]

실무 엔지니어링에서 끊임없이 돌아오는 주제입니다. 각 선택지의 손익을 분석해봅시다.

1) AutoConfig 올인 — "빠른 개발, 블랙박스 의존"

장점:

  • spring-ai-starter-model-* 추가 + YAML 만 쓰면 끝. 보일러플레이트 0줄
  • Spring AI 버전 업그레이드 시 버그 픽스·기능 개선이 자동 따라옴
  • 공식 Observability/Retry/Metrics 전부 기본 on 단점:
  • 커스텀이 필요한 순간 설정을 찾기 매우 어려움 (AutoConfig 클래스 열어봐야 함)
  • 등록된 빈 이름·타입 조합이 버전 업 과정에서 바뀌면 예고 없이 깨질 수 있음

2) 수동 올인 — "완전한 통제, 관리 부담"

장점:

  • 스프링 컨텍스트에 뭐가 등록되는지 100% 투명
  • 각 기능(Observation / Retry / Tool) 을 필요한 것만 골라 조립 가능
  • 버전 업 영향 최소화 — 내가 직접 쓴 코드만 바뀜 단점:
  • 보일러플레이트 폭증
  • 새 기능 도입 시 공식 베스트 프랙티스를 놓칠 위험
  • Observability 같은 기반 기능을 누락하기 쉬움

3) 하이브리드 — "실무 정답"

  • 주 프로바이더 1개는 AutoConfig 로 (YAML 만 채우면 됨)
  • 부 프로바이더 1~2개만 수동 @Bean 으로 추가
  • @Qualifier 로 구분, Observation/Retry 가 필요하면 주 프로바이더에서 주입받은 빈을 재사용
// AutoConfig 로 Gemini 는 자동 등록됨 (이름: "openAiChatModel")
// 우리는 Ollama 만 수동 추가
@Bean("ollamaChatModel")
public ChatModel ollamaChatModel(
        ObservationRegistry observationRegistry,  // AutoConfig 가 등록한 Observability 인프라 재활용
        ToolCallingManager toolCallingManager     // 공식 도구 시스템 그대로 사용
) {
    return OllamaChatModel.builder()
            .ollamaApi(OllamaApi.builder().baseUrl(...).build())
            .defaultOptions(OllamaOptions.builder().model("gemma3:4b").build())
            .observationRegistry(observationRegistry)
            .toolCallingManager(toolCallingManager)
            .build();
}

이렇게 쓰면 "AutoConfig 의 혜택을 80% 누리면서, 필요한 부분만 수동 확장" 할 수 있어요.

의사결정 기준 — "프로바이더 수가 몇 개냐" 에 달려 있습니다.

  • 1개 → AutoConfig 올인 (수동 등록이 낭비)
  • 2~3개, 그중 주력 1개 → 하이브리드 (주력은 AutoConfig)
  • N개를 동적으로 전환해야 하는 플레이그라운드 서비스 → 수동 올인 (어차피 AutoConfig 의 단일 활성화 제약과 충돌)

🎯 면접관을 홀리는 핵심 멘트

"AutoConfiguration 은 '빠른 개발 + 공식 베스트 프랙티스 자동 수혜' 를 주지만, 스프링 컨텍스트를 블랙박스로 만듭니다. 수동 @Bean 등록은 그 반대죠. 실무에서는 '주력 프로바이더 1개는 AutoConfig, 부 프로바이더만 수동 등록하되 ObservationRegistry 같은 AutoConfig 인프라 빈은 재주입' 하는 하이브리드가 최선입니다. 결정의 핵심 기준은 '버전 업 시 AutoConfig 가 깨질 위험 vs 수동 코드 유지 부담' 의 비교이고, 프로바이더 수와 커스텀 요구의 복잡도로 그 무게를 재평가합니다."


주제 3. "LLM 호출 로그, 어디까지 남겨야 법무팀이 안 울까"

[문제 상황 요약]

Step 6 에서 우리는 PII 가 새는 3경로를 배웠습니다.

그런데 정작 개발자가 매일 건드리는 LLM 호출 로그 는 PII 의 종합 선물세트일 수 있어요.

요청 시간·유저 ID·시스템 프롬프트·유저 메시지·LLM 응답·토큰 수·에러 스택트레이스 — 전부 남기고 싶어지잖아요? 디버깅·비용 분석·품질 평가 다 필요하니까.

그런데 유저 메시지 는 PII 가 들어있을 가능성이 가장 높은 필드예요.

여러분이 ai-friends 의 로그 설계자라면 어떤 원칙을 세우시겠어요?

💡 [튜터의 가이드 및 해설]

이 문제는 "디버깅 가능성 vs 법적 리스크" 의 최적화 문제예요. 실무에서 쓰이는 설계 원칙을 하나씩 정리해봅시다.

1) 로그 레벨 분리 — "정상 / 에러" 별도 정책

정상 응답은 최소만 남기고, 에러는 풍부하게 남깁니다. 이 비대칭성이 핵심.

정상 로그 (INFO):
  requestId, userId_hashed, providerLabel, promptTokenCount, completionTokenCount,
  latencyMs, status=200
  ↑ 유저 메시지·응답 본문은 남기지 않음 에러 로그 (ERROR):
  위 전부 + userMessage(마스킹된 버전) + llmReply(있는 경우, 마스킹) + 스택트레이스
  ↑ 디버깅에 필요한 최소한만 기록, 그마저도 마스킹 적용

2) 필드별 정책 매트릭스

필드 정상 로그 에러 로그 이유
유저 ID 해시값만 (sha256) 해시값만 원본 ID 는 DB 에서 조회 가능, 로그는 조인 키만
유저 메시지 원문 저장 안 함 마스킹 후 N자 제한 PII 핵심 위험 포인트
시스템 프롬프트 저장 안 함 (긴데다 버전 관리 대상 아님) 프롬프트 버전 ID 전체를 로그에 박으면 공간 낭비 + 유출 위험
LLM 응답 저장 안 함 마스킹 후 N자 제한 유저가 보여준 PII 가 응답에도 반영될 수 있음
토큰 수 / latency / status 저장 저장 PII 아님, 비용/성능 분석 핵심
IP / UA / 지역 저장 안 함 (또는 광역만) 광역만 간접 식별자

3) 마스킹 계층 — 3단계 방어

로그 저장 전에 자동 마스킹을 돌립니다.

Layer 1: 정규식 기반 (가장 빠름)
  "010-\d{4}-\d{4}" → "[PHONE]"
  "\S+@\S+\.\S+"   → "[EMAIL]"
  "\d{6}-\d{7}"    → "[SSN]"

Layer 2: NER (Named Entity Recognition) — 한국어 이름/주소/기관명
  "김철수 씨가" → "[PERSON] 씨가"
  "서울시 강남구 역삼동" → "[ADDRESS]"

Layer 3: LLM 기반 최종 검증 (배치 잡)
  주기적으로 로그 샘플링 → LLM 한테 "이 로그에 PII 남아있어?" 검증 적발된 패턴은 Layer 1 정규식으로 역피드백

4) 보관 기간 정책

한국 개인정보보호법 기준, 개인정보가 포함될 수 있는 로그 는 목적 달성 후 즉시 파기 가 원칙입니다.

디버깅 로그 (에러)     : 7~30일 보관 → 자동 파기
품질 평가용 샘플링 로그 : 90일 보관 (동의 받은 것만)
비용 분석용 지표        : 무기한 보관 OK (토큰 수만, PII 없음)

5) 접근 통제

로그 저장소에 누가 접근 가능한지 도 중요합니다. 개발자 전원이 조회 가능한 Kibana 에 유저 메시지 원문이 흘러가면 — 기술적 문제 없어도 내부 통제 감사에서 지적 받습니다.

🎯 면접관을 홀리는 핵심 멘트

"LLM 호출 로그는 '남기고 싶은 이유' 와 '남기면 안 되는 이유' 가 정확히 같은 필드에서 충돌합니다. 원칙은 '정상 응답은 지표만, 에러는 마스킹된 최소 본문만' 입니다. 유저 ID 는 해시값으로, 시스템 프롬프트는 버전 ID 로 치환하고, 유저 메시지는 정규식·NER·LLM 기반 3단 마스킹을 거쳐 저장합니다. 보관 기간은 목적별로 차등 두고, 저장소 접근 통제까지 포함해야 법무팀이 사인을 해줍니다. 디버깅의 편의는 유저 프라이버시를 깎아서 버는 게 아니라, 마스킹 파이프라인을 잘 만들어서 버는 겁니다."

오늘 과제와 생각해볼 주제까지 완주하신 여러분, 진짜 고생 많으셨습니다! 이제 여러분은 "LLM 은 그냥 쓰는 게 아니라 비용·프라이버시·운영을 동시에 설계하는 시스템이다" 라는 감각을 손에 얻으셨어요. Day 3 에서는 이 위에 PromptTemplate 이라는 새로운 레이어를 얹습니다. 다음 시간에 만나요!

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.