JAN's History

React 학습하기 - 탈출구 본문

React

React 학습하기 - 탈출구

JANNNNNN 2025. 8. 8. 17:15

https://ko.react.dev/learn/escape-hatches 를 보고 정리한 블로그입니다.

1. Ref로 값 참조하기

컴포넌트 ref를 어떻게 추가할까?

useRef 는 React에서 제공하는 훅이며, 다음처럼 사용한다.

import { useRef } from "react";
 
const ref = useRef(initialValue);
  • initialValue는 ref.current의 초기값
  • ref 객체는 { current: initialValue } 형태이며, ref.current를 통해 값을 읽고 쓸 수 있다.

ref의 값이 어떻게 업데이트 될까?

ref.current 는 일반 객체처럼 직접 수정할 수 있다.

ref.current = 5;
  • 변경 시 리렌더링 X
  • 렌더 시 값 유지

useState vs useRef 

 

값 변경 시 리렌더 안 됨
렌더링 간 값 유지
읽기/쓰기 방식 setter 필요 .current 직접 접근
주요 용도 UI 갱신, 사용자 입력 반영 DOM 조작, interval ID, 이전 값 저장 등

언제 useRef 를 써야 할까?

  • DOM 요소 직접 조작
  • 이전 값 기억
  • setInterval, setTimeout ID 저장
  • 리렌더링 없이 참조만 하고 싶은 값 저장
//클릭 수 세기 (UI표현 X)
//화면은 바뀌지 않지만, 값은 계속 누적된다.
function Counter() {
  const countRef = useRef(0);
  function handleClick() {
    countRef.current += 1;
    console.log(`Clicked ${countRef.current} times`);
  }
  return <button onClick={handleClick}>Click me</button>;
}

정리

  • 리렌더링 필요 없는 값 -> useRef
  • 렌더링 필요 -> useState
  • ref.current 는 즉시 읽고 쓰기 가능하지만, UI에는 영향 없음
  • 탈출구 의미 : ref는 React의 감시망을 피해서 몰래 데이터를 수정할 수 있기 때문에 자주 사용하면 좋지 않다. (= 상태(state)가 바뀌면 → React가 알아차림 → DOM 업데이트를 우회할 수 있음)

2. Ref로 DOM 조작하기

ref 어트리뷰트로 React가 관리하는 DOM 노드에 접근하는 법

  • ref를 DOM에 넣으면 ref.current에 해당 DOM이 저장된다.
  • 이벤트 핸들러나 effect 내에 DOM API를 호출할 수 있다.
  • 초기에는 ref.current === null
  • React가 렌더 후 자동으로 DOM을 넣어줌
const inputRef = useRef(null);
 
<input ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>Focus</button>

 ref JSX 어트리뷰트와 useRef Hook의 관련성

  • useRef()는 { current : null } 형태의 객체를 반환
  • JSX에서 ref={myRef}로 넘기면 React가 알아서 DOM과 연결

다른 컴포넌트의 DOM 노드에 접근하는 방법

<input ref={myRef} /> // 가능
<MyInput ref={myRef} /> // 기본적으로 안 됨
  • 직접 만든 컴포넌트는 기본적으로 ref를 받지 않기 때문에 전달하려면 forwardRef()를 사용해야한다.
  • <MyInput />처럼 사용자 정의 컴포넌트에는 ref를 자동으로 넘기지 않기 때문에, 접근이 필요할 경우 forwardRef로 명시적으로 허용해줘야한다. 
const MyInput = React.forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});
  • 첫 번째 인자는 일반 props, 두 번재 인자 ref는 부모가 전달한 ref 객체

React가 관리하는 DOM을 수정해도 안전한 경우

ref.current.remove(); // X 직접 DOM을 제거하면 충돌 가능
 
ref.current.scrollIntoView();   // O 비 파괴적
ref.current.focus();            // O 비 파괴적
  • React가 갱신하지 않는 영역이라면 DOM을 조작해도 괜찮다.

정리

  • Ref는 보통 DOM을 참조하기 위해 사용된다.
    • 많은 경우 ref는 포커싱, 스크롤, DOM 요소 크기 혹은 위치 측정 등 비 파괴적인 행동을 위해 사용된다.
  • React가 관리하는 DOM 노드를 바꾸면 안된다. (remove)
  • React에 <div ref={myRef}>와 같이 작성하면 myRef.current에 값이 들어간다.
  • 컴포넌트는 기본적으로 DOM 노드를 노출하지 않기 때문에 forwardRef의 두번째 인자에 ref를 전달하는 것으로 선택적으로 노출할 수 있다.

3. Effect로 동기화하기

Effect가 무엇일까

  • Effect는 React 컴포넌트의 렌더링 결과를 기반으로 하는 부수효과를 처리하기 위한 훅이다.
  • useEffect는 렌더링이 끝난 후 DOM이 그려진 뒤에 실행된다.
  • API 호출, 브라우저 API 사용 등에 사용된다.
  • 이벤트 핸들러는 사용자 동작에 의해 실행되는 반면, Effect는 렌더링 자체에 의해 실행된다.

Effect가 이벤트와 다른점

실행 시점 사용자의 상호작용(클릭 등) 시 렌더링 직후 (커밋 이후)
목적 상태 변경, 네트워크 요청 등 직접 처리 외부 시스템과의 동기화
예시 onClick={() => setState(x)} useEffect(() => {...}, [x])

Effect 작성법

//1. Effect 선언
useEffect(() => {
  // 실행할 코드
});
 
 
//2. 의존성 배열 지정
// 언제 다시 실행할지 지정
// [] : 최초 1회 (마운트 시)
// [a, b] : a 또는 b가 바뀔 때만 실행
// 생략 시 : 모든 렌더링 마다 실행
 
//3. 클린업 함수
//반환된 함수는 다음 Effect 실행 전이나 컴포넌트 언마운트 시 호출됨
useEffect(() => {
  const timer = setTimeout(() => console.log("hi"), 1000);
  return () => clearTimeout(timer); // 정리
}, []);

불필요한 Effect 재실행 건너뛰기

  • useEffect(() => {...}, [x]) 의 두 번째 인자로 의존성 배열을 반드시 선언해야 React가 변경된 값만 감지하고 불필요한 실행을 피할 수 있다.
  • 의존성 배열 생략 시 매 렌더링마다 실행됨 -> 성능 이슈 및 예상치 못한 동작 유발 가능

개발 중 Effect가 두 번 실행되는 이유 (strick Mode)

  • React의 Strict Mode Effect가 제대로 cleanup되는지 테스트하기 위해 개발 환경에서 컴포넌트를 두 번 마운트한다.
  • 이로 인해 useEffect()는 두 번 실행되며, 버그가 있으면 드러나게 된다.
  • 배포 환경에선 발생하지 않는다!
외부 API와 동기화 useEffect(() => fetchData(), [dependency])
이벤트 등록 addEventListener, 그리고 cleanup으로 removeEventListener
타이머, 애니메이션 setTimeout, requestAnimationFrame 등, cleanup 필요

정리

  • Effect는 렌더링 자체에 의해 발생
  • Effect를 사용하면 외부 시스템 (네트워크, 타사 API)과 동기화가 가능하다.
  • 기본적으로 Effect는 모든 렌더링 후에 실행
  • 의존성 배열은 "선택"할 수 없고, Effect 내부 코드에 의해 결정된다.
  • 빈 의존성 배열 []은 "마운팅"을 의미한다.
  • Strick Mode에서 React는 컴포넌트를 두번 마운트한다 (개발 환경에서만)
  • Effect가 다시 마운트로 인해 중단된 경우 클린업 함수를 구현해야한다.
  • React는 Effect가 다음에 실행되기 전 정리 함수를 호출하며, 마운트 해제 중에도 호출한다.

4. Effect가 필요하지 않을 수도 있다.

컴포넌트에서 불필요한 Effect를 제거하는 이유와 방법

  • Effect는 외부 시스템과 동기화할 때만 필요하다. (API호출, 브라우저 API조작..)
  • 단순한 데이터 변환이나 렌더링 로직, 이벤트 응답은 Effect 없이 처리해야 더 간결하고 빠르고, 예측 가능한 코드가 된다.
  • 불필요한 Effect는 불필요한 리렌더링, 복잡성 증가, 버그를 초래한다.

Effect 없이 값비싼 계산을 캐싱하는 방법

const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  • 렌더링 중 계산 비용이 비싸면 useMemo로 감싸서 캐싱
  • useMemo는 의존성이 변경될 때만 계산 수행 -> 성능 최적화 가능

Effect 없이 컴포넌트 state 초기화 및 조정하는 방법

  • props가 변경될 때 state를 초기화하려고 Effect를 쓰는 것은 비효율적이다.
  • 대신 key props를 이용해 컴포넌트를 다시 마운트하게 하거나,
  • 또는 렌더링 도중 조건을 보고 state를 직접 초기화하는 것이 좋다.
//key로 강제 마운트
<Profile userId={userId} key={userId} />
 
 
//렌더 중 상태 조정
if (items !== prevItems) {
  setPrevItems(items);
  setSelection(null);
}

이벤트 핸들러 간 로직을 공유하는 방법

  • 이벤트로 발생하는 동작은 Effect가 아니라 이벤트 핸들러 내부에서 처리해야한다.
  • 공통 로직은 핸들러 내부에서 공통 함수로 추출해 재사용
function buyProduct() {
  addToCart(product);
  showNotification(`Added ${product.name}`);
}
function handleBuyClick() {
  buyProduct();
}

이벤트 핸들러로 이동해야 하는 로직

  • 사용자의 직접적인 상호작용(클릭 등)에 의한 동작은 무조건 핸들러 안에서 처리해야한다.
  • Effect에서 처리할 경우 예기치 않는 중복 실행이나 버그 발생 가능
//잘못된 방식
useEffect(() => {
  if (product.isInCart) {
    showNotification(...);
  }
}, [product]);
 
 
//올바른 방식
function handleClick() {
  addToCart(product);
  showNotification(...);
}

부모 컴포넌트에 변경 사항을 알리는 방법

  • 자식 컴포넌트의 state가 바뀔 때 Effect를 통해 부모에게 알리는것은 좋지 않다.
  • 대신, 자식의 이벤트 핸들러에서 즉시 부모 콜백(onChange 등)을 호출하도록 해야한다.
function updateToggle(nextState) {
  setIsOn(nextState);
  onChange(nextState);
}

데이터 가져오기

useEffect(() => {
    // XXX 정리 로직 없이 가져오기
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);
 
useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
 
    return () => {
     ignore = true;
    };
  }, [query, page]);

정리

  • 렌더링 중 계산을 할 수 있다면 Effect가 필요하지 않다.
  • 비용이 많이 드는 계산을 캐싱하려면 useEffect 대신 useMemo를 추가하자
  • 전체 컴포넌트 트리의 state를 초기화하려면 다른 key를 전달하자
  • 컴포넌트가 표시되어 실행되는 코드는 Effect에 있어야 하고 나머지는 이벤트에 있어야 한다.
  • 여러 state를 업데이트 해야하는 경우 단일 이벤트 중에 수행하는 것이 좋다.
  • 다른 컴포넌트의 state를 동기화할 때마다 state를 끌어올리기를 고려하자
  • Effect로 데이터를 가져올 수 있지만 경쟁 조건을 피하기 위해 정리를 구현해야한다.

5. 반응형 effects의 생명주기

effect의 생명주기가 컴포넌트의 생명주기와 다른 점

  • 컴포넌트: 마운트 -> 업데이트 -> 언마운트.
  • effect: 단순히 동기화 시작 중지(cleanup)만 한다.
  • 하나의 컴포넌트가 마운트된 상태에서도, props나 state가 바뀌면 effect는 여러 번 실행되고 정리될 수 있다.
  • effect는 컴포넌트의 생명주기를 따라가지 않고, 의존성 값의 변화에 따라 스스로 시작/중지된다.

각 effect를 개별적으로 생각하는 방법

  • 하나의 effect는 하나의 외부 동기화 프로세스를 담당해야한다.
  • ex) 채팅 서버 연결 effect 와 방문 로그 effect는 분리해야함 -> 서로 다른 목적이기 때문'
  • But 혼잡한 effect는 복잡성과 버그 발생을 높인다.
  • useEffect(() => { /* 연결 */ return () => { /* 해제 */ } }, [roomId])처럼 명확하게 시작과 정리를 정의.

effect를 다시 동기화해야하는 시기와 그 이유

  • effect의 의존성이 바뀌면 다시 실행되며 이전 effect는 cleanup된다
  • ex) roomId가 바뀌면 기존 채팅방과의 연결은 끊고 새 채팅방과 연결해야한다.
  • React는 의존성 배열의 값이 바뀐 경우에만 effect를 다시 실행한다.

effect의 의존성이 결정되는 방법

  • effect 내부에서 사용하는 모든 반응형 값(state, props, context, 렌더링 중 계산된 값)을 의존성 배열에 포함
  • 포함되지 않으면 버그 발생 및 React 린트 에러 발생 가능
  • useEffect(() => { log(roomId) }, [roomId])처럼 사용한 값은 무조건 넣어야 한다.

값이 유동적이라는 의미

  • 컴포넌트 내부에서 선언된 모든 값은 렌더링마다 달라질 수 있기 때문에 반응형임.
  • 렌더링 중 계산된 변수도 반응형 → 무조건 의존성으로 넣어야 함.
  • 예외: useState의 setState, useRef의 .current 등은 변하지 않는(안정된) 값이므로 제외 가능.

빈 의존성 배열이 의미하는 것

  • []: 이 effect는 한 번만 실행됨 (마운트 시).
  • 의존성이 없는 경우 -> 외부에서 불변의 값을 참조하고 있을 때만 사용.
  • 반응형 값을 사용하고 있다면 절대 []로 두면 안 됨 -> 린터 에러 + 동기화 실패 가능.

React가 린터로 의존성이 올바른지 확인하는 방법

  • 의존성 루프 등의 이유로 빠뜨리는 건 바람직하지 않으며, 반드시 코드 구조 자체를 개선해야 함.

린터에 동의하지 않을 때 해야 할 일

  • // eslint-disable-next-line로 무시 X
  • 해결법:
    • 값을 컴포넌트 외부로 빼기 (정적이면 종속성 아님)
    • effect를 쪼개서 독립적인 역할만 수행하게 만들기
    • 이벤트 핸들러나 memoization(useCallback, useMemo)으로 구조 변경

정리

  • 컴포넌트는 마운트, 업데이트, 언마운트할 수 있다.
  • 각 effect는 주변 컴포너트와 다른 별도의 생명주기를 가진다.
  • 각 effect는 시작 및 중지할 수 있는 별도의 동기화 프로세스를 지닌다.
  • effect는 컴포넌트의 관점(마운트, 업데이트 또는 마운트 해제 방법)이 아닌 개별 effect의 관점(동기화 시작 및 중지 방법)에서 생각하자.
  • 컴포넌트 본문 내부에 선언된 값은 “반응형”이다.
  • 반응형 값은 시간이 지남에 따라 변경될 수 있으므로 effect를 다시 동기화해야 합니다
  • 린터에 의해 플래그가 지정된 모든 오류는 합법적인 오류이다. 규칙을 위반하지 않도록 코드를 수정할 방법은 항상 있다.

6. Effect에서 이벤트 분리하기

이벤트 핸들러와 Effect 중에 선택하는 방법

 

사용자가 클릭 등 직접 상호작용한 경우 이벤트 핸들러 특정 시점에 명시적 실행되어야 함
컴포넌트가 렌더링된 결과에 따라 자동 동작해야 하는 경우 Effect 특정 상호작용 없이도 상태와 동기화 유지가 필요
예: 메시지 전송 -> 이벤트 핸들러, 채팅 서버 연결 -> Effect
Effect는 반응형이고 이벤트 핸들러는 아닌 이유

 

이벤트 핸들러 비반응형 값이 변해도 재실행되지 않음. 사용자의 행위로만 실행
Effect 반응형 의존성 값이 바뀌면 자동으로 재실행

 

useEffect(() => { connect(roomId) }, [roomId])처럼 작성하면, roomId가 바뀔 때 자동으로 동작

Effect 코드의 일부만 반응형이 아니길 원한다면 해야 할 것

  • 문제: Effect내부에서 사용한 모든 반응형 값은 무조건 의존성 배열에 추가해야 함.
  • 하지만 특정 값(ex: theme)은 동기화가 아니라 단순 참조만 원할 때도 있음.
  • 해결책: 해당 로직을 useEffectEvent로 추출하면, 그 로직은 의존성 없이 최신 값만 사용함.

Effect 이벤트의 정의와 Effect에서 추출하는 방법

Effect 이벤트란?

  • useEffectEvent(callback)으로 정의
  • 항상 최신 props/state에 접근 가능
  • Effect 내부에서만 호출 가능
  • 이벤트 핸들러처럼 비반응형 동작으로 처리됨
const onConnected = useEffectEvent(() => {
  showNotification('연결됨!', theme); // theme은 최신 값
});
 
 
useEffect(() => {
  const conn = createConnection(serverUrl, roomId);
  conn.on('connected', () => {
    onConnected(); // Effect 이벤트 호출
  });
  conn.connect();
  return () => conn.disconnect();
}, [roomId]); // theme을 의존성에서 제외해도 됨

Effect 이벤트를 사용해 Effect에서 최근의 props와 state를 읽는 방법

  • 비동기 상황에서도 useEffectEvent를 통해 현재 최신값을 읽을 수 있음
  • 예시: 장바구니 item 수(numberOfItems)는 변해도 방문 로그는 기록하고 싶지 않다면:
const onVisit = useEffectEvent((url) => {
  logVisit(url, numberOfItems); // 항상 최신값 사용
});
useEffect(() => {
  onVisit(currentUrl); // url만 반응
}, [currentUrl]);

=> numberOfItems는 의존성 배열에 넣을 필요 없음

정리

  • 이벤트 핸들러는 특정 상호작용에 대한 응답으로 실행된다.
  • Effect는 동기화가 필요할 때마다 실행된다.
  • 이벤트 핸들러 내부 로직은 반응형이 아니다.
  • Effect 내부 로직은 반응형이다.
  • Effect의 비반응형 로직은 Effect 이벤트로 옮길 수 있다.
  • Effect의 이벤트는 Effect 내부에서만 호출할 수 있다.

7. Effect 의존성 제거하기

Effect 의존성 무한 루프를 수정하는 방법

  • Effect 안에서 사용한 모든 반응형 값(state, props 등)  의존성 배열에 포함해야 한다.
  • 포함하지 않으면 React 최신 값과 동기화하지 못해 버그 발생.
  • 포함한 값이 자주 바뀌면 무한 루프 발생 가능성 있다.
의존성을 제거하고자할 때 해야 할 일
  • 단순히 배열에서 제거하면 안된다. 대신,  값이 반응형이 아님을 “증명”해야 한다.
    • 예: props -> 컴포넌트 외부 상수로 옮기기
    • 메시지 상태 -> setMessages(prev => [...prev, new]) 업데이트

Effect에 반응하지 않고 Effect에서 값을 읽는 방법

  • useEffectEvent() 사용해 Effect 이벤트 로직 분리 (아직 개발중인 함수)
    • 최신 상태값만 읽되, 의존성은 피할  있다.

객체와 함수 의존성을 피하는 방법과 이유

  • JS에서 객체/함수는 참조가 달라지면 항상  값으로 간주된다.
    • 불필요한 리렌더/재실행 발생
  • 해결법:
  •  

    컴포넌트 외부로 이동 값이 고정일  참조가 변하지 않으므로 안전
    Effect 내부로 이동 반응형 값을 포함할  외부에서  객체  만들기
    원시값 추출해서 의존성에 넣기 props 객체를 받을  불필요한 재실행 방지
     

의존성 린터를 억제하는 것이 위험한 이유와 대신   있는 

린터를 억제하기보다는

  • 이벤트 핸들러로 로직 이동
  • Effect 분리
  • 비반응형 로직을 Effect 이벤트로 추출

정리

  • Effect 반응형 값을 기준으로 다시 실행됨 -> 의존성 배열과 코드가 항상 일치해야 한다.
  • 의존성 배열이 “마음에  들면” -> 코드를 바꿔야 한다. 배열을 억지로 수정하지  
  • 목적별로 Effect 분할하고, 비반응 로직은 이벤트로 분리하면 재실행을 제어할  있다.

8. 커스텀 Hook으로 로직 재사용하기

커스텀 Hook이란 무엇인지

  • React의 Hook은 상태 관리나 사이드 이펙트를 함수형 컴포넌트에서 처리할 수 있게 해준다.
  • 이 중 커스텀 Hook은 useState, useEffect 같은 기본 Hook을 조합하여 공통 로직을 재사용 가능한 함수로 분리한 것이다.

컴포넌트 간 로직 재사용하는 방법

  • 여러 컴포넌트에서 동일한 기능(예: API 호출, 폼 상태 관리 등)을 사용할 때,
  • 코드를 복붙하지 않고 공통된 비즈니스 로직을 하나의 함수로 분리하면 유지보수성과 가독성이 높아진다.

나만의 커스텀 Hook 이름 짓기와 구조 잡기

  • 이름은 use로 시작해야 함 (React 규칙)
  • 반드시 다른 Hook을 내부에서 호출해야 의미가 있다.
  • 입력(props)과 출력(return)을 명확하게 설계
import { useState } from 'react';
export default function Form() {
  const [firstName, setFirstName] = useState('Mary');
  const [lastName, setLastName] = useState('Poppins');
  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }
  function handleLastNameChange(e) {
    setLastName(e.target.value);
  }
  return (
    <>
      <label>
        First name:
        <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name:
        <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p><b>Good morning, {firstName} {lastName}.</b></p>
    </>
  );
}

반복되는 로직은 아래와 같다.

  • state 변화가 존재(firstname와 lastname)
  • 변화를 다루는 함수 존재 (handleFirstNameChange와 handleLastNameChange)
  • 해당 입력에 대한 value와 onChange 속성을 지정하는 JSX 존재
//useFormInput.js
import { useState } from 'react';
export function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  function handleChange(e) {
    setValue(e.target.value);
  }
  const inputProps = {
    value: value,
    onChange: handleChange
  };
  return inputProps;
}
 
//App.js
import { useFormInput } from './useFormInput.js';
export default function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');
  return (
    <>
      <label>
        First name:
        <input {...firstNameProps} />
      </label>
      <label>
        Last name:
        <input {...lastNameProps} />
      </label>
      <p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
    </>
  );
}

언제 커스텀 Hook을 추출할까 ?

  • 여러 컴포넌트에서 동일한 로직이 반복될 때
  • 사이드 이펙트 처리 로직이 복잡할 때
  • 코드 분리가 필요할 때