diff --git a/.gitignore b/.gitignore
index 5ef6a5207..f060dedd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
# testing
/coverage
+request.http
# next.js
/.next/
diff --git a/README.md b/README.md
index f5aaf6026..f44d27ed4 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,45 @@
**코드잇 12기 스프린트 내용입니다.**
+## 10주차 스프린트
+
+### 댓글 추가에 따른 revalidate 관련 이슈
+- 댓글 리스트 컴포넌트를 react-query를 사용하기 위해 클라이언트 컴포넌트로 사용했다.
+- 폼 제출을 한 후 revalidate를 강제로 페이지 단에서 하도록 서버액션을 실행시켰는데, 전혀 리스트가 최신화되지 않았다.
+- 원인을 찾아보니, 서버액션에서 revalidate를 강제 요청하는 것은 서버 컴포넌트만 해당 된다는 것이었다.
+- 이미 리액트 쿼리로 리스트를 구현하였기 때문에 수정할 수는 없었고, POST 요청을 성공적으로 했을 경우, 기존 쿼리 key에 있는 데이터 캐시를 초기화하였더니 해결 되었다.
+```js
+// onSubmit 핸들러 안에서 POST 요청 후 성공적으로 수행했을 경우 쿼리 키 기반으로 캐싱 해제
+if (result.success) {
+ queryClient.invalidateQueries({
+ queryKey: ['article-comments', Number(id)],
+ });
+}
+```
+
+### react-intersection-observer 감지오류 이슈
+- 리스트 컴포넌트 부분을 무한 스크롤링으로 구현하는 도중 옵저버가 감지가 안되는 경우가 있었다.
+- 왜 그런 지 분석하였더니, 이 옵저버는 요소가 뷰포트 영역에 안보임 -> 보임, 보임 -> 안보임 상태여야만 트리거가 되는데, limit값이 너무 작아서 보임 -> 보임 상태로만 유지되어 추가로 페칭이 되지 않은 것이었다.
+- 이 부분은 사용자 디바이스 높이에 따라 limit값을 동적으로 설정했다.
+
+### list 구현
+- 이번에 새롭게 @tanstack/react-query 라이브러리를 사용하여 서버 상태관리를 해보았다.
+- 활용해보니, 첫 리스트 페칭 로딩 상태와 향후 로딩 상태를 구분하기 쉬워서 초기에는 스켈레톤 UI로, 그 이후 데이터들은 로딩 스피너로 처리하였다.
+
+### Form 상태
+- 이번 미션에는 게시글 생성과 게시글 별 댓글 생성으로 총 2개의 폼이 필요했다.
+- 리액트 훅 폼으로 처리할 지 useActionState로 처리할 지 고민했는데, 리액트 훅 폼으로 구현한 경험이 없어서 리액트 훅 폼으로 처리했다.
+- useActionState를 쓰는 방식보다 코드가 더 길어지긴 했지만, 클라이언트 측에서 Form Valid 검사할 때 더 쉬웠다.
+- onSubmit으로 NEXT의 서버 액션을 활용하여 폼을 제출하도록 구현했다.
+
+### Token 처리
+- accessToken과 refreshToken을 판다마켓 API에서 response로 주기 때문에 해당 값을 우선 로컬스토리지에 저장해 놨다.
+ - POST 요청의 경우 사용자 식별을 위해 accessToken이 필요하다.
+- POST 요청을 할 때 우선, accessToken을 Header에 부착하여 보낸다.
+- acceessToken이 만료되면 가지고 있는 refreshToken으로 accessToken을 요청한다.
+ - refreshToken조차 만료되면 로그인 페이지로 유도하도록 구현했다.
+- 응답 받은 accessToken으로 POST 요청을 다시한다.
+
## 9주차 스프린트
### 실수했던 부분
diff --git a/package-lock.json b/package-lock.json
index eafea2d1f..a2aafb934 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,11 +9,15 @@
"version": "0.1.0",
"dependencies": {
"@fontsource/pretendard": "^5.1.0",
+ "@tanstack/react-query": "^5.64.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
+ "es-toolkit": "^1.31.0",
"next": "15.1.3",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "react-hook-form": "^7.54.2",
+ "react-intersection-observer": "^9.15.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -853,6 +857,30 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.64.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz",
+ "integrity": "sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.64.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz",
+ "integrity": "sha512-vW5ggHpIO2Yjj44b4sB+Fd3cdnlMJppXRBJkEHvld6FXh3j5dwWJoQo7mGtKI2RbSFyiyu/PhGAy0+Vv5ev9Eg==",
+ "dependencies": {
+ "@tanstack/query-core": "5.64.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -2057,6 +2085,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.31.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.31.0.tgz",
+ "integrity": "sha512-vwS0lv/tzjM2/t4aZZRAgN9I9TP0MSkWuvt6By+hEXfG/uLs8yg2S1/ayRXH/x3pinbLgVJYT+eppueg3cM6tg=="
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -4308,6 +4341,35 @@
"react": "^19.0.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.54.2",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
+ "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/react-intersection-observer": {
+ "version": "9.15.0",
+ "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.15.0.tgz",
+ "integrity": "sha512-qul9TzGgZtHIHAsLOXnRfMWNYCrqjU87HMKhRjwC8l6XSxz2Bo0xmpq5pklaXGj+brx2gSMe8lp1K17mMP2Q8w==",
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/package.json b/package.json
index 1c2c79d2e..0e88338df 100644
--- a/package.json
+++ b/package.json
@@ -10,11 +10,15 @@
},
"dependencies": {
"@fontsource/pretendard": "^5.1.0",
+ "@tanstack/react-query": "^5.64.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
+ "es-toolkit": "^1.31.0",
"next": "15.1.3",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "react-hook-form": "^7.54.2",
+ "react-intersection-observer": "^9.15.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
diff --git a/public/assets/icons/back_symbol.svg b/public/assets/icons/back_symbol.svg
new file mode 100644
index 000000000..253a47d7b
--- /dev/null
+++ b/public/assets/icons/back_symbol.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/assets/icons/plus.svg b/public/assets/icons/plus.svg
new file mode 100644
index 000000000..5bb9abf55
--- /dev/null
+++ b/public/assets/icons/plus.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/assets/images/Img_reply_empty.png b/public/assets/images/Img_reply_empty.png
new file mode 100644
index 000000000..b74f5fd1b
Binary files /dev/null and b/public/assets/images/Img_reply_empty.png differ
diff --git a/public/assets/images/auth-pages-logo.png b/public/assets/images/auth-pages-logo.png
new file mode 100644
index 000000000..37a5a9968
Binary files /dev/null and b/public/assets/images/auth-pages-logo.png differ
diff --git a/src/actions/submit-article.ts b/src/actions/submit-article.ts
new file mode 100644
index 000000000..2c845f79a
--- /dev/null
+++ b/src/actions/submit-article.ts
@@ -0,0 +1,59 @@
+'use server';
+
+export async function submitArticle(formData: FormData, accessToken: string | null, refreshToken: string | null) {
+ if (!accessToken || !refreshToken) {
+ return { success: false, message: '로그인이 필요합니다.' };
+ }
+ const formDataObject = Object.fromEntries(formData.entries());
+
+ try {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify(formDataObject),
+ });
+
+ if (response.ok) {
+ const { id }: { id: number } = await response.json();
+ return { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', id };
+ }
+ if (response.status === 401) {
+ const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ refreshToken }),
+ });
+ if (refreshResponse.ok) {
+ const { accessToken: newAccessToken } = await refreshResponse.json();
+
+ const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${newAccessToken}`,
+ },
+ body: JSON.stringify(formDataObject),
+ });
+
+ if (retryResponse.ok) {
+ const { id }: { id: number } = await retryResponse.json();
+ return { success: true, message: '게시글 생성이 완료되어 3초 후 페이지를 이동합니다.', accessToken: newAccessToken, id };
+ } else {
+ return { success: false, message: '게시글 생성 중 오류가 발생했습니다.' };
+ }
+ }
+
+ return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' };
+ }
+
+ return { success: false, message: '게시글 생성 중 오류가 발생했습니다.' };
+ } catch (error) {
+ console.log(error);
+ return { success: false, message: '서버 요청에 실패했습니다.' };
+ }
+}
diff --git a/src/actions/submit-comment.ts b/src/actions/submit-comment.ts
new file mode 100644
index 000000000..bdf6eeb9d
--- /dev/null
+++ b/src/actions/submit-comment.ts
@@ -0,0 +1,57 @@
+'use server';
+
+export async function submitComment(formData: FormData, accessToken: string | null, refreshToken: string | null, id: number) {
+ if (!accessToken || !refreshToken) {
+ return { success: false, message: '로그인이 필요합니다.' };
+ }
+ const formDataObject = Object.fromEntries(formData.entries());
+
+ try {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify(formDataObject),
+ });
+
+ if (response.ok) {
+ return { success: true, message: '댓글 등록이 완료되었습니다.' };
+ }
+ if (response.status === 401) {
+ const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ refreshToken }),
+ });
+ if (refreshResponse.ok) {
+ const { accessToken: newAccessToken } = await refreshResponse.json();
+
+ const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/articles/${id}/comments`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${newAccessToken}`,
+ },
+ body: JSON.stringify(formDataObject),
+ });
+
+ if (retryResponse.ok) {
+ return { success: true, message: '댓글 등록이 완료되었습니다.', accessToken: newAccessToken };
+ } else {
+ return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' };
+ }
+ }
+
+ return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' };
+ }
+
+ return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' };
+ } catch (error) {
+ console.log(error);
+ return { success: false, message: '서버 요청에 실패했습니다.' };
+ }
+}
diff --git a/src/actions/submit-login.ts b/src/actions/submit-login.ts
new file mode 100644
index 000000000..f918bbe3f
--- /dev/null
+++ b/src/actions/submit-login.ts
@@ -0,0 +1,39 @@
+'use server';
+
+import { SigninFailResponse, SigninSuccessResponse } from '@/types';
+
+// 이전 상태는 사용안하므로 임시 any로 지정
+// eslint-disable-next-line
+export async function submitLogin(_: any, formData: FormData) {
+ const email = formData.get('email')?.toString();
+ const password = formData.get('password')?.toString();
+
+ if (!email || !password)
+ return {
+ status: false,
+ error: '이메일과 비밀번호를 입력해주세요',
+ };
+
+ try {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/signIn`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ email, password }),
+ });
+ if (!response.ok) throw new Error(response.statusText);
+ const responseJson: SigninSuccessResponse | SigninFailResponse = await response.json();
+ return {
+ response: responseJson,
+ status: true,
+ error: '',
+ };
+ } catch (err) {
+ console.error(err);
+ return {
+ status: false,
+ error: `로그인을 실패했습니다. : ${err}`,
+ };
+ }
+}
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
new file mode 100644
index 000000000..3f06ae2bd
--- /dev/null
+++ b/src/app/(auth)/layout.tsx
@@ -0,0 +1,22 @@
+import Link from 'next/link';
+import Image from 'next/image';
+import { ReactNode } from 'react';
+
+function Header() {
+ return (
+
{article.title}
+{article.content}
+게시글
- + + 글쓰기 +