
개인 블로그의 SVG 그리드에서 셀별 핸들러를 상위 SVG의 이벤트 처리로 옮긴 이유와 구현을 정리합니다.

개인 블로그의 배경에는 마우스가 지나간 칸에 색이 나타났다가 사라지는 정사각형 모양의 SVG 그리드 셀이 있어요. 이 그리드의 이벤트 처리를 각 셀에서 상위 SVG로 옮겼어요. 셀마다 좌표를 담은 핸들러를 전달하는 대신, 상위 SVG에서 마우스 위치로 셀을 계산하도록 바꾼 거예요.
이 글에서는 셀별 이벤트 처리에서 어떤 반복을 없앨 수 있는지, 이벤트를 한곳으로 모을 때 기존 동작을 유지하려면 무엇을 함께 처리해야 하는지 살펴봐요. React의 상태와 이벤트 핸들러를 알고 있는 독자를 대상으로 해요.
InteractiveGrid는 기본 설정에서 가로 40칸, 세로 24칸을 그려요. 총 960개의 <rect>가 셀 역할을 해요. 셀 하나의 기본 크기는 가로와 세로 모두 56이에요.
변경 전 구조에서는 셀에 마우스가 들어오면 해당 좌표를 hit 함수에 전달했어요. hit은 좌표와 현재 시각을 React 상태에 기록하고, 이 상태를 바탕으로 셀에 애니메이션 클래스를 적용해요.
아래는 저장소의 변경 전 재현 코드에서 이벤트 처리 부분만 발췌한 예시예요.
<rect
key={`${x},${y}`}
x={x * width}
y={y * height}
width={width}
height={height}
onMouseEnter={() => hit(x, y)}
/>셀 하나만 보면 이해하기 쉬운 구현이에요. 하지만 같은 패턴이 모든 셀에 반복돼요. 각 셀은 자신을 그리는 속성뿐 아니라, 자신의 좌표를 캡처한 이벤트 핸들러도 전달받아요.
이 구조를 다시 살펴본 계기는 마우스를 빠르게 움직였을 때의 반응이었어요. 셀 위를 빠르게 지나가면 인터랙션이 기대만큼 매끄럽지 않게 느껴졌어요. 불필요한 처리가 있는 것은 아닌지 궁금해 브라우저 개발자 도구와 코드를 확인했어요. 그 과정에서 960개 셀에 같은 형태의 핸들러를 전달하는 구조를 문제 후보로 봤어요.
변경 전후의 메모리 프로파일 화면은 남아 있지만, 렌더링 시간을 기록한 자료는 없어요. 따라서 이 체감을 곧바로 “960개 핸들러가 렌더링 지연을 일으켰다”는 결론으로 연결할 수는 없어요.
코드에서 확인할 수 있는 개선 후보는 셀마다 반복하는 이벤트 처리 구조였어요. 다만 이것만으로 렌더링 지연의 원인을 확정할 수는 없어요. 상태가 바뀌면 셀 목록을 다시 계산하는 작업도 있고, 브라우저가 애니메이션을 그리는 작업도 있기 때문이에요.
처음에는 각 셀의 이벤트 처리 로직을 더 단순하게 만들거나, 컴포넌트를 메모이제이션하는 방법을 생각했어요. 하지만 그 전에 각 셀이 이벤트를 따로 처리해야 하는지부터 다시 살펴봤어요.
이 그리드에서 셀마다 다른 정보는 좌표예요. 셀의 크기는 일정하고, 모든 셀이 같은 hit 함수를 호출해요. 필요한 것은 마우스가 어느 셀에 있는지 알아내는 일이었어요. 마우스 이동 이벤트가 상위 SVG로 전파되는 흐름을 이용하면, 셀마다 핸들러를 두지 않아도 된다고 판단했어요.
이 구조를 바꾸는 방법과 다른 최적화 방법은 해결하는 대상이 달라요.
| 접근 | 해결하려는 문제 | 이번 변경과의 관계 |
|---|---|---|
| 셀 컴포넌트의 메모이제이션 | 바뀌지 않은 셀의 렌더링 작업 | 셀별 핸들러를 전달하는 구조 자체는 남아요. |
| 셀의 이벤트 로직 단순화 | 각 핸들러 안에서 수행하는 작업 | 동일한 처리를 셀마다 연결하는 구조는 남아요. |
| 상위 SVG에서 좌표 계산 | 셀마다 반복하는 이벤트 처리 | 규칙적인 격자의 좌표를 이용해 핸들러를 한곳으로 모을 수 있어요. |
각 접근을 실험해서 성능을 비교한 기록은 없어요. 이벤트 위임을 선택한 이유는 같은 동작을 하는 셀마다 처리 함수를 전달하는 구조를 직접 바꿀 수 있었기 때문이에요.
여기서 이벤트 위임은 자식마다 처리 함수를 두는 대신 공통 부모에서 이벤트를 처리하는 방식을 뜻해요. 이번 구현에서는 상위 <svg>의 onMouseMove로 마우스 이동을 받고, 이벤트 대상의 속성을 읽는 대신 좌표로 셀을 계산해요. React에서 자식의 이벤트를 부모에서도 처리하는 흐름은 이벤트 전파 문서에서 확인할 수 있어요.
마우스의 clientX, clientY는 뷰포트 기준 좌표예요. 여기에서 SVG의 왼쪽과 위쪽 위치를 빼면 SVG 안에서의 상대 위치를 구할 수 있어요. 그 값을 셀 크기로 나누고 내림하면 열과 행 번호가 돼요.
현재 코드의 핵심 구현은 다음과 같아요. hit, width, height, cols, rows는 기존 컴포넌트의 함수와 값을 사용해요.
const lastCell = useRef<string | null>(null);
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const cellX = Math.floor((e.clientX - rect.left) / width);
const cellY = Math.floor((e.clientY - rect.top) / height);
if (cellX >= 0 && cellX < cols && cellY >= 0 && cellY <
e.currentTarget을 사용하는 이유는 이벤트 핸들러를 연결한 SVG를 기준으로 계산하기 위해서예요. e.target은 이벤트가 발생한 자식 <rect>일 수 있어요.
계산한 좌표가 격자 범위 안인지도 확인해요. SVG는 화면 크기를 따르고, 셀은 정해진 개수만 그리므로 SVG 전체가 항상 유효한 셀 영역인 것은 아니에요.
이 계산은 현재처럼 별도 viewBox나 확대·회전 변환 없이 화면 좌표와 셀 좌표가 대응하는 구조를 전제로 해요. SVG에 좌표 변환을 추가한다면 이 계산도 함께 검토해야 해요.
핸들러를 부모로 옮기는 것만으로는 충분하지 않아요. onMouseMove는 같은 셀 안에서 움직일 때도 발생해요. 매번 hit을 호출하면 셀이 달라지지 않았는데도 새 Map을 만들어 상태를 갱신하게 돼요.
그래서 마지막으로 처리한 셀을 lastCell에 저장하고, 셀 키가 바뀔 때만 hit을 호출해요. 마지막 셀은 화면에 직접 표시하는 값이 아니라 다음 이벤트를 처리할지 결정하는 값이므로 useRef로 관리해요.
이 가드는 기존 onMouseEnter보다 상태 업데이트를 더 줄였다는 뜻은 아니에요. 이벤트를 onMouseMove로 바꾸면서 같은 셀 안에서 불필요한 업데이트가 새로 생기지 않도록 막는 역할이에요.
마우스가 SVG 밖으로 나가면 lastCell을 초기화해요. 그래야 마지막으로 방문했던 셀에 다시 들어와도 hit을 호출할 수 있어요.
이벤트 연결 부분만 보면 구조는 다음과 같아요. 셀의 위치와 애니메이션을 그리는 코드는 유지하고, 셀의 onMouseEnter를 제거해요.
<svg onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave}>
{/* 기존 격자와 셀 렌더링 */}
</svg>이제 이벤트 처리 흐름은 “셀의 핸들러가 자기 좌표를 전달한다”에서 “SVG가 좌표를 계산하고, 이전 셀과 다를 때 상태를 갱신한다”로 바뀌어요.
코드에서 확인할 수 있는 변화는 다음과 같아요. 아래 개수는 기본 격자 설정을 기준으로 한 JSX의 핸들러 연결 개수이며, 브라우저의 네이티브 리스너 개수가 아니에요.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 셀별 이벤트 핸들러 | 960개 셀에 onMouseEnter 전달 | 없음 |
| SVG의 이벤트 핸들러 | 없음 | onMouseMove, onMouseLeave |
| 대상 셀 판단 | 각 핸들러가 캡처한 좌표 | 마우스 상대 좌표로 계산 |
| 상태 갱신 조건 | 셀 진입 | 유효한 셀에서 마지막 처리 셀과 다를 때 |
셀 <rect> 수 | 960개 | 960개 |
| 렌더링 시간 | TBD | TBD |
셀별 핸들러를 없앴지만, 셀 자체를 줄인 것은 아니에요. hovered 상태가 바뀌면 컴포넌트에서 셀 목록을 만드는 구조도 남아 있어요. 반대로 변경 후에는 마우스 이동마다 SVG의 위치를 읽고 셀 좌표를 계산해요. 따라서 핸들러 연결 개수의 감소를 렌더링 시간의 감소율로 바꿔 말할 수는 없어요.
동작 검증에서는 셀 사이 이동, 같은 셀 안에서 이동, SVG 밖으로 나갔다가 같은 셀로 재진입하는 경우를 확인해야 해요. SVG 내부의 격자 바깥 영역을 거쳐 같은 셀로 돌아오는 경우도 별도로 봐야 해요. 현재 코드는 SVG를 벗어날 때만 마지막 셀을 초기화하므로, 그 경우까지 기존 셀별 onMouseEnter와 같다고 단정할 수는 없어요.
렌더링 성능까지 확인하려면 React 렌더링 시간과 브라우저의 스크립트 실행 시간을 함께 측정해야 해요. 이번에 남긴 자료는 메모리 프로파일이므로, 렌더링 속도에 대한 결론은 따로 구분했어요.
같은 실행 환경과 기록 시간, 마우스 이동 조건에서 이벤트 위임 변경만 적용해 비교했어요. CSS 변수 등 다른 최적화는 이번 비교에 포함하지 않았어요. 변경 후 기록에서는 Function 항목의 객체 수와 메모리 표시값이 줄어들었어요. 아래는 각각 변경 전과 변경 후의 캡처예요.

| Function 항목 | 변경 전 | 변경 후 | 표시값 기준 감소 |
|---|---|---|---|
| 객체 수 | 21,315개 | 19,152개 | 2,163개, 약 10.1% |
| Shallow Size | 625 kB | 564 kB | 61 kB, 약 9.8% |
| Retained Size | 8,913 kB | 2,890 kB | 6,023 kB, 약 67.6% |
감소율은 (변경 전 - 변경 후) / 변경 전 × 100으로 계산했어요. 메모리 크기는 캡처에 표시된 반올림 값을 사용했어요.
Shallow Size는 객체 자체가 차지하는 메모리예요. Retained Size는 해당 객체를 제거하고 그 객체에 의존하는 객체에도 도달할 수 없게 됐을 때 해제할 수 있는 메모리를 뜻해요. Chrome 공식 문서는 Summary의 생성자 그룹에서 Shallow Size는 합계, Retained Size는 그룹 내 최대값으로 설명해요. 따라서 Function 행의 Retained Size를 모든 함수의 메모리 합계로 해석하거나, 다른 행의 값과 더해서는 안 돼요. Chrome DevTools의 Summary 지표 설명
이 캡처에서 말할 수 있는 결과는 변경 후 기록에서 Function 객체 수와 해당 행의 메모리 표시값이 더 작았다는 것이에요. Function에는 셀의 이벤트 핸들러 외의 함수도 포함되므로, 감소한 2,163개를 모두 제거한 셀 핸들러라고 볼 수는 없어요. Retained Size의 67.6% 감소 역시 페이지 전체 메모리나 렌더링 비용이 그만큼 줄었다는 뜻은 아니에요.
측정 조건은 다음과 같아요.
| 확인할 조건 | 기록 |
|---|---|
| 실행 환경 | 전후 동일. 브라우저 버전과 빌드 모드의 구체적인 값은 TBD |
| 기록 시간 | 전후 동일. 정확한 시간은 TBD |
| 마우스 이동 조건 | 전후 동일 |
| 변경 범위 | 이벤트 위임만 적용. CSS 변수 등 다른 최적화 제외 |
이번 비교에서는 이벤트 위임만 변경한 뒤 메모리 지표가 낮아진 것을 관찰했어요. 반복 측정 횟수와 GC 조건은 확인되지 않아, 같은 감소율이 반복해서 나타난다고 단정하지는 않았어요. 렌더링 시간과 프레임 변화는 별도 측정 대상으로 남겨두었어요.