-
Notifications
You must be signed in to change notification settings - Fork 0
[3주차 과제] 숫자 카드 짝 맞추기 게임 #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
mimizae
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3주차 과제도 너무너무 수고 많으셨어욤. . . 🩷
코드리뷰 요청에 남겨주신 부분에 대한 답과 ux 및 성능 관련해서 더 개선의 여지가 있는 부분에 멘트 남겼습니닷.
그리고 커밋 내역의 수가 되게 적은데, 기능별로 하나씩 구현할 때마다 커밋을 나누어 올려 주시면 리뷰하는 입장에서 어떤 흐름으로 로직 구성하셨는지가 더 눈에 잘 들어올 것 같아요 ㅎㅎㅎ
우선 1차로 지금 남기고!! 내일 일어나서 멀쩡한 정신으로 다시 한 번 더 살펴 보겠습니다... ㅎㅎㅎ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오잉??? 저랑 형식이 완전 비슷해요!!! 대박 ㄷㄷㄷ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
귀여운 파비콘... >.<
| if (isMatch) { | ||
| setMatched((prev) => [...prev, a.id, b.id]); | ||
| setMessage('짝을 맞췄어요!'); | ||
| setFlipped([]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
카드가 매칭될 때, 두 번째 카드가 완전히 뒤집히기도 전에 두 카드의 색이 바뀌는데, flip 애니메이션이 끝난 뒤 모든 색이 바뀌면 더 자연스러울 것 같아요!! 👀
setTimeout으로 약 300~500ms 정도 delay를 주면 사용자가 “뒤집힘 → 매칭”의 순서를 명확히 인지할 수 있을 듯 합니닷!!
2025-11-14.3.28.30.mov
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
예리하다...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
코드리뷰 요청에 남겨주신 GameBoard 컴포넌트에 관해서 제 의견 말씀 드리자면,
이번 과제가 라우팅이 아니라 상태 기반 조건부 렌더링 구조이다 보니까 게임, 랭킹으로 나뉘고 게임 컴포넌트도 세부 컴포넌트로 나뉘는데 그 나뉜 컴포넌트가 서로 정보를 공유하다 보니 GameBoard 컴포넌트에서의 중앙 제어는 어쩔 수 없었던 것 같아요 😅
그래서 저도!! 처음에 GameSection.tsx 컴포넌트가 어진 님의 GameBoard 컴포넌트처럼 기능 로직 + UI 관련 로직이 모두 모여 있었는데 제가 보기에도 힘든 코드인데 리뷰어는 어떨까 싶더라고요...ㅜㅜ
그래서 고민을 거듭해 기능은 커스텀 훅으로, 그리고 GameSection.tsx 컴포넌트는 UI 중심으로 역할을 완전히 분리했어요!
이러니까 코드의 수도 줄고 확실히 뜯어보기 좋은 것 같더라고요
커스텀 훅을 이런 용도로 써도 되나? 하는 의문이 들긴 했지만... 재사용성이 크지 않더라도 기능 단위로 역할을 명확히 분리한다는 점에서 의미가 큰 것 같아요
(로직의 응집도를 높이기 위한 훅이라면 충분히 쓸 가치 있다는 의미... 어떻게 생각하시나요??)
결론적으로는 하나의 파일에서 모두 관리하기 보다는 커스텀 훅 이용해 기능을 분리하는 방향으로 리팩토링 해보심이 어떨지 제안 드립니닷. . . 👍🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
음... 저도 이 부분을 계속 생각해 봤는데
커스텀 훅을 사용하는 제일 큰 이유가 코드 로직의 재사용을 위해 분리한다고 생각합니다!
단순 코드 로직이 조금 지저분하니까 무조건 커스텀 훅으로 분리해버리자! 이 부분은 정확하게 잘 모르겠네요ㅠ
일단 적어도 이 과제에서는 커스텀 훅으로 분리를 해도 무관하겠지만
나중에 프로젝트를 진행할 때는 아키텍처 구조를 다른 방식으로 고려해 보는 것도 좋을 거 같아요!
| const [isActive, setIsActive] = useState(false); | ||
| const [showModal, setShowModal] = useState(false); | ||
| const [isWin, setIsWin] = useState(false); | ||
| const [clearTime, setClearTime] = useState(0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
거의 모든 걸 상태로 관리하다 보면, 혹시 모를 불필요한 re-render가 발생할 수 있지 않을까요?! (어떻게 생각하시나요 제가 상태의 쓰임을 잘못 파악했을 수도...;;;)
이를 방지하고자 저는 파생 값을 사용했는데, clearTime, isWin 정도의 상태는 파생 값으로 충분히 대체 가능할 것 같습니닷!!
| // 카드 앞면 | ||
| export const front = style({ | ||
| ...faceBase, | ||
| backgroundColor: '#bee0f4', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
크아아악 색이 너무너무 이뿌다 🩵
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://nninyeong.tistory.com/12
https://gurtn.tistory.com/157#google_vignette
혹시 이 두 개 참고하셨나요 ㅎㅎㅎㅎ 통했습니다... 김칫국 ㅋㅋ
| export const levelSelect = style({ | ||
| padding: '8px 12px', | ||
| border: 'none', | ||
| borderRadius: '8px', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
px와 rem이 혼용되고 있는 것 같은데, 혹시 의도된 부분일까요??!!
| cursor: 'pointer', | ||
| background: '#d5eef7', | ||
| color: '#1f3c54', | ||
| transition: 'all 0.25s ease', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 1주차 과제 코드리뷰하며 배운 내용인데 ㅎㅎㅎ 여기 transition에서 all 키워드를 사용하는 것은 렌더링 성능 측면에서 불필요한 업데이트를 유발할 수 있어 좋지 않은 방법으로 간주된다고 합니다!!
/* 만약 width와 background-color만 트랜지션하고 싶다면 */
.box {
transition: width 0.3s ease-in, background-color 0.15s ease-out;
}
huniversal
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3차 과제 구현 정말 고생 많으셨습니다!!
현재 프로젝트 규모에서는 폴더 구조가 깔끔하고 전혀 문제없습니다.
다만 추후 확장 및 다른 규모가 큰 프로젝트에 적용할 수 있는 폴더 구조를 추천드리면
역할 기반으로 컴포넌트를 분리하는 방법을 추천드려요
- features : 상태 관리 컨테이너 역할
- components : 순수 UI 컴포넌트
- 대표적인 예시로 FSD 아키텍처가 있습니다!!
관련 아티클 남겨드려요: https://velog.io/@huniversal/FSD-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98
그리고 현재는 커스텀 훅의 파일 갯수가 적지만 앞으로 구현을 한다면 기능별 서브 폴더로 구분하면 좋을거 같아요!!
스타일링은 저도 Vanilla Extract를 사용해본 경험이 없어서 자세한 설명이 불가능....
고생 많으셨습니다!!
| selectors: { | ||
| '&[data-level="1"]': { | ||
| gridTemplateColumns: 'repeat(4, 1fr)', | ||
| }, | ||
| '&[data-level="2"]': { | ||
| gridTemplateColumns: 'repeat(6, 1fr)', | ||
| }, | ||
| '&[data-level="3"]': { | ||
| gridTemplateColumns: 'repeat(6, 1fr)', | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
반응형 구현이 신기하네요? Vanilla Extract은 처음이라 잘 모르는데 좋은 방식 얻어갑니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
음... 저도 이 부분을 계속 생각해 봤는데
커스텀 훅을 사용하는 제일 큰 이유가 코드 로직의 재사용을 위해 분리한다고 생각합니다!
단순 코드 로직이 조금 지저분하니까 무조건 커스텀 훅으로 분리해버리자! 이 부분은 정확하게 잘 모르겠네요ㅠ
일단 적어도 이 과제에서는 커스텀 훅으로 분리를 해도 무관하겠지만
나중에 프로젝트를 진행할 때는 아키텍처 구조를 다른 방식으로 고려해 보는 것도 좋을 거 같아요!
| if (isMatch) { | ||
| setMatched((prev) => [...prev, a.id, b.id]); | ||
| setMessage('짝을 맞췄어요!'); | ||
| setFlipped([]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
예리하다...
| statusBox, | ||
| levelSelect, | ||
| timeSection, | ||
| timeItem, | ||
| timeValue, | ||
| cardStatusSection, | ||
| cardItem, | ||
| cardLabel, | ||
| cardValue, | ||
| messageSection, | ||
| message, | ||
| historySection, | ||
| historyList, | ||
| historyItem, | ||
| successText, | ||
| failText, | ||
| } from './GameStatus.css.js'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
원래 Vanilla Extract은 이렇게만 import 해야 하나요?
| useEffect(() => { | ||
| const saved = JSON.parse(localStorage.getItem('gameRecords') || '[]'); | ||
| const sorted = saved.sort((a, b) => { | ||
| if (a.level !== b.level) return Number(b.level) - Number(a.level); | ||
| return parseFloat(a.time) - parseFloat(b.time); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
정렬 로직에서 a.level과 b.level을 비교할 때마다 Number()로 변환하고, a.time과 b.time을 비교할 때마다 parseFloat()으로 변환하고 있습니다!
이 방법이 정렬 비교 횟수만큼 불필요하게 타입 변환을 반복하게 만들 수 있어서
useEffect 초기에 데이터를 불러오고 map 함수를 사용하여 level과 time을 숫자 타입으로 변환하고 새로운 배열을 만들어서 정렬하는 방식으로 진행하면 어떨까 싶어서 남겨드려요!!
| <button type="button" className={styles.resetBtn} onClick={handleReset}> | ||
| 기록 초기화 | ||
| </button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
버튼의 타입을 button으로 명확하게 정한거!!
사소해서 넘어갈 수 있는데 정말 좋아요....
| const [deckInfo, setDeckInfo] = useState({ | ||
| status: 'idle', | ||
| data: null, | ||
| level: 1, | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
deckInfo 객체 안에 데이터, 레벨, 상태 등 모든 상태를 담고 있어서 상위 컴포넌트에서 접근할 떄 조금 복잡하게 느낄 수 있을거 같아요!
| const clearStorage = () => { | ||
| setStoredValue([]); | ||
| window.localStorage.removeItem(key); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
일단 제가 이해한 부분은
- React 상태를 빈 배열로 업데이트
- 로컬 스토리지 항목 삭제
입니다.
1.clearStorage() 호출 -> setStoredValue([]) 실행
2. storedValue를 빈 배열([])로 업데이트하고 useEffect 실행
3. useEffect는 새로운 storedValue([])를 가져와 window.localStorage.setItem(key, "[]")를 호출하여 빈 배열 문자열을 저장
이 상황에서removeItem으로 로컬 스토리지의 항목을 삭제해도 useEffect가 다시 키를 생성하고 덮어쓰기 떄문에
removeItem 호출을 제거하는 것이 어떨까욧?
👽 과제 명세
💡 기본 과제
헤더
숫자 카드 짝 맞추기 게임
4 x 4로 고정한다.buildDeck사용 가능)2.1 게임 보드
2.2 게임 진행 상황
랭킹 기능
현재 시각,레벨,클리어 시간이 포함된다. (클리어 시간은 소수점 둘째 자리까지)클리어 시간오름차순이다. (빠른 기록이 위)🔥 심화 과제
게임 레벨 기능
Level 1:
4 x 48쌍 제한 시간 45초Level 2:
4 x 612쌍 제한 시간 60초Level 3:
6 x 618쌍 제한 시간 100초안내 메시지
시각 효과 추가
참고: https://ko.react.dev/reference/react-dom/createPortal
랭킹 정렬 기능
높은 레벨이 위쪽, 같은 레벨에서는 빠른 시간이 위쪽으로 정렬
공유과제
제목: React Server Components (RSC)
링크 첨부 : https://velog.io/@eojindesu/React-Server-Components
🔧 구현 요약 및 새로 배운 점
폴더 구조
폴더는 역할 단위로만 구분하고, 불필요하게 세분화하지 않도록 구성했어요.
기능별 연관성이 높은 파일들은 같은 폴더에 배치하여 구조적 복잡도를 최소화했습니다.
핵심 구현 로직
getLevelConfig,resetGame)getLevelConfig()에서 레벨별 짝 수와 제한 시간을 설정하고,resetGame()에서는 이를 기반으로 무작위로 섞인 카드 덱을 생성했습니다. 모든 상태(cards,flipped,matched,timeLeft)를 초기화하고,useCallback으로 감싸 참조 안정성을 확보했습니다.useEffect)setTimeout을 이용해 0.01초 단위로 남은 시간을 갱신하고, 시간이 0이 되면 자동으로 패배 상태로 전환되도록 구현했어요.cleanup함수에서 타이머를 명시적으로 정리해 중복 실행을 방지했습니다.handleFlip)동시에 두 장까지만 선택 가능하도록 제어하고, 같은 숫자일 경우
matched배열에 추가, 다르면 700ms 후 자동으로 뒤집히도록 했습니다. 매 시도 결과는history배열에 [a, b, 성공/실패] 형태로 기록되어 실시간 표시됩니다.인터스에서 배운
transform: rotateY(180deg)과transform-style: preserve-3d를 활용해 Card Flip 애니메이션을 구현했습니다. 매칭된 카드에는backMatched클래스를 적용해 하이라이트 효과와 그림자(box-shadow)를 추가했습니다.승패 여부에 따라
createPortal로ResultModal을 렌더링했고, 클리어 기록은localStorage에{ level, time, date }형태로 저장했습니다. 기록은 자동으로 레벨 내림차순 + 클리어 시간 오름차순으로 정렬됩니다.CSS 스타일링: Vanilla Extract
이전에는 Tailwind CSS를 주로 사용했는데, 한 번도 사용해 보지 않았던 라이브러리를 시도해 보고 싶어 처음으로 Vanilla Extract을 사용해 봤어요.
사실 간편하기도 하고 빠르고 직관적이라는 장점이 있어서 그동안 계속 tailwind만 사용해 왔었는데, 그에 비해 복잡하긴 했지만 확장성이 좋고 타입 안정성 측면에서는 Vanilla Extract이 훨씬 좋다고 느꼈어요. 그래서 큰 규모의 프로젝트를 할 때에는 Vanilla Extract을 사용하는 것이 좋을 것 같다는 생각이 들었습니다.
🥲 구현 과정에서 어려웠던 & 고민했던 부분
1. 커스텀 훅으로 로직 분리 (
useTimer,useLocalStorage,useDeck)처음에는 모든 로직이
GameBoard에 몰려 있었는데 컴포넌트가 많아지면서 재사용성과 가독성이 떨어졌어요. 그래서 공통 로직을 훅으로 분리했습니다.useTimersetInterval기반 타이머를 훅으로 분리하여isRunning상태와onTimeout콜백을 인자로 받아 제어하도록 구현했습니다.useRef로 타이머 ID를 저장해 리렌더링 시 중복 실행되지 않게 했어요.useLocalStorage클리어 기록을 저장할 때마다
JSON.parse/JSON.stringify를 반복하는 대신, 해당 로직을 훅으로 분리하여addRecord와clearStorage함수로 관리했습니다.try/catch로 스토리지 접근 오류에 대한 예외 처리도 추가했어요.useDeck카드 덱 생성 로직(
buildDeck)을 별도 훅으로 분리해, 레벨 변경 시 자동으로 덱이 재생성되도록 구현했습니다.deckInfo객체에 상태(status,data,level)를 함께 묶어 관리했어요.2. 타이머 제어 및 상태 동기화
useEffect내에서setTimeout으로 타이머를 구현했는데,isActive상태가 변할 때마다 의존성 배열로 인해 타이머가 중복 실행되거나 즉시 종료되는 문제가 발생했어요.이를 해결하기 위해
resetGame함수를useCallback으로 감싸 매 렌더링마다 동일한 함수 인스턴스를 유지하도록 했습니다. 그리고cleanup함수를 이용해 기존 타이머를 명시적으로 정리하여 이전 타이머가 누적 실행되지 않도록 했습니다.3. 카드 섹션 반응형 설계
레벨이 올라갈수록 카드의 개수가 달라지기 때문에, 화면 내에서 카드 간 간격과 비율을 유지하는 것이 생각보다 까다로웠어요. Vanilla Extract의
selectors속성을 활용해, 각 레벨(data-level속성 기준)에 맞게grid-template-columns를 동적으로 적용했습니다.🔭 리뷰 요청 포인트 & 질문
Game폴더에 게임 관련 컴포넌트들을 모두 담아 두었는데, 기능별로 세분화하여 폴더를 분리하는 것이 효율적일지 유지보수성이랑 확장성 측면에서 고민이에요. 다른 분들은 어떤 기준으로 컴포넌트를 분리하시고 폴더 구조를 잡으셨는지 궁금합니다!📷 결과물
최종 구현 뷰
레벨 선택
default.mov
게임 플레이
default.mov
게임 종료 (성공 시)
default.mov
게임 종료 (실패 시)
default.mov
랭킹
default.mov