Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5d7aec1
:truck: chore : type.d.ts -> type.ts rename
ToKyun02 Jan 16, 2025
18286cb
:recycle: refator : hidden -> className props 변경
ToKyun02 Jan 16, 2025
6986bc6
:sparkles: feat : 인증 관련 페이지 공통 레이아웃 설정
ToKyun02 Jan 16, 2025
9e0e7f1
:sparkles: feat : 로그인 페이지 구현
ToKyun02 Jan 17, 2025
319af5c
:recycle: refactor : 자유게시판 페이지 /boards 페이지로 라우팅
ToKyun02 Jan 17, 2025
7a3040e
:sparkles: feat : addboard 페이지 레이아웃 구현
ToKyun02 Jan 17, 2025
b19a043
:sparkles: feat : 게시글 작성 이미지 미리보기 기능 추가
ToKyun02 Jan 17, 2025
b26042e
:sparkles: feat : 게시글 생성(폼 제출) 기능 구현
ToKyun02 Jan 17, 2025
0c1476d
:sparkles: feat : 게시글 상세페이지 제목 구역 구현
ToKyun02 Jan 17, 2025
d781456
:sparkles: feat : 댓글 등록 기능 구현
ToKyun02 Jan 17, 2025
8e16ef1
:sparkles: feat : 게시글 댓글 목록 무한 스크롤링 구현
ToKyun02 Jan 17, 2025
c2ceee0
:recycle: refacotr : 게시글 댓글 등록 시 실시간 반영
ToKyun02 Jan 18, 2025
e2a9460
:recycle: refactor : 게시글 댓글 등록 폼 중복 제출 방지
ToKyun02 Jan 18, 2025
4d96996
:recycle: refactor : 게시글 생성 후 토큰 최신화 및 페이지 이동 처리
ToKyun02 Jan 18, 2025
96eb907
:recycle: refactor : 폼 제출 상태 로직 개선 및 토큰 만료 후처리
ToKyun02 Jan 18, 2025
c907b63
:memo: docs : 10주차 내용 정리
ToKyun02 Jan 18, 2025
2155ac0
:recycle: refactor : 상세 게시글 프로필 이미지 기본 값 설정
ToKyun02 Jan 20, 2025
8e16517
:recycle: refactor : 검색 아이콘 크기 반응형 개선
ToKyun02 Jan 20, 2025
f134fd9
:recycle: refactor : 댓글 없음 처리 추가
ToKyun02 Jan 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# testing
/coverage
request.http

# next.js
/.next/
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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주차 스프린트

### 실수했던 부분
Expand Down
64 changes: 63 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions public/assets/icons/back_symbol.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/assets/icons/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/images/Img_reply_empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/images/auth-pages-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions src/actions/submit-article.ts
Original file line number Diff line number Diff line change
@@ -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: '서버 요청에 실패했습니다.' };
}
}
57 changes: 57 additions & 0 deletions src/actions/submit-comment.ts
Original file line number Diff line number Diff line change
@@ -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());
Comment on lines +3 to +7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 값을 formData로 받아와서 Object.fromEntries로 object로 만들어주는 이유가 있나요?

그냥 차라리 object로 받아오는게 편하지 않을까여?

FormData같은경우엔 안에 어떤 값이 key-value로 있는지 몰라서 사용하기 조금 어렵거든요ㅠ

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

처음엔 FormData로 바로 request 요청을 보내려고했는데, 백엔드에서 Content-type이 application/json만 허용되는 걸 뒤늦게 알아서 이렇게 했습니다..


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: '댓글 등록 중 오류가 발생했습니다.' };
}
}
Comment on lines +22 to +47
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh-token을 사용해서 재발행하고 처리하는 로직인데, 이렇게되니 관리하기가 어렵겠죠ㅠ

아마 다른 API에 경우에도 refresh 해야하는 경우가 많을텐데, 매번 이렇게 처리하면 힘들꺼에요!ㅠ

그래서 axios에 retry라던가 interceptor같은 기능을 통해 해결하면 조금더 간결하게 처리하실 수 있을꺼에요!ㅎㅎ

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에 대해서 너무 로직이 읽기 힘들다고 생각하여 고민이 많았는데, 감사합니다!


return { success: false, message: '세션이 만료되었습니다. 다시 로그인해주세요.' };
}

return { success: false, message: '댓글 등록 중 오류가 발생했습니다.' };
} catch (error) {
console.log(error);
return { success: false, message: '서버 요청에 실패했습니다.' };
}
Comment on lines +53 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외처리 깔끔하게 잘 하신것같아요ㅎㅎ!

그 console.log 말고도 console.error 도 있어요~

이렇게하면 빨간색으로 표시되여ㅋㅋ
같은 기능으로 console.warn 도 있습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다! 습관적으로 console.log로 사용한 것 같습니다..

}
39 changes: 39 additions & 0 deletions src/actions/submit-login.ts
Original file line number Diff line number Diff line change
@@ -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}`,
};
}
}
22 changes: 22 additions & 0 deletions src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Link from 'next/link';
import Image from 'next/image';
import { ReactNode } from 'react';

function Header() {
return (
<header className='flex justify-center items-center mt-14'>
<Link href='/'>
<Image src='/assets/images/auth-pages-logo.png' alt='인증 페이지 로고' width={396} height={132} priority className='w-[198px] h-[66px] md:w-[396px] md:h-[132px]' />
</Link>
</header>
);
}

export default function Layout({ children }: { children: ReactNode }) {
return (
<>
<Header />
{children}
</>
);
}
35 changes: 35 additions & 0 deletions src/app/(auth)/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import SigninForm from '@/components/Auth/SigninForm';
import Link from 'next/link';

function SignupGuide() {
return (
<div className='flex items-center gap-2'>
<span>판다마켓이 처음이신가요?</span>
<Link href='/signup' className='text-blue-100 underline decoration-blue-100'>
회원가입
</Link>
</div>
);
}

function SimpleLoginContainer() {
return (
<div className='flex justify-between items-center w-[60%] rounded-lg py-4 px-6 bg-blue-50'>
<span>간편 로그인하기</span>
<div className='flex items-center gap-4'>
<a href='https://google.com'>구글</a>
<a href='https://www.kakaocorp.com'>카카오</a>
</div>
</div>
);
}

export default function Signin() {
return (
<main className='flex flex-col items-center gap-6 mt-12'>
<SigninForm />
<SimpleLoginContainer />
<SignupGuide />
</main>
);
}
3 changes: 3 additions & 0 deletions src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Signup() {
return <main className='my-[80px]'>회원가입 페이지입니다.</main>;
}
Loading
Loading