# React 19 useEffectEvent와 Activity 완벽 가이드: 새로운 API와 면접 질문 2026 > React 19.2에서 도입된 useEffectEvent와 Activity 컴포넌트의 동작 원리, 실전 패턴, 면접 핵심 질문을 체계적으로 다룹니다. - Published: 2026-06-03 - Updated: 2026-06-03 - Author: SharpSkill - Tags: react, react-19, useEffectEvent, activity, hooks, interview - Reading time: 10 min --- React 생태계는 꾸준히 진화하고 있으며, 2025년 하반기에 출시된 React 19.2는 그동안 개발자들이 겪어온 두 가지 핵심 문제를 정면으로 해결했습니다. 첫 번째는 Effect 내부의 stale closure 문제를 근본적으로 제거하는 `useEffectEvent` 훅이고, 두 번째는 컴포넌트의 상태를 보존하면서 백그라운드 프리렌더링을 가능하게 하는 `` 컴포넌트입니다. 이 두 API는 단순한 편의 기능이 아닙니다. React 애플리케이션의 사이드 이펙트 관리 방식과 네비게이션 패턴을 근본적으로 변화시키고 있으며, 2026년 프론트엔드 면접에서 핵심 질문으로 자리잡고 있습니다. 이 글에서는 각 API의 동작 원리, 실전 사용 패턴, 주의사항, 그리고 면접 대비를 위한 핵심 질의응답까지 체계적으로 다룹니다. > **React 19.2 이상 필요** > > useEffectEvent와 Activity는 React 19.2 이상에서 사용할 수 있습니다. `npm install react@latest react-dom@latest`로 업데이트하십시오. ESLint 플러그인 `eslint-plugin-react-hooks@6+`는 useEffectEvent의 의존성 배열 검사를 네이티브로 지원합니다. ## Stale Closure 문제: useEffectEvent 이전의 세계 React에서 `useEffect`를 사용할 때 가장 빈번하게 발생하는 버그 중 하나가 stale closure 문제입니다. Effect 콜백 함수가 생성 시점의 변수 값을 캡처하여, 이후 해당 변수가 변경되더라도 과거 값을 참조하는 현상을 말합니다. 전통적인 해결 방법은 `useRef`를 활용하는 것이었습니다. 아래 코드는 채팅방 연결 훅에서 `theme` 값이 변경될 때마다 불필요한 재연결을 방지하면서도 최신 값을 참조해야 하는 전형적인 상황을 보여줍니다. ```tsx // hooks/useChatRoom.ts // Before useEffectEvent: useRef workaround import { useEffect, useRef } from 'react' export function useChatRoom(roomId: string, theme: string) { // Store theme in a ref to avoid stale closure const themeRef = useRef(theme) themeRef.current = theme useEffect(() => { const connection = createConnection(roomId) connection.on('message', (msg) => { // themeRef.current always has the latest value logAnalytics('new_message', { roomId, theme: themeRef.current }) showNotification(msg) }) connection.connect() return () => connection.disconnect() }, [roomId]) // theme intentionally excluded — but linter warns } ``` 이 패턴에는 여러 문제가 존재합니다. `useRef`를 통한 최신 값 동기화는 React의 선언적 모델과 어긋나며, 의존성 배열에서 `theme`를 의도적으로 제외해야 하므로 ESLint의 `exhaustive-deps` 규칙과 충돌합니다. 코드가 복잡해질수록 어떤 값이 ref로 관리되고 있는지 추적하기 어려워지며, 이는 유지보수성을 크게 저하시킵니다. ## useEffectEvent: 반응형 로직과 비반응형 로직의 분리 `useEffectEvent`는 이 문제를 우아하게 해결합니다. 이 훅으로 생성된 함수는 항상 최신 props와 state를 읽지만, Effect의 의존성으로 취급되지 않습니다. 즉, "Effect가 언제 실행될지"와 "실행 시 어떤 값을 사용할지"를 명확하게 분리할 수 있습니다. ```tsx // hooks/useChatRoom.ts // After useEffectEvent: clean separation of concerns import { useEffect, useEffectEvent } from 'react' export function useChatRoom(roomId: string, theme: string) { // Effect Event: always reads latest theme, never triggers reconnect const onMessage = useEffectEvent((msg: string) => { logAnalytics('new_message', { roomId, theme }) showNotification(msg) }) useEffect(() => { const connection = createConnection(roomId) connection.on('message', onMessage) connection.connect() return () => connection.disconnect() }, [roomId]) // No linter warning — onMessage is an Effect Event } ``` 변경된 코드에서 주목할 점은 다음과 같습니다. `onMessage`는 `useEffectEvent`로 래핑되어 있으므로 `theme`가 변경되더라도 Effect가 다시 실행되지 않습니다. 그러면서도 `onMessage` 내부에서는 항상 최신 `theme` 값에 접근할 수 있습니다. `useRef`가 완전히 제거되었고, ESLint 경고도 발생하지 않습니다. ## useEffectEvent의 규칙과 제약사항 `useEffectEvent`를 올바르게 사용하기 위해 반드시 준수해야 하는 규칙들이 있습니다. 이 규칙들은 React의 내부 동작과 직결되므로, 면접에서도 정확한 이해를 요구하는 경우가 많습니다. 첫째, `useEffectEvent`로 생성된 함수는 반드시 `useEffect` 내부에서 호출해야 합니다. 이벤트 핸들러나 렌더링 로직에서 직접 호출하면 안 됩니다. 이 함수의 정체성은 "Effect의 비반응형 부분"이며, Effect 컨텍스트 밖에서는 그 의미가 성립하지 않습니다. 둘째, Effect Event 함수를 다른 컴포넌트나 훅에 props로 전달할 수 없습니다. React 내부적으로 이 함수는 특수한 라이프사이클을 갖고 있으며, 외부로 전달하면 호출 시점에 대한 보장이 깨집니다. 셋째, `useEffectEvent`는 컴포넌트 최상위 레벨 또는 커스텀 훅 내부에서만 선언할 수 있습니다. 조건문이나 반복문 안에서 사용할 수 없으며, 이는 다른 React 훅의 규칙과 동일합니다. 아래는 검색 추적 컴포넌트에서 `useEffectEvent`를 실전 활용하는 예시입니다. `userId`가 변경되더라도 검색 Effect는 `query`에만 반응하면서, 분석 이벤트 전송 시에는 항상 최신 `userId`를 사용합니다. ```tsx // components/SearchTracker.tsx // Correct: Effect Event used inside useEffect import { useEffect, useEffectEvent, useState } from 'react' interface SearchTrackerProps { query: string userId: string } export function SearchTracker({ query, userId }: SearchTrackerProps) { const [results, setResults] = useState([]) // Track searches with current user context const onSearchComplete = useEffectEvent((resultCount: number) => { analytics.track('search_complete', { query, userId, // Always latest userId resultCount, timestamp: Date.now(), }) }) useEffect(() => { const controller = new AbortController() fetchSearchResults(query, controller.signal).then((data) => { setResults(data) onSearchComplete(data.length) // Called from inside useEffect }) return () => controller.abort() }, [query]) // userId excluded safely — lives in the Effect Event return } ``` > **useEffectEvent 오용 주의** > > useEffectEvent는 의존성 린터 경고를 무시하기 위한 도구가 아닙니다. 특정 값이 Effect의 재실행 시점을 제어해야 하는 경우, 해당 값은 의존성 배열에 포함하는 것이 올바른 접근입니다. useEffectEvent는 로깅, 분석, 알림 등 반응형 값을 읽되 Effect 재실행을 유발하지 않아야 하는 부수적 로직에만 사용해야 합니다. ## Activity 컴포넌트: 상태 보존형 백그라운드 프리렌더링 React 19.2에서 도입된 `` 컴포넌트는 이전에 ``이라는 이름으로 알려져 있던 API의 정식 버전입니다. 이 컴포넌트의 핵심 가치는 화면에서 사라진 UI의 상태를 메모리에 보존하면서도, 숨겨진 컴포넌트의 Effect를 정리(cleanup)하여 리소스 누수를 방지하는 것입니다. 다음 표는 `mode` 속성에 따른 Activity의 동작 차이를 정리한 것입니다. | 동작 | `mode="visible"` | `mode="hidden"` | |----------|-------------------|------------------| | DOM 렌더링 | 정상 렌더링 | CSS `display: none` 처리 | | 컴포넌트 상태 | 활성 | 메모리에 보존 | | Effect (useEffect) | 마운트됨 | 클린업 실행 | | 업데이트 우선순위 | 일반 | 유휴 시간에 지연 처리 | | 프리렌더링 | 해당 없음 | 낮은 우선순위로 렌더링 | 가장 대표적인 활용 사례는 탭 레이아웃입니다. 사용자가 탭을 전환해도 이전 탭의 폼 입력값, 스크롤 위치, 컴포넌트 상태가 모두 유지됩니다. ```tsx // components/TabLayout.tsx // Activity preserves form state across tab switches import { useState } from 'react' import { Activity } from 'react' interface Tab { id: string label: string content: React.ReactNode } export function TabLayout({ tabs }: { tabs: Tab[] }) { const [activeTab, setActiveTab] = useState(tabs[0].id) return (
{tabs.map((tab) => ( {tab.content} ))}
) } ``` 기존에 조건부 렌더링(`{activeTab === tab.id && }`)을 사용하면 탭 전환 시마다 컴포넌트가 언마운트되고 다시 마운트되면서 모든 상태가 초기화됩니다. ``는 이 문제를 해결하면서도, `mode="hidden"` 상태에서는 Effect를 정리하여 WebSocket 연결이나 타이머 같은 리소스가 불필요하게 유지되는 것을 방지합니다. ## 백그라운드 프리렌더링과 Suspense 통합 ``의 또 다른 강력한 활용처는 사용자가 다음에 방문할 가능성이 높은 라우트를 미리 렌더링하는 것입니다. `Suspense`와 결합하면 데이터 페칭까지 포함한 전체 UI를 백그라운드에서 준비할 수 있습니다. ```tsx // components/PrerenderedRoute.tsx // Pre-render the next likely route in the background import { Activity, Suspense, use } from 'react' interface PrerenderedRouteProps { isActive: boolean dataPromise: Promise } export function PrerenderedRoute({ isActive, dataPromise }: PrerenderedRouteProps) { return ( }> ) } function DashboardContent({ dataPromise }: { dataPromise: Promise }) { // use() reads the cached promise — works during hidden pre-render const data = use(dataPromise) return (
) } ``` `mode="hidden"` 상태에서도 컴포넌트 트리는 렌더링되므로, `use()`를 통한 Promise 읽기와 Suspense 바운더리 처리가 백그라운드에서 완료됩니다. 사용자가 해당 라우트로 이동하면 `mode`가 `"visible"`로 전환되며, 이미 렌더링이 완료된 UI가 즉시 표시됩니다. ## Activity와 TanStack Query 사용 시 주의사항 ``를 TanStack Query와 함께 사용할 때 반드시 알아야 할 중요한 동작 특성이 있습니다. `mode="hidden"` 상태에서는 Effect가 클린업되므로, `useQuery` 훅이 데이터 페칭을 수행하지 않습니다. 이는 의도된 동작이지만, 프리페칭을 기대하는 경우 예상과 다른 결과를 초래할 수 있습니다. ```tsx // components/UserDashboard.tsx // Problem: useQuery won't fetch when Activity is hidden import { useQuery, useQueryClient } from '@tanstack/react-query' import { Activity } from 'react' function UserStats() { // This useQuery will NOT run while hidden const { data } = useQuery({ queryKey: ['user-stats'], queryFn: fetchUserStats, }) return } // Solution: prefetch data outside the Activity boundary function DashboardWithPrefetch({ showStats }: { showStats: boolean }) { const queryClient = useQueryClient() // Prefetch at the parent level — runs regardless of Activity mode queryClient.ensureQueryData({ queryKey: ['user-stats'], queryFn: fetchUserStats, }) return ( ) } ``` 해결 방법은 `` 바운더리 바깥에서, 즉 부모 컴포넌트 레벨에서 `queryClient.ensureQueryData()`를 호출하는 것입니다. 이 방법을 사용하면 Activity의 mode에 관계없이 데이터가 TanStack Query 캐시에 미리 적재되며, ``가 `"visible"`로 전환될 때 `useQuery`는 캐시된 데이터를 즉시 반환합니다. > **메모리 트레이드오프** > > Activity는 속도를 위해 메모리를 사용하는 트레이드오프를 수반합니다. 숨겨진 각 컴포넌트 트리는 전체 DOM과 함께 메모리에 유지됩니다. 숨겨진 라우트가 많은 애플리케이션에서는 메모리 사용량을 모니터링하는 것이 권장됩니다. ## useEffectEvent와 Activity의 결합: 실시간 대시보드 패턴 두 API를 결합하면 실시간 데이터 스트림을 처리하면서도 효율적인 탭 전환을 지원하는 고급 패턴을 구현할 수 있습니다. 아래 예시는 여러 채널의 실시간 피드를 WebSocket으로 수신하되, 활성 채널만 연결을 유지하고, 분석 추적에는 항상 최신 사용자 정보를 사용하는 라이브 대시보드입니다. ```tsx // components/LiveDashboard.tsx import { useEffect, useEffectEvent, useState } from 'react' import { Activity } from 'react' function LiveFeed({ channel, userId }: { channel: string; userId: string }) { const [messages, setMessages] = useState([]) // Analytics tracking with latest userId — no effect re-sync const onNewMessage = useEffectEvent((msg: Message) => { analytics.track('live_message', { channel, userId }) setMessages((prev) => [...prev, msg]) }) useEffect(() => { const ws = new WebSocket(`wss://api.example.com/${channel}`) ws.onmessage = (event) => { const msg = JSON.parse(event.data) as Message onNewMessage(msg) } return () => ws.close() }, [channel]) // Clean reconnect only when channel changes return } export function LiveDashboard({ userId }: { userId: string }) { const [activeChannel, setActiveChannel] = useState('general') const channels = ['general', 'alerts', 'metrics'] return (
{channels.map((ch) => ( ))}
) } ``` 이 패턴에서 두 API의 역할을 명확히 구분할 수 있습니다. `useEffectEvent`는 `userId` 변경 시 WebSocket 재연결을 방지하면서도 분석 이벤트에 최신 값을 보장합니다. ``는 비활성 채널의 WebSocket 연결을 자동으로 정리(hidden 시 Effect 클린업)하면서도, 해당 채널의 메시지 목록 상태는 메모리에 보존합니다. 사용자가 채널을 다시 선택하면 이전 메시지가 그대로 표시되고, WebSocket이 다시 연결되어 새 메시지를 수신합니다. ## 기술 면접 핵심 질문과 답변 다음은 2026년 React 면접에서 자주 출제되는 useEffectEvent와 Activity 관련 핵심 질문입니다. **Q1: useEffectEvent는 useCallback과 어떻게 다릅니까?** `useCallback`은 의존성 배열의 값이 변경될 때 새로운 함수를 생성하며, 반환된 함수는 의존성 배열 시점의 클로저를 캡처합니다. Effect의 의존성에 포함되므로 함수가 재생성되면 Effect가 다시 실행됩니다. 반면 `useEffectEvent`는 호출 시점의 최신 값을 항상 읽으며, Effect의 의존성으로 취급되지 않습니다. 따라서 Effect의 재실행 없이 최신 상태를 참조해야 하는 비반응형 로직에 적합합니다. **Q2: Activity의 mode가 hidden으로 전환되면 내부 Effect는 어떻게 처리됩니까?** `mode="hidden"`으로 전환되면 React는 해당 Activity 내부의 모든 `useEffect` 클린업 함수를 실행합니다. 이는 컴포넌트가 언마운트될 때와 동일한 동작입니다. 다만 컴포넌트 상태(useState)와 DOM 트리는 메모리에 보존됩니다. 다시 `mode="visible"`로 전환되면 Effect가 다시 마운트되면서 설정(setup) 함수가 실행됩니다. **Q3: useEffectEvent 함수를 이벤트 핸들러에서 직접 호출할 수 있습니까?** 호출할 수 없습니다. `useEffectEvent`로 생성된 함수는 반드시 `useEffect` 내부에서만 호출해야 합니다. 이벤트 핸들러에서 최신 상태를 읽어야 하는 경우에는 일반적인 함수 선언이나 `useCallback`을 사용하는 것이 올바른 접근 방식입니다. Effect Event는 "Effect의 비반응형 부분"이라는 특정 용도를 위해 설계된 것입니다. **Q4: Activity mode="hidden" 상태에서 useQuery(TanStack Query)가 동작하지 않는 이유는 무엇입니까?** TanStack Query의 `useQuery`는 내부적으로 `useEffect`를 사용하여 데이터 페칭을 트리거합니다. ``에서는 모든 Effect의 클린업이 실행되므로, `useQuery`의 구독이 비활성화됩니다. 이를 해결하려면 Activity 바운더리 바깥의 부모 컴포넌트에서 `queryClient.ensureQueryData()`를 호출하여 데이터를 프리페치해야 합니다. **Q5: Activity 내부 컴포넌트가 hidden 상태에서 리렌더링됩니까?** hidden 상태의 Activity 내부 컴포넌트는 부모의 상태 변경으로 인한 리렌더링이 발생할 수 있지만, 이러한 업데이트는 "deferred" 우선순위로 처리됩니다. 즉, 메인 스레드가 유휴 상태일 때만 처리되므로 사용자가 보고 있는 UI의 성능에는 영향을 주지 않습니다. **Q6: useEffectEvent와 Activity를 함께 사용할 때 hidden 전환 시 Effect Event 함수의 동작은 어떻게 됩니까?** ``으로 전환되면 Effect가 클린업되므로, Effect 내부에서 등록한 콜백(예: WebSocket onmessage)도 함께 해제됩니다. 따라서 hidden 상태에서는 Effect Event 함수가 호출될 경로 자체가 사라집니다. 다시 visible로 전환되면 Effect가 재실행되고, 그 시점에서 Effect Event 함수는 최신 props와 state를 정상적으로 읽을 수 있습니다. ## 결론 React 19.2의 `useEffectEvent`와 ``는 각각 독립적으로도 강력하지만, 결합할 때 진정한 가치가 드러나는 API입니다. 핵심 내용을 정리하면 다음과 같습니다. - **useEffectEvent**는 Effect 내부에서 최신 props/state를 읽으면서도 불필요한 Effect 재실행을 방지합니다. `useRef` 우회 패턴을 완전히 대체하며, 의존성 배열 관련 린터 경고 문제를 근본적으로 해결합니다. - **Activity 컴포넌트**는 `mode` 속성을 통해 컴포넌트의 가시성을 제어하면서 상태를 메모리에 보존합니다. hidden 상태에서는 Effect가 클린업되어 리소스 누수를 방지하고, 업데이트는 유휴 시간에 처리되어 메인 UI 성능에 영향을 주지 않습니다. - **TanStack Query와의 통합**에서는 Activity 바운더리 바깥에서 프리페치하는 패턴이 필수적입니다. - **두 API의 결합**으로 실시간 데이터 스트림, 멀티 탭 대시보드, 라우트 프리렌더링 등 복잡한 시나리오를 선언적이고 안전하게 처리할 수 있습니다. 2026년 React 면접에서 이 두 API에 대한 질문은 단순한 문법 수준을 넘어, "왜 필요한지"와 "어떤 문제를 해결하는지"에 대한 깊은 이해를 요구합니다. 각 API의 설계 의도와 내부 동작 원리를 확실히 파악해 두는 것이 중요합니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/react-next/react-19-useeffectevent-activity-new-apis-interview-questions