From fa75feb45031da095aff8a1e6996455e525f0d9d Mon Sep 17 00:00:00 2001 From: choiboa Date: Fri, 16 Jan 2026 23:09:34 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=93=8E=20post=20:=20plaist=20?= =?UTF-8?q?=EB=B8=94=EB=A1=9C=EA=B7=B8=20=EA=B8=80=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + content/posts/DEV_LOG/plaist-0-background.mdx | 130 +++++ content/posts/DEV_LOG/plaist-1-my-devlog.mdx | 445 ++++++++++++++++++ .../DEV_LOG/plaist-2-troubleshotting.mdx | 307 ++++++++++++ content/posts/INSIGHT/velite-test.mdx | 170 ------- src/lib/posts/registry/series.registry.ts | 8 + 6 files changed, 893 insertions(+), 170 deletions(-) create mode 100644 content/posts/DEV_LOG/plaist-0-background.mdx create mode 100644 content/posts/DEV_LOG/plaist-1-my-devlog.mdx create mode 100644 content/posts/DEV_LOG/plaist-2-troubleshotting.mdx delete mode 100644 content/posts/INSIGHT/velite-test.mdx diff --git a/.gitignore b/.gitignore index 543e789..ba0a509 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ tmp/ # --- OS / 기타 --- Thumbs.db + +# MDX 작성용 템플릿 +content/_templates/ \ No newline at end of file diff --git a/content/posts/DEV_LOG/plaist-0-background.mdx b/content/posts/DEV_LOG/plaist-0-background.mdx new file mode 100644 index 0000000..442249e --- /dev/null +++ b/content/posts/DEV_LOG/plaist-0-background.mdx @@ -0,0 +1,130 @@ +--- +title: "#0. 기획 및 회고" +slug: "plaist-0-background" +date: "2025-01-01" +category: "Dev_log" +series: "Plaist" +tags: ["project", "background"] +summary: "Plaist의 아이디어 도출, 사용자 인사이트, 서비스 구조, 협업 회고를 포함한 프로젝트 초기 기획 기록" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768563819/%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_ezdahf.jpg" +draft: false +--- + +## 📍 어떤 니즈가 있을까? + +- 프로젝트가 시작하기 전, 어떤 주제를 해야할지 고민을 많이 했던 것 같다. +- 사람들은 어떤 걸 **필요로 하고 어떤 것에 불편함을 느끼고 있을지**를 생각하고 +- 또 다른 한편으로는, **어떤 것에 흥미가 있고 지금의 트렌드**는 어떤 것인지를 중점적으로 생각했다. + + + +## 📍 어떻게 도출되었을까? + +- **figJam** 을 이용해서 각자 생각해온 아이디어나 지금 생각나는 것을 마구 쏟아내는 브레인스토밍을 진행했다. +- 각자 낸 아이디어를 설명하는 시간을 가졌고 가장 흥미로웠던 아이디어에 투표하였다. +- 그 결과, **데이트 코스 추천 플랫폼** 사이트를 제작하기로 했다. + + +
+ +- 이후 서비스의 **핵심 기능** 과 필요한 **부가 기능**들을 정리하였고, 서비스의 네이밍과 슬로건도 함께 확정하였다. +- **핵심 기능 - 나만의 코스 생성** 으로 사용자들이 직접 방문하고 추천하고 싶은 장소들을 위치 기반으로 추가하여 코스를 생성하고 이를 공유할 수 있는 기능이다. +- 이를 통해 데이트 코스를 계획할 때 느끼는 부담감과 걱정을 덜어주는 것을 목표로 하였다. + + +--- + +- **위치 기반 여정 공유 플랫폼 서비스**입니다. +- PLACE(장소)와 PLAYLIST(재생목록)의 합성어로, "나만의 특별한 장소들을 하나의 플레이리스트처럼 엮어보자"는 의미를 담고 있습니다. +- PLAIST에서 사용자는 **자신만의 코스를 공유하고 아카이빙**할 수 있습니다. +- 마이코스 페이지에서 "코스 생성하기"를 통해 **나만의 코스를 손쉽게 생성하고 공유**할 수 있습니다. +- **다양한 유저들의 코스 게시물을 탐방**하며, 마음에 드는 게시글에 좋아요를 누르거나 댓글을 작성할 수 있습니다. +- 좋아요를 누른 게시글은 **마이페이지에 아카이빙**되어, **원하는 대로 검색하고 열람**할 수 있습니다. +- 모든 게시물은 **지역**과 **카테고리**별로 구분하여 탐색할 수 있으며, 검색창을 통한 **키워드 검색도 지원**합니다. + + +## 📍 BackGround + +서비스를 소개할 때, **사람들이 이 서비스를 흥미롭게 받아들이고 공감할 수 있게 하려면** 어떻게 해야 할지 고민했다. +그렇게 생각해 낸 방식은 **일상에서 공감할 만한 상황을 예로 들고**, 설득력을 더할 **통계 자료**를 활용하기로 했다. + + +
+
+
+ + +## 📍 사용자 여정지도 & IA + +사용자가 서비스를 이용하면서, 어떠한 부분들이 개선되었고 어떠한 니즈가 충족되었는지 한 눈에 볼 수 있게 사용자 여정지도를 제작하였다. + + +
+
+ +- 서비스의 전반적인 기능과 메뉴 구조를 볼 수 있게 작성하였다. + + +## 💬 KPT 회고 + +- 전반적인 과정에서 **"왜?"** 라는 질문을 중심에 두고 사고하며 진행했다. +- **사람들이 왜 불편해하는지**, **왜 이 서비스가 필요한지** , **왜 이렇게 화면을 구성했는지** 등 스스로 끊임없이 물어보며, 그 답이 합당하고 설득력이 있는지 확인했다. +- 이러한 **고민의 과정과 결과가 기획에 고스란히 반영**되었다고 생각한다. + + + +- 가장 아쉬웠던 점은 프로젝트 초반에 너무 힘이 들어가서 낭비했던 시간들이다. +- 예를 들어 **API Mocking**이나 사소하게는 **잘 모르는 용어들로 이루어진 체계** 같은 것들이 당장 우리에게 필요한 과정은 아니었다고 생각한다. 실제로 이러한 부분들로 인해 **팀의 소통에 방해가 되었고 그로 인해 불필요한 시간이 낭비**되었다. +- 물론 이런 경험을 통해 잘 모르는 부분을 배워가고 습득해 나갈 수 있지만, **과도하게 이를 남용하는 것은 오히려 비효율성을 초래**할 수 있다. +- 따라서 앞으로는 이러한 상황을 **경계하고 필요한 경우에만 적절히 용어를 사용**하며 **우선순위를 잘 판단**하자. + + + +- 협업을 하면서 **가장 어려웠던 건 의사소통**이었다. 상대방이 이해할 수 있도록 **적당한 속도와 방식으로 설명하는 것**이 예상보다 훨씬 어려웠다. +- 내가 아는 용어가 **상대방에게도 당연히 익숙할 것**이라고 생각했던 부분을 고쳐야겠다고 느꼈다. 또한, **상대방이 제대로 이해했는지, 그 의도가 맞는지를 확인하는 습관**을 기르는 것도 중요하다고 생각했다. + diff --git a/content/posts/DEV_LOG/plaist-1-my-devlog.mdx b/content/posts/DEV_LOG/plaist-1-my-devlog.mdx new file mode 100644 index 0000000..8fa7383 --- /dev/null +++ b/content/posts/DEV_LOG/plaist-1-my-devlog.mdx @@ -0,0 +1,445 @@ +--- +title: "#1. 나의 Dev-Log : Q&A" +slug: "plaist-1-my-devlog" +date: "2025-01-02" +category: "Dev_log" +series: "Plaist" +tags: ["project", "background"] +summary: "Plaist 프로젝트를 하며 상태 관리와 API 통신에서 겪은 고민과 배움을 Q&A 형식으로 정리한 글이다." +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768563819/%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_ezdahf.jpg" +draft: false +--- + +## 📌 Zustand 왜 사용할까? + + +이번 프로젝트에서 `Zustand` 를 사용했는데, 확실히 컴포넌트가 깊어질수록 가독성도 좋아지고 상태를 관리하는 데 편하다는 느낌을 받았다. 그래서 구체적으로 `Zustand` 를 사용하면 어떠한 이점이 있는지 정확하게 짚고 넘어가고 싶었다. + + +### 1. 전역 상태 관리 + +- 특히 `Zustand` 는 전역 상태 관리를 간편하게 할 수 있는 도구이다. `React` 의 상태 관리 방식에서는 컴포넌트 간에 데이터를 공유하려면 `props` 를 통해 전달하거나, `Context API` 를 사용해야 하는데, 이는 상태가 깊게 전달되거나 여러 컴포넌트에서 상태를 동시에 다뤄야 할 경우 코드가 복잡해질 수 있다는 문제가 있다. +- 그런 면에서 `Zustand` 는 전역 상태를 별도의 스토어로 관리하고, 이를 필요한 컴포넌트에서 가져다 사용할 수 있게 해준다. 이 방식은 상태를 전역적으로 관리할 수 있게 해줘서 여러 컴포넌트에서 같은 상태를 공유하고 수정하는 데 유리하다. + +### 2. 간단하고 직관적인 API + +- `Zustand` 는 상태를 관리하는 데 필요한 기본적인 기능만 제공해서 이를 통해 복잡한 설정 없이 빠르게 상태를 관리할 수 있다. +- `create` : 상태를 생성하는 함수, 상태와 상태를 업데이트 하는 함수를 반환한다. +- `set` : 상태를 업데이트 하는 함수로, 이를 사용하여 상태를 변경할 수 있다. +- `get` : 현재 상태를 가져오는 함수로, 이를 사용하여 상태에 접근할 수 있다. + +### 3. 성능 최적화 & 컴포넌트와 상태 분리 + +- 최소한의 리렌더링을 보장한다. 상태가 변경되었을 때, 상태를 사용하는 컴포넌트만 리렌더링되어서 불필요한 렌더링을 줄일 수 있다. +- 이 부분이 가장 크게 와닿았던 부분으로, 상태를 컴포넌트와 분리하여 재사용성과 유지 보수성을 높일 수 있다. 상태를 외부 스토어에서 관리하면, 컴포넌트가 상태에 의존하지 않고 `단일 책임 원칙(SRP)` 을 따르게 된다. +- 이렇게 별도의 파일로 분리하면, 컴포넌트는 `UI` 에 집중하고, 상태 관리 부분은 독립적으로 관리할 수 있다. +- 그 결과 컴포넌트는 복잡해지지 않고, 나중에 상태 관리 방식이 변경되더라도 컴포넌트 코드에 영향을 주지 않는다. + +### 4. 작고 가벼운 라이브러리 + +- 다른 상태 관리 라이브러리들과 비교할 때, 설정이 적고 성능이 뛰어나며 코드 양도 적다. 따라서, 상태 관리의 복잡성을 줄이고 `간단한 상태 관리` 가 필요한 프로젝트에 매우 유용하다. + + +하지만 상태 의존성이 복잡하거나 성능 최적화가 중요한 대규모 프로젝트에서는 `Redux` 나 `Recoil` 같은 더 엄격한 상태 관리 라이브러리를 사용하는 것이 좋다고 한다. + + +### 5. 타입스크립트 지원 + +- 또, `Zustand` 는 `TypeScript` 와 잘 통합이 된다. 그래서 타입을 명확하게 정의하고 상태를 관리할 수 있기 때문에, 타입 안전성을 제공하고 코드 작성 시 오류를 미리 잡을 수 있다. + + +`create<CommentState>` 와 같이 상태의 타입을 지정해줘서 상태에 대한 타입 추론이 가능하고, 타입 에러를 방지할 수 있다. +💬 생각보다 타입 에러가 많이 발생했었는데, 에러 메세지를 꼭 잘 확인하고 어디서 타입 에러가 발생하는지 파악해서 디버깅 코드로 데이터 구조를 확인해보는 게 좋다. + + +### 6. 동시성 처리 및 비동기 상태 관리 + +- 비동기 작업을 처리하는 데 좋다. 상태 업데이트가 비동기 작업에 의존할 때, 상태와 비동기 작업을 하나의 스토어에서 관리하면 코드가 더 깔끔하고 유지보수가 용이해진다. + + +예를 들어, 댓글을 화면에 보여주기 전에 서버에서 댓글 데이터를 먼저 받아와야 하는데, 이러한 `상태 관리` 와 `비동기 작업`을 다른 곳에서 처리하면 상태를 업데이트 할 때마다 여러 곳을 변경해야 하고, 상태 관리가 분산되어서 코드가 복잡해질 수 있다. +
+🤥 왜 복잡해질까? +상태를 확인하려면 여러 곳을 돌아다니면서 확인해야 하니깐 헷갈리고 복잡해진다. +
+🤥 왜 비동기 작업을 처리할 때 좋을까? +한 곳에서 관리하기 때문에, 순서를 제대로 관리할 수 있다. +
+ +--- + +## 📌 초기화를 하지 않으면 오류가 발생한다? + + +`Zustand`에서 처음 생성할 때, 오류가 발생하고 렌더링이 안되는 경우가 발생했다. 왜 그럴까 찾아보니, 초기화를 안 해줘서였다. + + +- 초기화는 변수나 상태에 기본값을 설정해주는 것인데, 아무 값도 없을 때 사용할 `기본 상태` 를 만들어주는 것이다. + +### 🤥 왜 초기화가 필요할까? + +1. **코드가 오류를 일으킬 수 있다.** + + ```js + const useCommentsStore = create(() => ({ + comments: undefined, // 초기화하지 않음 + })); + + // 댓글 목록을 추가 + const addComment = () => { + const store = useCommentsStore(); + store.comments.push({ text: "새 댓글" }); // 오류 발생! + }; + ``` + + - `comments` 가 `undefined` 라서 `.push()` 를 호출하려고 하면 `Cannot read properties of undefined` 라는 오류가 발생한다. + +2. **초기값이 없으면, 상태가 어떤 값인지 알기 어렵다.** + + - 우리는 상태가 항상 `[]` 이거나 `{}` 이라고 생각하고 짐작하지만, 초기화하지 않으면 아무 값도 없어서 코드가 이상하게 동작할 수 있다. + - 그래서 초기화를 통해 개발자가 `"이 변수는 어떤 역할을 할 것이다"` 라는 의도를 명확히 표현하고, 의도한 대로 동작하게 할 수 있다. 이렇게 되면 코드를 읽는 사람도 상태의 기본 구조를 바로 이해할 수 있다. + +--- + +## 📌 스프레드 연산자의 역할 + + +상태 관리를 할 때, 특히 `Zustand` 에서 `set` 을 이용할 때, 기존 데이터는 유지한 채 새로운 데이터를 추가하거나 변경할 때 스프레드 연산자를 사용했는데 그 이유가 궁금해서, 정확한 동작 원리와 역할에 대해서 공부해보았다. + + +- 기존 데이터를 복사하거나, 새로운 데이터를 추가할 때 사용 + +```js +const bag = { book: "A", water: "B" }; +const newBag = { ...bag }; // { book: "A", water: "B" } +``` + +### 🤥 왜 스프레드 연산자를 쓰는 걸까? + +1. **기존 데이터를 유지하기 위해** + + - 기존 데이터를 지우지 않고 새 데이터를 추가하려면 기존 데이터를 복사해야 한다. 이때 스프레드 연산자가 이를 간단히 해준다. + + + 스프레드 연산자는 얕은 복사를 하기 때문이다. 즉, 객체나 배열을 복사할 때 내부에 있는 참조값은 그대로 복사된다. + + + - 얕은 복사 : 겉만 복사하는 것으로, 객체 안에 있는 값이 `원시 값`이면 복사되고, `참조값`이면 주소만 복사된다. 즉, 복사한 데이터가 원본 데이터와 일부를 공유하게 된다. + - 깊은 복사 : 안에 있는 모든 값까지 새롭게 복사하는 것으로, 원본 데이터와 완전히 독립된 새로운 데이터를 만든다. + +2. **왜 얕은 복사를 해야 할까?** + + - 원본 데이터는 변경되지 않고, 복사해서 새로운 데이터만을 추가할 수 있기 때문이다. + - 또한, 기존 데이터를 유지하면서 일부를 변경해야 할 때가 많은데, 얕은 복사로 변경이 필요한 부분만 손쉽게 덮어쓸 수 있다. + - 얕은 복사는 깊은 복사에 비해 빠르고 메모리 사용량이 적다. + 무엇보다 React 상태는 보통 얕은 구조를 가지거나, 상태 관리 라이브러리가 중첩된 데이터를 잘 관리할 수 있도록 설계되어 있어서(예: `Zustand`), 얕은 복사가 효율적이다. + +### 📍 불변성 유지 + + +`React 상태 관리`에서 불변성을 유지하는 것은 매우 중요하다. + + +1. **쉬운 디버깅** + 상태가 변경되기 전/후의 스냅샷을 비교하여 변경 내용을 쉽게 추적할 수 있다. +2. **예상치 못한 부작용 방지** + 원본 객체를 직접 수정하면, 다른 컴포넌트나 함수에서 해당 상태를 참조할 때 예상치 못한 결과가 발생할 수 있다. + +### 🔗 깊은 복사가 필요한 상황 + +- 얕은 복사는 상태 관리에 적합하지만, 중첩된 객체의 상태를 다룰 때는 깊은 복사가 필요할 수 있다. +- 이를 해결하기 위해 `lodash` 의 `cloneDeep` 이나 `JSON` 파싱 기법을 사용할 수 있다. + +--- + +## 📌 불필요한 API 요청 줄이기 + + +페이지에서 사용하는 상태는 API에서 데이터를 받아오는데, 다른 페이지를 갔다가 다시 기존의 페이지로 돌아왔을 때 또 다시 API에 요청해서 데이터를 받아오는 게 너무 불필요한 동작이라고 생각하게 되었다. 이걸 어떻게 하면 한 번만 처리해줄 수 있을까? + + +```tsx +useEffect(() => { + if (!isFetched) { + fetchComments(); + setIsFetched(true); + } +}, [isFetched, fetchComments]); +``` + +- 이렇게 조건문을 이용해서 `isFetched` 상태로 확인해서 데이터를 한 번만 가져올 수 있게 제어할 수 있다. +- `isFetched` 가 `true` 로 설정되어 있으면 데이터를 다시 받아올 필요가 없다. + +### 🤥 그러면 데이터가 바뀐 경우엔? + +- 만약 데이터를 삭제하거나 추가해서 변경되었다면, 그럴 때만 해당 로직에서 `fetchComments()` 를 다시 호출해주면 된다. + +### 🚨 새로고침을 해도 유지되는가? + + +페이지를 새로고침하면 `isFetched` 상태가 초기화되기 때문에 다시 데이터를 받아와야 한다. 왜냐하면 페이지 새로고침 시, `React 컴포넌트`와 `상태`가 초기화되기 때문이다. + + +- 새로고침을 하면 브라우저가 페이지를 새로 로드하게 되고, 이때 모든 상태가 초기화된다. 즉, `useState` 나 `useEffect` 로 관리하는 상태들은 새로고침 시 사라지게 된다. + +### 🤥 새로고침 후 데이터를 다시 요청하지 않으려면? + +- 브라우저의 `localStorage` 나 `sessionStorage`를 활용하여 데이터를 저장하고 불러오는 방법을 사용할 수 있다. + + +로컬/세션 스토리지는 웹 브라우저에 데이터를 저장할 수 있는 공간이다. 이 데이터는 웹 페이지를 새로고침해도 유지되지만, 수명 에 차이가 있다. + + +- localStorage : 브라우저를 닫아도 데이터 유지 +- sessionStorage : 탭이 닫히면 데이터 삭제 + +또한, `SWR` 이나 `React Query` 같은 데이터 패칭 라이브러리를 활용하면 캐싱과 데이터 동기화를 손쉽게 구현할 수 있다. + +--- + +## 📌 로컬 스토리지 (localStorage) + +- 영구적 저장 : 브라우저를 닫거나 컴퓨터를 꺼도 데이터가 계속 남아 있다. +- 클라이언트 측 저장 : 브라우저에 데이터를 저장하므로, 사용자가 직접 데이터를 수정할 수 있다. (개발자 도구에서 수정/삭제 가능) +- 민감한 정보 저장 금지 : 비밀번호, 인증 토큰, 카드 정보 등은 보안상 매우 위험하다. +- HTTP vs HTTPS : `HTTP` 로 접속한 웹사이트에서는 암호화 없이 저장되기 때문에, 중간자 공격에 노출될 위험이 있다. 반드시 `HTTPS` 환경에서 사용하는 것이 좋다. + + +주로 로그인 상태 유지, 사용자 설정 저장, 장바구니 데이터 저장처럼 일정 시간 동안 유지되어야 하는 데이터를 저장할 때 사용된다. + + +```js +// 로컬 스토리지에 데이터 저장 +localStorage.setItem("username", "JohnDoe"); + +// 로컬 스토리지에서 데이터 불러오기 +const username = localStorage.getItem("username"); +console.log(username); // "JohnDoe" +``` + +### ✔ㅤ세션 스토리지 (sessionStorage) + +- 세션 종료 시 삭제 : 브라우저 “탭”을 닫으면 데이터가 사라진다. +- 클라이언트 측 저장 : 로컬 스토리지와 마찬가지로 개발자 도구에서 수정/삭제 가능 +- 짧은 시간 동안만 유지 : 세션 동안만 필요한 데이터를 저장하는 데 적합하다. +- HTTP vs HTTPS : 마찬가지로 `HTTPS` 를 사용해야 보안이 강화된다. + + +주로 현재 브라우저 세션 동안만 필요한 데이터를 저장할 때 사용한다. + + +```js +// 세션 스토리지에 데이터 저장 +sessionStorage.setItem("cartItem", "Apple"); + +// 세션 스토리지에서 데이터 불러오기 +const cartItem = sessionStorage.getItem("cartItem"); +console.log(cartItem); // "Apple" +``` + +### ✔ㅤ공통점 + +- 도메인별 저장 : 같은 도메인에서만 데이터를 공유할 수 있다. +- 용량 제한 : 대부분의 브라우저에서 약 5MB 내외. + +### 🤥 왜 로컬/세션 스토리지를 사용할까? + + +웹 애플리케이션에서 데이터를 자주 불러오는 것은 서버에 부담을 줄 수 있고, 사용자에게도 불편을 줄 수 있다. +그래서 데이터를 브라우저에 저장해두면 빠르게 데이터를 불러올 수 있고, 새로고침이나 페이지 이동 시에도 불필요한 요청을 줄일 수 있다. + + +### 📍 보안 이슈 + +- 데이터 암호화 : 민감한 정보를 저장해야 한다면 반드시 암호화 후 저장해야 한다. +- 만료 시간 관리 : 로컬 스토리지는 영구적이라, 직접 만료 시간을 관리하거나 로그아웃 시 데이터를 삭제하는 로직이 필요하다. + +--- + +## 📌 메모이제이션 사용 + + +이번 프로젝트에서는 없는 API가 많아서 데이터를 받고 그 데이터를 가지고 또 다른 API를 호출하는 경우가 많았고, 반복적으로 계산해야 하는 값들이 많았다. 이 경우 메모이제이션을 사용하면 성능 최적화에 도움이 된다고 해서 사용해보았고, 이에 대해 정리해보고자 한다. + + +### ✔ㅤuseMemo + +- React의 `Hook` 으로, 특정 값이나 계산 결과를 메모이제이션(cache)하여 성능을 최적화하는 데 사용된다. +- 간단히 말하면, 값을 계산하는 작업이 매번 반복되지 않도록 React가 이전 계산 결과를 기억해두는 역할을 한다. + + +🤥 언제 사용 할까? +“계산 비용이 높은 작업” (예: 배열 필터링, 복잡한 데이터 변환)이 컴포넌트가 리렌더링될 때마다 실행되는 것을 방지하고 싶을 때 사용한다. + + +### 🔗 useMemo vs useCallback + +- `useMemo` : “값” 자체를 메모이제이션 +- `useCallback` : “함수”를 메모이제이션 + +--- + +## 📌 오류 처리 + + +멘토님께 오류 처리에 대한 코드 리뷰를 받으면서 관련 내용을 다시 한 번 찾아보고 수정을 진행했다. + + +API를 호출하는 함수, 특히 상태를 업데이트하는 `post / put / patch / delete` 같은 함수들은 `try-catch` 문을 작성할 때, `catch` 구문에서 에러를 단순히 로그만 남기고 넘기는 것보다 `throw error` 를 사용해 상위 호출 스택으로 오류를 전파하는 것이 중요하다. + +이렇게 하지 않으면: + +- 디버깅이 어려워지고 +- 상위 함수가 오류를 제대로 인지하지 못하는 문제가 발생할 수 있다. + +### ✔ㅤthrow error + +- 현재 실행 중인 코드 흐름을 중단하고 오류를 상위 호출 스택으로 전달해, 해당 오류가 전체적으로 관리될 수 있게 만든다. +- 이를 통해 호출자가 해당 오류를 처리할 수 있도록 하며, 오류가 발생했음을 명확히 알리는 역할을 한다. + 예: `API 호출 실패`, `데이터 검증 실패` 등. + +--- + +## 📌 Axios + + +프로젝트에서 발생했던 치명적인 문제들의 원인이 바로 이 `axios` 설정 문제였다. 나중에 이 사실을 알고 코드를 수정했더니, 여러 곳에서 발생하던 문제들이 한꺼번에 해결되었다. 이를 계기로 이 부분을 확실히 정리해 두어야겠다고 다짐했다. + + +### ✔ㅤ수정 전 코드 + +```ts +import axios from "axios"; +import { getToken } from "../utills/Auth/getTokenWithCloser"; + +const token = getToken(); + +export const axiosInstance = axios.create({ + baseURL: "", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, +}); +``` + +- `getToken()` 함수가 먼저 실행되어 토큰을 즉시 가져와 `Authorization` 헤더에 바로 설정된다. +- 이 방식은 `axiosInstance` 가 생성될 때 “한 번만” 토큰을 설정한다. +- 그래서 만약 토큰이 만료되거나 새로 발급된 경우, 업데이트된 토큰을 반영하지 못한다. + +### ✔ㅤ수정 후 코드 + +```ts +import axios from "axios"; +import { getToken } from "../utills/Auth/getTokenWithCloser"; + +export const axiosInstance = axios.create({ + baseURL: "", + headers: { + "Content-Type": "application/json", + }, +}); + +axiosInstance.interceptors.request.use((config) => { + const token = getToken(); + + if (token) { + config.headers["Authorization"] = `Bearer ${token}`; + } + + return config; +}); +``` + +- `axiosInstance` 를 생성할 때 `Authorization` 헤더는 설정하지 않는다. +- 대신 `interceptors` 를 사용하여 요청이 발생할 때마다 `getToken()` 을 호출하고, 최신 토큰을 `Authorization` 헤더에 추가한다. + +### 📍 axios란? + +- HTTP 요청을 쉽게 보낼 수 있게 해주는 라이브러리. +- 주로 서버와 데이터를 주고받을 때 사용된다. + +### 📍 axios.create + +- 공통 설정이 포함된 axios 인스턴스를 생성하는 함수. +- 매번 같은 설정을 반복하지 않고, 기본값을 설정해서 재사용할 수 있다. + +### 📍 interceptors란? + + +요청(request)이나 응답(response)을 가로채서 추가 작업을 수행할 수 있는 `axios` 의 기능이다. + + +- 요청을 보내기 전에 특정 데이터를 추가하거나, 응답을 받은 후 데이터를 변환할 때 주로 사용된다. + +#### 💬 request interceptors + +- 요청이 서버로 보내지기 전에 실행된다. + 예: 요청에 인증 토큰을 추가, 로그 남기기 등 + +```ts +axiosInstance.interceptors.request.use((config) => { + console.log("Request sent:", config); + config.headers["Custom-Header"] = "Hello"; + return config; // 변경된 설정 반환 +}); +``` + +#### 💬 response interceptors + +- 서버로부터 응답을 받은 후 실행된다. + 예: 응답 데이터 가공, 에러 공통 처리 등 + +```ts +axiosInstance.interceptors.response.use( + (response) => { + console.log("Response received:", response); + return response; // 수정한 응답 데이터 반환 + }, + (error) => { + console.error("Error occurred:", error); + return Promise.reject(error); + } +); +``` + +### 🤥 headers란? + + +HTTP 요청에 대한 메타데이터를 설정하는 부분이다. 예를 들어 인증 토큰, 데이터 형식, 언어 정보 등을 설정한다. + + +#### 🔗 주요 헤더 종류 + +- `Authorization` : 인증 정보(토큰 등)를 전달 + 예 `Authorization: Bearer ${token}` +- `Content-Type` : 요청 데이터의 형식을 지정 + 예 `application/json`, `multipart/form-data` +- `Accept` : 서버에서 어떤 데이터 형식을 받을지 지정 + 예 `Accept: application/json` + +### 🤥 use (interceptors.use) + +- `axios` 의 인터셉터를 등록할 때 사용하는 메서드로, 두 개의 함수를 매개변수로 받는다. + (정상 처리 콜백, 에러 처리 콜백) + +#### 🔗 config란? + +- 요청에 대한 모든 설정 정보를 담고 있는 객체. +- `interceptors` 는 이 `config` 객체를 수정하거나 읽어서 원하는 작업을 수행한다. + +예를 들어: + +- `url` : 요청할 서버의 주소 +- `method` : HTTP 요청 방식 (`GET`, `POST`, `PUT`, `DELETE` 등) +- `headers` : 요청에 포함된 HTTP 헤더 +- `data` : 서버로 보내는 데이터 (`POST`, `PUT` 등) +- `params` : URL에 붙는 쿼리 문자열 + +```ts +const config = { + url: "/users", + method: "GET", + params: { search: "Alice" }, // → /users?search=Alice +}; +``` \ No newline at end of file diff --git a/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx b/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx new file mode 100644 index 0000000..f1dc474 --- /dev/null +++ b/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx @@ -0,0 +1,307 @@ +--- +title: "#2. 나의 Dev-Log : 트러블슈팅" +slug: "plaist-2-troubleshotting" +date: "2025-01-03" +category: "Dev_log" +series: "Plaist" +tags: ["project", "background"] +summary: "Plaist의 개발 구현중에 기록한 트러블 슈팅에 대하여 문제해결 과정을 정리한 글이다. " +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768563819/%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_ezdahf.jpg" +draft: false +--- +## 1. 초기화되지 않는 문제 발생 + + +- 같은 브라우저에서 로그아웃 후 새로운 계정으로 로그인했을 때, 이전 유저의 데이터가 남아있는 문제가 발생했다. +- 새로고침하면 현재 유저의 데이터가 정상적으로 넘어오는 것은 실행된다. + + +
+ +### 💬 시도해본 것 +- 콘솔 창에 불러오는 `Data`를 찍어봤는데, 정상적으로 실행되면 `[]` 이 나와야 하는데, 직전에 로그아웃한 유저의 정보가 그대로 불러와지는 문제가 발생한 것 같다. 원인이 무엇일까? + +- 로그인한 유저의 `userId` 값으로 가져오는 `userInfo`에서 `userData` 는 제대로 가져오는 것을 확인했다. 하지만 `getMyCourseObj`는 이전 로그인 계정의 데이터를 반환하고 있다. + +- 디버깅을 해본 결과, `userData`는 정상적으로 받아오지만, `fetchMyCourses`에서 `userId` 값이 최신 상태로 갱신되기 전에 `getMyCourseObj`가 호출되어 이전 로그인 계정의 데이터가 요청되는 문제가 발생한 것 같다. + +```ts +fetchMyCourses: async () => { + const { userId } = useUserStore.getState(); + + if (!userId) { + console.warn("User ID is no set"); + return; + } + + try { + const data = await getMyCourseObj(userId); + console.log("data:", data); + + if (!data || !Array.isArray(data)) { + console.warn("Invalid data format:", data); + return; + } + + // ... + } catch (error) { + console.error("fetchMyCourses error:", error); + } +}, +``` + +### 💡 문제 분석 + +- 기존에는 `userId` 가 컴포넌트에서 관리되고, 그 값을 `fetchMyCourses` 함수로 전달하는 방식이었다. +- 이렇게 별도로 관리되고 있다면, 응집도가 떨어진다고 한다. 그러면 상태 변경이 즉시 반영이 되지 않을 수 있기 때문에 같은 로직에서 관리하는 것이 좋다고 한다. + + +### 💬 시도한 해결 과정 + + +`fetchMyCourses` 함수 내부에서 `userId` 값을 직접 받아올 수 있도록 수정하였다. 이전에는 `userId`가 컴포넌트에서 관리되어 `fetchMyCourses`로 전달되는 구조였기 때문에, 상태가 최신화되기 전에 `getMyCourseObj` 요청이 발생하면서 이전 로그인 계정의 데이터가 요청되는 문제가 생긴 것 같다. + + + + +### 💡 실패한 시도 분석 +- `userId` 값을 최신 상태로 갱신하려는 로직을 추가했지만, 여전히 이전 로그인 계정의 데이터가 불러와지는 문제는 해결되지 않았다. +- `userId` 가 정상적으로 갱신되었는지 확인하는 디버깅 코드를 추가하여 확인한 결과, `User ID is no set` 경고 메세지가 계속해서 출력되었다. +- 이 메세지로 보아, `userId` 가 최신화되지 않은 상태에서 `getMyCourseObj` 가 요청되고 있는 것을 다시 한 번 확인할 수 있었다. + + + +### ✅ 해결 방안 모색 +- 상태가 명확하게 갱신되지 않는 문제를 해결하기 위해서는 로그아웃을 할 때 상태를 초기화하고, 로그인 시 다시 상태를 갱신해주는 로직을 명시적으로 추가할 필요성을 느꼈다. +- 로그아웃 시 `userId` 와 `userData` 를 초기화하고, 새로운 유저가 로그인할 때 상태를 다시 갱신하는 로직을 추가해보기로 했다. + + + + +전역 상태로 관리하고 있던 유저 정보의 `useUserStore` 에서 `logout` 함수를 만들어 유저 상태 초기화를 할 수 있게 구현하였다. + + +```ts +logout: () => { + set(() => ({ + userId: null, + userProfilePic: "", + userInfo: { + fullName: "", + email: "", + region: "", + image: "", + }, + })); +}, +``` + +```tsx +const { logout } = useUserStore(); + +const handleLogout = async () => { + try { + await postLogout(navigate); + logout(); + } catch (error) { + console.error("logout error:", error); + } +}; + +// ... + +``` + + +- 유저 정보와 아이디 값을 관리하는 전역 상태 파일에서 모든 유저 정보의 상태를 초기화해주는 `logout` 를 추가했다. +- 그리고 `handleLogout` 를 추가해서 로그아웃 버튼을 눌렀을 때, 함수가 실행될 수 있게 추가해주었다. + + +```tsx +useEffect(() => { + const initializeUser = async () => { + await setUserId(); + const currentUserId = useUserStore.getState().userId; + + if (currentUserId) { + fetchMyCourses(currentUserId); + } + }; + + void initializeUser(); +}, []); +``` + + +새로운 계정으로 로그인했을 때, 상태가 명확히 갱신되도록 하기 위해서 상태 갱신 로직을 추가했다. + + +### 🔑 문제 해결 + +- 정상적으로 `getMyCourseObj` 를 불러오기 전에 `userId` 값이 정상적으로 갱신되는 것을 확인했다. +- 여러 번 테스트 결과, 로그아웃/로그인 시에도 정상적으로 각 계정에 맞는 데이터를 받아오는 것을 확인했다. + + + +--- +## 2. 회원가입 후 첫 유저 정보 변경 실패 + + + +사용자가 회원가입 후 처음으로 유저 정보 변경을 시도했을 때 실패하는 문제가 발생했다. 이후 새로고침 한 후, 다시 시도했을 때는 정상적으로 반영이 되는 상황이었다. + + + +### 💬 디버깅 결과 + +- 유저 정보를 업데이트하는 API 호출은 정상적으로 동작하여 서버에는 데이터가 저장되고 있다. +- `fetchUserInfo()` 호출 시 이전 데이터가 반환하여 상태 갱신이 실패한다. +- 새로 고침 후 2번째 시도부터는 제대로 반영된다. + + + + +처음에는 상태 관리 문제, 비동기 흐름 문제 등을 중점으로 검토하고 수정해보았다. `TEST-62` 번의 시도를 해봤지만, 전혀 해결되지 않았다. + + +### 💡 추정 원인 + +- API 요청 시, 잘못된 토큰이 포함되거나 토큰 갱신이 제대로 이루어지지 않아서 서버에서 캐싱된 이전 데이터를 반환했을 가능성 +- 토큰이 `Axios` 초기화 시점에서만 설정되고 이후 갱신되지 않는 방식으로 구현되어 발생했을 가능성 + + +```ts +import axios from "axios"; +import { getToken } from "../utills/Auth/getTokenWithCloser"; + +const token = getToken(); + +export const axiosInstance = axios.create({ + baseURL: "", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, +}); +``` + +### 💬 문제의 핵심 + +- 기존 방식에서는 `axiosInstance` 를 생성할 때, `getToken` 함수로 토큰을 한 번만 가져와 `Authorization` 헤더에 설정하고 있었다. +- 이후 로그인 상태가 변하거나 토큰이 갱신돼도, 헤더에 최신 토큰이 반영되지 않았다. +- 이러한 문제로, 서버에서 잘못된 요청을 처리하거나 이전 데이터를 반환했을 가능성이 높다. + + +```ts +import axios from "axios"; +import { getToken } from "../utills/Auth/getTokenWithCloser"; + +export const axiosInstance = axios.create({ + baseURL: "", + headers: { + "Content-Type": "application/json", + }, +}); + +axiosInstance.interceptors.request.use((config) => { + const token = getToken(); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); +``` + + +- `Axios` 생성 시 기본 헤더에는 `Content-Type` 만 설정하고, +- `Request Interceptor` 를 추가해 요청 직전에 최신 토큰을 가져와 `Authorization` 헤더에 추가했다. +- 이렇게 수정하여 최신 토큰이 항상 반영되도록 했다. + + + + + +- `Axios Request Interceptor` 를 도입하여, 매 요청 직전에 최신 토큰을 가져와 설정하도록 수정했다. +- `Interceptor` 는 모든 요청에 대해 일관된 인증 로직을 적용할 수 있어 유지보수성과 안정성을 향상시킨다. + + +### 🔑 문제 해결 + +- 수정 후, 정상적으로 첫 유저 정보 변경 시에도 화면 상태가 업데이트 API의 결과를 반영하는 것을 확인했다. +- 정확한 원인은 토큰이 비동기적으로 갱신되었음에도 불구하고, 초기화 시점에만 토큰이 설정된 것이 문제였다. +- `Interceptor` 도입으로 모든 요청에서 항상 최신 상태의 토큰을 사용할 수 있게 하여 해결하였다. + + +--- + +## 3. JWT 디코딩 후 ID 값 추출 +
+ + +`user.id`를 가져와야 하는데, 오류가 뜨기 시작했다. 어떤 강의에서 서버와 클라이언트 주소가 다르면 뜨는 게 `CORS` 정책 블락이라고 들었는데, 직접 서버 코드를 수정할 수 없어서 어떻게 해야할지 고민이었다. + + + +### 💬 시도해본 것 + +일단 디코딩 한 `JWT` 값이 나오긴 해서 그 안의 데이터 구조를 보았다. + + + +- 이렇게 `Decoded JWT` 안에 `user` 가 있고 그 안에 `_id` 값이 있었다. +- 그래서 혹시나 하고 코드를 다시 한 번 쭉 살펴보면서 제대로 가져오고 있나 확인했다. +- 역시나 접근 방식에 오류가 있었다. + + +### ✅ 해결 방법 + +`return decodedToken._id;` 에서 `return decodedToken.user._id;` 으로 바꿔줬더니 `CORS` 오류도 사라지고 정상적으로 값을 불러올 수 있었다. + + +### 🔥 느낀점 + +- 이 과정에서 정말 크게 깨달았다. 뭐든 실패하면 일단 데이터 구조를 살펴보고 `console.log` 로 그 데이터를 찍어보면서 잘 가져와지는 지 체크하고, +- 어디서 문제가 발생했는지 살펴보는 게 문제를 풀어가는 데 도움이 되는 것 같다. + + +--- + +## 4. 새로고침 후 초기화 + + + +전역 상태로 관리하고 있는 유저 정보를 불러온 다음 수정 후 저장을 클릭했을 때, 성공했다는 알림창이 떴지만, 새로고침하면 다시 초기화가 되어있다. 어디서 문제가 발생하는 걸까? + + + +### 💬 시도해본 것 + +- 디버깅 코드를 넣어보면서, 서버에 저장이 되는지 먼저 확인을 했다. +- 확인 결과, 서버에는 데이터가 정상적으로 업데이트 되었지만, UI에는 동기화가 되지 않는 것을 발견했다. + + +### 💡 문제 분석 + +현재 로직에서는 성공적으로 업데이트는 되지만, 이후 서버에서 데이터를 받아와 UI에 동기화시키는 로직이 없어서 문제가 발생한 것 같다. + + +### ✅ 해결 방법 + +`getUserInfo()` 로 서버에서 유저 정보를 가져와 다시 `refreshData` 에 저장해서 업데이트 해주는 로직을 추가하여 반영해주었다. + \ No newline at end of file diff --git a/content/posts/INSIGHT/velite-test.mdx b/content/posts/INSIGHT/velite-test.mdx deleted file mode 100644 index cc51b39..0000000 --- a/content/posts/INSIGHT/velite-test.mdx +++ /dev/null @@ -1,170 +0,0 @@ ---- -title: "RSC에서의 MDX 렌더링 흐름" -slug: "rsc-mdx-rendering" -date: "2025-12-21" -category: "Insight" -series: "Nextjs" -tags: ["Mdx", "Velite", "Rsc"] -summary: "App Router에서 MDX가 Server Component 트리로 렌더링되는 흐름을 정리." -thumbnail: "" -draft: false ---- - -## 개요 - - - 이 글은 h1(제목) + h2(섹션)만 사용합니다. 하위 구조는 - {""}, {""} 같은 문서 블록으로 - 표현합니다. - - - - - MDX 블록 컴포넌트는 표현 전용으로 유지하세요. - (fetch/인증/전역상태 등 - 앱 로직 금지) - - - - MDX 블록 컴포넌트는 표현 전용으로 유지하세요. - (fetch/인증/전역상태 등 앱 로직 금지) - - -
- - -React 18부터 새롭게 추가된 컴포넌트로 서버 측에서만 실행된다. - - - -## 1. 기본 코드 블럭 (언어 자동 표시) - -```ts -export function sum(a: number, b: number) { - return a + b; -} -``` - ---- - -## 2. 파일명(title) 지정 테스트 - -```ts title="utils/sum.ts" -export function sum(a: number, b: number) { - return a + b; -} -``` - ---- - -## 3. 인라인 코드 테스트 - -이 함수는 `sum(a, b)` 형태로 호출할 수 있습니다. -리턴 타입은 `number`입니다. - ---- - -## 4. JSX / React 코드 하이라이팅 - -```tsx title="components/Button.tsx" -type ButtonProps = { - variant?: "primary" | "secondary"; - children: React.ReactNode; -}; - -export function Button({ variant = "primary", children }: ButtonProps) { - return ( - - ); -} -``` - - ---- - -## 8. CSS 코드 (속성 / 값 / pseudo) - -```css title="styles.css" -.button { - background-color: hsl(200 90% 60%); - color: white; -} - -.button:hover { - background-color: hsl(200 90% 50%); -} -``` - ---- - -## 9. Bash / CLI 명령어 - -```bash title="terminal" -pnpm run build:content -pnpm run dev -``` - ---- - -## 10. 긴 코드 + 스크롤 + 라인넘버 테스트 - -```ts title="long-file.ts" -export function veryLongFunction() { - const data = Array.from({ length: 20 }).map((_, i) => ({ - id: i, - value: Math.random(), - })); - - return data.filter((item) => item.value > 0.5); -} -``` - -확인 포인트: -- 라인넘버 정렬 -- 세로 높이 -- 스크롤 UX - ---- - -## 11. 설명 캡션 + 코드 조합 패턴 - -아래 함수는 **에러를 명시적으로 처리**하고, -리턴 타입을 통해 **명확한 계약(Contract)** 을 제공합니다. - -```ts -type Result = - | { ok: true; data: T } - | { ok: false; error: Error }; - -export async function safeFetch(url: string): Promise> { - try { - const res = await fetch(url); - const data = await res.json(); - return { ok: true, data }; - } catch (error) { - return { ok: false, error: error as Error }; - } -} -``` - ---- - - -```diff {1}#minus {2}#plus" -- const res = await fetch("/api/user/" + id) -+ const res = await fetch(`/api/user/${id}`) -``` - -```ts title="highlight-lines.ts" {2,4} -export function sum(a: number, b: number) { - const result = a + b - return result -} -``` - -```js /apple/ /banana/ -const word = "apple banana orange banana melon lemon"; -``` \ No newline at end of file diff --git a/src/lib/posts/registry/series.registry.ts b/src/lib/posts/registry/series.registry.ts index 47c0a20..1f9936e 100644 --- a/src/lib/posts/registry/series.registry.ts +++ b/src/lib/posts/registry/series.registry.ts @@ -3,6 +3,14 @@ import type { SeriesMeta } from "./series.types"; export const SERIES_META_BY_CATEGORY: Record = { Dev_log: [ + { + id: "plaist", + name: "Plaist", + description: + "고민은 줄이고, 즐거움은 더하는 데이트 코스 추천 플랫폼의 개발 기록", + category: "Dev_log", + tone: "blue", + }, { id: "roome", name: "RoomE", From a75162633a6376919bf6122743d0bb99a4bee04f Mon Sep 17 00:00:00 2001 From: choiboa Date: Sat, 17 Jan 2026 03:12:14 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20:=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EB=AF=B8=EB=B0=98=EC=98=81=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EA=B3=A0=EB=A0=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[category]/_components/CategoryHeader.tsx | 2 +- .../[category]/_components/CategorySeries.tsx | 15 ++++++------- .../_components/series/SeriesFolder.tsx | 2 +- .../_components/series/SeriesPostList.tsx | 21 ++++++++++++------- .../(shell)/_components/posts/PostCard.tsx | 8 +++---- .../_components/posts/PostListGrid.tsx | 17 +++++++-------- .../(shell)/_components/posts/PostToolbar.tsx | 2 +- src/app/(layout)/posts/[slug]/page.tsx | 2 +- src/components/mdx/Figure.tsx | 6 +++--- src/lib/posts/queries.ts | 2 +- src/lib/posts/registry/series.registry.ts | 8 +++++++ src/styles/mdx.css | 2 +- 12 files changed, 50 insertions(+), 37 deletions(-) diff --git a/src/app/(layout)/(shell)/(category)/[category]/_components/CategoryHeader.tsx b/src/app/(layout)/(shell)/(category)/[category]/_components/CategoryHeader.tsx index db7c75e..b83b54e 100644 --- a/src/app/(layout)/(shell)/(category)/[category]/_components/CategoryHeader.tsx +++ b/src/app/(layout)/(shell)/(category)/[category]/_components/CategoryHeader.tsx @@ -5,7 +5,7 @@ interface CategoryHeaderProps { export default function CategoryHeader({ title, description }: CategoryHeaderProps) { return ( -
+

{title}

diff --git a/src/app/(layout)/(shell)/(category)/[category]/_components/CategorySeries.tsx b/src/app/(layout)/(shell)/(category)/[category]/_components/CategorySeries.tsx index f25c0cb..dd489a2 100644 --- a/src/app/(layout)/(shell)/(category)/[category]/_components/CategorySeries.tsx +++ b/src/app/(layout)/(shell)/(category)/[category]/_components/CategorySeries.tsx @@ -4,7 +4,6 @@ import { FolderTone } from "@/components/common/icons/FolderIcon"; import { CategoryKey } from "@/config/categories"; import { getAllPosts, getSeriesListByCategory, PostSort } from "@/lib/posts"; - export interface SeriesItem { id: string; name: string; @@ -52,12 +51,14 @@ export default function CategorySeries({ return (
- {seriesItems.length > 0 && ( - - )} +
+ {seriesItems.length > 0 && ( + + )} +
-
+
{visible.map((series) => { const isActive = series.id === activeId; diff --git a/src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx b/src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx index 766c5d5..bea83cd 100644 --- a/src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx +++ b/src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx @@ -1,9 +1,10 @@ import PostSection from "@/app/(layout)/(shell)/_components/posts/PostSection"; import SortSelectClient from "@/components/common/controls/sort/SortSelectClient"; +import { CategoryKey } from "@/config/categories"; import { getSeriesMeta, PostSort, queryPosts } from "@/lib/posts"; interface Props { - category: string; + category: CategoryKey; series?: string; sort: PostSort; page: number; @@ -29,18 +30,22 @@ export default async function SeriesPostList({ visiblePages: 5, }); - const seriesMeta = series ? getSeriesMeta(series) : null; + const seriesMeta = series ? getSeriesMeta(series, category) : null; return (
-

{seriesMeta?.name ?? "모아보기"}

+
+

+ {seriesMeta?.name ?? "모아보기"} +

-
- - {seriesMeta?.description ?? "모든 글들을 자유롭게 둘러보세요."} - - +
+ + {seriesMeta?.description ?? "모든 글들을 자유롭게 둘러보세요."} + + +
{/* 썸네일 */}
@@ -86,7 +86,7 @@ export default function PostCard({ /> {/* 썸네일 위 바텀 그라데이션 */} -
+
{/* 포스트 내용 */} diff --git a/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx b/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx index ca96359..6eefb99 100644 --- a/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx +++ b/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx @@ -1,6 +1,7 @@ import PostCard, { PostCardProps, } from "@/app/(layout)/(shell)/_components/posts/PostCard"; +import clsx from "clsx"; interface PostListGridProps { posts: PostCardProps[]; @@ -11,17 +12,15 @@ export default function PostListGrid({ posts, cardSize = "md", }: PostListGridProps) { + const isSmall = cardSize === "sm"; return (
{posts.map((post) => (
diff --git a/src/app/(layout)/(shell)/_components/posts/PostToolbar.tsx b/src/app/(layout)/(shell)/_components/posts/PostToolbar.tsx index 4ab1667..18250d8 100644 --- a/src/app/(layout)/(shell)/_components/posts/PostToolbar.tsx +++ b/src/app/(layout)/(shell)/_components/posts/PostToolbar.tsx @@ -9,7 +9,7 @@ interface PostToolbarProps { export default function PostToolbar({ sort, tags }: PostToolbarProps) { return ( -
+
diff --git a/src/app/(layout)/posts/[slug]/page.tsx b/src/app/(layout)/posts/[slug]/page.tsx index 17ad6f1..c1f167b 100644 --- a/src/app/(layout)/posts/[slug]/page.tsx +++ b/src/app/(layout)/posts/[slug]/page.tsx @@ -65,7 +65,7 @@ export default async function PostPage({ params }: PageProps) {
-
+
{/* */} diff --git a/src/components/mdx/Figure.tsx b/src/components/mdx/Figure.tsx index c262a3e..c77c98b 100644 --- a/src/components/mdx/Figure.tsx +++ b/src/components/mdx/Figure.tsx @@ -18,7 +18,7 @@ export default function Figure({ const isSingle = images.length === 1; return ( -
+
@@ -44,7 +44,7 @@ export default function Figure({
{caption && ( -
+
{caption}
)} diff --git a/src/lib/posts/queries.ts b/src/lib/posts/queries.ts index 4d77c3b..1a0e1ca 100644 --- a/src/lib/posts/queries.ts +++ b/src/lib/posts/queries.ts @@ -11,7 +11,7 @@ export function getAllPosts(options: PostQueryOptions = {}): VelitePost[] { const filtered = includeDrafts ? velitePosts - : velitePosts.filter((post) => post.draft === false); + : velitePosts.filter((post) => post.draft !== true); return [...filtered]; } diff --git a/src/lib/posts/registry/series.registry.ts b/src/lib/posts/registry/series.registry.ts index 1f9936e..d134bcc 100644 --- a/src/lib/posts/registry/series.registry.ts +++ b/src/lib/posts/registry/series.registry.ts @@ -9,6 +9,14 @@ export const SERIES_META_BY_CATEGORY: Record = { description: "고민은 줄이고, 즐거움은 더하는 데이트 코스 추천 플랫폼의 개발 기록", category: "Dev_log", + tone: "purple", + }, + { + id: "comma", + name: "Comma", + description: + "오락실 감성을 담은 미니게임과 커뮤니티로 ‘잠깐의 쉼표’를 제공하는 플랫폼", + category: "Dev_log", tone: "blue", }, { diff --git a/src/styles/mdx.css b/src/styles/mdx.css index 59974c5..9d2605c 100644 --- a/src/styles/mdx.css +++ b/src/styles/mdx.css @@ -85,7 +85,7 @@ Inline code padding: 0.2rem 0.4rem; border-radius: 0.2rem; - background: rgba(74, 74, 74, 0.848); + background: rgba(74, 74, 74, 0.476); color: white; white-space: nowrap; } From bbe3ba7dfd660da847b13fe3f2033580fb7f8ad4 Mon Sep 17 00:00:00 2001 From: choiboa Date: Sat, 17 Jan 2026 03:12:35 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=8E=20post=20:=20comma=20&=20roome?= =?UTF-8?q?=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20=EA=B8=80=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/posts/DEV_LOG/comma-0-background.mdx | 76 ++++ content/posts/DEV_LOG/comma-1-imageupload.mdx | 212 ++++++++++ content/posts/DEV_LOG/comma-2-flappyboo.mdx | 284 ++++++++++++++ .../posts/DEV_LOG/comma-3-troubleshotting.mdx | 194 +++++++++ content/posts/DEV_LOG/plaist-0-background.mdx | 2 +- content/posts/DEV_LOG/plaist-1-my-devlog.mdx | 4 +- .../DEV_LOG/plaist-2-troubleshotting.mdx | 4 +- content/posts/DEV_LOG/roome-0-background.mdx | 245 ++++++++++++ content/posts/DEV_LOG/roome-1-dockmenu.mdx | 254 ++++++++++++ content/posts/DEV_LOG/roome-2-3dmodel.mdx | 294 ++++++++++++++ content/posts/DEV_LOG/roome-3-preview.mdx | 371 ++++++++++++++++++ content/posts/DEV_LOG/roome-4-hexagongrid.mdx | 251 ++++++++++++ velite.config.ts | 2 +- 13 files changed, 2187 insertions(+), 6 deletions(-) create mode 100644 content/posts/DEV_LOG/comma-0-background.mdx create mode 100644 content/posts/DEV_LOG/comma-1-imageupload.mdx create mode 100644 content/posts/DEV_LOG/comma-2-flappyboo.mdx create mode 100644 content/posts/DEV_LOG/comma-3-troubleshotting.mdx create mode 100644 content/posts/DEV_LOG/roome-0-background.mdx create mode 100644 content/posts/DEV_LOG/roome-1-dockmenu.mdx create mode 100644 content/posts/DEV_LOG/roome-2-3dmodel.mdx create mode 100644 content/posts/DEV_LOG/roome-3-preview.mdx create mode 100644 content/posts/DEV_LOG/roome-4-hexagongrid.mdx diff --git a/content/posts/DEV_LOG/comma-0-background.mdx b/content/posts/DEV_LOG/comma-0-background.mdx new file mode 100644 index 0000000..05acd81 --- /dev/null +++ b/content/posts/DEV_LOG/comma-0-background.mdx @@ -0,0 +1,76 @@ +--- +title: "#0. 프로젝트 기획 및 회고" +slug: "comma-0-background" +date: "2025-03-13" +category: "Dev_log" +series: "comma" +tags: ["project", "background"] +summary: "Plaist의 아이디어 도출, 사용자 인사이트, 서비스 구조, 협업 회고를 포함한 프로젝트 초기 기획 기록" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768574588/2_turhvn.jpg" +draft: false +--- +## 1. COMMA의 배경 + +### 📍 어떤 니즈가 있을까? + +현대 사회는 고물가, 빠듯한 직장 생활, 치열한 학업 등으로 인해 많은 사람들이 스트레스를 해소할 공간을 찾고 있다. 스트레스 해소 방법을 고민하던 중, 문득 게임이 떠올랐다. 게임을 즐기는 짧은 순간에도 도파민이 분비되어 정서적 안정감을 주는 등, 게임은 훌륭한 스트레스 해소 수단이 될 수 있을 것 같았다. + + + +또한 자료 조사에 따르면, 현대 사회에서 레트로 문화가 하나의 휴식 방법으로 자리 잡고 있으며, 과거의 경험을 재현하는 것이 심리적 위안을 준다는 분석이 나왔다. + + +### 📍 어떻게 도출되었을까? +- "바쁜 현대인들에게 잠깐의 여유를 제공하고, 어린 시절 오락실의 감성을 되살릴 수 있는 공간이 있다면 어떨까?" 하는 생각이 도출되었고 +- 자연스럽게 `쉬다 → 쉼 → 쉼표` 로 이어져 "추억을 깨우는 즐거운 쉼표!" 의 COMMA가 탄생했다. +- COMMA는 향수를 자극하는 경험과 새로운 소셜 연결을 동시에 제공하는 미니게임 & 커뮤니티 플랫폼으로, +- 누구나 쉽게 접근할 수 있는 미니게임으로 짧은 시간 동안 스트레스를 해소하고 성취감을 느낄 수 있다. + +--- + +## 2. COMMA의 기획 + +### 📍 주요 기능 및 게임 종류 +- 소셜 로그인 +- 전광판 +- 게임 커뮤니티 +- 게임 (테트리스 / 플래피부 / 바운스볼 / 슈팅게임 / 지뢰찾기) +- 자유 커뮤니티 +- 게시글 작성/수정 +- 댓글 +- 랭킹 +- 마이페이지 + +### 📍 IA + +서비스의 전반적인 기능과 메뉴 구조를 볼 수 있게 작성하였다. + + +
+ +--- + +## 3. KPT 회고 + + +- 퍼블리싱이 확실히 익숙해지고 빨라졌다는 것을 느낄 수 있었다. 다른 팀원분들 것도 도와주면서 더 많이 익숙해진 것 같다. +- 가장 뿌듯했던 건 이번에 처음으로 라이브러리를 활용해보았는데, 라이브러리는 이렇게 활용하고 사용하는 거구나를 알 수 있는 계기가 + + + +- 가장 아쉬웠던 점은 `supabase` 를 공부하고 싶었는데, 그렇게 하지 못했다는 점이다. 아무래도 기간이 3주밖에 없어서 잘 - 아는 팀원이 붙어서 빠르게 작업을 할 수밖에 없었다. 하지만 배워가는 시기인데, 한 번 무모하게 도전을 해봐도 좋았을 거라는 생각이 든다. +- 그래서 이번 기회에 짜여져있는 것들을 참고하면서 `supabase` 에 대해서 좀 더 공부해보려고 한다. + + + +- 이번 프로젝트는 `Vue` 로 진행했는데, 이걸 다시 `React`로 마이그레이션 해보려고 한다. +- 또한 `Supabase` 백업본을 받아서 이걸 옮기는 작업도 해보면서 한번 유지 관리를 해보려고 한다. + diff --git a/content/posts/DEV_LOG/comma-1-imageupload.mdx b/content/posts/DEV_LOG/comma-1-imageupload.mdx new file mode 100644 index 0000000..d995a32 --- /dev/null +++ b/content/posts/DEV_LOG/comma-1-imageupload.mdx @@ -0,0 +1,212 @@ +--- +title: "#1. 다중 이미지 업로드 구현" +slug: "comma-1-imageupload" +date: "2025-03-26" +category: "Dev_log" +series: "comma" +tags: ["project", "vue"] +summary: "Vue 기반 다중 이미지 업로드 기능 개발 중 발생한 문제와 해결 과정" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768575993/image_rhw8oa.jpg" +draft: false +--- + +
+ +## 💬 구성 흐름 + + +파일 선택 → 미리보기 생성 → 상위 컴포넌트로 데이터 전달 → 서버 업로드 준비 → 업로드 + + +--- + +## 🔎 컴포넌트 구조 + +### 📍 PostEditImg (부모) +- 다중 이미지 업로드 UI와 로직 담당 +- 최대 4개의 이미지 슬롯 관리 +- 업로드 트리거 및 미리보기 생성 + +### 📍 PostEditImgCard (자식) +- 개별 이미지 카드 렌더링 +- 데이터 존재 여부에 따라 미리보기 or 업로드 버튼 표시 +- size / opacity 등 props 기반 스타일링 + + +input[type="file"]는 숨기고 카드 클릭으로 input을 트리거하는 방식 + + +--- + +## 🔗 임시 URL 생성 + +- `URL.createObjectURL(file)` → 브라우저 미리보기 URL 생성 +- `` 바로 표현 가능 + +### ✔ 메모리 해제 필요 + +- createObjectURL은 자동 해제되지 않음 +- 사용 후 `URL.revokeObjectURL(url)` 호출 필수 + +#### 수정 전 + +```js +handleFileChange(event, index) { + const files = event.target.files; + if (!files || files.length === 0) return; + + const newImages = Array.from(files).map((file) => ({ + file, + preview: URL.createObjectURL(file), + })); + + this.$emit("addImage", { index, images: newImages }); + event.target.value = ""; +} +``` + +#### 수정 후 + +```js +handleFileChange(event, index) { + const files = event.target.files; + if (!files || files.length === 0) return; + + if (this.images[index] && this.images[index].preview) { + URL.revokeObjectURL(this.images[index].preview); + } + + const newImages = Array.from(files).map((file) => ({ + file, + preview: URL.createObjectURL(file), + })); + + this.$emit("addImage", { index, images: newImages }); + event.target.value = ""; +} +``` + +### 💬 코멘트 + + +미리보기 구현은 쉬운데, 메모리 해제를 고려하지 않으면 장시간 사용 시 누수 발생 가능 + + +--- + +## 🚨 이미지 추가 시 슬롯 위치 벗어남 + +
+ +### 문제 상황 + + +이미지 추가 시 0번 슬롯에 위치해야 하지만, 조건 분기 불명확으로 인해 예상치 못한 레이아웃이 발생 + + +### 관련 코드 + +```vue + +``` + +--- + +## 💡 원인 분석 + + +- Vue는 Virtual DOM 기반으로 컴포넌트 재사용을 시도함 +- v-if만 존재하고 v-else가 없으면 상태 변화 시 렌더링 우선순위가 흔들릴 수 있음 +- 커스텀 컴포넌트 재사용 시 슬롯 위치가 어긋나는 문제 발생 + + +--- + +## ✅ 해결 방법 + +### 1. v-else 명시 + +```vue + + +``` + + +분기 조건이 명확해져서 Virtual DOM이 정상적으로 슬롯을 유지 + + +--- + +### 2. key 고정값 부여 + +```vue + +``` + + +key는 Vue가 컴포넌트를 비교할 때 식별자로 활용됨 → 위치 안정화 + + +--- + +## 📍 최종 구현 패턴 + +```vue + + + +``` + + +업로드 모드 + 수정 모드 모두 지원 가능하도록 설계 + + +--- + +## 🛠️ 개선 아이디어 + +- 드래그 앤 드랍 업로드 +- Remove/Insert 시 애니메이션 추가 +- 용량 및 파일 확장자 유효성 검사 +- Multi 업로드 + Progress 표시 +- 서버 업로드 큐 관리 \ No newline at end of file diff --git a/content/posts/DEV_LOG/comma-2-flappyboo.mdx b/content/posts/DEV_LOG/comma-2-flappyboo.mdx new file mode 100644 index 0000000..b38de28 --- /dev/null +++ b/content/posts/DEV_LOG/comma-2-flappyboo.mdx @@ -0,0 +1,284 @@ +--- +title: "#2. 플래피 부 게임 구현" +slug: "comma-2-flappyboo" +date: "2025-03-26" +category: "Dev_log" +series: "comma" +tags: ["project", "kaplay", "lib"] +summary: "Kaplay 라이브러리를 활용해 Flappy-Boo 게임을 구현하며 배운 점을 정리한 기록" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768576548/image_qx7y3b.png" +draft: false +--- + +유령 캐릭터 **Boo**가 장애물을 피하며 날아가는 게임으로, `space` 키로 점프하고 장애물에 부딪히지 않고 오래 비행하는 것이 목표 + +--- + +## 📍 Kaplay 라이브러리 사용 + + +Kaplay는 `객체 관리`, `충돌 감지`, `애니메이션 처리` 등을 간단한 코드로 구현할 수 있는 게임 엔진으로, 반복적인 코드를 줄이고 빠르게 게임 로직을 구성할 수 있었다. + + +--- + +## 🎬 Kaplay 초기화 설정 + +~~~js +k = kaplay({ + width: 1300, + height: 750, + letterbox: true, + global: true, + canvas: canvas, +}); +~~~ + +- `kaplay()` : 게임 엔진 초기화 +- `letterbox` : 비율 유지 +- `global` : 전역 접근 허용 +- `canvas` : HTML 캔버스 지정 + +--- + +## 🎬 Scene 분리 — Start / Game + + +시작 화면과 실제 게임 화면을 Kaplay의 `scene` 기능으로 분리 + + +### ✔ 구현 방식 + + +`k.scene("name", callback)` / `k.go("name")` 로 전환 가능 + + +~~~js +// Start 씬 +k.scene("start", async () => { + makeBackground(k); + const playBtn = k.add([ + k.sprite("playBtn"), + k.scale(0.35), + k.area(), + k.anchor("center"), + k.pos(k.center().x + 20, k.center().y + 40), + ]); + + playBtn.onClick(goToGame); +}); + +// Main 씬 +k.scene("main", async () => { + let score = 0; + makeBackground(k); + + const player = makePlayer(k); + player.setControls(); + + player.onCollide("obstacle", async () => { + if (player.isDead) return; + player.isDead = true; + isGameStarted.value = false; + emit("open-game-over", score, currentTime.value); + reset(); + }); + + k.onKeyPress("space", () => { + if (!isGameStarted.value) startGame(); + else if (!player.isDead) player.jump(400); + }); +}); +~~~ + +--- + +## ⚙️ 게임 요소 관리 + +### 1. 스프라이트/사운드 로드 + +~~~js +k.loadSprite("boo", "/assets/images/game/flappy/Boo.png"); +k.loadSound("jump", "/assets/images/game/flappy/jump.wav"); +k.play("jump", { volume: 0.02 }); +~~~ + +### 2. 객체 추가 + +~~~js +const player = k.add([ + k.sprite("boo"), + k.pos(100, 100), + k.area(), +]); +k.setGravity(2500); +~~~ + +### 3. 프레임 업데이트 + +~~~js +k.onUpdate(() => { + if (isGameStarted.value) { + clouds.forEach((cloud) => { + cloud.move(cloud.speed, 0); + if (cloud.pos.x > canvas.width) { + cloud.pos.x = -cloud.width; + } + }); + } +}); +~~~ + +### 4. 입력 처리 + +~~~js +k.onKeyPress("space", () => { + if (!isGameStarted.value) startGame(); + else if (!player.isDead) { + player.jump(400); + if (audioEnabled.value) k.play("jump", { volume: 0.02 }); + } +}); +~~~ + +### 5. 점수 증가 + +~~~js +k.loop(1, () => { + if (isGameStarted.value && !player.isDead) { + score += 50; + scoreLabel.updateScore(score); + } +}); +~~~ + +### 6. 충돌 감지 + +~~~js +player.onCollide("obstacle", async () => { + if (player.isDead) return; + if (audioEnabled.value) k.play("hurt"); + player.isDead = true; + player.disableControls(); + isGameStarted.value = false; + obstaclesLayer.speed = 0; + map.speed = 0; + stop(); + emit("open-game-over", score, currentTime.value); + reset(); +}); +~~~ + +### 7. 카메라 설정 + +~~~js +k.setCamScale(k.vec2(1.2)); +player.onUpdate(() => { + if (isGameStarted.value && !player.isDead) { + k.setCamPos(player.pos.x + 100, 400); + } +}); +~~~ + +--- + +## 💡 화면 크기 대응 + +> 해상도가 다른 환경에서 캔버스 스케일 문제 발생 + +### ✔ 해결 방식 + +~~~js +function setCanvasSize() { + const width = window.innerWidth; + const height = window.innerHeight; + game.setSize(width, height); +} + +setCanvasSize(); +window.addEventListener("resize", setCanvasSize); +~~~ + +--- + +## 💡 장애물 무한 스크롤 구현 + + +반복 생성 대신 2개의 장애물 스프라이트를 순환 사용 + + +~~~js +const obstaclesLayer = { + speed: -100, + parts: [ + k.add([k.sprite("obstacles"), k.pos(0, 0), k.area(), k.scale(SCALE_FACTOR)]), + k.add([k.sprite("obstacles"), k.pos(IMAGE_WIDTH, 0), k.area(), k.scale(SCALE_FACTOR)]), + ], +}; + +k.onUpdate(() => { + const currentTime = performance.now(); + const deltaTime = (currentTime - lastUpdateTime) / 1000; + lastUpdateTime = currentTime; + + if (isGameStarted.value) { + for (let i = 0; i < obstaclesLayer.parts.length; i++) { + const currentPart = obstaclesLayer.parts[i]; + const nextPart = obstaclesLayer.parts[(i + 1) % obstaclesLayer.parts.length]; + + if (currentPart.pos.x < -IMAGE_WIDTH) { + currentPart.pos.x = nextPart.pos.x + IMAGE_WIDTH; + } + currentPart.move(obstaclesLayer.speed * deltaTime * 60, 0); + } + } + + if (isGameStarted.value) obstaclesLayer.speed -= 5 * deltaTime; +}); +~~~ + +--- + +## 💡 난이도 조절 + + +장애물 속도를 프레임마다 증가시키는 방식으로 난이도 상승 구현 + + +~~~js +if (isGameStarted.value) { + obstaclesLayer.speed -= 5 * deltaTime; +} +~~~ + +--- + +## 💡 충돌 처리 및 게임 종료 + + +플레이어가 장애물에 부딪힐 때 `onCollide`로 게임 오버 처리 + + +~~~js +player.onCollide("obstacle", async () => { + if (player.isDead) return; + if (audioEnabled.value) k.play("hurt"); + player.isDead = true; + player.disableControls(); + isGameStarted.value = false; + obstaclesLayer.speed = 0; + map.speed = 0; + stop(); + emit("open-game-over", score, currentTime.value); + reset(); +}); +~~~ + + +## 🛠️ 개선 아이디어 + +- 드래그 앤 드랍 업로드 +- Remove/Insert 시 애니메이션 추가 +- 용량 및 파일 확장자 유효성 검사 +- Multi 업로드 + Progress 표시 +- 서버 업로드 큐 관리 \ No newline at end of file diff --git a/content/posts/DEV_LOG/comma-3-troubleshotting.mdx b/content/posts/DEV_LOG/comma-3-troubleshotting.mdx new file mode 100644 index 0000000..958772f --- /dev/null +++ b/content/posts/DEV_LOG/comma-3-troubleshotting.mdx @@ -0,0 +1,194 @@ +--- +title: "#3. 트러블슈팅_Hz에 따른 게임 속도 차이" +slug: "comma-3-troubleshotting" +date: "2025-03-27" +category: "Dev_log" +series: "comma" +tags: ["project", "trouble"] +summary: "모니터 주사율 차이로 인해 발생한 게임 속도 차이를 Delta Time으로 보정한 구현 기록" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768574588/2_turhvn.jpg" +draft: false +--- + +## 📌 Delta Time 적용 + + + - 최종 QA 과정에서 **동일한 게임이 팀원마다 다른 속도로 실행**되는 문제가 + 발견되었다. - 모니터의 주사율(Hz)이 다르기 때문에 **프레임 기반 업데이트 + 방식에서 게임 속도가 증가**하는 현상이 발생한 것이었다. - 즉, + **고주사율(144Hz, 240Hz)** 환경에서는 게임이 더 빠르게 진행되어 난이도 편차가 + 생기는 문제가 발생했다. + + +--- + +## 💡 문제 분석 + + + - 게임 오브젝트 이동이 **FPS(초당 프레임 수)** 에 의존하고 있는 구조였다. - + `60Hz` 모니터는 1초에 60번, `144Hz` 모니터는 144번 업데이트가 호출된다. - 그 + 결과 동일한 `이동량`을 프레임마다 적용하면 **고주사율 환경에서 더 많이 + 이동하는 구조**가 된다. + + +--- + +## 🔎 관련 개념과 용어들 + +### 📍 주사율(Hz) + +모니터가 **1초에 화면을 몇 번 갱신하는지**를 나타낸다. + +- 60Hz → 1초에 60번 +- 144Hz → 1초에 144번 +- 240Hz → 1초에 240번 + +➡️ 주사율이 높을수록 **더 부드러운 화면** + +### 📍 프레임 / FPS + +- **프레임**: 화면 한 장 +- **FPS**: 1초 동안 몇 개의 프레임을 보여주는가 + +예를 들어: + +- 60FPS → 1초에 60장 +- 144FPS → 1초에 144장 + +➡️ FPS가 높을수록 **부드러운 움직임** + +--- + + + - 게임의 이동 로직이 **프레임 단위**로 처리되기 때문에 - FPS가 높을수록 더 + 자주 이동하고, 결과적으로 더 빠르게 진행된다. + + +```js +function update() { + character.x += 5; // 1프레임마다 5px 이동 +} +``` + +비교해보면: + +- 60FPS → `5 * 60 = 300px/s` +- 144FPS → `5 * 144 = 720px/s` + +➡️ **고주사율 = 더 빠른 게임 진행** + + + 동일한 게임임에도 장비 성능 차이로 난이도 차이가 발생하게 됨 + +--- + +## ✅ 해결 방법: Delta Time 적용 + + +- 각 프레임 사이에 경과한 **시간 차이를 의미**하며, +- `속도 × 시간` 방식으로 이동을 적용해 **초 단위 기반의 일정한 이동**을 보장하는 기법이다. +- Delta Time 사용 방식 : `distance = speed × deltaTime` + + +--- + +### 💬 Delta Time 적용 예시 + +```js +let lastFrameTime = 0; + +function update(timestamp) { + if (!lastFrameTime) lastFrameTime = timestamp; + + const deltaTime = (timestamp - lastFrameTime) / 1000; // ms → s + lastFrameTime = timestamp; + + character.x += 100 * deltaTime; // 1초에 100px 이동 +} +``` + + +- 60FPS → `deltaTime ≈ 1/60 ≈ 0.016s` → `100 * 0.016 ≈ 1.6px` +- 144FPS → `deltaTime ≈ 1/144 ≈ 0.007s` → `100 * 0.007 ≈ 0.7px` + +- 둘 다 : `1.6px * 60` = `100px`, `0.7px * 144` = `100px` +- ➡️ **주사율과 상관없이 동일한 이동량 보장** + + + +--- + +## 🛠 적용한 코드 + + +`requestAnimationFrame` 의 timestamp 기반 Delta Time 계산 + + +```js +let lastFrameTime = 0; + +function main(timestamp) { +if (!lastFrameTime) lastFrameTime = timestamp; + +const deltaTime = (timestamp - lastFrameTime) / 1000; +lastFrameTime = timestamp; + +if (!Enemy.isGameOver) { +update(deltaTime); +render(); +requestId.value = requestAnimationFrame(main); +} else { +stop(); +stopAllMusic(); +emits("open-game-over", score.value, currentTime.value); +cancelAnimationFrame(requestId.value); +} +} + +``` + + +- `deltaTime = (현재 - 이전) / 1000` +- 이동/속도/애니메이션에 `* deltaTime` 적용 +- 게임 종료 시 `cancelAnimationFrame` 호출 + +--- + +## 🔗 Kaplay 적용 방식 + +Kaplay의 경우 `requestAnimationFrame` 직접 사용 대신 `onUpdate()`에서 처리 + +```js +let lastUpdateTime = performance.now(); + +k.onUpdate(() => { + if (!isGameStarted.value) return; + + const currentTime = performance.now(); + const deltaTime = (currentTime - lastUpdateTime) / 1000; + lastUpdateTime = currentTime; + + for (let i = 0; i < obstaclesLayer.parts.length; i++) { + const currentPart = obstaclesLayer.parts[i]; + const nextPart = obstaclesLayer.parts[(i + 1) % obstaclesLayer.parts.length]; + + if (currentPart.pos.x < -IMAGE_WIDTH) { + currentPart.pos.x = nextPart.pos.x + IMAGE_WIDTH; + } + + currentPart.move(obstaclesLayer.speed * deltaTime * 60, 0); + } + + obstaclesLayer.speed -= 5 * deltaTime; +}); +``` + + + - `performance.now()` 기반 시간 계산 - 장애물 무한 스크롤 로직에 deltaTime 반영 + - 속도 감소에도 deltaTime 적용 + + + + - **모니터 주사율 차이와 무관하게 일정한 속도 유지** FPS 기반 난이도 차이 문제 + - 해결 → **공정한 게임 플레이 환경 확보** + diff --git a/content/posts/DEV_LOG/plaist-0-background.mdx b/content/posts/DEV_LOG/plaist-0-background.mdx index 442249e..95efe68 100644 --- a/content/posts/DEV_LOG/plaist-0-background.mdx +++ b/content/posts/DEV_LOG/plaist-0-background.mdx @@ -3,7 +3,7 @@ title: "#0. 기획 및 회고" slug: "plaist-0-background" date: "2025-01-01" category: "Dev_log" -series: "Plaist" +series: "plaist" tags: ["project", "background"] summary: "Plaist의 아이디어 도출, 사용자 인사이트, 서비스 구조, 협업 회고를 포함한 프로젝트 초기 기획 기록" thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768563819/%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_ezdahf.jpg" diff --git a/content/posts/DEV_LOG/plaist-1-my-devlog.mdx b/content/posts/DEV_LOG/plaist-1-my-devlog.mdx index 8fa7383..6af59c8 100644 --- a/content/posts/DEV_LOG/plaist-1-my-devlog.mdx +++ b/content/posts/DEV_LOG/plaist-1-my-devlog.mdx @@ -3,8 +3,8 @@ title: "#1. 나의 Dev-Log : Q&A" slug: "plaist-1-my-devlog" date: "2025-01-02" category: "Dev_log" -series: "Plaist" -tags: ["project", "background"] +series: "plaist" +tags: ["project", "react"] summary: "Plaist 프로젝트를 하며 상태 관리와 API 통신에서 겪은 고민과 배움을 Q&A 형식으로 정리한 글이다." thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768563819/%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_ezdahf.jpg" draft: false diff --git a/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx b/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx index f1dc474..b021105 100644 --- a/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx +++ b/content/posts/DEV_LOG/plaist-2-troubleshotting.mdx @@ -3,8 +3,8 @@ title: "#2. 나의 Dev-Log : 트러블슈팅" slug: "plaist-2-troubleshotting" date: "2025-01-03" category: "Dev_log" -series: "Plaist" -tags: ["project", "background"] +series: "plaist" +tags: ["project", "react", "trouble"] summary: "Plaist의 개발 구현중에 기록한 트러블 슈팅에 대하여 문제해결 과정을 정리한 글이다. " thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768563819/%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_ezdahf.jpg" draft: false diff --git a/content/posts/DEV_LOG/roome-0-background.mdx b/content/posts/DEV_LOG/roome-0-background.mdx new file mode 100644 index 0000000..4b68efb --- /dev/null +++ b/content/posts/DEV_LOG/roome-0-background.mdx @@ -0,0 +1,245 @@ +--- +title: "#0. 프로젝트 기획 및 회고" +slug: "roome-0-background" +date: "2025-03-28" +category: "Dev_log" +series: "roome" +tags: ["project", "background"] +summary: "RoomE 프로젝트가 탄생하게 된 배경과 3D 구현 도전, 팀워크, 수상 결과와 비하인드까지 정리한 회고" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768578295/%E1%84%85%E1%85%AE%E1%84%86%E1%85%B5_%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_uqit0z.jpg" +draft: false +--- +## 💬 RoomE 의 탄생 배경 + + +처음엔 멋진 프로젝트를 만들고 싶다는 마음에 신박한 아이디어를 찾으려 했다. 하지만 그런 아이디어일수록 이미 존재하는 서비스가 많았고 고민할수록 점점 무겁고 어려운 주제만 남아 막막해졌다. 그런 주제들은 구현 과정에서 부딪힐 문제들도 많아 보였고 아이디어 자체에만 집착하다 보니 결국 제자리걸음을 하고 있었다. + + + +그래서 방향을 바꾸기로 했다. 우리 모두가 공감할 수 있고 개발하는 동안 진짜 재미있고 몰입할 수 있는 주제는 무엇일까? 이 질문을 시작으로, 자연스럽게 우리가 가진 고민들을 나누게 되었다. 우리는 모두 취업을 준비하고 있었고 저마다의 걱정이 있었지만, 본질적인 고민은 같았다. "나 자신을 제대로 알지 못하는데, 어떻게 날 잘 표현할 수 있을까?" +이 고민을 해결하려면, 먼저 나를 깊이 이해하는 과정이 필요하다는 걸 깨달았다. 하지만 바쁜 일상 속에서 우리는 점점 자신을 돌아볼 기회를 잃어가고 있었다. 그래서 우리는 자아 탐색과 심리적 안정을 위한 공간을 만들기로 했다. + + + +그렇게 탄생한 RoomE는 사용자가 스스로를 더 잘 이해하고, 자신의 생각과 감정을 정리하며 성장할 수 있는 경험을 제공하는 프로젝트다. 우리는 이를 통해 각자가 자연스럽게 자신을 돌아보고 스스로의 방식으로 고민을 정리하며 앞으로 나아갈 수 있도록 돕고 싶었다. + + +
+ +--- + +## 💬 구현 가능성 + + +우리의 기획은 기대되었지만, 현실적인 고민도 컸다. 처음 구상한 콘셉트는 싸이월드와 본디에서 영감을 받아, 사용자가 개성 있는 가상의 방을 만들고, 이웃과 연결되며 상호작용하는 시스템이었다. 방들이 연결되면서 하나의 네트워크를 이루는 구조를 생각했으며, 이는 시각적으로 더 매력적이고 인터랙션한 요소가 많았기에 다른 프로젝트들과 차별화될 수 있는 강점이 많았다. + + + +하지만 이를 구현하려면 3D 환경을 조성하고 방들이 어떻게 연결될지를 계산하는 복잡한 알고리즘이 필요했다. three.js를 사용하면 가능하겠지만, 팀원 중 누구도 3D 개발 경험이 없었고 주어진 개발 기간은 단 2~3주뿐이었다. 그래서 "이걸 현실적으로 해낼 수 있을까?" 라는 고민이 깊어졌다. + + + +그럼에도 불구하고, 이 프로젝트에서 3D는 핵심 요소였다. 기획과 디자인, 서비스의 퀄리티를 살리기 위해서는 3D 구현을 포기할 수 없다고 판단했다. 결국, 팀원 중 3D를 조금이라도 접해본 내가 직접 배워가며 구현해보기로 결심했다. 실패할 가능성도 고려해 백업 플랜을 준비하고 팀원들과 함께 방향을 조율했다. + + + +기획 단계에서 three.js 의 기본 개념과 사용법을 익히기 위해 강의를 듣기 시작했다. 그러나 가장 큰 문제는 3D 모델링이었다. AI 툴을 사용해보기도 했지만, 그 결과물이 서비스의 스타일과 맞지 않았고 원하는 퀄리티를 얻기에는 한계가 있었다. 그래서 유튜브 강의로 블렌더를 배워 직접 모델링 작업에 도전하기로 했다. + + + +처음 해보는 작업이라 막막하고 완성할 수 있을지 확신할 수 없었지만, 지레 겁먹고 주저하기보다 도전해보는 것이 더 많은 것을 배울 수 있는 기회라 생각했다. + + +--- + +## 💬 최종적인 회고 + + +이번 프로젝트는 가장 힘들었지만, 가장 애정이 가는 프로젝트였다. 무엇보다도 끊임없는 도전과 팀워크의 진정한 가치를 몸소 체험할 수 있었기 때문이다. + + + +가장 큰 난관은 타이트한 일정이었다. 디자인부터 3D 구현까지 혼자 맡다 보니 시간을 최대한 효율적으로 활용해야 했다. 하지만 다행히도 팀원들이 각자 잘할 수 있는 분야에서 적극적으로 도와주었고, 내가 그 부분에 온전히 집중할 수 있도록 도와주었다. 이런 어려운 순간마다 서로 역할을 나누고 그 역할을 완수하기 위해 협력하는 과정은 정말 값진 경험이었다. + + + +모든 팀원이 각자의 자리에서 최선을 다했기에, 제한된 시간 속에서도 우리가 낼 수 있는 최고의 퀄리티를 만들어낼 수 있었다고 생각한다. 개발을 시작한 이후 처음으로 "이게 진짜 팀워크구나" 하고 실감할 수 있었다. 이 과정은 정말 힘들었지만, 동시에 즐거웠다. 각자가 맡은 일에 완전히 몰입하며, 하나의 목표를 향해 나아가는 경험이 너무 좋았고, 이런 협업을 다시 한 번 경험하고 싶다는 생각이 들었다. + + + +이 프로젝트를 무사히 끝마친 내 자신이 자랑스럽다. 중간에 어려운 순간들이 많았지만, 타협하지 않고 끝까지 도전한 경험이 가장 값진 결과라고 생각한다. 이 경험을 통해 배운 것들은 단순한 기술을 넘어, 앞으로 나아가는 데 큰 자산이 될 것이라고 느꼈다. + + +--- + +## 🏆 결과 +
+ + + +이 프로젝트의 끝이 눈에 보이는 성과로 이어져서 너무 기뻤다. 특히, 우수상은 프로젝트의 완성도와 기술적인 평가를 바탕으로 주어진 상이라 의미가 깊고, 인기상은 수료생들이 가장 마음에 들었던 프로젝트를 뽑는 상이라 더 특별한 의미가 있다. + + + +완성도를 인정받은 것도 중요하지만, 사람들의 마음에 와닿았다는 점이 더 큰 기쁨이었다. 우리가 열심히 고민하고 노력한 결과가 진정성 있게 전달되어, 사람들이 그 매력을 느꼈다는 사실이 정말 뿌듯했다. 앞으로도 더 매력적이고 많은 사람들의 마음을 사로잡을 수 있는 프로젝트를 만들어 나가고 싶다. + + +--- + +## 💬 그 이후.. + + +지금은 디벨롭하여 실제로 서비스를 운영해보고자 기획하고 있다. 시간이 부족하여 신경 쓰지 못했던 부분들과 더 추가하고 싶었던 기능들을 보완하여 출시해볼 생각이다. + + +--- + +## 💬 비하인드 + +### 📍 팀 결성 + + +이번 프로젝트는 FE 팀을 구성하고 주제 및 기획을 잡은 뒤, BE 팀을 구인하여 프로젝트를 진행해나가는 방식이었다. 그래서 부랴부랴 PR_PPT 디자인 작업도 했다. 생각보다 잘 나와서 여기에도 올려본다. + + +
+ +### 📍 3D 모델링의 역경 + +
+ + +다행히 유튜브 강의가 잘 되어 있어서, 여러 가지 강의들을 보면서 하나씩 채워갔다. + + +
+ + +특히 테마별로 방을 만드는 작업은 생각보다 재밌었다. 블렌더 툴에 점점 익숙해져서 시간도 절약할 수 있었다. 이런 프로그램들을 다룰 때마다 느끼는 점은, 어도비 툴이나 피그마처럼 한 가지 프로그램을 익혀놓으면 비슷한 유형의 프로그램을 처음 접하더라도 훨씬 수월하게 다룰 수 있다는 것이다. 나 역시 이번 경험에서 큰 도움을 받았다. + + +--- + +## 📍 조각 디자인 모음 + +### 🎨 RoomE 로고 디자인 + + +- 귀엽고 포근한 느낌으로 작업했다. +- 사이트에 실제로 올려보니 컬러가 있는 버전보다는 심플한 버전이 더 잘 어울려서 심플한 로고로 채택했다. + + +
+ +### 🎖 RoomE 메달 디자인 + + +랭킹에 사용할 메달 디자인으로, 로고의 캐릭터를 활용해 작업했다. + + +
+ +### 💿 RoomE CD 플레이어 디자인 + + +음악이 재생될 때, CD 플레이어의 CD가 돌아가는 애니메이션을 주기 위해 별도의 일러스트로 제작했다. + + +
+ +### 🔘 RoomE 버튼 디자인 + + +클릭할 때 더 역동감을 주고자, 오락실 버튼에서 착안해 제작한 버튼 UI이다. + + +
+ +### 🧩 RoomE 커스텀 UI 디자인 + + +서비스의 분위기와 어울리는 토스트/모달 UI를 별도로 디자인해 적용했다. + + +
\ No newline at end of file diff --git a/content/posts/DEV_LOG/roome-1-dockmenu.mdx b/content/posts/DEV_LOG/roome-1-dockmenu.mdx new file mode 100644 index 0000000..f9bd45c --- /dev/null +++ b/content/posts/DEV_LOG/roome-1-dockmenu.mdx @@ -0,0 +1,254 @@ +--- +title: "#1. DockMenu 컴포넌트 제작기" +slug: "roome-1-dockmenu" +date: "2025-03-31" +category: "Dev_log" +series: "roome" +tags: ["project", "react", "interaction"] +summary: "테마/취향 설정을 위한 Dock-Menu를 직접 설계·구현하며 배운 레이아웃, 상태 관리, UX 개선 포인트를 정리한 글" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1768578295/%E1%84%85%E1%85%AE%E1%84%86%E1%85%B5_%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_uqit0z.jpg" +draft: false +--- + + +- 이번에 처음으로 복잡한 css 처리를 포함한 `Dock-Menu`를 만들어 보았다. +- 이 메뉴는 [테마 설정], [취향 설정]을 위한 도구들이 모여 있으며, +- 디자인에서는 자연스러운 애니메이션을 통해 선택된 아이콘이 바뀌고, 선택된 상태가 유지되도록 설계 했다. + + +
+ +- 기본적으로 메뉴가 **호버되면 툴팁이 나타나고 선택된 메뉴는 아이콘이 변경**되며, 나머지 메뉴들은 `#ffffff` 으로 바뀐다. **메뉴가 선택되면 메뉴는 자동으로 닫히고 관련된 컴포넌트가 렌더링**된다. +- 이번에 가장 신경 쓴 부분은 **UX와 부드러운 전환**이다. + + +- `button` : 기본적으로 키보드 접근성이 제공돼서 Tab 키로 이동 가능하다. +- `span` : 텍스트나 이미지를 감싸기 위해 사용하며, 스타일링을 위해 주로 쓰이는 인라인 요소로 적합하다. +- `aria-label` : **웹 접근성**을 위한 속성으로, 스크린 리더 같은 보조 기술이 요소를 읽을 때 제공할 텍스트 설명을 설정하는 역할을 한다. + + +--- + +## 01. 배치 및 스타일링 + + +- 기본 상태 : **기본 도구 메뉴 아이콘만** 보인다. +- 호버 : 높이가 커지고, **2개의 추가 아이콘(취향 설정, 테마 설정)**이 위로 슬라이드 되어 나타난다. + + +
+ + + +메뉴의 초기 사이즈는 `76px` 였으나, 실제 화면에서 테스트해본 결과 크기가 너무 커 보였다. 그래서 일반적인 Dock-Menu는 어떤 사이즈일까 찾아보았다. + + +- 모바일 : `48~64px` +- 태블릿/PC : `56~72px` +- macOs Dock : `48~64px` + +- 결론적으로는 `64px` 로 변경하였다. + + +### 1. fixed +- 화면을 스크롤해도 **항상 같은 위치에 고정**된다. +- 하단에 고정된 Dock-Menu/floating Btn을 만들 때 사용된다. + +### 2. sticky +- **부모 요소의 스크롤에 따라 위치가 변동**된다. +- 예를 들어, 특정 섹션을 스크롤할 때 해당 섹션 안에서만 고정되는 요소를 만들 때 사용된다. + +➡️ `Dock-Menu` 는 화면 하단에 고정되므로 `fixed` 사용 + + +### 📍 Dock-Menu 구조 +```jsx +export default function DockMenu({ activeSettings, onSettingsChange }) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + const getMainButtonBackground = () => { + if (!hasSelectedSetting) { + return 'bg-white'; + } + if (!isOpen && activeSettings) { + return 'bg-white'; + } + return 'bg-transparent hover:bg-white/50'; + }; + + return ( +
+
+ +
+ {['preference', 'theme'].map((setting, index) => ( +