Skip to content

Conversation

@LeeTaegyung
Copy link
Collaborator

요구사항

기본

  • ‘로고’ 버튼을 클릭하면 ‘/’ 페이지로 이동합니다. (새로고침)
  • 진행 중인 할 일과 완료된 할 일을 나누어 볼 수 있습니다.
  • 상단 입력창에 할 일 텍스트를 입력하고 추가하기 버튼을 클릭하거나 엔터를 치면 할 일을 새로 생성합니다.
  • 진행 중 할 일 항목의 왼쪽 버튼을 클릭하면 체크 표시가 되면서 완료 상태가 됩니다.
  • 완료된 할 일 항목의 왼쪽 버튼을 다시 클릭하면 체크 표시가 사라지면서 진행 중 상태가 됩니다.

주요 변경사항

  • 앱라우터로 적용되었습니다.

스크린샷

image

멘토에게

  • 리액트 쿼리를 한번 써보고 싶었는데, 넥스트 앱라우터를 제대로 적용해본 적이 없어서 우선 리액트쿼리 없이 적용해봤습니다.
  • 캐시 설정을 어떻게 할지에 대한 고민이 좀 컸습니다. 투두 리스트를 불러 오는 api를 force-cache로 설정 하기엔, 가장 처음에 불러온 데이터가 계속 남아 있어서 새로고침 할 때마다 업데이트 된 데이터가 아닌 이전 데이터로 남아 있고, revalidatePath()로 체크여부에 따라 서버에 업데이트를 하고 새로운 페이지를 만들어 달라고 요청하는 것도 너무 반복되면 서버에 부하가 클거 같다는 생각도 들어서, 캐싱을 제대로 사용을 못한거 같습니다...
  • 셀프 코드 리뷰를 통해 질문 이어가겠습니다.

@LeeTaegyung LeeTaegyung added the 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. label Aug 12, 2025
try {
loadingRef.current = true;
await createTodoItem(todoText.trim());
router.refresh(); // 서버컴포넌트 새로고침
Copy link
Collaborator Author

@LeeTaegyung LeeTaegyung Aug 12, 2025

Choose a reason for hiding this comment

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

현재 컴포넌트가 아래와 같은 형태로 되어 있습니다.

page                     # 여기서 getTodoList()로 투두리스트 데이터 페칭
  ㄴ TodoAddForm.tsx     # 투두폼
  ㄴ TodoListArea.tsx    # 투두리스트 - 응답값 props로 전달

투두폼에서 submit시, TodoListArea에도 업데이트가 필요한데, 상태관리를 page에서 하고 있는게 아니기 때문에 router.refresh()를 적용하였는데,,, 현업에서도 쓰이는건지 궁금합니다.
클라이언트 상태는 유지하고 서버 컴포넌트만 새로고침 되는거라 page에서 다시 데이터페칭을 해서 TodoListArea로 내려주기 때문에 업데이트가 잘 되는거 같더라구요.
page에서 상태 관리를 하지 않는 이유는, 내부적으로 인풋 입력이나 체크박스 인풋이 바뀔때마다 불필요한 리렌더링이 발생하기 때문에 page에서는 상태관리를 하지 않았습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

네, 잘 고민해보셨네요 :)

물론 현재 방식은 불필요한 리렌더링을 방지하기 위해서 고려된 결과지만, 구조를 봤을때 UX를 고려한다면 router.refresh()를 사용할때 전체 서버 컴포넌트를 새로 로드하게되므로 화면 깜빡임이 있을 수 있고, 만약 유저가 체크 박스 상태를 변경하고 바로 새 할일을 추가할때 낙관적 업데이트가 무효화되는 문제도 생길 수 있을 것 같아요.

따라서, page에서 상태관리를 하지 않는 방식은 유지하되, 할일을 추가할때

  • React Query를 사용해 즉시 캐시 무효화 & 재검증을 처리하거나
  • router.refresh()로 전체 페이지를 새로고침하는대신 revalidatePath()을 사용해 캐시를 무효화하는 전략을 택하시는게 더 좋은 선택인것으로 보여지네요 :)

지금과 같은 경우 revalidatePath()는 서버 액션과 결합해서 쓰셔야하는데, 전체적인 과정은 공식 문서 링크 참고해보세요!

Comment on lines +37 to +41
setTodoAll((prevTodoAll) =>
prevTodoAll.map((todo) =>
todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
)
);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

useOptimistic 훅을 통해 낙관적 업데이트를 적용해봤습니다.
초기값으로 page에서 넘겨 받은 data 프롭스를 todoAll 상태에 적용시켜주고
todoAll을 useOptimistic의 초기값으로 적용하는 방법으로 진행하였습니다.

구글링을 했을 땐, api 실패를 했을 때에만 useOptimistic의 상태값이 이전 값으로 돌아간다고 해서, 처음에는 아래의 코드 순서로 진행을 하였습니다.

await updateTodoItem(id, updateData);

setTodoAll((prevTodoAll) =>
  prevTodoAll.map((todo) =>
    todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
  )
);

그런데, 성공을 했는데도 아주 잠깐 0.5초정도 이전 상태로 렌더링 되었다가 다시 원래대로 돌아오는 깜빡 거리는 현상이 있었습니다.
콘솔을 찍어서 테스트를 해보니, useOptimistic의 상태값이 이전 값으로 돌아갔다가 다시 돌아오더라구요.
그래서 api 요청 전에 기존 값을 변수에 저장하고, api 요청전에 setTodoAll을 먼저 진행하고, api 요청에 실패를 했을 때 다시 이전값으로 돌아갈 수 있도록 코드를 짜서 깜빡거림은 해결을 했습니다.
근데 이렇게 하니깐 useOptimistic 을 활용한 낙관적 업데이트가 아니라 그냥 useState로 하드코딩을 한 낙관적 업데이트가 된거 같아서 이상한 코드가 된거 같습니다..

혹시 코드상 잘못된게 있을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

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

우선 toggleOptimisticState(id)를 호출한 후에 setTodoAll을 호출하면, useOptimistic의 초기값이 변경되어 깜빡임이 발생할거예요. 그리고 useOptimistic은 자체적으로 상태를 관리하는데, 지금 보면 별도로 useState로 todoAll을 관리하고 있어서 상태 업데이트 과정에서 동기화 문제가 생기기 쉬워요.

todoAll과 같은 별도 상태를 따로 사용하지않고 상태 업데이트를 위해 useOptimistic만 사용하고, useOptimistic의 초기값을 항상 data prop으로 고정하는 방식으로 바뀌면 깜빡임이 해결될거예요.

예시를 들어드릴게요!

  const [optimisticState, toggleOptimisticState] = useOptimistic<
    TodoItemType[],
    number
  >(data, (currentState, id) => {
    return currentState.map((todo) =>
      todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
    );
  });

Comment on lines +14 to +25
interface TodoResponseBase {
id: number;
tenantId: string;
name: string;
memo: string | null;
imageUrl: string | null;
isCompleted: boolean;
}

export interface PostTodoResponse extends TodoResponseBase {}

export interface PatchTodoResponse extends TodoResponseBase {}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

스웨거에서 응답 스키마를 참고해서 타입을 지정했습니다.
근데 보니깐 POST일때나 PATCH일때 둘 다 똑같이 응답하더라구요.
그렇다고 타입 이름을 동일하게 가져가는건 아닌거 같아서 확장하는 식으로 이름만 바꿔서 적용했는데 그냥 하나의 타입명으로 정해서 내보내주는게 더 좋을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

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

네, 불필요한 관리 포인트가 추가되니 하나의 타입명으로 내보내주시는게 좋을 것 같네요 :)

@addiescode-sj addiescode-sj self-requested a review August 12, 2025 07:40
Copy link
Collaborator

@addiescode-sj addiescode-sj left a comment

Choose a reason for hiding this comment

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

태경님 고민을 깊게 하시는게 보이네요!
계속 고민하시고, 설계적으로 어떤게 나을지 학습하시다보면 좋은 결과물 만들수있을거예요 :)
잘하셨습니다! ㅎㅎ

주요 리뷰 포인트

  • 최대 컨텐츠 사이즈에 따른 srcSet 최적화
  • route groups, private folder 활용 방법 피드백
  • 캐시 무효화 및 재검증과 관련한 피드백
  • 상태 업데이트 및 동기화 문제와 관련한 피드백

const Header = () => {
return (
<header className="border-b border-slate200 bg-white">
<div className="max-w-[1200px] mx-auto">
Copy link
Collaborator

Choose a reason for hiding this comment

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

최대 컨텐츠 사이즈가 1200px이라면, next/Image를 사용할때 이 최대 컨텐츠 너비에 맞춘 srcSet을 설정하면 어떨까요? 아래 아티클 참고해서 적용해보세요! :)

참고

Copy link
Collaborator

Choose a reason for hiding this comment

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

LoadingSpinner, LoadingArea가 연관되어있기도하고 간단한 컴포넌트라 파일이 분리될 필요는 없어보여요.
하나의 파일에 Loading 관련 컴포넌트를 모아놓고 관리해보는건 어떨까요?

Copy link
Collaborator

Choose a reason for hiding this comment

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

라우팅에 관련없지만 비슷한 역할을 하는 파일끼리 그룹화하고싶었던거면 route group이나 private folder를 써보는건 어떨까요?
공식문서 Project Structure 섹션에 적용 방법이 잘 나와있답니다 :)

https://nextjs.org/docs/app/getting-started/project-structure#route-groups-and-private-folders

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

해당 컴포넌트는 투두리스트의 컴포넌트에서만 쓰일 거 같아서 컴포넌트로 분리를 했었습니다 ㅎㅎ
근데 route group이나 private folder는 app 폴더 내에서 사용 하는 거로 알고 있는데,
컴포넌트에도 적용하기도 하는건가요?
app 폴더 내로 옮겨서 _component와 같은 폴더에 Empty 관련 컴포넌트를 구성해도 가져와서 사용을 하기도 하는지 궁금합니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

app 폴더 내로 옮겨서 _component private folder를 만드는 경우가 일반적입니다 :)

try {
loadingRef.current = true;
await createTodoItem(todoText.trim());
router.refresh(); // 서버컴포넌트 새로고침
Copy link
Collaborator

Choose a reason for hiding this comment

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

네, 잘 고민해보셨네요 :)

물론 현재 방식은 불필요한 리렌더링을 방지하기 위해서 고려된 결과지만, 구조를 봤을때 UX를 고려한다면 router.refresh()를 사용할때 전체 서버 컴포넌트를 새로 로드하게되므로 화면 깜빡임이 있을 수 있고, 만약 유저가 체크 박스 상태를 변경하고 바로 새 할일을 추가할때 낙관적 업데이트가 무효화되는 문제도 생길 수 있을 것 같아요.

따라서, page에서 상태관리를 하지 않는 방식은 유지하되, 할일을 추가할때

  • React Query를 사용해 즉시 캐시 무효화 & 재검증을 처리하거나
  • router.refresh()로 전체 페이지를 새로고침하는대신 revalidatePath()을 사용해 캐시를 무효화하는 전략을 택하시는게 더 좋은 선택인것으로 보여지네요 :)

지금과 같은 경우 revalidatePath()는 서버 액션과 결합해서 쓰셔야하는데, 전체적인 과정은 공식 문서 링크 참고해보세요!

Comment on lines +37 to +41
setTodoAll((prevTodoAll) =>
prevTodoAll.map((todo) =>
todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
)
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

우선 toggleOptimisticState(id)를 호출한 후에 setTodoAll을 호출하면, useOptimistic의 초기값이 변경되어 깜빡임이 발생할거예요. 그리고 useOptimistic은 자체적으로 상태를 관리하는데, 지금 보면 별도로 useState로 todoAll을 관리하고 있어서 상태 업데이트 과정에서 동기화 문제가 생기기 쉬워요.

todoAll과 같은 별도 상태를 따로 사용하지않고 상태 업데이트를 위해 useOptimistic만 사용하고, useOptimistic의 초기값을 항상 data prop으로 고정하는 방식으로 바뀌면 깜빡임이 해결될거예요.

예시를 들어드릴게요!

  const [optimisticState, toggleOptimisticState] = useOptimistic<
    TodoItemType[],
    number
  >(data, (currentState, id) => {
    return currentState.map((todo) =>
      todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
    );
  });

Comment on lines +14 to +25
interface TodoResponseBase {
id: number;
tenantId: string;
name: string;
memo: string | null;
imageUrl: string | null;
isCompleted: boolean;
}

export interface PostTodoResponse extends TodoResponseBase {}

export interface PatchTodoResponse extends TodoResponseBase {}
Copy link
Collaborator

Choose a reason for hiding this comment

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

네, 불필요한 관리 포인트가 추가되니 하나의 타입명으로 내보내주시는게 좋을 것 같네요 :)

@addiescode-sj
Copy link
Collaborator

질문에 대한 답변

멘토에게

  • 리액트 쿼리를 한번 써보고 싶었는데, 넥스트 앱라우터를 제대로 적용해본 적이 없어서 우선 리액트쿼리 없이 적용해봤습니다.
  • 캐시 설정을 어떻게 할지에 대한 고민이 좀 컸습니다. 투두 리스트를 불러 오는 api를 force-cache로 설정 하기엔, 가장 처음에 불러온 데이터가 계속 남아 있어서 새로고침 할 때마다 업데이트 된 데이터가 아닌 이전 데이터로 남아 있고, revalidatePath()로 체크여부에 따라 서버에 업데이트를 하고 새로운 페이지를 만들어 달라고 요청하는 것도 너무 반복되면 서버에 부하가 클거 같다는 생각도 들어서, 캐싱을 제대로 사용을 못한거 같습니다...
  • 셀프 코드 리뷰를 통해 질문 이어가겠습니다.

네, 서버 부하를 줄이는것또한 중요하죠 :)
보통은 짧은 캐시 시간을 가져가면서 태그 기반으로 무효화하는 방법을 많이 채택하는것같아요.
예를 들어 revalidate옵션을 30~60초로 설정하면서 태그 기반으로 필요한 경우에만 캐시를 무효화하는거죠!

캐싱 & 서버 컴포넌트로 클라이언트측 번들 크기를 줄이는 과정 또한 적절히 프로젝트/ 페이지 컨텐츠 특성에 따라 맞춰 사용하시면 좋고,
컨텐츠에 대한 고려없이 일괄적인 기준을 과하게 적용하거나하는경우 오히려 사용자가 보고 있는 페이지에서 불일치가 발생될 수 있으니,
페이지마다 다른 렌더링 & 캐싱 전략을 구상해보시는 연습을 하면 좋을것같네요! :)

@addiescode-sj addiescode-sj merged commit 48c4a5f into codeit-bootcamp-frontend:Next-이태경 Aug 13, 2025
@LeeTaegyung LeeTaegyung mentioned this pull request Aug 13, 2025
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants