프로젝트 목록

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 설정은 팀원 분담.


서비스 화면

서비스화면 펼쳐보기
메인 · 로그인로비 · 방 목록퀴즈 진행정답 공개 · 해설최종 결과 · 순위

기술 스택

영역기술
BackendJava 21 · Spring Boot 3.4 · Spring Security · JPA
AIOpenAI GPT-3.5-turbo (JDK HttpClient · ObjectMapper)
Cache / Pub-SubRedis (RedisTemplate) — List · Set · String · Pub/Sub
실시간WebSocket · STOMP · SockJS
DatabaseMySQL 8 (prod) · H2 (dev)
문서화 / 테스트springdoc-openapi · JUnit 5

GPT 호출은 별도 SDK 없이 JDK 내장 HttpClient + ObjectMapper 만 사용 — 의존성 추가 없이 API 키만 환경변수로 주입.


전체 시스템 아키텍처

Quizzle 시스템 아키텍처 — Quiz 도메인 (GPT 생성 + Redis 저장/조회)

핵심 포인트:

  • 진행 중 퀴즈 상태는 모두 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.

  • 클라이언트로 송신되는 QuizResponse DTO 에는 정답 필드 자체가 없음
  • 채점은 RedisQuizSubmissionService 가 Redis 인덱스(opsForList().index(answerKey, n - 1)) 로 단건 조회
  • 정답을 응답에 마스킹해서 내려보내는 안도 검토했으나, 캐시 노출 / 디코딩 위험으로 폐기

→ 클라이언트는 quizId · 문제 본문만 알고, 정답 비교는 100% 서버에서.

4. 답안 제출 — 진행 차수 + 중복 + 멤버십 3중 검증

RedisQuizSubmissionService.submitAnswer() — 클라이언트가 보내는 모든 값은 검증 대상.

  1. 진행 차수 검증quiz:{quizId}:currentRound (서버 보유) 와 클라이언트 questionNumber 비교, 진행 중 문제만 허용 (미래 문제 번호로 미리 답 제출 차단)
  2. 중복 제출 검증 (List):submissionsquestionNumber: 프리픽스로 스캔
  3. 멱등성 보장 (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() — 단일 트랜잭션 안에서:

  1. 회원 EXP 누적
  2. 레벨 식 EXP / 100 으로 신 레벨 산정
  3. 레벨업한 경우에만 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중 가드.

  1. currentRound (서버 보유) 와 클라이언트 questionNumber 비교 — 일치 시만 진행
  2. :submissions List 를 questionNumber: 프리픽스로 스캔, 이미 있으면 거부
  3. :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) 시나리오 가정 하의 호출 횟수 카운트로 한정합니다.

항목BeforeAfter
정답 조회 비용 (제출 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인 팀 프로젝트에서 백엔드 도메인 간 통신 규약(메시지 포맷·이벤트 이름)을 코드보다 먼저 합의했더니 충돌이 줄었다. 코드보다 합의가 먼저.