순수 자바스크립트로 클릭을 처리하던 방식을 떠올려 보자. 요소를 querySelector로 붙잡고, addEventListener("click", 함수)를 걸고, 나중엔 떼는 것까지 챙겨야 했다. React는 이 과정을 JSX 안으로 끌고 들어온다. 이벤트를 걸 자리에 바로 onClick 같은 속성을 적고 함수를 넘기면 끝이다. 요소를 따로 찾을 필요도, 리스너를 떼어낼 걱정도 없다.


대신 딱 몇 가지 함정이 있는데, 초보가 여기서 거의 다 걸린다. 오늘은 그 함정들을 순서대로 밟아보겠다.


이벤트 이름은 카멜케이스다. 순수 HTML에서는 onclick이라고 소문자로 붙였지만, JSX에서는 onClick처럼 중간을 대문자로 쓴다. onChange, onSubmit, onMouseEnter 전부 같은 규칙이다. 소문자로 onclick이라고 적으면 React가 못 알아듣고 조용히 무시하니, 눌러도 아무 일이 없으면 철자부터 의심하면 된다.


함수는 넘기는 것이지 부르는 게 아니다. 가장 흔한 실수다. 클릭했을 때 실행할 함수를 onClick에 넘겨야 하는데, 실수로 그 자리에서 함수를 호출해 버린다.


function App() {
function handleClick() {
alert("눌렸다");
}
return <button onClick={handleClick()}>클릭</button>;
}


버튼을 누르지도 않았는데 화면이 뜨자마자 경고창이 튀어나온다. handleClick()처럼 괄호를 붙이면 렌더 도중에 함수가 바로 실행되고, 그 반환값(여기선 undefined)이 onClick에 걸린다. 정작 클릭할 땐 아무 일도 안 일어난다. 괄호를 떼고 함수 자체를 넘겨야 한다.


return <button onClick={handleClick}>클릭</button>;


이제 버튼을 눌렀을 때만 handleClick이 실행된다. "onClick에는 함수 이름만, 괄호는 빼고"라고 외워두면 절반은 안 틀린다. React가 클릭 시점에 알아서 그 함수를 불러준다.


인자를 넘기려면 화살표로 감싼다. 그런데 클릭할 때 특정 값을 넘기고 싶으면 어떡할까. 괄호를 붙이면 바로 실행되니, 이럴 땐 화살표 함수로 한 겹 감싼다.


function List() {
function remove(id) {
console.log(id, "삭제");
}
return (
<button onClick={() => remove(3)}>삭제</button>
);
}


이러면 onClick에는 화살표 함수 자체가 걸리고, 실제 클릭 순간에야 그 안의 remove(3)이 실행된다. 반복문으로 버튼을 여러 개 찍을 때 각 버튼마다 다른 id를 넘기는 흔한 패턴이 바로 이거다. onClick={remove(3)}처럼 감싸지 않고 쓰면 앞에서 본 즉시 실행 함정에 그대로 빠진다.


이벤트 객체는 첫 번째 인자로 온다. 핸들러에 매개변수를 하나 두면, React가 이벤트 정보를 담은 객체를 넣어준다. 관례로 e라고 이름 붙인다.


function Input() {
function handleChange(e) {
console.log(e.target.value);
}
return <input onChange={handleChange} />;
}


입력창에 글자를 칠 때마다 handleChange가 불리고, e.target은 그 입력창 요소를, e.target.value는 지금 입력된 값을 가리킨다. 이 값을 useState의 setter에 넘기면 앞 편에서 본 제어 입력창이 된다. React가 넘겨주는 이 e는 브라우저 원본 이벤트를 감싼 합성 이벤트(SyntheticEvent)라, 브라우저가 달라도 같은 방식으로 다룰 수 있다.


폼 제출은 기본 동작부터 막는다. 입력 폼을 만들면 반드시 마주치는 문제다. form 안의 버튼을 누르면 브라우저가 페이지를 통째로 새로고침하려 든다.


function SearchForm() {
function handleSubmit(e) {
e.preventDefault();
console.log("검색 실행");
}
return (
<form onSubmit={handleSubmit}>
<input />
<button>검색</button>
</form>
);
}


e.preventDefault()가 없으면 제출 순간 화면이 깜빡이며 새로고침되고, 방금 콘솔에 찍으려던 로직은 날아간다. 이 한 줄이 브라우저의 기본 제출 동작을 막아, 새로고침 없이 내 코드로 처리하게 해준다. 나는 이걸 깜빡하고 "왜 자꾸 화면이 리셋되지" 하며 한참을 헤맨 적이 있다. 폼을 만들면 onSubmitpreventDefault는 세트라고 생각하는 게 안전하다.


핸들러는 붙여 쓰든 떼어 쓰든 취향이다. 지금까지는 함수를 따로 정의해 이름으로 넘겼지만, 짧은 로직이면 onClick 자리에 화살표 함수를 바로 적어도 된다.


<button onClick={() => setOpen(true)}>열기</button>


한두 줄짜리 간단한 동작이면 이렇게 인라인으로 쓰는 게 읽기 편하다. 반대로 로직이 길어지거나 여러 군데서 같은 처리를 재사용해야 하면, 함수로 따로 빼서 이름으로 넘기는 편이 낫다. 정답이 정해진 건 아니고, 그 자리에서 한 번만 쓰고 짧으면 인라인, 길거나 재사용하면 분리라는 감으로 나누면 된다.


클릭은 부모로 번져 올라간다. 자식 요소에서 일어난 클릭은 부모로도 전달된다. 이걸 이벤트 버블링(event bubbling)이라고 하는데, 모르고 있으면 이상한 동작에 당황한다. 카드 전체에 클릭 핸들러가 있고, 그 안에 삭제 버튼이 따로 있는 상황을 보자.


<div onClick={openCard}>
<p>카드 내용</p>
<button onClick={deleteCard}>삭제</button>
</div>


삭제 버튼만 누르고 싶었는데, 버튼 클릭이 부모 div로 번져 올라가 openCard까지 같이 실행된다. 삭제하려다 카드가 열리는 황당한 상황이다. 막으려면 자식 핸들러에서 전파를 끊는다.


function deleteCard(e) {
e.stopPropagation();
// 실제 삭제 로직
}


e.stopPropagation()은 이벤트가 위로 번지는 걸 여기서 멈춘다. 이제 삭제 버튼을 눌러도 openCard는 안 불린다. 앞의 preventDefault가 브라우저 기본 동작을 막는 거라면, 이건 부모로의 전파를 막는 거라 역할이 다르다. 이름이 비슷해 헷갈리기 쉬우니 용도를 갈라서 기억해 두는 게 좋다.


키보드 입력도 같은 방식으로 잡는다. 클릭 말고 키 입력을 다뤄야 할 때도 원리는 똑같다. 검색창에서 엔터를 누르면 검색이 실행되게 하려면 onKeyDown을 쓴다.


function Search() {
function handleKey(e) {
if (e.key === "Enter") {
console.log("검색 실행");
}
}
return <input onKeyDown={handleKey} />;
}


e.key에는 방금 누른 키 이름이 문자열로 들어온다. 엔터면 "Enter", 방향키면 "ArrowUp" 같은 식이다. 그래서 어떤 키인지 e.key로 걸러 원하는 키에만 반응하게 만든다. 이벤트 종류만 onClick에서 onKeyDown으로 바뀌었을 뿐, 함수를 넘기고 e를 받아 처리하는 흐름은 완전히 같다.


결국 이벤트는 함수를 언제 부르느냐의 문제다. React 이벤트에서 헷갈리는 대부분은 "지금 실행할까, 나중에 실행할까"의 혼동에서 온다. 이름만 넘기면 나중에(클릭할 때) 실행되고, 괄호를 붙이면 지금(렌더할 때) 실행된다. 인자가 필요하면 화살표로 감싸 실행을 미룬다. 이 감각 하나만 잡히면 onClick이든 onChangeonSubmit이든 다 같은 원리로 보인다. 그다음부터는 이벤트 이름 카멜케이스와 폼의 preventDefault만 안 잊으면 대부분 문제없이 굴러간다.