React 19 useEffectEvent와 Activity 완벽 가이드: 새로운 API와 면접 질문 2026
React 19.2에서 도입된 useEffectEvent와 Activity 컴포넌트의 동작 원리, 실전 패턴, 면접 핵심 질문을 체계적으로 다룹니다.

React 생태계는 꾸준히 진화하고 있으며, 2025년 하반기에 출시된 React 19.2는 그동안 개발자들이 겪어온 두 가지 핵심 문제를 정면으로 해결했습니다. 첫 번째는 Effect 내부의 stale closure 문제를 근본적으로 제거하는 useEffectEvent 훅이고, 두 번째는 컴포넌트의 상태를 보존하면서 백그라운드 프리렌더링을 가능하게 하는 <Activity> 컴포넌트입니다.
이 두 API는 단순한 편의 기능이 아닙니다. React 애플리케이션의 사이드 이펙트 관리 방식과 네비게이션 패턴을 근본적으로 변화시키고 있으며, 2026년 프론트엔드 면접에서 핵심 질문으로 자리잡고 있습니다. 이 글에서는 각 API의 동작 원리, 실전 사용 패턴, 주의사항, 그리고 면접 대비를 위한 핵심 질의응답까지 체계적으로 다룹니다.
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 값이 변경될 때마다 불필요한 재연결을 방지하면서도 최신 값을 참조해야 하는 전형적인 상황을 보여줍니다.
// 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가 언제 실행될지"와 "실행 시 어떤 값을 사용할지"를 명확하게 분리할 수 있습니다.
// 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를 사용합니다.
// 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<string[]>([])
// 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 <ResultsList results={results} />
}useEffectEvent는 의존성 린터 경고를 무시하기 위한 도구가 아닙니다. 특정 값이 Effect의 재실행 시점을 제어해야 하는 경우, 해당 값은 의존성 배열에 포함하는 것이 올바른 접근입니다. useEffectEvent는 로깅, 분석, 알림 등 반응형 값을 읽되 Effect 재실행을 유발하지 않아야 하는 부수적 로직에만 사용해야 합니다.
Activity 컴포넌트: 상태 보존형 백그라운드 프리렌더링
React 19.2에서 도입된 <Activity> 컴포넌트는 이전에 <Offscreen>이라는 이름으로 알려져 있던 API의 정식 버전입니다. 이 컴포넌트의 핵심 가치는 화면에서 사라진 UI의 상태를 메모리에 보존하면서도, 숨겨진 컴포넌트의 Effect를 정리(cleanup)하여 리소스 누수를 방지하는 것입니다.
다음 표는 mode 속성에 따른 Activity의 동작 차이를 정리한 것입니다.
| 동작 | mode="visible" | mode="hidden" |
|---|---|---|
| DOM 렌더링 | 정상 렌더링 | CSS display: none 처리 |
| 컴포넌트 상태 | 활성 | 메모리에 보존 |
| Effect (useEffect) | 마운트됨 | 클린업 실행 |
| 업데이트 우선순위 | 일반 | 유휴 시간에 지연 처리 |
| 프리렌더링 | 해당 없음 | 낮은 우선순위로 렌더링 |
가장 대표적인 활용 사례는 탭 레이아웃입니다. 사용자가 탭을 전환해도 이전 탭의 폼 입력값, 스크롤 위치, 컴포넌트 상태가 모두 유지됩니다.
// 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 (
<div>
<nav className="flex gap-2 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={activeTab === tab.id ? 'border-b-2 border-primary' : ''}
>
{tab.label}
</button>
))}
</nav>
{tabs.map((tab) => (
<Activity key={tab.id} mode={activeTab === tab.id ? 'visible' : 'hidden'}>
{tab.content}
</Activity>
))}
</div>
)
}기존에 조건부 렌더링({activeTab === tab.id && <Content />})을 사용하면 탭 전환 시마다 컴포넌트가 언마운트되고 다시 마운트되면서 모든 상태가 초기화됩니다. <Activity>는 이 문제를 해결하면서도, mode="hidden" 상태에서는 Effect를 정리하여 WebSocket 연결이나 타이머 같은 리소스가 불필요하게 유지되는 것을 방지합니다.
React / Next.js 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
백그라운드 프리렌더링과 Suspense 통합
<Activity>의 또 다른 강력한 활용처는 사용자가 다음에 방문할 가능성이 높은 라우트를 미리 렌더링하는 것입니다. Suspense와 결합하면 데이터 페칭까지 포함한 전체 UI를 백그라운드에서 준비할 수 있습니다.
// Pre-render the next likely route in the background
import { Activity, Suspense, use } from 'react'
interface PrerenderedRouteProps {
isActive: boolean
dataPromise: Promise<DashboardData>
}
export function PrerenderedRoute({ isActive, dataPromise }: PrerenderedRouteProps) {
return (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent dataPromise={dataPromise} />
</Suspense>
</Activity>
)
}
function DashboardContent({ dataPromise }: { dataPromise: Promise<DashboardData> }) {
// use() reads the cached promise — works during hidden pre-render
const data = use(dataPromise)
return (
<div className="grid grid-cols-3 gap-4">
<MetricsCard data={data.metrics} />
<ChartPanel data={data.charts} />
<RecentActivity items={data.activity} />
</div>
)
}mode="hidden" 상태에서도 컴포넌트 트리는 렌더링되므로, use()를 통한 Promise 읽기와 Suspense 바운더리 처리가 백그라운드에서 완료됩니다. 사용자가 해당 라우트로 이동하면 mode가 "visible"로 전환되며, 이미 렌더링이 완료된 UI가 즉시 표시됩니다.
Activity와 TanStack Query 사용 시 주의사항
<Activity>를 TanStack Query와 함께 사용할 때 반드시 알아야 할 중요한 동작 특성이 있습니다. mode="hidden" 상태에서는 Effect가 클린업되므로, useQuery 훅이 데이터 페칭을 수행하지 않습니다. 이는 의도된 동작이지만, 프리페칭을 기대하는 경우 예상과 다른 결과를 초래할 수 있습니다.
// 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 <StatsDisplay data={data} />
}
// 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 (
<Activity mode={showStats ? 'visible' : 'hidden'}>
<UserStats />
</Activity>
)
}해결 방법은 <Activity> 바운더리 바깥에서, 즉 부모 컴포넌트 레벨에서 queryClient.ensureQueryData()를 호출하는 것입니다. 이 방법을 사용하면 Activity의 mode에 관계없이 데이터가 TanStack Query 캐시에 미리 적재되며, <Activity>가 "visible"로 전환될 때 useQuery는 캐시된 데이터를 즉시 반환합니다.
Activity는 속도를 위해 메모리를 사용하는 트레이드오프를 수반합니다. 숨겨진 각 컴포넌트 트리는 전체 DOM과 함께 메모리에 유지됩니다. 숨겨진 라우트가 많은 애플리케이션에서는 메모리 사용량을 모니터링하는 것이 권장됩니다.
useEffectEvent와 Activity의 결합: 실시간 대시보드 패턴
두 API를 결합하면 실시간 데이터 스트림을 처리하면서도 효율적인 탭 전환을 지원하는 고급 패턴을 구현할 수 있습니다. 아래 예시는 여러 채널의 실시간 피드를 WebSocket으로 수신하되, 활성 채널만 연결을 유지하고, 분석 추적에는 항상 최신 사용자 정보를 사용하는 라이브 대시보드입니다.
import { useEffect, useEffectEvent, useState } from 'react'
import { Activity } from 'react'
function LiveFeed({ channel, userId }: { channel: string; userId: string }) {
const [messages, setMessages] = useState<Message[]>([])
// 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 <MessageList messages={messages} />
}
export function LiveDashboard({ userId }: { userId: string }) {
const [activeChannel, setActiveChannel] = useState('general')
const channels = ['general', 'alerts', 'metrics']
return (
<div>
<nav className="flex gap-2">
{channels.map((ch) => (
<button key={ch} onClick={() => setActiveChannel(ch)}>
{ch}
</button>
))}
</nav>
{channels.map((ch) => (
<Activity key={ch} mode={activeChannel === ch ? 'visible' : 'hidden'}>
<LiveFeed channel={ch} userId={userId} />
</Activity>
))}
</div>
)
}이 패턴에서 두 API의 역할을 명확히 구분할 수 있습니다. useEffectEvent는 userId 변경 시 WebSocket 재연결을 방지하면서도 분석 이벤트에 최신 값을 보장합니다. <Activity>는 비활성 채널의 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를 사용하여 데이터 페칭을 트리거합니다. <Activity mode="hidden">에서는 모든 Effect의 클린업이 실행되므로, useQuery의 구독이 비활성화됩니다. 이를 해결하려면 Activity 바운더리 바깥의 부모 컴포넌트에서 queryClient.ensureQueryData()를 호출하여 데이터를 프리페치해야 합니다.
Q5: Activity 내부 컴포넌트가 hidden 상태에서 리렌더링됩니까?
hidden 상태의 Activity 내부 컴포넌트는 부모의 상태 변경으로 인한 리렌더링이 발생할 수 있지만, 이러한 업데이트는 "deferred" 우선순위로 처리됩니다. 즉, 메인 스레드가 유휴 상태일 때만 처리되므로 사용자가 보고 있는 UI의 성능에는 영향을 주지 않습니다.
Q6: useEffectEvent와 Activity를 함께 사용할 때 hidden 전환 시 Effect Event 함수의 동작은 어떻게 됩니까?
<Activity mode="hidden">으로 전환되면 Effect가 클린업되므로, Effect 내부에서 등록한 콜백(예: WebSocket onmessage)도 함께 해제됩니다. 따라서 hidden 상태에서는 Effect Event 함수가 호출될 경로 자체가 사라집니다. 다시 visible로 전환되면 Effect가 재실행되고, 그 시점에서 Effect Event 함수는 최신 props와 state를 정상적으로 읽을 수 있습니다.
결론
React 19.2의 useEffectEvent와 <Activity>는 각각 독립적으로도 강력하지만, 결합할 때 진정한 가치가 드러나는 API입니다. 핵심 내용을 정리하면 다음과 같습니다.
- useEffectEvent는 Effect 내부에서 최신 props/state를 읽으면서도 불필요한 Effect 재실행을 방지합니다.
useRef우회 패턴을 완전히 대체하며, 의존성 배열 관련 린터 경고 문제를 근본적으로 해결합니다. - Activity 컴포넌트는
mode속성을 통해 컴포넌트의 가시성을 제어하면서 상태를 메모리에 보존합니다. hidden 상태에서는 Effect가 클린업되어 리소스 누수를 방지하고, 업데이트는 유휴 시간에 처리되어 메인 UI 성능에 영향을 주지 않습니다. - TanStack Query와의 통합에서는 Activity 바운더리 바깥에서 프리페치하는 패턴이 필수적입니다.
- 두 API의 결합으로 실시간 데이터 스트림, 멀티 탭 대시보드, 라우트 프리렌더링 등 복잡한 시나리오를 선언적이고 안전하게 처리할 수 있습니다.
2026년 React 면접에서 이 두 API에 대한 질문은 단순한 문법 수준을 넘어, "왜 필요한지"와 "어떤 문제를 해결하는지"에 대한 깊은 이해를 요구합니다. 각 API의 설계 의도와 내부 동작 원리를 확실히 파악해 두는 것이 중요합니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
React / Next.js 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 6월 3일 업데이트
태그
공유
관련 기사

React 19 Suspense와 Concurrent Rendering 완벽 가이드: Streaming SSR과 면접 대비 2026
React 19의 Suspense, Concurrent Rendering, Streaming SSR의 동작 원리를 상세히 설명합니다. 2026년 프론트엔드 면접에서 자주 나오는 질문과 답변 예시도 포함되어 있습니다.

React Compiler 2026: 자동 메모이제이션의 원리와 기술 면접 완벽 가이드
React Compiler v1.0의 자동 메모이제이션 내부 구조, 컴파일 파이프라인, 수동 최적화가 필요한 시나리오를 상세히 분석합니다. 2026년 React 기술 면접에서 자주 출제되는 핵심 질문을 포괄적으로 다룹니다.

Next.js 16 Server Actions 2026: 뮤테이션, 무효화 그리고 면접 질문
Next.js 16 Server Action이 뮤테이션, 무효화, 대기 상태, 낙관적 UI, 보안을 어떻게 처리하는지, 그리고 각 개념을 검증하는 면접 질문까지 살펴봅니다.