|
| 1 | +--- |
| 2 | +title: "이벤트 기반 아키텍처 (EDA) 활용해보기" |
| 3 | +date: "2025-12-18" |
| 4 | +slug: "20251218" |
| 5 | +tag: "Frontend" |
| 6 | +category: "Frontend" |
| 7 | +description: "클릭 이벤트부터 컴포넌트 간 느슨한 결합까지, 복잡한 의존성을 해결하는 방법을 장바구니 예시로 알아봅니다." |
| 8 | +--- |
| 9 | + |
| 10 | +# 들어가며: "정해진 시간" vs "사건이 발생한 순간" |
| 11 | +개발을 하다 보면 두 가지 방식의 일 처리를 마주하게 됩니다. |
| 12 | + |
| 13 | +- **Batch/Cron 방식** |
| 14 | +"매일 밤 12시에 오늘 가입한 회원 목록을 정리해." |
| 15 | +정해진 시간에 능동적으로 데이터를 확인(Polling) 합니다. |
| 16 | + |
| 17 | +- **Event-Driven 방식** |
| 18 | +"회원가입 버튼이 **눌리는 순간**, 웰컴 메일을 보내고 통계에 반영해." |
| 19 | +사건이 발생한 시점에 수동적으로 반응합니다. |
| 20 | + |
| 21 | + |
| 22 | +우리가 만드는 모던 웹 애플리케이션은 후자에 가깝습니다. 사용자의 행동에 즉각 반응해야 합니다. |
| 23 | +즉, 버튼을 클릭하면 바로 피드백이 와야 하고, 데이터가 변경되면 화면이 즉시 업데이트되어야 합니다. |
| 24 | + |
| 25 | +이러한 **EDA(Event-Driven Architecture)** 적 사고가 단순히 백엔드뿐만 아니라 프론트엔드 아키텍처의 핵심임을 깨닫게 되었습니다. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## 1. 프론트엔드는 태생부터 EDA였다. |
| 30 | +### 브라우저는 거대한 이벤트 루프다. |
| 31 | + |
| 32 | +프론트엔드 개발자라면 누구나 아는 `addEventListener` |
| 33 | +사실 이것이 EDA의 가장 원시적이고 완벽한 형태입니다. |
| 34 | + |
| 35 | +```typescript |
| 36 | +const button = document.querySelector('button'); |
| 37 | + |
| 38 | +// Producer: 버튼 (클릭 발생) |
| 39 | +// Consumer: 콜백 함수 (클릭 처리) |
| 40 | +button.addEventListener('click', () => { |
| 41 | + console.log('클릭됨!'); |
| 42 | +}); |
| 43 | +``` |
| 44 | + |
| 45 | +여기서 중요한 점은 버튼(Producer)은 그저 "나 눌렸어!"라고 브라우저(Channel)에 알릴 뿐, 누가 자신의 클릭을 listen을 가지고 있는지 전혀 모르고 있다는 점 입니다. |
| 46 | + |
| 47 | +> 이것이 바로 **느슨한 결합** 의 시작입니다. |
| 48 | +
|
| 49 | +<br /> |
| 50 | + |
| 51 | + |
| 52 | +### React의 `useEffect`도 결국 구독이다. |
| 53 | +React의 `useEffect`도 EDA 관점에서 해석할 수 있습니다. |
| 54 | + |
| 55 | +``` |
| 56 | +const [count, setCount] = useState(0); |
| 57 | +
|
| 58 | +useEffect(() => { |
| 59 | + console.log(`count가 ${count}로 변경됨`); |
| 60 | +}, [count]); |
| 61 | +``` |
| 62 | + |
| 63 | +- `count` 상태 변경은 이벤트. |
| 64 | +- `Dependency Array > [count]` 는 구독(Subscribe) 행위. |
| 65 | +- `useEffect` 내부 로직은 이벤트를 처리하는 Consumer |
| 66 | + |
| 67 | +우리는 알게 모르게 이미 이벤트 기반으로 사고하고 있었어요. 👀 |
| 68 | + |
| 69 | +<br /> |
| 70 | + |
| 71 | +## 2. 아키텍처로 확장하기: "장바구니 담기"의 딜레마 |
| 72 | +조금 더 들어가서, "장바구니 담기" 기능을 구현한다고 가정 하겠습니다. |
| 73 | + |
| 74 | +### **전통적인 방식 (강한 결합)** |
| 75 | +사용자가 '담기' 버튼을 눌렀다고 가정하면 보통 이런 로직으로 담을 수 있습니다. |
| 76 | +``` |
| 77 | +// ProductCard.tsx |
| 78 | +const handleAddToCart = async () => { |
| 79 | + await addToCartApi(); // 1. API 호출 |
| 80 | + headerBadge.update(); // 2. 헤더 숫자 갱신 |
| 81 | + toast.show("담겼습니다"); // 3. 토스트 알림 |
| 82 | + gtm.push("add_to_cart"); // 4. GA 태그 전송 |
| 83 | +}; |
| 84 | +``` |
| 85 | + |
| 86 | + |
| 87 | + |
| 88 | +- `ProductCard` 컴포넌트가 `Header`, `Toast`, `GTM` 로직을 전부 알고 있어야 합니다. |
| 89 | +- 만약 "최근 본 상품에 추가"라는 기능이 또 생긴다면? |
| 90 | + - `handleAddToCart` 함수를 또 수정해야 합니다. |
| 91 | +- 컴포넌트 간 의존성이 복잡하게 얽히기 시작해요. |
| 92 | + |
| 93 | +<br /> |
| 94 | + |
| 95 | +### **EDA 방식 (느슨한 결합)** |
| 96 | +그렇다면, 이걸 이벤트 기반으로 풀면 어떻게 될까요? |
| 97 | + |
| 98 | + |
| 99 | + |
| 100 | +`ProductCard`는 **"장바구니에 담겼음(ADD_TO_CART)"** 이라는 이벤트만 발행하고 끝냅니다. |
| 101 | +`Header`, `Toast`, `GA` 모듈은 각자 알아서 그 이벤트를 구독하고 처리하죠. |
| 102 | + |
| 103 | +이렇게 하면 `ProductCard`는 다른 컴포넌트의 존재를 알 필요가 없습니다. |
| 104 | +기능이 추가되어도 상품 카드 코드는 수정되지 않게됩니다. |
| 105 | + |
| 106 | +> 바로, 이것이 우리가 추구해야 할 확장 가능한 프론트엔드 아키텍처 입니다! |
| 107 | +
|
| 108 | +<br /> |
| 109 | + |
| 110 | +## 3. CustomEvent 활용 |
| 111 | +이 개념을 실제로 구현할 때, 복잡한 라이브러리 없이 브라우저 내장 API인 `CustomEvent`만으로도 충분히 구현 가능합니다. |
| 112 | + |
| 113 | +``` |
| 114 | +// hooks/useCustomEvent.ts |
| 115 | +import { useEffect } from 'react'; |
| 116 | +
|
| 117 | +export const useCustomEvent = <T>(eventName: string, handler: (data: T) => void) => { |
| 118 | + useEffect(() => { |
| 119 | + const listener = (e: Event) => { |
| 120 | + const customEvent = e as CustomEvent<T>; |
| 121 | + handler(customEvent.detail); |
| 122 | + }; |
| 123 | + |
| 124 | + window.addEventListener(eventName, listener); |
| 125 | + return () => window.removeEventListener(eventName, listener); |
| 126 | + }, [eventName, handler]); |
| 127 | +}; |
| 128 | +
|
| 129 | +export const emitCustomEvent = <T>(eventName: string, data: T) => { |
| 130 | + window.dispatchEvent(new CustomEvent(eventName, { detail: data })); |
| 131 | +}; |
| 132 | +``` |
| 133 | + |
| 134 | +1. Producer (상품 카드) |
| 135 | +``` |
| 136 | +const ProductCard = ({ product }) => { |
| 137 | + const handleAddToCart = () => { |
| 138 | + // API 호출 후 이벤트만 던진다. 누가 듣는지는 신경 안 쓴다. |
| 139 | + emitCustomEvent('CART_UPDATED', { count: 1 }); |
| 140 | + }; |
| 141 | + |
| 142 | + return <button onClick={handleAddToCart}>담기</button>; |
| 143 | +}; |
| 144 | +``` |
| 145 | + |
| 146 | +2. Consumer (헤더, 토스트 등) |
| 147 | +``` |
| 148 | +// Header.tsx (전혀 다른 파일) |
| 149 | +const Header = () => { |
| 150 | + const [count, setCount] = useState(0); |
| 151 | +
|
| 152 | + // 이벤트를 구독하고 있다가 반응한다. |
| 153 | + useCustomEvent('CART_UPDATED', (data) => { |
| 154 | + setCount(prev => prev + data.count); |
| 155 | + }); |
| 156 | +
|
| 157 | + return <header>장바구니: {count}</header>; |
| 158 | +}; |
| 159 | +``` |
| 160 | + |
| 161 | +이 방식은 Redux, Zustand 같은 전역 상태 관리 라이브러리의 내부 동작 원리 와도 일맥상통합니다. |
| 162 | +상태 관리 라이브러리 역시 거대한 이벤트 버스 역할을 수행하기 때문입니다. |
| 163 | + |
| 164 | +## 마치며: "느슨한 결합"의 미학 |
| 165 | +<aside data-type="note"> |
| 166 | +코드를 잘 짜는 것보다 **'고치기 쉽게 짜는 것'** 이 훨씬 중요하다고 생각합니다. |
| 167 | +</aside> |
| 168 | + |
| 169 | +EDA의 핵심 가치는 **느슨한 결합** 입니다. |
| 170 | +- 컴포넌트 간 직접 의존성을 제거한다. |
| 171 | +- 한 부분을 수정해도 다른 부분에 영향이 적다. |
| 172 | +- 기능 확장이 유연해진다. |
| 173 | + |
| 174 | +물론 모든 로직을 이벤트로 처리하면 흐름을 파악하기 어려워지는(디버깅의 어려움) 트레이드오프가 존재하기도 합니다. |
| 175 | +하지만 서로 관련 없는 컴포넌트 간의 통신이나, 확장성이 중요한 로직에서는 EDA가 강력한 무기가 되어줄 것 입니다. |
| 176 | + |
| 177 | +--- |
| 178 | + |
| 179 | +### 핵심 정리 |
| 180 | + |
| 181 | +- 우리는 이미 EDA를 쓰고 있다. |
| 182 | + - `addEventListener`, `useEffect`, 상태 관리 라이브러리 — 전부 이벤트 기반 |
| 183 | +- 브라우저는 거대한 이벤트 루프다. |
| 184 | + - 클릭, 네트워크, 렌더링 모두 이벤트로 처리 |
| 185 | +- 핵심은 느슨한 결합. |
| 186 | + - Producer는 Consumer를 모른다. |
| 187 | + - 그래서 독립적으로 수정/확장이 가능하다. |
| 188 | +- CustomEvent로 직접 구현 가능 |
| 189 | + - 관련 없는 컴포넌트 간 통신에 유용하다. |
| 190 | +- 적절히 사용하자. |
| 191 | + - 디버깅 복잡도와 트레이드오프를 고려해야 한다. |
| 192 | + |
| 193 | + |
| 194 | +--- |
| 195 | +### 📝 참고한 여러 레퍼런스 자료들 |
| 196 | + |
| 197 | +> "액션을 디스패치하는 것을 애플리케이션에서 **'이벤트를 트리거'** 하는 것으로 생각할 수 있습니다. |
| 198 | +무언가 일어났고, 스토어에 알려주고 싶은 것입니다. 리듀서는 이벤트 리스너처럼 동작합니다." |
| 199 | +— Redux Fundamentals |
| 200 | + |
| 201 | +- Zustand |
| 202 | +``` |
| 203 | +// Zustand 내부 구현 (단순화) |
| 204 | +const createStore = (createState) => { |
| 205 | + let state; |
| 206 | + const listeners = new Set(); |
| 207 | +
|
| 208 | + const setState = (newState) => { |
| 209 | + state = newState; |
| 210 | + // 모든 리스너에게 알림 (Pub/Sub!) |
| 211 | + listeners.forEach(listener => listener(state)); |
| 212 | + }; |
| 213 | +
|
| 214 | + const subscribe = (listener) => { |
| 215 | + listeners.add(listener); |
| 216 | + return () => listeners.delete(listener); |
| 217 | + }; |
| 218 | +
|
| 219 | + return { setState, getState, subscribe }; |
| 220 | +}; |
| 221 | +``` |
| 222 | + |
| 223 | +> 상태 관리 = Pub/Sub 패턴 = EDA의 일종이다. |
| 224 | +
|
| 225 | + |
| 226 | +> "느슨한 결합은 컴포넌트 간의 상호 의존도를 줄이는 것을 목표로 합니다. 그러나 완전히 분리하지는 않습니다." |
| 227 | +— Red Hat - What is EDA? |
| 228 | + |
| 229 | +> "Event Notification은 낮은 결합도를 제공하지만, 여러 이벤트에 걸친 논리적 흐름이 있을 때 문제가 될 수 있습니다. 그런 흐름은 어떤 프로그램 코드에도 명시적으로 드러나지 않습니다." |
| 230 | +> — Martin Fowler - Event-Driven |
| 231 | +
|
| 232 | +--- |
| 233 | +### Ref |
| 234 | +- Event Loop: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop |
| 235 | +- CustomEvent: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent |
| 236 | +- useEffect: https://react.dev/reference/react/useEffect |
| 237 | +- Fowler Event-Driven: https://martinfowler.com/articles/201701-event-driven.html |
| 238 | +- Redux 개념: https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow |
| 239 | +- Zustand 소스코드: https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts |
| 240 | + |
0 commit comments