본문 바로가기

(개인Project)_개발/PLC-PC 연결

[Program][보족] 설정을 마쳤는데 화면은 그대로였다 (커스텀 훅을 두 곳에서 쓰면 생기는 일)

반응형

이 글이 나한테 해당되는 얘기인지 먼저 확인해보세요

이런 상황이 하나라도 해당된다면, 이 글이 도움이 될 겁니다.

  • 커스텀 훅을 두 컴포넌트에서 각각 불렀는데 값이 따로 논다
  • SetupFlow나 Wizard에서 설정을 완료했는데 부모 컴포넌트가 변경을 모른다
  • useState, useLocalStorage 같은 훅을 여러 곳에서 쓰는데 한쪽 변경이 다른 쪽에 반영이 안 된다
  • "같은 훅이니까 같은 값을 볼 거다"라고 생각했는데 아니었다

버그를 재현할 수 없었습니다.

PLCLink SetupFlow 마법사에서 PLC 설정을 완료하고 연결 테스트까지 통과했는데, 메인 화면으로 넘어가면 여전히 "PLC 미연결" 상태로 표시됐습니다.

SetupFlow 안에서는 분명히 저장이 됐고, localStorage에도 값이 들어가 있었는데, App.jsx는 그 값을 모르는 것처럼 동작했습니다.

처음에는 타이밍 문제라고 생각했습니다.

SetupFlow가 저장하기 전에 App.jsx가 먼저 읽어버리는 건지, 아니면 비동기 순서 문제인지. 딜레이를 넣어봤습니다.

안 됐습니다.

그다음은 localStorage 직렬화 문제를 의심했습니다.

저장은 됐는데 읽어오는 쪽에서 파싱이 잘못된 건지. 값을 콘솔에 찍어봤습니다.

값은 정상이었습니다.

한참 지나서야 원인을 찾았습니다.

SetupFlow와 App.jsx가 같은 훅을 쓰고 있었지만, 전혀 다른 state를 보고 있었습니다.


커스텀 훅이 "공유"되는 게 아니라는 것

▲ 같은 커스텀 훅을 두 컴포넌트에서 호출했을 때의 인스턴스 구조. 각 호출마다 독립된 state가 생깁니다.

 

React 커스텀 훅은 함수입니다.

usePlcConfig()라는 훅을 만들면, 그 안에 useState가 있고 localStorage에서 값을 읽어오는 로직이 있습니다.

여러 컴포넌트에서 불러다 쓸 수 있으니까 "공유"처럼 느껴집니다.

그런데 실제로는 공유가 아닙니다.

// App.jsx
const { config } = usePlcConfig()   // ← 인스턴스 A

// SetupFlow.jsx
const { config, setConfig } = usePlcConfig()   // ← 인스턴스 B

usePlcConfig()를 호출하는 순간마다 React는 그 컴포넌트 전용 state를 새로 만듭니다.

App.jsx의 config와 SetupFlow의 config는 이름은 같지만 완전히 별개의 변수입니다.

SetupFlow에서 setConfig(newValue)를 호출하면 인스턴스 B의 state가 바뀝니다.

App.jsx의 인스턴스 A는 아무것도 모릅니다. 리렌더링도 없고, 값 변경도 없습니다.

localStorage에 저장은 되지만, App.jsx는 이미 마운트 시 한 번 읽고 그 값을 유지하고 있습니다.

SetupFlow가 저장한 이후에 App.jsx가 다시 읽는 트리거가 없었던 거예요.


왜 이 함정에 빠지기 쉬운가

▲ 커스텀 훅을 "전역 저장소"처럼 착각하는 흔한 패턴.

 

이 실수를 하는 이유는 분명합니다.

useLocalStorage, usePlcConfig 같은 훅의 이름에서 "어딘가에 저장해두고 공유하는 것"처럼 느껴지기 때문입니다.

Redux나 Zustand 같은 상태 관리 라이브러리를 써봤다면 그 감각으로 접근하기 쉽습니다.

스토어에 저장하면 어디서 구독해도 같은 값을 보는 방식에 익숙해져 있으면, 커스텀 훅도 그럴 거라 자연스럽게 가정합니다.

React의 훅은 그렇지 않습니다.

훅은 그 훅을 호출한 컴포넌트에 귀속됩니다.

컴포넌트가 다르면 state도 다릅니다.

전역 공유가 필요하다면 Context를 써야 합니다.

훅이 Context를 감싸고 있다면 공유가 되지만, 순수하게 useState를 담은 커스텀 훅은 공유되지 않습니다.


PLCLink에서 어떻게 해결했나

▲ 해결 방법 두 가지 — Context 사용 vs 경계에서 직접 write.

 

이 버그를 고치는 방법은 크게 두 가지입니다.

방법 1 — Context로 공유

// PlcConfigContext.jsx
const PlcConfigContext = createContext()

export function PlcConfigProvider({ children }) {
  const [config, setConfig] = useLocalStorage('plclink_config', {})
  return (
    <PlcConfigContext.Provider value={{ config, setConfig }}>
      {children}
    </PlcConfigContext.Provider>
  )
}

export const usePlcConfig = () => useContext(PlcConfigContext)

Provider로 감싸면 어디서 usePlcConfig()를 호출해도 같은 state를 봅니다.

구독한 컴포넌트 전체가 같이 리렌더링됩니다.

 

방법 2 — 경계에서 localStorage 직접 write

PLCLink에서 실제로 채택한 방법입니다.

SetupFlow 마법사는 설정 완료 시점(finish() 함수)이 명확합니다.

그 시점에 localStorage를 직접 씁니다.

App.jsx는 훅 대신 별도 setupDone state를 두고, 이 값이 바뀌면 localStorage에서 다시 읽도록 합니다.

// SetupFlow.jsx
const finish = () => {
  // 훅을 통하지 않고 localStorage에 직접 저장
  localStorage.setItem('plclink_config_v2', JSON.stringify(newConfig))
  onComplete()  // App.jsx에 완료 신호
}

// App.jsx
const [setupDone, setSetupDone] = useState(false)

useEffect(() => {
  if (setupDone) {
    // setupDone이 바뀌면 그때 localStorage를 읽음
    const saved = localStorage.getItem('plclink_config_v2')
    if (saved) setConfig(JSON.parse(saved))
  }
}, [setupDone])

// SetupFlow에 완료 콜백 전달
<SetupFlow onComplete={() => setSetupDone(true)} />

Context보다 구조가 단순합니다.

앱 전체를 Provider로 감쌀 필요가 없고, SetupFlow처럼 명확한 완료 시점이 있는 경우에 잘 맞습니다.

훅 인스턴스 충돌 자체를 피하는 방식이기도 합니다.


어떤 방법을 선택할지 기준

▲ Context vs 직접 write 선택 기준. 공유가 지속적으로 필요한지, 완료 시점이 명확한지에 따라 달라집니다.

 

두 방법 모두 동작하지만 상황에 따라 맞는 선택이 다릅니다.

Context가 맞는 경우:

  • 여러 페이지, 여러 컴포넌트에서 같은 state를 지속적으로 읽고 써야 할 때
  • 어느 컴포넌트에서 변경해도 나머지가 즉시 반응해야 할 때

직접 write가 맞는 경우:

  • 변경 시점이 명확할 때 (마법사 완료, 저장 버튼 클릭 등)
  • 상태 공유 범위가 제한적일 때
  • Context 설정 비용이 굳이 필요 없을 때

PLCLink의 SetupFlow는 "설정 완료" 순간이 하나로 명확합니다.

그 시점에 한 번 쓰고 끝이기 때문에 Context보다 직접 write가 더 간단했습니다.


정리

증상:  SetupFlow에서 설정 완료 → App.jsx는 여전히 초기 상태
원인:  usePlcConfig()를 두 컴포넌트에서 각각 호출 → 인스턴스 분리
      SetupFlow의 setConfig()는 자신의 인스턴스만 변경
      App.jsx 인스턴스는 변경 감지 없음
해결:  (1) Context로 state 공유
      (2) 완료 시점에 localStorage 직접 write + 별도 트리거 state

커스텀 훅을 만들고 나서 "이걸 어디서나 가져다 쓸 수 있다"는 편리함에 집중하다 보면, "각 호출은 독립된 인스턴스를 만든다"는 점을 놓치기 쉽습니다.

공유가 필요하면 Context가 필요합니다.

훅만으로는 되지 않습니다.

반응형