React - 다중 편집 버그 해결 과정

Obebe·2026년 4월 22일

React

목록 보기
10/12
post-thumbnail

스터디 습관 관리 앱을 개발하는 프로젝트를 진행 중 모달 안에서 여러 습관 항목의 이름을 자유롭게 편집하고, "수정 완료" 버튼 하나로 한 번에 저장하는 기능을 구현해야 했다.

항목을 클릭하면 인풋으로 전환되어 이름을 수정할 수 있고, "수정 완료"를 누르면 변경사항이 서버에 반영되는 구조였다.

언뜻 단순해 보이는 기능이었지만 테스트하다가 이상한 점을 발견했다.


버그 발견

두 가지 이상의 항목을 수정한 뒤 수정 완료를 눌렀더니, 마지막 항목만 저장되고 이전에 수정한 내역들은 반영이 되지 않았다.


원인 분석

코드에 이유가 있었다.

// 기존 코드
const [editingHabitId, setEditingHabitId] = useState(null);
const [editingValue, setEditingValue] = useState("");

editingHabitId는 현재 포커스된 항목 하나의 ID만 기억한다. 그렇기에 수정 후 다른 항목을 수정하면서 포커싱이 옮겨지면 editingHabitId가 덮어씌워지고, 이전 변경사항은 상태에 남아있지 않게 되는 것이다.

"수정 완료"로직도 마찬가지였다.

// editingHabitId 하나만 저장 → 마지막으로 편집한 항목만 API 호출
if (editingHabitId !== null && editingValue.trim() !== originalHabitName) {
  await editHabit(editingHabitId, editingValue.trim());
}

결국 저장할 수 있는 건 editingHabitId 하나뿐이니, 마지막으로 클릭한 항목만 전송됐던 것이다.


해결 방향

이것을 해결하기 위해 여러 방안을 모색해봤지만 별다른 수가 떠오르지 않았고 생각해낸 방법은 "수정 완료를 누르기 전까지 모든 변경사항을 어딘가에 쌓아두면 좋겠다"였다.

항목 하나를 추적하는 editingHabitId대신, 변경된 모든 항목을 담는 객체를 도입해보기로 했다.

// { [habitId]: 수정된 이름 } 형태로 누적
stagedEdits = {
  1: "운동하기 (수정됨)",
  3: "물 마시기 (수정됨)",
}

스테이징의 의미처럼 아직 서버에는 반영되지 않은, 저장 직전 상태를 나타내기 위해 stagedEdits로 변수명을 정했다.

API가 항목 하나씩만 받더라도 문제없었다. Promiss.all로 병렬 호출하면 여러 항목을 동시에 전송할 수 있다.

// PATCH /habits/1
// PATCH /habits/3   → 세 요청이 동시에 전송됨
// PATCH /habits/5
await Promise.all(editPromises);

구현

① stagedEdits 상태 추가

const [stagedEdits, setStagedEdits] = useState({});

② 편집값이 바뀔 때마다 누적

jsconst handleEditChange = (habitId, value) => {
  setEditingValue(value);  // 인풋 표시용 (기존 유지)
  setStagedEdits((prev) => ({ ...prev, [habitId]: value }));  // 변경사항 누적
};

③ 수정 완료 시 stagedEdits 전체를 API로 전송

jsconst handleConfirm = async () => {
  const editPromises = Object.entries(stagedEdits)
    .filter(([, name]) => name.trim())
    .map(([id, name]) => editHabit(Number(id), name.trim()));

  await Promise.all(editPromises);
  setStagedEdits({});  // 저장 후 초기화
};

파생 버그 - 수정한 내용이 화면에서 사라진다

이렇게 수정 후 테스트를 진행해보니 먼저 수정한 습관 이후에 다른 습관으로 포커싱을 옮겨 수정하려고하면 화면에는 이전 이름이 다시 나타났다.

stagedEdits에는 잘 저장되어있는데, 화면만 초기화된 것이다.


원인은 각 항목이 편집 모드에서 벗어날 때 habit.habitName(서버의 원본값)을 그대로 표시하기 때문이었다.

// 편집 모드가 아닐 때 원본값만 표시 → staged된 값이 무시됨
<span>{habit.habitName}</span>

서버 데이터는 아직 바뀌지 않았으니 어쩌면 당연한 결과였다. stagedEdits에 값이 있으면 그걸 우선 보여주도록 수정했다.

// 부모 컴포넌트 — stagedName prop 추가
<HabitItem
  stagedName={stagedEdits[habit.id]}
  ...
/>

// HabitItem — staged 값이 있으면 우선 표시, 없으면 원본
<span>{stagedName ?? habit.habitName}</span>

항목으로 다시 돌아올 때도 이전 편집값이 복원되어야 한다.

const handleEditStart = (habit) => {
  setEditingHabitId(habit.id);
  // stagedEdits에 값이 있으면 복원, 없으면 원본값으로 시작
  setEditingValue(stagedEdits[habit.id] ?? habit.habitName);
};

파생 버그 - 삭제한 항목에 API가 호출된다

항목을 삭제한 뒤 "수정 완료"를 눌렀더니 에러가 발생했다. 삭제한 항목의 ID가 stagedEdits에 그대로 남아있어, 이미 존재하지 않는 항목에 PATCH 요청이 날아갔기 때문이었다.

삭제 시 stagedEdits도 함께 정리하면 해결된다.

jsconst handleDelete = async (habitId) => {
  await removeHabit(habitId);

  setStagedEdits((prev) => {
    const next = { ...prev };
    delete next[habitId];  // 삭제한 항목은 stagedEdits에서도 제거
    return next;
  });
};

최종 흐름

"운동하기" 클릭 → "헬스" 입력
→ stagedEdits = { 1: "헬스" }

"물 마시기" 클릭 → "운동하기"는 화면에 "헬스"로 유지
→ "물 2L" 입력
→ stagedEdits = { 1: "헬스", 3: "물 2L" }

수정 완료 클릭
→ PATCH /habits/1, PATCH /habits/3 동시 호출
→ 둘 다 저장 ✅

처음에는 단순한 버그처럼 보였지만, 고치는 과정에서 파생 버그가 두 번이나 더 나왔다. 공통된 원인은 하나였다. "저장 전 클라이언트 상태"를 명확하게 설계하지 않으면 화면과 실제 데이터가 어긋난다.

stagedEdits 패턴은 할 일 목록, 설정 화면, 인라인 편집이 있는 어떤 UI에서라도 응용할 수 있다고 한다. 다음에 비슷한 UX를 구현하게 된다면 이 구조를 떠올려봐야겠다.

profile
다른 건 노력의 시간

0개의 댓글