MoEasy - 자바스크립트 스터디(4주차) - 디바운스, 쓰로틀링, 계산기 만들기

연도·2025년 2월 7일

JavaScript, TypeScript

목록 보기
4/8
post-thumbnail

들어가기 전 간단 회고

와 쌩자바스크립트로 계산기 만드는 것 그냥 어려운 게 아니라, 엄청 어렵다.....!

디바운스, 쓰로틀링에 대해 공부 한 뒤 이를 직접 구현해보시오.

필요한 이유

DOM 이벤트(scroll, resize, input)나 마우스 이벤트는 짧은 시간 안에 수십 ~ 수백 번 발생함.

브라우저 렌더링/서버 요청/연산 부하 문제로 퍼포먼스 저하가 발생할 수 있음. 이를 제어하기 위한 핵심 기술은 디바운스와 쓰로틀링이다.

디바운스

짧은 시간 동안에 연속해서 발생하는 이벤트를 하나의 그룹으로 묶어서 마지막 이벤트 이후 지정된 시간이 지나면 함수를 실행하는 방식. 쉽게 말해서 연속 입력/작업이 끝나고 일정 시간 후 한 번만 실행

예시

검색창 입력할 때, 한 글자 칠 대마다 서버 요청 대신에 입력을 멈춘 후 500ms 지나고 한 번 요청.

창 크기 조절 시 마지막 시점에만 이벤트 실행함.

이것으로 인해서 윈도우 리사이즈, 버튼 연속 클릭 방지, 검색창 입력할 때 네트워크 최적화.

쓰로틀링

이벤트가 아무리 자주 발생해도, 일정 시간 간격(limit) 으로 한 번만 함수 실행을 허용하는 기법. 쉽게 말해서 일정 주기마다 한 번씩만 실행하도록 제한 하는 것이다.

예시

마우스 무빙, 스크롤 이벤트 감지 시에 0.5초마다 한 번씩 위치를 계산한다.

디바운스 & 쓰로틀링 차이

디바운스는 마지막 이벤트 이후 일정 시간이 지나면 실행하고, 빈번한 이벤트를 하나로 묶어서 최종 한 번만 실행한다. 입력창, 검색, 창 리사이즈 등 사용자의 입력 완료 후에 작업한다.

쓰로틀링은 일정 시간마다 한 번씩 실행을 허용한다.

지속적인 이벤트를 일정 간격마다 실행한다.

스크롤, 마우스 무빙, 실시간 동작 등 지속 적으로 발생하는 이벤트 제어다.

function debounce(func, delay) {
  let idDebounce;

  return function () {
    clearTimeout(idDebounce); // 이전에 예약된 타이머 취소

    idDebounce = setTimeout(() => {
      func.apply(this); // delay 이후 실행
    }, delay);
  };
}

function throttle(func, limit) {
  let inThrottle;

  return function () {

    // 처음 함수가 호출될 때
    if (!inThrottle) {
      // 쓰로틀 상태가 펄스일때만
      func.apply(this); // 함수 실행
      inThrottle = true; // 잠금

      setTimeout(() => {
        inThrottle = false; // limit 이후 다시 false로 변경
      }, limit); // limit 이후 다시 실행 가능
    }
  };
}

const testFunction = () => {
  console.log("함수 실행!", new Date().toLocaleTimeString());
};

const debouncedFunction = debounce(testFunction, 1000);
const throttledFunction = throttle(testFunction, 1000);

최종 정리

디바운스는 마지막 입력 후 일정 시간 이후 한 번만 실행해 리소스 낭비를 막는 패턴이다. 쓰로틀링은 일정 시간마다 한 번만 실행하도록 제한해 지속적 이벤트의 부하를 제어함.

간단한 계산기 만들어보기

문제 설명

그림에 있는 버튼 설명
| AC (0으로 초기화)| +/-(부호 변경) | % (100으로 나누기) | ÷ (나누기) |
|---|---|---|---|
| 7 | 8 | 9 | × (곱하기)|
| 4 | 5 | 6 | - (빼기)|
| 1 | 2 | 3 | + (더하기)|
| 0 | . (소수점 추가)| | = (계산 실행)|

화면 개발에 필요한 라이브러리의 선택은 자유입니다. 라이브러리 사용 여부는 아무래도 상관없습니다.

다만 상태 관리에 대한 라이브러리(web component[라이브러리 아니긴 함], vue, react, svelte)의 선택이 자유일 뿐이지 계산 자체를 해주는 로직을 어디서 빌려오거나 계산기 컴포넌트 라이브러리 같은걸 들고와서 시연하면 인정해드리지 않겠습니다.

빌드해서 어디에 호스팅할 필요까지는 없고 로컬에서 시연하는 것을 볼 예정입니다.

프론트엔드 과제가 아니라 js과제이기 때문에 디자인도 예쁘거나 세련될 필요는 없습니다만 그래도 봤을 때 계산기의 형태라는 생각이 들 정도로 생겼으면 좋겠습니다.

js로 계산기 만들 시 고려해볼 만한 키워드

  1. reducer 패턴
  2. jsdoc (타입스크립트를 안 쓰는 환경에서 어디까지 도움을 받을 수 있을지)

이론

reducer 패턴이란

Redux의 개념과 동일한 핵심 설계 원칙

State: 현재 상태 (화면에 표시될 값, 연산자, 이전 값, 입력 상태 등)

Action : 상태를 변경하기 위한 이벤트 객체

Reducet : (state, action) ⇒ newState 형태의 순수 함수

같은 입력에도 같은 출력, 불변성을 유지하며 새로운 상태를 반환

MVC & 상태 관리와의 관계

View : 화면

Model : 상태

Controller : 이벤트 리스너 (dispatch → reducer 호출)

JSDoc이란

타입스크립트를 쓰지 않는 순수한 JS 환경에서 타입 힌트 및 IDE 자동완성을 제공

함수의 매개변수, 반환값, 구조체 등을 주석으로 정의

유지보수성 및 협업에서 커다란 장점

최종 코드

/**
 * @typedef {Object} CalculatorState
 * @property {string} displayValue - 현재 화면에 표시되는 값
 * @property {string|null} operator - 현재 선택된 연산자
 * @property {string|null} previousValue - 이전 입력값 저장
 * @property {boolean} waitingForOperand - 새로운 입력값을 기다리는 상태
 */

/**
 * @typedef {'NUMBER'|'OPERATOR'|'CLEAR'|'CALCULATE'|'DECIMAL'|'PERCENT'|'SIGN'} ActionType
 *
 * @typedef {Object} CalculatorAction
 * @property {ActionType} type - 액션 타입
 * @property {string} [payload] - 액션과 관련된 데이터
 */
// 초기 상태 설정
const initialState = {
  displayValue: "0",
  operator: null, // 현재 선택된 연산자
  previousValue: null,
  waitingForOperand: false,
};

// 현재 상태를 초기 상태로 설정
let state = { ...initialState };

const display = document.querySelector(".calculator_display");
const buttons = document.querySelector(".buttons");

// 화면 업데이트 함수
function updateDisplay() {
  display.textContent = state.displayValue;
}

/**
 * @param {string} a - 첫 번째 숫자
 * @param {string} b - 두 번째 숫자
 * @param {string} operator - 연산자
 * @returns {string} 계산 결과
 */
function calculate(a, b, operator) {
  const num1 = parseFloat(a);
  const num2 = parseFloat(b);

  // 선택된 연산자에 따라서
  switch (operator) {
    case "+":
      return String(num1 + num2);
    case "-":
      return String(num1 - num2);
    case "X":
      return String(num1 * num2);
    case "/":
      return num2 !== 0 ? String(num1 / num2) : "Error";
    default:
      return b;
  }
}

/**
 * 계산기 상태를 관리하는 리듀서
 * @param {CalculatorState} state - 현재 상태
 * @param {CalculatorAction} action - 실행할 액션
 * @returns {CalculatorState} 새로운 상태
 */
function calculatorReducer(state, action) {
  switch (action.type) {
    case "NUMBER":
      if (state.displayValue === "0" || state.waitingForOperand) {
        return {
          ...state,
          displayValue: action.payload,
          waitingForOperand: false,
        };
      }
      return {
        ...state,
        displayValue: state.displayValue + action.payload,
      };
    case "OPERATOR":
      return {
        ...state,
        operator: action.payload,
        previousValue: state.displayValue,
        waitingForOperand: true,
      };

    case "CALCULATE":
      if (state.operator === null || state.previousValue === null) {
        return state;
      }
      return {
        ...state,
        displayValue: calculate(state.previousValue, state.displayValue, state.operator),
        operator: null,
        previousValue: null,
        waitingForOperand: true,
      };

    case "CLEAR": // 초기화 요청
      return {
        ...initialState,
      };

    case "SIGN": // 부호 변경
      return {
        ...state,
        displayValue: String(-1 * parseFloat(state.displayValue)),
      };

    case "PERCENT": // 백분율 계산
      const currentValue = parseFloat(state.displayValue);

      if (state.previousValue !== null && state.operator) {
        const prevValue = parseFloat(state.previousValue);
        const percentValue = currentValue / 100;

        let result;
        switch (state.operator) {
          case "+":
          case "-":
            result = prevValue * percentValue;
            break;
          case "X":
          case "/":
            result = percentValue;
            break;
          default:
            result = currentValue;
        }

        return {
          ...state,
          displayValue: String(result), // 결과 업데이트
        };
      }
      return {
        ...state,
        displayValue: String(parseFloat(state.displayValue) / 100),
      };

    case "DECIMAL":
      if (state.displayValue.includes(".")) {
        return state;
      }

      if (state.waitingForOperand) {
        return {
          ...state,
          displayValue: "0.",
          waitingForOperand: false, // 새로운 입력 대기 상태 종료
        };
      }

      return {
        ...state,
        displayValue: state.displayValue + ".",
        waitingForOperand: false,
      };

    default:
      return state;
  }
}

// 상태 업데이트 함수
function dispatch(action) {
  state = calculatorReducer(state, action); // 리듀서로 상태 갱신
  updateDisplay();
}

// 버튼 클릭 이벤트 핸들링
buttons.addEventListener("click", (event) => {
  if (!event.target.matches("button")) return;

  const value = event.target.textContent;
  if (/[0-9]/.test(value)) {
    dispatch({
      type: "NUMBER",
      payload: value,
    });
  } else if (["+", "-", "X", "/"].includes(value)) {
    dispatch({
      type: "OPERATOR",
      payload: value,
    });
  } else if (value === "=") {
    dispatch({
      type: "CALCULATE",
    });
  } else if (value === "AC") {
    dispatch({
      type: "CLEAR",
    });
  } else if (value === "+/-") {
    dispatch({
      type: "SIGN",
    });
  } else if (value === "%") {
    dispatch({
      type: "PERCENT",
    });
  } else if (value === ".") {
    dispatch({
      type: "DECIMAL",
    });
  }
});

최종 정리

reducet 패턴을 활용하여 상태 전이를 순수 함수로 관리했고, JSDoc으로 타입 힌트를 제공해 협업과 유지보수성이 높아짐. 상태 관리의 핵심을 배웠다. 불변성을 유지하면서 액션을 기반으로 새로운 상태를 반환한다는 점이다.

profile
Software Engineer

0개의 댓글