리액트를 처음 배우면 이런 생각이 많이 든다.
- 버튼을 누르면 화면이 왜 바뀌지?
- 입력창에 글자를 치면 그 값은 어디에 저장되지?
state랑ref는 뭐가 다른 거지?- DOM을 직접 만지면 안 된다는데, 그럼 언제
ref를 써야 하지? useEffect는 왜 필요한 거지?
이번 글에서는 이 흐름을 이벤트 → State → Ref → Effect 순서로 아주 쉽게 정리해보려고 한다.
이 순서대로 이해하면 리액트가 왜 그렇게 동작하는지 훨씬 잘 보인다.
이벤트란?
이벤트는 사용자와의 상호작용으로 일어난 사건이다.
예를 들면 이런 것들이다.
- 버튼 클릭
- 입력 값 변경
- 마우스 호버
- 인풋 포커스
- 창 크기 조절
- 동영상 재생
즉, 사용자가 화면에서 어떤 행동을 했을 때 발생하는 것이 이벤트다.
쉽게 말하면
“사용자가 뭔가 했다” = 이벤트가 발생했다
이렇게 이해하면 된다.
이벤트 핸들러란?
이벤트 핸들러는 이벤트가 발생했을 때 실행되는 함수다.
예를 들어
- 버튼을 눌렀을 때 숫자를 증가시키기
- 입력값이 바뀌었을 때 state에 저장하기
- 클릭했을 때 알림창 띄우기
이런 동작들이 모두 이벤트 핸들러 안에서 처리된다.
한 줄로 정리하면 이렇게 볼 수 있다.
- 이벤트 = 사건
- 이벤트 핸들러 = 그 사건이 일어났을 때 실행되는 함수
예시 코드는 아래처럼 생긴다.
function App() {
function handleClick() {
alert("버튼이 클릭되었습니다!");
}
return <button onClick={handleClick}>클릭</button>;
}
여기서 onClick은 클릭 이벤트를 연결하는 부분이고, handleClick은 실제로 실행되는 함수다.
리액트에서 이벤트를 처리하는 과정
리액트에서 이벤트를 처리할 때는 보통 이런 순서로 진행된다.
1. 이벤트 핸들러 선언
먼저 함수를 만든다.
2. 이벤트 핸들러 작성
그 함수 안에 실행할 동작을 적는다.
3. 이벤트에 이벤트 핸들러 등록
버튼이나 input 같은 요소에 연결한다.
즉, 흐름은 결국 이렇다.
함수 만들기 → 동작 작성 → 요소에 연결
처음에는 어려워 보여도 실제로는 이 패턴이 계속 반복된다.
이벤트 전파란?
이벤트는 한 요소에서만 끝나는 것이 아니라 다른 요소로 전달될 수 있다.
이걸 이벤트 전파라고 한다.
이벤트 전파는 보통 세 단계로 생각할 수 있다.
1. 캡처링 단계
이벤트가 상위 요소에서 하위 요소로 내려가는 단계
2. 타깃 단계
이벤트가 실제로 발생한 요소에 도달하는 단계
3. 버블링 단계
이벤트가 다시 하위 요소에서 상위 요소로 올라가는 단계
초보자 입장에서는 일단 버블링을 먼저 이해하는 게 중요하다.
예를 들어 이런 구조가 있다고 해보자.
<div onClick={() => console.log("부모 클릭")}>
<button onClick={() => console.log("자식 클릭")}>버튼</button>
</div>
이때 버튼을 클릭하면 보통
- 자식 버튼 클릭
- 부모 div 클릭
이렇게 둘 다 실행될 수 있다.
이게 바로 버블링 때문이다.
부모까지 이벤트가 전달되지 않게 막고 싶다면 stopPropagation()을 사용한다.
function App() {
function handleParentClick() {
console.log("부모 클릭");
}
function handleChildClick(e) {
e.stopPropagation();
console.log("자식 클릭");
}
return (
<div onClick={handleParentClick}>
<button onClick={handleChildClick}>클릭</button>
</div>
);
}
State란?
State는 컴포넌트가 기억해야 할 것을 저장하는 공간이다.
리액트에서는 화면이 바뀌는 이유가 결국 state가 바뀌기 때문이다.
예를 들면 이런 값들이 state가 될 수 있다.
- 현재 카운터 숫자
- input에 입력한 값
- 모달이 열려 있는지 여부
- 체크박스 선택 상태
- 할 일 목록 데이터
즉,
화면을 바꾸는 데 필요한 데이터는 state에 저장한다
이렇게 이해하면 된다.
왜 state가 바뀌면 화면도 바뀔까?
리액트는 화면을 직접 하나하나 수정하는 방식보다,
현재 데이터에 맞는 화면을 다시 계산하는 방식으로 동작한다.
예를 들어 count가 0이면 화면에 0이 보이고,
버튼을 눌러 count가 1이 되면 화면도 1로 다시 보여준다.
즉, 핵심 흐름은 이렇다.
이벤트 발생 → 함수 실행 → state 변경 → 화면 변경
이게 리액트의 가장 중요한 동작 흐름이다.
Props와 State 차이
리액트를 공부하다 보면 props와 state를 자주 비교하게 된다.
Props
- 부모 컴포넌트가 자식 컴포넌트에게 전달하는 데이터
- 바깥에서 들어오는 값
- 자식 입장에서는 보통 읽기 전용
State
- 컴포넌트 내부에서 직접 관리하는 데이터
- 사용자 행동에 따라 바뀔 수 있는 값
- 값이 바뀌면 화면도 바뀜
쉽게 구분하면 이렇게 생각하면 된다.
- 밖에서 받은 값 → props
- 내가 직접 관리하면서 바꾸는 값 → state
카운터 앱으로 State 이해하기
State를 가장 쉽게 이해할 수 있는 대표 예제가 카운터 앱이다.
버튼을 누르면 숫자가 증가하거나 감소한다.
이건 단순해 보이지만 리액트 핵심 개념이 전부 들어 있다.
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
function increase() {
setCount(count + 1);
}
function decrease() {
setCount(count - 1);
}
return (
<div>
<h1>카운터: {count}</h1>
<button onClick={decrease}>-</button>
<button onClick={increase}>+</button>
</div>
);
}
이 코드에서 중요한 부분은 아래 두 개다.
count: 현재 상태값setCount: 상태를 바꾸는 함수
버튼을 누르면 이벤트 핸들러가 실행되고,
그 안에서 setCount()가 호출되며,
state가 바뀌고 화면이 다시 렌더링된다.
즉, 카운터 앱은 리액트의 핵심 흐름을 가장 간단하게 보여주는 예제다.
Ref란?
이제 ref를 보자.
Ref는 컴포넌트가 기억하고 싶은 정보를 저장하는 공간이다.
하지만 state와 가장 큰 차이가 있다.
바로
값을 바꿔도 리렌더링이 일어나지 않는다는 점이다.
Ref는 useRef로 만들고, current 속성을 통해 값을 읽거나 바꾼다.
import { useRef } from "react";
function App() {
const countRef = useRef(0);
function handleClick() {
countRef.current += 1;
console.log(countRef.current);
}
return <button onClick={handleClick}>클릭</button>;
}
여기서 버튼을 누르면 countRef.current 값은 증가한다.
하지만 이건 state가 아니기 때문에 화면이 다시 렌더링되지는 않는다.
즉, ref는 이렇게 생각하면 편하다.
- 값은 기억하고 싶다
- 그런데 화면은 다시 안 바뀌어도 된다
- 그럴 때 ref를 사용한다
Ref와 State 차이
이 둘은 헷갈리기 쉽다.
그래서 아래처럼 확실히 구분해두면 좋다.
State
useState로 선언- setter 함수로 값 변경
- 값이 바뀌면 리렌더링됨
- 화면에 보이는 데이터를 관리할 때 사용
Ref
useRef로 선언current로 값 접근- 값이 바뀌어도 리렌더링되지 않음
- 화면과 직접 관련 없는 값 저장이나 DOM 접근에 사용
한 줄 요약:
화면을 바꾸려면 state, 화면 재렌더링 없이 값만 기억하려면 ref
리액트에서 DOM을 직접 조작하지 않는 이유
리액트는 UI를 선언적으로 표현한다.
즉, state를 바꾸면 리액트가 화면을 알아서 다시 업데이트한다.
그래서 대부분의 경우는 DOM을 직접 조작할 필요가 없다.
예전 자바스크립트에서는 이런 식으로 많이 했다.
document.querySelector()innerText변경style직접 수정
하지만 리액트에서는 보통 그렇게 하지 않는다.
왜냐하면 state만 바꾸면 화면이 자동으로 다시 계산되기 때문이다.
즉,
- 숫자 바꾸고 싶다 → state 변경
- 텍스트 바꾸고 싶다 → state 변경
- UI 보이게/숨기고 싶다 → state 변경
이 흐름이 기본이다.
그럼 언제 ref로 DOM을 직접 만질까?
대부분은 state로 해결되지만, 어떤 경우에는 직접 DOM을 조작해야 한다.
대표적인 경우는 아래와 같다.
- input에 포커스를 주고 싶을 때
- 특정 위치로 스크롤 이동시키고 싶을 때
- 요소의 크기나 위치를 계산하고 싶을 때
이럴 때 ref를 사용해서 DOM 요소에 직접 접근한다.
예를 들어 input에 포커스를 주는 코드는 이렇게 쓸 수 있다.
import { useRef } from "react";
function App() {
const inputRef = useRef(null);
function focusInput() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} />
<button onClick={focusInput}>입력창 포커스</button>
</div>
);
}
여기서 inputRef.current는 실제 input DOM 요소를 가리킨다.
그래서 focus() 같은 DOM 메서드를 직접 사용할 수 있다.
Effect란?
이제 useEffect를 볼 차례다.
Effect는 렌더링이 완료된 후 실행되는 후처리 작업이다.
쉽게 말하면
화면이 그려진 뒤에 해야 하는 일
이라고 생각하면 된다.
대표적인 예시는 이런 것들이다.
- API 호출
- 이벤트 리스너 등록
- 타이머 시작
- 콘솔 출력
- 구독 시작 및 해제
즉, JSX 안에서 화면을 그리는 것과는 다른 종류의 작업을 effect에서 처리한다.
예를 들어 count가 바뀔 때마다 콘솔에 출력하고 싶다면 이렇게 할 수 있다.
import { useState, useEffect } from "react";
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Count: ${count}`);
}, [count]);
return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
여기서 [count]는 count가 바뀔 때 effect를 다시 실행하라는 뜻이다.
컴포넌트는 무엇으로 구성될까?
리액트 컴포넌트는 크게 아래처럼 나눠서 생각할 수 있다.
1. 렌더링 코드
화면에 무엇을 보여줄지 결정하는 부분
2. 이벤트 핸들러
사용자 행동이 발생했을 때 실행되는 부분
3. Effect
렌더링이 끝난 뒤 외부 환경과 상호작용하는 부분
이렇게 나누면 코드 구조도 더 잘 보이고,
“이건 화면 코드인지”, “이건 클릭 함수인지”, “이건 후처리인지” 구분하기 쉬워진다.
라이프사이클이란?
리액트 컴포넌트에도 라이프사이클이 있다.
쉽게 말하면 컴포넌트도
- 생기고
- 바뀌고
- 사라진다
이 흐름을 가진다.
보통 아래 세 가지로 이해하면 된다.
마운트
컴포넌트가 처음 화면에 나타날 때
업데이트
state나 props가 바뀌어서 다시 렌더링될 때
언마운트
컴포넌트가 화면에서 사라질 때
useEffect는 이런 라이프사이클 흐름과 함께 자주 사용된다.
예를 들어
- 처음 마운트됐을 때 데이터 요청
- 특정 값이 바뀔 때 다시 실행
- 언마운트될 때 정리 작업
이런 식으로 사용할 수 있다.
타이머 앱으로 Effect 이해하기
Effect를 가장 쉽게 이해할 수 있는 예시 중 하나가 타이머 앱이다.
타이머 앱에서는 보통 이런 기능이 들어간다.
- 시작 버튼을 누르면 시간이 줄어든다
- 일시정지 버튼을 누르면 잠시 멈춘다
- 다시 시작할 수 있다
- 시간이 0이 되면 초기화한다
이건 단순히 state만 쓰는 문제가 아니라,
시간이 흐르는 외부 동작을 함께 다뤄야 한다.
그래서 effect가 잘 어울린다.
예를 들어 타이머 개념을 아주 간단히 표현하면 이런 느낌이다.
import { useState, useEffect } from "react";
function App() {
const [time, setTime] = useState(10);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isRunning) return;
const timer = setInterval(() => {
setTime((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [isRunning]);
return (
<div>
<h1>{time}</h1>
<button onClick={() => setIsRunning(true)}>시작</button>
<button onClick={() => setIsRunning(false)}>일시정지</button>
</div>
);
}
이 코드에서 핵심은
isRunning이 true일 때 타이머 시작- effect 안에서
setInterval()실행 - 정리 함수에서
clearInterval()호출
즉, effect는 단순한 화면 출력이 아니라
외부 동작을 관리하는 도구라고 이해하면 된다.
실무 감각으로 정리하면
리액트를 배우면서 가장 중요한 건
“어떤 상황에 어떤 도구를 써야 하는지” 감을 잡는 것이다.
이벤트
사용자 행동을 감지할 때 사용
이벤트 핸들러
그 행동이 발생했을 때 실행할 함수를 만들 때 사용
state
값이 바뀌면 화면도 바뀌어야 할 때 사용
ref
값은 기억해야 하지만 화면을 다시 그릴 필요가 없을 때 사용
또는 DOM 요소에 직접 접근해야 할 때 사용
effect
렌더링 이후 외부 환경과 상호작용해야 할 때 사용
초보자가 가장 많이 헷갈리는 포인트
1. state와 ref를 자꾸 헷갈림
기준은 딱 하나다.
값이 바뀌었을 때 화면도 바뀌어야 하면 state
화면은 안 바뀌어도 되면 ref
2. DOM을 너무 빨리 직접 만지려고 함
리액트에서는 대부분 state로 해결한다.
정말 필요한 경우에만 ref로 DOM에 접근한다.
3. useEffect를 아무 데나 쓰려고 함
effect는 “렌더링 후 해야 하는 작업”에 쓰는 것이다.
단순 계산이나 일반 함수 실행은 굳이 effect에 넣지 않아도 된다.
한 번에 흐름 정리
지금까지 내용을 한 줄 흐름으로 연결하면 이렇게 볼 수 있다.
1. 사용자가 행동한다
예: 버튼 클릭, 입력, 마우스 이동
2. 이벤트가 발생한다
리액트가 그 행동을 감지한다
3. 이벤트 핸들러가 실행된다
등록해둔 함수가 실행된다
4. 필요한 값이 바뀐다
- 화면에 반영해야 하면 state
- 화면에 반영할 필요 없으면 ref
5. 화면이 다시 렌더링된다
state가 바뀐 경우 UI가 업데이트된다
6. 렌더링 후 effect가 실행된다
필요하면 API 호출, 타이머, 이벤트 리스너 같은 외부 작업을 처리한다
이 흐름이 보이면 리액트 공부가 훨씬 쉬워진다.
마무리
리액트를 처음 배울 때는 용어가 많아서 어렵게 느껴진다.
하지만 핵심은 생각보다 단순하다.
- 이벤트는 사용자의 행동이다
- 이벤트 핸들러는 그 행동에 반응하는 함수다
- state는 화면을 바꾸는 값이다
- ref는 화면 렌더링 없이 기억하거나 DOM에 접근할 때 쓴다
- effect는 렌더링이 끝난 뒤 외부 작업을 처리할 때 쓴다
즉, 리액트의 핵심 흐름은 결국 이것이다.
이벤트 발생 → 함수 실행 → state/ref 변경 → 필요 시 화면 갱신 → effect 실행
이 흐름을 제대로 이해하면
카운터 앱, 투두리스트, 마우스 트래커, 타이머 앱 같은 예제들이 왜 그렇게 만들어지는지도 자연스럽게 보이기 시작한다.
리액트를 공부하는 초보자라면 오늘은 딱 이 한 문장만 기억해도 좋다.
리액트는 화면을 직접 고치는 방식이 아니라, 상태를 바꿔서 화면을 다시 그리는 방식으로 동작한다.
같이 보면 좋은 키워드
- React event
- event handler
- state
- props
- ref
- useRef
- DOM 조작
- useEffect
- 라이프사이클
- 타이머 앱
- 카운터 앱
- 투두리스트