[토토리] 음운/조사 오류 기반 AI 퀴즈 생성 구현

프로젝트명: 토토리(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. 회고와 배운 점


잘 한 점

  1. 분석/생성의 분리 설계 (Redis 도입)
    • 처음에는 한 엔드포인트에서 다 하려고 했지만, 분리한 덕분에 동화 페이지가 늘어도 자연스럽게 누적되고, 원문 텍스트도 즉시 폐기할 수 있게 되었다.
  2. LLM 출력 신뢰하지 않기
    • 프롬프트만으로는 100% 제어할 수 없다는 것을 인정하고, 후처리 단계에서 dedupe/정렬을 넣어 안정성을 확보했다.
  3. 부분 실패 허용 설계
    • TTS가 1개 실패해도 나머지는 정상 동작하도록 한 것이 사용자 경험에 큰 차이를 만들었다.

 

 

아쉬운 점과 개선 과제

  1. GPT 응답 캐싱 부재
    • 같은 오류 패턴(예: "ㄹ 탈락"+"닭")이 반복되는 경우 매번 GPT를 호출합니다. 패턴-단어 쌍을 키로 Redis에 캐싱하면 비용/지연 둘 다 절감 가능하다.
  2. TTS 사전 캐싱
    • 자주 등장하는 단어("닭", "물", "별" 등)의 음성은 매번 ElevenLabs를 호출할 필요 없이 미리 S3에 캐싱해두면 좋을 것 같다.
  3. 테스트 자동화
    • 현재는 GPT 응답 품질을 수동으로 평가했는데, 자체 평가 셋을 구축하고 회귀 테스트하는 시스템이 필요하다.

 

 

프로젝트 전체에서 배운 점

  • LLM은 도구다. 단순히 호출하는 것이 아니라, 입력 데이터 설계 → 프롬프트 → 후처리 검증까지 전 과정을 책임지는 것이 엔지니어의 역할이다.
  • 상태 관리를 어디에 둘지가 곧 아키텍처다. Redis(휘발성 분석 데이터), MySQL(영구 학습 기록), S3(음성 파일) — 각 데이터의 수명과 접근 패턴에 따라 저장소를 분리하는 것이 중요했다.
  • 에러 코드는 곧 운영 도구다. 디버깅이 어려운 시스템은 결국 운영도 어려워진다. 처음부터 기능별로 명확한 에러 코드를 두는 것이 비용이 적게 든다.