-
Notifications
You must be signed in to change notification settings - Fork 42
[김진선] sprint9 #230
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
The head ref may contain hidden characters: "Next-\uAE40\uC9C4\uC120-sprint9"
[김진선] sprint9 #230
Conversation
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.
수고하셨어요!
정리할 부분도 있고 다듬어나갈 부분도 보이지만, 전체적으로 감을 잘 잡으시는군요 👍
리팩토링 고민이 많아보여서, 리팩토링 제안 위주로 리뷰 드려봤습니다!
그리고 서버 컴포넌트로 만들어지면 클라이언트 측 전송량이 줄어들어 성능상 이점이 있기때문에,
클라이언트 컴포넌트로 꼭 만들어야하는경우가 아니라면 use client 디렉티브를 붙이지 않는걸 권장합니다. 전체적으로 반복되는 부분들이 몇개 보여서 요건 꼭 수정해보세요!
주요 리뷰 포인트
- 클라이언트 / 서버 컴포넌트 역할 구분해보기
- 명확한 props만 남기는 습관 들여보기
- Tailwind CSS 지시어 사용해 중복 줄이고 가독성 개선하기
- 하드 코딩된 값 사용 지양하기
- 리턴문 내부에서 여러번 분기가 반복된다면 조건문 분기로 바꿔주기
- 낙관적 업데이트 + retry 옵션 같이 사용해보기
- 컴포넌트 핵심 로직에 집중할 수 있도록 loading, error 파일 만들어주기
- 관심사 응집 사용하기
textStyle?: string; | ||
leftIcon: string; | ||
iconStyle?: string; | ||
hoverColor?: HoverColor; |
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.
스타일에 관련된 props는 제거하고, 명확한 props만 남기는 습관을 들여볼까요?
variant 기반으로 관리하면 이렇게 바뀔 수 있을거예요:
"use client";
import { MouseEventHandler } from "react";
import Icon from "./Icon";
type ButtonVariant = "primary" | "secondary" | "success";
interface CustomButtonProps {
title: string;
variant?: ButtonVariant;
leftIcon?: string;
handleClick?: MouseEventHandler<HTMLButtonElement>;
btnType?: "button" | "submit";
className?: string;
}
const variantStyles = {
primary: {
container: "w-[168px] h-14 mt-6",
hoverColor: "group-hover:bg-violet-600",
iconColor: "fill-slate-900",
textColor: "text-slate-900",
},
secondary: {
container: "w-[168px] h-14 mt-6",
hoverColor: "group-hover:bg-rose-500",
iconColor: "fill-slate-900",
textColor: "text-slate-900",
},
success: {
container: "w-[168px] h-14 mt-6",
hoverColor: "group-hover:bg-lime-300",
iconColor: "fill-slate-900",
textColor: "text-slate-900",
},
};
export default function CustomButton({
title,
variant = "primary",
leftIcon,
handleClick,
btnType,
className,
}: CustomButtonProps) {
const styles = variantStyles[variant];
return (
<button
type={btnType || "button"}
disabled={false}
onClick={handleClick}
className={`group relative cursor-pointer ${styles.container} ${className || ""}`}
>
<div className="relative flex justify-center items-center gap-1 z-20 group-hover:text-amber-50 transition-colors duration-200">
{leftIcon && (
<Icon id={leftIcon} className={`h-5 w-5 ${styles.iconColor}`} />
)}
<span
className={`font-[--font-nanumsquareB] font-bold leading-none translate-y-[1.5px] ${styles.textColor}`}
>
{title}
</span>
</div>
<div
className={`absolute inset-[1px] rounded-3xl bg-[#f5f8fc] border border-black transition-colors duration-200 z-10 ${styles.hoverColor}`}
></div>
<div className="absolute left-[9px] right-[-4px] top-[5px] h-14 rounded-3xl bg-black z-0"></div>
</button>
);
}
@@ -0,0 +1,58 @@ | |||
"use client"; |
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.
해당 컴포넌트가 클라이언트에서 쓰여야하는 이유가 있나요?
name={name} | ||
placeholder={placeholder} | ||
autoComplete="off" | ||
className="absolute inset-0 px-6 py-[15px] bg-transparent z-20 text-slate-800 placeholder-slate-500 font-normal focus:outline-none" |
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.
해당 컴포넌트가 자주 쓰이는 공용 컴포넌트인만큼, 반복되는 스타일 규칙이 있다면 TailwindCSS 지시어를 사용해 코드 중복을 줄이고 가독성을 개선해보는건 어떨까요~?
const isMobile = useInnerWidth() < 768; | ||
const imgWidth = isMobile ? 71 : 151; | ||
const imgHeight = 40; |
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.
하드코딩한 숫자를 쓰지않고, 값의 명확한 의미를 전달할 수 있는 상수로 관리해보면 좋을것같네요 :)
<li | ||
className={`flex items-center justify-between px-2 py-3 h-[50px] max-w-[588px] rounded-[27px] border-2 border-slate-800 text-slate-800 | ||
${ | ||
item.isCompleted |
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.
내부에서 item.isCompleted
가 여러 번 반복되는 것보다 따로 조건문으로 분기하는게 코드 흐름이 더 명확해질 수 있고, 수정도 쉬워지고, 성능상 이점도 있겠네요.
import { MouseEvent } from "react";
import { Todo } from "../../types/todo";
import Icon from "../common/Icon";
interface TodoItemProps {
item: Todo;
onToggle: (
e: MouseEvent<HTMLButtonElement>,
id: number,
current: boolean
) => void;
disabled: boolean;
}
export default function TodoItem({ item, onToggle, disabled }: TodoItemProps) {
if (item.isCompleted) {
return (
<li className="flex items-center justify-between px-2 py-3 h-[50px] max-w-[588px] rounded-[27px] border-2 border-slate-800 text-slate-800 bg-violet-100 hover:bg-yellow-50">
<button
className="flex items-center gap-2 flex-1 text-left cursor-pointer line-through hover:no-underline"
onClick={(e) => onToggle(e, item.id, item.isCompleted)}
disabled={disabled}
>
<div className="w-8 h-8 flex items-center justify-center rounded-full bg-violet-500 border-0">
<Icon id="check" size={20} className="text-yellow-50" />
</div>
<span>{item.name}</span>
</button>
</li>
);
}
return (
<li className="flex items-center justify-between px-2 py-3 h-[50px] max-w-[588px] rounded-[27px] border-2 border-slate-800 text-slate-800 bg-yellow-50 hover:bg-violet-100">
<button
className="flex items-center gap-2 flex-1 text-left cursor-pointer hover:line-through"
onClick={(e) => onToggle(e, item.id, item.isCompleted)}
disabled={disabled}
>
<div className="w-8 h-8 flex items-center justify-center rounded-full bg-yellow-50 border-2">
</div>
<span>{item.name}</span>
</button>
</li>
);
}
Pick<Todo, "id" | "isCompleted">, | ||
MutationContext | ||
>({ | ||
mutationFn: ({ id, isCompleted }) => patchItem(id, { isCompleted }), |
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.
여기 id, isCompleted가 any 타입으로 추론되고있는데 타입 안정성을 위해 함수 인자 타입을 올바르게 설정해주시면 좋을것같네요!
mutationFn: ({ id, isCompleted }) => patchItem(id, { isCompleted }), | |
mutationFn: ({ id, isCompleted }: Pick<Todo, "id" | "isCompleted">) => patchItem(id, { isCompleted }), |
>({ | ||
mutationFn: ({ id, isCompleted }) => patchItem(id, { isCompleted }), | ||
onMutate: async ({ id, isCompleted }): Promise<MutationContext> => { | ||
// 낙관적 업데이트 |
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.
사용자 입장에서 해당 액션이 매우 빈번한 편이고, 네트워크 지연을 기다리지않고 사용성을 위해 바로 피드백을 전달한다는 측면에서 낙관적 업데이트를 적용해보셨군요!
학습한걸 바로 적용해보시는건 매우 좋은 시도입니다 :)
사용성 개선에 더해, 만약 네트워크 문제로 인해 요청이 실패한다면 자동으로 재시도해보는 옵션을 써보는건 어떨까요? 낙관적 업데이트를 적용하려 할때 같이 써주면 좋은 옵션이랍니다!
mutate 함수 옵션중에 retry 옵션을 써보시면 될것같아요!
const mutation = useMutation({
mutationFn: patchItem,
retry: 1, // 재시도 1회만
retryDelay: 300, // 재시도 간격 0.3초
});
if (isLoading) return <p>로딩 중...</p>; | ||
if (error) return <p>에러 발생</p>; |
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.
next.js app router 파일중에 loading과 error 파일을 써서 이런 로딩, 에러 상황에 대한 분기를 컴포넌트 핵심 로직에 집중할 수 있게끔 제거해보는건 어떨까요? :)
공식문서 참고해보시면 좋을듯합니다 :)
loading.js 참고
error.js 참고
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Todo에 관련된 동작을 담당하는 data fetching 로직들을 UI로부터 분리하고자하는 용도였다면,
여러 훅으로 분산시키지않고 useTodoQuery 커스텀훅을 만들어 관련 로직끼리 모아두는게 좋을것같아요 :)
우선 관심사 응집이 일어나게되어, Todo 관련 모든 데이터 로직이 한 곳에 모이게되고
좀 더 통합되고 일관된 방식으로 관련된 타입 / 쿼리 키 관리가 가능해지기때문이에요.
예시)
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { addTodo, getTodos } from "../api/todos";
import { MutationContext, Todo } from "../types/todo";
export const useTodoQuery = () => {
const queryClient = useQueryClient();
// Todo 목록 조회
const todosQuery = useQuery<Todo[], Error>({
queryKey: ["todos"],
queryFn: getTodos,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});
// Todo 추가
const addTodoMutation = useMutation<void, Error, string, MutationContext>({
mutationFn: (name: string) => addTodo(name),
onMutate: async (newTodo: string): Promise<MutationContext> => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old: Todo[] | undefined) => {
if (!old) return [];
return [
...old,
{
id: Date.now(),
name: newTodo,
isCompleted: false,
},
];
});
return { previousTodos };
},
onError: (
err: Error,
variables: string,
context: MutationContext | undefined
) => {
if (context?.previousTodos) {
queryClient.setQueryData(["todos"], context.previousTodos);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return {
// Query 상태
todos: todosQuery.data ?? [],
isLoading: todosQuery.isLoading,
isError: todosQuery.isError,
error: todosQuery.error,
getTodos: todosQuery.refetch,
// Mutations
addTodo: addTodoMutation.mutate,
// Mutation 상태
isAdding: addTodoMutation.isPending,
};
};
if (!validation.success) { | ||
const flattened = z.flattenError(validation.error); | ||
const newErrors: Record<string, string> = {}; | ||
for (const key in flattened.fieldErrors) { | ||
const messages = | ||
flattened.fieldErrors[key as keyof typeof flattened.fieldErrors]; | ||
if (messages && messages.length > 0) { | ||
newErrors[key] = messages.join(" "); | ||
} | ||
} | ||
setErrors(newErrors); | ||
return; | ||
} |
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.
이 부분 로직 너무 좋은데요? ㅎㅎ 잘하셨습니다! 👍
질문에 대한 답변
현재 구조상 HomePage에서만 쓰인다면, 말씀하신것처럼 훅으로 분리해도 재사용 빈도가 낮을 수 있고, 오히려 코드가 분산되어 추적이 어려울 수 있습니다. 저는 훅으로 분리하지않는게 나을것같네요! 리팩토링 고민이 있어보이셔서 이번 리뷰는 최대한 리팩토링 제안을 많이 드려봤습니다 :) |
요구사항
목록 조회
할 일 추가
할 일 완료
배포 링크
https://doit-jinsunkim.netlify.app/
주요 변경사항
FormInput
컴포넌트의 유효성 검사할 때 조언해 주신 대로zod
라이브러리를 사용해 보았습니다.@tanstack/react-query
라이브러리를 사용하였습니다.Tailwindcss
최신 버전(버전4)을 사용해서 스타일링을 적용해 보았습니다.스크린샷
Screen.Recording.2025-07-15.at.14.40.51.mov
멘토에게
page
(홈페이지)에서handleSubmit
함수가 좀 길이가 긴 것 같습니다. 전체적으로 훅으로 만들기에는 재사용성이 좋지는 않은 것 같아서 validation부분만 훅으로 빼는 게 좋지 않을까 고민이 됩니다.