TIL

[Spring Boot/React] AI 맞춤형 오답노트 기능 구현 및 트러블슈팅 🚀

ohs020105 2026. 3. 19. 01:48

이번 포스팅에서는 현재 진행 중인 'Study-Flow AI (AI 기반 학습 보조 서비스)' 프로젝트에 맞춤형 오답노트 기능을 추가하면서 겪은 트러블슈팅 과정과 기능 고도화(다시 풀기 및 상태 업데이트) 경험을 공유하고자 합니다.

프론트엔드(React)와 백엔드(Spring Boot)를 오가며 데이터의 생명주기와 인증 처리를 어떻게 맞췄는지 정리해 보았습니다.


💡 1. 어떤 기능을 만들었나요?

AI가 이미지를 분석해 만들어준 퀴즈를 풀고 나면, 틀린 문제들만 따로 모아서 볼 수 있는 '오답노트' 페이지를 기획했습니다. 단순히 틀린 목록만 보여주는 것을 넘어, 아래의 목표를 가지고 개발을 진행했습니다.

  1. 직관적인 UI: 내가 적은 오답과 실제 정답을 한눈에 비교할 수 있는 카드형 UI.
  2. 다시 풀기 기능: 틀렸던 문제를 모달창에서 즉시 다시 풀어볼 수 있는 기능.
  3. 성취감 부여: 다시 풀어서 정답을 맞추면, 서버에서 해당 오답 기록을 지우고 화면에서도 즉각적으로 카드를 삭제(Optimistic UI).

🚨 2. 트러블슈팅 (Troubleshooting)

호기롭게 프론트엔드와 백엔드 코드를 작성하고 테스트를 진행했는데, 두 가지 큰 에러를 마주했습니다.

💥 Issue 1: 퀴즈 제출 시 500 에러 (quizId undefined 문제)

[상황] React에서 퀴즈 정답을 제출할 때 POST /api/ai/quiz/undefined/submit 이라는 URL로 요청이 가면서 500 Internal Server Error가 발생했습니다.

[원인 파악] AI 서버(Python)가 퀴즈 데이터를 생성해 Spring Boot로 넘겨줄 때는 아직 DB에 저장되기 전이라 식별자(PK)가 존재하지 않습니다. Spring Boot에서 이 데이터를 MySQL에 저장(save)하면 Auto-increment로 quizId가 생성되는데, 이 생성된 ID를 프론트엔드로 반환하는 DTO에 담아주지 않고 초기 상태의 데이터를 그대로 리턴하고 있었습니다.

[해결 과정] 백엔드의 DTO와 서비스 로직을 수정하여, DB 저장 직후 생성된 ID를 추출해 객체를 재조립하도록 변경했습니다.

 

// AiDatabaseService.java
@Transactional
public AiResponseDto saveAnalysisResult(User user, String prompt, AiResponseDto response) {
    // ... (히스토리 저장 로직) ...

    if (response.hasQuiz()) {
        AiQuizDto originalQuizDto = response.quizDto();
        
        // 1. 퀴즈 엔티티 생성 및 DB 저장
        Quiz quiz = Quiz.builder()
                // ... 필드 세팅
                .build();
        Quiz savedQuiz = quizRepository.save(quiz); // 💡 여기서 자동 생성된 ID 획득!

        // 2. 획득한 ID를 포함하여 DTO 재조립
        AiQuizDto updatedQuizDto = new AiQuizDto(
                savedQuiz.getId(), // 생성된 ID 삽입
                originalQuizDto.question(),
                originalQuizDto.options(),
                originalQuizDto.answer()
        );

        // 3. 재조립된 퀴즈 DTO를 담은 새로운 응답 객체 반환
        return new AiResponseDto(..., updatedQuizDto, ...);
    }
    return response;
}

 

결과적으로 프론트엔드에서 정확한 quizId를 상태로 관리할 수 있게 되어 제출 로직이 정상 작동했습니다. 데이터의 생명주기(Lifecycle) 동기화가 얼마나 중요한지 깨달은 순간이었습니다.

💥 Issue 2: 오답노트 조회 시 403 Forbidden 에러

[상황] 오답노트 페이지에 진입했는데 목록을 불러오지 못하고 403 에러가 발생했습니다.

[원인 파악] Spring Security로 /api/ai/** 경로는 인증된 사용자만 접근하도록 막아두었습니다. 그런데 프론트엔드에서 데이터를 요청할 때 커스텀된 API 클라이언트 대신 기본 axios 객체를 사용하는 바람에 HTTP Header에 JWT(Bearer Token)가 누락되었습니다.

[해결 과정] 매 요청마다 localStorage의 토큰을 헤더에 자동 주입해주는 커스텀 apiClient 인스턴스로 통신 모듈을 교체하여 깔끔하게 해결했습니다. (추가로 엔드포인트 오타였던 /nots/를 /notes/로 수정하는 디버깅도 거쳤습니다 😅)


✨ 3. 기능 고도화: '다시 풀기'와 Optimistic UI

오답노트의 핵심인 '다시 풀어서 맞추면 목록에서 삭제하기' 기능을 추가했습니다.

  1. 백엔드 (Spring Boot) QuizResultRepository에 deleteByUserIdAndQuizId 쿼리 메서드를 추가하고, 컨트롤러에 DELETE API를 열어주었습니다.
  2. 프론트엔드 (React) 사용자가 정답을 맞춘 즉시 서버에 삭제 요청을 보내고, **서버의 응답을 기다리거나 새로고침을 하지 않고 곧바로 React State(setWrongNotes)를 조작해 화면에서 카드를 지우는 Optimistic UI Update(낙관적 업데이트)**를 적용했습니다.
// WrongNotePage.js 내부 '다시 풀기' 제출 핸들러
const handleRetrySubmit = async (opt) => {
    if (isRetryCorrect !== null) return; 
    
    setRetryAnswer(opt);
    const isCorrect = opt === retryQuiz.correctAnswer;
    setIsRetryCorrect(isCorrect);

    // 💡 정답을 맞췄을 경우: 오답 기록 삭제 요청 및 상태 즉시 업데이트
    if (isCorrect) {
        try {
            await apiClient.delete(`/api/ai/notes/wrong/${retryQuiz.quizId}`);
            // 리스트에서 방금 맞춘 퀴즈를 즉시 필터링하여 화면에서 제거
            setWrongNotes(prevNotes => prevNotes.filter(note => note.quizId !== retryQuiz.quizId));
        } catch (error) {
            console.error("오답노트 삭제 실패:", error);
        }
    }
};

이로 인해 사용자 입장에서는 네트워크 지연(Latency) 없이 쾌적하고 즉각적인 피드백을 받을 수 있어 UX가 크게 향상되었습니다.


🎯 마무리

이번 작업을 통해 프론트엔드 단독, 혹은 백엔드 단독으로만 생각해서는 안 되며, "데이터가 어디서 생성되어 어떻게 흘러가고 화면에 렌더링되는가" 하는 풀스택 관점의 파이프라인 설계가 필수적임을 배웠습니다.

앞으로도 사용자 경험(UX)을 고려한 기능 고도화와 안정적인 서버 구축을 위해 꾸준히 리팩토링해 나갈 예정입니다! 🔥

'TIL' 카테고리의 다른 글

유지보수성을 높이는 백엔드 리팩토링 (FastAPI & Spring Boot)  (0) 2026.03.10
EC2 (nest.js) 서버 배포 2  (0) 2025.04.17
ec2 서버 배포 (nest.js)  (0) 2025.04.17
위치 기반 흐름 정리  (0) 2025.04.13
80일차 TIL  (0) 2025.03.07