
설정해놓은 게 사라진다 — useLocalStorage 직렬화 버그
PLCLink Phase 0를 만들면서 IO 포인트 등록 기능을 붙였습니다.
등록한 포인트 목록을 브라우저 새로고침 후에도 유지하기 위해 localStorage(브라우저에 데이터를 저장하는 공간)를 썼습니다.
그런데 이런 피드백이 왔습니다.
"엔지니어 모드에서 포인트를 추가했는데, 확인 모드로 갔다가 돌아오면 목록이 비어있어요."
처음엔 확인 모드 전환 로직 문제라고 생각했습니다.
그런데 확인해보니 단순 새로고침만 해도 같은 현상이 생겼습니다.
등록한 게 저장이 안 되고 있었습니다.
증상

동작 순서로 설명하면 이렇습니다.
- 엔지니어 모드에서 IO 포인트 등록 (Y3920, 실린더A 전진)
- 페이지를 새로고침하거나 다른 URL로 이동 후 복귀
- 등록한 포인트 목록이 비어있음
localStorage에 데이터가 있는지 브라우저 개발자 도구로 확인해봤습니다.
해당 키의 값이 undefined로 저장되어 있었습니다. 저장 자체가 실패하고 있었던 겁니다.
원인

useLocalStorage라는 커스텀 훅(자주 쓰는 로직을 묶어둔 함수)을 직접 만들어서 쓰고 있었습니다.
이 훅이 localStorage에 값을 저장할 때 JSON.stringify를 사용합니다.
문제는 React의 useState 업데이트 방식에 있었습니다.
React에서 상태를 업데이트할 때 두 가지 방식을 씁니다.
// 방식 1 — 값 직접 전달
setEntries([...entries, newEntry])
// 방식 2 — 함수 전달 (함수형 업데이트)
setEntries(prev => [...prev, newEntry])
방식 2는 이전 상태(prev)를 보장받아야 할 때 씁니다. 비동기 상황에서 안전한 방법입니다.
그런데 useLocalStorage 내부에서 이 값을 받아서 JSON.stringify로 저장할 때 문제가 생겼습니다.
// useLocalStorage 내부 — 문제 있는 코드
const setValue = (newVal) => {
setState(newVal)
localStorage.setItem(key, JSON.stringify(newVal)) // ← 여기
}
newVal이 함수(prev => [...prev, newEntry])인 경우, JSON.stringify는 함수를 직렬화(변환)할 수 없습니다.
결과가 undefined가 됩니다.
JSON.stringify(prev => [...prev, newEntry])
// → undefined
undefined가 저장되니까 다음에 페이지를 열면 빈 값으로 초기화됩니다.
수정 — 코드 한 줄 차이

newVal이 함수인 경우를 먼저 처리해서, 실제 값으로 변환한 뒤에 저장하면 됩니다.
// 수정 전 — 함수가 그대로 직렬화 시도됨
const setValue = (newVal) => {
setState(newVal)
localStorage.setItem(key, JSON.stringify(newVal))
}
// 수정 후 — 함수면 먼저 실행해서 실제 값을 구함
const setValue = (newVal) => {
const next = typeof newVal === 'function' ? newVal(state) : newVal
setState(next)
localStorage.setItem(key, JSON.stringify(next))
}
typeof newVal === 'function'으로 함수인지 먼저 확인하고, 함수라면 현재 상태(state)를 넣어서 실행합니다.
그러면 함수가 아닌 실제 배열이나 값이 next에 담기고, 그걸 저장합니다.
왜 이 버그가 생겼나 — 맥락 정리
React의 useState는 함수형 업데이트를 공식적으로 지원합니다. 그래서 컴포넌트 코드에서는 아무 문제 없이 씁니다.
// 컴포넌트에서 — 정상 동작
const [entries, setEntries] = useState([])
setEntries(prev => [...prev, newEntry])
React 내부에서 prev를 실제 상태값으로 바꿔서 처리해줍니다.
그런데 useLocalStorage는 React의 setState를 래핑(감싸서 확장)한 커스텀 훅입니다.
래핑 레이어에서 newVal을 받을 때 이미 함수인 채로 받아서 JSON.stringify에 직접 넘기면,
React가 처리하기 전에 직렬화가 먼저 일어납니다.
커스텀 훅을 만들 때 React의 함수형 업데이트 패턴을 고려하지 않으면 이런 문제가 생깁니다.
발견 과정

코드를 보는 것만으로는 잡기 어려운 버그였습니다.
"저장은 되는 것 같은데 다음에 열면 없다"는 증상이라서 처음엔 렌더링 타이밍 문제로 생각했습니다.
실제 발견은 브라우저 개발자 도구에서 localStorage 값을 직접 확인했을 때였습니다.
Application 탭 → Local Storage에서 해당 키를 보니 값이 undefined로 찍혀 있었습니다.
저장 실패 원인이 직렬화에 있다는 걸 알고 나서 JSON.stringify에 뭘 넘기고 있는지 console.log로 찍어봤고,
함수 자체가 넘어오고 있다는 걸 확인했습니다.
개발 팁 : useLocalStorage 커스텀 훅 만들 때
비슷한 커스텀 훅을 만들 때 확인할 것들입니다.
1. 함수형 업데이트를 처리하고 있는가
setState를 래핑한다면, 함수가 넘어왔을 때 실행해서 실제 값을 구하는 처리가 있어야 합니다.
const next = typeof newVal === 'function' ? newVal(currentState) : newVal
2. 초기값 로딩 시 파싱 오류를 처리하고 있는가
localStorage에 저장된 값이 깨져 있을 수 있습니다. JSON.parse 실패를 try-catch로 잡아야 합니다.
try {
return JSON.parse(localStorage.getItem(key))
} catch {
return defaultValue
}
3. 저장 실패를 알 수 있는가
localStorage.setItem은 용량 초과 시 예외를 던집니다.
예외가 조용히 묻히면 "저장된 줄 알았는데 없다"는 상황이 생깁니다.
TMI
Q. React에서 useState의 함수형 업데이트는 왜 쓰나요?
비동기 상황에서 이전 상태를 안전하게 참조하기 위해서입니다. setCount(count + 1) 방식은 클로저 시점의 count를 쓰기 때문에, 여러 업데이트가 겹치면 예상과 다른 결과가 나올 수 있습니다. setCount(prev => prev + 1)은 React가 직전 실제 상태를 넘겨주기 때문에 항상 정확합니다.
Q. 이 버그는 TypeScript를 썼어도 생겼나요?
네. 타입이 T | ((prev: T) => T)로 선언되어 있어도, 직렬화 레이어에서 함수 타입을 처리하는 로직이 없으면 동일한 문제가 생깁니다.
Q. localStorage 말고 다른 방법을 쓸 수 있나요?
sessionStorage는 탭을 닫으면 사라지니까 이 용도엔 안 맞습니다. IndexedDB는 더 복잡한 데이터 구조를 저장할 수 있지만 설정 몇 개를 저장하는 데는 과합니다. PLCLink 규모에서는 localStorage로 충분합니다.
'(개인Project)_개발 > PLC-PC 연결' 카테고리의 다른 글
| [Program][Phase1.5] PLC 껐다 켜도 데이터 남기는 방법 (SQLite 전환으로 이력 관리 시작) (0) | 2026.05.12 |
|---|---|
| [Program][보족] Mitsubishi MC Protocol로 Keyence PLC 연결하기 ( 주의할 것들) (0) | 2026.05.02 |
| [Program][보족] GitHub 몰라도 괜찮다 (PLCLink 다운받는 법 단계별 정리) (0) | 2026.04.30 |
| [Program][Phase_0] HMI 없어도 브라우저로 PLC IO 본다 (PLCLink Phase 0 구현기) (0) | 2026.04.29 |
| [Program][시작] 현장 엔지니어가 직접 만든 PLC 웹 모니터링 툴 (왜 만들었나?) (0) | 2026.04.20 |