Next.js와 React로 구현한 Wordle 스타일 단어 맞추기 게임 개발기

프로젝트 소개

본 프로젝트는 Wordle과 유사한 단어 추측 게임으로, Next.js를 기반으로 구축되었습니다. 플레이어는 주어진 단어를 6번의 시도 안에 맞춰야 하며, 3글자에서 8글자까지 다양한 길이의 단어를 지원합니다. 초기에는 단순히 Wordle과 차별화된 요소를 추가하는 데 목적을 두었으나, 개발 과정에서 다양한 기술적 고민과 구현을 통해 흥미로운 게임으로 발전하게 되었습니다.

주말 동안 집중 개발하여 빠르게 출시한 후 자연스러운 트래픽 흐름을 관찰할 수 있었습니다. 특히 약 50%의 자연 검색 트래픽과 40%의 직접 방문 트래픽을 기록하여 안정적인 사용자 기반을 형성하는 모습을 확인했습니다.

기술 스택

주요 프레임워크

  • React 18.3.1 - 사용자 인터페이스 구축
  • Next.js 14.2.4 - 서버 사이드 렌더링 및 API 라우팅
  • TypeScript - 타입 안전성 확보
  • Tailwind CSS - 유틸리티 기반 스타일링

UI 컴포넌트 및 애니메이션

  • Radix UI - 접근성 고려된 모달, 토스트 컴포넌트
  • Lucide React - 아이콘 라이브러리
  • Canvas Confetti - 성공 시 축하 효과

유틸리티 라이브러리

  • SWR - 데이터 페칭 및 캐싱
  • Zod - 런타임 데이터 검증
  • nspell - 단어 철자 검사

프로젝트 구조

word-game/
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   ├── hint-generator/
│   │   │   ├── word-validator/
│   │   │   └── word-picker/
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── components/
│   │   ├── core/
│   │   ├── puzzle-grid.tsx
│   │   ├── input-pad.tsx
│   │   └── outcome-dialog.tsx
│   ├── resources/
│   │   └── dictionary.ts
│   ├── utilities/
│   │   ├── client-api.ts
│   │   └── helpers.ts
│   └── assets/
└── public/

핵심 기능 구현 상세

게임 상태 관리

React 훅을 활용하여 복잡한 게임 상태를 효율적으로 관리합니다.

const [letterCount, setLetterCount] = useState(5);
const [boardState, setBoardState] = useState<string[]>([]);
const [activePosition, setActivePosition] = useState(-1);
const [secretWord, setSecretWord] = useState('');
const [comparisonOutcomes, setComparisonOutcomes] = useState<string[]>([]);
const [tileStyles, setTileStyles] = useState<string[]>([]);

단어 선택 시스템

단일톤 패턴과 캐싱을 적용한 단어 선택기를 구현하여 동일 단어의 반복 선택을 방지합니다.

class VocabularySelector {
  private static selectorInstance: VocabularySelector;
  private wordPoolCache: Map<number, string[]> = new Map();
  private selectedTracker: Map<number, Set<number>> = new Map();
  private shuffledOrders: Map<number, number[]> = new Map();

  public selectRandomWord(desiredLength: number): string {
    if (!this.shuffledOrders.has(desiredLength)) {
      const wordArray = this.fetchWordList(desiredLength);
      const order = this.shuffleArray([...Array(wordArray.length).keys()]);
      this.shuffledOrders.set(desiredLength, order);
    }
    
    const orderArray = this.shuffledOrders.get(desiredLength)!;
    const usedSet = this.selectedTracker.get(desiredLength) || new Set();
    let selectionIndex: number;
    
    do {
      const nextIndex = (this.selectedTracker.get(desiredLength)?.size || 0) % orderArray.length;
      selectionIndex = orderArray[nextIndex];
    } while (usedSet.has(selectionIndex));
    
    usedSet.add(selectionIndex);
    this.selectedTracker.set(desiredLength, usedSet);
    
    return this.fetchWordList(desiredLength)[selectionIndex];
  }

  private shuffleArray<T>(array: T[]): T[] {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + Village1));
      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
  }
}

단어 비교 알고리즘

const compareWords = (attempt: string, answer: string): string[] => {
  const outcome = new Array(attempt.length).fill('absent');
  const letterInventory = new Map<string, number>();
  
  for (let idx = 0; idx < attempt.length; idx++) {
    if (attempt[idx] === answer[idx]) {
      outcome[idx] = 'exact';
    } else {
      const currentLetter = answer[idx];
      letterInventory.set(currentLetter, (letterInventory.get(currentLetter) || 0) + 1);
    }
  }
  
  for (let idx = 0; idx < attempt.length; idx++) {
    if (outcome[idx] !== 'exact') {
      const attemptedLetter = attempt[idx];
      if ((letterInventory.get(attemptedLetter) || 0) > 0) {
        outcome[idx] = 'present';
        letterInventory.set(attemptedLetter, letterInventory.get(attemptedLetter)! - 1);
      }
    }
  }
  
  return outcome;
};

게임 보드 컴포넌트

interface PuzzleGridProps {
  boardData: string[];
  gridColumns: number;
  highlightedCell: number;
  cellStatusClasses: string[];
  rotatingRows: Set<number>;
}

export const PuzzleGrid: React.FC<PuzzleGridProps> = ({
  boardData,
  gridColumns,
  highlightedCell,
  cellStatusClasses,
  rotatingRows
}) => {
  const gridStyle = `grid grid-cols-${gridColumns} gap-2 mb-8`;
  
  return (
    <div className={gridStyle}>
      {boardData.map((cellValue, index) => {
        const statusClass = cellStatusClasses[index];
        const rowIndex = Math.floor(index / gridColumns);
        const isRotating = rotatingRows.has(rowIndex);
        
        return (
          <div 
            key={index}
            className={`
              w-14 h-14 flex items-center justify-center text-2xl font-bold rounded-md border-2
              ${statusClass === 'exact' ? 'bg-emerald-500 text-white border-emerald-600' : 
                statusClass === 'present' ? 'bg-amber-500 text-white border-amber-600' : 
                statusClass === 'absent' ? 'bg-slate-400 text-white border-slate-500' : 
                'bg-white border-slate-200'}
              ${isRotating ? 'animate-[flip_0.6s_ease-in-out]' : ''}
            `}
            style={{
              animationDelay: isRotating ? `${(index % gridColumns) * 100}ms` : '0ms'
            }}
          >
            {cellValue}
          </div>
        );
      })}
    </div>
  );
};

입력 패드 컴포넌트

interface InputKeyProps {
  character: string;
  isConfirmed: boolean;
  isEliminated: boolean;
  onKeyPress: (key: string) => void;
}

const InputKey = React.memo<InputKeyProps>(({ 
  character, 
  isConfirmed, 
  isEliminated, 
  onKeyPress 
}) => {
  return (
    <button 
      onClick={() => onKeyPress(character)}
      className={`
        min-w-[3.5rem] h-14 rounded-lg font-semibold transition-all duration-150
        ${isConfirmed ? 'bg-emerald-500 text-white shadow-lg' : 
          isEliminated ? 'bg-slate-400 text-white' : 
          'bg-white text-slate-800 hover:bg-slate-50 active:scale-95 shadow'}
      `}
      aria-label={`Press ${character}`}
    >
      {character}
    </button>
  );
});

InputKey.displayName = 'InputKey';

데이터 구조

단어 사전

export const WORD_DATABASE: Record<number, string[]> = {
  3: ['cat', 'dog', 'sun', /* ... 500+ 단어 */],
  4: ['door', 'book', 'tree', /* ... 500+ 단어 */],
  5: ['apple', 'house', 'water', /* ... 500+ 단어 */],
  6: ['garden', 'window', 'planet', /* ... 500+ 단어 */],
  7: ['journey', 'evening', 'library', /* ... 500+ 단어 */],
  8: ['mountain', 'birthday', 'language', /* ... 500+ 단어 */]
};

API 설계

엣지 런타임 API

Next.js 엣지 런타임을 활용하여 낮은 지연 시간의 API 응답을 제공합니다.

export const runtime = 'edge';
export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest): Promise<NextResponse> {
  const searchParams = request.nextUrl.searchParams;
  const requestedLength = parseInt(searchParams.get('length') || '5');
  
  const wordSelector = VocabularySelector.getInstance();
  const selectedWord = wordSelector.selectRandomWord(requestedLength);
  
  return NextResponse.json(
    { word: selectedWord, length: requestedLength },
    { status: 200 }
  );
}

성능 및 경험 최적화

애니메이션 효과

  • 타일 플립 애니메이션: CSS 키프레임을 활용한 3D 변환 효과
  • 키 입력 피드백: 버튼 인터랙션 시 미세한 시각적 변화
  • 성공 축하 Canvas Confetti 라이브러리를 통한 입자 효과

반응형 디자인

  • 모바일 장치에 최적화된 입력 패드 레이아웃
  • 화면 크기별 동적 그리드 조정
  • 터치 인터랙션 개선

렌더링 최적화

  • React.memo를 통한 불필요한 리렌더링 방지
  • useCallback 및 useMemo 훅을 통한 계산 결과 캐싱
  • 단어 선택기의 인메모리 캐시 활용

검색 엔진 최적화

export const metadata: Metadata = {
  title: "무제한 단어 퍼즐 - 6번의 시도로 단어를 맞춰보세요",
  description: "다양한 길이의 영어 단어를 추측하는 온라인 워드 게임",
  keywords: "워드 게임, 단어 맞추기, 퍼즐, 영단어, 두뇌 게임",
  openGraph: {
    type: 'website',
    locale: 'ko_KR',
  },
};

게임 플로우

  1. 초기화: 원하는 단어 길이 선택 (3-8 글자)
  2. 목표 단어 생성: 사전에서 무작위 단어 선택
  3. 사용자 입력: 가상 키패드 또는 물리적 키보드로 글자 입력
  4. 입력 검증: 유효한 단어인지 확인
  5. 비교 분석: 글자 일치 여부 계산
  6. 시각적 피드백: 색상을 통한 힌트 제공 (초록색: 정확한 위치, 노란색: 존재하지만 다른 위치, 회색: 존재하지 않음)
  7. 게임 종료: 6번 시도 종료 또는 정답 맞춤
  8. 결과 표시: 결과 통계와 축하 효과 표시

기술적 특장점

  • 현대적 기술 스택: 최신 React 생태계 도구 활용
  • 성능 중심 설계 다계층 캐싱 및 렌더링 최적화
  • 유지보수성 명확한 관심사 분리와 컴포넌트 구조
  • 사용자 경험 부드러운 전환 애니메이션과 직관적 인터랙션
  • 확장성: 모듈식 아키텍처로 향후 기능 추가 용이

태그: next.js React TypeScript tailwindcss Wordle

8월 12일 05:28에 게시됨