react 학습하기 - 탈출구
일부 컴포넌트는 react 외부 시스템을 제어하거나 동기화할 수 있다. 브라우저 API라든가, 비디오 플레이어를 재생하거나, 원격 서버에 연결하는 것 등 다양함.
이때 react의 외부로 나가서 외부 시스템에 연결하는 것을 탈출구라고 한다. 하지만 대부분의 앱 로직과 데이터 흐름은 이러한 기능에 의존하면 안 됨.
1️⃣ ref로 값 참조하기
컴포넌트가 일부 정보를 기억하고 싶지만, 해당 정보가 렌더링을 유발하지 않도록 하려면 ref를 사용하는 것은 좋은 선택이다.
📌 컴포넌트에 ref 추가하기
import {useRef} from 'react';
컴포넌트 내에서 useRef Hook을 호출하고 참조할 초깃값을 유일한 인자로 전달.
이때 useRef는 {current: initialValue} 일반 JS 객체를 반환한다.
ref.current 프로퍼티를 통해 해당 Ref의 current 값에 접근할 수 있다. react가 추적하지 않는 비밀주머니라고 생각하면 편함.
📌 ref와 state 차이
| ref | state |
|---|---|
| useRef(initialValue) 는 { current: initialValue }를 반환 | useState(initialValue)는 State 변수의 현재 값과 Setter 함수 [value, setValue]를 반환 |
| current 값을 바꿔도 리렌더링 하지 않음 | state 값을 바꾸면 리렌더링됨 |
| Mutable: 렌더링 프로세스 외부에서 current 값을 수정 및 업데이트할 수 있음 | Immutable: State를 수정하기 위해서는 State 설정 함수를 반드시 사용하여 리렌더링 대기열에 넣어야 함 |
| 렌더링 중에는 current 값을 읽거나 쓰면 안 됨 | 언제든지 State를 읽을 수 있음. 그러나 각 렌더링마다 변경되지 않는 자체적인 State의 snapshot이 있어야 함 |
렌더링 중에 ref.current를 출력하면 신뢰할 수 없는 코드가 나올 수 있기 때문에 주의해야 함.
useRef는 내부적으로 어떻게 동작할까?원칙적으로 useRef는 useState 위에 구현할 수 있음
function useRef(initialValue) { const [ref, unused] = useState({ current: initialValue }); return ref; }첫 번째 렌더링 중에
useRef은{ current: initialValue }를 반환State Setter를 사용하지 않음.
useRef는 항상 동일한 객체를 반환해야 하므로 필요하지 않음!
📌 ref를 사용할 시기
- Timeout ID를 저장
- 다음 페이지에서 다루는 DOM 엘리먼트 저장 및 조작
- JSX를 계산하는 데 필요하지 않은 다른 객체 저장
가장 일반적인 사례는 DOM 엘리먼트에 접근하는 것. 입력창에 초점을 맞추려는 경우 유용.
사용할 때도 탈출구이기 때문에 렌더링 중에 ref.current를 읽거나 쓰지 말자
2️⃣ ref로 DOM 조작하기
react는 렌더링 결과물에 맞춰 DOM 변경을 자동으로 처리하기 때문에 딱히 조작할 필요는 없음. 하지만 가끔 접근해야 할 때가 있음. 이럴 때 필요함.
📌 ref로 노드 가져오기
import {useRef} from 'react';
function focusInput() {
const myRef = useRef(null);
<div ref={myRef} />
}
이렇게 설정하면, 이 DOM 노드를 이벤트 핸들러에서 접근하거나 노드에 정의된 브라우저 API를 사용할 수 있다.
myRef.current.scrollIntoView();
ref 콜백을 사용하여 ref 리스트 관리하기
동적인 ref를 관리할 때 아래와 같은 코드는 사용할 수 없다. hook은 컴포넌트 최상단에서만 호출되어야 하기 때문이다.
<ul> {items.map((item) => { // 작동하지 않습니다! const ref = useRef(null); return <li ref={ref} />; })} </ul>이럴 때 사용하는 것이
ref 콜백이다.ref속성에 함수를 전달하여 설정 시점에서 콜백을 호출하고, 해제할 시점에는 정리 함수를 호출하면 된다. 이렇게 하면 배열이나 Map을 관리하고 인덱스 또는 ID를 사용하여 동적으로 관리할 수 있다.<li key={cat.id} ref={node => { const map = getMap(); // Add to the Map map.set(cat, node); return () => { // Remove from the Map map.delete(cat); }; }}>
Strict Mode를 사용하면 ref 콜백이 두 번 호출됨. 이때 return이 없어서 메모리 누수가 일어나거나 발생되는 실수를 조기에 발견할 수 있음. 그래서 한 번 추가했다가 제거했다가 다시 추가되는 건 버그가 아니라 react가 원하는 결과임
📌 다른 컴포넌트의 DOM 노드 접근하기
부모 컴포넌트에서 자식 컴포넌트로 ref를 prop처럼 전달할 수 있음
이때, 부모 컴포넌트에서 DOM 노드의 CSS 스타일을 직접 변경하는 등의 예상치 못한 작업을 실행할 수 있음. 따라서 몇몇 상황에서는 노출된 기능을 제한하고 싶을 때가 있는데, react가 제공하는 useImperativeHandle를 사용하면 가능하다.
📌 react가 ref를 부여할 때
react는 렌더링과 커밋 단계로 나눌 수 있다. 이때 렌더링 단계에서 ref에 접근하는 것을 원하지 않음.
따라서, 커밋 단계에서 ref.current을 설정함. 대부분 이벤트 핸들러에서 일어남.
flushSync
react가 DOM의 변경을 동기적으로 수행할 수 있게 해주는 react-dom 패키지의 함수
3️⃣ effect로 동기화하기
일부 컴포넌트에서는 외부 시스템과 동기화해야 할 수 있음. 이때 Effect를 사용하면 됨.
Effect란 무엇이고 이벤트와 차이점컴포넌트 내부에는 2가지 로직이 있음
- 렌더링 코드를 주관하는 로직 : 최상단에 위치하며, 결과적으로 JSX를 반환. 순수해야 함
- 이벤트 핸들러 : 컴포넌트 내부의 중첩 함수. 사이드 이펙트를 포함.
이외에도 특정 이벤트가 없는데도 순수한 계산도 아니고 사이드 이펙트도 아닌(ex: 서버 접속) 경우가 있음.
Effect는 렌더링 자체에 의해 발생하는 부수 효과를 특정하는 것으로, 렌더링에 의해 직접 발생.
Effect는 커밋이 끝난 후에 화면 업데이트가 이루어지고 나서 실행. 보통 브라우저 API, 서드 파티 위젯, 네트워크 등이 포함됨.
📌 effect 작성하는 법
1. effect 선언
import 후에 최상위 레벨에서 호출해야 함.
import { useEffect } from 'react';
useEffect(() => {
}, []);
컴포넌트가 렌더링될 때마다 react는 화면을 업데이트한 다음 useEffect를 호출함. 이 말은 useEffect가 화면 렌더링이 반영될 때까지 코드 실행을 지연시킨다는 뜻.
2. effect 의존성 지정하기
기본적으로 Effect는 모든 렌더링 후에 실행됨. 이는 원하는 동작이 아닐 수 있음. 이때 useEffect의 두 번째 인자로 배열을 전달하여 의존성을 지정할 수 있음.
단, 의존성을 "선택"할 수는 없음.
의존성 배열이 없는 경우와 빈
[]배열의 차이의존성 배열이 없는 경우 : 컴포넌트가 렌더링 될 때마다
Effect가 실행됨 빈[]배열 : 컴포넌트가 마운트 될 때만Effect가 실행됨
왜
ref는 의존성 배열에서 생략해도 되는 걸까?
ref객체가 **안정된 식별성(stable identity)**를 가지기 때문임. react는 동일한ref객체를 얻을 수 있음을 보장하기 때문에 포함하든 포함하지 않든 상관 없다. 때때로,useState로 반환되는set함수도 안정된 식별성을 가지기 때문에, ESLint에서 의존성 검사를 생략해도 오류가 발생하지 않음. 만약 안정된 식별성을 가지지 않은(부모 컴포넌트에서 전달)되었다면 의존성 배열에 명시하여야만 한다.
3. 필요하다면 클린업을 추가
컴포넌트가 마운트 해제될 때 연결을 끊지 않으면 서버가 계속 열려있을 수 있다. 이때 useEffect의 반환값으로 클린업 함수를 반환할 수 있다.
react는 다시 실행되기 전마다 클린업 함수를 호출한다.
Effect에서 데이터를 가져오는 좋은 대안은 무엇인가?
Effect 안에서
fetch호출을 작성하는 것은 데이터를 가져오는 인기 있는 방법임. 하지만, 이는 매우 수동적인 접근 방식이며 중요한 단점이 있음
- effect는 서버에서 실행되지 않으므로, 서버 렌더링에 불리하다
- effect 안에서 직접 가져오면 "네트워크 폭포"를 쉽게 만들 수 있음
- effect 안에서 직접 가져오는 것은 prefetch랑 cache를 사용할 수 없음
- 버그 잡으려면 많은 보일러플레이트 코드 필요
따라서 다음 방식을 권장
- 프레임워크가 제공하는 내장 데이터 패칭 기능 사용
- 클라이언트 사이드 캐시를 사용하거나 직접 구축(TanStack Query, React Router)
4️⃣ effect가 필요하지 않은 경우
1. 렌더링을 위해 데이터를 변환
기존 prop나 state를 사용하여 JSX를 계산하는 것은 effect가 필요하지 않음.
또한, 비용이 많이 드는 계산의 경우 hook으로 래핑해서 캐싱하면 된다.
react 컴파일러의 등장
비용이 많이 들어가는 것들에 대하여 일일히 useMemo, useCallback를 사용하여 최적화 해왔음
하지만, react 컴파일러를 사용하면 알아서 자동으로 최적화해줌
대표적으로 캐싱하는 훅에는 useMemo, useCallback이 있다.
계산이 비싼지 아는 법
일반적으로 수천 개의 객체를 반복해서 만들지 않는 이상 웬만해서는 비용이 많이 들지 않음. 좀 더 확신을 얻고 싶다면 소요된 시간 측정이 최고
전체적인 시간이 1ms 이상으로 합산되면 메모이제이션하는 것이 좋음 CPU 스로틀링 옵션을 사용하여 인위적으로 속도 저하 테스트도 좋음
prop가 변경 될 때 일부 state를 조정할 때는 effect 보다는 직접 렌더링 중에 조정하는 것이 좋다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
useEffect(() => {
setSelection(null);
}, [items]);
}
// 이상적이지 못함 useEffect는 렌더링 이후에 작동하기 때문
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
}
// 하지만, 이 패턴이 더 효율적이지만 디버깅이 어려움 따라서
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const selection = items.find(item => item.id === selectedId) ?? null;
}
2. 사용자 이벤트 처리
사용자의 이벤트에 대하여 알림 등을 표시하고 싶을 때 Effect에 배치하고 싶을 수 있음. 하지만 이는 버그를 유발할 가능성이 매우 큼.
또한, 사용자 이벤트에 대하여 POST 요청을 보낼 때도 마찬가지, Effect에 배치하는 것보다 이벤트 핸들러에 있는 것이 좋음.
3. 연쇄 계산
다른 state를 조정하는 것을 Effect를 활용하여 체이닝하고 싶을 때가 있음.
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 피하세요: 서로를 트리거하기 위해서만 state를 조정하는 Effect 체인
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
다음은 매우 비효율적인 코드임. 최악의 경우 3번이나 리렌더링을 함. 두 번째는 체인을 이루기 때문에 융통성이 없고 취약한 경우가 많음. 따라서, 아래를 추천한다.
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 렌더링 중에 가능한 것을 계산합니다.
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ 이벤트 핸들러에서 다음 state를 모두 계산합니다.
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
// ...
4. 데이터 가져오기
데이터 가져오기를 이벤트 핸들러로 옮길 필요는 없음. 하지만 의존성 배열로 버그 날 수 있음. query에서 어떤 순서로 도착할지 보장할 수 없기 때문에 정리 함수를 추가해야 함.
하지만, 네트워크 워터폴, 서버에서 데이터 가져오는 법, 응답 캐싱 등 고려할 것이 많다. 이러한 문제는 모든 라이브러리가 겪는 문제이기 때문에 최신 프레임워크를 사용하자.
사용하기 싫으면 사용자 정의 hook으로 추출하는 것을 고려해라.
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
5️⃣ effect에서 이벤트 분리하기
이벤트 핸들러와 effect 중에 선택하기
- 이벤트 핸들러 : 특정 상호작용에 대한 응답(수동)
- effect : 동기화가 필요할 때마다 실행(자동)
그런데 비반응형 로직과 반응형 로직을 섞는 상황이 나옴. 예시를 보자.
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('연결됨!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ 모든 의존성 선언됨
// ...
props로 theme를 받아서 호출하므로 의존성을 선언하여야한다. 그러면 전환 할때마다 다시 연결됨. 좋은 코드는 아님.
📌 effect 이벤트 선언하기
useEffectEvent는 effect에서 반응형이 아닌 로직을 추출하는 특수한 hook이다.
여기서 반응형이 아닌 로직을 Effect Event라고 한다. effect 로직의 일부지만, 반응형이 아니며 항상 props와 state의 최근 값을 바라본다.
따라서, 위의 예제를
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언됨
// ...
이때, 의존성 린터를 무시하지는 말자.
인수 없이 함수를 호출하고 내부에서 밖의 변수를 읽을 수 있을까?
가능은 하다. 하지만
Effect Event에 명시적으로 전달하는 것도 좋은 방법이다. 의존성에서 실수로 제거하는 일이 생길 수 있기 때문이다. 특히 비동기 로직이 있는 경우에 특히 중요해진다. 혹시 변수가 변경되었을 수도 있기 때문이다.
📌 effect 이벤트의 한계
Effect Event는 활용 방법이 매우 제한적이다.
- effect 내부에서만 호출
- 절대로 다른 컴포넌트나 hook으로 전달하지 말자
Effect Event는 항상 사용되는 effect의 바로 근처에 선언하자.
6️⃣ effect의 의존성 제거하기
의존성이 코드와 일치하지 않으면 버그가 발생할 위험이 매우 높다. 따라서 린터를 억제하지 말자.
대부분 effect를 잘못 사용해서 이런 경우가 많은데 진짜 의존해야 하는지 살펴봐야 한다.
- 이 코드가 effect가 되어야 하는가?(ex: 버튼 누르고 post 요청 보내는 것)
- effect가 관련 없는 여러 가지 작업을 수행하는가?
- 다음 state 계산을 위해 어떤 state를 읽고 있는가?(업데이터 함수 활용)
Effect Event를 사용할 수 있는가?
7️⃣ 커스텀 hook으로 로직 재사용하기
중복된 로직을 커스텀 hook으로 추출하는 것은 좋은 선택이다. 하지만, 몇 가지 룰이 있다.
- 반드시
use로 시작해야한다. - state 그 자체를 공유하는게 아닌 state 저장 로직을 공유하자.
커스텀 hook은 명확해야 한다
커스텀 hook은 이름 고르는 것부터 시작한다. 만약 명확한 이름이 없으면 그건 컴포넌트 로직의 일부분에 너무 결합돼 있다는 뜻이다. 이상적인 커스텀 hook은 무슨 일을 하고 무엇을 props로 받고, 무엇을 반환하는지 아주 명확하다.(ex:
useData(url),useChatRoom(options)) 외부 시스템과 동기화할 때는 기술적이고 특정해야 하는 이름이 좋다. 생명 주기 hook은 이름을 사용하지 말자.

