- Published on
리액트 공식문서 스터디 7-2 주차
- Authors

- Name
- junyeol kim
You Might Not Need an Effect
Effect는 React 패러다임(선언적 렌더링의 기본 규칙)에서 잠깐 벗어나, non-React 위젯, 네트워크, 브라우저 DOM 같은 외부 시스템과 컴포넌트를 동기화하기 위한 탈출구이다.
만약 이런 외부 시스템이 전혀 관여하지 않고, 단지 props나 state가 바뀔 때 그에 맞춰 컴포넌트의 state를 업데이트하고 싶은 것뿐이라면, 그런 경우에는 Effect를 쓸 필요가 없다.
이렇게 불필요한 Effect를 제거하면, 코드 흐름을 더 쉽게 따라갈 수 있고, 렌더 사이클이 줄어들어 더 빠르게 실행되며, 동기화 문제·의존성 배열 실수 같은 에러 발생 가능성도 줄어든다.
How to remove unnecessary Effects
Effects가 필요하지 않는 대표적인 두 가지 Case를 알아보자
Rendering을 위해 transform data 할 때 굳이 Effects를 쓸 필요가 없다.
- 불필요한 리렌더링을 유발할 수 있는 패턴이기 때문이다.
User events를 처리하는 데에는 Effect가 필요없다.
- Effect의 실행시점에 사용자의 행동을 정확하게 파악할 수 없기 때문이다.
그럼 언제 Effeects를 사용해야할까?
- jQuery 위젯, 브라우저 API, 서버 데이터 같은 외부 시스템과 React state를 동기화할 때 필요하고, 단순 렌더링용 데이터 변환이나 사용자 이벤트 처리에는 웬만하면 쓰지 말라는 것이다 🤔
Updating state based on props or state
어떤 state를 기반으로 다른 state를 업데이트하는 관점에서 Effect와 state를 불필요하게 사용하지 말고, 기존 props·state에서 계산 가능한 값은 렌더 단계에서 바로 계산하는 것이 더 효율적이다.
이렇게 하면 연쇄적인 업데이트로 인한 불필요한 리렌더링을 피할 수 있고, 코드가 더 단순해지며, 여러 state 값이 서로 안 맞아서 생기는 동기화 버그도 줄어든다.
Caching expensive calculations
기존 props 또는 state로 항상 계산 가능한 값은, 새 state + Effect로 동기화하지 말고 렌더 단계에서 바로 계산하는 것이 좋다.
그 계산이 expensive하다면, Effect가 아니라 useMemo 훅을 사용해 결과를 캐싱하는 것이 좋다.
useMemo 안에 넘기는 callback 함수는 렌더링 과정에서 실행되기 때문에, side effect 없는 pure calculation 이어야 한다.
React 19에서는 React Compiler가 이런 메모이제이션을 자동으로 해 주기 때문에, 원칙만 잘 지키면 수동 useMemo 사용이 훨씬 줄어들어 더 편리해졌다.
How to tell if a calculation is expensive?
console.time / console.timeEnd로 계산 시간을 재서 여러 번 합산했을 때 1ms 이상이면 메모이제이션을 고려해볼 만한, 즉 expensive calculation일 가능성이 높다.
useMemo는 첫 렌더를 빠르게 만드는 게 아니라, 이후 렌더에서 의존성이 안 바뀐 경우 불필요한 재계산을 건너뛰게 해 주는 것이다.
개발 환경은 Strict Mode 등으로 렌더가 두 번 일어나서 수치가 부정확하니, 프로덕션 빌드나 CPU Throttling을 사용해 실제 유저 환경과 비슷한 조건에서 측정하는 게 좋다.
Resetting all state when a prop changes
- 아래 예시 코드는 ProfilePage 컴포넌트에서 userId를 props로 받고, 댓글 입력값을 comment state로 관리하는 상황이다. 예시로 프로필 A에서 프로필 B로 이동 시 comment가 그대로 남아, 잘못된 사용자 프로필에 댓글을 달 위험이 있다.
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('')
// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('')
}, [userId])
// ...
}
위 코드는 userId가 바뀔 때마다 Effect로 comment를 비우지만,
- 먼저 이전 comment 값으로 한 번 렌더되고, Effect 실행 후 다시 렌더되어 비효율적이며,
- ProfilePage 안에 state가 여러 개 있으면 모든 하위 컴포넌트마다 이런 Effect를 반복해서 작성해야 해 구조가 복잡해진다.
그래서 아래 코드처럼 Profile에
key={userId}를 주어, React가 각 userId를 서로 다른 컴포넌트 인스턴스로 인식하게 만들어야 한다. 이렇게 하면 userId가 바뀔 때마다 Profile과 그 아래 모든 state가 자동으로 리셋되고, 댓글 입력값도 자연스럽게 초기화된다.
export default function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />
}
function Profile({ userId }) {
// ✅ This and any other state below will reset on key change automatically
const [comment, setComment] = useState('')
// ...
}
- 그렇기에 결론적으로, props 변화에 따라 컴포넌트 안의 여러 state를 한 번에 초기화하고 싶을 때는, Effect로 각각의 state를 비우기보다 key를 사용해서 컴포넌트 인스턴스를 갈아끼우는 방식으로 전체 state를 리셋하는 것이 더 단순하고 효율적이다. 이렇게 하면 불필요한 리렌더와 중복 초기화 코드를 줄이면서도, userId마다 완전히 독립적인 상태를 보장할 수 있다.
Adjusting some state when a prop changes
이번에는 전체 state 초기화가 아닌 일부 state를 재설정하거나 초기화하는 경우를 알아보자
아래 코드처럼 Effect에서 prop 변경시 state를 조정하는 방식은 비효율적이라고 볼 수 있다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false)
const [selection, setSelection] = useState(null)
// 🔴 Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null)
}, [items])
// ...
}
- 위 코드 대신, 아래 코드처럼 “선택된 아이템 전체”를 state로 들고 있지 말고, ID만 state로 저장해 두고 렌더 단계에서 selection을 계산하는 방식이 가장 단순하고 안전하다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false)
const [selectedId, setSelectedId] = useState(null)
// ✅ Best: Calculate everything during rendering
const selection = items.find((item) => item.id === selectedId) ?? null
// ...
}
- 이렇게 하면 items가 바뀌어도 Effect로 selection을 리셋하거나 조정할 필요 없이, 항상 현재 items와 selectedId를 기준으로 selection이 자동으로 일관되게 계산된다.
Sharing logic between event handlers
예시 코드
이벤트 핸들러 간 로직을 공유할 때는, 그 로직이 “컴포넌트가 화면에 표시됐기 때문에” 실행되어야 하는지, 아니면 “사용자 이벤트(클릭 등) 때문에” 실행되어야 하는지를 기준으로 Effect에 둘지 이벤트 핸들러에 둘지를 결정해야 한다.
예시 코드에서는 알림이 “페이지가 렌더링되었다”는 사실 때문에 실행되는 것이 아니라 “사용자가 버튼을 눌렀다”는 상호작용 때문에 실행되어야 하므로, Effect가 아니라 각 버튼의 이벤트 핸들러에서 공유 함수를 호출하는 방식이 맞다.
Sending a POST request
예시 코드POST 요청 로직을 어디에 둘지 결정할 때 가장 중요한 점은, 그 로직이 “컴포넌트가 화면에 표시됐기 때문에” 실행되어야 하는지, 아니면 “사용자 상호작용(버튼 클릭 등) 때문에” 실행되어야 하는지 기준을 세우는 것이다.
예시 코드에서는 analytics 이벤트처럼 폼이 표시될 때 한 번 실행돼야 하는 로직은 Effect 안에 두고, 회원가입 /api/register 요청처럼 사용자가 버튼을 클릭했을 때만 실행돼야 하는 로직은 Effect가 아닌 이벤트 핸들러 내부에 두어야 한다.
Chains of computations
예시 코드여러 state를 서로 Effect로 체이닝해서 업데이트하면 불필요한 리렌더링이 많이 발생하고, 이전 state를 복원할 때 체인이 다시 트리거되는 등 구조가 취약해지므로 피하는 것이 좋다.
이 예시에서는 isGameOver처럼 렌더링 중에 계산 가능한 값은 렌더링에서 직접 계산하고, place card와 관련된 연속적인 state 변경(card, goldCardCount, round, 알림 표시)은 하나의 이벤트 핸들러 안에서 한 번에 처리하도록 구조를 바꾸는 것이 더 효율적이고 요구사항 변화에도 유연하다.
Initializing the application
예시 코드
앱이 로드될 때 한 번만 실행되어야 하는 로직을 단순히 최상위 컴포넌트의 Effect에 넣으면, 개발 모드에서 Effect가 두 번 실행되기 때문에 인증 토큰 무효화 같은 문제가 생길 수 있어 지양해야 한다.
이런 종류의 초기화 로직은 “컴포넌트 마운트당 한 번”이 아니라 “앱 로드당 한 번”만 실행되어야 하므로, 최상위 스코프에 didInit 같은 변수를 두고 실행 여부를 추적하거나, 아예 모듈 초기화 단계에서 한 번만 실행되도록 분리해 App.js나 엔트리 포인트에 배치하는 것이 권장된다.
Notifying parent components about state changes
예시 코드
자식 컴포넌트의 state 변경을 부모에게 알릴 때, Effect 안에서 부모의 onChange를 호출하면 자식이 먼저 렌더링된 뒤 부모가 다시 렌더링되는 두 번의 패스가 생겨 비효율적이므로, 하나의 이벤트 흐름 안에서 자식과 부모 state를 함께 업데이트하는 것이 좋다.
예시처럼 updateToggle 함수 안에서 setIsOn과 onChange를 동시에 호출하거나, 아예 Toggle의 state를 없애고 isOn을 부모로부터 완전히 제어받도록 “state 끌어올리기”를 적용하면, 두 컴포넌트가 한 번의 렌더링 패스로 동기화되고 관리해야 할 state도 줄어든다.
Passing data to the parent
예시 코드
React에서는 데이터 흐름이 기본적으로 부모 → 자식 방향이기 때문에, 자식이 Effect 안에서 부모의 state를 직접 갱신하게 만들면 데이터의 출발점을 추적하기 어려워져 흐름이 복잡해진다.
이 예시에서는 자식과 부모가 같은 데이터를 필요로 하므로, 데이터를 가져오는 책임을 부모 컴포넌트로 올리고(useSomeAPI를 부모에서 호출), 그 결과를 props로 자식에게 내려보내도록 “state 끌어올리기”를 적용하면, 데이터가 항상 부모에서 자식으로만 내려가서 흐름이 단순하고 예측 가능하게 유지된다.
Subscribing to an external store
예시 코드
기존 Effect 기반 패턴
예시 코드에서는 navigator.onLine 값을 읽어와 isOnline state로 옮기고, online/offline 이벤트 리스너를 Effect에서 수동으로 등록,해제하는 useOnlineStatus 훅을 먼저 보여준다.
이 방식은 “외부 저장소 → React state”로 일일이 옮겨 적는 구조라, 변경 가능한 데이터를 수동으로 동기화해야 해서 실수 가능성이 크고 유지보수가 어렵다는 단점이 있다.
useSyncExternalStore 패턴
- 위와 같은 방식을 useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)으로 바꾸면, subscribe에서 이벤트 리스너 등록/해제를 정의하고 navigator.onLine을 스냅샷 함수로 바로 읽게 되어, 외부 저장소를 React가 공식적으로 지원하는 방식으로 subscribe 할 수 있다.
Fetching data
예시 코드많은 앱에서 검색 결과 같은 데이터를 가져올 때 useEffect를 사용하며, SearchResults 예제처럼 query와 page 값에 맞춰 results를 서버 데이터와 계속 동기화한다. 이 경우 핵심 요구사항은 “사용자가 어떻게 여기까지 왔든, 이 컴포넌트가 보이는 동안 현재 query/page에 해당하는 데이터가 항상 맞게 떠 있어야 한다”이기 때문에, 특정 클릭 이벤트가 아니라 Effect에서 fetch를 트리거하는 것이 적절한 선택이 된다.
단순 구현으로는 “race condition” 이라는 문제가 생긴다. 사용자가 "hello"를 빠르게 입력하면 "h", "he", "hel" 등 여러 요청이 거의 동시에 날아가는데, "hello" 요청보다 "hel" 응답이 더 늦게 도착하면 오래된 결과가 마지막에 setResults를 호출해서 화면이 잘못된 검색어의 결과로 덮어쓰인다. 이를 막기 위해 Effect 안에서 let ignore = false 같은 플래그를 두고, fetch 응답에서 if (!ignore) setResults(json)으로 체크한 뒤 정리 함수에서 ignore = true로 바꿔 주면, 이전 렌더에서 시작된 모든 요청 응답은 무시되고 “마지막 렌더에서 만든 요청의 응답만 유효”하게 된다.
같은 패턴을 여러 곳에 복사하기보다는, useData(url)처럼 커스텀 훅으로 추상화하는 편이 좋다. 이 훅 안에서 fetch, 정리, 에러 처리, 로딩 상태 관리까지 한 번에 처리해 두면, 화면 컴포넌트는 const results = useData(url)처럼 선언적으로 사용할 수 있고, 나중에 프레임워크 내장 데이터 패칭이나 다른 방식으로 변경할 때도 호출부는 거의 건드리지 않고 내부 구현만 교체하면 된다.