Wonny
Wonny
🏅 AI 마스터
🎻 루키 파트너
🏅 베스트오브베스트

[맘스맘 개발기] 260207 멀티모달이 뭔지 모르지만 멀티모달 달아서 사진 인식 기능 넣기

■ 소개

목표: 419 (4업으로 1달러 벌기 시작해서 9달러까지 벌어보기)

오늘은 Rose 님이 주신 피드백을 반영해봤습니다.

https://www.gpters.org/dev/post/260206-taking-photos-implementing-RImDyXsgA957WxW

바로 멀티모달?? 입니다 ㅎㅎ
(아직도 뭔지 모름 주의 ㅎㅎ)

한국어로 된 두 사람의 대화 스크린샷

멀티모달이 뭔지 모르는데,

일단 이미지를 AI에게 인식시키는데 필요한 툴이라고 들었습니다.

.

우당탕당 성공한 후기입니다.

.

■ 진행 및 결과

1.커서로 plan하기

오늘도 PLAN으로 시작합니다.

우선 계획을 짜달라고 합니다.

.

MD를 주긴했는데,

잘 모르는 분야라

예상되는 문제점은 없을지 미리 물어봅니다.

.

반영한 최종 MD는 아래와 같습니다.

챗봇 멀티모달(이미지) 지원 — 현재 상태 분석 및 개발 계획

1. 현재 상태 분석

1.1 클라이언트 (Flutter)





채팅 구조: lib/features/chat/ — ChatController, ChatState, ChatMessage, ChatScreen



이미지 플로우:





+ 버튼 → 사진 찍기 / 앨범 선택 → _pickImage → _showImagePreviewSheet



사용자가 설명(선택) 입력 후 "전송" → ChatController.send(text, imagePath: imageFile.path) 호출



모델: chat_models.dart의 ChatMessage에 imagePath 존재 (로컬 경로, 렌더링 전용)



API 전송: chat_controller.dart 237~241행 — createChatReply 호출 시 텍스트만 전송:





userMessage, childAge, concernCategory, sessionId, historyMessages, memorySummary



이미지 필드 없음 → 서버/AI는 이미지를 받지 못함

1.2 백엔드 (Firebase Functions)





엔드포인트: functions/src/index.ts — createChatReply (POST)



요청 타입: CreateChatReplyBody — userMessage, childAge, concernCategory, sessionId, memorySummary, historyMessages 만 정의 (11~20행)



OpenAI 호출: 551~561행 — client.chat.completions.create에 messages가 모두 텍스트:





content가 문자열만 사용 (role + content: string)



Vision용 멀티모달 메시지(content 배열: text + image_url) 미사용



모델: gpt-4o-mini / gpt-4o 등 이미 Vision 지원 모델이 allowlist에 있음 (54~57행) — 모델 변경 불필요



RAG 경로: createGroundedAnswer(functions/src/rag/rag.ts 523~532행)도 userMessage 문자열만 전달 → 이미지 포함 시 동일하게 멀티모달 user 메시지로 확장 필요

1.3 갭 요약







구간



현재



목표





클라이언트 → 서버



텍스트만 전송, imagePath는 로컬 표시용



이미지 바이너리(base64)를 요청 본문에 포함





서버 → OpenAI



content: string 만 사용



이미지 있을 때 content: [{ type: 'text' }, { type: 'image_url', image_url: { url: 'data:image/...' } }]





RAG(grounded)



userMessage 문자열만



동일 턴의 user 메시지를 멀티모달로 전달



2. “이미지를 먼저 AI에게 인식시키는” 흐름





선택지 A: 이미지만 별도 API로 먼저 “설명/캡션” 생성 후, 그 결과를 텍스트로만 대화에 넣기.
→ 구현·비용 부담이 크고, 이미지가 “직접” 인식된 것이 아님.



선택지 B (권장): 사용자가 보낸 현재 턴의 메시지를 “텍스트 + 이미지” 한 번에 OpenAI에 보내서, Vision이 이미지를 직접 인식하고 답변하도록 함.
→ 일반적인 멀티모달 챗봇 방식이며, “이미지를 먼저 인식”은 같은 요청 안에서 이미지와 텍스트가 함께 user 메시지로 들어가므로 자연스럽게 충족됨.

아래 계획은 선택지 B 기준으로 작성함.



3. 개발 계획

3.1 클라이언트 (Flutter)





이미지 → base64





send(text, imagePath) 호출 시 imagePath가 있으면 해당 파일을 읽어 base64 인코딩 (JPEG/PNG 등 MIME 타입 유지).



용량 제한: Firebase/Cloud Functions 요청 크기(예: 10MB)를 고려해, 전송용 리사이즈 적용 권장 (예: 긴 변 1024px, 품질 80). 현재도 imageQuality: 80 사용 중이므로, 최대 해상도 제한만 추가해도 됨.



API 요청 확장





createChatReply POST body에 선택 필드 추가:





userMessageImageBase64: 문자열 (optional)



userMessageImageMimeType: image/jpeg / image/png 등 (optional, 없으면 jpeg 가정)



imagePath가 있을 때만 위 필드를 채워 전송; 없으면 기존과 동일하게 텍스트만 전송.



히스토리





기존처럼 historyMessages는 텍스트만 유지 (이미지 포함 턴은 해당 메시지의 text만 서버에 보냄). OpenAI에 보낼 때는 “현재 턴”만 멀티모달로 처리.

3.2 백엔드 (Firebase Functions)





요청 타입 확장 (index.ts)





CreateChatReplyBody에 userMessageImageBase64?: string, userMessageImageMimeType?: string 추가.



현재 턴 user 메시지 구성





userMessageImageBase64가 없으면: 기존처럼 { role: 'user', content: userMessage }.



있으면:
content를 배열로 설정:





{ type: 'text', text: userMessage } (또는 이미지만 보낸 경우 빈 문자열/placeholder)



{ type: 'image_url', image_url: { url: 'data:${mime};base64,${userMessageImageBase64}' } }
OpenAI Vision API 스펙에 맞게 image_url는 객체로 전달 (문자열만 넘기면 에러 발생).



fast 경로 (551~561행)





위와 같이 “현재 user 메시지”만 멀티모달로 넣고, 나머지 historyMessages는 기존처럼 content: string 유지.



grounded 경로 (RAG)





rag.ts의 createGroundedAnswer에 현재 턴 user 메시지를 멀티모달로 넘길 수 있도록 인자 확장:





예: userMessageContent: string | Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }>
또는 userMessage + optional userMessageImageUrl(data URL)로 받아서 서버에서 배열 조립.



createGroundedAnswer 내부 messages에서 마지막 user 메시지만 content 배열로 넣고, 나머지 히스토리는 기존 형식 유지.



보안·안전





이미지가 들어오는 턴에도 기존 detectSafetyType(userMessage) 유지. (필요 시 추후 이미지 기반 안전 검사 확장 가능.)



body 크기 제한: base64 길이 상한(예: 4MB 분량) 초과 시 400 응답으로 거절하면 안전.

3.3 히스토리 및 RAG





히스토리: 클라이언트의 _buildHistoryPayloadExcludingLatestUser()는 계속 텍스트만 보냄 (이미지 포함 메시지도 m.text만 전송). 서버의 normalizeHistoryMessages는 기존대로 content: string만 처리.



RAG 검색: 이미지가 있어도 텍스트 쿼리는 userMessage 그대로 사용. RAG는 텍스트 기반 검색만 유지하고, 답변 생성 시에만 “현재 user 메시지”를 멀티모달로 OpenAI에 전달.

3.4 테스트·검증





이미지 없이 전송 → 기존과 동일 동작 확인.



이미지 + 텍스트 전송 → 응답이 이미지 내용을 반영하는지 확인.



이미지만 전송(텍스트 빈 문자열 또는 “사진 보냈어요”) → Vision이 이미지만 보고 설명/답변하는지 확인.



RAG가 켜진 경우, 이미지 포함 턴에서도 grounded 응답이 정상 동작하는지 확인.

3.5 (선택) 프롬프트 보강





시스템 프롬프트에 “사용자가 보낸 사진이 있으면 그 내용을 함께 참고해서 답한다”는 한 줄을 넣으면, 이미지 인식 역할이 더 명확해짐.



4. 아키텍처 요약

sequenceDiagram
  participant App as Flutter_App
  participant CF as createChatReply
  participant OpenAI as OpenAI_API

  App->>App: pick image, optional text
  App->>App: encode image to base64, resize
  App->>CF: POST userMessage, userMessageImageBase64, ...
  CF->>CF: build user turn: content = [text, image_url]
  CF->>OpenAI: chat.completions (messages with vision)
  OpenAI->>CF: replyText
  CF->>App: replyText, evidence, ...





이미지를 먼저 인식: 같은 요청 안에서 user 메시지에 이미지가 포함되므로, AI는 그 메시지를 처리할 때 이미지와 텍스트를 함께 인식한 뒤 답변함.



5. 작업 순서 제안





백엔드: CreateChatReplyBody 확장 + fast 경로에서 멀티모달 user 메시지 구성 및 호출.



클라이언트: 이미지 읽기 → 리사이즈 → base64 인코딩 → createChatReply 요청에 필드 추가.



백엔드 RAG: createGroundedAnswer 인자 및 내부 메시지 구성 확장 (마지막 user만 멀티모달).



테스트: 이미지 없음/있음, RAG on/off 시나리오로 검증.



(선택) 시스템 프롬프트에 이미지 참고 문구 추가, body 크기 제한 및 400 처리.



6. 예상 문제점 및 대응

6.1 페이로드·용량





Cloud Functions 요청 크기: 2세대 기준 보통 10MB. base64는 바이너리 대비 약 1.33배이므로, 전송 전 리사이즈(예: 긴 변 1024px) + 품질 제한이 필수. 서버에서 base64 길이 상한(예: 4MB 분량) 초과 시 400으로 거절하면 이중 안전장치.



OpenAI 이미지 제한: GPT-4o 계열은 이미지 크기/해상도 제한이 있음. 과도하게 큰 이미지를 보내면 API 에러가 나므로, 클라이언트 리사이즈로 미리 맞추는 것이 좋음.

6.2 히스토리에서 이미지 "기억" 불가





현재 설계: historyMessages는 텍스트만 전송. 이전 턴에서 보낸 사진은 다음 턴에서 AI에게 전달되지 않음.



영향: 사용자가 "아까 보낸 사진 보니까…"라고 이어서 말해도, AI는 그 사진을 다시 보지 못함. 텍스트(예: "사진을 보냈어요" 또는 사용자 캡션)만으로 맥락을 추론해야 함.



대응: 기획/UX에서 "이미지는 해당 턴에서만 참고"라고 명시하거나, 추후 필요 시 "이전 이미지 요약을 메모리/시스템 컨텍스트에 넣기" 같은 확장을 검토.

6.3 RAG 검색은 텍스트만 사용





현재 설계: RAG 검색 쿼리는 userMessage(텍스트)만 사용. 이미지 내용으로는 벡터/키워드 검색을 하지 않음.



영향: "이거 봐줘"처럼 텍스트가 짧고 이미지가 핵심인 경우, 검색된 evidence가 빈약하거나 무관할 수 있음. 답변 자체는 Vision으로 이미지를 보므로 적절할 수 있으나, "근거 보기" 품질은 텍스트에 의존.



대응: 1차는 현재 설계로 출시하고, 필요 시 "이미지 설명을 먼저 생성해 그걸 RAG 쿼리로 사용"하는 2단계 플로우를 별도 검토.

6.4 지연 시간·체감





이미지 업로드: base64 JSON이 커질수록 업로드 시간 증가. 저사양/저대역폭 환경에서 "멈춘 것 같다"는 체감 가능.



대응: 전송 전 로딩 인디케이터 명확히 표시, 리사이즈로 payload를 작게 유지.

6.5 비용





Vision 입력: 이미지 토큰은 텍스트보다 비용이 높음. 이미지 전송이 잦으면 API 비용이 눈에 띄게 늘 수 있음.



대응: 리사이즈로 이미지 크기 감소 → 토큰 수 감소. 필요 시 이미지당/일당 상한을 모니터링하거나, Remote Config로 이미지 기능 on/off.

6.6 에러 처리





OpenAI 측 에러: 이미지 형식/크기 문제, 정책 위반 등으로 API가 에러를 반환할 수 있음.



대응: 서버에서 해당 에러를 받으면 사용자에게 "사진을 인식하지 못했어요. 크기를 줄이거나 다시 보내주세요." 같은 메시지와 함께 200 + fallback 응답 또는 4xx + 메시지를 내려주고, 클라이언트에서 동일하게 안내.

6.7 이미지 포맷(HEIC 등)





iOS: 앨범 선택 시 HEIC가 많음. OpenAI는 jpeg/png/gif/webp 등을 지원하지만 HEIC는 직접 지원하지 않을 수 있음.



대응: 클라이언트에서 HEIC → JPEG 변환 후 전송하거나, image_picker 옵션으로 변환된 파일을 받도록 설정. (Flutter image_picker는 플랫폼에 따라 이미 변환해 주는 경우가 있음.)

6.8 클라이언트 메모리





대용량 파일 로드: 고해상도 이미지를 한 번에 메모리에 올려 base64 인코딩하면 저사양 기기에서 OOM 가능성.



대응: 먼저 리사이즈/썸네일 생성한 뒤 그 결과만 읽어서 base64로 보내면, 원본 전체를 메모리에 올리지 않아도 됨.

6.9 안전·검열





이미지 내용: 텍스트만으로는 detectSafetyType이 이미지를 검사하지 못함. 부적절한 이미지가 올라갈 수 있음.



대응: OpenAI 쪽 content policy/moderation에 일부 의존. 추후 필요 시 이미지 전용 검사(별도 모델 또는 OpenAI image moderation)를 단계적으로 도입 검토.



이 순서로 진행하면 “이미지를 먼저 AI에게 인식시키는” 멀티모달 챗봇이 최소 변경으로 동작하게 됨.

.

오 그럴싸해서 BUILD합니다.

.

2.이미지 보냈는데 서버가 안쓰는 버그 해결하기

근데, 사진 내용을 반영하지를 못하는 버그가 발생합니다.

(왜 귤인데 귤이라고 하지를 못하니...ㅠ)

.

고쳐봅니다.

.

우선 원인을 파악해서 다시 버그 잡을 PLAN을 짜달라고 합니다.

.

사진 내용 미반영 개선 계획

1. 현상 정리





사용자가 귤 사진을 보내도 챗봇은 "어떤 사진인지 궁금해요"라고만 하고, 사용자가 "귤인데"라고 텍스트로 알려준 뒤에야 귤 관련 답변을 함.



즉, AI가 이미지를 받지 못하고 텍스트("사진을 보냈어요.")만 받은 상태로 동작한 것으로 보임.

2. 원인 분석

멀티모달 구현은 이미 되어 있음:





클라이언트: chat_controller.dart에서 _encodeImageForUpload로 리사이즈·JPEG 인코딩 후 userMessageImageBase64를 body에 포함.



서버: index.ts에서 buildCurrentUserMessageContent로 content 배열(text + image_url)을 만들어 OpenAI에 전달.

가장 유력한 원인: 클라이언트에서 이미지 인코딩이 실패해 imagePayload가 null이 되고, 이 경우 body에 userMessageImageBase64가 아예 빠짐. 서버는 텍스트만 받으므로 AI는 이미지를 보지 못함.

sequenceDiagram
  participant User
  participant App
  participant Server
  participant OpenAI

  User->>App: 사진 선택 (예: HEIC)
  App->>App: _encodeImageForUpload (decode 실패)
  Note over App: imagePayload = null
  App->>Server: POST userMessage만 (이미지 없음)
  Server->>OpenAI: content: "사진을 보냈어요."
  OpenAI->>Server: "어떤 사진인지 궁금해요"
  Server->>App: replyText
  App->>User: 사진 내용 미반영 응답

인코딩 실패가 나기 쉬운 경우





iOS HEIC: 앨범에서 고른 사진이 HEIC인 경우가 많음. Dart image 패키지는 HEIC 디코드를 지원하지 않아 decodeImage(bytes)가 실패하거나 null을 반환함.



파일 경로/권한: 일부 환경에서 임시 파일이 이미 삭제되었거나 읽기 실패.



기타 포맷: image 패키지가 지원하지 않는 포맷이면 decode 실패.

현재 코드는 인코딩 실패 시 아무 안내 없이 텍스트만 전송하므로, 사용자는 사진을 보냈다고 생각하지만 실제로는 이미지가 서버에 가지 않음.

3. 개선 방안

3.1 인코딩 실패 시 사용자 안내 (필수)





위치: chat_controller.dart의 send() 내부, imagePath != null이면서 imagePayload == null인 경우.



동작:





"사진을 서버로 보낼 수 없었어요. 사진을 다시 고르거나, 카메라로 찍어 보내 주세요." 같은 문구를 스낵바 또는 채팅으로 표시.



이 경우 API 호출을 하지 않거나, 하더라도 "사진 전송에 실패했어요" 메시지를 먼저 보여주고, 텍스트만 보내는 것은 피하는 것이 좋음 (텍스트만 보내면 지금처럼 "어떤 사진인지 궁금해요"만 나옴).



권장: imagePath != null && imagePayload == null이면 API 호출하지 않고, 스낵바만 띄운 뒤 isTyping 해제 및 사용자 메시지는 그대로 두거나, 어시스턴트 메시지로 "사진을 인식하지 못했어요. 다시 시도해 주세요."를 한 번 추가.

3.2 HEIC 등 포맷 대응 (권장)





원인: iOS 앨범 선택 시 HEIC 파일이 오고, image 패키지가 HEIC를 디코드하지 못해 _encodeImageForUpload가 null을 반환함.



방안 (택 1 또는 병행)  





A) 촬영/선택 시 JPEG 유도  





image_picker의 imageQuality는 이미 80으로 설정되어 있음.  



iOS에서 갤러리 선택 시 포맷을 바꿀 수 있다면, 가능한 경우 JPEG로 저장된 사진을 선택하도록 안내하거나, "사진 앱에서 편집 후 저장"하면 JPEG로 바뀌는 경우가 있음을 활용.



B) HEIC → JPEG 변환 패키지 사용  





heic_to_jpeg 등 HEIC 디코드 가능 패키지가 있으면, decodeImage 실패 시 해당 패키지로 디코딩 후 image 패키지로 리사이즈·인코딩하는 fallback 추가.



C) 인코딩 실패 시 원본 bytes base64 전송 시도 (선택)  





decode 실패 시, 원본 바이트를 그대로 base64로 보내고 MIME을 image/jpeg로 보내는 것은 HEIC에서는 의미 없음(OpenAI가 HEIC를 지원하지 않을 수 있음).  



JPEG/PNG인데 decode만 실패한 드문 경우에만 fallback으로 시도할 수 있으나, 우선순위는 낮게 두고, 실패 안내(3.1) 와 HEIC 대응(3.2) 에 집중하는 것을 권장.

3.3 디버깅 용이성





위치: chat_controller.dart의 send().



내용: kDebugMode일 때만,





imagePath != null인데 imagePayload == null이면 "image encode failed" 등 로그 출력.



imagePayload != null이면 "image attached, base64 length: ..." 정도만 로그 (payload 자체는 로그하지 않음).



목적: 실제 기기에서 사진 전송 시 이미지가 요청에 포함되는지, 인코딩 실패인지 빠르게 구분.

3.4 서버 배포 확인





가능 원인: Firebase Functions에 멀티모달 코드가 아직 배포되지 않았을 수 있음. 이 경우 클라이언트가 base64를 보내도 구버전 서버가 해당 필드를 무시하고 텍스트만 처리함.



확인 방법:  





Functions 소스(functions/src/index.ts)에 userMessageImageBase64, buildCurrentUserMessageContent 등이 반영된 상태로 firebase deploy --only functions 실행했는지 확인.  



배포 후에도 동일 현상이면, 3.1·3.2·3.3을 적용한 뒤 클라이언트 로그로 이미지 포함 여부를 확인.

4. 작업 순서 제안





3.1 인코딩 실패 시 사용자 안내





imagePath != null && imagePayload == null일 때 스낵바 + (선택) 어시스턴트 메시지, API 호출 생략 또는 실패 메시지 명확히.



3.3 디버그 로그





개발 중 "이미지가 정말 전송되는지" 확인할 수 있도록 로그 추가.



3.4 서버 배포





멀티모달 반영된 Functions 배포 여부 확인 및 필요 시 재배포.



3.2 HEIC 대응





HEIC 전용 패키지 도입 또는 JPEG 유도 등으로 인코딩 성공률 높이기.

이 순서로 적용하면, "사진을 보냈는데 내용이 반영되지 않는" 문제의 원인(인코딩 실패 vs 서버 미배포)을 좁히고, 사용자에게는 실패 시 원인을 알 수 있는 안내를 줄 수 있으며, HEIC 환경에서도 이미지가 서버까지 전달될 가능성을 높일 수 있습니다.

.

아이폰도 아닌데, HEIC 파일 어쩌고 하는거보니 불안하긴 한데,

BUILD해봤습니다.

.

여전히 안됩니다.

.

커서한테, 안된다고 다시 말합니다.

.

.

아무래도 이미지는 보내지는데, 서버가 안쓰는 것 같다고 합니다.

(그럼 처음부터 잘했어야지 커서야!)

.

고쳐달라고 합니다.

.

..

...

....

고쳐졌습니다! ㅎㅎ

.

3.결과물

이제 사진을 알아봅니다 ㅎㅎㅎ

.

■ 느낀점 및 향후 계획

커서를 잘 다루려면, 전문용어를 수박 겉핥기 식으로 알면 도움이 많이 되는 것 같습니다.

멀티모달이나 RAG 이런 것들이요 ㅎㅎ

.

이래서 지피터스 스터디를 계속 하는 것 같습니다.

.

이번 스터디 마지막 게시글이 될것 같네요 ㅎㅎ

.

CLOSED TEST가 얼른 마무리되면 좋겠습니다.

1
1개의 답글
지피터스 뉴스레터

이번 주 AI, 딱 쓸 것만

매주 월요일 오후,
읽고 바로 써먹을 AI 소식만