Day 7. ImageModel — "AI 화가에게 그림 의뢰하기, 캐릭터 프로필을 동적으로 그려내는 형태"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 6, 정말 단단하게 마무리하셨어요.
지난 시간 우리는 답변이 흘러 도착하는 진행 을 익히셨어요.
.call() 을 .stream().content() 로 바꾸는 한 줄, Flux<String> 의 받는 모양, text/event-stream 미디어 타입, MessageChatMemoryAdvisor.adviseStream + ChatClientMessageAggregator 의 청크 누적 —
여섯 가지 도구로 캐릭터가 입을 떼고 천천히 말을 잇는 UX 를 직접 만들었죠.
체감 대기 시간 4 배 단축이었어요.
그리고 지난 시간 마무리에서 제가 또 복선을 슬쩍 던지고 도망갔어요.
"텍스트는 흘러 도착했지만, 이미지는 한 번에 큰 payload 가 도착한다. Day 7 의 이미지 생성은 어느 쪽도 아닌 제 3 의 호흡이에요. 흘려보낼 것이 없거든요. 응답 시간이 수 초~수십 초, 비용은 텍스트 호출의 수십 배.
text/event-stream의 흘려보내는 방식이 안 통하는 부분이에요."
오늘이 그 약속을 펼치는 날입니다.
지난 시간 우리가 만든 흘러오는 토큰들 을 한 번 더 떠올려 봅시다.
사용자가 "오늘 진짜 별로였어" 라고 입력하면, 0.6 초만에 첫 토큰이 도착하고 한 글자씩 캐릭터의 말풍선에 쌓여갔죠.
그런데 — 캐릭터의 얼굴 그림 은 어디서 왔을까요? 지금 우리 ai-friends 의 캐릭터들 (쿠로미·마이멜로디·피카츄...) 은 고정 프로필 이미지 URL 을 그냥 적어두고 쓰고 있어요.
누군가 디자이너에게 의뢰해서 일러스트 5 장을 받아둔 그런 거예요.
오늘은 그 장면을 한 단계 진화시킵니다. 사용자가 캐릭터를 만들 때, 거기서 AI 화가에게 그림을 의뢰하는 방식으로요.
💡 오늘 수업의 핵심 "ChatModel 의 자매 추상화 ImageModel — 그림 의뢰 한 줄, 응답은 다시 JSON 으로 회귀"
오늘 수업은 한 문장으로 요약돼요.
"텍스트 생성 (
ChatModel) 과 같은 방식으로 그림 생성 (ImageModel) 도 한 줄짜리 추상화로 풀린다. 단 응답은 흘려보낼 수 없는 큰 payload 라 다시 JSONApiResponse<T>로 회귀한다."
여기서 네 가지 도구가 등장해요. 지난 시간 Day 6 에서 흘려보내는 채널 을 익혔다면, 오늘은 한 방에 도착하는 큰 payload 를 다루는 채널 부분이에요.
ImageModel 인터페이스 — ChatModel 의 자매 추상화.
Spring AI 가 생성 종류별 로 모델을 추상화한 또 하나의 주제예요.
ChatModel.call(prompt) → ChatResponse 의 모양 그대로, ImageModel.call(imagePrompt) → ImageResponse 로 풀려요.
Day 2 에서 익힌 프로바이더 추상화가 이미지 도메인에서도 똑같이 동작한다는 게 오늘의 핵심 감각이에요.
Pollinations.ai 무료 프로바이더 — 학생 진입장벽을 최저 로 낮추는 무료 옵션이에요.
API 키도 안 받고, 회원가입도 안 받고, 그냥 URL 한 번 호출하면 그림이 떨어져요.
학습 단계에선 이게 정답 부분이에요.
DALL-E · Midjourney · Stability AI 같은 유료 옵션은 비용 감각 매트릭스 에서만 다루고 실습엔 안 씁니다.
응답 패턴 회귀 — text/event-stream 안 씁니다.
다시 application/json + ApiResponse<T> 로 돌아와요.
Day 6 Step 4 에서 짚었던 ApiResponse 표준 패턴의 왜 일반 패턴이 표준인지 가 오늘 다시 한 번 또렷이 보일 부분 에요.
흘려보낼 게 없으면 표준 패턴으로 회귀한다 는 감각이 오늘 잡힐 거예요.
비용 가드 + 일일 호출 제한 — 이미지 생성 호출 한 번 이 텍스트 LLM 호출 수십 번 분량의 비용이에요.
무료 옵션을 쓴다 해도 비용 감각 자체 는 처음부터 적어둬야 해요.
Step 5 에서 일일 호출 제한 + 비용 에코 같은 가드를 손으로 깔아봅니다.
Day 10 비디오 생성 의 비용 경고가 오늘 미리 한 단계 가벼운 모양으로 등장해요.
이 넷이 들어오면, 사용자가 만든 캐릭터마다 고유한 일러스트 가 동적으로 생성돼요. 같은 도메인, 같은 ChatMemory, 같은 SSE 채팅 — 그림 한 장 이 더해졌을 뿐인데 ai-friends 의 게임 세계가 내 캐릭터 라는 감각을 얻습니다.
🙋 한 학생의 걱정
"튜터님, 지난 시간 Day 6 끝나고 Reactor
Flux도 그럭저럭 받아들였는데, 오늘 또ImageModel이라는 새 추상화에 Pollinations.ai 라는 처음 듣는 프로바이더, 게다가 base64 응답 / 이미지 파일 저장 / 멀티파트까지 한꺼번에 등장한다고요? 머리에 안 들어와요... 이미지 처리는 무거운 분야 같아서 무서워요."
그 걱정 너무 잘 알아요. 세 가지를 짧게 풀어드릴게요.
첫째, 오늘 새로 외울 핵심 은 세 단어 예요 — ImageModel / ImagePrompt / ImageResponse. 지난 시간 Day 6 의 .stream().content() / Flux<String> / text/event-stream 와 같은 세 단어 호흡이에요. 그리고 이 셋은 —
Day 1 부터 익힌 ChatModel / Prompt / ChatResponse 의 자매 예요. 완전히 새로 배우는 게 아니라 같은 패턴이 다른 도메인에 한 번 더 인 거예요. 손이 한 번에 들어옵니다.
둘째, base64 / 멀티파트 같은 무서운 단어들은 — 오늘 대부분 안 만져요. Pollinations.ai 무료 프로바이더는 그림을 URL 형태 로 던져줘요. 우리는 그 URL 을 받아서 프론트가 띄우게 하거나 서버가 한 번 다운로드해서 로컬에 저장 하는 정도만 합니다.
base64 인라인 응답은 유료 프로바이더의 다른 모양 이라 비교 한 줄로만 다루고 넘어가요.
학생이 손으로 만지는 건 URL 받기 → 파일 저장 이 전부예요.
셋째, 비용 폭발 시나리오 는 경고만 하고 안 만들어요. 예를 들어 대화마다 배경 일러스트를 새로 생성 같은 형태 — 이론상 가능하지만 비용이 미친 듯이 폭발 해요. 오늘 우리가 만들 건 캐릭터 만들기 시 1회 자동 (Step 8) + 챗 셀카 요청마다 N회 단, 가드로 통제 (Step 9) 의 콤보 형태예요.
언제 그리고 안 그릴지의 정책 결정 이 오늘 코드보다 더 중요한 부분라는 걸 미리 적어둬요.
요약하자면 오늘 새로 외울 건 세 가지의 단어 예요 — ImageModel / ImagePrompt / ImageResponse. 그리고 세 가지의 결정 — 언제 그릴지 / 어디 저장할지 / 비용을 어떻게 가둘지. 지난 시간과 같은 호흡으로 풀면 됩니다.
🚨 비용 경고 — 이미지 생성은 텍스트 LLM 의 수십 배 부터 시작합니다
오늘 본 Step 으로 들어가기 전에 — 비용 감각 을 한 번 적어둘게요. 본격 가드는 Step 5 에서 손으로 깔지만, 왜 그렇게 일찍 가드를 넣는지 의 근거를 미리 안내합니다.
⚠ 이미지 생성 1 회 호출의 비용 감각 (2026-04 기준 대략)
모델 1 장 생성 비용 (대략) 텍스트 LLM 환산 Pollinations.ai 무료 (rate-limited) — Gemini Imagen 3 (무료 할당 한도 내) 무료 (월 한도 있음) — DALL-E 3 (Standard) $0.04 / 장 Gemini Flash 텍스트 호출 ≈ 80 회 분량 DALL-E 3 (HD) $0.08 / 장 Gemini Flash 텍스트 호출 ≈ 160 회 분량 Midjourney v6 (월 구독) 월 $30~ — 본 강의 실습은 Pollinations.ai 무료 + Gemini Imagen 무료 할당 으로만 갑니다. 유료 모델은 비교 매트릭스에서만 언급하고 학생 실습 코드에는 들어가지 않아요.
왜 이렇게까지 비용을 일찍 넣는가 — 텍스트 LLM 은 1 회 호출이 0.001 달러 수준 이라 실수로 100 회 보내도 0.1 달러 입니다. 그런데 이미지 생성은 실수로 100 회 보내면 4~8 달러 — 3 부분 차이 가 나요. 대화마다 배경 그림 새로 생성 같은 장면을 무심코 만들면 하룻밤에 청구서가 수십 달러 가 되는 부분이에요.
Day 10 비디오 생성에선 비용 폭발 이 수백 달러 단위 로 또 한 번 점프해요. 오늘 잡는 비용 감각이 그때의 전조 훈련 부분이에요. 가드를 깔 줄 아는 손이 오늘 만들어집니다.
학습 목표
ImageModel자매 추상화 를 익히고,ChatModel과 같은 식의 프로바이더 추상화가 이미지 도메인에서도 동일하게 동작함을 체득합니다.- Pollinations.ai 무료 프로바이더 환경 설정을 손으로 깔고, 학생 진입장벽 최저 의 학습 환경에서 첫 그림을 받아봅니다.
- 이미지 생성 호출 → 응답 처리 → DTO 변환 → 컨트롤러 응답 과정을 한 번 끝까지 흘려보면서, ApiResponse 표준 패턴 회귀 를 직접 손으로 만집니다.
- 일일 호출 제한 + 비용 에코 같은 가드를 학습용 단순 모양 으로 깔아두고, 언제 그리고 안 그릴지 의 정책을 한 줄로 정리합니다.
- ai-friends 의 캐릭터 프로필 동적 생성 컨트롤러 / 서비스 / 로컬 저장 흐름까지 통합해서, 내가 만든 캐릭터마다 고유한 일러스트 가 떠 있는 장면을 직접 만들어 봅니다.
- ai-friends 본 게임에 prod 결합 — 캐릭터 만들기 5트랙 외모 선택 (4 프리셋 + 커스텀 자동 생성) + 챗 셀카 요청 분기 (외모 일관성 prompt 합성 + 가드 한도 초과 시 캐릭터 인격 우회) 까지 한 Day 안에 넣어서, Step 5 가드가 prod 에서 진짜 일하는 부분을 손으로 만집니다.
Step 1: "왜 이미지는 못 흘려보내나" — Day 6 과 정반대 호흡, ApiResponse 표준 패턴의 회귀
자, 본 Step 으로 들어가기 전에 — 지난 시간 우리가 쥐었던 흐름의 장면 을 한 번 더 손에 떠올려 봅시다.
사용자가 "오늘 진짜 별로였어" 라고 입력하면, 0.6 초만에 첫 토큰이 도착하고 "에이," 부터 시작해서 한 글자씩 캐릭터의 말풍선에 차곡차곡 쌓여갔어요. 1.0 초쯤엔 "에이, 무슨 일 있어?" 까지, 2.0 초쯤엔 "에이, 무슨 일 있어? 오늘 하루 힘들었" 까지, 그리고 2.3 초에 완성되는.
지난 시간 체감 대기 시간 4 배 단축 의 그 감각이 손에 살아있죠?
그리고 Day 6 Step 4 에서 우리가 결정 문서화 했던 한 줄을 떠올려 봅시다.
"미디어타입의 본질이 근본적으로 JSON 과 비호환인 경우, ApiResponse 래핑은 정당한 예외다."
이 한 줄이 지난 시간 우리가 예외 를 인정했던 근거였어요. SSE (text/event-stream) 의 청크 단위 본문은 JSON wrapper 가 낄 부분이 없으니까, 정상 응답을 raw Flux<String> 으로 흘려보내는 게 정당한 예외 라고 정리했죠.
그런데 오늘 — 그 예외가 다시 닫힙니다. 이미지 응답은 다시 표준 패턴 으로 회귀해요. ResponseEntity<ApiResponse<ImageResponseDto>> 같은 평범한 모양으로 돌아옵니다. 왜 그런가 — 이 주제를 푸는 게 Step 1 의 미션이에요.
1. 반쪽 이미지 를 상상해봅시다
질문 하나 드릴게요. 만약 우리가 지난 시간 익힌 SSE 채널로 이미지를 흘려보내려고 한다고 상상해봅시다. 1024 × 1024 픽셀 짜리 그림 한 장을 위에서부터 차례로 청크 단위로 흘려보낸다고 쳐요. 0.5 초쯤엔 위쪽 30% 만 도착했고, 1.0 초쯤엔 60%, 1.5 초쯤엔 90%, 그리고 2.0 초에 완성되는.
자, 0.5 초 시점의 위쪽 30% 만 받은 상태 — 그 반쪽 그림 이 의미가 있을까요?
답은 — 거의 없어요. 머리 윗부분만 그려진 캐릭터 이미지를 상상해보세요. 머리는 보이지만 얼굴이 없어요. 옷도 없어요. 배경도 없어요. 사용자 입장에선 "이건 깨진 그림이네" 라고 인식하지, "아, 위쪽부터 그려지고 있구나" 라고 인식하지 않아요. 그림은 완성되기 전엔 의미가 없는 식의 응답이에요.
이게 Day 6 의 흘려보내는 형태 과의 결정적인 차이예요. 텍스트는 — "에이, 무슨 일 있어?" 까지 받았을 때 사용자가 이미 그 부분의 의미 를 온전히 읽어낼 수 있어요. "에이" 만으로도 위로의 톤 이라는 감각이 손에 잡히고, "무슨 일" 까지 가면 질문 이라는 형태가 잡혀요. 부분이 부분의 의미를 가져요.
이미지는 정반대예요. 픽셀 30% 만 받은 상태는 부분의 의미가 없는 노이즈 에 가까워요. 전체가 와야 비로소 한 장의 그림. 이 차이가 — 오늘 SSE 가 안 통하는 원리적인 이유예요. 흘려보낼 의미 가 없거든요.
2. text/event-stream 의 본질을 다시 떠올려봐요
Day 6 Step 3 에서 우리는 SSE 의 본질을 한 줄로 정리했어요.
"
text/event-stream은 청크 단위 본문 — 각 청크는 빈 줄로 구분되며, 청크가 흐를 때마다 클라이언트가 즉시 받아 처리한다."
이 본질의 핵심은 청크 라는 단위에 있어요. SSE 가 정당한 부분는 — 청크 하나가 그 자체로 의미를 가지거나, 청크들의 누적이 점진적 의미를 만드는 형태 부분이에요. 텍스트 토큰은 후자 에 정확히 부합해요. 토큰들이 누적되며 의미가 점점 또렷해지거든요.
이미지 응답은 둘 다 아니에요.
- 청크 하나의 의미 — 픽셀 100 개 받았다고 그게 의미를 가지나? 아뇨. 그냥 컬러 데이터의 일부.
- 청크 누적의 점진적 의미 — 30% → 60% → 90% 가 점진적 의미를 만드나? 아뇨. 100% 도착할 때까지 의미는 0 이고 그 시점에 한 번에 의미가 1 로 점프해요.
| 응답 종류 | 청크 하나의 의미 | 청크 누적의 점진적 의미 | SSE 적합? |
|---|---|---|---|
| 텍스트 토큰 (Day 6) | 약함 (한 토큰 = 한 단어 일부) | 강함 (누적되며 문장이 또렷해짐) | ✅ 정당 |
| 이미지 픽셀 (Day 7) | 거의 없음 | 거의 없음 (완성 전엔 의미 0) | ❌ 부적합 |
| 비디오 프레임 (Day 10) | 강함 (프레임 한 장 = 한 순간) | 강함 (프레임 누적이 움직임) | ✅ 가능 |
비디오 프레임 은 사실 SSE 와 비슷한 방식으로 흘려보낼 수 있는 모습입니다. 다만 비디오는 별도의 streaming 프로토콜 (HLS · DASH 같은) 이 표준이라 SSE 보단 더 특화된 채널을 써요. Day 10 비디오 생성 때 그 모양을 한 번 더 들여다볼 거예요.
3. 🙋 한 학생의 날카로운 질문
"튜터님, 잠깐요. HTTP 자체는 byte 단위로 점진적 다운로드 가 가능하잖아요. 큰 png 파일을 받을 때도 결국 byte stream 으로 조금씩 받는 거 아니에요? 그럼 그것도 일종의 흘려보내는 아닌가요? 왜 SSE 모양으로는 안 된다는 거예요?"
🔥 정말 날카로운 질문이에요. 이 질문에 답하려면 두 레이어를 분리 해서 봐야 해요.
transport 레이어 (TCP / HTTP byte stream) 와 application 레이어 (SSE 의미적 메시지) 는 다른 주제예요. 분리해서 짚을게요.
| 레이어 | 단위 | 의미 부여 |
|---|---|---|
| transport (TCP / HTTP byte) | byte (8 bit) | 없음 — 그냥 byte 의 |
| application (SSE event) | event (data: ...\n\n 단위) |
있음 — 각 event 가 application 레벨에서 의미 있는 메시지 |
학생분이 떠올린 byte 단위 점진적 다운로드 는 transport 레이어입니다. 어떤 HTTP 응답이든 어차피 byte stream 으로 흘러요 — 큰 png 파일이든, JSON 응답이든, SSE 응답이든. 이건 공통 부분이에요.
SSE 의 흘려보내는 결은 그 위에 한 단계 더 — application 레이어에서 의미적 청크 단위 를 정의 한 거예요. data: 에이,\n\n 한 덩어리가 application 입장에서 완결된 한 메시지. 클라이언트의 EventSource 가 그걸 받자마자 화면에 그릴 수 있어요. 토큰 1 개의 의미 가 있으니까요.
이미지는 — application 레벨에서 "한 청크 = ?" 를 정의할 수가 없어요. 픽셀 100 개? 의미 없음. 1 줄 (= 1024 픽셀)? 의미 없음. 이미지 절반? 여전히 의미 없음. application 레벨의 의미 단위 가 정의되지 않는 응답 은 SSE 모양으로 흘려보낼 수 없는 거예요.
(참고로, byte 단위 점진적 다운로드 자체는 png 파일 받을 때도 일어나요. 다만 그건 transport 의 흐름 일 뿐, application 의 streaming UX 가 아니에요. 사용자 입장에선 완성되기 전까지 화면에 못 띄움. — 결국 블로킹 부분이에요.)
요약하자면 — transport 의 byte vs application 의 의미적 청크 — 두 레이어를 헷갈리지 마세요. 이미지는 transport 는 흐를 수 있어도 application 은 흐를 의미 단위가 없어 서 SSE 가 안 통하는 거예요.
4. 응답 모양 의 두 분기 (URL vs base64)
오늘 우리가 다룰 이미지 생성 응답이 어떤 모양으로 떨어지는가 — 이걸 짧게 짚고 갈게요. 프로바이더마다 응답 모양이 두 가지 로 갈려요.
| 응답 모양 | 본문 | 장점 | 단점 |
|---|---|---|---|
| URL | https://image.pollinations.ai/prompt/...png 같은 공개 URL |
응답 본문 가벼움 (수백 byte), 프론트가 그대로 <img src> 가능 |
프로바이더 서버가 살아있어야 URL 이 유효 (서버가 내려가면 깨짐) |
| base64 | iVBORw0KGgo... 같은 인라인 Base64 문자열 |
응답 자체가 자급자족 (프로바이더 서버 의존 X) | 응답 본문 무거움 (수백 KB 이상), 직렬화·전송 비용 ↑ |
오늘 우리가 채택할 Pollinations.ai 는 URL 모양 으로 응답해요. 학생 진입장벽이 최저 인 옵션이에요 — 응답이 가볍고, 프론트가 그대로 <img> 태그에 적을 수 있고, 서버는 그냥 URL 한 줄 만 클라이언트한테 흘려보내면 끝. 무겁지 않아요.
다른 프로바이더의 응답 모양은 — DALL-E 3 / Stability AI 가 URL 또는 base64 둘 다 지원 (옵션으로 선택), Gemini Imagen 도 URL 또는 base64 둘 다.
우리 강의는 URL 모양 단일 로 갑니다.
base64 처리는 비교 한 줄로만 다루고 학생이 손으로 만지진 않아요.
(Step 5 에서 응답 DTO 변환 다룰 때 URL → 우리 서버에 다운로드해서 로컬 저장 하는 진행까지 가는데, 그것도 base64 감각은 아니고 외부 URL 파일을 한 번 다운로드 하는 손쉬운 모양이에요.)
⚠ Pollinations.ai 는 완전 무료 지만 — Step 2 의 매트릭스에서 짚을 비용 감각이 살아나는 부분은 학생이 유료 프로바이더로 갈아끼우게 됐을 때예요. 무료라고 비용 감각 자체 를 안 잡고 가는 게 아니라, 무료 프로바이더 위에서도 언제 그릴지의 정책 은 처음부터 적어둬야 해요. (오프닝 🚨 박스에서 짚었던 그 부분.)
5. 💡 튜터의 결론 — Step 1 한 줄
"이미지 응답은 완성되기 전엔 의미 없는 식이라 SSE 가 안 통한다. 그래서 표준 패턴 ApiResponse 로 회귀한다. 이 회귀가 기술 선택의 후퇴 가 아니라, 원리적으로 자연스러운 부분 라는 감각을 익히는 게 오늘의 출발점이다."
이 원리를 익히고 — 이제 어떤 인터페이스로 그 한 방 큰 payload 를 받을 것인가 라는 다음 주제로 넘어갑니다. Spring AI 가 우리한테 던져주는 도구가 — 지난 시간 익힌 ChatModel 의 자매 추상화 예요. 같은 패턴, 다른 도메인. Step 2 에서 그 인터페이스의 모양을 공식 라이브러리 시그니처 인용 으로 펼쳐봅시다.
Step 2: `ImageModel` 자매 추상화 — `ChatModel` 과 같은 식의 인터페이스
자, Step 1 에서 우리는 왜 이미지는 못 흘려보내나 의 원리를 익히셨어요. 이미지 응답은 완성되기 전엔 의미 없는 식이라 SSE 가 안 통하고, 표준 패턴 (ApiResponse) 으로 회귀한다는 부분까지 정리했죠. 그러면 이제 어떤 인터페이스로 그 한 방 큰 payload 를 받을 것인가 가 다음 학습 포인트이에요.
답부터 말씀드리면 — ImageModel 인터페이스, ChatModel 의 자매 추상화. Day 2 에서 우리가 후드를 열어 들여다본 그 형태이 — 이미지 도메인에서 한 번 더 펼쳐져요. 같은 패턴, 다른 도메인.
1. Day 2 의 장면을 한 번 더 떠올려봅시다
Day 2 Step 1 에서 우리가 후드를 열어 들여다본 장면을 떠올려봅시다. 그날 우리는 HelloAiController 에서 — 구현체가 아니라 ChatModel 인터페이스로 주입 받았죠.
// Day 2 의 ChatModel 추상화 — 인터페이스로 주입
private final ChatModel chatModel; // ← OpenAiChatModel 이 아니라 ChatModel
그날 우리가 정리한 한 줄이 이거였어요.
"구현체로 주입하면 갈아끼움이 막힌다. 인터페이스로 주입하면
spring.ai.model.chat프로퍼티 한 줄로 모델이 바뀐다. 이게 프로바이더 추상화의 본질."
2. Spring AI 1.1.x 의 ImageModel 시그니처 펼치기
자, 그 느낌이 이미지 도메인에서 그대로 펼쳐진 모양 — Spring AI 1.1.x 의 org.springframework.ai.image 패키지 안에서 공식 인터페이스 시그니처 를 펼쳐볼게요.
다섯 가지 인터페이스/클래스가 등장해요.
(이 코드 블록은 Spring AI 1.1.x 라이브러리의 핵심 인터페이스 시그니처 인용 부분이에요.
우리 코드베이스의 신규 클래스는 아직 등장하지 않아요.
Step 3 부터 손으로 깔 거예요.)
// Spring AI 1.1.x — org.springframework.ai.image 패키지의 핵심 시그니처
// ① ImageModel — Model<ImagePrompt, ImageResponse> 를 상속한 인터페이스
public interface ImageModel extends Model<ImagePrompt, ImageResponse> {
@Override
ImageResponse call(ImagePrompt request);
}
// ② ImagePrompt — ModelRequest<List<ImageMessage>>
// 텍스트 프롬프트 (List<ImageMessage>) + 옵션 (ImageOptions) 두 자루를 들고 다님
public class ImagePrompt implements ModelRequest<List<ImageMessage>> {
private final List<ImageMessage> messages;
private final ImageOptions imageModelOptions;
// 생성자 + 게터들...
}
// ③ ImageMessage — 텍스트 프롬프트 + (선택) weight
// 예: "a cute cartoon kitten with pink ribbon" (weight = 1.0f)
public class ImageMessage {
private final String text;
private final Float weight;
// ...
}
// ④ ImageOptions — 모델명 · width · height · N · responseFormat 등 호출 옵션
// 프로바이더별 옵션은 ImageOptionsBuilder 또는 OpenAiImageOptions 같은 구현체로 넣음
public interface ImageOptions extends ModelOptions {
String getModel();
Integer getN();
Integer getWidth();
Integer getHeight();
String getResponseFormat(); // "url" or "b64_json"
String getStyle();
}
// ⑤ ImageResponse — ModelResponse<ImageGeneration>
// 안에 List<ImageGeneration>, 각 ImageGeneration 안에 Image (url 또는 b64Json)
public class ImageResponse implements ModelResponse<ImageGeneration> {
private final List<ImageGeneration> imageGenerations;
private final ImageResponseMetadata imageResponseMetadata;
// ...
}
public class Image {
private final String url; // URL 모양일 때
private final String b64Json; // base64 모양일 때
// ...
}
이 다섯 인터페이스의 느낌이 — Day 2 에서 익힌 ChatModel / Prompt / ChatResponse / ChatOptions / Generation 과 완전히 똑같아요. 한 표로 정리하면 한눈에 들어와요.
| 텍스트 도메인 (Day 1~6) | 이미지 도메인 (Day 7) | 역할 |
|---|---|---|
ChatModel |
ImageModel |
모델 호출 인터페이스 (Model<Request, Response> 상속) |
Prompt |
ImagePrompt |
호출 입력 (메시지 + 옵션) |
Message |
ImageMessage |
메시지 한 줄 (텍스트 + 메타) |
ChatOptions |
ImageOptions |
호출 옵션 (모델명 · 사이즈 · N 등) |
ChatResponse → Generation |
ImageResponse → ImageGeneration → Image |
호출 출력 (생성 결과) |
완전한 자매 관계 죠. Day 2 에서 우리가 적어둔 ChatModel 추상화의 감각이 — 이미지 도메인에서 그대로 적용 가능해요. 학생 입장에선 "또 새 API 학습이네" 가 아니라 "같은 식의 형제가 한 번 더" 인 거예요. 손이 한 번에 들어옵니다.
3. 무료 옵션 매트릭스
자, 인터페이스의 모양을 익히었으니 — 어떤 프로바이더의 구현체 를 우리가 쓸지 매트릭스로 짚어봅시다. 오프닝 🚨 박스에서 비용 감각 을 미리 적어뒀으니, 여기선 기술 선택의 결정에 집중할게요.
| 프로바이더 | 무료 여부 | 응답 모양 | 한국어 프롬프트 | Spring AI 공식 starter | 본 강의 채택 |
|---|---|---|---|---|---|
| Pollinations.ai | ✅ 무료 / 무가입 | URL | ✅ OK | ❌ 없음 (Custom 구현) | 학생 1순위 (실습) |
| Gemini Imagen | ✅ 무료 할당 한도 | URL + b64 | ✅ OK | ✅ spring-ai-starter-model-vertex-ai-imagen (Vertex AI 키 필요) |
튜터 시연 보조 |
| Ollama 로컬 SDXL | ✅ 무료 / 로컬 | b64 | ⚠ 약함 | ⚠ 1.1.x 미보장 (ollama 의 image 지원은 모델 따라) | Day 후반 옵션 |
| DALL-E 3 (OpenAI) | ❌ 유료 ($0.04/장) | URL + b64 | ✅ OK | ✅ spring-ai-starter-model-openai 의 OpenAiImageModel |
비교 매트릭스에만 (실습 X) |
| Stability AI | ❌ 유료 | b64 | ⚠ 약함 | ✅ spring-ai-starter-model-stabilityai |
비교 매트릭스에만 (실습 X) |
본 강의 실습은 Pollinations.ai 단일 채택 이에요. 이유 세 가지를 짧게 풀어드릴게요.
- 무료 + 무가입 —
.env에 키 한 줄 적을 필요도 없어요. 학생 진입장벽 최저. 그냥 URL 한 번 호출하면 그림이 떨어져요. - 공식 starter 가 없는 프로바이더 — 이게 오히려 학습 자산 부분이에요. 우리가
ImageModel인터페이스를 직접 구현 하는 장면을 만들 수 있거든요. Custom 어댑터 를 손으로 깐다는 건 — 추상화의 진짜 가치 를 손끝으로 체득하는 부분이에요. - 응답이 URL 모양 — 응답 본문 가벼움, 프론트가 그대로
<img>태그 적기 가능. base64 직렬화 비용 없음.
Gemini Imagen 은 튜터 시연 시 "Pollinations 가 갑자기 정전이면 어떻게 하지?" 같은 부분에서 대체 옵션 으로 꺼낼 수 있어요. 학생 실습 코드엔 안 들어가요. DALL-E 3 / Stability AI 는 비용 감각의 비교 부분에서만 등장해요.
4. Custom 구현체 를 만들지만, 주입은 인터페이스로
자, 여기서 본 강의의 프로바이더 추상화 원칙을 한 번 더 짚고 가요.
우리가 다음 Step 부터 Custom ImageModel 구현체 를 만들 거예요.
클래스 이름은 PollinationsImageModel 같은 모양이 될 거예요.
근데 — 서비스/컨트롤러에 주입할 때는 그 구현체 타입이 아니라 ImageModel 인터페이스로 주입할 거예요.
// 미래의 우리 서비스 (Step 4 에서 등장 예정)
@Service
public class CharacterImageService {
private final ImageModel imageModel; // ✅ ImageModel 인터페이스로 주입
// private final PollinationsImageModel imageModel; // ❌ 구현체 직접 주입 금지
public CharacterImageService(ImageModel imageModel) {
this.imageModel = imageModel;
}
// ...
}
"왜 굳이 이렇게 고집스럽게?" 라는 의문이 들 수 있어요. 답은 — 갈아끼움의 자유 때문이에요. 만약 6 개월 뒤 Pollinations.ai 가 갑자기 유료화되거나 서비스 종료되면 — Gemini Imagen 으로 갈아탈 수 있어야 해요. 인터페이스로 주입해뒀으면 Bean 등록만 바꾸면 끝. 서비스 코드는 한 줄도 안 건드려요. 구현체 직접 주입했으면 — 서비스 코드 전체 검색-치환 의이 펼쳐져요. 이 차이가 —
1 시간 vs 1 일 단위의 작업량 차이를 만들어요.
이게 Day 2 에서 우리가 적어둔 프로바이더 추상화의 감각 부분이에요. 이미지 도메인에서 한 번 더 같은 방식으로.
5. 🙋 한 학생의 날카로운 질문
"튜터님, Spring AI 가 공식 starter 를 안 만들어준 프로바이더 (Pollinations.ai) 를 굳이 학생 1순위로 채택하는 게 맞나요? Custom
ImageModel을 직접 짜야 한다는 게 무서워요. 공식 starter 가 잘 만들어진 DALL-E 3 같은 옵션을 쓰는 게 더 안전하지 않을까요?"
🔥 정말 좋은 질문이에요. 이 질문에 답하면 — 추상화의 진짜 가치 가 한 번에 손에 잡혀요.
세 가지로 풀어드릴게요.
첫째, 공식 starter 가 없다 = 무섭다 라는 등식이 사실은 깨진 부분이에요. 추상화의 진짜 가치는 — 공식 starter 의 유무에 갇히지 않는 데 있어요. ImageModel 인터페이스만 구현하면 어떤 프로바이더라도 같은 방식으로 갈아끼울 수 있다. 공식 starter 는 편리한 자동 설정 일 뿐, 추상화의 본질 이 아니에요.
Custom 구현체도 인터페이스를 따르는 한 공식 starter 와 동일한 자리 에 잡혀요.
둘째, 직접 짜는 코드의 양 이 무겁지 않아요. Step 3 에서 보겠지만 — PollinationsImageModel 의 핵심 로직은 50 라인 정도 예요. RestClient 한 번 호출하고, 응답 URL 을 Image 객체로 감싸서 ImageResponse 에 담는 진행이 전부. 우리가 Day 1 에서 적은 RestClient 감각 + Day 2 에서 적은 ChatModel 추상화 감각이 그대로 이어져요.
학생이 새로 외울 게 거의 없어요.
셋째, 비용 0 이 학습 단계에선 가장 큰 가치 예요. DALL-E 3 가 안전해 보이긴 하지만 — 학생이 실수로 100 회 호출 하면 4 달러 가 청구되거든요. Pollinations.ai 는 실수로 1000 회 호출해도 0 달러. 학습 단계에선 실수의 비용이 0 인 환경 이 압도적으로 안전해요. 공식 starter 의 안전 보다 비용의 안전 이 더 큰 가치인 부분이에요.
요약하자면 — Custom 구현체 직접 짜기 가 무섭게 들리지만, 실은 추상화의 가치를 손끝으로 체득하는 부분이에요. 그리고 그 코드 양은 50 라인 정도라 무겁지 않아요. Step 3 에서 한 줄씩 풀어가니까 따라오시면 됩니다.
6. 💡 튜터의 결론 — Step 2 한 줄
"
ChatModel의 자매 추상화ImageModel— 추상화의 느낌이 같아서 손이 한 번에 들어온다. 인터페이스 5 개 (ImageModel·ImagePrompt·ImageMessage·ImageOptions·ImageResponse) 의 시그니처가 Day 2 에서 익숙해진ChatModel/Prompt/Message/ChatOptions/ChatResponse와 완전한 자매. Pollinations.ai 의 Custom 구현체를 직접 짜더라도, 주입은 인터페이스로 — 감각이 그대로 살아있다."
Step 3: Pollinations.ai Custom `ImageModel` 어댑터 — 직접 짜는 50 라인
자, Step 2 에서 우리는 ImageModel 의 자매 추상화 를 익히셨어요.
그리고 한 학생이 정확한 걱정을 던졌죠 — "공식 starter 가 없는 Pollinations.ai 를 굳이 학생 1순위로?" 그 답으로 제가 "Custom 구현체도 50 라인 정도면 끝나요" 라고 말씀드렸어요.
오늘 — 그 50 라인을 직접 손으로 깔러 갑니다.
이 Step 의 진짜 가치 는 코드 양이 아니라 추상화의 가치를 손끝으로 체득하는 데 있어요. Day 2 에서 적은 프로바이더 추상화 가 — 공식 starter 의 유무와 무관하게 동작한다는 게 손에 잡힐 겁니다. ImageModel 인터페이스 하나만 구현하면 — 그 뒤로 서비스/컨트롤러 코드는 손도 안 대고 갈아끼움이 가능해요.
1. PollinationsImageModel 의 call() 본체 50 라인 ️
ImageModel 인터페이스를 구현 한 클래스가 필요해요. 인터페이스의 시그니처는 Step 2 에서 봤죠?
public interface ImageModel extends Model<ImagePrompt, ImageResponse> {
@Override
ImageResponse call(ImagePrompt request);
}
call(ImagePrompt) → ImageResponse 한 메서드만 구현하면 끝이에요. 클래스 어노테이션도 없어요. 생성자 + 메서드 하나 의 방식으로 풀려요. 우리 코드베이스에 들어간 진짜 모양을 바로 펼쳐볼게요.
package kr.spartaclub.aifriends.image.service;
import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageGeneration;
import org.springframework.ai.image.ImageMessage;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Day 7 Step 3 — Pollinations.ai 용 커스텀 {@link ImageModel} 어댑터.
*
* <p>Pollinations.ai 는 별도의 인증 키 없이 {@code GET https://image.pollinations.ai/prompt/{prompt}?model=flux&width=1024&height=1024}
* 형태로 호출하면 그대로 PNG/JPEG 이미지 바이트가 응답으로 내려온다. URL 자체가 결정론적이라
* 같은 prompt+seed 조합은 같은 이미지를 돌려준다 (캐싱 친화적).</p>
*
* <p><b>이 어댑터는 외부 호출을 직접 수행하지 않는다.</b> Pollinations.ai 의 prompt URL 호출 = 이미지 응답이
* idempotent 하므로, 어댑터는 URL 만 빌드해 {@link Image#getUrl()} 에 담아 돌려주고,
* 실제 다운로드는 호출자({@link ImageGenerationService}) 가 RestClient 로 별도 처리한다.
* 이렇게 분리하면 {@link ImageModel} 추상화는 "프롬프트 → 이미지 식별자(URL/b64)" 까지로 좁아지고,
* 다운로드/저장은 호출자의 책임으로 명확히 갈린다 — 그 결과 OpenAI · Vertex · Stability 등 다른
* 프로바이더로 갈아끼울 때도 같은 패턴이 그대로 통한다.</p>
*
* @see PollinationsImageOptions
*/
public class PollinationsImageModel implements ImageModel {
private final String baseUrl;
public PollinationsImageModel(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public ImageResponse call(ImagePrompt request) {
List<ImageMessage> messages = request.getInstructions();
String promptText = messages.isEmpty() ? "" : messages.get(0).getText();
ImageOptions opts = request.getOptions();
String model = (opts != null && opts.getModel() != null) ? opts.getModel() : "flux";
Integer width = (opts != null && opts.getWidth() != null) ? opts.getWidth() : 1024;
Integer height = (opts != null && opts.getHeight() != null) ? opts.getHeight() : 1024;
Long seed = (opts instanceof PollinationsImageOptions p) ? p.getSeed() : null;
String encoded = URLEncoder.encode(promptText, StandardCharsets.UTF_8);
StringBuilder url = new StringBuilder(baseUrl);
url.append("/prompt/").append(encoded);
url.append("?model=").append(model);
url.append("&width=").append(width);
url.append("&height=").append(height);
url.append("&nologo=true");
if (seed != null) {
url.append("&seed=").append(seed);
}
Image image = new Image(url.toString(), null);
ImageGeneration generation = new ImageGeneration(image);
return new ImageResponse(List.of(generation));
}
}
자, 한 줄씩 풀어볼게요.
상단 import — Spring AI 의 org.springframework.ai.image 패키지에서 5 개 타입 만 가져왔어요 (Image · ImageGeneration · ImageMessage · ImageModel · ImageOptions · ImagePrompt · ImageResponse). Step 2 에서 본 자매 인터페이스 5 종세트 그대로.
그리고 java.net.URLEncoder + StandardCharsets.UTF_8 — 한국어 프롬프트 같은 비ASCII 문자를 URL 에 안전하게 적기 위한 도구예요.
call(ImagePrompt request) 의 핵심 흐름 — 네 단계예요.
- 프롬프트 텍스트 꺼내기 —
request.getInstructions()로List<ImageMessage>를 받고, 첫 메시지의getText()를 꺼내요. (오늘 우리는 항상 메시지 한 개만 던질 거라messages.get(0)로 충분.)
옵션 꺼내기 — getModel() · getWidth() · getHeight() 는 표준 ImageOptions 인터페이스에서 바로 꺼낼 수 있어요.
seed 는 Pollinations 고유 옵션 이라 instanceof PollinationsImageOptions 패턴 매칭으로 우리 자체 옵션 클래스일 때만 꺼내요.
(Java 16+ 의 instanceof 패턴 매칭 문법, Day 1 selfcheck 에서 익혔죠.)
3.
URL 빌드 — URLEncoder.encode(promptText, StandardCharsets.UTF_8) 로 프롬프트를 퍼센트 인코딩 한 뒤, StringBuilder 로 base URL + /prompt/{인코딩된프롬프트} + 쿼리 파라미터 를 차곡차곡 쌓아요.
seed 는 있을 때만 추가 — 없으면 생략하는 옵셔널 쿼리 파라미터 의 감각.
4.
Spring AI 응답 객체로 감싸기 — new Image(url, null) 로 (URL 모양, base64 는 null) Image 한 장을 만들고 → new ImageGeneration(image) 로 한 번 더 감싸고 → new ImageResponse(List.of(generation)) 로 마지막 컨테이너에 적어 돌려줘요.
왜 이렇게 세 겹이나? — Spring AI 의 응답이 N 개 이미지 를 동시에 생성할 수 있는 방식으로 설계됐기 때문이에요 (DALL-E 의 n=4 옵션처럼).
우리는 한 장만 쓸 거지만 — 인터페이스의 모양을 따라 똑같이 감싸는 게 프로바이더 추상화 원칙의 감각이에요.
여기서 결정적인 한 줄 을 한 번 더 짚을게요. 클래스 상단의 javadoc 에 들어간 한 문장 —
"이 어댑터는 외부 호출을 직접 수행하지 않는다."
이 문장이 오늘의 가장 중요한 설계 결정 부분이에요.
Pollinations.ai 의 URL 은 idempotent 하기 때문에 — URL 을 빌드한 시점에 이미 이미지가 결정 돼요.
굳이 어댑터가 RestClient 호출을 직접 할 이유가 없어요.
URL 만 빌드해서 던지고 — 실제 다운로드는 호출자 (ImageGenerationService) 의 책임으로 분리.
이렇게 분리하면 — ImageModel 추상화의 느낌이 더 깔끔하고 (= 단일 책임 원칙) Step 4 에서 다운로더를 별도 인터페이스로 더 추상화 할 수 있어요.
(다른 프로바이더 — 예를 들어 POST 요청을 보내야 하는 OpenAI DALL-E — 의 어댑터는 여기서 RestClient 호출이 반드시 들어가요. URL 만 빌드해서 던질 수 없거든요. 그래서 그쪽 어댑터는 50 라인이 아니라 100~150 라인 정도가 돼요. 그래도 3 부분 수 는 안 넘어요. 추상화의 느낌이 얇은 덕분.)
3. PollinationsImageOptions 의 자체 옵션 클래스 ️
자, call() 본체에서 PollinationsImageOptions p 같은 패턴 매칭이 등장했죠? 왜 표준 ImageOptions 가 있는데 자체 옵션 클래스를 또 만들었을까 — 이 주제를 풀고 갈게요.
답은 — Pollinations 고유 파라미터 (seed) 를 흡수하기 위해 + Spring AI 1.1.x ImageOptions 인터페이스의 6 개 메서드 (model · n · width · height · responseFormat · style) 구현 의무 를 동시에 만족하기 위함이에요.
표준 인터페이스 위에 프로바이더 고유 필드 를 더 얹는 감각.
package kr.spartaclub.aifriends.image.service;
import org.springframework.ai.image.ImageOptions;
/**
* Day 7 Step 3 — Pollinations.ai 전용 ImageOptions 구현체.
*
* <p>{@link ImageOptions} 표준 인터페이스(model · width · height · style 등)에 더해
* Pollinations.ai 가 지원하는 {@code seed} 파라미터를 추가로 받기 위해 자체 클래스로 둔다.
* 표준 옵션만 필요한 경우 {@link org.springframework.ai.image.ImageOptionsBuilder} 로 충분하다.</p>
*
* <p>학생 입장에서 의미: <b>프로바이더 고유 옵션은 표준 인터페이스를 확장하는 자체 옵션 클래스</b>
* 로 흡수한다는 패턴을 보여준다. OpenAI 의 {@code OpenAiImageOptions} 도 동일한 방식으로
* {@code quality}, {@code user} 등을 추가 필드로 갖는다.</p>
*/
public class PollinationsImageOptions implements ImageOptions {
private final String model;
private final Integer width;
private final Integer height;
private final Long seed;
private final String style;
public PollinationsImageOptions(String model, Integer width, Integer height, Long seed, String style) {
this.model = model;
this.width = width;
this.height = height;
this.seed = seed;
this.style = style;
}
public static PollinationsImageOptions defaults() {
return new PollinationsImageOptions("flux", 1024, 1024, null, null);
}
public Long getSeed() {
return seed;
}
@Override
public Integer getN() {
return 1;
}
@Override
public String getModel() {
return model;
}
@Override
public Integer getWidth() {
return width;
}
@Override
public Integer getHeight() {
return height;
}
@Override
public String getResponseFormat() {
return "url";
}
@Override
public String getStyle() {
return style;
}
}
핵심 짚을 곳 두 군데예요.
첫째, getN() 이 항상 1 을 반환해요. Pollinations.ai 는 한 번 호출 = 한 장 의 식이라 N 개 동시 생성은 지원 안 해요. 그래서 고정값 1. DALL-E 의 OpenAiImageOptions 라면 getN() 이 사용자 입력값을 따라가지만, 우리 어댑터는 프로바이더의 한계 를 반영해서 고정해요.
둘째, getResponseFormat() 이 항상 "url" 을 반환해요. Pollinations 은 URL 모양 응답만 줘요 (base64 모양 없음). Step 1 매트릭스에서 짚었던 URL 모양 단일 채택 의 감각이 여기 들어가 있어요.
그리고 getSeed() 는 — ImageOptions 인터페이스에 없는 메서드예요.
우리가 추가로 적은 거예요.
call() 메서드에서 instanceof PollinationsImageOptions p 패턴 매칭으로 우리 자체 옵션일 때만 꺼내려고.
표준 인터페이스의 모양을 흐리지 않으면서 프로바이더 고유 파라미터를 흡수하는 패턴이에요.
4. @Bean ImageModel pollinationsImageModel(...)
자, 어댑터 클래스를 다 짰으니 — 이걸 Spring 빈으로 등록 해야 서비스가 주입받을 수 있어요. 그리고 여기가 — Step 2 에서 적어둔 프로바이더 추상화 원칙의 진짜 재등장 부분 예요.
package kr.spartaclub.aifriends.image.config;
import kr.spartaclub.aifriends.image.service.PollinationsImageModel;
import org.springframework.ai.image.ImageModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Day 7 Step 3 — Pollinations.ai 어댑터를 {@link ImageModel} 인터페이스 빈으로 등록.
*
* <p>리턴 타입은 일부러 구현체 {@code PollinationsImageModel} 이 아니라
* 인터페이스 {@link ImageModel} 로 선언한다. 서비스 계층은 인터페이스에만 의존하므로,
* Pollinations 대신 {@code OpenAiImageModel} 이나 {@code StabilityAiImageModel} 로
* 갈아끼우려면 이 빈만 교체하면 된다 (프로바이더 추상화 원칙).</p>
*/
@Configuration
public class PollinationsImageModelConfig {
@Bean
public ImageModel pollinationsImageModel(
@Value("${aifriends.image.pollinations.base-url:https://image.pollinations.ai}") String baseUrl) {
return new PollinationsImageModel(baseUrl);
}
}
자, 진하게 적어둘 한 줄 — @Bean 메서드의 반환 타입이 ImageModel 인터페이스 예요. 구현체 타입 (PollinationsImageModel) 이 아니에요.
왜 이게 결정적인가 — 만약 우리가 @Bean public PollinationsImageModel ... 로 적어뒀다면, 6 개월 뒤 Pollinations 가 종료됐을 때 —
ImageGenerationService 에서 PollinationsImageModel imageModel 로 받고 있던 부분이 컴파일 에러 로 무너져요. 모든 호출자 코드를 전수 수정 해야 해요.
그런데 인터페이스 (ImageModel) 로 적어두면 — 이 @Bean 메서드의 본체만 바꾸면 끝. 예를 들어 미래에 Imagen 으로 갈아탄다고 하면 —
// 미래의 갈아끼움 — @Bean 본체만 바뀐다
@Bean
public ImageModel pollinationsImageModel(VertexAiImagenApi api) {
return new VertexAiImagenImageModel(api); // ← 구현체만 바꿈
}
서비스 코드는 한 줄도 안 바뀌어요. Day 2 에서 제가 "인터페이스 주입의 본질" 이라고 적어둔 그 약속이 — 오늘 이미지 도메인에서 한 번 더 펼쳐졌어요.
(빈 이름 pollinationsImageModel 은 지금 의 구현체를 따라간 이름이에요. 미래에 갈아끼울 때 — 이 이름까지 바꾸면 더 깔끔하지만, 서비스 코드 영향 없음 이라는 본질은 그대로예요.)
5. application.yml 의 aifriends.image.* 블록
자, @Value("${aifriends.image.pollinations.base-url:...}") 로 빈에 주입되는 base-url 은 어디서 올까요? 우리가 깔아둔 application.yml 의 Day 7 전용 섹션 부분이에요.
# =========================================================
# Day 7 — 이미지 생성 도메인 설정
# =========================================================
# Pollinations.ai 는 키 없이 무료로 호출 가능한 학습용 프로바이더.
# 학생이 다른 프로바이더(OpenAI / Stability / Vertex Imagen) 로 갈아끼울 때
# 이 섹션 + ImageModel 빈만 교체하면 된다 (프로바이더 추상화 원칙).
aifriends:
image:
pollinations:
base-url: https://image.pollinations.ai
quota:
# 학습용 단순 일일 호출 한도. 실제 운영은 Redis INCR + EXPIRE 로 대체해야 한다.
daily-limit: 30
storage:
upload-dir: ./uploads/portraits
cost:
# Pollinations.ai 는 무료. OpenAI DALL-E 3 standard 1장 ≈ $0.04 (참고)
pollinations-usd: 0.0
세 가지 짚어둘게요.
첫째, pollinations.base-url — @Value 의 기본값 표기법 (${... :https://image.pollinations.ai}) 덕분에 yml 에 없어도 동작해요. 학생이 .env 로 AIFRIENDS_IMAGE_POLLINATIONS_BASE_URL=... 환경 변수를 넣으면 우선 적용 되고, 없으면 yml 의 기본값을 따라가요.
환경별 오버라이드 의 감각.
둘째, quota.daily-limit: 30 — Step 5 에서 깔아볼 일일 호출 한도 가 여기 미리 들어가 있어요. 학습용 단순 카운터 모양이에요. 실제 운영에선 Redis INCR + EXPIRE 같은 분산 카운터로 가야 해요 (Day 19 harness 의 cost guardrail에서 한 번 더 다룰 거예요).
셋째, storage.upload-dir + cost.pollinations-usd — Step 7 (로컬 저장 부분) 와 Step 5 (비용 에코 부분) 에서 다시 만나요. 지금은 Day 7 전용 도메인 설정이 yml 한 블록에 깔끔히 모여 있다 정도만 손에 담아두세요.
6. 🙋 한 학생의 걱정
"튜터님... Custom
ImageModel직접 짜는 게 진짜 무서웠는데 — 코드가 이렇게 짧다고요? 50 라인 정도로 끝났어요? 뭔가 더 무거운 작업 이 있을 줄 알았어요."
정확하게 읽으셨어요. 여기서 추상화의 느낌이 얇은 덕분 이라는 감각을 한 번 더 적어드릴게요.
세 가지로 풀어드릴게요.
첫째, ImageModel 인터페이스가 메서드 한 개 짜리예요 (call()). 그래서 구현해야 할 의무가 한 줄. ChatModel 도 사실 같은 느낌이에요 (call(Prompt) → ChatResponse). Spring AI 의 추상화 디자인이 얇은 구조 로 잘 잡혀 있어서 —
Custom 구현체의 최소 골격 이 짧게 유지돼요.
둘째, Pollinations.ai 의 idempotent URL 패턴 이라는 행운 도 한몫 했어요. URL 한 줄로 모든 정보 가 들어가는 식이라 — 외부 RestClient 호출이 어댑터 안에 들어갈 필요가 없어요. 만약 다른 프로바이더 (예: POST body 를 쓰는 OpenAI DALL-E) 면 —
RestClient 호출 코드가 들어가서 50 라인 → 100~150 라인 으로 늘어나요. 그래도 3 부분 수 안쪽. 무겁지 않아요.
셋째, Custom 구현체의 진짜 가치는 코드 양이 아니라 추상화 위치 에요. 우리가 짠 PollinationsImageModel 은 — Spring AI 의 공식 구현체 (OpenAiImageModel · StabilityAiImageModel) 와 같은 슬롯 에 잡혀요. 서비스 코드 입장에선 공식이냐 커스텀이냐를 구별할 수 없어요.
이게 추상화의 본질.
코드 양보다 위치 가 가치예요.
요약하자면 — Custom 구현체 가 무섭게 들리는 건 공식 starter 의 신화 에 갇혀 있기 때문이에요. 추상화가 잘 잡힌 라이브러리 (Spring AI 같은) 위에선 — Custom 도 공식과 동등한 부분. 50 라인의이 그걸 손끝으로 보여줬어요.
7. 💡 튜터의 결론 — Step 3 한 줄
"
ImageModel인터페이스 한 개 + 메서드 한 개 (call) — 이 얇은 느낌이 Custom 어댑터를 50 라인으로 끝낼 수 있게 한다. 그리고@Bean public ImageModel ...로 반환 타입을 인터페이스로 적어두면, 미래의 갈아끼움이 빈 한 개 교체로 끝난다. 이게 프로바이더 추상화 원칙의 진짜 가치 — 공식 starter 의 유무에 갇히지 않는 감각이다."
이 방식으로 — Step 4 에서는 ImageModel.call() 만으로는 일이 끝나지 않는다 는으로 넘어가요. URL 만 빌드한 상태라 — 외부 다운로드 + 로컬 저장 + 가드 + 비용 에코 까지의 오케스트레이션 이 필요해요. 그 장면의 첫 줄에 왜 가드가 있는지 의 주제를 풀어봅니다.
Step 4: `ImageGenerationService` 의 — 세 단계 협업과 ApiResponse 의 본격 회귀
자, Step 3 에서 우리는 PollinationsImageModel 어댑터 를 손으로 깔았어요. 그 어댑터의 결과물은 — URL 한 줄. 진짜 그림은 아직 우리 손에 없어요. URL 을 받아서 우리 서버로 다운로드 하고 로컬에 저장 하는 단계가 남아 있어요. 그리고 그 사이사이에 비용 가드 와 비용 에코 가 끼워져야 해요.
이 모든 단계를 오케스트레이션 하는 부분이 ImageGenerationService 예요. Step 4 의 미션은 — 세 단계 서비스 협업의 장면을 손으로 만지기 + ApiResponse의 본격 회귀를 결정 문서로 적기 두 가지예요.
1. 6 단계 협업의
ImageGenerationService 의 코어 메서드 generate() 는 6 단계의 콜라보레이션 으로 풀려요. 이 메서드 전문을 펼쳐볼게요. (이번엔 발췌가 아니라 전문 부분이에요. 자체가 본질이라 잘라 보면 그림이 깨져요.)
package kr.spartaclub.aifriends.image.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.image.dto.ImageGenerationResult;
import kr.spartaclub.aifriends.image.exception.ImageException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.stereotype.Service;
import java.io.IOException;
/**
* Day 7 Step 4 — 이미지 생성의 도메인 진입점.
*
* <p>책임 분리:
* <ol>
* <li>{@link ImageDailyQuotaGuard}: 호출 전 한도 체크</li>
* <li>{@link ImageCostEstimator}: 호출 전 비용 로그 에코</li>
* <li>{@link ImageModel}: 프롬프트 → 외부 URL (어떤 프로바이더든)</li>
* <li>{@link ImageDownloader}: 외부 URL → 바이트</li>
* <li>{@link ImageFileStorageService}: 바이트 → 우리 서버 정적 리소스 경로</li>
* </ol>
*
* <p>{@link ImageModel} 은 인터페이스로만 주입받는다 (프로바이더 추상화 원칙). 빈은 {@code pollinationsImageModel}
* 이지만 호출자는 모른다 — {@code application.yml} 의 빈 교체만으로 OpenAI / Stability / Imagen 으로
* 갈아끼울 수 있다는 게 이 추상화의 핵심.</p>
*
* <p>모든 실패 경로는 {@link ImageException} + 도메인 {@link ErrorCode} 로 래핑한다.
* {@link RuntimeException} / {@link IllegalArgumentException} 직접 throw 금지 (도메인 예외 규약).</p>
*/
@Slf4j
@Service
public class ImageGenerationService {
private static final String MODEL_NAME = "pollinations-flux";
private final ImageModel imageModel;
private final ImageDownloader imageDownloader;
private final ImageDailyQuotaGuard quotaGuard;
private final ImageCostEstimator costEstimator;
private final ImageFileStorageService storageService;
public ImageGenerationService(ImageModel imageModel,
ImageDownloader imageDownloader,
ImageDailyQuotaGuard quotaGuard,
ImageCostEstimator costEstimator,
ImageFileStorageService storageService) {
this.imageModel = imageModel;
this.imageDownloader = imageDownloader;
this.quotaGuard = quotaGuard;
this.costEstimator = costEstimator;
this.storageService = storageService;
}
public ImageGenerationResult generate(String prompt, String stylePreset, Long seed, String fileNameHint) {
// (1) 한도 체크 — 한도 초과면 ImageModel 호출조차 일어나지 않는다 (비용 차단의 본질).
quotaGuard.checkAndIncrement();
// (2) 비용 에코 — 학습용. 운영에선 Prometheus counter / cost-tracker 로 대체.
costEstimator.echo(MODEL_NAME);
// (3) ImageModel 호출 — 프로바이더 추상화의 핵심. 우리 코드는 ImageModel 인터페이스만 안다.
String enrichedPrompt = (stylePreset == null || stylePreset.isBlank())
? prompt
: prompt + ", style: " + stylePreset;
PollinationsImageOptions options = new PollinationsImageOptions(
"flux", 1024, 1024, seed, stylePreset);
ImageResponse response;
try {
response = imageModel.call(new ImagePrompt(enrichedPrompt, options));
} catch (RuntimeException e) {
log.warn("[ImageGeneration] provider call failed: {}", e.getMessage(), e);
throw new ImageException(ErrorCode.IMAGE_GENERATION_FAILED);
}
String externalUrl = response.getResult().getOutput().getUrl();
// (4) 외부 이미지 다운로드 — RestClient 추상화는 ImageDownloader 가 흡수.
byte[] bytes;
try {
bytes = imageDownloader.download(externalUrl);
} catch (ImageDownloader.ImageDownloadException e) {
log.warn("[ImageGeneration] download failed: {}", e.getMessage(), e);
throw new ImageException(ErrorCode.IMAGE_DOWNLOAD_FAILED);
}
// (5) 로컬 저장 + 정적 리소스 경로 반환.
String localPath;
try {
localPath = storageService.save(bytes, fileNameHint);
} catch (IOException e) {
log.error("[ImageGeneration] storage failed: {}", e.getMessage(), e);
throw new ImageException(ErrorCode.IMAGE_STORAGE_FAILED);
}
double estimatedCost = costEstimator.estimateCostUsd(MODEL_NAME);
return new ImageGenerationResult(localPath, externalUrl, enrichedPrompt, MODEL_NAME, estimatedCost);
}
}
자, 6 단계을 한 줄씩 풀어볼게요.
(1) quotaGuard.checkAndIncrement() — 한도 체크가 맨 처음 — 여기가 오늘 가장 강조하고 싶은 한 줄이에요. 왜 외부 호출보다 먼저? — 외부 API 호출은 호출된 시점에 이미 비용이 발생 해요. 100 회 한도를 초과한 상태에서 체크 없이 호출 하면 —
그 한 번의 호출은 이미 비용으로 빠져나간 거예요. 재등장 불가. 그래서 한도 체크가 항상 호출 전. 비용 가드의 본질 이 이 순서 에 들어가 있어요.
(2) costEstimator.echo(MODEL_NAME) — 비용 에코 — 호출 전에 로그로 "이번 호출 비용 ≈ $0.0 (Pollinations 무료)" 같은 메시지를 남겨요. 학습용 단순 모양이에요. 운영에선 —
Prometheus counter 로 비용 누적 메트릭 을 적거나, cost-tracker 서비스에 push 해요. 우리는 학습 단계라 콘솔 로그 한 줄 로 충분.
(3) imageModel.call(new ImagePrompt(...)) — 프로바이더 호출의 핵심 — 우리 코드는 ImageModel 인터페이스만 안다. imageModel 의 진짜 정체가 PollinationsImageModel 이라는 걸 — 이 서비스는 모르고 알 필요도 없어요.
그리고 이 호출이 RuntimeException 을 던지면 —
ImageException(IMAGE_GENERATION_FAILED) 로 래핑 해요.
(4) imageDownloader.download(externalUrl) — 외부 다운로드 — Step 3 에서 적어둔 어댑터는 외부 호출 안 한다 의 약속이 여기서 다시 만나요. URL 만 받은 상태라 —
진짜 바이트 를 받으려면 별도 RestClient 호출이 필요. 이걸 ImageDownloader 인터페이스 로 한 번 더 추상화한 게 핵심.
(5) storageService.save(bytes, fileNameHint) — 로컬 저장 — Step 7 (로컬 /uploads 저장) 과 만나요. 바이트를 받아 우리 서버의 정적 리소스 경로 (예: /uploads/portraits/portrait-abc-123.jpg) 를 돌려줘요. 이 경로가 —
컨트롤러 응답에 실려 클라이언트가 <img src> 로 띄울 수 있는 자리.
(6) ImageGenerationResult 조립 + 반환 — record 한 줄로 — 완성된 한 객체 가 만들어져요.
2. 커스텀 예외 정책: ImageException + ErrorCode 의 감각
자, generate() 의 세 부분에서 catch + 래핑 패턴이 등장했어요.
} catch (RuntimeException e) {
log.warn("[ImageGeneration] provider call failed: {}", e.getMessage(), e);
throw new ImageException(ErrorCode.IMAGE_GENERATION_FAILED);
}
왜 그대로 던지지 않고 래핑하는가 — 이게 코드베이스 전체의 일관성 부분이에요.
세 가지 이유로 풀어드릴게요.
첫째, GlobalExceptionHandler 가 도메인 예외만 잡아서 ApiResponse.fail 로 자동 변환 해요. RuntimeException 같은 익명의 예외 를 그대로 흘려보내면 — INTERNAL_SERVER_ERROR 의 익명 메시지 (E003) 가 클라이언트에 도착해요. 그런데 도메인 예외 (ImageException(IMAGE_GENERATION_FAILED)) 로 래핑하면 —
BAD_GATEWAY (502) + I005 + "이미지 생성에 실패했습니다." 가 도착해요. 클라이언트가 어떤 단계의 어떤 종류 실패인지 를 정확히 읽을 수 있어요.
둘째, 에러 코드 카탈로그 의 감각. 이미지 도메인의 모든 에러를 한 enum 에 적어두면 — 전수 검토 가 가능해요. ErrorCode.java 의 IMAGE_* 5 종세트를 펼쳐볼게요.
// kr.spartaclub.aifriends.common.exception.ErrorCode (Day 7 추가분 발췌)
// Image (Day 7)
IMAGE_PROMPT_REQUIRED(HttpStatus.BAD_REQUEST, "I001", "이미지 생성을 위한 프롬프트를 입력해 주세요."),
IMAGE_QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "I002", "오늘의 이미지 생성 횟수 한도를 초과했습니다."),
IMAGE_DOWNLOAD_FAILED(HttpStatus.BAD_GATEWAY, "I003", "생성된 이미지를 가져오지 못했습니다."),
IMAGE_STORAGE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "I004", "이미지를 저장하는 중 문제가 발생했습니다."),
IMAGE_GENERATION_FAILED(HttpStatus.BAD_GATEWAY, "I005", "이미지 생성에 실패했습니다.");
다섯 줄에 들어간 책임 분리 의이 깔끔하죠? I001 (입력 검증) → I002 (한도) → I003 (다운로드 실패) → I004 (저장 실패) → I005 (호출 실패). 어느 단계의 실패인지 5 단계로 또렷이 갈려 있어요. ️
셋째, ImageException 클래스 자체는 딱 한 줄짜리 예요.
public class ImageException extends BusinessException {
public ImageException(ErrorCode errorCode) {
super(errorCode);
}
}
BusinessException 을 상속받아 — 생성자에 ErrorCode 만 받아 넘김. 도메인별로 짧은 클래스 한 개 씩 적어두면 — GlobalExceptionHandler 의 catch 분기 가 도메인 단위로 깔끔히 정리돼요. (Day 4 에서 적은 StructuredOutputException 과 같은 패턴.)
3. ImageDownloader 인터페이스: 한 번 더 추상화
자, imageDownloader.download(externalUrl) 한 줄이 했던 일이 — RestClient 호출 부분이에요. 그런데 왜 RestClient 를 직접 안 쓰고 인터페이스를 한 번 더 끼워넣었을까요? 이게 프로바이더 추상화 정신의 연장선 부분이에요.
package kr.spartaclub.aifriends.image.service;
/**
* Day 7 Step 4 — 외부 이미지 URL 을 바이트 배열로 가져오는 단일 책임 추상.
*
* <p>{@link ImageGenerationService} 가 RestClient 를 직접 받지 않고 이 인터페이스에 의존하면,
* 호출 의도(=다운로드) 가 명확해지고, 프록시/캐시/CDN 같은 변형 구현체로 갈아끼우는 비용이 사라진다.
* 프로바이더 추상화 정신 — 어떻게 가져오느냐는 호출자가 알 필요 없다.</p>
*/
public interface ImageDownloader {
/**
* @throws ImageDownloadException 외부 호출 실패 (HTTP 4xx/5xx, 타임아웃, 연결 오류 등)
*/
byte[] download(String url);
/**
* 다운로더 내부의 모든 외부 호출 실패를 한 곳으로 모은다.
* 호출자({@link ImageGenerationService}) 는 이 예외만 잡아서 도메인 예외로 래핑한다.
*/
class ImageDownloadException extends RuntimeException {
public ImageDownloadException(String message, Throwable cause) {
super(message, cause);
}
}
}
인터페이스 + 단일 메서드 + 단일 예외. 이 얇은 구조 이 두 가지 가치를 줍니다.
첫째, 교체 자유. RestClient 직접 의존이면 — 미래에 프록시 모드 / 캐시 레이어 / CDN 경유 같은 변형이 끼어들 때 ImageGenerationService 본문을 손대야 해요. 인터페이스로 한 단계 추상화하면 — 구현체만 바꾸면 끝. 서비스 코드는 한 줄도 안 건드려요.
둘째, 호출 의도가 명확해져요. RestClient.get()... 라고 쓰면 "외부 어딘가 GET 호출" 이라는 모호한 신호. 그런데 imageDownloader.download(url) 라고 쓰면 "이미지 다운로드" 라는 의도 가 메서드 이름에 잡혀요. 코드 가독성이 — 읽는 사람의 의도 추측 비용을 줄여주는 방식이에요.
구현체 (RestClientImageDownloader) 도 짧고 깔끔해요.
@Component
public class RestClientImageDownloader implements ImageDownloader {
private final RestClient externalImageRestClient;
public RestClientImageDownloader(@Qualifier("externalImageRestClient") RestClient externalImageRestClient) {
this.externalImageRestClient = externalImageRestClient;
}
@Override
public byte[] download(String url) {
try {
byte[] bytes = externalImageRestClient.get()
.uri(url)
.retrieve()
.body(byte[].class);
if (bytes == null || bytes.length == 0) {
throw new ImageDownloadException("Empty image body: " + url, null);
}
return bytes;
} catch (RestClientException e) {
throw new ImageDownloadException("Failed to download image: " + url, e);
}
}
}
세 가지 짚어둘게요.
첫째, @Qualifier("externalImageRestClient") — RestClient 빈이 여러 개 등록돼 있을 때 어떤 빈 을 받을지 명시해요. 우리 코드베이스엔 geminiRestClient · jsonPlaceholderRestClient · boredRestClient 같은 다른 RestClient 빈도 있어서 —
이미지 다운로드 전용 빈을 콕 찍어 받아요. 다음 절 4 에서 그 빈을 풀어드릴게요.
둘째, body 가 null 이거나 empty 인 경우도 실패 로 분류해요. HTTP 200 이지만 본문이 비었으면 — 그건 깨진 이미지. ImageDownloadException 으로 던져요.
셋째, RestClientException 만 catch 해서 단일 예외 (ImageDownloadException) 로 래핑. 호출자는 한 종류만 잡으면 돼요. 앞 절 2 의 예외 단일화 감각이 여기서도 반복돼요.
4. externalImageRestClient 빈: 5 초 / 60 초 타임아웃의 감각
@Qualifier("externalImageRestClient") 의 그 빈 이 어떻게 등록돼 있는지 — RestClientConfig 에서 펼쳐볼게요.
/**
* Day 7 Step 4 — 외부 이미지 다운로드 전용 RestClient.
*
* <p>이미지 생성·다운로드는 텍스트 호출보다 응답 시간이 길다 (Pollinations 는 cold-start 시
* 5~20초). 그래서 read-timeout 을 60초로 넉넉히 잡고, 연결 타임아웃은 5초로 짧게 둔다.</p>
*/
@Bean("externalImageRestClient")
public RestClient externalImageRestClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(60000);
return RestClient.builder()
.requestFactory(factory)
.build();
}
5 초 connect / 60 초 read — 이 비대칭 의 감각이 핵심이에요.
| 타임아웃 | 값 | 의미 | 왜 이 값? |
|---|---|---|---|
| connect | 5 초 | TCP 연결 수립 한도 | 연결조차 안 되면 그 서버는 죽은 것. 빨리 포기. |
| read | 60 초 | 연결 후 응답 본문 도착 한도 | Pollinations 는 cold-start 시 5~20 초 가 정상. 여유 필요. |
비교 — Day 1 에서 적은 geminiRestClient 는 5 초 / 30 초.
텍스트 채팅은 30 초면 충분 한데, 이미지는 60 초 가 필요해요.
왜? — 이미지 생성은 모델이 픽셀 1024 × 1024 = 100 만 개 를 처음부터 끝까지 그려야 해요.
텍스트의 수십 토큰 생성 과 비교 자체가 안 되는 작업량이에요.
그래서 실제 응답이 느림. 30 초 타임아웃이면 cold-start 하나에 매번 실패. 60 초로 넉넉히 잡아둬야 학생이 본 호흡 으로 첫 그림을 받을 수 있어요.
5. 🙋 한 학생의 걱정
"튜터님,이 6 단계라니 — 한 메서드가 너무 무거운 거 아닌가요? Service 가 이렇게 많은 일을 하는 건 SRP 위반이지 않나요? 메서드 분리 안 하나요?"
정확한 부분에 의문을 던지셨어요. 답은 — 이건 Application Service 의 자연스러운 패턴 이고, SRP 위반이 아니라 오히려 SRP 의 모범 부분이에요. 세 가지로 풀어드릴게요.
첫째, 각 단계가 한 줄짜리 호출 부분이에요. quotaGuard.checkAndIncrement() / costEstimator.echo(...) / imageModel.call(...) / imageDownloader.download(...) / storageService.save(...). Service 자신이 직접 일하지 않아요.
콜라보레이터에게 위임 만 해요. Service 의 책임은 —
오케스트레이션 (= 어떤 순서로 누구를 부를지). 직접 일하지 않으니 책임이 얇아요.
둘째, 6 단계의 콜라보레이터들은 각자 단일 책임 부분이에요.
ImageDailyQuotaGuard 는 한도 체크만, ImageCostEstimator 는 비용 에코만, ImageModel 은 프롬프트 → URL 만, ImageDownloader 는 URL → bytes 만, ImageFileStorageService 는 bytes → 경로만.
다섯 클래스가 각자 SRP 모범생 이고, Service 는 그 모범생들을 줄세우는 합주 지휘자.
셋째, 메서드 분리 가 오히려 흐름을 흐려요. 만약 generate() 를 validateQuota() + callProvider() + saveLocally() 3 개 메서드로 쪼개면 — 읽는 사람이 진짜을 보려면 3 군데 점프 해야 해요. 한 메서드에 6 줄짜리 단계 가 위에서 아래로 흐르는 모양 이 — 읽는 비용 을 가장 줄여줘요.
(Application Service 패턴의 모범 —
Vaughn Vernon 의 Implementing DDD 도 같은 패턴.)
요약하자면 — Service 가 6 단계를 묶는 건 SRP 위반이 아니라 SRP 의 상위 레이어 예요. 직접 일하지 않고 위임만 한다면 얇은 책임. 학생이 SRP 를 "한 클래스 한 줄" 로 오해하지 않도록 — 여기서 한 번 더 적어둘게요.
6. 🚨 비용 가드 박스 — 왜 가드가 첫 줄인가
여기서 한 번 더 적어둘게요.
quotaGuard.checkAndIncrement()가 맨 처음에 있는 이유 — 외부 API 호출 전에 한도 체크가 반드시 일어나야 하기 때문이에요. 호출 후 체크 는 비용 재등장 불가 의 사고를 낳아요.비유하자면 — 놀이공원 입구의 키 측정 같은 모양이에요. 키가 모자란 사람을 놀이기구에 태운 뒤에 키 재는 게 아니라, 입구에서 먼저 잰 다음에 들여보내요. 한 번 태운 뒤엔 되돌릴 수 없으니까. 이미지 생성도 같은 맥락 — 한 번 호출된 비용은 되돌릴 수 없어요.
Step 5 에서 —
ImageDailyQuotaGuard+ImageCostEstimator의 감각을 직접 깔러 갑니다. 학습용 단순 카운터 + Redis INCR 의 운영 패턴 비교까지. 언제 그릴지의 정책 이 본격 풀리는 부분이에요.
7. 💡 튜터의 결론 — Step 4 한 줄
"
ImageGenerationService.generate()의 6 단계 협업은 Application Service 의 모범 패턴 — 가드 → 에코 → 호출 → 다운로드 → 저장 → 결과 조립. 이 방식의 마지막 줄 인ImageGenerationResultrecord 가 만들어지는 자리에서 — ApiResponse 표준 패턴 회귀 가 자연스럽게 닫힌다. 지난 시간 SSE 의 정당한 예외를 본 손이, 오늘 예외의 사정이 사라지면 표준으로 돌아오는 모양을 손으로 만진다."
이 방식으로 — Step 5 에서 그 가드의 감각 을 직접 깔러 갑니다. ImageDailyQuotaGuard 의 일일 카운터 패턴 + ImageCostEstimator 의 비용 에코 + 응답 DTO 변환까지 — 학습용 단순 모양 으로 한 번 익히어보고, 운영 환경의 Redis 패턴 과 비교 한 줄 로 한 방식을 닫겠습니다.
Step 5: DTO + 비용 가드 — **언제 그리고 안 그릴지** 의 정책을 손으로 깔기
자, Step 4 에서 우리는 ImageGenerationService.generate() 의 6 단계 협업 장면을 위에서 아래로 훑었어요.
그 첫 줄에 들어가 있던 두 콜라보레이터 — ImageDailyQuotaGuard 와 ImageCostEstimator 가 어떤 모양으로 깔려 있는지 는 사실 주석으로만 짚고 넘어갔죠.
오늘 Step 5 의 미션은 바로 그 부품들 을 직접 손으로 만지는 부분이에요. ️
그리고 호출 경계 를 닦는 일도 같이 합니다. 컨트롤러로 들어가는 입구 모양 (요청 DTO) 과 컨트롤러로 빠져나가는 출구 모양 (응답 DTO) 을 — Step 6 컨트롤러를 깔기 직전에 record 두 개 로 적어둬요. 얇은 컨트롤러 가 가능해지는 출발점이 이 두 record 예요.
1. 입구 record PortraitGenerationRequest
먼저 컨트롤러로 들어가는 본문 모양 부터 적어둘게요. PortraitGenerationRequest 라는 record 한 개예요.
package kr.spartaclub.aifriends.image.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Day 7 Step 5 — 캐릭터 초상화 생성 요청 DTO.
*
* <p>{@code prompt} 만 필수로 강제하고, {@code stylePreset} 과 {@code seed} 는
* 선택값으로 둔다. {@code seed} 가 있으면 같은 입력 → 같은 출력이 보장돼
* "이 캐릭터의 초상화는 항상 이렇게 보인다" 같은 결정론적 시나리오에 쓰인다.</p>
*/
public record PortraitGenerationRequest(
@NotBlank(message = "이미지 생성을 위한 프롬프트를 입력해 주세요.") String prompt,
String stylePreset,
Long seed
) {
}
세 가지 짚을게요.
첫째, 왜 record — Day 4 에서 Quote · RecipeSuggestion 같은 Structured Output 을 record 로 받았던 그 결과 같아요. 불변 + getter 자동 생성 + equals/hashCode 자동 생성 이라 —
경계 데이터 (DTO) 의 모양으로 가장 잘 어울려요. setter 가 없으니 컨트롤러로 들어온 뒤 누가 손대는 사고 가 원천 차단돼요.
둘째, @NotBlank 한 줄 의 감각 — 이 어노테이션이 진짜 일을 하는 부분 는 Step 6 의 컨트롤러 시그니처에서 @Valid @RequestBody PortraitGenerationRequest request 로 받을 때예요.
Spring 이 자동으로 검증을 돌리고, 비어 있으면 MethodArgumentNotValidException 을 던져요.
그러면 우리 코드베이스의 GlobalExceptionHandler 가 자동으로 ApiResponse.fail(BAD_REQUEST) 로 변환해줘요. 컨트롤러에서 if (prompt == null) throw ... 한 줄도 안 써요. 검증 코드가 어노테이션 한 줄 로 끝나는 감각이에요.
셋째, stylePreset + seed 가 optional 인 이유. 둘 다 없어도 그림은 나와요. stylePreset 은 "미니멀, 수채화" 같은 화풍 힌트 라 — Step 4 의 ImageGenerationService 에서 prompt + ", style: " + stylePreset 으로 프롬프트에 합쳐 들어가요. seed 는 결정론 의 손잡이 —
같은 prompt + 같은 seed 면 같은 그림이 나와요 (Pollinations.ai 의 결정론적 동작). 캐릭터의 초상화가 언제 봐도 같은 얼굴 이게 하고 싶을 때 — 이 한 줄이 그 보장이에요.
2. 출구 record ImageGenerationResult
이번엔 컨트롤러에서 빠져나가는 응답 모양. ImageGenerationResult 도 record 한 개예요.
package kr.spartaclub.aifriends.image.dto;
/**
* Day 7 Step 5 — 이미지 생성 결과 DTO.
*
* @param localPath 우리 서버의 정적 리소스 경로 (예: {@code /uploads/portraits/abc.jpg}).
* 프론트가 그대로 {@code <img src="...">} 에 적어 쓰면 된다.
* @param externalUrl 프로바이더가 발급한 원본 URL (참고/디버깅 용도).
* @param prompt 실제 LLM 에 흘려보낸 프롬프트 본문 (감사 로그 · 학생 디버깅용).
* @param modelName 사용된 모델 식별자 (예: {@code pollinations-flux}, {@code openai-dall-e-3-standard}).
* @param estimatedCostUsd 1회 호출 추정 비용. 학습용 하드코딩 매핑이며, 실제 청구는 프로바이더 대시보드 기준.
*/
public record ImageGenerationResult(
String localPath,
String externalUrl,
String prompt,
String modelName,
double estimatedCostUsd
) {
}
다섯 필드 중 — 오늘 가장 짚어두고 싶은 부분 는 위 두 줄. localPath 와 externalUrl 이 둘 다 들어 있는 이유.
🙋 한 학생의 질문
"튜터님, 우리 서버에 다운로드까지 해뒀잖아요. 그럼
localPath만 내려주면 되지 않나요?externalUrl까지 같이 주는 게 낭비 아닌가요? "
좋은 질문이에요. 세 가지 이유로 둘 다 줘요.
첫째, 디버깅 친화성. 학생이 "내가 받은 이 그림이 진짜 Pollinations 에서 온 거 맞나?" 를 원본 URL 을 브라우저에 직접 적어서 확인할 수 있어요. 우리 서버에 저장된 본은 압축 / 변환 가능성 이 있어요. 원본과 비교하면서 어디서 변형이 일어났는지 추적할 수 있는 손잡이가 원본 URL 한 줄.
둘째, 프론트의 선택권. 정상 흐름에선 localPath 를 <img src> 에 적어 띄워요. 하지만 우리 서버 정적 리소스가 잠깐 깨졌을 때 — 프론트가 fallback 으로 externalUrl 로 띄울 수 있어요. 이중화 채널 의 손잡이.
셋째, 감사 로그. prompt 와 modelName 도 비슷한 느낌이에요. 어떤 프롬프트로 어떤 모델이 그렸는지 응답 자체에 들어가 있으면 — 학생이 콘솔/네트워크 탭만 보고도 추적 가능. 운영에선 PII 마스킹 등 별도 처리가 필요하지만 학습 단계에선 투명성 이 가치예요.
3. ImageDailyQuotaGuard 의 학습용 단순 모양
자, Step 4 의 가장 첫 줄 quotaGuard.checkAndIncrement() — 그 자물쇠 의 안쪽을 펼쳐볼 부분이에요. 의외로 짧고 솔직한 코드예요.
package kr.spartaclub.aifriends.image.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.image.exception.ImageException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
/**
* Day 7 Step 5 — 일일 이미지 생성 호출 횟수 가드.
*
* <p><b>학습용 단순 모양</b>이라 in-memory + synchronized 로 카운터를 관리한다.
* 실제 운영에서는 다음 두 한계를 반드시 고려해야 한다:
* <ul>
* <li>인스턴스 다중화: WAS 가 N대면 카운터가 N배 부풀려진다 → Redis {@code INCR} + {@code EXPIRE} 로 공유.</li>
* <li>유저별 격리: 지금은 "전체 호출" 한도다 → 운영에서는 {@code key = "image:quota:{userId}:{yyyyMMdd}"} 로 키 분리.</li>
* </ul>
* Day 7 의 학습 목표는 "비용이 큰 모달리티에 가드를 끼우는 패턴" 그 자체이므로 이 단순 구현으로 충분하다.</p>
*/
@Component
public class ImageDailyQuotaGuard {
private final int dailyLimit;
private final Clock clock;
private LocalDate currentDate;
private int counter;
public ImageDailyQuotaGuard(@Value("${aifriends.image.quota.daily-limit:30}") int dailyLimit) {
this(dailyLimit, Clock.systemDefaultZone());
}
/**
* 시간 의존성을 외부에서 주입받는 생성자 — 운영용 (`Clock.systemDefaultZone()`) 외에 자정 경계
* 시나리오를 재현해야 할 때 임의 `Clock` 을 끼워넣을 수 있게 한다.
*/
ImageDailyQuotaGuard(int dailyLimit, Clock clock) {
this.dailyLimit = dailyLimit;
this.clock = clock;
this.currentDate = LocalDate.now(clock);
this.counter = 0;
}
/**
* 호출 횟수를 1 증가시키고, 한도를 넘기면 {@link ImageException} 을 던진다.
* 자정을 넘기면 카운터가 자동으로 0으로 리셋된다.
*/
public synchronized void checkAndIncrement() {
LocalDate today = LocalDate.now(clock);
if (!today.equals(currentDate)) {
currentDate = today;
counter = 0;
}
if (counter + 1 > dailyLimit) {
throw new ImageException(ErrorCode.IMAGE_QUOTA_EXCEEDED);
}
counter++;
}
/**
* 현재 사용량 (디버깅/관리 엔드포인트 노출용 — 학습용으로 두지만 노출은 신중히).
*/
public synchronized int currentCount() {
return counter;
}
}
핵심은 48 ~ 58 라인의 checkAndIncrement() 한 메서드 예요. 네 줄로 풀어드릴게요.
첫째, synchronized 한 단어로 동시성을 막아요. 멀티스레드가 동시에 들어와도 카운터 증가가 한 줄씩 직렬화 돼요. 두 사람이 동시에 31 번째 호출 을 시도해도 — 한 명만 카운터 30 → 31 을 보고 거절 당하고 다른 한 명은 31 → 32 시도하다 거절 의 방식으로 풀려요.
둘째, 자정 리셋 의 감각. LocalDate.now(clock) 으로 오늘 날짜 를 매 호출마다 확인해요. 어제와 다르면 — currentDate 를 갱신하고 counter 를 0 으로 제로화. 별도의 스케줄러 (@Scheduled) 가 자정에 깨워서 리셋 할 필요가 없어요. 호출이 와야 카운터가 의미 있는 식이라 —
호출 시점에만 리셋해도 충분해요. ⏰
셋째, 체크 → 증가의 순서 — if (counter + 1 > dailyLimit) 로 증가 후 가정의 값 을 먼저 비교하고, 통과해야 비로소 counter++. 이 순서가 한도 초과 시 카운터를 더럽히지 않는 보장이에요. 31 번째 시도 가 와도 카운터는 30 에 머물러 있어요. 작아 보이지만 정확성에 결정적인 줄이에요.
넷째, @Value("${aifriends.image.quota.daily-limit:30}") 로 기본값 30 을 외부 설정에서 받아요. application.yml 에 aifriends.image.quota.daily-limit: 50 한 줄만 넣으면 50 으로 갈아끼울 수 있어요. 학생 실습용 30 은 —
충분히 풍부 (수업 중 여러 번 시도 가능) + 남용 방지 (실수로 백 번 호출하는 사고 차단) 의 균형이에요.
4. ImageCostEstimator 의 USD 추정 매핑
자물쇠 옆에 들어가 있는 두 번째 부품 — ImageCostEstimator. 호출 비용을 USD 로 추정 하고 로그로 에코 하는 느낌이에요.
package kr.spartaclub.aifriends.image.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Day 7 Step 5 — 이미지 모델별 1회 호출 추정 비용을 로그로 에코하고 USD 값을 돌려주는 헬퍼.
*
* <p><b>실제 청구 금액이 아니다.</b> 학생이 "텍스트 호출 대비 이미지 호출이 얼마나 비싼지" 감을 잡도록
* 강의용 하드코딩 매핑을 제공한다. 운영에서는 프로바이더 응답 메타데이터(예: OpenAI {@code usage})
* 또는 별도 cost-tracker 서비스로 대체해야 한다.</p>
*
* <p>비교 기준 (2026-04 시점 공개 가격):
* <ul>
* <li>Pollinations.ai (flux): 무료 (0.0)</li>
* <li>OpenAI DALL-E 3 standard 1024×1024: ≈ $0.04</li>
* <li>OpenAI DALL-E 3 HD 1024×1024: ≈ $0.08</li>
* <li>Google Imagen 3: ≈ $0.04 (Vertex AI 기준, 참고)</li>
* </ul>
* 텍스트 LLM 한 번 ≈ $0.0001~$0.001 수준이므로 이미지 1장 = 텍스트 호출 수십~수백 회 분량이다.</p>
*/
@Slf4j
@Component
public class ImageCostEstimator {
private static final Map<String, Double> COST_USD_PER_CALL = Map.of(
"pollinations-flux", 0.0,
"openai-dall-e-3-standard", 0.04,
"openai-dall-e-3-hd", 0.08,
"google-imagen-3", 0.04
);
public void echo(String modelName) {
double cost = estimateCostUsd(modelName);
log.info("[ImageGenerationCost] model={} estimated_cost_usd={}", modelName, cost);
}
public double estimateCostUsd(String modelName) {
return COST_USD_PER_CALL.getOrDefault(modelName, 0.0);
}
}
세 가지 짚을게요.
첫째, 모델명 → USD 의 Map 매핑 이 핵심. 학생이 유료 프로바이더로 갈아끼울 때 — costMap 에 한 줄 ("openai-dall-e-3-hd", 0.08) 추가만 하면 자동으로 누적 비용 추정 이 따라가요. 비용을 코드로 관측한다 는 감각의 출발점이에요.
둘째, getOrDefault(..., 0.0) 의 fail-safe — 매핑되지 않은 모델은 0.0 으로 fall-back. 운영 관점에선 "모르는 모델 = 청구 안 받는다" 가 위험한 가정 일 수 있지만, 학습용 보수적 추정 에선 모름 = 0 이 과대 추정으로 학생을 겁주지 않는 패턴.
운영에선 throw new IllegalStateException("Unknown model") 같은 엄격한 분기 로 바꿔야 해요.
감각은 같지만 정책이 반대.
셋째, echo() 와 estimateCostUsd() 가 분리 된 부분 — Step 4 의을 다시 떠올려보면, costEstimator.echo(MODEL_NAME) 은 호출 전에 로그 한 줄, costEstimator.estimateCostUsd(MODEL_NAME) 은 마지막에 result 의 estimatedCostUsd 필드 값으로 들어가요.
같은 데이터를 두 가지 채널 (로그 / 응답) 로 흘려보내는 느낌이에요. 로그는 운영자/학생용, 응답 필드는 클라이언트/디버깅용. 🔁
그리고 — ImageDailyQuotaGuard.checkAndIncrement() 가 한도 초과 시 던지는 ImageException(IMAGE_QUOTA_EXCEEDED) 가, Step 6 컨트롤러를 거치면서 GlobalExceptionHandler 를 통해 HTTP 429 + ApiResponse.fail(I002) 로 변환되는 마지막.
Step 5 의 가드 → Step 6 의 컨트롤러 → 사용자의 클라이언트 화면 에러 토스트 까지 한 개의 ErrorCode 가 세 계층을 관통 해서 흐르는 느낌이에요.
5. 🙋 한 학생의 걱정
"튜터님...
AtomicInteger도 아니고 그냥int+synchronized라니, 좀 ad-hoc 같지 않나요? 실 운영 환경에선 어떻게 깔아요? 이렇게 단순하게 둬도 되는 건가요?"
학생이 정확한 부분에 의문을 던지셨어요. 솔직히 풀어드릴게요.
첫째, 맞아요. 실 운영에선 안 써요. WAS 인스턴스가 여러 대 면 — 각 인스턴스가 각자의 카운터 를 돌려요. 3 대 띄우면 30 → 90 회 까지 호출이 나가요. 비용이 3 배로 새요. 그래서 운영 환경의 표준 패턴은 Redis INCR + EXPIRE 예요. 하나의 외부 카운터를 모든 인스턴스가 공유하고, TTL 86400 (24 시간) 으로 자동 만료. INCR 자체가 atomic 이라 동시성도 같이 풀려요.
둘째, 유저별 격리도 빠져 있어요. 지금 가드는 전체 호출 한도 30 이라 — 한 유저가 30 회 다 써버리면 다른 유저가 못 써요. 운영에선 key = "image:quota:{userId}:{yyyyMMdd}" 로 유저+날짜 별 키를 분리해야 해요. 그래야 각 유저가 각자의 30 회 를 갖죠. 이것도 Redis 키 한 줄 차이.
셋째, 그럼에도 오늘은 단순한 모양 인 이유 — 오늘의 학습 목표는 "비용 가드 의 순서와 책임 분리 가 본질" 이지 "분산 환경의 동시성 카운터" 가 아니에요. Day 7 의 8 부 흐름에서 Redis 를 끌어오면 — 오늘 수업이 Redis 강의로 변질 돼요. 추상화 층 한 개의 감각 을 가르치는 자리에 분산 시스템 강의가 끼면 호흡이 깨져요.
Day 11~12 의 Tool Calling 에서 호출 횟수 가드 를 한 번 더 손볼 때 —
Redis 패턴을 그때 정식으로 깔 거예요. 오늘은 순서의 본질만 가져가시면 충분해요.
요약하자면 — "학습용 단순 모양" 이라는 코드 주석은 솔직한 자기 고백. 운영에선 다르다는 걸 알면서도 오늘의 본질 을 가르치기 위해 얇게 둔. 학생이 "이대로 운영 가능한 코드인가?" 를 항상 질문 하는 손이 — 나중에 진짜 운영을 만나도 헤매지 않게 해줘요.
6. 🚨 비용 가드 박스 — 왜 30 회 / 왜 USD 추정 로그 의 정책 풀이
여기서 가드 정책의 모양 을 한 번 더 적어둘게요.
첫째, 왜 일일 30 회 인가 — 학생 실습용으로 충분히 풍부 + 남용 방지 의 균형이에요. 수업 중 평균 5~10 회 시도 한다고 가정하면 30 회는 3~6 배 여유, 실수로 루프 안에 호출 같은 사고를 쳐도 30 회에서 자동 정지 돼요. 운영에선 유저 plan 별 한도 (free 10 / pro 100 / enterprise 1000) + 빌링 모듈 연동 까지 가야 해요. 오늘 우리 가드는 그 정책 엔진의 베이비 버전.
둘째, 왜 USD 추정 로그를 넣는가 — 비용을 보이지 않으면 비용은 통제되지 않아요. 운영자가 콘솔에 매번 USD 값이 흘러가는 을 보면 — "오늘 100 회 호출 = 4 달러" 라는 감각이 몸에 배요. 이 감각이 — 대화마다 배경 일러스트 새로 생성 같은 비용 폭발 시나리오 에 경고등이 켜지는 감각 을 만들어요. 비용을 코드로 관측한다 는 게 — 비용을 통제할 수 있는 첫 발.
셋째, Day 11~12 (Tool Calling) 와 Day 19 (Harness) 와의 연결. 오늘의 호출 횟수 가드 는 — Day 11 의 툴 호출 횟수 제한, Day 19 의 Spring AI Bench rate limiting 과 같은 느낌이에요. 오늘 익힌 순서 (가드 첫 줄) 의 패턴이 — Tool Calling / Agent / Harness 부분마다 반복 재등장 돼요. 비용 가드는 한 번 배우고 끝나는 토픽이 아니라 — 반복 박제되는 패턴 이라는 신호를 미리 적어둬요.
7. 💡 튜터의 결론 — Step 5 한 줄
"DTO 두 개의 record + 가드 두 개의 부품 — 이 네 조각이 컨트롤러를 얇게 만들어주는 경계 이다.
@NotBlank한 줄이 검증을 자동화하고,Clock주입 한 줄이 시간 의존성을 일급으로 끌어올리고,ImageCostEstimator의 Map 한 줄이 비용을 코드로 관측 하는 첫 발을 떼게 한다. 학습용 단순 모양이지만 — 순서의 본질 만은 운영과 같다."
이 방식으로 — Step 6 에서 그 얇은 컨트롤러 를 직접 손으로 깔러 갑니다. 컨트롤러 시그니처가 너무 짧아 의심스러울 정도 라는 + ApiResponse 표준 패턴의 마지막 회수 + 시나리오 매트릭스 — (a) 캐릭터 만들기 1회 자동 + (b) 챗 셀카 요청 N회(가드) 의 콤보 채택 풀이까지.
Step 6: `ImageGenerationController` — **얇은 컨트롤러** 의 정석
자, 이제 진짜 마지막 경계 만 남았어요. Step 4 에서 6 단계 협업의 서비스 를 깔았고, Step 5 에서 경계 record 두 개 + 가드 부품 두 개 를 깔았어요. 이 모든 부품이 들어왔으니 — 이제 그것들을 프론트와 연결할 출입구 만 넣으면 돼요.
오늘의 컨트롤러는 한 메서드 + 두 줄 짜리예요.
진짜로요.
그리고 그 짧음이 — ApiResponse 표준 패턴의 가치 가 가장 또렷이 보이는 부분이에요.
Day 6 에서 정당한 예외 (Flux<String>) 를 봤던 손이, 오늘 표준 패턴 (ResponseEntity<ApiResponse<T>>) 의 얇은 앞에 서면 — 왜 일반 패턴이 표준인지 가 즉석에서 들어와요.
1. 컨트롤러 전문 펼치기 (한 메서드 짜리)
ImageGenerationController 전문 부터 펼쳐볼게요. 발췌가 아니라 전문 부분이에요.
package kr.spartaclub.aifriends.image.controller;
import jakarta.validation.Valid;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.image.dto.ImageGenerationResult;
import kr.spartaclub.aifriends.image.dto.PortraitGenerationRequest;
import kr.spartaclub.aifriends.image.service.ImageGenerationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Day 7 Step 6 — 캐릭터 초상화 생성 엔드포인트.
*
* <p>{@link ApiResponse} 로 정상 응답을 래핑한다 (본 강의의 표준 응답 패턴). 에러 응답은 GlobalExceptionHandler 가
* {@code ApiResponse.fail(...)} 로 자동 변환하므로, 정상/에러 응답 형태가 대칭으로 맞춰진다.</p>
*/
@RestController
@RequestMapping("/api/images")
public class ImageGenerationController {
private final ImageGenerationService imageGenerationService;
public ImageGenerationController(ImageGenerationService imageGenerationService) {
this.imageGenerationService = imageGenerationService;
}
@PostMapping("/portraits")
public ResponseEntity<ApiResponse<ImageGenerationResult>> generatePortrait(
@Valid @RequestBody PortraitGenerationRequest request) {
String fileNameHint = "portrait-" + UUID.randomUUID();
ImageGenerationResult result = imageGenerationService.generate(
request.prompt(),
request.stylePreset(),
request.seed(),
fileNameHint);
return ResponseEntity.ok(ApiResponse.success(result));
}
}
generatePortrait() 의 본문이 — 4 줄 부분이에요. fileNameHint 한 줄, result 한 줄 (멀티라인이지만 의미상 한 줄), return 한 줄. 그게 다예요.
처음 보는 학생은 진심으로 의아 할 수 있어요. 검증은 어디 있지? 에러 처리는? 응답 변환은? 다음 절에서 그 의문을 정면으로 다뤄볼게요.
2. 왜 이렇게 짧은가
🙋 한 학생의 진심 어린 걱정
"튜터님, 컨트롤러가 너무 짧은데 — 이게 진짜 다 인가요?
prompt가 비었는지 확인하는 코드, 가드 초과 시 에러 응답 만드는 코드,ImageGenerationResult를 JSON 으로 변환하는 코드... 다 어디로 갔어요? 컨트롤러가 진지하지 않아 보여요."
너무 좋은 질문이에요. 답은 — 그게 진짜 가치 예요. 컨트롤러는 오케스트레이션만 하면 되고, 나머지는 세 가지 자동 메커니즘 이 다 처리해요. 손가락으로 짚어드릴게요.
첫째 — @Valid + @NotBlank 가 검증 자동화 — @Valid @RequestBody PortraitGenerationRequest request 한 줄이 들어가 있어요. 이 어노테이션이 Spring 의 검증 엔진 을 깨워요.
prompt 필드의 @NotBlank 가 자동으로 동작해서, 비었으면 컨트롤러 본문이 시작되기 전에 MethodArgumentNotValidException 을 던져요.
우리는 컨트롤러에 if (request.prompt() == null) throw ... 한 줄도 안 짜요. 검증 책임이 DTO 의 어노테이션 으로 이동한 거예요.
둘째 — GlobalExceptionHandler 가 에러 변환 자동화 — MethodArgumentNotValidException 도, ImageException(IMAGE_QUOTA_EXCEEDED) 도 —
컨트롤러에서 try-catch 하지 않아요. 그냥 위로 흘러나가요. 그러면 우리 코드베이스의 GlobalExceptionHandler 가 전역적으로 잡아서 적절한 ApiResponse.fail(...) 로 변환해요.
// kr.spartaclub.aifriends.common.exception.GlobalExceptionHandler (발췌)
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e, HttpServletRequest request) {
ErrorCode errorCode = e.getErrorCode();
log.warn("BusinessException: code={}, message={}", errorCode.getCode(), e.getMessage());
ErrorResponse error = buildErrorResponse(errorCode, e.getMessage(), request.getRequestURI());
return ResponseEntity.status(errorCode.getStatus()).body(ApiResponse.fail(error));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e,
HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(err -> err.getDefaultMessage() != null ? err.getDefaultMessage() : "입력값이 올바르지 않습니다.")
.orElse("입력값 검증에 실패했습니다.");
ErrorResponse error = buildErrorResponse(ErrorCode.BAD_REQUEST, message, request.getRequestURI());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.fail(error));
}
두 핸들러가 대칭으로 들어가 있어요. BusinessException (도메인 예외 — ImageException 포함) 은 각자의 ErrorCode + status 로 변환되고, MethodArgumentNotValidException 은 BAD_REQUEST + 검증 메시지 로 변환돼요. 컨트롤러는 둘 다 모르고도 일이 끝나는 패턴.
셋째 — ApiResponse.success(data) 가 응답 표준화 — 정상 응답은 항상 같은 모양. ApiResponse<T> 의 record 한 줄로 — success 플래그 + data + error 의 세 필드 가 항상 자리잡혀 있어요.
// kr.spartaclub.aifriends.common.response.ApiResponse
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(
boolean success,
T data,
ErrorResponse error) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> fail(ErrorResponse error) {
return new ApiResponse<>(false, null, error);
}
}
success(data) 는 — success=true, data=<채워진>, error=null.
fail(error) 는 — success=false, data=null, error=<채워진>.
완전 대칭의 두 모양.
클라이언트는 success 플래그 한 줄만 보고 분기할 수 있어요.
어떤 엔드포인트 의 정상이든 에러든 — 같은 wrapper 로 도착하는 보장.
세 메커니즘이 각자 자기 부분 에서 일하고 — 컨트롤러는 오케스트레이션만 해요. 이게 얇은 컨트롤러 의 정석이에요. 컨트롤러가 짧아 보이는 게 진지하지 않아서 가 아니라 나머지가 다 자동화돼서 그런 거예요. Day 5 의 SoulmateChatController 도 같은 느낌이었던 거 — 떠오르시죠?
3. Day 6 ↔ Day 7 마지막 대비표 🔁
오늘 마지막으로 대비표 한 장 으로 닫을게요. Step 4 절 5 에서 한 번 짚었지만, 오늘은 컨트롤러의 모양 자체 로 시각화 해서 적어둘게요.
| 비교 축 | Day 6 (SSE 채팅) | Day 7 (이미지 생성) |
|---|---|---|
| 컨트롤러 반환 타입 | Flux<String> (raw) |
ResponseEntity<ApiResponse<ImageGenerationResult>> |
| 미디어 타입 | text/event-stream |
application/json |
| 응답 결과 모양 | 토큰의 흐름 (시간) | record 의 컨테이너 (한 방) |
ApiResponse<T> 적용 |
예외 (정당화 받음) | 표준 (회귀) |
| 정당화 근거 | "JSON wrapper 가 낄 부분 없음" | "한 방 객체에 wrapper 가 자연스러움" |
| 컨트롤러 길이 | 비슷 (오케스트레이션만) | 비슷 (오케스트레이션만) |
이 표의 맨 아래 줄 이 — 오늘의 진짜 깨달음.
컨트롤러 길이는 둘 다 비슷 해요.
Day 6 의 SSE 컨트롤러도 얇았고, Day 7 의 이미지 컨트롤러도 얇아요.
차이는 반환 타입의 모양 뿐.
얇은 컨트롤러 는 — 예외 패턴 이든 표준 패턴 이든 어느 부분에서나 가능 한 느낌이에요.
추상화가 잘 잡힌 컨트롤러는 응답 채널의 미디어 타입에 영향받지 않아요.
ResponseEntity.ok(ApiResponse.success(result)) 한 줄이 — Day 6 에선 안 됐던 부분 에서 오늘은 자연스럽게 닫혀요. 예외는 예외라서 인정받았던 거고, 예외의 사정이 사라지면 표준으로 돌아오는 게 자연스러워요. 지난 시간과 오늘이 대비되는 부분 에 있었던 거예요.
4. 시나리오 매트릭스 — (a) + (b) 콤보 채택 의 풀이
자, 컨트롤러를 깔았으니 — 언제 이 엔드포인트를 호출하는가 의 정책을 한 번 짚을게요. 오프닝의 약속 재등장 부분이에요.
오프닝에서 우리는 세 가지 호출 시나리오 를 던져둔 적이 있죠.
- (a) 캐릭터 만들기 시 1회 자동 생성 — 캐릭터 만들기 흐름의 마지막 1 회.
- (b) 챗 셀카 요청 — 가드 통제 — 사용자가 "셀카 보내줘" 입력 시 N 회. 단
ImageDailyQuotaGuard가 한도 차단. - (c) 호감도 단계별 일러스트 — 호감도 레벨업 이벤트 시 1 회.
(a) + (b) 콤보 채택 의 이유 — 두 느낌이 보완 하기 때문이에요.
(a) 의 패턴 — 영속적 자산 1회
내 캐릭터의 고유 얼굴 을 그 캐릭터의 일생 동안 가져가는 부분이에요. 캐릭터 한 명당 최대 1 회 자동 호출 (5트랙 중 ⑤ 커스텀 트랙 선택 시만 발생, ① ~ ④ 프리셋 트랙은 비용 0). Step 5 의 가드 30 회 한도 안에 충분히 들어가는 빈도. 비용 예측 가능성 —
운영자가 오늘 100 명 캐릭터 생성 예상 (커스텀 비율 30%) = 이미지 30 장 = $0~$1.20 (Pollinations 무료, 유료 모델 환산) 같은 예산 시뮬 을 깔끔히 돌릴 수 있어요.
(b) 의 패턴 — 살아있는 인격 N회
매 셀카 요청마다 새 그림이 도착하는 부분이에요. 미연시 판타지의 정중앙 — 내가 부탁한 포즈/표정/장소 그대로 캐릭터가 셀카로 응답해요. 단 호출 빈도가 높을 가능성 이 있어 — ImageDailyQuotaGuard 가 prod 에서 진짜 일하는 부분 가 됩니다. 한도 초과 시 캐릭터가 "오늘 셀카 너무 많이 찍었어 ㅠㅠ 카메라 배터리 다 됐어..." 같은 인격 톤으로 우회 —
비용 가드의 기술적 차단 이 캐릭터 인격 으로 변환되는 감각.
왜 (a) 단독이 아니라 (a) + (b) 콤보인가 — 비용 가드의 진짜 가치는 (b) 에서만 시연됨 부분이에요. (a) 1회 자동만 채택하면 가드는 거의 발동 안 함 — 학습 측면에서 Step 5 가드 코드가 작동 장면 을 못 보여줘요. (b) 가 가드를 prod 에서 발동시키는 부분 라, Step 5 가드 → Step 9 prod 작동 의 느낌이 한 Day 안에 닫혀요. 가드를 가르치고 끝 이 아니라 깔고 작동시키고 닫는.
🔁
비교 — 대화마다 배경 일러스트 는 — 한 사용자가 하루 50 메시지 보내면 매 메시지마다 이미지 1장 = 50 장. 유료 모델 ($0.04) 면 하루 $2 / 월 $60 / 사용자 한 명에서. 사용자 100 명이면 월 $6,000. 청구서가 눈물 나는. 그래서 학습용으로도 경고만 하고 안 만들어요 —
셀카 요청은 사용자 의지로만 발생 하므로 가드 통제 가능, 자동 배경 그림은 모든 메시지에서 발생 하므로 가드로도 통제 어려움 이라는 식의 차이. 🚨
(c) 호감도 단계별 일러스트 는 — 과제로 던질 거예요. Step 6 컨트롤러를 그대로 재활용 해서 — 호감도가 친밀 단계로 올라갈 때 한 번 더 생성 같은 이벤트 기반 1 회 장면을 학생이 직접 만들어 보는. 캐릭터마다 3~5 장 이라 비용 예측도 가능.
5. 💡 튜터의 결론 — Step 6 한 줄
"
ImageGenerationController.generatePortrait()의 본문은 4 줄 — 그 짧음이 진지하지 않아서가 아니라@Valid(검증) +GlobalExceptionHandler(에러 변환) +ApiResponse.success/fail(응답 표준화) 의 세 가지 자동 메커니즘 이 일하기 때문이다. ApiResponse 의 가치는 컨트롤러를 얇게 만드는 데 있고, 그 결과 같은 엔드포인트의 정상/에러 응답 형태가 대칭 으로 떨어진다. Day 6 의 정당한 예외와 Day 7 의 표준 패턴 회귀가 — 같은 표준 응답 규약의 두 면 이라는 걸 컨트롤러 모양 자체로 시각화하는 부분다."
Step 7: 로컬 `/uploads` 저장 + 정적 리소스 서빙 — **오케스트레이션의 마지막 부품** 과 Day 8 복선
자, 드디어 마지막 Step 이에요.
Step 4 에서 ImageGenerationService.generate() 의 6 단계 협업 을 폈을 때, 그 5 번째 단계 에 — storageService.save(bytes, fileNameHint) 한 줄이 들어가 있었던 거 기억나시죠? 그 부분은 위임만 표시되어 있고 실체 는 비어 있었어요.
오늘 그 마지막 부품 을 손으로 깔고, 생성 → 다운로드 → 저장 → 응답 → 브라우저 표시 의 전체 모습 을 한 번 닫습니다.
그리고 Step 7 의 마지막 한 줄에서 — Day 8 (Vision) 의 복선 을 슬쩍 흘려보내요. 오늘 우리가 생성 한 이미지를 — 다음 시간은 ChatModel 이 입력 으로 받습니다. 생성 → 인식 → 대화 의. 그 첫 글자가 오늘 마지막 코드 한 줄에서 시작돼요.
1. ImageFileStorageService.save() 의 감각
ImageFileStorageService 의 전문 부터 펼쳐볼게요. 짧아요 — 60 줄 안에 다 들어가요.
package kr.spartaclub.aifriends.image.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Day 7 Step 7 — 생성된 이미지를 로컬 파일시스템에 저장하고 정적 리소스 URL 을 돌려준다.
*
* <p>업로드 디렉토리는 {@code aifriends.image.storage.upload-dir} (기본값 {@code ./uploads/portraits}).
* {@code StaticResourceConfig} 가 {@code /uploads/**} 를 {@code file:./uploads/} 로 매핑하므로
* 반환된 경로(예: {@code /uploads/portraits/portrait-xxx.jpg}) 는 브라우저에서 바로 조회된다.</p>
*
* <p>실제 운영에서는 S3 / CloudFront / GCS 등 객체 스토리지로 대체하는 것이 정석이다.
* 학습용 로컬 저장 시나리오로는 충분하지만, 다음 한계를 학생에게 명시해야 한다:
* <ul>
* <li>WAS 인스턴스가 N대면 파일이 한 인스턴스에만 떨어진다 → 객체 스토리지 필수</li>
* <li>컨테이너 재기동 시 휘발 → 볼륨 마운트 또는 외부 스토리지</li>
* <li>디스크 용량 폭발 위험 → TTL/cleanup 정책 필요</li>
* </ul></p>
*/
@Slf4j
@Service
public class ImageFileStorageService {
private final String uploadDir;
public ImageFileStorageService(
@Value("${aifriends.image.storage.upload-dir:./uploads/portraits}") String uploadDir) {
this.uploadDir = uploadDir;
}
/**
* 바이트 배열을 파일로 저장하고 정적 리소스 URL 을 돌려준다.
*
* @param bytes 이미지 바이트
* @param fileNameHint 파일명 힌트 (예: {@code portrait-uuid}). 안전한 문자만 남기고 sanitize 한다.
* @return 정적 리소스 URL (예: {@code /uploads/portraits/portrait-xxx-1714500000000.jpg})
* @throws IOException 디렉토리 생성 또는 파일 쓰기 실패 시
*/
public String save(byte[] bytes, String fileNameHint) throws IOException {
Path dir = Paths.get(uploadDir);
Files.createDirectories(dir);
String safeHint = sanitize(fileNameHint);
String fileName = safeHint + "-" + System.currentTimeMillis() + ".jpg";
Path target = dir.resolve(fileName);
Files.write(target, bytes);
String publicPath = "/uploads/portraits/" + fileName;
log.info("[ImageFileStorage] saved: bytes={}, path={}", bytes.length, target.toAbsolutePath());
return publicPath;
}
private String sanitize(String hint) {
if (hint == null || hint.isBlank()) {
return "image";
}
return hint.replaceAll("[^a-zA-Z0-9_-]", "_");
}
}
save() 메서드이 일직선 부분이에요. 디렉토리 보장 → 파일명 sanitize → 파일명 조합 → 쓰기 → 정적 URL 반환 의 다섯 단계. 다섯 줄짜리 일이에요.
첫째 — Files.createDirectories(dir) 의 멱등 보장 — 이 호출은 디렉토리가 이미 있으면 그냥 통과 하고, 없으면 중간 경로까지 모두 생성 해요. 운영 시점에 uploads/portraits/ 이 없을 수 있다 는 가정을 안전하게 처리하는 거예요. 첫 호출에서 디렉토리를 만들고, 두 번째 호출부터는 그냥 재사용.
학생이 처음 ./run.sh 로 띄울 때 디렉토리가 없어도 알아서 만들어주는 친절한 느낌이에요.
둘째 — safeHint + "-" + System.currentTimeMillis() + ".jpg" 의 충돌 방지 + 디버깅 친화 — 파일명에 밀리초 단위 timestamp 를 넣는 두 가지 이유가 있어요.
- 충돌 방지 — 같은
fileNameHint(예:portrait-uuid-abc) 로 여러 번 호출 되어도 timestamp 가 다르면 파일이 덮어쓰이지 않아요. UUID 두 개가 우연히 같아도 안전. - 디버깅 친화 — 운영 중
./uploads/portraits/디렉토리를ls -la하면 언제 생성된 파일인지 timestamp 로 한눈에 보여요. 느린 시간대 / 몰린 시간대 분석에 도움이 돼요.
셋째 — sanitize(...) 의 경로 조작 방어 — fileNameHint 가 클라이언트로부터 흘러올 가능성이 있는 불신 입력 부분이에요. 만약 누군가 "../etc/passwd portrait" 같은 경로 조작 문자 를 흘려넣으면 —
Files.write() 가 디렉토리 경계를 넘어 임의 경로에 파일을 쓰려 시도할 위험이 있어요. path traversal 이라는 보안 취약점이에요.
sanitize() 한 줄로 그 위험을 원천 차단 해요. replaceAll("[^a-zA-Z0-9_-]", "_") — 알파벳 / 숫자 / 언더스코어 / 하이픈 외의 모든 문자 를 언더스코어로 치환. .., /, 공백, 한글 — 전부 안전한 문자로 깎여나가요.
️ 보안 규약 — 사용자 입력이 파일명에 닿는 부분은 항상 sanitize
파일 시스템에 닿는 사용자 입력은 예외 없이 sanitize. SQL Injection 의 PreparedStatement 와 같은 방식으로 — 기본 가드 가 깔려야 해요. Day 19~20 (운영) 에서 입력 검증 정책 을 한 번 더 정리할 거예요.
2. StaticResourceConfig 의 정적 리소스 매핑
이제 저장된 파일 을 브라우저가 직접 호출 할 수 있게 정적 리소스 로 노출해야 해요. Spring MVC 의 WebMvcConfigurer.addResourceHandlers() 한 줄이 그 부분을 풀어줘요. 전문 펼칠게요.
package kr.spartaclub.aifriends.image.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Day 7 Step 7 — 생성된 이미지를 정적 리소스로 노출.
*
* <p>{@code /uploads/**} 요청을 {@code file:./uploads/} 로 매핑한다.
* 따라서 {@link kr.spartaclub.aifriends.image.service.ImageFileStorageService}
* 가 돌려준 {@code /uploads/portraits/xxx.jpg} 경로를 그대로 {@code <img src="...">} 에 적어 쓸 수 있다.</p>
*
* <p>실제 운영에서는 S3 + CloudFront 같은 CDN 으로 대체하는 것이 정석. 학습용 단순 모양이다.</p>
*/
@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {
private final String uploadBaseDir;
public StaticResourceConfig(
@Value("${aifriends.image.storage.upload-dir:./uploads/portraits}") String uploadDir) {
// upload-dir 이 ./uploads/portraits 라면 base 는 ./uploads/ 로 한 단계 위로 매핑한다.
// (학습용으로는 그냥 고정 ./uploads/ 를 둬도 충분하다.)
this.uploadBaseDir = "file:./uploads/";
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations(uploadBaseDir);
}
}
핵심은 addResourceHandler("/uploads/**").addResourceLocations("file:./uploads/") 한 줄이에요. 이 한 줄이 — URL 의 /uploads/... 패턴 요청 을 디스크의 ./uploads/... 파일 로 자동 매핑 해요.
왜 file: 프로토콜인가 — addResourceLocations() 는 세 가지 프로토콜 을 받아요.
classpath:— JAR 안에 들어간 정적 리소스 (예:src/main/resources/static/). 빌드 시점에 들어간 파일.file:— 런타임 디스크 경로 의 파일. 런타임에 생성된 파일.- (HTTP URL) — 외부 리소스 프록시. 거의 안 씀.
우리 케이스는 런타임에 생성된 이미지라 — file: 이 정답이에요. classpath: 는 빌드 시점에 들어간 정적 자산 (CSS / JS / 디자이너 일러스트) 용이고, AI 가 그려낸 그림 처럼 런타임에 태어난 파일은 파일시스템 에 있어야 해요. 클래스패스 안 쓰는 이유 가 거기예요.
왜 ./uploads/ 의 상대 경로 인가 — 이 부분은 Docker 컨테이너의 작업 디렉토리 와 묶여 있어요. 우리 Dockerfile (또는 docker-compose.yml) 이 컨테이너의 workdir 을 /app 같은 곳으로 잡아두면, ./uploads/ 는 /app/uploads/ 가 돼요.
컨테이너 안에서 일관된 위치 가 되는 패턴.
운영 환경 한 줄 예고 — 위 설계는 학습용 단순 모양 부분이에요. 실 운영에선:
- S3 + CloudFront 같은 객체 스토리지 + CDN 으로 가요. WAS 인스턴스 N 대 환경에서 어느 인스턴스에 저장됐는가 와 무관하게 모든 사용자가 같은 URL 로 접근 가능.
- 파일이 WAS 의 디스크 에 있는 한 — 스케일아웃의 적 부분이에요. 인스턴스 1 번에 저장된 파일을 인스턴스 2 번이 못 봐요.
- 이 한계는 Day 19~20 (운영 / Observability) 에서 영속 스토리지 정책 으로 한 번 더 다룰 부분예요. 오늘은 학습 단계 단순화 라는 점만 적어두고 가요.
3. .gitignore 의 한 줄 — 생성물은 git 에 두지 않는다
./uploads/ 가 만들어지기 시작하면 — git status 에 낯선 파일들 이 한가득 떠요. 여기에 원칙 한 줄을 적어두는 게 — .gitignore 의 마지막 줄.
.DS_Store
# Day 7 — generated images
uploads/
세 번째 줄, uploads/ 가 그 한 줄. 이 한 줄이 파급력 이 큰 결정이에요. 두 가지 이유로요.
첫째 — 생성물 (artifact) 은 코드와 분리한다 의 정석 — git 은 소스코드 를 넣는 도구지 런타임 산출물 을 넣는 도구가 아니에요. 같은 방식으로 build/, target/, .gradle/ 도.gitignore 에 들어가 있죠.
AI 가 생성한 이미지 는 컴파일러가 만든.class 파일 과 같은 식의 산출물. 코드와 분리해야 해요.
둘째 — 비결정적 산출물의 함정 — 이 부분은 더 날카로운 이유예요. 같은 prompt 로 호출해도 Pollinations.ai 의 응답이 매번 다른 그림이에요. 시드를 고정해도 서비스 측 모델 업데이트 면 결과가 바뀌어요. 비결정적 산출물.
만약 이걸 git 에 넣으면 — 학생 A 의 브랜치 와 학생 B 의 브랜치 가 같은 코드 인데 uploads 디렉토리만 다른 상태로 충돌해요. PR 리뷰가 의미 없는 이미지 diff 로 가득해지는. ️
.gitignore 의 한 줄이 그 장면을 원천 차단 해요. 코드는 깨끗 하게, 산출물은 학습자 각자의 디스크에 — 분리의 느낌이에요.
💡 이런 산출물의 git 정책 — 한 줄로 — 결정적이면 적고 (소스), 비결정적이면 분리한다 (산출물).
.class/.jar/ AI 생성 이미지 / DB 의 Flyway migration 결과 — 모두 비결정적이거나 컴파일 산출물 이라 분리. 한편 디자이너가 손으로 그린 일러스트 는 결정적 (사람의 의지로 픽셀이 정해진) 이라 git 에 적어도 OK. 그 경계가 오늘 잡혔어요.
4. ./run.sh 시연으로 닫기
자, 코드만 보고 끝내면 호흡이 안 닫혀요. 실제로 띄워서 내 손으로 한 번 돌려봐야 Day 7 의 결실 이 손에 남아요.
cd lecture-source-code/ai-friends
# 1. 환경 변수 (Pollinations.ai 는 API 키 없음 — 다른 키만 채우면 됨)
cp .env.example .env # 이미 있으면 생략
# .env 에서 GEMINI_API_KEY (Day 5 ChatMemory) · MYSQL 비밀번호만 채움
# 2. 앱 기동 (MySQL + Spring Boot 컨테이너)
./run.sh up
# 3. 첫 호출 — POST /api/images/portraits
curl -X POST "http://localhost:8080/api/images/portraits" \
-H "Content-Type: application/json" \
-d '{"prompt":"a cute pink rabbit, anime style", "stylePreset":null, "seed":null}'
# {
# "success": true,
# "data": {
# "localPath": "/uploads/portraits/portrait-3f1e...-1714500123456.jpg",
# "externalUrl": "https://image.pollinations.ai/prompt/a%20cute%20pink%20rabbit...",
# "prompt": "a cute pink rabbit, anime style",
# "modelName": "pollinations-flux",
# "estimatedCostUsd": 0.0
# }
# }
# 4. 브라우저 직접 호출 — localPath 를 그대로 URL 로
open "http://localhost:8080/uploads/portraits/portrait-3f1e...-1714500123456.jpg"
# 핑크 토끼 일러스트가 떠 있으면 OK 🐇
세 단계가 끝. ./run.sh up → curl POST → open (또는 브라우저 주소창에 URL 붙이기). 그게 다예요. 그리고 그 세 단계의 결과 가 — AI 가 방금 그려낸 핑크 토끼 가 화면에 떠 있는 형태입니다.
💡 호출 전 준비물 — Pollinations.ai 만의 단순함 — 다른 프로바이더라면 API 키 발급 → 환경변수 설정 → 결제 정보 등록 같은 부분가 세 단계 더 늘어나요. Pollinations.ai 는 키 없음, 결제 없음, 회원가입 없음.
./run.sh up만으로 첫 그림이 뜨는 식이라 — 학습 단계의 진입장벽 이 없어요. Step 3 에서 깐 학생 진입장벽 최저 의 느낌이 오늘 마지막에 다시 또렷이 보였어요.
5. 🙋 한 학생의 운영 걱정 ⚠
🙋 한 학생의 진심 어린 걱정
"튜터님, Docker 컨테이너에서
./uploads/에 저장하면 — 컨테이너 재시작 시 사라지는 거 아니에요? 오늘 만든 그림이 다음 날 사라진다면 — 프로필 이미지가 매일 새로 그려지는 사고가 나잖아요."
진짜 정확한 통찰 부분이에요. 두 가지로 풀어드릴게요.
첫째 — 맞아요, 본 강의 코드 그대로면 휘발돼요. 우리 docker-compose.yml 은 학습용 단순 모양 이라 — 컨테이너의 ./uploads/ 가 호스트와 분리된 컨테이너 내부 디스크 에 있어요. docker compose down 후 up 하면 —
오늘 그린 그림이 다 사라져요. 학습 환경에선 충분 하지만 운영에선 절대 안 되는 느낌이에요.
둘째 — 실 운영의 두 가지 길 —
- Docker 볼륨 마운트 —
docker-compose.yml의volumes섹션에./uploads:/app/uploads한 줄을 넣으면 — 컨테이너의/app/uploads/가 호스트의./uploads/에 영속 으로 묶여요. 컨테이너 재시작에도 살아남아요. - S3 같은 외부 객체 스토리지 — 진짜 운영의 정답. WAS 인스턴스 N 대로 스케일아웃 가능, CDN 으로 빠른 전송, 백업 자동화. 작은 코드 변경만으로
ImageFileStorageService를S3FileStorageService로 갈아끼우면 됨 (인터페이스 추출 시).
본 강의는 학습용 단순 모양 이라 둘 다 안 깔지만, — Day 19~20 (Observability + 배포) 에서 영속 스토리지 정책 을 한 번 더 다룰 부분예요. 그때 볼륨 마운트 한 줄, S3 마이그레이션 한 줄로 풀어요. 오늘은 왜 휘발되는지 알면서 넘어가는 방식으로 충분해요.
7. Day 8 (Vision) 복선 심기
자, 오늘의 마지막 주제이자 다음 시간의 첫 글자.
오늘 우리는 — ChatModel 의 자매 추상화 ImageModel 을 익히셨어요. 텍스트 생성 과 같은 방식으로 이미지 생성 도 한 줄짜리 추상화로 풀린다는이었죠.
그런데 생성 과 인식 은 반대 방향이에요.
| 방향 | 입력 | 출력 | Day |
|---|---|---|---|
| 생성 (Generation) | 텍스트 prompt | 이미지 byte[] | Day 7 (오늘) |
| 인식 (Vision) | 이미지 + 텍스트 prompt | 텍스트 응답 | Day 8 (다음 시간) |
다음 시간 우리가 만날 장면은 — 오늘 우리가 만든 portrait URL 을 ChatModel 에게 보여주는 부분이에요. 같은 캐릭터의 사진을 보고 캐릭터가 자기소개 하거나, 사용자가 업로드한 방식 사진에 캐릭터가 코멘트 하는 인터랙션이에요.
핵심 키워드 두 개를 미리 던져둘게요.
MediaContent/Media객체 — Spring AI 의 멀티모달 입력 추상화.UserMessage안에 텍스트 + 이미지 를 함께 담아 보내는 패턴.Media.builder().mimeType(MimeTypeUtils.IMAGE_JPEG).data(URI.create("...")).build()같은 한 줄로.- Vision 지원 모델 — 모든 ChatModel 이 vision 가능한 건 아니에요. 멀티모달 라인업 만 이미지를 입력으로 받습니다 (강의 시점 기준 무료 1순위는 Gemini 의 최신 Flash 모델 — 본 교안 집필 시점엔 Gemini 2.5 Flash 였어요). 모델 라인업은 시간이 지남에 따라 빠르게 진화하니, Day 8 시작에서 그 시점의 1순위 무료 옵션을 다시 정리해 드릴게요. 모델 선택의 느낌이 또 한 번 등장하는 부분이에요.
다음 시간의 한 문장 예고
"오늘 만든 portrait URL 이 — 다음 시간 ChatModel 의 입력으로 흘러갑니다. 캐릭터가 자기 사진을 보고 첫 마디를 건네는 모습입니다."
오늘 생성 한 그림을 — 다음 시간 인식 시켜요. 같은 도메인 모델, 같은 ChatMemory, 같은 ai-friends 캐릭터 — 입력 채널이 텍스트 한 줄에서 텍스트 + 이미지 로 늘어났을 뿐인데, 사용자 경험은 완전히 다른 차원 이 펼쳐져요. 생성 → 인식 → 대화 의.
8. 💡 튜터의 결론 — Step 7 한 줄
"
ImageFileStorageService의 60 줄짜리 마지막 부품으로 — AI 가 그려낸 byte[] 가 디스크의 파일이 되고, 정적 URL 이 되고, 브라우저의<img>가 되는 5 단계이 닫힌다. 디렉토리 자동 생성 + 파일명 sanitize + 정적 리소스 매핑 +.gitignore 의 한 줄 — 학습용 단순 모양 안에 운영 시점의 정책 결정 (객체 스토리지·볼륨 마운트·생성물 분리) 이 그림자처럼 들어가 있다."
이 방식으로 — Step 7 의 오케스트레이션 장면 이 닫혔어요. 본 교안 Step 1~7 으로 학습용 lab (ImageGenerationController POST /api/images/portraits) 까지 AI 화가에게 그림 의뢰 하는 진행은 익히셨어요.
부록 안내 — Day 7 의 두 부록 (선택)
Day 7 본 교안과 함께 두 개의 부록을 분리해뒀어요. 둘 다 선택 자료로, 강의 시간이 빠듯하면 학생 자율 학습 으로 미뤄도 됩니다. 본 교안 Step 1~7 만 따라가도 ImageModel 추상화 + 학습용 lab 의 학습 목표는 완결됩니다.
A. [Day 7.5 부록] — ai-friends prod 결합 (5트랙 캐릭터 만들기 + 챗 셀카 요청)
Step 6 의 콤보 매트릭스 에서 약속한 (a) + (b) prod 결합 두 부분 — (a) 캐릭터 만들기 5트랙 외모 선택 + (b) 챗 셀카 요청 — 은 호흡을 한 박자 끊고 Day 7.5 부록 으로 분리해뒀어요. 약 60~80분 분량.
무엇이 들어 있나 — SoulmateService 가 학습용 lab ImageGenerationService 를 흡수하는 부분 (Before/After 리팩토링 흐름의 lab → prod 흡수 가 같은 Day 안에 닫히는 첫 패턴), SelcaService 의 키워드 분기 + 외모 일관성 prompt 합성 + 가드 한도 초과 시 캐릭터 인격 우회 ("오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어..."), prod 시연으로만 보이는 모양 두 부분의 hotfix.
B. [Day 7 post-mortem 부록] — 자동 설정의 흐름이 깨지는 4 부분
Step 6 의 마지막 절에서 포인터로 안내한 진짜 prod 시연에서 발견한 4 부분 — ① 자동 설정의 전역 키 누수 · ② OpenAiImageModel.call() checkcast · ③ Azure SAS URL 이중 인코딩 · ④ DALL-E 3 prompt expansion — 의 hotfix은 별도 사후 부록 으로 분리해뒀어요.
약 30~40분 분량.
무엇이 들어 있나 — 공통 추상화의 한 줄로 끝남 이 멀티 프로바이더 / pre-signed URL / 상위 모델의 자체 해석 같은 모양을 만나면 어떻게 깨지는지의 4 가지 카테고리 + 우리가 적은 손가락 보호 한 줄들 (자동 설정 끔 · 빈 옵션 적기 · URI.create() · ensureAnimeStyle() suffix).
공통 추상화 (얇은 컨트롤러) 의 약속이 호출 부분에선 손가락 보호 한 줄들로 떠받쳐진다 는 한 문장을 가져갈 수 있는 부분.
언제 따라가나 — 강의 시간이 넉넉하면 본 교안 마무리 → A (Day 7.5) → B (사후 부록) → Day 8 진입 순서. 또는 흥미가 있는 분만 B 만 골라 봐도 좋아요. 프로바이더 추상화의 약속이 어디서 깨지는가 에 호기심이 있는 학생 대상.
마무리 — 오늘 익힌 도구들
오늘의 여정 한눈에 — 오늘 만든 다섯 가지 (+ 부록의 두 부분)
Day 7 의 3 시간을 한 문장으로 요약하면 — "AI 화가에게 그림 의뢰하는 진행 — ChatModel 의 자매 추상화 ImageModel 을 익히고, 학습용 lab 까지 완결한 하루" 였어요.
지난 시간 Day 6 에서 흘러오는 토큰들 을 익힌 손이, 오늘 한 방에 도착하는 큰 payload 앞에서 왜 이건 못 흘려보내는가 를 자연스럽게 묻고, ApiResponse 표준 패턴 회귀 로 답을 찾았어요.
ai-friends 본 게임의 prod 흡수 두 부분는 Day 7.5 부록으로 분리해서 호흡을 끊어뒀어요 — 그래도 학습용 lab 자체로 ImageModel 추상화의 학습 목표는 완결됩니다.
오늘 익힌 다섯 가지 모습 을 한 줄씩 묶을게요.
| 한 줄 요약 | |
|---|---|
ImageModel 자매 추상화 |
"ChatModel 과 같은 방식으로 ImageModel 인터페이스 주입 — 프로바이더 추상화가 이미지 도메인에서도 동일하게 동작" |
Custom PollinationsImageModel |
"공식 starter 가 없어도 ImageModel 인터페이스 구현체 50 줄로 끼워넣을 수 있다는 진행 — Spring AI 의 열린 추상화" |
| 6 단계 오케스트레이션 서비스 | "ImageGenerationService.generate() 가 가드 → 추정 → 모델 → 다운로드 → 저장 → 결과의 6 단계를 순차 협업 으로 묶는다 — 컨트롤러는 4 줄로 얇아진다" |
| ApiResponse 표준 패턴 회귀 | "Day 6 SSE 의 정당한 예외 (Flux<String>) ↔ Day 7 이미지의 표준 패턴 (ResponseEntity<ApiResponse<T>>) — 같은 응답 규약의 두 면이 컨트롤러 모양 자체로 시각화" |
| 비용 가드 + USD 추정 | "ImageDailyQuotaGuard 가 가드 첫 줄에 자리잡혀 30 회 한도 초과 시 IMAGE_QUOTA_EXCEEDED 차단, ImageCostEstimator 가 응답에 USD 비용 에코 — 비용 감각 을 코드로 적어둔 부분" |
부록 (Day 7.5) 에 추가로 들어 있는 두 부분:
- 5트랙 캐릭터 만들기 (prod ①) — 4 프리셋 (즉시·비용 0) + 1 커스텀 (외모 prompt·1회 비용). 비용 의식이 UX 분기에 들어가는.
Soulmate.appearancePrompt컬럼이 외모 정체성을 영속. - 챗 셀카 요청 (prod ②) —
SelcaService가 키워드 분기 + 외모 일관성 prompt 합성 + 가드 한도 초과 시 캐릭터 인격 우회. 기술 차단이 인격 톤으로 변환되는 미연시 몰입감.
이 다섯 개를 다 외우라는 게 아니에요.
"ChatModel 과 같은 방식으로 ImageModel 도 인터페이스 주입한다 — 프로바이더 추상화는 도메인에 무관하다" 와 "이미지 생성 응답은 흘려보낼 수 없는 큰 payload 라 ApiResponse로 회귀한다" 두 문장만 3 개월 뒤에도 기억하시면 오늘 수업은 성공이에요.
(부록까지 따라온 분은 비용 가드가 캐릭터 인격으로 변환되어 사용자 화면에 도착한다 — 기술이 인격으로 흘러들어가는 부분 한 문장을 더 가져갈 수 있어요.)
💡 Before/After 리팩토링 박제 — lab → prod 흡수 가 같은 Day 안에 닫히는 첫 패턴
Day 3 ~ Day 6 까지의 lab 들 (
SoulmateChatController· streaming) 은 prod 흡수가 다음 Day 또는 같은 Day 후반 으로 흘러갔어요. Day 7 은 한 발 더 나아간 부분 — lab (Step 6 의ImageGenerationController) → prod 흡수 (부록 Step 8 의SoulmateService5트랙 + 부록 Step 9 의SelcaService분기) 가 같은 Day (본 교안 + 부록) 안에 두에 적어뒀어요.왜 이게 의미 있는가 — 학생이 "새 추상화 (ImageModel) 를 익히는 부분 → 그게 ai-friends 본 게임에 어떻게 들어가는지 보는 부분" 가 연속된 호흡 으로 펼쳐져요. "신규 기능 시연만 하는 lab 형 강의" 가 아니라 "기존 코드의 어디를 어떻게 더 깔끔하게 만들어주는가" 의 모양을 한 Day 안에 닫는 패턴. Spring AI 가치의 정중앙.
이 결은 — Day 8 (Vision) 에서도 동일하게 닫힐 부분 예요. 이미지 분석 lab → ai-friends 본 게임에 사용자가 사진 보내면 캐릭터가 코멘트 의 prod 흡수 가 한 Day 안에 또 닫혀요. Day 7 의 패턴이 Day 8 ~ 14 의 모든 새 추상화에 반복됨 의 시그널.
Day 8 복선 클로저 — 한 줄로 닫기
오늘 생성 의 장면을 익힌 손이, 다음 시간 인식 의 장면을 만나요. 핵심 한 문장으로 마무리할게요.
"
ChatModel의 자매 추상화ImageModel— 들어왔고, 학습용 lab 까지 완결했어요. (부록까지 따라왔다면 5트랙 캐릭터 만들기 와 챗 셀카 요청 으로 ai-friends 본 게임의 prod 흐름에도 적어뒀고요.) 다음 시간엔 반대 방향 —ChatModel이 이미지를 입력으로 받는 장면입니다. 오늘 만든 그림이 Day 8 의 입력 이 되어요 — 사용자가 캐릭터에게 사진을 보내면 캐릭터가 코멘트 하는. 생성 → 인식 → 대화의, 다음 시간에 만나요."
오늘의 도전 과제 (Homework)
[구현 1] 호감도 단계별 일러스트 — 이벤트 기반 1 회 생성 ⭐⭐
배경 시나리오
ai-friends 의 PM 이 캐릭터 친밀도 시스템을 새로 디자인했어요.
"튜터님, 캐릭터마다 호감도 0~100 사이를 8 단계로 끊었거든요. 단계 1 은 차갑고 시크 한 표정, 단계 8 은 눈이 마주치자 함박웃음 — 단계가 올라갈 때마다 캐릭터의 현재 단계에 어울리는 일러스트 가 한 장씩 떠 있으면 사용자가 내 캐릭터가 마음을 열어가는 감각을 진하게 느낄 것 같아요."
오늘 시나리오 (a) 캐릭터 만들기 1회 자동 과 시나리오 (b) 챗 셀카 요청 가드 통제 의 콤보를 완성했고, Step 6 의 시나리오 매트릭스 에서 시나리오 (c) 호감도 단계별 일러스트 는 과제로 미뤄둔다 고 약속했어요. 그 약속을 다시 다룰 부분예요.
💡 왜 굳이 이 과제를 할까요?
ImageGenerationService의 재사용성 검증 — 오늘 만든 서비스가 새 호출 컨텍스트 에서도 손 한 번 안 대고 그대로 동작하는지 확인. 컨트롤러만 새로 짜면 끝나는 느낌이 들어와요.- 캐싱의 첫 감각 — 같은
(characterId, stage)조합은 첫 1 회만 생성 하고 이후엔 캐시 히트. Step 5 의 비용 가드와 같은 정신 — 호출하지 않을 수 있으면 가장 좋은 가드. Day 16 (RAG) 의 임베딩 캐싱과도 같은 패턴. - 이벤트 기반 호출의 예측 가능성 — 캐릭터 1 명 × 8 단계 = 최대 8 회. 사용자 100 명이면 최대 800 회. 비용 시뮬레이션이 깔끔 한 부분.
✅ 요구사항
- 새 엔드포인트 —
POST /api/characters/{characterId}/affinity/portraits
- 입력: 캐릭터 ID (PathVariable) + 현재 호감도 (RequestBody, 0~100 정수)
- 출력:
ResponseEntity<ApiResponse<ImageGenerationResult>>
- 호감도 → 단계 매핑 — 8 단계 (0~10, 11~25, 26~40, 41~55, 56~70, 71~80, 81~90, 91~100). 단계 경계 는 학생이 직접 디자인 가능
- 단계 → prompt 매핑 — 단계 1 = "a cold and aloof anime girl", 단계 8 = "a warmly smiling anime girl with sparkling eyes" 같은 패턴. 학생이 직접 매핑 (분기 if/else 또는 enum + Map 권장)
- 캐시 —
(characterId, stage)첫 1 회만 생성 — 이후 호출은 캐시 히트. 학습용은Map<String, ImageGenerationResult>를@Service필드에 두면 충분 (분산 동시성 X) - 동작 검증 (필수) — 같은
(characterId, stage)로 두 번 연속 호출 시 두 번째 응답의localPath가 첫 번째와 동일 해야 함 (= 캐시 히트로 새 이미지 생성이 일어나지 않음). 다른stage로 호출하면 새 localPath 가 떨어져야 함 (= cache miss → 새로 생성).
🔁 확인 방법
./run.sh up
# 1) 같은 캐릭터 / 같은 단계 — 두 번 호출
curl -X POST "http://localhost:8080/api/characters/1/affinity/portraits" \
-H "Content-Type: application/json" \
-d '{"affinity": 50}'
# 첫 호출: localPath 가 새로 생성됨
curl -X POST "http://localhost:8080/api/characters/1/affinity/portraits" \
-H "Content-Type: application/json" \
-d '{"affinity": 50}'
# 두 번째 호출: 같은 localPath 가 반환됨 (캐시 히트)
# 2) 같은 캐릭터 / 다른 단계 — 새로 생성됨
curl -X POST "http://localhost:8080/api/characters/1/affinity/portraits" \
-H "Content-Type: application/json" \
-d '{"affinity": 90}'
# 단계 7 → 새 prompt → 새 ImageGenerationResult
💡 힌트
- Step 6 의 컨트롤러를 복제 해서 시작 —
ImageGenerationController그대로 옆에 두고, 새AffinityPortraitController를 만들어 호감도 → 단계 → prompt 매핑 만 추가.imageGenerationService.generate(...)호출은 동일. - 캐시 키는
characterId + ":" + stage— 학습용은 String 으로 충분. - 분산 환경 동시성은 오늘 범위 외 — 같은
(characterId, stage)에 동시 호출이 들어올 가능성이 학습 환경에선 낮으니ConcurrentHashMap정도로 가볍게. 본격 분산 락은 Day 19~20 에서.
제약 / 금지
- 새
ImageModel만들지 말 것 — 오늘 만든PollinationsImageModel그대로 재사용. 재사용성 이 본 과제의 핵심. - 컨트롤러 응답 raw 객체 직접 반환 금지 — 본 강의의 ApiResponse 표준 패턴 —
ResponseEntity<ApiResponse<ImageGenerationResult>>형태 유지.
[구현 2] 비용 가드 강화 — 예상 비용을 응답에 노출 + 누적 가드 ⭐⭐⭐
배경 시나리오
같은 PM 이 한 가지 더 부탁해요.
"튜터님, 지금 응답에
estimatedCostUsd: 0.0이 들어가 있는데 — Pollinations 를 쓰니 0 이지만, 나중에 DALL-E 3 로 갈아끼울 때 사용자가 오늘 총 얼마 썼는지 한눈에 보면 좋을 것 같아요. 그리고 누적 비용이 $1 을 넘으면 추가 가드까지 적어주면 — 영업팀이 고객별 일일 예산 을 안전하게 운영할 수 있어요."
Step 5 의 횟수 기반 30 회 한도 는 깔았지만, 비용 기반 누적 한도 는 없어요. 둘은 다른 주제예요 — 횟수가 적어도 유료 모델 + 고해상도 면 비용이 폭발할 수 있고, 횟수가 많아도 무료 모델 이면 비용이 0 일 수 있거든요. 누적 비용 가드 를 새로 깔 부분이에요.
💡 왜 굳이 이 과제를 할까요?
- 단일 책임 원칙 (SRP) 의 감각 —
ImageCostEstimator가 추정 과 누적 두 책임을 가지면 책임이 흐려져요. 새 클래스ImageCostBudgetGuard로 누적 + 가드 발동 만 따로 떼는 패턴. - 새 ErrorCode 의 재현 — Day 1 부터 익숙해진
ErrorCode + BusinessException + GlobalExceptionHandler의 흐름을 처음부터 끝까지 한 번 더 펼쳐 보는. - 공통 Clock 컴포넌트의 그림자 —
ImageDailyQuotaGuard와ImageCostBudgetGuard가 각자 day rollover 코드 를 갖고 있으면 — 동기화 시점 이 어긋날 위험. 공통Clock추출이 Day 19 운영 의 모양을 미리 익히는 패턴.
✅ 요구사항
ImageGenerationResultrecord 에cumulativeCostUsdToday필드 추가 — 신규 record 만들고 매퍼 한 줄.ImageCostBudgetGuard새 클래스 추가 —ImageDailyQuotaGuard의 day rollover 패턴을 그대로 따라 구현.addAndCheck(double newCost)메서드 한 개. 누적이 $1.0 초과 시ImageException(IMAGE_COST_BUDGET_EXCEEDED)발동.- 새 ErrorCode
IMAGE_COST_BUDGET_EXCEEDED추가 —ErrorCodeenum 에 한 줄 추가 (status: 429 또는 402, code: I003 같은 맥락). ImageGenerationService.generate()의 가드 첫 줄 바로 다음 에 budget guard 호출 —quotaGuard.checkAndIncrement()다음 줄에budgetGuard.addAndCheck(cost).GlobalExceptionHandler별도 핸들러 추가 금지 —ImageException은BusinessException상속이므로 기존 핸들러 가 자동 처리되는 흐름을 그대로 활용.
동작 검증 (필수) — aifriends.image.budget.daily-usd: 1.0 같은 작은 한도로 설정 후 유료 모델 매핑 (예: openai-dall-e-3-standard = $0.04) 으로 갈아끼워 시뮬레이션.
25 회 호출까지는 통과 ($1.00 누적), 26 번째 호출이 IMAGE_COST_BUDGET_EXCEEDED (HTTP 429) 로 거절되는지 확인.
정확히 $1.0 까지 통과 / $1.01 부터 발동 처럼 어느 쪽 경계에 두는지 의 결정도 코드로 박혀 있어야 함.
🔁 확인 방법
./run.sh up
# 1) 한도 거의 안 닿을 때 — 정상 응답
curl -X POST "http://localhost:8080/api/images/portraits" \
-H "Content-Type: application/json" \
-d '{"prompt":"a cat", "stylePreset":null, "seed":null}'
# 200 OK + ApiResponse.success
# `cumulativeCostUsdToday` 가 0.04 같은 누적값으로 떨어지는지 확인 (유료 모델 매핑 시)
# 2) 한도 직전 / 직후 — 한도 초과 시 429 거절
# (위 호출을 한도까지 반복 후 한 번 더 호출)
curl -X POST "http://localhost:8080/api/images/portraits" \
-H "Content-Type: application/json" \
-d '{"prompt":"a cat", "stylePreset":null, "seed":null}'
# 429 + ApiResponse.fail(code="I003", message="이미지 누적 비용이 일일 한도를 초과했습니다.")
💡 힌트
ImageDailyQuotaGuard의 day rollover 코드를 그대로 따라가서 새 클래스 작성 —AtomicReference<LocalDate>+AtomicReference<Double>두 필드. 매 호출마다 오늘 날짜와 비교 → 다르면 reset.- bonus — 공통
Clock컴포넌트 추출 —ImageDailyQuotaGuard와ImageCostBudgetGuard둘 다LocalDate.now()직접 호출이라면,Clock인터페이스로 빼서 주입 하면 자정 경계 시나리오를 임의 시점으로 재현 가능 해져요. 이 부분은 선택 이지만 시도하면 보너스 채점. @RequiredArgsConstructor+ final 필드 방식으로 —ImageGenerationService가 두 가드를 동시 주입 받게 두면 됨.
제약 / 금지
ImageCostEstimator에 누적 책임을 추가 금지 — 단일 책임 원칙 위반. 새 클래스ImageCostBudgetGuard로 분리.GlobalExceptionHandler에 새 핸들러 추가 금지 —BusinessException핸들러가 코드 한 줄도 안 건드리고 새 ErrorCode 를 자동 처리하는 느낌이 핵심.
생각해볼 주제
이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운
ImageModel추상화 · 비용 가드 · 정적 리소스 매핑 의 결정들을 한 발 떨어져 바라보고, "왜 이렇게 결정했지?" 와 "다른 길은 없었나?" 를 사고하는 자리예요. 면접에서도 자주 등장하는 토픽들이라, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다. ️
주제 1 — 추상화의 가치는 어디까지 가는가
Step 3 에서 우리는 PollinationsImageModel 을 직접 짜봤어요. 공식 Spring AI starter 가 없어도 ImageModel 인터페이스만 구현하면 같은 방식으로 끼워넣을 수 있다는이었죠. 50 줄짜리 어댑터 한 장이 기존 서비스 코드를 한 줄도 안 건드리고 새 프로바이더를 받아들였어요. 추상화의 단맛.
그런데 — 추상화는 비용 부분이에요.
코드 라인 수, 학습 부담, 디버깅 난이도, 간접 호출 진행 추적 비용.
Spring AI 가 ChatModel / ImageModel 같은 공통 추상화 를 제공한다는 사실 자체 가 — 그 추상화의 가치가 도메인 간 공유될 만큼 충분히 크다 는 누군가의 판단 이었거든요.
그 판단의 결 을 우리도 직접 내려봐야 해요.
🎯 핵심 질문 — 모든 외부 API 호출을
ChatModel/ImageModel같은 인터페이스 추상화 로 감싸야 할까? 어디까지가 과한 추상화 이고, 어디부터가 합리적 추상화 인가? 우리 코드베이스의 외부 의존 지점 (예:JdbcChatMemoryRepository,RestClient직접 호출,RedissonClient) 마다 추상화 도입 / 직접 의존 중 어느 쪽이 맞는지의 판단 기준은 무엇인가?
생각해볼 자료:
- Step 3 의
PollinationsImageModel50 줄 — 추상화로 얻은 것 (스위치 가능성, 모킹 용이성) 과 낸 비용 (어댑터 클래스 한 개, 의존 한 단계 더) - Day 2 의
ChatModel인터페이스 주입 원칙 — 왜 이건 합리적이었는가 - 우리 코드베이스에 추상화 안 한 부분 (예:
RestClient직접 호출,ObjectMapper직접 사용) — 왜 이 부분들은 추상화하지 않았는가
주제 2 — 비용 가드의 책임 분리 — 어디서 막나
Step 4 에서 quotaGuard.checkAndIncrement() 가 서비스 첫 줄 에 적어뒀어요. 우리는 서비스 진입 시점 에 가드를 발동시키는 방식을 채택했지만, 이 결정엔 반대 의견 이 있을 수 있어요.
- 컨트롤러 에 두는 의견 — 진입점에서 가장 빠르게 막는다 의 입장. 서비스 호출 자체가 일어나지 않으니 더 가벼움.
- AOP
@RateLimit어노테이션 으로 빼는 의견 — 횡단 관심사로 분리 의 입장. 비즈니스 로직과 가드 로직을 코드 위치 자체에서 분리. - 우리가 채택한 서비스 첫 줄 — 도메인 응집 의 입장. 가드의 임계값 / 정책이 도메인 규칙의 일부 라 도메인 코드 안에 있는 게 자연스럽다는 입장.
면접에서 "가드를 왜 거기에 두셨어요?" 가 들어오면 30 초 안에 정리할 수 있어야 해요. 그리고 반대 입장 의 합리성도 합리화 할 줄 알아야 결정의 트레이드오프를 진짜로 이해한 거예요.
🎯 핵심 질문 —
quotaGuard.checkAndIncrement()의 위치 — 컨트롤러 첫 줄 / 서비스 첫 줄 / AOP@RateLimit세 가지 중 우리는 서비스 첫 줄 을 골랐다. 그 결정의 트레이드오프 3 가지를 면접관에게 30 초 안에 설명한다면? 그리고 AOP 로 빼야 한다 는 시니어 동료가 합류한다면, 그 입장의 합리성을 어떻게 인정하면서 우리 결정을 변호할 것인가?
생각해볼 자료:
- Step 4 의 6 단계 협업 — 가드가 왜 첫 줄 에 들어갔는지의 한 줄 정리
- Day 19 (Rate Limit / Harness) 의 키워드 — Spring AI Agent Client 의 가드레일이 선언적 이 되는. 그 부분은 AOP 의 결 인가, 컴포넌트의 패턴 인가?
- Spring Boot 과정에서 만들었던
@DistributedLock어노테이션 (인스타그램 클론 Day 18) — AOP 로 횡단 관심사를 뺀 그 결과 어떤 좋은 점 과 함정 이 있었나?
주제 3 — 정적 리소스로 응답을 흘려보내도 되는가
Step 7 에서 /uploads/** 를 정적 리소스 로 매핑해서 브라우저가 직접 호출 하게 했어요. 학습용 단순 모양 으로 충분하다는 결정이었지만 — 이 부분엔 프라이버시 / 인가 의 그림자가 진하게 깔려 있어요.
생각해보면 — /uploads/portraits/portrait-3f1e...-1714500123456.jpg 같은 URL 은 추측이 어려운 모양이긴 해요 (UUID + timestamp).
하지만 완벽한 비밀 은 아니에요.
누군가 URL 을 복사해서 다른 사람에게 보내면 — 그 사람도 로그인 없이 그 그림을 볼 수 있어요.
브라우저 캐시에 들어간 URL 이 공유 PC 의 이력 으로 흘러갈 위험도 있고요.
인스타그램 / X / 슬랙 같은 SNS 가 이 부분를 어떻게 푸는지 한 번 떠올려보세요. 프로필 사진은 공개 가 기본 가정 — 정적 리소스로 노출. DM 첨부 이미지는 비공개 — 컨트롤러 + 인가 검증 으로 분리. 프로필 사진과 DM 첨부 가 같은 도메인 인데 접근 제어가 다른 형태.
🎯 핵심 질문 — 본 강의 코드처럼 생성된 이미지를 정적 리소스로 매핑 해도 되는 도메인의 경계는 어디까지인가? 프로필 사진 은 OK 이고 DM 첨부 이미지 는 안 되는 이유는 무엇인가? 그리고 비공개 이미지 를 인가 검증과 함께 응답하려면 코드의 어느 부분이 어떻게 바뀌어야 하는가? (정적 매핑 → 컨트롤러 + 인가 + 파일 스트림 응답)
생각해볼 자료:
- Step 7 의
StaticResourceConfig— 정적 리소스의 전제 (URL 알면 공개) - Spring Boot 과정에서 만들었던 프로필 이미지 vs DM 첨부 도메인의 분리 — 접근 제어가 어디서 갈렸는가
- Pre-signed URL 같은 시간 제한 공개 URL 패턴 — S3 가 정석적으로 푸는 패턴. 공개도 비공개도 아닌 제 3 의 모양.
주제 4 — 기술적 차단을 도메인 인격으로 변환하는 것은 어디까지 정당한가
Step 9 에서 비용 가드 한도 초과 시 — 사용자에겐 시스템 메시지 ("오늘의 이미지 생성 한도(30회)를 초과했습니다") 대신 캐릭터 인격 톤 ("오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어...") 으로 응답하도록 적었어요. 미연시 몰입감 + 비용 통제 두 축을 한 부분에 살리는 결정이었죠.
그런데 — 이 결정의 반대 방향 도 한 번 생각해볼 부분이에요. 기술적 시그널을 인격으로 가리는 게 항상 정당한가?
예를 들어 "AI 가 대답을 거부했을 때" — 이건 모델의 안전 정책 이 발동한 부분예요. 이걸 "오늘은 그 얘기 하기 좀 그래..." 같은 캐릭터 인격으로 가리면 — 사용자는 AI 의 안전 정책이 발동했다는 사실을 모르고 그냥 캐릭터의 변덕 이라고 인식해요. AI 시스템에 대한 투명성 이 깨지는.
또 "개인정보 보호 정책으로 차단" 같은 부분도 마찬가지. 시스템적으로 반드시 알려야 하는 부분이 있어요. 비용 가드는 운영 디테일 이라 가려도 OK 지만, 안전/개인정보는 법적/윤리적 의무 가 있는 부분.
🎯 핵심 질문 — 기술적 차단의 어디까지 가 캐릭터 인격으로 가려져도 되고, 어디부터 는 시스템 메시지로 명시되어야 하는가? 비용 가드 (오늘 적은 결) ↔ AI 안전 정책 차단 ↔ 개인정보 보호 차단 — 세 부분의 경계는 어떻게 그어야 하며, 그 경계의 기준은 운영자의 편의 인가 사용자의 권리 인가?
생각해볼 축:
- 몰입감 vs 투명성 의 트레이드오프 — UX 정책의 가장 깊은 부분
- 법적 의무 가 있는 차단 (개인정보/안전) vs 운영적 편의 인 차단 (비용/한도)
- EU AI Act 의 AI 시스템 식별 의무 — 사용자에게 "이게 AI 입니다" 를 명시해야 한다는 식의 일반화
- 하이브리드 — 사용자 화면엔 인격 톤 + 응답 헤더/푸터에 "본 응답은 시스템 정책으로 변환되었습니다" 한 줄
✅ 예시 답안정답 보기
Day 7 의 답안은 네 줄 정신 으로 갑니다.
(1) ImageGenerationService 를 재사용 하는 게 프로바이더 추상화 원칙의 진짜 가치 (과제 1).
(2) 추정과 누적 가드는 책임이 다르다 — 새 클래스로 쪼개는 게 정답 (과제 2).
(3) 추상화·가드·정적 리소스의 경계와 트레이드오프 를 30 초 안에 면접관 앞에 풀어내기 (생각해볼 주제 1~3).
(4) 기술적 차단을 도메인 인격으로 변환 하는 윤리적 경계 — 몰입감 vs 투명성의 트레이드오프 (생각해볼 주제 4).
Day 6 답안과 같은 호흡이에요.
예시답안은 유일 정답이 아니라 모범 사례 한 갈래 입니다.
본인의 매핑·경계·결정이 다르더라도 왜 그렇게 결정했는지 가 한 줄로 들어가 있다면 그게 더 좋은 답이에요.
특히 과제 1·2 의 코드는 현재 코드베이스에 들어가 있지 않은 예시 구현 입니다 (Day 7 본문 코드는 Step 3 ~ Step 9 까지 — ImageGenerationService / ImageGenerationController / ImageDailyQuotaGuard / ImageCostEstimator / CharacterPreset enum / Soulmate.appearancePrompt / SelcaService + SelcaResult — 가 검증되어 있고, 호감도 단계 일러스트 + 캐싱 과 누적 비용 가드 는 이번 과제에서 학생이 직접 구현 합니다).
검증된 코드는 Day 7 Step 3 ~ Step 9 의 코드뿐이라는 점을 먼저 기억하고 시작합니다.
💡 Day 7 의 늘어난 답안 범위 — 본 답안은 Day 7 교안 (Step 8/9 신설 + 생각해볼 주제 4 추가) 에 맞춰 함께 갱신됐어요. 주제 1~3 답안은 Step 8/9 의 prod 결합 풍경 (5트랙 외모 선택 · 챗 셀카 요청 · 가드 우회 톤) 사례를 곁들여 보강됐고, 주제 4 답안은 새로 더해졌습니다. 답안의 세 줄 정신 이 네 줄 로 자란 것 — 기술 결정의 모양뿐 아니라 기술이 사용자에게 어떻게 변환되어 도착하는가 까지 묻는 흐름입니다.
🔥 도전 과제 예시답안
과제 1 예시답안: 호감도 단계별 일러스트 — **이벤트 기반 1 회 생성** + 캐싱의 첫 손맛
[문제 다시 보기]
ai-friends 의 PM 이 캐릭터 친밀도 시스템을 새로 디자인했어요 — 캐릭터마다 호감도 0~100 사이를 8 단계로 끊고, 단계 1 은 차갑고 시크 한 표정, 단계 8 은 눈이 마주치자 함박웃음 — 단계가 올라갈 때마다 현재 단계에 어울리는 일러스트 가 한 장씩 떠 있는 풍경. 같은
(characterId, stage)조합은 첫 1 회만 생성 하고 이후엔 캐시 히트.
[핵심 접근]
이 과제의 본질은 새로운 ImageModel 구현체를 만드는 것 이 아니에요. 정반대 — ImageGenerationService 를 손 한 번 안 대고 재사용 하는 게 프로바이더 추상화 원칙의 진짜 가치를 직접 만져보는 대목입니다. 학생이 짤 코드는 컨트롤러 + 서비스 + enum 세 부품이고, 이미지 생성의 6 단계 협업 은 기존 서비스가 이미 다 책임지고 있죠. 추상화는 변하는 부분과 변하지 않는 부분의 경계 를 긋는 일이라는 걸 캐시 키 한 줄로 체감하는 게 본 과제의 학습 목표예요.
[채점 포인트]
| # | 항목 | 배점 가중 | 핵심 |
|---|---|---|---|
| 1 | ImageGenerationService 재사용 (인터페이스 호출만 추가) |
상 | 새 ImageModel 구현체 만들면 감점 — 프로바이더 추상화 원칙의 의미가 무너짐 |
| 2 | 컨트롤러 응답 ResponseEntity<ApiResponse<ImageGenerationResult>> |
상 | 본 강의의 ApiResponse 표준 패턴 — raw record 직접 반환 금지 |
| 3 | 호감도 → 단계 매핑이 enum + Map 으로 명시화 | 상 | 분기 if/else 도 OK 지만 enum 이 경계 + prompt 매핑 둘 다 한 곳에 모아 점수 우위 |
| 4 | 캐시 키 = (characterId, stage) — ConcurrentHashMap 권장 |
상 | 학습용은 in-memory Map 충분. 단 동시성 안전 자료구조여야 |
| 5 | 테스트 cache miss 케이스 — verify(imageModel, times(1)).call(...) |
상 | imageModel 을 모킹하고 1 회 호출 verify |
| 6 | 테스트 cache hit 케이스 — verify(imageModel, never()).call(...) 추가분 |
상 | 같은 키로 두 번 호출 시 두 번째는 LLM 호출이 아예 일어나지 않음 을 verify |
| 7 | 캐시 invalidation 정책 한 줄 — 학습용은 없음 이라고 명시 | 중 | 답안에 왜 무시했는가 의 한 줄이 있으면 채점자가 알면서 감수 한 결정으로 인정 |
3 번이 자주 빠지는 포인트예요. 단순 if/else 분기로 짜도 동작은 하지만 — 단계 경계 와 prompt 문자열 이 코드 곳곳에 흩어지면 기획자가 단계를 7 개로 줄여달라 했을 때 손댈 곳이 N 군데 가 돼요. enum 한 곳에 모으면 한 줄 수정 으로 끝나요.
[정답 풀이]
1. AffinityStage — 단계 enum + prompt 매핑
왜 이 코드? — 호감도 0~100 의 정수 한 개를 받아서 8 단계 중 하나 + 그 단계에 어울리는 prompt 두 가지를 한 곳에 모아둡니다. 단계 경계가 바뀌어도 / prompt 문구가 바뀌어도 — 이 enum 한 파일 만 손대면 끝나요.
package kr.spartaclub.aifriends.image.domain;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.image.exception.ImageException;
import java.util.Arrays;
/**
* Day 7 과제 1 — 호감도(0~100) 를 8 단계로 끊고, 각 단계에 어울리는 prompt 를 매핑하는 도메인 enum.
*
* <p>단계 경계 / prompt 문구의 *변경 축* 을 한 파일에 모아둔다. 기획자가
* "단계를 7 개로 줄여 달라" 또는 "단계 5 의 분위기를 더 따뜻하게" 같은 요구를 들고 와도
* 이 enum 한 곳만 수정하면 컨트롤러 / 서비스가 손도 안 닿는다.</p>
*
* <p>호감도가 0~100 범위 밖이면 {@link ImageException} 으로 래핑한다 —
* IllegalArgumentException 직접 throw 금지 (도메인 예외 규약).</p>
*/
public enum AffinityStage {
DISTANT(0, 10, "a cold and aloof anime girl, distant expression, neutral pose"),
NEUTRAL(11, 25, "a calm anime girl with a gentle and polite smile"),
INTERESTED(26, 40, "an anime girl showing mild interest, slight smile"),
FRIENDLY(41, 55, "a friendly anime girl smiling warmly"),
CLOSE(56, 70, "a close anime friend smiling, relaxed and intimate atmosphere"),
AFFECTIONATE(71, 85, "a warmly smiling anime girl with affectionate eyes"),
DEEPLY_BONDED(86, 95, "a deeply bonded anime girl, soft smile and tender gaze"),
SOULMATE(96, 100, "a warmly smiling anime girl with sparkling eyes, deeply in love");
private final int min;
private final int max;
private final String promptTemplate;
AffinityStage(int min, int max, String promptTemplate) {
this.min = min;
this.max = max;
this.promptTemplate = promptTemplate;
}
/**
* 호감도 정수를 단계로 변환한다. 범위 밖이면 ImageException.
*/
public static AffinityStage from(int affinity) {
return Arrays.stream(values())
.filter(s -> affinity >= s.min && affinity <= s.max)
.findFirst()
.orElseThrow(() -> new ImageException(ErrorCode.IMAGE_PROMPT_REQUIRED));
}
public String getPromptTemplate() {
return promptTemplate;
}
}
from(int) 가 범위 밖 입력에 대해 ImageException 을 던지는 게 핵심이에요.
IllegalArgumentException 직접 throw 는 금지 (도메인 예외 규약).
새 ErrorCode 를 만들지 않고 기존 IMAGE_PROMPT_REQUIRED 를 재활용하는 게 학습용으로 충분하지만, 본격 운영에선 INVALID_AFFINITY_RANGE 같은 이름으로 분리하는 게 더 깔끔해요.
2. AffinityPortraitService — 캐시 + 위임의 두 줄 요지
왜 이 코드? — 본 과제의 심장 이에요. 새 비즈니스는 두 가지 — 호감도 → 단계 변환 과 캐싱 — 단 두 가지뿐이고, 그림 생성의 6 단계 는 imageGenerationService.generate(...) 한 줄에 모두 위임돼요.
package kr.spartaclub.aifriends.image.service;
import kr.spartaclub.aifriends.image.domain.AffinityStage;
import kr.spartaclub.aifriends.image.dto.ImageGenerationResult;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Day 7 과제 1 — 호감도 단계별 일러스트 캐시 + 위임 서비스.
*
* <p>책임은 두 가지로만 좁힌다:
* <ol>
* <li>호감도 정수 → {@link AffinityStage} 변환</li>
* <li>{@code (characterId, stage)} 캐시 키로 첫 1 회만 {@link ImageGenerationService#generate} 호출</li>
* </ol>
* 이미지 생성의 6 단계 (가드 → 추정 → 모델 → 다운로드 → 저장 → 결과) 는
* 모두 {@link ImageGenerationService} 가 책임진다 — *재사용* 의 정신.</p>
*
* <p><b>학습용 단순 모양</b>: 캐시는 인스턴스 메모리 (ConcurrentHashMap). 분산 환경 / TTL /
* invalidation 은 본 과제 범위 밖. 운영에선 Redis + TTL 로 갈아끼우면 된다.</p>
*/
@Service
public class AffinityPortraitService {
private final ImageGenerationService imageGenerationService;
/**
* 캐시 키 = "{characterId}:{stage.name()}" — String 한 줄로 단순화.
* computeIfAbsent 는 *같은 키 동시 접근* 을 한 번만 실행해주지만 — 분산 환경에선 보장 X.
*/
private final Map<String, ImageGenerationResult> cache = new ConcurrentHashMap<>();
public AffinityPortraitService(ImageGenerationService imageGenerationService) {
this.imageGenerationService = imageGenerationService;
}
public ImageGenerationResult getOrGenerate(Long characterId, int affinity) {
AffinityStage stage = AffinityStage.from(affinity);
String cacheKey = characterId + ":" + stage.name();
return cache.computeIfAbsent(cacheKey, key ->
imageGenerationService.generate(
stage.getPromptTemplate(),
"anime-portrait",
null,
"affinity-" + characterId + "-" + stage.name()
)
);
}
}
핵심은 cache.computeIfAbsent(...) 한 줄이에요. 같은 키가 이미 있으면 — imageGenerationService.generate(...) 호출 자체가 일어나지 않아요. Step 5 의 비용 가드와 같은 정신 — 호출하지 않을 수 있으면 가장 좋은 가드 의 원칙을 캐시로 다시 한 번 만지는 대목입니다.
3. AffinityPortraitController — ApiResponse 표준 패턴 정상 응답
왜 이 코드? — Day 7 Step 6 의 ImageGenerationController 와 완전히 같은 결 이에요. PathVariable + RequestParam 받아서 서비스에 위임하고 ApiResponse.success(...) 로 감싸는 4 줄 컨트롤러.
package kr.spartaclub.aifriends.image.controller;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.image.dto.ImageGenerationResult;
import kr.spartaclub.aifriends.image.service.AffinityPortraitService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Day 7 과제 1 — 캐릭터 호감도 단계별 일러스트 생성 엔드포인트.
*
* <p>{@code POST /api/characters/{characterId}/affinity/portraits?affinity=50}</p>
*
* <p>응답은 ApiResponse 표준 패턴의 정상 응답 {@link ApiResponse} 로 래핑한다.
* 같은 (characterId, affinity 의 단계) 조합은 첫 1 회만 LLM 을 호출하고
* 이후엔 캐시 히트 — 응답 데이터는 동일.</p>
*/
@RestController
public class AffinityPortraitController {
private final AffinityPortraitService affinityPortraitService;
public AffinityPortraitController(AffinityPortraitService affinityPortraitService) {
this.affinityPortraitService = affinityPortraitService;
}
@PostMapping("/api/characters/{characterId}/affinity/portraits")
public ResponseEntity<ApiResponse<ImageGenerationResult>> getOrGenerate(
@PathVariable Long characterId,
@RequestParam int affinity) {
ImageGenerationResult result = affinityPortraitService.getOrGenerate(characterId, affinity);
return ResponseEntity.ok(ApiResponse.success(result));
}
}
ResponseEntity<ApiResponse<ImageGenerationResult>> 가 ApiResponse 표준 패턴의 핵심이에요.
record 직접 반환은 금지 — GlobalExceptionHandler 가 에러 응답을 자동으로 ApiResponse.fail(...) 로 감싸기 때문에 정상 응답도 같은 모양이어야 대칭이 맞아요.
4. 테스트 — cache miss / cache hit 두 케이스
왜 이 코드? — 본 과제 채점의 결정적 대목 입니다. 같은 키로 두 번 호출 했을 때 두 번째는 LLM 호출이 일어나지 않음 을 행위 검증 으로 보장하는 곳입니다.
package kr.spartaclub.aifriends.image.service;
import kr.spartaclub.aifriends.image.dto.ImageGenerationResult;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AffinityPortraitServiceTest {
@Test
@DisplayName("cache miss — 같은 (characterId, stage) 첫 호출 시 ImageGenerationService 1 회 호출")
void cacheMiss_calls_imageGenerationService_once() {
// given
ImageGenerationService mockService = mock(ImageGenerationService.class);
ImageGenerationResult fake = new ImageGenerationResult(
"/uploads/portraits/affinity-1-NEUTRAL.jpg",
"https://image.pollinations.ai/...",
"neutral",
"pollinations-flux",
0.0
);
when(mockService.generate(anyString(), anyString(), any(), anyString())).thenReturn(fake);
AffinityPortraitService sut = new AffinityPortraitService(mockService);
// when
ImageGenerationResult result = sut.getOrGenerate(1L, 20); // → NEUTRAL
// then
assertThat(result.localPath()).isEqualTo("/uploads/portraits/affinity-1-NEUTRAL.jpg");
verify(mockService, times(1)).generate(anyString(), anyString(), any(), anyString());
}
@Test
@DisplayName("cache hit — 같은 (characterId, stage) 두 번째 호출 시 ImageGenerationService 호출 없음")
void cacheHit_does_not_call_imageGenerationService_again() {
// given
ImageGenerationService mockService = mock(ImageGenerationService.class);
ImageGenerationResult fake = new ImageGenerationResult(
"/uploads/portraits/affinity-1-NEUTRAL.jpg",
"https://image.pollinations.ai/...",
"neutral",
"pollinations-flux",
0.0
);
when(mockService.generate(anyString(), anyString(), any(), anyString())).thenReturn(fake);
AffinityPortraitService sut = new AffinityPortraitService(mockService);
// when — 같은 키로 두 번 호출
ImageGenerationResult first = sut.getOrGenerate(1L, 20); // miss → 호출 1 회
ImageGenerationResult second = sut.getOrGenerate(1L, 22); // 같은 NEUTRAL 단계 → hit
// then — 두 번째는 호출되지 않아야 한다
assertThat(first.localPath()).isEqualTo(second.localPath());
verify(mockService, times(1)).generate(anyString(), anyString(), any(), anyString());
// ↑ 누적 1 회만 호출된 게 핵심. never() 가 아니라 times(1) 로 *총 호출 횟수* 검증.
}
}
verify(mockService, times(1)) 가 누적 호출 횟수 를 검증해요. 두 번째 호출이 반환값은 같지만 LLM 호출은 안 일어났다 는 걸 행위 검증 으로 보여줍니다. 만약 times(2) 가 나오면 — 캐싱이 안 된 거고, 채점은 0 점.
[실무 개선 포인트]
본 답안은 학습용 단순화 가 진하게 깔린 코드예요. 실제 운영에서는 다음 4~5 가지를 보강해야 해요.
ConcurrentHashMap은 프로세스 한정 — WAS 인스턴스가 N 대면 같은(characterId, stage)가 최대 N 회 생성될 수 있어요. 운영은 Redis (예:image:portrait:{characterId}:{stage}) + TTL 로 가야 분산 캐시 일관성 이 유지됨.- 캐시 invalidation 정책 부재 — 캐릭터의 외형 설정 이 바뀌었거나 (예: 머리 색 변경) 기획팀이 prompt 문구 자체 를 손봤을 때, 기존 캐시는 stale 이에요. 운영은 TTL (예: 7 일) + 명시적 무효화 API (
DELETE /api/cache/portraits/{characterId}) 둘 다 필요. AffinityStageenum 의 prompt 가 코드에 박힘 — 기획팀이 prompt 한 줄 수정에도 코드 PR + 배포 가 필요해요. 본격 운영은 DB 또는 외부 prompt 파일 (yaml) 로 빼서 관리자가 어드민 UI 로 편집 가능한 구조로 가야 함.computeIfAbsent의 분산 동시성 한계 — 단일 JVM 안에선 같은 키 동시 호출 이 한 번만 실행되지만, 두 인스턴스에서 동시에 같은 키가 들어오면 둘 다 LLM 호출 이 일어남. RedisSETNX+ 짧은 락 패턴으로 보강 가능.characterId검증 부재 — 존재하지 않는 캐릭터 ID 가 들어와도 그림은 생성돼요. 컨트롤러 또는 서비스 진입 시점에characterRepository.findById(characterId)로 존재 검증 한 줄을 박는 게 정석.
🎯 면접관을 홀리는 핵심 멘트
"호감도 단계 일러스트를 추가하면서 새
ImageModel구현체를 만들지 않은 게 프로바이더 추상화 원칙의 진짜 가치예요. 도메인 컴포넌트 (AffinityPortraitService+AffinityStageenum) 만 추가하고, 그림 생성의 6 단계 는 같은ImageGenerationService를 그대로 재사용했죠. 추상화는 변하는 부분과 변하지 않는 부분의 경계 를 긋는 일이라는 걸 — 캐시 키 한 줄 + computeIfAbsent 한 줄로 체감했어요."
과제 2 예시답안: 비용 가드 강화 — **추정** 과 **누적 가드** 의 책임 분리
[문제 다시 보기]
같은 PM 의 추가 부탁 — 응답에
cumulativeCostUsdToday를 노출해서 오늘 총 얼마 썼는지 한눈에 보이게 하고, 누적 비용이 $1 을 넘으면 추가 가드를 발동시켜라.ImageCostEstimator에 누적 책임을 추가하는 건 금지 — 새 클래스ImageCostBudgetGuard로 책임 분리. 새 ErrorCodeIMAGE_COST_BUDGET_EXCEEDED를 추가하되GlobalExceptionHandler에 별도 핸들러는 추가 금지 —BusinessException이 자동 처리되는 흐름을 그대로 활용.
[핵심 접근]
본 과제의 본질은 코드를 더 짜는 것 이 아니라 단일 책임 원칙 (SRP) 을 직접 만져보는 것 입니다. ImageCostEstimator 가 추정 과 누적 가드 두 책임을 가지면 변하는 축 이 두 개가 돼요 — 모델 가격이 바뀔 때 와 예산 한도가 바뀔 때 — 두 변경 이유가 한 클래스에 섞이는 거죠. SRP 의 핵심은 변하는 축이 다르면 클래스가 다르다 라는 한 줄이고, 이번 과제로 그 한 줄을 비용 가드 한 곳에서 직접 익혀봅니다.
[채점 포인트]
| # | 항목 | 배점 가중 | 핵심 |
|---|---|---|---|
| 1 | ImageCostEstimator 와 ImageCostBudgetGuard 의 책임 분리 |
상 | 한 클래스에 누적 책임을 끼우면 감점 — SRP 위반 |
| 2 | 새 ErrorCode IMAGE_COST_BUDGET_EXCEEDED 추가 + GlobalExceptionHandler 건드리지 않음 |
상 | BusinessException 핸들러가 자동 처리 되는 흐름의 가치 |
| 3 | day rollover 로직 (ImageDailyQuotaGuard 패턴을 그대로 재사용) |
상 | 자정 경계에서 누적이 0 으로 리셋되어야 함 |
| 4 | ImageGenerationResult 에 cumulativeCostUsdToday 필드 추가 |
상 | record 시그니처 변경 + ImageGenerationService 매퍼 한 줄 갱신 |
| 5 | 테스트 누적 케이스 — 0.5 + 0.4 = 0.9 OK, +0.2 → 발동 |
상 | 누적 추적이 상태로 박혀 있는지 행위 검증 |
| 6 | 테스트 경계 케이스 — 정확히 $1.0 까지 통과, $1.01 부터 발동 | 중 | 학생이 어느 쪽 경계 (> vs >=) 를 택했는지 코드와 일치하는지 |
| 7 | (보너스) 공통 Clock 컴포넌트 추출 |
중 | @Bean Clock systemClock() + 두 가드가 동일한 시각 기준 사용 |
1 번이 자주 빠지는 포인트예요. 코드를 짧게 짜고 싶어서 ImageCostEstimator 안에 addAndCheck(...) 메서드를 끼워 넣으면 — 비용 추정의 변경 이 가드의 동작 까지 흔드는 결합이 생겨요. SRP 의 핵심은 짧은 코드 가 아니라 변경 이유의 분리 라는 걸 본 과제가 직접 가르칩니다.
[정답 풀이]
1. ErrorCode 한 줄 추가
왜 이 코드? — 도메인 가드의 새 실패 경로는 항상 새 ErrorCode 항목 한 줄 로 표현돼요. BusinessException 핸들러가 자동으로 ApiResponse.fail(...) 응답을 만들어주니, 핸들러 추가 코드가 0 줄 이에요.
// kr.spartaclub.aifriends.common.exception.ErrorCode
// (Image 섹션 마지막에 한 줄 추가)
IMAGE_COST_BUDGET_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "I006",
"오늘의 이미지 생성 누적 비용 한도를 초과했습니다.");
HttpStatus.PAYMENT_REQUIRED (402) 도 의미상 가능하지만 — Day 7 Step 5 의 IMAGE_QUOTA_EXCEEDED 가 이미 TOO_MANY_REQUESTS (429) 라서 같은 부류의 가드 로 통일하는 게 학생 입장에서 일관성이 좋아요.
본격 운영이라면 402 와 429 의 의미 차이를 프론트가 어떻게 분기 처리하는가 까지 합의해서 정해야 해요.
2. ImageCostBudgetGuard — ImageDailyQuotaGuard 의 골격을 그대로
왜 이 코드? — 본 과제의 심장 입니다. ImageDailyQuotaGuard 의 day rollover 패턴을 그대로 복제 해서 — 누적 비용 가산 + 한도 비교 + 자정 리셋 세 가지를 한 클래스에 모아요. 추정 과 누적 가드 가 분리됐다는 SRP 가 시각적으로 드러나는 모양입니다.
package kr.spartaclub.aifriends.image.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.image.exception.ImageException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
/**
* Day 7 과제 2 — 일일 *누적 비용* 가드.
*
* <p>{@link ImageDailyQuotaGuard} 가 "호출 횟수" 를 가두고, 본 컴포넌트는 "누적 비용 (USD)" 을 가둔다.
* 두 책임이 *변하는 축* 이 다르기에 (가격은 모델 정책 변경, 횟수는 운영 정책 변경) 별도 클래스로 분리한다 — SRP.</p>
*
* <p><b>학습용 단순 모양</b>: in-memory + synchronized + double 합산. 운영에선 Redis
* {@code INCRBYFLOAT} + {@code EXPIRE} 가 정답 — 분산 환경에서 N 인스턴스가 같은 키를 안전하게 누적.</p>
*/
@Component
public class ImageCostBudgetGuard {
private final double dailyBudgetUsd;
private final Clock clock;
private LocalDate currentDate;
private double accumulatedUsd;
public ImageCostBudgetGuard(@Value("${aifriends.image.cost.daily-budget-usd:1.0}") double dailyBudgetUsd) {
this(dailyBudgetUsd, Clock.systemDefaultZone());
}
/**
* 테스트 한정 — Clock 을 외부에서 주입해 자정 경계 검증.
*/
ImageCostBudgetGuard(double dailyBudgetUsd, Clock clock) {
this.dailyBudgetUsd = dailyBudgetUsd;
this.clock = clock;
this.currentDate = LocalDate.now(clock);
this.accumulatedUsd = 0.0;
}
/**
* 추정 비용을 누적하고, 한도(=정확히 dailyBudgetUsd) 를 *초과* 하면 ImageException.
*
* <p>경계 정책: {@code next > dailyBudgetUsd} — 정확히 $1.0 까지는 통과, $1.0 초과부터 발동.
* 다른 한 갈래는 {@code next >= dailyBudgetUsd} 인데, 이 경우 정확히 $1.0 에서 막힌다.
* 본 답안은 *학습 직관* 을 우선해 *초과* 기준을 채택한다.</p>
*/
public synchronized void addAndCheck(double estimatedUsd) {
LocalDate today = LocalDate.now(clock);
if (!today.equals(currentDate)) {
currentDate = today;
accumulatedUsd = 0.0;
}
double next = accumulatedUsd + estimatedUsd;
if (next > dailyBudgetUsd) {
throw new ImageException(ErrorCode.IMAGE_COST_BUDGET_EXCEEDED);
}
accumulatedUsd = next;
}
/**
* 현재 누적 비용 — 응답 DTO 의 cumulativeCostUsdToday 필드 채우기용.
*/
public synchronized double getAccumulatedUsdToday() {
LocalDate today = LocalDate.now(clock);
return today.equals(currentDate) ? accumulatedUsd : 0.0;
}
}
addAndCheck 가 가산과 검증 한 메서드에 묶인 게 핵심이에요. 검사를 먼저 하고 가산을 나중에 하는 두 단계로 쪼개면 — 동시 호출 시 각자 검사 통과 후 동시 가산 으로 한도를 넘길 수 있거든요. synchronized + 한 메서드가 학습용 정답.
3. ImageGenerationResult — 필드 한 개 추가
왜 이 코드? — record 는 불변 데이터 라 필드 추가가 시그니처 변경 이에요. 컴파일러가 자동으로 모든 사용처 를 깨워줘서 안전.
package kr.spartaclub.aifriends.image.dto;
/**
* Day 7 Step 5 + 과제 2 — 이미지 생성 결과 DTO.
*
* @param cumulativeCostUsdToday 오늘 누적된 추정 비용 (USD). {@link kr.spartaclub.aifriends.image.service.ImageCostBudgetGuard}
* 가 매 호출 직후 더해 넣은 *현재 누적값.* 응답을 받아본 사용자가
* "오늘 얼마 썼는지" 한눈에 보도록 박는다.
*/
public record ImageGenerationResult(
String localPath,
String externalUrl,
String prompt,
String modelName,
double estimatedCostUsd,
double cumulativeCostUsdToday // ← 신규
) {
}
4. ImageGenerationService — 가드 한 줄 + 매퍼 한 줄
왜 이 코드? — 기존 generate(...) 메서드의 가드 첫 줄 바로 다음 에 budgetGuard.addAndCheck(...) 를 끼워 넣어요. 그리고 마지막 ImageGenerationResult 생성 시 getAccumulatedUsdToday() 한 줄 추가. 수정 라인 5 줄 미만 으로 책임 분리가 끝납니다.
// ImageGenerationService 의 변경점만 발췌
@Slf4j
@Service
public class ImageGenerationService {
private static final String MODEL_NAME = "pollinations-flux";
private final ImageModel imageModel;
private final ImageDownloader imageDownloader;
private final ImageDailyQuotaGuard quotaGuard;
private final ImageCostEstimator costEstimator;
private final ImageCostBudgetGuard budgetGuard; // ← 신규 주입
private final ImageFileStorageService storageService;
public ImageGenerationService(ImageModel imageModel,
ImageDownloader imageDownloader,
ImageDailyQuotaGuard quotaGuard,
ImageCostEstimator costEstimator,
ImageCostBudgetGuard budgetGuard, // ← 신규
ImageFileStorageService storageService) {
this.imageModel = imageModel;
this.imageDownloader = imageDownloader;
this.quotaGuard = quotaGuard;
this.costEstimator = costEstimator;
this.budgetGuard = budgetGuard;
this.storageService = storageService;
}
public ImageGenerationResult generate(String prompt, String stylePreset, Long seed, String fileNameHint) {
// (1) 한도 체크 (횟수)
quotaGuard.checkAndIncrement();
// (1-bis) 누적 비용 가드 — 추정 비용을 미리 더해 한도를 *넘게 될지* 차단
double estimated = costEstimator.estimateCostUsd(MODEL_NAME);
budgetGuard.addAndCheck(estimated);
// (2) 비용 에코 (로그)
costEstimator.echo(MODEL_NAME);
// ... (3)~(5) 생략 — Day 7 Step 4 의 코드 그대로
return new ImageGenerationResult(
localPath, externalUrl, enrichedPrompt, MODEL_NAME,
estimated,
budgetGuard.getAccumulatedUsdToday() // ← 신규 매퍼 한 줄
);
}
}
가드 순서 가 자연스러워요 — quotaGuard (싸고 빠른 횟수 체크) → budgetGuard (추정 + 누적 검증) → 외부 호출. 가장 싼 가드부터 발동 시키는 순서.
5. 테스트 — 누적 + 경계 두 케이스
왜 이 코드? — 상태로 박힌 누적값 이 정확히 동작하는지를 두 시나리오로 검증해요. Clock 을 주입할 수 있는 패키지-private 생성자가 있어서 — 시간 조작 없이 같은 날 누적만 검증하면 충분.
package kr.spartaclub.aifriends.image.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.image.exception.ImageException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ImageCostBudgetGuardTest {
@Test
@DisplayName("누적 추적 — 0.5 + 0.4 = 0.9 까지 통과, +0.2 시 IMAGE_COST_BUDGET_EXCEEDED")
void accumulates_until_budget_then_throws() {
ImageCostBudgetGuard sut = new ImageCostBudgetGuard(1.0, Clock.systemDefaultZone());
sut.addAndCheck(0.5); // 누적 0.5
sut.addAndCheck(0.4); // 누적 0.9
assertThat(sut.getAccumulatedUsdToday()).isEqualTo(0.9);
assertThatThrownBy(() -> sut.addAndCheck(0.2))
.isInstanceOf(ImageException.class)
.extracting(e -> ((ImageException) e).getErrorCode())
.isEqualTo(ErrorCode.IMAGE_COST_BUDGET_EXCEEDED);
// 발동 시 누적은 *그 이전 값* 으로 머물러야 한다 (실패한 시도는 누적되지 않음)
assertThat(sut.getAccumulatedUsdToday()).isEqualTo(0.9);
}
@Test
@DisplayName("경계 — 정확히 $1.0 까지는 통과, 그 다음 어떤 값이든 발동")
void boundary_exactly_one_dollar_passes() {
ImageCostBudgetGuard sut = new ImageCostBudgetGuard(1.0, Clock.systemDefaultZone());
sut.addAndCheck(1.0); // 정확히 $1.0 → 통과 (`next > dailyBudget` 정책)
assertThat(sut.getAccumulatedUsdToday()).isEqualTo(1.0);
assertThatThrownBy(() -> sut.addAndCheck(0.01))
.isInstanceOf(ImageException.class);
}
}
assertThat(sut.getAccumulatedUsdToday()).isEqualTo(0.9) 가 실패한 시도가 누적되지 않음 을 검증하는 핵심 줄이에요. 만약 가드가 검사 전에 가산 하는 식으로 짜였다면 0.9 가 아니라 1.1 이 나올 거예요 — 그럼 다음 호출은 아예 입장도 못하고 모두 실패하는 데이터 사고 가 납니다.
[실무 개선 포인트]
본 답안은 학습 단계라 4 가지 단순화 를 깔고 갔어요. 운영에서는 다음을 보강해야 해요.
double+synchronized의 정밀도 한계 — IEEE 754 가산은 근본적으로 정확한 가산성 보장이 안 돼요. 0.1 + 0.2 = 0.30000000000000004 같은 함정. 운영은BigDecimal또는 센트 단위 long 으로 가야 회계 일치성 보장.- 분산 환경 INCR — N 인스턴스 환경에선 Redis
INCRBYFLOAT+EXPIRE가 정답. 학습용 in-memory 카운터는 N 배 부풀려진 한도가 됨. - 추정 비용과 청구 비용의 차이 — 본 가드는 추정 비용 만 추적해요. 실제 청구는 프로바이더 invoice 로 다음달에 들어와요. 운영은 추정 추적 + 월말 reconcile 두 계층으로.
- 모델 가격 핫리로드 부재 —
ImageCostEstimator의 가격이 코드에 박혀 있어요. OpenAI 가 내일 가격을 인상 하면 코드 PR + 배포 가 필요. 운영은application.yml또는 외부 설정 서버 (Spring Cloud Config) 로 빼서 재시작 없이 갱신. - 사용자별 격리 부재 — 지금은 전체 호출의 누적 이에요. 운영은
key = "image:cost:budget:{userId}:{yyyyMMdd}"로 유저별 키 분리 가 필수. 한 사용자의 폭주가 다른 사용자의 응답 까지 막으면 안 되니까.
🎯 면접관을 홀리는 핵심 멘트
"비용 가드를 추정 과 누적 가드 로 분리한 게 답안의 핵심이에요. 한 클래스에 둘 다 넣으면 비용 추정의 변경 이 가드의 동작 까지 흔들거든요. 변하는 축 이 다르면 클래스가 다르다 — SRP 의 핵심을 비용 가드 한 곳에서 직접 익혔어요. 그리고 새 ErrorCode 한 줄 + GlobalExceptionHandler 건드리지 않음 으로 — Day 1 부터 깐 BusinessException 자동 처리 가 6 일째에 다시 한 번 이자처럼 돌아온 부분이고요."
🤔 생각해볼 주제 예시답안
주제 1 예시답안 — 추상화의 가치는 **어디까지** 가는가
[문제 상황 요약]
Step 3 의 PollinationsImageModel 50 줄짜리 어댑터가 기존 서비스 코드를 한 줄도 안 건드리고 새 프로바이더를 받아들였어요. 추상화의 단맛. 그런데 추상화는 비용이에요 — 코드 라인, 학습 부담, 디버깅 난이도, 간접 호출의 흐름 추적 비용. 모든 외부 의존을 인터페이스로 추상화해야 하는가 / 어디까지가 합리적이고 어디부터가 과한가 — 우리 코드베이스의 외부 의존 지점 (예: JdbcChatMemoryRepository, RestClient 직접 호출, RedissonClient) 마다 어느 쪽이 맞는지의 판단 기준 을 한 줄로 정리해보는 문제입니다.
[튜터의 가이드 및 해설]
이 질문의 정답은 모든 의존을 추상화해야 한다 도 직접 의존이 단순하니까 좋다 도 아니에요. 두 축의 트레이드오프를 어느 지점에서 어느 쪽이 무거운지 로 판단하는 거고, 결정 기준은 변하는 축의 빈도와 폭 으로 정렬돼요.
축 1: 추상화의 비용 — 코드 라인 + 학습 부담 + 흐름 추적 비용
PollinationsImageModel 한 클래스 (50 줄) + application.yml 의 ImageModel 빈 등록 한 줄 — 코드 라인 자체는 별것 아닌데, 흐름 추적의 비용 이 의외로 커요. IDE 에서 imageModel.call(...) 위에 Cmd+Click 하면 인터페이스 로 점프하고, 실제 구현체 로 가려면 한 단계 더 클릭해야 해요. 학생 입장에선 왜 여기서 두 단계 호흡이 들어가는가 의 부담이 분명히 있어요.
축 2: 추상화의 이익 — 갈아끼움 + 테스트 가능성 + 변경 격리
ChatModel / ImageModel 추상화의 가치는 세 가지 가 한꺼번에 와요. (1) Pollinations → DALL-E → Imagen 스위칭이 application.yml 한 줄 로 풀림. (2) 테스트에서 mock(ImageModel.class) 한 줄로 프로바이더 호출 없이 행위 검증 가능 (오늘 과제 1 답안의 cache miss / hit 테스트가 그 가치의 직접 회수). (3) 프로바이더가 바뀔 때 ImageGenerationService 코드는 한 줄도 안 닿음. — 변경 격리.
축 3: 언제 추상화하는가 의 판단 기준 세 가지
- 두 번째 구현체가 곧 필요할 때 (Rule of Three) — 이미 두 갈래 갈 가능성이 보이면 추상화. 한 번만 쓰는 자리는 X.
- 외부 의존이 비결정적 일 때 (테스트 필요) — LLM 호출, 외부 HTTP, 시간, 랜덤 — 테스트에서 격리해야 결정적 검증이 가능. 모킹 어댑터가 필요한 자리는 인터페이스로.
- 도메인 경계가 변경 빈도가 다를 것 같을 때 — 예: 이미지 생성 도메인 (변경 잦음) 과 프로바이더 호출 (시장 변화 잦음) 은 다른 속도로 변할 게 자명 해서 분리 가치가 큼.
축 4: 추상화하지 않을 때 의 판단 기준
반대로 — 한 번만 쓰는 외부 호출, 도메인 경계가 흐릿한 곳, 추상화가 도메인보다 무거워지는 지점 은 직접 의존이 정답이에요. 우리 코드베이스의 RestClient 가 그 좋은 예 — ImageDownloader 안에서 URL 받아 byte[] 반환 한 줄짜리 호출이라 추상화 어댑터로 한 번 더 감싸는 가치 가 직접 호출의 단순함 을 못 이겨요. 그래서 우리는 그 부분은 직접 의존 으로 두고, ImageModel 은 인터페이스 주입 으로 갔죠.
사례로 한 번 더 — Spring AI 가 ChatModel / ImageModel 을 추상화한 건 프로바이더가 빠르게 바뀌는 시장 이라서고 (OpenAI · Gemini · Anthropic · Ollama · Bedrock... 분기마다 새 프로바이더), 우리가 RestClient 호출을 추상화 안 한 부분은 그 호출 패턴이 안 변하기 때문이에요. 변할 거 같으면 추상화, 안 변할 거 같으면 두 번째 케이스 보고 결정 이 한 줄 정답.
Day 7 Step 9 의 결정적 사례 — SelcaService 의 책임 분리 🎯
Step 9 에서 셀카 요청 분기를 어디에 둘지 두 후보가 있었어요. (a) SoulmateChatService.chat() 안 분기 — LLM 진입점 = 분기점, (b) 새 SelcaService 분리 + AiChatService facade 분기 — 교차 관심사 캡슐화. 우리는 (b) 를 채택했어요.
왜 (b) 가 정답인가 — 셀카 요청은 chat 도메인의 책임 도 아니고 image 도메인의 책임 도 아니에요. 둘을 합치는 별도 영역. (a) 로 가면 SoulmateChatService 의 책임이 LLM 호출 + 키워드 매칭 + 외모 일관성 합성 + 가드 우회 넷으로 부풀고, image 도메인을 직접 의존 하게 돼요. SRP 위반.
그럼 (b) 는 추상화 비용이 안 드나 — 들어요. SelcaService 클래스 한 개 + SelcaResult record 한 개 + AiChatService 의 facade 코드 늘어남. 단 그 비용은 — 셀카 요청이라는 교차 관심사가 별도 컴포넌트에 들어간 가치로 충분히 회수돼요. 새 교차 관심사 (예: Day 8 vision 의 사진 첨부 분기) 가 들어올 때 — 같은 패턴으로 VisionService 를 신축하면 된다는 진화 경로의 일관성 까지 회수. 이게 추상화의 진짜 ROI 입니다.
이 결정은 — Spring AI ChatModel 추상화의 반대 방향 에서 같은 메시지를 보여줘요. ChatModel 은 프로바이더 라는 변하는 축 을 격리하는 추상화, SelcaService 는 교차 관심사 라는 책임이 다른 축 을 격리하는 분리. 변하는 축 격리 와 책임이 다른 축 격리 가 추상화의 두 면 — 둘 다 코드가 무거워지는 비용 보다 유지보수의 가치 가 큰 곳에 들어갔다는 의미입니다.
Day 7 후속의 반대 면 — 공통 추상화가 prod 의 다양한 요구를 만나면 깨지는 지점
추상화의 가치 만큼 — 깨지는 곳 도 학습해야 진짜 판단이 가능해요. Day 7 작업 중 Spring AI 의 OpenAI 자동 설정이 멀티 프로바이더 환경 에서 전역 키 누수 로 깨지는 사례 를 만났어요 (교안 Step 6 의 후속 부록 ①).
ai-friends 의 chat 영역은 Gemini 의 OpenAI 호환 엔드포인트 를 위해 spring.ai.openai.api-key=${GEMINI_API_KEY} + base-url=https://generativelanguage... 로 설정되어 있어요. Spring AI 의 OpenAI 자동 설정 은 image 영역에도 같은 전역 설정 을 그대로 인계해 — 진짜 OpenAI 의 image 엔드포인트 호출 시 Gemini 키 + Google 도메인 으로 가 401. 공통 자동 설정의 "한 줄로 끝남" 이 한 OpenAI 호환 프로바이더 가정 위에 놓였다는 의미.
보호 한 줄 — spring.ai.model.image: none 으로 자동 설정 명시적 OFF + OpenAiImageModelConfig 가 OpenAiImageApi.builder().apiKey(${OPENAI_API_KEY}).baseUrl(https://api.openai.com).build() 로 진짜 OpenAI 키 + URL 직접 빈 등록. 추상화를 끄고 직접 빈 등록 으로 우회.
| 자리 | 추상화의 가치 | 깨지는 지점 | 우리의 결정 |
|---|---|---|---|
ChatModel / ImageModel 인터페이스 주입 |
프로바이더 갈아끼움 (yml 한 줄) | — | ✅ 채택 (Step 3·4) |
| Spring AI OpenAI 자동 설정 | 빈 자동 등록 한 줄 | 전역 키 공간 가정이 모달리티별 프로바이더 분리 에서 깨짐 | ❌ 끄고 직접 빈 등록 (Step 6 후속 부록 ①) |
OpenAiImageModel.call() 의 cast |
— | 공통 ImageOptions 가 unconditional cast 로 깨짐 |
⚠ 빈 OpenAiImageOptions.builder().build() 로 떠받침 |
RestClient.uri(String) URI template |
RestClient 한 줄 호출의 깔끔함 | pre-signed URL 의 이미 인코딩된 query 가 이중 인코딩 으로 깨짐 | ⚠ URI.create(url) 로 완성된 URI 객체 넘기기 |
이 매트릭스가 주제 1 의 정답을 한 단계 더 깊게 만들어줘요. 추상화는 가치이고 그 가치는 깨지는 지점도 함께 학습할 때 진짜. 공통 추상화의 한 줄로 끝남 이 멀티 프로바이더 / pre-signed URL / 상위 모델의 자체 해석 같은 prod 의 다양한 현실 을 만나면 깨지는 지점 이 반드시 있다 는 의미. 그 깨진 곳은 추상화를 더 두껍게 만드는 것 으로 풀리지 않고 — 추상화를 명시적으로 끄거나 호출부에서 한 줄 보호 를 더하는 방향으로 풀려요. 추상화의 가치 = 깨끗함, prod 의 가치 = 그 깨끗함을 둘러싼 손가락 보호 한 줄들이 잘 들어가 있음.
🎯 면접관을 홀리는 핵심 멘트
"추상화는 변하는 축 을 격리하는 일이에요. 변하지 않는 축에 추상화를 들이면 코드만 무거워지고 가치는 0. Spring AI 가
ChatModel/ImageModel을 추상화한 건 프로바이더가 빠르게 바뀌는 시장 이라서고, 우리가RestClient호출을 추상화 안 한 곳은 그 호출 패턴이 안 변하기 때문이에요. 두 번째 구현체가 곧 들어오거나, 외부 의존이 비결정적이라 모킹이 필요하거나, 도메인 경계가 다른 속도로 변할 것 같으면 추상화 — 아니면 두 번째 케이스 들어올 때 그때 결정. 추상화의 반대 방향 도 잊지 마세요 — 책임이 다른 축 을 격리하는 분리도 추상화의 한 면이에요. Day 7 Step 9 의SelcaService가 그 사례 — chat 도메인도 image 도메인도 아닌 교차 관심사 를 별도 컴포넌트로 분리하는 책임 격리. 이게 두 번째 답이에요. 그리고 한 가지 더 — 공통 추상화는 깨지는 지점 도 함께 학습해야 진짜 예요. Day 7 작업 중 만난 Spring AI OpenAI 자동 설정의 전역 키 누수 가 그 예 — 한 OpenAI 호환 프로바이더 가정 으로 설계된 자동 설정이 진짜 OpenAI + Gemini 호환 혼합 환경에서 401 로 깨졌고, 보호는 자동 설정을 명시적으로 끄고 직접 빈 등록 으로 풀었어요. 추상화의 가치 = 깨끗함, prod 의 가치 = 그 깨끗함 주변 의 손가락 보호 한 줄들."
주제 2 예시답안 — 비용 가드의 **책임 분리** — 어디서 막나
[문제 상황 요약]
Step 4 에서 quotaGuard.checkAndIncrement() 가 서비스 첫 줄 에 박혔어요. 컨트롤러 첫 줄 / 서비스 첫 줄 / AOP @RateLimit 세 갈래 중 서비스 첫 줄 을 골랐는데, 결정의 트레이드오프 3 가지를 면접관에게 30 초 안에 설명할 수 있어야 하고, AOP 로 빼야 한다 는 시니어 동료의 합리성도 인정하면서 변호할 수 있어야 해요.
[튜터의 가이드 및 해설]
이 질문은 어느 쪽이 정답이냐 를 묻는 게 아니에요. 세 갈래 모두 합리성이 있고, 우리가 채택한 이유와 다른 갈래의 합리성을 동시에 30 초 안에 풀어내는 능력을 봅니다.
축 1: 컨트롤러 첫 줄 에 두는 안 — 가장 빠른 차단
// 가상의 풀이
@PostMapping("/api/images/portraits")
public ResponseEntity<...> generate(...) {
quotaGuard.checkAndIncrement(); // ← 진입 즉시 차단
return ResponseEntity.ok(ApiResponse.success(service.generate(...)));
}
장점은 진입 즉시 차단 — 서비스 호출 자체가 일어나지 않으니 가장 가벼움. 단점은 서비스를 다른 컨트롤러나 @Scheduled 에서 재사용할 때 가드가 누락 됨. 우리 ai-friends 같은 강의 프로젝트는 컨트롤러 1 개만 이 서비스를 부르니 큰 문제 안 보이지만, 운영 환경에서 배치 잡 / 다른 도메인에서 재사용 이 시작되는 순간 가드 누락 사고가 나요.
축 2: 서비스 첫 줄 에 두는 안 — 우리가 채택한 자리
장점은 어디서 호출되든 가드가 발동 — 도메인 응집. 가드 임계값 / 정책이 도메인 규칙의 일부 라 도메인 컴포넌트 안에 있는 게 자연스러움. 단점은 서비스가 무거워짐 — 비즈니스 로직과 가드 로직이 한 메서드 안에 섞여 서비스의 첫 줄을 읽을 때마다 가드를 만나는 시각적 부담.
축 3: AOP @RateLimit 로 빼는 안 — 횡단 관심사 분리의 우아함
@RateLimit(key = "'image:quota'", limit = 30, period = "1d")
public ImageGenerationResult generate(...) { ... }
장점은 비즈니스 로직과 가드 로직이 코드 위치 자체에서 분리 — 우아함. 단점이 진해요. (1) AOP 흐름 추적이 디버깅 어려움 — 어노테이션 한 줄 뒤에 어떤 Advisor 가 끼어드는지 IDE 에서 바로 안 보임. (2) 사용자 plan 별 가드 (free 30 회 / pro 1000 회) 같은 조건부 가드 적용이 어려워요 — SpEL 로 풀 수 있긴 한데 어노테이션 한 줄이 두 줄 세 줄 길어지면 가독성이 무너짐. (3) 가드 발동 후 도메인 응답 형태 (예: "오늘 이미 N 회 호출됨" 응답에 현재 카운트 포함) 같은 부가 응답 이 어려움 — Aspect 가 도메인 데이터를 직접 다루기 시작하면 횡단 관심사의 경계가 흐려져요.
그래서 우리가 서비스 첫 줄 을 고른 세 가지 트레이드오프:
- 어디서 호출되든 가드 보장 — 컨트롤러보다 안전
- 도메인 응집 — 가드 정책이 도메인 규칙의 일부라 도메인 코드에 있는 게 자연스러움
- 조건부 가드 / 부가 응답 이 명시적 코드 로 풀려서 디버깅 흐름이 직선적
시니어 동료가 AOP 로 빼야 한다 고 합류한다면 — 그 입장의 합리성을 그대로 인정 해요. 횡단 관심사 분리는 진짜 우아하고, Spring Boot 인스타그램 클론의 @DistributedLock 어노테이션 도 그 방식으로 풀린 좋은 사례예요. 다만 Day 7 의 비용 가드는 도메인 응집이 더 강해서 서비스 첫 줄에 둔 거고, 조건부 가드 / 사용자 plan 분기 가 들어오는 시점에 — 그때 AOP 로 옮긴다 는 진화 경로 를 합의해두는 게 정답이에요.
층위 별로 다른 가드가 정석 — 한 곳에 박는 게 아니에요. 컨트롤러 = 입력 형식 검증 (@Valid), 서비스 = 도메인 가드 (비용 / 한도 / 권한), AOP = 공통 횡단 관심사 (rate limit / circuit breaker / 메트릭 수집). 셋이 층위 별로 겹쳐서 박히는 게 정석이고, 어느 한 곳에만 박으면 다른 진입로 에서 새요.
Day 7 Step 9 의 결정적 사례 — 서비스 첫 줄 채택의 prod 가치 회수 🛡️
Step 5 에서 ImageDailyQuotaGuard 를 ImageGenerationService 의 첫 줄 에 박았어요. 이 결정의 진짜 가치 는 Step 9 에서 회수돼요.
Step 9 의 SelcaService.generate() — 가드 호출 코드가 한 줄도 보이지 않아요. imageGenerationService.generate(...) 한 줄 뒤에서 가드가 알아서 발동 해요.
public SelcaResult generate(Soulmate soulmate, String userMessage) {
String prompt = composePrompt(soulmate.getAppearancePrompt(), userMessage);
String fileNameHint = "selca-" + UUID.randomUUID();
try {
ImageGenerationResult result = imageGenerationService.generate( // ← 가드는 이 안에
prompt, null, null, fileNameHint);
return SelcaResult.success(result.localPath());
} catch (ImageException e) {
if (e.getErrorCode() == ErrorCode.IMAGE_QUOTA_EXCEEDED) {
return SelcaResult.quotaExceeded(QUOTA_EXCEEDED_FALLBACK);
}
return SelcaResult.failed(GENERATION_FAILED_FALLBACK);
}
}
만약 가드를 ImageGenerationController 의 첫 줄 에 두었더라면 — SelcaService 가 직접 컨트롤러를 호출하지 않으므로 가드가 발동 안 함. Step 6 의 controller 진입과 Step 9 의 SelcaService 진입은 다른 진입로 인데, 가드가 도메인 컴포넌트에 들어가 있어서 두 진입로에서 공통으로 발동. 이게 서비스 첫 줄 채택의 prod 가치예요.
한 발 더 — 기술적 차단의 정체를 도메인 인격으로 변환
Step 9 의 또 다른 손맛 — 가드가 throw 한 IMAGE_QUOTA_EXCEEDED 예외를 사용자 화면에 노출 하지 않고 캐릭터 인격 톤으로 변환 했어요.
사용자 화면: "오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어..."
↑ 캐릭터 인격 톤 (몰입감 보존)
운영자 로그: log.info("[SelcaService] quota exceeded — falling back to character voice")
↑ 기술적 시그널 (디버깅 가능)
같은 사실 이 두 채널로 다르게 변환 되어 도착해요. 사용자에겐 몰입감 (캐릭터의 인격적 응답), 운영자에겐 투명성 (가드가 발동했다는 기술적 사실). 이게 — 가드의 위치 결정 만이 아니라 결과의 변환 채널 까지 운영 자리의 정책이라는 신호예요. 자세한 결정의 윤리적 경계는 생각해볼 주제 4 답안 에서 따로 다뤄요.
Day 7 후속 — 기술적 가드의 또 한 면: 결과 품질 가드 (ensureAnimeStyle)
가드의 호출 차단 만으로는 풀리지 않는 자리가 있어요. Day 7 작업 중 DALL-E 3 의 prompt expansion 으로 짧은 user prompt 가 photorealistic 으로 자동 확장되는 자리를 만났어요 (교안 Step 6 후속 부록 ④). 미연시 게임의 anime 일러스트 아이덴티티 와 정면 충돌. 이 자리는 비용 차단 도 입력 검증 도 아니에요 — 모델이 응답을 내 도메인 모양으로 만들도록 강제 하는 결과 품질 가드.
보호 한 줄 — ensureAnimeStyle() 가 모든 prompt 끝에 anime portrait illustration, soft cel shading, korean character, no photorealism suffix 를 자동으로 붙임. 호출부에서 반드시 거치게 강제 (generate() 의 (3) 단계에서 enrichedPrompt = ensureAnimeStyle(...)).
기술적 가드의 두 면 비교 — 비용 가드 (오늘 주제) vs 결과 품질 가드 (ensureAnimeStyle) 가 같은 "가드" 라는 단어를 쓰지만 성격이 또렷이 달라요.
| 면 | 비용 가드 (ImageDailyQuotaGuard) |
결과 품질 가드 (ensureAnimeStyle) |
|---|---|---|
| 막는 대상 | 호출 자체 — 한도 초과 시 외부 호출 안 일어남 | 결과 형태 — 호출은 일어나되 모델 응답이 도메인 모양으로 락 |
| 막는 시점 | 호출 직전 (서비스 첫 줄) | 호출 직전 prompt 합성 (suffix 자동 박기) |
| 실패 시 사용자 화면 | 캐릭터 인격 톤 우회 ("카메라 배터리 다 됐어") | (실패 자체가 없음 — 항상 anime 로 강제) |
| 운영자 시그널 | log.info + 향후 헤더 X-Image-Quota-Exceeded |
log.debug 정도 (정상 동작이라 시그널 약함) |
| 확장의 자리 | 사용자 plan 별 가드 (free/pro), AOP 이전 | style preset 별 suffix 분기 (캐릭터/카드 일러스트) |
둘이 같이 박혀야 하는 이유 — 비용 차단 + 결과 품질 이 둘 다 살아야 prod 데이터플로우가 닫혀요. 비용 가드 없이 결과 품질 가드만 있으면 — 비용 폭발, 결과 품질 가드 없이 비용 가드만 있으면 — DALL-E 가 사진처럼 응답해서 미연시 정체성 깨짐. 기술적 가드는 한 면 이 아니라 여러 면 이 데이터플로우의 다른 단계 에 박혀야 prod 가 살아남는다는 의미.
이 사례는 주제 2 의 정답을 한 단계 더 깊게 만들어줘요. 가드는 층위 별로 박는다 (컨트롤러/서비스/AOP) 가 위치의 분리 라면, 비용 가드 vs 결과 품질 가드 는 책임의 분리. 위치 × 책임 의 매트릭스로 가드를 보면 — 한 가드가 모든 자리를 떠받칠 수 없다 의 직관이 또렷이 살아남아요. 🛡️
🎯 면접관을 홀리는 핵심 멘트
"가드는 한 곳에 박는 게 아니라 층위별로 박는다 가 정석이에요. 컨트롤러에서 입력 형식 검증, 서비스에서 도메인 가드 (비용 / 한도 / 권한), AOP 로 공통 횡단 관심사 (rate limit / circuit breaker). 어디 한 곳 에만 박으면 다른 진입로 에서 새요. 우리는 비용 가드를 서비스 첫 줄 에 둔 이유가 도메인 응집 인데, 조건부 가드 / 사용자 plan 분기 가 들어오는 순간 AOP 로 옮길 진화 경로를 미리 합의해두는 게 시니어의 판단이에요. 그리고 가드의 책임도 여러 가지 — 비용 가드는 호출 차단,
ensureAnimeStyle같은 결과 품질 가드 는 호출은 일어나되 모델 응답이 도메인 모양으로 락. DALL-E 3 의 prompt expansion 으로 photorealistic 드리프트 되는 자리를 suffix 자동 박기 로 막은 게 그 예. 위치 × 책임 의 매트릭스로 가드를 보면 한 가드가 모든 자리를 떠받칠 수 없다 가 또렷이 보여요."
주제 3 예시답안 — 정적 리소스로 응답을 흘려보내도 되는가
[문제 상황 요약]
Step 7 에서 /uploads/** 를 정적 리소스로 매핑해 브라우저가 직접 호출 하게 했어요. URL 이 UUID + timestamp 로 추측이 어려운 모양이긴 하지만 완벽한 비밀 은 아니죠. 프로필 사진은 OK 이고 DM 첨부는 안 되는 이유 가 무엇이고, 비공개 이미지를 인가 검증과 함께 응답하려면 코드의 어느 자리가 어떻게 바뀌어야 하는가 — 라는 질문이에요.
[튜터의 가이드 및 해설]
이 질문은 정적 리소스 매핑 = 무조건 위험 이라는 단순 결론이 아니라 — 공개해도 되는 자산 과 인가 검증이 필요한 자산 의 경계 판단 을 묻습니다.
축 1: 정적 리소스의 공개성 가정
addResourceHandler("/uploads/**") 가 깐 매핑은 기본적으로 공개 라는 전제를 깔고 있어요. URL 만 알면 — 로그인 없이 누구나 접근. 추측 어려운 URL (UUID) 정도가 최소 보호 인데, 이건 security through obscurity 라서 진짜 보안 이 아니에요. URL 이 한 번 새면 — 끝.
축 2: 인가 검증이 필요한 자산 — 컨트롤러 + Spring Security 로
DM 첨부 사진처럼 권한 있는 사용자만 접근해야 하는 자산은 반드시 컨트롤러로 흘러야 해요.
@GetMapping("/api/dm/{dmId}/attachment")
@PreAuthorize("@dmAccessChecker.canRead(authentication, #dmId)")
public ResponseEntity<byte[]> getAttachment(@PathVariable Long dmId) {
byte[] bytes = dmAttachmentService.read(dmId); // 권한 검증 포함
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG)
.body(bytes);
}
이 방식으로 가면 — @PreAuthorize 가 권한 검증, 서비스가 도메인 검증 (DM 의 발신자 / 수신자가 본인인지), 컨트롤러가 byte[] 응답. 정적 매핑 이 못 하는 세 단계 검증 이 다 들어가요.
축 3: 우리 강의 코드의 단순화 의도
ai-friends 의 /uploads/portraits/... 는 공개해도 무방한 자산 이에요. 캐릭터 portrait 은 내가 만든 캐릭터의 외형 이고 — URL 이 새도 외형이 보일 뿐, 사용자 PII 가 노출되는 게 아님. 인스타그램의 프로필 사진 과 같은 가족이죠. 그래서 학습용 단순화 로 정적 매핑을 깔아도 프로덕션 위험이 적은 부분입니다.
축 4: Pre-signed URL 이 운영의 정석
운영 환경에서 공개도 비공개도 아닌 제 3 의 모양 이 표준이에요. AWS S3 의 presigned URL / GCS 의 signed URL — 시간 제한 (예: 5 분) 이 박힌 서명된 URL 이라 (1) 인가 검증을 통과한 클라이언트만 발급받음, (2) URL 이 새도 5 분 뒤엔 죽음, (3) WAS 가 byte[] 응답을 직접 흘리지 않음 (CDN / 객체 스토리지가 직접 처리). 학습용 정적 매핑 → 운영 presigned URL 의 진화 경로 가 명확.
축 5: PII / 민감 자산 — 영구 보존 X + 접근 로그 필수
이미지 자체가 PII (얼굴, 신분증, 의료 사진) 면 영구 보존 자체가 위험해요. 짧은 TTL + 자동 삭제 + 접근 로그 가 운영 의무. 우리 강의 코드의 .gitignore uploads/ 처럼 비결정적 산출물의 분리 정신이 PII 분리 정신 으로 한 단계 더 나아간 모양.
한 줄 정리 — 공개해도 무방한 자산 이면 정적 매핑 OK (학습용 + 일부 운영 가능), 인가 검증이 필요한 자산 이면 컨트롤러 + 서비스 + Spring Security 로 흘려야 함. 운영의 정답은 Pre-signed URL + 짧은 TTL + 접근 로그. 이 세 갈래 를 자산 종류에 따라 맞춰서 선택하는 게 정답이에요.
Day 7 Step 9 의 결정적 사례 — 셀카 응답도 같은 정적 매핑인가
Step 9 에서 챗 셀카 응답의 imageUrl 도 /uploads/portraits/selca-{uuid}.jpg 로 정적 매핑 영역에 들어갔어요. 왜 이게 OK 인가 — 두 가지 이유가 있어요.
첫째 — 공개해도 무방한 자산의 패턴을 그대로 따름. 캐릭터 portrait 이 공개 가능했던 것과 같은 가족 — 셀카도 AI 가 생성한 캐릭터의 외형 이지 사용자 본인의 사진이 아님. 학습용 단순화로 정적 매핑 OK.
둘째 — URL 의 추측 어려움이 한 단계 더 깊어짐. selca-{uuid} 의 UUID 는 특정 캐릭터의 특정 셀카 1장 만 가리켜요. 같은 사용자의 셀카 30장 (Step 5 가드 한도) 도 각각 다른 UUID. 한 URL 이 새도 다른 셀카 29장 은 안전. single-shot exposure 에 가까운 모양.
다만 — 한 단계 진화 — Day 8 Vision 에서 사용자가 직접 업로드한 사진 (자기 얼굴, 풍경 등) 이 들어오는 순간 — 그건 PII 자산 이에요. 그 자리는 반드시 컨트롤러 라우팅 + 인가 검증으로 갈아야 해요. 같은 /uploads/ 디렉토리 안에서도 생성된 자산 (공개 OK) ↔ 사용자 업로드 자산 (PII) 을 분리 해서 — 생성된 자산만 정적 매핑 하고 사용자 업로드는 컨트롤러 로 풀어야 해요.
이 결정의 코드 표현 은 — /uploads/portraits/... 는 정적 매핑, /uploads/user-uploads/... 는 매핑 안 함 + /api/uploads/{id} 같은 컨트롤러로만 접근 — 디렉토리 분리 + URL 분리 + 인가 분리. Day 8 작업 시점에 들어갈 부분입니다.
🎯 면접관을 홀리는 핵심 멘트
"정적 리소스 매핑은 기본적으로 공개 라는 가정을 깔고 있어요. UGC (사용자 생성 콘텐츠) 를 그대로 정적으로 풀면 URL 추측 만으로 다른 사람의 콘텐츠가 노출되거나 외부 SNS 로 URL 이 흘러나가는 사고가 나요. 운영의 정답은 Pre-signed URL + 짧은 TTL 이고, 권한 검증이 필요한 자산 은 컨트롤러 + 서비스 + Spring Security 로 흘려야 해요. 우리 강의는 학습용 단순화 + 공개해도 무방한 캐릭터 portrait 이라 정적 매핑이지만, DM 첨부 / 사용자 셀카 같은 자산이 들어오는 시점엔 즉시 컨트롤러 라우팅 + 인가 검증으로 옮겨야 한다는 진화 경로를 전제로 깔고 있어요."
주제 4 예시답안 — **기술적 차단을 도메인 인격으로** 변환하는 것은 어디까지 정당한가
[문제 상황 요약]
Step 9 에서 비용 가드 한도 초과 시 — 사용자 화면엔 시스템 메시지 ("오늘의 이미지 생성 한도(30회)를 초과했습니다") 대신 캐릭터 인격 톤 ("오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어...") 으로 응답하도록 설계했어요. 미연시 몰입감 + 비용 통제 두 가치를 한 곳에 살리는 결정이었죠. 그런데 이 결정의 반대 방향 도 존재해요 — 기술적 시그널을 인격으로 가리는 게 항상 정당한가? AI 의 안전 정책 차단 이나 개인정보 보호 차단 같은 영역은 반드시 알려야 하는 자리예요. 어디까지가 가려져도 되고, 어디부터는 명시되어야 하는가 의 경계를 묻는 질문입니다.
[튜터의 가이드 및 해설]
이 질문은 — 기술 결정 의 답이 아니라 기술 윤리 의 답을 묻습니다. 몰입감의 가치 는 진짜고, 투명성의 의무 도 진짜인데, 둘이 충돌하는 지점에서 어느 쪽이 무거운지 의 판단 기준을 만들어야 해요.
축 1: 몰입감의 가치 — 사용자 경험의 정중앙
미연시 게임의 몰입감 은 단순한 부수효과가 아니에요. 내 캐릭터가 살아있다는 감각 을 깨는 메시지가 한 번이라도 등장하면 세계관의 환상 이 깨져요. 시스템 메시지 "한도 초과 (30/30)" 가 채팅창에 떠오르는 순간 — 사용자는 AI 시스템에 대화하고 있다는 사실 을 다시 인식해요. 미연시의 4 시간짜리 몰입 이 한 메시지로 무너지는 모양.
이 몰입감을 비용 가드 차단 같은 운영 디테일 에서 보존하는 건 — 정당해요. 비용 한도 는 사용자의 권리와 무관한 영역이고, 운영자의 비용 통제 정책 의 일부예요. 왜 차단되었는지 를 사용자가 정확히 알 법적 권리 가 없는 부분.
축 2: 투명성의 의무 — AI 시스템의 정체성
반면 — AI 의 안전 정책 차단 영역은 달라요. 왜 AI 가 이 답을 안 했는가 는 사용자의 권리에 직결 돼요. 만약 "오늘은 그 얘기 하기 좀 그래..." 같은 캐릭터 인격으로 AI 의 안전 차단 을 가리면 — 사용자는 AI 의 한계 를 캐릭터의 변덕 으로 오해해요. AI 시스템의 정체성을 가리는 행위라 윤리적으로 무거운 영역.
EU AI Act (2024 시행) 의 Article 50 — Transparency obligations 가 이 부분을 직접 다뤄요. AI 와 대화 중임 을 사용자가 명확히 인지 할 수 있도록 해야 하고, AI 가 생성한 콘텐츠 임을 표시해야 한다는 의무. 미연시 게임이라 몰입감을 위해 캐릭터 인격으로 변환 해도 — 법적으로 명시되어야 하는 영역 이 따로 있다는 시그널.
축 3: 경계의 세 자리 — 비용 / 안전 / 개인정보
| 차단 종류 | 사용자에게 가려도 되는가 | 이유 |
|---|---|---|
| 비용 가드 (오늘 도입한 가드) | ✅ 가려도 OK | 운영자의 비용 정책 영역, 사용자 권리와 무관. 단 재시도 가능 시점 은 한 줄로 안내 ("내일 또") |
| AI 안전 정책 차단 | ❌ 가리면 안 됨 | AI 의 한계 가 캐릭터의 변덕 으로 오해됨. EU AI Act 의 투명성 의무. 명시 + 캐릭터 톤 보조 가 정답 |
| 개인정보 보호 차단 | ❌ 절대 가리면 안 됨 | 법적 의무. 왜 차단되었는지 를 사용자가 명확히 알 권리. GDPR / 개인정보보호법의 직접 적용 |
축 4: 하이브리드 정답 — 두 채널 분리
가장 정직한 답은 전부 가린다 도 전부 노출한다 도 아니에요. 사용자 화면엔 인격 톤으로 부드럽게 + 응답 헤더 / 푸터 / 별도 안내 영역에 시스템 시그널 한 줄 — 두 채널로 분리.
사용자 화면 (몰입감 채널):
💬 "오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어..."
응답 헤더 / DevTools (시스템 시그널 채널):
X-Image-Quota-Exceeded: true
X-Quota-Reset-At: 2026-05-09T00:00:00Z
사용자 설정 메뉴의 별도 안내:
"오늘의 이미지 생성 한도: 30/30. 자정에 리셋됩니다."
기술 시그널 은 원하는 사용자가 찾을 수 있게 하되 기본 채널 은 인격 톤. AI 안전 차단 이라면 — 짧은 시스템 메시지 ("이 주제는 AI 가 답할 수 없어요") + 캐릭터 톤 보조 ("음... 다른 얘기 할까?") 를 둘 다 띄움. 사용자에게 AI 시스템의 한계임 이 명시되면서도 대화의 흐름은 끊기지 않는 모양.
축 5: 판단 기준의 한 줄 정답
몰입감 vs 투명성 의 트레이드오프를 푸는 한 줄 — "사용자의 권리 와 무관한 차단은 가려도 OK, 권리에 직결되는 차단 은 반드시 명시 — 다만 두 채널로 분리해서 몰입감 + 투명성 을 동시에 살리는 게 정답."
이 결정은 — Day 11 (Tool Calling) 에서 또 만나요.
Tool 호출 실패 시 사용자에게 어떻게 변환할지의 문제. Day 14 (Agent 자율성 경계) 에서도 만나요. Agent 의 가드레일 차단 을 사용자에게 어떻게 알릴지. Day 19 (Harness) 에서도 만나요. Rate limit 차단 을 어떻게 표현할지. AI 시스템의 운영 시점마다 반복적으로 등장하는 정책 결정 이라는 시그널 — 한 번 잡은 판단 기준이 그 사이의 모든 곳에서 회수 돼요.
Day 7 후속 — 기술 디테일을 사용자 화면에서 가리는 사례: Azure SAS URL 함정
도메인 인격으로 변환 의 흐름이 기술적 실패의 표현 에도 흐른다는 회수가 Day 7 작업 중 있었어요 (교안 Step 6 후속 부록 ③). OpenAI DALL-E 응답 URL 은 내부적으로 Azure Blob Storage 의 SAS (Shared Access Signature) URL 이에요. ?sig=abc123%3D&se=... 같은 Azure 내부 구현 디테일 이 포함되어 있고, 그 인코딩을 잘못 다루면 403 "Signature fields not well formed" 가 떨어져요. 이 기술 디테일이 사용자 화면에 흘러나가면 어떻게 될까 — 이 질문이 주제 4 의 영토 와 닿아요.
보호 한 줄 — RestClient.uri(URI.create(url)) 로 이중 인코딩 차단 (코드 차원) + 실패 시 사용자에겐 캐릭터 인격 톤 우회 (도메인 차원). Azure SAS URL · 403 · signature 검증 실패 같은 내부 구현 디테일 은 사용자 화면 어디에도 등장하지 않음.
기술 디테일 가리기의 세 영역
| 영역 | 사용자에게 가려도 되는가 | 왜 |
|---|---|---|
외부 시스템의 내부 구현 (Azure SAS URL · sig=%3D 인코딩 형식 · 403 응답 본문) |
✅ 반드시 가림 | Azure 와 우리 사이의 계약 일 뿐 사용자의 권리와 무관. 우리 도메인 표현으로 변환 이 정답 |
| 외부 시스템 의존성 자체 (DALL-E · Pollinations 같은 프로바이더 이름) | 🟡 상황에 따라 | 비용 가드 우회 메시지 처럼 몰입감 영역은 가림 / AI 가 생성한 콘텐츠임 의 EU AI Act 의무 는 명시 |
| AI 의 안전 정책 차단 (콘텐츠 필터 발동) | ❌ 명시 | AI 의 한계 가 캐릭터의 변덕 으로 오해되면 안 됨 |
첫째 영역 이 Azure SAS URL 함정 같은 기술 디테일 의 영토예요. 외부 시스템과 우리 시스템 사이의 계약 디테일 이 사용자 화면에 흘러나가는 경우 가 주제 4 의 또 다른 면. 비용 차단 → 캐릭터 인격 의 패턴을 기술 실패 → 캐릭터 인격 으로 확장하는 흐름.
실패 시 두 채널 분리의 모양
사용자 화면 (몰입감 채널):
💬 "셀카 보내려고 했는데 핸드폰이 말썽이네 ㅠㅠ 다음에 다시 시도해보자!"
↑ Azure 403 / RestClient 예외 / signature 검증 실패의 *내부 디테일은 일절 없음*
운영자 로그 (투명성 채널):
log.warn("[ImageGeneration] download failed: 403 ... Signature fields not well formed")
↑ Azure SAS URL 의 정확한 실패 원인 + 재현 가능한 디테일
같은 실패 한 건 이 두 채널로 다르게 변환 돼서 도착해요. 사용자에겐 도메인 인격, 운영자에겐 기술 디테일. 이 두 채널 분리가 비용 가드의 두 채널 분리 와 글자 단위로 같은 패턴. 차단이든 실패든 기술 디테일을 도메인 인격으로 변환 한다는 원칙은 같다 의 회수.
이 사례로 주제 4 의 정답이 한 단계 더 넓어져요. 기술적 차단의 도메인 인격 변환 만이 아니라 기술적 실패의 도메인 인격 변환 도 사용자의 권리와 무관한 디테일을 가리는 같은 가족. 외부 시스템과 우리 시스템 사이의 계약 디테일은 항상 가림, 사용자의 권리와 직결되는 차단/안전 정책은 항상 명시 — 그 사이가 두 채널 분리의 영토.
🎯 면접관을 홀리는 핵심 멘트
"AI 시스템의 차단을 도메인 인격 으로 변환하는 건 몰입감 의 정답이지만 항상 정당하진 않아요. 사용자 권리와 무관한 운영 디테일 (비용 한도) 은 가려도 OK 지만 — AI 의 안전 정책 이나 개인정보 보호 차단은 반드시 명시 해야 해요. EU AI Act 의 투명성 의무가 직접 적용되는 영역이거든요. 정답은 전부 가림 / 전부 노출 의 양극단이 아니라 두 채널 분리 — 사용자 화면엔 인격 톤, 응답 헤더 / 별도 메뉴엔 시스템 시그널. 몰입감과 투명성 을 동시에 살리는 모양이에요. 이 판단 기준은 Day 11 Tool Calling, Day 14 Agent 가드레일, Day 19 운영의 rate limit 마다 반복 적용돼요. 그리고 차단 만이 아니라 기술적 실패 도 같은 가족 — Day 7 작업 중의 Azure SAS URL 함정처럼 외부 시스템의 내부 구현 디테일 (sig=%3D 인코딩 · 403 signature 검증) 은 우리 도메인 표현으로 변환 해야 사용자 권리와 무관한 디테일이 화면에 안 흘러나가요. 외부 계약 디테일 = 항상 가림, 사용자 권리 = 항상 명시, 그 사이가 두 채널 분리의 영토."
마무리 한 줄
오늘 답안 두 개와 생각해볼 주제 네 개를 한 줄로 닫아요 — 추상화의 가치는 변하는 축 + 책임이 다른 축 의 격리, 가드의 가치는 층위 별 분리 + 결과 변환 채널의 분리, 정적 리소스의 가치는 공개성 가정의 명시 + 생성 자산 / 사용자 업로드의 분리, 그리고 — 기술적 차단의 도메인 인격 변환 은 사용자 권리와의 거리 로 결정한다. Day 7 에서 다룬 ImageModel / 비용 가드 / 정적 리소스 / 인격 변환 네 축 이 왜 거기에 그렇게 있는지 의 결정 과정을 면접관 앞에서 30 초 안에 풀어낼 수 있다면 오늘은 성공이에요. 답안의 코드는 유일 정답이 아니라 모범 사례 한 갈래 — 본인의 결정과 근거가 다르더라도 왜 그렇게 결정했는지 가 한 줄로 들어가 있다면 그게 더 좋은 답입니다.