오늘은 브라우저 렌더링 방식인 SSG, CSR, SSR, ISR 내부 작동원리에 대해 살펴볼 것이다.

 

1. SSG (Static Site Generation) 작동원리

전체 흐름도

[빌드 타임]                    [런타임]
개발자 코드                     사용자 요청
    ↓                              ↓
npm run build                  CDN/Server
    ↓                              ↓
데이터 페칭 (API 호출)         정적 HTML 파일 전송
    ↓                              ↓
HTML 생성 (모든 페이지)        브라우저에서 즉시 렌더링
    ↓                              ↓
.next/static 폴더에 저장       (Hydration으로 인터랙티브)

 

1. 빌드타임(Build Time)

  • 개발자가 코드를 작성하고 nun run build 명령어를 실행하면, 모든 페이지가 미리 HTML로 생성된다.
  • 이 과정에서 필요한 데이터가 있다면 API를 호출해 가져오고, 가져온 데이터를 바탕으로 HTML 파일을 만들어 .next/static (Next라면) 같은 폴더에 저장한다.
  • 즉, 사용자가 페이지를 요청하기 전 미리 모든 페이지를 준비하는 단계이다.

2. 런타임(Runtime)

  • 사용자가 브라우저에서 페이지를 요청하면, 서버나 CDN이 미리 만들어둔 정적 HTML파일을 즉시 전송한다.
  • 브라우저에서는 받은 HTML을 바로 화면에 보여주고, 필요하면 React의 Hydration 과정을 통해 인터랙티브 기능(버튼 클릭, 폼 입력 등)을 사용할 수 있다.

 

상세 작동 과정

1단계: 빌드 타임 (Build Time)

npm run build

 

내부에서 일어나는 일

1. Next.js가 모든 페이지 스캔
   ├─ app/page.tsx
   ├─ app/about/page.tsx
   ├─ app/blog/[slug]/page.tsx
   └─ ...

2. 각 페이지별로 실행:
   ├─ generateStaticParams() 호출 (동적 라우트)
   ├─ 데이터 페칭 함수 실행
   ├─ React 컴포넌트 렌더링
   └─ HTML 파일 생성

3. 출력물:
   ├─ .next/server/app/page.html
   ├─ .next/server/app/about.html
   ├─ .next/server/app/blog/post-1.html
   └─ JavaScript 번들 (.next/static/chunks/)

 

Next.js SSG 내부에서 일어나는 과정

  1. 페이지 스캔
    • Next.js는 빌드 과정에서 프로젝트 내 모든 페이지를 확인합니다.
    • 예를 들어:
      • app/page.tsx → 홈 페이지
      • app/about/page.tsx → 소개 페이지
      • app/blog/[slug]/page.tsx → 동적 블로그 글 페이지
    • 이렇게 모든 페이지를 자동으로 찾아서 처리 대상으로 등록합니다.
  2. 페이지별 처리
    • 동적 라우트 처리: generateStaticParams() 함수가 호출되어, 동적 URL(slug 등)에 필요한 모든 조합을 생성합니다.
    • 데이터 가져오기: 각 페이지의 데이터 페칭 함수(API 호출 등)를 실행해서 필요한 데이터를 불러옵니다.
    • React 컴포넌트 렌더링: 가져온 데이터를 이용해 React 컴포넌트를 HTML로 렌더링합니다.
    • HTML 파일 생성: 렌더링된 결과를 정적 HTML 파일로 변환합니다.
  3. 출력물 생성
    • 생성된 HTML 파일과 JavaScript 번들이 프로젝트 내부 .next 폴더에 저장됩니다.
    • 예시:
      • .next/server/app/page.html → 홈 페이지
      • .next/server/app/about.html → 소개 페이지
      • .next/server/app/blog/post-1.html → 블로그 글
      • .next/static/chunks/ → React 기능과 인터랙티브를 위한 JS 번들

 

Next.js 코드 예제

// app/blog/[slug]/page.tsx

// 1. 빌드 시 생성할 경로 정의
export async function generateStaticParams() {
  console.log('🏗️  빌드 타임: 경로 생성 중...');

  // API에서 모든 포스트 가져오기
  const posts = await fetch('<https://api.example.com/posts>')
    .then(res => res.json());

  // 각 포스트마다 정적 페이지 생성
  return posts.map((post: any) => ({
    slug: post.slug, // /blog/post-1, /blog/post-2 등
  }));
}

// 2. 각 페이지의 데이터 페칭
export default async function BlogPost({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  console.log(`🏗️  빌드 타임: ${params.slug} 페이지 생성 중...`);
  const { slug } = await params;

  // 이 fetch는 빌드 시 한 번만 실행됨
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    cache: 'force-cache' // SSG의 핵심 설정(DEFAULT)
  }).then(res => res.json());

  // React 컴포넌트를 HTML로 변환
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );

2단계: 빌드 결과물

.next/
├── static/
│   ├── chunks/          # JavaScript 코드 조각들
│   │   ├── main.js      # React 런타임
│   │   └── pages/       # 페이지별 JS
│   └── css/             # CSS 파일들
├── server/
│   └── app/
│       ├── page.html           # 미리 생성된 HTML
│       ├── about.html
│       └── blog/
│           ├── post-1.html     # 각 포스트의 HTML
│           └── post-2.html

 

생성된 HTML 예시

<!DOCTYPE html>
<html>
<head>
  <title>My Blog Post</title>
  <link rel="stylesheet" href="/_next/static/css/app.css" />
</head>
<body>
  <!-- 이미 렌더링된 콘텐츠 -->
  <article>
    <h1>내 첫 번째 포스트</h1>
    <div>
      <p>이것은 내용입니다...</p>
    </div>
  </article>

  <!-- JavaScript는 Hydration을 위해 로드 -->
  <script src="/_next/static/chunks/main.js"></script>
  <script src="/_next/static/chunks/pages/blog/post-1.js"></script>
</body>
</html>

 

3단계: 사용자 요청 (Request Time)

사용자가 /blog/post-1 접속
         ↓
CDN/Server가 미리 생성된 HTML 전송
         ↓
브라우저가 HTML 즉시 렌더링 (0.1초)
         ↓
JavaScript 다운로드 & 실행
         ↓
Hydration (HTML에 이벤트 리스너 연결)
         ↓
인터랙티브한 페이지 완성

 


Hydration 과정

// 1. 서버에서 생성된 HTML (정적)
<button>클릭하세요</button>

// 2. JavaScript 로드 후 Hydration
<button onClick={() => alert('클릭!')}>클릭하세요</button>
//      ↑ 이벤트 리스너가 추가됨

 

Hydration 코드 흐름:

// React가 내부적으로 수행
ReactDOM.hydrateRoot(
  document.getElementById('root'),
  <App />
);

// 과정:
// 1. 기존 HTML 요소 찾기
// 2. Virtual DOM 생성
// 3. 기존 DOM과 비교
// 4. 이벤트 리스너 연결
// 5. 상태 관리 활성화

 

 

2. ISR (Incremental Static Regeneration) 작동원리

전체 흐름도

[빌드 타임]              [첫 요청]            [revalidate 후]
모든 페이지 생성      → 정적 HTML 제공 →         백그라운드 재생성
                          ↓                    ↓
                    캐시된 버전 제공           새 버전으로 교체

 

 

ISR 상세 작동 과정

시나리오: revalidate: 60 설정

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: {
      revalidate: 60 // 60초마다 재검증
    }
  }).then(res => res.json());

  return <article>{post.title}</article>;
}

타임라인 분석

시간         요청          응답            내부 동작
──────────────────────────────────────────────────────────
00:00    빌드 완료      -              HTML v1 생성
         (초기 상태)

01:00    사용자 A      HTML v1 제공    ✅ 캐시 히트
         방문          (0.1초)         (타임스탬프: 00:00)

01:30    사용자 B      HTML v1 제공    ✅ 캐시 히트
         방문          (0.1초)         (아직 60초 안됨)

02:00    사용자 C      HTML v1 제공    🔄 재생성 트리거!
         방문          (0.1초)         (60초 경과)
                                      백그라운드에서:
                                      - API 재호출
                                      - HTML v2 생성
                                      - 캐시 교체 (10초 소요)

02:05    사용자 D      HTML v1 제공    ⏳ 재생성 진행 중
         방문          (0.1초)

02:10    재생성 완료    -              ✅ 캐시 업데이트
                                      HTML v2 준비 완료

02:15    사용자 E      HTML v2 제공    ✅ 새 버전!
         방문          (0.1초)

03:00    사용자 F      HTML v2 제공    ✅ 캐시 히트
         방문          (0.1초)

 

ISR 내부 캐시 메커니즘

// Next.js 내부 캐시 구조 (단순화)
const cache = {
  '/blog/post-1': {
    html: '<article>...</article>',
    timestamp: 1704067200000,      // 생성 시각
    revalidateAfter: 60,            // 재검증 간격 (초)
    isRevalidating: false           // 재생성 중인지
  }
};

// 요청 처리 로직
async function handleRequest(path) {
  const cached = cache[path];
  const now = Date.now();
  const age = (now - cached.timestamp) / 1000; // 초 단위

  // 1. 캐시가 유효한 경우
  if (age < cached.revalidateAfter) {
    console.log('✅ 캐시 히트: 신선한 데이터');
    return cached.html;
  }

  // 2. 캐시가 만료되었지만 재생성 중이 아닌 경우
  if (!cached.isRevalidating) {
    console.log('🔄 Stale-While-Revalidate: 오래된 캐시 제공 + 백그라운드 재생성');

    // 오래된 캐시 즉시 반환
    const staleResponse = cached.html;

    // 백그라운드에서 재생성
    cached.isRevalidating = true;
    regeneratePage(path).then(newHtml => {
      cache[path] = {
        html: newHtml,
        timestamp: Date.now(),
        revalidateAfter: 60,
        isRevalidating: false
      };
      console.log('✅ 재생성 완료: 캐시 업데이트됨');
    });

    return staleResponse;
  }

  // 3. 이미 재생성 중인 경우
  console.log('⏳ 재생성 진행 중: 오래된 캐시 제공');
  return cached.html;
}

 

 

ISR의 Stale-While-Revalidate 전략

ISR의 핵심은 "오래된 콘텐츠를 제공하면서 동시에 새 콘텐츠를 준비"하는 것입니다.

요청 → 캐시 확인
       ├─ 신선함 → 즉시 반환 ✅
       └─ 오래됨 → 오래된 것 반환 + 백그라운드 갱신 🔄
                   (다음 요청부터 새 버전)

 

On-Demand Revalidation

수동으로 캐시를 무효화할 수도 있습니다.

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const path = request.nextUrl.searchParams.get('path');

  if (path) {
    // 특정 경로 재검증
    revalidatePath(path);
    console.log(`🔄 수동 재검증: ${path}`);
    return Response.json({ revalidated: true });
  }

  return Response.json({ revalidated: false });
}

// 사용 예:
// POST /api/revalidate?path=/blog/post-1

 

 

ISR 내부 동작:

// revalidatePath 내부 로직
function revalidatePath(path) {
  // 1. 캐시에서 해당 경로 찾기
  const cached = cache[path];

  if (cached) {
    // 2. 타임스탬프를 과거로 설정 (만료시킴)
    cached.timestamp = 0;
    console.log(`✅ ${path} 캐시 무효화됨`);

    // 3. 다음 요청 시 자동으로 재생성됨
  }
}

 


3.  SSR (Server-Side Rendering) 작동원리

전체 흐름도

사용자 요청
    ↓
Next.js 서버
    ↓
데이터 페칭 (API 호출)
    ↓
React 컴포넌트 렌더링
    ↓
HTML 생성
    ↓
브라우저로 전송
    ↓
Hydration

 

상세 작동 과정

요청별 처리 흐름

// app/dashboard/page.tsx
export default async function Dashboard() {
  console.log('🖥️  서버에서 실행 중...');

  const data = await fetch('<https://api.example.com/user>', {
    cache: 'no-store', // SSR 활성화
    headers: {
      cookie: cookies().toString() // 쿠키 포함
    }
  }).then(res => res.json());

    return (
    <div>
      <h1>환영합니다, {data.name}님</h1>
      <p>마지막 로그인: {data.lastLogin}</p>
    </div>
  );
}
 

SSR 타임라인 분석

사용자 A가 /dashboard 요청 (10:00:00)
│
├─ [10:00:00.000] 요청 도착
│   └─ 서버: "새 요청 받음"
│
├─ [10:00:00.050] 쿠키/헤더 파싱
│   └─ 서버: "인증 토큰 확인"
│
├─ [10:00:00.100] API 호출 시작
│   └─ 서버 → API: "사용자 데이터 요청"
│
├─ [10:00:00.500] API 응답 대기 중...
│   └─ 서버: "응답 대기 중"
│
├─ [10:00:00.800] API 응답 도착
│   └─ 서버: "데이터 수신 완료"
│
├─ [10:00:00.850] React 렌더링
│   └─ 서버: "컴포넌트를 HTML로 변환"
│
├─ [10:00:00.900] HTML 생성 완료
│   └─ 서버: "최종 HTML 준비"
│
└─ [10:00:00.950] 브라우저로 전송
    └─ 사용자: "페이지 표시"

──────────────────────────────────────────

사용자 B가 /dashboard 요청 (10:00:01)
│
└─ 위 과정 반복 (캐시 없음!)
   └─ 총 소요시간: ~1초

 

서버 내부 처리

// Next.js 서버의 요청 핸들러 (단순화)
async function handleSSRRequest(req, res) {
  console.log('🖥️  SSR 요청 처리 시작');
  const startTime = Date.now();

  try {
    // 1. 라우트 매칭
    const route = matchRoute(req.url);
    console.log(`📍 라우트: ${route.path}`);

    // 2. 페이지 컴포넌트 가져오기
    const PageComponent = await import(route.componentPath);

    // 3. 서버 컴포넌트 실행 (데이터 페칭 포함)
    console.log('🔄 데이터 페칭 시작...');
    const props = await PageComponent.getServerSideProps?.(req, res);

    // 4. React 렌더링
    console.log('🎨 React 렌더링 시작...');
    const html = ReactDOMServer.renderToString(
      <PageComponent {...props} />
    );

    // 5. HTML 템플릿에 삽입
    const fullHtml = `
      <!DOCTYPE html>
      <html>
        <head>
          <title>${props.title}</title>
          <link rel="stylesheet" href="/styles.css" />
        </head>
        <body>
          <div id="root">${html}</div>
          <script src="/runtime.js"></script>
          <script>
            window.__INITIAL_DATA__ = ${JSON.stringify(props)};
          </script>
        </body>
      </html>
    `;

    const endTime = Date.now();
    console.log(`✅ 렌더링 완료 (${endTime - startTime}ms)`);

    // 6. 응답 전송
    res.setHeader('Content-Type', 'text/html');
    res.send(fullHtml);

  } catch (error) {
    console.error('❌ SSR 에러:', error);
    res.status(500).send('Server Error');
  }
}

 

SSR 인증/쿠키 처리

// app/profile/page.tsx
import { cookies, headers } from 'next/headers';

export default async function Profile() {
  // 1. 쿠키 읽기
  const cookieStore = cookies();
  const token = cookieStore.get('auth_token');

  console.log('🔐 인증 토큰:', token?.value);

  if (!token) {
    redirect('/login');
  }

  // 2. 헤더 읽기
  const headersList = headers();
  const userAgent = headersList.get('user-agent');

  console.log('👤 User-Agent:', userAgent);

  // 3. 인증된 API 호출
  const user = await fetch('<https://api.example.com/profile>', {
    headers: {
      'Authorization': `Bearer ${token.value}`,
      'User-Agent': userAgent || ''
    },
    cache: 'no-store'
  }).then(res => res.json());

  return

 

 

성능 최적화: Streaming SSR

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>대시보드</h1>

      {/* 즉시 렌더링 */}
      <QuickStats />

      {/* 느린 부분은 나중에 */}
      <Suspense fallback={<Loading />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

async function SlowComponent() {
  // 3초 걸리는 API 호출
  const data = await fetch('https://slow-api.com/data', {
    cache: 'no-store'
  });

  return <HeavyChart data={data} />;
}
 

Streaming 동작:

클라이언트로 전송되는 HTML:

[0.1초]
<div>
  <h1>대시보드</h1>
  <div>빠른 통계: ...</div>
  <div id="suspense-boundary">로딩 중...</div>
</div>

[3.1초 - 추가 청크]
<script>
  // Suspense 경계 업데이트
  replaceSuspenseBoundary('suspense-boundary', `
    <div>느린 컴포넌트 내용...</div>
  `);
</script>

 

4. CSR (Client-Side Rendering) 작동원리

 전체 흐름도

사용자 요청
    ↓
빈 HTML + JavaScript 번들 전송
    ↓
브라우저에서 JavaScript 실행
    ↓
React 앱 초기화
    ↓
API 호출 (fetch/axios)
    ↓
컴포넌트 렌더링
    ↓
화면에 표시

 

상세 작동 과정

초기 HTML (거의 비어있음)

<!DOCTYPE html>
<html>
<head>
  <title>My App</title>
</head>
<body>
  <!-- 거의 빈 껍데기 -->
  <div id="root"></div>

  <!-- JavaScript 번들만 로드 -->
  <script src="/static/js/runtime.js"></script>
  <script src="/static/js/vendors.js"></script>
  <script src="/static/js/main.js"></script>
</body>
</html>

 

JavaScript 실행 흐름

// app/dashboard/page.tsx
'use client'; // CSR 명시

import { useState, useEffect } from 'react';

export default function Dashboard() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log('🌐 브라우저에서 실행 중...');
    console.log('📡 API 호출 시작...');

    fetch('/api/user')
      .then(res => {
        console.log('✅ API 응답 받음');
        return res.json();
      })
      .then(data => {
        console.log('📦 데이터 파싱 완료');
        setUser(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('❌ 에러:', error);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div>로딩 중...</div>;
  }

  return (
    <div>
      <h1>환영합니다, {user.name}님!</h1>
    </div>
  );
}

CSR 타임라인 분석

[0.0초] 사용자가 /dashboard 요청
│
├─ [0.1초] HTML 다운로드 (5KB)
│   └─ 화면: 빈 페이지
│
├─ [0.2초] JavaScript 다운로드 시작
│   ├─ runtime.js (50KB)
│   ├─ vendors.js (200KB - React 등)
│   └─ main.js (100KB - 앱 코드)
│   └─ 화면: 여전히 빈 페이지
│
├─ [1.0초] JavaScript 파싱 & 실행
│   └─ 화면: 여전히 빈 페이지
│
├─ [1.2초] React 앱 초기화
│   └─ ReactDOM.render(<App />)
│   └─ 화면: "로딩 중..." 표시
│
├─ [1.3초] useEffect 실행
│   └─ fetch('/api/user') 호출
│   └─ 화면: "로딩 중..."
│
├─ [1.8초] API 응답 도착
│   └─ 화면: "로딩 중..."
│
└─ [2.0초] setState 완료 & 리렌더링
    └─ 화면: 실제 콘텐츠 표시 ✅

 

CSR 브라우저 내부 처리

// React의 CSR 초기화 (단순화)
window.addEventListener('DOMContentLoaded', () => {
  console.log('📄 DOM 로드 완료');

  // 1. React 루트 생성
  const root = ReactDOM.createRoot(document.getElementById('root'));
  console.log('🌱 React 루트 생성');

  // 2. 앱 렌더링
  console.log('🎨 앱 렌더링 시작');
  root.render(<App />);

  // 3. 컴포넌트 마운트
  // App → Dashboard → useEffect 순서로 실행
});

 

CSR 상태 관리와 리렌더링

'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  console.log('🔄 Counter 컴포넌트 렌더링:', count);

  const increment = () => {
    console.log('➕ 증가 버튼 클릭');
    setCount(prev => {
      const newCount = prev + 1;
      console.log(`상태 변경: ${prev} → ${newCount}`);
      return newCount;
    });
    // 상태 변경 후 자동으로 리렌더링 트리거
  };

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}

 

실행 흐름:

[초기 렌더링]
└─ 🔄 Counter 컴포넌트 렌더링: 0

[버튼 클릭]
├─ ➕ 증가 버튼 클릭
├─ 상태 변경: 0 → 1
└─ 🔄 Counter 컴포넌트 렌더링: 1
   └─ Virtual DOM 생성
      └─ 실제 DOM과 비교 (Reconciliation)
         └─ 변경된 부분만 업데이트
            └─ <p>카운트: 1</p> ✅

 

React의 Reconciliation (재조정)

// React 내부 로직 (단순화)
function updateComponent(Component, newProps) {
  // 1. 새로운 Virtual DOM 생성
  const newVDOM = Component(newProps);
  console.log('🆕 새 Virtual DOM:', newVDOM);

  // 2. 이전 Virtual DOM과 비교
  const diff = compareVDOM(oldVDOM, newVDOM);
  console.log('🔍 변경 사항:', diff);

  // 3. 변경된 부분만 실제 DOM 업데이트
  diff.forEach(change => {
    if (change.type === 'UPDATE_TEXT') {
      const element = document.querySelector(change.selector);
      element.textContent = change.newValue;
      console.log(`✏️  텍스트 업데이트: ${change.oldValue} → ${change.newValue}`);
    }
  });

  // 4. 이전 VDOM을 새 VDOM으로 교체
  oldVDOM = newVDOM;
}

 

 

 

 

원문

https://tkdodo.eu/blog/deriving-client-state-from-server-state

 

Deriving Client State from Server State

How to use derived state in React to keep client state and server data aligned without manual sync or effects.

tkdodo.eu

 

 

휴가에서 막 돌아왔는데, Zustand를 사용할 때 가장 큰 트레이드오프에 대한 Reddit 질문을 보게 되었다.
코드는 대략 이렇다(최신 문법으로 조금 수정하고, 커스텀 훅 안에 넣었다).

 

Manual-sync

const useSelectedUser = () => {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  const { selectedUserId, setSelectedUserId } = useUserStore()

  // If the selected user gets deleted from the server,
  // Zustand won't automatically clear selectedUserId
  // You have to manually handle this:
  useEffect(() => {
    if (!users?.some((u) => u.id === selectedUserId)) {
      setSelectedUserId(null) // Manual sync required
    }
  }, [users, selectedUserId])

  return [selectedUserId, selectedUserId]
}

 

물론, useEffect—특히 그 안에서 setState를 호출하는 경우—를 볼 때마다 나는 더 나은 해결책이 없을까 생각하게 된다.
내 경험상, 거의 항상 더 나은 방법이 존재하며, 보통 그 방법을 찾는 것이 가치 있는 일이다.
그러니 한 걸음 물러서서 우리가 먼저 무엇을 달성하고 싶은지를 먼저 살펴보자.

 

 

상태 동기화 유지하기 (Keeping State in Sync)

간단히 말하면, 우리는 클라이언트 상태(Client State)인 selectedUserId를 서버 상태(Server State)인 사용자 목록(users)과 동기화하고 싶다.
이것은 당연한 일이다.


예를 들어, useQuery를 통해 백그라운드에서 다시 데이터를 가져왔는데, 사용자가 목록에서 삭제되었음에도 여전히 상태에 저장되어 있다면, 그 선택은 유효하지 않다.

 

 

클라이언트 상태(Client State)

이 접근 방식은 Zustand에만 국한된 것이 아니다.
useUserStore()를 단순히 로컬 상태로 바꿔도 똑같이 적용할 수 있다:

const [selectedUserId, setSelectedUserId] = useState()

또는 URL 상태(nuqs 같은)를 사용해도 된다:

const [selectedUserId, setSelectedUserId] = useQueryState('userId')

중요한 건 상태가 어디에 저장되느냐가 아니라,
서버 상태(Server State)가 바뀔 때 업데이트해야 하는 클라이언트 상태(Client State)라는 점이다.

 

 

쿼리(Query)에는 onSuccess 콜백이 없고,
렌더 중에 setState를 호출하는 트릭은 React의 내장 상태에서만 동작하기 때문에,
사실상 남은 유일한 방법은 무시무시한 useEffect를 사용하는 것이다.

 

결국 user selection을 업데이트하려면 다른 방법이 있을까?

 

상태를 동기화하지 말고, 파생하라(Don’t Sync State – Derive It)

Kent C. Dodds가 쓴 글을 기억하는가?
그는 네 개의 서로 다른 useState를 하나로 줄이고, 나머지는 단일 진실의 출처(source of truth)에서 파생하는 방법을 보여주었다.

우리 상황에서도 비슷한 접근을 할 수 있다.
useEffect를 사용하는 방법은 다소 명령형(imperative) 사고 방식이다:

사용자가 변경되고, 현재 선택이 유효하지 않으면, 선택을 null로 재설정한다.

하지만 이 사고 방식을 조금 더 선언형(declarative)으로 바꿀 수 있다:

여기 백엔드에서 가져온 Users와 Current selection이 있다.
이 정보를 기반으로 실제 상태(real state)를 달라.

 

 

const useSelectedUser = () => {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  const { selectedUserId, setSelectedUserId } = useUserStore()

  const selectedId = users?.some((u) => u.id === selectedUserId)
    ? selectedUserId
    : null

  return [selectedId, setSelectedUserId]
}

 

이 코드는 매우 간단하다.
스토어 값을 직접 업데이트하는 대신, Selection 자체는 그대로 두고,
서버 상태(Server State)에서 더 이상 해당 id를 찾을 수 없는 경우 커스텀 훅에서 다른 값을 반환하도록 한다.


즉, useSelectedUser()를 호출하는 곳에서는 이전처럼 null을 받게 된다.

그리고 스토어를 건드리지 않기 때문에 추가적인 이점도 있다:

  • 사용자가 다시 목록에 추가되면, 선택(selection)이 자동으로 복원된다.
  • UX 요구사항이 바뀌어 선택을 제거하고 싶지 않고, 단지 선택이 유효하지 않음을 시각적으로 표시하고 싶을 때도 가능하다.
    → 원래 값을 항상 유지하기 때문에 쉽게 구현할 수 있다.

isSelectionValid

const useSelectedUser = () => {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  const { selectedUserId, setSelectedUserId } = useUserStore()
  const isSelectionValid = users?.some((u) => u.id === selectedUserId)

  return [selectedUserId, setSelectedUserId, isSelectionValid]
}

 

함정은 어디에 있을까요?

"상태 파생 솔루션"의 명백한 단점 중 하나는 사용자 저장소에 저장된 내용을 더 이상 "신뢰"할 수 없다는 것입니다. useUserStore에서 다른 곳의 selectedUserId를 읽어오면 추가 검사를 받지 못하므로 항상 사용자 정의 hook에서 읽어야 합니다.

저는 저장소를 최종 검증된 값의 소스라기보다는 UI에서 실제로 선택된 항목의 기록으로 보기 때문에 이 점에 대해서는 전혀 문제가 없습니다.

또, Reddit 질문에서 Redux Toolkit이 “이 문제를 해결한다”고 언급했지만, 실제로 크게 다르지 않을 것이다.
보통 API 슬라이스와 사용자 선택 슬라이스를 읽어 두 값을 결합하는 selector를 작성하게 되는데,
이것이 바로 우리가 커스텀 훅에서 하고 있는 방식과 동일하다.

 

오히려 이 방식은 상태를 파생(derive)하도록 유도하므로, 좋은 접근입니다.

 

다른 예시

서버 상태(Server State)가 바뀌어도 클라이언트 상태(Client State)를 업데이트하지 않는 개념은 여러 경우에 유용하다.
흔한 예시는 서버에서 가져온 기본값으로 폼을 미리 채울 때(prefilling forms)이다.

 

default-value-effect

function UserSelection() {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  const [selection, setSelection] = useState()

  // use the first value as default selection
  useEffect(() => {
    if (users?.[0]) {
      setSelection(users[0])
    }
  }, [users])

  // return markup
}

 

이 효과는 장황할 뿐만 아니라, 쿼리에서 새 데이터가 들어올 때 현재 선택 항목을 덮어쓰는 버그도 있습니다. 🐛

이 문제는 다른 검사를 추가하면 쉽게 해결할 수 있지만, 더 나은 해결책은 여전히 ​​상태를 파생하는 것입니다.

 

 

derived-default-value

function UserSelection() {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  const [selection, setSelection] = useState()

  const derivedSelection = selection ?? users?.[0]

  // return markup
}

 

 

지금 우리가 해야 할 일은 selection 대신 derivedSelection을 계속 사용하는 것뿐이며, 그렇게 하면 항상 원하는 값을 얻을 수 있습니다. 🚀

 

 

https://tkdodo.eu/blog/the-useless-use-callback

 

The Useless useCallback

Why most memoization is downright useless...

tkdodo.eu

원문

 

 

메모이제이션(memoization)에 대해서는 이제 충분히 다뤘다고 생각했는데, 요즘 자주 보이는 한 가지 패턴을 보면서 생각이 달라졌다.
그래서 오늘은 useCallback과, 부분적으로는 useMemo에 대해 이야기해보려 한다.
특히 내가 보기엔 정말 불필요하게 사용되는 경우들에 대해 말이다.

 

왜 메모이제이션을 사용할까?

보통 useCallback으로 함수를, useMemo로 값을 메모이제이션하는 이유는 단 두 가지뿐이다.

 

1. 성능 최적화(Performance optimization)

어떤 동작이 느릴 때, 우리는 보통 그것을 “나쁘다”고 느낀다. 이상적으로는 더 빠르게 만드는 게 좋지만, 항상 그렇게 할 수는 없다. 대신 그 느린 동작이 일어나는 횟수를 줄이는 방법을 시도할 수 있다.

React에서는 이런 “느린 동작”이 주로 하위 트리(sub-tree)의 리렌더링이다. 따라서 “굳이 필요하지 않다”고 판단되면 이를 피하고 싶어진다.

 

이런 이유로 가끔 React.memo로 컴포넌트를 감싸기도 한다. 물론 이는 대부분 “노력 대비 효과가 크지 않은 싸움”이지만, 그래도 존재하는 테크닉이다.

그런데 메모이제이션된 컴포넌트에 함수를 전달하거나, 원시 타입이 아닌 값을 props로 넘길 경우엔 참조(reference)가 안정적이어야 한다.
그 이유는 React가 Object.is를 사용해 props를 비교하고, 동일하다고 판단되면 해당 하위 트리의 리렌더링을 건너뛰기 때문이다.

만약 이 참조가 안정적이지 않다면 — 예를 들어, 매 렌더링마다 새롭게 생성되는 값이라면 — 우리의 메모이제이션은 깨지게 된다.

 

function Meh() {
  return (
    <MemoizedComponent
      value={{ hello: 'world' }}
      onChange={(result) => console.log('result')}
    />
  )
}

function Okay() {
  const value = useMemo(() => ({ hello: 'world' }), [])
  const onChange = useCallback((result) => console.log(result), [])

  return <MemoizedComponent value={value} onChange={onChange} />
}

 

물론 때로는 useMemo 내부의 계산 자체가 느릴 때가 있다.
이럴 땐 불필요한 재계산을 피하기 위해 메모이제이션을 사용하는데, 이런 경우의 useMemo 사용은 전혀 문제 없다.
하지만 솔직히 말하자면, 이런 경우가 대부분은 아니다.

 

 

2. Effect가 불필요하게 자주 실행되는 것을 막기 위해

메모이제이션된 값을 메모된 컴포넌트의 props로 넘기지 않더라도,
대부분의 경우 그 값은 결국 어떤 Effect의 의존성(dependency) 으로 전달된다.
(때로는 여러 단계의 커스텀 훅을 거쳐서 전달되기도 한다.)

Effect의 의존성 비교 방식은 React.memo와 동일하다.
React는 각 의존성을 Object.is로 하나씩 비교해, 값이 바뀌었다면 Effect를 다시 실행한다.

따라서 Effect의 의존성 값을 메모이제이션하지 않으면,
렌더링이 일어날 때마다 Effect가 매번 재실행되는 상황이 발생할 수 있다.

 

가만히 생각해보면, 이 두 가지 상황은 사실 완전히 같은 맥락이라는 걸 알 수 있다.
둘 다 “같은 참조(reference)를 유지해서 어떤 일이 일어나지 않도록 막는 것”이 목적이다.

즉, useCallback이나 useMemo를 사용하는 가장 공통된 이유는 결국 이거다:

 

 

참조의 안정성이 필요하다.
즉, 매 렌더링마다 값이나 함수가 새로 생성되지 않고 
같은 참조를 유지하도록 하는 것,
그게 바로 useCallback과 useMemo를 사용하는 핵심 이유다.

 

우리 모두 인생에서 어느 정도의 안정성(stability)은 필요하다고 생각한다.
하지만 처음에 말했듯이, 그 안정성을 굳이 추구할 필요가 없는 경우, 즉 ‘쓸데없는 안정성 추구’는 언제일까?

 


 

1.  메모이제이션이 없다고 해서 성능 향상이 사라지는 건 아니다

앞서 살펴본 예시에서, 아주 사소한 부분만 한번 바꿔보자.

 

no-memo

function Okay() {
  const value = useMemo(() => ({ hello: 'world' }), [])
  const onChange = useCallback((result) => console.log(result), [])

  return <Component value={value} onChange={onChange} />
}

 

차이점을 찾을 수 있는가? 맞다 — 이제 value와 onChange를 메모이제이션된 컴포넌트에 전달하지 않는다.
그냥 일반적인 React 함수형 컴포넌트일 뿐이다.

이런 상황은 특히 결국엔 값이 React의 기본(built-in) 컴포넌트로 전달될 때 자주 발생한다:

 

react-built-ins

function MyButton() {
  const onClick = useCallback(
    (event) => console.log(event.currentTarget.value),
    []
  )

  return <button onClick={onClick} />
}

 

 

여기서 onClick을 메모이제이션해도 아무런 효과가 없다.
왜냐하면 버튼 컴포넌트는 onClick이 참조가 안정적인지 여부를 신경 쓰지 않기 때문이다.

 

“아무 효과가 없다(Achieves nothing)”라고 표현했지만, 엄밀히 말하면 약간 잘못된 표현이다. 사실 내부적으로는 분명 무언가 일이 일어나고 있다.

React는 onClick 함수를 유지하기 위해 캐시를 만들어야 하고, 의존성을 추적하며 매 렌더링마다 비교를 수행한다. useCallback에 전달된 인라인 함수도 매 렌더마다 새로 생성되지만, 캐시된 버전이 반환되면 즉시 버려진다.

결과적으로, 기술적으로는 내부에 약간의 오버헤드가 생긴다. 하지만 나는 이 오버헤드에 너무 집중하고 싶진 않다. 왜냐하면 진짜 문제는 그게 아니기 때문이다.

 

 

즉, 사용자 정의 컴포넌트가 메모이제이션되어 있지 않다면, 참조 안정성(referential stability)은 사실 신경 쓸 필요가 없다는 뜻이다.

 

하지만 잠깐 — 만약 그 컴포넌트가 props를 내부에서 useEffect의 의존성으로 사용하거나, 다른 메모이제이션된 값을 만들어 그 값을 자식 컴포넌트에 전달한다면 어떻게 될까?


이 경우, 지금 useMemo나 useCallback을 제거하면 , 기존 메모이제이션을 제거하면 무언가가 깨질 수도 있다.

바로 이 점이 두 번째 포인트로 이어진다.

 

2. props를 의존성으로 사용할 때

컴포넌트에 전달받은 원시 타입이 아닌(non-primitive) 비원시값 props를 내부 useEffect나 useMemo 등의 의존성 배열에 그대로 넣는 건은 거의 대부분 적절하지 않다.


왜냐하면 이 컴포넌트는 그 props의 참조 안정성에 대해 어떤 통제권도 없기 때문이다.

흔한 예시는 다음과 같다.

 

props-as-dependencies

function OhNo({ onChange }) {
  const handleChange = useCallback((e: React.ChangeEvent) => {
    trackAnalytics('changeEvent', e)
    onChange?.(e)
  }, [onChange])

  return <SomeMemoizedComponent onChange={handleChange} />
}

 

 

이 useCallback은 아마 쓸모가 없거나,
기껏해야 이 컴포넌트를 사용하는 쪽이 어떻게 사용하느냐에 따라 달라질 뿐이다.

대부분의 경우, 호출하는 쪽에서 그냥 인라인 함수를 호출할 가능성이 높다.

 

<OhNo onChange={() => props.doSomething()} />​

 

 

이건 무해한 사용 사례다. 전혀 문제될 게 없다.
사실 오히려 좋은 패턴이라고 볼 수 있다.
이벤트 핸들러와 관련된 동작을 한곳에 모아두어, 파일 상단으로 끌어올려서 길게 이름 붙인 handleChange 같은 함수를 만드는 일을 피할 수 있다.

 

이 코드를 작성한 개발자가 메모이제이션이 깨진다는 사실을 알 수 있는 유일한 방법은, 컴포넌트 내부를 깊이 파고 들어가 props가 어떻게 사용되는지 확인하는 것뿐이다.
솔직히 말해, 그건 끔찍하다.

 

다른 해결책으로는 “모든 걸 항상 메모이제이션한다”라는 정책을 세우거나,
참조 안정성이 필요한 props에는 mustBeMemoized 같은 엄격한 네이밍 규칙을 적용하는 방법이 있다.
하지만 이 둘 다 좋은 방법은 아니다.

 

실제 사례(Real Life Example)

지금 나는 Sentry 코드베이스에서 작업하고 있는데, 이건 오픈소스라서 🎉 실제 사용 사례를 쉽게 보여줄 수 있다.

내가 발견한 한 가지 상황은 useHotkeys 커스텀 훅이다.
핵심 부분은 대략 이렇게 생겼다:

 

useHotkeys

export function useHotkeys(hotkeys: Hotkey[]): {
  const onKeyDown = useCallback(() => ..., [hotkeys])

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [onKeyDown])
}

 

 

이 커스텀 훅은 hotKeys 배열을 인자로 받고,
그걸 기반으로 onKeyDown 함수를 메모이제이션한 뒤, 해당 함수를 effect에 넘긴다.

 

함수를 메모이제이션하는 이유는 명백하다: Effect가 너무 자주 실행되는 것을 방지하기 위해서다.


하지만 입력값인 hotkeys가 배열이기 때문에, 사용하는 쪽에서 직접 hotkeys를 메모이제이션해주어야 한다.

 

그래서 나는 useHotkeys가 실제 코드에서 어떻게 쓰이고 있는지 전수 조사를 해봤다.
놀랍게도 거의 대부분(한 곳을 제외하고)은 입력값을 메모이제이션하고 있었다.

 

하지만 이야기는 여기서 끝나지 않는다.
더 깊이 들어가 보면, 결국에는 여전히 문제가 생기기 쉬운 구조라는 걸 알 수 있다.
예를 들어, 다음과 같은 사용 사례를 보자.

 

 

paginateHotKeys

const paginateHotkeys = useMemo(() => {
  return [
    { match: 'right', callback: () => paginateItems(1) },
    { match: 'left', callback: () => paginateItems(-1) },
  ]
}, [paginateItems])

useHotkeys(paginateHotkeys)

 

 

useHotKeys는 메모이제이션된 paginateHotkeys를 전달한다.
그런데 이 paginateHotkeys는 paginateItems에 의존한다.

그럼 paginateItems는 어디서 올까?


바로 또 다른 useCallback이고, 이 콜백은 screenshots와 currentAttachmentIndex에 의존한다.

그렇다면 screenshots는 어디서 오는 걸까?

 

breaking-memoization

const screenshots = attachments.filter(({ name }) =>
  name.includes('screenshot')
)

 

screenshots는 메모이제이션되지 않은 attachments.filter 함수에서 생성된다.
이 함수는 매번 새로운 배열(Array)을 생성하므로, downstream에 있는 모든 메모이제이션이 깨진다.

결과적으로 모든 메모이제이션이 무용지물이 된다.


즉, paginateItems, paginateHotkeys, onKeyDown
이 세 가지 메모이제이션이 매 렌더링마다 다시 실행되며,
마치 아예 작성하지 않은 것과 동일한 상황이 된다! (이렇게 되면 우리가 했던 메모이제이션은 전부 무용지물이 된다)

 

 

이 예시가 내가 왜 메모이제이션 적용에 반대하는 입장인지 잘 보여줬으면 한다.
내 경험상, 메모이제이션은 너무 자주 깨지고, 그럴 가치가 없다.


게다가 코드 전체에 엄청난 오버헤드와 복잡성을 추가한다.

여기서 해결책이 screenshots까지 메모이제이션하는 것은 아니다.


그렇게 하면 책임이 결국 컴포넌트의 prop인 attachments로 넘어가게 된다.
세 가지 호출 지점(call-side) 모두에서 실제로 메모이제이션이 필요한 useHotkeys와는 최소 두 단계 이상 떨어져 있게 된다.


이건 코드를 탐색하기 악몽 같은 상황을 만들며, 결국 아무도 메모이제이션 하나를 제거하지 못하게 된다.
왜냐하면 그 메모이제이션이 실제로 무엇을 하고 있는지 알 수 없기 때문이다.

실제로는, 이런 문제를 해결하려면 컴파일러에게 모든 걸 맡기는 것이 가장 좋다.


모든 환경에서 잘 작동한다면 정말 훌륭하다.
하지만 그 전까지는, 참조 안정성(referential stability) 필요성의 한계를 우회할 패턴을 찾아야 한다.

 


 

 

최신 Ref 패턴(The Latest Ref Pattern)

이 패턴에 대해서는 이전에 언급한 적이 있다.
핵심 아이디어는, Effect 안에서 즉시 접근하고 싶은 값을 ref 안에 저장해두고,
또 다른 Effect를 통해 의도적으로 매 렌더링마다 값을 업데이트하는 것이다.

 

the-latest-ref

export function useHotkeys(hotkeys: Hotkey[]): {
  const hotkeysRef = useRef(hotkeys)

  useEffect(() => {
    hotkeysRef.current = hotkeys
  })

  const onKeyDown = useCallback(() => ..., [])

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [])
}

 

 

그렇게 하면 hotkeysRef를 Effect 안에서 바로 사용할 수 있다.
의존성 배열에 추가할 필요도 없고, 단순히 린터를 무시하다가 발생할 수 있는 stale closure 문제도 걱정하지 않아도 된다.

실제로 React Query도 이 패턴을 사용한다.


예를 들어 PersistQueryClientProvider나 useMutationState에서 최신 옵션(latest options)을 추적할 때 그렇다.
즉, 검증된(tried-and-true) 패턴이라고 볼 수 있다.
만약 라이브러리가 사용자가 옵션을 직접 메모이제이션하도록 강제한다면 얼마나 귀찮을지 상상해보라.

 

 

UseEffectEvent

좋은 소식이 하나 더 있다.
React 팀은 대부분의 경우, 리액티브 Effect 안에서 최신 값을 명령형(imperative)으로 접근할 필요가 있지만, Effect를 명시적으로 다시 실행하고 싶지 않은 상황이 있다는 것을 인식했다.

그래서 이 패턴을 위한 1급 프리미티브(first-class primitive), useEffectEvent를 새로 추가할 예정이다.

 

이 훅이 도입되면, 기존 코드를 보다 깔끔하고 안전하게 리팩터링할 수 있게 된다:

 

the-latest-ref

export function useHotkeys(hotkeys: Hotkey[]): {
  const onKeyDown = useEffectEvent(() => ...)

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [])
}

 

 

이렇게 하면 onKeyDown은 리액티브하지 않게 만들 수 있다. 하지만 여전히 hotkeys의 최신 값을 항상 “볼 수 있게” 되며, 렌더 사이에서도 참조 안정성(referential stability) 을 유지한다.

즉, useCallback이나 useMemo를 단 하나도 쓰지 않고도, 모든 장점을 동시에 얻을 수 있게 되는 것이다.

 

 

정리

단순히 성능 최적화나 안정성을 이유로 무작정 useMemo/useCallback을 사용하는 것은 대부분 불필요하거나 오히려 문제를 만든다.
대신 Ref를 활용하거나 새로운 React 패턴을 사용해 최신 값을 안전하게 참조하는 방법을 쓰는 게 좋다.

 

 

 

nuqs는 Next.js에서 URL 쿼리 파라미터를 상태처럼 쉽게 관리할 수 있게 해주는 라이브러리이다.

오늘은 'nuqs'의 useQueryState에 대해 알아볼 것이다.

 

 

기본 개념

useQueryState는 React의 useState와 비슷하게 동작하지만, 상태를 컴포넌트 내부가 아닌 URL 쿼리 파라미터에 저장합니다.

 

import { useQueryState } from 'nuqs'

function SearchComponent() {
  const [search, setSearch] = useQueryState('search')
  
  // URL: /?search=hello
  // search 값: "hello"
  
  return (
    <input 
      value={search || ''} 
      onChange={(e) => setSearch(e.target.value)}
    />
  )
}

 

주요 특징

URL과 자동 동기화

  • 상태가 변경되면 URL이 자동으로 업데이트됩니다
  • 뒤로가기/앞으로가기 버튼이 정상 작동합니다
  • URL을 공유하면 같은 상태를 공유할 수 있습니다

타입 안전성

  • 파서(parser)를 사용해 타입을 지정할 수 있습니다
import { useQueryState, parseAsInteger, parseAsBoolean } from 'nuqs'

const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
const [isActive, setIsActive] = useQueryState('active', parseAsBoolean)

 

 

기본값 설정

const [sort, setSort] = useQueryState('sort', { defaultValue: 'name' })

 

 

여러 파라미터 동시 관리

import { useQueryStates } from 'nuqs'

const [filters, setFilters] = useQueryStates({
  search: parseAsString,
  page: parseAsInteger.withDefault(1),
  category: parseAsString
})

// 여러 값을 한 번에 업데이트
setFilters({ search: 'test', page: 1 })

 

 

실제 사용 예시

검색과 필터링이 있는 상품 목록:

function ProductList() {
  const [search, setSearch] = useQueryState('search')
  const [category, setCategory] = useQueryState('category')
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))

  return (
    <div>
      <input 
        value={search || ''} 
        onChange={(e) => setSearch(e.target.value)}
        placeholder="검색..."
      />
      
      <select 
        value={category || ''} 
        onChange={(e) => setCategory(e.target.value)}
      >
        <option value="">전체</option>
        <option value="electronics">전자제품</option>
        <option value="clothing">의류</option>
      </select>
      
      {/* URL: /?search=laptop&category=electronics&page=2 */}
    </div>
  )
}

 

 

장점

  • SEO 친화적: 필터링된 상태가 URL에 있어 크롤러가 인덱싱 가능
  • 공유 가능: URL만으로 정확한 페이지 상태 공유
  • 새로고침 안전: 페이지를 새로고침해도 상태 유지
  • 브라우저 히스토리: 뒤로가기/앞으로가기 지원

Next.js의 App Router와 Pages Router 모두에서 사용할 수 있으며, 특히 검색, 필터링, 페이지네이션 같은 기능을 구현할 때 매우 유용합니다.

 

 

올바른 접근: 1차원 DP


각 물건을 A에게 맡기면 A 흔적은 +a, B는 그대로

B에게 맡기면 B 흔적은 +b, A는 그대로

모든 물건을 처리했을 때 A의 누적 흔적 < n, B의 누적 흔적 < m을 만족하면서 A의 흔적 합을 최소화해야 합니다


function solution(info, n, m) {
  const INF = m + 1; // m 이상이면 이미 실패 상태라 간단히 컷
  // dp[a] = A 흔적이 a일 때 가능한 B의 최소 흔적
  let dp = new Array(n).fill(INF);
  dp[0] = 0;

  for (const [aTrace, bTrace] of info) {
    const next = new Array(n).fill(INF);
    for (let a = 0; a < n; a++) {
      const b = dp[a];
      if (b >= m) continue; // 이미 실패 상태는 확장하지 않음

      // 1) 이 물건을 A가 훔치는 경우
      const na = a + aTrace;
      if (na < n) {
        if (next[na] > b) next[na] = b;
      }

      // 2) 이 물건을 B가 훔치는 경우
      const nb = b + bTrace;
      if (nb < m) {
        if (next[a] > nb) next[a] = nb;
      }
    }
    dp = next;
  }

  // A의 누적 흔적을 최소화해서 찾기
  for (let a = 0; a < n; a++) {
    if (dp[a] < m) return a;
  }
  return -1;
}


1. 문제 핵심

- 각 물건은 A가 훔치거나 B가 훔치거나 둘 중 한 명만 훔칠 수 있음
- A, B가 남기는 흔적은 각각 누적되고,
A의 흔적 < n, B의 흔적 < m 조건을 항상 만족해야 함
- 목표: 모든 물건을 훔친 뒤 A의 흔적 합을 최소화

즉, “각 물건을 누구에게 맡기느냐”라는 선택의 조합 문제이고,
모든 경우를 다 따지면 2^(물건 개수)라서 불가능
그래서 DP로 최적값을 구한다


2. DP 정의


우리가 궁금한 건 “어떤 A의 흔적 합이 가능하냐?”예요.
그래서 이렇게 정의합니다

dp[a] = A의 흔적 합이 정확히 a일 때, 가능한 B의 최소 흔적 합

- a는 0 ~ n-1 (n 이상이면 경찰에게 잡힘)
- dp[a] 값이 m 이상이면 의미 없음(잡히거나 불가능 상태)


3. 초기 상태

아직 아무 물건도 훔치지 않았을 때

dp[0] = 0  // A 흔적 0, B 흔적 0
dp[다른 값] = 불가능 (m+1 같은 큰 값으로 설정)


4. 점화식 (물건 하나씩 배정)


물건 [aTrace, bTrace]를 만났을 때 선택은 2가지

1. A가 훔치는 경우
• 새로운 A 흔적: na = a + aTrace
• 새로운 B 흔적: 그대로 b
• 단, na < n 이어야 함

next[na] = min(next[na], b)


2. B가 훔치는 경우
• 새로운 A 흔적: 그대로 a
• 새로운 B 흔적: nb = b + bTrace
• 단, nb < m 이어야 함

next[a] = min(next[a], nb)



즉, 한 물건을 A에게 맡기거나 B에게 맡길 때 상태를 업데이트해 줍니다

5. 최종 결과


모든 물건을 처리한 후

dp[a] < m 인 a 중에서 가장 작은 a가 정답.
없다면 -1



멀티 스레드 구현: Web Worker와 WebAssembly

비동기로 멀티 스레드를 구현한다? 많이들 async/await만 쓰면 작업이 “동시에” 돈다고 생각합니다. 하지만 비동기는 하나의 메인 스레드가 일을 잘게 쪼개 순서 없이 처리해 보이는 기법이지, 실제로 여러 스레드가 병렬로 계산하는 건 아닙니다.

UI가 멈추지 않게 하는 데는 비동기로도 충분하지만, 대용량 데이터 처리나 이미지/영상 변환처럼 CPU를 잡아먹는 작업은 결국 메인 스레드를 묶어버립니다.

1. 멀티 스레드가 필요한 이유

자바스크립트는 기본적으로 싱글 스레드 언어입니다. 즉, 한 번에 하나의 작업만 처리할 수 있습니다. 하지만 복잡한 계산, 대규모 데이터 처리, 이미지 변환 같은 작업은 시간이 오래 걸려 UI가 멈출 수도 있습니다. 이런 문제를 해결하기 위해 멀티 스레드 개념을 활용할 수 있는데요.

2. Web Worker로 동시성 구현

Web Worker는 별도의 스레드에서 자바스크립트를 실행할 수 있게 해주는 기능입니다. 메인 스레드(UI)와 독립적으로 동작하므로 무거운 연산을 맡기기 좋습니다.

// worker.js (별도의 파일)
self.onmessage = function(event) {
  let num = event.data;
  let result = 0;
  for (let i = 0; i < num; i++) {
    result += i;
  }
  self.postMessage(result);
};
// main.js
const worker = new Worker("worker.js");
worker.postMessage(1000000); // Worker에게 작업 요청

worker.onmessage = (e) => {
  console.log("결과:", e.data);
};



Web Worker를 사용하면 UI 멈춤 없이 무거운 계산을 처리할 수 있습니다.

3. WebAssembly를 활용한 멀티 스레드

WebAssembly(WASM)는 브라우저에서 동작하는 저수준 이진 코드 포맷입니다. 성능이 중요한 연산을 자바스크립트 대신 C/C++/Rust 같은 언어로 작성한 뒤, WebAssembly로 컴파일해서 실행할 수 있습니다.

// 예시: C 코드 (sum.c)
int sum(int n) {
  int result = 0;
  for (int i = 0; i < n; i++) {
    result += i;
  }
  return result;
}

이 코드를 emscripten 같은 도구로 WebAssembly로 컴파일하면 자바스크립트에서 고성능으로 실행할 수 있습니다.

4. Web Worker vs WebAssembly 비교

구분 Web Worker WebAssembly
주요 목적 UI와 분리된 비동기 실행 고성능 연산 (C/C++/Rust 활용)
장점 JS만으로 멀티 스레드 가능 네이티브에 가까운 성능
단점 성능은 JS 한계 내 학습/빌드 과정이 필요

웹 환경에서도 멀티 스레드를 활용할 수 있는 방법은 다양합니다. 간단한 동시성 처리는 Web Worker, 고성능 연산은 WebAssembly를 선택하는 것이 좋습니다. 프로젝트 성격에 맞춰 두 기술을 적절히 조합하면 사용자 경험을 크게 개선할 수 있습니다.

프론트엔드 개발 환경은 지난 몇 년 동안 눈부시게 발전했습니다. 과거에는 단순히 <script> 태그로 JS 파일을 불러오면 되었지만, 규모가 커지고 모듈 단위 개발이 보편화되면서 “모듈 번들러(Module Bundler)”가 필수 도구가 되었습니다. 이번 글에서는 대표적인 모듈 번들러인 Webpack, Rollup.js, 그리고 차세대 번들링 도구로 각광받는 Vite에 대해 비교해 보겠습니다.


1. 모듈 번들러란

모듈 번들러는 여러 개의 JavaScript, CSS, 이미지 등의 파일을 의존성 그래프(Dependency Graph)로 분석해 하나의 파일(또는 최적화된 여러 파일)로 묶어주는 도구입니다.

목적: 브라우저가 효율적으로 로드할 수 있도록 최적화된 번들을 만들어주는 것.

왜 필요한가?

👀속도 개선: 파일이 많으면 브라우저가 하나씩 요청해야 해서 느려요. 번들러가 파일을 합쳐서 요청을 줄여줍니다.
👀최적화: 쓰이지 않는 코드를 제거(트리 쉐이킹)하거나, 코드를 압축해 용량을 줄여줍니다.
👀개발 편의성: 최신 문법(ES6, TypeScript 등)을 구버전 브라우저에서도 쓸 수 있도록 변환해 줍니다.


2. 주요 모듈 번들러

이제 많이 쓰이는 3가지 도구를 비교해볼게요

Webpack

가장 유명하고 오래된 번들러
설정이 강력하지만 복잡하다는 단점이 있다
entry → loader → plugin → output 구조로 동작.

대규모 프로젝트에서 안정적이고 확장성이 높습니다.

예시
React, Vue 같은 프레임워크의 기본 번들러로 많이 쓰였습니다.
복잡한 빌드(이미지, CSS, JS, TS 다 합치기)에 유리.

Rollup.js

라이브러리 제작에 강점.
Webpack보다 단순하고 가볍습니다.
트리 쉐이킹(tree shaking) 기능이 강력해서, 쓰지 않는 코드가 거의 남지 않아요.

예시:
Lodash, D3.js 같은 유명한 JS 라이브러리도 Rollup으로 빌드됩니다.
라이브러리 제작할 때는 Webpack보다 Rollup이 더 선호됩니다.


Vite

차세대 번들러로, 최근 가장 핫합니다.

내부적으로 Rollup을 기반으로 하지만, 개발 서버는 ESBuild(Go 언어로 만든 초고속 번들러)를 사용


🔴장점: 개발 서버 속도가 엄청 빠름 (변경된 부분만 즉시 반영 → HMR)

🔴단점: 아직은 Webpack만큼 플러그인 생태계가 방대하지 않음


예시:
Vue 3, React 최신 템플릿에서 기본 빌드 도구로 채택됨
스타트업이나 개인 프로젝트에서 빠르게 개발할 때 최적


회사에서도 기존에 React + Webpack 기반으로 운영하던 서비스를 Vite로 마이그레이션한 경험이 있습니다. 프로젝트가 무거워지면서 Webpack 환경에서 빌드 속도가 오래걸려 초기 개발 서버를 시작하는데에 10분 정도가 걸리는 아주 힘든 상황이 생겨버린거죠! HMR 속도도 개선하기 위함과 최신 생태계와 호환성을 고려해서 논의 결과 vite로 마이그레이션하기로 결정을 했습니다



그 결과, 개발 서버 구동 속도가 1분 미만으로 단축, HMR속도는 말할 것도 없이 현저히 빨라졌습니다.

결론적으로, Vite는 Webpack 대비 훨씬 가볍고 빠른 개발 경험을 제공했고, 팀의 생산성에도 긍정적인 효과를 줬습니다.


https://www.felgus.dev/blog/future-spa



최근 프론트엔드 생태계에서 가장 뜨거운 주제 중 하나는 서버 컴포넌트(Server Components) 와 서버 렌더링입니다. Next.js와 같은 메타 프레임워크가 각광받으면서 “SPA는 이제 뒤쳐지는 게 아닐까?”라는 논의도 자주 등장하죠.


하지만 단순한 SPA(Single Page Application), 즉 정적 HTML과 JS 파일 하나로 시작하는 애플리케이션도 여전히 강력한 경쟁력을 가집니다. 오히려 새로운 React 기능들을 적절히 활용하면 기존 SPA의 한계를 극복하고 훨씬 매끄러운 사용자 경험(UX)을 제공할 수 있습니다.

이 글에서는 SPA에서 적용할 수 있는 최신 전략들을 살펴보겠습니다.

1. Render-on-Fetch에서 Render-as-You-Fetch로


전통적인 SPA는 보통 다음 단계로 동작합니다:

1. HTML 요청
2. JS 다운로드 후 React 초기화
3. 컴포넌트 렌더링 시작
4. 컴포넌트 안에서 fetch 호출
5. Suspense fallback UI 표시
6. 응답 도착 후 실제 UI 렌더링

이 방식은 단순하지만 네트워크 워터폴이 생기며 UI 표시가 지연됩니다.

반면 Render-as-You-Fetch 패턴을 사용하면 컴포넌트 렌더링 직전에 라우트 로더(Route Loader) 를 실행하여 데이터를 더 빨리 요청할 수 있습니다.


즉, 필요한 데이터를 가져오는 과정과 UI 렌더링을 맞물려 동시에 진행하므로 초기 반응 속도가 개선됩니다.

React Router v7, Tanstack Router 같은 최신 라우터가 이 패턴을 지원합니다.

2. 서버 컴포넌트의 정적 활용

서버 컴포넌트는 이름 그대로 서버에서 렌더링해야 할 것처럼 들리지만, 꼭 그렇지는 않습니다.

빌드 단계에서 미리 생성한 정적 Server Component Payload를 활용해 SPA 안에 포함시킬 수도 있습니다.
예를 들어, 세션 상태나 사용자 입력과 무관한 컴포넌트는 빌드 시점에 미리 만들어 두고, SPA 실행 시 필요한 순간 불러오는 방식입니다.
→ 이렇게 하면 서버 환경이 없어도 SPA 성능과 초기 로딩 경험을 개선할 수 있습니다.

3. Concurrent Features: 더 매끄러운 인터랙션


React 18에서 도입된 useTransition, useDeferredValue 같은 동시성 기능은 단순히 “대형 서비스 전용”이 아닙니다.
프론트엔드에서의 규모(scale) 는 사용자 수가 아니라 “페이지, 상호작용, 정보 흐름의 복잡도”로 정의됩니다.
SPA에서도 사용자는 아무 때나 다양한 인터랙션을 수행할 수 있기 때문에, 업데이트의 우선순위를 명확히 지정하지 않으면 UX가 쉽게 끊기게 됩니다.

동시성 기능을 활용하면 React가 높은 우선순위 작업부터 처리하도록 지시할 수 있고, 그 결과 더 부드러운 화면 전환과 반응성을 확보할 수 있습니다.

4. Preload를 통한 선제적 사용자 경험

코드 스플리팅과 지연 로딩은 이미 흔한 기법입니다. 하지만 여기서 더 나아가 SPA 라우터의 Link 컴포넌트를 활용하면 성능을 또 한 단계 개선할 수 있습니다.

예를 들어, 사용자가 특정 링크 위에 마우스를 올려놓은 순간, 라우터가 해당 페이지의 데이터와 코드 스플릿된 JS를 미리 요청하도록 하는 것입니다.

이렇게 하면 실제 페이지 전환 시 이미 준비된 콘텐츠가 즉시 표시되어, 마치 네이티브 앱처럼 반응하는 UX를 제공할 수 있습니다.

5. React Labs의 신기능: ViewTransition & Activity

React 팀이 발표한 실험적 기능 중 두 가지가 SPA UX에 특히 주목할 만합니다:
ViewTransition
새로운 웹 표준으로, 페이지 전환 시 애니메이션을 더 부드럽게 적용할 수 있도록 React와 브라우저 API 간의 간극을 메워줍니다.
→ 결과적으로 화면 전환 경험이 훨씬 자연스러워집니다.
Activity (구 Offscreen)
렌더링과 커밋 과정 사이에 “미리 UI 스냅샷을 준비해두는” 기능입니다.

즉, 컴포넌트를 보이지 않는 상태로 렌더링해둔 뒤, 필요할 때 즉시 화면에 적용할 수 있습니다.
→ 전환 시점에서 기다림 없이 곧바로 새로운 화면을 보여줄 수 있습니다.

이 기능을 Preload 전략과 함께 사용하면, 페이지 전환 속도 및 연속성이 대폭 개선됩니다.

결론: SPA는 더 부드럽게, 더 빠르게
SPA가 서버 렌더링 프레임워크에 밀려 사라질 거라고 생각하기 쉽지만, 오히려 반대입니다.
Render-as-you-fetch 패턴
• Server Component의 정적 활용
• Concurrent Features로 부드러운 인터랙션
• Preload & Activity & ViewTransition으로 네이티브 같은 전환 경험

이 전략들을 결합하면 SPA는 지금보다 훨씬 강력하고 세련된 UX를 제공할 수 있습니다.

📌 즉, SPA의 미래는 성능과 UX를 모두 갖춘 “더 똑똑한 클라이언트 사이드 렌더링”입니다.

+ Recent posts