본문 바로가기
DEV/FE

[네이버 부스트캠프] 퍼널 적용하기

by krokerdile 2024. 11. 12.
728x90

BooQuiz 프로젝트의 React Custom Hook을 활용한 퍼널 구현하기

목차

  1. 문제 상황
  2. 구현 목표
  3. 설계 및 구현
  4. 상세 구현
  5. 사용 예시
  6. 최적화 및 개선
  7. 결론

문제 상황

실시간 퀴즈 플랫폼인 BooQuiz를 개발하면서 가장 큰 도전 과제는 퀴즈 진행 과정의 복잡한 상태 관리였습니다. 퀴즈 참여자들이 대기실에서 시작하여 문제 풀이, 결과 확인까지 이어지는 일련의 과정(퍼널)을 자연스럽게 경험할 수 있도록 만드는 것이 중요했습니다. 특히 다음과 같은 문제들이 있었습니다:

  • 퀴즈 진행 단계별로 복잡한 상태 전이가 필요했습니다.
  • 실시간 타이머와 답안 제출이 동기화되어야 했습니다.
  • 여러 컴포넌트에서 퀴즈 상태를 일관되게 관리해야 했습니다.
  • 네트워크 지연이나 오류 상황에 대한 견고한 처리가 필요했습니다.

구현 목표

이러한 문제들을 해결하기 위해 다음과 같은 목표를 설정했습니다:

핵심 요구사항

  • 대기실, 준비, 문제 풀이, 결과 확인까지 명확한 퍼널 구조 구현
  • 문제별 타이머와 답안 제출의 정확한 동기화
  • 실시간 상태 업데이트와 오류 처리
  • TypeScript를 활용한 타입 안정성 보장

주요 기능

  • 퀴즈 시작/종료의 자연스러운 전환
  • 5초 준비 시간 카운트다운
  • 30초 문제 풀이 시간 관리
  • 답안 제출 및 시간 초과 처리
  • 결과 화면으로의 자연스러운 전환

설계 및 구현

상태 구조 설계

퀴즈 퍼널의 각 단계를 명확하게 표현하기 위한 타입 구조를 설계했습니다:

type MainStage = 'LOBBY' | 'QUIZ_PROGRESS' | 'RESULT';
type QuizSubStage = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED';

interface QuizProgress {
    currentQuizIndex: number;
    totalQuizzes: number;
    timeLimit: number;
}

interface StageData {
    Lobby: {
        participants: number;
        isHost: boolean;
        quizTitle: string;
        description?: string;
    };
    quizProgress: {
        currentQuiz: {
            question: string;
            options?: string[];
            timeLimit: number;
            submissionResult?: SubmissionResult;
            type?: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER';
        };
        progress: QuizProgress;
    };
    result: {
        score: number;
        rank: number;
        totalParticipants: number;
        correctAnswers: number;
    };
}

타이머 로직 분리

재사용 가능한 타이머 커스텀 훅을 구현했습니다:

interface TimerConfig {
    initialTime: number;
    onComplete?: () => void;
    autoStart?: boolean;
}

export const useTimer = ({ initialTime, onComplete, autoStart = false }: TimerConfig) => {
    const [time, setTime] = useState<number | null>(autoStart ? initialTime : null);
    const [isRunning, setIsRunning] = useState(autoStart);

    useEffect(() => {
        if (!isRunning || time === null) return;

        const timer = setInterval(() => {
            setTime(prev => {
                if (prev === null || prev <= 1) {
                    setIsRunning(false);
                    onComplete?.();
                    return null;
                }
                return prev - 1;
            });
        }, 1000);

        return () => clearInterval(timer);
    }, [isRunning, time, onComplete]);

    const start = () => {
        setTime(initialTime);
        setIsRunning(true);
    };

    const stop = () => {
        setIsRunning(false);
        setTime(null);
    };

    return { time, isRunning, start, stop };
};

퀴즈 퍼널 흐름

BooQuiz의 퍼널 진행 과정을 시퀀스 다이어그램으로 표현했습니다:

sequenceDiagram
    participant User
    participant QuizComponent
    participant QuizManager
    participant Timer
    participant State

    User->>QuizComponent: Enter Quiz Zone
    QuizComponent->>QuizManager: Initialize
    QuizManager->>State: Set Initial State (LOBBY)

    User->>QuizComponent: Click Start Quiz
    QuizComponent->>QuizManager: startQuiz()
    QuizManager->>State: Set Stage(QUIZ_PROGRESS)
    QuizManager->>Timer: Start PrepareTimer(5s)

    Timer-->>QuizManager: PrepareTimer Complete
    QuizManager->>State: Set SubStage(IN_PROGRESS)
    QuizManager->>Timer: Start SolutionTimer(30s)

상세 구현

useQuizZoneManager Hook

퀴즈 퍼널 전체를 관리하는 핵심 커스텀 훅입니다:

export const useQuizZoneManager = (config: QuizStageConfig) => {
    // 상수 정의
    const PREPARE_TIME = 5;
    const SOLVE_TIME = 30;

    // 상태 관리
    const [mainStage, setMainStage] = useState<MainStage>('LOBBY');
    const [subStage, setSubStage] = useState<QuizSubStage>('WAITING');
    const [quizProgress, setQuizProgress] = useState<QuizProgress>({
        currentQuizIndex: 0,
        totalQuizzes: config.totalQuizzes,
        timeLimit: SOLVE_TIME,
    });
    const [stageData, setStageData] = useState<Partial<StageData>>({});
    const [isTransitioning, setIsTransitioning] = useState(false);

    // 타이머 설정
    const prepareTimer = useTimer({
        initialTime: PREPARE_TIME,
        onComplete: () => handleQuizCycle('IN_PROGRESS')
    });

    const solutionTimer = useTimer({
        initialTime: SOLVE_TIME,
        onComplete: handleTimeExpired
    });

    // 메인 로직...

핵심 기능 구현

  1. 타이머 만료 처리:

    function handleTimeExpired() {
     solutionTimer.stop();
     setIsTransitioning(true);
    
     try {
         const nextIndex = quizProgress.currentQuizIndex + 1;
         const isLastQuiz = nextIndex >= quizProgress.totalQuizzes;
    
         if (isLastQuiz) {
             setMainStage('RESULT');
             config.onQuizComplete?.();
         } else {
             setQuizProgress(prev => ({ ...prev, currentQuizIndex: nextIndex }));
             setSubStage('WAITING');
             prepareTimer.start();
         }
    
         updateStageData('quizProgress', {
             currentQuiz: {
                 ...stageData.quizProgress?.currentQuiz,
                 submissionResult: { submitted: false, timeExpired: true },
             },
         });
     } catch (err) {
         config.onError?.(err instanceof Error ? err : new Error('Unknown error'));
     } finally {
         setIsTransitioning(false);
     }
    }
  2. 퀴즈 사이클 관리:

    function handleQuizCycle(stage: QuizSubStage, data?: any) {
     setIsTransitioning(true);
     try {
         setSubStage(stage);
         if (stage === 'WAITING') {
             prepareTimer.start();
             solutionTimer.stop();
         } else if (stage === 'IN_PROGRESS') {
             prepareTimer.stop();
             solutionTimer.start();
         }
    
         if (data) {
             updateStageData('quizProgress', data);
         }
    
         config.onSubStageChange?.(stage);
    
         if (stage === 'COMPLETED') {
             solutionTimer.stop();
             proceedToNextQuiz();
         }
     } catch (err) {
         config.onError?.(err instanceof Error ? err : new Error('Unknown error'));
     } finally {
         setIsTransitioning(false);
     }
    }

사용 예시

BooQuiz 프로젝트에서 실제 사용된 컴포넌트 예시입니다:

const QuizZone: React.FC<QuizZoneProps> = ({ pinNumber }) => {
    const {
        mainStage,
        subStage,
        quizProgress,
        stageData,
        prepareTime,
        solutionTime,
        startQuiz,
        submitAnswer,
    } = useQuizZoneManager({
        totalQuizzes: 5,
        onQuizComplete: () => console.log('퀴즈 완료!'),
        onError: (error) => console.error('에러 발생:', error),
    });

    return (
        <div className="quiz-zone">
            {mainStage === 'LOBBY' && (
                <LobbyScreen
                    onStart={startQuiz}
                    participants={stageData.Lobby?.participants}
                />
            )}
            {mainStage === 'QUIZ_PROGRESS' && (
                <QuizScreen
                    subStage={subStage}
                    currentQuiz={stageData.quizProgress?.currentQuiz}
                    timer={subStage === 'WAITING' ? prepareTime : solutionTime}
                    onSubmit={submitAnswer}
                />
            )}
            {mainStage === 'RESULT' && (
                <ResultScreen result={stageData.result} />
            )}
        </div>
    );
};

최적화 및 개선

향후에 적용 할 수 있을 것 같은 부분입니다.

성능 최적화

  1. React.memo를 활용한 컴포넌트 메모이제이션
  2. useCallback과 useMemo를 통한 함수와 값의 안정성 확보
  3. 상태 업데이트 배치 처리로 리렌더링 최소화

에러 처리

  1. ErrorBoundary 컴포넌트를 통한 장애 격리
  2. 타입 가드를 활용한 런타임 타입 안정성 강화
  3. 사용자 친화적인 에러 메시지와 복구 UI 제공

확장성

  1. 다양한 퀴즈 유형(객관식, 주관식) 지원
  2. 호스트와 참가자 권한 분리
  3. WebSocket을 통한 실시간 데이터 동기화

결론

BooQuiz 프로젝트에서 React Custom Hook을 활용한 퍼널 구현을 했고 다음과 같은 항목을 적용했다고 생각합니다.

  1. 사용자 경험 개선

    • 자연스러운 퀴즈 진행 흐름
    • 직관적인 타이머 및 상태 표시
    • 안정적인 답안 제출 처리
  2. 개발 생산성 향상

    • 재사용 가능한 커스텀 훅 구조
    • 명확한 타입 시스템
    • 테스트 용이성 확보
  3. 유지보수성 강화

    • 관심사 분리를 통한 코드 구조화
    • 확장 가능한 아키텍처
728x90