들어가기
지금까지는 이벤트를 처리할 때 DOM 속성에 직접 핸들러를 지정하는 방식을 사용했다. 이 방식은 이벤트가 중접 등록되는 것을 방지하기 위할 수 있는 장점이 있지만, 다양한 이벤트 처리를 유연하게 관리하기에는 제약이 많다. 좀 더 확장성 있고 체계적인 이벤트 관리를 위해 addEventListener() 기반의 구조가 더 적합하며, 이벤트 위임(Event Delegation)을 통해 이벤트 시스템을 개선하고자 한다. 또한 기존에는 각 컴포넌트마다 직접 상태를 수정했다. 단순한 상태는 문제 없지만, 상태 로직이 복잡해질수록 코드가 복잡하고 흩어져 있어 유지보수가 어려워진다. 이를 해결하기 위해 상태 관리 로직을 한곳에 관리할 수 있도록 useReducer()를 추가하였다.
작성자: http://ospace.tistory.com/ (ospace114@empal.com)
이벤트 처리 개선
자바스크립트에서는 이벤트 처리하는 방식이 두가지다.
- onClick 같은 이벤트 속성에 직접 핸들러를 등록하는 방식
- addEventListener()를 사용해 추가하는 방식
첫 번째 방식은 새 핸들러를 등록하면 기존 핸들러가 덮어씌워지면서 중복등록이 안된다. 단순하고 편리하지만 복잡한 애플리케이션이 적합하지 않다. 두 번째 방식인 addEventListener()는 여러 핸들러를 누적등록 할 수 있고 HTML과 자바스크립트를 분리함으로써 확장성이 좋아지기 때문에 복잡한 어플리케이션에 더 유리하다.
기존 방식의 한계
기존 구현은 “on”으로 시작하는 속성을 직접 실제 DOM 속성으로 직접 할당했다.
function updateAttributes(vdom, dom) {
//...
if (k.startsWith("on") || k === "value") {
dom[k.toLowerCase()] = newVal;
} else {
dom.setAttribute(k, newVal);
}
//...
}
이 방식은 이벤트를 각 DOM 요소에 직접 등록하기 때문에 DOM이 변경될 때마다 이벤트도 다시 등록해야 한다. 단순한 경우는 문제없지만 복잡한 이벤트 처리할 경우에는 한계가 있다. 이를 addEventListener()로 변경하여 이벤트를 처리할려고 한다. 그러나, addEventListener()을 그대로 적용할 경우에는 문제가 발생할 수 있다. 중복등록 문제로서 기존 핸들러를 제거하고 새로 등록해야하지만 함수 표현식을 사용할 경우 중복등록되는 문제가 발생해서 더 관리가 복잡해진다. 이런 문제점 때문에 addEventListener()를 그대로 사용하지 않고 이벤트 위임방식을 적용하였다.
이벤트 위임
이벤트 위임은 하위 요소에 각각 이벤트를 등록하는 대신에 상위 요소 하나에만 이벤트를 등록하는 방식이다. 하위 요소에서 발생한 이벤트는 버블링을 통해 상위 요소까지 전달되며 상위 요소는 이를 적절하게 처리하면 된다. 이벤트 위임은 다음과 같은 장점이 있다.
- 이벤트를 한 번만 등록하면 된다
- 동적 DOM 변경에 강하다
- 이벤트 관리가 단순해진다
이벤트 위임에서는 상위 요소에서는 하위 요소에서 어떤 이벤트가 있는지 알아야 한다. 알아내는 방법으로 하위 요소의 이벤트를 탐색하거나 필요한 모든 이벤트 타입을 한번에 등록하는 방법이 있다. 이벤트를 탐색하는 경우는 사전에 고정되어 있다면 한번으로 탐색하면 되지만 이벤트가 가변적인 상황에서는 관리하는게 쉽지 않다. 이를 관리 복잡도와 구현 난이도가 높아지기 때문에 이번 구현에서는 루트노드에 사용할 모든 이벤트를 한번에 등록하도록 구성했다.
const supportedEvents = ["click", "input"];
function App(opts) {
let root = opts.root;
//...
supportedEvents.forEach((it) => root.addEventListener(it, dispatchEvent));
//...
}
핸재 등록할 이벤트 타입은 supportedEvents에 저장된 click과 input뿐이지만, 필요하다면 이벤트를 더 추가한다.
이벤트 디스패치 구조
하위 요소에서 이벤트가 발생하면 루트 노드의 addEventListener()에 의해 등록된 핸들러인 dispatchEvent()가 실제 핸들러를 찾아서 실행한다.
const eventStore = new WeakMap();
function dispatchEvent(ev) {
const { target, type } = ev;
const handlers = eventStore.get(target);
if (!handlers) return;
handlers[type] && handlers[type](ev);
}
이벤트 디스패치 동작방식은 다음과 같다.
- 하위 노드에서 이벤트 발생
- 루트 노드가 수신
- target과 type으로 핸들러 조회
- 해당 핸들러 실행
각 DOM 노드의 이벤트 정보는 eventStore에 저장된다. 이벤트가 발생한 경우 dispatchEvent()가 실행고 eventStore에서 이벤트 타입과 발생한 대상으로 핸들러를 조회해서 실행한다.
이벤트 등록
실제 DOM에 사용할 이벤트를 등록하는 과정을 살펴보자.
function updateAttributes(vdom, dom) {
//...
if (k.startsWith("on")) {
registerEvent(dom, k.substring(2).toLowerCase(), newVal);
} else if (k === "value") {
dom[k.toLowerCase()] = newVal;
} else {
dom.setAttribute(k, newVal);
}
//...
}
가상 DOM에서 “on” 속성은 직접 DOM에 할당하지 않고 registerEvent()로 등록했다. registerEvent()는 어떤 요소에서 어떤 이벤트가 발생할 경우 실행할 핸들러를 등록한다.
registerEvent()는 전달받은 인자로 eventStore에서 이벤트 정보를 저장한다.
function registerEvent(target, type, handler) {
let handlers = eventStore.get(target);
if (!handlers) {
eventStore.set(target, (handlers = {}));
}
handlers[type] = handler;
}
이벤트 제거
DOM의 삭제, 변경으로 인해 존재하지 않는 이벤트는 eventStore에서 제거해야 한다. eventStore의 데이터 타입을 WeakMap을 사용했기 때문에 DOM이 제거되는 경우 이벤트 정보가 자동으로 정리된다. DOM의 변경으로 인해서 이벤트가 제거되는 경우를 보자.
updateAttributes()에서 가상 DOM에 없는 이벤트를 제거하는 코드를 추가하였다
function updateAttributes(vdom, dom) {
//...
const types = getAllEventType(dom);
// 속성의 값이 다르면 값을 반영
Object.keys(props).forEach((k) => {
//...
if (k.startsWith("on")) {
const evType = k.substring(2).toLowerCase();
registerEvent(dom, evType, newVal);
removeIf(types, evType);
}
//...
});
//...
// dom의 이벤트가 vdom에 없다면 삭제
types && types.forEach((t) => unregisterEvent(dom, t));
// ...
}
이 과정을 통해 가상 DOM과 실제 DOM 간에 이벤트 상태를 항상 일치시킨다.
useReducer() 추가
useReducer()는 각 컴포넌트의 상태를 외부로 분리하여 가독성와 유지보수성을 높이기 위해서 사용한다.
기존 문제점
기존 상태 변경 방식은 컴포넌트 내부에서 직접 배열이나 객체를 수정하였다.
function delItem(idx) {
items.splice(idx, 1);
setItems(items);
}
간단한 예제라서 괜찮아보이지만, 점차 복잡해질수록 다음과 같은 문제가 생긴다.
- 로직 중복
- 상태변경 규칙 분산
- 테스트 어려움
- 복잡도 증가
이를 해결하기 위해 상태 변경을 한 곳에 관리하기 위한 Reducer 패턴을 도입하였다.
useReducer 구현
아래와 같이 useReducer()를 작성했다. 이전에 useState() 활용해서 작성하였다.
function useReducer(reducer, initState) {
const [state, setState] = useState(initState);
const dispatch = (action) => {
const nextState = reducer(state, action);
setState(nextState);
};
return [state, dispatch];
}
reducer은 상태 계산함수이고, state는 현재 상태, setState은 현재 상태 변경 함수이다. 그리고 dispatch는 상태 변경 요청 함수이다. 컴포넌트는 무엇을 할지를 담당하고 reducer는 어떻게 변경할지를 담당한다.
사용자 reducer 작성
여기서 reducer()는 상태에 따라서 직접 작성해야하는 부분이다.
reducer(state, action)
- state: 현재 상태
- action: 현재 상태에 대한 행동으로 상태를 변경하는 행동이다.
reducer를 작성하는 예를 보자.
function reducerTodo(state, action) {
const { type, value } = action;
switch (type) {
case "ADD":
state.push(value);
break;
case "DEL":
state.splice(value, 1);
break;
}
return state;
}
action를 통해 state 상태 변경 요청을 표현한다. 그리고 상태변경된 결과를 리턴한다.
컴포넌트 사용 예
컴포넌트에서 reducerTodo()를 사용하는 하는 경우를 보자.
function TableData(data) {
let [items, dispatch] = useReducer(reducerTodo, data);
return items.map((it, i) =>
o.h(
"tr",
null,
o.h("td", null, i + 1),
o.h("td", null, it),
o.h(
"td",
null,
o.h(
"button",
{
class: "btn btn-danger",
onClick() {
dispatch({ type: "DEL", value: i });
}
},
"DEL"
)
)
)
);
}
useReducer()에 사용할 reducer인 reducerTodo()과 초기 데이터를 인자인 data를 넘겨서 items와 dispatch을 얻을 수 있다. items을 통해서 현재 상태를 조회할 수 있고 dispatch을 통해 원하는 액션을 호출하면 상태를 변경해준다.
컴포넌트는 상태 변경 로직을 직접 다루지 않고, 단순히 액션만 전달하는 선언적 구조가 되었다. 이로 인해 상태 변경 규칙이 나눠지면서 관심사가 분리되고 재사용성과 유지보수성이 향상된다.
데모
https://codepen.io/ospace/embed/JoKMRgN
마무리
이벤트 위임을 사용해 이벤트 처리를 개선하였다. 현재 적용한 방식이 모든 이벤트 타입을 등록하는 방식이 비효율적이라고 생각할 수 있다. 그러면, 필요한 이벤트 타입만 등록하도록 구현할 수 있다. 이때에는 렌더링시 필요한 이벤트 타입을 확인해서 등록한다. 처음에는 문제 없지만 이벤트 변경이 있을 수 있기 때문에 필요없는 이벤트는 제거해줘야 한다. 이때 여러가지 전략을 선택할 수 있다. 결국, 생각보다 로직이 복잡해지고 고려할 것도 많다. 그래서 간단하게 필요한 이벤트를 미리 등록하는 방식을 사용했다. 물론 이것도 상황에 따라서 판단하는 기준이 달라질 수 있다.
useReducer는 생각보다 간단한 로직이다. 이렇게 간단한 로직으로 복잡한 상태 관리를 분리해서 유지보수성과 코드 가독성이 좋아진다. 이로인해 컴포넌트에 복잡한 로직이 사라져서 더 간결해진다. 물론 컴포넌트가 단순한 구조이면 useReducer로 인해 도리어 복잡해질 수 있다. 단순 상태 변경일 경우에는 setState를 사용해 더 좋을 수도 있다. 상황에 맞는 도구 선택이 가장 중요하다.
참조
[1] Meta Open Source, useReducer, https://ko.react.dev/reference/react/useReducer
[2] mdn, EventTarget.addEventListener(), https://developer.mozilla.org/ko/docs/Web/API/EventTarget/addEventListener
[3] ospace, [javascript] 리액트 같은 UI 라이브러리 개발 1 - VDOM 만들기, https://ospace.tistory.com/1012
[4] ospace, [javascript] 리액트 같은 UI 라이브러리 개발 2 - 랜더링 개선과 useState() 추가, https://ospace.tistory.com/1013
'3.구현 > HTML5&Javascript' 카테고리의 다른 글
| [javascript] 리액트 같은 UI 라이브러리 개발 2 - 랜더링 개선과 useState() 추가 (0) | 2026.02.03 |
|---|---|
| [javascript] 리액트 같은 UI 라이브러리 개발 1 - VDOM 만들기 (0) | 2026.01.30 |
| 직접 캐러셀 스크롤(Carousel Scroll) 구현 (0) | 2026.01.12 |
| 간단한 캐러셀 스크롤(Carousel Scroll) 만들기 (0) | 2025.11.04 |
| [CSS] Flex 사용하기 (2) | 2024.04.02 |
