Project
Quizzle
GPT로 자동 생성한 퀴즈를 같은 방의 여러 사용자가 실시간으로 풀고 순위를 겨루는 학습 게임. Quiz 도메인 백엔드 담당.
- 기간
- 2025.03 ~ 2025.04
- 역할
- 5인 팀 / Quiz 도메인 백엔드 — GPT 연동 · Redis 채점 · 결과 집계 · EXP 연동
- 기술 스택
- Spring Boot 3.4Java 21Spring SecurityOAuth2JWTWebSocket (STOMP)RedisMySQLOpenAI GPT-3.5AWS EC2DockerGitHub Actions
주요 기능
- GPT-3.5로 카테고리·난이도별 퀴즈 자동 생성 → Redis 캐싱
- 정답을 응답 DTO에서 제외, 서버 Redis 키에만 보관
- List + Set 이중 자료구조로 채점/멤버십 모두 O(1)~O(N)
- 모두 제출 완료 시점에만 Pub/Sub으로 진행 이벤트 발행 — 폴링 0
프로젝트 개요
GPT API 로 매 게임마다 카테고리·난이도에 맞는 퀴즈를 즉석에서 생성하고, 같은 방에 모인 여러 사용자가 동시에 풀어 순위를 겨루는 실시간 멀티플레이 학습 게임. Quiz 도메인 백엔드(생성·저장·채점·결과 집계) + 점수→EXP 연동 흐름을 1인 담당으로 맡았습니다.
| 기간 | 2025.03 ~ 2025.04 (약 4주) |
| 역할 | 5인 팀 / Quiz 도메인 백엔드 — GPT 연동 · Redis 채점 · 결과 집계 · EXP 연동 |
본인 범위 — domain/quiz/** 전체 + RedisQuizSubmissionService (답안 제출 처리) + MemberExpService (점수→EXP 연동) 신설.
방·WebSocket 인프라·OAuth·Pub/Sub broker 설정은 팀원 분담.
서비스 화면
서비스화면 펼쳐보기





기술 스택
| 영역 | 기술 |
|---|---|
| Backend | Java 21 · Spring Boot 3.4 · Spring Security · JPA |
| AI | OpenAI GPT-3.5-turbo (JDK HttpClient · ObjectMapper) |
| Cache / Pub-Sub | Redis (RedisTemplate) — List · Set · String · Pub/Sub |
| 실시간 | WebSocket · STOMP · SockJS |
| Database | MySQL 8 (prod) · H2 (dev) |
| 문서화 / 테스트 | springdoc-openapi · JUnit 5 |
GPT 호출은 별도 SDK 없이 JDK 내장
HttpClient+ObjectMapper만 사용 — 의존성 추가 없이 API 키만 환경변수로 주입.
전체 시스템 아키텍처
핵심 포인트:
- 진행 중 퀴즈 상태는 모두 Redis — 매 메시지·매 제출마다 RDB 트랜잭션이 열리지 않게
- 정답은 서버 측 Redis 키에만 보관 — 응답 DTO 에서 정답 필드 제외, 개발자 도구 우회 차단
- List + Set 이중 자료구조 — 채점 순회는 List(O(N)), 중복 / 멤버십은 Set(O(1))
- 모두 제출 완료 판정 시점에만 Pub/Sub 으로
nextQuestion/quizEnd이벤트 — 클라이언트 폴링 0
구현 내용
Quiz 도메인 (Spring Boot · Redis)
1. GPT 호출 — JDK HttpClient 직접 구현 (SDK 의존성 0)
OpenAIClient.sendRequest() — java.net.http.HttpClient + ObjectMapper 로 직접 호출.
system/user메시지 2개 구성,temperature=0.7로 다양성 확보- 비정상 응답(HTTP 5xx · 빈 본문)은
INTERNAL_SERVER_ERROR로 통일해 상위 호출자에 전파 openai-java/spring-ai검토했으나 본 프로젝트 시점엔 안정 버전이 부족 — 표준 라이브러리로 가니 Spring Boot 버전 호환 이슈 없음- 응답 형식이 바뀌어도 파서만 손보면 됨
→ 의존성 추가 0개, API 키 1줄(OPENAI_API_KEY 환경변수)로 동작.
2. 프롬프트 빌드 + 응답 파싱 — 관대한 파서 / 엄격한 프롬프트
QuizPromptBuilder.buildPrompt() — MainCategory · SubCategory · AnswerType · 문제 수 · Difficulty 를 한국어 전용 프롬프트로 변환.
AnswerType 에 따라 객관식(a~d) · OX 보기 텍스트를 동적으로 생성.
QuizResponseParser.parse() —
QUESTION_PATTERN:^(?:Question\s+|문제\s*)?(\d+)\.한·영 헤더 모두 허용ANSWER_PATTERN:(?i)(?:정답|answer)[::]\s*([A-DOX])대소문자 / 전각콜론까지 흡수- 결과는
LinkedHashMap으로 순서 보존
→ LLM 응답은 결정적이지 않으니 파서는 관대하게, 프롬프트는 엄격하게 ("절대로 영어 사용 금지", "인삿말 없이").
3. 정답은 서버 Redis 키에만 — 클라이언트 노출 차단
RedisQuizAnswerService.saveQuiz() — quiz:{quizId}:questions / quiz:{quizId}:answers 두 키로 분리 저장, 30분 TTL.
- 클라이언트로 송신되는
QuizResponseDTO 에는 정답 필드 자체가 없음 - 채점은
RedisQuizSubmissionService가 Redis 인덱스(opsForList().index(answerKey, n - 1)) 로 단건 조회 - 정답을 응답에 마스킹해서 내려보내는 안도 검토했으나, 캐시 노출 / 디코딩 위험으로 폐기
→ 클라이언트는 quizId · 문제 본문만 알고, 정답 비교는 100% 서버에서.
4. 답안 제출 — 진행 차수 + 중복 + 멤버십 3중 검증
RedisQuizSubmissionService.submitAnswer() — 클라이언트가 보내는 모든 값은 검증 대상.
- 진행 차수 검증 —
quiz:{quizId}:currentRound(서버 보유) 와 클라이언트 questionNumber 비교, 진행 중 문제만 허용 (미래 문제 번호로 미리 답 제출 차단) - 중복 제출 검증 (List) —
:submissions를questionNumber:프리픽스로 스캔 - 멱등성 보장 (Set) —
:submitted:{n}멤버십으로 한 번 더 확인
→ SSOT 는 항상 서버. 클라이언트가 보내는 값은 검증 통과 시에만 기록.
5. List + Set 이중 자료구조 — 채점 + 멤버십 둘 다 O(1)~O(N)
같은 제출 이벤트를 두 자료구조에 동시 기록.
:submissions(List) —questionId:answer:status문자열을 순서대로 push. 채점은 회원별 제출 순서대로 순회.:submitted:{n}(Set) — 문제별 제출자 ID 멤버. 중복 검사 · "모두 제출 완료" 판정용.
판정 비교:
| 연산 | List만 | List + Set |
|---|---|---|
| 중복 제출 검사 | 전체 스캔 O(N) | 멤버십 O(1) |
| "모두 제출 완료" 판정 | 참가자 수만큼 List 조회 O(P) | opsForSet().size() 1회 |
| 채점 순회 | 순서대로 O(N) | 동일 |
→ submitAnswer 한 호출이 검증·기록·진행 알림까지 모두 O(1)~O(P) 로 끝.
6. 모두 제출 완료 → Pub/Sub 으로 진행 이벤트 발행 (폴링 0)
RedisQuizSubmissionService 가 모두 제출 완료 조건 만족 순간에만 quiz:{quizId}:notifications 채널에 1회 발행.
event = (questionNumber == totalQuestions.intValue()) ? "quizEnd" : "nextQuestion";
// totalQuestions = redisTemplate.opsForList().size(answerListKey)- 총 문제 수를 서버 상태(저장된 정답 수)에서 동적 파생 — 클라이언트 / 상수 신뢰 X
- 1초 폴링 안 가정 시 게임당 수천 요청 → Pub/Sub 으로 두 자릿수 메시지로 축소
- 다중 인스턴스 환경에서도 채널 구독자 전원에게 전파
7. 결과 집계 + 순위 + 점수→EXP 연동
QuizResultService.getQuizResults() — 정답 수 × 10점, 점수 내림차순으로 rank 부여 (동점은 그대로).
MemberExpService.updateMemberExp() — 단일 트랜잭션 안에서:
- 회원 EXP 누적
- 레벨 식
EXP / 100으로 신 레벨 산정 - 레벨업한 경우에만
PointService.applyPointPolicy(member, LEVEL_UP)로 보너스 포인트 지급
→ EXP 갱신 / 레벨업 / 포인트 지급이 한 트랜잭션 → 중간 상태 노출 0. → 부동소수점 대신 정수 연산으로 누적 오차 회피.
트러블슈팅
1. GPT 응답 포맷이 매번 달라서 문제·정답이 일부 누락되던 문제
증상. 같은 프롬프트인데 GPT 가 "문제 1.", "Question 1.", "1." 처럼 헤더 표현을 바꿔서 응답하거나, "정답:" / "Answer:" 를 섞어서 출력 → 파싱이 일부 라인을 본문으로 오인.
원인. 초기 파서는 "문제 N." 만 인식하도록 고정 매칭. 한·영 혼용, 공백 변형, 콜론 종류 차이를 흡수하지 못함.
해결. QuizResponseParser 의 정규식 두 개를 관대하게 재작성.
QUESTION_PATTERN = Pattern.compile("^(?:Question\\s+|문제\\s*)?(\\d+)\\.");
ANSWER_PATTERN = Pattern.compile("(?i)(?:정답|answer)[::]\\s*([A-DOX])");동시에 프롬프트도 더 엄격하게 — "절대로 영어 사용 금지", "인삿말 없이", "정확히 N문제, JSON-like" 명시.
남는 원칙. LLM 응답은 결정적이지 않다. 파서는 관대하게, 프롬프트는 엄격하게 가는 게 안정적.
2. 중복 제출 / 미래 문제 번호로 미리 답 보내는 부정 행위
증상. 빠른 클라이언트가 같은 답을 여러 번 보내거나, 클라이언트 코드를 우회해 questionNumber=5 가 진행 중인데 6 으로 답을 보내면 결과가 어긋남.
원인. 답안 제출에 진행 차수 / 중복 검증이 없으면 클라이언트가 보낸 값이 그대로 점수로 반영됨.
해결. RedisQuizSubmissionService.submitAnswer() 에 3중 가드.
currentRound(서버 보유) 와 클라이언트questionNumber비교 — 일치 시만 진행:submissionsList 를questionNumber:프리픽스로 스캔, 이미 있으면 거부:submitted:{n}Set 멤버십으로 한 번 더 확인 (멱등성)
남는 원칙. 클라이언트가 보내는 값은 모두 검증 대상. 상태 머신의 SSOT 는 항상 서버.
3. 마지막 문제에서도 nextQuestion 이 발행되던 진행 이벤트 일관성 문제
증상. 마지막 문제(20번)인데도 nextQuestion 이벤트가 발행돼 클라이언트가 존재하지 않는 다음 문제를 기다림.
원인. 다음 문제 진행 이벤트 발행 시 총 문제 수가 코드에 하드코딩 되어 있어서, 동적으로 끝 문제를 판정 못 함.
해결.
- 총 문제 수를
redisTemplate.opsForList().size(answerListKey)로 서버 상태에서 매번 동적 조회 event = (questionNumber == totalQuestions.intValue()) ? "quizEnd" : "nextQuestion"로 명시 분기
남는 원칙. 클라이언트 진행에 영향을 주는 상수는 서버 상태에서 파생시켜야 한다. 코드에 박지 말 것.
4. 비현실적 문제 수 (problemCount=0 또는 100) 가 GPT 호출까지 흘러가던 문제
증상. 클라이언트가 problemCount=0 또는 100 같은 값을 보내면 GPT 호출이 실패하거나 API 비용이 과다.
원인. 요청 DTO 에 검증이 없어 컨트롤러를 통과해 그대로 GPT 호출까지 전달.
해결. QuizGenerationRequest 에 @Min(1) @Max(20) 적용 — 컨트롤러 진입 단계에서 차단.
남는 원칙. 외부 API 비용이 발생하는 입력은 가장 바깥 경계에서 검증한다. GPT · STT · 결제 등 외부 호출 직전이 아니라 컨트롤러에서 끊는다.
5. quizId 가 일부 클라이언트에만 전달되던 문제
증상. 퀴즈 생성 직후 quizId 가 호출자(방장) 응답으로만 가서 나머지 참가자 클라이언트가 quizId 를 모름.
원인. 초기엔 generateQuiz() 응답을 호출자에게만 반환. 다른 참가자는 quizId 를 알 수 없는 상태로 다음 메시지 대기.
해결. GPTQuizService.generateQuiz() 가 roomId 가 있으면 /topic/room/chat/{roomId} 채널로 QuizResponse 전체를 브로드캐스트해 모든 참가자가 동시에 quizId 수신.
남는 원칙. 동시에 시작되어야 하는 멀티플레이 이벤트는 호출자 응답이 아닌 채널 브로드캐스트로 전달한다.
성과
본 프로젝트는 학습용 팀 프로젝트로 운영 환경 실측 수치는 없습니다. 아래는 (a) 자료구조의 Big-O 로 확정 가능한 비교와 (b) 시나리오 가정 하의 호출 횟수 카운트로 한정합니다.
| 항목 | Before | After |
|---|---|---|
| 정답 조회 비용 (제출 1건당) | List 전체 스캔 O(N) | opsForList().index() 단건 O(1) |
| "모두 제출 완료" 판정 비용 | 참가자별 List 조회 O(P) | Set size() O(1) |
| 한 게임당 판정 누적 호출 (20문 × 6인) | 120회 | 20회 |
| 제출 1건당 RDB 호출 (가상 RDB 안 대비) | 2~3회 | 0회 (EXP 반영 시점만) |
| 한 게임당 RDB 호출 (20문 × 6인) | 240~360회 | 6회 (EXP 갱신만) |
| 진행 동기화 트래픽 (6인 × 20문 × 25초 가정) | 1초 폴링 시 3,000 요청/게임 | Pub/Sub O(N) 메시지/게임 |
| GPT 호출 검증 | 컨트롤러 통과 후 GPT 단에서 실패 | @Min(1) @Max(20) 으로 컨트롤러 차단 |
| 중복 / 부정 제출 | 클라이언트 신뢰 | 서버 3중 가드로 차단 |
회고
- "진행 중 게임 상태는 Redis" 라는 선택의 임팩트가 가장 컸다. RDB 로 갔다면 매 메시지마다 트랜잭션이 생겨 동시 접속이 두 자릿수만 돼도 못 버텼을 것. 게임이 끝나면 영속화할 게 거의 없다는 도메인 특성이 자료구조 선택을 결정했다.
- GPT 응답을 "요청 → 응답" 한 단계 함수로 보면 안 된다. 응답 검증 · 재시도 · 프롬프트 보정까지 한 사이클로 묶여야 안정적. 파서는 관대하게, 프롬프트는 엄격하게.
- 클라이언트가 보내는 모든 값은 검증 대상이다. 상태 머신의 SSOT 는 항상 서버이고, 진행 차수 · 중복 · 멤버십을 서버에서 강제하지 않으면 그 신뢰가 그대로 점수로 반영된다.
- 동시에 시작되어야 하는 멀티플레이 이벤트는 호출자 응답이 아니라 채널 브로드캐스트여야 한다. quizId 누락 사례에서 배웠다.
- 5인 팀 프로젝트에서 백엔드 도메인 간 통신 규약(메시지 포맷·이벤트 이름)을 코드보다 먼저 합의했더니 충돌이 줄었다. 코드보다 합의가 먼저.