<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>맞왜틀</title>
    <link>https://deok2kim.tistory.com/</link>
    <description>코딩 기록하기</description>
    <language>ko</language>
    <pubDate>Mon, 1 Jun 2026 01:08:28 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>deo2kim</managingEditor>
    <image>
      <title>맞왜틀</title>
      <url>https://tistory1.daumcdn.net/tistory/3734788/attach/e9c8295ceaab46d39390cda5ad181ce1</url>
      <link>https://deok2kim.tistory.com</link>
    </image>
    <item>
      <title>Playwright E2E 테스트 추가하기</title>
      <link>https://deok2kim.tistory.com/439</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;투두 앱에 Playwright E2E 테스트 추가하기&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.8;
            max-width: 900px;
            margin: 0 auto;
            padding: 20px;
            color: #333;
        }
        h1 {
            color: #2c3e50;
            border-bottom: 3px solid #3498db;
            padding-bottom: 10px;
        }
        h2 {
            color: #34495e;
            margin-top: 40px;
            border-left: 4px solid #3498db;
            padding-left: 15px;
        }
        h3 {
            color: #7f8c8d;
            margin-top: 30px;
        }
        code {
            background: #f8f9fa;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: 'Monaco', 'Courier New', monospace;
            font-size: 0.9em;
        }
        pre {
            background: #282c34;
            color: #abb2bf;
            padding: 20px;
            border-radius: 8px;
            overflow-x: auto;
            font-size: 0.9em;
        }
        pre code {
            background: none;
            padding: 0;
            color: #abb2bf;
        }
        .highlight {
            background: #fff3cd;
            padding: 15px;
            border-left: 4px solid #ffc107;
            margin: 20px 0;
        }
        .success {
            background: #d4edda;
            padding: 15px;
            border-left: 4px solid #28a745;
            margin: 20px 0;
        }
        .info {
            background: #d1ecf1;
            padding: 15px;
            border-left: 4px solid #17a2b8;
            margin: 20px 0;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 20px 0;
        }
        th, td {
            padding: 12px;
            text-align: left;
            border-bottom: 1px solid #ddd;
        }
        th {
            background: #3498db;
            color: white;
        }
        tr:hover {
            background: #f5f5f5;
        }
        .commit-list {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 8px;
            margin: 20px 0;
        }
        .commit-item {
            padding: 10px 0;
            border-bottom: 1px solid #e9ecef;
        }
        .commit-item:last-child {
            border-bottom: none;
        }
        .commit-hash {
            color: #6c757d;
            font-family: monospace;
            margin-right: 10px;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;h1&gt;  투두 앱에 Playwright E2E 테스트 추가하기&lt;/h1&gt;

&lt;h2&gt;  작업 개요&lt;/h2&gt;

&lt;p&gt;
레트로 스타일의 투두 앱에 Playwright를 활용한 E2E(End-to-End) 테스트를 추가했습니다. 
접근성(Accessibility)과 테스트 가능성을 함께 개선했습니다.
&lt;/p&gt;

&lt;h2&gt;  주요 작업 내용&lt;/h2&gt;

&lt;h3&gt;2. 접근성(Accessibility) 개선&lt;/h3&gt;

&lt;p&gt;스크린 리더 사용자와 E2E 테스트를 위해 &lt;code&gt;aria-label&lt;/code&gt; 속성을 추가했습니다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// TodoInput.tsx
&amp;lt;Input
  placeholder=&quot;오늘 할 일을 입력하세요...&quot;
  aria-label=&quot;할 일 입력&quot;
/&amp;gt;

&amp;lt;Button onClick={handleSubmit} aria-label=&quot;할 일 추가&quot;&amp;gt;
  ADD
&amp;lt;/Button&amp;gt;&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;// TodoItem.tsx
&amp;lt;Checkbox
  onClick={() =&gt; onToggleTodo(todo.id)}
  aria-label={todo.completed ? '할 일 완료 취소' : '할 일 완료'}
&amp;gt;
  {todo.completed ? '✓' : ''}
&amp;lt;/Checkbox&amp;gt;

&amp;lt;Button
  onClick={() =&gt; onRemoveTodo(todo.id)}
  aria-label={`${todo.title} 삭제`}
&amp;gt;
  DEL
&amp;lt;/Button&amp;gt;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;highlight&quot;&gt;
&lt;strong&gt;  핵심 포인트:&lt;/strong&gt;&lt;br&gt;
삭제 버튼의 &lt;code&gt;aria-label&lt;/code&gt;에 투두 제목을 포함시켜 &lt;strong&gt;&quot;아침 운동하기 삭제&quot;&lt;/strong&gt;처럼 
어떤 항목을 삭제하는지 명확하게 표현했습니다. 이는 접근성뿐만 아니라 
테스트에서 특정 항목을 정확히 선택할 수 있게 해줍니다.
&lt;/div&gt;

&lt;h3&gt;3. Playwright E2E 테스트 작성&lt;/h3&gt;

&lt;p&gt;총 &lt;strong&gt;17개의 테스트 케이스&lt;/strong&gt;를 작성했습니다.&lt;/p&gt;

&lt;h4&gt;테스트 카테고리&lt;/h4&gt;

&lt;table&gt;
    &lt;thead&gt;
        &lt;tr&gt;
            &lt;th&gt;카테고리&lt;/th&gt;
            &lt;th&gt;테스트 수&lt;/th&gt;
            &lt;th&gt;주요 테스트&lt;/th&gt;
        &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
        &lt;tr&gt;
            &lt;td&gt;초기 화면&lt;/td&gt;
            &lt;td&gt;4개&lt;/td&gt;
            &lt;td&gt;제목, 빈 상태, 입력창, 통계 표시&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;투두 추가&lt;/td&gt;
            &lt;td&gt;5개&lt;/td&gt;
            &lt;td&gt;버튼 클릭, Enter 키, 입력창 초기화, 여러 개 추가, 빈 문자열 검증&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;투두 완료&lt;/td&gt;
            &lt;td&gt;3개&lt;/td&gt;
            &lt;td&gt;완료 처리, 완료 취소, 일부만 완료&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;투두 삭제&lt;/td&gt;
            &lt;td&gt;3개&lt;/td&gt;
            &lt;td&gt;삭제, 특정 항목만 삭제, 완료된 항목 삭제&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;통계&lt;/td&gt;
            &lt;td&gt;3개&lt;/td&gt;
            &lt;td&gt;추가/완료/삭제 시 통계 업데이트&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;통합 시나리오&lt;/td&gt;
            &lt;td&gt;1개&lt;/td&gt;
            &lt;td&gt;추가 → 완료 → 삭제 전체 플로우&lt;/td&gt;
        &lt;/tr&gt;
    &lt;/tbody&gt;
&lt;/table&gt;

&lt;h4&gt;테스트 코드 구조&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;test.describe('Todo App', () =&gt; {
  // 헬퍼 함수로 중복 제거
  const getTodoInput = (page: Page) =&gt;
    page.getByRole('textbox', { name: '할 일 입력' });

  const addTodo = async (page: Page, todoText: string) =&gt; {
    const input = getTodoInput(page);
    await input.fill(todoText);
    await page.getByRole('button', { name: '할 일 추가' }).click();
  };

  test.beforeEach(async ({ page }) =&gt; {
    await page.goto('/todos');
  });

  // 테스트는 카테고리별로 그룹화
  test.describe('초기 화면', () =&gt; { ... });
  test.describe('투두 추가', () =&gt; { ... });
  test.describe('투두 완료', () =&gt; { ... });
  test.describe('투두 삭제', () =&gt; { ... });
  test.describe('통계', () =&gt; { ... });
  test.describe('통합 시나리오', () =&gt; { ... });
});&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;테스트 작성 시 학습한 Best Practices&lt;/h4&gt;

&lt;div class=&quot;success&quot;&gt;
&lt;strong&gt;✅ 헬퍼 함수 활용&lt;/strong&gt;&lt;br&gt;
중복되는 &quot;투두 추가&quot; 로직을 &lt;code&gt;addTodo()&lt;/code&gt; 헬퍼 함수로 분리했습니다. 
단, 헬퍼 함수에는 검증(assertion)을 넣지 않고 각 테스트에서 명시적으로 검증합니다.
&lt;/div&gt;

&lt;div class=&quot;success&quot;&gt;
&lt;strong&gt;✅ 접근성 기반 선택자 사용&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;getByRole()&lt;/code&gt;, &lt;code&gt;getByLabel()&lt;/code&gt; 같은 접근성 기반 선택자를 우선 사용했습니다. 
CSS 선택자보다 더 의미론적이고 리팩토링에 강합니다.
&lt;/div&gt;

&lt;div class=&quot;success&quot;&gt;
&lt;strong&gt;✅ 명확한 aria-label&lt;/strong&gt;&lt;br&gt;
모든 버튼에 고유한 &lt;code&gt;aria-label&lt;/code&gt;을 부여해서 테스트에서 정확한 요소를 선택할 수 있게 했습니다.
예: &lt;code&gt;&quot;아침 운동하기 삭제&quot;&lt;/code&gt;
&lt;/div&gt;

&lt;h3&gt;4. Playwright 타임아웃 최적화&lt;/h3&gt;

&lt;p&gt;빠른 피드백을 위해 타임아웃을 대폭 단축했습니다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// playwright.config.ts
export default defineConfig({
  timeout: 10000,              // 전체 테스트: 10초 (기본 30초)
  use: {
    actionTimeout: 3000,       // 클릭, fill 등: 3초
    navigationTimeout: 5000,   // 페이지 이동: 5초
  },
  expect: {
    timeout: 3000,             // assertion: 3초
  },
});&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;highlight&quot;&gt;
&lt;strong&gt;⚡ 효과:&lt;/strong&gt; 테스트 실패 시 30초 대기 → 3초 만에 빠르게 실패하여 개발 속도가 10배 향상되었습니다.
&lt;/div&gt;

&lt;h2&gt;  커밋 내역&lt;/h2&gt;

&lt;div class=&quot;commit-list&quot;&gt;
    &lt;div class=&quot;commit-item&quot;&gt;
        &lt;span class=&quot;commit-hash&quot;&gt;2293f49&lt;/span&gt;
        &lt;strong&gt;config:&lt;/strong&gt; Playwright 타임아웃 3초로 최적화
    &lt;/div&gt;
    &lt;div class=&quot;commit-item&quot;&gt;
        &lt;span class=&quot;commit-hash&quot;&gt;5e120b1&lt;/span&gt;
        &lt;strong&gt;test:&lt;/strong&gt; 투두 앱 Playwright e2e 테스트 추가
    &lt;/div&gt;
    &lt;div class=&quot;commit-item&quot;&gt;
        &lt;span class=&quot;commit-hash&quot;&gt;96fdeca&lt;/span&gt;
        &lt;strong&gt;feat:&lt;/strong&gt; 투두 컴포넌트에 aria-label 접근성 속성 추가
    &lt;/div&gt;
    &lt;div class=&quot;commit-item&quot;&gt;
        &lt;span class=&quot;commit-hash&quot;&gt;b08ac20&lt;/span&gt;
        &lt;strong&gt;refactor:&lt;/strong&gt; 투두 앱 포켓몬 테마 제거 및 일반 텍스트로 변경
    &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;  배운 점&lt;/h2&gt;

&lt;h3&gt;1. Playwright 선택자 전략&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;&lt;code&gt;getByRole('button', { name: '할 일 추가' })&lt;/code&gt; - 접근성 기반, 가장 권장&lt;/li&gt;
    &lt;li&gt;&lt;code&gt;getByLabel('할 일 입력')&lt;/code&gt; - label 요소와 연결된 input 선택&lt;/li&gt;
    &lt;li&gt;&lt;code&gt;getByPlaceholder('...')&lt;/code&gt; - placeholder로 선택 (보조적으로 사용)&lt;/li&gt;
    &lt;li&gt;&lt;code&gt;getByText('아침 운동하기')&lt;/code&gt; - 텍스트로 선택 (간단할 때 유용)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;2. 테스트 구조화&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;헬퍼 함수:&lt;/strong&gt; Setup 용도로만 사용 (assertion 없이)&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;테스트:&lt;/strong&gt; 명시적인 검증 포함 필수&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;그룹화:&lt;/strong&gt; &lt;code&gt;test.describe()&lt;/code&gt;로 카테고리별 정리&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;3. 접근성과 테스트의 시너지&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;접근성을 위해 추가한 &lt;code&gt;aria-label&lt;/code&gt;이 테스트 작성을 훨씬 쉽게 만듦&lt;/li&gt;
    &lt;li&gt;고유한 레이블은 스크린 리더와 자동화 테스트 모두에게 유용&lt;/li&gt;
    &lt;li&gt;좋은 접근성 = 좋은 테스트 가능성&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;  다음 단계&lt;/h2&gt;

&lt;div class=&quot;info&quot;&gt;
&lt;strong&gt;CI/CD 통합&lt;/strong&gt;&lt;br&gt;
GitHub Actions에 Playwright 테스트를 추가하여 PR 생성 시 자동으로 테스트가 실행되도록 설정할 예정입니다.

&lt;pre&gt;&lt;code&gt;// .github/workflows/ci.yml (예정)
jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx nx e2e web-e2e&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h2&gt;  테스트 실행 방법&lt;/h2&gt;

&lt;pre&gt;&lt;code&gt;# 전체 테스트 실행
npx nx e2e web-e2e

# UI 모드로 실행 (디버깅)
npx nx e2e web-e2e --ui

# 특정 브라우저만 실행
npx nx e2e web-e2e --project=chromium&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;  결론&lt;/h2&gt;

&lt;p&gt;
처음 Playwright를 도입하면서 E2E 테스트 작성 방법과 접근성의 중요성을 함께 배울 수 있었습니다. 
특히 &lt;code&gt;aria-label&lt;/code&gt;을 제대로 활용하면 접근성과 테스트 가능성을 동시에 향상시킬 수 있다는 점이 
가장 큰 수확이었습니다.
&lt;/p&gt;

&lt;p&gt;
타임아웃을 3초로 줄여서 테스트 실패 시 빠르게 피드백을 받을 수 있게 된 것도 개발 경험을 
크게 개선시켰습니다. 앞으로는 CI/CD에 통합해서 더욱 안정적인 개발 프로세스를 구축할 예정입니다.
&lt;/p&gt;

&lt;hr style=&quot;margin: 40px 0; border: none; border-top: 2px solid #ecf0f1;&quot;&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <category>aria-label</category>
      <category>e2e테스트</category>
      <category>Next.js</category>
      <category>Playwright</category>
      <category>typescript</category>
      <category>웹테스트</category>
      <category>자동화테스트</category>
      <category>접근성</category>
      <category>프론트엔드테스트</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/439</guid>
      <comments>https://deok2kim.tistory.com/439#entry439comment</comments>
      <pubDate>Sun, 9 Nov 2025 00:51:54 +0900</pubDate>
    </item>
    <item>
      <title>React Native + Next.js WebView: 네이티브와 웹을 자유롭게 오가는 법</title>
      <link>https://deok2kim.tistory.com/438</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;React Native와 WebView 양방향 네비게이션 완벽 가이드&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;h1&gt;  React Native와 WebView 양방향 네비게이션 완벽 가이드&lt;/h1&gt;

&lt;p&gt;
React Native 앱 안에서 웹 콘텐츠를 보여주고, 서로 자유롭게 화면을 이동하는 방법을 알아봅니다. 
Expo Router와 Next.js를 사용한 실전 예제로, Native ↔ WebView 간의 완벽한 네비게이션을 구현해보겠습니다.
&lt;/p&gt;

&lt;hr&gt;

&lt;h2&gt;  목차&lt;/h2&gt;
&lt;ol&gt;
    &lt;li&gt;&lt;a href=&quot;#intro&quot;&gt;왜 WebView 네비게이션이 필요한가?&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#architecture&quot;&gt;전체 아키텍처&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#native-to-webview&quot;&gt;Native → WebView 이동&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#webview-to-webview&quot;&gt;WebView → WebView 이동&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#webview-to-native&quot;&gt;WebView → Native 이동&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#tips&quot;&gt;실전 팁 &amp; 주의사항&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;hr&gt;

&lt;h2 id=&quot;intro&quot;&gt;1. 왜 WebView 네비게이션이 필요한가?&lt;/h2&gt;

&lt;h3&gt;  실무 시나리오&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;하이브리드 앱&lt;/strong&gt;: Native 기능 + 웹 콘텐츠 혼합&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;빠른 업데이트&lt;/strong&gt;: 앱 배포 없이 웹 페이지만 수정&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;웹 재사용&lt;/strong&gt;: 기존 웹사이트를 앱에 통합&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;A/B 테스트&lt;/strong&gt;: 웹에서 빠르게 실험 후 Native 이식&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;  구현할 3가지 네비게이션&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. Native → WebView   (URL 파라미터)
2. WebView → WebView  (일반 Link)
3. WebView → Native   (postMessage)
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;h2 id=&quot;architecture&quot;&gt;2. 전체 아키텍처&lt;/h2&gt;

&lt;h3&gt;  프로젝트 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;naemamdaero/
├── apps/
│   ├── mobile/              # React Native (Expo)
│   │   └── app/
│   │       ├── (tabs)/
│   │       │   ├── index.tsx      # 홈 (Native)
│   │       │   ├── webview.tsx    # WebView 화면
│   │       │   └── todo/
│   │       │       └── detail.tsx # Todo 상세 (Native)
│   │
│   └── web/                 # Next.js (웹)
│       └── src/app/
│           ├── page.tsx           # 웹 홈
│           └── todos/
│               └── page.tsx       # Todo 목록 (WebView)
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  데이터 흐름&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;[Native 홈]
    ↓ router.push('/webview?url=/todos')
[WebView 화면] → http://localhost:3000/todos
    ↓ &lt;Link href=&quot;/todos&quot;&gt;
[WebView Todo 목록]
    ↓ window.ReactNativeWebView.postMessage()
[Native Todo 상세]
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;h2 id=&quot;native-to-webview&quot;&gt;3. Native → WebView 이동&lt;/h2&gt;

&lt;h3&gt;  목표&lt;/h3&gt;
&lt;p&gt;Native 홈 화면에서 버튼을 누르면 WebView의 특정 페이지로 이동&lt;/p&gt;

&lt;h3&gt;  Step 1: Native 화면에서 WebView로 이동&lt;/h3&gt;

&lt;h4&gt;apps/mobile/app/(tabs)/index.tsx&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;import { useRouter } from 'expo-router';

export default function HomeScreen() {
  const router = useRouter();

  return (
    &amp;lt;TouchableOpacity
      onPress={() =&amp;gt; {
        //   WebView 탭으로 이동 + URL 파라미터 전달
        router.push('/webview?url=/todos');
      }}
    &amp;gt;
      &amp;lt;Text&amp;gt;WebView에서 Todo 보기&amp;lt;/Text&amp;gt;
    &amp;lt;/TouchableOpacity&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  Step 2: WebView 화면에서 URL 파라미터 받기&lt;/h3&gt;

&lt;h4&gt;apps/mobile/app/(tabs)/webview.tsx&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;import { useLocalSearchParams } from 'expo-router';
import WebView from 'react-native-webview';

export default function WebViewScreen() {
  const params = useLocalSearchParams();
  
  //   URL 파라미터에서 경로 추출
  const targetPath = (params.url as string) || '/';
  const webviewUrl = `http://localhost:3000${targetPath}`;

  return (
    &amp;lt;WebView
      source={{ uri: webviewUrl }}
      javaScriptEnabled={true}
      domStorageEnabled={true}
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;✅ 결과&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;&lt;code&gt;router.push('/webview?url=/todos')&lt;/code&gt; → WebView가 &lt;code&gt;localhost:3000/todos&lt;/code&gt; 로드&lt;/li&gt;
    &lt;li&gt;&lt;code&gt;router.push('/webview?url=/about')&lt;/code&gt; → &lt;code&gt;localhost:3000/about&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;

&lt;h2 id=&quot;webview-to-webview&quot;&gt;4. WebView → WebView 이동&lt;/h2&gt;

&lt;h3&gt;  목표&lt;/h3&gt;
&lt;p&gt;WebView 내부에서 일반 웹 페이지처럼 자유롭게 이동&lt;/p&gt;

&lt;h3&gt;  Step 1: 웹 홈 페이지&lt;/h3&gt;

&lt;h4&gt;apps/web/src/app/page.tsx&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;import Link from 'next/link';

export default function HomePage() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;내맘대로 웹 앱&amp;lt;/h1&amp;gt;
      
      {/*   일반 Link로 페이지 이동 */}
      &amp;lt;Link href=&quot;/todos&quot;&amp;gt;
        &amp;lt;button&amp;gt;  Todo 목록&amp;lt;/button&amp;gt;
      &amp;lt;/Link&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  Step 2: Todo 목록 페이지&lt;/h3&gt;

&lt;h4&gt;apps/web/src/app/todos/page.tsx&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;'use client';

import { useState, useEffect } from 'react';

export default function TodosPage() {
  const [isNativeApp, setIsNativeApp] = useState(false);

  //   Native 환경인지 확인
  useEffect(() =&amp;gt; {
    setIsNativeApp(!!window.ReactNativeWebView);
  }, []);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;  Todo 목록&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{isNativeApp ? 'Native 앱' : '일반 브라우저'} 환경&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;✅ 결과&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;일반 웹처럼 &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;로 페이지 이동&lt;/li&gt;
    &lt;li&gt;WebView 안에서 자유롭게 네비게이션&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;

&lt;h2 id=&quot;webview-to-native&quot;&gt;5. WebView → Native 이동 (핵심!)&lt;/h2&gt;

&lt;h3&gt;  목표&lt;/h3&gt;
&lt;p&gt;WebView의 Todo를 클릭하면 Native의 상세 화면으로 이동&lt;/p&gt;

&lt;h3&gt;  Step 1: WebView에서 postMessage 전송&lt;/h3&gt;

&lt;h4&gt;apps/web/src/app/todos/page.tsx&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;'use client';

import { useState, useEffect } from 'react';

const TODOS = [
  { id: '1', title: 'Expo Router 학습하기', completed: true },
  { id: '2', title: 'WebView 통신 구현', completed: false },
];

export default function TodosPage() {
  const [isNativeApp, setIsNativeApp] = useState(false);

  useEffect(() =&amp;gt; {
    setIsNativeApp(!!window.ReactNativeWebView);
  }, []);

  const handleTodoClick = (todoId: string) =&amp;gt; {
    if (isNativeApp &amp;&amp; window.ReactNativeWebView) {
      //   Native로 메시지 전송
      window.ReactNativeWebView.postMessage(
        JSON.stringify({
          type: 'NAVIGATE',
          screen: 'todo/detail',
          params: { id: todoId },
        })
      );
      console.log('  [WebView] Native로 메시지 전송:', { todoId });
    } else {
      //   일반 브라우저
      alert(`Todo 상세: ${todoId}`);
    }
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;  Todo 목록&amp;lt;/h1&amp;gt;
      {TODOS.map((todo) =&amp;gt; (
        &amp;lt;div
          key={todo.id}
          onClick={() =&amp;gt; handleTodoClick(todo.id)}
          style={{ cursor: 'pointer' }}
        &amp;gt;
          {todo.completed ? '✅' : '⏳'} {todo.title}
        &amp;lt;/div&amp;gt;
      ))}
    &amp;lt;/div&amp;gt;
  );
}

//   TypeScript 타입 선언
declare global {
  interface Window {
    ReactNativeWebView?: {
      postMessage: (message: string) =&amp;gt; void;
    };
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  Step 2: Native에서 메시지 수신&lt;/h3&gt;

&lt;h4&gt;apps/mobile/app/(tabs)/webview.tsx&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;import { useRouter } from 'expo-router';
import WebView, { WebViewMessageEvent } from 'react-native-webview';

export default function WebViewScreen() {
  const router = useRouter();

  //   WebView → Native 메시지 핸들러
  const handleWebViewMessage = (event: WebViewMessageEvent) =&amp;gt; {
    try {
      const message = JSON.parse(event.nativeEvent.data);
      console.log('  [Native] WebView로부터 메시지 수신:', message);

      if (message.type === 'NAVIGATE') {
        //   Native 화면으로 이동
        const { screen, params } = message;
        const queryString = params
          ? `?${new URLSearchParams(params).toString()}`
          : '';

        router.push(`/${screen}${queryString}`);
        console.log(`✅ [Native] 화면 이동: /${screen}${queryString}`);
      }
    } catch (error) {
      console.error('❌ [Native] 메시지 파싱 에러:', error);
    }
  };

  return (
    &amp;lt;WebView
      source={{ uri: 'http://localhost:3000/todos' }}
      onMessage={handleWebViewMessage}  {/*   핵심! */}
      javaScriptEnabled={true}
      domStorageEnabled={true}
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;✅ 전체 흐름&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. 사용자가 WebView에서 Todo 클릭
2. handleTodoClick() 실행
3. window.ReactNativeWebView.postMessage() 호출
4. Native의 onMessage 이벤트 발생
5. handleWebViewMessage() 실행
6. router.push('/todo/detail?id=1') 실행
7. Native Todo 상세 화면으로 이동 ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;h2 id=&quot;tips&quot;&gt;6. 실전 팁 &amp; 주의사항&lt;/h2&gt;

&lt;h3&gt;⚠️ 하이드레이션 에러 해결&lt;/h3&gt;

&lt;h4&gt;문제&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;// ❌ 잘못된 코드 (하이드레이션 에러!)
const [isNativeApp, setIsNativeApp] = useState(
  !!window.ReactNativeWebView  // 서버/클라이언트 불일치!
);
&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;해결&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;// ✅ 올바른 코드
const [isNativeApp, setIsNativeApp] = useState(false);

useEffect(() =&amp;gt; {
  setIsNativeApp(!!window.ReactNativeWebView);
}, []);
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  디버깅 방법&lt;/h3&gt;

&lt;h4&gt;WebView 콘솔 로그 보기 (iOS)&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;// Safari → 개발자용 → 시뮬레이터 → localhost
console.log('WebView에서 실행 중!');
&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;Native 콘솔 로그 보기&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;// Expo 터미널에서 확인
console.log('[Native] 메시지 수신:', message);
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  실전 팁&lt;/h3&gt;

&lt;h4&gt;1. 메시지 타입 정의&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;// 메시지 타입 명확히 정의
type WebViewMessage =
  | { type: 'NAVIGATE'; screen: string; params?: Record&amp;lt;string, string&amp;gt; }
  | { type: 'SHARE'; data: string }
  | { type: 'CLOSE_WEBVIEW' };
&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;2. 에러 핸들링&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;const handleWebViewMessage = (event: WebViewMessageEvent) =&amp;gt; {
  try {
    const message = JSON.parse(event.nativeEvent.data);
    // ... 처리
  } catch (error) {
    console.error('메시지 파싱 실패:', error);
    // Sentry 등으로 에러 리포팅
  }
};
&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;3. 로딩 상태 관리&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;WebView
  onLoadStart={() =&amp;gt; setLoading(true)}
  onLoadEnd={() =&amp;gt; setLoading(false)}
  onError={(e) =&amp;gt; setError(e.nativeEvent.description)}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;  성능 최적화&lt;/h3&gt;

&lt;h4&gt;1. WebView 캐싱&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;WebView
  cacheEnabled={true}
  cacheMode=&quot;LOAD_CACHE_ELSE_NETWORK&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;2. 불필요한 리렌더링 방지&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;const webViewRef = useRef&amp;lt;WebView&amp;gt;(null);

// WebView 컴포넌트를 React.memo로 감싸기
const MemoizedWebView = React.memo(WebView);
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;h2&gt;  완성!&lt;/h2&gt;

&lt;h3&gt;구현한 기능&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;✅ Native → WebView (URL 파라미터)&lt;/li&gt;
    &lt;li&gt;✅ WebView → WebView (일반 Link)&lt;/li&gt;
    &lt;li&gt;✅ WebView → Native (postMessage)&lt;/li&gt;
    &lt;li&gt;✅ 하이드레이션 에러 해결&lt;/li&gt;
    &lt;li&gt;✅ TypeScript 타입 안정성&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;실전 활용&lt;/h3&gt;
&lt;ul&gt;
    &lt;li&gt;  &lt;strong&gt;쇼핑몰 앱&lt;/strong&gt;: 상품 목록(WebView) → 결제(Native)&lt;/li&gt;
    &lt;li&gt;  &lt;strong&gt;뉴스 앱&lt;/strong&gt;: 기사(WebView) → 댓글(Native)&lt;/li&gt;
    &lt;li&gt;  &lt;strong&gt;금융 앱&lt;/strong&gt;: 이벤트 페이지(WebView) → 송금(Native)&lt;/li&gt;
    &lt;li&gt;  &lt;strong&gt;게임 앱&lt;/strong&gt;: 공지사항(WebView) → 게임 시작(Native)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;다음 단계&lt;/h3&gt;
&lt;ol&gt;
    &lt;li&gt;&lt;strong&gt;Native → WebView 데이터 전송&lt;/strong&gt;: &lt;code&gt;injectJavaScript()&lt;/code&gt;&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;딥링크 처리&lt;/strong&gt;: &lt;code&gt;expo-linking&lt;/code&gt;&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;오프라인 지원&lt;/strong&gt;: WebView 캐싱&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;보안 강화&lt;/strong&gt;: SSL Pinning&lt;/li&gt;
&lt;/ol&gt;

&lt;hr&gt;

&lt;h2&gt;  참고 자료&lt;/h2&gt;
&lt;ul&gt;
    &lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/router/introduction/&quot;&gt;Expo Router 공식 문서&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;https://github.com/react-native-webview/react-native-webview&quot;&gt;react-native-webview GitHub&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs&quot;&gt;Next.js 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;

&lt;h2&gt;  마무리&lt;/h2&gt;
&lt;p&gt;
React Native와 WebView를 연결하면, Native의 성능과 웹의 유연성을 모두 활용할 수 있습니다. 
처음에는 복잡해 보이지만, &lt;code&gt;postMessage&lt;/code&gt;와 &lt;code&gt;onMessage&lt;/code&gt;의 원리만 이해하면 
다양한 시나리오에 응용할 수 있습니다.
&lt;/p&gt;

&lt;p&gt;
여러분의 프로젝트에 적용하면서 궁금한 점이 있다면 댓글로 남겨주세요!  
&lt;/p&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <category>expo router</category>
      <category>Next.js</category>
      <category>PostMessage</category>
      <category>react native</category>
      <category>typescript</category>
      <category>WebView</category>
      <category>실전예제</category>
      <category>앱개발</category>
      <category>양방향통신</category>
      <category>하이브리드앱</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/438</guid>
      <comments>https://deok2kim.tistory.com/438#entry438comment</comments>
      <pubDate>Sun, 2 Nov 2025 18:12:22 +0900</pubDate>
    </item>
    <item>
      <title>  Expo Router Stack Navigator 실전 패턴과 꿀팁</title>
      <link>https://deok2kim.tistory.com/437</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Expo Router로 Stack Navigation 마스터하기&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Helvetica Neue&quot;, Arial, sans-serif;
            line-height: 1.8;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            color: #333;
        }
        h1 {
            color: #2c3e50;
            border-bottom: 3px solid #3498db;
            padding-bottom: 10px;
            margin-top: 40px;
        }
        h2 {
            color: #34495e;
            margin-top: 35px;
            border-left: 4px solid #3498db;
            padding-left: 15px;
        }
        h3 {
            color: #7f8c8d;
            margin-top: 25px;
        }
        code {
            background-color: #f8f9fa;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: &quot;Monaco&quot;, &quot;Menlo&quot;, &quot;Ubuntu Mono&quot;, monospace;
            font-size: 0.9em;
            color: #e74c3c;
        }
        pre {
            background-color: #2c3e50;
            color: #ecf0f1;
            padding: 20px;
            border-radius: 8px;
            overflow-x: auto;
            line-height: 1.5;
        }
        pre code {
            background-color: transparent;
            color: #ecf0f1;
            padding: 0;
        }
        .info-box {
            background-color: #e8f4f8;
            border-left: 4px solid #3498db;
            padding: 15px 20px;
            margin: 20px 0;
            border-radius: 4px;
        }
        .warning-box {
            background-color: #fff3cd;
            border-left: 4px solid #ffc107;
            padding: 15px 20px;
            margin: 20px 0;
            border-radius: 4px;
        }
        .tip-box {
            background-color: #d4edda;
            border-left: 4px solid #28a745;
            padding: 15px 20px;
            margin: 20px 0;
            border-radius: 4px;
        }
        ul, ol {
            margin: 15px 0;
            padding-left: 30px;
        }
        li {
            margin: 8px 0;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 20px 0;
        }
        th, td {
            border: 1px solid #ddd;
            padding: 12px;
            text-align: left;
        }
        th {
            background-color: #3498db;
            color: white;
        }
        tr:nth-child(even) {
            background-color: #f8f9fa;
        }
        img {
            max-width: 100%;
            height: auto;
            border-radius: 8px;
            margin: 20px 0;
        }
        .emoji {
            font-size: 1.2em;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;h1&gt;  Expo Router로 Stack Navigation 마스터하기&lt;/h1&gt;

&lt;p&gt;React Native와 Expo로 모바일 앱을 개발하면서 &lt;strong&gt;Stack Navigation&lt;/strong&gt;을 구현해봤습니다. 특히 Expo Router의 파일 기반 라우팅과 네비게이션 메서드들(&lt;code&gt;push&lt;/code&gt;, &lt;code&gt;replace&lt;/code&gt;, &lt;code&gt;back&lt;/code&gt;)의 차이를 실습하며 많은 것을 배웠는데요, 그 경험을 공유합니다!  &lt;/p&gt;

&lt;h2&gt;  목표&lt;/h2&gt;

&lt;ul&gt;
    &lt;li&gt;Expo Router로 Stack Navigator 구현하기&lt;/li&gt;
    &lt;li&gt;목록 → 상세 → 편집 3단계 화면 구조 만들기&lt;/li&gt;
    &lt;li&gt;&lt;code&gt;push&lt;/code&gt;, &lt;code&gt;replace&lt;/code&gt;, &lt;code&gt;back&lt;/code&gt; 차이 이해하기&lt;/li&gt;
    &lt;li&gt;크로스 탭 네비게이션 구현하기&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;  파일 구조&lt;/h2&gt;

&lt;p&gt;Expo Router는 파일 시스템 기반 라우팅을 사용합니다. &lt;code&gt;_layout.tsx&lt;/code&gt; 파일로 네비게이터를 정의하고, 폴더 구조가 곧 라우트 구조가 되죠.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;apps/mobile/app/(tabs)/
├─ _layout.tsx          # Tab Navigator
├─ index.tsx            # 홈 화면
└─ todo/
   ├─ _layout.tsx       # Stack Navigator ✨
   ├─ index.tsx         # Todo 목록 (루트)
   ├─ detail.tsx        # Todo 상세
   └─ edit.tsx          # Todo 편집&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;info-box&quot;&gt;
    &lt;strong&gt;  핵심:&lt;/strong&gt; &lt;code&gt;todo/_layout.tsx&lt;/code&gt;가 Todo 탭 내부의 Stack Navigator를 정의합니다!
&lt;/div&gt;

&lt;h2&gt; ️ Stack Navigator 구현&lt;/h2&gt;

&lt;h3&gt;1. Layout 파일 생성 (&lt;code&gt;todo/_layout.tsx&lt;/code&gt;)&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;import { Stack } from 'expo-router';

export default function TodoLayout() {
  return (
    &amp;lt;Stack
      screenOptions={{
        headerShown: true,
        headerStyle: {
          backgroundColor: '#007AFF',
        },
        headerTintColor: '#fff',
        headerTitleStyle: {
          fontWeight: 'bold',
        },
      }}
    &amp;gt;
      &amp;lt;Stack.Screen
        name=&quot;index&quot;
        options={{
          title: '  Todo 목록',
        }}
      /&amp;gt;

      &amp;lt;Stack.Screen
        name=&quot;detail&quot;
        options={{
          title: '  Todo 상세',
        }}
      /&amp;gt;

      &amp;lt;Stack.Screen
        name=&quot;edit&quot;
        options={{
          title: '✏️ Todo 편집',
        }}
      /&amp;gt;
    &amp;lt;/Stack&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;tip-box&quot;&gt;
    &lt;strong&gt;✅ 장점:&lt;/strong&gt; 파일 이름만으로 자동으로 라우트가 생성되고, 타입 안정성도 보장됩니다!
&lt;/div&gt;

&lt;h3&gt;2. 목록 화면 (&lt;code&gt;todo/index.tsx&lt;/code&gt;)&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;import { useRouter } from 'expo-router';

export default function TodoListScreen() {
  const router = useRouter();

  const handleTodoPress = (todoId: string) =&gt; {
    // Stack에 상세 화면 추가
    router.push(`/todo/detail?id=${todoId}`);
  };

  return (
    &amp;lt;View&amp;gt;
      {todos.map((todo) =&amp;gt; (
        &amp;lt;TouchableOpacity
          key={todo.id}
          onPress={() =&amp;gt; handleTodoPress(todo.id)}
        &amp;gt;
          &amp;lt;Text&amp;gt;{todo.title}&amp;lt;/Text&amp;gt;
        &amp;lt;/TouchableOpacity&amp;gt;
      ))}
    &amp;lt;/View&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;3. 상세 화면 (&lt;code&gt;todo/detail.tsx&lt;/code&gt;)&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;import { useRouter, useLocalSearchParams } from 'expo-router';

export default function TodoDetailScreen() {
  const router = useRouter();
  const params = useLocalSearchParams();
  const todoId = params.id as string; // URL 파라미터 받기

  return (
    &amp;lt;View&amp;gt;
      &amp;lt;Text&amp;gt;Todo ID: {todoId}&amp;lt;/Text&amp;gt;
      
      {/* 편집 화면으로 이동 */}
      &amp;lt;TouchableOpacity
        onPress={() =&amp;gt; router.push(`/todo/edit?id=${todoId}`)}
      &amp;gt;
        &amp;lt;Text&amp;gt;편집하기&amp;lt;/Text&amp;gt;
      &amp;lt;/TouchableOpacity&amp;gt;

      {/* 목록으로 이동 */}
      &amp;lt;TouchableOpacity
        onPress={() =&amp;gt; router.push('/todo')}
      &amp;gt;
        &amp;lt;Text&amp;gt;목록으로&amp;lt;/Text&amp;gt;
      &amp;lt;/TouchableOpacity&amp;gt;
    &amp;lt;/View&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;4. 편집 화면 (&lt;code&gt;todo/edit.tsx&lt;/code&gt;)&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;export default function TodoEditScreen() {
  const router = useRouter();
  const params = useLocalSearchParams();
  const todoId = params.id as string;

  const handleSave = () =&gt; {
    // ✅ back()으로 이전 화면(상세)으로
    // 사용자가 온 경로 그대로 유지
    router.back();
  };

  return (
    &amp;lt;View&amp;gt;
      &amp;lt;Button title=&quot;저장&quot; onPress={handleSave} /&amp;gt;
    &amp;lt;/View&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;  네비게이션 메서드 완벽 정리&lt;/h2&gt;

&lt;p&gt;이번 프로젝트에서 가장 헷갈렸던 부분이 &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;replace&lt;/code&gt;, &lt;code&gt;back&lt;/code&gt;의 차이였습니다. 실습하면서 정리한 내용을 공유합니다!&lt;/p&gt;

&lt;table&gt;
    &lt;thead&gt;
        &lt;tr&gt;
            &lt;th&gt;메서드&lt;/th&gt;
            &lt;th&gt;동작&lt;/th&gt;
            &lt;th&gt;Stack 변화&lt;/th&gt;
            &lt;th&gt;사용 시나리오&lt;/th&gt;
        &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
        &lt;tr&gt;
            &lt;td&gt;&lt;code&gt;push()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;Stack에 추가&lt;/td&gt;
            &lt;td&gt;[목록, 상세] → [목록, 상세, 편집]&lt;/td&gt;
            &lt;td&gt;새 화면으로 이동, 뒤로가기 지원&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;&lt;code&gt;replace()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;현재 화면 교체&lt;/td&gt;
            &lt;td&gt;[목록, 상세, 편집] → [목록, 상세(새)]&lt;/td&gt;
            &lt;td&gt;로그인 후 로그인 화면 제거&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;&lt;code&gt;back()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;이전 화면으로&lt;/td&gt;
            &lt;td&gt;[목록, 상세, 편집] → [목록, 상세]&lt;/td&gt;
            &lt;td&gt;취소, 저장 후 복귀&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;&lt;code&gt;navigate()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;중복 방지 이동&lt;/td&gt;
            &lt;td&gt;[목록, 상세] → [목록]&lt;/td&gt;
            &lt;td&gt;이미 Stack에 있으면 이동만&lt;/td&gt;
        &lt;/tr&gt;
    &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3&gt;실전 예시&lt;/h3&gt;

&lt;h4&gt;1️⃣ 편집 후 저장: &lt;code&gt;back()&lt;/code&gt; 사용&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;const handleSave = () =&gt; {
  // API 호출...
  router.back(); // ✅ 사용자가 온 경로로 복귀
};&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;tip-box&quot;&gt;
    &lt;strong&gt;  왜 back()인가?&lt;/strong&gt;&lt;br&gt;
    사용자가 &lt;strong&gt;목록 → 상세 → 편집&lt;/strong&gt;으로 왔든, &lt;strong&gt;홈 → 상세 → 편집&lt;/strong&gt;으로 왔든, 
    &lt;code&gt;back()&lt;/code&gt;은 항상 이전 화면(상세)으로 돌아갑니다!
&lt;/div&gt;

&lt;h4&gt;2️⃣ &quot;목록으로&quot; 버튼: &lt;code&gt;push()&lt;/code&gt; 사용&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;const handleGoToList = () =&gt; {
  router.push('/todo'); // ✅ 목록을 Stack에 추가
};&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;info-box&quot;&gt;
    &lt;strong&gt;  왜 push()인가?&lt;/strong&gt;&lt;br&gt;
    목록 화면에서 뒤로가기하면 상세 화면으로 돌아갈 수 있어야 하므로!
&lt;/div&gt;

&lt;h4&gt;3️⃣ 크로스 탭 네비게이션: &lt;code&gt;push()&lt;/code&gt;만 의미 있음&lt;/h4&gt;

&lt;pre&gt;&lt;code&gt;// 홈 화면에서 Todo 상세로 이동
router.push('/todo/detail?id=1');   // ✅
router.replace('/todo/detail?id=1'); // ❌ push와 동일한 효과!&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;warning-box&quot;&gt;
    &lt;strong&gt;⚠️ 주의:&lt;/strong&gt; 크로스 탭 네비게이션에서는 &lt;strong&gt;다른 탭의 Stack&lt;/strong&gt;에 쌓이기 때문에, 
    현재 탭의 Stack과는 무관합니다. 따라서 &lt;code&gt;replace()&lt;/code&gt;를 써도 &lt;code&gt;push()&lt;/code&gt;와 동일하게 동작합니다!
&lt;/div&gt;

&lt;h2&gt;  Stack Navigator 핵심 특징&lt;/h2&gt;

&lt;h3&gt;1. 같은 라우트는 중복 안 쌓임&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;// 파라미터가 달라도 route name이 같으면 중복 안 됨!
router.push('/todo/detail?id=1');
router.push('/todo/detail?id=2'); // Stack: [목록, detail] (id만 업데이트)&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;info-box&quot;&gt;
    &lt;strong&gt;  핵심:&lt;/strong&gt; React Navigation은 &lt;strong&gt;route name&lt;/strong&gt;을 기준으로 중복을 판단합니다. 
    파라미터가 달라도 같은 &lt;code&gt;detail&lt;/code&gt; route면 기존 화면만 업데이트돼요!
&lt;/div&gt;

&lt;h3&gt;2. 각 탭은 독립적인 Stack 유지&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;// 홈 탭 Stack: [홈]
// Todo 탭 Stack: [목록, 상세]

// 탭을 전환해도 각 Stack은 유지됨!
// 홈 → Todo 탭 → 상세 뒤로가기 → 목록 ✅&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;3. 크로스 탭 네비게이션&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;// 홈 화면에서
router.push('/todo/detail?id=1');

// 결과:
// - Expo Router가 자동으로 Todo 탭 활성화
// - Todo 탭 Stack에 [상세] 추가
// - 홈 탭 Stack: [홈] (그대로 유지)&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;  실전 패턴&lt;/h2&gt;

&lt;h3&gt;패턴 1: 마감 임박 Todo (크로스 탭)&lt;/h3&gt;

&lt;p&gt;홈 화면에 마감 임박한 Todo를 표시하고, 클릭하면 바로 상세 화면으로!&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 홈 화면 (apps/mobile/app/(tabs)/index.tsx)
const urgentTodos = [
  { id: '1', title: 'Expo Router 학습하기', deadline: '오늘 14:00' },
  { id: '2', title: 'WebView 통신 구현', deadline: '내일 10:00' },
];

const handleTodoPress = (todoId: string) =&gt; {
  // 크로스 탭 네비게이션!
  router.push(`/todo/detail?id=${todoId}`);
};&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;tip-box&quot;&gt;
    &lt;strong&gt;✨ UX 개선:&lt;/strong&gt; 사용자가 홈에서 바로 Todo 상세를 볼 수 있어 편리합니다!
&lt;/div&gt;

&lt;h3&gt;패턴 2: &quot;목록으로&quot; 버튼&lt;/h3&gt;

&lt;p&gt;상세 화면에서 목록으로 빠르게 이동할 수 있는 버튼을 추가했습니다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// 상세 화면
&amp;lt;TouchableOpacity
  onPress={() =&amp;gt; router.push('/todo')}
&amp;gt;
  &amp;lt;Text&amp;gt;  목록으로&amp;lt;/Text&amp;gt;
&amp;lt;/TouchableOpacity&amp;gt;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;동작:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;li&gt;목록 → 상세 → &quot;목록으로&quot; → Stack: [목록, 상세, 목록]&lt;/li&gt;
    &lt;li&gt;뒤로가기 → 상세 → 목록 (사용자 경로 유지!)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;패턴 3: 편집 후 저장&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;const handleSave = () =&gt; {
  // API 저장...
  
  // ✅ back()으로 이전 화면(상세)으로
  router.back();
  
  // ❌ replace는 파라미터 업데이트 필요시만
  // router.replace(`/todo/detail?id=${newId}`);
};&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;  실습 결과&lt;/h2&gt;

&lt;h3&gt;테스트 시나리오&lt;/h3&gt;

&lt;ol&gt;
    &lt;li&gt;&lt;strong&gt;기본 Stack 동작&lt;/strong&gt;
        &lt;ul&gt;
            &lt;li&gt;✅ 목록 → 상세 → 편집 → back → 상세 → back → 목록&lt;/li&gt;
            &lt;li&gt;✅ 같은 상세 화면 여러 번 push 시 중복 안 쌓임&lt;/li&gt;
        &lt;/ul&gt;
    &lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;크로스 탭 네비게이션&lt;/strong&gt;
        &lt;ul&gt;
            &lt;li&gt;✅ 홈 → Todo 상세 → Todo 탭 Stack에만 추가&lt;/li&gt;
            &lt;li&gt;✅ 홈 → Todo 상세 → &quot;목록으로&quot; → 목록 추가됨&lt;/li&gt;
        &lt;/ul&gt;
    &lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;편집 화면&lt;/strong&gt;
        &lt;ul&gt;
            &lt;li&gt;✅ 상세 → 편집 → 저장(back) → 상세로 복귀&lt;/li&gt;
            &lt;li&gt;✅ 상세 → 편집 → 취소(back) → 상세로 복귀&lt;/li&gt;
        &lt;/ul&gt;
    &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;  배운 점 &amp; 꿀팁&lt;/h2&gt;

&lt;h3&gt;1. segments vs Stack&lt;/h3&gt;

&lt;div class=&quot;warning-box&quot;&gt;
    &lt;strong&gt;⚠️ 헷갈리는 점:&lt;/strong&gt;&lt;br&gt;
    &lt;code&gt;useSegments()&lt;/code&gt;는 &lt;strong&gt;URL 경로&lt;/strong&gt;를 반환하고, 
    &lt;code&gt;useNavigationState()&lt;/code&gt;는 &lt;strong&gt;실제 Stack&lt;/strong&gt;을 반환합니다!&lt;br&gt;&lt;br&gt;
    이 둘은 다를 수 있습니다. (특히 크로스 탭 네비게이션 시)
&lt;/div&gt;

&lt;h3&gt;2. push vs replace, 언제 뭘 쓸까?&lt;/h3&gt;

&lt;table&gt;
    &lt;thead&gt;
        &lt;tr&gt;
            &lt;th&gt;상황&lt;/th&gt;
            &lt;th&gt;사용할 메서드&lt;/th&gt;
            &lt;th&gt;이유&lt;/th&gt;
        &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
        &lt;tr&gt;
            &lt;td&gt;일반적인 화면 이동&lt;/td&gt;
            &lt;td&gt;&lt;code&gt;push()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;뒤로가기 지원&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;편집/취소 후 복귀&lt;/td&gt;
            &lt;td&gt;&lt;code&gt;back()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;사용자 경로 유지&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;로그인 후 이동&lt;/td&gt;
            &lt;td&gt;&lt;code&gt;replace()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;로그인 화면으로 뒤로가기 방지&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td&gt;크로스 탭 이동&lt;/td&gt;
            &lt;td&gt;&lt;code&gt;push()&lt;/code&gt;&lt;/td&gt;
            &lt;td&gt;replace는 효과 동일&lt;/td&gt;
        &lt;/tr&gt;
    &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3&gt;3. 파일 기반 라우팅의 장점&lt;/h3&gt;

&lt;ul&gt;
    &lt;li&gt;✅ &lt;strong&gt;타입 안정성:&lt;/strong&gt; 경로가 자동 완성되고 오타 방지&lt;/li&gt;
    &lt;li&gt;✅ &lt;strong&gt;직관적:&lt;/strong&gt; 폴더 구조 = 라우트 구조&lt;/li&gt;
    &lt;li&gt;✅ &lt;strong&gt;유지보수 용이:&lt;/strong&gt; 파일 이름만 봐도 화면 구조 파악&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;  다음 단계&lt;/h2&gt;

&lt;p&gt;이제 Stack Navigation의 기본은 마스터했으니, 다음은:&lt;/p&gt;

&lt;ol&gt;
    &lt;li&gt;&lt;strong&gt;Modal Stack:&lt;/strong&gt; &lt;code&gt;presentation: 'modal'&lt;/code&gt;로 모달 화면 구현&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;Deep Linking:&lt;/strong&gt; URL로 직접 특정 화면 열기&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;Shared Element Transition:&lt;/strong&gt; 화면 전환 애니메이션&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;Tab + Drawer:&lt;/strong&gt; Drawer Navigator와 함께 사용하기&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;  참고 자료&lt;/h2&gt;

&lt;ul&gt;
    &lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/router/introduction/&quot; target=&quot;_blank&quot;&gt;Expo Router 공식 문서&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;https://reactnavigation.org/docs/stack-navigator/&quot; target=&quot;_blank&quot;&gt;React Navigation Stack Navigator&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;https://github.com/deok2kim/naemamdaero&quot; target=&quot;_blank&quot;&gt;GitHub 저장소&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;✨ 마무리&lt;/h2&gt;

&lt;p&gt;Stack Navigation을 직접 구현하면서 &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;replace&lt;/code&gt;, &lt;code&gt;back&lt;/code&gt;의 차이와 각 메서드를 언제 써야 하는지 명확히 이해할 수 있었습니다. 특히 &lt;strong&gt;크로스 탭 네비게이션&lt;/strong&gt;에서 replace가 push와 동일하게 동작한다는 점이 인상 깊었네요!&lt;/p&gt;

&lt;p&gt;Expo Router의 파일 기반 라우팅은 정말 직관적이고, 타입 안정성까지 보장해주니 생산성이 크게 향상됩니다. 여러분도 꼭 시도해보세요!  &lt;/p&gt;

&lt;hr&gt;

&lt;p style=&quot;text-align: center; color: #7f8c8d; margin-top: 40px;&quot;&gt;
    궁금한 점이나 피드백은 댓글로 남겨주세요!  
&lt;/p&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <category>Expo</category>
      <category>ExpoRouter</category>
      <category>reactnative</category>
      <category>StackNavigation</category>
      <category>typescript</category>
      <category>네비게이션</category>
      <category>리액트네이티브</category>
      <category>모바일앱개발</category>
      <category>앱개발</category>
      <category>파일기반라우팅</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/437</guid>
      <comments>https://deok2kim.tistory.com/437#entry437comment</comments>
      <pubDate>Sat, 1 Nov 2025 22:29:52 +0900</pubDate>
    </item>
    <item>
      <title>쿠키 기반 인증 vs JWT, 그리고 CSRF와 SameSite 이야기</title>
      <link>https://deok2kim.tistory.com/436</link>
      <description>&lt;h1&gt;쿠키 기반 인증 vs JWT, 그리고 CSRF까지 완벽 정리&lt;/h1&gt;

&lt;p&gt;
요즘 프론트엔드 개발을 하다 보면, &lt;strong&gt;JWT 인증&lt;/strong&gt;과 &lt;strong&gt;쿠키 기반 인증&lt;/strong&gt;을 모두 접하게 됩니다.  
처음에는 “JWT는 토큰이라서 안전하고, 세션은 옛날 방식 아닌가?” 하는 오해가 생기기 쉬운데요.  
이번 글에서는 두 방식의 차이부터, 실제 서비스에서 고려해야 하는 &lt;strong&gt;보안 이슈(CSRF, HttpOnly, SameSite)&lt;/strong&gt;까지 정리해보려 합니다.
&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;1. 쿠키, 세션, JWT 기본 개념&lt;/h2&gt;

&lt;h3&gt;1-1. 세션 기반 인증&lt;/h3&gt;
&lt;p&gt;
서버가 로그인 요청을 받으면, 해당 유저 정보를 세션 저장소(메모리, Redis 등)에 저장하고  
세션 ID를 브라우저 쿠키로 내려줍니다.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Set-Cookie: sessionId=abc123; HttpOnly; Secure;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
이후 요청마다 브라우저는 자동으로 이 쿠키를 포함시켜 요청을 보냅니다.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Cookie: sessionId=abc123&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
서버는 이 세션 ID로 사용자 정보를 식별합니다.  
즉, 세션 ID가 곧 “로그인 상태를 증명하는 신분증”입니다.
&lt;/p&gt;

&lt;h3&gt;1-2. JWT 기반 인증&lt;/h3&gt;
&lt;p&gt;
JWT(Json Web Token)는 서버가 서명한 &lt;code&gt;accessToken&lt;/code&gt; 자체에 인증 정보를 담아두는 방식입니다.
서버에 세션 저장소가 없고, 토큰만으로 인증이 가능합니다.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
{
  &quot;header&quot;: { &quot;alg&quot;: &quot;HS256&quot;, &quot;typ&quot;: &quot;JWT&quot; },
  &quot;payload&quot;: { &quot;userId&quot;: 123, &quot;role&quot;: &quot;admin&quot; },
  &quot;signature&quot;: &quot;서명값&quot;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
서버는 서명을 검증해 유효한 토큰인지 확인하고, &lt;code&gt;payload&lt;/code&gt;의 정보를 읽어 인증을 수행합니다.
&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;2. 쿠키 방식과 JWT 방식의 차이&lt;/h2&gt;

&lt;table border=&quot;1&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot;&gt;
  &lt;tr&gt;
    &lt;th&gt;구분&lt;/th&gt;
    &lt;th&gt;세션 기반 인증&lt;/th&gt;
    &lt;th&gt;JWT 기반 인증&lt;/th&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;저장 위치&lt;/td&gt;
    &lt;td&gt;서버 (세션 스토리지)&lt;/td&gt;
    &lt;td&gt;클라이언트 (JWT 자체)&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;확장성&lt;/td&gt;
    &lt;td&gt;서버 부하 증가&lt;/td&gt;
    &lt;td&gt;서버 간 공유 불필요 → 확장성 높음&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;만료 처리&lt;/td&gt;
    &lt;td&gt;서버에서 세션 삭제 가능&lt;/td&gt;
    &lt;td&gt;토큰 만료(`exp`) 시점까지 유효&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;보안 위험&lt;/td&gt;
    &lt;td&gt;세션 ID 탈취&lt;/td&gt;
    &lt;td&gt;Access Token 탈취&lt;/td&gt;
  &lt;/tr&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2&gt;3. 세션 ID나 JWT가 탈취되면 위험한 이유&lt;/h2&gt;
&lt;p&gt;
세션 ID나 JWT 모두 “로그인 상태를 증명하는 신분증”이기 때문에,  
이 값이 탈취되면 공격자는 해당 사용자의 계정으로 행동할 수 있습니다.
이를 &lt;strong&gt;세션 하이재킹(Session Hijacking)&lt;/strong&gt;이라고 합니다.
&lt;/p&gt;

&lt;h3&gt;대표적인 공격 경로&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;XSS&lt;/strong&gt;: 악성 스크립트를 통해 &lt;code&gt;document.cookie&lt;/code&gt;로 쿠키 탈취&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HTTP 통신 도청&lt;/strong&gt;: HTTPS가 아닌 평문 통신에서 쿠키 노출&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;세션 고정 공격&lt;/strong&gt;: 공격자가 미리 발급받은 세션 ID를 유도&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;방어 방법&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict;&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;HttpOnly&lt;/strong&gt;: JavaScript에서 쿠키 접근 차단 (XSS 방어)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Secure&lt;/strong&gt;: HTTPS 통신에서만 전송&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;SameSite&lt;/strong&gt;: 외부 도메인 요청 시 쿠키 전송 제한 (CSRF 방어)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2&gt;4. CSRF(Cross-Site Request Forgery)란?&lt;/h2&gt;

&lt;p&gt;
CSRF는 &lt;strong&gt;“다른 사이트에서 인증된 사용자의 세션을 악용해 요청을 보내는 공격”&lt;/strong&gt;입니다.
&lt;/p&gt;

&lt;p&gt;
예를 들어 사용자가 A사이트에 로그인된 상태에서,  
공격자가 만든 B사이트에서 자동으로 A사이트로 POST 요청을 보내면,  
브라우저는 A사이트 쿠키를 자동으로 전송합니다.  
결과적으로 사용자가 의도하지 않은 요청이 수행될 수 있습니다.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
&lt;form action=&quot;https://bank.com/transfer&quot; method=&quot;POST&quot;&gt;
  &lt;input type=&quot;hidden&quot; name=&quot;to&quot; value=&quot;attacker&quot;&gt;
  &lt;input type=&quot;hidden&quot; name=&quot;amount&quot; value=&quot;100000&quot;&gt;
&lt;/form&gt;
&lt;script&gt;document.forms[0].submit()&lt;/script&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
이때 사용자는 &lt;strong&gt;로그인된 상태의 쿠키&lt;/strong&gt;가 자동으로 전송되기 때문에,
서버는 정상적인 요청으로 오인합니다.
&lt;/p&gt;

&lt;h3&gt;✅ 방어 방법&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;SameSite 쿠키 설정&lt;/strong&gt;: 외부 사이트에서 쿠키가 전송되지 않게 함&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CSRF 토큰&lt;/strong&gt;: 서버가 발급한 토큰을 함께 검증&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2&gt;5. 쿠키 기반 JWT 인증은 안전한가?&lt;/h2&gt;

&lt;p&gt;
요즘은 &lt;strong&gt;JWT를 로컬스토리지에 저장하지 않고 HttpOnly 쿠키에 저장하는 방식&lt;/strong&gt;이 보안적으로 권장됩니다.
&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;✅ &lt;strong&gt;XSS 방어&lt;/strong&gt;: HttpOnly 쿠키는 JS로 접근 불가&lt;/li&gt;
  &lt;li&gt;✅ &lt;strong&gt;자동 전송&lt;/strong&gt;: fetch 시 쿠키 자동 포함 (credentials: 'include')&lt;/li&gt;
  &lt;li&gt;⚠️ &lt;strong&gt;CSRF 주의&lt;/strong&gt;: 쿠키는 자동 전송되므로 SameSite 설정이 중요&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;
즉, &lt;strong&gt;Access Token을 HttpOnly 쿠키에 저장&lt;/strong&gt;하고,  
&lt;code&gt;SameSite=Lax&lt;/code&gt; 또는 &lt;code&gt;Strict&lt;/code&gt; 설정을 사용하면 매우 안전한 구조입니다.
&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;6. 리프레시 토큰은 항상 같이 보내면 안 되나?&lt;/h2&gt;

&lt;p&gt;
리프레시 토큰은 새로운 Access Token을 발급받기 위한 “마스터 키”이기 때문에,  
&lt;code&gt;모든 요청에 함께 보내면 탈취 시 피해가 매우 큽니다.&lt;/code&gt;
&lt;/p&gt;

&lt;p&gt;
보통 아래처럼 별도의 경로로만 전송되게 합니다.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
Path=/auth/refresh;
HttpOnly;
SameSite=Strict;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
즉, &lt;strong&gt;Access Token&lt;/strong&gt;은 여러 요청에 사용되고,  
&lt;strong&gt;Refresh Token&lt;/strong&gt;은 오직 &lt;code&gt;/auth/refresh&lt;/code&gt; 요청에서만 사용되도록 설계하는 것이 베스트 프랙티스입니다.
&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;7. 정리&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;세션이든 JWT든, 탈취되면 위험하다.&lt;/li&gt;
  &lt;li&gt;HttpOnly, Secure, SameSite 설정으로 쿠키 보호가 필수.&lt;/li&gt;
  &lt;li&gt;CSRF는 브라우저의 자동 쿠키 전송을 악용한 공격.&lt;/li&gt;
  &lt;li&gt;Access Token은 짧게, Refresh Token은 안전하게 별도 경로로 관리.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;결론:&lt;/strong&gt;  
“JWT를 쿠키에 담는 방식은 보안적으로 좋은 선택이지만,  
CSRF와 토큰 주기 관리까지 신경써야 완전한 인증 시스템이 된다.”
&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;  참고 키워드&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Session Hijacking&lt;/li&gt;
  &lt;li&gt;CSRF vs XSS&lt;/li&gt;
  &lt;li&gt;HttpOnly / Secure / SameSite 쿠키 속성&lt;/li&gt;
  &lt;li&gt;JWT Refresh Token Rotation&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>면접/질문</category>
      <category>CSRF</category>
      <category>httponly</category>
      <category>JWT</category>
      <category>samesite</category>
      <category>secure</category>
      <category>XSS</category>
      <category>세션</category>
      <category>인가</category>
      <category>인증</category>
      <category>쿠키</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/436</guid>
      <comments>https://deok2kim.tistory.com/436#entry436comment</comments>
      <pubDate>Mon, 27 Oct 2025 20:31:47 +0900</pubDate>
    </item>
    <item>
      <title>Expo Router 25.10 패치 노트 - 모바일 네비게이션의 새로운 시대</title>
      <link>https://deok2kim.tistory.com/435</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Expo Router 업데이트 - Bottom Tab Navigation 구현&lt;/title&gt;
    &lt;style&gt;
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: 'Malgun Gothic', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: #1a1a2e;
            line-height: 1.8;
            padding: 20px;
        }
        
        .container {
            max-width: 900px;
            margin: 0 auto;
            background: white;
            border-radius: 16px;
            overflow: hidden;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
        }
        
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 60px 40px;
            text-align: center;
        }
        
        .header h1 {
            font-size: 2.5em;
            margin-bottom: 15px;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
        }
        
        .header .subtitle {
            font-size: 1.2em;
            opacity: 0.9;
        }
        
        .header .date {
            margin-top: 20px;
            font-size: 0.95em;
            opacity: 0.8;
        }
        
        .content {
            padding: 40px;
        }
        
        .section {
            margin-bottom: 50px;
        }
        
        .section h2 {
            color: #667eea;
            font-size: 1.8em;
            margin-bottom: 20px;
            padding-bottom: 10px;
            border-bottom: 3px solid #667eea;
        }
        
        .section h3 {
            color: #764ba2;
            font-size: 1.4em;
            margin: 30px 0 15px 0;
        }
        
        .highlight-box {
            background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
            border-left: 4px solid #667eea;
            padding: 20px;
            margin: 20px 0;
            border-radius: 8px;
        }
        
        .tech-stack {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 15px;
            margin: 20px 0;
        }
        
        .tech-item {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 8px;
            border: 2px solid #e9ecef;
            transition: all 0.3s;
        }
        
        .tech-item:hover {
            border-color: #667eea;
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
        }
        
        .tech-item strong {
            color: #667eea;
            display: block;
            margin-bottom: 5px;
        }
        
        .commit-list {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 8px;
            margin: 20px 0;
        }
        
        .commit-item {
            padding: 12px;
            margin: 8px 0;
            background: white;
            border-left: 3px solid #28a745;
            border-radius: 4px;
        }
        
        .commit-item.refactor {
            border-left-color: #ffc107;
        }
        
        .commit-item.chore {
            border-left-color: #6c757d;
        }
        
        code {
            background: #2d2d2d;
            color: #f8f8f2;
            padding: 2px 8px;
            border-radius: 4px;
            font-family: 'Consolas', 'Monaco', monospace;
            font-size: 0.9em;
        }
        
        pre {
            background: #2d2d2d;
            color: #f8f8f2;
            padding: 20px;
            border-radius: 8px;
            overflow-x: auto;
            margin: 20px 0;
        }
        
        .emoji {
            font-size: 1.3em;
        }
        
        .footer {
            background: #f8f9fa;
            padding: 30px;
            text-align: center;
            color: #6c757d;
        }
        
        .tags {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            margin: 20px 0;
        }
        
        .tag {
            background: #667eea;
            color: white;
            padding: 6px 15px;
            border-radius: 20px;
            font-size: 0.9em;
        }
        
        a {
            color: #667eea;
            text-decoration: none;
        }
        
        a:hover {
            text-decoration: underline;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div class=&quot;container&quot;&gt;
        &lt;!-- 헤더 --&gt;
        &lt;div class=&quot;header&quot;&gt;
            &lt;h1&gt;  Expo Router 업데이트&lt;/h1&gt;
            &lt;div class=&quot;subtitle&quot;&gt;Bottom Tab Navigation 구현 완료!&lt;/div&gt;
            &lt;div class=&quot;date&quot;&gt;2025-10-26 | React Native + Expo + Nx&lt;/div&gt;
        &lt;/div&gt;

        &lt;!-- 본문 --&gt;
        &lt;div class=&quot;content&quot;&gt;
            &lt;!-- 개요 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 개요&lt;/h2&gt;
                &lt;div class=&quot;highlight-box&quot;&gt;
                    &lt;p&gt;
                        Expo Router를 사용하여 파일 시스템 기반 라우팅과 Bottom Tab Navigation을 구현했습니다. 
                        전통적인 React Native 구조에서 Next.js 스타일의 라우팅으로 마이그레이션하며, 
                        더 직관적이고 유지보수하기 쉬운 구조를 갖추게 되었습니다.
                    &lt;/p&gt;
                &lt;/div&gt;

                &lt;div class=&quot;tags&quot;&gt;
                    &lt;span class=&quot;tag&quot;&gt;React Native&lt;/span&gt;
                    &lt;span class=&quot;tag&quot;&gt;Expo Router&lt;/span&gt;
                    &lt;span class=&quot;tag&quot;&gt;TypeScript&lt;/span&gt;
                    &lt;span class=&quot;tag&quot;&gt;Nx Monorepo&lt;/span&gt;
                    &lt;span class=&quot;tag&quot;&gt;iOS&lt;/span&gt;
                &lt;/div&gt;
            &lt;/div&gt;

            &lt;!-- 주요 변경사항 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt;✨&lt;/span&gt; 주요 변경사항&lt;/h2&gt;

                &lt;h3&gt;1. Expo Router 도입&lt;/h3&gt;
                &lt;div class=&quot;tech-stack&quot;&gt;
                    &lt;div class=&quot;tech-item&quot;&gt;
                        &lt;strong&gt;expo-router&lt;/strong&gt;
                        파일 시스템 기반 라우팅
                    &lt;/div&gt;
                    &lt;div class=&quot;tech-item&quot;&gt;
                        &lt;strong&gt;expo-linking&lt;/strong&gt;
                        딥링킹 지원
                    &lt;/div&gt;
                    &lt;div class=&quot;tech-item&quot;&gt;
                        &lt;strong&gt;expo-constants&lt;/strong&gt;
                        환경 변수 관리
                    &lt;/div&gt;
                    &lt;div class=&quot;tech-item&quot;&gt;
                        &lt;strong&gt;react-native-screens&lt;/strong&gt;
                        네이티브 화면 최적화
                    &lt;/div&gt;
                &lt;/div&gt;

                &lt;h3&gt;2. Metro 설정 업데이트&lt;/h3&gt;
                &lt;pre&gt;&lt;code&gt;const defaultConfig = getDefaultConfig(__dirname, {
  isCSSEnabled: true, // package.json exports 지원
});

// Nx 모노레포와 호환되는 간결한 설정
module.exports = withNxMetro(mergeConfig(defaultConfig, customConfig));&lt;/code&gt;&lt;/pre&gt;

                &lt;h3&gt;3. Bottom Tab Navigation 구현&lt;/h3&gt;
                &lt;p&gt;&lt;strong&gt;3개의 탭으로 구성:&lt;/strong&gt;&lt;/p&gt;
                &lt;ul style=&quot;margin-left: 30px; margin-top: 15px;&quot;&gt;
                    &lt;li&gt;&lt;strong&gt;  홈&lt;/strong&gt;: 환영 화면 및 Expo Router 학습 자료&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;  웹뷰&lt;/strong&gt;: Next.js 웹 앱 (localhost:3000) 통합&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;⚙️ 설정&lt;/strong&gt;: 앱 정보 및 외부 링크&lt;/li&gt;
                &lt;/ul&gt;

                &lt;h3&gt;4. 주요 기능&lt;/h3&gt;
                &lt;ul style=&quot;margin-left: 30px; margin-top: 15px;&quot;&gt;
                    &lt;li&gt;Ionicons 아이콘 사용으로 일관된 디자인&lt;/li&gt;
                    &lt;li&gt;커스텀 탭 바 스타일링 (높이, 색상, 여백)&lt;/li&gt;
                    &lt;li&gt;SafeAreaView로 노치 영역 완벽 처리&lt;/li&gt;
                    &lt;li&gt;로딩 상태 및 에러 핸들링&lt;/li&gt;
                &lt;/ul&gt;
            &lt;/div&gt;

            &lt;!-- 커밋 내역 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 커밋 내역&lt;/h2&gt;
                &lt;div class=&quot;commit-list&quot;&gt;
                    &lt;div class=&quot;commit-item&quot;&gt;
                        &lt;strong&gt;feat:&lt;/strong&gt; Expo Router 및 필수 패키지 추가
                    &lt;/div&gt;
                    &lt;div class=&quot;commit-item&quot;&gt;
                        &lt;strong&gt;feat:&lt;/strong&gt; Expo Router 지원을 위한 Metro 설정 추가
                    &lt;/div&gt;
                    &lt;div class=&quot;commit-item&quot;&gt;
                        &lt;strong&gt;feat:&lt;/strong&gt; Expo Router 플러그인 및 엔트리 포인트 설정
                    &lt;/div&gt;
                    &lt;div class=&quot;commit-item refactor&quot;&gt;
                        &lt;strong&gt;refactor:&lt;/strong&gt; 기존 App 컴포넌트 및 테스트 파일 제거
                    &lt;/div&gt;
                    &lt;div class=&quot;commit-item&quot;&gt;
                        &lt;strong&gt;feat:&lt;/strong&gt; Expo Router 기반 Bottom Tab Navigation 구현
                    &lt;/div&gt;
                    &lt;div class=&quot;commit-item chore&quot;&gt;
                        &lt;strong&gt;chore:&lt;/strong&gt; iOS 네이티브 모듈 및 CocoaPods 의존성 업데이트
                    &lt;/div&gt;
                &lt;/div&gt;
            &lt;/div&gt;

            &lt;!-- 기술적 도전 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 기술적 도전과 해결&lt;/h2&gt;

                &lt;h3&gt;1. Node.js 내장 모듈 해결&lt;/h3&gt;
                &lt;div class=&quot;highlight-box&quot;&gt;
                    &lt;p&gt;&lt;strong&gt;문제:&lt;/strong&gt; React Native 환경에서 Node.js 모듈(&lt;code&gt;console&lt;/code&gt;, &lt;code&gt;fs&lt;/code&gt; 등)을 찾을 수 없다는 에러 발생&lt;/p&gt;
                    &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt; Metro Bundler의 &lt;code&gt;isCSSEnabled: true&lt;/code&gt; 설정으로 Expo Router의 고급 resolver 활성화&lt;/p&gt;
                &lt;/div&gt;

                &lt;h3&gt;2. SafeArea 처리&lt;/h3&gt;
                &lt;div class=&quot;highlight-box&quot;&gt;
                    &lt;p&gt;&lt;strong&gt;문제:&lt;/strong&gt; 노치 영역에 콘텐츠가 가려지는 현상&lt;/p&gt;
                    &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt; &lt;code&gt;react-native-safe-area-context&lt;/code&gt;의 SafeAreaView 사용, 배경색을 헤더와 통일하여 자연스러운 UI 구현&lt;/p&gt;
                &lt;/div&gt;

                &lt;h3&gt;3. 네이티브 모듈 통합&lt;/h3&gt;
                &lt;div class=&quot;highlight-box&quot;&gt;
                    &lt;p&gt;&lt;strong&gt;문제:&lt;/strong&gt; ExpoLinking 네이티브 모듈을 찾을 수 없음&lt;/p&gt;
                    &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt; &lt;code&gt;apps/mobile/package.json&lt;/code&gt;에 직접 의존성 추가 후 &lt;code&gt;npx nx prebuild mobile --platform ios --clean&lt;/code&gt;으로 네이티브 재빌드&lt;/p&gt;
                &lt;/div&gt;
            &lt;/div&gt;

            &lt;!-- 학습 포인트 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 학습 포인트&lt;/h2&gt;
                &lt;ul style=&quot;margin-left: 30px;&quot;&gt;
                    &lt;li&gt;&lt;strong&gt;파일 시스템 라우팅:&lt;/strong&gt; Next.js처럼 &lt;code&gt;app/&lt;/code&gt; 폴더 구조로 라우트 자동 생성&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;Route Groups:&lt;/strong&gt; &lt;code&gt;(tabs)&lt;/code&gt;처럼 괄호로 감싸면 URL에 포함되지 않음&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;SafeAreaView:&lt;/strong&gt; 노치, 다이나믹 아일랜드, 홈 인디케이터 모두 처리&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;headerShown: false:&lt;/strong&gt; 실무에서는 대부분 커스텀 헤더 사용&lt;/li&gt;
                    &lt;li&gt;&lt;strong&gt;Nx Monorepo:&lt;/strong&gt; 루트 &lt;code&gt;package.json&lt;/code&gt;에서 버전 관리, 하위 앱은 &lt;code&gt;&quot;*&quot;&lt;/code&gt; 사용&lt;/li&gt;
                &lt;/ul&gt;
            &lt;/div&gt;

            &lt;!-- 실행 방법 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 실행 방법&lt;/h2&gt;
                &lt;pre&gt;&lt;code&gt;# 1. Web 서버 실행 (별도 터미널)
npx nx dev web

# 2. iOS 앱 실행
export LANG=en_US.UTF-8
npx nx run-ios mobile&lt;/code&gt;&lt;/pre&gt;
            &lt;/div&gt;

            &lt;!-- 다음 단계 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 다음 단계&lt;/h2&gt;
                &lt;ul style=&quot;margin-left: 30px;&quot;&gt;
                    &lt;li&gt;Android 지원 추가&lt;/li&gt;
                    &lt;li&gt;딥링킹 테스트 및 활용&lt;/li&gt;
                    &lt;li&gt;네비게이션 애니메이션 커스터마이징&lt;/li&gt;
                    &lt;li&gt;E2E 테스트 작성 (Detox)&lt;/li&gt;
                    &lt;li&gt;Push 알림 통합&lt;/li&gt;
                &lt;/ul&gt;
            &lt;/div&gt;

            &lt;!-- 참고 자료 --&gt;
            &lt;div class=&quot;section&quot;&gt;
                &lt;h2&gt;&lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt; 참고 자료&lt;/h2&gt;
                &lt;ul style=&quot;margin-left: 30px;&quot;&gt;
                    &lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/router/introduction/&quot; target=&quot;_blank&quot;&gt;Expo Router 공식 문서&lt;/a&gt;&lt;/li&gt;
                    &lt;li&gt;&lt;a href=&quot;https://reactnavigation.org/docs/bottom-tab-navigator/&quot; target=&quot;_blank&quot;&gt;React Navigation - Bottom Tabs&lt;/a&gt;&lt;/li&gt;
                    &lt;li&gt;&lt;a href=&quot;https://nx.dev/recipes/react/react-native&quot; target=&quot;_blank&quot;&gt;Nx React Native Guide&lt;/a&gt;&lt;/li&gt;
                    &lt;li&gt;&lt;a href=&quot;https://github.com/deok2kim/naemamdaero/pull/6&quot; target=&quot;_blank&quot;&gt;Pull Request #6&lt;/a&gt;&lt;/li&gt;
                &lt;/ul&gt;
            &lt;/div&gt;
        &lt;/div&gt;

        &lt;!-- 푸터 --&gt;
        &lt;div class=&quot;footer&quot;&gt;
            &lt;p&gt;© 2025 내맘대로 개발 일지 | React Native + Expo + Nx&lt;/p&gt;
            &lt;p style=&quot;margin-top: 10px; font-size: 0.9em;&quot;&gt;
                &lt;a href=&quot;https://github.com/deok2kim/naemamdaero&quot; target=&quot;_blank&quot;&gt;GitHub Repository&lt;/a&gt;
            &lt;/p&gt;
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/435</guid>
      <comments>https://deok2kim.tistory.com/435#entry435comment</comments>
      <pubDate>Sun, 26 Oct 2025 17:46:18 +0900</pubDate>
    </item>
    <item>
      <title>React Native 웹뷰로 웹앱을 네이티브 앱처럼 만들기 (feat. 삽질 경험담)</title>
      <link>https://deok2kim.tistory.com/434</link>
      <description>&lt;!-- 티스토리 HTML 모드에 붙여넣기 --&gt;

&lt;style&gt;
  .blog-post {
    line-height: 1.8;
  }
  .blog-post h2 {
    color: #2c3e50;
    margin-top: 50px;
    padding-bottom: 10px;
    border-bottom: 2px solid #3498db;
  }
  .blog-post h3 {
    color: #34495e;
    margin-top: 30px;
    padding-left: 10px;
    border-left: 4px solid #3498db;
  }
  .blog-post code {
    background-color: #f4f4f4;
    padding: 3px 6px;
    border-radius: 3px;
    font-family: 'Courier New', monospace;
    color: #e74c3c;
  }
  .blog-post pre {
    background-color: #2d2d2d;
    color: #f8f8f2;
    padding: 20px;
    border-radius: 8px;
    overflow-x: auto;
    border-left: 4px solid #3498db;
    line-height: 1.6;
  }
  .blog-post pre code {
    background-color: transparent;
    padding: 0;
    color: #f8f8f2;
  }
  .warning-box {
    background-color: #fff3cd;
    border-left: 4px solid #ffc107;
    padding: 20px;
    margin: 25px 0;
    border-radius: 6px;
  }
  .success-box {
    background-color: #d4edda;
    border-left: 4px solid #28a745;
    padding: 20px;
    margin: 25px 0;
    border-radius: 6px;
  }
  .info-box {
    background-color: #d1ecf1;
    border-left: 4px solid #17a2b8;
    padding: 20px;
    margin: 25px 0;
    border-radius: 6px;
  }
  .step-box {
    background-color: #f8f9fa;
    padding: 25px;
    margin: 25px 0;
    border-radius: 10px;
    border: 2px solid #dee2e6;
  }
&lt;/style&gt;

&lt;div class=&quot;blog-post&quot;&gt;
  &lt;div class=&quot;info-box&quot;&gt;
    &lt;strong&gt;  이 글의 목표:&lt;/strong&gt; React Native 앱에 WebView를 추가해서
    웹앱을 모바일 앱 안에서 띄워보겠습니다. 삽질 포함!
  &lt;/div&gt;

  &lt;h2&gt;  왜 WebView가 필요할까?&lt;/h2&gt;

  &lt;p&gt;
    Next.js로 웹 앱을 만들었는데, 이걸 모바일 앱에서도 보여주고 싶었어요.&lt;br /&gt;
    완전히 새로 네이티브 UI를 만들 수도 있지만, 일단 웹을 앱 안에 넣어보자!
  &lt;/p&gt;

  &lt;h2&gt;  1. react-native-webview 설치&lt;/h2&gt;

  &lt;div class=&quot;step-box&quot;&gt;
    &lt;h3&gt;JavaScript 패키지 설치&lt;/h3&gt;

    &lt;pre&gt;&lt;code&gt;cd apps/mobile
npm install react-native-webview&lt;/code&gt;&lt;/pre&gt;

    &lt;div class=&quot;success-box&quot;&gt;
      ✅ &lt;strong&gt;설치 완료!&lt;/strong&gt; 2개 패키지 추가됨
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;h2&gt;  2. iOS 네이티브 설정 (CocoaPods)&lt;/h2&gt;

  &lt;p&gt;React Native는 두 부분으로 나뉘어요:&lt;/p&gt;

  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;JavaScript 부분:&lt;/strong&gt; npm install로 설치 ✅&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;네이티브 부분:&lt;/strong&gt; iOS는 CocoaPods로 설치 필요!&lt;/li&gt;
  &lt;/ul&gt;

  &lt;div class=&quot;step-box&quot;&gt;
    &lt;h3&gt;CocoaPods 설치&lt;/h3&gt;

    &lt;pre&gt;&lt;code&gt;cd apps/mobile/ios
export LANG=en_US.UTF-8  # UTF-8 설정 (중요!)
pod install&lt;/code&gt;&lt;/pre&gt;

    &lt;div class=&quot;info-box&quot;&gt;
      &lt;strong&gt;왜 export LANG이 필요?&lt;/strong&gt;&lt;br /&gt;
      CocoaPods가 UTF-8 인코딩을 요구해서, 매번 터미널에서 설정해줘야 해요.
    &lt;/div&gt;

    &lt;div class=&quot;success-box&quot;&gt;
      &lt;strong&gt;  성공!&lt;/strong&gt;
      &lt;pre&gt;&lt;code&gt;Pod installation complete! 
There are 83 dependencies from the Podfile 
and 82 total pods installed.&lt;/code&gt;&lt;/pre&gt;
      &lt;p&gt;81개 → 82개로 증가! (react-native-webview가 추가됨)&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;h2&gt;  3. 코드 작성 (학습용 주석 가득!)&lt;/h2&gt;

  &lt;p&gt;
    기존의 긴 예제 코드를 다 지우고, 간단하게 시작해봤어요.&lt;br /&gt;
    &lt;strong&gt;핵심 아이디어:&lt;/strong&gt; 버튼을 누르면 웹뷰가 나타나고, 다시 누르면
    사라지게!
  &lt;/p&gt;

  &lt;h3&gt;3-1. Import 추가&lt;/h3&gt;

  &lt;pre&gt;&lt;code&gt;import { useState } from 'react';
import WebView from 'react-native-webview';&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;3-2. 상태 관리&lt;/h3&gt;

  &lt;pre&gt;&lt;code&gt;// 웹뷰를 보여줄지 말지 결정하는 상태
const [showWebView, setShowWebView] = useState(false);

// 웹뷰에서 보여줄 URL
const webviewUrl = 'http://localhost:3000';  // Next.js 웹 서버&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;3-3. WebView 컴포넌트&lt;/h3&gt;

  &lt;pre&gt;&lt;code&gt;&amp;lt;WebView
  source={{ uri: webviewUrl }}
  style={styles.webview}
  // 로딩 시작할 때
  onLoadStart={() =&amp;gt; console.log('  웹뷰 로딩 시작...')}
  // 로딩 완료될 때
  onLoadEnd={() =&amp;gt; console.log('✅ 웹뷰 로딩 완료!')}
  // 에러 발생 시
  onError={(error) =&amp;gt; console.error('❌ 웹뷰 에러:', error)}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;3-4. 조건부 렌더링&lt;/h3&gt;

  &lt;pre&gt;&lt;code&gt;{showWebView ? (
  // 웹뷰 화면
  &amp;lt;WebView ... /&amp;gt;
) : (
  // 환영 화면
  &amp;lt;View&amp;gt;
    &amp;lt;Text&amp;gt;환영합니다!  &amp;lt;/Text&amp;gt;
    &amp;lt;Text&amp;gt;학습 포인트: WebView 사용법&amp;lt;/Text&amp;gt;
  &amp;lt;/View&amp;gt;
)}&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;3-5. 토글 버튼&lt;/h3&gt;

  &lt;pre&gt;&lt;code&gt;&amp;lt;TouchableOpacity onPress={() =&amp;gt; setShowWebView(!showWebView)}&amp;gt;
  &amp;lt;Text&amp;gt;
    {showWebView ? '  닫기' : '  웹뷰 열기'}
  &amp;lt;/Text&amp;gt;
&amp;lt;/TouchableOpacity&amp;gt;&lt;/code&gt;&lt;/pre&gt;

  &lt;h2&gt;  4. 실행하기&lt;/h2&gt;

  &lt;div class=&quot;warning-box&quot;&gt;
    &lt;strong&gt;⚠️ 중요!&lt;/strong&gt; 웹 서버를 먼저 실행해야 해요!
  &lt;/div&gt;

  &lt;div class=&quot;step-box&quot;&gt;
    &lt;h3&gt;단계 1: 웹 서버 실행&lt;/h3&gt;

    &lt;p&gt;터미널 1번:&lt;/p&gt;
    &lt;pre&gt;&lt;code&gt;npx nx dev web&lt;/code&gt;&lt;/pre&gt;

    &lt;p&gt;→ http://localhost:3000 에서 Next.js 웹 앱이 실행됩니다.&lt;/p&gt;

    &lt;h3&gt;단계 2: 모바일 앱 실행&lt;/h3&gt;

    &lt;p&gt;터미널 2번:&lt;/p&gt;
    &lt;pre&gt;&lt;code&gt;export LANG=en_US.UTF-8
npx nx run-ios mobile&lt;/code&gt;&lt;/pre&gt;

    &lt;p&gt;→ iOS 시뮬레이터가 열리고 앱이 실행됩니다!&lt;/p&gt;
  &lt;/div&gt;

  &lt;h2&gt;  5. 결과 화면&lt;/h2&gt;

  &lt;h3&gt;처음 실행 시&lt;/h3&gt;

  &lt;pre&gt;
┌─────────────────────────┐
│  내맘대로 모바일 앱      │
│ React Native + WebView │
├─────────────────────────┤
│                         │
│    환영합니다!          │
│                         │
│    학습 포인트          │
│  1️⃣ WebView 사용       │
│  2️⃣ 웹-앱 연동         │
│  3️⃣ 로딩 이벤트        │
│  4️⃣ 에러 핸들링        │
│                         │
├─────────────────────────┤
│   [   웹뷰 열기 ]       │
└─────────────────────────┘
&lt;/pre
  &gt;

  &lt;h3&gt;버튼 누른 후&lt;/h3&gt;

  &lt;pre&gt;
┌─────────────────────────┐
│  내맘대로 모바일 앱      │
├─────────────────────────┤
│   localhost:3000       │
├─────────────────────────┤
│                         │
│    Next.js 웹 앱        │
│  (웹뷰 안에 표시!)       │
│                         │
├─────────────────────────┤
│   [   닫기 ]           │
└─────────────────────────┘
&lt;/pre
  &gt;

  &lt;h2&gt;  6. 핵심 학습 포인트&lt;/h2&gt;

  &lt;div class=&quot;success-box&quot;&gt;
    &lt;h3&gt;WebView 컴포넌트&lt;/h3&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;code&gt;source&lt;/code&gt;: 표시할 웹페이지 URL&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;onLoadStart&lt;/code&gt;: 로딩 시작 이벤트&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;onLoadEnd&lt;/code&gt;: 로딩 완료 이벤트&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;onError&lt;/code&gt;: 에러 발생 이벤트&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;

  &lt;div class=&quot;info-box&quot;&gt;
    &lt;h3&gt;웹과 앱 연동&lt;/h3&gt;
    &lt;ul&gt;
      &lt;li&gt;웹 서버가 켜져있어야 함&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;http://&lt;/code&gt; 프로토콜 필수!&lt;/li&gt;
      &lt;li&gt;localhost는 iOS 시뮬레이터에서 Mac을 가리킴&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;

  &lt;h2&gt;  7. 자주 발생하는 에러&lt;/h2&gt;

  &lt;div class=&quot;step-box&quot;&gt;
    &lt;h4&gt;에러 1: CocoaPods UTF-8 에러&lt;/h4&gt;
    &lt;pre&gt;&lt;code&gt;Unicode Normalization not appropriate for ASCII-8BIT&lt;/code&gt;&lt;/pre&gt;

    &lt;strong&gt;해결:&lt;/strong&gt;
    &lt;pre&gt;&lt;code&gt;export LANG=en_US.UTF-8
pod install&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;

  &lt;div class=&quot;step-box&quot;&gt;
    &lt;h4&gt;에러 2: 웹뷰가 하얗게만 나옴&lt;/h4&gt;

    &lt;strong&gt;원인:&lt;/strong&gt; 웹 서버가 안 켜져있음&lt;br /&gt;
    &lt;strong&gt;해결:&lt;/strong&gt; &lt;code&gt;npx nx dev web&lt;/code&gt; 먼저 실행!
  &lt;/div&gt;

  &lt;div class=&quot;step-box&quot;&gt;
    &lt;h4&gt;에러 3: Metro 번들러 에러&lt;/h4&gt;
    &lt;pre&gt;&lt;code&gt;No script URL provided&lt;/code&gt;&lt;/pre&gt;

    &lt;strong&gt;해결:&lt;/strong&gt; Metro 서버 재시작
    &lt;pre&gt;&lt;code&gt;lsof -ti:8081 | xargs kill -9
npx nx start mobile&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;

  &lt;h2&gt;  8. 추가 실험 아이디어&lt;/h2&gt;

  &lt;h3&gt;다른 웹사이트 띄우기&lt;/h3&gt;
  &lt;pre&gt;&lt;code&gt;const webviewUrl = 'https://www.google.com';&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;HTML 직접 렌더링&lt;/h3&gt;
  &lt;pre&gt;&lt;code&gt;&amp;lt;WebView
  source={{ 
    html: '&amp;lt;h1&amp;gt;Hello from HTML!&amp;lt;/h1&amp;gt;' 
  }}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;JavaScript 실행하기&lt;/h3&gt;
  &lt;pre&gt;&lt;code&gt;&amp;lt;WebView
  source={{ uri: 'https://google.com' }}
  injectedJavaScript={`
    alert('웹뷰에서 JavaScript 실행!');
  `}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;

  &lt;h3&gt;로딩 인디케이터 추가&lt;/h3&gt;
  &lt;pre&gt;&lt;code&gt;const [isLoading, setIsLoading] = useState(false);

&amp;lt;WebView
  onLoadStart={() =&amp;gt; setIsLoading(true)}
  onLoadEnd={() =&amp;gt; setIsLoading(false)}
/&amp;gt;

{isLoading &amp;&amp; &amp;lt;ActivityIndicator /&amp;gt;}&lt;/code&gt;&lt;/pre&gt;

  &lt;h2&gt;  9. 전체 파일 구조&lt;/h2&gt;

  &lt;pre&gt;
apps/mobile/
├── src/app/
│   └── App.tsx              # ✅ 웹뷰 컴포넌트 추가됨
├── ios/
│   ├── Podfile.lock         # ✅ react-native-webview pod 추가됨
│   └── Pods/
│       └── RNCWebview/      # ✅ 네이티브 코드
└── package.json             # ✅ react-native-webview 의존성
&lt;/pre
  &gt;

  &lt;h2&gt;✨ 10. 결론&lt;/h2&gt;

  &lt;div class=&quot;success-box&quot;&gt;
    &lt;h3&gt;성공적으로 완료!  &lt;/h3&gt;

    &lt;p&gt;웹 앱을 모바일 앱 안에 넣는 데 성공했습니다!&lt;/p&gt;

    &lt;strong&gt;배운 것들:&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;✅ react-native-webview 설치 및 설정&lt;/li&gt;
      &lt;li&gt;✅ CocoaPods로 iOS 네이티브 연동&lt;/li&gt;
      &lt;li&gt;✅ WebView 컴포넌트 사용법&lt;/li&gt;
      &lt;li&gt;✅ 웹과 네이티브 앱 통합&lt;/li&gt;
      &lt;li&gt;✅ 이벤트 핸들링 (로딩, 에러)&lt;/li&gt;
    &lt;/ul&gt;

    &lt;p&gt;
      &lt;strong&gt;다음 단계:&lt;/strong&gt;&lt;br /&gt;
      - 웹 ↔ 앱 메시지 통신 (postMessage)&lt;br /&gt;
      - 진행률 바 추가&lt;br /&gt;
      - 여러 페이지 탭으로 관리&lt;br /&gt;
      - 오프라인 모드 지원
    &lt;/p&gt;

    &lt;p&gt;생각보다 간단하죠? 여러분도 한 번 시도해보세요!  &lt;/p&gt;
  &lt;/div&gt;

  &lt;hr style=&quot;margin: 40px 0; border: none; border-top: 2px solid #ddd&quot; /&gt;

  &lt;p style=&quot;text-align: center; color: #666&quot;&gt;
    &lt;strong&gt;작성일:&lt;/strong&gt; 2025년 10월 21일&lt;br /&gt;
    &lt;strong&gt;개발 환경:&lt;/strong&gt; React Native 0.79, react-native-webview 13.16.0,
    Next.js 15
  &lt;/p&gt;
&lt;/div&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/434</guid>
      <comments>https://deok2kim.tistory.com/434#entry434comment</comments>
      <pubDate>Wed, 22 Oct 2025 00:51:06 +0900</pubDate>
    </item>
    <item>
      <title>Nx 모노레포에서 React Native 앱 만들다가 3시간 날린 썰 (feat. 해결법)</title>
      <link>https://deok2kim.tistory.com/433</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;
      Nx 모노레포에 Expo React Native 앱 추가하고 Xcode에서 실행하기
    &lt;/title&gt;
    &lt;style&gt;
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
          'Helvetica Neue', Arial, sans-serif;
        line-height: 1.6;
        max-width: 900px;
        margin: 0 auto;
        padding: 20px;
        color: #333;
      }
      h1 {
        color: #2c3e50;
        border-bottom: 3px solid #3498db;
        padding-bottom: 10px;
      }
      h2 {
        color: #34495e;
        margin-top: 40px;
        padding-left: 10px;
        border-left: 4px solid #3498db;
      }
      h3 {
        color: #555;
        margin-top: 30px;
      }
      code {
        background-color: #f4f4f4;
        padding: 2px 6px;
        border-radius: 3px;
        font-family: 'Courier New', monospace;
        font-size: 0.9em;
      }
      pre {
        background-color: #2d2d2d;
        color: #f8f8f2;
        padding: 15px;
        border-radius: 5px;
        overflow-x: auto;
        border-left: 4px solid #3498db;
      }
      pre code {
        background-color: transparent;
        padding: 0;
        color: #f8f8f2;
      }
      .warning {
        background-color: #fff3cd;
        border-left: 4px solid #ffc107;
        padding: 15px;
        margin: 20px 0;
        border-radius: 4px;
      }
      .success {
        background-color: #d4edda;
        border-left: 4px solid #28a745;
        padding: 15px;
        margin: 20px 0;
        border-radius: 4px;
      }
      .info {
        background-color: #d1ecf1;
        border-left: 4px solid #17a2b8;
        padding: 15px;
        margin: 20px 0;
        border-radius: 4px;
      }
      .error {
        background-color: #f8d7da;
        border-left: 4px solid #dc3545;
        padding: 15px;
        margin: 20px 0;
        border-radius: 4px;
      }
      ul,
      ol {
        line-height: 1.8;
      }
      .step {
        background-color: #f8f9fa;
        padding: 20px;
        margin: 20px 0;
        border-radius: 8px;
        border: 1px solid #dee2e6;
      }
      .file-structure {
        background-color: #f5f5f5;
        padding: 15px;
        border-radius: 5px;
        font-family: 'Courier New', monospace;
        font-size: 0.9em;
      }
      img {
        max-width: 100%;
        height: auto;
        border-radius: 8px;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      }
      .emoji {
        font-size: 1.2em;
      }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;
      Nx 모노레포에 Expo React Native 앱 추가하고 Xcode에서 실행하기
      &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;
    &lt;/h1&gt;

    &lt;div class=&quot;info&quot;&gt;
      &lt;strong&gt;개요:&lt;/strong&gt; Next.js 웹 앱이 있는 Nx 모노레포에 Expo를 사용한
      React Native 모바일 앱을 추가하고, Xcode에서 네이티브로 실행하는 전체
      과정을 정리했습니다.
    &lt;/div&gt;

    &lt;h2&gt;1. 프로젝트 초기 상태 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;p&gt;시작 시점의 프로젝트 구조:&lt;/p&gt;

    &lt;div class=&quot;file-structure&quot;&gt;
      naemamdaero/ ├── apps/ │ ├── web/ # Next.js 15 + Emotion │ └── web-e2e/ #
      Playwright E2E 테스트 ├── package.json ├── nx.json └── tsconfig.base.json
    &lt;/div&gt;

    &lt;h2&gt;2. Expo 플러그인 설치 &lt;span class=&quot;emoji&quot;&gt;⚙️&lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 1: Nx Expo 플러그인 설치&lt;/h3&gt;
      &lt;p&gt;Nx에서 React Native와 Expo를 사용하려면 전용 플러그인이 필요합니다.&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;npm install -D @nx/expo @nx/react-native&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;success&quot;&gt;
        &lt;strong&gt;설치 완료:&lt;/strong&gt; 145개의 패키지가 추가되었습니다.
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h2&gt;3. Expo 애플리케이션 생성 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 2: mobile 앱 생성&lt;/h3&gt;

      &lt;pre&gt;&lt;code&gt;npx nx generate @nx/expo:application mobile \
  --directory=apps/mobile \
  --e2eTestRunner=none \
  --unitTestRunner=jest \
  --js=false&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;&lt;strong&gt;생성된 파일들:&lt;/strong&gt;&lt;/p&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;code&gt;apps/mobile/src/app/App.tsx&lt;/code&gt; - 메인 앱 컴포넌트&lt;/li&gt;
        &lt;li&gt;&lt;code&gt;apps/mobile/app.json&lt;/code&gt; - Expo 설정&lt;/li&gt;
        &lt;li&gt;&lt;code&gt;apps/mobile/metro.config.js&lt;/code&gt; - Metro 번들러 설정&lt;/li&gt;
        &lt;li&gt;&lt;code&gt;apps/mobile/package.json&lt;/code&gt; - 앱 의존성&lt;/li&gt;
        &lt;li&gt;&lt;code&gt;apps/mobile/assets/&lt;/code&gt; - 이미지, 폰트 등&lt;/li&gt;
      &lt;/ul&gt;

      &lt;div class=&quot;success&quot;&gt;
        &lt;strong&gt;생성 완료:&lt;/strong&gt; &lt;code&gt;@naemamdaero/mobile&lt;/code&gt; 프로젝트가
        성공적으로 생성되었습니다!
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h3&gt;생성된 프로젝트 구조&lt;/h3&gt;

    &lt;div class=&quot;file-structure&quot;&gt;
      apps/mobile/ ├── src/ │ └── app/ │ ├── App.tsx # 메인 컴포넌트 │ └──
      App.spec.tsx # 테스트 ├── assets/ │ ├── images/ │ │ ├── icon.png │ │ ├──
      splash-icon.png │ │ └── adaptive-icon.png │ └── fonts/ ├── app.json # Expo
      설정 ├── metro.config.js # Metro 번들러 설정 ├── jest.config.ts # Jest
      설정 ├── eas.json # EAS Build 설정 ├── index.js # 엔트리 포인트 ├──
      package.json └── tsconfig.json
    &lt;/div&gt;

    &lt;h2&gt;4. 웹 브라우저에서 테스트 시도 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;웹 실행 명령어&lt;/h3&gt;

      &lt;pre&gt;&lt;code&gt;npx nx serve mobile&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;React Native는 웹에서도 실행할 수 있지만 제한사항이 있습니다:&lt;/p&gt;

      &lt;div class=&quot;warning&quot;&gt;
        &lt;h4&gt;웹에서 가능한 것 ✅&lt;/h4&gt;
        &lt;ul&gt;
          &lt;li&gt;기본 UI 컴포넌트 (View, Text, TouchableOpacity 등)&lt;/li&gt;
          &lt;li&gt;스타일링 및 레이아웃&lt;/li&gt;
          &lt;li&gt;상태 관리 및 React 훅&lt;/li&gt;
          &lt;li&gt;API 호출&lt;/li&gt;
        &lt;/ul&gt;

        &lt;h4&gt;웹에서 제한되는 것 ❌&lt;/h4&gt;
        &lt;ul&gt;
          &lt;li&gt;카메라, 센서 등 네이티브 모듈&lt;/li&gt;
          &lt;li&gt;react-native-webview&lt;/li&gt;
          &lt;li&gt;네이티브 애니메이션 일부&lt;/li&gt;
          &lt;li&gt;파일 시스템 접근&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h2&gt;5. iOS 시뮬레이터 실행 준비 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;h3&gt;첫 시도와 문제 발견&lt;/h3&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;p&gt;초기에 iOS에서 실행을 시도했을 때:&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;npx nx run-ios mobile&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;error&quot;&gt;
        &lt;h4&gt;발생한 에러들:&lt;/h4&gt;
        &lt;ul&gt;
          &lt;li&gt;
            &lt;strong&gt;Xcode 경로 문제:&lt;/strong&gt; Command Line Tools만 설정되어 있음
          &lt;/li&gt;
          &lt;li&gt;&lt;strong&gt;CocoaPods 인코딩 에러:&lt;/strong&gt; UTF-8 설정 필요&lt;/li&gt;
          &lt;li&gt;&lt;strong&gt;iOS 네이티브 프로젝트 미생성:&lt;/strong&gt; prebuild 필요&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h2&gt;6. Xcode 네이티브 빌드 설정 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 3: UTF-8 환경 변수 설정&lt;/h3&gt;

      &lt;p&gt;&lt;code&gt;~/.zshrc&lt;/code&gt; 파일 수정:&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;# 다음 라인의 주석 해제
export LANG=en_US.UTF-8&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 4: Xcode 개발자 디렉토리 설정&lt;/h3&gt;

      &lt;p&gt;Command Line Tools에서 전체 Xcode로 변경:&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;warning&quot;&gt;
        &lt;strong&gt;주의:&lt;/strong&gt; 이 명령어는 비밀번호 입력이 필요하며, Xcode가
        설치되어 있어야 합니다.
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 5: iOS 네이티브 프로젝트 생성 (prebuild)&lt;/h3&gt;

      &lt;p&gt;Expo prebuild를 실행하여 네이티브 iOS 프로젝트를 생성합니다:&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;npx nx prebuild mobile --platform ios&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;이 명령어는 다음을 수행합니다:&lt;/p&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;code&gt;ios/&lt;/code&gt; 디렉토리 생성&lt;/li&gt;
        &lt;li&gt;Xcode 프로젝트 파일 생성 (&lt;code&gt;.xcworkspace&lt;/code&gt;)&lt;/li&gt;
        &lt;li&gt;CocoaPods 의존성 설치 (81개 pods)&lt;/li&gt;
        &lt;li&gt;React Native 모듈 링크&lt;/li&gt;
        &lt;li&gt;네이티브 코드 생성&lt;/li&gt;
      &lt;/ul&gt;

      &lt;div class=&quot;success&quot;&gt;
        &lt;strong&gt;성공:&lt;/strong&gt; Pod installation complete! 81 total pods
        installed.
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h3&gt;생성된 iOS 프로젝트 구조&lt;/h3&gt;

    &lt;div class=&quot;file-structure&quot;&gt;
      apps/mobile/ ├── ios/ #   네이티브 iOS 프로젝트 │ ├── Mobile.xcworkspace
      # Xcode에서 열어야 하는 파일 │ ├── Mobile.xcodeproj │ ├── Podfile #
      CocoaPods 의존성 │ ├── Pods/ # 설치된 pods │ └── Mobile/ │ ├──
      AppDelegate.h │ ├── AppDelegate.mm │ └── Info.plist └── ...
    &lt;/div&gt;

    &lt;h2&gt;7. Xcode에서 프로젝트 열기 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 6: Xcode 워크스페이스 열기&lt;/h3&gt;

      &lt;pre&gt;&lt;code&gt;cd apps/mobile/ios
open Mobile.xcworkspace&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;warning&quot;&gt;
        &lt;strong&gt;중요:&lt;/strong&gt; &lt;code&gt;.xcodeproj&lt;/code&gt;가 아닌
        &lt;code&gt;.xcworkspace&lt;/code&gt; 파일을 열어야 합니다!
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h2&gt;8. Metro 번들러 시작 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;첫 번째 실행 시도와 에러&lt;/h3&gt;

      &lt;p&gt;Xcode에서 앱을 빌드했을 때 다음 에러가 발생:&lt;/p&gt;

      &lt;div class=&quot;error&quot;&gt;
        &lt;pre&gt;&lt;code&gt;No script URL provided. Make sure the packager is running 
or you have embedded a JS bundle in your application bundle.

unsanitizedScriptURLString = (null)&lt;/code&gt;&lt;/pre&gt;
      &lt;/div&gt;

      &lt;p&gt;
        &lt;strong&gt;원인:&lt;/strong&gt; Metro 번들러(JavaScript 개발 서버)가 실행되지
        않음
      &lt;/p&gt;
    &lt;/div&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 7: Metro 번들러 시작&lt;/h3&gt;

      &lt;p&gt;별도의 터미널 창에서 Metro를 실행해야 합니다:&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;# 프로젝트 루트에서
npx nx start mobile --port 8081&lt;/code&gt;&lt;/pre&gt;

      &lt;p&gt;Metro가 시작되면 다음과 같이 표시됩니다:&lt;/p&gt;

      &lt;pre&gt;&lt;code&gt;Packager is ready at http://localhost:8081
Starting project at /Users/.../naemamdaero/apps/mobile
Using src/app as the root directory for Expo Router.
Starting Metro Bundler
Waiting on http://localhost:8081&lt;/code&gt;&lt;/pre&gt;

      &lt;div class=&quot;success&quot;&gt;
        &lt;strong&gt;확인:&lt;/strong&gt; &lt;code&gt;http://localhost:8081/status&lt;/code&gt;에
        접속하면 &lt;code&gt;packager-status:running&lt;/code&gt;이 표시되어야 합니다.
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h2&gt;9. 앱 실행 성공! &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;단계 8: Xcode에서 앱 실행&lt;/h3&gt;

      &lt;p&gt;Metro가 실행된 상태에서:&lt;/p&gt;

      &lt;ol&gt;
        &lt;li&gt;Xcode 상단에서 시뮬레이터 선택 (예: iPhone 17 Pro)&lt;/li&gt;
        &lt;li&gt;&lt;code&gt;⌘ + R&lt;/code&gt; 누르거나 재생 버튼 클릭&lt;/li&gt;
        &lt;li&gt;빌드 및 번들링 진행&lt;/li&gt;
      &lt;/ol&gt;

      &lt;div class=&quot;success&quot;&gt;
        &lt;h4&gt;성공 로그:&lt;/h4&gt;
        &lt;pre&gt;&lt;code&gt;iOS apps/mobile/index.js ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ 99.7% (780/781)
iOS Bundled 4034ms apps/mobile/index.js (781 modules)&lt;/code&gt;&lt;/pre&gt;

        &lt;p&gt;
          &lt;strong&gt;결과:&lt;/strong&gt; iPhone 시뮬레이터에 &quot;Welcome Mobile  &quot; 화면이
          표시됩니다!
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    &lt;h2&gt;10. 개발 워크플로우 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;info&quot;&gt;
      &lt;h3&gt;일상적인 개발 흐름&lt;/h3&gt;

      &lt;h4&gt;1. Metro 서버 시작 (한 번만)&lt;/h4&gt;
      &lt;pre&gt;&lt;code&gt;npx nx start mobile --port 8081&lt;/code&gt;&lt;/pre&gt;
      &lt;p&gt;이 터미널은 계속 실행 상태로 유지합니다.&lt;/p&gt;

      &lt;h4&gt;2. Xcode에서 앱 실행&lt;/h4&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;code&gt;open apps/mobile/ios/Mobile.xcworkspace&lt;/code&gt;&lt;/li&gt;
        &lt;li&gt;시뮬레이터 선택&lt;/li&gt;
        &lt;li&gt;&lt;code&gt;⌘ + R&lt;/code&gt;로 실행&lt;/li&gt;
      &lt;/ul&gt;

      &lt;h4&gt;3. 코드 수정 및 리로드&lt;/h4&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;code&gt;apps/mobile/src/app/App.tsx&lt;/code&gt; 수정&lt;/li&gt;
        &lt;li&gt;저장&lt;/li&gt;
        &lt;li&gt;Xcode에서 &lt;code&gt;⌘ + R&lt;/code&gt; 또는&lt;/li&gt;
        &lt;li&gt;시뮬레이터에서 &lt;code&gt;⌘ + D&lt;/code&gt; → &quot;Reload&quot;&lt;/li&gt;
      &lt;/ul&gt;
    &lt;/div&gt;

    &lt;h2&gt;11. 유용한 명령어 모음 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;Nx 명령어&lt;/h3&gt;

      &lt;pre&gt;&lt;code&gt;# 테스트 실행
npx nx test mobile

# 린트 체크
npx nx lint mobile

# 타입 체크
npx nx typecheck mobile

# 프로젝트 정보 확인
npx nx show project @naemamdaero/mobile

# 프로젝트 그래프 시각화
npx nx graph&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;div class=&quot;step&quot;&gt;
      &lt;h3&gt;Expo 명령어&lt;/h3&gt;

      &lt;pre&gt;&lt;code&gt;# Metro 서버 시작
npx nx start mobile

# 웹에서 실행
npx nx serve mobile

# iOS 네이티브 프로젝트 재생성
npx nx prebuild mobile --platform ios --clean

# Android 네이티브 프로젝트 생성
npx nx prebuild mobile --platform android&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;h2&gt;12. 트러블슈팅 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;error&quot;&gt;
      &lt;h3&gt;문제: Metro 서버 포트 충돌&lt;/h3&gt;
      &lt;pre&gt;&lt;code&gt;Port 8081 is already in use&lt;/code&gt;&lt;/pre&gt;
      &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt;&lt;/p&gt;
      &lt;pre&gt;&lt;code&gt;lsof -ti:8081 | xargs kill -9
npx nx start mobile --port 8081&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;div class=&quot;error&quot;&gt;
      &lt;h3&gt;문제: Xcode에서 &quot;No script URL provided&quot; 에러&lt;/h3&gt;
      &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt; Metro 번들러가 실행 중인지 확인&lt;/p&gt;
      &lt;pre&gt;&lt;code&gt;curl http://localhost:8081/status
# 응답: packager-status:running&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;div class=&quot;error&quot;&gt;
      &lt;h3&gt;문제: CocoaPods 인코딩 에러&lt;/h3&gt;
      &lt;pre&gt;&lt;code&gt;Unicode Normalization not appropriate for ASCII-8BIT&lt;/code&gt;&lt;/pre&gt;
      &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt; &lt;code&gt;~/.zshrc&lt;/code&gt;에 추가&lt;/p&gt;
      &lt;pre&gt;&lt;code&gt;export LANG=en_US.UTF-8&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;div class=&quot;error&quot;&gt;
      &lt;h3&gt;문제: xcrun simctl 에러&lt;/h3&gt;
      &lt;pre&gt;&lt;code&gt;Error: xcrun simctl help exited with non-zero code: 72&lt;/code&gt;&lt;/pre&gt;
      &lt;p&gt;&lt;strong&gt;해결:&lt;/strong&gt;&lt;/p&gt;
      &lt;pre&gt;&lt;code&gt;sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer&lt;/code&gt;&lt;/pre&gt;
    &lt;/div&gt;

    &lt;h2&gt;13. 최종 프로젝트 구조 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;file-structure&quot;&gt;
      naemamdaero/ ├── apps/ │ ├── web/ # Next.js 웹 앱 │ ├── web-e2e/ # E2E
      테스트 │ └── mobile/ #   React Native 모바일 앱 │ ├── src/ │ │ └── app/ │
      │ ├── App.tsx │ │ └── App.spec.tsx │ ├── ios/ #   네이티브 iOS 프로젝트 │
      │ └── Mobile.xcworkspace │ ├── assets/ │ ├── app.json │ ├──
      metro.config.js │ └── package.json ├── package.json ├── nx.json └──
      tsconfig.base.json
    &lt;/div&gt;

    &lt;h2&gt;14. 다음 단계 제안 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;info&quot;&gt;
      &lt;h3&gt;앱 개선 아이디어&lt;/h3&gt;

      &lt;ul&gt;
        &lt;li&gt;
          &lt;strong&gt;공유 라이브러리:&lt;/strong&gt; &lt;code&gt;libs/&lt;/code&gt; 폴더에 web/mobile
          공통 로직 생성
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;UI 컴포넌트 라이브러리:&lt;/strong&gt; React Native Paper,
          NativeBase
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;네비게이션:&lt;/strong&gt; Expo Router (이미 설정됨) 또는 React
          Navigation
        &lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;상태 관리:&lt;/strong&gt; Zustand, Jotai, Redux Toolkit&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;API 통합:&lt;/strong&gt; 공유 API 클라이언트 라이브러리&lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;스타일링:&lt;/strong&gt; Tailwind (NativeWind), Styled Components
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;테스트:&lt;/strong&gt; React Native Testing Library, Detox E2E
        &lt;/li&gt;
      &lt;/ul&gt;
    &lt;/div&gt;

    &lt;h2&gt;15. 참고 자료 &lt;span class=&quot;emoji&quot;&gt; &lt;/span&gt;&lt;/h2&gt;

    &lt;ul&gt;
      &lt;li&gt;&lt;a href=&quot;https://nx.dev/nx-api/expo&quot;&gt;Nx Expo Plugin 문서&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/&quot;&gt;Expo 공식 문서&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href=&quot;https://reactnative.dev/&quot;&gt;React Native 공식 문서&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;
        &lt;a href=&quot;https://docs.expo.dev/router/introduction/&quot;
          &gt;Expo Router 문서&lt;/a
        &gt;
      &lt;/li&gt;
      &lt;li&gt;
        &lt;a href=&quot;https://nx.dev/concepts/more-concepts/monorepo-vs-polyrepo&quot;
          &gt;Nx 모노레포 개념&lt;/a
        &gt;
      &lt;/li&gt;
    &lt;/ul&gt;

    &lt;h2&gt;결론 &lt;span class=&quot;emoji&quot;&gt;✨&lt;/span&gt;&lt;/h2&gt;

    &lt;div class=&quot;success&quot;&gt;
      &lt;p&gt;
        Nx 모노레포에 Expo React Native 앱을 성공적으로 추가하고 Xcode에서
        실행했습니다! 이제 다음을 할 수 있습니다:
      &lt;/p&gt;

      &lt;ul&gt;
        &lt;li&gt;
          ✅ 웹(Next.js)과 모바일(React Native) 앱을 하나의 모노레포에서 관리
        &lt;/li&gt;
        &lt;li&gt;✅ 공통 로직과 타입을 라이브러리로 공유&lt;/li&gt;
        &lt;li&gt;✅ Xcode에서 네이티브 코드 수정 및 디버깅&lt;/li&gt;
        &lt;li&gt;✅ Metro 번들러를 통한 빠른 개발 경험&lt;/li&gt;
        &lt;li&gt;✅ Nx의 강력한 빌드 시스템과 캐싱 활용&lt;/li&gt;
      &lt;/ul&gt;

      &lt;p&gt;모노레포의 장점을 활용하여 효율적인 풀스택 개발을 시작하세요!  &lt;/p&gt;
    &lt;/div&gt;

    &lt;hr /&gt;

    &lt;footer
      style=&quot;text-align: center; color: #666; padding: 20px 0; margin-top: 40px&quot;
    &gt;
      &lt;p&gt;작성일: 2025년 10월 19일&lt;/p&gt;
      &lt;p&gt;
        개발 환경: macOS, Xcode, Node.js, Nx 21.6.5, Expo 53, React Native 0.79
      &lt;/p&gt;
    &lt;/footer&gt;
  &lt;/body&gt;
&lt;/html&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/433</guid>
      <comments>https://deok2kim.tistory.com/433#entry433comment</comments>
      <pubDate>Mon, 20 Oct 2025 00:13:27 +0900</pubDate>
    </item>
    <item>
      <title>'use client' 하나 안 써서 개발 서버가 터졌다</title>
      <link>https://deok2kim.tistory.com/432</link>
      <description>&lt;h1&gt;Next.js 15 App Router에서 Emotion 사용 시 createContext 에러 해결하기&lt;/h1&gt;

&lt;h2&gt;문제 상황&lt;/h2&gt;

&lt;p&gt;Next.js 15 App Router 환경에서 &lt;code&gt;@emotion/styled&lt;/code&gt;를 사용하여 개발 서버를 실행했더니 다음과 같은 에러가 발생했습니다.&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
⨯ TypeError: createContext only works in Client Components. 
Add the &quot;use client&quot; directive at the top of the file to use it. 
Read more: https://nextjs.org/docs/messages/context-in-server-component

at eval (webpack-internal:///(rsc)/../../node_modules/@emotion/react/dist/emotion-element-6bdfffb2.esm.js:37:77)
at eval (webpack-internal:///(rsc)/./src/app/layout.tsx:6:88)
&lt;/pre&gt;

&lt;p&gt;브라우저에서도 500 에러가 발생하며 페이지가 렌더링되지 않았습니다.&lt;/p&gt;

&lt;h2&gt;원인 분석&lt;/h2&gt;

&lt;p&gt;Next.js 15의 App Router는 &lt;strong&gt;기본적으로 모든 컴포넌트를 서버 컴포넌트로 처리&lt;/strong&gt;합니다. 그런데 Emotion은 내부적으로 React의 &lt;code&gt;createContext&lt;/code&gt;를 사용하는데, 이는 클라이언트 사이드에서만 작동합니다.&lt;/p&gt;

&lt;p&gt;문제를 더 복잡하게 만든 것은 &lt;code&gt;tsconfig.json&lt;/code&gt;의 설정이었습니다:&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
{
  &quot;compilerOptions&quot;: {
    &quot;jsxImportSource&quot;: &quot;@emotion/react&quot;
  }
}
&lt;/pre&gt;

&lt;p&gt;이 설정은 &lt;strong&gt;모든 JSX를 Emotion의 JSX runtime으로 변환&lt;/strong&gt;하기 때문에, &lt;code&gt;layout.tsx&lt;/code&gt; 같은 서버 컴포넌트에도 Emotion이 적용되어 에러가 발생했습니다.&lt;/p&gt;

&lt;h3&gt;왜 이런 에러가 발생할까?&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;서버 컴포넌트의 제약사항&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;서버 컴포넌트는 서버에서만 실행됨&lt;/li&gt;
&lt;li&gt;브라우저 API (useState, useEffect, createContext 등)를 사용할 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Emotion의 동작 방식&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;CSS-in-JS 라이브러리는 런타임에 스타일을 생성&lt;/li&gt;
&lt;li&gt;Context API를 통해 스타일 캐시를 관리&lt;/li&gt;
&lt;li&gt;클라이언트 환경이 필수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;jsxImportSource의 영향&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;모든 파일의 JSX를 Emotion으로 변환&lt;/li&gt;
&lt;li&gt;서버 컴포넌트와 클라이언트 컴포넌트 구분 없이 적용&lt;/li&gt;
&lt;li&gt;결과적으로 서버 컴포넌트에서 Context API 사용 시도&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;해결 방법&lt;/h2&gt;

&lt;h3&gt;1단계: Emotion Provider 생성&lt;/h3&gt;

&lt;p&gt;먼저 Emotion 캐시를 관리할 클라이언트 컴포넌트를 생성합니다.&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
&lt;code&gt;// src/app/emotion-provider.tsx
'use client';

import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react';

export default function EmotionProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [cache] = useState(() =&amp;gt; {
    const cache = createCache({ key: 'css' });
    cache.compat = true;
    return cache;
  });

  useServerInsertedHTML(() =&amp;gt; {
    return (
      &amp;lt;style
        data-emotion={`${cache.key} ${Object.keys(cache.inserted).join(' ')}`}
        dangerouslySetInnerHTML={{
          __html: Object.values(cache.inserted).join(' '),
        }}
      /&amp;gt;
    );
  });

  return &amp;lt;CacheProvider value={cache}&amp;gt;{children}&amp;lt;/CacheProvider&amp;gt;;
}&lt;/code&gt;
&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;핵심 포인트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;'use client'&lt;/code&gt; 지시어로 클라이언트 컴포넌트 명시&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useServerInsertedHTML&lt;/code&gt;로 SSR 시 스타일 주입&lt;/li&gt;
&lt;li&gt;Emotion 캐시를 격리하여 관리&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;2단계: tsconfig.json 수정&lt;/h3&gt;

&lt;p&gt;전역 &lt;code&gt;jsxImportSource&lt;/code&gt; 설정을 제거합니다.&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
&lt;code&gt;{
  &quot;compilerOptions&quot;: {
    &quot;jsx&quot;: &quot;preserve&quot;,
    // &quot;jsxImportSource&quot;: &quot;@emotion/react&quot;, ← 이 줄 제거
    &quot;noEmit&quot;: true,
    // ... 나머지 설정
  }
}&lt;/code&gt;
&lt;/pre&gt;

&lt;h3&gt;3단계: layout.tsx에 Provider 적용&lt;/h3&gt;

&lt;p&gt;루트 레이아웃에 EmotionProvider를 추가합니다.&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
&lt;code&gt;// src/app/layout.tsx
import './global.css';
import EmotionProvider from './emotion-provider';

export const metadata = {
  title: 'Welcome to ',
  description: 'Generated by create-nx-workspace',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;EmotionProvider&amp;gt;{children}&amp;lt;/EmotionProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&lt;/code&gt;
&lt;/pre&gt;

&lt;h3&gt;4단계: 페이지 컴포넌트에 'use client' 추가&lt;/h3&gt;

&lt;p&gt;Emotion을 사용하는 페이지 컴포넌트를 클라이언트 컴포넌트로 만듭니다.&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
&lt;code&gt;// src/app/page.tsx
'use client';

import styled from '@emotion/styled';

const StyledPage = styled.div`
  .page {
    /* 스타일 */
  }
`;

export default function Index() {
  return (
    &amp;lt;StyledPage&amp;gt;
      {/* 컨텐츠 */}
    &amp;lt;/StyledPage&amp;gt;
  );
}&lt;/code&gt;
&lt;/pre&gt;

&lt;h3&gt;5단계: 필요한 패키지 설치&lt;/h3&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
npm install @emotion/cache
&lt;/pre&gt;

&lt;h2&gt;결과&lt;/h2&gt;

&lt;p&gt;위 단계를 모두 완료하면 개발 서버가 정상적으로 실행되며, Emotion styled-components가 Next.js 15 App Router 환경에서 정상 작동합니다.&lt;/p&gt;

&lt;pre style=&quot;background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;&quot;&gt;
npx nx dev web
# 또는
cd apps/web &amp;amp;&amp;amp; npm run dev

✓ Ready in 1322ms
✓ Compiled / in 2.3s (749 modules)
&lt;/pre&gt;

&lt;h2&gt;핵심 정리&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Next.js 15 App Router + Emotion 사용 시 체크리스트:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☑️ Emotion을 사용하는 컴포넌트에 &lt;code&gt;'use client'&lt;/code&gt; 추가&lt;/li&gt;
&lt;li&gt;☑️ &lt;code&gt;EmotionProvider&lt;/code&gt;를 클라이언트 컴포넌트로 분리&lt;/li&gt;
&lt;li&gt;☑️ &lt;code&gt;tsconfig.json&lt;/code&gt;에서 전역 &lt;code&gt;jsxImportSource&lt;/code&gt; 제거&lt;/li&gt;
&lt;li&gt;☑️ &lt;code&gt;layout.tsx&lt;/code&gt;에 &lt;code&gt;EmotionProvider&lt;/code&gt; 적용&lt;/li&gt;
&lt;li&gt;☑️ &lt;code&gt;@emotion/cache&lt;/code&gt; 패키지 설치&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;기억해야 할 원칙:&lt;/strong&gt;&lt;/p&gt;

&lt;p style=&quot;background: #fff3cd; padding: 15px; border-left: 4px solid #ffc107;&quot;&gt;
CSS-in-JS 라이브러리(Emotion, styled-components 등)는 클라이언트 환경이 필수이므로, Next.js 15 App Router에서는 반드시 클라이언트 컴포넌트로 사용해야 합니다. 전역 설정보다는 명시적으로 필요한 곳에서만 적용하는 것이 안전합니다.
&lt;/p&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/rendering/server-components&quot; target=&quot;_blank&quot;&gt;Next.js 13+ Server and Client Components&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://emotion.sh/docs/introduction&quot; target=&quot;_blank&quot;&gt;Emotion Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/messages/context-in-server-component&quot; target=&quot;_blank&quot;&gt;Next.js context-in-server-component Error&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발 일지/회사 기술 스택</category>
      <category>emotion</category>
      <category>nextjs</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/432</guid>
      <comments>https://deok2kim.tistory.com/432#entry432comment</comments>
      <pubDate>Sun, 19 Oct 2025 01:20:55 +0900</pubDate>
    </item>
    <item>
      <title>ESModule 쓸모 없는 거 아님? (CommonJS vs ESModule)</title>
      <link>https://deok2kim.tistory.com/431</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c3omD9/btsQYJ042cx/gYbqG8Zumj35kjNLFxwtdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c3omD9/btsQYJ042cx/gYbqG8Zumj35kjNLFxwtdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c3omD9/btsQYJ042cx/gYbqG8Zumj35kjNLFxwtdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc3omD9%2FbtsQYJ042cx%2FgYbqG8Zumj35kjNLFxwtdk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;360&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1 data-end=&quot;199&quot; data-start=&quot;167&quot;&gt;CommonJS vs ESModule, 제대로 정리하기&lt;/h1&gt;
&lt;p data-end=&quot;258&quot; data-start=&quot;201&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론부터 말하면, 현재 자바스크립트에서 모듈 시스템은 두 가지(CJS, ESM)가 공존한다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;410&quot; data-start=&quot;259&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;308&quot; data-start=&quot;259&quot;&gt;오래된 Node.js 생태계에서는 **CommonJS(CJS)**가 널리 쓰였고,&lt;/li&gt;
&lt;li data-end=&quot;410&quot; data-start=&quot;309&quot;&gt;현대적인 웹/번들링/트리 셰이킹에서는 **ESModule(ESM)**이 표준이다.&lt;br /&gt;따라서 실무에서는 여전히 두 방식을 동시에 마주하게 되며, 이를 이해하는 것이 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-end=&quot;436&quot; data-start=&quot;417&quot; data-ke-size=&quot;size26&quot;&gt;1. CommonJS(CJS)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;785&quot; data-start=&quot;437&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;556&quot; data-start=&quot;437&quot;&gt;&lt;b&gt;형식&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1759319536060&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 가져오기
const fs = require(&quot;fs&quot;);

// 내보내기
module.exports = { read: fs.readFileSync };&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;785&quot; data-start=&quot;437&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;673&quot; data-start=&quot;557&quot;&gt;&lt;b&gt;특징&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;673&quot; data-start=&quot;568&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;586&quot; data-start=&quot;568&quot;&gt;Node.js 초창기부터 사용&lt;/li&gt;
&lt;li data-end=&quot;625&quot; data-start=&quot;589&quot;&gt;require, module.exports 키워드 사용&lt;/li&gt;
&lt;li data-end=&quot;649&quot; data-start=&quot;628&quot;&gt;&lt;b&gt;런타임에 모듈 로딩&lt;/b&gt; (동적)&lt;/li&gt;
&lt;li data-end=&quot;673&quot; data-start=&quot;652&quot;&gt;동기적 특성 &amp;rarr; 서버 환경에서 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;731&quot; data-start=&quot;674&quot;&gt;&lt;b&gt;장점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;731&quot; data-start=&quot;685&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;706&quot; data-start=&quot;685&quot;&gt;Node.js 생태계에 풍부한 지원&lt;/li&gt;
&lt;li data-end=&quot;731&quot; data-start=&quot;709&quot;&gt;오래된 라이브러리 대부분 CJS 기반&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;785&quot; data-start=&quot;732&quot;&gt;&lt;b&gt;단점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;785&quot; data-start=&quot;743&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;754&quot; data-start=&quot;743&quot;&gt;정적 분석 어려움&lt;/li&gt;
&lt;li data-end=&quot;785&quot; data-start=&quot;757&quot;&gt;트리 셰이킹 불가능 &amp;rarr; 번들 크기 최적화 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;790&quot; data-start=&quot;787&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;811&quot; data-start=&quot;792&quot; data-ke-size=&quot;size26&quot;&gt;2. ESModule(ESM)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1174&quot; data-start=&quot;812&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;812&quot;&gt;&lt;b&gt;형식&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1759319584031&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 가져오기
import fs from &quot;fs&quot;;

// 내보내기
export const read = fs.readFileSync;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1174&quot; data-start=&quot;812&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;812&quot;&gt;&lt;b&gt;특징&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;812&quot;&gt;자바스크립트 표준 모듈 시스템&lt;/li&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;812&quot;&gt;import, export 키워드 사용&lt;/li&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;812&quot;&gt;&lt;b&gt;정적 로딩&lt;/b&gt; (컴파일 타임 분석 가능)&lt;/li&gt;
&lt;li data-end=&quot;919&quot; data-start=&quot;812&quot;&gt;브라우저 &amp;amp; Node.js 양쪽 모두 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1116&quot; data-start=&quot;1037&quot;&gt;&lt;b&gt;장점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1116&quot; data-start=&quot;1048&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1071&quot; data-start=&quot;1048&quot;&gt;트리 셰이킹 지원 &amp;rarr; 불필요 코드 제거&lt;/li&gt;
&lt;li data-end=&quot;1098&quot; data-start=&quot;1074&quot;&gt;비동기 로딩 가능 (import())&lt;/li&gt;
&lt;li data-end=&quot;1116&quot; data-start=&quot;1101&quot;&gt;TS/번들러와 궁합 좋음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1174&quot; data-start=&quot;1117&quot;&gt;&lt;b&gt;단점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1174&quot; data-start=&quot;1128&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1151&quot; data-start=&quot;1128&quot;&gt;구버전 Node.js에서는 지원 안 됨&lt;/li&gt;
&lt;li data-end=&quot;1174&quot; data-start=&quot;1154&quot;&gt;여전히 일부 패키지는 CJS 전용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1179&quot; data-start=&quot;1176&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1192&quot; data-start=&quot;1181&quot; data-ke-size=&quot;size26&quot;&gt;3. 성능 차이&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1311&quot; data-start=&quot;1193&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1251&quot; data-start=&quot;1193&quot;&gt;&lt;b&gt;CJS&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1251&quot; data-start=&quot;1205&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1237&quot; data-start=&quot;1205&quot;&gt;런타임에 모듈을 해석 &amp;rarr; 실행 속도 약간 느릴 수 있음&lt;/li&gt;
&lt;li data-end=&quot;1251&quot; data-start=&quot;1240&quot;&gt;트리 셰이킹 불가&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1311&quot; data-start=&quot;1252&quot;&gt;&lt;b&gt;ESM&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1311&quot; data-start=&quot;1264&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1293&quot; data-start=&quot;1264&quot;&gt;빌드 타임에 의존성 그래프를 분석 &amp;rarr; 최적화 유리&lt;/li&gt;
&lt;li data-end=&quot;1311&quot; data-start=&quot;1296&quot;&gt;번들 크기 절감 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1349&quot; data-start=&quot;1313&quot; data-ke-size=&quot;size16&quot;&gt;  실제 서비스 규모로 갈수록 &lt;b&gt;ESM이 성능상 유리&lt;/b&gt;하다.&lt;/p&gt;
&lt;hr data-end=&quot;1354&quot; data-start=&quot;1351&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1373&quot; data-start=&quot;1356&quot; data-ke-size=&quot;size26&quot;&gt;4. 왜 ESM이 나왔나?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1473&quot; data-start=&quot;1374&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1397&quot; data-start=&quot;1374&quot;&gt;브라우저에서 표준화된 모듈 시스템 필요&lt;/li&gt;
&lt;li data-end=&quot;1430&quot; data-start=&quot;1398&quot;&gt;빌드 툴(웹팩, 롤업 등)에서 정적 분석과 최적화 필요&lt;/li&gt;
&lt;li data-end=&quot;1473&quot; data-start=&quot;1431&quot;&gt;CJS의 한계를 극복하고 &lt;b&gt;&quot;언어 차원에서 공식 지원&quot;&lt;/b&gt;을 하기 위함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1478&quot; data-start=&quot;1475&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1497&quot; data-start=&quot;1480&quot; data-ke-size=&quot;size26&quot;&gt;5. CJS와 ESM 혼용&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1703&quot; data-start=&quot;1498&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1600&quot; data-start=&quot;1498&quot;&gt;&lt;b&gt;같은 파일 안에서는 불가능&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1759319644648&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ 실행 오류
import fs from &quot;fs&quot;;
const path = require(&quot;path&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1703&quot; data-start=&quot;1498&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1703&quot; data-start=&quot;1602&quot;&gt;&lt;b&gt;다른 파일 단위로는 가능&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1703&quot; data-start=&quot;1624&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1648&quot; data-start=&quot;1624&quot;&gt;어떤 파일은 CJS, 어떤 파일은 ESM&lt;/li&gt;
&lt;li data-end=&quot;1703&quot; data-start=&quot;1651&quot;&gt;Node.js는 import()와 createRequire API로 브리지 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1729&quot; data-start=&quot;1705&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 1. CJS에서 ESM 가져오기&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1759319657702&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// index.cjs
(async () =&amp;gt; {
  const { readFile } = await import(&quot;fs/promises&quot;);
  const data = await readFile(&quot;./hello.txt&quot;, &quot;utf-8&quot;);
  console.log(data);
})();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1927&quot; data-start=&quot;1903&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 2. ESM에서 CJS 가져오기&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1759319669732&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// index.mjs
import { createRequire } from &quot;module&quot;;
const require = createRequire(import.meta.url);

const fs = require(&quot;fs&quot;);
console.log(fs.existsSync(&quot;./hello.txt&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;2113&quot; data-start=&quot;2110&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2126&quot; data-start=&quot;2115&quot; data-ke-size=&quot;size26&quot;&gt;6. 변환 예시&lt;/h2&gt;
&lt;h3 data-end=&quot;2141&quot; data-start=&quot;2128&quot; data-ke-size=&quot;size23&quot;&gt;CJS &amp;rarr; ESM&lt;/h3&gt;
&lt;pre id=&quot;code_1759319689060&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// CJS
const fs = require(&quot;fs&quot;);
module.exports = { read: fs.readFileSync };

// ESM
import fs from &quot;fs&quot;;
export const read = fs.readFileSync;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2309&quot; data-start=&quot;2296&quot; data-ke-size=&quot;size23&quot;&gt;ESM &amp;rarr; CJS&lt;/h3&gt;
&lt;pre id=&quot;code_1759319698957&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ESM
import fs from &quot;fs&quot;;
export function read(path) {
  return fs.readFileSync(path, &quot;utf-8&quot;);
}

// CJS
const fs = require(&quot;fs&quot;);
exports.read = function (path) {
  return fs.readFileSync(path, &quot;utf-8&quot;);
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-end=&quot;2535&quot; data-start=&quot;2532&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2545&quot; data-start=&quot;2537&quot; data-ke-size=&quot;size26&quot;&gt;7. 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2790&quot; data-start=&quot;2546&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2596&quot; data-start=&quot;2546&quot;&gt;&lt;b&gt;CJS&lt;/b&gt;: Node.js 초창기 모듈 시스템, 런타임 로딩, 트리 셰이킹 불가&lt;/li&gt;
&lt;li data-end=&quot;2644&quot; data-start=&quot;2597&quot;&gt;&lt;b&gt;ESM&lt;/b&gt;: 자바스크립트 표준 모듈 시스템, 정적 분석 가능, 최적화 유리&lt;/li&gt;
&lt;li data-end=&quot;2714&quot; data-start=&quot;2645&quot;&gt;&lt;b&gt;한 파일 내 혼용 불가&lt;/b&gt; &amp;rarr; 프로젝트 단위에서는 혼용 가능 (import(), createRequire)&lt;/li&gt;
&lt;li data-end=&quot;2741&quot; data-start=&quot;2715&quot;&gt;&lt;b&gt;실무에서는 ESM 기본 사용 권장&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2790&quot; data-start=&quot;2742&quot;&gt;단, 레거시 라이브러리나 특정 패키지 때문에 CJS 브리지가 필요한 경우가 많음&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;2795&quot; data-start=&quot;2792&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;2912&quot; data-start=&quot;2797&quot; data-ke-size=&quot;size16&quot;&gt;  정리하면, &lt;b&gt;ESM은 앞으로의 표준&lt;/b&gt;이고 &lt;b&gt;CJS는 과거 유산&lt;/b&gt;이지만, 여전히 공존해야 하는 상황이다.&lt;br /&gt;따라서 개발자는 &lt;b&gt;두 방식의 차이와 변환 방법&lt;/b&gt;을 반드시 이해하고 있어야 한다.&lt;/p&gt;</description>
      <category>cjs</category>
      <category>CommonJS</category>
      <category>ESM</category>
      <category>esmodule</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/431</guid>
      <comments>https://deok2kim.tistory.com/431#entry431comment</comments>
      <pubDate>Wed, 1 Oct 2025 20:59:38 +0900</pubDate>
    </item>
    <item>
      <title>[SSL] Certbot으로 HTTPS 설정 (도메인 구입 후)</title>
      <link>https://deok2kim.tistory.com/430</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;542&quot; data-origin-height=&quot;336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJivYN/btsOsAE6okl/rdSXMSqFUz9mkJ3uksHkF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJivYN/btsOsAE6okl/rdSXMSqFUz9mkJ3uksHkF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJivYN/btsOsAE6okl/rdSXMSqFUz9mkJ3uksHkF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJivYN%2FbtsOsAE6okl%2FrdSXMSqFUz9mkJ3uksHkF0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;542&quot; height=&quot;336&quot; data-origin-width=&quot;542&quot; data-origin-height=&quot;336&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 들어가기 전에 기본 설정 (nginx)&lt;/h2&gt;
&lt;pre id=&quot;code_1749280832133&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vim /etc/nginx/sites-available/default&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1749280913165&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server {
        root /var/www/html;

        index index.html index.htm index.nginx-debian.html;

        server_name your_domain_name; # 여기

        location / {
                proxy_pass http://localhost:8080; # 여기
                proxy_set_header X-Real-IP $remote_addr; # 여기
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 여기
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. install certbot&lt;/h2&gt;
&lt;pre id=&quot;code_1749280657278&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install certbot python3-certbot-nginx -y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 도메인 인증 받기&lt;/h2&gt;
&lt;pre id=&quot;code_1749280753926&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo certbot --nginx -d your_domain_name&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 접근&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 주소로 접근하면 자물쇠가 잘 달려있는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;620&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uXuv5/btsOr2PE12h/TFAPBrb1aEQnjyMrLUlIY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uXuv5/btsOr2PE12h/TFAPBrb1aEQnjyMrLUlIY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uXuv5/btsOr2PE12h/TFAPBrb1aEQnjyMrLUlIY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuXuv5%2FbtsOr2PE12h%2FTFAPBrb1aEQnjyMrLUlIY1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;416&quot; data-origin-width=&quot;620&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Infra</category>
      <author>deo2kim</author>
      <guid isPermaLink="true">https://deok2kim.tistory.com/430</guid>
      <comments>https://deok2kim.tistory.com/430#entry430comment</comments>
      <pubDate>Mon, 9 Jun 2025 17:24:25 +0900</pubDate>
    </item>
  </channel>
</rss>