[Flutter] AI 도슨트와 채팅하며 명화 작품 감상하기 : 부드러운 채팅 인터랙션을 구현하자
본 글은 GPT 4o 모델과 프롬프트 엔지니어링을 활용한 AI 도슨트 애플리케이션 제작 프로젝트에서, 애플리케이션의 채팅 기능에서 클라이언트 / 서버 단에서 기술적인 부분이 어떻게 구현되었는지 설명한다.
작성자는 팀에서 프론트엔드(클라이언트)를 맡았기에, 백엔드보다는 프론트엔드 코드와 UI, 사용성 위주로 작성하였다.
또한 코드는 핵심 함수 위주로 첨부하며 UI 등 자잘한 부분은 많이 생략하였다.
1. 프롬프트 설계
AI 도슨트 프롬프트를 설계하고 Streamlit으로 테스트 앱을 제작하는 과정을 설명한, 이전 블로그 글이 있기에 링크를 첨부한다.
최종 프롬프트는 아래 글의 프롬프트에서 예시를 수정하고 영어로 번역하는 등 추가적인 수정이 이루어졌다.
https://day4fternoon.tistory.com/85
GPT 모델로 미술 작품 감상을 위한 채팅봇 구현하기
본 포스트에서는 프롬프트 엔지니어링을 통해 목적 달성에 적절한 시스템을 설계하고, 실제 사용자 테스트를 위해 Streamlit을 이용해 테스트 애플리케이션을 만드는 과정을 서술한다. 1. 프롬프
day4fternoon.tistory.com
2. 명화 불러오기
우리의 앱(이하 QnArt)에서는 2가지 방법으로 명화 작품을 감상할 수 있다.
- 오늘의 명화 카드 : 데이터베이스에서 랜덤으로 1개의 명화를 가져와 감상
- 미술관 전시 감상(미술관 찾기) : 실제 미술관에서 진행 중인 전시를 선택하여, 해당 전시의 명화들을 감상
사용자 시나리오만 다르고, 채팅의 구현 자체는 동일하므로 본 글에서는 1. 오늘의 명화 카드 기능을 중심으로 작성한다.
명화 데이터는 사전 제작한 데이터베이스를 이용한다.
제목, 작가, 제작년도, 설명, hook(흥미를 유발할 수 있는 한줄요약), 이미지를 포함하며, RAG 기능을 위해 여러 공식 문서에서 수집한 작가 및 작품의 추가 정보 또한 들어가 있다.
이 데이터베이스를 기반으로, 백엔드에서 랜덤으로 1개의 데이터를 뽑아 프론트에 데이터를 response로 돌려준다.
그 데이터를 가지고 아래와 같은 '오늘의 명화 카드' 화면을 만든다.
🖥️ Flutter code : ArtCardScreen
랜덤 명화 출력 API를 호출하는 handleRandom 함수이다.
유저 id인 user_pk와, 중복 출력 방지를 위해 제외할 artwork id를 POST 해주면 명화 정보를 반환해준다.
이때 받은 response는 UTF-8 디코딩이 필요하다. Flutter에서 mySQL 데이터베이스의 한글 데이터를 그대로 가져오면 한글이 깨지기 때문이다.
'오늘의 명화 카드' 화면에 접속함과 동시에 위 API를 호출하는 함수가 실행되어야 하므로, initState를 사용한다.
handleRandom에서 받아 setState로 imgPath, hook, title 등 필요한 값들을 세팅해줬으면, 위 캡쳐처럼 명화 사진과 한줄요약, 제목이 뜨게 된다.
3. 명화 감상 채팅하기
랜덤명화 선정 시 받았던 sessionId를 POST해주면, 백엔드에서 OpenAI API를 불러와 채팅 세션을 시작한다.
채팅은 아래와 같은 방식으로 이루어진다.
프론트엔드에서 유저가 입력한 message를 백엔드에 전송 → 백엔드가 OpenAI API를 호출해 GPT 4o 모델의 응답 받아오고, 그 응답의 content를 프론트에 돌려줌 → 받은 메시지를 프론트에서 화면에 출력 |
채팅 화면은 아래 캡처 참고. (ChatScreen)
자연스럽게 대화가 핑퐁되고 있다.
![]() |
![]() |
GPT를 부르고, 랭체인으로 대화 맥락을 유지하고, RAG으로 환각을 방지하는 등의 로직은 백엔드 위주로 구현되었다.
나는 프론트엔드니까... AI와의 부드러운 채팅 인터랙션 구현 위주로 코드를 첨부, 설명하겠다.
🖥️ Flutter code : ChatScreen
1) 음성 기능: STT / TTS
Naver Premium Voice API와 플러터의 speech-to-text 패키지를 사용하여 챗봇의 말을 음성으로 출력 / 사용자가 음성으로 채팅 입력하는 기능을 구현하였다. 이 부분은 이전에 구현하며 글을 작성했던 기록이 있어 링크 첨부로 대신한다. 주가 되는 기능은 아니니...
STT: https://day4fternoon.tistory.com/126
[Flutter] speech_to_text로 음성 인식 구현하기
AI 채팅봇과 대화할 수 있는 채팅 페이지를 만드는데, 타이핑뿐만 아니라 음성으로도 메시지를 입력할 수 있게 해야 한다.speech_to_text 플러터 패키지를 사용해 구현할 수 있다. https://pub.dev/packages
day4fternoon.tistory.com
TTS: https://day4fternoon.tistory.com/127
[Flutter] OpenAI Text to speech로 챗봇 채팅 음성으로 읽어주기(tts)
지난번에 플러터 패키지를 이용해 stt를 구현했었다.https://day4fternoon.tistory.com/126 [Flutter] speech_to_text로 음성 인식 구현하기AI 채팅봇과 대화할 수 있는 채팅 페이지를 만드는데, 타이핑뿐만 아니
day4fternoon.tistory.com
(* 위 글에서는 OpenAI TTS를 사용했지만 이후 Naver TTS로 교체하였다. API 키 등, 인증 관련한 부분을 제외하고는 거의 동일한 방식으로 구현할 수 있다.)
2) 응답을 받아오는 동안 임시 메시지 출력하기
백엔드에서 GPT 응답을 받아올 때까지는 3~5초 정도의 시간이 걸린다. RAG 참고 파일로 들어가는 것이 커질 경우 더 걸린다. 게다가 인터넷 이슈 등도 존재하므로 API 응답은 필연적으로 딜레이가 생길 수밖에 없다.
그 딜레이 시간 동안 UI에 아무런 변화가 없다면, 사용자는 '렉 걸렸나?' '오류 났나?' 같은 생각을 자연히 하게 될 것이다. 또한 채팅을 전송할 때마다 채팅 API가 호출되므로, 대답을 기다리는 동안 사용자가 또 다른 메시지를 입력해 보낸다면 API가 또다시 호출되고 자칫하면 백엔드에 보내는 Request가 왕창 쌓여버릴 수 있다.
그러므로 응답이 오는 동안, '입력 중...' 이라는 임시 메시지를 출력하고, 그동안은 사용자의 추가적인 입력을 막아주자.
임시 메시지(입력 중...)는 저 말줄임표가 0개에서 3개까지 늘었다 줄었다 하는 식의 StatefulWidget으로 만들어주었다.
일정 시간이 지나면 state(점의 개수)를 변경시켜줄 것이므로 Timer을 사용하고, AnimationController을 사용하기 위해 SingleTickerProviderStateMixin을 사용해준다.
Timer.periodic을 사용해 0.5초마다 state를 업데이트해 점 개수를 변경해준다.
UI에는 이런 식으로 적용한다.
'입력 중' 텍스트와, '...' 부분을 Row로 수평 배치하고, '...' 부분은 AnimationBuilder로 아까 만든 controller와 dotCnt를 사용한다.
ChatScreen에서는 위에서 만든 BotLoadingMessage 위젯을 이용해, API 호출 함수의 최상단에 bot loading message를 출력해준다. 또한 isChatting이라는, 채팅이 가능한지 여부를 저장하는 bool 변수를 false로 바꿔준다. 이 bool 변수를 통해 채팅 텍스트필드의 enabled 여부와 hintText를 적절히 바꿔줌으로써 사용자가 UI에서 헷갈리는 부분이 없도록 해준다.
3) 새 메시지가 만들어질 경우 화면을 자동으로 스크롤
AI 채팅이든 유저 채팅이든, 새 채팅이 생성되면 화면을 최하단으로 스크롤해주어야 한다. 그래야 내가 보낸 게 바로 보이니까. 개발하면서 느낀 건데, 사소한 줄 알았던 이런 인터랙션들이 채팅 앱에서 굉장히 큰 편리함을 책임져주고 있었다. 없으니까 진심... 너무너무 불편했다.
그래서 만들었다.
채팅 스크린에서, GPT의 채팅 응답을 받아오는 함수의 일부이다. response가 잘 올 경우 gptResponse를 utf8로 디코딩해 메시지를 추출하고, bot loading 메시지를 삭제한 뒤, 받아온 메시지를 화면에 출력한다. 그 다음 WidgetsBinding부분이 자동 스크롤 부분이다.
WidgetsBinding~addPost... 이부분은 Flutter에서 프레임 렌더링이 완료된 직후 실행할 작업을 등록한다는 뜻이다. 왜냐면 새 메시지를 받고, 그 메시지가 화면 UI에 출력된 이후에 스크롤이 이루어져야 하니까.
채팅 화면은 Expanded위젯으로 구현되어 있는데, 여기에 controller로 scrollController을 부여해준다.
그리고 animateTo 함수를 사용해 maxScroll까지 쭈욱 내려준다. duration과 curve로 부드러운 애니메이션을 만들어줄 수도 있다.
새 채팅이 생기면 자동으로 화면이 아래로 내려가 새 채팅을 화면에 보이게 해주는 것을 볼 수 있다.
프롬프트 엔지니어링, 랭체인, RAG 등 다양한 기술이 적용되었지만 일단 나는 프론트가 메인이니까... AI 채팅앱을 만들 때 고려하면 좋은 인터랙션을 플러터에서 어떤 식으로 구현하는지! 에 대한 포스트를 작성해보았다.
당연히 응답을 받아오고 출력하는 기능 구현이 먼저지만, 개발을 하다 보면 저런 자잘한 사용성을 챙겨주지 않을 경우 사용자 경험에 꽤나 큰 애로사항이 생기게 된다. 우리의 QnArt는 실제 사용자 테스트도 거쳐야 했기에... 부드러운 사용감을 위해 이래저래 신경을 써 주었다.
구현해볼 만한 다른 채팅 인터랙션이 더 있을지 고민해보아도 좋을 것 같다. 🤗