React

[React] useReducer 사용하여 간단한 계산기 만들기

어렵지만 2025. 6. 16. 10:57

useReducer 란 무엇일까요?

 

useState의 대체재이며 복잡한 상태 로직을 컴포넌트 외부로 분리하는 상태 관리 훅입니다.

 

Reducer: (주방장/레시피북): 상태를 어떻게 변경할지에 대한 '로직'이 담긴 순수 함수.
Action: (주문서): 상태 변경을 요청하는 '명령'이 담긴 객체. (type, payload)
Dispatch: (웨이터): Action을 Reducer에게 전달하는 '함수'.

(컴포넌트 UI) → dispatch(action) → (Reducer) → (새로운 State) → (컴포넌트 UI 업데이트)

 

그럼 왜 useReducer 을 사용할까요?

1. "UI를 그리는 코드"와 "데이터를 처리하는 로직"이 분리되어 컴포넌트가 훨씬 깔끔해지고 본연의 역할(렌더링)에 충실해집니다.

2. 모든 상태 변경은 오직 Reducer를 통해서만 일어나므로, 상태가 어떻게 변할지 예측하기 쉽고 버그 추적이 용이합니다.

3. Reducer는 UI와 분리된 순수 함수이므로, 입력(state, action)에 따른 출력(newState)을 테스트하기가 매우 간단합니다.

4. 한 번의 dispatch로 여러 상태 값을 한꺼번에 업데이트할 수 있어 코드가 더 명료해집니다.

 

그럼 언제 사용하면 좋을까요?

계산기: 현재 값, 이전 값, 연산자 등 여러 값이 유기적으로 연결된 경우 (오늘의 예제!)
복잡한 폼(Form): 여러 입력 필드와 유효성 검사 상태를 동시에 관리해야 할 때
쇼핑몰 장바구니: 상품 목록, 수량, 총액, 쿠폰 적용 여부 등 복잡한 상태 객체를 다룰 때
단계별 UI (Step-by-step wizard): 여러 단계를 거치는 UI에서 현재 단계와 각 단계의 데이터를 관리할 때

 

그럼 훅은 무엇일까요?

 

함수형 컴포넌트(Functional Component) 안에서 state나 생명주기 같은 리액트의 핵심 기능들을 '연결(hook into)'할 수 있게 해주는 특별한 함수입니다.
훅 덕분에 이제 더 이상 복잡한 클래스를 쓰지 않고도, 간결하고 직관적인 함수형 컴포넌트만으로 리액트의 모든 기능을 사용할 수 있게 되었습니다.

 

예제코드

src/App.js

import { useReducer } from 'react';
import './App.css';

const ACTIONS = { // 액션이란?
  // 액션은 상태를 변경하기 위한 명령어로, dispatch를 통해 reducer에 전달된다.
  // 각 액션은 type 속성을 가지고 있으며, payload를 통해 추가 정보를 전달할 수 있다.
  // 예를 들어, 숫자를 추가하는 액션은 type이 'add-digit'이고, payload에 추가할 숫자를 포함한다.
  // 이 액션들은 reducer 함수에서 처리되어 상태를 업데이트한다.
  // 각 액션은 상태를 변경하는 방법을 정의하며, reducer 함수에서 이 액션들을 처리한다.
  // 액션의 종류를 정의하는 상수 객체로, 각 액션의 type을 문자열로 지정한다.
  // 이 액션들은 컴포넌트에서 dispatch를 통해 호출되어 상태를 변경한다.

  ADD_DIGIT: 'add-digit', // 숫자를 추가하는 액션
  CHOOSE_OPERATION: 'choose-operation', // 연산자를 선택하는 액션
  CLEAR: 'clear', // 계산기를 초기화하는 액션
  DELETE_DIGIT: 'delete-digit', // 숫자를 지우는 액션
  EVALUATE: 'evaluate' // 계산을 수행하는 액션
}

function reducer(state, { type, payload }) {
  switch (type) {
    case ACTIONS.ADD_DIGIT:
      // 만약 계산이 끝난 직후(overwrite=true) 숫자를 누르면, 덧붙이는게 아니라 덮어쓴다.
      if (state.overwrite) {
        return {
          ...state,
          currentOperand: payload.digit,
          overwrite: false,
        }
      }
      if (payload.digit === "0" && state.currentOperand === "0") return state; // 0이 여러번 눌리는 것 방지
      if (payload.digit === "." && state.currentOperand.includes(".")) return state; // .이 여러번 눌리는 것 방지
      return {
        ...state,
        currentOperand: `${state.currentOperand || ""}${payload.digit}`
      };
   
    case ACTIONS.CHOOSE_OPERATION:
      if (state.currentOperand == null && state.previousOperand == null) return state; // 아무것도 없을땐 연산자 선택 불가

      if (state.currentOperand == null) { // 연산자를 바꾸고 싶을 때
        return {
          ...state,
          operation: payload.operation,
        }
      }

      if (state.previousOperand == null) { // 처음 연산자를 선택할 때
        return {
          ...state,
          operation: payload.operation,
          previousOperand: state.currentOperand,
          currentOperand: null,
        }
      }
      // 이미 연산이 가능한 상태 (e.g. 1 + 2)에서 +를 또 누른 경우
      return {
        ...state,
        previousOperand: evaluate(state),
        operation: payload.operation,
        currentOperand: null,
      }

    case ACTIONS.CLEAR:
      return {}; // 초기 상태인 빈 객체로 리셋

   // --- 여기에 DELETE_DIGIT 케이스를 추가합니다 ---
    case ACTIONS.DELETE_DIGIT:
      // 만약 계산이 끝난 직후(overwrite=true)라면, 아무것도 하지 않거나 clear와 동일하게 동작
      if (state.overwrite) {
        return {
          ...state,
          overwrite: false,
          currentOperand: null,
        }
      }
      // 현재 입력값이 없다면 아무것도 하지 않음
      if (state.currentOperand == null) return state;
      // 현재 입력값이 한 자리 숫자라면, 값을 null로 만듦
      if (state.currentOperand.length === 1) {
        return { ...state, currentOperand: null }
      }
      // 그 외의 경우, 마지막 글자를 제외하고 반환
      return {
        ...state,
        currentOperand: state.currentOperand.slice(0, -1)
      }

    case ACTIONS.EVALUATE:
      if (state.operation == null || state.currentOperand == null || state.previousOperand == null) {
        return state; // 계산할 준비가 안됐으면 아무것도 안 함
      }
      return {
        ...state,
        overwrite: true, // 계산 직후 상태임을 표시
        previousOperand: null,
        operation: null,
        currentOperand: evaluate(state),
      }
    default:
      return state;
  }
}

// 계산을 수행하는 헬퍼 함수
// 함수의 매개변수를 받을 때는 객체 구조 분해 할당을 사용
function evaluate({ currentOperand, previousOperand, operation }) {
  const prev = parseFloat(previousOperand);
  const current = parseFloat(currentOperand);
  if (isNaN(prev) || isNaN(current)) return "";
  let computation = "";
  switch (operation) {
    case "+":
      computation = prev + current;
      break;
    case "-":
      computation = prev - current;
      break;
    case "*":
      computation = prev * current;
      break;
    case "÷":
      computation = prev / current;
      break;
    default:
      break;
  }
  return computation.toString();
}

// 컴포넌트 렌더링 부분에 dispatch 연결
function App() {
  const [{ currentOperand, previousOperand, operation }, dispatch] = useReducer(
    reducer,
    {}
  );

  return (
    <div className="calculator-grid">
      <div className="output">
        <div className="previous-operand">{previousOperand} {operation}</div>
        <div className="current-operand">{currentOperand}</div>
      </div>
      <button className="span-two" onClick={() => dispatch({ type: ACTIONS.CLEAR })}>AC</button>
      <button onClick={() => dispatch({ type: ACTIONS.DELETE_DIGIT })}>DEL</button> {/* DEL은 로직 생략 */}
      <button onClick={() => dispatch({ type: ACTIONS.CHOOSE_OPERATION, payload: { operation: "÷" } })}>÷</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "1" } })}>1</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "2" } })}>2</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "3" } })}>3</button>
      <button onClick={() => dispatch({ type: ACTIONS.CHOOSE_OPERATION, payload: { operation: "*" } })}>*</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "4" } })}>4</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "5" } })}>5</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "6" } })}>6</button>
      <button onClick={() => dispatch({ type: ACTIONS.CHOOSE_OPERATION, payload: { operation: "+" } })}>+</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "7" } })}>7</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "8" } })}>8</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "9" } })}>9</button>
      <button onClick={() => dispatch({ type: ACTIONS.CHOOSE_OPERATION, payload: { operation: "-" } })}>-</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "." } })}>.</button>
      <button onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit: "0" } })}>0</button>
      <button className="span-two" onClick={() => dispatch({ type: ACTIONS.EVALUATE })}>=</button>
    </div>
  );
}

export default App;

 

src/App.css

*, *::before, *::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: linear-gradient(to right, #00AAFF, #00FF6C);
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}

.calculator-grid {
  display: grid;
  grid-template-columns: repeat(4, 100px);
  grid-template-rows: minmax(120px, auto) repeat(5, 100px);
  border-radius: 10px;
  overflow: hidden;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}

.calculator-grid > button {
  cursor: pointer;
  font-size: 2rem;
  border: 1px solid white;
  outline: none;
  background-color: rgba(255, 255, 255, .75);
}

.calculator-grid > button:hover {
  background-color: rgba(255, 255, 255, .9);
}

.span-two {
  grid-column: span 2;
}

.output {
  grid-column: 1 / -1;
  background-color: rgba(0, 0, 0, .75);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  justify-content: space-around;
  padding: 10px;
  word-wrap: break-word;
  word-break: break-all;
}

.output .previous-operand {
  color: rgba(255, 255, 255, .75);
  font-size: 1.5rem;
}

.output .current-operand {
  color: white;
  font-size: 2.5rem;
}

 

시연 영상