- Today
- Total
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 워드프레스
- TIL
- Hostinger
- 의존성주입
- PDO
- 이클립스
- 백엔드
- Oracle
- 오라클
- PLSQL
- url파라미터
- 프로시저
- javascript
- 문제해결
- iframe
- sql
- 쿼리개선
- PROCEDURE
- php
- dbeaver
- spring boot
- 클론코딩
- 코덱스
- mysqli
- 트러블슈팅
- 엘리멘터
- MariaDB
- 비주얼코드
- 환경세팅
- 오류해결
개발 공부중
[BOOKMATE #2] Gemini 응답을 기다리느라 화면 전환이 느려졌다 본문
이번에는 Google Gemini를 연결한 뒤, 질문이 생성될 때까지 기다려야 했던 요청 구조를 바꾼 이야기다.
BOOKMATE에서는 새로운 북클럽을 열면 Gemini가 독서토론 질문을 만들어준다. 그런데 질문 생성에 시간이 걸리다 보니 북클럽 화면에 들어가는 것까지 같이 늦어졌다.


처음에는 한 번의 요청에서 전부 처리했다
처음 흐름은 단순했다. 사용자가 책을 선택하고 북클럽에 들어가면, 서버가 Gemini를 호출하고 질문을 받아온 뒤 응답하는 방식이었다.
POST /enter
→ Gemini API 호출
→ 질문 생성 대기
→ 결과 처리 및 저장
→ 응답
한 번 요청해서 책 정보와 질문을 같이 받으니 프론트엔드에서는 이해하기 편한 구조였다. 응답이 오면 그 데이터로 북클럽 화면을 보여주면 됐다.
문제는 Gemini의 응답 시간이었다.
질문이 생성될 때까지 약 5~15초를 기다려야 했다. 사용자는 북클럽에 들어가려고 버튼을 눌렀는데, 서버는 그동안 질문을 만들고 있는 상황이었다.
AI가 질문을 생성하는 시간은 필요했다. 하지만 그 시간 동안 북클럽 화면까지 못 보여줘야 하는지는 다시 생각해볼 문제였다.
Gemini를 호출하는 코드는 따로 두었다
Gemini를 연결하면서 먼저 구분한 것은 API 요청을 받는 부분과 실제 AI 호출을 처리하는 부분이었다.
화면에서 선택한 책 정보는 백엔드로 전달하고, Gemini 클라이언트를 만들거나 프롬프트를 구성하고 응답을 해석하는 코드는 ai_service.py에 두었다.
역할은 다음처럼 나뉜다.
- 요청 처리 부분은 어떤 책의 북클럽에 들어갈지 판단한다.
- ai_service.py는 전달받은 책 정보로 Gemini에 질문 생성을 요청한다.
- 질문 저장 부분은 생성된 질문을 DB에 등록한다.
이렇게 나누면 Gemini 모델이나 프롬프트를 바꾸는 작업과, 북클럽 입장 흐름을 바꾸는 작업을 구분해서 볼 수 있다.
실제 질문 생성 함수는 책 제목과 저자를 받고, 추가 정보가 있다면 함께 전달하는 형태다.
def generate_initial_questions(
book_title: str,
author: str,
memo: str | None = None,
memorable_scene: str | None = None,
) -> list[str]:
지금은 작품 소개와 기억에 남는 장면도 받을 수 있지만, 기본적으로는 화면에서 선택한 책 정보가 백엔드를 거쳐 이 함수까지 전달되는 구조다.
API 키와 모델명은 코드 밖에서 관리했다
Gemini 호출에 필요한 API 키는 서버의 환경변수로 관리했다. 프론트엔드에서 키를 가지고 직접 호출하는 방식은 사용하지 않았다.
프로젝트에서는 .env 설정을 읽고, 코드에서 다음과 같이 사용한다.
return genai.Client(api_key=settings.GEMINI_API_KEY)
모델명도 settings.GEMINI_MODEL로 가져오도록 했다. 호출하는 코드 안에 모델명을 반복해서 적는 대신 설정으로 분리한 것이다.
이 부분은 나중에 모델을 변경하거나 실행 환경을 옮길 때도 확인해야 하는 항목이 됐다. 코드에서 설정을 읽는 것과, 실제 실행 중인 서버에 원하는 값이 적용되어 있는지는 따로 확인할 필요가 있었다.
실제 API 키가 들어 있는 .env 파일은 GitHub에 올리지 않도록 주의해야 한다.
기다리는 작업을 입장 요청에서 분리했다
응답 지연을 해결하기 위해 북클럽 입장과 질문 생성을 나눴다.
기존에는 질문이 완성되어야 POST /enter 요청이 끝났지만, 변경 후에는 질문이 아직 없더라도 먼저 응답하도록 했다.
POST /enter
→ 즉시 응답 (questions=[])
응답 후 BackgroundTask 실행
→ Gemini API 호출
→ 생성된 질문 DB 저장
여기서 핵심은 Gemini가 질문을 만드는 속도를 빠르게 한 것이 아니다.
질문이 만들어질 때까지 기다리는 일을 북클럽 입장 요청에서 분리한 것이다.
프론트엔드는 먼저 받은 책 정보로 북클럽 화면을 보여줄 수 있다. 질문 영역은 아직 비어 있으므로, 그 자리에는 생성 중이라는 Loading UI를 표시한다.
실제 static/js/app.js에서는 응답을 받으면 먼저 switchView("club")으로 화면을 전환한다. 질문이 없는 경우에만 반복 조회를 시작한다.
BookMateState.currentBook = response.book;
BookMateState.saveToSession();
renderBookClubView(response.book, response.questions);
switchView("club");
// 질문이 아직 없으면 (AI 생성 중) 자동으로 재조회 시작
if (!response.questions || response.questions.length === 0) {
startQuestionPolling(response.book.id);
}
이렇게 바꾸면서 화면 진입과 질문 도착이 서로 다른 시점에 일어나도록 흐름이 달라졌다.
실제 app/routers/books.py에서는 다음과 같이 처리했다.
아래 예시는 책 조회와 응답 객체 구성, 로그·예외 처리는 생략했다.
def enter_book_club(req: BookEnterRequest, background_tasks: BackgroundTasks):
# ... 책 조회 및 book_id 준비 부분 생략 ...
questions = question_service.get_questions_by_book_id(book_id)
# 질문이 0개이면 BackgroundTask로 Gemini 생성 예약 (즉시 응답)
if len(questions) == 0:
background_tasks.add_task(
_background_generate_questions,
book_id,
book["title"],
book["author"],
req.memo,
req.memorable_scene,
)
# ... 로그 출력 부분 생략 ...
# ... book_resp, question_responses 구성 부분 생략 ...
return BookEnterResponse(book=book_resp, questions=question_responses)
핵심은 background_tasks.add_task()다. _background_generate_questions를 이 자리에서 직접 실행하는 대신, 응답 후 실행할 작업으로 등록한다. 따라서 Gemini가 질문을 완성할 때까지 기다리지 않고 아래의 return으로 책 정보와 현재 질문 목록을 먼저 돌려준다.
질문이 없었다면 응답의 질문 목록은 빈 배열이다. 여기서 ‘즉시 응답’은 Gemini를 기다리지 않는다는 뜻이고, 책과 기존 질문을 DB에서 조회하는 시간은 여전히 필요하다.
응답 후 실행되는 _background_generate_questions() 안에서는 다음 코드가 Gemini를 호출하고 결과를 DB에 저장한다. 실제 함수의 중복 생성 확인과 오류 처리 부분은 생략했다.
q_texts = ai_service.generate_initial_questions(
book_title=book_title,
author=author,
memo=memo,
memorable_scene=memorable_scene,
)
question_service.create_initial_questions(book_id, q_texts)
generate_initial_questions()에서 질문을 받아오면 create_initial_questions()로 저장한다. 그 뒤 프론트엔드가 질문 목록을 다시 조회했을 때 저장된 결과를 받게 된다.
프론트엔드는 질문이 도착했는지 다시 확인한다
서버가 먼저 응답하면 그 응답에는 아직 질문이 없다. 그러면 프론트엔드에서 나중에 완성된 질문을 가져올 방법이 필요하다.
여기에는 5초마다 질문 목록을 다시 조회하는 방식을 사용했다.
북클럽 화면 즉시 진입
→ Loading UI 표시
→ 5초마다 GET /api/books/{id}/questions
→ 질문이 조회되면 화면 갱신
이렇게 일정한 간격으로 서버에 상태를 확인하는 방식을 폴링이라고 한다.
반복 조회는 같은 파일의 startQuestionPolling()에서 처리한다. 다음은 실제 함수다.
let _pollingTimer = null;
function startQuestionPolling(bookId) {
if (_pollingTimer) clearTimeout(_pollingTimer);
let attempts = 0;
const MAX_ATTEMPTS = 8; // 최대 8회 = 약 40초
const INTERVAL_MS = 5000; // 5초 간격
function poll() {
attempts++;
BookMateAPI.getBookQuestions(bookId)
.then((questions) => {
if (questions && questions.length > 0) {
// 질문 도착 — 질문 영역만 갱신
renderQuestionsLists(questions);
} else if (attempts < MAX_ATTEMPTS) {
// 아직 없으면 재시도
_pollingTimer = setTimeout(poll, INTERVAL_MS);
} else {
// 최대 재시도 초과 → 에러 UI
showQuestionsError(bookId);
}
})
.catch(() => {
if (attempts < MAX_ATTEMPTS) {
_pollingTimer = setTimeout(poll, INTERVAL_MS);
} else {
showQuestionsError(bookId);
}
});
}
_pollingTimer = setTimeout(poll, INTERVAL_MS);
}
질문이 도착하면 renderQuestionsLists(questions)로 질문 영역만 갱신하고 다음 조회를 예약하지 않는다. 질문이 없거나 요청이 실패하면 5초 뒤 다시 조회하고, 최대 8회에 도달하면 showQuestionsError(bookId)를 실행한다. 코드 주석에는 약 40초로 적혀 있지만, 실제 대기 시간에는 각 조회 요청의 처리 시간도 더해진다.
백그라운드 작업이 질문을 DB에 저장하면, 이후 조회 요청에서 질문 목록을 받을 수 있다. 프론트엔드는 그 결과로 질문 영역을 갱신한다.
이 구조에서는 질문을 다 받았을 때 반복 조회를 끝내는 것도 중요하다. 다른 책으로 이동했는데 이전 책의 조회가 계속되거나, 뒤늦게 도착한 응답이 현재 화면에 섞이지 않도록 조회 대상도 구분해야 한다.
처음에는 한 번의 응답으로 끝났던 일이, 변경 후에는 입장 응답과 질문 조회 응답을 따로 관리하는 일이 됐다.
Gemini 응답은 그대로 DB에 넣지 않았다

Gemini에서 답변이 돌아왔다고 바로 저장할 수 있는 것도 아니었다. 프로그램에서 질문을 하나씩 다루려면 응답 형식이 필요했다.
그래서 Gemini에 JSON 배열로 답하도록 요청하고, 서버에서 이를 질문 목록으로 변환했다.
text = response.text.strip()
questions = json.loads(text)
현재 코드에는 응답 앞뒤에 마크다운 코드 블록이 붙는 경우를 정리하는 처리도 들어 있다. 변환한 결과가 배열인지 확인한 뒤 질문 목록으로 사용한다.
질문을 저장할 때는 책의 ID와 연결하고, 출처를 AI로 기록한다. 이후 프론트엔드가 질문 목록을 조회하면 이 저장된 데이터를 받게 된다.
정리하면 Gemini 응답과 화면 사이에는 다음 과정이 있다.
Gemini 응답
→ 응답 형식 정리
→ JSON 변환 및 확인
→ 질문 목록 DB 저장
→ 프론트엔드 조회
→ 화면 표시
이 중간 과정을 나눠보니 문제가 생겼을 때도 확인할 지점이 분명해졌다. AI 호출이 실패한 것인지, 응답 변환에서 실패한 것인지, 저장이나 조회에서 문제가 생긴 것인지 구분해야 했다.
먼저 응답하면 실패 처리도 달라진다
백그라운드로 옮기면서 한 가지 더 생각해야 했다.
기존에는 Gemini 호출이 실패하면 아직 끝나지 않은 입장 요청에서 오류를 알려줄 수 있었다. 하지만 변경 후에는 입장 요청이 이미 성공 응답을 보낸 상태다.
따라서 북클럽 입장 성공이 질문 생성 성공까지 뜻하는 것은 아니게 됐다.
현재 AI 호출 코드에는 API 키가 없을 때 오류를 내고, 일시적인 과부하나 호출 제한으로 판단되는 오류에는 재시도하는 처리가 있다. 잘못된 응답도 정상 질문처럼 저장하지 않고 오류로 처리한다.
다만 빈 질문 목록만으로는 아직 생성 중인지, 생성에 실패한 것인지 구분하기 어렵다. 그래서 현재 프론트엔드 코드는 최대 8회 조회 후 오류 UI를 표시하도록 되어 있다. 이는 서버가 생성 실패 상태를 알려주는 방식이 아니라, 정해진 횟수 안에 질문을 가져오지 못하면 사용자에게 알리는 방식이다.
또 FastAPI의 BackgroundTask는 별도의 영구 작업 큐는 아니다. 응답 후 같은 애플리케이션에서 실행되는 작업이므로, 서버가 재시작되어도 반드시 완료되는 구조라고 생각해서는 안 된다. 이번 변경은 우선 긴 작업을 입장 응답에서 분리한 것으로 보는 게 맞다.
돌아보며

Gemini를 연결할 때 처음에는 요청을 보내고 답을 받아오면 기능이 완성될 것 같았다. 그런데 실제 서비스 흐름에 넣으니 AI의 응답 시간이 사용자 경험에 바로 영향을 줬다.
이번에는 질문 생성 자체를 빠르게 만드는 대신, 사용자가 먼저 북클럽에 들어갈 수 있도록 요청 구조를 바꿨다.
그 대신 프론트엔드에서는 로딩과 반복 조회를 관리해야 했고, 서버에서는 입장 성공과 질문 생성 성공을 구분해서 생각해야 했다. 한 요청에 묶여 있던 작업을 나누면서 그 사이의 상태를 다루는 일이 생긴 것이다.
...
그리고 질문이 화면에 잘 나타나는 것과, 그 질문의 내용이 정확한 것도 별개의 문제였다.
사용하다 보니 Gemini가 모르는 책에 대해서도 그럴듯한 질문을 만들었고, 실제 작품과 다른 내용이 섞여 나왔다.
다음 글에서는 “AI가 모르는 책의 토론 질문은 어떻게 만들까?”를 두고 고민했던 과정을 정리하려고 한다.
프로젝트 코드: GitHub · BOOKMATE
'REVIEW' 카테고리의 다른 글
| [BOOKMATE #4] 화면에서 막았는데 AI API는 열려 있었다 (0) | 2026.10.22 |
|---|---|
| [BOOKMATE #3] Gemini가 모르는 책은 어떻게 질문을 만들게 할까? (0) | 2026.10.21 |
| [BOOKMATE #1] Antigravity로 시작하며 MD 문서로 설계부터 정리한 이유 (1) | 2026.10.19 |
| [사이드 프로젝트 회고 #5] 서비스 운영하면서 알게 된 것들 (0) | 2026.07.20 |
| [사이드 프로젝트 회고 #4] 구독 기능 설계하면서 고민했던 DB 구조 (0) | 2026.07.19 |
