운영 중인 프로젝트의 Redux 구조를 점검해봤다
store 설정 파일이 두 개로 갈라져 있고, selector 위치가 사람마다 다르고, slice 확장자가 섞여 있었다. 실제 프로젝트의 Redux 구조를 진단하고 리팩터링 방향을 정리한 기록.
운영 중인 프로젝트의 Redux 구조를 점검해봤다
지난 글(Redux Toolkit — 내가 정리한 표준 폴더 구조)에서 정리한 베이스라인이 있다. 이번에는 실제로 운영 중인 Next.js 프로젝트의 Redux 구조를 그 기준으로 점검한 기록이다.
결론부터 말하면, 표준에서 어긋난 항목이 네 개 있었다. 그 중 하나는 잠깐 보고도 식은땀이 났던 항목이다 — store 설정 파일이 두 개로 나뉘어 있어서, 사실상 store 인스턴스가 두 개 만들어지는 구조였다. 더 정확히는, ReduxProvider가 쓰는 store와 컴포넌트들이 타입을 끌어다 쓰는 store가 서로 다른 파일에서 정의되고 있었다.
각 문제를 하나씩 본다.
현재 구조 스냅샷
app/store/
├─ index.tsx ← configureStore #1 (ReduxProvider가 사용)
├─ RootReducer.tsx ← configureStore #2 (RootState/AppDispatch 타입 export)
├─ modalSlice.tsx ← .tsx
├─ sidebarSlice.tsx ← .tsx
├─ configSlice.tsx ← .tsx
├─ clusterInfoSlice.tsx ← .tsx
├─ errorSlice.ts ← .ts
├─ tableSlice.ts ← .ts
├─ modalSelectors.ts ← store 루트
└─ selector/
└─ configSelector.ts ← selector/ 서브폴더 (단수)같은 폴더 안에서 슬라이스가 .ts와 .tsx로 갈리고, selector가 루트와 서브폴더에 흩어져 있다. 더 심각한 건 index.tsx와 RootReducer.tsx가 둘 다 configureStore를 호출하고 있다는 점이다.
문제 1 — store 설정 파일이 두 개
가장 큰 문제다. index.tsx와 RootReducer.tsx 두 파일이 둘 다 store를 만들고 있다.
어떻게 분리되어 있나
ReduxProvider.tsx는 @store/index에서 store를 가져온다.
// app/providers/ReduxProvider.tsx
import { persistor, store } from '@store/index';반면 컴포넌트들과 typed hooks는 @store/RootReducer에서 타입을 가져온다.
// app/hooks/reduxHooks.tsx
import type { RootState, AppDispatch } from '@store/RootReducer';
// app/components/admin-panel/admin-panel-layout.tsx
import { RootState } from '@store/RootReducer';
// app/docker/host/constants/columns.tsx
import { AppDispatch } from '@store/RootReducer';두 파일이 거의 같은 코드를 가지고 있지만 세부 설정이 다르다.
| 항목 | index.tsx | RootReducer.tsx |
|---|---|---|
persist blacklist | 없음 (모든 reducer가 persist) | ['modal', 'error', 'table'] |
serializableCheck | redux-persist 액션만 제외 | false (전체 비활성화) |
| logger | dev 환경에서만 | 항상 적용 |
| devTools | production 제외 | 기본값 (production에서도 켜짐) |
| HYDRATE 처리 | 없음 | next-redux-wrapper HYDRATE 처리 있음 |
| persistor export | 있음 | 있음 |
왜 위험한가
ReduxProvider가 실제로 마운트하는 store는 index.tsx의 것이다. 그래서 다음과 같은 어긋남이 발생한다.
1) persist blacklist가 적용되지 않는다. 실제 동작하는 store(index.tsx)에는 blacklist가 없다. 따라서 modal/error/table 상태가 전부 localStorage에 저장된다. 새로고침하면 이전 모달 상태나 에러 메시지가 그대로 떠 있을 수 있다는 뜻이다. 의도와 정확히 반대.
2) HYDRATE 액션이 처리되지 않는다. SSR에서 서버 상태를 클라이언트로 넘기는 HYDRATE 로직은 RootReducer.tsx에만 있다. 하지만 실제 store는 그 reducer를 안 쓰니까, next-redux-wrapper가 동작하지 않는다.
3) logger가 production에서도 켜져 있다 — 그런데 안 켜져 있다. RootReducer.tsx는 logger를 항상 적용하지만, 실제 store는 dev에서만 logger를 켠다. 두 파일을 읽은 사람은 production에서도 로그가 찍힐 거라 오해할 수 있다.
4) 타입과 실제가 어긋난다. RootState는 RootReducer.tsx의 combineReducers에서 추론되고, 실제 store는 index.tsx의 reducer에서 동작한다. 두 파일의 reducer 키 구성이 우연히 같으니까 지금은 문제가 없을 뿐, 한쪽만 reducer를 추가하면 그 순간 컴파일은 통과되는데 런타임에선 undefined가 된다.
어떻게 합치는가
RootReducer.tsx를 삭제하고 index.ts로 통합한다. 통합 시 결정해야 할 사항은:
- HYDRATE 처리를 살릴 것인가? App Router로 마이그레이션 중이라면 next-redux-wrapper는 필요 없을 가능성이 크다. Pages Router에
getServerSideProps로 store 초기화를 넘기는 흐름이 있으면 살린다. - persist blacklist는 `RootReducer.tsx` 쪽이 맞다. modal/error/table은 persist 대상이 아니다.
- serializableCheck는 `index.tsx` 쪽이 맞다. redux-persist 액션만 제외하고 나머지는 검사한다. 전체
false는 너무 느슨하다. - logger/devTools는 dev only. production에 logger가 들어가면 모든 액션마다 console.log가 호출돼 성능에 영향이 있다.
통합 후 typed hooks와 컴포넌트들의 import 경로를 @store/RootReducer → @store/index로 일괄 변경한다. tsconfig path alias가 잡혀 있으니 codemod 없이 grep + sed로 충분히 끝난다.
문제 2 — selector 위치가 일관되지 않음
selector가 두 가지 다른 위치에 있다.
app/store/
├─ modalSelectors.ts ← 루트에, 파일명은 복수
└─ selector/
└─ configSelector.ts ← 서브폴더(단수)에, 파일명은 단수같은 종류인데 폴더가 다르고, 파일명도 한쪽은 Selectors(복수), 한쪽은 Selector(단수)다. 사람이 늘면 다음 selector가 어디로 갈지 매번 의사결정이 필요해진다.
내부 코드는 둘 다 같은 패턴을 따른다 — selector를 객체로 묶고, (state: RootState) => state.xxx.yyy 형태로 정의한다. 구현이 같으니까 합치는 데 큰 작업은 없다.
정리 방향
폴더 하나로 통합한다. 이름은 selectors/(복수). 파일명도 xxxSelectors.ts(복수)로 통일.
app/store/selectors/
├─ modalSelectors.ts
└─ configSelectors.tsconfigSelector.ts → configSelectors.ts로 파일명을 바꾸고, 내부 export 이름은 이미 configSelectors(복수)니까 그대로 두면 된다.
이 작업의 본질은 “하나의 컨벤션”을 정하는 것이다. 어디다 두든 같은 곳에 두면 된다. selector를 새로 추가할 때 사람마다 다른 위치에 놓는 게 가장 안 좋다.
문제 3 — 파일 확장자(.ts / .tsx) 혼재
modalSlice.tsx ← JSX 없음
sidebarSlice.tsx ← JSX 없음
configSlice.tsx ← JSX 없음
clusterInfoSlice.tsx ← JSX 없음
errorSlice.ts ← OK
tableSlice.ts ← OK.tsx 4개, .ts 2개. slice 안에는 JSX가 없다. 그런데도 .tsx로 만들어졌다.
왜 문제인가
- 빌드 영향은 거의 없다. TypeScript 컴파일러나 SWC는
.tsx안에 JSX가 없어도 잘 처리한다. - 하지만 컨벤션 시그널이 깨진다. 누군가는 “이 폴더는
.tsx로 쓰는 거구나” 하고 새 파일도.tsx로 만든다. 누군가는.ts로 만든다. 결국 사람마다 다른 확장자를 쓰게 된다. - IDE 자동 import가 헷갈린다. 같은 이름의
.ts/.tsx파일이 공존하면 어느 한쪽이 우선되어 import되는데, 그 우선순위가 도구마다 다르다.
정리
slice는 전부 .ts로. JSX를 쓸 일이 생기면 그건 slice가 아니라 컴포넌트로 분리되어야 한다.
modalSlice.tsx → modalSlice.ts
sidebarSlice.tsx → sidebarSlice.ts
configSlice.tsx → configSlice.ts
clusterInfoSlice.tsx → clusterInfoSlice.tsgit mv로 파일명을 바꾸고, import 구문은 확장자 없이 쓰고 있으니 컴포넌트 코드는 손댈 게 없다.
문제 4 — createStateHook 패턴 재평가
이건 “고쳐야 할 문제”라기보다 “쓰는 방식을 명확히 정해야 할 패턴”이다.
// app/util/createStateHook.ts
export const createStateHook = <T extends Record<string, (state: RootState) => unknown>>(
selectors: T,
) => {
return (): { [K in keyof T]: ReturnType<T[K]> } => {
const entries = Object.entries(selectors).map(
([key, selector]) => [key, useAppSelector(selector)],
);
return Object.fromEntries(entries) as { [K in keyof T]: ReturnType<T[K]> };
};
};selector 객체를 받아 “모든 selector를 동시에 구독하는 hook”을 반환한다. 사용처는 깔끔하다.
const { isOpen, modalType, btnText } = useModalState();좋은 점
- selector 추가가 자동으로 hook에 반영된다. selectors 객체에 필드 하나만 추가하면 끝.
- destructuring 한 줄로 필요한 상태를 꺼낼 수 있다.
- 타입 추론이 정확하다. selector 함수의 return 타입이 그대로 hook의 return 타입으로 이어진다.
신경 써야 할 점
1) 모든 selector를 구독하게 된다. useModalState()를 호출하면 modal 상태의 isOpen, btnText, modalType, modalKeyword, subModal, payload 등 selectors 객체에 정의된 모든 필드를 구독한다. 컴포넌트가 isOpen만 쓰더라도 modalType이 바뀔 때 함께 리렌더된다.
리렌더 비용이 큰 컴포넌트(테이블, 차트 등)는 useModalState() 대신 useAppSelector(modalSelectors.isOpen) 같이 selector를 직접 호출해야 한다.
2) React Hooks 규칙 의존. Object.entries(selectors).map(...) 안에서 useAppSelector를 반복 호출한다. 매 렌더에서 같은 순서로 같은 개수의 hook이 호출되어야 한다. selectors 객체가 모듈 스코프에 선언된 정적 객체라면 안전하지만, 동적으로 selector를 추가/제거하면 hook 호출 순서가 깨진다.
3) `Object.entries` 순서. 객체 키 순서는 V8 기준 “정수 키 오름차순 → 문자열 키 삽입순”이다. 일반 객체 selector는 문자열 키만 쓰니까 삽입순이 보장된다. 그래도 키 순서에 의존하는 코드가 hook 안에 있다는 점은 인지하고 있어야 한다.
어떻게 쓰기로 정했나
createStateHook은 편의 hook으로 두고, 다음과 같이 사용 기준을 분리하기로 했다.
- 일반 사용:
useModalState,useConfigState같은 편의 hook. selectors 전체를 구독해도 리렌더 비용이 크지 않은 컨테이너성 컴포넌트(모달 컨트롤러, 사이드바 등). - 성능 민감 컴포넌트: 테이블, 차트, 리스트 row 같은 곳은
useAppSelector(xxxSelectors.특정필드)를 직접 호출.
이 기준을 standards/frontend/redux.md 같은 문서에 명시하고, 코드 리뷰에서 점검 항목으로 둔다.
정리 — 리팩터링 순서
리팩터링 PR을 쪼개는 순서다. 한 번에 다 바꾸면 리뷰가 안 된다.
- 확장자 통일 (
.tsx→.ts) —git mv만 하면 끝난다. import 경로에 확장자가 없으니 코드 변경도 없다. 가장 안전한 첫 PR이다. - selector 폴더 통합 —
modalSelectors.ts를selectors/안으로 옮기고configSelector.ts를configSelectors.ts로 rename한 뒤 import 경로를 일괄 변경한다. - store 설정 파일 통합 (가장 큰 변경) —
RootReducer.tsx의 persistConfig·HYDRATE 처리를index.ts에 병합하고@store/RootReducerimport 경로를@store/index로 일괄 변경한다. PR 하나로 가되 변경 직후 회귀 테스트가 필수다. - createStateHook 사용 기준 문서화 —
standards/frontend/redux.md에 "편의 hook vs 단일 selector" 기준을 명시하고 코드 리뷰에서 사용한다.
3번 PR 직후 확인할 항목은 세 가지다.
- persist blacklist가 적용되는지: 새로고침 후 modal/error/table이 초기 상태인가
- logger가 dev에서만 켜지는지
- HYDRATE를 살리기로 했다면 SSR 페이지에서 store 초기화가 동작하는지
메모
이 점검에서 가장 크게 느낀 건 두 가지다.
첫 번째, store 설정 파일이 두 개로 갈라진 상황은 “언젠가 한 명이 둘 중 하나를 정리하다가 그만둔” 흔적에 가깝다. 둘 다 동작하는 코드처럼 보이니까 누구도 위험을 못 느낀다. 하지만 실제로는 한쪽만 마운트되고 있고, 다른 쪽은 “문서처럼 읽히는 죽은 코드”다. 죽은 코드보다 더 나쁜 건, 그 코드를 읽고 어긋난 가정을 만드는 사람이 생긴다는 점이다.
두 번째, 컨벤션은 하나만 정해도 충분하다. selector 폴더가 두 군데로 갈린 건 “어디다 둘지” 합의가 없어서다. 어디든 좋다. 한 곳에만 두면 된다.
다음에 새 프로젝트를 시작할 때, 위 표준 구조 글의 체크리스트를 그대로 들고 가서 첫 PR에 적용할 생각이다.
Related Posts
같이 읽으면 좋은 글
공유 화면이 다른 도메인을 직접 import하고 있었다 — 의존성 역전으로 역의존 끊기
docker와 k8s가 같이 쓰는 host 목록 화면. 그런데 공유 구현이 docker/host에 있으면서 k8s 도메인을 직접 import하고 있었다. docker가 k8s를 아는 역방향 의존 7건을, 계약(HostExtension)만 남기고 도메인 조각을 주입하는 방식으로 0건까지 끊은 기록.
폴더를 옮기는 데 사고가 났다 — 존 재구조화 세션에서 데인 것들
한 세션 동안 모노레포의 폴더/의존성 구조를 존(zone) 단위로 재정리했다. 단순한 git mv처럼 보이지만 repoint 스코프 누락, alias 이름 함정, zsh 단어분할, tsc 동시 실행 thrash까지 사고가 끊이지 않았다. 매 단계 tsc 에러 수를 baseline으로 두고 깨지면 되돌린 작업 일지.
SharedWorker는 이름이 인스턴스다 - docker/k8s 스코프 분리
SharedWorker는 최초 연결의 baseURL로 SSE가 고정된다. k8s 스코프를 추가하자 먼저 연 탭의 스코프로 전체가 고정되는 문제가 생겼다 - 워커 이름을 파티셔닝 키로 쓰고, 프록시는 catch-all 라우트 하나로 합친 과정.