Post

[React] ReduxHelper 유틸리티 - Redux 보일러플레이트 코드 제거하기

예전에 프로젝트에 넣었던 코드를 수업용으로 만드는 과정에서 필받는 바람에 실컷 작성해 버린 코드이다. Redux의 반복적인 보일러플레이트 코드를 줄이고 CRUD 작업을 간소화하는 ReduxHelper 유틸리티인데, 난이도가 너무 높아져 버려서 수업에서 사용은 못하고 백업용으로 포스팅 한다.

[React] ReduxHelper 유틸리티 - Redux 보일러플레이트 코드 제거하기

개요

Redux는 강력한 상태 관리 라이브러리이지만, 실제 프로젝트에서 사용할 때 많은 보일러플레이트 코드가 필요합니다. 특히 CRUD(Create, Read, Update, Delete) 작업을 위한 Redux Slice를 작성할 때마다 비슷한 패턴의 코드를 반복해서 작성하게 됩니다.

이러한 문제를 해결하기 위해 ReduxHelper 유틸리티를 제작했습니다. 이 유틸리티는 다음과 같은 기능을 제공합니다:

  • 보일러플레이트 코드 제거: 반복되는 Redux 코드를 자동화
  • CRUD 작업 간소화: 기본적인 CRUD 액션을 한 번에 생성
  • 에러 처리 표준화: 일관된 에러 처리 패턴 제공
  • 커스터마이징 가능: 프로젝트 요구사항에 맞게 확장 가능

Redux의 일반적인 문제점

1. 반복적인 Slice 구조

일반적인 Redux Slice는 다음과 같은 반복적인 구조를 가집니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 매번 비슷한 initialState
const initialState = {
    status: 200,
    message: "OK",
    items: [],
    loading: false
};

// 매번 비슷한 extraReducers
extraReducers: (builder) => {
    builder
        .addCase(fetchItems.pending, (state) => {
            state.loading = true;
        })
        .addCase(fetchItems.fulfilled, (state, action) => {
            state.loading = false;
            state.items = action.payload.items;
            state.status = action.payload.status;
        })
        .addCase(fetchItems.rejected, (state, action) => {
            state.loading = false;
            state.status = action.payload.status;
            state.message = action.payload.message;
        });
}

2. 중복되는 AsyncThunk 패턴

각 API 호출마다 비슷한 AsyncThunk를 작성해야 합니다:

1
2
3
4
5
6
7
8
9
10
11
export const fetchItems = createAsyncThunk(
    'items/fetchItems',
    async (params, { rejectWithValue }) => {
        try {
            const response = await api.get('/items', params);
            return response.data;
        } catch (error) {
            return rejectWithValue(error.response.data);
        }
    }
);

ReduxHelper의 해결책

ReduxHelper는 이러한 반복적인 코드를 추상화하여 간단한 함수 호출로 해결합니다.

핵심 기능

  1. 표준화된 초기 상태 관리
  2. 자동화된 pending/fulfilled/rejected 처리
  3. HTTP 메서드별 AsyncThunk 생성기
  4. CRUD 액션 일괄 생성
  5. 커스텀 콜백 지원

의존성: helper/FetchHelper.js

ReduxHelper는 HTTP 요청을 처리하기 위해 FetchHelper 유틸리티를 사용합니다. 먼저 이 의존성부터 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
/**
 * HTTP 요청을 위한 FetchHelper 유틸리티
 * 리팩토링: 코드 중복 제거, 가독성 향상, 타입 안전성 개선
 */

// 상수 정의
const HTTP_METHODS = {
    GET: 'GET',
    POST: 'POST',
    PUT: 'PUT',
    DELETE: 'DELETE'
};

const DEFAULT_LOADER_SELECTOR = '#loader';

const fetchHelper = {
    /**
     * URL 객체 생성 및 검증
     * @param {string|URL} url - 요청할 URL
     * @returns {URL} URL 객체
     */
    __createUrl(url) {
        if (url instanceof URL) return url;

        if (typeof url !== 'string') {
            throw new Error('URL은 문자열 또는 URL 객체여야 합니다.');
        }

        // HTTP/HTTPS로 시작하지 않으면 현재 도메인을 기본으로 사용
        const baseUrl = url.startsWith('http') ? undefined : window.location.origin;
        return new URL(url, baseUrl);
    },

    /**
     * 파라미터를 FormData로 변환
     * @param {*} params - 변환할 파라미터
     * @returns {FormData|null} FormData 객체 또는 null
     */
    __toFormData(params) {
        if (!params) return null;
        if (params instanceof FormData) return params;

        // SubmitEvent나 HTMLFormElement인 경우
        if (params instanceof SubmitEvent) {
            return new FormData(params.currentTarget);
        }
        if (params instanceof HTMLFormElement) {
            return new FormData(params);
        }

        // 일반 객체인 경우 FormData로 변환
        const formData = new FormData();
        const clonedParams = structuredClone(params);

        Object.entries(clonedParams).forEach(([key, value]) => {
            if (value != null && value !== '') {
                formData.set(key, value);
            }
        });

        return formData;
    },

    /**
     * URL에 쿼리 파라미터 추가
     * @param {URL} url - URL 객체
     * @param {*} params - 쿼리 파라미터
     */
    __addQueryParams(url, params) {
        if (!params) return;

        let processedParams = params;

        // FormData로 변환 (SubmitEvent, HTMLFormElement 처리)
        if (params instanceof SubmitEvent || params instanceof HTMLFormElement) {
            processedParams = this.__toFormData(params);
        }

        if (processedParams instanceof FormData) {
            // FormData의 경우
            for (const [key, value] of processedParams.entries()) {
                if (value) url.searchParams.set(key, value);
            }
        } else {
            // 일반 객체의 경우
            Object.entries(processedParams).forEach(([key, value]) => {
                if (value != null && value !== '') {
                    url.searchParams.set(key, String(value));
                }
            });
        }
    },

    /**
     * 로더 요소 제어
     * @param {string|Element} loader - 로더 요소 또는 셀렉터
     * @param {boolean} show - 표시 여부
     */
    __toggleLoader(loader, show) {
        if (!loader) return;

        const element = typeof loader === 'string'
            ? document.querySelector(loader)
            : loader;

        if (element) {
            element.style.display = show ? 'block' : 'none';
        }
    },

    /**
     * HTTP 요청 처리 핵심 메서드
     * @param {string|URL} url - 요청 URL
     * @param {string} method - HTTP 메서드
     * @param {*} params - 요청 파라미터
     * @param {string|Element} loader - 로더 요소
     * @returns {Promise<Object>} 응답 데이터
     */
    async __request(url, method = HTTP_METHODS.GET, params = null, loader = DEFAULT_LOADER_SELECTOR) {
        const startTime = Date.now();
        console.group(`🌐 FetchHelper [${method}] :: ${new Date().toLocaleString()}`);

        try {
            // URL 객체 생성
            const requestUrl = this.__createUrl(url);

            // 요청 옵션 설정
            const options = this.__buildRequestOptions(method, params);

            console.log(`📤 Request: [${method}] ${requestUrl}`);
            if (options.body) console.log(`📦 Body:`, options.body);

            // 로더 표시
            this.__toggleLoader(loader, true);

            // HTTP 요청 실행
            const response = await fetch(requestUrl, options);

            // 응답 상태 검증
            await this.__validateResponse(response);

            // JSON 응답 파싱
            const result = await response.json();

            const duration = Date.now() - startTime;
            console.log(`📥 Response (${duration}ms):`, result);

            return result;

        } catch (error) {
            console.error('❌ Request failed:', error);
            throw error;
        } finally {
            this.__toggleLoader(loader, false);
            console.groupEnd();
        }
    },

    /**
     * 요청 옵션 빌드
     * @param {string} method - HTTP 메서드
     * @param {*} params - 파라미터
     * @returns {Object} fetch 옵션 객체
     */
    __buildRequestOptions(method, params) {
        if (method === HTTP_METHODS.GET) {
            return { method };
        }

        return {
            method,
            cache: 'no-cache',
            headers: {},
            body: this.__toFormData(params)
        };
    },

    /**
     * 응답 상태 검증
     * @param {Response} response - fetch 응답 객체
     */
    async __validateResponse(response) {
        const statusCode = Math.floor(response.status / 100);

        if (statusCode === 2) return; // 2xx는 성공

        // 에러 응답 처리
        let errorMessage = response.statusText || '서버에서 에러가 발생했습니다.';

        try {
            const errorData = await response.json();
            errorMessage = errorData?.message || errorMessage;
        } catch {
            // JSON 파싱 실패시 기본 메시지 사용
        }

        const error = new Error(errorMessage);
        error.status = response.status;
        error.response = response;
        throw error;
    },

    /**
     * GET 요청
     * @param {string|URL} url - 요청 URL
     * @param {Object} params - 쿼리 파라미터
     * @param {string|Element} loader - 로더 요소
     * @returns {Promise<Object>} 응답 데이터
     */
    async get(url, params = null, loader = DEFAULT_LOADER_SELECTOR) {
        const requestUrl = this.__createUrl(url);
        this.__addQueryParams(requestUrl, params);
        return this.__request(requestUrl, HTTP_METHODS.GET, null, loader);
    },

    /**
     * POST 요청
     * @param {string|URL} url - 요청 URL
     * @param {*} params - 요청 파라미터
     * @param {string|Element} loader - 로더 요소
     * @returns {Promise<Object>} 응답 데이터
     */
    async post(url, params = null, loader = DEFAULT_LOADER_SELECTOR) {
        return this.__request(url, HTTP_METHODS.POST, params, loader);
    },

    /**
     * PUT 요청
     * @param {string|URL} url - 요청 URL
     * @param {*} params - 요청 파라미터
     * @param {string|Element} loader - 로더 요소
     * @returns {Promise<Object>} 응답 데이터
     */
    async put(url, params = null, loader = DEFAULT_LOADER_SELECTOR) {
        return this.__request(url, HTTP_METHODS.PUT, params, loader);
    },

    /**
     * DELETE 요청
     * @param {string|URL} url - 요청 URL
     * @param {*} params - 요청 파라미터
     * @param {string|Element} loader - 로더 요소
     * @returns {Promise<Object>} 응답 데이터
     */
    async delete(url, params = null, loader = DEFAULT_LOADER_SELECTOR) {
        return this.__request(url, HTTP_METHODS.DELETE, params, loader);
    }
};

export default fetchHelper;

FetchHelper의 주요 특징

  1. 타입 안전성: 엄격한 타입 검증과 에러 처리
  2. 다양한 입력 지원: FormData, HTMLFormElement, SubmitEvent, 일반 객체 모두 처리
  3. 자동 로더 관리: 요청 중 로딩 UI 자동 제어
  4. 상세한 로깅: 개발 시 디버깅을 위한 상세한 콘솔 로그
  5. 에러 처리 표준화: 일관된 에러 응답 처리

helper/ReduxHelper.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
/**
 * /src/helpers/ReduxHelper.js
 *
 * ReduxSlice를 작업하면서 반복되는 중복코드의 모듈화
 */
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import fetchHelper from "./FetchHelper";

// 기본 초기 상태
const DEFAULT_INITIAL_STATE = {
    status: 200,
    message: "OK",
    item: null,
    timestamp: null,
    loading: false,
};

// 리덕스가 로딩 상태를 관리하는 상태값을 생성하는 함수 (Immer 사용으로 최적화)
const pending = (state) => {
    state.loading = true;
};

// 리덕스가 성공 상태를 관리하는 상태값을 생성하는 함수
const fulfilled = (state, { payload }) => {
    return { ...payload, loading: false };
};

// 리덕스가 실패 상태를 관리하는 상태값을 생성하는 함수 (Immer 사용으로 최적화)
const rejected = (state, { payload }) => {
    state.loading = false;
    state.status = payload?.status || 0;
    state.message = payload?.message || "Unknown Error";
};

// HTTP 메서드별 공통 함수 생성 (개선된 콜백 구조)
const createHttpAsyncThunk = (method) => {
    return (alias, url, options = {}) => {
        const {
            beforeSend = (payload) => ({ url, params: payload }),
            onSuccess = null,
            onError = null
        } = options;

        const asyncThunk = createAsyncThunk(alias, async (payload, { rejectWithValue }) => {
            const { url: finalUrl, params } = beforeSend(payload);

            try {
                return await fetchHelper[method](finalUrl, params);
            } catch (err) {
                console.group(`[ReduxHelper.${method}] Redux Action Error`);
                console.error(err);
                console.groupEnd();

                // 커스텀 에러 처리
                if (onError) {
                    const customError = onError(err);
                    if (customError) return rejectWithValue(customError);
                }

                return rejectWithValue(err);
            }
        });

        // onSuccess 콜백을 asyncThunk에 메타데이터로 저장
        if (onSuccess) {
            asyncThunk._onSuccess = onSuccess;
        }

        return asyncThunk;
    };
};

const reduxHelper = {
    // 리덕스 Slice 객체를 생성하는 함수 (개선된 구조)
    // 1) sliceName: slice 객체의 이름
    // 2) asyncActions: 비동기 액션들의 배열
    // 3) reducers: 동기 액션을 위한 리듀서 객체
    getDefaultSlice: (sliceName, asyncActions = [], reducers = {}) => {
        if (!sliceName || typeof sliceName !== 'string') {
            throw new Error('sliceName은 필수이며 문자열이어야 합니다.');
        }

        return createSlice({
            name: sliceName,
            initialState: DEFAULT_INITIAL_STATE,
            reducers,
            extraReducers: (builder) => {
                asyncActions.forEach((asyncAction) => {
                    builder.addCase(asyncAction.pending, pending);

                    // onSuccess 콜백이 있다면 사용, 없다면 기본 fulfilled 사용
                    const successHandler = asyncAction._onSuccess
                        ? (state, action) => {
                            state.loading = false;
                            asyncAction._onSuccess(state, action);
                          }
                        : fulfilled;

                    builder.addCase(asyncAction.fulfilled, successHandler);
                    builder.addCase(asyncAction.rejected, rejected);
                });
            },
        });
    },

    // HTTP 메서드별 함수들
    get: createHttpAsyncThunk('get'),
    post: createHttpAsyncThunk('post'),
    put: createHttpAsyncThunk('put'),
    delete: createHttpAsyncThunk('delete'),

    // 편의 메서드: CRUD 액션을 한번에 생성
    createCrudActions: (baseName, baseUrl) => {
        return {
            getList: createHttpAsyncThunk('get')(`${baseName}/getList`, baseUrl),

            getItem: createHttpAsyncThunk('get')(`${baseName}/getItem`, baseUrl, {
                beforeSend: (id) => ({
                    url: `${baseUrl}/${id}`,
                    params: {}
                })
            }),

            postItem: createHttpAsyncThunk('post')(`${baseName}/postItem`, baseUrl, {
                onSuccess: (state, { payload }) => {
                    // 새로운 아이템을 배열에 추가 (낙관적 업데이트)
                    if (payload.item && Array.isArray(state.item)) {
                        state.item.push(payload.item);
                    } else {
                        // 전체 응답을 상태로 설정
                        Object.assign(state, payload);
                    }
                }
            }),

            putItem: createHttpAsyncThunk('put')(`${baseName}/putItem`, baseUrl, {
                beforeSend: (payload) => {
                    const { id, ...data } = payload;
                    return {
                        url: id ? `${baseUrl}/${id}` : baseUrl,
                        params: data
                    };
                },
                onSuccess: (state, { payload }) => {
                    // 수정된 아이템을 배열에서 업데이트 (낙관적 업데이트)
                    if (payload.item && Array.isArray(state.item)) {
                        const index = state.item.findIndex(item => item.id === payload.item.id);
                        if (index !== -1) {
                            state.item[index] = payload.item;
                        }
                    } else {
                        Object.assign(state, payload);
                    }
                }
            }),

            deleteItem: createHttpAsyncThunk('delete')(`${baseName}/deleteItem`, baseUrl, {
                beforeSend: (id) => ({
                    url: `${baseUrl}/${id}`,
                    params: {}
                }),
                onSuccess: (state, { payload, meta }) => {
                    // 삭제된 아이템을 배열에서 제거 (낙관적 업데이트)
                    // meta.arg는 원래 전달된 인수 (여기서는 id)
                    const deletedId = meta.arg;
                    if (Array.isArray(state.item)) {
                        state.item = state.item.filter(item => item.id !== deletedId);
                    }
                }
            })
        };
    }
};

export default reduxHelper;

ReduxHelper 상세 분석

1. 기본 구조와 상태 관리

표준화된 초기 상태

1
2
3
4
5
6
7
const DEFAULT_INITIAL_STATE = {
    status: 200,        // HTTP 상태 코드
    message: "OK",      // 응답 메시지
    item: null,         // 데이터 (단일 항목 또는 배열)
    timestamp: null,    // 타임스탬프
    loading: false,     // 로딩 상태
};

모든 Redux Slice는 이 표준화된 구조를 기본으로 사용하여 일관성을 보장합니다.

상태 변화 함수들

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 로딩 시작 (pending)
const pending = (state) => {
    state.loading = true;
};

// 성공 시 (fulfilled)
const fulfilled = (state, { payload }) => {
    return { ...payload, loading: false };
};

// 실패 시 (rejected)
const rejected = (state, { payload }) => {
    state.loading = false;
    state.status = payload?.status || 0;
    state.message = payload?.message || "Unknown Error";
};

2. HTTP 메서드별 AsyncThunk 생성기

createHttpAsyncThunk 함수는 HTTP 메서드에 따라 AsyncThunk를 동적으로 생성합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const createHttpAsyncThunk = (method) => {
    return (alias, url, options = {}) => {
        // options에서 콜백 함수들 추출
        const {
            beforeSend = (payload) => ({ url, params: payload }),
            onSuccess = null,
            onError = null
        } = options;

        // AsyncThunk 생성
        const asyncThunk = createAsyncThunk(alias, async (payload, { rejectWithValue }) => {
            const { url: finalUrl, params } = beforeSend(payload);

            try {
                return await fetchHelper[method](finalUrl, params);
            } catch (err) {
                // 에러 로깅
                console.group(`[ReduxHelper.${method}] Redux Action Error`);
                console.error(err);
                console.groupEnd();

                // 커스텀 에러 처리
                if (onError) {
                    const customError = onError(err);
                    if (customError) return rejectWithValue(customError);
                }

                return rejectWithValue(err);
            }
        });

        // onSuccess 콜백을 메타데이터로 저장
        if (onSuccess) {
            asyncThunk._onSuccess = onSuccess;
        }

        return asyncThunk;
    };
};

콜백 옵션 설명

  • beforeSend: 요청 전 URL과 파라미터를 변경할 수 있는 콜백
  • onSuccess: 성공 시 상태를 커스터마이징할 수 있는 콜백
  • onError: 에러 시 추가 처리를 할 수 있는 콜백

3. Slice 생성 함수

getDefaultSlice 함수는 표준화된 Redux Slice를 생성합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
getDefaultSlice: (sliceName, asyncActions = [], reducers = {}) => {
    if (!sliceName || typeof sliceName !== 'string') {
        throw new Error('sliceName은 필수이며 문자열이어야 합니다.');
    }

    return createSlice({
        name: sliceName,
        initialState: DEFAULT_INITIAL_STATE,
        reducers,
        extraReducers: (builder) => {
            asyncActions.forEach((asyncAction) => {
                builder.addCase(asyncAction.pending, pending);

                // onSuccess 콜백이 있다면 사용, 없다면 기본 fulfilled 사용
                const successHandler = asyncAction._onSuccess
                    ? (state, action) => {
                        state.loading = false;
                        asyncAction._onSuccess(state, action);
                      }
                    : fulfilled;

                builder.addCase(asyncAction.fulfilled, successHandler);
                builder.addCase(asyncAction.rejected, rejected);
            });
        },
    });
}

4. CRUD 액션 일괄 생성

createCrudActions 함수는 기본적인 CRUD 작업을 위한 액션들을 한 번에 생성합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
createCrudActions: (baseName, baseUrl) => {
    return {
        // 목록 조회 (GET /api/resource)
        getList: createHttpAsyncThunk('get')(`${baseName}/getList`, baseUrl),

        // 단일 항목 조회 (GET /api/resource/:id)
        getItem: createHttpAsyncThunk('get')(`${baseName}/getItem`, baseUrl, {
            beforeSend: (id) => ({
                url: `${baseUrl}/${id}`,
                params: {}
            })
        }),

        // 새 항목 생성 (POST /api/resource)
        postItem: createHttpAsyncThunk('post')(`${baseName}/postItem`, baseUrl, {
            onSuccess: (state, { payload }) => {
                // 낙관적 업데이트: 새 항목을 배열에 추가
                if (payload.item && Array.isArray(state.item)) {
                    state.item.push(payload.item);
                } else {
                    Object.assign(state, payload);
                }
            }
        }),

        // 항목 수정 (PUT /api/resource/:id)
        putItem: createHttpAsyncThunk('put')(`${baseName}/putItem`, baseUrl, {
            beforeSend: (payload) => {
                const { id, ...data } = payload;
                return {
                    url: id ? `${baseUrl}/${id}` : baseUrl,
                    params: data
                };
            },
            onSuccess: (state, { payload }) => {
                // 낙관적 업데이트: 배열에서 해당 항목 수정
                if (payload.item && Array.isArray(state.item)) {
                    const index = state.item.findIndex(item => item.id === payload.item.id);
                    if (index !== -1) {
                        state.item[index] = payload.item;
                    }
                } else {
                    Object.assign(state, payload);
                }
            }
        }),

        // 항목 삭제 (DELETE /api/resource/:id)
        deleteItem: createHttpAsyncThunk('delete')(`${baseName}/deleteItem`, baseUrl, {
            beforeSend: (id) => ({
                url: `${baseUrl}/${id}`,
                params: {}
            }),
            onSuccess: (state, { payload, meta }) => {
                // 낙관적 업데이트: 배열에서 해당 항목 제거
                const deletedId = meta.arg;
                if (Array.isArray(state.item)) {
                    state.item = state.item.filter(item => item.id !== deletedId);
                }
            }
        })
    };
}

사용 방법

1. 기본 CRUD Slice 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import reduxHelper from '../helpers/ReduxHelper';

const API_URL = '/professors';

// CRUD 액션을 한번에 생성
const crudActions = reduxHelper.createCrudActions("ProfessorSlice", API_URL);
export const { getList, getItem, postItem, putItem, deleteItem } = crudActions;

// 슬라이스 생성
const ProfessorSlice = reduxHelper.getDefaultSlice(
    "ProfessorSlice",
    [getList, getItem, postItem, putItem, deleteItem]
);

export default ProfessorSlice.reducer;

2. 커스텀 액션 추가

기본 CRUD 외에 추가 액션이 필요한 경우:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import reduxHelper from '../helpers/ReduxHelper';

const API_URL = '/professors';

// 기본 CRUD 액션
const crudActions = reduxHelper.createCrudActions("ProfessorSlice", API_URL);
export const { getList, getItem, postItem, putItem, deleteItem } = crudActions;

// 커스텀 액션 - 교수 검색
export const searchProfessors = reduxHelper.get(
    "ProfessorSlice/searchProfessors",
    API_URL,
    {
        beforeSend: (searchTerm) => ({
            url: `${API_URL}/search`,
            params: { q: searchTerm }
        }),
        onSuccess: (state, { payload }) => {
            // 검색 결과를 별도로 처리
            state.searchResults = payload.item;
            state.status = payload.status;
            state.message = payload.message;
        }
    }
);

// 커스텀 액션 - 교수 통계
export const getProfessorStats = reduxHelper.get(
    "ProfessorSlice/getProfessorStats",
    `${API_URL}/stats`,
    {
        onSuccess: (state, { payload }) => {
            state.stats = payload.stats;
        }
    }
);

// 슬라이스 생성 (커스텀 액션 포함)
const ProfessorSlice = reduxHelper.getDefaultSlice(
    "ProfessorSlice",
    [getList, getItem, postItem, putItem, deleteItem, searchProfessors, getProfessorStats],
    {
        // 동기 액션 (필요한 경우)
        clearSearchResults: (state) => {
            state.searchResults = [];
        }
    }
);

export default ProfessorSlice.reducer;

3. 컴포넌트에서 사용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getList, getItem, postItem, putItem, deleteItem } from '../slices/ProfessorSlice';

const ProfessorComponent = () => {
    const dispatch = useDispatch();
    const { item, loading, status, message } = useSelector(state => state.professor);

    useEffect(() => {
        // 교수 목록 조회
        dispatch(getList());
    }, [dispatch]);

    const handleCreate = (professorData) => {
        // 새 교수 생성
        dispatch(postItem(professorData));
    };

    const handleUpdate = (id, professorData) => {
        // 교수 정보 수정
        dispatch(putItem({ id, ...professorData }));
    };

    const handleDelete = (id) => {
        // 교수 삭제
        dispatch(deleteItem(id));
    };

    const handleGetDetail = (id) => {
        // 특정 교수 상세 조회
        dispatch(getItem(id));
    };

    if (loading) return <div>Loading...</div>;
    if (status !== 200) return <div>Error: {message}</div>;

    return (
        <div>
            {/* UI 렌더링 */}
        </div>
    );
};

4. 고급 사용법

파일 업로드 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const uploadProfessorPhoto = reduxHelper.post(
    "ProfessorSlice/uploadPhoto",
    "/professors/upload",
    {
        beforeSend: (payload) => {
            const formData = new FormData();
            formData.append('photo', payload.file);
            formData.append('professorId', payload.id);

            return {
                url: "/professors/upload",
                params: formData
            };
        },
        onSuccess: (state, { payload }) => {
            // 업로드된 사진 URL을 해당 교수 데이터에 반영
            if (Array.isArray(state.item)) {
                const index = state.item.findIndex(p => p.id === payload.professorId);
                if (index !== -1) {
                    state.item[index].photoUrl = payload.photoUrl;
                }
            }
        }
    }
);

페이지네이션 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const getProfessorsByPage = reduxHelper.get(
    "ProfessorSlice/getByPage",
    API_URL,
    {
        beforeSend: ({ page, limit }) => ({
            url: API_URL,
            params: { page, limit }
        }),
        onSuccess: (state, { payload }) => {
            state.item = payload.items;
            state.pagination = {
                currentPage: payload.currentPage,
                totalPages: payload.totalPages,
                totalItems: payload.totalItems
            };
        }
    }
);

장점과 특징

1. 코드 재사용성

  • 한 번 작성한 ReduxHelper로 모든 CRUD 작업을 표준화
  • 프로젝트 전반에 걸쳐 일관된 Redux 패턴 적용

2. 개발 생산성 향상

  • 보일러플레이트 코드 95% 이상 제거
  • 새로운 엔티티 추가 시 몇 줄의 코드만으로 완전한 CRUD 구현

3. 에러 처리 표준화

  • 모든 API 호출에 대한 일관된 에러 처리
  • 개발자 도구에서 에러 추적 용이

4. 낙관적 업데이트 지원

  • Create/Update/Delete 시 즉시 UI 반영
  • 사용자 경험 향상

5. 유연한 커스터마이징

  • beforeSend, onSuccess, onError 콜백으로 세밀한 제어 가능
  • 프로젝트별 요구사항에 맞게 확장 가능

전통적인 방식과 비교

전통적인 Redux Slice (약 80-100줄)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 전통적인 방식 - ProfessorSlice.js
const initialState = {
    professors: [],
    professor: null,
    loading: false,
    status: 200,
    message: 'OK'
};

export const fetchProfessors = createAsyncThunk(
    'professor/fetchProfessors',
    async (_, { rejectWithValue }) => {
        try {
            const response = await api.get('/professors');
            return response.data;
        } catch (error) {
            return rejectWithValue(error.response.data);
        }
    }
);

export const fetchProfessor = createAsyncThunk(
    'professor/fetchProfessor',
    async (id, { rejectWithValue }) => {
        try {
            const response = await api.get(`/professors/${id}`);
            return response.data;
        } catch (error) {
            return rejectWithValue(error.response.data);
        }
    }
);

// ... (더 많은 AsyncThunk들)

const professorSlice = createSlice({
    name: 'professor',
    initialState,
    reducers: {},
    extraReducers: (builder) => {
        builder
            .addCase(fetchProfessors.pending, (state) => {
                state.loading = true;
            })
            .addCase(fetchProfessors.fulfilled, (state, action) => {
                state.loading = false;
                state.professors = action.payload.professors;
                state.status = action.payload.status;
            })
            .addCase(fetchProfessors.rejected, (state, action) => {
                state.loading = false;
                state.status = action.payload.status;
                state.message = action.payload.message;
            })
            // ... (더 많은 케이스들)
    }
});

ReduxHelper 사용 방식 (약 10줄)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ReduxHelper 방식 - ProfessorSlice.js
import reduxHelper from '../helpers/ReduxHelper';

const API_URL = '/professors';

const crudActions = reduxHelper.createCrudActions("ProfessorSlice", API_URL);
export const { getList, getItem, postItem, putItem, deleteItem } = crudActions;

const ProfessorSlice = reduxHelper.getDefaultSlice(
    "ProfessorSlice",
    [getList, getItem, postItem, putItem, deleteItem]
);

export default ProfessorSlice.reducer;

주의사항과 베스트 프랙티스

1. FetchHelper 의존성

ReduxHelper는 FetchHelper에 의존하므로, 해당 유틸리티도 함께 구현해야 합니다. 위에서 소개한 FetchHelper.js를 먼저 구현하고 ReduxHelper를 사용하세요.

2. 데이터 구조 일관성

모든 API 응답이 동일한 구조를 가져야 최적의 효과를 얻을 수 있습니다:

1
2
3
4
5
6
{
    status: 200,
    message: "OK",
    item: [...], // 또는 단일 객체
    timestamp: "2025-08-05T07:30:22.000Z"
}

3. 에러 처리

서버에서 일관된 에러 응답 형식을 사용해야 합니다:

1
2
3
4
5
{
    status: 400,
    message: "Bad Request",
    errors: {...}
}

결론

ReduxHelper와 FetchHelper의 조합은 Redux의 강력함을 유지하면서도 개발자의 생산성을 크게 향상시키는 완전한 솔루션입니다.

주요 이점

  1. 완전한 추상화: HTTP 요청부터 Redux 상태 관리까지 모든 보일러플레이트 제거
  2. 타입 안전성: FetchHelper의 엄격한 타입 검증으로 런타임 에러 방지
  3. 개발자 경험: 상세한 로깅과 에러 처리로 디버깅 용이
  4. 확장성: 프로젝트 요구사항에 맞게 점진적 확장 가능

특히 CRUD 중심의 애플리케이션에서 그 효과가 극대화되며, 팀 프로젝트에서 일관된 코드 스타일을 유지하는 데도 큰 도움이 됩니다.

더 복잡한 상태 관리가 필요한 경우에도 기본 구조를 유지하면서 점진적으로 확장할 수 있어, 프로젝트의 모든 단계에서 유용하게 활용할 수 있습니다.

This post is licensed under CC BY 4.0 by the author.