|
| 1 | +--- |
| 2 | +title: "[커리어핏] React 렌더링 최적화 분투기 (useState 에서 useRef, 그리고 EventBus 아키텍쳐까지)" |
| 3 | +createdAt: 2025-09-25 |
| 4 | +category: React |
| 5 | +description: React에서 상태 관리를 위해 useState 대신 useRef를 사용하여 불필요한 리렌더링을 줄이고, EventBus 아키텍쳐를 도입하여 컴포넌트 간의 효율적인 통신 방법을 알아봅니다. |
| 6 | +comment: true |
| 7 | +--- |
| 8 | + |
| 9 | +# React 렌더링 최적화 분투기 (useState 에서 useRef, 그리고 EventBus 아키텍쳐까지) |
| 10 | + |
| 11 | +:::warning |
| 12 | +아직 작성중이거나 검토중인 글입니다. 내용이 부정확하거나 변경될 수 있습니다 |
| 13 | +::: |
| 14 | + |
| 15 | +## 🤔 가장 React스러운 방법이 항상 정답일까? |
| 16 | + |
| 17 | +React 개발자라면 재렌더링을 위해 `useState`를 사용해 상태를 UI에 반영하는 '선언적인' 방식에 익숙하다 |
| 18 | + |
| 19 | +> 나 역시도 그랬다.. 미친 재렌더링으로 고생하기 전까지는...🥲 |
| 20 | +
|
| 21 | +최근 PDF 문서 위에 사용자가 드래그로 영역을 선택해 피드백을 남기는 기능을 개발하게 되었다. |
| 22 | + |
| 23 | +당연하게도 `useState` 로 드래그 영역의 좌표를 관리했다. 잘 동작했지만, 결과는 처참했다. <br/> |
| 24 | +마우스를 조금만 빠르게 움직여도 이벤트가 씹히는 현상이 발생했다. `onMouseMove` 이벤트가 발생할 때마다 `setState`가 호출되면서 초당 수십 번의 리렌더링이 일어났기 때문이다. ([PR - Feature#31](https://github.com/kakao-tech-campus-3rd-step3/Team7_FE/pull/38)) |
| 25 | + |
| 26 | + |
| 27 | + |
| 28 | +이처럼 간단해 보이는 기능에서, 어떻게 성능과 멋찐 아키텍쳐를 모두 잡을 수 있을까? |
| 29 | + |
| 30 | +## 🛠️ 1차시도: useRef로 불필요한 리렌더링 최소화하기 |
| 31 | + |
| 32 | +### 1️⃣ 문제인식: 불필요한 리렌더링 |
| 33 | + |
| 34 | +문제의 핵심은 '불필요한 리렌더링'이었다. <br/> |
| 35 | +드래그 중인 좌표는 최종 결과가 아니므로, 매번 React 의 생명주기에 포함할 필요가 없었다 |
| 36 | + |
| 37 | +### 2️⃣ 해결책: useRef로 상태 관리하기 |
| 38 | + |
| 39 | +`useRef`는 컴포넌트가 리렌더링 되더라도 값이 유지되며, 값이 변경되어도 리렌더링을 발생시키지 않는다. <br/> |
| 40 | +따라서 드래그 중인 좌표를 `useRef`로 관리하면 불필요한 리렌더링을 피할 수 있다. |
| 41 | + |
| 42 | + |
| 43 | + |
| 44 | +또, `useRef`를 사용해 DOM 엘리먼트를 직접 참조하고 `style` 속성을 직접 변경했다. <br/> |
| 45 | +리렌더링은 0회. 성능 문제는 해결되었따! |
| 46 | + |
| 47 | +## 🛠️ 2차시도: 관심사 분리를 향하여 (EventBus 의 등장) |
| 48 | + |
| 49 | +### 1️⃣ 문제인식: 컴포넌트의 비대화 |
| 50 | + |
| 51 | +하지만 새로운 문제가 생겼다.<br/> |
| 52 | +모든 상태와 로직이 `PortfolioFeedbackWidget` 이라는 하나의 컴포넌트에 집중되기 시작했다 |
| 53 | + |
| 54 | +앞으로 추가될 줌(Zoom), 패닝(Panning) 기능은 어떻게 처리해야 하지? <br/> |
| 55 | +모든 로직이 한 곳에 섞여 유지보수가 어려워질 것이 뻔했다 |
| 56 | + |
| 57 | +> View 로직과 Business 로직을 어떻게 분리할 수 있을까? |
| 58 | +
|
| 59 | +### 2️⃣ 해결책: EventBus 아키텍쳐 도입하기 |
| 60 | + |
| 61 | +> `이벤트 버스(EventBus)` 는 소프트웨어 컴포넌트들이 서로 직접적으로 통신하지 않고, 중앙의 이벤트 버스를 통해 **이벤트(Event)** 라는 메시지를 주고 받게 하는 디자인 패턴이다. |
| 62 | +> Publish-Subscribe 모델의 한 형태로, 시스템의 각 부분을 독립적인 모듈로 만들어 결합도를 낮추는데 도움을 준다. |
| 63 | +
|
| 64 | +<center> |
| 65 | + <img src="./img/kareer-fit-1/image-1.png" alt="이게뭐고.." width="500px" /> |
| 66 | +</center> |
| 67 | + |
| 68 | +쉽게 말해! 인스타 팔로우 라고 생각하면된다! <br/> |
| 69 | + |
| 70 | +1. 내(`subscriber`)가 A 라는 사람(`publisher`)을 팔로우 한다 |
| 71 | +2. A 가 게시글을 올리면(`dispatch`) 내 피드(`subscriber`)에 게시글이 뜬다 |
| 72 | +3. 게시글은 인스타그램 서버(`EventBus`)가 중간에서 전달해준다 |
| 73 | + |
| 74 | +```ts |
| 75 | +export class EventBus { |
| 76 | + public listeners = new Map<keyof EventTypes, Array<EventHandlerOf<keyof EventTypes>>>(); |
| 77 | + |
| 78 | + public subscribe<K extends keyof EventTypes>(eventType: K, handler: EventHandlerOf<K>) { |
| 79 | + if (!this.listeners.has(eventType)) { |
| 80 | + this.listeners.set(eventType, []); |
| 81 | + } |
| 82 | + this.listeners.get(eventType)?.push(handler as EventHandlerOf<keyof EventTypes>); |
| 83 | + } |
| 84 | + |
| 85 | + public unsubscribe<K extends keyof EventTypes>(eventType: K, handler: EventHandlerOf<K>) { |
| 86 | + const handlers = this.listeners.get(eventType); |
| 87 | + if (!handlers) return; |
| 88 | + |
| 89 | + this.listeners.set( |
| 90 | + eventType, |
| 91 | + handlers.filter((h) => h !== handler), |
| 92 | + ); |
| 93 | + } |
| 94 | + |
| 95 | + public dispatch(event: EventTypeOf<keyof EventTypes>) { |
| 96 | + const handlers = this.listeners.get(event.type); |
| 97 | + if (!handlers) return; |
| 98 | + |
| 99 | + handlers.forEach((handler) => handler(event)); |
| 100 | + } |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +## 🛠️ 3차 시도: EventController로 책임 분리하기 |
| 105 | + |
| 106 | +### 1️⃣ 문제인식: 그냥 MouseEvent 를 EventBus 에 던져? |
| 107 | + |
| 108 | +`onMouseMove` 같은 원시 마우스 이벤트를 EventBus에 던져주기만 하면 될까? <br/> |
| 109 | + |
| 110 | +인스타그램 서버(`EventBus`)가 게시물(`Event`)을 중간에서 전달(`dispatch`)할때 JSON 값을 그대로 전달하지는 않는다. <br/> |
| 111 | +게시물의 내용, 작성자, 작성시간 등 필요한 정보만 담긴 객체를 전달한다! |
| 112 | + |
| 113 | +따라서 원시 마우스 이벤트를 그대로 던져주기보다는, 드래그 제스처를 해석해 '고수준 이벤트'로 변환하는 중간 다리 역할이 필요했다. |
| 114 | + |
| 115 | +### 2️⃣ 해결책: 저수준 이벤트를 고수준 이벤트로 변환하는 EventController |
| 116 | + |
| 117 | +그렇다면 저수준 이벤트를 고수준 이벤트로 변환하면되겠구나? <br/> |
| 118 | +그럼 이벤트 변환은 어디서 일어나야 할까? 세 가지 방법을 고민해봤다 |
| 119 | + |
| 120 | +1. View 컴포넌트에서 직접 관리하기 |
| 121 | + - `react-pdf` 의 `<Page/>` 컴포넌트의 `onMouseDown`, `onMouseMove`, `onMouseUp` 핸들러 내부에서 `isDragging` 과 같은 상태를 직접 관리하는 방법 |
| 122 | + - 장점 : 단순함 |
| 123 | + - 단점 : View 와 Business 로직이 섞임, 컴포넌트가 담당하는 역할이 커짐 (SRP 위반) |
| 124 | + |
| 125 | +2. EventBus 내부에서 관리하기 |
| 126 | + - `eventBus.dispatch` 메서드가 `document:mousedown` 같은 특정 이벤트를 받으면, 버스 내부에서 상태를 관리하다가 `document:mouseup`이 들어왔을 때 area-selected 이벤트를 추가로 발행하는 방법 |
| 127 | + - 장점 : View 컴포넌트가 단순해짐 |
| 128 | + - 단점 : EventBus가 너무 많은 책임을 짐, 유지보수 어려움 |
| 129 | + |
| 130 | +3. EventController에서 관리하기 ✅ |
| 131 | + - Page는 저수준 이벤트를 발행하고, EventController가 이를 구독해서 그 결과를 고수준 이벤트로 다시 발행하는 방법 |
| 132 | + - 장점 : 관심사 분리, 유지보수 용이, 확장성 높음 |
| 133 | + - 단점 : 구조가 복잡해짐 |
| 134 | + |
| 135 | +여러가지 고민 끝에 3번 방법을 선택했다. |
| 136 | + |
| 137 | +### 3️⃣ 구현: EventController |
| 138 | + |
| 139 | +```ts |
| 140 | +// EventController.ts |
| 141 | +export abstract class EventController { |
| 142 | + public abstract attach(eventBus: EventBus): void; |
| 143 | +} |
| 144 | + |
| 145 | +// EventBus.ts |
| 146 | +export class EventBus { |
| 147 | + // ... |
| 148 | + public use(eventController: EventController) { |
| 149 | + eventController.attach(this); |
| 150 | + return this; |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +// usage |
| 155 | +const eventBus = new EventBus() |
| 156 | + .use(new SelectionEventController()) |
| 157 | + .use(new ZoomEventController()) |
| 158 | + .use(new PanEventController()); |
| 159 | +``` |
| 160 | + |
| 161 | +이제 EventBus에 EventController를 '플러그인'처럼 장착할 수 있고, <br/> |
| 162 | +새로운 기능을 추가할 때마다 새로운 Controller를 만들어 붙이기만 하면 된다! |
| 163 | + |
| 164 | +또, 각각의 플러그인은 독립적으로 개발 및 테스트할 수 있어 유지보수도 용이하다. |
| 165 | + |
| 166 | + |
| 167 | + |
| 168 | +SelectionEventController는 단순히 `attach` 로 부터 받은 `eventBus` 를 통해 이벤트를 구독하고, 상태머신을 이용해 고수준 이벤트로 변환해 발행하는 역할만 한다. |
| 169 | + |
| 170 | + |
| 171 | + |
| 172 | +```ts |
| 173 | +export type SelectionState = "idle" | "dragging"; |
| 174 | + |
| 175 | +export class SelectionEventController extends EventController { |
| 176 | + private eventBus: Nullable<EventBus> = null; |
| 177 | + |
| 178 | + public state: SelectionState = "idle"; |
| 179 | + public startPosition: Vector2d = { x: 0, y: 0 }; |
| 180 | + |
| 181 | + public override attach(eventBus: EventBus): void { |
| 182 | + this.eventBus = eventBus; |
| 183 | + |
| 184 | + this.eventBus.subscribe("document:mousedown", this.handleMouseDown); |
| 185 | + this.eventBus.subscribe("document:mousemove", this.handleMouseMove); |
| 186 | + this.eventBus.subscribe("document:mouseup", this.handleMouseUp); |
| 187 | + } |
| 188 | + |
| 189 | + private handleMouseDown: EventHandlerOf<"document:mousedown"> = (event) => { |
| 190 | + if (this.state !== "idle") return; |
| 191 | + |
| 192 | + this.state = "dragging"; |
| 193 | + this.startPosition = event.payload; |
| 194 | + |
| 195 | + this.eventBus?.dispatch({ |
| 196 | + type: "selection:start", |
| 197 | + payload: this.startPosition, |
| 198 | + }); |
| 199 | + }; |
| 200 | + |
| 201 | + private handleMouseMove: EventHandlerOf<"document:mousemove"> = (event) => { |
| 202 | + if (this.state !== "dragging") return; |
| 203 | + |
| 204 | + this.eventBus?.dispatch({ |
| 205 | + type: "selection:move", |
| 206 | + payload: { start: this.startPosition, current: event.payload }, |
| 207 | + }); |
| 208 | + }; |
| 209 | + |
| 210 | + private handleMouseUp: EventHandlerOf<"document:mouseup"> = (event) => { |
| 211 | + if (this.state !== "dragging") return; |
| 212 | + |
| 213 | + this.state = "idle"; |
| 214 | + |
| 215 | + this.eventBus?.dispatch({ |
| 216 | + type: "selection:end", |
| 217 | + payload: { start: this.startPosition, end: event.payload }, |
| 218 | + }); |
| 219 | + }; |
| 220 | +} |
| 221 | +``` |
| 222 | + |
| 223 | +## 🏁 마무리하며 |
| 224 | + |
| 225 | +지금까지의 여정을 통해 성능과 확장성을 모두 만족하는 다음과 같은 구조를 만들 수 있었다. |
| 226 | + |
| 227 | +### 교훈1. 모든 데이터가 React state 일 필요는 없다 |
| 228 | + |
| 229 | +애플리케이션의 핵심 로직에 영향을 주는 `'진짜 상태(State)'`(예: 최종 선택된 영역 좌표)와, 화면에 잠시 나타났다 사라지는 `'시각적 표현(View)'`(예: 드래그 중인 임시 박스)을 구분하는 것이 얼마나 중요한지 깨달았다. |
| 230 | + |
| 231 | +- 시각적 표현: useRef를 사용한 명령형 DOM 조작으로 리렌더링 없이 성능을 확보한다 |
| 232 | +- 진짜 상태: 최종 결과값은 `useState`나 `useSyncExternalStore`를 통해 React의 생명주기에 포함시켜 선언적으로 관리한다 |
| 233 | + |
| 234 | +### 교훈2. 좋은 아키텍처는 '관심사의 분리'에서 시작된다 |
| 235 | + |
| 236 | +처음에는 하나의 컴포넌트에서 모든 것을 해결하려 했다. 하지만 기능이 복잡해질수록 각자의 역할에만 충실한 작은 모듈들(`EventBus`, `EventController`)로 나누는 것이 오히려 전체 시스템을 더 단순하고 예측 가능하게 만든다는 것을 배웠습니다. |
| 237 | + |
| 238 | +> "이것이 정말 상태여야만 하는가?" <br/> |
| 239 | +> "이 로직은 정말 이 컴-포넌트가 책임져야 하는가?" |
| 240 | +
|
| 241 | +다음번에 복잡한 UI 인터랙션과 성능 문제에 부딪힌다면, 이번 경험을 떠올리며 '관심사의 분리'와 '적절한 상태 관리'를 고민해보려 한다. <br/> |
0 commit comments