상태 관리 아키텍처 상세 문서
목차
개요
이 프로젝트는 Context API + Custom Hooks 패턴을 사용하여 전역 상태를 관리합니다.
아키텍처 구조
GameProvider (Context)
├─ useGameState (게임 로직 상태)
└─ useModalManager (UI 상태)핵심 원칙:
- 관심사의 분리(Separation of Concerns): 게임 로직과 UI 상태를 별도의 훅으로 분리
- 단일 진입점(Single Source of Truth):
GameContext를 통해 모든 상태에 접근 - Props Drilling 방지: 깊은 컴포넌트 트리에서도 직접 상태 접근 가능
useGameState Hook 상세 분석
파일 위치: src/hooks/useGameState.ts
전체 구조 개요
이 훅은 게임의 핵심 비즈니스 로직을 담당합니다. 게임 진행 상태, 플레이어 정보, 게임 종료 조건 등을 관리합니다.
1. Import 구문
import { useState } from 'react';
import type { Team } from '../types/types';
import { createGame } from '../api/gameApi';
라인별 설명:
- Line 1: React의
useState훅을 가져옵니다. 이 훅은 컴포넌트의 상태를 관리하는 기본 도구입니다. - Line 2:
Team타입을 가져옵니다.type키워드를 사용하여 타입만 import하므로, 런타임에는 포함되지 않습니다 (TypeScript의 타입 전용 import).Team은'cho' | 'han'같은 유니온 타입으로, 장기의 두 진영을 나타냅니다.
- Line 3: 백엔드 API와 통신하는
createGame함수를 가져옵니다. 새 게임을 생성할 때 사용됩니다.
2. 상태 선언부
2-1. turnInfo (턴 정보)
const [turnInfo, setTurnInfo] = useState<{ count: number; turn: Team }>({
count: 1,
turn: 'cho',
});
목적: 현재 게임의 턴 정보를 추적합니다.
상세 설명:
- 타입 정의:
{ count: number; turn: Team }count: 현재 몇 번째 턴인지 (1부터 시작)turn: 현재 누구의 차례인지 ('cho'또는'han')
- 초기값:
{ count: 1, turn: 'cho' }- 장기는 항상 초(楚)가 먼저 시작하므로
turn: 'cho' - 첫 번째 턴이므로
count: 1
- 장기는 항상 초(楚)가 먼저 시작하므로
사용 시나리오:
Board컴포넌트에서 현재 턴에 해당하는 기물만 선택 가능하도록 제한InfoBox에서 "1턴 - 초 차례" 같은 정보 표시
2-2. isReplay (리플레이 모드 여부)
const [isReplay, setIsReplay] = useState(false);
목적: 현재 게임이 새 게임인지, 과거 게임을 다시 보는 리플레이인지 구분합니다.
상세 설명:
- 타입:
boolean - 초기값:
false(기본적으로 새 게임 모드) - true일 때: 과거 게임 기록을 재생 중 (수정 불가, 읽기 전용)
- false일 때: 실시간으로 진행 중인 게임 (수정 가능)
사용 시나리오:
- 리플레이 모드에서는 기물 이동을 막고, 재생/일시정지 버튼만 활성화
- 새 게임 모드에서는 기물 이동 가능
2-3. isEnabled (게임 활성화 여부)
const [isEnabled, setIsEnabled] = useState(false);
목적: 게임이 시작되었는지 여부를 나타냅니다.
상세 설명:
- 타입:
boolean - 초기값:
false(앱 시작 시 게임이 시작되지 않은 상태) - true일 때: 플레이어 이름이 입력되고 게임이 시작됨 →
Board표시 - false일 때: 아직 게임 시작 전 → 플레이어 입력 모달 표시
사용 시나리오:
GamePage에서!isEnabled && <GameStartModals />로 조건부 렌더링- 게임 시작 전에는 보드를 숨기고 모달만 표시
2-4. player1Name, player2Name (플레이어 이름)
const [player1Name, setPlayer1Name] = useState('');
const [player2Name, setPlayer2Name] = useState('');
목적: 두 플레이어의 이름을 저장합니다.
상세 설명:
- 타입:
string - 초기값:
''(빈 문자열) - 사용처:
PlayerInputContent모달에서 입력받음InfoBox에서 "플레이어1 vs 플레이어2" 형태로 표시- 백엔드에 게임 생성 요청 시 전달
왜 두 개의 state로 분리했나?
- 각 입력창이 독립적으로 제어되어야 하므로 (한 명의 이름을 바꿔도 다른 사람 이름은 유지)
- 나중에 각 플레이어별 통계나 정보를 추가할 때 확장성이 좋음
2-5. gameId (게임 ID)
const [gameId, setGameId] = useState<number | null>(null);
목적: 백엔드 데이터베이스에 저장된 게임의 고유 ID를 저장합니다.
상세 설명:
- 타입:
number | nullnumber: 게임이 생성되면 백엔드에서 받은 IDnull: 아직 게임이 생성되지 않음
- 초기값:
null - 사용처:
- 게임 기록을 저장할 때 어떤 게임인지 식별
- 리플레이 모드에서 특정 게임의 기록을 불러올 때 사용
왜 null을 허용하나?
- 게임 시작 전에는 ID가 없으므로
null이 자연스러운 초기 상태 undefined보다null을 사용하는 이유: 명시적으로 "값이 없음"을 나타내기 위함
2-6. gameOver (게임 종료 여부)
const [gameOver, setGameOver] = useState(false);
목적: 게임이 끝났는지 여부를 나타냅니다.
상세 설명:
- 타입:
boolean - 초기값:
false - true일 때: 승부가 결정됨 →
GameWinnerModal표시 - false일 때: 게임 진행 중
사용 시나리오:
Board에서 왕이 잡히면setGameOver(true)호출GamePage에서{gameOver && <GameWinnerModal />}로 승리 모달 표시
2-7. winner (승자)
const [winner, setWinner] = useState<'cho' | 'han' | null>(null);
목적: 게임의 승자를 저장합니다.
상세 설명:
- 타입:
'cho' | 'han' | null'cho': 초(楚) 진영 승리'han': 한(漢) 진영 승리null: 아직 승자가 결정되지 않음
- 초기값:
null
사용 시나리오:
WinnerContent에서 "초 진영 승리!" 같은 메시지 표시- 승자에 따라 다른 색상이나 애니메이션 적용 가능
왜 gameOver와 winner를 분리했나?
gameOver는 "게임이 끝났다"는 사실만 표현winner는 "누가 이겼는지"라는 추가 정보 제공- 나중에 무승부 기능을 추가하면
gameOver: true, winner: null같은 상태 가능
3. 함수 정의부
3-1. handleNewGame (게임 초기화)
const handleNewGame = () => {
setTurnInfo({ count: 1, turn: 'cho' });
setIsEnabled(false);
setGameId(null);
setPlayer1Name('');
setPlayer2Name('');
setIsReplay(false);
setGameOver(false);
setWinner(null);
};
목적: 모든 게임 상태를 초기값으로 되돌립니다.
라인별 설명:
- Line 19: 턴을 1턴, 초 차례로 초기화
- Line 20: 게임을 비활성화 (다시 플레이어 입력 모달 표시)
- Line 21: 게임 ID 제거 (새 게임이므로 이전 ID 무효화)
- Line 22-23: 플레이어 이름 초기화 (새로 입력받기 위해)
- Line 24: 리플레이 모드 해제
- Line 25: 게임 종료 상태 해제
- Line 26: 승자 정보 제거
사용 시나리오:
- "새 게임" 버튼 클릭 시
- 게임 종료 후 다시 시작할 때
왜 이 순서로 초기화하나?
- 순서는 크게 중요하지 않지만, 논리적 흐름을 따름:
- 게임 진행 상태 초기화 (턴)
- 게임 활성화 해제
- 게임 메타데이터 초기화 (ID, 플레이어)
- 모드 및 결과 초기화
3-2. startNewGame (새 게임 시작)
const startNewGame = async () => {
if (player1Name.trim() && player2Name.trim()) {
setIsEnabled(true);
const game = await createGame(player1Name, player2Name);
setGameId(game.id);
}
};
목적: 플레이어 이름을 검증하고, 백엔드에 게임을 생성한 후 게임을 시작합니다.
라인별 설명:
- Line 29:
async함수로 선언 (백엔드 API 호출이 비동기이므로) - Line 30: 유효성 검사
player1Name.trim(): 앞뒤 공백 제거 후 빈 문자열이 아닌지 확인&&: 두 이름이 모두 입력되었을 때만 진행- 왜 trim()을 사용하나?: 사용자가 실수로 공백만 입력하는 것을 방지
- Line 31: 게임 활성화 (보드 표시 시작)
- Line 32: 백엔드 API 호출
await: API 응답을 기다림 (비동기 처리)createGame(player1Name, player2Name): 플레이어 정보를 서버에 전송- 서버는 새 게임을 DB에 저장하고
{ id: number }형태로 응답
- Line 33: 받은 게임 ID를 상태에 저장
사용 시나리오:
PlayerInputContent에서 "게임 시작" 버튼 클릭 시- Enter 키로 입력 완료 시
왜 async/await를 사용하나?
- 백엔드 API 호출은 시간이 걸리므로 비동기 처리 필요
await를 사용하면 응답을 받은 후에 다음 코드 실행 (순차적 흐름 보장)
에러 처리가 없는 이유:
- 현재는 간단한 구현이지만, 실제 프로덕션에서는
try-catch로 에러 처리 필요 - 예: 네트워크 오류 시 사용자에게 알림 표시
3-3. selectGame (리플레이 게임 선택)
const selectGame = (game: { id: number; player1: string; player2: string }) => {
setGameId(game.id);
setPlayer1Name(game.player1);
setPlayer2Name(game.player2);
setIsReplay(true);
setIsEnabled(true);
setTurnInfo({ count: 1, turn: 'cho' });
};
목적: 과거 게임 기록을 선택하여 리플레이 모드로 진입합니다.
라인별 설명:
- Line 37: 매개변수로 게임 객체를 받음
id: 게임의 고유 IDplayer1,player2: 해당 게임의 플레이어 이름
- Line 38: 선택한 게임의 ID 저장
- Line 39-40: 플레이어 이름 복원 (과거 게임의 플레이어 정보)
- Line 41: 리플레이 모드 활성화 (수정 불가 모드)
- Line 42: 게임 활성화 (보드 표시)
- Line 43: 턴을 1턴으로 초기화 (리플레이는 처음부터 시작)
사용 시나리오:
GameSelectContent에서 게임 목록 중 하나를 클릭했을 때
왜 turnInfo를 초기화하나?
- 리플레이는 항상 1턴부터 재생되므로
- 사용자가 중간부터 보고 싶다면 재생 컨트롤로 조작
4. Return 구문
return {
turnInfo,
setTurnInfo,
isReplay,
setIsReplay,
isEnabled,
setIsEnabled,
player1Name,
setPlayer1Name,
player2Name,
setPlayer2Name,
gameId,
setGameId,
gameOver,
setGameOver,
winner,
setWinner,
handleNewGame,
startNewGame,
selectGame,
isDisabled: !player1Name.trim() || !player2Name.trim(),
};
목적: 훅의 모든 상태와 함수를 외부에 노출합니다.
구조 분석:
상태값 + Setter 쌍: 각 상태마다 읽기(
turnInfo)와 쓰기(setTurnInfo) 모두 제공- 왜 Setter도 노출하나?: 컴포넌트에서 직접 상태를 변경할 수 있도록 (유연성)
- 예:
Board에서 턴이 바뀌면setTurnInfo({ count: turnInfo.count + 1, turn: ... })
함수들: 복잡한 로직을 캡슐화한 헬퍼 함수들
handleNewGame,startNewGame,selectGame- 왜 함수로 분리했나?: 여러 상태를 동시에 변경하는 로직을 재사용 가능하게
계산된 값 (Computed Value):
isDisabled: !player1Name.trim() || !player2Name.trim()- 목적: 버튼 비활성화 여부를 실시간으로 계산
- 왜 별도 state가 아닌가?: 이미
player1Name,player2Name에서 파생되는 값이므로 중복 저장 불필요 - 장점: 이름이 바뀔 때마다 자동으로 재계산됨 (동기화 문제 없음)
useModalManager Hook 상세 분석
파일 위치: src/hooks/useModalManager.ts
전체 구조 개요
이 훅은 UI 상태 관리를 담당합니다. 특히 게임 시작 시 표시되는 두 모달(플레이어 입력, 게임 선택) 간의 전환을 관리합니다.
1. activeModal (현재 활성 모달)
const [activeModal, setActiveModal] = useState<'player' | 'game'>('player');
목적: 현재 어떤 모달이 표시되고 있는지 추적합니다.
상세 설명:
- 타입:
'player' | 'game'(리터럴 유니온 타입)'player': 플레이어 이름 입력 모달'game': 게임 선택 (리플레이) 모달
- 초기값:
'player'(앱 시작 시 플레이어 입력부터)
사용 시나리오:
GameStartModals에서activeModal === 'player'일 때PlayerInputContent표시activeModal === 'game'일 때GameSelectContent표시
2. toggleModal (모달 전환)
const toggleModal = () => {
setActiveModal((prev) => (prev === 'player' ? 'game' : 'player'));
};
목적: 두 모달 사이를 토글합니다.
상세 설명:
- 함수형 업데이트 사용:
setActiveModal((prev) => ...)- 왜 함수형 업데이트인가?: 이전 상태를 기반으로 새 상태를 계산하므로 안전
- 일반 업데이트(
setActiveModal('game'))와의 차이: 최신 상태를 보장
- 삼항 연산자:
prev === 'player' ? 'game' : 'player'- 현재
'player'면'game'으로,'game'이면'player'로 전환
- 현재
사용 시나리오:
- "리플레이" 버튼 클릭 시 (플레이어 입력 → 게임 선택)
- "새 게임" 버튼 클릭 시 (게임 선택 → 플레이어 입력)
3. modalVariants (애니메이션 설정)
const modalVariants = {
enterRight: { x: '100%', opacity: 0 },
center: { x: 0, opacity: 1 },
exitLeft: { x: '-100%', opacity: 0 },
};
목적: Framer Motion 라이브러리를 위한 애니메이션 변형(variants)을 정의합니다.
상세 설명:
- enterRight: 오른쪽에서 들어올 때
x: '100%': 화면 오른쪽 밖에 위치 (100% 이동)opacity: 0: 투명 (보이지 않음)
- center: 중앙에 정착했을 때
x: 0: 원래 위치opacity: 1: 완전히 보임
- exitLeft: 왼쪽으로 나갈 때
x: '-100%': 화면 왼쪽 밖으로 이동opacity: 0: 투명
사용 시나리오:
GameStartModals에서 모달 전환 시 슬라이드 애니메이션 적용<motion.div variants={modalVariants} initial="enterRight" animate="center" exit="exitLeft">
왜 훅 안에 정의했나?
- 애니메이션 설정도 모달 관리의 일부로 간주
- 나중에 애니메이션 속도나 방향을 동적으로 변경할 수 있음
GameContext 상세 분석
파일 위치: src/contexts/GameContext.tsx
전체 구조 개요
GameContext는 두 개의 훅을 결합하여 단일 진입점을 제공합니다. 이를 통해 컴포넌트는 게임 상태와 모달 상태를 한 곳에서 가져올 수 있습니다.
1. Import 구문
import { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
import { useGameState } from '../hooks/useGameState';
import { useModalManager } from '../hooks/useModalManager';
라인별 설명:
- Line 1: React Context API의 핵심 함수들
createContext: Context 객체 생성useContext: Context 값을 읽는 훅
- Line 2:
ReactNode타입 (children prop의 타입) - Line 3-4: 앞서 정의한 두 커스텀 훅
2. 타입 정의
type GameContextType = ReturnType<typeof useGameState> &
ReturnType<typeof useModalManager>;
목적: Context가 제공할 값의 타입을 정의합니다.
상세 설명:
ReturnType<typeof useGameState>:useGameState훅이 반환하는 타입을 자동으로 추출- 왜 수동으로 타입을 안 쓰나?: 훅의 반환값이 바뀌면 자동으로 타입도 업데이트됨 (DRY 원칙)
- 예:
useGameState에 새 상태를 추가하면 타입도 자동 반영
&(Intersection Type): 두 타입을 합침- 결과:
{ turnInfo, setTurnInfo, ..., activeModal, toggleModal, ... }
- 결과:
장점:
- 타입 안정성: 잘못된 속성 접근 시 컴파일 에러
- 자동 완성: IDE에서 사용 가능한 모든 속성 표시
3. Context 생성
const GameContext = createContext<GameContextType | undefined>(undefined);
목적: Context 객체를 생성합니다.
상세 설명:
createContext<GameContextType | undefined>: 제네릭으로 타입 지정GameContextType: Provider가 제공할 값의 타입| undefined: 초기값이undefined이므로 포함
- 초기값
undefined: Provider 밖에서 사용하면undefined
왜 undefined를 허용하나?
- Provider 밖에서 Context를 사용하려는 실수를 감지하기 위함
- 다음 섹션의
useGameContext에서 에러를 던져 개발자에게 알림
4. useGameContext (안전한 Context 접근)
export const useGameContext = () => {
const context = useContext(GameContext);
if (!context) {
throw new Error('useGameContext must be used within GameProvider');
}
return context;
};
목적: Context를 안전하게 사용할 수 있는 커스텀 훅을 제공합니다.
라인별 설명:
- Line 12:
useContext(GameContext)로 Context 값을 읽음 - Line 13-15: 가드 체크
if (!context): Context가undefined인지 확인- 언제 undefined인가?:
GameProvider밖에서 사용했을 때 throw new Error(...): 명확한 에러 메시지로 개발자에게 알림
- Line 16: 타입이
GameContextType으로 좁혀진 context 반환
왜 이렇게 하나?
- 타입 안정성: 반환값이
GameContextType으로 보장됨 (undefined 제거) - 개발자 경험: 실수 시 즉시 명확한 에러 메시지 표시
- 대안: 직접
useContext(GameContext)를 쓰면 매번undefined체크 필요
5. GameProvider (Context 제공자)
type GameProviderProps = {
children: ReactNode;
};
export const GameProvider = ({ children }: GameProviderProps) => {
const gameState = useGameState();
const modalManager = useModalManager();
const value = {
...gameState,
...modalManager,
};
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
};
목적: Context 값을 생성하고 하위 컴포넌트에 제공합니다.
라인별 설명:
- Line 19-21: Props 타입 정의
children: Provider로 감쌀 컴포넌트들
- Line 24:
useGameState()호출로 게임 상태 생성 - Line 25:
useModalManager()호출로 모달 상태 생성 - Line 27-30: 두 훅의 반환값을 하나의 객체로 합침
- 스프레드 연산자 (
...): 객체의 모든 속성을 펼침 - 결과:
{ turnInfo, setTurnInfo, ..., activeModal, toggleModal, ... }
- 스프레드 연산자 (
- Line 32:
GameContext.Provider로 값을 제공value={value}: 위에서 만든 합쳐진 객체를 제공{children}: 하위 컴포넌트들을 렌더링
사용 예시:
// App.tsx 또는 GamePage.tsx
<GameProvider>
<GameLayout />
<GameStartModals />
<GameWinnerModal />
</GameProvider>
왜 두 훅을 합치나?
- 단일 진입점: 컴포넌트에서
useGameContext()하나로 모든 상태 접근 - 관심사 분리 유지: 훅은 분리되어 있지만, 사용은 통합됨
- 확장성: 나중에
useBoardState같은 훅을 추가해도 쉽게 통합 가능
설계 의도 및 패턴
1. 왜 Context를 사용했나?
문제: Props Drilling
// Context 없이 Props로 전달하는 경우
<App turnInfo={turnInfo} setTurnInfo={setTurnInfo} player1Name={...} ...>
<GamePage turnInfo={turnInfo} setTurnInfo={setTurnInfo} ...>
<GameLayout turnInfo={turnInfo} ...>
<InfoBox turnInfo={turnInfo} /> {/* 여기서 실제 사용 */}
<Board setTurnInfo={setTurnInfo} /> {/* 여기서 실제 사용 */}
</GameLayout>
</GamePage>
</App>
GameLayout은turnInfo를 사용하지 않지만, 자식에게 전달하기 위해 받아야 함- Props가 많아질수록 관리 복잡도 증가
해결: Context
// Context 사용
<GameProvider>
<GameLayout>
<InfoBox /> {/* useGameContext()로 직접 접근 */}
<Board /> {/* useGameContext()로 직접 접근 */}
</GameLayout>
</GameProvider>
- 중간 컴포넌트는 props를 받을 필요 없음
- 필요한 컴포넌트만
useGameContext()로 접근
2. 왜 Custom Hook으로 분리했나?
장점:
- 재사용성: 다른 곳에서도 같은 로직 사용 가능
- 테스트 용이성: 훅만 따로 테스트 가능
- 관심사 분리: 게임 로직(
useGameState)과 UI 로직(useModalManager) 분리 - 가독성: Context Provider가 간결해짐
대안 (모든 로직을 Provider 안에 작성):
// 나쁜 예
export const GameProvider = ({ children }) => {
const [turnInfo, setTurnInfo] = useState(...);
const [isReplay, setIsReplay] = useState(...);
// ... 50줄의 상태와 로직
return <GameContext.Provider value={{...}}>{children}</GameContext.Provider>;
};
- Provider가 너무 비대해짐
- 로직 재사용 불가
- 테스트 어려움
3. 계산된 값(Computed Value) 패턴
isDisabled: !player1Name.trim() || !player2Name.trim()
왜 별도 state로 만들지 않았나?
나쁜 예 (중복 상태):
const [isDisabled, setIsDisabled] = useState(true);
// player1Name이 바뀔 때마다 수동으로 업데이트
const handlePlayer1Change = (name: string) => {
setPlayer1Name(name);
setIsDisabled(!name.trim() || !player2Name.trim()); // 동기화 필요
};
- 동기화 문제:
player1Name이 바뀌었는데isDisabled를 업데이트하지 않으면 버그 - 중복 저장: 이미
player1Name,player2Name에서 계산 가능한 값
좋은 예 (계산된 값):
isDisabled: !player1Name.trim() || !player2Name.trim()
- 항상 최신 값 반영
- 동기화 문제 없음
- 메모리 절약
4. 함수형 업데이트 패턴
setActiveModal((prev) => (prev === 'player' ? 'game' : 'player'));
왜 사용하나?
문제 상황 (일반 업데이트):
// 나쁜 예
const toggleModal = () => {
setActiveModal(activeModal === 'player' ? 'game' : 'player');
};
// 빠르게 두 번 클릭하면?
toggleModal(); // activeModal = 'player' 읽음 → 'game'으로 설정
toggleModal(); // 아직 activeModal = 'player' (리렌더링 전) → 또 'game'으로 설정
// 결과: 한 번만 토글됨 (버그!)
해결 (함수형 업데이트):
setActiveModal((prev) => (prev === 'player' ? 'game' : 'player'));
// 빠르게 두 번 클릭해도
toggleModal(); // prev = 'player' → 'game'
toggleModal(); // prev = 'game' (최신 값) → 'player'
// 결과: 정상적으로 두 번 토글됨
규칙:
- 이전 상태를 기반으로 새 상태를 계산할 때는 항상 함수형 업데이트 사용
5. 타입 추론 패턴 (ReturnType)
type GameContextType = ReturnType<typeof useGameState> &
ReturnType<typeof useModalManager>;
장점:
- DRY (Don't Repeat Yourself): 타입을 두 번 정의하지 않음
- 자동 동기화: 훅의 반환값이 바뀌면 타입도 자동 업데이트
- 유지보수성: 한 곳만 수정하면 됨
대안 (수동 타입 정의):
// 나쁜 예
type GameContextType = {
turnInfo: { count: number; turn: Team };
setTurnInfo: (value: ...) => void;
// ... 50개의 속성을 수동으로 작성
};
useGameState에 새 상태를 추가하면 여기도 수동으로 추가해야 함- 실수로 빠뜨리면 타입 불일치 발생
요약
핵심 개념
- Custom Hook: 로직을 재사용 가능한 단위로 분리
- Context API: Props Drilling 없이 전역 상태 공유
- 관심사 분리: 게임 로직과 UI 로직을 별도 훅으로 분리
- 타입 안정성: TypeScript로 런타임 에러 방지
데이터 흐름
useGameState() ──┐
├─> GameProvider ──> useGameContext() ──> 컴포넌트
useModalManager()─┘사용 예시
// 컴포넌트에서
const { turnInfo, setTurnInfo, activeModal, toggleModal } = useGameContext();
이 아키텍처는 확장 가능하고, 유지보수하기 쉬우며, 타입 안전합니다.
'PROJECT > [ 장기게임 ]' 카테고리의 다른 글
| react query란????? (1) | 2025.12.03 |
|---|---|
| react query를 도입해보자 (0) | 2025.12.03 |
| App.tsx 줄이기 (0) | 2025.11.27 |
| 내 기록 (0) | 2025.11.26 |