
와 쌩자바스크립트로 계산기 만드는 것 그냥 어려운 게 아니라, 엄청 어렵다.....!
필요한 이유
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로 계산기 만들 시 고려해볼 만한 키워드
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으로 타입 힌트를 제공해 협업과 유지보수성이 높아짐. 상태 관리의 핵심을 배웠다. 불변성을 유지하면서 액션을 기반으로 새로운 상태를 반환한다는 점이다.