
이런 버그 경험해본 적 있으신가요?
위젯을 드래그해서 오른쪽으로 옮겼습니다.
그다음 오른쪽 설정 패널에서 라벨을 바꾸고 저장 버튼을 눌렀어요.
그런데 위젯이 원래 자리로 돌아가 있습니다.
"어? 방금 드래그했는데?"
저장 버튼을 눌렀을 뿐인데 드래그한 게 취소된 것처럼 되돌아가는 버그입니다.
처음 이 버그를 만났을 때 원인이 뭔지 바로 파악하기 어려웠어요.
드래그 코드 문제인지, 저장 코드 문제인지, 아니면 렌더링 문제인지.
결론부터 말하면, 세 가지 중 어느 것도 아니었습니다.
자동저장이 아직 서버에 반영되지 않은 상태에서 다른 저장이 서버에서 "오래된 위치"를 가져온 것이 문제였습니다.
용어 먼저 짚고 넘어갈게요

자동저장이란
사용자가 저장 버튼을 누르지 않아도 일정 조건이 되면 자동으로 저장하는 기능입니다.
구글 독스에서 글을 쓰면 저장 버튼 없이 자동으로 저장되는 것과 같아요.
드래그 중에 매번 저장하면 서버 부하가 크기 때문에, 일정 시간 조작이 없거나 변경 횟수가 쌓이면 한 번에 저장하는 방식을 씁니다.
로컬 상태란
React 컴포넌트 안의 useState에 저장된 값입니다.
서버 DB에 아직 저장되지 않았을 수 있지만, 화면에는 반영되어 있는 상태예요.
메모지에 임시로 적어둔 내용이라고 생각하면 됩니다.
최종 저장이 되기 전까지는 메모지에만 있고, DB에는 아직 없어요.
서버 상태란
실제로 DB에 저장된 값입니다.
API를 호출해서 받아오는 응답이에요.
DB는 항상 "마지막으로 성공한 저장" 기준의 값을 가지고 있습니다.
PATCH 요청이란
서버에 "이 항목의 일부 속성만 바꿔줘"라고 요청하는 방식입니다.
위젯의 라벨만 바꾸거나, 색상만 바꾸거나 할 때 씁니다.
서버는 요청받은 속성만 바꾸고 나머지는 DB에 있던 값 그대로 응답합니다.
왜 이 버그가 생기나 : 타임라인으로 보기

버그가 생기는 순서를 시간순으로 따라가 보겠습니다.
시간 흐름
──────────────────────────────────────────────────────
t=0s 위젯 드래그 시작
로컬 상태: x=300 (움직이는 중)
DB 상태: x=100 (아직 반영 전)
t=2s 드래그 완료
로컬 상태: x=300
자동저장 타이머 시작 (3초 후 저장 예정)
t=3s 패널에서 라벨 변경 → "저장" 버튼 클릭
PATCH 요청 전송: { label: "새 라벨" }
t=3.1s 서버가 PATCH 응답 반환
서버 응답: { x: 100, label: "새 라벨" }
(DB에는 아직 x=100이 남아있었음)
t=3.1s React가 서버 응답으로 로컬 상태 업데이트
로컬 상태: x=100 ← 다시 덮어씌워짐
위젯이 원래 위치로 돌아감
t=5s 자동저장 실행
이미 위치가 x=100으로 돌아간 상태라
의미 없음
핵심은 t=3.1s입니다.
서버 응답에 x: 100이 포함되어 있었고, 그게 로컬 상태를 덮어씌운 거예요.
냉장고 비유로 설명하면 이렇습니다.
내가 냉장고 안 배치를 바꿨습니다(드래그).
그런데 아직 수첩(DB)에 새 배치를 기록하지 않은 상태예요.
그때 누군가 수첩을 보고 "냉장고 배치가 이렇구나"라고 다시 정리해버렸습니다(PATCH 응답으로 상태 덮어쓰기).
내가 바꾼 배치가 사라지고 수첩에 있던 오래된 배치로 돌아간 거예요.
처음에 생각한 해결 방법들

버그를 발견하고 나서 여러 방법을 생각해봤습니다.
"자동저장 속도를 더 빠르게 하면 되지 않나?"
자동저장 간격을 3초에서 0.5초로 줄이면 패널에서 저장을 누르기 전에 자동저장이 먼저 끝날 수도 있습니다.
그런데 이건 운에 맡기는 방법이에요.
빠른 사용자라면 0.5초 안에 패널 저장을 누를 수도 있고, 네트워크가 느린 환경이라면 더 벌어질 수도 있습니다.
근본 해결이 아닙니다.
"PATCH 요청 전에 자동저장이 됐는지 확인하면 되지 않나?"
패널 저장 버튼을 누르기 전에 "자동저장이 완료됐는지" 체크하고, 안 됐으면 기다렸다가 PATCH를 보내는 방식입니다.
구현할 수 있지만 복잡해집니다.
저장 버튼이 눌렸는데 "잠깐만요, 자동저장 중..." 같은 상태를 보여줘야 하는데, 사용자 경험이 이상해집니다.
"서버 응답을 로컬 상태에 반영하지 말면 되지 않나?"
PATCH 응답을 받아도 로컬 상태를 업데이트하지 않으면 됩니다.
그러면 서버 응답이 로컬을 덮어쓸 일이 없어요.
그런데 이러면 다른 문제가 생깁니다.
서버에서 값을 계산하거나 변환해서 돌려주는 경우, 그 결과를 화면에 반영할 수 없습니다.
진짜 해결 : PATCH에 현재 로컬 위치를 같이 보낸다

가장 단순하면서 근본적인 해결이었습니다.
패널에서 "라벨 저장" 버튼을 누를 때, 라벨만 보내는 게 아니라 현재 로컬 상태의 위치(x, y, w, h)를 함께 보내는 것입니다.
// 수정 전 : 라벨만 PATCH
const handleLabelSave = async (newLabel) => {
const updated = await api.patch(widgetId, {
label: newLabel
// x, y, w, h 없음 → 서버가 DB 값(오래된 위치) 반환
})
setWidgets(prev =>
prev.map(w => w.id === widgetId ? updated : w)
// DB 응답으로 로컬 상태 덮어씌워짐
)
}
// 수정 후 : 현재 로컬 위치를 함께 PATCH
const handleLabelSave = async (newLabel) => {
const currentWidget = widgets.find(w => w.id === widgetId)
const updated = await api.patch(widgetId, {
x: currentWidget.x, // 현재 로컬 위치 포함
y: currentWidget.y,
w: currentWidget.w,
h: currentWidget.h,
label: newLabel // 바꾸려는 속성
})
setWidgets(prev =>
prev.map(w => w.id === widgetId ? updated : w)
// 이제 서버 응답에 현재 위치가 담겨 있어서
// 로컬 상태를 덮어써도 위치가 바뀌지 않음
)
}
서버 응답에 이미 현재 위치가 담겨 있으니까, 응답으로 로컬 상태를 덮어써도 위치가 변하지 않습니다.
더 일반화된 패턴 : mergedPatch

패널에서 바꿀 수 있는 속성이 라벨만이 아니라 색상, 소수점 자리수, 단위 등 여러 가지입니다.
매번 "현재 위치를 같이 보내야 한다"는 걸 기억해서 코딩하는 건 실수할 가능성이 있어요.
그래서 패치를 보내는 함수를 하나로 통일하고, 항상 현재 로컬 위치를 병합해서 보내도록 했습니다.
// 패널의 모든 저장이 이 함수를 통과함
const handleWidgetUpdate = async (patch) => {
// 선택된 위젯의 현재 로컬 위치 조회
const localWidget = widgets.find(w => w.id === selectedWidgetId)
// 현재 위치 + 수정하려는 속성을 병합
const mergedPatch = localWidget
? {
x: localWidget.x,
y: localWidget.y,
w: localWidget.w,
h: localWidget.h,
...patch // 라벨이든 색상이든 여기서 덮어씀
}
: patch
const updated = await api.patch(selectedWidgetId, mergedPatch)
setWidgets(prev =>
prev.map(w => w.id === selectedWidgetId ? updated : w)
)
}
// 패널의 각 저장 버튼은 이렇게만 쓰면 됨
handleWidgetUpdate({ label: "새 라벨" })
handleWidgetUpdate({ color: "#ff0000" })
handleWidgetUpdate({ decimal: 2, unit: "RPM" })
패널에서 뭘 바꾸든 handleWidgetUpdate를 통과하면 현재 로컬 위치가 자동으로 포함됩니다.
각 저장 버튼에서 위치를 신경 쓸 필요가 없어요.
자동저장은 어떻게 설계했나

충돌 문제와 별개로, 자동저장 자체도 부하를 최소화하도록 설계했습니다.
드래그할 때마다 API를 호출하면 어떻게 될까요?
위젯을 2초 동안 드래그하면 mousemove 이벤트가 수백 번 발생합니다.
매번 API를 부르면 서버에 수백 개의 요청이 가요. 서버도 힘들고, 응답들이 뒤섞여서 상태가 이상해질 수 있습니다.
해결책은 두 조건 중 하나가 될 때 한 번만 저장하는 것입니다.
조건 1 : 변경 횟수가 5회 누적됐다
조건 2 : 마지막 조작 후 3초가 지났다
둘 중 먼저 되는 조건에서 PUT /batch 한 번 호출
타이머와 카운터를 함께 쓰는 방식입니다.
const triggerAutosave = useCallback(() => {
pendingCountRef.current++
// 기존 타이머 취소
clearTimeout(idleTimerRef.current)
const doSave = () => {
pendingCountRef.current = 0
batchSaveToServer(widgetsRef.current)
}
// 5회 쌓이면 즉시 저장
if (pendingCountRef.current >= 5) {
doSave()
} else {
// 3초 후 저장 (새 조작이 오면 타이머 리셋)
idleTimerRef.current = setTimeout(doSave, 3000)
}
}, [])
드래그 중에는 triggerAutosave가 계속 호출되지만, 실제 API 호출은 5회 간격 또는 3초 유휴 시 한 번만 나갑니다.
두 가지를 합치면 이렇게 됩니다

자동저장과 충돌 방지 패턴을 같이 쓰면 전체 흐름이 이렇습니다.
드래그 시작
↓
로컬 상태 업데이트 (즉시 화면 반영)
↓
triggerAutosave 호출 (카운트 누적)
↓
[5회 or 3초 후] 자동저장 → PUT /batch
↓
패널에서 라벨 저장 클릭
↓
handleWidgetUpdate({ label: "새 라벨" })
↓
mergedPatch = { x:현재, y:현재, w:현재, h:현재, label:"새 라벨" }
↓
PATCH 요청 → 서버 응답에 현재 위치 포함
↓
로컬 상태 업데이트해도 위치 변하지 않음
사용자는 드래그와 라벨 저장을 자유롭게 해도 위젯이 제자리에 있습니다.
저장 타이밍을 신경 쓸 필요가 없어요.
정리 : 이 패턴이 필요한 상황
이 패턴은 이런 조건이 겹칠 때 필요합니다.
첫째, 자동저장이 있어서 아직 서버에 반영 안 된 로컬 상태가 존재할 수 있다.
둘째, 별도의 저장 버튼(패널, 폼 등)이 있어서 서버 응답으로 로컬 상태를 업데이트한다.
셋째, 서버 응답 안에 "내가 바꾼 게 아닌 속성"(위치)이 포함되어 있다.
이 세 가지가 겹치는 구조라면, PATCH 요청 시 현재 로컬 상태의 "건드리지 않은 속성"을 함께 보내는 것이 가장 단순한 해결입니다.
마치며
자동저장과 수동 저장이 공존하는 UI를 만들면, 이 충돌 문제는 언젠가 만나게 됩니다.
겪어보기 전까지는 "왜 드래그한 게 취소되지?" 하고 드래그 코드만 계속 들여다보게 돼요.
실제 원인이 저장 로직에 있는데도요.
핵심 원칙은 하나입니다.
서버에 PATCH를 보낼 때, 내가 바꾸려는 속성만 보내지 말고, 서버 응답이 덮어쓰면 안 되는 로컬 상태도 함께 보내라.
이걸 mergedPatch 패턴으로 통일해두면, 이후에 속성이 얼마나 늘어나도 같은 방식으로 해결됩니다.
'(개인Project)_개발 > PLC-PC 연결' 카테고리의 다른 글
| [Program][보족] Ctrl+C로 서버를 껐더니 traceback이 쏟아졌다 : FastAPI CancelledError 억제하는 방법 (0) | 2026.06.03 |
|---|---|
| [Program][보족] 드래그 구현했는데 빠르게 움직이면 위젯을 놓쳐버린다 : 오버레이 패턴으로 해결 (0) | 2026.06.01 |
| [Program][Phase3.5] 브라우저가 HMI가 됐다 : PLCLink Phase 3.5 캔버스 HMI 완성기 (0) | 2026.05.31 |
| [Program][보족] 실제 하드웨어로 테스트하기 전까지 발견 못 한 버그 : SLMP 디바이스 주소 오파싱 (0) | 2026.05.29 |
| [Program][보족] React 프로덕션 빌드에서만 나는 오류의 원인을 찾는 방법 (0) | 2026.05.28 |