-
Notifications
You must be signed in to change notification settings - Fork 1
무한 스크롤 기능 구현 및 공용 UI, 유틸, API 관련 파일 추가 #22
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
Changes from all commits
3abaf80
df42eae
2f82de3
1eae942
ac7905b
8ca94b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,26 @@ | ||
export default function Page() { | ||
import Link from 'next/link'; | ||
|
||
import { getInfiniteScrollData } from '@/features/post/post-list/api/post-infinite-scroll'; | ||
import { InfiniteScroll } from '@/features/post/post-list/ui/infinite-scroll'; | ||
import { Button } from '@/shared/ui/button'; | ||
|
||
export default async function Home() { | ||
const { data, nextCursor, hasMore } = await getInfiniteScrollData('', 10); | ||
|
||
return ( | ||
<div className="min-h-svh flex items-center justify-center"> | ||
<div className="flex flex-col items-center justify-center gap-4"> | ||
<h1 className="flex flex-col justify-center text-2xl font-bold"> | ||
Hello World | ||
</h1> | ||
{/* <Button size="sm">Button</Button> */} | ||
<div className="flex items-center bg-blue-500 p-4 hover:bg-blue-700"> | ||
Test | ||
</div> | ||
</div> | ||
</div> | ||
<> | ||
<Button | ||
divClassName="text-right mr-8" | ||
buttonClassName="bg-black text-white font-semibold text-base p-2 rounded" | ||
> | ||
<Link href="/posts/write">새 글 작성</Link> | ||
</Button> | ||
|
||
<InfiniteScroll | ||
postList={data} | ||
lastPostId={nextCursor} | ||
hasMore={hasMore} | ||
/> | ||
</> | ||
); | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. entities 슬라이스에, model 세그먼트에 아래코드를 작성하셨는지 이유가 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dgd03146 님 리뷰 감사합니다!😊 @rmkim7 님과 논의하여 해당 파일을 배치하였는데 처음에는 해당 코드를 단순하게 데이터를 다루는 요소의 일부라는 저의 생각 하에 model에 배치했던 것 같습니다. 리뷰를 통해 다시 논의해본 결과, API는 데이터를 가져오는 역할이고, 모델은 데이터를 가공하고 사용하는 역할이라고 의견을 모았습니다. 예를 들어, 따라서 데이터 소스 역할을 하는 해당 파일은 좋은 관점 제공해주셔서 감사합니다!🙂 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { httpClient } from '@/shared/api/http-client'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FSD에서는 index.ts를 통해서만 외부로 공개하는 걸 원칙으로 두고 있는데 세그먼트에서 코드를 직접적으로 가져오는 것은 캡슐화 원칙을 깨는 것 아닐까 하는 생각이 듭니다. Shared에서는 segment 하나당 별도의 공개 API를 적용하는게 추천된다고 하네요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 우선은 팀원인 선오님과 배럴 파일을 따로 생성할지 말지 논의하다가 |
||
import { CreatePostDTO, Post, UpdatePostDTO } from '@/shared/types/post-types'; | ||
|
||
// 게시글 생성 (POST /posts) | ||
export const createPost = async (postData: CreatePostDTO): Promise<Post> => { | ||
return httpClient<Post>('/posts', { | ||
method: 'POST', | ||
body: JSON.stringify(postData), | ||
}); | ||
}; | ||
|
||
// 모든 게시글 조회 (GET /posts) | ||
export const getPosts = async (): Promise<Post[]> => { | ||
return httpClient<Post[]>('/posts'); | ||
}; | ||
|
||
// 특정 게시글 조회 (GET /posts/{id}) | ||
export const getPostById = async (postId: string): Promise<Post> => { | ||
return httpClient<Post>(`/posts/${postId}`); | ||
}; | ||
|
||
// 게시글 수정 (PUT /posts/{id}) | ||
export const updatePost = async ( | ||
postId: string, | ||
updatedData: UpdatePostDTO | ||
): Promise<Post> => { | ||
return httpClient<Post>(`/posts/${postId}`, { | ||
method: 'PUT', | ||
body: JSON.stringify(updatedData), | ||
}); | ||
}; | ||
|
||
// 게시글 삭제 (DELETE /posts/{id}) | ||
export const deletePost = async ( | ||
postId: string | ||
): Promise<{ message: string }> => { | ||
return httpClient<{ message: string }>(`/posts/${postId}`, { | ||
method: 'DELETE', | ||
}); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { BASE_URL } from '@/shared/api/http-client'; | ||
import { GetPostsCursor } from '@/shared/types/post-types'; | ||
|
||
const commonHeaders = new Headers(); | ||
commonHeaders.append('Content-Type', 'application/json'); | ||
|
||
export async function getInfiniteScrollData( | ||
cursor?: string, | ||
limit?: number | ||
): Promise<GetPostsCursor> { | ||
const followingURL = `/posts/infinite?${new URLSearchParams({ | ||
...(cursor && { cursor }), // cursor가 있으면 추가, undefined or null이면 추가 안 함 | ||
...(limit && { limit: limit.toString() }), // limit가 있으면 추가 | ||
})}`; | ||
Comment on lines
+11
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 쿼리 스트링을 구성하는 방식에 대한 좋은 접근입니다. URLSearchParams를 활용해 쿼리 문자열을 만드는 코드가 잘 작성되어 있네요. 템플릿 리터럴 내에서 URLSearchParams 인스턴스를 사용할 때 또한, 이런 쿼리 파라미터 생성 로직을 utils 폴더에 별도 함수로 분리하면 여러 곳에서 재사용할 수 있고 코드 중복을 줄일 수 있을 것 같습니다. 예를 들어 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #22 (comment) 에서 http-client.ts 파일과 post-infinite-scroll.ts 파일을 수정하는 과정에서 해당 부분은 post-infinite-scroll.ts 파일에서 사라지게 될 것 같습니다. 대신 http-client.ts 파일에 다음과 같이 반영될 예정입니다. const url = new URL(`${BASE_URL}${endpoint}`);
// queryParams가 있을 경우 URL에 추가
if (options?.queryParams) {
Object.entries(options.queryParams).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
}
const response = await fetch(url.toString(), { 추가적으로 말씀해주신 쿼리 파라미터 생성 로직 관련 유틸리티 함수는 기존의 코드가 상당부분 변경된 관계로 좀 더 고민해보도록 하겠습니다. 이 부분은 분리할 수 있을 거라고 미처 생각하지 못 했던 포인트였는데 짚어주셔서 감사합니다! |
||
|
||
const response = await fetch(`${BASE_URL}${followingURL}`, { | ||
method: 'GET', | ||
headers: commonHeaders, | ||
}); | ||
|
||
if (!response.ok) { | ||
throw new Error(`${response.status} ${response.statusText}`); | ||
} | ||
|
||
return response.json(); | ||
Comment on lines
+16
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. http-client.ts 파일에서 이미 설정된 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. API 요청과 관련한 파일을 저는 fetch API를 사용하는 코드로 작성했고 팀원인 선오님은 httpClient를 별도로 분리하는 방식으로 코드를 작성하신 상태였는데요. 이렇게 서로 다른 방식으로 작성한 후 통일하는 과정에서 post-infinite-scroll.ts의 getInfiniteScrollData는 cursor, limit 2개의 쿼리파라미터를 props로 받아야 하는 상황인데 httpClient의 props인 endpoint, options가 cursor, limit props까지 포함할 수 있도록 추상화되지 않은 상황이었습니다. 그런데 제가 1주일 이상 자리를 비우게 된 상황이어서 이 부분은 추후 작업으로 남겨둔 채 우선 PR을 하게됐습니다. 그러다 보니 일관성 측면에서 부족한 점을 미처 수정하지 못했던 것 같습니다. @choi1five 말씀대로 일관성을 위해 아래와 같이 수정하고자 합니다.
export const BASE_URL = 'http://localhost:4000/api';
interface HttpClientOptions extends RequestInit {
queryParams?: Record<string, string | number | undefined>;
}
export const httpClient = async <T>(
endpoint: string,
options?: HttpClientOptions
): Promise<T> => {
const url = new URL(`${BASE_URL}${endpoint}`);
// queryParams가 있을 경우 URL에 추가
if (options?.queryParams) {
Object.entries(options.queryParams).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
}
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
},
...options,
});
if (!response.ok) {
throw new Error(`API 요청 실패: ${response.status}`);
}
return response.json();
};
import { httpClient } from '@/shared/api/http-client';
import { GetPostsCursor } from '@/shared/types/post-types';
export async function getInfiniteScrollData(
cursor?: string,
limit?: number
): Promise<GetPostsCursor> {
return httpClient<GetPostsCursor>('/posts/infinite', {
method: 'GET',
queryParams: {
cursor,
limit,
},
});
} 이렇게 수정하려고 하는데 추가로 수정이 더 필요한 곳이 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. API 요청 시 쿼리파라미터 처리에 대한 고민이 인상적이네요! 👍
함수 추출 후에는 fetch에서 기본 제공하지 않는 쿼리스트링 관련 구현 방식으로는 두 가지가 있을 것 같아요!
두 가지 모두 좋은 선택지라 생각되어, 프론트 동료들과 회의를 통해 결정하면 좋을 것 같습니다. 이렇게 변경하면 유지보수성이 더 향상될 것 같습니다! 물론, 제안해주신 코드처럼 내부적으로 추상화한 방식이 꼭 나쁜 것은 아니에요! 오히려 사용자 입장에서는 쿼리파라미터 생성 로직을 직접 구현할 필요 없이 queryParams 객체만 전달하면 되는 간결한 API를 사용할 수 있어 편리하다는 장점도 있을 수 있겠네요🧐 |
||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네이밍만 보면 무한 스크롤 기능을 제공하는 컴포넌트로 보이는데, 내부 코드에 post 관련 로직이 포함되어 있어서 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dgd03146 님이 말씀하신 것처럼 파일명만 보고도 구분할 수 있도록 명시하는 게 좋겠네요. post에 관한 무한스크롤 기능을 제공하는 컴포넌트라는 것을 바로 알 수 있도록 리뷰 감사합니다! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
'use client'; | ||
|
||
import { useCallback, useEffect, useRef, useState } from 'react'; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 공개 API를 통해 캡슐화를 유지하는 방식으로 리팩토링하는 게 어떨까요? 현재 코드에서는 slice 내부의 특정 구현을 직접 import하는 부분이 있어서 slice 내부 구조가 변경될 때, 이를 사용하는 모든 코드도 함께 수정해야 하는 문제가 발생할 수 도 있을 것 같습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 공개 API에 그런 장점이 있었군요!🧐 |
||
import { getInfiniteScrollData } from '@/features/post/post-list/api/post-infinite-scroll'; | ||
import { PostItem } from '@/features/post/post-list/ui/post-item'; | ||
import { formatToLocaleDate } from '@/shared/lib/format-date'; | ||
import { Post } from '@/shared/types/post-types'; | ||
|
||
type InfiniteScrollProps = { | ||
postList: Post[]; | ||
lastPostId: string | null; | ||
hasMore: boolean; | ||
}; | ||
|
||
export function InfiniteScroll({ | ||
postList, | ||
lastPostId, | ||
hasMore, | ||
}: InfiniteScrollProps) { | ||
const [loading, setLoading] = useState(false); | ||
const [posts, setPosts] = useState<Post[]>(postList); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 컴포넌트에서 props로 받아온 초기 데이터를 상태에 저장하지 않고 바로 렌더링하는 방식은 어떨까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 컴포넌트에서 받아온 props 중에서 postList의 초기 데이터를 그대로 렌더링해봤더니 따라서 postList는 업데이트 될 수 있도록 |
||
const [cursor, setCursor] = useState(lastPostId ? lastPostId : null); | ||
|
||
const loadMorePosts = useCallback(async () => { | ||
if (!hasMore || loading || !cursor) return; | ||
|
||
setLoading(true); | ||
|
||
try { | ||
const { data } = await getInfiniteScrollData(cursor, 10); | ||
setPosts((prevPosts) => [...prevPosts, ...data]); | ||
const lastItem = data.at(-1); | ||
setCursor(lastItem ? lastItem.id : null); | ||
} catch (error) { | ||
console.error('Failed to fetch more posts:', error); | ||
} finally { | ||
setLoading(false); | ||
} | ||
}, [cursor, hasMore, loading]); | ||
|
||
const target = useRef<HTMLHeadingElement>(null); | ||
|
||
useEffect(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. observer를 체크해서 데이터를 불러오는 로직은 다른 댓글 같은 곳에서 사용될 수도 있기에 커스텀 훅으로 분리해서 사용하는거도 고려해볼만 한 것 같습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 부분은 댓글팀과 논의해서 댓글에도 무한 스크롤 방식을 사용한다는 게 결정되면 @dgd03146 님 좋은 의견 감사합니다! |
||
const observer = new IntersectionObserver((entries) => { | ||
const firstEntry = entries[0]; | ||
if (firstEntry?.isIntersecting) loadMorePosts(); | ||
}); | ||
|
||
const currentTarget = target.current; | ||
if (currentTarget) observer.observe(currentTarget); | ||
|
||
return () => { | ||
if (currentTarget) observer.unobserve(currentTarget); | ||
}; | ||
}, [loadMorePosts]); | ||
|
||
return ( | ||
<div> | ||
{posts.map(({ id, title, content, author, createdAt }) => { | ||
const localeCreatedAt = formatToLocaleDate(createdAt); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버에서 응답 받은 데이터를 view에 어떻게 노출시킬지에 대한 처리가 잘 되어 있습니다. 핵심적으로 고민해볼 점은 "이러한 데이터 가공 로직을 어디에서 처리하는 것이 좋을까?"입니다. 현재는 UI 컴포넌트 내에서 직접 처리하고 계신데, 이 데이터 변환 로직을 다음과 같은 위치에 배치하는 것을 고려해볼 수 있을 것 같네요!
이렇게 관심사를 분리하면 코드 유지보수성이 향상되고 컴포넌트는 본연의 역할인 UI 표현에 집중할 수 있지 않을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제안해주신 데이터 변환 로직 위치 중에서 그래서 아래와 같이 entities/post/model 폴더 내에 post-view-model.ts 파일을 생성하려고 합니다.
뷰모델 함수 생성 후 아래와 같이 데이터 변환 로직을 분리하도록 수정할 예정입니다.
@choi1five님 덕분에 데이터 가공 로직을 어디에서 처리할지 고민해볼 수 있는 좋은 계기가 됐던 것 같습니다. 상세한 조언 감사합니다! |
||
return ( | ||
<PostItem | ||
key={id} | ||
linkPostId={id} | ||
title={title} | ||
content={content} | ||
author={author} | ||
localeCreatedAt={localeCreatedAt} | ||
/> | ||
); | ||
})} | ||
<h3 | ||
ref={target} | ||
className="mx-8 mb-4 mt-8 text-center text-9xl font-semibold" | ||
> | ||
{posts.at(-1)?.id === cursor | ||
? '*************더 많은 게시글 로딩 중****************' | ||
: ''} | ||
</h3> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import Link from 'next/link'; | ||
|
||
export function PostItem({ | ||
linkPostId = '', | ||
title = '', | ||
content = '', | ||
author = '', | ||
localeCreatedAt = '', | ||
}) { | ||
return ( | ||
<div className="mx-8 my-4 rounded border border-gray-400 p-4"> | ||
<Link href={`/posts/${linkPostId}`}> | ||
<h3 className="pb-4 font-semibold">{title}</h3> | ||
<hr /> | ||
<p className="pt-4">{content}</p> | ||
<br></br> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. 제가 못 보고 놓친 부분을 찾아주셔서 감사합니다🙏 |
||
<div className="flex flex-col gap-2"> | ||
<span className="rounded border border-gray-300 bg-gray-300 px-2 py-0.5 italic"> | ||
{author} | ||
</span> | ||
<time>{localeCreatedAt}</time> | ||
</div> | ||
</Link> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
export const BASE_URL = 'http://localhost:4000/api'; | ||
|
||
export const httpClient = async <T>( | ||
endpoint: string, | ||
options?: RequestInit | ||
): Promise<T> => { | ||
const response = await fetch(`${BASE_URL}${endpoint}`, { | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
...options, | ||
}); | ||
|
||
if (!response.ok) { | ||
throw new Error(`API 요청 실패: ${response.status}`); | ||
} | ||
|
||
return response.json(); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export function formatToLocaleDate(dateString: string): string { | ||
const date = new Date(dateString); | ||
return date.toLocaleString('ko-KR'); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
export interface Post { | ||
id: string; | ||
title: string; | ||
content: string; | ||
author: string; | ||
createdAt: string; | ||
updatedAt?: string; | ||
} | ||
|
||
export type CreatePostDTO = Pick<Post, 'title' | 'content' | 'author'>; | ||
export type UpdatePostDTO = Partial<CreatePostDTO>; | ||
|
||
interface CursorPaginationResponse<T> { | ||
data: T[]; | ||
nextCursor: string | null; | ||
hasMore: boolean; | ||
} | ||
interface OffsetPaginationResponse<T> { | ||
data: T[]; | ||
currentPage: number; | ||
totalPages: number; | ||
hasMore: boolean; | ||
} | ||
Comment on lines
+13
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. 현재는 게시글 기능을 기준으로 작성했습니다. |
||
|
||
export type GetPostsCursor = CursorPaginationResponse<Post>; | ||
export type GetPostsOffset = OffsetPaginationResponse<Post>; |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드를 살펴보니 packages/ui에 이미 정의된 button 컴포넌트가 있는데, 새로운 button 컴포넌트를 추가로 생성하셨네요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제가 직접 작성한 button 컴포넌트(shared/ui/button.tsx)는 onClick이라는 prop이 있는데 packages/ui에 정의된 button 컴포넌트를 훑어봤을 때 onClick 같은 prop이 없는 것 같아 보여서 활용하지 않았던 것 같습니다. shadcn이나 radix ui의 컴포넌트를 사용해본 적이 없어서 slot 컴포넌트와 asChild 패턴에 대해 이해하지 못 했고, 결국 더 확장된 기능이 포함된 packages/ui의 button 컴포넌트를 알아보지 못했네요😓 원오님이 짚어주신 덕분에 그냥 지나칠 수 있었던 slot과 asChild 패턴의 구조, 역할, 사용 사례 등을 찾아보게 되었습니다. 감사합니다! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
'use client'; | ||
|
||
import { MouseEventHandler } from 'react'; | ||
|
||
interface ButtonProps { | ||
divClassName?: string; | ||
buttonClassName?: string; | ||
onClick?: MouseEventHandler<HTMLButtonElement>; | ||
children: React.ReactNode; | ||
} | ||
|
||
const Button = ({ | ||
divClassName = '', | ||
buttonClassName = '', | ||
onClick, | ||
children, | ||
}: ButtonProps) => { | ||
return ( | ||
<div className={divClassName}> | ||
<button onClick={onClick} className={buttonClassName}> | ||
{children} | ||
</button> | ||
</div> | ||
); | ||
}; | ||
|
||
export { 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>
컴포넌트 내부에<Link>
컴포넌트를 사용하신 부분이 있네요. 이 구조는 HTML 웹 표준 관점에서 인터랙티브 요소 중첩으로 인해 접근성 문제가 발생할 수 있습니다.이런 방법으로 개선해보시는 건 어떨까요?
<Link>
컴포넌트에 버튼과 유사한 스타일을 적용하는 방법asChild
속성을 활용하는 방법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.
이런 구조로 사용해도 에러가 발생하지 않아서 문제가 될 거라는 생각은 못 했는데 역할이나 상호작용, 접근성 측면에서 문제가 될 수 있군요😮
제시해주신 개선 방법들을 비교해본 결과 디자인 시스템을 유지하면서도 Link 컴포넌트를 사용할 수 있고, 조건에 따른 추가 로직도 유연하게 사용할 수 있는 packages/ui의 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.
HTML5 명세 관련해 참고하시면 좋을 것 같아 공유드립니다!🧐
<a>
태그와<button>
태그는 모두 인터랙티브 콘텐츠로 분류validator.w3.org/ 사이트에서 유효성 검사를 해보면 오류로 표시되는 것을 확인하실 수 있으니 참고하시면 좋을 것 같습니다!✅