Day 1. Spring AI 입문 & 프로젝트 세팅 — "Hello, AI"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
드디어 우리가 그렇게 기다려왔던 Spring AI 과목의 막이 올랐습니다.
지금까지 우리는 정말 많은 것을 함께 쌓아왔죠.
Spring Boot로 인스타그램 클론을 만들면서 Redis로 캐시·분산 락·Pub/Sub을 다뤘고, WebSocket으로 실시간 채팅을 붙였고, RestClient로 외부 API와 통신했고, MySQL로 영속성을 다졌습니다.
거기다 ai-friends라는 미연시 게임 데모에서는 RestClient로 Gemini API를 직접 두드려서 AI 캐릭터 응답을 받는 것까지 시연했죠.
기억나시나요? GeminiService 안에 buildSystemInstructionText, buildRequest, buildResponseJsonSchema... 한 200줄 가까이 되는 그 코드. 헤더 직접 만들고, JSON Schema 직접 조립하고, 응답 파싱하다가 4xx/5xx 분기 다 적어줬던 그 코드 말이에요.
"튜터님, 그거 진짜 잘 돌아가긴 하는데… 모델만 바꾸려고 해도 코드를 한참 뒤져야 하잖아요. 만약에 OpenAI나 Claude로도 쓰고 싶으면요? 그럼 또 그 회사 API 스펙 보고 처음부터 다시 짜야 하나요?"
네, 정확히 그 지점입니다. 오늘 우리가 풀어볼 첫 번째 갈래가 바로 그거예요.
💡 오늘 수업의 핵심 — "Spring 생태계가 LLM을 다루는 표준어, Spring AI의 시작"
오늘 다룰 핵심 주제는 'Spring AI가 왜 등장했고, 우리는 왜 RestClient를 떠나야 하는가' 입니다.
그리고 이론에서 끝나는 게 아니라, 진짜로 우리 ai-friends 프로젝트에 Spring AI를 얹어서 로컬 Ollama와 Gemini 무료 티어, 두 모델로 "Hello, AI"라는 첫 응답을 받아보는 것까지가 오늘의 목표입니다.
자, 본격 시작하기 전에 잠깐만 더 말씀드릴게요.
이번 Spring AI 과목은 "한 번 만들고 끝"이 아니라 20일 동안 ai-friends 위에 차곡차곡 쌓아 올리는 구조예요.
매일 끝나면 dayNN-주제 브랜치로 세이브해두기 때문에, 혹시 중간에 어떤 Day를 놓치더라도 그 직전 브랜치를 git checkout 해서 바로 합류할 수 있게 설계했습니다.
마라톤이라 생각하고 호흡 길게 가져가요.
"튜터님, 그럼 진짜 첫날부터 OpenAI 키 결제해야 하나요?" 절대 아닙니다.
이번 과정의 철학 중 하나가 바로 "무료 우선, 유료는 선택" 이에요.
학습용 실습은 거의 100% Ollama 로컬 + Gemini 무료 티어로 돌아갑니다.
지갑 걱정 없이 마음껏 쳐보세요.
다만 "어떤 모델은 왜 유료여야 하는지", "실무에서는 어떻게 골라야 하는지" 같은 트레이드오프는 Day 2에서 정식으로 정리할 거예요.
🎯 학습 목표
- Spring AI가 해결하는 문제가 무엇인지 이해합니다. (왜 RestClient만으로는 부족한가)
spring-ai-bom,application.yml프로파일 분리,.env보안 패턴까지 Spring AI 프로젝트의 표준 세팅을 직접 손으로 갖춥니다.- Ollama 로컬과 Gemini 무료 티어 두 가지 환경을 모두 띄워놓고,
ChatClient로 첫 호출 "Hello, AI" 응답을 받습니다. - API 키를 코드/Git에 노출시키지 않는 보안 기본기(
.env,.gitignore, 환경변수 로딩)를 점검합니다.
오늘의 진행 순서
| Step | 주제 | 예상 시간 |
|---|---|---|
| Step 1 | "RestClient로 충분하지 않을까?" — Spring AI 등장 배경 | 약 20분 |
| Step 2 | LangChain4j vs Spring AI — 우리가 Spring AI를 고른 이유 | 약 15분 |
| Step 3 | build.gradle 정렬 + spring-ai-bom 의존성 추가 |
약 25분 |
| Step 4 | Ollama 로컬 설치 & 모델 풀(pull) + 호스트↔컨테이너 브리지 | 약 30분 |
| Step 5 | Gemini 무료 티어 키 발급 + application-*.yml 프로파일 분리 |
약 25분 |
| Step 6 | API 키 보안 — .env · .gitignore · 환경변수 로딩 |
약 20분 |
| Step 7 | 드디어 "Hello, AI!" — ChatClient 첫 호출 |
약 30분 |
| 마무리 | 회고 + 브랜치 세이브 + Day 2 예고 | 약 10분 |
자, 그럼 첫 번째 갈래를 풀러 가볼까요?
Step 1: "RestClient로 충분하지 않을까?" — Spring AI가 해결하려는 진짜 문제
여러분, 솔직히 한번 같이 떠올려봅시다. 우리 ai-friends 프로젝트의 GeminiService.java 한 번 열어보세요. 메서드만 다섯 개입니다.
buildSystemInstructionText(soulmate)— 시스템 프롬프트 만들기buildRequest(...)— Gemini가 원하는 JSON 모양으로 조립하기buildResponseJsonSchema()— 응답 JSON Schema 손으로 짜기generateReply(...)— 호출하고, 4xx/5xx 분기 처리하고, 응답 파싱하기- 거기에 슬라이딩 윈도우(
MAX_CONTEXT_MESSAGES), 보정 프롬프트(AFFECTION_REMINDER_MESSAGE,NO_CHOICES_REMINDER_MESSAGE)까지
이게 "Gemini API에 그냥 메시지 한 번 보내고 답 받는다"의 코드 분량이에요. 코드 자체가 잘못된 건 절대 아닙니다. 사실 RestClient 학습용으로는 정말 잘 만들어진 예제예요. 다만 우리가 이걸 본격 AI 프로덕트로 키우려고 하는 순간 한계가 우르르 드러납니다. 그 한계가 정확히 뭔지, 같이 짚어볼게요.
1. 첫 번째 한계: 프로바이더에 강하게 묶여있다
지금 우리 코드 안에는 "Gemini"라는 단어가 사방에 박혀있어요. 한번 같이 세어볼까요.
- 클래스 이름:
GeminiService,GeminiRequest,GeminiResponse,GeminiParsedResponse - 헤더:
defaultHeader("x-goog-api-key", geminiApiKey)— 구글 전용 헤더 - URL:
/models/{geminiModel}:generateContent— 구글 전용 경로 - 요청 본문 구조:
Content,Part,SystemInstruction,GenerationConfig— 전부 구글이 정의한 스펙
이 상태에서 누군가 와서 "튜터님, 다음 주에 OpenAI로도 같은 캐릭터 응답 받게 해주세요" 한다면 어떻게 될까요? 거의 다시 짜야 합니다. 클래스 이름 바꾸고, 헤더 바꾸고, 요청 본문 구조 바꾸고, JSON Schema 표현 방식도 다르고… "그냥 모델만 바꾸고 싶었던" 우리 마음과 달리 한 일주일은 걸릴 거예요.
🙋 학생 질문 — "튜터님, 그러면 인터페이스 하나 만들어서 추상화하면 되잖아요?"
정확합니다! 그게 정답이에요.
그런데 그 추상화를 우리가 직접 하려면 — 각 프로바이더의 요청/응답 스펙, 토큰 한도, 함수 호출(Tool Calling) 방식, 스트리밍 포맷, 멀티모달 입력 형태 전부를 알아내고 공통 분모로 묶어야 해요.
그리고 OpenAI가 새 기능 추가하면 그것도 따라가야죠.
이걸 누군가가 이미 해놨다면, 그걸 그대로 갖다 쓰는 게 더 합리적이지 않을까요? 그게 바로 Spring AI의 ChatClient 추상화 레이어가 하는 일입니다.
2. 두 번째 한계: 프롬프트가 자바 문자열 안에 박혀있다
buildSystemInstructionText 메서드를 떠올려보세요. 시스템 프롬프트가 자바 텍스트 블록("""...""") 안에 줄줄이 박혀있죠? 처음 만들 때는 편해요. 하지만 이게 운영으로 가면 진짜 골치 아파집니다.
- 버전 관리가 안 돼요. 프롬프트 한 줄 바꾸려면 자바 코드를 빌드·배포해야 해요. "이번 주말에 카피 한 줄만 살짝 바꿔보고 싶어요" 라는 기획자의 한마디에 배포 파이프라인이 돌아가야 합니다.
- A/B 테스트가 어려워요. "프롬프트 A와 프롬프트 B를 50:50으로 나눠서 호감도 변화량을 비교해보자"는 실험을 하려면, 자바 if문으로 두 가지 시스템 프롬프트를 분기시켜야 합니다. 지저분하죠.
- 프롬프트 + 변수 조합도 수동입니다. 지금 코드는
String.format()의formatted(...)로 5개 변수를 끼워넣고 있는데, 변수가 20개로 늘어나면 어떨까요? 누락된 변수가 있는지 컴파일 타임에 잡을 방법도 없어요.
Spring AI는 이걸 **PromptTemplate**과 SystemPromptTemplate 으로 깔끔하게 풀어줍니다.
변수를 {soulmateName} 같은 플레이스홀더로 박아두고, 외부 파일(.st, .txt)에 빼두고, 런타임에 값을 주입하는 식이에요.
이건 Day 3에서 본격적으로 다룰 거예요.
지금은 "아, 이게 자바 문자열 안에 머물러 있는 게 정상은 아니구나" 정도만 느끼시면 됩니다.
3. 세 번째 한계: 멀티턴·메모리·에러 보정을 전부 직접 구현해야 한다
GeminiService가 진짜로 무거운 이유는 사실 여기 있어요. LLM 한 번 호출하는 거 자체는 사실 단순합니다. 어려운 건 "LLM과 자연스러운 대화를 만드는 부가 로직" 들이에요. 우리 코드에 어떤 게 들어있는지 한번 살펴볼까요.
// 현재 ai-friends GeminiService에 흩어져 있는 부가 로직들 — 직접 손코딩
private static final int MAX_CONTEXT_MESSAGES = 20; // 슬라이딩 윈도우 직접 관리
private static final String AFFECTION_REMINDER_MESSAGE = ... // 호감도 누락 보정 프롬프트
private static final String NO_CHOICES_REMINDER_MESSAGE = ...// 선택지 강제 차단 보정 프롬프트
if (recentLogsAsc.size() > MAX_CONTEXT_MESSAGES) { // 컨텍스트 자르기 직접 구현
recentLogsAsc = recentLogsAsc.subList(...);
}
지금 우리가 직접 손으로 만들고 있는 것들을 정리하면 이래요.
| 우리가 직접 구현한 것 | 실무에서 흔히 필요한 것 |
|---|---|
MAX_CONTEXT_MESSAGES로 직접 자르기 |
토큰 카운트 기반 슬라이딩 윈도우 |
| 호감도 누락 시 보정 메시지 추가 | 응답 검증 후 재시도 정책 |
forceNoChoices 분기 |
응답 후처리 파이프라인 |
responseJsonSchema 손으로 LinkedHashMap |
자바 Record를 JSON Schema로 자동 변환 |
| 4xx/5xx 분기 직접 작성 | 프로바이더별 에러를 공통 예외로 매핑 |
이 부가 로직들이 LLM 프로덕트의 진짜 살림살이거든요.
RAG 붙이고, 함수 호출(Tool) 붙이고, 멀티모달 들어가면 더 늘어납니다.
매번 우리가 바닥부터 짜기엔 너무 비싸요.
Spring AI는 이런 미들웨어들을 Advisor 라는 일관된 체인 패턴으로 풀어요.
마치 Spring MVC의 Filter / Interceptor 같은 개념입니다.
(이건 Day 5 ChatMemory부터 본격적으로 만나게 됩니다.)
4. 그래서 Spring AI는 무엇인가? — 한 줄 요약
길게 한계를 늘어놨으니 이제 정의를 깔끔하게 정리해볼게요.
Spring AI는 "Spring 개발자가 이미 익숙한 방식 그대로 LLM을 다룰 수 있게 해주는 추상화 레이어" 입니다.
좀 더 구체적으로는, 다음 세 가지를 표준화해줘요.
ChatClient— 어떤 프로바이더든 같은 빌더 패턴(prompt().user("...").call())으로 호출Advisor— 메모리·RAG·로깅 같은 부가 로직을 체인으로 끼워넣는 미들웨어- 자동 설정(Spring Boot Starter) —
application.yml의 프로퍼티만 바꿔도 모델·프로바이더 교체
비유하자면 이런 느낌이에요.
우리가 JDBC를 직접 쓰지 않고 Spring Data JPA 를 쓰는 이유랑 정확히 같습니다.
JDBC도 잘 동작하지만, 매번 Connection, PreparedStatement, ResultSet 직접 다루기엔 너무 반복적이고 실수도 잦았죠.
JPA가 그 반복을 가져가준 덕에 우리는 비즈니스 로직에 집중할 수 있게 됐어요.
Spring AI는 LLM 시대의 JPA 라고 생각하시면 됩니다.
💡 튜터의 핵심 포인트 — 그렇다고 RestClient가 사라진다는 뜻은 아니에요
오해하지 마세요. Spring AI를 쓴다고 RestClient를 버리는 게 절대 아닙니다. 우리 ai-friends 안의 PracticeController는 여전히 JSONPlaceholder, Bored API에 RestClient로 접근하고 있죠? 그건 그대로 둡니다.
Spring AI는 "LLM을 다루는 부분만" 우아하게 바꿔주는 거예요. 그 외의 일반 외부 API 호출은 RestClient가 여전히 정답이에요. 도구는 용도에 맞게 쓰는 거고, 우리가 오늘 떠나는 건 "LLM API를 다루기 위한 RestClient의 한계" 일 뿐입니다.
🙋 날카로운 질문 타임!
"튜터님, 그래도 어떤 회사는 RestClient만으로 LLM 프로덕트 운영하던데요? Spring AI 안 쓰면 안 되나요?"
물론 가능합니다. 그리고 실제로 그렇게 하는 팀도 많아요. 트레이드오프는 분명합니다.
| 비교 축 | RestClient 직접 | Spring AI |
|---|---|---|
| 학습 곡선 | 낮음 (이미 알고 있음) | 중간 (개념 셋: ChatClient/Advisor/VectorStore) |
| 자유도 | 매우 높음 (원하는 대로) | 표준화된 길이 정해져 있음 |
| 프로바이더 교체 | 거의 새로 짜야 함 | 설정만 바꾸면 됨 |
| 멀티모달·RAG·Tool | 직접 구현 | 라이브러리 제공 |
| 새 기능 따라잡기 | 직접 추적 | 버전 올리면 따라옴 |
| 적합 시나리오 | 단순 프록시, 1개 프로바이더 고정 | 본격 AI 프로덕트, 멀티 프로바이더 |
우리 ai-friends는 곧 함수 호출(Day 11), 에이전트 패턴(Day 12-14), RAG(Day 15-16), MCP(Day 17-18) 까지 들어갑니다.
이런 기능들을 RestClient로 직접 구현하면 진짜 프로젝트가 산으로 가요.
그래서 우리는 Spring AI를 선택합니다.
하지만 "어떤 회사가 Spring AI 안 쓰는 거 봤어요"라는 말이 들렸을 때, 상대방을 깎아내리지 말고 위 표를 떠올리세요. 둘 다 합리적인 선택일 수 있습니다.
"튜터님, Spring AI 1.x는 안정 버전 맞나요? 새로 나온 거 같은데 프로덕션에 써도 괜찮은가요?"
좋은 질문이에요.
결론부터 말씀드리면 "2026년 5월 기준, 1.1.x는 stable 최신 라인으로 실무에서 충분히 쓸 만하다" 입니다.
다만 코앞(2026-05-28)에 2.0 GA가 예정돼 있는데, 이건 Spring Boot 4.0 / Jackson 3 / Null Safety 기반이라 우리가 지금까지 배워온 Spring Boot 3.x 스택과는 잘 안 맞아요.
그래서 본 강의는 1.1.x를 고정해서 가고, 2.0 마이그레이션은 마지막 Day 20에서 노트로 따로 정리해드릴 겁니다.
강의 끝나고 한 달 정도 익숙해진 다음에 2.0으로 넘어가시면 충분해요.
너무 일찍 새 버전에 손대면 API가 출렁여서 학습 자체가 흔들립니다.
자, 이제 우리가 왜 Spring AI를 쓰는지에 대한 명분이 머릿속에 잡히셨죠? 그런데 AI 자바 라이브러리에는 사실 Spring AI 말고 강력한 경쟁자가 하나 더 있어요.
바로 LangChain4j 입니다.
이름만 들어도 "어, Python LangChain이랑 비슷한 거 아니야?" 싶죠? 다음 Step에서 이 둘을 비교해보고, 우리가 왜 그 중에서도 굳이 Spring AI를 골랐는지 그 결정 근거를 함께 짚고 가겠습니다.
Step 2: LangChain4j vs Spring AI — 우리가 Spring AI를 고른 이유
자바 진영에서 LLM을 다루려고 라이브러리를 찾아보면, 사실상 두 개로 압축됩니다.
- LangChain4j — Python의 그 유명한 LangChain을 자바·코틀린 진영으로 포팅한 OSS 프로젝트
- Spring AI — Spring 팀이 직접 만든, Spring 생태계에 네이티브로 녹아드는 AI 라이브러리
여러분, 솔직히 검색하시다 보면 한 번씩 헷갈리실 거예요.
둘 다 ChatModel 비슷한 게 있고, 둘 다 메모리 기능이 있고, 둘 다 RAG·Tool Calling을 지원합니다.
그래서 "그럼 도대체 뭘 써야 해?" 라는 질문이 자연스럽게 나오죠.
오늘 그 결정의 근거를 깔끔하게 잡아두고 가겠습니다.
우리가 남은 19일 동안 같이 살게 될 라이브러리니까, 첫 만남부터 확신을 갖고 시작하면 좋잖아요?
1. 한 줄 요약부터: 두 라이브러리의 출신 성분이 다르다
가장 본질적인 차이는 의외로 "누가, 어떤 철학으로 만들었느냐" 에 있어요. 이걸 알고 나면 나머지 차이는 다 이걸로 설명이 됩니다.
| 출신 | LangChain4j | Spring AI |
|---|---|---|
| 모태 | Python LangChain 의 자바 포팅 | Spring 팀 자체 신규 프로젝트 |
| 출시 | 2023 (커뮤니티 주도) | 2024 (Spring 공식) |
| 철학 | 자유로운 컴포넌트 조합 (LangChain 스타일) | Spring 표준 어휘로 정렬 |
| 배포 모델 | 단독 자바 라이브러리 | Spring Boot Starter 중심 |
| 문서 | 자체 사이트, 빠른 변화 | docs.spring.io 공식 라인 |
LangChain4j는 본질적으로 "Python에서 인기 있던 LangChain을 자바에서도 쓰고 싶다" 라는 동기에서 시작됐어요.
그래서 LangChain 사용자가 보면 굉장히 친숙합니다.
ChatLanguageModel, EmbeddingStore, ContentRetriever 같은 이름들이 LangChain의 그 어휘를 거의 그대로 가져왔거든요.
반면 Spring AI는 Spring 팀이 처음부터 "Spring 개발자가 익숙한 어휘로 LLM을 다루게 하자" 라는 목표로 만들었어요. 그래서 ChatClient라는 이름부터가 RestClient, WebClient와 결을 맞췄고, 의존성 주입·자동 설정·Actuator 같은 Spring의 기본 인프라에 자연스럽게 맞물립니다.
🙋 학생 질문 — "튜터님, 출신이 다르다는 게 그렇게 큰 차이인가요? 결국 LLM 호출만 잘 되면 되는 거 아닌가요?"
좋은 질문입니다.
그런데 우리가 만들 게 단순 데모면 그 말이 맞아요.
다만 이걸 운영 서비스로 키우는 순간 출신 성분이 무섭게 작동합니다.
Spring Boot 프로젝트에 LangChain4j를 얹으면 메모리 객체 빈 등록, 트랜잭션 경계, Actuator 메트릭 노출 같은 걸 우리가 손으로 한 번 더 다리를 놔줘야 해요.
Spring AI는 그게 자동 설정으로 다 들어옵니다.
"이미 Spring Boot 프로젝트라면, Spring AI가 마찰이 적다" — 이게 핵심입니다.
2. 우리 프로젝트 입장에서 본 결정적인 차이 5가지
이제 추상적 비교는 그만하고, 우리 ai-friends 프로젝트 입장에서 진짜 살에 와닿는 차이를 정리해볼게요. 5가지면 충분합니다.
(1) Spring Boot 자동 설정 — application.yml만 바꿔도 모델이 바뀐다
Spring AI의 가장 큰 무기는 Spring Boot Starter 입니다.
spring-ai-starter-model-ollama, spring-ai-starter-model-openai 같은 스타터를 의존성에 넣고, application.yml에 모델·키만 적어두면 끝이에요.
ChatModel 빈이 자동으로 등록됩니다.
우리는 그냥 @Autowired ChatModel chatModel만 받아서 쓰면 되고요.
LangChain4j도 Spring Boot Starter를 제공하긴 합니다(langchain4j-spring-boot-starter). 그런데 표준 Spring Boot AutoConfiguration 패턴을 그대로 따랐다기보다는 자체 어휘로 한 번 더 감싼 형태라서, Spring 개발자 입장에선 한 번 더 학습해야 합니다.
(2) 의존성 주입과 트랜잭션 경계 — Spring AOP가 그대로 먹힌다
ai-friends에는 이미 @Service, @Transactional이 깔려 있죠. Spring AI의 Advisor는 Spring 빈으로 등록되기 때문에 우리가 만든 @Component나 @Service를 자연스럽게 주입받아 쓸 수 있어요. 그리고 @Async, @Transactional 같은 AOP가 그대로 작동합니다.
LangChain4j도 안 되는 건 아니지만, "LangChain4j 객체 → Spring 빈" 사이의 다리를 우리가 한 번씩 직접 놓는 그림이 자주 나옵니다. 우리 입장에선 이미 익숙한 Spring 어휘 안에서 다 끝나는 게 학습 비용이 훨씬 낮아요.
(3) Actuator·Micrometer 통합 — 운영 단계에서 빛난다
지금은 와닿지 않으실 텐데, Day 20에서 LLM 호출 메트릭을 Prometheus·Grafana로 보내는 작업을 할 거예요. Spring AI는 ChatClient 호출이 Micrometer로 자동 계측되도록 정렬돼 있습니다. 응답 시간·토큰 사용량·에러율 같은 지표가 우리가 별도로 손대지 않아도 Actuator에 잡혀요.
LangChain4j는 이 부분을 우리가 직접 계측 코드를 끼워넣어야 합니다. 학습용으로는 큰 차이 없지만, 실무 운영으로 가면 이 차이가 한참 벌어져요.
(4) Spring 공식 + Pivotal 라인업 — 장기 유지보수 신뢰
Spring AI는 Spring 팀이 직접 운영하는 공식 프로젝트예요. 6개월~1년 단위 릴리즈 사이클이 Spring Boot와 정렬돼 있고, 보안 패치도 Spring 일정에 맞춰 나옵니다.
우리가 회사에서 "왜 이걸 골랐어요?" 라고 질문받았을 때 "Spring 공식 라인업에 들어가 있고, Spring Boot와 라이프사이클이 맞물려서요" 라고 답할 수 있다는 건 꽤 큰 무기예요.
LangChain4j는 OSS로서 충분히 활발하고 좋은 라이브러리지만, 장기 후원 주체와 메인테이너 베드(bed) 의 두께는 Spring AI 쪽이 두껍습니다. 적어도 향후 5년 단위 베팅이라면 Spring AI 쪽이 안전한 선택이에요.
(5) MCP · Vector Store 등 신기능 — 둘 다 따라가지만 결합도가 다르다
요즘 핫한 MCP(Model Context Protocol), 다양한 Vector Store(pgvector, Chroma, Qdrant, Redis Stack), 이미지·음성·임베딩 같은 신기능들은 사실 양쪽 다 빠르게 따라가고 있어요. 기능 갯수만 비교하면 우열을 가리기 어려워요.
다만 결합 방식이 달라요. Spring AI는 새 프로바이더가 추가돼도 기존 ChatClient 빌더 패턴이 그대로 유지되도록 계속 설계가 들어옵니다. 우리는 표면 API를 학습하고 나면 신기능이 들어와도 같은 어휘로 맞이할 수 있어요.
3. 그렇다고 LangChain4j가 나쁜 라이브러리는 절대 아니에요
여기서 정말 중요한 거 짚고 갈게요. 우리가 Spring AI를 고른다고 해서 LangChain4j를 깎아내리는 게 절대 아닙니다. 오히려 LangChain4j가 더 나은 시나리오도 분명히 존재해요.
| 이런 시나리오라면 | 더 나은 선택 |
|---|---|
| Spring Boot 프로젝트 안에서 LLM을 다룸 | Spring AI |
| 코틀린 단독, Spring 안 씀 | LangChain4j |
| Python LangChain을 그대로 자바로 옮겨야 함 | LangChain4j |
| Quarkus·Micronaut 같은 비-Spring 프레임워크 | LangChain4j |
| 실험적 컴포넌트 조합을 빠르게 찍어보고 싶음 | LangChain4j |
| 운영·관측·장기 유지보수가 중요한 사내 시스템 | Spring AI |
저는 회사 코드베이스가 이미 Spring Boot라면 무조건 Spring AI를 권합니다. 그게 아니라면 LangChain4j도 충분히 합리적인 선택이에요. "무엇이 더 좋다"가 아니라 "내 컨텍스트에 무엇이 더 잘 맞는다" 의 관점으로 접근하시면 됩니다.
🙋 학생 질문 — "튜터님, 그럼 둘 다 같이 쓰면 안 되나요? 어떤 부분은 Spring AI, 어떤 부분은 LangChain4j 이렇게요."
기술적으론 가능합니다만, 저는 강력하게 비추천해요.
두 라이브러리 모두 자신만의 ChatModel, Memory, Embedding 추상화를 가지고 있어서, 같이 쓰면 비슷한 일을 두 가지 방식으로 하게 됩니다.
빈도 두 벌, 설정도 두 벌, 메모리 객체도 두 벌… 6개월 뒤 신규 입사자가 그 코드를 받으면 "왜 이렇게 돼있어요?" 라는 표정을 지을 거예요.
하나로 통일하는 게 운영 비용이 훨씬 저렴합니다.
4. 우리의 최종 결정 — 20일을 함께할 동반자, Spring AI
자, 결론을 정리할게요. 우리가 Spring AI를 고른 이유는 다음 다섯 줄로 정리됩니다.
ai-friends는 이미 Spring Boot 3.5.x 위에서 돌아가고 있다. (마찰 최소)- Auto-configuration으로
ChatModel빈이 자동 등록되어 의존성 주입이 깔끔하다. - Actuator·Micrometer 통합이 Day 20 운영 모니터링까지 자연스럽게 이어진다.
- Spring 공식 라인업 으로 장기 유지보수 신뢰가 두껍다.
ChatClient→Advisor→VectorStore→Tool의 어휘 일관성이 20일 학습에 적합하다.
그렇다고 LangChain4j를 일부러 외면하지는 마세요. 나중에 회사에서 "우리는 Quarkus를 쓰는데 LLM을 붙여야 해요" 라는 상황이 오면 LangChain4j가 정답이 될 수도 있습니다. 오늘 우리가 익힌 트레이드오프 표가 그때 빛을 발할 거예요.
💡 튜터의 핵심 포인트 — "라이브러리 선택은 코드 양보다 어휘 일관성으로 따져라"
라이브러리를 고를 때 흔히 빠지는 함정이 "기능 갯수 세기" 예요. 그런데 두 라이브러리가 비슷한 기능 갯수를 갖춘 시점부터는 어휘의 일관성이 더 중요해집니다. 우리 팀이 6개월 뒤에 RAG를 붙일 때, 1년 뒤에 새 모델을 추가할 때, 같은 어휘로 같은 패턴으로 일이 되느냐 — 이게 진짜 운영 비용을 결정해요.
Spring 개발자에게 Spring AI는 이미 알고 있는 어휘로 LLM을 다룬다는 강력한 장점을 줍니다. 우리가 "RestClient → ChatClient" 라는 한 단어 변화로 패러다임을 옮길 수 있는 이유가 바로 이 어휘 일관성이에요.
🙋 날카로운 질문 타임!
"튜터님, Python LangChain이 그렇게 사실상 표준이라면, 우리도 Python으로 가야 하는 거 아닌가요?"
날카롭네요. 솔직히 말씀드리면 AI/ML 연구·실험 영역에서는 여전히 Python이 사실상 표준이 맞습니다. 새 모델 발표나 새 기법 논문은 거의 Python 코드로 같이 풀려요.
다만 우리는 지금 "AI 모델을 만드는 사람" 이 아니라 "이미 만들어진 AI 모델을 우리 백엔드 서비스에 붙여서 사용자에게 제공하는 사람" 이에요.
이 영역에서는 답이 좀 달라집니다.
트래픽 처리, 영속성, 트랜잭션, 보안, 인증, 모니터링… 이런 운영 인프라가 두꺼운 건 Spring/JVM 진영이에요.
그래서 백엔드 회사들 다수가 "AI 모델은 Python 팀이 만들고, 우리 백엔드는 그걸 가져다 Spring으로 서빙한다" 는 분업 구조를 가져갑니다.
우리가 Spring AI를 배우는 건 그 서빙 라인의 정중앙에 서는 일 이에요.
"튜터님, Spring AI 1.1.x 기준이라고 하셨는데, 만약 회사에서 이미 LangChain4j로 다 짜놨으면 어떡하죠?"
좋은 시나리오입니다. 두 가지 경우로 나눠 답변드릴게요.
- 1년차 미만의 신규 프로젝트라면 — 마이그레이션 검토를 한번 해볼 만합니다. 다만 그 결정은 단독으로 하지 말고 팀 합의로 가져가세요. 핵심 어휘만 매핑되면 의외로 빠르게 옮겨집니다.
- 3년차 이상의 운영 시스템이라면 — 굳이 갈아엎지 마세요. 돌아가는 시스템을 굳이 손대는 건 더 큰 위험입니다. LangChain4j도 충분히 좋은 라이브러리이고, 신규 모듈만 Spring AI로 짓는 식의 점진 전환도 어색하지 않아요. 다만 두 라이브러리를 한 모듈에 섞는 건 앞에서 말씀드린 이유로 피하시고요.
기술 선택은 항상 "지금 우리 컨텍스트" 에서 가장 합리적인 답을 찾는 거예요. "남들이 다 쓰는 거"가 아니라요.
자, 이제 우리가 20일 동안 함께 갈 라이브러리에 확신이 생기셨죠?
그럼 이제 진짜로 손을 움직일 시간입니다.
다음 Step에서는 우리 ai-friends 프로젝트의 build.gradle부터 함께 해부합니다.
사실 이 프로젝트는 큰 줄기(Spring Boot 3.5.14 + Java 21 + Spring AI 1.1.0 BOM)가 이미 정렬돼있어요.
다만 실제 모델 스타터(Ollama, OpenAI 호환)는 아직 비어있는 상태라서, "왜 이 버전 조합을 골랐는지" 설명을 한 번 정리한 뒤 손으로 의존성을 직접 채워 넣겠습니다.
본격 세팅 들어갈게요.
Step 3: `build.gradle` 해부 + Spring AI 모델 스타터 추가하기
자, 본격 손을 움직일 시간입니다. IntelliJ를 켜고 ai-friends 프로젝트를 여세요. 그리고 루트의 build.gradle 부터 같이 펼쳐봅니다.
이 파일은 우리 프로젝트의 신분증 같은 거예요. 어떤 자바 버전을 쓰는지, 어떤 Spring Boot 위에 서있는지, 어떤 라이브러리를 쓰는지 — 모든 게 여기 적혀있죠. 우리가 Spring AI를 얹기 전에 이 신분증부터 정확히 읽고 가야 합니다.
1. 핵심 4줄 — Boot 3.5.14, Java 21, Spring AI 1.1.0, BOM
먼저 build.gradle 파일에서 딱 봐야 할 핵심 4부분만 잘라서 같이 봅시다.
// build.gradle (발췌 — 이미 정렬되어 있는 상태)
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.14' // ① Spring Boot 3.5.14
id 'io.spring.dependency-management' version '1.1.7'
id 'jacoco'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21) // ② Java 21
}
}
ext {
// Spring AI 1.1.x (Spring Boot 3.4.x / 3.5.x 호환 라인 — 공식 매트릭스 기준)
set('springAiVersion', '1.1.0') // ③ Spring AI 1.1.0
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" // ④ BOM
}
}
이 네 줄이 "우리는 이 버전 조합으로 20일을 갑니다" 라는 약속입니다. 하나씩 의미를 짚어볼게요.
| 번호 | 의미 | 왜 이 값인가 |
|---|---|---|
| ① Spring Boot 3.5.14 | 런타임 골격 | Spring AI 1.1.x 공식 호환 매트릭스(3.4.x / 3.5.x) 안의 최신 패치 |
| ② Java 21 | LTS 버전 | Spring Boot 3.5.x가 권장하는 최신 LTS |
| ③ Spring AI 1.1.0 | 우리가 배울 라이브러리 버전 | 2025-11-12 GA, 2026-05 기준 stable 최신 라인 |
④ spring-ai-bom |
의존성 버전 묶음 관리자 | 모든 spring-ai-* 의존성 버전을 한 줄로 통일 |
🙋 학생 질문 — "튜터님, 왜 굳이 Spring Boot 3.5.x예요? 4.0이 이미 나왔다면서요?"
좋은 질문이에요.
Spring Boot 4.0은 2025년 11월 20일에 이미 GA 됐어요.
Jackson 3, Jakarta EE 11, JSpecify Null Safety, 70여 개 모듈로 분리된 baseline 같은 큰 변화가 동반됩니다.
그리고 그 위에 Spring AI 2.0(2026-05-28 GA 예정)이 올라가요.
이건 두 개의 메이저 변화를 동시에 새로 배우는 부담이에요.
우리는 이미 Spring Boot 3.x로 인스타그램 클론을 만들어왔잖아요? 익숙한 베이스 위에서 Spring AI라는 한 가지 새 토픽에만 집중하는 게 학습 효율이 훨씬 높습니다.
"새 기술은 한 번에 하나씩" — 이게 학습 전략의 기본입니다. 4.0/2.0 마이그레이션 방향은 마지막 Day 20에서 따로 정리할게요.
2. BOM(Bill of Materials)이 뭐예요?
build.gradle을 처음 보시는 분은 spring-ai-bom 이 뭐 하는 친구인지 헷갈릴 수 있어요. 한 줄로 정의하면 이렇습니다.
BOM = "이 우산 아래 들어오는 모든 의존성의 버전을 한꺼번에 결정해주는 메뉴판"
식당에 비유해볼게요. 만약 우리가 "스테이크 + 샐러드 + 와인"을 하나하나 따로 시키면, 셰프는 매번 어떤 와인이 어떤 스테이크 굽기와 잘 맞는지 우리한테 물어봐야 해요. 골치 아프죠. 그런데 "오늘의 코스" 를 시키면? 셰프가 알아서 와인·전채·메인을 조합 좋게 묶어서 내줘요. BOM이 그 "오늘의 코스" 메뉴예요.
// BOM 없이 직접 적었다면 — 매번 버전 적어줘야 함
implementation 'org.springframework.ai:spring-ai-starter-model-ollama:1.1.0'
implementation 'org.springframework.ai:spring-ai-starter-model-openai:1.1.0'
implementation 'org.springframework.ai:spring-ai-advisors-vector-store:1.1.0'
// BOM이 있으면 — 버전을 BOM이 채워줌
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-advisors-vector-store'
차이가 보이시죠? 위는 버전을 일일이 적어주는 코드, 아래는 BOM이 알아서 1.1.0으로 통일해주는 코드입니다. 20일 동안 우리는 점점 더 많은 spring-ai-* 의존성을 추가할 거예요. 그때마다 매번 버전을 적었다면 어딘가 버전이 어긋나서 버그가 났을 거예요. BOM이 그 모든 버전을 잠가주는 안전장치입니다.
3. 그런데 정말 중요한 게 빠져있어요 — 모델 스타터 의존성
자, build.gradle의 dependencies 블록을 펼쳐봅시다. 현재 이 프로젝트의 의존성 목록은 이래요.
// build.gradle (현재 dependencies 블록)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
implementation 'io.github.cdimascio:dotenv-java:3.0.0'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
뭐 빠진 게 보이세요? 그렇죠. spring-ai- 로 시작하는 의존성이 단 하나도 없어요. BOM은 정렬돼있지만, BOM은 어디까지나 "만약 너가 이걸 쓴다면, 버전은 1.1.0으로 잡아줄게" 라는 약속일 뿐이에요. 실제로 모델 스타터를 추가해야 비로소 ChatModel 빈이 등장합니다.
오늘 우리가 쓸 두 가지 모델 스타터를 추가해보겠습니다.
dependencies {
// ... 기존 의존성들 ...
// Spring AI 모델 스타터 — 추가!
implementation 'org.springframework.ai:spring-ai-starter-model-ollama' // 로컬 Ollama (무료)
implementation 'org.springframework.ai:spring-ai-starter-model-openai' // OpenAI 호환 (Gemini도 이 스타터로 호출)
// ... 나머지 의존성들 ...
}
추가하고 나면 IntelliJ 우측 상단에 작은 코끼리 아이콘이 깜빡일 거예요(Gradle 다시 로드 알림). 클릭해서 동기화시켜주세요. 30~60초 정도 기다리면 의존성이 다 받아집니다.
🙋 학생 질문 — "튜터님, 왜 Gemini를 위한 스타터가 따로 없고 `spring-ai-starter-model-openai` 를 쓰나요?"
정말 핵심 질문입니다. 사실 Spring AI에는 Vertex AI Gemini용 스타터 (spring-ai-starter-model-vertex-ai-gemini) 가 별도로 있어요. 그런데 그건 Google Cloud(GCP) 프로젝트와 IAM 인증이 있어야 합니다. 학습용으로는 너무 무겁죠.
다행히 Google이 OpenAI API와 호환되는 엔드포인트(https://generativelanguage.googleapis.com/v1beta/openai)를 무료로 제공해줍니다.
이걸 사용하면 우리는 spring-ai-starter-model-openai 한 개로 OpenAI도, Gemini도, Groq도 다 호출할 수 있어요.
키와 base-url만 바꾸면 됩니다.
이게 바로 Step 1에서 강조했던 "프로바이더 추상화" 의 첫 번째 실제 사례예요.
Step 5에서 application.yml로 직접 보실 수 있어요.
4. Gradle 빌드로 확인 사살
의존성을 추가했으면 진짜로 잘 받아졌는지 확인해봐야겠죠. 터미널을 열고 프로젝트 루트에서 한 줄 입력합니다.
./gradlew build -x test
-x test 는 "테스트는 건너뛰고 빌드만 하자" 라는 옵션이에요. 처음 받는 의존성이 많아서 1~2분 걸릴 수 있습니다. 마지막에 BUILD SUCCESSFUL 이 보이면 성공입니다.
만약 빌드가 실패한다면 가장 흔한 원인 두 가지부터 확인하세요.
| 에러 메시지 | 원인 | 조치 |
|---|---|---|
Could not resolve org.springframework.ai:... |
인터넷 / 회사 프록시 차단 | 네트워크·프록시 설정 확인 |
Unsupported class file major version XX |
자바 21이 아닌 다른 버전이 쓰임 | IntelliJ → Project Structure → SDK를 21로 |
대부분은 IntelliJ가 자바 17이나 다른 버전을 쓰고 있어서 발생합니다. Project Structure(Cmd + ; 또는 Ctrl + Alt + Shift + S) → Project SDK → JDK 21 로 맞춰주시면 99% 해결돼요.
💡 튜터의 핵심 포인트 — 의존성 추가는 "한 줄짜리 결정"이 아니다
자, 한 가지 짚고 갈게요. 우리가 방금 한 일은 표면적으로 두 줄을 추가한 것밖에 없어요.
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
그런데 이 두 줄 뒤에는 수십 개의 ChatModel, ChatClient, Advisor 자동 설정 빈이 우리 모르는 사이에 등록됩니다. 자동 설정의 위력이자, 동시에 블랙박스의 위험이기도 해요. 그래서 저는 학생 분들께 항상 이렇게 말씀드립니다.
"새 의존성을 추가하면 의존성 트리(
./gradlew dependencies)와 어떤 빈이 등록되는지(AutoConfiguration클래스)를 한 번은 들여다보세요."
오늘 안 봐도 큰 일은 없어요. 다만 운영 단계에서 "이 빈은 어디서 등록된 거지?" 라는 질문이 나왔을 때 자동 설정의 본문을 직접 읽을 수 있는 사람 이 트러블슈팅을 가장 빨리 합니다. 우리 마지막 Day 20에서 이 자동 설정 본문을 한 번 같이 까볼 거예요.
🙋 날카로운 질문 타임!
"튜터님, 이미 Spring Boot 3.5.14인데, 더 신선한 패치(3.5.15+)로 올려도 되나요?"
좋은 질문이에요. 결론부터 말씀드리면 본 강의 동안에는 3.5.14에 고정해주시고, 강의 끝난 뒤 회사 프로젝트로 옮기실 때 패치를 올리시면 됩니다.
이유는 두 가지예요.
- 수업 중 모든 학생의 환경을 동일하게 유지하기 위함입니다. "튜터님 저는 3.5.20으로 했는데 에러나요" 같은 분기가 생기면 진도가 흩어져요.
- Spring AI 1.1.0 BOM의 호환성 매트릭스가 명시적으로 지원하는 라인이 Spring Boot 3.4.x / 3.5.x예요. 3.5.14는 그 안의 검증된 패치 지점이고, 이론상 3.5.x 라인 안에서는 호환되지만 학습 환경에서 굳이 사이드 케이스를 자초할 이유는 없어요.
회사 프로젝트라면 항상 최신 패치 버전으로 올리시는 게 보안상 옳습니다. 학습 환경과 운영 환경의 정책이 다르다는 점만 기억하세요.
"튜터님, 한 프로젝트에 Ollama 스타터랑 OpenAI 스타터를 둘 다 추가하면, ChatModel 빈이 두 개 등록돼서 충돌나지 않나요?"
날카로운 질문입니다! 정확하게 보셨어요. 두 스타터를 동시에 추가하면 OllamaChatModel 과 OpenAiChatModel 빈이 둘 다 등록됩니다. 그 상태에서 @Autowired ChatModel chatModel 만 적으면 Spring이 "어느 걸 주입해야 할지 모르겠어요" 라는 에러를 뱉어요.
해결 방법은 두 가지예요.
spring.ai.model.chat=ollama같은 프로퍼티로 활성화 모델을 한 개만 명시하기 (Spring AI 1.0.0-M7부터 지원)@Qualifier("ollamaChatModel")으로 주입 시점에 명확히 지정하기
이 강의에서는 1번 방법을 기본으로 가져갑니다. 그래야 "한 줄 프로퍼티 변경으로 프로바이더 전환"이라는 우리의 핵심 메시지가 살아요. Step 5에서 application.yml을 직접 만들 때 이 부분 다시 짚을 거예요.
자, 이제 우리 프로젝트는 "Spring AI를 쓸 준비가 됐다" 는 신호가 박혔습니다.
그런데 한 가지 문제가 남았어요. 우리가 추가한 스타터 두 개 중 spring-ai-starter-model-ollama 는 단순히 의존성만 들어왔다고 동작하지 않아요. 여러분의 노트북 어딘가에 진짜로 Ollama 데몬이 떠있어야, 그리고 그 데몬에 모델이 다운로드돼있어야 우리 앱이 그 모델로 호출을 보낼 수 있어요.
다음 Step에서는 노트북에 Ollama를 직접 설치하고, 우리 실습용 모델을 pull 해서 진짜로 로컬에서 LLM이 돌아가는 환상의 순간을 함께 보겠습니다. 인터넷 끊고도 AI랑 대화할 수 있어요. 진짜로요.
Step 4: Ollama 로컬 설치 & 모델 풀(pull) — 내 노트북이 LLM 서버가 된다
자, 솔직히 한번 같이 떠올려봅시다. 지금까지 우리가 LLM을 어떻게 사용했죠?
"OpenAI나 Gemini 같은 외부 회사 서버에 HTTP 요청 보내고 → 응답 받기" 였어요. 그런데 가만 생각해보면 이게 좀 이상하지 않나요? 우리 노트북에 메모리 16GB, GPU도 어느 정도 있는데, 왜 항상 누군가의 클라우드를 빌려야 할까요?
오늘 이 의문을 풀어드립니다. 우리 노트북을 LLM 서버로 만드는 도구, 그게 바로 Ollama 입니다.
1. Ollama가 뭐예요? — 한 줄 요약
Ollama = "내 컴퓨터에서 LLM 모델을 데몬으로 띄우고, REST API로 호출할 수 있게 해주는 무료 오픈소스 도구"
비유하자면 "LLM계의 Docker" 라고 보시면 정확합니다. Docker가 컨테이너를 pull 받아서 로컬에서 띄우잖아요? Ollama도 똑같아요. 모델을 pull 받아서 로컬에서 띄우고, localhost:11434 라는 포트에 REST API를 열어줍니다.
| Docker | Ollama |
|---|---|
docker pull nginx |
ollama pull llama3.2 |
docker run nginx |
ollama run llama3.2 |
| Docker Hub에서 이미지 다운 | Ollama Library에서 모델 다운 |
| 컨테이너가 포트 노출 | 데몬이 11434 포트 노출 |
이제 왜 굳이 Ollama를 쓰는지 궁금하시죠? 핵심 이유 4가지로 정리해볼게요.
- 무료 — API 키 발급도, 결제도 필요 없습니다. CPU·GPU만 있으면 끝.
- 오프라인 OK — 한 번 모델만 받으면 인터넷 끊겨도 돕니다. 비행기 모드 LLM.
- 데이터 프라이버시 — 사내 민감 데이터를 외부 클라우드로 보내지 않아도 됩니다.
- 개발 단계 빠른 반복 — 토큰 비용 걱정 없이 마음껏 호출하면서 프롬프트를 다듬을 수 있어요.
그리고 가장 중요한 학습 측면 이유: "내 손으로 LLM의 본체를 만져본다" 는 경험이에요. OpenAI를 쓰면 LLM이 마법의 블랙박스로 느껴지지만, Ollama는 "내 노트북 메모리에 진짜로 모델이 올라가있구나" 라는 감각을 줍니다. 이 감각이 향후 19일 동안 굉장히 큰 자산이 돼요.
2. Ollama 설치하기 — OS별로 5분
설치는 정말 간단합니다. 본인 운영체제에 맞춰서 따라오세요.
macOS (Apple Silicon / Intel 모두)
# Homebrew 사용 (가장 깔끔)
brew install ollama
# 데몬 백그라운드 시작
brew services start ollama
Homebrew를 안 쓰신다면 ollama.com/download 에서 macOS용 .dmg 를 받아서 더블클릭 설치하셔도 됩니다.
Windows
ollama.com/download 에서 Windows 인스톨러를 받아 더블클릭 → 설치. 끝입니다. 설치 직후 자동으로 백그라운드 데몬이 떠요.
Linux (Ubuntu / Debian)
# 한 줄 설치 스크립트
curl -fsSL https://ollama.com/install.sh | sh
# 데몬은 systemd로 자동 등록됨
systemctl status ollama
설치가 끝나면 어떤 OS든 터미널에 ollama --version 을 입력해서 버전이 떠야 정상입니다.
$ ollama --version
ollama version is 0.x.x
3. 모델 풀(pull) — 우리가 쓸 모델 고르기
Ollama는 ollama.com/library 에서 다양한 모델을 제공합니다. 그런데 처음 시작하시는 분들은 "뭘 받아야 할지 모르겠다" 가 가장 큰 진입장벽이에요. 제가 학습용으로 추천하는 조합을 정리해드릴게요.
| 모델 | 크기 | 메모리 권장 | 용도 |
|---|---|---|---|
gemma3:1b |
~1GB | 4GB+ | 가장 가벼움. 저사양 노트북도 OK. 한국어 적당히 됨. |
gemma3:4b |
~3GB | 8GB+ | 균형형. 추천 기본값. Google의 최신 경량 모델로 응답 톤이 깔끔. 🌟 |
qwen3:4b |
~3GB | 8GB+ | 한국어 품질이 좋음. 중국 알리바바. |
llama3.2:3b |
~2GB | 8GB+ | Meta의 경량 모델. 영어 위주. |
오늘 우리는 gemma3:4b 한 개로 갑니다. 학습용으로 가장 무난하고 한국어 응답 톤이 깔끔해요. 더 가벼운 게 필요하시면 gemma3:1b 를, 한국어 응답 품질이 더 좋길 원하시면 qwen3:4b 를 받으셔도 됩니다.
# 우리가 쓸 모델 받기
ollama pull gemma3:4b
처음 받을 땐 인터넷 속도에 따라 5~15분 정도 걸립니다. 진행률 바가 100%까지 차오를 때까지 커피 한 잔 마시고 오세요.
다 받으면 어떤 모델이 깔려있는지 확인할 수 있어요.
$ ollama list
NAME ID SIZE MODIFIED
gemma3:4b c0494fe00251 3.3 GB 1 minute ago
🙋 학생 질문 — "튜터님, 노트북이 8GB 메모리인데 4b 모델 돌려도 되나요? 끊겨버리지 않을까요?"
충분히 돌아갑니다! 4b 모델은 양자화(quantization) 처리돼서 약 3GB 정도의 메모리를 차지해요.
운영체제 + IntelliJ + 브라우저 다 돌려도 8GB 안에서 빠듯하지만 가능합니다.
다만 응답 속도가 느릴 수는 있어요(토큰당 0.5~2초).
응답이 느릴 뿐 끊기지는 않으니 마음 놓고 시도하세요.
진짜로 부담스러우시면 gemma3:1b(메모리 1.5GB)로 가시면 됩니다.
4. 첫 대화 — ollama run 으로 즉석 채팅
자, 모델까지 받았으니 진짜로 대화해봅시다. 터미널에서 한 줄 입력하면 바로 채팅창이 열려요.
$ ollama run gemma3:4b
>>> 안녕! 너 누구야?
저는 Gemma 3, Google이 만든 경량 언어 모델입니다.
무엇을 도와드릴까요? 😊
>>> 한국 백엔드 개발자에게 추천할 만한 책 한 권 알려줘
훌륭한 책으로는 "Effective Java"가 있어요. ...
>>> /bye
>>> 프롬프트가 뜨면 그냥 한국어로 채팅하시면 됩니다. 채팅 종료는 /bye 입력 또는 Ctrl + D. 이 순간 우리 노트북은 인터넷 없이도 LLM과 대화할 수 있는 상태가 된 겁니다. 비행기 모드 켜고도 똑같이 됩니다.
이게 왜 중요하냐면, 우리가 다음 Step에서 Spring AI의 ChatClient로 호출할 때 "이 호출이 진짜로 내 노트북 안에서 일어나고 있다" 는 확신을 가질 수 있기 때문이에요. 외부 클라우드를 비난하지 않으면서도, 로컬에서 돌릴 수 있다는 옵션을 손에 쥐는 것 — 이게 엔지니어로서 진짜 큰 무기입니다.
5. 데몬 동작 검증 — 11434 포트 확인
Spring AI의 spring-ai-starter-model-ollama 는 기본적으로 http://localhost:11434 로 호출을 보냅니다. 그래서 우리 앱이 동작하려면 그 포트에 Ollama 데몬이 살아있어야 해요. 정말 간단히 확인할 수 있습니다.
# 데몬이 응답하는지 확인 (REST API)
$ curl http://localhost:11434/api/tags
{"models":[{"name":"gemma3:4b","model":"gemma3:4b",...}]}
JSON으로 모델 목록이 응답되면 데몬이 정상 동작 중 이라는 신호예요. 만약 Connection refused 가 나면 데몬이 안 떠있는 거니까 OS별로 시작해주세요.
| OS | 데몬 시작 명령 |
|---|---|
| macOS (Homebrew) | brew services start ollama |
| Windows | 시스템 트레이 라마 아이콘 우클릭 → Start (보통 자동 시작) |
| Linux | sudo systemctl start ollama |
🙋 학생 질문 — "튜터님, 11434는 어디서 나온 숫자예요?"
Ollama가 기본으로 사용하는 포트입니다. 변경하고 싶다면 환경변수 OLLAMA_HOST=0.0.0.0:11500 같은 식으로 바꿀 수 있어요. 다만 우리는 디폴트 그대로 갑니다. 기본값을 함부로 바꾸면 Spring AI의 자동 설정도 같이 바꿔야 하니까요. "기본값을 존중하라" — 저의 오랜 신조입니다.
6. ⚠️ 잠깐, 우리는 곧 도커로 앱을 띄울 거예요 — 호스트 ↔ 컨테이너 브리지 한 번만 짚기
자, 여기서 한 가지 미리 짚고 갈 게 있어요. 우리 ai-friends 프로젝트는 Step 6 이후 모든 실행을 ./run.sh(= docker compose up)로 통일 합니다. Spring 앱은 도커 컨테이너 안에서 돌아가요. 그런데 Ollama 데몬은 방금 우리가 호스트(노트북)에 설치 했죠?
이게 왜 중요하냐면, 컨테이너 안에서 본 localhost는 호스트의 localhost가 아니에요. 컨테이너 안의 localhost:11434 는 "컨테이너 자기 자신의 11434" 를 가리키기 때문에 호스트 Ollama로 못 갑니다.
| 어디서 호출? | localhost:11434 의 의미 |
|---|---|
| 호스트(IDE) 실행 시 | 호스트의 Ollama ✅ |
| 도커 컨테이너 안에서 실행 시 | 컨테이너 자기 자신 → Ollama 없음 ❌ |
해결책: 도커 컴포즈에서 컨테이너에 host.docker.internal 이라는 호스트명을 붙여두면, 그 이름이 호스트의 IP를 가리키게 됩니다. 우리 docker-compose.yml 에는 이미 다음이 들어있어요.
# docker-compose.yml — app 서비스 일부 (이미 적용돼있음)
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
그리고 Step 5에서 우리가 추가할 application.yml 의 spring.ai.ollama.base-url 값은 ${OLLAMA_BASE_URL:http://localhost:11434} 형태로 적습니다. 그러면 다음과 같이 동작해요.
- IDE 로컬 실행 →
OLLAMA_BASE_URL환경변수가 없음 → 기본값http://localhost:11434사용 → 호스트 Ollama 호출 ✅ ./run.sh도커 실행 →OLLAMA_BASE_URL=http://host.docker.internal:11434주입됨 → 컨테이너에서 호스트 Ollama 호출 ✅
한 줄 요약: Ollama는 호스트에 그대로 두고, 컨테이너의 Spring 앱이 다리(host.docker.internal)를 건너 호스트 Ollama로 호출 합니다. Mac과 Win은 도커 데스크톱이 자동으로 host.docker.internal 을 지원하고, Linux는 extra_hosts: host-gateway 설정으로 호환성을 맞춰뒀어요.
🙋 학생 질문 — "튜터님, 그럼 Ollama도 그냥 컨테이너로 띄우면 안 돼요? 한 방에 깔끔하잖아요."
일리 있는 질문이에요. 답은 "가능하지만 학습용으로는 비추" 입니다. 이유는 두 가지예요.
- GPU 가속 손실 — Mac의 Metal, Windows의 CUDA 가속은 호스트 설치본이 가장 잘 먹습니다. 컨테이너로 들어가면 GPU 패스스루 설정이 OS별로 다 달라서 학습 시간을 까먹어요.
- 모델 캐시 분리 — 호스트에 깔면 Cursor, Raycast, ChatGPT 데스크톱 같은 다른 도구들이 같은 Ollama 모델 캐시를 공유합니다. 컨테이너 안에 가두면 매번 따로 받아야 해요.
그래서 우리 강의는 "Ollama는 호스트, 우리 앱은 컨테이너" 의 하이브리드 패턴으로 갑니다. 실무에서도 흔한 구성이에요.
💡 튜터의 핵심 포인트 — 로컬 LLM은 학습뿐 아니라 실무 옵션이기도 합니다
오늘 우리는 학습용으로 Ollama를 깔았지만, 사실 실무에서도 로컬 LLM은 굉장히 중요한 옵션이에요.
- 금융·의료·국방 처럼 데이터가 외부로 못 나가는 도메인은 사내망 GPU 서버에 Ollama 또는 vLLM을 띄우고 거기로만 호출합니다.
- 개발 단계의 비용 절감 — 통합 테스트 시나리오를 매번 OpenAI로 돌리면 지갑이 거덜나요. 로컬 모델로 갈음하다가 검수 단계에서만 클라우드로 바꿉니다.
- Latency가 중요한 사이드 기능 — 자동 완성, 의도 분류 같은 가벼운 NLP는 로컬 모델로 응답 속도를 끌어올릴 수 있어요.
오늘 우리가 익힌 Ollama 한 줄짜리 명령 이, 회사로 돌아가셨을 때 그대로 무기가 됩니다. "튜터님, 그거 학습용이라면서요" 라고 무시하지 마시고요.
🙋 날카로운 질문 타임!
"튜터님, 굳이 Ollama 깔아야 하나요? Gemini 무료 티어만 써도 되지 않나요?"
좋은 질문입니다. 사실 학습 진도만 따지면 Gemini 무료 티어 하나만 있어도 20일 강의를 다 따라갈 수 있어요. 하지만 저는 두 가지를 함께 깔게 한 두 가지 이유가 있어요.
- 프로바이더 추상화의 실감 — 같은 코드가 Ollama와 Gemini 양쪽에서 똑같이 도는 걸 직접 보셔야 "아, Spring AI가 진짜로 추상화를 한다"는 실감이 옵니다. 입으로만 들은 추상화는 배운 게 아니에요.
- Gemini 무료 티어 Rate Limit — Google AI Studio 무료 티어는 분당 호출 횟수 제한이 있어요(2026-05 기준 모델별 RPM 15~30, 시기에 따라 변동). 강의 중 토론·실험·디버깅하다 보면 금방 소진됩니다. 그럴 때 Ollama가 안전망이 돼줘요.
그리고 실무에서도 로컬 + 클라우드 멀티 프로바이더 가 표준 패턴이에요. 미리 익혀두시는 게 좋습니다.
"튜터님, GPU 없는 노트북에서도 Ollama가 돌긴 도나요?"
네, CPU만으로도 돕니다. 다만 응답 속도가 GPU 대비 5~10배 느려요. 가벼운 모델(gemma3:1b)로 시작하시고, "응답이 한 단어씩 똑딱 떨어진다" 정도의 속도면 정상입니다. Apple Silicon(M1/M2/M3) 맥은 통합 GPU를 쓰기 때문에 의외로 빠르고요.
만약 정말 너무 느려서 학습이 어렵다면, 그 Step에서는 Gemini로 갈아타셔도 됩니다. 우리 강의의 핵심은 "어느 모델을 쓰느냐"가 아니라 "어떤 어휘로 LLM을 다루느냐" 거든요. 모델은 어디까지나 부품이에요.
자, 이제 우리 노트북에 로컬 LLM 한 마리가 자리 잡았습니다.
다음 Step에서는 두 번째 옵션, Google AI Studio에서 Gemini 무료 티어 키를 발급받고, application.yml 의 프로파일 분리를 통해 우리 Spring 앱이 양쪽 모델을 모두 바라볼 수 있게 만들어보겠습니다. "한 줄 프로파일 변경으로 프로바이더 전환" 의 그 한 줄을 직접 만져보는 시간이에요.
Step 5: Gemini 무료 티어 키 발급 + `application.yml` 프로파일로 프로바이더 전환
자, 이제 두 번째 옵션 차례입니다. Ollama로 로컬을 쥐었으니, 클라우드 쪽도 한 손에 잡아야죠. 우리가 쓸 클라우드 모델은 Gemini 2.5 Flash Lite — Google이 무료 티어로 풀어놓은 가벼운 모델입니다.
이번 Step에서 우리가 할 일은 딱 세 가지예요.
- Google AI Studio에서 Gemini API 키를 무료로 발급받는다.
application.yml에 Spring AI 섹션을 추가한다.ollama/gemini두 개의 프로파일을 만들어서 한 줄 프로파일 변경으로 모델을 갈아탈 수 있게 한다.
1. Google AI Studio에서 Gemini 키 발급 — 1분 컷
자, 브라우저를 켭니다. 아래 주소로 이동하세요.
처음 들어가시면 Google 계정 로그인부터 묻습니다. 본인 계정으로 로그인하시면 돼요. 로그인 후엔 화면 가운데 "Create API key" 버튼이 보일 거예요.
- "Create API key" 클릭
- 프로젝트 선택 화면이 뜨면 → 새 프로젝트 자동 생성 옵션 선택 (또는 기존 프로젝트 사용)
- 키가 생성되면
AIzaSy...로 시작하는 긴 문자열이 표시됩니다. - 그걸 반드시 복사해서 안전한 곳에 임시로 보관하세요. (이 키는 한 번만 보여주고 다시는 안 보여줍니다)
키 발급은 진짜 1분이면 끝나요. 그리고 무료 티어는 신용카드 등록도 필요 없습니다.
🙋 학생 질문 — "튜터님, 무료 티어인데 진짜로 비용이 안 나가나요? 어디까지 무료예요?"
좋은 질문입니다. 2026-05 기준 Gemini 2.5 Flash Lite의 무료 티어 한도는 다음과 같아요(Google이 자주 조정하니 마지막 한 줄은 항상 발급 페이지에서 재확인하세요).
- 분당 요청 수(RPM): 약 15~30회 (모델별·시기별 상이)
- 일일 요청 수(RPD): 약 1,000회
- 컨텍스트 윈도우: 100만 토큰
학습용으로는 차고 넘칩니다.
다만 분당 한도는 의외로 빨리 소진될 수 있어요 — 학생들이 동시에 디버깅 들어가면 429(Too Many Requests) 에러가 떨어집니다.
그래서 우리가 Step 4에서 Ollama를 같이 깔아둔 거예요.
"Gemini가 막히면 Ollama로 갈아탄다" 는 안전망이 작동합니다.
정확한 최신 한도는 발급 페이지에서 확인하세요.
2. .env 파일에 키 채우기 — 코드에 박지 마세요
발급받은 키를 어디에 두느냐. 절대 자바 코드에 직접 박지 않습니다. 우리는 Step 6에서 보안을 본격적으로 다룰 거지만, 여기서 미리 그 원칙 한 줄만 적용해둡니다.
프로젝트 루트의 .env.example 파일을 한 번 펼쳐보세요. 이렇게 생겼어요.
# .env.example (이미 존재하는 파일 — 발췌)
# Google Gemini API (필수, AI 채팅용)
# https://aistudio.google.com/app/apikey
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash-lite
# 로컬(IDE) 실행용 DB URL (H2 인메모리 기본값)
DB_URL=jdbc:h2:mem:aifriends;DB_CLOSE_DELAY=-1
# docker-compose 용 MySQL 설정
MYSQL_ROOT_PASSWORD=root1234
...
이 .env.example 을 그대로 .env 라는 이름으로 복사한 뒤, GEMINI_API_KEY 값을 채웁니다.
# 프로젝트 루트에서 실행
cp .env.example .env
# 그리고 IDE에서 .env 파일을 열어 GEMINI_API_KEY 줄을 다음처럼 채움
# GEMINI_API_KEY=AIzaSy당신의실제키여기
이 .env 파일은 .gitignore 에 이미 등록돼있어서(Step 6에서 직접 확인합니다) Git에 올라갈 일이 없어요.
그리고 Spring 부팅 시 DotenvInitializer 라는 친구가 .env를 읽어서 자동으로 환경변수처럼 주입해줍니다.
그래서 application.yml에서 ${GEMINI_API_KEY} 라고 쓰면 그 값이 알아서 채워져요.
💡
DotenvInitializer가 뭐예요? Spring 부팅 직전에.env파일을 읽어 Spring Environment에 등록해주는 작은 초기화 클래스입니다. 이미AiFriendsApplication의main()에 등록돼있어요. 우리가 손댈 필요는 없고, "있다는 사실만" 알아두시면 됩니다. Step 6에서 그 동작 원리를 자세히 볼 거예요.
3. application.yml 에 Spring AI 섹션 추가
자, 이제 진짜 본 작업입니다. src/main/resources/application.yml 을 펼치세요. 현재 파일은 spring, server, gemini (구 RestClient용) 세 섹션으로 구성돼있어요.
여기에 spring.ai 섹션을 추가합니다. 위치는 server: 블록 다음, gemini: 블록 위 어디든 OK예요. 깔끔하게 가도록 합시다.
# src/main/resources/application.yml (최상단 공통 섹션에 추가)
# ─────────────────────────────────────────────
# Spring AI — 두 프로바이더 모두 등록해두고
# `ollama` / `gemini` 프로파일로 활성화 모델을 선택한다.
# ─────────────────────────────────────────────
spring:
ai:
# ⓐ 활성 모델은 프로파일별로 덮어쓴다 (default: none)
model:
chat: none
# ⓑ Ollama (로컬) 설정 — Step 4에서 띄운 데몬을 바라본다
# base-url 은 환경변수로 덮어쓸 수 있게 둔다.
# - IDE 로컬 실행: 환경변수 없음 → http://localhost:11434 (호스트 Ollama)
# - ./run.sh 도커 실행: OLLAMA_BASE_URL=http://host.docker.internal:11434 주입됨
ollama:
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
chat:
options:
model: gemma3:4b
# ⓒ OpenAI 호환 엔드포인트 (Gemini를 OpenAI 스타터로 호출)
#
# ⚠️ completions-path 를 왜 따로 박았을까?
# Spring AI 의 기본값은 "/v1/chat/completions" (실제 OpenAI 기준).
# 그런데 Gemini 의 OpenAI 호환 경로는 "/v1beta/openai/chat/completions" 라
# base-url 끝에 "/openai" 가 있는 상태에서 기본 path 가 붙으면
# 최종 URL 이 "/v1beta/openai/v1/chat/completions" 가 되어 404 가 난다.
# → "/chat/completions" 로 오버라이드해서 이중 /v1 중복을 피한다.
openai:
api-key: ${GEMINI_API_KEY:}
base-url: https://generativelanguage.googleapis.com/v1beta/openai
chat:
completions-path: /chat/completions
options:
model: ${GEMINI_MODEL:gemini-2.5-flash-lite}
💡 참고: 실제
day01-setup브랜치를 펼치면model:아래에 키가 더 있어요. Day 1 은chat만 다루지만, 강의용으로 박아둔application.yml에는embedding,image,moderation,audio.speech,audio.transcription까지 모두none으로 함께 적혀 있어요. OpenAI 스타터가 끌어오는 자동설정들이 키 없이도 부팅이 깨지지 않도록 보호하기 위함입니다. 각 모달리티는 Day 7(이미지 생성), Day 8(Vision), Day 9(음성) 에서 차례로 켜집니다 — 그때 하나씩none→openai/ollama/elevenlabs로 풀어가는 흐름을 직접 만나게 돼요.
세 부분의 역할을 한 줄씩 설명할게요.
| 표시 | 키 | 의미 |
|---|---|---|
| ⓐ | spring.ai.model.chat |
활성 모델 스위치. none/ollama/openai 중 하나. |
| ⓑ | spring.ai.ollama.* |
Ollama 데몬 위치와 사용할 모델 이름 |
| ⓒ | spring.ai.openai.* |
OpenAI 호환 엔드포인트 + Gemini API 키 + 모델명 |
⚠️ 중요: ⓒ에서 api-key 를 ${GEMINI_API_KEY:} 처럼 적었어요. 콜론 뒤가 비어있는 건 "환경변수가 없으면 빈 문자열을 기본값으로" 라는 의미입니다. 이렇게 해야 .env에 키를 안 채워도 앱 자체는 부팅되고, Ollama 프로파일로는 정상 동작해요.
🙋 학생 질문 — "튜터님, `completions-path` 를 왜 굳이 손으로 써줘야 하나요? OpenAI 스타터 기본값 쓰면 안 되나요?"
이거 많은 학생이 한 번은 밟는 함정이에요. 결론부터 말씀드리면 "OpenAI 호환" 이라고 해도 프로바이더마다 URL 규칙이 조금씩 달라서, 기본값을 그대로 쓰면 404 가 납니다.
Spring AI 의 기본 completions-path 는 /v1/chat/completions — 실제 api.openai.com 기준이에요.
그런데 Gemini 의 엔드포인트는 /v1beta/openai/chat/completions, Groq 은 /openai/v1/chat/completions 처럼 각자 고유 prefix 를 포함 합니다.
우리는 그 prefix 를 base-url 에 포함시키는 컨벤션으로 통일했으니, completions-path 는 /chat/completions 로 짧게 덮어써야 base-url + path 가 올바른 URL 을 만듭니다.
"OpenAI 호환 = URL 규칙까지 동일" 이 아니라, OpenAI 호환은 JSON 스키마·인증 방식이 동일할 뿐 이에요.
이 사실 하나 기억해두시면 나중에 세 번째, 네 번째 프로바이더 붙일 때도 안 막힙니다.
4. 두 개의 프로파일 만들기 — ollama / gemini
이제 진짜 중요한 부분이에요. application.yml은 --- 으로 여러 프로파일 섹션을 한 파일에 둘 수 있어요. 현재 파일에 이미 local / docker 두 DB 프로파일이 있죠? 그 아래에 ollama / gemini 두 AI 프로파일을 추가합니다.
# application.yml (파일 맨 아래에 추가)
---
# =========================================================
# ollama 프로파일: 로컬 Ollama로 호출
# 사용 예 (도커): .env 에 SPRING_PROFILES_ACTIVE=docker,ollama
# 사용 예 (IDE) : SPRING_PROFILES_ACTIVE=local,ollama
# =========================================================
spring:
config:
activate:
on-profile: ollama
ai:
model:
chat: ollama
---
# =========================================================
# gemini 프로파일: OpenAI 호환 엔드포인트로 Gemini 호출
# 사용 예 (도커): .env 에 SPRING_PROFILES_ACTIVE=docker,gemini
# 사용 예 (IDE) : SPRING_PROFILES_ACTIVE=local,gemini
# =========================================================
spring:
config:
activate:
on-profile: gemini
ai:
model:
chat: openai
자, 가만 보세요. 각 프로파일이 하는 일이 단 한 줄이에요. spring.ai.model.chat 값을 ollama 또는 openai 로 덮어쓰는 것뿐입니다. 이게 바로 Spring AI 1.1.x가 가져온 깔끔함이에요.
ollama활성화 →ChatModel빈으로OllamaChatModel만 등록 →${OLLAMA_BASE_URL}호출 (도커는host.docker.internal:11434, IDE 로컬은localhost:11434)gemini활성화 →ChatModel빈으로OpenAiChatModel만 등록 → Gemini OpenAI 호환 엔드포인트 호출
비즈니스 코드는 그냥 ChatModel chatModel 한 개만 주입받으면 되고, 어떤 모델인지는 신경 쓰지 않아도 돼요. 이게 Step 1 추상화 레이어 이야기의 실체입니다.
5. 프로파일 활성화 — .env 한 줄 바꾸고 ./run.sh 재기동
이제 마지막 단계, 이 프로파일을 어떻게 켜느냐 입니다.
우리 강의의 표준은 .env 파일에 활성 프로파일을 적고 → ./run.sh 로 도커 컴포즈를 띄우는 것 입니다. IDE Run/gradlew bootRun 은 "도커 없이 빨리 한 번 돌려보고 싶을 때" 의 보조 수단이에요(Step 6 마지막에 참고로만 잠깐 언급). 모든 Day의 표준 실행 경로는 도커입니다.
방법 A. .env + ./run.sh (강의 표준 🌟)
.env.example 을 펼쳐보면 이미 다음 줄이 있어요(Step 6에서 직접 만질 거예요).
# .env (프로젝트 루트)
SPRING_PROFILES_ACTIVE=docker,gemini # 기본값
GEMINI_API_KEY=AIzaSy당신의실제키여기
여기서 docker 는 MySQL 접속용 DB 프로파일 이고(Step 6에서 자세히), gemini 는 방금 우리가 만든 AI 프로파일 입니다. Ollama 로 바꾸고 싶으면 단 한 줄을 이렇게 고치세요.
SPRING_PROFILES_ACTIVE=docker,ollama
그리고 프로젝트 루트에서 한 줄.
./run.sh up
이 스크립트가 도커 이미지를 빌드하고, MySQL 헬스체크를 기다린 뒤, 우리 앱을 8080 포트에 띄우고, 마지막에 로그를 따라 보여줍니다. 부팅 로그에 다음 비슷한 줄이 보이면 성공이에요.
The following 2 profiles are active: "docker", "ollama"
...
Started AiFriendsApplication in 5.123 seconds
프로파일을 바꾸고 싶을 땐 다시 .env 한 줄 → ./run.sh up (또는 ./run.sh down && ./run.sh up). 편집할 곳이 한 군데, 실행할 명령이 한 줄 — 이게 우리가 20일 동안 매일 쓸 표준 흐름입니다.
# 자주 쓰는 ./run.sh 서브커맨드
./run.sh up # 빌드 + 백그라운드 기동 + 로그 팔로우
./run.sh down # 컨테이너 정지 + 제거
./run.sh logs # 앱 로그만 따로 보기
./run.sh clean # 컨테이너 + 볼륨까지 싹 정리 (DB 초기화)
방법 B. (참고) IntelliJ Run Configuration / ./gradlew bootRun — 도커 없이 빨리 돌려볼 때
도커 없이 IDE만으로 빠르게 한 번 띄워보고 싶을 때는 다음을 쓸 수 있어요. 단, 우리 강의의 표준 실행 경로는 아닙니다. 검수·디버깅용 보조 수단입니다.
- IntelliJ Run Configuration → Environment variables →
SPRING_PROFILES_ACTIVE=local,ollama(DB는 H2 인메모리로 떨어집니다) - 또는 CLI:
./gradlew bootRun --args='--spring.profiles.active=local,ollama'
이 경로는 MySQL 도커가 안 떠있어도 동작하도록 local 프로파일이 H2를 잡아주게 돼있어요(현재 application.yml 에 그렇게 되어 있습니다).
다만 도커로 띄울 때와 환경이 미묘하게 달라서 (localhost:11434 vs host.docker.internal:11434, MySQL vs H2) 재현성 측면에서 도커가 항상 정답 입니다.
⚠️ 핵심 규칙: 과제 제출, 동료 코드 리뷰, 강사 시연 — 모든 공식 검수는
./run.sh기준입니다. IDE 단독 실행에서만 동작하는 코드는 인정되지 않아요. (호스트의 Ollama는 어느 쪽이든 켜져 있어야 합니다)
💡 튜터의 핵심 포인트 — 프로파일 분리는 "이름의 명확성" 게임이다
자, 한 가지 강조할 게 있어요. 우리가 만든 두 프로파일 이름이 ollama 와 gemini 죠? 그런데 사실 gemini 프로파일은 내부적으로 spring.ai.model.chat=openai 를 활성화합니다. 이름과 실체가 다르게 보일 수 있어요.
저는 일부러 gemini 라는 이름을 골랐습니다.
왜냐하면 개발자가 신경 쓸 단위는 "내가 어떤 모델을 호출하는가" 이지, "내부적으로 어떤 스타터를 쓰는가" 가 아니거든요.
만약 나중에 진짜 OpenAI(GPT-4o)로도 호출하고 싶어지면, openai 라는 세 번째 프로파일 을 만들어서 base-url만 OpenAI 공식 URL로 바꿔주면 됩니다.
그때도 이름은 의미 단위로 가져가는 게 맞아요.
프로파일 이름 = "그 환경에서 사용자가 받을 경험의 이름" 으로 짓는 게 좋습니다. "ollama / gemini / openai / claude" 처럼요. "starter1 / starter2" 같은 기술 단위 이름은 나중에 누가 봐도 헷갈려요.
이건 LLM뿐 아니라 모든 프로파일 설계에 적용되는 원칙입니다.
🙋 날카로운 질문 타임!
"튜터님, 두 프로파일을 동시에 활성화하면(local,ollama,gemini) 어떻게 되나요?"
날카로운 질문이에요. 결과는 "마지막에 적용되는 프로파일이 이긴다" 입니다. local,ollama,gemini 라고 적으면 gemini 가 나중에 적용되니까 spring.ai.model.chat=openai 가 최종값이 돼요. 즉 Gemini로 호출하게 됩니다.
다만 이건 행복한 실수일 뿐, 실무에서는 절대 두 AI 프로파일을 함께 활성화하지 마세요. 누군가 reorder만 살짝 해도 동작이 바뀌는 코드는 시한폭탄이에요. 한 번에 하나의 AI 프로파일만 켜는 걸 컨벤션으로 가져가세요.
"튜터님, 프로파일 말고 그냥 if-else로 자바 코드에서 분기하면 안 되나요?"
기술적으론 가능하지만 강력 비추천이에요. 이유 두 가지.
- Spring AI 자동 설정의 빈 등록 자체가 프로파일·프로퍼티 기반입니다. 자바 if-else로 분기해도 양쪽 빈이 다 등록돼버려서 충돌이 나요.
- 운영 환경 분리가 자바 코드로 들어가면 같은 jar를 다른 환경에서 못 굴립니다. 프로파일 분리는 "코드는 한 벌, 설정은 환경별" 이라는 12-Factor App 원칙의 핵심이에요.
그러니까 "어떤 모델을 쓸지"는 무조건 설정으로 결정합니다. 자바 코드는 모델이 누군지 모르고 살아요. 그게 깨끗한 분리예요.
자, 이제 우리 앱은 두 모델을 모두 바라볼 준비가 됐어요.
그런데 마음이 조금 불편하실 거예요. 우리가 .env 파일에 발급받은 Gemini 키를 그냥 적어놨는데, 이게 진짜 Git에 안 올라갈까요? 동료가 실수로 커밋했다면? AWS S3 버킷이 한밤중에 털렸다는 그 사고 기사처럼 우리도 당하는 거 아닐까요?
다음 Step에서는 잠깐 코드를 멈추고 API 키 보안의 기본기 — .env · .gitignore · 환경변수 로딩 패턴 을 정리하고 갑니다. 한 번 사고 나면 회복이 안 되는 영역이라, 처음부터 단단히 잡고 가야 해요.
Step 6: API 키 보안 — `.env` · `.gitignore` · 환경변수 로딩 패턴
자, 잠깐 코드 흐름을 멈추고 굉장히 중요한 이야기 한 번 하고 갈게요. 실제로 있었던 일 하나로 시작합니다.
한 개발자가 주말에 사이드 프로젝트를 GitHub에 올렸어요. AWS S3 키를
application.yml에 그대로 적은 채로요. 5분 뒤, 누군가가 그 키로 비트코인 채굴 EC2 인스턴스 50대를 띄웠습니다. 월요일 아침에 받은 청구서: $23,000. 진짜 있었던 일이에요.
웃고 넘어가시겠지만, 이런 사고가 1년에 수만 건씩 발생합니다. GitHub에 키가 올라가면 봇들이 평균 30초~5분 안에 스캔합니다. 여러분의 토이 프로젝트도 예외가 아니에요.
오늘 우리가 발급받은 Gemini 키도 마찬가지입니다. 다행히 무료 티어라 청구 폭탄은 없겠지만, 누군가 우리 키로 유해 콘텐츠 생성을 자동화한 뒤 신고당하면? 우리 Google 계정이 정지될 수 있어요. 무료 티어 키도 키입니다. 노출되면 안 됩니다.
1. 첫 번째 방어선: .gitignore 점검 — Git에 안 올린다
가장 단순하고 가장 강력한 방어는 "커밋되지 않게 하는 것" 이에요. 우리 프로젝트 루트의 .gitignore 를 펼쳐봅시다.
# .gitignore (실제 파일에서 발췌)
.gradle
build/
...
### Local env (secrets) ###
.env
.DS_Store
### Local env (secrets) ### 섹션에 .env 가 정확히 박혀있죠? 이게 첫 번째 방어선입니다. 이 한 줄이 있는 한 .env 파일은 git add 자체가 안 됩니다. Git이 무시해버려요.
확인하려면 터미널에서:
$ git status
On branch day01-setup
nothing to commit, working tree clean
# .env 파일을 강제로 추가 시도해보면?
$ git add .env
The following paths are ignored by one of your .gitignore files:
.env
"ignored by .gitignore" 메시지가 뜨면 안전합니다. 만약 이 메시지가 안 뜨고 그냥 추가되면? .gitignore 가 적용 전에 이미 한 번 추적된 파일이라는 뜻이에요. 그땐 다음 명령으로 추적 해제하세요.
git rm --cached .env
git commit -m "chore: stop tracking .env"
🙋 학생 질문 — "튜터님, .gitignore 파일 자체는 commit 해도 되나요?"
네, .gitignore 자체는 반드시 commit합니다. 팀원이 우리 저장소를 clone 받았을 때도 같은 보호를 받아야 하니까요. "무엇을 무시할지" 가 아니라 "무엇이 들어있는지" 가 비밀이에요. .gitignore는 규칙 파일이지 비밀이 아닙니다.
2. 두 번째 방어선: .env.example 패턴 — 템플릿만 커밋
그런데 한 가지 문제가 있어요. .env 를 .gitignore 했더니 새로 합류한 동료가 "어떤 환경변수를 채워야 하는지" 모르는 상황이 됩니다. "GEMINI_API_KEY를 넣어야 한다"는 것조차 모르면 앱이 안 떠요.
해결책은 .env.example 파일이에요. 이건 키는 비워두고 변수 이름만 적은 템플릿입니다. 이 파일은 commit합니다.
# .env.example (이미 존재하는 파일 - 발췌)
# .env.example — 복사해서 .env 로 저장한 뒤 값을 채워 주세요.
# .env 파일은 .gitignore 되어 있어 커밋되지 않습니다.
# 활성 Spring 프로파일 (도커 실행 시 적용)
SPRING_PROFILES_ACTIVE=docker,gemini
# Google Gemini API (필수)
# https://aistudio.google.com/app/apikey
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash-lite
# Ollama base-url (도커 컨테이너 → 호스트 Ollama 다리)
OLLAMA_BASE_URL=http://host.docker.internal:11434
# 로컬(IDE) 실행용 DB URL
DB_URL=jdbc:h2:mem:aifriends;DB_CLOSE_DELAY=-1
값이 있을 자리는 비워두거나(GEMINI_API_KEY=), 안전한 디폴트로 채워뒀어요(GEMINI_MODEL=gemini-2.5-flash-lite, DB_URL=jdbc:h2:...). 이게 핵심 패턴입니다.
| 파일 | Git 추적 | 내용 |
|---|---|---|
.env.example |
✅ 커밋 | 변수 이름 + 안전한 디폴트 + 발급 가이드 주석 |
.env |
❌ 무시 | 실제 키 값 |
새 동료가 합류하면 cp .env.example .env 한 줄이면 끝. 이게 "비밀은 보호하면서 협업은 막지 않는" 공식입니다.
3. 세 번째 방어선: .env 가 어떻게 Spring에 전달될까 — DotenvInitializer 해부
자, 이제 진짜 흥미로운 부분이에요. .env 파일에 적힌 값이 어떻게 Spring application.yml의 ${GEMINI_API_KEY} 자리에 채워질까요? 마법처럼 알아서 되는 게 아닙니다. 누군가가 그 다리를 놓아줘야 해요.
그 다리가 바로 DotenvInitializer 입니다. src/main/java/kr/spartaclub/aifriends/config/DotenvInitializer.java 를 같이 펼쳐봅시다.
// DotenvInitializer.java (실제 코드)
@Slf4j
public class DotenvInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
try {
// .env 파일 로드 (파일이 없으면 무시)
Dotenv dotenv = Dotenv.configure()
.ignoreIfMissing()
.load();
Map<String, Object> dotenvMap = new HashMap<>();
// .env 파일의 모든 key-value를 Map에 저장
dotenv.entries().forEach(entry -> dotenvMap.put(entry.getKey(), entry.getValue()));
// 스프링 Environment에 등록 (우선순위를 높게 설정)
ConfigurableEnvironment environment = applicationContext.getEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource("dotenv", dotenvMap));
} catch (Exception e) {
// .env 파일이 없거나 로드할 수 없는 경우 무시
log.info("INFO: .env file not found or could not be loaded. Using default configuration.");
}
}
}
복잡해 보이지만 흐름은 4단계예요.
Dotenv.configure().ignoreIfMissing().load()—.env파일을 읽음. 없으면 조용히 패스.dotenv.entries().forEach(...)— 파일 내용을Map<String, Object>로 옮김.environment.getPropertySources().addFirst(...)— Spring의 환경 우선순위 맨 앞에 그 Map을 꽂음.- 결과:
application.yml의${GEMINI_API_KEY}가 그 Map에서 값을 찾아서 채워짐.
그리고 이 친구를 누가 호출하느냐 — AiFriendsApplication 의 main() 입니다.
// AiFriendsApplication.java (실제 코드)
@SpringBootApplication
public class AiFriendsApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(AiFriendsApplication.class);
app.addInitializers(new DotenvInitializer()); // ← 여기!
app.run(args);
}
}
app.addInitializers(...) 한 줄이 핵심이에요. 이 한 줄 덕에 Spring이 부팅되기 직전에 .env 파일이 먼저 읽혀서 환경변수가 채워집니다.
💡 왜 굳이 직접 만들었을까요? Spring Boot는 OS 환경변수와 시스템 프로퍼티는 자동으로 읽어주지만,
.env파일은 표준 지원이 아니에요. (Node.js의dotenv라이브러리에서 빌려온 컨벤션입니다) 그래서io.github.cdimascio:dotenv-java같은 외부 라이브러리 +ApplicationContextInitializer한 개로 그 다리를 직접 놓아준 거예요. 한 번 만들어두면 모든 환경(IDE / Docker / CI)에서 통일된 방식으로 시크릿을 다룰 수 있어서 굉장히 깔끔합니다.
4. 네 번째 방어선: 사고 났을 때의 복구 절차 ⚠️
자, 솔직히 가장 중요한 이야기. 만약 실수로 키를 commit하고 push했다면? 침착하게, 그러나 빠르게.
Step 1. 키를 즉시 폐기하세요. 무조건 첫 번째.
Google AI Studio로 들어가서 해당 키를 Delete 합니다. "Git에서 지웠으니 됐겠지"는 함정 이에요. 이미 봇은 키를 가져갔습니다. 유효한 키를 무효화 하는 게 가장 먼저예요. 그리고 새 키를 발급받아 .env 에 다시 넣습니다.
Step 2. Git 히스토리에서 키를 진짜로 지우기
git rm 만으로는 부족해요. Git 히스토리에 commit되어 있으니까요. git filter-repo 나 BFG Repo-Cleaner 같은 도구를 써야 진짜로 사라집니다.
# BFG 사용 예시 (커밋 히스토리에서 .env 파일 자체를 모두 제거)
bfg --delete-files .env
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
--force push 가 들어가니까 팀에 사전 공지는 필수입니다. 그리고 모든 동료가 다시 clone을 받아야 해요.
Step 3. 팀 슬랙에 사고 보고
쉬쉬하지 마세요. 사고는 숨기는 게 가장 큰 사고입니다. "내가 실수해서 키 노출됐다, 폐기 완료, 새 키 발급 진행 중" — 이렇게 알리는 사람이 진짜 신뢰받습니다. 다음에 누가 같은 실수를 반복하지 않도록 하는 데도 도움이 돼요.
사고는 일어납니다. 책임감 있는 복구가 더 중요해요. 우리가 지금 깔아둔 안전망(.env + .gitignore + .env.example)은 그 사고 확률을 5%대로 떨어뜨려주는 장치예요. 0%는 아닙니다. 사람이라서요.
5. 운영 환경에서는? — .env 는 학습/개발용
마지막으로 한 가지만 더. .env 패턴은 "내 노트북에서 개발할 때"의 솔루션 입니다. 실제 서비스 운영 환경에서는 다른 도구를 씁니다.
| 환경 | 시크릿 보관 도구 |
|---|---|
| 로컬 개발 | .env (오늘 우리 방식) |
| Docker / docker-compose | .env 파일 자동 로드 또는 --env-file |
| AWS EC2 / ECS | AWS Secrets Manager, Parameter Store |
| Kubernetes | K8s Secret + ExternalSecrets |
| GitHub Actions / Jenkins | CI 시크릿 저장소 (Settings → Secrets) |
다 똑같은 사상이에요. "코드와 시크릿은 분리한다". 단지 환경마다 분리 도구가 다를 뿐입니다. 우리가 오늘 익힌 .env 패턴은 그 사상의 가장 단순한 입문이라고 생각하시면 돼요. 회사 갈 땐 같은 사상을 Secrets Manager로 옮겨 적용하시면 됩니다.
💡 튜터의 핵심 포인트 — "한 번 노출된 시크릿은 영원히 노출된 것"
오늘 가장 중요한 한 줄을 드릴게요.
"한 번이라도 git history에 들어간 시크릿은, 폐기 외에 안전한 방법이 없다."
git rm으로 지웠다고요? 히스토리에 남아있어요. private 저장소라고요? 누가 fork 받아갔는지 모릅니다. 5초 전이라고요? 봇은 1초면 가져갑니다. 무조건 폐기 → 새 키 발급이 정답이에요.
이걸 머릿속에 박아두면 평생 안전합니다.
🙋 날카로운 질문 타임!
"튜터님, application.yml에 직접 키 적으면 진짜로 안 되나요? 어차피 private repo인데요?"
저는 강력히 비추천합니다. 이유 세 가지.
- Private repo 가 영원히 private은 아니에요. 회사 정책 변경, 오픈소스화, 인수합병으로 갑자기 public이 될 수 있어요.
- fork 한 동료가 자기 GitHub 계정에 push하면 그 순간 public이 됩니다. 본인은 모르고요.
- Git history는 영원합니다. 1년 뒤 누가 옛날 commit을 들춰봐도 키가 그대로 보여요.
게다가 우리 강의는 처음부터 docker-compose 로 띄우고, Day 19(Rate Limiting · 비용 관리)·Day 20(배포 체크리스트) 에서 운영 환경 키 관리까지 들어갑니다. 그땐 어차피 외부 환경변수로 빼야 해요. 처음부터 분리된 형태로 익히는 게 손에 익는 데 훨씬 빠릅니다.
"튜터님, GitHub에 토이 프로젝트만 올리는데도 이렇게 신경써야 해요?"
오히려 토이 프로젝트가 더 위험합니다. 회사 코드는 보통 보안 검수를 거치고 코드 리뷰가 있어요. 토이 프로젝트는 혼자 신나게 push하다가 키가 슉 올라갑니다.
그리고 GitHub에는 시크릿 스캐닝 봇이 24시간 돌고 있어요.
AWS·Google·OpenAI·Stripe·SendGrid… 거의 모든 메이저 SaaS의 키 패턴을 알고 있습니다.
공개 push 30초 후면 봇이 와있다고 보면 됩니다. 토이라고 봐주지 않아요.
오히려 AWS·Google이 자동으로 키를 폐기시켜주는 사후처리도 토이 저장소가 더 자주 트리거됩니다.
작은 프로젝트일수록 보안 패턴은 처음부터 단단히 박아두세요. 회사 가서도 그게 그대로 자산이 됩니다.
자, 이제 안전망을 단단히 깔았어요. Gemini 키는 .env에 안전하게, .gitignore 는 그걸 막아주고, DotenvInitializer 는 그 값을 Spring에 잘 흘려주는 우리만의 보안 파이프라인이 완성됐습니다.
이제 모든 인프라가 갖춰졌습니다. 다음 Step은 진짜로 우리가 20일을 시작하는 그 한 줄, ChatClient 로 "Hello, AI" 호출입니다. 두 모델 모두에게 인사를 건네보고, 같은 코드가 두 프로바이더에서 어떻게 동작하는지 직접 눈으로 확인해보겠습니다. 드디어 약속한 그 순간이에요.
Step 7: 드디어 "Hello, AI!" — `ChatClient` 첫 호출
자, 여러분. 드디어 그 순간이 왔습니다.
지금까지 우리는 6시간(?) 동안의 사전 준비를 거쳤어요. 이론, 의존성, 로컬 LLM 설치, 클라우드 키 발급, 프로파일 분리, 보안 안전망까지. 이 모든 게 사실 다음 한 줄을 위해서였어요.
chatClient.prompt().user("Hello, AI!").call().content()
이 한 줄이 진짜로 우리 노트북에서 동작하는 순간 — 그게 오늘의 클라이맥스입니다. 그리고 같은 한 줄이 Ollama에서도, Gemini에서도 동작한다는 걸 직접 두 눈으로 확인해야 끝나요.
1. ChatClient 가 뭐예요? — ChatModel 과의 관계 정리
본격 코드로 들어가기 전에, ChatModel 과 ChatClient 의 관계를 깔끔하게 잡고 갑시다. Spring AI에는 두 개의 어휘가 동시에 있어요. 처음 보면 헷갈립니다.
| 이름 | 레이어 | 비유 |
|---|---|---|
ChatModel |
저수준 API. 프로바이더별 빈(OllamaChatModel, OpenAiChatModel 등)이 직접 등록됨. |
JDBC PreparedStatement |
ChatClient |
고수준 Fluent API. ChatModel을 한 번 더 감싸서 prompt().user().call() 같은 빌더 체인을 제공. |
Spring JdbcTemplate |
비유가 또 나오죠? 하지만 이게 정말 정확합니다. ChatModel은 "할 수 있다" 의 영역이고, ChatClient는 "쉽고 일관되게 한다" 의 영역이에요. 우리가 매일 쓸 친구는 ChatClient 입니다.
규칙: 비즈니스 코드에서는
ChatClient를 쓴다.ChatModel은 직접 만지지 않는다. 향후 Day 5에Advisor, Day 11에Tool Calling이 들어올 때 모두ChatClient빌더 체인 위에 얹힙니다. 처음부터 일관된 어휘로 가져가는 게 학습 효율이 훨씬 높아요.
2. ChatClient 빈 등록의 정석 — ChatClient.Builder 주입
이제 진짜 코드를 짭니다. 먼저 한 가지 컨벤션부터 안내드릴게요.
Spring AI 1.x에서는
ChatClient자체가 자동 등록되지 않습니다. 대신ChatClient.Builder가 자동 등록돼요.
왜 그럴까요? 사람마다·기능마다 ChatClient 를 다르게 구성하고 싶을 수 있기 때문이에요. 어떤 기능엔 시스템 프롬프트 A, 다른 기능엔 시스템 프롬프트 B를 기본값으로 박고 싶은 식이죠. 그래서 Spring AI는 "빌더만 줄게, 만들어 써라" 정책으로 갑니다. 굉장히 합리적이에요.
이걸 살리는 정석 패턴은 다음과 같아요. 실제로 우리가 만들 코드입니다.
// src/main/java/kr/spartaclub/aifriends/hello/HelloAiController.java
package kr.spartaclub.aifriends.hello;
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;
@RestController
public class HelloAiController {
private final ChatClient chatClient;
// ChatClient.Builder 를 주입받아 컨트롤러 단위로 ChatClient 를 빌드한다.
public HelloAiController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/api/hello-ai")
public String hello(
@RequestParam(defaultValue = "Hello, AI! 한 줄로 자기소개 부탁해.") String message
) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
코드가 딱 25줄 입니다.
진짜로요.
이게 Spring AI의 위력이에요.
Step 1에서 봤던 200줄짜리 GeminiService 와 비교해보세요.
buildSystemInstructionText, buildRequest, buildResponseJsonSchema, 4xx/5xx 분기, JSON 파싱… 그 모든 게 단 4줄(prompt → user → call → content)로 줄어들었습니다.
코드의 흐름을 한 줄씩 짚어볼게요.
| 라인 | 의미 |
|---|---|
chatClient.prompt() |
새 프롬프트 빌더 시작 |
.user(message) |
사용자 메시지로 message 설정 |
.call() |
동기 호출 실행 (스트리밍은 .stream()) |
.content() |
응답에서 본문 텍스트만 꺼내기 |
이게 우리가 20일 동안 매일 같이 쓸 그 4줄 빌더입니다. 시스템 프롬프트가 추가되든, Advisor가 끼이든, Tool 이 등록되든, 이 빌더 체인 위에서만 변형 됩니다. 어휘 일관성, 기억나시죠?
🙋 학생 질문 — "튜터님, 왜 `ChatModel` 직접 안 쓰고 굳이 `ChatClient.Builder` 거치나요? 한 단계 더 가는 거 아닌가요?"
정확한 질문입니다.
ChatModel 도 .call(prompt) 가 있어서 직접 부를 수 있어요.
다만 그러면 시스템 프롬프트, 메모리, RAG, Tool 주입 같은 부가 기능들이 다 손코딩 으로 돌아가요.
ChatClient 는 그것들을 빌더 한 줄로 끼워넣을 수 있게 만든 친구예요.
"한 단계 더"의 비용이 압도적으로 적어요. Day 5 Advisor 에서 이 차이가 본격 드러납니다.
3. Ollama 프로파일로 첫 호출 — 우리 노트북이 응답한다
자, 코드가 갖춰졌으니 띄워볼 시간입니다. .env 파일의 활성 프로파일을 docker,ollama 로 바꾸고 ./run.sh up 한 줄 입니다.
# .env (프로젝트 루트)
SPRING_PROFILES_ACTIVE=docker,ollama
# 프로젝트 루트에서
./run.sh up
부팅 로그에 다음이 떠야 정상이에요.
The following 2 profiles are active: "docker", "ollama"
...
Tomcat started on port 8080 (http) with context path '/'
Started AiFriendsApplication in 5.123 seconds
그리고 Ollama 데몬이 호스트에 켜져있는지 한 번 더 확인하세요(curl http://localhost:11434/api/tags).
컨테이너 안의 앱은 host.docker.internal 다리를 건너 호스트의 11434를 호출합니다(Step 4 마지막 "호스트 ↔ 컨테이너 브리지" 섹션에서 짚었죠).
둘 다 OK라면, 드디어 호출입니다.
# curl 은 한글 쿼리 파라미터를 자동으로 URL 인코딩하지 않아요.
# -G + --data-urlencode 조합으로 curl 이 대신 인코딩하게 합니다.
$ curl -G "http://localhost:8080/api/hello-ai" \
--data-urlencode "message=한 줄로 자기소개해줘"
저는 Gemma 3 모델 기반의 AI 어시스턴트입니다. 무엇을 도와드릴까요?
💡 "튜터님, 그냥
?message=한 줄로...라고 적으면 안 되나요?" curl 은 쿼리 스트링을 그대로 raw 바이트 로 보내기 때문에, 한글 같은 non-ASCII 문자가 포함되면 Tomcat 이 RFC 7230/3986 위반으로 거부합니다(Invalid character found in the request target). 브라우저 주소창은 알아서 인코딩해주지만 curl 은 해주지 않아요.-G --data-urlencode를 버릇처럼 쓰세요.
첫 응답이 떨어졌습니다!
그런데 이게 진짜로 우리 노트북에서 일어났다는 게 실감 안 나실 수 있어요. Wi-Fi 끄고 한 번 더 호출 해보세요. 비행기 모드로 가도 똑같이 응답이 옵니다. 그 순간 "아, 진짜로 내 노트북이 LLM이구나" 라는 감각이 옵니다. 이 감각, 평생 안 잊어요.
💡 첫 호출은 좀 느릴 수 있어요. Ollama가 모델을 메모리에 처음 적재하는 시간(콜드 스타트)이 5~15초 걸립니다. 두 번째 호출부터는 1~3초 안에 응답이 와요. "튜터님 너무 느려요" 라고 생각되시면 워밍업 한 번 한 뒤 측정해보세요.
4. Gemini 프로파일로 같은 호출 — 코드 한 줄도 안 바꿨다
이제 진짜 마법의 순간입니다. .env 의 활성 프로파일 한 줄만 docker,gemini 로 바꾸고 재기동 합니다.
# .env (프로젝트 루트)
SPRING_PROFILES_ACTIVE=docker,gemini
./run.sh down
./run.sh up
부팅 로그를 확인해요.
The following 2 profiles are active: "docker", "gemini"
그리고 완전히 똑같은 curl 명령 을 던집니다.
$ curl -G "http://localhost:8080/api/hello-ai" \
--data-urlencode "message=한 줄로 자기소개해줘"
안녕하세요! 저는 Google에서 만든 대규모 언어 모델, Gemini입니다.
응답 주체가 바뀌었죠? Gemma 3 → Gemini. 그런데 HelloAiController 자바 코드는 단 한 글자도 바뀌지 않았어요. 우리가 한 일은 .env 한 줄 바꿈 + ./run.sh 재기동, 그게 전부입니다.
이게 Step 1에서 입으로만 했던 "프로바이더 추상화" 의 실체입니다. 이제 약속드린 게 손 안에 잡히죠?
5. 호출이 어디로 흐르는지 한 번만 추적해봅시다
마지막으로 한 번만, 이 한 줄이 내부적으로 어떤 다리를 건너는지 짚고 갈게요. 이걸 한 번이라도 따라가본 사람과 안 가본 사람의 디버깅 능력은 천지 차이입니다.
[1] (호스트) HTTP GET localhost:8080/api/hello-ai?message=...
↓ 도커 포트 매핑 (호스트 8080 → 컨테이너 8080)
[2] (컨테이너) HelloAiController.hello(message)
↓
[3] chatClient.prompt().user(message).call().content()
↓
[4] ChatClient → 활성 ChatModel 빈으로 위임 (spring.ai.model.chat 값에 따라)
↓ (docker,ollama 프로파일이라면)
[5] OllamaChatModel → ${OLLAMA_BASE_URL}/api/chat 호출
(도커: http://host.docker.internal:11434/api/chat)
(IDE 로컬: http://localhost:11434/api/chat)
↓ host.docker.internal 다리 통과
[6] (호스트) Ollama 데몬 → gemma3:4b 모델 추론
↓ (응답 역방향으로 돌아옴)
[1] HTTP 200 OK + 본문 텍스트 반환
가장 중요한 분기점이 [4]번이에요. ChatClient 가 여러 ChatModel 후보 중 누구를 부를지는 우리가 아닌 Spring AI의 자동 설정 이 결정해요. 그 결정의 근거가 바로 우리가 Step 5에서 적은 spring.ai.model.chat 프로퍼티 한 줄이고요. 진짜로 한 줄이 모든 걸 좌우합니다.
💡 튜터의 핵심 포인트 — "Hello, AI" 한 호출이 보여준 세 가지
오늘 우리가 Step 1부터 Step 7까지 쌓아온 게 결국 다음 세 가지로 압축돼요.
- 추상화의 가치 — 같은 코드, 다른 프로바이더. 이게 손에 잡혔어요.
- 빌더 체인의 일관성 —
prompt().user().call().content()가 20일 내내 변하지 않아요. - 설정 분리의 힘 — 비즈니스 코드는 그대로, 행동은 환경변수가 결정.
이 세 가지가 20일 동안 우리가 매일 만나게 될 Spring AI의 핵심 정수 입니다. 오늘 이 세 가지가 머리뿐 아니라 손에 새겨졌다면 Day 1은 100% 성공이에요.
🙋 날카로운 질문 타임!
"튜터님, .call().content() 말고 다른 호출 방식도 있나요?"
훌륭한 질문입니다! Spring AI의 ChatClient 는 응답을 받는 방식이 세 가지 있어요.
| 방식 | 코드 | 용도 |
|---|---|---|
| 단일 응답 | .call().content() |
일반 동기 호출 (오늘) |
| 스트리밍 | .stream().content() |
ChatGPT처럼 토큰이 흐르듯 출력 |
| 구조화 응답 | .call().entity(MyDto.class) |
JSON으로 받고 싶은 자바 객체로 자동 매핑 |
오늘은 가장 단순한 첫 번째만 다뤘어요. 구조화 응답(엔티티 매핑)은 Day 4 에서, 스트리밍은 Day 6 에서 본격적으로 만나게 됩니다. 우리 ai-friends 의 캐릭터 응답에 호감도·선택지를 함께 받는 그 부분이 바로 구조화 응답의 영역이에요.
"튜터님, 응답이 한국어로 안 오고 영어로만 와요. 어떻게 하죠?"
재밌는 질문이에요. 두 가지 케이스로 나뉩니다.
Ollama 모델의 기본 언어가 영어 입니다.
gemma3:4b 도 한국어 코퍼스가 영어 대비 적어서 짧은 한국어 입력에는 영어로 답할 때가 있어요.
해결책은 "한국어로 답해줘"를 명시적으로 적기 — 시스템 프롬프트로 박는 게 정석이지만, 일단 사용자 메시지에 직접 붙이셔도 돼요.
더 나은 한국어를 원하시면 qwen3:4b 로 바꿔보세요.
2. Gemini는 사용자 언어를 잘 추측 해서 한국어로 답할 가능성이 높아요. 만약 영어로 응답한다면 모델 버전이 너무 가벼운 거니까 gemini-2.5-flash 정식판으로 올려보시고요.
언어·톤·페르소나 같은 것을 시스템 프롬프트 로 안정적으로 다루는 방법은 Day 3에서 본격 다룹니다. 지금은 "한 번 부르면 응답이 온다" 까지가 100점이에요.
여러분, Day 1을 끝까지 따라오셨습니다!
Spring AI라는 새 세계의 첫 발을 디뎠고, 그 첫 발자국이 진짜로 우리 노트북에 응답으로 찍혔어요. 마라톤은 이제 시작이지만, 시작이 절반이라는 말 그대로 오늘 절반은 끝났습니다.
이제 마지막으로 마무리 섹션에서 오늘 회고 + 브랜치 세이브 + Day 2 예고 만 함께 하고 헤어지겠습니다. 잠깐만 더 같이 가요.
마무리 — 회고 + 브랜치 세이브 + Day 2 예고
자, 3시간 동안 정말 많이 달려왔네요. 같이 숨 한 번 고르고, 오늘 우리가 무엇을 가지고 가는지 정리해보겠습니다.
오늘 우리가 손에 쥔 것
| Step | 우리가 한 일 | 손에 남은 것 |
|---|---|---|
| Step 1 | RestClient의 한계 4가지 짚기 | "왜 Spring AI가 필요한가" 의 명분 |
| Step 2 | LangChain4j vs Spring AI 비교 | "왜 Spring AI를 골랐는가" 의 결정 근거 |
| Step 3 | build.gradle 정렬 + 모델 스타터 추가 |
Spring AI 1.1.0 BOM과 두 개의 모델 스타터 |
| Step 4 | Ollama 설치 + gemma3:4b pull |
로컬에서 인터넷 없이 도는 LLM |
| Step 5 | Gemini 키 발급 + 프로파일 분리 | ollama / gemini 두 프로파일 |
| Step 6 | .env · .gitignore · 보안 패턴 |
사고 안 나는 시크릿 파이프라인 |
| Step 7 | ChatClient 로 첫 호출 "Hello, AI" |
같은 코드, 두 프로바이더에서 동작 확인 |
학습 목표 네 개도 다시 한 번 짚어볼까요? 모두 ✅ 처리됩니다.
- ✅ Spring AI가 해결하는 문제 이해 (Step 1, 2)
- ✅
spring-ai-bom, 프로파일,.env표준 세팅 (Step 3, 5, 6) - ✅ Ollama 로컬 + Gemini 무료 두 환경에서 첫 호출 (Step 4, 7)
- ✅ API 키 보안 기본기 (Step 6)
브랜치 세이브 — 오늘의 코드를 박제하기
자, 이제 우리가 만든 모든 변경사항을 day01-setup 브랜치로 박제하겠습니다. 이게 우리 강의의 약속이었죠? 매일 끝나면 그날의 코드를 브랜치로 저장합니다.
# lecture-source-code/ai-friends 디렉토리에서 실행
cd lectures/spring-ai/lecture-source-code/ai-friends
# 1) Day 1 작업 브랜치 생성 (혹은 이미 만들어 두셨다면 체크아웃)
git checkout -b day01-setup
# 2) 변경된 파일 확인
git status
# 3) 추적할 파일들만 명시적으로 스테이징 (절대 git add . 하지 마세요!)
git add build.gradle
git add src/main/resources/application.yml
git add src/main/java/kr/spartaclub/aifriends/hello/HelloAiController.java
# ※ .env 는 .gitignore 가 막아주지만 한 번 더 git status 로 확인!
# 4) 커밋
git commit -m "feat(day01): Spring AI 도입 + ChatClient 첫 호출 (Ollama, Gemini)"
# 5) 원격 저장소에 push
git push -u origin day01-setup
⚠️ git add .는 절대 쓰지 마세요. 우리가 Step 6에서 익힌 보안 감각, 여기서도 그대로 적용합니다. 변경할 파일을 손으로 하나씩 골라야 사고가 안 납니다. 한 번 잘못 들어간 시크릿은 폐기 외에 답이 없다고 했죠?
💡 참고: 강의 레포의
day01-setup브랜치는 과제 1 정답까지 박혀 있어요. 학생이 직접 박제하는 위 흐름은 Step 7 시점의 v1(/api/hello-ai한 개) 까지가 정답입니다. 반면 강의 레포에 미리 박아둔day01-setup브랜치는 그 위에 과제 1 정답 (/api/hello-ai/v2+HelloResponserecord +ProviderInfo빈) 까지 함께 박혀 있어요. 그래서git checkout day01-setup후HelloAiController를 펼치면 강의 시연에서 본 v1 위에 v2 메서드가 추가로 보일 거예요. 이건 의도된 박제입니다. 시연 단계에선 v1 만 따라 치시고, 과제 1 을 풀고 난 뒤에 v2 가 어떻게 들어가는지 답안과 비교해보세요. 과제 2(Groq) 정답 코드(application.yml의groq프로파일 +.env.example/docker-compose.yml의GROQ_*) 도 같은 브랜치 끝에 흡수되어 들어가 있으니 답안과 함께 펼쳐 보면 흐름이 바로 보입니다.
중간 합류 학생을 위한 가이드
만약 혹시라도 다음 Day에 새로 합류하시는 분이 계시다면, 아래 5단계만 따라오세요. 1시간 안에 오늘 우리가 도달한 지점에 합류할 수 있습니다.
# 1) 저장소 클론
git clone <레포 URL> ai-friends && cd ai-friends/lectures/spring-ai/lecture-source-code/ai-friends
# 2) Day 1 종료 시점 코드로 이동
git checkout day01-setup
# 3) 시크릿 템플릿 복사 + 본인 Gemini 키 채우기
cp .env.example .env
# 그리고 .env 파일 열어서 GEMINI_API_KEY=AIzaSy... 본인 키 입력
# 활성 프로파일은 그대로 두면 docker,gemini 가 기본값
# 4) Ollama 호스트 설치 + 모델 받기 (Step 4 참고)
brew install ollama && brew services start ollama # macOS
ollama pull gemma3:4b
# 5) Docker Desktop(또는 docker engine) 켜져있는지 확인 후 한 줄로 기동
./run.sh up
그리고 호출:
# 영문 쿼리는 그대로도 되지만, 한글까지 섞일 수 있으니 처음부터 -G --data-urlencode 로 습관 들이기
curl -G "http://localhost:8080/api/hello-ai" \
--data-urlencode "message=hello"
응답이 떨어지면 합류 완료!
💡 Ollama 로 바꿔서 호출해보고 싶다면
.env의SPRING_PROFILES_ACTIVE줄을docker,ollama로 바꾸고./run.sh down && ./run.sh up. 이게 우리가 20일 동안 매일 쓸 표준 흐름입니다.
Day 2 예고 — "프로파일 하나만 더 추가하면, OpenAI도 부를 수 있을까?"
오늘 우리는 Ollama 와 Gemini 두 모델을 다뤘어요. 하지만 솔직히 말씀드리면 이건 빙산의 일각 입니다. 실무에서는 더 많은 옵션이 있어요.
- OpenAI (GPT-4.1, GPT-4o-mini)
- Anthropic (Claude 4 Sonnet)
- Groq (Llama·DeepSeek·Mixtral 무료 고속)
- OpenRouter (수십 개 모델을 한 키로)
- Cohere, Mistral, Perplexity, …
여러분 머릿속에 의문이 줄줄 떠오르실 거예요. "GPT-4o랑 Claude 4랑 Gemini 2.5 Pro 중에 뭐가 가장 좋아?", "가격 차이가 얼마야?", "우리 서비스엔 뭘 써야 해?"
Day 2의 키워드는 바로 그겁니다. 모델 트레이드오프 매트릭스 + 멀티 프로바이더 운영.
- 모델별 트레이드오프 — 가격, 속도, 컨텍스트 윈도우, 특기 분야(코드/한국어/멀티모달/추론)
- 세 번째 프로파일 추가 —
openai프로파일을 추가해서 진짜 GPT를 호출 (실무에서 흔히 쓰는 패턴) - API 호환 어댑터의 한계 — 우리가 오늘 Gemini를 OpenAI 호환 엔드포인트로 호출했는데, 그 한계는 어디까지인가
- 요청 옵션 튜닝 —
temperature,topP,maxTokens같은 LLM 파라미터의 의미와 권장값 - Production 시나리오 — Failover, A/B 테스트, 모델별 라우팅 전략
오늘 깔아둔 추상화 위에 "여러 모델을 동시에 운영하는 그림" 이 본격적으로 그려질 거예요. 기대하셔도 좋습니다.
마지막 한 마디
오늘 마라톤 한 칸을 끝내신 여러분, 진심으로 고생하셨습니다.
처음에 우리가 던진 질문 기억하시죠?
"튜터님, 모델만 바꾸려고 해도 코드를 한참 뒤져야 하잖아요. 만약에 OpenAI나 Claude로도 쓰고 싶으면요?"
오늘 그 답을 손으로 만지셨어요. 환경변수 한 줄. 그게 답이었습니다. 코드는 그대로, 프로파일만 바꾸면 모델이 바뀌는 그 경험. 이게 우리가 20일 동안 쌓아 올릴 모든 것의 첫 번째 벽돌이에요.
혹시라도 오늘 어떤 Step에서 막히셨다면, 부담 없이 질문 주세요. 이 강의는 마라톤이고, 마라톤은 페이스 조절이 생명이에요. 한 명도 뒤처지지 않게 같이 갑니다.
자, 그럼 잠깐 휴식하시고 — 이어서 오늘의 과제 와 생각해볼 주제 를 받으시면 됩니다. 다음 시간 (Day 2)에서 다시 만나요!
오늘의 과제
오늘 우리가 손에 쥔 도구들을 다시 한 번 직접 만져보는 시간입니다. 머리로 이해한 것과 손으로 익힌 것은 완전히 다른 차원이에요. 두 개의 과제가 있습니다. 과제 1은 필수, 과제 2는 심화 입니다.
🎯 과제 1 (필수): 두 프로바이더의 응답 비교 엔드포인트 만들기
문제 상황
오늘 우리는 /api/hello-ai 한 개의 엔드포인트로 두 프로파일을 바꿔가며 호출했습니다. 그런데 매번 .env 를 바꾸고 ./run.sh 를 재기동하는 건 좀 귀찮죠. 또 한 가지 응답을 받았을 때 "이게 어느 모델의 응답인지" 도 분명하지 않았어요.
이번 과제에서는 응답에 "누가 답했는지" 를 명시적으로 담아서, 같은 질문을 두 프로파일에서 각각 호출한 결과를 나란히 비교할 수 있는 작은 도구를 만들어봅니다.
요구사항
- 새 컨트롤러 또는 기존
HelloAiController를 확장해 다음 엔드포인트를 만든다.GET /api/hello-ai/v2?message=...
- 응답은 JSON 형태로 다음 필드를 포함해야 한다.
provider— 현재 활성 프로파일에서 사용 중인 모델/프로바이더 이름 (예:"ollama-gemma3:4b","gemini-2.5-flash-lite")message— 사용자가 보낸 질문 그대로reply— LLM이 응답한 본문latencyMs— 호출에 걸린 시간(밀리초)
docker,ollama프로파일과docker,gemini프로파일 양쪽에서(.env의SPRING_PROFILES_ACTIVE한 줄 바꾸고./run.sh down && ./run.sh up) 다음 동일한 질문을 던지고, 각 응답을 캡처(또는 복사)한다.- 질문 1:
"백엔드 개발자가 1년 안에 익혀야 할 기술 3가지를 한 줄씩 추천해줘." - 질문 2:
"피보나치 수열을 구하는 자바 메서드를 짧게 보여줘." - 질문 3:
"오늘 날씨가 어때?"(의도적인 함정 질문 — LLM은 실시간 정보 접근이 안 됨)
- 질문 1:
- 간단한 비교 보고서 (Markdown 파일 또는 HackMD/Notion 페이지)를 작성한다.
- 각 질문마다 두 모델의 응답을 나란히 정리
- 느낀 점 3줄 이상: 응답 길이, 한국어 자연스러움, 응답 속도, 함정 질문 처리 등
제출물
- 추가/변경한 자바 코드 (
HelloAiController.java또는 새 파일) - 비교 보고서 마크다운 파일 (
day01-task1-report.md같은 이름) - 자기 GitHub의
day01-setup브랜치 또는 별도 브랜치에 push 후 PR 또는 링크 공유
힌트
- 활성 프로파일 / 모델 이름 같은 메타정보는
Environment빈을 주입받아getProperty(...)로 읽으면 깔끔합니다. latencyMs는System.currentTimeMillis()차이로도 충분합니다.- JSON 응답은
record HelloResponse(String provider, String message, String reply, long latencyMs)같은 record로 깔끔하게.
과제 2 (심화/선택): 세 번째 프로파일 — Groq 무료 고속 추론 추가하기
문제 상황
마무리 섹션에서 잠깐 언급했던 Groq 라는 프로바이더가 있어요. 무료 티어를 제공하면서도 추론 속도가 OpenAI 대비 5~10배 빠른 게 특징입니다(전용 LPU 가속 칩 덕분). Groq도 다행히 OpenAI 호환 엔드포인트 를 제공해요.
이걸 활용해서 groq 라는 세 번째 프로파일 을 추가해봅니다. 세 번째 프로파일을 손으로 만들어보면 "프로파일 추가의 비용이 정말 한 줄 수준" 이라는 걸 몸으로 느낄 수 있어요.
요구사항
- console.groq.com 에서 회원가입 + API 키 발급 (무료, 카드 등록 불필요)
.env.example과.env에GROQ_API_KEY추가 —.env.example은 비워두고,.env에 본인 키 채우기application.yml에groq프로파일 추가spring.ai.model.chat=openai로 활성화- 단,
spring.ai.openai.base-url을 Groq 엔드포인트로,api-key를${GROQ_API_KEY}로 덮어쓰기 - 기본 Groq 모델은
llama-3.3-70b-versatile같은 무료 모델 중 하나로 지정
.env의SPRING_PROFILES_ACTIVE를docker,groq로 바꾸고./run.sh down && ./run.sh up으로 재기동- 과제 1에서 만든
/api/hello-ai/v2를groq프로파일로 호출해서 응답이 정상적으로 오는지 확인 - 비교 보고서에 Groq 응답 컬럼을 한 줄 추가 (속도가 진짜 빠른지 직접 확인)
제출물
- 변경된
application.yml,.env.example .env의SPRING_PROFILES_ACTIVE를 세 가지(docker,ollama/docker,gemini/docker,groq)로 각각 바꿔./run.sh up한 결과 부팅 로그 캡처 3장 (각 캡처에The following 2 profiles are active: ...줄이 나오면 OK)- 과제 1 비교 보고서 업데이트 — 3개 모델 응답 비교
제약사항
- 절대
.env자체를 commit 하지 말 것 (Step 6 보안 원칙) - Groq 키도
application.yml에 직접 박지 말 것 — 반드시${GROQ_API_KEY:}형태 - 기존
ollama,gemini프로파일 동작은 깨지지 않아야 함
힌트
- Groq의 OpenAI 호환 엔드포인트는
https://api.groq.com/openai/v1형태입니다. - Groq의 모델 카탈로그는 console.groq.com 의 docs 섹션에서 확인.
- "한 프로파일 추가에 코드를 한 줄도 안 바꾼다" 가 이 과제의 진짜 목표입니다.
HelloAiController자바 코드는 손도 대지 않고 응답이 나와야 정답이에요.
공통 제출 가이드
- 제출 기한: 다음 강의 시작 전까지
- 제출 위치: 본인 GitHub 저장소 (PR 링크 또는 브랜치 링크)
- 막혔을 때: 슬랙 #spring-ai-day01 채널에 질문 — 부담 없이 올려주세요
- AI 활용: ChatGPT/Claude 등 AI 도구를 사용해도 좋지만, 반드시 본인이 코드를 이해한 뒤 직접 손으로 한 번은 타이핑해보세요. 복붙만으로는 손에 안 익습니다.
다 풀고 나면 "프로바이더 추상화" 가 이론이 아니라 손가락 끝의 감각으로 바뀝니다. 그 감각이 진짜 자산이에요. 화이팅!
생각해볼 주제
기술을 손에 익히는 것만큼 "왜 이런 식으로 설계됐을까?" 를 한 번씩 곱씹어보는 것도 중요합니다. 정답이 정해진 게 아니에요. 슬랙·디스코드에 본인 생각을 나눠주시면 다음 시간 시작할 때 함께 토론하겠습니다.
오늘은 세 가지 주제를 던집니다. 각각 독립적인 질문이니, 시간 되는 만큼 골라서 생각해보셔도 좋아요.
주제 1. 프로바이더 추상화의 진짜 ROI는 어디서 발생하는가?
오늘 우리는 "코드 한 줄도 안 바꾸고 모델 교체" 라는 추상화의 매력을 손에 쥐었습니다. 그런데 솔직히 한번 따져봅시다 — 실무에서 이런 식으로 모델을 자주 갈아탈 일이 정말 있을까요? 한 번 결정한 모델로 6개월~1년은 그냥 가는 게 보통이잖아요.
그럼에도 불구하고 추상화에 비용을 들여야 하는 이유는 어딘가 다른 곳에 있어야 합니다. 개발 단계 / 테스트 단계 / 장애 대응 / 비용 협상 / 컴플라이언스 변경 — 이 중 어디에서 진짜 ROI가 발생할까요? 본인이 다녔던 회사(또는 다닐 회사)의 맥락에서 한번 그려보시면 더 재밌습니다.
주제 2. 로컬 LLM과 클라우드 LLM, 우리 서비스라면 어떤 시나리오에서 무엇을 골라야 할까?
흔히 "민감한 데이터면 로컬, 아니면 클라우드" 라고 단순화해서 설명하곤 합니다. 그런데 막상 실무에 들어가면 그 경계가 그렇게 깔끔하지 않아요. 비용 / 지연(latency) / 응답 품질 / 운영 부담 / 가용성 / 트래픽 스파이크 대응 — 이 6가지 축이 모두 함께 작용합니다.
본인이 잘 아는 서비스 하나(인스타그램 / 토스 / 당근 / 회사 내부 시스템 등)를 떠올리고, 그 서비스의 어떤 기능에 로컬 LLM이 더 어울리고, 어떤 기능에 클라우드 LLM이 더 어울릴지 한번 매핑해보세요. 정답은 없고, 본인의 논리가 흥미로우면 그게 100점입니다.
주제 3. .env 는 학습용 패턴이라고 했다. 실무로 갈 때 어떤 차원이 추가되어야 할까?
오늘 우리는 .env 파일 한 개로 시크릿을 관리하는 가장 단순한 패턴을 익혔습니다. 마무리에서 잠깐 언급했듯, 운영 환경에서는 AWS Secrets Manager / K8s Secret / HashiCorp Vault 같은 도구로 옮겨갑니다. 그런데 도구가 바뀌는 것 외에 추가로 고려해야 할 차원 이 여러 개 더 있어요.
예를 들어 키 로테이션 주기(90일마다 자동으로 새 키로 교체), 접근 권한 통제(누구만 읽을 수 있는지), 감사 로그(누가 언제 키를 조회했는지), 무중단 키 교체(서비스를 멈추지 않고 키만 바꾸기) 같은 것들이죠. 본인이 운영팀 입장이라면 어디부터 챙기고 싶나요? 그리고 그 순서의 근거는 무엇인가요?
발표·토론 가이드
- 정답 없는 질문이니 본인 경험과 논리 가 가장 큰 무기입니다.
- 글로 정리해도 좋고, 다음 시간에 1~2분 분량으로 말로 풀어주셔도 좋아요.
- "잘 모르겠다" 라는 답도 100% OK — 어디까지 생각이 닿았고 어디부터 막혔는지를 공유하면 거기서부터 함께 풀어갑니다.
- 다른 분의 생각에 댓글로 반응하는 것도 적극 환영입니다. 토론은 혼자 하는 게 아니에요.
오늘도 정말 고생 많으셨습니다. 다음 시간 (Day 2)에서 더 멋진 추상화의 세계로 함께 들어가요.
✅ 예시 답안정답 보기
수업에서 우리가 한 일은 단순했습니다. .env 의 활성 프로파일 한 줄을 바꾸고 ./run.sh 를 재기동 한 다음, 같은 curl 을 던져서 응답이 모델별로 어떻게 다른지를 눈으로 비교했죠. 그런데 막상 응답을 봤을 때 한 가지 답답한 게 있었어요.
"이게 누구 답이지? Llama야, Gemini야?"
Spring 부팅 로그까지 거슬러 올라가서 활성 프로파일을 확인해야 합니다. 응답만 보고는 누가 답했는지 알 수가 없어요. 그래서 이번 과제에서는 응답 자체에 "누가 답했고, 얼마나 걸렸는지" 를 함께 담는 작은 도구를 만듭니다.
과제가 끝나면 이런 응답이 나옵니다.
{
"provider": "ollama-gemma3:4b",
"message": "백엔드 개발자가 1년 안에 익혀야 할 기술 3가지를 한 줄씩 추천해줘.",
"reply": "1) Docker 컨테이너화...\n2) RDB 인덱스 튜닝...\n3) 분산 트랜잭션 패턴...",
"latencyMs": 1843
}
이제 단계별로 가봅시다.
Step 1. 응답 DTO를 `record` 로 깔끔하게
먼저 응답을 담을 DTO부터 만듭니다. Java 21을 쓰는 우리가 굳이 class + Lombok 으로 갈 이유는 없죠. record 한 줄 이면 충분합니다.
// src/main/java/kr/spartaclub/aifriends/hello/HelloResponse.java
package kr.spartaclub.aifriends.hello;
/**
* /api/hello-ai/v2 의 응답 페이로드.
*
* - provider : 활성 프로파일에서 사용 중인 모델/프로바이더 이름 (예: "ollama-gemma3:4b", "gemini-2.5-flash-lite")
* - message : 사용자가 보낸 질문 그대로 (응답만 보고도 어떤 질문이었는지 추적 가능)
* - reply : LLM 본문
* - latencyMs : ChatClient 호출 ~ 응답 수신까지 걸린 시간 (밀리초)
*
* 의도적으로 "활성 모델명" 을 응답에 박았다.
* 동일한 질문을 두 프로파일로 호출했을 때, 응답만으로 "누가 답했는지" 가 명확해진다.
*/
public record HelloResponse(
String provider,
String message,
String reply,
long latencyMs
) {
}
🙋 학생 질문 — "필드를 굳이 4개나 박을 필요 있나요? `provider` + `reply` 만 있어도 되지 않나요?"
처음엔 그렇게 시작해도 됩니다.
다만 학습 목적상 message(에코) 와 latencyMs(성능 감각) 를 같이 담아두는 게 훨씬 유용해요.
message 가 있으면 비교 보고서를 만들 때 응답 캡처 한 장에 "질문-답-걸린 시간" 이 모두 포함되고, latencyMs 는 다음 과제 2(Groq) 와 비교할 때 결정적인 데이터가 됩니다.
Step 2. 활성 프로파일 / 모델 이름 — `Environment` 빈 한 번이면 끝
가장 헷갈릴 만한 부분이에요. "내가 지금 어떤 프로바이더로 호출 중인지" 를 어떻게 자바 코드 안에서 알 수 있을까?
답은 두 가지 정보의 조합입니다.
| 정보 | 어디서 읽나? |
|---|---|
활성 모델 종류(ollama / openai / none) |
spring.ai.model.chat 프로퍼티 |
| 실제 모델 이름 | spring.ai.ollama.chat.options.model 또는 spring.ai.openai.chat.options.model |
이 두 값을 합치면 "ollama-gemma3:4b" 같은 깔끔한 표시가 나옵니다. 그리고 이런 프로퍼티는 모두 Environment 빈 으로 읽을 수 있어요.
// src/main/java/kr/spartaclub/aifriends/hello/ProviderInfo.java
package kr.spartaclub.aifriends.hello;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* 현재 활성 ChatModel 의 "사람이 읽을 수 있는 이름" 을 만들어 준다.
*
* 의도:
* - HelloAiController 가 application.yml 의 키 이름을 직접 알 필요가 없게 한다.
* - 프로파일을 더 추가했을 때(예: groq) 이 클래스 한 곳만 손대면 된다.
*
* 동작 예:
* - spring.ai.model.chat = ollama → "ollama-" + spring.ai.ollama.chat.options.model
* - spring.ai.model.chat = openai → base-url 도메인에 따라 gemini/groq/openai 중 하나로 라벨링.
* 단, 모델명이 이미 그 접두사로 시작하면(예: "gemini-2.5-flash-lite")
* 중복을 피해 모델명을 그대로 쓴다 — "gemini-gemini-..." 가 되지 않게.
*/
@Component
@RequiredArgsConstructor
public class ProviderInfo {
private final Environment env;
/** 응답에 박을 짧은 라벨. 예: "ollama-gemma3:4b", "gemini-2.5-flash-lite". */
public String currentLabel() {
// 1) 활성 모델 종류 (ollama / openai / none)
String chatType = env.getProperty("spring.ai.model.chat", "none");
return switch (chatType) {
case "ollama" -> "ollama-" + env.getProperty(
"spring.ai.ollama.chat.options.model", "unknown");
case "openai" -> openAiLabel();
default -> "none"; // 활성 모델이 없는 부팅 케이스(개발 초기)
};
}
/**
* spring.ai.openai 는 "OpenAI 호환" 이라는 점만 약속할 뿐,
* 실제로 어디로 호출하는지는 base-url 에 따라 달라진다.
* - https://generativelanguage.googleapis.com → Gemini
* - https://api.groq.com → Groq (과제 2)
* - https://api.openai.com → 진짜 OpenAI
*
* 응답 라벨은 호출 대상에 맞춰 보여주되, 모델명이 이미 프로바이더 접두사로
* 시작하는 경우(예: "gemini-2.5-flash-lite") 는 중복을 피해 그대로 사용한다.
*/
private String openAiLabel() {
String baseUrl = env.getProperty("spring.ai.openai.base-url", "");
String model = env.getProperty(
"spring.ai.openai.chat.options.model", "unknown");
if (baseUrl.contains("googleapis.com")) return prefix("gemini-", model);
if (baseUrl.contains("groq.com")) return prefix("groq-", model);
return prefix("openai-", model);
}
/** 모델명이 이미 그 접두사로 시작하면(예: "gemini-2.5-..."), 그대로 두어 중복을 피한다. */
private String prefix(String tag, String model) {
return model.startsWith(tag) ? model : tag + model;
}
}
🙋 학생 질문 — "왜 `@Value("${spring.ai.model.chat}")` 로 안 받고 굳이 `Environment` 를 주입받나요?"
@Value 는 빈 생성 시점에 한 번만 평가 되는 정적 주입이에요.
우리 케이스는 부팅 시점에 프로파일이 정해지면 더는 안 바뀌니까 사실 @Value 로도 동작합니다.
그런데 Environment 를 쓰면 좋은 점이 두 가지 있어요.
(1) 한 클래스에서 여러 프로퍼티를 한꺼번에 읽기 쉬움 (생성자 인자가 줄줄이 늘어나지 않음).
(2) Step 2 의 openAiLabel() 처럼 조건부로 다른 키를 참조 해야 할 때 코드가 자연스럽습니다. "여러 키를 동적으로 다룰 땐 Environment, 한두 개만 정적으로 박을 땐 @Value" 정도로 구분해두시면 됩니다.
Step 3. 컨트롤러 확장 — `/api/hello-ai/v2` 추가
이제 본 작업입니다. 기존 HelloAiController 를 확장해 새 엔드포인트 한 개만 추가합니다. 자바 코드는 딱 두 가지가 추가 돼요.
// src/main/java/kr/spartaclub/aifriends/hello/HelloAiController.java
package kr.spartaclub.aifriends.hello;
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;
@RestController
public class HelloAiController {
private final ChatClient chatClient;
private final ProviderInfo providerInfo; // ← (1) 의존성 한 개 추가
public HelloAiController(ChatClient.Builder builder, ProviderInfo providerInfo) {
this.chatClient = builder.build();
this.providerInfo = providerInfo;
}
/** Day 1 본문 — 그대로 둔다. */
@GetMapping("/api/hello-ai")
public String hello(
@RequestParam(defaultValue = "Hello, AI! 한 줄로 자기소개 부탁해.") String message
) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
/**
* (2) 과제 1 — 응답에 활성 프로바이더와 latency 를 함께 담아 반환한다.
*
* 같은 message 를 docker,ollama / docker,gemini 두 프로파일로 각각 호출하면
* provider 필드만 다른 응답이 나란히 만들어진다. 비교 보고서가 깔끔해진다.
*/
@GetMapping("/api/hello-ai/v2")
public HelloResponse helloV2(
@RequestParam(defaultValue = "Hello, AI! 한 줄로 자기소개 부탁해.") String message
) {
long start = System.currentTimeMillis();
// 핵심: ChatClient 호출 코드 자체는 v1 과 100% 동일하다.
// 어떤 모델이 응답할지는 spring.ai.model.chat 프로퍼티가 결정한다.
String reply = chatClient.prompt()
.user(message)
.call()
.content();
long latencyMs = System.currentTimeMillis() - start;
return new HelloResponse(
providerInfo.currentLabel(),
message,
reply,
latencyMs
);
}
}
🙋 학생 질문 — "왜 `latencyMs` 를 컨트롤러에서 직접 재나요? AOP 로 빼는 게 더 깔끔하지 않나요?"
정확한 지적이에요.
실무에선 보통 HandlerInterceptor 또는 Micrometer @Timed 로 빼서 모든 엔드포인트에 일괄 적용합니다.
다만 이 과제의 목적은 "내가 호출한 한 번의 latency 를 손으로 측정해본다" 는 감각 잡기에 있어요.
그래서 일부러 컨트롤러 안에 박았습니다.
Day 20 (Observability & LLM Ops) 에서 이걸 Micrometer + ChatClient observation 으로 옮기는 그림을 자연스럽게 보게 됩니다.
Step 4. 두 프로파일에서 같은 질문을 던져 본다
자, 이제 비교의 시간입니다. .env 한 줄 바꾸고 ./run.sh 재기동 — 우리가 하루 종일 손에 익혔던 그 흐름을 그대로 씁니다.
# 1) Ollama 로 호출
# .env 파일을 열어 다음 줄로 변경
# SPRING_PROFILES_ACTIVE=docker,ollama
./run.sh down
./run.sh up
curl "http://localhost:8080/api/hello-ai/v2?message=백엔드+개발자가+1년+안에+익혀야+할+기술+3가지를+한+줄씩+추천해줘."
# 2) Gemini 로 호출
# .env 파일을 열어 다음 줄로 변경
# SPRING_PROFILES_ACTIVE=docker,gemini
./run.sh down
./run.sh up
curl "http://localhost:8080/api/hello-ai/v2?message=백엔드+개발자가+1년+안에+익혀야+할+기술+3가지를+한+줄씩+추천해줘."
응답이 이런 식으로 떨어집니다(예시 — 실제 응답은 모델/시점에 따라 다릅니다).
// docker,ollama 프로파일
{
"provider": "ollama-gemma3:4b",
"message": "백엔드 개발자가 1년 안에 익혀야 할 기술 3가지를 한 줄씩 추천해줘.",
"reply": "1) Docker 와 컨테이너 오케스트레이션 기초\n2) RDB 인덱스 설계와 실행 계획 읽기\n3) 비동기 메시징(Kafka/RabbitMQ) 흐름 이해",
"latencyMs": 1843
}
// docker,gemini 프로파일
{
"provider": "gemini-2.5-flash-lite",
"message": "백엔드 개발자가 1년 안에 익혀야 할 기술 3가지를 한 줄씩 추천해줘.",
"reply": "1. **컨테이너 (Docker/Kubernetes)** — ...\n2. **메시지 큐 (Kafka/RabbitMQ)** — ...\n3. **분산 추적 (OpenTelemetry)** — ...",
"latencyMs": 612
}
provider 필드가 다르고, latencyMs 가 다르고, 응답 스타일이 다르죠. HelloAiController 의 자바 코드는 /api/hello-ai/v2 메서드 한 개를 추가한 것 외에는 손도 대지 않았습니다. 같은 메서드, 같은 빌더 체인, 다른 모델 — 추상화가 정확히 자기 일을 하고 있는 거예요.
Step 5. 비교 보고서 — 표 한 장으로 정리하기
비교 보고서는 형식보다 "무엇을 보고 무엇을 느꼈는가" 가 중요합니다. 표 한 장 + 느낀 점 3줄이면 충분해요. 아래는 보고서 예시 골격입니다.
# Day 1 과제 1 — 두 프로바이더 응답 비교 보고서
## 환경
- 모델 A: ollama-gemma3:4b (호스트 macOS, M1 Pro 16GB)
- 모델 B: gemini-2.5-flash-lite (Google AI Studio 무료 티어)
- 호출 방식: docker,ollama / docker,gemini 프로파일을 .env 한 줄로 전환
## 질문별 응답
### Q1. 백엔드 개발자가 1년 안에 익혀야 할 기술 3가지를 한 줄씩 추천해줘.
| 모델 | 응답 길이 | latencyMs | 응답 요약 |
|------|----------|-----------|----------|
| ollama-gemma3:4b | 약 80자 | 1843 | Docker / 인덱스 / 비동기 메시징 |
| gemini-2.5-flash-lite | 약 220자 | 612 | 컨테이너 / 메시지 큐 / 분산 추적 (각 항목에 짧은 설명 포함) |
### Q2. 피보나치 수열을 구하는 자바 메서드를 짧게 보여줘.
| 모델 | latencyMs | 코드 정확성 | 비고 |
|------|-----------|------------|------|
| ollama-gemma3:4b | 2210 | ✅ 동작 | 재귀 버전 한 가지만 제시 |
| gemini-2.5-flash-lite | 740 | ✅ 동작 | 재귀/반복문 두 버전 + 시간복잡도 코멘트 |
### Q3. 오늘 날씨가 어때? (의도적 함정)
| 모델 | latencyMs | 처리 방식 |
|------|-----------|----------|
| ollama-gemma3:4b | 980 | "저는 인터넷 접근이 안 됩니다" 형태로 솔직하게 거절 |
| gemini-2.5-flash-lite | 540 | "실시간 정보는 모르지만 OO 도시의 4월 평균은…" 으로 우회 답변 |
## 느낀 점
1. **응답 속도 격차가 생각보다 컸다.** 같은 길이 질문에 대해 Ollama 는 1.8~2.2초, Gemini 는 0.5~0.7초로 약 3배 차이. 무료 모델 기준으로는 클라우드의 인프라 우위가 체감된다.
2. **함정 질문(Q3) 처리 스타일이 달랐다.** Ollama 는 "모릅니다" 로 끝났고, Gemini 는 "실시간은 모르지만 일반 정보로 대체" 라는 안전한 우회 패턴을 썼다. 사용자 경험 측면에선 후자가 더 친절하지만, 환각 위험이 약간 더 높을 수 있겠다.
3. **자바 코드는 정말 한 줄도 안 바꿨다.** `.env` 한 줄과 `./run.sh` 재기동만으로 응답 주체가 완전히 바뀌었다. 추상화의 가치가 "코드 다이어트" 가 아니라 "교체 비용 다이어트" 라는 게 손에 잡혔다.
💡 채점 포인트
| 항목 | 통과 기준 | 비중 |
|---|---|---|
record HelloResponse 정의 |
4개 필드 모두 포함 | ⭐ |
provider 라벨 정확성 |
두 프로파일에서 각각 다르게 표시 (ollama-... / gemini-...) |
⭐⭐ |
latencyMs 측정 |
System.currentTimeMillis() 차이 또는 동등한 방식 |
⭐ |
HelloAiController 확장 |
기존 /api/hello-ai 동작은 깨지지 않음 |
⭐ |
| 두 프로파일 응답 캡처 | provider 필드가 실제로 다르게 나오는 것이 보고서에 보여야 함 |
⭐⭐ |
| 비교 느낀 점 3줄 이상 | 길이/속도/품질/함정질문 처리 중 최소 한 축 이상에 대한 본인 관찰 | ⭐ |
실무 개선 포인트 (심화)
이번 과제는 학습용으로 단순화했어요. 실무로 가면 다음 같은 차원이 추가로 들어옵니다.
응답 메타데이터 표준화 — provider 외에도 requestId, tokensUsed, finishReason 같은 정보를 함께 응답에 박아두면 사후 비용 정산·디버깅이 쉬워집니다.
Spring AI 1.1.x 의 ChatResponse.getMetadata() 에 이런 정보가 들어 있어요.
Day 19 (Harness — Usage 메타데이터로 토큰 사용량 가드) 와 Day 20 (Observability) 에서 본격적으로 다룹니다.
2. latency 는 Micrometer 로 옮긴다 — 컨트롤러 안에서 직접 재면 단발성 측정밖에 못 합니다. @Timed 로 빼면 평균/p99/요청 수가 자동으로 메트릭에 쌓여서 Grafana 에 그릴 수 있어요. Day 20 에서 ChatClient observation 과 함께 붙입니다.
3. /api/hello-ai/v2 에서 두 프로바이더를 병렬 호출 — 진짜 비교 도구로 가려면 한 번 호출에 두 프로바이더로 동시에 던져 응답을 나란히 받는 형태가 더 유용합니다. @Async + CompletableFuture 는 Spring Boot 선이수 범위이니, 복습 겸 혼자 도전해보셔도 좋아요.
4. 비용 가시화 — 응답에 estimatedCostUsd 같은 필드를 추가하면 학생들에게 "한 번 호출이 얼마짜리인지" 의 감각을 즉시 잡아줄 수 있습니다. 클라우드 모델은 비싸 보이지만 무료 티어가 큰 안전망이라는 점도 함께 와닿게 됩니다. Day 19 에서 cost guardrail 관점의 비용 관리를 다뤄요.
이런 확장은 모두 오늘 만든 작은 record 와 ChatClient 호출 위에서 그대로 자라납니다. 추상화의 진짜 보상은 "지금 코드를 줄여주는 것" 이 아니라 "앞으로의 확장을 막지 않는 것" 이라는 걸 한 번 더 떠올려 보세요. 🎯
[과제 2 예시 답안] 세 번째 프로파일 — Groq 무료 고속 추론 추가하기
과제 의도 다시 짚기
이 과제의 진짜 메시지는 "한 프로바이더 추가에 자바 코드를 0줄도 건드리지 않는다" 입니다. 과제 1에서 만든 ProviderInfo 의 openAiLabel() 헬퍼가 groq.com 분기를 이미 쥐고 있었던 이유도 여기에 있어요. 오늘은 그게 진짜 작동하는지를 직접 손으로 확인합니다.
💡 이 과제를 다 풀고 나면 이런 감각이 손에 잡힙니다. "프로바이더 추가 비용 = 프로퍼티 4~5줄 + 환경변수 1줄". 자바 컴파일도, 빌드도, 재배포도, 테스트도 다시 안 합니다. 추상화의 ROI가 머리가 아니라 손가락 끝에서 잡히는 순간이에요.
Step 1. Groq API 키 발급 + `.env.example` 갱신
console.groq.com 에 들어가서 회원가입(GitHub 로그인 가능, 카드 등록 불필요) → API Keys 메뉴 → Create API Key 로 키를 발급받습니다. 키는 gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 형태예요. 한 번 보여주고 다시 안 보여주니 즉시 .env 에 복사해 넣으세요.
.env.example 에 다음 줄을 추가합니다(역시 값은 비워둠).
# .env.example (프로젝트 루트, 발췌)
# ---------------------------------------------------------
# Groq API (선택, 무료 고속 추론용)
# https://console.groq.com (카드 등록 불필요)
# ---------------------------------------------------------
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
GROQ_MODEL 도 같이 환경변수로 빼둔 이유는, 나중에 Groq 카탈로그에서 다른 무료 모델(mixtral-8x7b-32768 같은 것)로 바꾸고 싶을 때 다시 yml 을 안 건드리고 .env 한 줄만 고치면 되도록 하려는 의도예요.
과제 1에서 Gemini 모델명을 ${GEMINI_MODEL:gemini-2.5-flash-lite} 로 빼둔 패턴과 정확히 같습니다.
일관성이 손에 익으면 그게 곧 표준입니다.
본인의 .env 에는 실제 키를 채워주세요.
# .env (커밋 금지!)
GROQ_API_KEY=gsk_여러분의실제키
GROQ_MODEL=llama-3.3-70b-versatile
Step 2. `application.yml` 에 `groq` 프로파일 추가
이제 진짜 핵심입니다. 기존 ollama / gemini 두 프로파일 블록 아래에 groq 프로파일 을 한 덩어리 더 끼워 넣습니다.
# src/main/resources/application.yml (파일 맨 아래에 추가)
---
# =========================================================
# groq 프로파일: OpenAI 호환 엔드포인트로 Groq 호출
# 사용 예 (도커): .env 에 SPRING_PROFILES_ACTIVE=docker,groq
# 사용 예 (IDE) : SPRING_PROFILES_ACTIVE=local,groq
#
# 핵심 포인트:
# - 스타터는 그대로 spring-ai-starter-model-openai 를 재활용
# - base-url 만 Groq 엔드포인트로 바꾸고, api-key 는 GROQ_API_KEY 로 덮어쓴다
# - 즉, Java 코드는 단 한 줄도 손대지 않는다
# =========================================================
spring:
config:
activate:
on-profile: groq
ai:
model:
chat: openai
openai:
api-key: ${GROQ_API_KEY:}
base-url: https://api.groq.com/openai/v1
chat:
# Step 5(교안) 에서 Gemini 용으로 설정한 것과 동일한 이유 —
# 기본 "/v1/chat/completions" 를 쓰면 base-url 끝의 "/v1" 과 이중이 되어
# "/openai/v1/v1/chat/completions" 404 가 난다.
completions-path: /chat/completions
options:
model: ${GROQ_MODEL:llama-3.3-70b-versatile}
여기서 의도적으로 챙긴 디테일을 짚을게요.
| 항목 | 값 | 왜 이렇게? |
|---|---|---|
spring.ai.model.chat |
openai |
Groq 도 OpenAI 호환 → Spring AI 입장에선 그냥 OpenAI 채팅 모델 한 개로 보임 |
base-url |
https://api.groq.com/openai/v1 |
Groq의 OpenAI 호환 엔드포인트 루트 |
completions-path |
/chat/completions |
기본값 /v1/chat/completions 를 그대로 두면 /v1 이 이중으로 붙어 404. Gemini 와 동일 사유 |
api-key |
${GROQ_API_KEY:} |
콜론 뒤가 비어있는 건 "환경변수 없으면 빈 문자열" — 프로파일을 안 켰을 때도 앱 부팅이 깨지지 않게 |
model |
${GROQ_MODEL:llama-3.3-70b-versatile} |
모델 교체를 yml 수정 없이 가능하게 — 카탈로그가 자주 바뀌는 Groq 특성을 고려 |
🙋 학생 질문 — "`completions-path` 를 안 적으면 어떤 에러가 나나요?"
./run.sh up 후 /api/hello-ai/v2 를 찔러보면 다음 메시지가 로그에 박힙니다.
HTTP 404 - {"error":{"message":"Unknown request URL: POST /openai/v1/v1/chat/completions.
Please check the URL for typos, or see the docs at https://console.groq.com/docs/",
"type":"invalid_request_error","code":"unknown_url"}}
/v1/v1/ 에 주목하세요.
base-url 끝의 /v1 + 기본 path /v1/chat/completions 가 합쳐져 생긴 흔적입니다.
이 에러를 본 적이 있다면 십중팔구 completions-path 오버라이드를 빼먹은 거예요.
반대로 base-url 을 https://api.groq.com/openai (끝의 /v1 제거) 로 바꿔도 똑같이 동작해요 — 단 그러면 gemini 와 규칙이 달라져 머릿속이 복잡해지니, "base-url 에 provider prefix 전부 포함 + completions-path=/chat/completions 공통 오버라이드" 컨벤션으로 통일하는 게 낫습니다.
🙋 학생 질문 — "같은 `spring.ai.openai.*` 키를 `gemini` 와 `groq` 가 둘 다 쓰는데, 어떻게 안 부딪히죠?"
정확히 말하면 부딪히지 않습니다, 한 시점에는 한 프로파일만 활성화되니까요.
SPRING_PROFILES_ACTIVE=docker,gemini 로 띄우면 gemini 블록만 머지되어 openai.base-url=generativelanguage.googleapis.com/... 으로 잡힙니다.
docker,groq 로 띄우면 groq 블록만 머지되어 openai.base-url=api.groq.com/... 으로 잡혀요.
프로파일은 시간 축에서 서로 배타적이라 같은 키를 덮어써도 안전합니다. 두 프로파일을 동시에 켜는 (docker,gemini,groq) 짓만 안 하면 됩니다(어차피 그건 의미가 없죠 — 마지막 적용된 게 이깁니다).
Step 3. `docker-compose.yml` — 컨테이너로 가는 환경변수 통로 열기
여기서 많은 학생이 한 번은 밟는 함정이 있어요. .env 에 GROQ_API_KEY=gsk_... 를 채웠는데도 ./run.sh up 이 아래처럼 터집니다.
Caused by: java.lang.IllegalArgumentException:
OpenAI API key must be set.
Use the connection property: spring.ai.openai.api-key or spring.ai.openai.chat.api-key property.
"분명히 .env 에 키를 적었는데 왜?" 라고 3분쯤 멘붕 옵니다. 원인은 단순해요.
docker-compose는.env를 "compose 파일 자체의${변수}치환용" 으로만 읽어요. 컨테이너 안까지 자동으로 흘려주지 않습니다.
.env 에 값이 있어도 docker-compose.yml 의 environment: 블록에 명시적으로 매핑된 변수만 컨테이너로 전달됩니다. 기존 compose 파일은 Gemini 까지만 뚫려있었어요. Groq 가 처음 추가됐으니 우리가 직접 통로를 한 줄 더 열어줘야 합니다.
# docker-compose.yml (app 서비스의 environment 블록 — 발췌)
services:
app:
environment:
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-docker,gemini}
# ... DB 관련 변수들 ...
GEMINI_API_KEY: ${GEMINI_API_KEY}
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash-lite}
# Day 1 과제 2 — Groq 용 환경변수 통로를 추가 (이 두 줄이 핵심)
GROQ_API_KEY: ${GROQ_API_KEY:-}
GROQ_MODEL: ${GROQ_MODEL:-llama-3.3-70b-versatile}
${GROQ_API_KEY:-} 는 호스트 쉘/.env 에 변수가 없으면 빈 문자열 을 기본값으로 넣으라는 뜻이에요.
이게 왜 중요하냐면, groq 프로파일을 안 켠 케이스(ollama/gemini) 에서도 compose 파싱이 깨지지 않아야 하거든요.
${GROQ_API_KEY} (기본값 없음) 이라고만 적으면 compose 가 "GROQ_API_KEY 변수 설정이 없다" 며 경고를 띄우고 커뮤니케이션이 꼬여요.
🙋 학생 질문 — "근데 왜 compose 는 `.env` 를 자동으로 컨테이너까지 안 주나요? 귀찮은데요?"
명시적으로 통로를 그리게 하는 게 "어떤 변수가 컨테이너까지 가는지" 를 compose 파일 하나만 봐도 알 수 있게 하기 위함입니다.
.env 에 실수로 AWS_SECRET=... 같은 민감한 값이 섞여도, environment: 에 안 적혀있으면 컨테이너 안으로는 안 새어나가요.
보안 관점에서 default-deny 가 default-allow 보다 항상 안전 하다는 12-Factor 정신의 반영입니다.
반대급부로 "새 환경변수 추가 = compose 에도 한 줄 추가" 라는 작은 세금이 붙지만, 이게 프로젝트 후반부에 훨씬 큰 사고를 막아줘요.
Step 4. `.env` 한 줄 + `./run.sh` 재기동
여기까지 오면 자바 코드는 단 한 줄도 안 만졌어요. 이제 Groq 으로 전환합니다.
# .env (한 줄만 바꿉니다)
SPRING_PROFILES_ACTIVE=docker,groq
./run.sh down && ./run.sh up
부팅 로그에서 다음 줄이 나오면 OK입니다.
The following 2 profiles are active: "docker", "groq"
이제 과제 1에서 만든 /api/hello-ai/v2 를 그대로 호출합니다. 새 컨트롤러를 만들지 않았다는 점이 중요해요.
curl 'http://localhost:8080/api/hello-ai/v2?message=Spring%20AI%20한%20줄%20설명해줘'
응답 예시:
{
"provider": "groq-llama-3.3-70b-versatile",
"message": "Spring AI 한 줄 설명해줘",
"reply": "Spring AI는 Spring 생태계 안에서 다양한 LLM 프로바이더를 추상화해 호출할 수 있게 해주는 공식 라이브러리입니다.",
"latencyMs": 142
}
여기서 두 가지를 봐야 합니다.
provider필드가groq-llama-3.3-70b-versatile로 정확히 잡힌다 — 과제 1 의ProviderInfo.openAiLabel()안에 미리 박아둔groq.com분기가 처음으로 발화하는 순간이에요.latencyMs가 Gemini(보통 500~700ms)보다 확연히 빠르다 — Groq 의 LPU(Language Processing Unit) 가속 덕분입니다. 같은 70B 급 Llama 모델이지만, 우리가 평소 보던 GPU 추론 대비 5~10배 빠른 속도가 나와요.
Step 5. 비교 보고서에 Groq 컬럼 추가
과제 1 Step 5에서 만든 비교 보고서(day01-comparison.md) 에 한 컬럼만 더 붙입니다. 새로 보고서를 처음부터 쓰지 않아요 — 같은 질문에 대해 세 번째 응답을 받아 옆에 줄을 추가하는 겁니다.
# Day 1 — 세 프로바이더 응답 비교 보고서
## 환경
- Ollama: gemma3:4b (호스트 로컬, M1 Pro 16GB Metal)
- Gemini: gemini-2.5-flash-lite (Google AI Studio 무료 티어)
- Groq: llama-3.3-70b-versatile (Groq 무료 티어)
## Q1. "Spring AI를 한 줄로 설명해줘"
| 모델 | latencyMs | 응답 요지 |
|------|-----------|----------|
| gemma3:4b | 1850 | "Spring 생태계의 AI 통합 라이브러리" |
| gemini-2.5-flash-lite | 580 | "다양한 LLM 프로바이더를 통합 호출하는 Spring 공식 라이브러리" |
| llama-3.3-70b-versatile (Groq) | 142 | "Spring 안에서 다양한 LLM을 추상화해 호출하는 공식 라이브러리" |
## Q2. "피보나치 수열을 자바 코드로 짜줘"
| 모델 | latencyMs | 응답 요지 |
|------|-----------|----------|
| gemma3:4b | 2100 | 재귀 버전, 메모이제이션 없음 |
| gemini-2.5-flash-lite | 620 | 반복문 버전, BigInteger 사용 |
| llama-3.3-70b-versatile (Groq) | 178 | 반복문 버전 + 재귀 비교 코멘트 함께 제시 |
## Q3. "오늘 부산 날씨 어때?" (실시간 정보 함정 질문)
| 모델 | latencyMs | 응답 요지 |
|------|-----------|----------|
| gemma3:4b | 1900 | "실시간 정보는 모릅니다." |
| gemini-2.5-flash-lite | 540 | "실시간은 모르지만 4월 평균은…" 우회 답변 |
| llama-3.3-70b-versatile (Groq) | 165 | "실시간 정보 접근 불가, 공식 기상 사이트 안내" |
## 새로 느낀 점 (Groq 추가 후)
1. **속도 격차가 한 번 더 벌어졌다.** Gemini 도 빠른 편이었는데, Groq 은 3~5배가 더 빨랐다. 같은 70B 급 모델이 Ollama(3B) 보다도 10배 빠른 게 충격적. LPU 가속의 위력이 손에 잡힘.
2. **응답 품질도 70B 급답게 더 두텁다.** Q2 에서 Groq 만 "재귀 vs 반복" 비교 코멘트를 같이 줬다. 모델 크기가 답변 깊이에 영향을 주는 게 명확히 보인다.
3. **자바 코드는 정말 0줄을 안 바꿨다.** `application.yml` 에 한 블록(8줄), `.env` 에 한 줄, 끝. 이 경험이 "프로바이더 추상화" 의 진짜 의미가 무엇인지를 확정해준다 — **추상화는 코드를 줄이는 게 아니라 변경 비용을 줄이는 도구**다.
💡 채점 포인트
| 항목 | 통과 기준 | 비중 |
|---|---|---|
.env.example 에 GROQ_API_KEY 추가 |
값은 비어있어야 함, 이름은 정확히 GROQ_API_KEY |
⭐ |
application.yml 에 groq 프로파일 추가 |
기존 두 프로파일(ollama, gemini) 블록 동작이 깨지지 않음 |
⭐⭐ |
spring.ai.model.chat=openai + base-url=https://api.groq.com/openai/v1 |
둘 다 정확해야 호출이 나감 | ⭐⭐ |
completions-path: /chat/completions 오버라이드 |
생략 시 /v1/v1/chat/completions 로 404 — Gemini 와 동일 사유 |
⭐⭐ |
api-key, model 모두 환경변수 참조 |
yml 에 키나 모델명을 직접 박지 않을 것 | ⭐⭐ |
docker-compose.yml 에 GROQ_API_KEY / GROQ_MODEL 환경변수 추가 |
environment: 블록에 명시적 매핑 필요. ${GROQ_API_KEY:-} 처럼 기본값까지 주는지 |
⭐⭐ |
| 부팅 로그 캡처 3장 | ollama / gemini / groq 각각에서 The following 2 profiles are active: ... 줄이 정확히 다르게 나와야 함 |
⭐ |
/api/hello-ai/v2 응답에 provider="groq-..." |
과제 1 의 ProviderInfo 가 Groq 을 정확히 라벨링하는지가 검증 포인트 |
⭐⭐ |
| 비교 보고서에 Groq 컬럼 추가 | 속도 차이/응답 품질 중 최소 한 축에 대한 본인 관찰이 있어야 함 | ⭐ |
| 자바 코드 변경 0줄 | HelloAiController / HelloResponse / ProviderInfo 어디에도 새 import, 새 분기, 새 메서드가 들어가지 않아야 함 |
⭐⭐⭐ |
마지막 항목이 이 과제의 진짜 채점 포인트입니다. 자바 코드를 한 줄이라도 바꿨다면, "왜 바꿔야 했는지" 를 PR 설명에 적어주세요. 대부분의 경우 그건 과제 1의 ProviderInfo 설계가 미흡했다는 신호이고, 그 부분을 일반화하는 리팩토링으로 이어지면 그건 그것대로 만점입니다.
실무 개선 포인트 (심화)
이번 과제를 학습용에서 한 단계 더 끌어올린다면 이런 차원이 들어갑니다.
프로바이더 메타데이터를 yml 중심으로 — 지금은 ProviderInfo 가 base-url 패턴 매칭으로 라벨을 만들어요.
프로바이더가 4~5개로 늘어나면 이게 부담이 됩니다.
실무에서는 app.providers.{name}.label, app.providers.{name}.base-url 같은 앱 자체 설정 네임스페이스 를 만들어 yml 에 메타데이터까지 같이 두는 게 깔끔해요.
@ConfigurationProperties 패턴은 Spring Boot 선이수 범위이니 복습 겸 직접 적용해보셔도 좋습니다.
2.
Groq Rate Limit 보호 — Groq 무료 티어는 분당 30 요청, 일당 14,400 요청 같은 제한이 있어요.
Spring AI 호출에 Resilience4j Rate Limiter 를 붙여 두면 부하 테스트나 사고로 한도를 넘기는 걸 사전에 막을 수 있습니다.
Day 19 (Harness — Rate Limit · Resilience4j 재시도) 에서 본격적으로 다룹니다. 3.
응답 토큰 수 / 비용 추정 — Groq 은 무료지만, 미래에 유료 모델로 옮기면 토큰 수 × 단가 = 호출당 비용 이 되죠.
ChatResponse.getMetadata().getUsage() 로 입력/출력 토큰 수를 받아 호출 1건당 예상 비용을 응답에 박아두면, 학생에게 "한 호출 = 얼마짜리" 의 감각이 생깁니다.
Day 19 (Harness — Usage 메타데이터로 cost guardrail) 에서 다뤄요.
4.
프로파일 선택 자동화 — CI/CD 에서 환경별로 다른 프로바이더를 쓰고 싶을 때(예: dev=ollama, staging=groq, prod=openai), .env 를 손으로 바꾸는 게 아니라 GitHub Actions / Argo CD 같은 도구의 환경변수 주입 으로 해결합니다.
Day 20 (배포 체크리스트 — 프로바이더 폴백, API 키 로테이션) 에서 다룹니다.
이 과제의 핵심은 결국 한 문장입니다 — "잘 만든 추상화 한 개가 미래의 백 줄짜리 코드 변경을 막는다." 오늘 손에 익은 이 감각이 앞으로 19일 동안 계속 자라날 자산이에요.
[생각해볼 주제 예시 답안]
본 답안은 "한 가지 정답" 이 아니라 "이렇게 생각해 볼 수 있다" 의 예시입니다. 학생들이 본인의 경험과 컨텍스트로 다르게 풀어내면 그게 더 멋진 답이에요. 각 주제마다 마지막에 🎯 면접관을 홀리는 핵심 멘트 를 한 줄로 정리해 두었으니, 면접·기술 토론에서 그대로 꺼내 써도 좋습니다.
주제 1. 프로바이더 추상화의 진짜 ROI는 어디서 발생하는가?
[문제 상황 요약]
오늘 우리는 .env 한 줄로 Ollama ↔ Gemini ↔ Groq 을 갈아탔습니다. "와, 추상화 멋있다" 의 감각은 손에 쥐었어요. 그런데 한 번 더 따져봅시다 — 한 번 결정한 모델을 6개월~1년은 그냥 가는 게 보통이라면, 추상화에 들이는 비용은 어디에서 회수되는가?
추상화는 공짜가 아닙니다. 인터페이스 한 겹, 설정 분리, 빈 등록 로직, 추가 학습 비용이 따라붙어요. 이 비용을 정당화할 만한 ROI가 있어야 합니다.
[튜터의 가이드 및 해설]
학생들이 흔히 떠올리는 답은 "모델 교체가 쉬워진다" 인데, 사실 이건 ROI 의 가장 약한 축입니다. 진짜 ROI 는 다섯 군데에서 나뉘어 발생해요.
① 개발 단계 — 로컬에서 무료로 빠르게 실험
가장 흔하지만 의외로 무게가 큰 축입니다. 신규 기능 프로토타이핑할 때 매번 OpenAI 키로 돈 태우면 부담스러워요. 로컬 Ollama 로 1차 골격을 잡고, 동작 확인되면 클라우드 모델로 한 번 더 검증 — 이 사이클이 가능해지는 게 추상화의 첫 보상입니다. 개발자 한 명당 월 몇 만 원의 토큰 비용이 0원으로 떨어져요.
② 테스트 단계 — CI/CD 에서 결정론적 응답
LLM 호출은 본질적으로 비결정적이라 단위 테스트가 까다롭습니다. 추상화가 있으면 테스트용 가짜 ChatModel 빈 (스텁/페이크)을 한 줄로 끼워 넣을 수 있어요. CI 파이프라인에서 외부 API 키 없이 빠르게 돌고, 비용도 0원, 응답도 결정적. 이 ROI 가 가장 실무적입니다.
③ 장애 대응 — 한 프로바이더 다운 시 즉시 우회
OpenAI 가 12시간 다운된 적 있죠(2025년 사례).
추상화가 없으면 그 12시간 동안 우리 서비스도 죽습니다.
Day 19 (Harness — Rate Limit · Resilience4j) 과 Day 20 (배포 체크리스트 — 프로바이더 폴백) 에서 다룰 fallback 패턴의 전제 조건이 바로 이 추상화예요. "OpenAI 죽으면 자동으로 Anthropic 으로" 같은 정책이 가능해지는 건 ChatModel 인터페이스가 깔끔히 한 겹 분리돼있어야 합니다.
④ 비용 협상 — "언제든 갈아탈 수 있다" 가 가장 큰 협상 카드
벤더 락인 회피라는 추상적 표현 뒤의 진짜 의미입니다.
우리가 OpenAI 한 곳에만 묶여 있으면 가격 인상에 그냥 따라가야 해요. "언제든 Gemini / Anthropic / Groq 으로 옮길 수 있다" 가 코드 레벨에서 사실이면, 그게 곧 단가 협상력 입니다.
사내에서 이 이야기를 명확히 하면 인프라팀·재무팀의 추상화 ROI 인식이 달라집니다.
⑤ 컴플라이언스 변경 — 데이터 주권 정책이 바뀔 때
가장 늦게 오지만 가장 무거운 ROI 입니다. EU AI Act 처럼 "특정 카테고리 데이터는 EU 리전 / 온프레미스에서만 처리" 같은 규제가 어느 날 적용되면, 클라우드 호출을 한 번에 다 로컬 모델로 옮겨야 해요. 추상화가 없으면 이건 재작성 프로젝트 가 됩니다. 있으면 설정 변경 입니다.
현실적 결론: ROI 는 ②(테스트)와 ③(장애 대응) 에서 즉시 회수되고, ⑤(컴플라이언스) 에서 가장 크게 폭발합니다. ①(개발 비용 절감)과 ④(비용 협상)는 꾸준히 적립되는 백그라운드 ROI 고요. "모델 교체 쉽다" 는 이 다섯 가지의 부산물에 불과합니다.
본인이 다닐 회사가 작은 스타트업이면 ①, ②가 즉시 ROI일 거고, 금융/의료 도메인이면 ⑤가 결정적 ROI 입니다. 본인 컨텍스트에 어떤 축이 가장 무거운지 를 한 번 그려보면 그게 본 주제의 진짜 답이에요.
🎯 면접관을 홀리는 핵심 멘트
"프로바이더 추상화의 ROI 는 모델 교체가 쉬워진다는 게 아닙니다. 테스트에서 가짜 모델 한 줄 끼워넣기, 장애 시 fallback, 컴플라이언스 변경 시 재작성이 아닌 설정 변경 — 이 세 가지가 진짜 보상이에요. '교체 가능성' 은 사용하지 않아도 가치가 발생하는, 옵션의 가치입니다."
주제 2. 로컬 LLM과 클라우드 LLM, 우리 서비스라면 어떤 시나리오에서 무엇을 골라야 할까?
[문제 상황 요약]
흔한 단순화는 "민감 데이터 → 로컬, 그 외 → 클라우드" 입니다. 깔끔해 보이지만 실무는 그렇게 깨끗하지 않아요. 비용 / 지연 / 응답 품질 / 운영 부담 / 가용성 / 트래픽 스파이크 대응 — 6가지 축이 한꺼번에 작용합니다.
본인이 잘 아는 서비스 하나를 떠올려서, 그 서비스의 어떤 기능이 로컬에 어울리고 어떤 기능이 클라우드에 어울릴지 를 매핑해보는 게 이 주제의 핵심이에요.
[튜터의 가이드 및 해설]
여섯 축을 로컬 vs 클라우드 관점에서 한 줄씩 정리하면 이렇습니다.
| 축 | 로컬 LLM 우세 | 클라우드 LLM 우세 |
|---|---|---|
| 비용 | 트래픽이 일정하게 높을 때 (서버 한 대로 무한 호출) | 트래픽이 변동성 클 때 (사용한 만큼만 지불) |
| 지연 | 우리 데이터센터 내부 호출 (수십 ms) | 외부 API 호출 (수백 ms ~ 수 초) |
| 응답 품질 | 7B~70B 오픈소스 모델 한도 | GPT-4.1 / Claude 4 / Gemini 2.5 Pro 급 |
| 운영 부담 | 우리가 GPU·모델·튜닝·업데이트 다 책임 | 벤더가 다 처리, 키만 관리 |
| 가용성 | 우리 SRE 의 SLA 가 곧 SLA | 벤더 SLA(보통 99.9%) 의존, 다운 시 통제 불가 |
| 트래픽 스파이크 | 사전 증설 안 했으면 그대로 죽음 | 자동 스케일링(우리 입장에선 무한 탄력) |
이걸 인스타그램 으로 매핑해보면 그림이 잡힙니다.
🟢 로컬 LLM 이 어울리는 기능
- DM 신고 사유 자동 분류 — 분류 결과가 외부에 노출 안 되고, 사용자 사적 메시지가 외부 벤더로 새는 게 부담. 7B 모델로도 충분한 분류 정확도. 민감성 + 단순 태스크 조합.
- 댓글 욕설 1차 필터링 — 트래픽이 균질하게 항상 높음. 클라우드로 호출하면 토큰 비용이 미친 듯이 누적. 로컬 모델로 1차 필터, 의심되는 것만 클라우드로 2차 분석. 고볼륨 + 단순 조합.
- 이미지 캡션 자동 생성(접근성) — Vision 모델 호출이 무겁고 비싼데, 사진 업로드 트래픽이 균질. 우리 GPU 풀로 처리하는 게 단가 상 유리.
🟡 클라우드 LLM 이 어울리는 기능
- DM 자동 답장 추천 (Smart Reply) — 응답 품질이 사용자 만족도와 직결. 로컬 7B 로는 어색한 말투가 나오기 쉬움. 품질 우위가 결정적 인 케이스.
- 신규 기능 프로토타이핑 — 트래픽 패턴 모름, 운영 부담 안 지고 빠르게 실험. 안정화되면 로컬 마이그레이션 검토.
- 간헐적 고난이도 분석 (예: 광고 부정 사용 패턴 탐지) — 호출 빈도는 낮지만 한 번 호출 시 깊은 추론 필요. 저빈도 + 고품질 조합.
🟠 흥미로운 회색 지대
- 타임라인 추천 텍스트 요약 — 매일 수억 건 호출 → 비용은 로컬이 압도적. 그런데 품질이 사용자 체류 시간에 직결 → 클라우드 우위. 이런 케이스는 하이브리드(로컬 1차 → 임계 이상의 게시물만 클라우드 재호출)로 푸는 게 정답이에요.
이 매핑의 일반화된 규칙은 다음 두 줄로 압축됩니다.
- 트래픽이 균질하게 높고, 태스크가 단순하면 → 로컬
- 트래픽이 변동성 크고, 품질 우위가 결정적이면 → 클라우드
여기에 민감성 차원이 추가 되면 로컬 쪽 무게가 더 실리는 거고요.
본인이 토스를 떠올렸으면 고객 응대 봇은 클라우드(품질), 거래 이상 탐지는 로컬(민감성+볼륨) 이 자연스럽고, 당근이라면 상품 카테고리 자동 분류는 로컬(볼륨), 사기 거래 탐지는 클라우드(고난이도 추론) 같은 매핑이 나올 거예요. 정답은 없고, 본인 매핑의 논리 가 단단하면 그게 100점입니다.
🎯 면접관을 홀리는 핵심 멘트
"민감 데이터냐 아니냐로만 자르면 1차원 답이고, 실무는 2차원이에요. 트래픽 균질성·품질 민감도 라는 두 축으로 잘라서 — 균질·단순은 로컬, 변동·고품질은 클라우드. 회색 지대는 로컬 1차 + 클라우드 폴백 의 하이브리드로 푸는 게 진짜 실무 답입니다."
주제 3. `.env` 는 학습용 패턴이라고 했다. 실무로 갈 때 어떤 차원이 추가되어야 할까?
[문제 상황 요약]
오늘 우리는 .env 파일 한 개로 시크릿을 관리하는 가장 단순한 패턴을 익혔습니다. .gitignore 로 커밋만 막으면 학습용으로는 훌륭해요. 운영에서는 AWS Secrets Manager / K8s Secret / HashiCorp Vault 같은 도구로 옮겨갑니다.
그런데 도구가 바뀌는 것이 전부가 아닙니다. 추가로 고려해야 할 차원 이 여러 개 있어요. 본인이 운영팀 입장이라면 어디부터 챙기고 싶은가, 그 우선순위의 근거는 무엇인가 — 이 주제의 진짜 질문입니다.
[튜터의 가이드 및 해설]
.env 가 학습용 패턴인 이유는 다음 5가지가 빠져있기 때문이에요.
① 키 로테이션 (Key Rotation)
OpenAI 키 한 개를 1년 동안 같은 값으로 쓰면, 1년 동안 한 번이라도 어딘가에 노출됐을 때 그 데미지가 1년치예요. 실무에서는 90일마다 새 키로 자동 교체 합니다. AWS Secrets Manager 의 자동 로테이션이 이 일을 해줍니다. 핵심은 로테이션이 자동이어야 한다는 것 — 사람 손을 타면 6개월에 한 번도 어렵습니다.
② 접근 권한 통제 (Least Privilege)
.env 파일은 이 파일을 읽을 수 있는 모든 사람이 모든 시크릿을 볼 수 있어요. 실무에서는 IAM 으로 "결제 서비스만 Stripe 키 읽기 허용", "AI 서비스만 OpenAI 키 읽기 허용" 같은 권한 분리를 합니다. 한 서비스가 털려도 다른 서비스 키는 안전하게 만드는 거예요.
③ 감사 로그 (Audit Log)
누가 언제 어떤 시크릿을 조회했는지 기록이 남아야 합니다. 사고가 났을 때 "우리 키가 언제 어디로 새어 나갔나" 를 추적할 유일한 단서거든요. AWS 면 CloudTrail, K8s 면 audit-log, Vault 면 자체 audit log 가 이 일을 합니다. 없으면 사고 후 원인 분석이 추측에 의존하게 돼요.
④ 무중단 키 교체 (Hot Reload)
키를 교체할 때 서비스를 재시작해야 한다면, 트래픽이 큰 서비스는 매번 다운타임이 발생합니다. 실무에서는 Vault Agent / Spring Cloud Config 같은 도구로 시크릿 변경 시 앱이 자동으로 새 값을 받아오게 만들어요. 우리 강의에서는 .env 변경 → 재기동인데, 실무는 .env 변경 → 자동 reload 입니다.
⑤ 비상 회수 (Revocation)
키가 새었다는 의심이 들면 즉시 무효화 할 수 있어야 해요. 새 키 발급은 5분이 걸려도, 이전 키 무효화는 즉시 되어야 합니다. AWS Secrets Manager 의 DeleteSecret 이나 OpenAI 콘솔의 "Revoke key" 가 이 역할입니다. 학습용 .env 에는 이 개념이 아예 없어요.
우선순위는 어디부터?
운영팀 입장에서는 다음 순서가 합리적입니다.
- ① 키 로테이션 + ⑤ 비상 회수 먼저 — 사고가 나도 데미지 시간 창을 줄이는 게 최우선. 자동 로테이션이 깔리면 키가 새도 90일 안에 무효화돼요.
- ② 접근 권한 통제 — 사고 발생 시 폭발 반경(blast radius)을 줄임. 한 서비스 털림이 전체 시스템 키 유출로 번지지 않게.
- ③ 감사 로그 — 사고 발생 후 원인 분석 가능하게. 사후 대응의 근거.
- ④ 무중단 키 교체 — 가장 마지막. 운영 편의성 차원이라 ①~③ 이 갖춰진 다음에 챙기는 게 맞아요.
우선순위의 근거: 실무에서 가장 무서운 건 "사고가 났는데 얼마나 새어 나갔는지 모른다" 입니다. 그래서 사고 가능성 자체를 줄이는 ① + 사고 시 대응 가능성을 만드는 ③ 이 가장 무겁습니다. ④는 편의성이라 후순위.
또 하나 짚을 점 — .env 도 작은 팀에선 충분히 합리적인 선택 이라는 거예요.
Vault 한 대 운영하는 비용이 직원 5명 회사에는 과합니다.
.env + 1Password Team / Doppler 같은 가벼운 시크릿 매니저 조합이 그 단계의 정답입니다.
회사 규모와 데이터 민감도에 비례해서 위 5가지를 단계적으로 도입하는 게 실무 감각이에요.
🎯 면접관을 홀리는 핵심 멘트
"
.env가 부족한 게 아니라, 자동 로테이션·권한 분리·감사 로그·핫 리로드·즉시 회수 다섯 차원이 빠져있는 거예요. 운영팀 입장에서는 사고 데미지 창을 줄이는 자동 로테이션 + 사후 추적 가능성을 만드는 감사 로그 두 개가 가장 무겁고, 핫 리로드는 가장 후순위입니다. 그리고 작은 팀에선.env+ 가벼운 시크릿 매니저 조합도 충분히 합리적 선택이라는 걸 잊으면 안 돼요."
발표·토론 가이드 (튜터용)
학생이 이 주제들에 대한 답을 가져오면 다음 흐름으로 함께 풀어가시면 좋아요.
- 본인 답을 먼저 듣는다 — 정답을 먼저 던지면 학생이 자기 사고를 멈춥니다.
- 답에서 빠진 축이 있으면 1~2개만 선물한다 — 5가지를 한 번에 던지면 소화 못 해요. "그 축에 더해서 ○○ 도 한번 같이 생각해보면…" 정도가 적당합니다.
- 본인이 다닐 회사 컨텍스트에 어떻게 적용되는지 한 번 더 생각하게 한다 — 이게 내재화의 핵심입니다.
- "정답은 없고, 본인 논리가 일관되면 100점" 을 한 번 더 강조해주세요. 이 주제들은 사고 훈련용이지 평가용이 아닙니다.
오늘도 정말 고생 많으셨습니다. Day 1 의 진짜 메시지는 "한 줄 코드" 가 아니라 "한 줄 코드 뒤에 있는 5가지 사고 축" 이었어요. 다음 시간 (Day 2)에서 더 깊은 추상화의 세계로 함께 들어가요.