복잡한 Redux에서 벗어나기
2026.01.04
Redux에서 벗어나기
Redux → axios + transformer + Nanostores로 점진적 마이그레이션한 이유와 전체 과정
프론트엔드 개발을 하다 보면 Redux는 점점 상태 관리 라이브러리가 아니라 비동기 처리, 데이터 가공, 비즈니스 로직까지 모두 몰아넣는 장소가 되는 순간이 옵니다.
저 역시 그런 프로젝트를 경험했습니다.
이 글은 실제 레거시 프로젝트에서 Redux 중심 구조를 axios + transformer + Nanostores 구조로 점진적으로 전환한 경험을 정리한 기록입니다.
핵심은 “Redux가 나쁘다”가 아니라, Redux가 너무 많은 책임을 지고 있던 구조를 어떻게 분해했는가입니다.
0. 기존 Redux 구조의 전체 그림
0.1 전형적인 Redux + Thunk 구조
Action (Thunk)
// actions/messages.ts
export const fetchMessages = () => async (dispatch) => {
dispatch({ type: 'FETCH_MESSAGES_REQUEST' })
try {
const response = await axios.get('/api/messages')
dispatch({
type: 'FETCH_MESSAGES_SUCCESS',
payload: response.data,
})
} catch (error) {
dispatch({
type: 'FETCH_MESSAGES_FAILURE',
error,
})
}
}
이 코드만 보면 “나쁘지 않아 보이는” 구조입니다. 하지만 실제 문제는 reducer에서 발생했습니다.
0.2 전형적인 Redux Reducer (문제의 핵심)
// reducers/messages.ts
const initialState = {
list: [],
loading: false,
error: null,
currentUserId: 10,
}
export default function messagesReducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_MESSAGES_REQUEST':
return {
...state,
loading: true,
error: null,
}
case 'FETCH_MESSAGES_SUCCESS': {
const rawMessages = action.payload.messages
// ❗ reducer 안에서 로직 가공 시작
const parsedMessages = rawMessages
.filter((m) => m.is_deleted !== true)
.map((m) => ({
id: m.id,
text: m.content,
authorId: m.user_id,
createdAt: new Date(m.created_at),
isMine: m.user_id === state.currentUserId,
}))
.sort((a, b) => b.createdAt - a.createdAt)
return {
...state,
list: parsedMessages,
loading: false,
}
}
case 'FETCH_MESSAGES_FAILURE':
return {
...state,
loading: false,
error: action.error,
}
default:
return state
}
}
1. 이 reducer가 실제로 하고 있던 일들
이 reducer는 단순히 “상태를 업데이트”하고 있지 않았습니다.
reducer가 떠안고 있던 책임
-
API 스펙 의존
content,user_id,created_at,is_deleted등 서버 필드 구조를 직접 알고 있음
-
데이터 필터링
-
데이터 변환
-
비즈니스 규칙 처리
isMine계산
-
정렬 로직
-
상태 업데이트
즉, reducer 하나가 너무 많은 맥락과 책임을 가지고 있었습니다.
2. 이 구조가 실제로 만든 문제들
2.1 디버깅 난이도 폭증
UI에서 메시지 순서가 이상하면:
- API 문제인지
- reducer의 정렬 로직 문제인지
- 날짜 파싱 문제인지 한 번에 판단하기 어려웠습니다.
2.2 API 스펙 변경에 취약
서버에서 created_at → createdAt으로 변경되면
런타임 에러가 reducer에서 바로 발생했습니다.
2.3 테스트가 사실상 불가능
reducer 테스트 하나에:
- API payload mocking
- state 구성
- 날짜/정렬/비즈니스 규칙 검증 이 모두가 필요했습니다.
👉 단위 테스트가 아니라 통합 테스트에 가까운 구조
3. 해결 전략: Redux를 버리는 것이 아니라 “분해”하기
문제의 원인은 Redux가 아니라, Redux가 너무 많은 역할을 맡고 있던 구조였습니다.
그래서 저는 다음 원칙을 세웠습니다.
핵심 설계 원칙
- API 호출은 API 레이어에서만
- 데이터 가공/비즈니스 로직은 transformer(순수 함수)에서만
- 상태 관리는 store에서만
- 이들을 연결하는 역할은 UI 에서만
즉,
Redux 하나가 하던 일을 axios + transformer + store 로 나눈다
4. 새 구조의 전체 아키텍처
┌─────────────┐
│ UI │ ← 흐름을 조합하여 데이터 사용
└─────┬───────┘
│
┌─────▼───────┐
│ API │ ← axios
└─────┬───────┘
│
┌─────▼───────┐
│ Transformer │ ← Pure Function
└─────┬───────┘
│
┌─────▼───────┐
│ Store │ ← Nanostores
└─────────────┘
5. API 레이어: axios는 여기서만 사용한다
// api/client.ts
import axios from 'axios'
export const apiClient = axios.create({
baseURL: '/api',
withCredentials: true,
timeout: 5000,
})
// api/messages.ts
import { apiClient } from './client'
export async function fetchMessages() {
const res = await apiClient.get('/messages')
return res.data
}
👉 여기서는 데이터를 절대 가공하지 않는다
6. Transformer: reducer에서 떼어낸 로직 가공
// transformer/messages.ts
export type ApiMessage = {
id: number
content: string
user_id: number
created_at: number
is_deleted: boolean
}
export type Message = {
id: number
text: string
authorId: number
createdAt: number
isMine: boolean
}
export function transformerFetchMessages(
response: { messages: ApiMessage[] },
currentUserId: number
): Message[] {
return response.messages
.filter((m) => m.is_deleted !== true)
.map((m) => ({
id: m.id,
text: m.content,
authorId: m.user_id,
createdAt: m.created_at,
isMine: m.user_id === currentUserId,
}))
.sort((a, b) => b.createdAt - a.createdAt)
}
Transformer의 특징
- 순수 함수
- side-effect 없음
- 테스트 용이
- API 스펙 변경 시 수정 지점 명확
7. Store: Nanostores로 상태를 작게 쪼개기
// stores/messages.ts
import { atom } from 'nanostores'
import type { Message } from '../transformer/messages'
export const $messages = atom<Message[]>([])
- 비동기 없음
- 로직 없음
- 상태만 관리
8. UI 레이어에서 사용
import { useEffect } from 'react'
import { useStore } from '@nanostores/react'
import { $messages } from '../stores/messages'
export function MessageList() {
const messages = useStore($messages)
useEffect(() => {
const response = await fetchMessages()
const userId = $currentUserId.get()
const transformedMessages = transformerFetchMessages(
response,
userId
)
$messages.set(transformedMessages)
}, [])
return (
<ul>
{messages.map((m) => (
<li key={m.id}>
{m.text}
</li>
))}
</ul>
)
}
UI는:
- Redux도
- axios도
- transformer도 전혀 알 필요가 없음
10. 점진적 마이그레이션 전략
Redux를 한 번에 제거하지 않았습니다.
적용 순서
- reducer 하나 선택
- 해당 reducer의 책임 분석
- 동일 기능을 신규 구조로 구현
- UI를 신규 store로 연결
- 기존 Redux 코드 제거
Redux와 Nanostores가 공존 가능한 상태를 유지하며 전환했습니다.
마무리
이 마이그레이션의 핵심은 기술 교체가 아닙니다.
“한 곳이 너무 많은 책임을 가지지 않도록 구조를 나누어” 리덕스보다 더 단순하고 명확한 구조가 필요했습니다.
프론트엔드도 결국 시간이 지나도 운영 가능한 시스템이어야 합니다.