상태를 객체로 묶어서 관리하기 : useState에서 useReducer로

    왜 useReducer를 사용하게 되었을까?

    요구사항

    • A 컴포넌트에서 파일이 업로드되면 [A] 버튼이 활성화된다.
    • B 컴포넌트에서 파일이 업로드되면 [B] 버튼이 활성화된다.
    • [A] 버튼을 클릭하면 해당 버튼은 비활성화된다.
    • [B] 버튼을 클릭하면 해당 버튼은 비활성화된다.
    • [A]와 [B] 버튼이 모두 클릭된 상태일 때만 [C] 버튼이 활성화된다.

    그동안 상태 관리에 useState를 사용하고 있었는데, 여러 상태값이 흩어져 있다 보니 나중에 코드를 볼 때 "아... 이게 뭐 하는 코드였더라?" 하는 순간이 찾아왔다. 이번 요구사항에 클린코드 리액트에서 배운 useReducer를 사용해보기로 했다.

    Reducer란?

    Reducer는 '줄이다' 또는 '축소하다'라는 의미의 영단어에서 유래했다. 프로그래밍에서 Reducer는 여러 개의 값이나 상태를 하나의 결과값으로 처리하는 함수를 의미한다.

    React의 useReducer에서 reducer 함수는 현재 상태(state)와 액션(action)을 받아서, 이 두 값을 '결합'하여 새로운 상태를 만들어내는 역할을 한다. 이는 마치 여러 개의 입력값을 하나의 출력값으로 '줄여서' 처리하는 것과 같다.

    useReducer 문법

    useReducer는 상태 관리를 위한 React Hook으로, 다음과 같은 형태로 사용된다:

    const [state, dispatch] = useReducer(reducer, initialState);
    

     

    • state: 현재 상태값
    • dispatch: 상태를 업데이트하기 위한 함수
    • reducer: 상태를 어떻게 업데이트할지 정의하는 함수 (state, action) => newState
    • initialState: 초기 상태값

    reducer 함수는 현재 상태(state)와 액션(action)을 받아서 새로운 상태를 반환하는 순수 함수이다. 주로 switch문을 사용하여 action.type에 따라 다른 상태 업데이트 로직을 실행한다.

    dispatch 함수는 액션 객체를 인자로 받으며, 이 액션 객체는 일반적으로 다음과 같은 형태를 가진다:

    dispatch({ type: 'ACTION_TYPE', payload: data });
    

     

     

    위 요구 사항을 useState로 관리하면 어떨까?

    // 파일 업로드 상태
    const [fileA, setFileA] = useState(null);
    const [fileB, setFileB] = useState(null);
    
    // 버튼 활성화 상태
    const [isButtonAEnabled, setIsButtonAEnabled] = useState(false);
    const [isButtonBEnabled, setIsButtonBEnabled] = useState(false);
    const [isButtonCEnabled, setIsButtonCEnabled] = useState(false);
    
    // A 파일 업로드 처리
    const handleFileAUpload = (file) => {
        setFileA(file);
        setIsButtonAEnabled(true);
    };
    
    // B 파일 업로드 처리
    const handleFileBUpload = (file) => {
        setFileB(file);
        setIsButtonBEnabled(true);
    };
    
    // A 버튼 클릭 처리
    const handleButtonAClick = () => {
        setIsButtonAEnabled(false);
    };
    
    // B 버튼 클릭 처리
    const handleButtonBClick = () => {
        setIsButtonBEnabled(false);
    };
    
    // C 버튼 활성화 조건: A와 B 버튼이 모두 클릭된 경우
    useEffect(() => {
        if (!isButtonAEnabled && !isButtonBEnabled && fileA && fileB) {
            setIsButtonCEnabled(true); // C 버튼 활성화
        } else {
            setIsButtonCEnabled(false); // C 버튼 비활성화
        }
    }, [isButtonAEnabled, isButtonBEnabled, fileA, fileB]);
    
     return (
            <Box>
                <input type="file" onChange={(e) => handleFileAUpload(e.target.files[0])} />
                <input type="file" onChange={(e) => handleFileBUpload(e.target.files[0])} />
                
                <Button disabled={!isButtonAEnabled}
                        onClick={handleButtonAClick}> A 버튼 </Button>
                
                <Button disabled={!isButtonBEnabled}
                        onClick={handleButtonBClick}> B 버튼 </Button>
                
                <Button disabled={!isButtonCEnabled}> C 버튼 </Button>
            </Box>
        );

     

    위 코드처럼 useState를 사용하면 여러 상태값을 개별적으로 관리해야 하고, 상태 업데이트 로직이 컴포넌트 내부에 분산되어 있어 코드의 복잡성이 증가하게 된다.

    handleFileAUpload, handleFileBUpload, handleButtonAClick 등 각각의 함수에서 개별적으로 상태를 업데이트하는 코드가 흩어져있어서 상태 변경 로직을 파악하기 위해 여러 함수를 찾아다녀야 하고, 만약 새로운 요구사항이 추가된다면, 여러 개의 useState와 그에 따른 핸들러 함수들을 수정해야 하므로, 코드 유지보수가 어려워지고 버그 발생 가능성이 높아진다.

     

    useState를 useReducer로 바꿔보자!

    // 상태와 액션 타입 정의
    interface State {
        fileA: File | null;
        fileB: File | null;
        isButtonAEnabled: boolean;
        isButtonBEnabled: boolean;
        isButtonCEnabled: boolean;
    }
    
    type Action =
        | { type: "UPLOAD_FILE_A"; payload: File }
        | { type: "UPLOAD_FILE_B"; payload: File }
        | { type: "DISABLE_BUTTON_A" }
        | { type: "DISABLE_BUTTON_B" }
        | { type: "RESET_FILES" }
        | { type: "TOGGLE_BUTTON_C"; payload: boolean };
    
    // 초기 상태 정의
    const initialState: State = {
        fileA: null,
        fileB: null,
        isButtonAEnabled: false,
        isButtonBEnabled: false,
        isButtonCEnabled: false,
    };
    
    // Reducer 함수
    const reducer = (state: State, action: Action): State => {
        switch (action.type) {
            case "UPLOAD_FILE_A":
                return {...state, fileA: action.payload, isButtonAEnabled: true};
            case "UPLOAD_FILE_B":
                return {...state, fileB: action.payload, isButtonBEnabled: true};
            case "DISABLE_BUTTON_A":
                return {...state, isButtonAEnabled: false};
            case "DISABLE_BUTTON_B":
                return {...state, isButtonBEnabled: false};
            case "RESET_FILES":
                return {...state, fileA: null, fileB: null, isButtonCEnabled: false};
            case "TOGGLE_BUTTON_C":
                return {...state, isButtonCEnabled: action.payload};
            default:
                return state;
        }
    };
    
    // useReducer를 사용한 예시
    export const FileUploadComponent_Reducer = () => {
        const [state, dispatch] = useReducer(reducer, initialState);
    
        // C 버튼 활성화 조건: A와 B 버튼이 모두 클릭된 경우
        useEffect(() => {
            if (
                !state.isButtonAEnabled &&
                !state.isButtonBEnabled &&
                state.fileA &&
                state.fileB
            ) {
                dispatch({type: "TOGGLE_BUTTON_C", payload: true});
            } else {
                dispatch({type: "TOGGLE_BUTTON_C", payload: false});
            }
        }, [state.isButtonAEnabled, state.isButtonBEnabled, state.fileA, state.fileB]);
    
        return (
            <Box>
                <Text> useReducer </Text>
                <input
                    type="file"
                    onChange={(e) =>
                        dispatch({type: "UPLOAD_FILE_A", payload: e.target.files![0]})
                    }
                />
                <Button
                    size="xs"
                    disabled={!state.isButtonAEnabled}
                    onClick={() => dispatch({type: "DISABLE_BUTTON_A"})}
                >
                    A 버튼
                </Button>
    
                <input
                    type="file"
                    onChange={(e) =>
                        dispatch({type: "UPLOAD_FILE_B", payload: e.target.files![0]})
                    }
                />
                <Button
                    size="xs"
                    disabled={!state.isButtonBEnabled}
                    onClick={() => dispatch({type: "DISABLE_BUTTON_B"})}
                >
                    B 버튼
                </Button>
    
                <Box mt="4">
                    <Button
                        size="xs"
                        disabled={!state.isButtonCEnabled}
                        onClick={() => {
                            dispatch({type: "RESET_FILES"});
                            alert("초기화 됨");
                        }}
                    >
                        C 버튼
                    </Button>
                </Box>
            </Box>
        );
    };

     

     

    useState로 관리하는 것보다 코드의 라인 수가 더 늘어났는데??라고 생각할 수 있다. 실제로 그렇다. useReducer가 상태 변경 로직을 명시적으로 정의하기 때문에 그렇다. 따라서 간단한 상태 관리에서는 오히려 코드가 길고 복잡해 보일 수 있다.

     

    강의에서 useReduce는 “데이터를 구조화 할 수 있다.”는 장점이 있다고 말씀해주셨는데,

    이는 관련된 상태들을 하나의 객체로 묶어서 관리할 수 있다는 의미였다. 예를 들어, 파일 업로드와 관련된 상태(fileA, fileB)와 버튼 상태(isButtonAEnabled, isButtonBEnabled, isButtonCEnabled)를 하나의 객체에서 관리함으로써 상태 간의 관계를 더 명확하게 표현할 수 있다. 이러한 구조화된 상태 관리는 코드의 가독성을 높이고 상태 변화를 추적하기 쉽게 만든다.

     

    이를 일상생활에 비유하면?

    useState: 단순한 상태 변화 관리

    비유: 개인이 할 일을 직접 처리하는 상황

    • “아침에 커피를 마신다” → 상태를 “커피를 마셨음”으로 업데이트.

    • “점심에 밥을 먹는다” → 상태를 “밥을 먹었음”으로 업데이트.

    useReducer: 복잡한 상태 변화 관리

    비유: 담당자에게 업무 지시하는 상황

    • “오늘 오전에 회의 준비하세요” → 회의 담당자가 알아서 준비 (회의실 예약, 아젠다 작성, 초대 메일 발송, 회의실 준비등)

    • “오늘 오후에 프로젝트 발표하세요” → 발표 담당자가 알아서 발표 준비 (발표 자료 준비, 대본 작성등) 

     

    핵심 차이

    특성 useState useReducer
    비유 상황 혼자서 직접 일을 처리하는 개인 여러 사람(리듀서)에게 일을 맡기는 팀장
    상태 구조 개별적인 상태 변수들 (1~2개 상태) 하나의 객체로 통합된 상태 (여러 상태가 상호작용)
    상태 업데이트 직접적인 setState 호출 dispatch를 통한 액션 기반 업데이트
    코드 복잡도 단순한 상태에 적합 복잡한 상태 로직에 적합
    유지보수성 상태가 많아지면 관리 어려움 중앙화된 로직으로 관리 용이
    디버깅 상태 변화 추적이 어려울 수 있음 액션을 통한 명확한 상태 변화 추적
    테스트 개별 상태 테스트 리듀서 함수 단위 테스트 용이

     

     


    느낀 점 

    useReducer를 실제 개발에 적용해 보니 상태 관리가 매우 직관적이고 편리했다. 액션에 따라 상태를 객체로 정의하니 상태 변화를 추적하고 디버깅할 때 효과적이었다. 액션 타입과 상태만 잘 정의하고 원하는 시점에 해당 dispatch 함수를 호출하면 되니까! 이번에 포스팅하면서 다시 한번 개념 정리를 하다 보니 useReducer와 조금 더 친해진 기분이 든다. 처음에는 useState보다 복잡해 보여서 '그냥 바꾸지 말고 useState 그대로 쓸까..' 했었지만, 배운 내용을 실제 업무에 적용해보고 싶다는 생각 때문에 다시 복습했다. 그래서인지 실제로 사용해 보면서 자연스럽게 이해되었다. 나는 예시를 통해서 개념을 이해하는 편인데, 누군가가 리듀서가 뭐야?라고 물어보면 실제 업무 프로세스와 비슷해!라고 대답할 수 있을 거 같다. 팀장은 지시(dispatch)하고, 각 파트(reducer)에서 자신의 업무를 처리하는 구조! 나머지는 reducer가 알아서! (회의실 예약, 자료 준비, 참석자에게 연락 등등). 타입스크립트와 조합도 아주 좋았다! 미리 액션과 상태를 정의해서 액션 타입과 페이로드에 대한 타입 안정성을 확보하고, 컴파일 시점에서 타입 오류를 미리 발견할 수 있어서 개발 과정이 좀 더 안정적이었다. useState와 useReduer 각자의 장단점을 가지고 상황에 맞는 적절한 도구를 선택해서 사용해야겠다. 

    728x90

    댓글