[Redux Toolkit] Next.js + TypeScript에서 그림판 만들기 - #4 (그림판 저장 stageRef null 오류 & 전역 상태관리 리팩토링)
🫠 [Redux Toolkit] Next.js + TypeScript에서 그림판 만들기 - #4 (그림판 저장 stageRef null 오류 & 전역 상태관리 리팩토링)
2025.02.21 - [개발일지/Next.js] - [Konva.js] Next.js + TypeScript에서 그림판 만들기 - #3 (그림 저장하기)
[Konva.js] Next.js + TypeScript에서 그림판 만들기 - #3 (그림 저장하기)
🎨 Next.js + TypeScript에서 그림판 만들기 - #3 (그림 저장하기)2025.02.20 - [개발일지/Next.js] - [Konva.js] Next.js + TypeScript에서 그림판 만들기 - #2 (Undo/Redo & 지우개 기능 추가) [Konva.js] Next.js + TypeScript에서
ayeongjin.tistory.com
문제 상황
이전 게시물에서 이렇게 그림 저장을 하고 작성까지 성공했지만
그림일기 수정과 로직을 분기하면서 두 가지 문제가 발생했다.
❗문제 1: 그림 저장은 되는데 이미지가 렌더링되지 않음
그림을 그린 뒤 저장을 눌러도 다시 페이지에 진입하면 이미지가 보이지 않는 현상이 간헐적으로 발생했다.
❗문제 2: 저장 버튼 누르면 오류 발생
Error: stageRef가 null입니다. 확인해주세요.
그림판은 잘 뜨는데 저장만 하려고 하면 "그림판이 아직 준비되지 않았어요!" 같은 alert이 뜨며 저장 실패.
심지어 이 오류는 항상 재현되지 않고, 간헐적으로 발생했다는 점에서 더 혼란스러웠다.
원인 분석
처음 설계 당시에는 stageRef를 Redux가 아닌 전역 유틸 모듈(stateRef.ts)에 저장하도록 구현했었다.
✅ 처음에 Redux가 아니라 전역 변수로 구현한 이유
- useRef로 얻는 stageRef는 DOM 객체(Konva Stage)인데,
이건 Redux의 상태로 넣기엔 직렬화(serialize)가 안 되는 객체다. - Redux Toolkit은 비직렬화 값이 들어오면 경고를 뱉고, 상태 추적에도 어려움이 생긴다.
- 그래서 초반에는 비교적 간단하게,
"그냥 module-scoped 변수에 저장해서 가져다 쓰자"는 방식을 선택했다.
// stateRef.ts
export let stageRef: any = null;
export const setStageRef = (ref: any) => {
stageRef = ref;
};
이 방식은 초기 렌더 이후 계속 같은 객체를 참조할 수 있다는 장점이 있었지만
Next.js에서는 예상과 달리 문제가 생긴다.
✅ 문제가 간헐적으로 발생하는 이유
전역 모듈에 저장된 stageRef가 다음 상황에서 null로 초기화되기 때문이다:
- Next.js의 Fast Refresh가 발생했을 때
- SSR에서 클라이언트 진입 시 hydration 타이밍 차이
- dynamic import된 컴포넌트가 비동기로 mount될 때
즉, 저장 시점에는 이미 다시 초기화된(=null) 전역 변수를 참조하게 되어 저장 실패.
이 문제는 정확한 시점에서만 발생하기 때문에 간헐적으로 보인다.
→ 개발 중엔 괜찮다가도, 코드가 바뀌거나, 탭을 새로 열면 오류가 생김.
→ 심하면 저장 버튼 눌렀는데 아무 일도 안 일어나는 느낌도 들 수 있다.
해결 방법
1. 전역 변수 → Redux 상태로 전환
stageRef를 Redux로 옮기되, 비직렬화 문제를 감안해 serializableCheck를 끄고
확실히 mount된 후 저장되도록 requestAnimationFrame으로 타이밍 조절했다.
// pictureSlice.ts
setStageRef: (state, action: PayloadAction<any>) => {
state.stageRef = action.payload;
}
2. stageRef가 준비될 때까지 기다렸다가 저장
// KonvaCanvas.tsx
useEffect(() => {
const waitForStage = () => {
if (stageRef.current) {
dispatch(setStageRef(stageRef.current));
console.log("stageRef Redux 저장 완료");
} else {
requestAnimationFrame(waitForStage);
}
};
waitForStage();
}, [dispatch]);
requestAnimationFrame으로 렌더 완료 시점을 보장 → ref.current === null 방지
3. Redux Toolkit 직렬화 검사 무시 설정
Redux는 기본적으로 DOM 같은 직렬화 불가능한 객체를 상태에 저장할 수 없게 막고 있음
Konva.Stage 객체는 JSON으로 직렬화가 안 되기 때문에 예외 설정을 해줬다
// store.ts
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ["picture/setStageRef"],
ignoredPaths: ["picture.stageRef"],
},
}),
💡 배운 점
- 전역 유틸 변수는 SSR/동적 import 환경에서 매우 불안정하다.
- ref는 null일 수 있는 시점이 많기 때문에 렌더 타이밍을 기다려주는 로직이 필수.
- Redux에 직렬화 불가능한 값을 저장할 경우 serializableCheck를 설정해줘야 한다.
- Redux에 저장할 수 없는 객체라도, 명확한 예외 설정과 함께 구조화하면 충분히 안정적으로 관리 가능하다.
- 상태를 잘 정의하고 관리하면 협업/디버깅/유지보수 전부 편해진다.
- "간헐적 오류"는 대부분 비동기 타이밍 이슈에서 나온다.
이번 오류는 단순히 ref가 null이라는 문제가 아니라,
렌더링 타이밍 + 전역 변수 구조 + Redux의 직렬화 제약까지 복합적으로 얽힌 상황이었다.
이 문제를 해결하면서 Redux 구조에 대한 이해와 상태 관리의 안정성을 확보하면서 구조 다시 짰고, 이제 왜 그랬는지도 안다.