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

- Name
- junyeol kim
Lifecycle of Reactive Effects
Effects는 Components와 다른 life cycle를 가지고 있다.
동기화를 시작하거나 동기화를 중지하는 두 가지 뿐이다.
The lifecycle of an Effect
Effects의 동기화 과정을 Components 관점에서는 마운트시 한 번, 언마운트시 한 번 진행될 것 처럼 보이지만
실제로는 동기화를 여러번 시작하고 중지할 수 도 있다.
이러한 이유를 다음 챕터를 통해 알아보자. 🤔
Why synchronization may need to happen more than once
간단한 예시를 통해 동기화를 여러번 시작할수도 있는 이유를 알아보자.
채팅방 A 와 채팅방 B가 있을 때, 사용자가 채팅방 A를 클릭하게 된다면 어떤일이 벌어질까?
A 라는 아이디를 가진 채팅방 컴포넌트가 마운트 된 후 useEffect를 통해 외부 서비스가 연결 될 것이다.
그렇다면 사용자가 채팅방 B를 클릭하게 되면 아이디 값이 바뀌면서 컴포넌트가 리렌더링 될 것이다.
여기서 문제는 useEffect를 통해 연결된 서비스는 여전히 채팅방 A를 가리키고 있다는 것이다.
이러한 문제를 해결하기 위해 동기화를 여러번 진행해야하는 이유가 완성된다.
useEffect를 통해 외부서비스를 연결하는 동작이 한 번 더 실행되어야 비로소 올바른 결과를 표출하기 때문이다.
How React re-synchronizes your Effect
그렇다면 React가 Effect를 재동기화하는 방법을 알아보자.
처음에 사용자가 채팅방 A를 선택해서 들어가면, 컴포넌트가 렌더링되고 Effect가 실행되면서 채팅방 A와 동기화가 시작된다.
이후 사용자가 채팅방 B를 선택해서 아이디 값이 A에서 B로 바뀌면, React는 먼저 이전 Effect가 반환한 cleanup 함수를 호출해서 채팅방 A와의 동기화를 중지한다.
그리고 이번 렌더 기준으로 Effect를 다시 실행해서, 바뀐 아이디값에 맞춰 채팅방 B와 새로 동기화를 시작한다.
사용자가 채팅방 B에서 또 다른 방으로 이동해도, 항상
이전 채팅방과의 동기화 중지 → 새로운 채팅방과의 동기화 시작이 반복된다. 마지막으로 채팅방 UI를 닫거나 다른 화면으로 이동해서 컴포넌트가 언마운트되면, React가 cleanup을 한 번 더 호출해서 마지막으로 접속해 있던 채팅방과의 동기화도 중지한다.
Thinking from the Effect’s perspective
동기화 과정을 Effect의 관점에서 바라본다면 어떨까?
컴포넌트 관점에서는 아이디가 채팅방 A로 설정된 채 마운트되었다가, 채팅방 B로 업데이트되고, 다시 채팅방 C로 업데이트된 뒤 언마운트되는 흐름으로 보인다.
같은 상황을 Effect 관점에서 보면
채팅방 A에 연결된 Effect(끊어질 때까지)
채팅방 B에 연결된 Effect(끊어질 때까지)
채팅방 C에 연결된 Effect(끊어질 때까지)라는 겹치지 않는 시간 구간들의 연속으로 이해할 수 있다.
따라서 Effect를 렌더링 후 한 번 실행되는 콜백 함수가 아니라,
한 번의 동기화 시작 → 나중의 동기화 중지로 이루어진 하나의 사이클 단위 프로세스로 보는 것이 좋다.
이때 중요한 것은 “지금이 마운트냐 업데이트냐”가 아니라,
- 동기화를 어떻게 시작할지
- 언제·어떻게 중지할지 명확히 정의하는 것이다.
이렇게 해두면 React가 필요할 때마다 Effect를 여러 번 시작하고 중지해 준다.
How React verifies that your Effect can re-synchronize
그렇다면 React는 Effect의 동기화 과정을 어떻게 점검할까?
개발 환경(Strict Mode) 에서 React는 컴포넌트를 일부러 한 번 더 마운트했다가 언마운트해서, Effect의 동기화가 정상적으로 시작/중지 되는지를 테스트한다.
이때 같은 채팅방에 두 번 연결, 해제되는 로그가 찍히는 것은 dev-only stress test일 뿐이고, 프로덕션에서는 한 번만 실행된다.
실제 앱에서 Effect가 재동기화되는 주된 이유는 아이디 같은 반응형 값이 변경될 때이며, 이런 상황에서도 cleanup → 재실행 흐름이 올바르게 동작하는지 React가 개발 단계에서 미리 검증해 두는 과정이라 생각하면 된다.
How React knows that it needs to re-synchronize the Effect
이어서 React는 Effect가 재동기화해야 한다는 것을 어떻게 인식할까?
아래 예시코드를 통해 알아보자.
function ChatRoom({ roomId }) {
// roomId prop은 시간이 지남에 따라 변경될 수 있다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId) // 이 Effect는 roomId를 읽음
connection.connect()
return () => {
connection.disconnect()
}
}, [roomId]) // 이 Effect가 roomId에 "의존"한다고 React에 알려줌
// ...
}
roomId는 시간에 따라 바뀔 수 있는 prop이고, Effect 안에서 이 값을 읽고 있다.
그래서 의존성 배열에 roomId를 넣어 두면, React는 이 Effect는 roomId에 의존한다라고 인식한다.
컴포넌트가 다시 렌더링될 때마다 React는 이전 렌더링의 의존성 배열과 지금 배열을 각 인덱스별로 비교한다.
이전이 ["A"], 지금이 ["B"]이면 값이 다르므로 기존 Effect를 cleanup → 새 Effect 실행해서 재동기화한다. 이전도 ["A"], 지금도 ["A"]이면 값이 같으므로 기존 연결을 유지하고 Effect를 재실행하지 않는다.
Each Effect represents a separate synchronization process
- 각 Effect는 무엇을 동기화할지 기준으로 나뉜 독립된 동기화 프로세스 하나를 표현해야 한다.
Effects “react” to reactive values
반응형 값이면서, 해당 Effect 안에서 읽고 있다면 → 반드시 의존성 배열에 포함해야 한다.
반응형 값이라도 그 Effect 내부에서 전혀 사용하지 않는다면 → 그 Effect의 의존성 배열에는 넣지 않는다.
그렇다면, 반응형 값이면서 Effect가 읽고 있는 경우에도 의존성 배열에 포함하고 싶지 않은 경우가 존재한다면 어떻게 해야할까?
What an Effect with empty dependencies means
빈 의존성 배열에 대해 알아보자.
의존성 배열이 비어 있다면 컴포넌트 관점에서는 마운트될 때 한 번 동기화 시작, 언마운트될 때 한 번 cleanup 하는 Effect를 뜻한다.
이때 Effect 안에서 사용하는 값들은 모두 반응형이 아닌 상수여야 하고, 그래서 []만으로도 모든 의존성을 선언했다고 간주할 수 있다.
나중에 roomId나 serverUrl처럼 반응형 값이 추가로 필요해지면, Effect 코드는 그대로 두고 Effect가 읽는 반응형 값들을 의존성 배열에 추가해서 다시 동기화되도록 만들면 된다.
All variables declared in the component body are reactive
두 가지 경우로 생각해볼 수 있을 것 같다.
컴포넌트 내부에 선언 됐지만 렌더링에 따라 값이 변하지 않는 값
- ex ) 고정 상수, 환경에 따라 절대 안바뀌는 값 등..
- 이런 것들은 컴포넌트 외부나 Effect 내부로 옮겨서 반응형이 아니라는 것을 나타내는게 좋다.
컴포넌트 내부에 선언됐고, 렌더링에 따라 달라질 수 있는 값 = 반응형 값
- ex ) 예: props, state, context, 그들로부터 계산된 serverUrl 같은 변수들
- 이런 것들 중에서 Effect가 실제로 읽는 것들만 의존성 배열에서 관리하면 된다.
React verifies that you specified every reactive value as a dependency
- Effect에서 읽는 모든 반응형 값들은 의존성 배열에 반드시 포함되어야 하며,linter는 이를 자동으로 검사한다.
What to do when you don’t want to re-synchronize
마지막으로, 재동기화를 하지 않으려면 어떻게 해야할지 알아보자.
앞서 말한,
반응형 값이면서 Effect가 읽고 있는 경우에도 의존성 배열에 포함하고 싶지 않은 경우가 존재한다면 어떻게 해야할까?질문에 대한 답변을 할 수 있을 것 같다.바로 의존성은 선택할 수 없다.
Effect는 읽는 모든 반응형 값을 의존성으로 반드시 포함해야 하고, linter가 이를 강제한다.
만약 재동기화하고 싶지 않다면, 그 값을 애초에 반응형이 아니게(컴포넌트 외부 상수나 Effect 내부 상수로) 옮기거나
Effect와 이벤트/로직을 분리해서 정말로 반응해야 하는 값들만 읽는 Effect 가 되도록 설계를 바꿔야 한다.
반대로, 렌더링 때마다 새로 만들어지는 객체/함수를 의존성에 넣어서 매번 재동기화가 발생하는 상황이라면
- eslint-ignore로 무시하는 것이 아니라 구조를 바꿔 불필요한 의존성을 없애는 것이 정석적인 해결책이다.
Separating Events from Effects
이벤트 핸들러는 같은 상호작용을 반복하는 경우에만 재실행한다.
Effect는 prop, state 등 읽은 값이 마지막 렌더링 때와 다르면 재동기화한다.
Choosing between event handlers and Effects
이벤트 핸들러와 Effect 중 어떠한 로직일 때 선택을 해야할까?
먼저 이벤트 핸들러는 특정 상호작용 때문에 기능이 호출되어야 할 때 사용한다.
그와 반대로 Effect는 특정 상호작용과 무관하게 상태를 계속 동기화 해야할 때 사용한다.
Reactive values and reactive logic
이벤트 핸들러는 버튼 클릭과 같이 항상 수동으로 트리거 되지만, Effect는 동기화 유지에 필요한 만큼 자주 실행, 재실행 되므로 자동으로 트리거 된다고 할 수 있다.
이벤트 핸들러와 Effect 사용 기준에 있어 좀 더 자세하게 알아보자면
컴포넌트의 어떤 값이 바뀔 수 있는지와, 그 값을 쓰는 코드가 값 변경에 따라 다시 실행되어야 하는지를 기준으로 이벤트 핸들러와 Effect 사용을 나눌 수 있을 것 같다.
이에 앞서 반응형 값에 대해 먼저 알아보자
반응형 값이란 컴포넌트 본문안에서 선언된 prop, state, useContext 등 얘네로 부터 계산된 변수는 모두 반응형 값이다.
이런 값들은 리렌더링 때마다 바뀔 수 있으니, 값 변화에 반응하는 로직인 Effect 쪽으로 가야한다.
결론적으로 반응형 값이 바뀐다고 해서 그 로직이 자동으로 실행되면 안되는 경우에는 이벤트 핸들러, 반응형 값이 바뀌며 반드시 실행되어야하는 동기화 로직은 Effect를 사용해야 한다로 정리할 수 있을 것 같다.
Extracting non-reactive logic out of Effects
import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('연결됨!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>{roomId} 방에 오신 것을 환영합니다!</h1>
}
export default function App() {
...
}
이 코드의 문제는
theme이 의존성에 포함되어 있어서, 테마가 바뀔 때마다 채팅 연결 Effect가 다시 실행되고 연결 알림이 반복해서 뜬다는 점이다.이 문제를 해결하기 위해,
theme처럼 최신 값은 읽되 그 값의 변화에 Effect가 반응하지는 않도록 비반응형 로직을 분리하는 방법을 알아보자.
Declaring an Effect Event
import { useEffect, useEffectEvent } from 'react'
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme) // 항상 최신 theme를 읽음
})
useEffect(() => {
const connection = createConnection(serverUrl, roomId)
connection.on('connected', () => {
onConnected() // Effect 안에서만 호출
})
connection.connect()
return () => connection.disconnect()
}, [roomId]) // theme 제거
}
- useEffectEvent로 비반응형 로직을 Effect 이벤트로 분리하면
- Effect는 roomId만 의존성으로 두고 알림 로직은 theme를 읽되, theme 변화에 따른 재실행은 발생하지 않는다.
Reading latest props and state with Effect Events
// 잘못된 예시
function Page({ url }) {
const { items } = useContext(ShoppingCartContext)
const numberOfItems = items.length
useEffect(() => {
logVisit(url, numberOfItems)
}, [url]) // 🔴 numberOfItems 빠짐 → linter 경고
}
// 올바른 예시
import { useEffect, useContext, useEffectEvent } from 'react'
function Page({ url }) {
const { items } = useContext(ShoppingCartContext)
const numberOfItems = items.length
const onVisit = useEffectEvent((visitedUrl) => {
logVisit(visitedUrl, numberOfItems) // 항상 최신 numberOfItems
})
useEffect(() => {
onVisit(url) // url 변화에만 반응
}, [url])
}
그렇다면 useEffectEvent가 무조건적인 해결방법일까?
useEffectEvent는 최신 props와 state를 읽되, 특정 값 변화에 Effect가 다시 실행되지 않게 하고 싶을 때만 적절한 도구다.
반대로, 그 값 변화에 Effect가 실제로 반응해야 한다면, 그냥 의존성 배열에 명시하는 것이 맞고, useEffectEvent로 억지로 빼면 안 된다.
즉 useEffectEvent는 linter 경고를 없애기 위한 해결책이 아니라, 진짜로 비반응형이어야 하는 로직을 분리하는 데만 써야 한다.
Limitations of Effect Events
Effect 이벤트는 Effect 안에서만 호출해야 한다.
다른 컴포넌트나 Hook에 인자로 넘기면 안 된다.