이벤트 처리
React 이벤트 핸들러를 연결하고 이벤트 객체와 추가 인자를 전달하며 버블링과 전파 중단을 제어합니다.
우리는 지금까지 리액트 컴포넌트를 만들고(App.jsx, UserProfile.jsx, ToggleSwitch.jsx), props로 데이터를 전달하며(UserProfile.jsx), state를 통해 컴포넌트 내부의 동적인 데이터를 관리(ToggleSwitch.jsx)하는 방법을 배웠습니다.
이제 웹 애플리케이션의 핵심인 사용자와의 상호작용을 구현해 볼 차례입니다.
사용자가 버튼을 클릭하거나, 입력 필드에 텍스트를 입력하거나, 마우스를 움직이는 등의 행위를 우리는 이벤트(Event)라고 부릅니다.
리액트에서는 이러한 이벤트를 어떻게 감지하고 처리하는지 이번 장에서 자세히 알아보겠습니다.
이벤트에서 다음 렌더까지 — 상태 추적
사용자가 React를 입력하면 target.value, 현재 text snapshot, 다음 state 요청, DOM value/문장이 서로 다른 순간의 값이다. setter 즉시 대입 오해를 줄인다.
EVENT → STATE → UI
이벤트 핸들러가 읽은 입력값을 state와 화면에 연결합니다
InputMirror에서 사용자가 React를 입력하면 handleChange가 값을 읽어 setText를 호출합니다. 다음 렌더는 input의 value와 아래 표시 문장에 같은 text를 사용합니다.
InputMirror의 한 데이터 흐름
onChange={handleChange}
setText(e.target.value);
value={text}
표시: 입력된 내용: React새 입력값 읽기 → setter 요청 → 다음 렌더 → input과 표시 텍스트 반영입니다.
함수 전달과 인자 전달
onClick={handleClickHello}
onClick={() =>
handleDeleteItem(item.id)}함수 자체나 wrapper 함수를 전달합니다. onClick={handleClickHello()}는 렌더 중 즉시 호출되므로 이벤트 시점 실행과 다릅니다.
stopPropagation · 전파 중단
버튼 핸들러가 실행된 뒤 부모 click 핸들러로 이어지는 버블링을 막습니다. 링크 이동 같은 기본 동작까지 자동 차단하지는 않습니다.
preventDefault · 기본 동작 중단
링크 이동·form 제출 같은 기본 동작을 막습니다. 부모의 이벤트 핸들러로 전파되는지 여부는 별도로 결정합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 입력 이벤트→handleChange에서 target.value 읽기→setText 요청→다음 렌더의 노드 4개·간선 3개다. 같은 text가 제어 input의 value 및 표시 문장에 쓰이며 현재 핸들러 변수에 즉시 대입하는 경로가 아니다. |
| 전체 관계·조건 설명 | 입력 이벤트→handleChange에서 target.value 읽기→setText 요청→다음 렌더의 노드 4개·간선 3개다. 같은 text가 제어 input의 value 및 표시 문장에 쓰이며 현재 핸들러 변수에 즉시 대입하는 경로가 아니다. 모바일에서도 노드와 연결 방향·독립 경로가 같다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| DOM 입력 | DOM 값: React / handler text: 현재 snapshot | 사용자가 React를 입력하면 event target.value가 React다 | DOM 사건 값과 React state의 순간이 다르다 |
| setText 요청 | 요청: React / 현재 text: 즉시 대입 아님 | handler가 읽은 값을 다음 state로 요청한다 | setter는 실행 중 지역 변수를 다시 쓰지 않는다 |
| 다음 UI | input: React / 표시 문장: React | input value와 문장이 새 text를 읽는다 | 다음 렌더에서 제어 값과 출력이 같은 state를 사용한다 |
이벤트에서 다음 렌더까지 — 상태 추적
사용자가 React를 입력하면 target.value, 현재 text snapshot, 다음 state 요청, DOM value/문장이 서로 다른 순간의 값이다. setter 즉시 대입 오해를 줄인다.
EVENT → STATE → UI
이벤트 핸들러가 읽은 입력값을 state와 화면에 연결합니다
InputMirror에서 사용자가 React를 입력하면 handleChange가 값을 읽어 setText를 호출합니다. 다음 렌더는 input의 value와 아래 표시 문장에 같은 text를 사용합니다.
InputMirror의 한 데이터 흐름
onChange={handleChange}
setText(e.target.value);
value={text}
표시: 입력된 내용: React새 입력값 읽기 → setter 요청 → 다음 렌더 → input과 표시 텍스트 반영입니다.
함수 전달과 인자 전달
onClick={handleClickHello}
onClick={() =>
handleDeleteItem(item.id)}함수 자체나 wrapper 함수를 전달합니다. onClick={handleClickHello()}는 렌더 중 즉시 호출되므로 이벤트 시점 실행과 다릅니다.
stopPropagation · 전파 중단
버튼 핸들러가 실행된 뒤 부모 click 핸들러로 이어지는 버블링을 막습니다. 링크 이동 같은 기본 동작까지 자동 차단하지는 않습니다.
preventDefault · 기본 동작 중단
링크 이동·form 제출 같은 기본 동작을 막습니다. 부모의 이벤트 핸들러로 전파되는지 여부는 별도로 결정합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 입력 이벤트→handleChange에서 target.value 읽기→setText 요청→다음 렌더의 노드 4개·간선 3개다. 같은 text가 제어 input의 value 및 표시 문장에 쓰이며 현재 핸들러 변수에 즉시 대입하는 경로가 아니다. |
| 전체 관계·조건 설명 | 입력 이벤트→handleChange에서 target.value 읽기→setText 요청→다음 렌더의 노드 4개·간선 3개다. 같은 text가 제어 input의 value 및 표시 문장에 쓰이며 현재 핸들러 변수에 즉시 대입하는 경로가 아니다. 모바일에서도 노드와 연결 방향·독립 경로가 같다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| DOM 입력 | DOM 값: React / handler text: 현재 snapshot | 사용자가 React를 입력하면 event target.value가 React다 | DOM 사건 값과 React state의 순간이 다르다 |
| setText 요청 | 요청: React / 현재 text: 즉시 대입 아님 | handler가 읽은 값을 다음 state로 요청한다 | setter는 실행 중 지역 변수를 다시 쓰지 않는다 |
| 다음 UI | input: React / 표시 문장: React | input value와 문장이 새 text를 읽는다 | 다음 렌더에서 제어 값과 출력이 같은 state를 사용한다 |
리액트 이벤트 시스템 개요
이벤트 처리는 핸들러 함수로 사용자 행동을 받아 상태를 변경하거나 기본 동작을 제어하는 흐름입니다.
먼저 이 흐름을 잡고 세부 예제를 보겠습니다.
리액트의 이벤트 시스템은 브라우저의 원시(Native) 이벤트 시스템과 유사하지만, 몇 가지 중요한 차이점이 있습니다.
- JSX 내부에서 이벤트 핸들러 정의: HTML에서
onclick="myFunction()"과 같이 속성으로 이벤트 핸들러를 정의하는 대신, 리액트 JSX에서는 자바스크립트 함수를 중괄호{}안에 직접 전달합니다.// HTML <button onclick="alert('클릭!')">클릭</button> // React (JSX) <button onClick={() => alert('클릭!')}>클릭</button> - CamelCase 속성 이름:
onClick,onChange,onMouseOver등 이벤트 속성 이름은 HTML의 소문자와 달리 CamelCase를 사용합니다. - 합성 이벤트(Synthetic Event): 리액트의 이벤트는 실제 브라우저의 이벤트 객체가 아니라, 리액트가 브라우저의 이벤트를 감싸서 제공하는 합성 이벤트 객체(SyntheticEvent)입니다. 이 합성 이벤트 객체는 모든 브라우저에서 일관된 방식으로 작동하도록 표준화되어 있어 크로스 브라우징 이슈를 줄여줍니다. 합성 이벤트 객체는
e.stopPropagation(),e.preventDefault()와 같은 표준 메서드를 동일하게 제공합니다.
버튼 클릭 이벤트 처리하기
가장 흔하게 사용되는 이벤트 중 하나인 버튼 클릭 이벤트를 처리하는 방법을 살펴보겠습니다.
예제: 메시지 변경 버튼MessageChanger.jsx 컴포넌트 생성:
src/components 폴더 안에 MessageChanger.jsx 파일을 생성합니다.
import { useState } from 'react';
function MessageChanger() {
// (1) state 선언: 초기 메시지는 "환영합니다!"
const [message, setMessage] = useState("환영합니다!");
// (2) 이벤트 핸들러 함수 정의
const handleClickHello = () => {
setMessage("안녕하세요!"); // state 업데이트
};
const handleClickGoodbye = () => {
setMessage("안녕히 가세요!"); // state 업데이트
};
return (
<div style={{ border: '1px dashed #999', padding: '15px', margin: '20px', borderRadius: '8px' }}>
<h2>메시지 변경하기</h2>
<p style={{ fontSize: '20px', fontWeight: 'bold' }}>{message}</p>
<button onClick={handleClickHello} style={{ margin: '5px' }}>
안녕하세요 버튼
</button>
<button onClick={handleClickGoodbye} style={{ margin: '5px' }}>
안녕히 가세요 버튼
</button>
</div>
);
}
export default MessageChanger;messagestate를 선언하고 초기값을 환영합니다!로 설정했습니다.handleClickHello와handleClickGoodbye두 개의 함수를 정의했습니다. 이 함수들은 버튼이 클릭될 때 호출될 이벤트 핸들러입니다.onClick={handleClickHello}와 같이button태그의onClick속성에 정의된 함수 이름을 전달했습니다. 함수를 호출하는 형태(handleClickHello())가 아님에 주의하세요! 함수 호출 형태로 작성하면 컴포넌트가 렌더링될 때마다 함수가 즉시 실행되어 버립니다. 우리는 이벤트가 발생했을 때 함수가 실행되기를 원하므로, 함수 자체를 참조로 전달해야 합니다.
App.jsx에서 MessageChanger 컴포넌트 사용:
src/App.jsx 파일을 열고, MessageChanger 컴포넌트를 불러와 사용합니다.
import './App.css';
// ... 다른 컴포넌트 임포트 ...
import MessageChanger from './components/MessageChanger'; // MessageChanger 컴포넌트 불러오기
function App() {
return (
<div className="App">
<h1>나의 React 앱</h1>
{/* ... 다른 컴포넌트 ... */}
<MessageChanger />
</div>
);
}
export default App;결과 확인: 브라우저를 확인하면 메시지가 표시된 버튼 두 개를 볼 수 있을 것입니다.
처음 환영합니다!, 안녕하세요 버튼 뒤 안녕하세요!, 안녕히 가세요 버튼 뒤 안녕히 가세요! 순서로 확인합니다. 같은 메시지를 다시 선택해도 값이 누적되지는 않습니다.
이벤트 객체(e) 사용하기
이벤트 핸들러 함수는 이벤트가 발생했을 때 해당 이벤트에 대한 정보를 담고 있는 이벤트 객체(SyntheticEvent)를 인자로 받습니다.
이 객체를 통해 이벤트 발생 요소, 마우스 좌표, 키보드 입력 값 등 다양한 정보에 접근할 수 있습니다.
예제: 입력 필드 값 실시간 반영InputMirror.jsx 컴포넌트 생성:
src/components 폴더 안에 InputMirror.jsx 파일을 생성합니다.
import { useState } from 'react';
function InputMirror() {
const [text, setText] = useState(""); // 입력 필드의 텍스트 상태
// 이벤트 핸들러: 입력 필드 값이 변경될 때마다 호출
const handleChange = (e) => { // (1) 이벤트 객체 'e'를 인자로 받습니다.
console.log(e.target.value); // 콘솔에 입력된 값 출력
setText(e.target.value); // (2) e.target.value로 입력 필드의 현재 값에 접근
};
return (
<div style={{ border: '1px solid #c9c9c9', padding: '15px', margin: '20px', borderRadius: '8px' }}>
<h2>입력 필드 미러</h2>
<input
type="text"
value={text}
onChange={handleChange}
placeholder="여기에 입력하세요..."
style={{ padding: '8px', fontSize: '16px', width: '80%', marginBottom: '10px' }}
/>
<p>
입력된 내용: <span style={{ fontWeight: 'bold', color: '#007bff' }}>{text}</span>
</p>
</div>
);
}
export default InputMirror;handleChange함수는e라는 이름으로 이벤트 객체를 인자로 받습니다.e.target은 이벤트가 시작된 DOM 요소(여기서는<input>)를 참조합니다.e.currentTarget은 현재 핸들러가 붙은 요소입니다. 버튼 안 span을 클릭하면 target은 span이지만 버튼 핸들러의 currentTarget은 button일 수 있습니다.e.target.value는 해당<input>요소의 현재 값을 의미합니다. 우리는 이 값을setText함수를 통해textstate에 업데이트합니다.<input>태그의value속성을textstate와 연결하는 것은 매우 중요합니다. 이를 통해 입력 필드의 값이 항상state와 동기화되며, 리액트가 입력 필드를 제어할 수 있게 됩니다. 이러한<input>요소를 제어 컴포넌트(Controlled Component)라고 부릅니다.
App.jsx에서 InputMirror 컴포넌트 사용import './App.css';
// ... 다른 컴포넌트 임포트 ...
import InputMirror from './components/InputMirror'; // InputMirror 컴포넌트 불러오기
function App() {
return (
<div className="App">
<h1>나의 React 앱</h1>
{/* ... 다른 컴포넌트 ... */}
<InputMirror />
</div>
);
}
export default App;결과 확인: 브라우저에서 입력 필드에 텍스트를 입력하면, 아래 텍스트도 실시간으로 똑같이 변경되는 것을 확인할 수 있을 것입니다.
InputMirror 예제처럼 입력값을 state와 연결하면, 사용자의 입력과 화면에 보이는 값이 하나의 데이터 흐름 안에서 동기화됩니다.
이벤트 핸들러에 인자 전달하기
때로는 이벤트 핸들러 함수에 추가적인 인자를 전달해야 할 때가 있습니다.
예를 들어, 여러 개의 아이템 중 특정 아이템을 클릭했을 때 그 아이템의 ID를 전달하고 싶을 때입니다.
이때는 이벤트 핸들러를 화살표 함수로 감싸서 정의하는 방법이 일반적입니다.
예제: 아이템 삭제 버튼import { useState } from 'react';
function ItemList() {
const [items, setItems] = useState([
{ id: 1, text: '첫 번째 아이템' },
{ id: 2, text: '두 번째 아이템' },
{ id: 3, text: '세 번째 아이템' },
]);
// (1) 삭제할 아이템의 ID를 인자로 받는 함수 정의
const handleDeleteItem = (idToDelete) => {
// filter() 메서드를 사용하여 해당 id를 가진 아이템을 제외하고 새로운 배열 생성
setItems(items.filter(item => item.id !== idToDelete));
};
return (
<div style={{ border: '1px solid #abc', padding: '15px', margin: '20px', borderRadius: '8px' }}>
<h2>아이템 목록</h2>
<ul>
{items.map(item => (
<li key={item.id} style={{ marginBottom: '10px' }}>
{item.text}
{/* (2) 화살표 함수로 감싸서 handleDeleteItem에 item.id를 전달 */}
<button
onClick={() => handleDeleteItem(item.id)}
style={{ marginLeft: '10px', backgroundColor: '#dc3545', color: 'white', border: 'none', padding: '5px 10px', borderRadius: '4px', cursor: 'pointer' }}
>
삭제
</button>
</li>
))}
</ul>
</div>
);
}
export default ItemList;handleDeleteItem함수는idToDelete라는 인자를 받아, 해당 ID를 제외한 새 배열을 setter에 전달합니다. 원items를 직접 잘라내는 코드가 아닙니다. 이 예제는 한 클릭에 한 번 삭제하며, 같은 이벤트에서 여러 삭제를 조합한다면setItems(prev => prev.filter(...))로 큐의 직전 배열을 받아 계산합니다.onClick={() => handleDeleteItem(item.id)}:button의onClick에는 화살표 함수() => ...를 사용했습니다. 이 화살표 함수가 클릭 이벤트 발생 시 실행되고, 그 안에서handleDeleteItem(item.id)를 호출함으로써 원하는item.id를 인자로 전달할 수 있게 됩니다.key={item.id}: 리스트를 렌더링할 때 각 아이템에keyprops를 부여하는 것은 매우 중요합니다. 리액트가 리스트의 각 아이템을 효율적으로 식별하고 업데이트하는 데 사용됩니다.key는 같은 부모의 형제 목록 안에서 유일하고, 같은 항목에는 안정된 값을 유지해야 합니다. 서로 다른 목록까지 전역으로 유일할 필요는 없습니다. 컴포넌트에서 ID를 읽어야 한다면 key와 별도로 입력을 전달합니다.
이벤트 전파(Event Propagation)와 방지
브라우저에서 이벤트는 DOM 트리를 따라 전파됩니다.
예를 들어, 버튼을 클릭하면 버튼에 대한 클릭 이벤트뿐만 아니라, 그 버튼을 감싸고 있는 div, body 등 부모 요소들에게도 클릭 이벤트가 전달(버블링)됩니다.
때로는 이러한 이벤트 전파를 막거나, 기본 동작(예: 링크 클릭 시 페이지 이동, 폼 제출 시 새로고침)을 방지해야 할 필요가 있습니다.
-
e.stopPropagation(): 이벤트가 부모 요소로 전파되는 것을 막습니다.<div onClick={() => alert('부모 div 클릭!')}> <button onClick={(e) => { e.stopPropagation(); // 버튼 클릭 시 부모 div의 클릭 이벤트는 발생하지 않음 alert('버튼 클릭!'); }}> 버튼 </button> </div> -
e.preventDefault(): 이벤트의 기본 동작을 막습니다. 예를 들어,<a>태그의 기본 동작인 페이지 이동을 막거나,<form>태그의 기본 동작인 페이지 새로고침을 막을 때 사용합니다.<a href="https://google.com" onClick={(e) => { e.preventDefault(); // 링크 클릭 시 페이지 이동을 막음 alert('링크 이동이 차단되었습니다!'); }}> 구글로 이동 (차단) </a>
두 메서드는 성능 최적화를 위해 습관적으로 호출하는 것이 아니라, 원하는 동작을 정한 뒤 선택합니다. 클릭의 버블링을 멈춰도 링크 이동의 기본 동작은 남을 수 있으며, 이동만 막아도 부모 클릭 핸들러는 실행될 수 있습니다. 이 절은 버블링 단계의 클릭을 다룹니다. 별도의 capture 핸들러나 React의 onScroll 같은 다른 이벤트 조건으로 일반화하지 않습니다. 공식 이벤트 설명이 두 중단 메서드를 구별합니다.
독립 동작 확인
원 InputMirror에 React를 입력했다가 모두 지우면 입력칸과 아래 text 부분이 함께 비는지 확인합니다. ItemList는 src/components/ItemList.jsx로 저장한 뒤 App에서 import하여 <ItemList />를 표시합니다. ID 2 삭제 후에는 첫 번째와 세 번째 아이템만 남아야 합니다. 현재 코드의 filter가 어떤 새 배열을 만드는지 먼저 적고 클릭 결과와 대조하세요.
혼자 전파와 기본 동작 구별하기
부모 div에 클릭 로그가 있고 그 안의 링크에도 클릭 핸들러가 있습니다. capture 핸들러는 없으며 링크를 일반 클릭한다고 둡니다. 링크 핸들러가 ① preventDefault만 호출 ② stopPropagation만 호출 ③ 둘 다 호출할 때, 부모의 버블링 클릭 로그와 링크 이동이 각각 남는지 표로 적어 보세요. 또 현재 ItemList에서 ID 2를 삭제한 뒤 ID 1을 삭제하면 어떤 항목이 남는지 판단합니다.
전파·이동·삭제 해설
① 부모 로그는 남고 이동은 막습니다. ② 부모의 버블링 로그는 막지만 링크 이동은 이 메서드만으로 막지 않습니다. ③ 두 동작을 모두 막습니다. 이벤트가 부모로 전달되는 경로와 링크 자체의 기본 동작을 따로 판단했기 때문입니다. 목록은 첫 삭제 뒤 ID 1·3, 다음 삭제 뒤 ID 3만 남습니다. 클릭마다 반영된 새 items에서 다음 필터를 계산합니다.
버튼 클릭, 입력 변경, 인자 전달, 전파 제어를 따로 외우기보다 같은 점검 순서로 묶어 보면 실수가 줄어듭니다.
이벤트를 읽을 때는 onClick, onChange 같은 속성 이름보다 먼저 함수가 언제 실행되는지 확인해야 합니다.
함수 참조를 넘기는지, 이벤트 객체에서 어떤 값을 읽는지, 필요할 때 전파와 기본 동작을 끊는지까지 이어서 보면 props, state, 이벤트 처리가 하나의 상호작용 흐름으로 연결됩니다.