🌐 웹개발

Redux

Redux Toolkit

JavaScript 앱의 예측 가능한 상태 관리 라이브러리. 단방향 데이터 흐름과 불변성 원칙.

📖 상세 설명

Redux는 2015년 Dan Abramov가 개발한 JavaScript 상태 관리 라이브러리로, Flux 아키텍처를 단순화한 구현체입니다. 세 가지 핵심 원칙이 있습니다. 단일 진실의 원천(Single Source of Truth) - 전체 앱 상태가 하나의 Store에 저장됩니다. 상태는 읽기 전용(State is Read-Only) - Action을 통해서만 변경 가능합니다. 순수 함수(Pure Functions) - Reducer는 이전 상태와 Action을 받아 새 상태를 반환하는 순수 함수입니다.

Redux의 데이터 흐름은 단방향입니다. 사용자 이벤트가 발생하면 dispatch(action)을 호출하고, Store가 Reducer를 실행하여 새 상태를 계산하며, 구독된 컴포넌트가 리렌더링됩니다. 이 예측 가능한 흐름은 디버깅을 쉽게 하고, 상태 변화를 추적할 수 있게 합니다. Redux DevTools로 시간 여행 디버깅(Time Travel Debugging)이 가능합니다.

Redux Toolkit(RTK)은 2019년에 공식 출시된 Redux의 현대적 구현체로, 보일러플레이트를 대폭 줄였습니다. createSlice로 Action과 Reducer를 한 번에 정의하고, Immer를 내장하여 불변성을 자동 처리합니다. configureStore가 DevTools, middleware 설정을 자동화합니다. RTK는 이제 Redux 사용의 표준 방식입니다.

RTK Query는 Redux Toolkit 1.6에서 추가된 데이터 페칭 라이브러리로, React Query와 유사한 캐싱, 자동 리페칭, 낙관적 업데이트를 제공합니다. createApi로 엔드포인트를 정의하면 자동으로 hooks(useGetUsersQuery, useCreateUserMutation 등)가 생성됩니다. 서버 상태 관리에 Redux와 별도 라이브러리를 조합할 필요가 없어졌습니다.

💻 코드 예제

Redux Toolkit Slice
// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
    id: string;
    name: string;
    price: number;
    quantity: number;
}

interface CartState {
    items: CartItem[];
    isOpen: boolean;
}

const initialState: CartState = {
    items: [],
    isOpen: false,
};

const cartSlice = createSlice({
    name: 'cart',
    initialState,
    reducers: {
        // Immer로 "변경"처럼 작성해도 불변성 자동 처리
        addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
            const existingItem = state.items.find(item => item.id === action.payload.id);

            if (existingItem) {
                existingItem.quantity += 1;
            } else {
                state.items.push({ ...action.payload, quantity: 1 });
            }
        },

        removeItem: (state, action: PayloadAction<string>) => {
            state.items = state.items.filter(item => item.id !== action.payload);
        },

        updateQuantity: (state, action: PayloadAction<{ id: string; quantity: number }>) => {
            const item = state.items.find(item => item.id === action.payload.id);
            if (item) {
                item.quantity = action.payload.quantity;
            }
        },

        clearCart: (state) => {
            state.items = [];
        },

        toggleCart: (state) => {
            state.isOpen = !state.isOpen;
        },
    },
});

// Action creators 자동 생성
export const { addItem, removeItem, updateQuantity, clearCart, toggleCart } = cartSlice.actions;

// Selectors
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) =>
    state.cart.items.reduce((total, item) => total + item.price * item.quantity, 0);
export const selectCartCount = (state: RootState) =>
    state.cart.items.reduce((count, item) => count + item.quantity, 0);

export default cartSlice.reducer;
RTK Query API
// services/api.ts - RTK Query
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface User {
    id: number;
    name: string;
    email: string;
}

export const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api',
        prepareHeaders: (headers) => {
            const token = localStorage.getItem('token');
            if (token) {
                headers.set('Authorization', `Bearer ${token}`);
            }
            return headers;
        },
    }),
    tagTypes: ['User', 'Post'],
    endpoints: (builder) => ({
        // Query - GET 요청
        getUsers: builder.query<User[], void>({
            query: () => '/users',
            providesTags: ['User'],
        }),

        getUser: builder.query<User, number>({
            query: (id) => `/users/${id}`,
            providesTags: (result, error, id) => [{ type: 'User', id }],
        }),

        // Mutation - POST/PUT/DELETE 요청
        createUser: builder.mutation<User, Omit<User, 'id'>>({
            query: (body) => ({
                url: '/users',
                method: 'POST',
                body,
            }),
            invalidatesTags: ['User'], // 목록 자동 리페치
        }),

        updateUser: builder.mutation<User, Partial<User> & Pick<User, 'id'>>({
            query: ({ id, ...body }) => ({
                url: `/users/${id}`,
                method: 'PATCH',
                body,
            }),
            invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
        }),

        deleteUser: builder.mutation<void, number>({
            query: (id) => ({
                url: `/users/${id}`,
                method: 'DELETE',
            }),
            invalidatesTags: ['User'],
        }),
    }),
});

// Hooks 자동 생성
export const {
    useGetUsersQuery,
    useGetUserQuery,
    useCreateUserMutation,
    useUpdateUserMutation,
    useDeleteUserMutation,
} = api;

// -------------------------------------------------

// 컴포넌트에서 사용
function UserList() {
    const { data: users, isLoading, error } = useGetUsersQuery();
    const [deleteUser, { isLoading: isDeleting }] = useDeleteUserMutation();

    if (isLoading) return <div>로딩 중...</div>;
    if (error) return <div>에러 발생</div>;

    return (
        <ul>
            {users?.map(user => (
                <li key={user.id}>
                    {user.name}
                    <button
                        onClick={() => deleteUser(user.id)}
                        disabled={isDeleting}
                    >
                        삭제
                    </button>
                </li>
            ))}
        </ul>
    );
}

🗣️ 실무 대화 예시

💼 상태관리 선택 논의
"Redux vs Zustand vs Jotai, 뭘 써야 할까요?"

"규모가 크고 DevTools로 상태 추적이 중요하면 Redux Toolkit이 좋아요. 작은 프로젝트나 간단한 전역 상태면 Zustand가 더 가볍고요. 서버 상태가 대부분이면 TanStack Query만으로도 충분할 수 있어요. Redux는 복잡하지만 팀원들이 익숙하면 생산성이 높습니다."
🔍 코드 리뷰
"Reducer에서 state.items.push() 쓰면 불변성 위반 아닌가요?"

"Redux Toolkit의 createSlice 안에서는 괜찮아요. Immer가 내장되어 있어서 '변경'처럼 작성해도 내부적으로 불변성을 유지한 새 객체를 반환해요. 하지만 slice 밖에서 reducer를 직접 작성할 때는 여전히 불변성을 지켜야 합니다."
📱 기술 면접
"Redux에서 비동기 작업은 어떻게 처리하나요?"

"RTK Query 쓰면 가장 간단해요. 아니면 createAsyncThunk로 비동기 thunk를 만들고, extraReducers에서 pending/fulfilled/rejected 상태를 처리합니다. 예전엔 redux-saga나 redux-observable도 많이 썼지만, 지금은 RTK Query가 대부분의 경우를 커버합니다."

⚠️ 주의사항

  • 과도한 전역 상태 - 모든 상태를 Redux에 넣지 마세요. 폼 입력, 모달 열림 같은 로컬 상태는 useState로 충분합니다. 여러 컴포넌트가 공유해야 하는 상태만 Redux에 두세요.
  • Legacy Redux 피하기 - switch문 reducer, action type 상수, createStore는 구식입니다. 반드시 Redux Toolkit의 createSlice, configureStore를 사용하세요.
  • Selector 최적화 - 매번 새 객체/배열을 반환하는 selector는 불필요한 리렌더링을 유발합니다. createSelector(reselect)로 메모이제이션하세요.

🔗 관련 용어

📚 더 배우기