프로그래밍/next.js

낙관적 업데이트(use optimistic) 적용 기록

d 0_0 b 2026. 7. 10. 11:57

개인적으로 진행하는 프로젝트 때문에 현장 상세 화면의 반응성을 개선했다.

 

 

 

기존에는 부품을 추가하거나 삭제할 때마다 서버 요청이 끝난 뒤 router.refresh()로 페이지 전체 데이터를 다시 불러왔다. 기능상 문제는 없었지만, 사용자가 버튼을 눌렀을 때 화면이 늦게 반응하는 느낌이 있었다.

 

 

특히 부품 삭제는 사용자가 기대하는 동작이 명확하다. 삭제 버튼을 누르면 해당 행이 바로 사라져야 한다. 하지만 기존 구조에서는 다음 순서를 거친다.

삭제 버튼 클릭
→ DELETE API 요청
→ DB 삭제
→ router.refresh()
→ 현장 상세 데이터 전체 재조회
→ 화면 갱신

서버 왕복과 전체 데이터 재조회가 끝나야 화면이 바뀌기 때문에, 실제 작업보다 느리게 느껴졌다.

 

 

 

개선 방향

이번 개선의 핵심은 단순하다.

전체 새로고침을 줄이고,
응답받은 데이터로 필요한 부분만 갱신한다.

부품 삭제의 경우에는 낙관적 업데이트를 적용했다. 사용자가 삭제 버튼을 누르는 즉시 화면에서 해당 행을 제거한다. 이후 서버 요청이 성공하면 그대로 유지하고, 실패하면 이전 상태로 되돌린다.

 

삭제 버튼 클릭
→ 화면에서 행 즉시 제거
→ DELETE API 요청
→ 성공 시 유지
→ 실패 시 이전 상태 복구

사용자 입장에서는 버튼을 누르자마자 화면이 반응한다.

 

 

적용한 내용

1. 부품 삭제 즉시 반영

삭제 전 현재 상태를 저장해 둔다.

const previousState = state;

그리고 먼저 화면 상태를 갱신한다.

const nextParts = state.parts.filter((part) => part.id !== partId);

setState((current) =>
  withUpdatedProjectCounts(current, nextParts, nextGroups),
);

서버 요청이 실패하면 저장해 둔 상태로 되돌린다.

if (!response.ok) {
  setState(previousState);
  setStatus("부품 삭제 실패");
  return;
}

이렇게 하면 삭제 버튼을 누르는 순간 행이 사라지고, 실패한 경우에만 복구된다.

2. 빈 재단 그룹 자동 정리

부품을 삭제했을 때 해당 그룹에 더 이상 남은 부품이 없으면 화면에서도 그룹을 제거한다.

const nextGroups =
  partToDelete?.groupId &&
  !nextParts.some((part) => part.groupId === partToDelete.groupId)
    ? state.groups.filter((group) => group.id !== partToDelete.groupId)
    : state.groups;

서버에서도 같은 정책을 적용했다. 마지막 부품이 삭제되면 project_cutting_group도 함께 삭제한다.

const remainingPartCount = await tx.projectCuttingPart.count({
  where: { groupId: deletedPart.groupId },
});

if (remainingPartCount === 0) {
  await tx.projectCuttingGroup.delete({
    where: { id: deletedPart.groupId },
  });
}

화면과 DB의 정책을 맞춘 것이다.

3. 생성과 수정은 응답 데이터로 부분 갱신

수동 부품 추가와 수정도 router.refresh()를 제거했다.

기존에는 저장 후 전체 페이지를 다시 불러왔다. 이제는 API가 반환한 부품 데이터만 화면 state에 반영한다.

수정일 때는 기존 부품을 교체한다.

const parts = current.parts.map((part) =>
  part.id === savedPart.id ? savedPart : part,
);

추가일 때는 목록 뒤에 붙인다.

const parts = [...current.parts, savedPart];

가구 품목 추가도 개선했다. API가 새로 생성된 재단 그룹과 부품 목록을 함께 반환하도록 바꿨다.

return jsonOk(
  {
    group: serializeCuttingGroup(group),
    parts: group.parts.map(serializeCuttingPart),
  },
  201,
);

클라이언트는 이 응답을 받아 바로 화면에 반영한다.

setState((current) =>
  withUpdatedProjectCounts(
    current,
    [...current.parts, ...created.parts],
    [...current.groups, created.group],
  ),
);

4. 버튼 중복 클릭 방지

서버 요청 중에는 버튼을 비활성화했다.

const [pendingAction, setPendingAction] = useState<PendingAction>(null);

작업별로 상태를 구분한다.

const isAddingGroup = pendingAction === "add-group";
const isSavingPart = pendingAction === "save-part";

버튼에는 disabled를 적용했다.

<button className="primary-button" disabled={isSavingPart} type="submit">
  {isSavingPart ? "저장 중" : "수동 추가"}
</button>

삭제 버튼도 같은 부품을 반복해서 누르지 못하게 처리했다.

const [deletingPartIds, setDeletingPartIds] = useState<Set<string>>(
  () => new Set(),
);
<button disabled={deletingPartIds.has(part.id)}>
  {deletingPartIds.has(part.id) ? "삭제 중" : "삭제"}
</button>

router.refresh()를 완전히 없애지는 않았다

모든 작업에서 router.refresh()를 제거하지는 않았다.

부품 추가, 수정, 삭제처럼 화면의 일부 데이터만 바꾸면 되는 작업은 클라이언트 state로 처리했다.

반면 다음 작업은 여전히 refresh를 유지했다.

재단 최적화
최적화 승인
견적 생성

이 작업들은 단순히 한 행만 바뀌는 게 아니다. 최적화 결과, 원판 배치, 견적 정보처럼 여러 데이터가 연동된다. 이런 작업까지 무리하게 클라이언트에서 맞추면 상태 관리가 복잡해지고 오류 가능성이 커진다.

그래서 이번 단계에서는 체감이 큰 부품 관리 영역부터 부분 갱신으로 바꿨다.

 

 

결과

변경 후 흐름은 이렇게 바뀌었다.

부품 삭제
→ 화면에서 즉시 제거
→ 서버 삭제 요청
→ 실패 시만 복구
부품 추가/수정
→ 서버 응답 데이터 수신
→ 해당 부품만 state 갱신
→ 전체 페이지 refresh 없음

덕분에 버튼을 눌렀을 때 화면 반응이 훨씬 빨라졌다.

이번 개선은 사용자 경험 개선에 가깝다. 서버 처리 시간이 조금 걸리더라도, 사용자가 한 행동에 대해 화면이 즉시 반응하면 서비스가 훨씬 가볍게 느껴진다는걸 적용해 보았다.