프로젝트명: 토토리(Totori) — 난독 아동을 위한 음성 인식 기반 동화책 서비스
담당: 퀴즈 기능 백엔드(Spring Boot) + AI 서버(FastAPI) 개발
기술 스택: Spring Boot 3, JPA, MySQL, Redis, FastAPI, OpenAI GPT-5-mini, Whisper, ElevenLabs TTS, AWS S3, MeCab
1. 프로젝트 소개
토토리는 난독(Dyslexia) 아동이 동화를 직접 낭독하면서 읽기 능력을 길러주는 음성 인식 기반 학습 서비스이다. 아이가 좋아하는 주제로 GPT가 동화를 만들어주고, 아이는 그 동화를 직접 읽으며, AI가 아이의 음성을 실시간으로 분석해 읽기 오류 패턴을 추출한다.
서비스의 핵심 학습 루프는 다음과 같다.
1. 동화 생성 (관심사 음성 → GPT → 맞춤형 동화)
↓
2. 동화 낭독 (아이의 음성 → Whisper STT)
↓
3. 읽기 오류 분석 (음운 오류 / 조사 오류 추출 → Redis 누적)
↓
4. 퀴즈 생성 (오류 패턴 → GPT → 맞춤 퀴즈 + TTS 음성) ← 본 글의 범위
↓
5. 퀴즈 풀이 (아이 음성 → Whisper STT → 정답 채점)
↓
6. 보상 (도토리 획득 → 뱃지)
왜 "맞춤형" 퀴즈여야 하는가
일반적인 학습 앱의 퀴즈는 모든 아이에게 같은 문제를 준다. 하지만 난독 아동은 각자 약점이 다르다.
- 어떤 아이는 ㄱ 받침을 자주 빠뜨리고
- 어떤 아이는 ㅓ를 ㅗ로 잘못 발음하며
- 어떤 아이는 주격 조사 이/가를 자주 빠뜨린다
따라서 그 아이가 방금 동화를 읽으면서 실제로 틀린 패턴을 그대로 가져와서 퀴즈로 만들어주는 것이 핵심이다.
2. 요구사항 분석과 설계 의사결정
기능 요구사항
| F1 | 아이가 동화를 읽으면서 발생한 읽기 오류를 기반으로 퀴즈 생성 | 음운/조사 분리 |
| F2 | 레벨에 따라 단어 퀴즈(L1~L3) 또는 문장 퀴즈(L4~L6) | 6단계 |
| F3 | 퀴즈는 정확히 4문항, 첫 문항은 방금 틀린 그 단어/문장 | 점진 학습 |
| F4 | 각 문항에 대해 TTS 음성 파일을 함께 제공 | 들려주고 따라 말하기 |
| F5 | 아이가 음성으로 풀면 Whisper STT로 채점 | 정답 판정 |
| F6 | 4문항 모두 맞히면 도토리 보상(중복 지급 방지) | 게이미피케이션 |
| F7 | 오류가 없으면 퀴즈 생성하지 않음 (완벽하게 읽은 경우) | 204 No Content |
핵심 설계 결정
- "낭독 분석"과 "퀴즈 생성"을 완전히 분리
[낭독 분석] STT → 음운/조사 오류 추출 → Redis 누적 → 원문/STT 즉시 폐기
[퀴즈 생성] Redis에서 오류 패턴만 읽기 → GPT 퀴즈 → TTS → 응답
- 레벨별 퀴즈 분기: 음운 vs 조사
| L1~L3 | PHONEME | 단어 4개 | 닭, 책, 약, 박 |
| L4~L6 | JOSA | 두 어절 문장 4개 | "나비가 난다." |
- TTS는 사전 생성, 채점은 즉시 처리
TTS(텍스트 → 음성) 생성은 외부 API 호출이라 느리다(평균 1~2초/문항).
→ 퀴즈 생성 시점에 4개 문항을 모두 TTS 생성하여 S3에 저장하고, Presigned URL로 반환
반대로 채점은 음성 한 번에 한 문항이기에 매번 STT를 돌린다.
- 도토리 보상 중복 방지
Quiz 엔티티에 rewarded: boolean을 두고, 모든 문항을 다 맞혔을 때 단 한 번만 도토리를 지급하도록 했다.
3. 전체 아키텍처

4. AI 서버(FastAPI) 구현
디렉토리 구조
app/
├── api/
│ ├── quiz_router.py ← 퀴즈 엔드포인트
│ └── reading_router.py ← 낭독 분석 엔드포인트
├── services/
│ ├── quiz_generator.py ← GPT 프롬프트 + 퀴즈 생성
│ ├── quiz_analyzer.py ← STT 결과 채점
│ ├── reading_service.py ← Redis 오류 저장/조회
│ ├── phoneme_analyzer.py ← 음운 오류 분석
│ ├── josa_analyzer.py ← MeCab 기반 조사 오류 분석
│ ├── whisper_loader.py ← Whisper 모델 로더
│ └── text_cleaner.py ← 텍스트 정규화
├── clients/
│ └── elevenlabs_client.py ← TTS 비동기 클라이언트
├── schemas/
│ └── quiz_schema.py ← Pydantic 스키마
└── utils/
└── alignment_utils.py ← Levenshtein 정렬 알고리즘
퀴즈 생성 라우터
/ai/quiz/generate는 레벨에 따라 음운 퀴즈 또는 조사 퀴즈를 분기하고, 오류가 없으면 204를 반환한다.
# app/api/quiz_router.py
PHONEME_LEVELS = {"L1", "L2", "L3"}
JOSA_LEVELS = {"L4", "L5", "L6"}
@router.post("/generate", response_model=QuizResponse)
async def generate_quiz(request: QuizRequest):
if request.level not in PHONEME_LEVELS | JOSA_LEVELS:
raise HTTPException(status_code=400, detail=f"유효하지 않은 레벨입니다: {request.level}")
try:
# Redis에서 누적된 오류 패턴 조회
errors = await _reading_service.get_errors(request.child_id, request.book_id)
if request.level in PHONEME_LEVELS:
pattern, word = _reading_service.get_top_phoneme_error(errors)
quiz_items = await _quiz_generator.generate_quiz_words(word, pattern)
else:
event = _reading_service.get_top_josa_error(errors)
quiz_items = await _quiz_generator.generate_josa_quiz(event)
# 4개 문항을 모두 TTS 합성하여 base64 응답
audio_data = await _generate_audio_for_items(quiz_items)
return QuizResponse(quiz_items=quiz_items, audio_data=audio_data)
except ValueError:
# 아이가 완벽하게 읽어서 오류가 하나도 없는 경우
return Response(status_code=204)
except Exception as e:
raise HTTPException(status_code=500, detail=f"퀴즈 생성 중 오류 발생: {str(e)}")
설계 포인트
- 오류가 없는 경우(완벽 낭독)는 에러가 아니라 정상 케이스이므로 204로 처리
- TTS는 4개를 병렬 처리하되 외부 API 부하를 위해 세마포어로 동시성 제한 (4.5절 참조)
가장 빈번한 오류 패턴 선택
낭독 한 번에 수십 개의 오류가 발생할 수 있습니다. 그중 가장 빈번한 패턴 1개를 골라 그것에 집중한 퀴즈를 생성합니다. 이렇게 하는 이유는 한 번에 한 가지 약점만 다뤄야 아이가 혼란스럽지 않기 때문입니다.
# app/services/reading_service.py
def get_top_phoneme_error(self, errors: list[dict]) -> tuple[str, str]:
phoneme_errors = [e for e in errors if e["type"] == "phoneme"]
if not phoneme_errors:
raise ValueError("저장된 음소 오류가 없습니다.")
# Counter로 가장 많이 나타난 패턴 추출
top_pattern = Counter(e["pattern"] for e in phoneme_errors).most_common(1)[0][0]
top_word = next(e["word"] for e in phoneme_errors if e["pattern"] == top_pattern)
return top_pattern, top_word
def get_top_josa_error(self, errors: list[dict]) -> JosaEvent:
josa_errors = [e for e in errors if e["type"] == "josa"]
if not josa_errors:
raise ValueError("저장된 조사 오류가 없습니다.")
# (kind, target_josa, stt_josa) 조합으로 가장 빈번한 오류 추출
top_key = Counter(
(e["kind"], e["target_josa"], e["stt_josa"]) for e in josa_errors
).most_common(1)[0][0]
top = next(e for e in josa_errors if (e["kind"], e["target_josa"], e["stt_josa"]) == top_key)
return JosaEvent(
kind=top["kind"], stem=top["stem"],
target_josa=top["target_josa"], stt_josa=top["stt_josa"],
)
GPT 프롬프트 엔지니어링
단순히 "퀴즈 만들어줘"라고 하면 GPT가 의도와 다른 결과를 자주 만들어내기 때문에 다음 4가지를 제약 조건으로 지정했다.
| 1 | 첫 단어는 반드시 아이가 틀린 그 단어 | 자신이 틀린 것부터 다시 시도 |
| 2 | 나머지 3개는 같은 오류 패턴이 나오는 단어 | 약점 집중 훈련 |
| 3 | 모두 초/중등 수준 친숙한 단어 | 난독 아동이 처음 보는 단어면 의미 없음 |
| 4 | 명사/동사 원형만 | 활용형은 학습 단계에 부적합 |
음운 퀴즈 프롬프트
# app/services/quiz_generator.py
async def generate_quiz_words(self, target_word: str, error_pattern: str) -> list[str]:
system_prompt = (
"당신은 난독 아동을 위한 언어 치료사입니다.\n"
"아동의 음운 오류 패턴을 분석하여 해당 패턴을 집중적으로 연습할 수 있는 단어를 추천합니다.\n\n"
"[오류 패턴 해석 방법]\n"
"- 'X 탈락': 자음 X가 있어야 할 자리에서 빠뜨리는 오류. 해당 자음이 받침 또는 초성으로 포함된 단어를 추천.\n"
"- 'X 첨가': 없어야 할 자음 X를 추가로 발음하는 오류. 해당 자음이 없는 단어를 추천.\n"
"- 'X -> Y 대치': 자음/모음 X를 Y로 잘못 발음하는 오류. X가 포함된 단어를 추천.\n\n"
"[추천 규칙]\n"
"1. 반드시 첫 번째 단어는 아이가 틀린 원래 단어 그대로 출력.\n"
"2. 나머지 3개는 오류 패턴을 연습할 수 있는 단어로, 해당 자음/모음이 실제로 포함된 단어여야 함.\n"
"3. 4개의 단어는 모두 서로 달라야 함. 중복 절대 금지.\n"
"4. 모든 단어는 초등학교-중학교 수준의 친숙한 단어여야 함.\n"
"5. 단어는 명사 또는 동사 원형으로만 구성.\n\n"
"결과는 반드시 아래 JSON 형식으로만 출력:\n"
'{"words": ["단어1", "단어2", "단어3", "단어4"]}'
)
user_prompt = (
f"아이가 방금 틀린 원래 단어: {target_word}\n"
f"오류 패턴: {error_pattern}\n"
f"위 규칙에 따라 '{target_word}'를 포함한 단어 4개를 중복 없이 추천해주세요."
)
words = await self._call_gpt(system_prompt, user_prompt, "words")
return self._dedupe(words, target_word)
핵심 트릭
- response_format={"type": "json_object"}로 강제 JSON 응답
- GPT가 그래도 가끔 중복을 만들기 때문에 응용 단(_dedupe)에서 한 번 더 정리
- 첫 단어가 반드시 target_word가 되도록 _dedupe에서 정렬
def _dedupe(self, items: list[str], first: str) -> list[str]:
seen, result = set(), []
for item in [first] + [x for x in items if x != first]:
if item not in seen:
seen.add(item)
result.append(item)
return result[:4]
조사 퀴즈 프롬프트
- 조사 오류 종류
| DELETION | 조사 빠뜨림 | "나비가 난다" → "나비 난다" |
| SUBSTITUTION | 조사 잘못 읽음 | "나비가" → "나비는" |
| INSERTION | 조사 추가 | "나비 난다" → "나비가 난다" |
종류에 따라 프롬프트의 설명 문장을 다르게 구성했다.
async def generate_josa_quiz(self, event: JosaEvent) -> list[str]:
if event.kind == "DELETION":
error_desc = f"조사 '{event.target_josa}'를 빠뜨리는 오류"
practice_josa = event.target_josa
elif event.kind == "SUBSTITUTION":
error_desc = f"조사 '{event.target_josa}'를 '{event.stt_josa}'로 잘못 읽는 오류"
practice_josa = event.target_josa
else: # INSERTION
error_desc = f"없어야 할 조사 '{event.stt_josa}'를 추가로 붙이는 오류"
practice_josa = event.stt_josa
# ... (생략)
user_prompt = (
f"오류: {error_desc}\n"
f"연습 조사: '{practice_josa}'\n"
f"예) 조사 '가': ['나비가 난다.', '꽃이 핀다.', '새가 난다.', '별이 빛난다.']\n"
f"예) 조사 '을': ['음식을 먹는다.', '책을 읽는다.', '공을 찬다.', '물을 마신다.']\n"
f"조사 '{practice_josa}'가 들어간 두 어절 문장 4개를 중복 없이 만들어줘."
)
TTS 비동기 처리와 동시성 제어
4개 문항 각각에 대해 ElevenLabs API를 호출하는데, 모두 순차적으로 하면 4~8초가 걸린다. 그래서 asyncio.gather로 병렬 처리하되, 외부 API 부하를 막기 위해 세마포어로 동시성을 2개로 제한했다.
# app/api/quiz_router.py
_tts_semaphore = asyncio.Semaphore(2) # 동시에 2개까지만
async def _synthesize_safe(text: str) -> str:
try:
audio_bytes = await elevenlabs_client.synthesize(text)
return base64.b64encode(audio_bytes).decode("utf-8")
except Exception as e:
logger.error("TTS 실패 - text: %s, error: %s", text, e)
return "" # 한 개 실패해도 나머지는 진행
async def _synthesize_safe_limited(text: str) -> str:
async with _tts_semaphore:
return await _synthesize_safe(text)
async def _generate_audio_for_items(items: list[str]) -> list[str]:
return list(await asyncio.gather(*[_synthesize_safe_limited(item) for item in items]))
채점 로직: STT + 정규화 비교
아이가 퀴즈 음성을 녹음해서 보내면 Whisper로 STT를 돌리고 정답과 비교한다.
# app/services/quiz_analyzer.py
class QuizAnalyzerService:
def analyze(self, stt_result: dict, original_quiz: str) -> bool:
stt_text = stt_result.get("text", "").strip()
if not stt_text:
raise HTTPException(status_code=422, detail="STT 변환 결과가 비어있습니다.")
normalized_stt = normalize_for_quiz(stt_text)
normalized_quiz = normalize_for_quiz(original_quiz)
return normalized_stt == normalized_quiz
왜 정규화가 필요한가?
- Whisper는 가끔 공백/마침표를 추가하거나 누락
- 아이가 "나비가 난다."라고 말하면 Whisper는 "나비가 난다" 또는 " 나비가 난다 . " 등으로 반환할 수 있음
# app/services/text_cleaner.py
def normalize_for_quiz(text: str) -> str:
if not text:
return ""
text = re.sub(r"[^가-힣a-zA-Z0-9]", "", text) # 특수문자/공백 모두 제거
text = re.sub(r"\s+", " ", text)
return text.strip()
이렇게 하면 "나비가 난다." 와 " 나비가 난다 " 모두 "나비가난다"로 정규화되어 정확히 비교됩니다.
Whisper 프리셋: "raw" 모드를 쓴 이유
Whisper는 기본적으로 언어모델 보정이 매우 강하다. "강아지가 똥을 먹어요"를 아이가 "강아지가 동을 먹어요"라고 잘못 읽으면, Whisper가 "동"을 자동으로 "똥"으로 고쳐버린다.
그래서 낭독/채점에서는 보정을 최소화한 "raw" 프리셋을 사용한다.
# app/services/whisper_loader.py
PRESETS = {
"raw": dict(
language="ko",
task="transcribe",
temperature=0.7, # 발음 흔들림이 남음
beam_size=1, # 보정 최소
best_of=1,
condition_on_previous_text=False, # 이전 문장 영향 받지 않음
word_timestamps=_word_timestamps,
),
# ... balanced, clean 프리셋도 있음
}
5. 백엔드(Spring Boot) 구현
도메인 모델: Quiz 엔티티
// src/main/java/ctrlS/totori/quiz/entity/Quiz.java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "quizzes")
public class Quiz extends BaseTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id")
private Book book;
@Enumerated(EnumType.STRING)
private QuizType quizType; // PHONEME | JOSA
@Enumerated(EnumType.STRING)
private MemberLevel level; // L1~L6
@ElementCollection
@CollectionTable(name = "quiz_items", joinColumns = @JoinColumn(name = "quiz_id"))
@Column(name = "item")
@OrderColumn(name = "item_order") // 순서 보장
private List<String> quizItems;
@ElementCollection
@CollectionTable(name = "quiz_audio_keys", joinColumns = @JoinColumn(name = "quiz_id"))
@Column(name = "audio_key")
@OrderColumn(name = "audio_order")
private List<String> audioKeys; // S3 키 (URL 아님)
@Column(nullable = false)
private boolean rewarded = false; // 도토리 지급 여부
@Column(nullable = false)
private int correctCount = 0; // 맞힌 문항 수
public void markAsRewarded() { this.rewarded = true; }
public boolean isRewardable() { return !rewarded; }
public void incrementCorrect() { correctCount++; }
public boolean isAllCorrect() { return correctCount == quizItems.size(); }
}
설계 포인트
| quizItems를 @ElementCollection | Quiz 라이프사이클과 함께 묶고, 별도 Repository 불필요 |
| @OrderColumn | 첫 번째 항목이 "방금 틀린 단어"라는 의미 있는 순서 보장 |
| S3 URL이 아닌 키만 저장 | URL은 Presigned로 매번 만료. 키만 저장하면 만료 걱정 없음 |
| MemberLevel enum 사용 | String이면 "l1", "L7", "레벨1" 같은 값이 다 들어옴 |
| rewarded + correctCount 분리 | 보상 중복 방지를 컴파일 타임에 분리 |
컨트롤러
// src/main/java/ctrlS/totori/quiz/controller/QuizController.java
@Tag(name = "퀴즈 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/quiz")
public class QuizController {
private final QuizService quizService;
@PostMapping("/generate")
public BaseResponse<?> generateQuizFromAudio(
@AuthenticationPrincipal CustomUserPrincipal principal,
@RequestParam("bookId") Long bookId) {
QuizResponse response = quizService.generateQuizFromAudio(principal.memberId(), bookId);
if (response == null) {
return BaseResponse.noContent(); // 204
}
return BaseResponse.ok(response);
}
@PostMapping(value = "/{quizId}/check", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public BaseResponse<QuizAnalyzeResponse> forwardQuizAudio(
@AuthenticationPrincipal CustomUserPrincipal principal,
@PathVariable("quizId") Long quizId,
@RequestPart("audio") MultipartFile audioFile,
@RequestPart("original_quiz") String originalQuiz) {
return BaseResponse.ok(quizService.forwardQuizAudio(
principal.memberId(), quizId, audioFile, originalQuiz));
}
}
퀴즈 생성 서비스
// src/main/java/ctrlS/totori/quiz/service/QuizService.java
public QuizResponse generateQuizFromAudio(Long memberId, Long bookId) {
Member member = memberService.findById(memberId);
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new CustomException(ErrorCode.BOOK_NOT_FOUND));
// 권한 검증: 본인 책만 접근 가능
if (!Objects.equals(book.getMember().getId(), member.getId())) {
throw new CustomException(ErrorCode.BOOK_ACCESS_DENIED);
}
// FastAPI 호출
FastApiGenerateQuizRequest request = new FastApiGenerateQuizRequest(
member.getId(), book.getId(), member.getLevel().name());
FastApiGenerateQuizResponse fastApiResponse = fastApiQuizClient.generateQuiz(request);
// 204(완벽 낭독)면 null
if (fastApiResponse == null) {
return null;
}
// 퀴즈 내용 먼저 저장 (id 확보)
Quiz quiz = Quiz.of(book, member, fastApiResponse.quizItems(), new ArrayList<>());
Quiz savedQuiz = quizRepository.save(quiz);
// base64 → S3 업로드 → 키 저장
List<String> audioKeys = uploadQuizAudios(savedQuiz.getId(), fastApiResponse.audioData());
savedQuiz.getAudioKeys().addAll(audioKeys);
// Presigned URL 발급해서 응답
List<String> audioUrls = resolveAudioUrls(audioKeys);
return QuizResponse.of(savedQuiz, audioUrls);
}
왜 Quiz를 먼저 저장하는가?
S3 파일명에 quizId를 포함하기 위해서. quiz_1_0.mp3, quiz_1_1.mp3 형식으로 저장하면 어떤 퀴즈의 몇 번째 문항인지 한눈에 보인다.
S3 업로드와 부분 실패 처리
private List<String> uploadQuizAudios(Long quizId, List<String> audioData) {
List<String> keys = new ArrayList<>();
for (int i = 0; i < audioData.size(); i++) {
String base64 = audioData.get(i);
if (base64 == null || base64.isBlank()) {
keys.add(""); // FastAPI에서 TTS 실패한 항목
continue;
}
byte[] audioBytes = Base64.getDecoder().decode(base64);
String fileName = String.format("quiz_%d_%d.mp3", quizId, i);
s3AudioStorageService.uploadAudio(audioBytes, fileName);
keys.add(fileName);
}
return keys;
}
private List<String> resolveAudioUrls(List<String> audioKeys) {
return audioKeys.stream()
.map(key -> key.isBlank() ? "" : s3AudioStorageService.getPresignedUrl("bookAudios", key))
.toList();
}
FastAPI에서 TTS가 실패한 항목은 ""(빈 문자열) base64로 온다. 그대로 Spring Boot에서도 빈 키/빈 URL로 두어 클라이언트가 텍스트만 표시할 수 있도록 일관성을 유지했다.
채점 + 도토리 보상 트랜잭션
public QuizAnalyzeResponse forwardQuizAudio(
Long memberId, Long quizId, MultipartFile audioFile, String originalQuiz) {
audioFileValidator.validate(audioFile);
Member member = memberService.findById(memberId);
MemberStat stat = memberStatRepository.findByMember(member)
.orElseThrow(() -> new CustomException(ErrorCode.STAT_NOT_FOUND));
Quiz quiz = quizRepository.findById(quizId)
.orElseThrow(() -> new CustomException(ErrorCode.QUIZ_NOT_FOUND));
if (!Objects.equals(quiz.getBook().getMember().getId(), member.getId())) {
throw new CustomException(ErrorCode.BOOK_ACCESS_DENIED);
}
// FastAPI 채점
FastApiAnalyzeQuizResponse fastApiResponse = fastApiQuizClient.analyzeQuiz(audioFile, originalQuiz);
boolean isCorrect = fastApiResponse.isCorrect();
boolean rewarded = false;
if (isCorrect) {
quiz.incrementCorrect();
}
// 4문항 모두 맞혔고 + 아직 보상 안 받았다면
if (quiz.isAllCorrect() && quiz.isRewardable()) {
quiz.markAsRewarded();
quiz.getBook().addAcorn(); // 책에 도토리 추가
member.earnAcorn(); // 회원 보유 도토리 +1
stat.addAcquiredAcorn(1); // 통계에 +1
badgeService.checkAndGrantBadge(memberId, BadgeCategory.ACORN); // 뱃지 검사
rewarded = true;
}
return new QuizAnalyzeResponse(isCorrect, rewarded, member.getAcorn());
}
처음에는 addAcquiredAcorn(1)보다 checkAndGrantBadge를 먼저 호출해서 뱃지 조건 판정에 증가 전 값이 쓰이는 버그가 있었다(8.3절 참조).
FastAPI 클라이언트 (WebClient)
JSON 호출용과 Multipart 업로드용 WebClient를 분리했다. Multipart는 STT가 들어가 응답 시간이 길어서 timeout이 길어야 하기 때문이다.
// src/main/java/ctrlS/totori/global/config/WebClientConfig.java
@Bean
public WebClient fastApiWebClient(WebClient.Builder builder,
@Value("${fastapi.base-url}") String fastApiBaseUrl) {
return builder
.baseUrl(fastApiBaseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
@Bean
public WebClient fastApiSttWebClient(WebClient.Builder builder,
@Value("${fastapi.base-url}") String fastapiBaseUrl) {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000)
.responseTimeout(Duration.ofSeconds(300)); // STT는 오래 걸림
return builder.baseUrl(fastapiBaseUrl)
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
// src/main/java/ctrlS/totori/quiz/client/FastApiQuizClient.java
@Component
@RequiredArgsConstructor
public class FastApiQuizClient {
private final WebClient fastApiWebClient;
private final WebClient fastApiSttWebClient;
public FastApiGenerateQuizResponse generateQuiz(FastApiGenerateQuizRequest request) {
return fastApiWebClient.post()
.uri("/ai/quiz/generate")
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::isError, clientResponse ->
Mono.error(new CustomException(ErrorCode.QUIZ_GENERATE_FAILED)))
.bodyToMono(FastApiGenerateQuizResponse.class)
.block(); // 204면 null 반환
}
public FastApiAnalyzeQuizResponse analyzeQuiz(MultipartFile audioFile, String originalQuiz) {
MultipartBodyBuilder builder = buildQuizMultipartBody(audioFile, originalQuiz);
return fastApiSttWebClient.post()
.uri("/ai/quiz/analyze")
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(builder.build()))
.retrieve()
.onStatus(HttpStatusCode::isError, clientResponse ->
Mono.error(new CustomException(ErrorCode.QUIZ_ANALYZE_FAILED)))
.bodyToMono(FastApiAnalyzeQuizResponse.class)
.block();
}
}
6. 실행 방법
환경 요구사항
| Java | 17 |
| Spring Boot | 3.x |
| Python | 3.10+ |
| MySQL | 8.x |
| Redis | 7.x |
| MeCab (ko-dic) | 설치 필요 |
| Whisper | medium 모델 |
API 호출 예시
퀴즈 생성
curl -X POST 'http://localhost:8080/api/quiz/generate?bookId=1' \
-H 'Authorization: Bearer <JWT>'
응답 (200 OK)
{
"status": 200,
"message": "OK",
"data": {
"quizId": 42,
"quizItems": ["닭", "약", "박", "책"],
"audioUrls": [
"https://s3.../quiz_42_0.mp3?...",
"https://s3.../quiz_42_1.mp3?...",
"https://s3.../quiz_42_2.mp3?...",
"https://s3.../quiz_42_3.mp3?..."
]
}
}
오류가 없는 경우 (204 No Content)
{
"status": 204,
"message": "No Content",
"data": null
}
퀴즈 채점
curl -X POST 'http://localhost:8080/api/quiz/42/check' \
-H 'Authorization: Bearer <JWT>' \
-F 'audio=@answer.m4a' \
-F 'original_quiz=닭'
응답
{
"status": 200,
"data": {
"isCorrect": true,
"rewarded": false,
"currentAcorn": 12
}
}
7. 실제 동작 결과
시나리오 1: 음운 퀴즈 (L2 아동)
상황: 아이가 "강아지가 닭을 보고 짖었어요"를 "강아지가 다을 보고 짖었어요"라고 읽음 (받침 ㄹ 탈락)
- Redis에 누적된 오류 (낭독 분석 결과)
[
{"type": "phoneme", "pattern": "ㄹ 탈락", "word": "닭"},
{"type": "phoneme", "pattern": "ㄹ 탈락", "word": "물"},
{"type": "phoneme", "pattern": "ㄴ 첨가", "word": "비"}
]
Top 패턴 추출: "ㄹ 탈락" (2회), 단어 "닭"
- GPT 응답 (생성된 퀴즈)
{
"words": ["닭", "물", "달", "별"]
}
시나리오 2: 조사 퀴즈 (L5 아동)
상황: 아이가 "나비가 꽃을 봐요"를 "나비 꽃을 봐요"로 읽음 (조사 "가" 빠뜨림)
- Redis에 누적된 오류
[
{"type": "josa", "kind": "DELETION", "stem": "나비", "target_josa": "가", "stt_josa": null},
{"type": "josa", "kind": "DELETION", "stem": "꽃", "target_josa": "이", "stt_josa": null}
]
Top 이벤트: DELETION, target_josa="가"
- GPT 응답
{
"sentences": [
"나비가 난다.",
"강아지가 짖는다.",
"별이 빛난다.",
"토끼가 뛴다."
]
}
정답률 (정성 평가)
자체 테스트 데이터 30케이스에 대한 평가:
| 의도한 오류 패턴이 들어간 단어 생성 | 28/30 (93.3%) |
| 첫 항목이 원래 단어와 일치 | 30/30 (100%) |
| 4개 모두 서로 다른 단어 | 30/30 (100%) |
| 친숙도 (초/중등 수준) | 27/30 (90.0%) |
_dedupe 후처리 덕분에 중복은 100% 막을 수 있었다.
8. 구현 중 만난 문제
GPT가 첫 단어를 자꾸 바꿔버린 문제
문제: "첫 번째 단어는 반드시 아이가 틀린 원래 단어"라고 프롬프트에 적었지만, GPT가 종종 두 번째나 세 번째 위치에 두는 경우가 있었다.
해결: 프롬프트만 믿지 않고 응답 후처리에서 직접 정렬.
def _dedupe(self, items: list[str], first: str) -> list[str]:
seen, result = set(), []
for item in [first] + [x for x in items if x != first]:
if item not in seen:
seen.add(item)
result.append(item)
return result[:4]
LLM 출력에 대한 제약은 프롬프트만으로는 부족하다. 응용 단에서 검증/정정 로직을 두는 것이 안전하다.
Quiz 빌더에서 member null 저장 버그
문제: Quiz의 빌더 생성자에 member 파라미터를 받았는데 this.member = member 할당을 빠뜨려서 항상 member가 null로 저장됐다.
해결: 빌더 생성자에 this.member = member 추가하고 정적 팩토리 메서드에서도 전달.
// 잘못된 코드
@Builder
public Quiz(Book book, Member member, ...) {
this.book = book;
// this.member 누락!
}
Lombok @Builder도 만능이 아니다. 생성자 본문은 직접 확인해야 한다.
보상 트랜잭션의 순서 버그
문제: 도토리 4개째 획득 시 "도토리 5개 모으기" 뱃지가 즉시 지급되어야 하는데, checkAndGrantBadge가 먼저 호출되어 증가 전 값(3)으로 뱃지 조건이 판정됐다.
해결: 증가 → 뱃지 검사 순서로 변경.
// 수정 전
badgeService.checkAndGrantBadge(memberId, BadgeCategory.ACORN); // 3으로 판정 ❌
stat.addAcquiredAcorn(1);
// 수정 후
stat.addAcquiredAcorn(1);
badgeService.checkAndGrantBadge(memberId, BadgeCategory.ACORN); // 4로 판정 ✅
트랜잭션 안에서도 메서드 호출 순서가 곧 시점 순서다.
모든 에러가 STT_TRANSCRIBE_FAILED로 떨어지던 문제
문제: FastAPI에서 어떤 에러가 나든 Spring Boot가 항상 STT_TRANSCRIBE_FAILED(502)로 응답해서 디버깅이 너무 힘들었다.
// 잘못된 코드 - 모든 에러를 같은 코드로 묶음
.onStatus(HttpStatusCode::isError, clientResponse ->
Mono.error(new CustomException(ErrorCode.STT_TRANSCRIBE_FAILED)))
해결: 기능별 에러 코드 분리.
// quiz 클라이언트
.onStatus(HttpStatusCode::isError, clientResponse ->
Mono.error(new CustomException(ErrorCode.QUIZ_GENERATE_FAILED)))
// reading 클라이언트
.onStatus(HttpStatusCode::isError, clientResponse ->
Mono.error(new CustomException(ErrorCode.STT_TRANSCRIBE_FAILED)))
// ErrorCode.java
QUIZ_GENERATE_FAILED(502, "퀴즈 생성 서버 요청에 실패했습니다."),
QUIZ_ANALYZE_FAILED(502, "퀴즈 분석 서버 요청에 실패했습니다.");
에러 코드는 기능별로 명확히 분리해야 운영 시 원인 파악이 가능하다.
NPE: FastAPI 204를 처리하지 않은 버그
문제: "오류 없음"을 204로 반환하도록 바꾼 후, Spring Boot의 WebClient.block()이 null을 반환하는데 그대로 Quiz.of()에 넘겨버려 NPE 발생.
Cannot invoke "FastApiGenerateQuizResponse.quizItems()" because "response" is null
해결: FastAPI 호출 직후 null 체크 추가.
FastApiGenerateQuizResponse fastApiResponse = fastApiQuizClient.generateQuiz(request);
if (fastApiResponse == null) {
return null; // 컨트롤러에서 BaseResponse.noContent() 처리
}
Quiz quiz = Quiz.of(book, member, fastApiResponse.quizItems(), new ArrayList<>());
HTTP 상태별 동작을 클라이언트 레이어에서 명시적으로 다뤄야 한다.
quizRepository.save() 전에 ID 참조
문제: S3에 업로드할 파일명에 quizId를 넣어야 하는데, save() 호출 전에 quiz.getId()를 참조해서 항상 null이 됐다.
해결: save() 반환값을 사용.
// 수정 전
quizRepository.save(quiz);
Long quizId = quiz.getId(); // null 위험
// 수정 후
Quiz savedQuiz = quizRepository.save(quiz);
Long quizId = savedQuiz.getId(); // 안전
Redis에 데이터가 없어 404가 나는 문제
문제: 처음 개발할 때 낭독 분석을 먼저 안 돌리고 퀴즈 생성을 호출하면 Redis에 데이터가 없어 FastAPI에서 404가 나고, Spring Boot는 그걸 그대로 502로 던졌다.
해결: "오류 없음"은 정상 케이스이므로 404가 아닌 204로 변경.
# FastAPI quiz_router.py
except ValueError: # 오류 패턴이 없는 경우
return Response(status_code=204)
"데이터 없음"은 비즈니스 관점에서 종종 정상 케이스다. HTTP 코드를 잘 선택하면 클라이언트 코드가 깔끔해진다.
9. 회고와 배운 점
잘 한 점
- 분석/생성의 분리 설계 (Redis 도입)
- 처음에는 한 엔드포인트에서 다 하려고 했지만, 분리한 덕분에 동화 페이지가 늘어도 자연스럽게 누적되고, 원문 텍스트도 즉시 폐기할 수 있게 되었다.
- LLM 출력 신뢰하지 않기
- 프롬프트만으로는 100% 제어할 수 없다는 것을 인정하고, 후처리 단계에서 dedupe/정렬을 넣어 안정성을 확보했다.
- 부분 실패 허용 설계
- TTS가 1개 실패해도 나머지는 정상 동작하도록 한 것이 사용자 경험에 큰 차이를 만들었다.
아쉬운 점과 개선 과제
- GPT 응답 캐싱 부재
- 같은 오류 패턴(예: "ㄹ 탈락"+"닭")이 반복되는 경우 매번 GPT를 호출합니다. 패턴-단어 쌍을 키로 Redis에 캐싱하면 비용/지연 둘 다 절감 가능하다.
- TTS 사전 캐싱
- 자주 등장하는 단어("닭", "물", "별" 등)의 음성은 매번 ElevenLabs를 호출할 필요 없이 미리 S3에 캐싱해두면 좋을 것 같다.
- 테스트 자동화
- 현재는 GPT 응답 품질을 수동으로 평가했는데, 자체 평가 셋을 구축하고 회귀 테스트하는 시스템이 필요하다.
프로젝트 전체에서 배운 점
- LLM은 도구다. 단순히 호출하는 것이 아니라, 입력 데이터 설계 → 프롬프트 → 후처리 검증까지 전 과정을 책임지는 것이 엔지니어의 역할이다.
- 상태 관리를 어디에 둘지가 곧 아키텍처다. Redis(휘발성 분석 데이터), MySQL(영구 학습 기록), S3(음성 파일) — 각 데이터의 수명과 접근 패턴에 따라 저장소를 분리하는 것이 중요했다.
- 에러 코드는 곧 운영 도구다. 디버깅이 어려운 시스템은 결국 운영도 어려워진다. 처음부터 기능별로 명확한 에러 코드를 두는 것이 비용이 적게 든다.