카드 게임 제작 기록 처음부터 보기 : https://helloworld.ai.kr/14
카드 게임 제작 ( 1 ) - 회고
게임 링크 : cardgame.perfect.ai.kr 개발 기간 : 2025.10.28 ~ 2025.11.27총 소요 시간 : 110시간Lovable에게 감탄 ( UI 작업 )https://blog.helloworld.ai.kr/15JWT 토큰은 json web token token이다 ( 로그인 구현 )https://blog.helloworl
helloworld.ai.kr
게임 링크 : cardgame.perfect.ai.kr
Magician Duel
cardgame.perfect.ai.kr
이 시점의 소요 시간 : 58시간 / 110시간
계정 생성, 로그인, 덱 선택, 방 생성 및 참가 구현 완료
해당 부분에 대한 클라이언트 테스트 코드 작성
React Query에 대한 이해
CORS에 대한 이해
React Query, CORS 적용
이전 단계와 마찬가지로 React Query, CORS에 대한 기본적인 이해에 시간을 들여야 했다.
클라이언트와 백엔드의 랑데뷰
클라이언트 코드 중 백엔드와 통신하는 부분을 API, Query, View의 세 계층으로 나누어서 구현한다.
- fetch wrapper
헤더, URL, 에러 처리, 쿠키 전송 등 API 호출 시 반복되는 부분을 모아둔다. - 공통 데이터 타입
반복적으로 사용되는 DTO(data transfer object) 타입들을 모아둔다. DB 스키마와 거의 1:1 대응된다. - 도메인별 API 호출 함수
각 API endpoint를 직접 호출하는 함수들을 도메인별로 정리해놓는다. - React Query용 커스텀 훅
API 함수들을 useQuery, useMutation 등으로 감싼 것. 서버 데이터와 동기화하고 캐싱하는 역할이다. - 화면 UI 계층
데이터 로직을 몰라도 된다. React Query 커스텀 훅을 활용해 데이터를 컴포넌트에 바인딩한다.
React Query
React Query는 프론트엔드의 상태 관리, 특히 서버와 연동되는 데이터를 관리하는 도구다.
자기가 알아서 캐싱, 갱신 등을 해주니 참 편리하다.
useQuery
읽기(fetch) 전용 훅. useState와 유사하다.
어떤 API의 응답을 관리할지 지정해주면 되고, 상태는 곧 DTO와 같은 타입이 된다.
예시)
const { data, error, isLoading, isFetching, refetch } = useQuery({
queryKey: ["decks", userId], // 캐시 키
queryFn: () => http("/api/decks"), // Promise 반환 함수
enabled: true, // false면 자동 실행 안 함
staleTime: 1000 * 10, // fresh로 유지되는 시간
gcTime: 1000 * 60 * 5, // 캐시 가비지 컬렉션 시간
retry: 2, // 실패 시 재시도
refetchOnWindowFocus: false, // 포커스 시 자동 refetch 여부
select: (rows) => rows.slice(0, 10), // 데이터 가공
});
- isLoading : 초기 로딩 중 여부
- isFetching : 초기 요청 + 이후 백그라운드 재요청 포함 상태
- refetch : 수동 갱신 함수
- enabled : 자동 실행 옵션 (false면 직접 refetch 해야 갱신)
- queryKey : 배열 자체가 키 역할
useMutation
생성/수정/삭제 같은 쓰기 용도.
정보 갱신 후에 invalidateQueries로 캐시를 무효화하거나 refetch로 수동 갱신해서 동기화를 하면 된다.
예시)
const qc = useQueryClient();
const createDeck = useMutation({
mutationFn: (payload: { name: string }) =>
http("/api/decks", { method: "POST", body: JSON.stringify(payload) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["decks"] }); // 목록 최신화
},
onError: (err) => {
/* 토스트 등 에러 처리 */
},
});
사용할 때는:
createDeck.mutate({ name: "나만의 마법사 덱" })
useQueryClient
전역 캐시에 접근하는 훅.
- 캐시 무효화: invalidateQueries
- 캐시 즉시 리프레시: refetchQueries
- 데이터 직접 수정: setQueryData
- 데이터 읽기: getQueryData
React Query Provider
<QueryClientProvider>로 app 전체를 감싸서 전역 캐시 컨텍스트를 주입한다.
이게 있어야 useQuery, useMutation이 전역 캐시에 접근할 수 있다.
React Query와 Zustand를 함께 사용
보통은 React Query useMutation의 mutationFn에 서버 요청을 넣고,
onSuccess에서 Zustand 상태를 갱신하는 방식을 쓴다.
이렇게 하면 서버 통신이 성공했을 때 자연스럽게 UI에 반영된다.
그러나
React Query를 적용하고 보니 zustand가 필요없어진 것을 깨달았다.
클라이언트가 따로 들고 있어야 하는 전역 상태가 없었다.
모두 서버와 동기화되어야 하는 데이터뿐이었다.
그래서 zustand를 모두 뜯어내 버렸다.
몇 가지 문제
1. 시크릿 탭에서 로그인이 되지 않는다.
크롬 시크릿 탭이 기본적으로 서드파티 쿠키를 차단해버리는 탓에,
백엔드와 프론트엔드의 도메인이 다른 경우 쿠키를 통한 JWT 토큰 인증이 되지 않는다.
XSS 공격을 막을 수 있다고 해서 HttpOnly 쿠키를 쓰기로 결정했던 건데
득보다 실이 많은 것 같다.
나중에 헤더 방식으로 바꾸든가 하고, 일단 시크릿 탭은 포기하기로 했다.
현 시점에 해결
이 문제는 프로젝트를 마무리한 이후 업데이트에서 해결되었다.
기본적으로 CORS의 많은 문제는 백엔드와 프론트엔드의 오리진이 다르기 때문에 발생한다.
서드파티 쿠키 차단 문제도 마찬가지다.
그래서 백엔드와 프론트엔드의 도메인을 똑같이 맞춰버리는 방법으로 해결하였다.
원래는 Vercel 페이지만 개인 도메인과 연결하고 Render는 제공해주는 도메인을 그대로 쓰고 있었다.
Vercel의 VPN 설정을 활용해 프론트엔드 코드 기준으로는 같은 도메인과 API를 주고 받는 것처럼 작성하고,
실제로는 Render로 우회해서 요청이 들어가도록 만들었다.
이를 통해 시크릿 탭에서도 로그인을 할 수 있게 되었다.
2. UX가 "끈적"거린다.
QA를 할 때 덱 저장, 방 생성 버튼을 눌렀을 때 딜레이가 길게 느껴졌다.
Optimistic Update의 필요성을 실감했다.
3. vercel에 배포된 페이지에서 새로고침하면 404 페이지로 간다.
vercel 페이지에서 새로고침을 하거나 주소를 직접 입력해서 페이지를 이동하면
404 페이지로 갔다. 내가 준비해놓은 404 페이지도 아니고 vercel app 자체 404 페이지였다.
React가 CSR(client-side rendering)을 사용하는 SPA(single page application)여서 발생하는 문제다.
다른 페이지로 이동할 때는 React 코드가 라우팅을 해야 하는데,
vercel 앱은 그 페이지를 새로 요청해버린다.
주어진 파일은 index.html 하나뿐인데(SPA), 다른 페이지를 요청하니 404가 되는 것이다.
어떤 주소든 기본 주소 index.html로 보내도록 rewrite 설정을 추가해주어 해결했다.
해당 설정은 vercel.json 파일에 추가되었다.

CORS와 Cross-Origin 보안 구조 정리
CORS(Cross-Origin Resource Sharing)는 브라우저가 사용자의 정보를 보호하기 위해 적용하는 보안 정책이다.
같은 출처(origin)가 아닌 서버로 JS가 요청을 보냈을 때 브라우저가 응답을 차단해서
민감한 데이터가 외부 사이트로 유출되는 것을 막는다.
1. Origin의 개념
Origin = 프로토콜 + 호스트 + 포트
예:
- https://example.com:443
- http://localhost:5173
출처(Origin)가 다르면 cross-origin 요청으로 간주된다.
2. 기본 원리
브라우저는 다른 origin으로 요청을 보낼 수는 있지만,
그 응답을 JS 코드가 읽지 못하게 한다.
단, 서버가 응답 헤더에 아래처럼 명시하면 허용된다.
Access-Control-Allow-Origin: https://trusteddomain.com
즉,
브라우저는 차단자, 서버는 허용자이다.
둘이 협력해서 신뢰 가능한 출처만 응답을 읽을 수 있게 하는 구조다.
3. 예시로 이해하기
예시 1)
사용자가 A.com 접속. A.com의 JS가 A.com/api 요청 → 같은 출처 → CORS 적용 안 됨
예시 2)
사용자가 hack.com 접속. hack.com JS가 bank.com/api 호출 → 응답이 브라우저에 의해 차단 → JS가 결과를 못 읽음
예시 3)
사용자가 bank-frontend.com 접속. JS가 bank.com/api 호출
→ 서버가 Access-Control-Allow-Origin: https://bank-frontend.com 으로 허용
→ 브라우저가 응답 통과
4. CORS는 어떻게 동작하나
- JS가 cross-origin 요청을 보냄
- 브라우저가 preflight 요청(OPTIONS)을 보냄
- “이 origin에서 보내도 되나요?”
- 단순 GET 요청 등에선 생략될 수 있음
- 서버가 Access-Control-Allow-* 헤더로 허용 범위를 알려줌
- 브라우저가 실제 요청을 보내고 응답을 JS에 전달
- preflight는 정책 협상 단계다.
- Express에서는 cors() 미들웨어가 이 전 과정을 자동으로 처리해준다.
5. 추가 방어 수단
(1) 서버의 Origin 검사
요청 헤더의 Origin/Referer를 확인해 신뢰 도메인만 상태변경 요청(POST/DELETE 등)을 처리
(2) CSRF 토큰
서버가 로그인 유저에게 CSRF 토큰 발급 → 클라이언트는 이를 헤더에 담아 요청
공격자는 CORS 때문에 토큰 값을 얻지 못한다(응답을 읽을 수 없으므로)
(3) 쿠키 SameSite 정책
- Strict: same-origin에서만 쿠키 전송
- Lax: top-level 이동(링크 클릭 등)만 허용
- None: cross-origin 요청에도 허용 (단 Secure 필수)
- 프론트/백엔드 도메인이 다르고 쿠키 기반 로그인을 쓰면 SameSite=None; Secure가 필수다.
6. 브라우저가 허용 도메인을 판별하는 방식
브라우저는 origin을 단순 문자열 비교로 판단한다.
IP, DNS 검사 같은 복잡한 인증은 없다.
그래서 http://localhost:3000 같은 값도 화이트리스트에 포함할 수 있다.
로컬 프론트엔드 / render 백엔드 조합으로 테스트할 수 있었던 이유다.
7. CORS + CSRF 조합 방어 구조
브라우저가 요청 시 쿠키를 자동 첨부한다.
서버는 쿠키 기반 인증(JWT in HttpOnly cookie)을 확인한 뒤
CSRF 토큰이 유효한지 검사한다.
공격자는 CORS 때문에 CSRF 토큰을 발급받을 수 없으므로
유효한 요청을 위조할 수 없다.
다음 단계
게임을 하기 직전 단계까지 모두 구현 완료되었다.
이제 WebSocket을 붙이고, 이벤트 명세랑 게임 상태 스키마를 정리한 다음 게임 엔진을 설계한다.
다음 글 보러 가기 : https://helloworld.ai.kr/19
카드 게임 제작 ( 6, 완 ) - 게임이 돌아가기는 한다
카드 게임 제작 기록 처음부터 보기 : https://helloworld.ai.kr/14 카드 게임 제작 ( 1 ) - 회고게임 링크 : cardgame.perfect.ai.kr 개발 기간 : 2025.10.28 ~ 2025.11.27총 소요 시간 : 110시간Lovable에게 감탄 ( UI 작업 )ht
helloworld.ai.kr
'개발로그' 카테고리의 다른 글
| 프로그래밍 좀비 리뷰 : 나는 좀비가 되지 않을래요 (0) | 2026.02.16 |
|---|---|
| 카드 게임 제작 ( 6, 완 ) - 게임이 돌아가기는 한다 (0) | 2026.02.16 |
| 카드 게임 제작 ( 4 ) - JS 이벤트 루프와 비동기 처리의 이해 (0) | 2026.02.16 |
| 카드 게임 제작 ( 3 ) - JWT 토큰은 json web token token이다 (0) | 2026.02.16 |
| 카드 게임 제작 ( 2 ) - Lovable에게 감탄 (0) | 2026.02.16 |