이번 포스팅에서는 현재 진행 중인 'Study-Flow AI (AI 기반 학습 보조 서비스)' 프로젝트에 맞춤형 오답노트 기능을 추가하면서 겪은 트러블슈팅 과정과 기능 고도화(다시 풀기 및 상태 업데이트) 경험을 공유하고자 합니다.
프론트엔드(React)와 백엔드(Spring Boot)를 오가며 데이터의 생명주기와 인증 처리를 어떻게 맞췄는지 정리해 보았습니다.
💡 1. 어떤 기능을 만들었나요?
AI가 이미지를 분석해 만들어준 퀴즈를 풀고 나면, 틀린 문제들만 따로 모아서 볼 수 있는 '오답노트' 페이지를 기획했습니다. 단순히 틀린 목록만 보여주는 것을 넘어, 아래의 목표를 가지고 개발을 진행했습니다.
- 직관적인 UI: 내가 적은 오답과 실제 정답을 한눈에 비교할 수 있는 카드형 UI.
- 다시 풀기 기능: 틀렸던 문제를 모달창에서 즉시 다시 풀어볼 수 있는 기능.
- 성취감 부여: 다시 풀어서 정답을 맞추면, 서버에서 해당 오답 기록을 지우고 화면에서도 즉각적으로 카드를 삭제(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
오답노트의 핵심인 '다시 풀어서 맞추면 목록에서 삭제하기' 기능을 추가했습니다.
- 백엔드 (Spring Boot) QuizResultRepository에 deleteByUserIdAndQuizId 쿼리 메서드를 추가하고, 컨트롤러에 DELETE API를 열어주었습니다.
- 프론트엔드 (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 |