<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>TechVault</title>
    <link>https://tech-vault-lior.tistory.com/</link>
    <description>lior 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Fri, 17 Jul 2026 21:27:19 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Lior</managingEditor>
    <item>
      <title>[Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝</title>
      <link>https://tech-vault-lior.tistory.com/15</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
  &lt;div class=&quot;db-eyebrow&quot;&gt;Flutter Riverpod 실무 시리즈 · 5편&lt;/div&gt;
  &lt;h1&gt;[Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝&lt;/h1&gt;
  &lt;p&gt;동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다.&lt;/p&gt;
  &lt;div class=&quot;db-meta&quot;&gt;버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-env&quot;&gt;&lt;span&gt;Flutter&lt;/span&gt;&lt;span&gt;Dart 3.x&lt;/span&gt;&lt;span&gt;flutter_riverpod ^2.5.1&lt;/span&gt;&lt;span&gt;수동 Provider 정의 기준&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;strong&gt;이 글에서 다루는 내용&lt;/strong&gt;동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.&lt;/div&gt;
&lt;div class=&quot;db-series&quot;&gt;&lt;div class=&quot;db-series-title&quot;&gt;Flutter Riverpod 실무 시리즈&lt;/div&gt;&lt;ol&gt;&lt;li&gt;1편. Provider 종류와 기본 사용법&lt;/li&gt;&lt;li&gt;2편. AsyncNotifier로 비동기 CRUD 상태 관리하기&lt;/li&gt;&lt;li&gt;3편. ref.watch / ref.read / ref.listen 차이 완전 정리&lt;/li&gt;&lt;li&gt;4편. family 패턴으로 매개변수 기반 Provider 만들기&lt;/li&gt;&lt;li class=&quot;db-current&quot;&gt;5편. invalidate 전략과 Provider 의존성 체이닝&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;
&lt;nav class=&quot;db-toc&quot;&gt;&lt;div class=&quot;db-toc-title&quot;&gt;목차&lt;/div&gt;&lt;ol&gt;&lt;li&gt;&lt;a href=&quot;#1.-소개&quot;&gt;1. 소개&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#2.-invalidate-invalidateself-refresh-차이&quot;&gt;2. invalidate / invalidateSelf / refresh 차이&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#3.-provider-의존성-체이닝&quot;&gt;3. Provider 의존성 체이닝&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#4.-실무-적용-방법-완전한-동기화-연쇄-무효화-예제&quot;&gt;4. 실무 적용 방법 — 완전한 동기화 + 연쇄 무효화 예제&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#5.-autodispose와-ref.ondispose로-리소스-관리&quot;&gt;5. autoDispose와 ref.onDispose로 리소스 관리&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#6.-주의사항&quot;&gt;6. 주의사항&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#7.-성능-구조-팁&quot;&gt;7. 성능 / 구조 팁&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#8.-정리&quot;&gt;8. 정리&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#시리즈-전체-정리&quot;&gt;시리즈 전체 정리&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/nav&gt;
&lt;figure class=&quot;db-diagram&quot;&gt;&lt;pre&gt;동기화 완료
  ├─ invalidate(productNotifierProvider)
  ├─ invalidate(categoryNotifierProvider)
  └─ invalidate(productsByCategoryProvider)&lt;/pre&gt;&lt;figcaption&gt;추천 다이어그램: 이 흐름을 Mermaid 또는 이미지로 변환해 대표 설명 이미지로 사용할 수 있습니다.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;db-good&quot;&gt;&lt;strong&gt;실무 적용 포인트&lt;/strong&gt;처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;시리즈 전체 목차&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;1편: Provider 종류와 기본 사용법 2편: AsyncNotifier — 비동기 상태의 기준점 3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자 4편: family 패턴 — 같은 Provider, 다른 데이터 &lt;strong&gt;5편: invalidate 전략과 Provider 의존성 체이닝 ← 현재 글&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1.-소개&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p&gt;앱을 처음 만들 때는 Provider 하나가 독립적으로 동작하는 것처럼 보여요. 그런데 실제 앱에선 한 액션이 여러 Provider에 영향을 미치고, 이 Provider들이 서로 의존하는 경우가 생겨요.&lt;/p&gt;
&lt;p&gt;예를 들어, 상품 동기화가 완료되면 어떻게 되어야 할까요?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;카테고리별 상품 목록 갱신&lt;/li&gt;
&lt;li&gt;검색 결과 갱신&lt;/li&gt;
&lt;li&gt;장바구니 내 상품 정보 갱신&lt;/li&gt;
&lt;li&gt;상단 뱃지 개수 갱신&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이걸 각 화면에서 개별로 처리하면 놓치는 케이스가 생겨요. 특히 첫 설치 직후처럼 로컬 DB는 비어있다가 동기화로 데이터가 채워지는 경우, DB엔 데이터가 있는데 Riverpod Provider는 여전히 빈 배열을 들고 있는 상황이 실제로 꽤 자주 생겨요.&lt;/p&gt;
&lt;p&gt;이 편에서는 이런 상황을 체계적으로 다루는 방법을 정리할게요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;invalidate&lt;/code&gt; vs &lt;code&gt;invalidateSelf&lt;/code&gt; vs &lt;code&gt;refresh&lt;/code&gt; — 언제 뭘 써야 하나&lt;/li&gt;
&lt;li&gt;Provider 의존성 체이닝 — watch로 자동 연결하는 방법&lt;/li&gt;
&lt;li&gt;동기화 완료 후 연쇄 무효화 패턴&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autoDispose&lt;/code&gt;와 &lt;code&gt;ref.onDispose()&lt;/code&gt;로 리소스 관리&lt;/li&gt;
&lt;li&gt;순환 의존성 피하기&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;flutter_riverpod: ^2.5.1&lt;/code&gt; 기준으로 작성했어요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2.-invalidate-invalidateself-refresh-차이&quot;&gt;2. invalidate / invalidateSelf / refresh 차이&lt;/h2&gt;
&lt;p&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;동작&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;ref.invalidate(provider)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;어디서든&lt;/td&gt;&lt;td&gt;캐시를 버리고, 다음 접근 시 재실행&lt;/td&gt;&lt;td&gt;&lt;code&gt;void&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ref.invalidateSelf()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Notifier 내부&lt;/td&gt;&lt;td&gt;자기 자신의 build() 재실행 예약&lt;/td&gt;&lt;td&gt;&lt;code&gt;void&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ref.refresh(provider)&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;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;invalidate-캐시를-버려-다음에-쓸-때-다시-실행해&quot;&gt;invalidate — 캐시를 버려, 다음에 쓸 때 다시 실행해&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 상품 추가 후 관련 목록들 무효화
Future&amp;lt;void&amp;gt; addProduct(Product product) async {
  await ref.read(productRepositoryProvider).insert(product);

  // 이 카테고리의 캐시를 버림 → 다음에 ref.watch할 때 다시 API 호출
  ref.invalidate(productsByCategoryProvider(product.categoryId));

  // 전체 상품 목록도 갱신
  ref.invalidate(allProductsProvider);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;invalidate()&lt;/code&gt;는 즉시 재실행하지 않아요. &amp;quot;다음에 누군가 이 Provider를 쓸 때 새로 실행해&amp;quot;라는 예약이에요. 만약 현재 아무도 이 Provider를 감시하고 있지 않다면 그냥 캐시만 지워요.&lt;/p&gt;
&lt;h3 id=&quot;invalidateself-나-자신을-무효화&quot;&gt;invalidateSelf — 나 자신을 무효화&lt;/h3&gt;
&lt;p&gt;Notifier 안에서만 쓸 수 있어요. &lt;code&gt;ref.invalidateSelf()&lt;/code&gt;를 호출하면 &lt;code&gt;build()&lt;/code&gt;가 다시 실행돼요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ProductNotifier extends AsyncNotifier&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; {
  @override
  Future&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; build() {
    return ref.read(productRepositoryProvider).fetchAll();
  }

  Future&amp;lt;void&amp;gt; deleteProduct(String id) async {
    await ref.read(productRepositoryProvider).delete(id);
    ref.invalidateSelf();  // build() 재실행 → 목록 갱신
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;refresh-지금-당장-재실행하고-값-반환해&quot;&gt;refresh — 지금 당장 재실행하고 값 반환해&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 풀-투-리프레시에서 즉시 데이터 갱신
Future&amp;lt;void&amp;gt; onRefresh() async {
  await ref.refresh(productNotifierProvider.future);
}

// 구독 상태를 즉시 확인해야 할 때
await ref.refresh(subscriptionProvider.future);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;refresh()&lt;/code&gt;는 동기적으로 재실행을 시작하고, &lt;code&gt;.future&lt;/code&gt;를 붙이면 완료를 기다릴 수 있어요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3.-provider-의존성-체이닝&quot;&gt;3. Provider 의존성 체이닝&lt;/h2&gt;
&lt;h3 id=&quot;ref.watch-로-자동-의존성-선언&quot;&gt;ref.watch()로 자동 의존성 선언&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;build()&lt;/code&gt; 안에서 &lt;code&gt;ref.watch()&lt;/code&gt;로 다른 Provider를 감시하면, 그 Provider가 바뀔 때 자동으로 &lt;code&gt;build()&lt;/code&gt;가 재실행돼요. 이게 Riverpod에서 의존성을 연결하는 기본 방법이에요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class OrderNotifier extends AsyncNotifier&amp;lt;List&amp;lt;Order&amp;gt;&amp;gt; {
  @override
  Future&amp;lt;List&amp;lt;Order&amp;gt;&amp;gt; build() async {
    // 로그인 상태가 바뀌면 자동으로 재실행
    final user = ref.watch(currentUserProvider);
    if (user == null) return [];

    // 구독 상태가 바뀌면 자동으로 재실행
    final isSubscribed = ref.watch(isSubscribedProvider);
    if (!isSubscribed) return [];

    return ref.read(orderRepositoryProvider).fetchByUser(user.id);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;로그아웃하면 &lt;code&gt;currentUserProvider&lt;/code&gt;가 &lt;code&gt;null&lt;/code&gt;로 바뀌고, &lt;code&gt;OrderNotifier.build()&lt;/code&gt;가 자동으로 재실행돼서 빈 배열을 반환해요.&lt;/p&gt;
&lt;h3 id=&quot;체이닝-예제-동기화-초기화&quot;&gt;체이닝 예제 — 동기화 초기화&lt;/h3&gt;
&lt;p&gt;아래는 로그인 상태와 구독 상태를 모두 확인한 다음 동기화를 실행하는 예제예요. 로그인이나 구독 상태가 바뀌면 동기화가 자동으로 재트리거돼요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;&lt;summary&gt;전체 코드 보기&lt;/summary&gt;&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class SyncInitNotifier extends AsyncNotifier&amp;lt;void&amp;gt; {
  @override
  Future&amp;lt;void&amp;gt; build() async {
    // 1. 로그인 상태 감시
    final user = ref.watch(currentUserProvider);

    // 2. 구독 상태 감시
    final isSubscribed = ref.watch(isSubscribedProvider);

    // 조건 불충족 시 조기 반환
    if (user == null || !isSubscribed) return;

    // 3. 동기화 실행 (수동 단계별 처리)
    final syncService = ref.read(syncServiceProvider);

    ref.read(syncStateProvider.notifier).state = SyncState.syncing;

    try {
      await syncService.fullSync(
        onProgress: (step, current, total) {
          ref.read(syncProgressProvider.notifier).state =
              SyncProgress(step: step, current: current, total: total);
        },
      );

      // 4. 동기화 완료 → 관련 Provider 전체 무효화
      ref.read(syncStateProvider.notifier).state = SyncState.synced;
      ref.read(syncProgressProvider.notifier).state = null;

      _invalidateDataProviders();
    } catch (e) {
      ref.read(syncStateProvider.notifier).state = SyncState.error;
    }
  }

  void _invalidateDataProviders() {
    // 동기화로 DB 내용이 바뀌었으니 관련 캐시 전부 버림
    ref.invalidate(productNotifierProvider);
    ref.invalidate(categoryNotifierProvider);
    ref.invalidate(productsByCategoryProvider);  // family 전체
    ref.invalidate(allProductsProvider);
  }
}

final syncInitProvider = AsyncNotifierProvider&amp;lt;SyncInitNotifier, void&amp;gt;(
  SyncInitNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;&lt;/details&gt;
&lt;p&gt;홈 화면에서 &lt;code&gt;syncInitProvider&lt;/code&gt;를 감시만 하면, 로그인 상태가 바뀔 때마다 동기화가 자동으로 실행돼요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
Widget build(BuildContext context, WidgetRef ref) {
  // 이것만 있으면 로그인/로그아웃 시 동기화 자동 재트리거
  ref.watch(syncInitProvider);

  // 나머지 UI 빌드...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;의존성-흐름-시각화&quot;&gt;의존성 흐름 시각화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-&quot;&gt;SyncInitProvider
  ├── ref.watch(currentUserProvider)  ← 로그인 상태
  │     └── StreamProvider(authStateChanges)
  └── ref.watch(isSubscribedProvider) ← 구독 상태
        └── StreamProvider(firestoreSubscriptionStream)

동기화 완료 후 → invalidate:
  ├── productNotifierProvider
  ├── categoryNotifierProvider
  └── productsByCategoryProvider (family 전체)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4.-실무-적용-방법-완전한-동기화-연쇄-무효화-예제&quot;&gt;4. 실무 적용 방법 — 완전한 동기화 + 연쇄 무효화 예제&lt;/h2&gt;
&lt;p&gt;쇼핑몰 앱에서 데이터 동기화 완료 후 모든 화면이 갱신되는 패턴이에요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;&lt;summary&gt;전체 코드 보기&lt;/summary&gt;&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ---- providers/sync_provider.dart ----

// 동기화 진행 상태
enum SyncState { idle, syncing, synced, error }
final syncStateProvider = StateProvider&amp;lt;SyncState&amp;gt;((_) =&amp;gt; SyncState.idle);
final syncProgressProvider = StateProvider&amp;lt;SyncProgress?&amp;gt;((_) =&amp;gt; null);

class SyncInitNotifier extends AsyncNotifier&amp;lt;void&amp;gt; {
  @override
  Future&amp;lt;void&amp;gt; build() async {
    final user = ref.watch(currentUserProvider);
    final isSubscribed = ref.watch(isSubscribedProvider);

    if (user == null || !isSubscribed) return;

    await _runSync(user);
  }

  Future&amp;lt;void&amp;gt; _runSync(User user) async {
    final syncService = ref.read(syncServiceProvider);
    ref.read(syncStateProvider.notifier).state = SyncState.syncing;

    try {
      await syncService.fullSync(
        userId: user.id,
        onProgress: (step, current, total) {
          ref.read(syncProgressProvider.notifier).state =
              SyncProgress(step: step, current: current, total: total);
        },
      );

      ref.read(syncStateProvider.notifier).state = SyncState.synced;
      ref.read(syncProgressProvider.notifier).state = null;

      // 동기화 완료 → 모든 데이터 Provider 무효화
      ref.invalidate(productNotifierProvider);
      ref.invalidate(categoryNotifierProvider);
      ref.invalidate(productsByCategoryProvider);
      ref.invalidate(cartNotifierProvider);
      ref.invalidate(orderNotifierProvider);
    } catch (e) {
      ref.read(syncStateProvider.notifier).state = SyncState.error;
      rethrow;
    }
  }

  // 수동 재동기화
  Future&amp;lt;void&amp;gt; triggerSync() async {
    ref.invalidateSelf();
  }
}

final syncInitProvider =
    AsyncNotifierProvider&amp;lt;SyncInitNotifier, void&amp;gt;(SyncInitNotifier.new);


// ---- widgets/sync_indicator.dart ----

class SyncIndicator extends ConsumerWidget {
  const SyncIndicator({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final syncState = ref.watch(syncStateProvider);
    final progress = ref.watch(syncProgressProvider);

    return switch (syncState) {
      SyncState.syncing =&amp;gt; Tooltip(
          message: progress != null
              ? &amp;#x27;동기화 중... ${progress.step} (${progress.current}/${progress.total})&amp;#x27;
              : &amp;#x27;동기화 중...&amp;#x27;,
          child: const SizedBox(
            width: 18,
            height: 18,
            child: CircularProgressIndicator(strokeWidth: 2),
          ),
        ),
      SyncState.synced =&amp;gt; const Tooltip(
          message: &amp;#x27;동기화 완료&amp;#x27;,
          child: Icon(Icons.cloud_done_outlined, size: 20, color: Colors.green),
        ),
      SyncState.error =&amp;gt; Tooltip(
          message: &amp;#x27;동기화 실패&amp;#x27;,
          child: Icon(Icons.cloud_off_outlined, size: 20, color: Colors.red),
        ),
      SyncState.idle =&amp;gt; const SizedBox.shrink(),
    };
  }
}


// ---- screens/home_screen.dart ----

class HomeScreen extends ConsumerStatefulWidget {
  const HomeScreen({super.key});

  @override
  ConsumerState&amp;lt;HomeScreen&amp;gt; createState() =&amp;gt; _HomeScreenState();
}

class _HomeScreenState extends ConsumerState&amp;lt;HomeScreen&amp;gt; {
  String? _selectedCategoryId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 로그인/구독 상태 변경 시 동기화 자동 재트리거
    ref.watch(syncInitProvider);

    // 동기화 오류 감지 → 스낵바
    ref.listen&amp;lt;SyncState&amp;gt;(
      syncStateProvider,
      (prev, next) {
        if (next == SyncState.error &amp;amp;&amp;amp; context.mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text(&amp;#x27;동기화에 실패했어요. 나중에 다시 시도할게요.&amp;#x27;)),
          );
        }
      },
    );

    final categoriesAsync = ref.watch(categoryNotifierProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text(&amp;#x27;쇼핑몰&amp;#x27;),
        actions: const [SyncIndicator()],
      ),
      body: categoriesAsync.when(
        data: (cats) =&amp;gt; Row(
          children: [
            // 카테고리 사이드바
            NavigationRail(
              selectedIndex: cats
                  .indexWhere((c) =&amp;gt; c.id == _selectedCategoryId)
                  .clamp(0, cats.length - 1),
              destinations: cats
                  .map((c) =&amp;gt; NavigationRailDestination(
                        icon: Icon(c.icon),
                        label: Text(c.name),
                      ))
                  .toList(),
              onDestinationSelected: (i) =&amp;gt;
                  setState(() =&amp;gt; _selectedCategoryId = cats[i].id),
            ),
            // 상품 목록
            Expanded(
              child: _selectedCategoryId == null
                  ? const Center(child: Text(&amp;#x27;카테고리를 선택해주세요&amp;#x27;))
                  : ProductGrid(categoryId: _selectedCategoryId!),
            ),
          ],
        ),
        loading: () =&amp;gt; const Center(child: CircularProgressIndicator()),
        error: (e, _) =&amp;gt; Center(child: Text(&amp;#x27;$e&amp;#x27;)),
      ),
    );
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;/details&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5.-autodispose와-ref.ondispose로-리소스-관리&quot;&gt;5. autoDispose와 ref.onDispose로 리소스 관리&lt;/h2&gt;
&lt;h3 id=&quot;autodispose-감시하는-곳이-없으면-자동-정리&quot;&gt;autoDispose — 감시하는 곳이 없으면 자동 정리&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;autoDispose&lt;/code&gt;를 붙이면 해당 Provider를 감시하는 위젯이 모두 사라질 때 Provider가 자동으로 dispose돼요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 검색 화면을 닫으면 검색 결과 자동 정리
final searchResultsProvider =
    FutureProvider.autoDispose.family&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt;((ref, query) {
  return ref.read(productRepositoryProvider).search(query);
});

// 상품 상세 화면을 닫으면 자동 정리
final productDetailProvider =
    FutureProvider.autoDispose.family&amp;lt;Product, String&amp;gt;((ref, id) {
  return ref.read(productRepositoryProvider).findById(id);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;전역으로 유지해야 하는 상태는 &lt;code&gt;autoDispose&lt;/code&gt;를 빼요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 앱 전체에서 유지해야 하는 상태 — autoDispose 없음
final currentUserProvider = Provider&amp;lt;User?&amp;gt;(...);
final cartNotifierProvider = AsyncNotifierProvider&amp;lt;CartNotifier, Cart&amp;gt;(...);
final syncInitProvider = AsyncNotifierProvider&amp;lt;SyncInitNotifier, void&amp;gt;(...);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;ref.ondispose-provider가-폐기될-때-정리-작업&quot;&gt;ref.onDispose — Provider가 폐기될 때 정리 작업&lt;/h3&gt;
&lt;p&gt;스트림 구독, 타이머, 컨트롤러처럼 명시적으로 정리해야 하는 리소스는 &lt;code&gt;ref.onDispose()&lt;/code&gt;를 써요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final iapServiceProvider = Provider&amp;lt;IapService&amp;gt;((ref) {
  final service = IapService(
    onPurchaseComplete: (productId) async {
      // 구매 완료 → Firestore에 구독 상태 업데이트
      final user = ref.read(currentUserProvider);
      if (user == null) return;
      await ref.read(subscriptionRepositoryProvider)
          .activate(user.id, productId);
    },
  );

  // Provider가 폐기될 때 구독 스트림 닫기
  ref.onDispose(service.dispose);

  return service;
});

final searchDebounceProvider = Provider.autoDispose&amp;lt;Debouncer&amp;gt;((ref) {
  final debouncer = Debouncer(delay: const Duration(milliseconds: 300));

  // 화면 닫히면 타이머 정리
  ref.onDispose(debouncer.cancel);

  return debouncer;
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6.-주의사항&quot;&gt;6. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;순환-의존성은-런타임-에러&quot;&gt;순환 의존성은 런타임 에러&lt;/h3&gt;
&lt;p&gt;Provider A가 Provider B를 감시하고, B가 A를 감시하면 순환 참조가 생겨요. Riverpod은 이걸 런타임에 감지하고 에러를 던져요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ❌ 순환 의존 — 에러
final providerA = Provider((ref) {
  return ref.watch(providerB);  // B를 감시
});

final providerB = Provider((ref) {
  return ref.watch(providerA);  // A를 감시 → 순환!
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;해결책은 한쪽을 &lt;code&gt;ref.read()&lt;/code&gt;로 바꾸거나, 공통 의존성을 제3의 Provider로 분리하는 거예요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ✅ 한쪽을 read로
final providerA = Provider((ref) {
  return ref.watch(providerB);
});

final providerB = Provider((ref) {
  return ref.read(providerA);  // 감시 아닌 단순 읽기
});

// ✅ 또는 공통 상태를 별도 Provider로
final sharedStateProvider = StateProvider&amp;lt;SomeState&amp;gt;(...);

final providerA = Provider((ref) =&amp;gt; ref.watch(sharedStateProvider));
final providerB = Provider((ref) =&amp;gt; ref.watch(sharedStateProvider));&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;invalidate-타이밍-ui-이벤트-핸들러-vs-notifier-내부&quot;&gt;invalidate 타이밍 — UI 이벤트 핸들러 vs Notifier 내부&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;invalidate&lt;/code&gt;는 어디서든 호출할 수 있지만, Notifier 내부에서 다른 Provider를 &lt;code&gt;invalidate&lt;/code&gt;하는 건 Notifier 메서드에서만 하는 게 좋아요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class CartNotifier extends AsyncNotifier&amp;lt;Cart&amp;gt; {
  @override
  Future&amp;lt;Cart&amp;gt; build() async {
    final user = ref.watch(currentUserProvider);
    if (user == null) return Cart.empty();
    return ref.read(cartRepositoryProvider).fetch(user.id);
  }

  Future&amp;lt;void&amp;gt; checkout() async {
    final current = state.valueOrNull;
    if (current == null || current.isEmpty) return;

    final user = ref.read(currentUserProvider);
    if (user == null) return;

    await ref.read(orderRepositoryProvider).place(user.id, current);

    // 결제 완료 → 장바구니 초기화 + 주문 목록 갱신
    await ref.read(cartRepositoryProvider).clear(user.id);
    ref.invalidateSelf();  // 장바구니 갱신
    ref.invalidate(orderNotifierProvider);  // 주문 목록 갱신
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;invalidate-후-로딩-깜빡임&quot;&gt;invalidate 후 로딩 깜빡임&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;invalidateSelf()&lt;/code&gt;나 &lt;code&gt;invalidate()&lt;/code&gt;는 Provider를 로딩 상태로 만들어요. 화면이 잠깐 로딩 인디케이터로 바뀌는 게 UX적으로 어색하다면, 낙관적 업데이트로 상태를 직접 업데이트하는 게 나을 수 있어요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 로딩 깜빡임이 있는 방법
Future&amp;lt;void&amp;gt; addToCart(Product product) async {
  await ref.read(cartRepositoryProvider).add(product);
  ref.invalidateSelf();  // 로딩 → 데이터 순서로 UI 전환
}

// 낙관적 업데이트 — 로딩 없이 즉시 반영
Future&amp;lt;void&amp;gt; addToCart(Product product) async {
  final current = state.valueOrNull ?? Cart.empty();
  state = AsyncValue.data(current.copyWith(
    items: [...current.items, CartItem(product: product, quantity: 1)],
  ));
  await ref.read(cartRepositoryProvider).add(product);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;7.-성능-구조-팁&quot;&gt;7. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 id=&quot;watch는-최대한-좁게&quot;&gt;watch는 최대한 좁게&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ref.watch()&lt;/code&gt;로 대형 Provider를 통째로 감시하면 불필요한 리빌드가 생겨요. &lt;code&gt;select()&lt;/code&gt;로 필요한 부분만 뽑아요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 전체 동기화 상태를 감시 — syncProgress가 바뀔 때도 리빌드
final syncState = ref.watch(syncStateProvider);

// 동기화 여부만 감시
final isSyncing = ref.watch(
  syncStateProvider.select((s) =&amp;gt; s == SyncState.syncing),
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;provider-의존-관계를-도식화해두면-좋아요&quot;&gt;Provider 의존 관계를 도식화해두면 좋아요&lt;/h3&gt;
&lt;p&gt;Provider가 10개 이상 넘어가면 어떤 게 어디에 의존하는지 파악하기 어려워져요. 팀 내에서 공유하는 간단한 도식이 있으면 협업이 쉬워져요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-&quot;&gt;currentUserProvider (StreamProvider)
  └── isSubscribedProvider (StreamProvider)
        └── syncInitProvider (AsyncNotifier)
              ├── productNotifierProvider ← invalidate 대상
              ├── categoryNotifierProvider ← invalidate 대상
              └── productsByCategoryProvider (family) ← invalidate 대상&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;8.-정리&quot;&gt;8. 정리&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ref.invalidate(provider)&lt;/code&gt; — 캐시를 버림, 다음 접근 시 재실행&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.invalidateSelf()&lt;/code&gt; — Notifier 내부에서 자기 build() 재실행 예약&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.refresh(provider)&lt;/code&gt; — 즉시 재실행하고 새 값 반환&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.watch()&lt;/code&gt; — 의존성 선언, 값이 바뀌면 build() 자동 재실행&lt;/li&gt;
&lt;li&gt;동기화 완료 후 연쇄 무효화로 모든 화면을 일관되게 갱신&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autoDispose&lt;/code&gt; — 감시자가 없으면 자동 정리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.onDispose()&lt;/code&gt; — 스트림, 타이머, 컨트롤러 명시적 정리&lt;/li&gt;
&lt;li&gt;순환 의존성 — 한쪽을 &lt;code&gt;ref.read()&lt;/code&gt;로, 또는 공통 상태를 분리&lt;/li&gt;
&lt;li&gt;낙관적 업데이트 — &lt;code&gt;invalidateSelf()&lt;/code&gt; 대신 &lt;code&gt;state = AsyncValue.data()&lt;/code&gt;로 로딩 깜빡임 없애기&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;시리즈-전체-정리&quot;&gt;시리즈 전체 정리&lt;/h2&gt;
&lt;p&gt;5편에 걸쳐서 Riverpod 2.x의 핵심을 다뤘어요.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1편 — 기초&lt;/strong&gt; Provider 종류를 이해하고 ProviderScope를 설정하는 법을 배웠어요. &lt;code&gt;Provider&lt;/code&gt;, &lt;code&gt;StateProvider&lt;/code&gt;, &lt;code&gt;FutureProvider&lt;/code&gt;, &lt;code&gt;StreamProvider&lt;/code&gt; 각각의 용도가 명확하게 구분돼요.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2편 — AsyncNotifier&lt;/strong&gt; 읽기만 하는 FutureProvider를 넘어서, CRUD 액션까지 포함한 상태 객체를 만드는 방법이에요. &lt;code&gt;build()&lt;/code&gt;와 &lt;code&gt;invalidateSelf()&lt;/code&gt;, 낙관적 업데이트의 조합이 핵심이에요.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3편 — ref.watch / ref.read / ref.listen&lt;/strong&gt; 세 가지 규칙만 기억하면 돼요. &lt;code&gt;build()에서는 watch&lt;/code&gt;, &lt;code&gt;핸들러에서는 read&lt;/code&gt;, &lt;code&gt;부작용은 listen&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4편 — family&lt;/strong&gt; 같은 Provider를 매개변수별로 독립 운용하는 패턴이에요. 캐시 효율이 올라가고 매개변수 타입이 &lt;code&gt;==&lt;/code&gt; 기반이어야 한다는 것만 주의하면 돼요.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5편 — invalidate 전략&lt;/strong&gt; Provider 간 의존성을 watch로 연결하고, 한 이벤트가 여러 Provider에 영향을 미칠 때 연쇄 무효화로 일관성을 유지해요.&lt;/p&gt;
&lt;p&gt;이 시리즈의 패턴들은 실제 프로덕션 앱에서 검증된 것들이에요. 처음엔 낯설어도 한 번 손에 익으면 상태 관리가 훨씬 예측 가능해져요.&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;strong&gt;시리즈 연결&lt;/strong&gt;이 글로 Riverpod 기본 시리즈를 마무리합니다. 이후 인증, 동기화, 로컬 DB 캐싱으로 확장할 수 있습니다.&lt;/div&gt;
&lt;footer class=&quot;db-footer&quot;&gt;&lt;strong&gt;추천 태그&lt;/strong&gt;: Flutter, Riverpod, Dart, 상태관리, 모바일앱개발, invalidate, autoDispose, Provider 의존성&lt;br&gt;&lt;strong&gt;대표 이미지 문구&lt;/strong&gt;: Riverpod invalidate / 캐시 갱신과 의존성 설계&lt;br&gt;&lt;strong&gt;SEO Description&lt;/strong&gt;: 동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다.&lt;/footer&gt;
&lt;/div&gt;</description>
      <category>Mobile/Flutter</category>
      <category>autoDispose</category>
      <category>DART</category>
      <category>Flutter</category>
      <category>invalidate</category>
      <category>Provider 의존성</category>
      <category>riverpod</category>
      <category>모바일앱개발</category>
      <category>상태관리</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/15</guid>
      <comments>https://tech-vault-lior.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 17 Jul 2026 17:10:25 +0900</pubDate>
    </item>
    <item>
      <title>[Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기</title>
      <link>https://tech-vault-lior.tistory.com/14</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
  &lt;div class=&quot;db-eyebrow&quot;&gt;Flutter Riverpod 실무 시리즈 · 4편&lt;/div&gt;
  &lt;h1&gt;[Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기&lt;/h1&gt;
  &lt;p&gt;카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다.&lt;/p&gt;
  &lt;div class=&quot;db-meta&quot;&gt;버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-env&quot;&gt;&lt;span&gt;Flutter&lt;/span&gt;&lt;span&gt;Dart 3.x&lt;/span&gt;&lt;span&gt;flutter_riverpod ^2.5.1&lt;/span&gt;&lt;span&gt;수동 Provider 정의 기준&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;strong&gt;이 글에서 다루는 내용&lt;/strong&gt;카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.&lt;/div&gt;
&lt;div class=&quot;db-series&quot;&gt;&lt;div class=&quot;db-series-title&quot;&gt;Flutter Riverpod 실무 시리즈&lt;/div&gt;&lt;ol&gt;&lt;li&gt;1편. Provider 종류와 기본 사용법&lt;/li&gt;&lt;li&gt;2편. AsyncNotifier로 비동기 CRUD 상태 관리하기&lt;/li&gt;&lt;li&gt;3편. ref.watch / ref.read / ref.listen 차이 완전 정리&lt;/li&gt;&lt;li class=&quot;db-current&quot;&gt;4편. family 패턴으로 매개변수 기반 Provider 만들기&lt;/li&gt;&lt;li&gt;5편. invalidate 전략과 Provider 의존성 체이닝&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;
&lt;nav class=&quot;db-toc&quot;&gt;&lt;div class=&quot;db-toc-title&quot;&gt;목차&lt;/div&gt;&lt;ol&gt;&lt;li&gt;&lt;a href=&quot;#1.-소개&quot;&gt;1. 소개&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#2.-기본-사용-방법&quot;&gt;2. 기본 사용 방법&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#3.-실무-적용-방법-카테고리별-상품-관리-전체-예제&quot;&gt;3. 실무 적용 방법 — 카테고리별 상품 관리 전체 예제&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#4.-주의사항&quot;&gt;4. 주의사항&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#5.-성능-구조-팁&quot;&gt;5. 성능 / 구조 팁&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#6.-정리&quot;&gt;6. 정리&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/nav&gt;
&lt;figure class=&quot;db-diagram&quot;&gt;&lt;pre&gt;family parameter
  ├─ categoryId = electronics → Provider A
  └─ categoryId = fashion     → Provider B&lt;/pre&gt;&lt;figcaption&gt;추천 다이어그램: 이 흐름을 Mermaid 또는 이미지로 변환해 대표 설명 이미지로 사용할 수 있습니다.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;db-good&quot;&gt;&lt;strong&gt;실무 적용 포인트&lt;/strong&gt;처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;시리즈 전체 목차&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;1편: Provider 종류와 기본 사용법 2편: AsyncNotifier — 비동기 상태의 기준점 3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자 &lt;strong&gt;4편: family 패턴 — 같은 Provider, 다른 데이터 ← 현재 글&lt;/strong&gt; 5편: invalidate 전략과 Provider 의존성 체이닝&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1.-소개&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p&gt;앱을 만들다 보면 이런 상황이 생겨요.&lt;/p&gt;
&lt;p&gt;&amp;quot;카테고리마다 상품 목록을 따로 관리하고 싶은데, Provider를 카테고리 수만큼 만들어야 하나?&amp;quot;&lt;/p&gt;
&lt;p&gt;그럼 이 질문이 떠오르죠.&lt;/p&gt;
&lt;p&gt;&amp;quot;카테고리 ID를 받아서 해당 카테고리 상품만 가져오는 Provider를 만들 수 있을까?&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;family&lt;/code&gt;가 딱 그 역할이에요. 매개변수를 받아서 그 값별로 독립적인 Provider 인스턴스를 만들어줘요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 이렇게 쓸 수 있어요
final productsAsync = ref.watch(productsByCategoryProvider(&amp;#x27;electronics&amp;#x27;));
final reviewsAsync = ref.watch(reviewsByProductProvider(&amp;#x27;product-123&amp;#x27;));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;같은 매개변수로 호출하면 캐시된 인스턴스를 재사용해요. 다른 매개변수면 별도 인스턴스가 생겨요.&lt;/p&gt;
&lt;p&gt;이 편은 &lt;code&gt;flutter_riverpod: ^2.5.1&lt;/code&gt; 기준으로 작성했어요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2.-기본-사용-방법&quot;&gt;2. 기본 사용 방법&lt;/h2&gt;
&lt;h3 id=&quot;futureprovider.family&quot;&gt;FutureProvider.family&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 일반 FutureProvider — 매개변수 없음
final allProductsProvider = FutureProvider&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt;((ref) {
  return ref.read(productRepositoryProvider).fetchAll();
});

// FutureProvider.family — 매개변수 포함
final productsByCategoryProvider =
    FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt;((ref, categoryId) {
  return ref.read(productRepositoryProvider).fetchByCategory(categoryId);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;타입 파라미터는 &lt;code&gt;&amp;lt;반환값 타입, 매개변수 타입&amp;gt;&lt;/code&gt; 순서예요.&lt;/p&gt;
&lt;p&gt;UI에서 사용할 때는 Provider 이름에 괄호로 매개변수를 전달해요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// &amp;#x27;electronics&amp;#x27; 카테고리 상품 목록
final productsAsync = ref.watch(productsByCategoryProvider(&amp;#x27;electronics&amp;#x27;));

// &amp;#x27;fashion&amp;#x27; 카테고리 상품 목록 — 별도 인스턴스
final fashionAsync = ref.watch(productsByCategoryProvider(&amp;#x27;fashion&amp;#x27;));&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;asyncnotifierprovider.family&quot;&gt;AsyncNotifierProvider.family&lt;/h3&gt;
&lt;p&gt;액션 메서드까지 포함한 경우엔 &lt;code&gt;AsyncNotifierProvider.family&lt;/code&gt;를 써요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;&lt;summary&gt;전체 코드 보기&lt;/summary&gt;&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class CategoryProductsNotifier
    extends FamilyAsyncNotifier&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt; {
  @override
  Future&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; build(String categoryId) {
    // arg == categoryId (초기에 전달된 매개변수)
    return ref.read(productRepositoryProvider).fetchByCategory(arg);
  }

  Future&amp;lt;Product&amp;gt; addProduct({
    required String name,
    required int price,
  }) async {
    final product = Product(
      id: const Uuid().v4(),
      name: name,
      price: price,
      categoryId: arg,  // 이 Notifier의 categoryId
      createdAt: DateTime.now(),
    );
    await ref.read(productRepositoryProvider).insert(product);
    ref.invalidateSelf();
    return product;
  }

  Future&amp;lt;void&amp;gt; deleteProduct(String id) async {
    await ref.read(productRepositoryProvider).delete(id);
    ref.invalidateSelf();
  }
}

final categoryProductsNotifierProvider = AsyncNotifierProvider.family&amp;lt;
    CategoryProductsNotifier, List&amp;lt;Product&amp;gt;, String&amp;gt;(
  CategoryProductsNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;&lt;/details&gt;
&lt;p&gt;&lt;code&gt;FamilyAsyncNotifier&lt;/code&gt;의 &lt;code&gt;arg&lt;/code&gt;로 초기 매개변수에 언제든지 접근할 수 있어요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3.-실무-적용-방법-카테고리별-상품-관리-전체-예제&quot;&gt;3. 실무 적용 방법 — 카테고리별 상품 관리 전체 예제&lt;/h2&gt;
&lt;p&gt;여러 카테고리를 탭으로 탐색하는 쇼핑 앱 예제예요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;&lt;summary&gt;전체 코드 보기&lt;/summary&gt;&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ---- providers/product_providers.dart ----

// 카테고리별 상품 목록 (읽기 전용)
final productsByCategoryProvider =
    FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt;((ref, categoryId) {
  final repo = ref.read(productRepositoryProvider);
  final sort = ref.watch(sortOrderProvider);  // 정렬 기준 변경 시 재실행
  return repo.fetchByCategory(categoryId, sortBy: sort);
});

// 카테고리별 상품 관리 (CRUD 포함)
final categoryProductsNotifierProvider = AsyncNotifierProvider.family&amp;lt;
    CategoryProductsNotifier, List&amp;lt;Product&amp;gt;, String&amp;gt;(
  CategoryProductsNotifier.new,
);

// 상품 단건 조회 (autoDispose — 상세 화면 닫히면 정리)
final productDetailProvider =
    FutureProvider.autoDispose.family&amp;lt;Product, String&amp;gt;((ref, productId) {
  return ref.read(productRepositoryProvider).findById(productId);
});

// 상품 리뷰 (autoDispose + family)
final reviewsByProductProvider =
    FutureProvider.autoDispose.family&amp;lt;List&amp;lt;Review&amp;gt;, String&amp;gt;((ref, productId) {
  return ref.read(reviewRepositoryProvider).fetchByProduct(productId);
});


// ---- screens/category_screen.dart ----

class CategoryScreen extends ConsumerWidget {
  final String categoryId;
  const CategoryScreen({super.key, required this.categoryId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 이 화면의 categoryId로 해당 카테고리 상품만 감시
    final productsAsync =
        ref.watch(categoryProductsNotifierProvider(categoryId));

    return Scaffold(
      appBar: AppBar(title: Text(&amp;#x27;카테고리 상품&amp;#x27;)),
      floatingActionButton: FloatingActionButton(
        onPressed: () =&amp;gt; _showAddDialog(context, ref),
        child: const Icon(Icons.add),
      ),
      body: productsAsync.when(
        data: (products) =&amp;gt; ListView.builder(
          itemCount: products.length,
          itemBuilder: (_, i) =&amp;gt; ProductTile(
            product: products[i],
            onDelete: () =&amp;gt; ref
                .read(categoryProductsNotifierProvider(categoryId).notifier)
                .deleteProduct(products[i].id),
          ),
        ),
        loading: () =&amp;gt; const CircularProgressIndicator(),
        error: (e, _) =&amp;gt; Text(&amp;#x27;$e&amp;#x27;),
      ),
    );
  }

  Future&amp;lt;void&amp;gt; _showAddDialog(BuildContext context, WidgetRef ref) async {
    final result = await showDialog&amp;lt;String&amp;gt;(
      context: context,
      builder: (_) =&amp;gt; const AddProductDialog(),
    );
    if (result == null || !context.mounted) return;
    await ref
        .read(categoryProductsNotifierProvider(categoryId).notifier)
        .addProduct(name: result, price: 0);
  }
}


// ---- screens/product_detail_screen.dart ----

class ProductDetailScreen extends ConsumerWidget {
  final String productId;
  const ProductDetailScreen({super.key, required this.productId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productAsync = ref.watch(productDetailProvider(productId));
    final reviewsAsync = ref.watch(reviewsByProductProvider(productId));

    return Scaffold(
      body: productAsync.when(
        data: (product) =&amp;gt; CustomScrollView(
          slivers: [
            SliverAppBar(title: Text(product.name)),
            SliverToBoxAdapter(
              child: Column(children: [
                Text(&amp;#x27;가격: ${product.price}원&amp;#x27;),
                const Divider(),
                const Text(&amp;#x27;리뷰&amp;#x27;, style: TextStyle(fontWeight: FontWeight.bold)),
              ]),
            ),
            reviewsAsync.when(
              data: (reviews) =&amp;gt; SliverList(
                delegate: SliverChildBuilderDelegate(
                  (_, i) =&amp;gt; ReviewTile(reviews[i]),
                  childCount: reviews.length,
                ),
              ),
              loading: () =&amp;gt; const SliverToBoxAdapter(
                child: CircularProgressIndicator(),
              ),
              error: (e, _) =&amp;gt; SliverToBoxAdapter(child: Text(&amp;#x27;$e&amp;#x27;)),
            ),
          ],
        ),
        loading: () =&amp;gt; const CircularProgressIndicator(),
        error: (e, _) =&amp;gt; Text(&amp;#x27;$e&amp;#x27;),
      ),
    );
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;/details&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4.-주의사항&quot;&gt;4. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;매개변수로-복잡한-타입을-쓸-때&quot;&gt;매개변수로 복잡한 타입을 쓸 때&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;family&lt;/code&gt;의 매개변수는 &lt;code&gt;==&lt;/code&gt;으로 비교돼요. 같은 값이면 캐시를 재사용해요. 그래서 매개변수 타입은 반드시 &lt;code&gt;==&lt;/code&gt;이 올바르게 동작해야 해요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ✅ 기본 타입은 항상 OK
FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt;(...)
FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, int&amp;gt;(...)
FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, bool&amp;gt;(...)

// ✅ freezed 또는 equatable 클래스도 OK (== 오버라이드됨)
@freezed
class ProductFilter with _$ProductFilter {
  const factory ProductFilter({
    required String categoryId,
    required SortOrder sort,
    int? maxPrice,
  }) = _ProductFilter;
}

final filteredProductsProvider =
    FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, ProductFilter&amp;gt;((ref, filter) {
  return ref.read(productRepositoryProvider).fetchFiltered(filter);
});

// 사용할 때
ref.watch(filteredProductsProvider(
  const ProductFilter(categoryId: &amp;#x27;electronics&amp;#x27;, sort: SortOrder.newest),
));

// ❌ 일반 클래스는 위험 — == 오버라이드 안 하면 매번 새 인스턴스로 인식
class BadFilter {
  final String categoryId;
  BadFilter(this.categoryId);
  // == 오버라이드 없음 → 참조 비교 → 같은 값이어도 다른 인스턴스로 취급
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;map-list를-매개변수로-쓰면-안-돼요&quot;&gt;Map, List를 매개변수로 쓰면 안 돼요&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Map&lt;/code&gt;이나 &lt;code&gt;List&lt;/code&gt;는 Dart에서 기본적으로 참조 비교라서 같은 내용이어도 다른 인스턴스로 인식해요. 매번 새 Provider 인스턴스가 생겨서 캐시가 전혀 동작하지 않아요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ❌ 위험 — Map은 == 비교 안 됨
FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, Map&amp;lt;String, dynamic&amp;gt;&amp;gt;(...)

// ✅ 대신 freezed 클래스나 직렬화 가능한 레코드 사용
FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, ({String categoryId, String? tag})&amp;gt;(
  (ref, params) {
    return ref.read(repo).fetch(params.categoryId, tag: params.tag);
  },
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;family와-autodispose를-같이-쓸-때&quot;&gt;family와 autoDispose를 같이 쓸 때&lt;/h3&gt;
&lt;p&gt;화면 단위 임시 Provider라면 &lt;code&gt;autoDispose&lt;/code&gt;도 붙여줘야 메모리가 정리돼요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// autoDispose + family 조합
final productDetailProvider =
    FutureProvider.autoDispose.family&amp;lt;Product, String&amp;gt;((ref, productId) {
  ref.keepAlive();  // 특정 조건에서 dispose를 막으려면
  return ref.read(productRepositoryProvider).findById(productId);
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5.-성능-구조-팁&quot;&gt;5. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 id=&quot;캐시-활용-같은-id로-여러-번-접근해도-한-번만-요청해요&quot;&gt;캐시 활용 — 같은 ID로 여러 번 접근해도 한 번만 요청해요&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final categories = ref.watch(categoriesProvider).valueOrNull ?? [];

    return ListView(
      children: [
        for (final cat in categories)
          CategorySection(
            category: cat,
            // 각 카테고리별로 별도 캐시, 여러 위젯에서 같은 ID로 접근해도 API 1번
            productsAsync: ref.watch(productsByCategoryProvider(cat.id)),
          ),
      ],
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;family-provider-무효화&quot;&gt;family Provider 무효화&lt;/h3&gt;
&lt;p&gt;특정 카테고리의 캐시만 무효화하고 싶을 때는 매개변수를 전달해요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// &amp;#x27;electronics&amp;#x27; 카테고리 캐시만 무효화
ref.invalidate(productsByCategoryProvider(&amp;#x27;electronics&amp;#x27;));

// 전체 family 캐시 무효화 — 매개변수 없이 Provider 전체를 무효화
ref.invalidate(productsByCategoryProvider);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;여러-매개변수가-필요할-때-record-타입-활용&quot;&gt;여러 매개변수가 필요할 때 — Record 타입 활용&lt;/h3&gt;
&lt;p&gt;Dart 3.0의 Record 타입을 쓰면 간단하게 여러 매개변수를 합칠 수 있어요. Record는 &lt;code&gt;==&lt;/code&gt;이 값 기반으로 동작해서 family에 적합해요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Record를 매개변수로 사용
final filteredProductsProvider =
    FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, (String, SortOrder)&amp;gt;(
  (ref, params) {
    final (categoryId, sortOrder) = params;
    return ref.read(productRepositoryProvider)
        .fetchByCategory(categoryId, sortBy: sortOrder);
  },
);

// 사용
ref.watch(filteredProductsProvider((&amp;#x27;electronics&amp;#x27;, SortOrder.newest)));&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6.-정리&quot;&gt;6. 정리&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FutureProvider.family&amp;lt;ReturnType, ParamType&amp;gt;&lt;/code&gt; — 매개변수별 독립 캐시&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AsyncNotifierProvider.family&lt;/code&gt; — CRUD 포함, &lt;code&gt;FamilyAsyncNotifier&lt;/code&gt; 상속&lt;/li&gt;
&lt;li&gt;&lt;code&gt;arg&lt;/code&gt; — FamilyAsyncNotifier 안에서 초기 매개변수 접근&lt;/li&gt;
&lt;li&gt;같은 매개변수로 접근하면 캐시 재사용 → API/DB 중복 호출 방지&lt;/li&gt;
&lt;li&gt;매개변수 타입은 &lt;code&gt;==&lt;/code&gt; 비교가 올바르게 동작해야 해요 (freezed, Record, 기본 타입)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autoDispose + family&lt;/code&gt; — 화면 단위 임시 상태에서 자동 정리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.invalidate(provider(arg))&lt;/code&gt; — 특정 매개변수만 무효화&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.invalidate(provider)&lt;/code&gt; — 전체 family 무효화&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;다음 편 예고&lt;/strong&gt;: 지금까지 개별 Provider를 다뤘는데, 실제 앱에선 여러 Provider가 서로 의존하고 연쇄적으로 무효화되는 경우가 많아요. 5편에서는 &lt;code&gt;invalidate&lt;/code&gt; 전략과 Provider 의존성 체이닝을 다뤄요. 동기화 완료 후 관련된 모든 Provider를 갱신하는 패턴, &lt;code&gt;autoDispose&lt;/code&gt;와 &lt;code&gt;ref.onDispose()&lt;/code&gt;로 리소스를 정리하는 방법, 그리고 순환 의존성을 피하는 방법까지 정리할게요.&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;strong&gt;시리즈 연결&lt;/strong&gt;다음 글에서는 invalidate 전략과 Provider 의존성 체이닝를 이어서 다룹니다.&lt;/div&gt;
&lt;footer class=&quot;db-footer&quot;&gt;&lt;strong&gt;추천 태그&lt;/strong&gt;: Flutter, Riverpod, Dart, 상태관리, 모바일앱개발, family, Provider&lt;br&gt;&lt;strong&gt;대표 이미지 문구&lt;/strong&gt;: Riverpod family / 같은 Provider, 다른 데이터&lt;br&gt;&lt;strong&gt;SEO Description&lt;/strong&gt;: 카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다.&lt;/footer&gt;
&lt;/div&gt;</description>
      <category>Mobile/Flutter</category>
      <category>DART</category>
      <category>family</category>
      <category>Flutter</category>
      <category>Provider</category>
      <category>riverpod</category>
      <category>모바일앱개발</category>
      <category>상태관리</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/14</guid>
      <comments>https://tech-vault-lior.tistory.com/14#entry14comment</comments>
      <pubDate>Fri, 17 Jul 2026 17:09:33 +0900</pubDate>
    </item>
    <item>
      <title>[Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리</title>
      <link>https://tech-vault-lior.tistory.com/13</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
  &lt;div class=&quot;db-eyebrow&quot;&gt;Flutter Riverpod 실무 시리즈 · 3편&lt;/div&gt;
  &lt;h1&gt;[Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리&lt;/h1&gt;
  &lt;p&gt;Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다.&lt;/p&gt;
  &lt;div class=&quot;db-meta&quot;&gt;버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-env&quot;&gt;&lt;span&gt;Flutter&lt;/span&gt;&lt;span&gt;Dart 3.x&lt;/span&gt;&lt;span&gt;flutter_riverpod ^2.5.1&lt;/span&gt;&lt;span&gt;수동 Provider 정의 기준&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;strong&gt;이 글에서 다루는 내용&lt;/strong&gt;Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.&lt;/div&gt;
&lt;div class=&quot;db-series&quot;&gt;&lt;div class=&quot;db-series-title&quot;&gt;Flutter Riverpod 실무 시리즈&lt;/div&gt;&lt;ol&gt;&lt;li&gt;1편. Provider 종류와 기본 사용법&lt;/li&gt;&lt;li&gt;2편. AsyncNotifier로 비동기 CRUD 상태 관리하기&lt;/li&gt;&lt;li class=&quot;db-current&quot;&gt;3편. ref.watch / ref.read / ref.listen 차이 완전 정리&lt;/li&gt;&lt;li&gt;4편. family 패턴으로 매개변수 기반 Provider 만들기&lt;/li&gt;&lt;li&gt;5편. invalidate 전략과 Provider 의존성 체이닝&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;
&lt;nav class=&quot;db-toc&quot;&gt;&lt;div class=&quot;db-toc-title&quot;&gt;목차&lt;/div&gt;&lt;ol&gt;&lt;li&gt;&lt;a href=&quot;#1.-소개&quot;&gt;1. 소개&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#2.-세-가지-핵심-차이&quot;&gt;2. 세 가지 핵심 차이&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#3.-ref.watch-값이-바뀌면-나를-다시-그려줘&quot;&gt;3. ref.watch() — 값이 바뀌면 나를 다시 그려줘&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#4.-ref.read-지금-이-순간의-값만-줘&quot;&gt;4. ref.read() — 지금 이 순간의 값만 줘&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#5.-ref.listen-바뀔-때-부작용을-실행해줘&quot;&gt;5. ref.listen() — 바뀔 때 부작용을 실행해줘&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#6.-실무-적용-방법-세-가지를-함께-쓰는-패턴&quot;&gt;6. 실무 적용 방법 — 세 가지를 함께 쓰는 패턴&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#7.-주의사항&quot;&gt;7. 주의사항&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#8.-select-로-불필요한-리빌드-줄이기&quot;&gt;8. select()로 불필요한 리빌드 줄이기&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;#9.-정리&quot;&gt;9. 정리&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/nav&gt;
&lt;figure class=&quot;db-diagram&quot;&gt;&lt;pre&gt;ref.watch → UI 리빌드
ref.read  → 이벤트에서 한 번 읽기
ref.listen → 스낵바/네비게이션 같은 부작용&lt;/pre&gt;&lt;figcaption&gt;추천 다이어그램: 이 흐름을 Mermaid 또는 이미지로 변환해 대표 설명 이미지로 사용할 수 있습니다.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;db-good&quot;&gt;&lt;strong&gt;실무 적용 포인트&lt;/strong&gt;처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;시리즈 전체 목차&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;1편: Provider 종류와 기본 사용법 2편: AsyncNotifier — 비동기 상태의 기준점 &lt;strong&gt;3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자 ← 현재 글&lt;/strong&gt; 4편: family 패턴 — 같은 Provider, 다른 데이터 5편: invalidate 전략과 Provider 의존성 체이닝&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1.-소개&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p&gt;Riverpod 코드를 보다 보면 비슷해 보이는 세 가지가 계속 나와요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;ref.watch(someProvider)
ref.read(someProvider)
ref.listen(someProvider, (prev, next) { ... })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음엔 &amp;quot;그냥 값 가져오는 거 아닌가?&amp;quot; 싶은데, 세 가지가 동작 방식이 완전히 달라요. 잘못 쓰면 리빌드가 과하게 일어나거나, 상태가 바뀌어도 UI가 업데이트 안 되거나, 최악엔 런타임 에러가 나요.&lt;/p&gt;
&lt;p&gt;이 편에서는 세 가지의 정확한 차이와, 어떤 상황에서 어떤 걸 써야 하는지 규칙을 정리할게요. &lt;code&gt;flutter_riverpod: ^2.5.1&lt;/code&gt; 기준이에요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2.-세-가지-핵심-차이&quot;&gt;2. 세 가지 핵심 차이&lt;/h2&gt;
&lt;p&gt;한 줄 요약부터 하고 시작할게요.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;메서드&lt;/th&gt;&lt;th&gt;UI 리빌드&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;code&gt;ref.watch()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;값이 바뀌면 리빌드&lt;/td&gt;&lt;td&gt;없음&lt;/td&gt;&lt;td&gt;&lt;code&gt;build()&lt;/code&gt; 안&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ref.read()&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;ref.listen()&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;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3.-ref.watch-값이-바뀌면-나를-다시-그려줘&quot;&gt;3. ref.watch() — 값이 바뀌면 나를 다시 그려줘&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ref.watch()&lt;/code&gt;는 Provider를 구독해요. 그 Provider의 값이 바뀌면, &lt;code&gt;ref.watch()&lt;/code&gt;를 호출한 위젯이나 Provider가 자동으로 다시 실행돼요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // productsAsync 값이 바뀌면 build() 전체가 다시 실행돼요
    final productsAsync = ref.watch(productNotifierProvider);
    final sortOrder = ref.watch(sortOrderProvider);  // 이것도 감시

    return productsAsync.when(
      data: (products) =&amp;gt; ProductGrid(products: products, sort: sortOrder),
      loading: () =&amp;gt; const CircularProgressIndicator(),
      error: (e, _) =&amp;gt; Text(&amp;#x27;오류: $e&amp;#x27;),
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;ref.watch-를-쓰면-안-되는-곳&quot;&gt;ref.watch()를 쓰면 안 되는 곳&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;이벤트 핸들러 안&lt;/strong&gt;에서 &lt;code&gt;ref.watch()&lt;/code&gt;를 쓰면 Riverpod이 해당 감시를 추적할 수 없어요. 이 경우 경고 없이 이상하게 동작하거나, 특정 버전에선 에러가 나요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ❌ 잘못된 예
ElevatedButton(
  onPressed: () {
    final notifier = ref.watch(productNotifierProvider.notifier);
    notifier.deleteProduct(id);
  },
  child: const Text(&amp;#x27;삭제&amp;#x27;),
)

// ✅ 올바른 예
ElevatedButton(
  onPressed: () {
    ref.read(productNotifierProvider.notifier).deleteProduct(id);
  },
  child: const Text(&amp;#x27;삭제&amp;#x27;),
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;initState 안&lt;/strong&gt;에서도 &lt;code&gt;ref.watch()&lt;/code&gt;는 안 돼요. &lt;code&gt;ref.read()&lt;/code&gt;를 쓰거나, &lt;code&gt;build()&lt;/code&gt;로 로직을 옮겨야 해요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ❌ 잘못된 예
@override
void initState() {
  super.initState();
  ref.watch(productNotifierProvider);  // 에러
}

// ✅ 올바른 예 — initState에선 ref.read()
@override
void initState() {
  super.initState();
  Future.microtask(() {
    ref.read(productNotifierProvider.notifier).setFilter(&amp;#x27;active&amp;#x27;);
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4.-ref.read-지금-이-순간의-값만-줘&quot;&gt;4. ref.read() — 지금 이 순간의 값만 줘&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ref.read()&lt;/code&gt;는 현재 시점의 값을 딱 한 번 읽어요. 구독이 아니라 단순 조회예요. Provider가 나중에 바뀌어도 아무 반응 없어요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 버튼 누를 때 현재 사용자 정보 읽기
ElevatedButton(
  onPressed: () async {
    final user = ref.read(currentUserProvider);  // 현재 값만
    if (user == null) return;
    await ref.read(orderRepositoryProvider).placeOrder(user.id, cart);
  },
  child: const Text(&amp;#x27;주문하기&amp;#x27;),
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;notifier-메서드-호출&quot;&gt;Notifier 메서드 호출&lt;/h3&gt;
&lt;p&gt;Notifier의 메서드를 호출할 때는 항상 &lt;code&gt;ref.read()&lt;/code&gt;를 써요. &lt;code&gt;ref.watch(notifier.notifier)&lt;/code&gt;처럼 쓰면 Notifier 자체를 구독하게 되는데, 이렇게 쓰는 경우는 거의 없어요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Notifier 메서드 호출 패턴
ref.read(productNotifierProvider.notifier).addProduct(product);
ref.read(cartNotifierProvider.notifier).addItem(item);
ref.read(currentProductProvider.notifier).setProductId(id);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;ref.read-를-build-안에서-쓰면-안-되는-이유&quot;&gt;ref.read()를 build() 안에서 쓰면 안 되는 이유&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;build()&lt;/code&gt; 안에서 &lt;code&gt;ref.read()&lt;/code&gt;를 쓰면 해당 Provider가 바뀌어도 UI가 갱신되지 않아요. 처음엔 올바른 값이 나오는 것처럼 보이지만, 상태가 바뀌어도 UI가 굳어있는 버그가 생겨요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
Widget build(BuildContext context, WidgetRef ref) {
  // ❌ 이렇게 하면 상품 목록이 바뀌어도 UI가 업데이트 안 돼요
  final products = ref.read(productNotifierProvider).valueOrNull ?? [];
  return ListView.builder(...);

  // ✅ 이래야 바뀔 때마다 UI가 갱신돼요
  final productsAsync = ref.watch(productNotifierProvider);
  return productsAsync.when(...);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5.-ref.listen-바뀔-때-부작용을-실행해줘&quot;&gt;5. ref.listen() — 바뀔 때 부작용을 실행해줘&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ref.listen()&lt;/code&gt;은 구독은 하지만 UI를 리빌드하지는 않아요. 값이 바뀔 때 콜백만 실행해요. 다이얼로그 표시, 스낵바, 네비게이션처럼 UI 리빌드가 아닌 부작용이 필요할 때 써요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;@override
Widget build(BuildContext context, WidgetRef ref) {
  // 오류가 발생하면 스낵바 표시 — UI 리빌드는 일어나지 않아요
  ref.listen&amp;lt;AsyncValue&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt;&amp;gt;(
    productNotifierProvider,
    (prev, next) {
      if (next is AsyncError &amp;amp;&amp;amp; prev is! AsyncError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(&amp;#x27;오류: ${next.error}&amp;#x27;)),
        );
      }
    },
  );

  // 인증 상태 변경 시 네비게이션
  ref.listen&amp;lt;User?&amp;gt;(
    currentUserProvider,
    (prev, next) {
      if (next == null &amp;amp;&amp;amp; prev != null) {
        // 로그아웃 → 로그인 화면으로
        context.go(&amp;#x27;/login&amp;#x27;);
      }
    },
  );

  // 실제 UI 빌드
  final productsAsync = ref.watch(productNotifierProvider);
  return Scaffold(...);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;ref.listen-의-특징&quot;&gt;ref.listen()의 특징&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;build()&lt;/code&gt; 안에서 호출해야 해요 (이벤트 핸들러 안 ❌)&lt;/li&gt;
&lt;li&gt;이전 값(&lt;code&gt;prev&lt;/code&gt;)과 새 값(&lt;code&gt;next&lt;/code&gt;) 모두 받아요&lt;/li&gt;
&lt;li&gt;리빌드를 일으키지 않아요 → ScaffoldMessenger, Navigator에 적합&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prev == null&lt;/code&gt;이면 처음 등록될 때예요&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6.-실무-적용-방법-세-가지를-함께-쓰는-패턴&quot;&gt;6. 실무 적용 방법 — 세 가지를 함께 쓰는 패턴&lt;/h2&gt;
&lt;p&gt;주문 화면에서 세 가지를 조합해서 쓰는 예제예요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;&lt;summary&gt;전체 코드 보기&lt;/summary&gt;&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class CheckoutScreen extends ConsumerStatefulWidget {
  const CheckoutScreen({super.key});

  @override
  ConsumerState&amp;lt;CheckoutScreen&amp;gt; createState() =&amp;gt; _CheckoutScreenState();
}

class _CheckoutScreenState extends ConsumerState&amp;lt;CheckoutScreen&amp;gt; {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ---- ref.listen: 주문 완료/실패 시 부작용 ----
    ref.listen&amp;lt;AsyncValue&amp;lt;Order?&amp;gt;&amp;gt;(
      orderNotifierProvider,
      (prev, next) {
        // 로딩에서 데이터로 전환 = 주문 완료
        if (prev is AsyncLoading &amp;amp;&amp;amp; next is AsyncData &amp;amp;&amp;amp; next.value != null) {
          context.go(&amp;#x27;/order-complete/${next.value!.id}&amp;#x27;);
        }
        // 에러 발생
        if (next is AsyncError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(&amp;#x27;주문 실패: ${next.error}&amp;#x27;)),
          );
        }
      },
    );

    // ---- ref.watch: UI 리빌드가 필요한 상태 ----
    final cartAsync = ref.watch(cartNotifierProvider);
    final orderAsync = ref.watch(orderNotifierProvider);
    final isLoading = orderAsync is AsyncLoading;

    return Scaffold(
      appBar: AppBar(title: const Text(&amp;#x27;결제&amp;#x27;)),
      body: cartAsync.when(
        data: (cart) =&amp;gt; _CartSummary(cart: cart),
        loading: () =&amp;gt; const CircularProgressIndicator(),
        error: (e, _) =&amp;gt; Text(&amp;#x27;$e&amp;#x27;),
      ),
      bottomNavigationBar: Padding(
        padding: const EdgeInsets.all(16),
        child: ElevatedButton(
          onPressed: isLoading
              ? null
              : () {
                  // ---- ref.read: 이벤트 핸들러에서 액션 실행 ----
                  final user = ref.read(currentUserProvider);
                  final cart = ref.read(cartNotifierProvider).valueOrNull;
                  if (user == null || cart == null) return;

                  ref.read(orderNotifierProvider.notifier).placeOrder(
                    userId: user.id,
                    cart: cart,
                  );
                },
          child: isLoading
              ? const SizedBox(
                  height: 20,
                  width: 20,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : const Text(&amp;#x27;주문하기&amp;#x27;),
        ),
      ),
    );
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;/details&gt;
&lt;hr&gt;
&lt;h2 id=&quot;7.-주의사항&quot;&gt;7. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;ref.listen-은-build-에서만-호출해요&quot;&gt;ref.listen()은 build()에서만 호출해요&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ref.listen()&lt;/code&gt;을 &lt;code&gt;initState()&lt;/code&gt;나 버튼 콜백 안에서 호출하면 구독이 제대로 등록되지 않거나, 구독이 해제되지 않는 문제가 생겨요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// ❌ 잘못된 예 — initState에서 ref.listen
@override
void initState() {
  super.initState();
  ref.listen(orderNotifierProvider, (prev, next) { ... });  // 위험
}

// ✅ 올바른 예 — build()에서 ref.listen
@override
Widget build(BuildContext context, WidgetRef ref) {
  ref.listen(orderNotifierProvider, (prev, next) { ... });  // 정상
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;context가-살아있는지-확인해야-해요&quot;&gt;context가 살아있는지 확인해야 해요&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ref.listen()&lt;/code&gt;의 콜백 안에서 &lt;code&gt;context&lt;/code&gt;를 쓸 때 위젯이 이미 unmount됐을 수 있어요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;ref.listen&amp;lt;AsyncValue&amp;lt;Order?&amp;gt;&amp;gt;(
  orderNotifierProvider,
  (prev, next) {
    if (next is AsyncData &amp;amp;&amp;amp; next.value != null) {
      // ✅ mounted 체크 또는 context.mounted 사용
      if (context.mounted) {
        context.go(&amp;#x27;/order-complete&amp;#x27;);
      }
    }
  },
);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;8.-select-로-불필요한-리빌드-줄이기&quot;&gt;8. select()로 불필요한 리빌드 줄이기&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ref.watch()&lt;/code&gt;는 Provider의 모든 변화에 반응해요. 장바구니에 10개 항목이 있는데 아이콘 뱃지에 개수만 표시한다면, 상품 하나가 바뀔 때마다 리빌드될 필요가 없어요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;select()&lt;/code&gt;로 관심 있는 부분만 뽑아서 감시할 수 있어요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 장바구니 전체를 감시 — 어떤 변화든 리빌드
final cart = ref.watch(cartNotifierProvider).valueOrNull;

// 개수만 감시 — 개수가 바뀔 때만 리빌드
final itemCount = ref.watch(
  cartNotifierProvider.select((async) =&amp;gt; async.valueOrNull?.items.length ?? 0),
);

// 구독 활성 여부만 감시 — 구독 상태 전체가 아닌 active 필드만 체크
final isSubscribed = ref.watch(
  subscriptionProvider.select((async) =&amp;gt; async.valueOrNull?.isActive ?? false),
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;select()&lt;/code&gt;는 반환값이 이전과 동일하면 리빌드를 건너뛰어요. 값 비교는 &lt;code&gt;==&lt;/code&gt; 연산자로 해요.&lt;/p&gt;
&lt;h3 id=&quot;listen과-select-조합&quot;&gt;listen과 select 조합&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 구독 상태가 비활성으로 바뀔 때만 다이얼로그 표시
ref.listen&amp;lt;bool&amp;gt;(
  subscriptionProvider.select((s) =&amp;gt; s.valueOrNull?.isActive ?? false),
  (prev, next) {
    if (prev == true &amp;amp;&amp;amp; next == false &amp;amp;&amp;amp; context.mounted) {
      _showSubscriptionExpiredDialog(context);
    }
  },
);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;9.-정리&quot;&gt;9. 정리&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;규칙 3개만 기억하면 돼요.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ref.watch()&lt;/code&gt; → build()에서, UI 갱신이 필요할 때&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.read()&lt;/code&gt; → 이벤트 핸들러·메서드에서, 한 번만 읽을 때&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.listen()&lt;/code&gt; → build()에서, 리빌드 없이 부작용만 실행할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;자주 하는 실수 정리&lt;/strong&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;올바른 방법&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;버튼 onPressed에서 상태 읽기&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.watch()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.read()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;build()에서 상태 읽기&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.read()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.watch()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;initState에서 Notifier 메서드 호출&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.watch()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;Future.microtask(() =&amp;gt; ref.read(...))&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;오류 시 스낵바 표시&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.watch()&lt;/code&gt; + 조건문&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.listen()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;build()에서 조건부 이벤트&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.listen()&lt;/code&gt; 없이 조건문만&lt;/td&gt;&lt;td&gt;&lt;code&gt;ref.listen()&lt;/code&gt; + prev/next 비교&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;select()&lt;/code&gt;로 최적화&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// 전체 객체가 아닌 필요한 필드만 감시
ref.watch(cartProvider.select((c) =&amp;gt; c.valueOrNull?.itemCount ?? 0))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;다음 편 예고&lt;/strong&gt;: 같은 Provider인데 카테고리마다, 상품마다 다른 인스턴스가 필요할 때 어떻게 할까요? 4편에서 &lt;code&gt;family&lt;/code&gt; 패턴을 다뤄요. &lt;code&gt;FutureProvider.family&lt;/code&gt;, &lt;code&gt;AsyncNotifierProvider.family&lt;/code&gt;, 그리고 family 캐시를 어떻게 관리하는지 설명할게요.&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;strong&gt;시리즈 연결&lt;/strong&gt;다음 글에서는 family 패턴으로 매개변수 기반 Provider 만들기를 이어서 다룹니다.&lt;/div&gt;
&lt;footer class=&quot;db-footer&quot;&gt;&lt;strong&gt;추천 태그&lt;/strong&gt;: Flutter, Riverpod, Dart, 상태관리, 모바일앱개발, ref.watch, ref.read, ref.listen&lt;br&gt;&lt;strong&gt;대표 이미지 문구&lt;/strong&gt;: watch read listen / 세 가지 ref 사용 규칙&lt;br&gt;&lt;strong&gt;SEO Description&lt;/strong&gt;: Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다.&lt;/footer&gt;
&lt;/div&gt;</description>
      <category>Mobile/Flutter</category>
      <category>DART</category>
      <category>Flutter</category>
      <category>ref.listen</category>
      <category>ref.read</category>
      <category>ref.watch</category>
      <category>riverpod</category>
      <category>모바일앱개발</category>
      <category>상태관리</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/13</guid>
      <comments>https://tech-vault-lior.tistory.com/13#entry13comment</comments>
      <pubDate>Fri, 17 Jul 2026 17:08:23 +0900</pubDate>
    </item>
    <item>
      <title>[Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기</title>
      <link>https://tech-vault-lior.tistory.com/12</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
&lt;div class=&quot;db-eyebrow&quot;&gt;Flutter Riverpod 실무 시리즈 &amp;middot; 2편&lt;/div&gt;
&lt;h1&gt;[Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FutureProvider로 부족해지는 추가&amp;middot;수정&amp;middot;삭제 액션을 AsyncNotifier로 묶어 실무형 비동기 상태를 설계합니다.&lt;/p&gt;
&lt;div class=&quot;db-meta&quot;&gt;버전 기준: flutter_riverpod ^2.5.1 &amp;middot; Dart 3.x &amp;middot; Flutter 3.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-env&quot;&gt;&lt;span&gt;Flutter&lt;/span&gt;&lt;span&gt;Dart 3.x&lt;/span&gt;&lt;span&gt;flutter_riverpod ^2.5.1&lt;/span&gt;&lt;span&gt;수동 Provider 정의 기준&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;b&gt;이 글에서 다루는 내용&lt;/b&gt;FutureProvider로 부족해지는 추가&amp;middot;수정&amp;middot;삭제 액션을 AsyncNotifier로 묶어 실무형 비동기 상태를 설계합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.&lt;/div&gt;
&lt;div class=&quot;db-series&quot;&gt;
&lt;div class=&quot;db-series-title&quot;&gt;Flutter Riverpod 실무 시리즈&lt;/div&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;1편. Provider 종류와 기본 사용법&lt;/li&gt;
&lt;li class=&quot;db-current&quot;&gt;2편. AsyncNotifier로 비동기 CRUD 상태 관리하기&lt;/li&gt;
&lt;li&gt;3편. ref.watch / ref.read / ref.listen 차이 완전 정리&lt;/li&gt;
&lt;li&gt;4편. family 패턴으로 매개변수 기반 Provider 만들기&lt;/li&gt;
&lt;li&gt;5편. invalidate 전략과 Provider 의존성 체이닝&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;nav class=&quot;db-toc&quot;&gt;
&lt;div class=&quot;db-toc-title&quot;&gt;목차&lt;/div&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#1.-소개&quot;&gt;1. 소개&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2.-기본-사용-방법&quot;&gt;2. 기본 사용 방법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3.-실무-적용-방법-상품-crud-전체-예제&quot;&gt;3. 실무 적용 방법 &amp;mdash; 상품 CRUD 전체 예제&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4.-주의사항&quot;&gt;4. 주의사항&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5.-성능-구조-팁&quot;&gt;5. 성능 / 구조 팁&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6.-정리&quot;&gt;6. 정리&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/nav&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;AsyncNotifier
  ├─ build(): 초기 로딩
  ├─ add/update/delete(): 액션
  └─ state / invalidateSelf로 UI 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;db-diagram&quot;&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;div class=&quot;db-good&quot;&gt;&lt;b&gt;실무 적용 포인트&lt;/b&gt;처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편: Provider 종류와 기본 사용법 &lt;b&gt;2편: AsyncNotifier &amp;mdash; 비동기 상태의 기준점 &amp;larr; 현재 글&lt;/b&gt; 3편: ref.watch / ref.read / ref.listen &amp;mdash; 세 가지 차이를 제대로 알자 4편: family 패턴 &amp;mdash; 같은 Provider, 다른 데이터 5편: invalidate 전략과 Provider 의존성 체이닝&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;1.-소개&quot; data-ke-size=&quot;size26&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서 본 &lt;code&gt;FutureProvider&lt;/code&gt;는 데이터를 읽어오기엔 딱 좋아요. 그런데 실무에선 읽기만 하는 경우가 드물잖아요. 상품을 추가하고, 수정하고, 삭제하는 액션이 필요해요. 그러면서도 그 상태 변화가 UI에 즉시 반영돼야 하죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;FutureProvider&lt;/code&gt;로는 이걸 깔끔하게 처리하기 어려워요. 외부 함수를 만들어서 직접 &lt;code&gt;ref.invalidate()&lt;/code&gt;를 쓰면 Provider와 액션이 분리돼서 관리가 힘들어지고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AsyncNotifier&lt;/code&gt;는 바로 이 문제를 해결해요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;상태 + 액션&lt;/b&gt;을 한 클래스에서 관리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build()&lt;/code&gt;가 초기 데이터 로딩 담당&lt;/li&gt;
&lt;li&gt;메서드에서 DB/API 호출 후 상태 갱신&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state&lt;/code&gt;를 직접 업데이트해서 낙관적 UI도 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 편에서는 &lt;code&gt;flutter_riverpod: ^2.5.1&lt;/code&gt; 기준의 &lt;code&gt;AsyncNotifier&lt;/code&gt;를 실무 예제 중심으로 설명할게요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;2.-기본-사용-방법&quot; data-ke-size=&quot;size26&quot;&gt;2. 기본 사용 방법&lt;/h2&gt;
&lt;h3 id=&quot;asyncnotifier-구조&quot; data-ke-size=&quot;size23&quot;&gt;AsyncNotifier 구조&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;class ProductNotifier extends AsyncNotifier&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; {
  @override
  Future&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; build() async {
    // 초기 데이터 로딩
    // ref.watch()로 다른 Provider에 의존 가능
    return ref.watch(productRepositoryProvider).fetchAll();
  }

  // 액션 메서드들
  Future&amp;lt;void&amp;gt; add(Product product) async { ... }
  Future&amp;lt;void&amp;gt; update(Product product) async { ... }
  Future&amp;lt;void&amp;gt; delete(String id) async { ... }
}

final productNotifierProvider =
    AsyncNotifierProvider&amp;lt;ProductNotifier, List&amp;lt;Product&amp;gt;&amp;gt;(
  ProductNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;build()&lt;/code&gt;는 이 Notifier가 처음 감시될 때 실행돼요. 이후 &lt;code&gt;invalidateSelf()&lt;/code&gt;가 호출되거나, &lt;code&gt;ref.watch()&lt;/code&gt;로 의존하던 다른 Provider가 바뀌면 &lt;code&gt;build()&lt;/code&gt;가 다시 실행돼요.&lt;/p&gt;
&lt;h3 id=&quot;build-안에서-ref.watch&quot; data-ke-size=&quot;size23&quot;&gt;build() 안에서 ref.watch()&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;build()&lt;/code&gt; 안에서 &lt;code&gt;ref.watch()&lt;/code&gt;를 쓰면 해당 Provider가 바뀔 때 자동으로 &lt;code&gt;build()&lt;/code&gt;가 재실행돼요.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;@override
Future&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; build() async {
  // 정렬 순서가 바뀌면 build()가 다시 실행돼 목록을 다시 가져와요
  final sortOrder = ref.watch(sortOrderProvider);
  return ref.watch(productRepositoryProvider).fetchAll(sortBy: sortOrder);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;3.-실무-적용-방법-상품-crud-전체-예제&quot; data-ke-size=&quot;size26&quot;&gt;3. 실무 적용 방법 &amp;mdash; 상품 CRUD 전체 예제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쇼핑몰 앱의 상품 목록을 AsyncNotifier로 관리하는 전체 예제예요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;
&lt;summary&gt;전체 코드 보기&lt;/summary&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// ---- providers/product_notifier.dart ----

class ProductNotifier extends AsyncNotifier&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; {
  @override
  Future&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; build() async {
    // 정렬 순서 변경 시 자동 재실행
    final sort = ref.watch(sortOrderProvider);
    return ref.read(productRepositoryProvider).fetchAll(sortBy: sort);
  }

  // 상품 추가
  Future&amp;lt;Product&amp;gt; addProduct({
    required String name,
    required int price,
    required String categoryId,
  }) async {
    final product = Product(
      id: const Uuid().v4(),
      name: name,
      price: price,
      categoryId: categoryId,
      createdAt: DateTime.now(),
    );

    await ref.read(productRepositoryProvider).insert(product);

    // build()를 다시 실행해서 최신 목록으로 갱신
    ref.invalidateSelf();

    return product;
  }

  // 상품 수정 (낙관적 업데이트)
  Future&amp;lt;void&amp;gt; updateProduct(Product updated) async {
    // 1. 먼저 로컬 상태를 즉시 업데이트 (낙관적 UI)
    final current = state.valueOrNull ?? [];
    state = AsyncValue.data(
      current.map((p) =&amp;gt; p.id == updated.id ? updated : p).toList(),
    );

    try {
      // 2. 실제 API/DB 호출
      await ref.read(productRepositoryProvider).update(updated);
    } catch (e, st) {
      // 3. 실패하면 에러 상태로 롤백
      state = AsyncValue.error(e, st);
      // 또는 원래 목록으로 되돌리기
      ref.invalidateSelf();
    }
  }

  // 상품 삭제
  Future&amp;lt;void&amp;gt; deleteProduct(String id) async {
    await ref.read(productRepositoryProvider).delete(id);
    ref.invalidateSelf();
  }

  // 가격 일괄 변경
  Future&amp;lt;void&amp;gt; applyDiscount(double rate) async {
    // 로딩 상태로 전환
    state = const AsyncValue.loading();

    try {
      final current = await ref.read(productRepositoryProvider).fetchAll();
      final discounted = current
          .map((p) =&amp;gt; p.copyWith(price: (p.price * (1 - rate)).round()))
          .toList();
      await ref.read(productRepositoryProvider).batchUpdate(discounted);
      state = AsyncValue.data(discounted);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

final productNotifierProvider =
    AsyncNotifierProvider&amp;lt;ProductNotifier, List&amp;lt;Product&amp;gt;&amp;gt;(
  ProductNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI에서는 이렇게 써요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;
&lt;summary&gt;전체 코드 보기&lt;/summary&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ---- screens/product_list_screen.dart ----

class ProductListScreen extends ConsumerWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productNotifierProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('상품 목록'),
        actions: [
          IconButton(
            icon: const Icon(Icons.local_offer),
            tooltip: '10% 할인 적용',
            onPressed: () {
              ref.read(productNotifierProvider.notifier).applyDiscount(0.1);
            },
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () =&amp;gt; _showAddProductDialog(context, ref),
        child: const Icon(Icons.add),
      ),
      body: productsAsync.when(
        data: (products) =&amp;gt; products.isEmpty
            ? const Center(child: Text('상품이 없어요'))
            : ListView.builder(
                itemCount: products.length,
                itemBuilder: (_, i) =&amp;gt; ProductTile(
                  product: products[i],
                  onEdit: (p) =&amp;gt; _showEditDialog(context, ref, p),
                  onDelete: (id) =&amp;gt; ref
                      .read(productNotifierProvider.notifier)
                      .deleteProduct(id),
                ),
              ),
        loading: () =&amp;gt; const Center(child: CircularProgressIndicator()),
        error: (e, _) =&amp;gt; Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('오류: $e'),
              ElevatedButton(
                onPressed: () =&amp;gt; ref.invalidate(productNotifierProvider),
                child: const Text('다시 시도'),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Future&amp;lt;void&amp;gt; _showAddProductDialog(BuildContext context, WidgetRef ref) async {
    // 다이얼로그 로직 생략
    final result = await showDialog&amp;lt;Map&amp;lt;String, dynamic&amp;gt;&amp;gt;(
      context: context,
      builder: (_) =&amp;gt; const AddProductDialog(),
    );
    if (result == null) return;
    await ref.read(productNotifierProvider.notifier).addProduct(
      name: result['name'],
      price: result['price'],
      categoryId: result['categoryId'],
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h3 id=&quot;autodispose-화면-단위-임시-상태&quot; data-ke-size=&quot;size23&quot;&gt;autoDispose &amp;mdash; 화면 단위 임시 상태&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 편집 중인 상품처럼, 화면이 닫히면 상태를 버려도 되는 경우엔 &lt;code&gt;autoDispose&lt;/code&gt;를 붙여요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;
&lt;summary&gt;전체 코드 보기&lt;/summary&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;class CurrentProductNotifier extends AutoDisposeAsyncNotifier&amp;lt;Product?&amp;gt; {
  String? _productId;

  @override
  Future&amp;lt;Product?&amp;gt; build() async {
    if (_productId == null) return null;
    return ref.read(productRepositoryProvider).findById(_productId!);
  }

  void setProductId(String id) {
    _productId = id;
    ref.invalidateSelf();  // build() 재실행
  }

  Future&amp;lt;void&amp;gt; updateTitle(String title) async {
    final current = state.valueOrNull;
    if (current == null) return;

    final updated = current.copyWith(name: title, updatedAt: DateTime.now());
    await ref.read(productRepositoryProvider).update(updated);

    // 낙관적 업데이트: API 완료를 기다리지 않고 즉시 UI 갱신
    state = AsyncValue.data(updated);

    // 목록 Provider도 같이 갱신
    ref.invalidate(productNotifierProvider);
  }
}

final currentProductProvider =
    AsyncNotifierProvider.autoDispose&amp;lt;CurrentProductNotifier, Product?&amp;gt;(
  CurrentProductNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터 화면에서 이렇게 써요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;
&lt;summary&gt;전체 코드 보기&lt;/summary&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;class ProductEditScreen extends ConsumerStatefulWidget {
  final String productId;
  const ProductEditScreen({super.key, required this.productId});

  @override
  ConsumerState&amp;lt;ProductEditScreen&amp;gt; createState() =&amp;gt; _ProductEditScreenState();
}

class _ProductEditScreenState extends ConsumerState&amp;lt;ProductEditScreen&amp;gt; {
  final _nameController = TextEditingController();
  bool _initialized = false;

  @override
  void initState() {
    super.initState();
    // initState에서는 microtask로 감싸서 실행
    Future.microtask(() {
      ref.read(currentProductProvider.notifier).setProductId(widget.productId);
    });
  }

  @override
  Widget build(BuildContext context) {
    final productAsync = ref.watch(currentProductProvider);

    return productAsync.when(
      data: (product) {
        // 처음 한 번만 텍스트 필드 초기화
        if (product != null &amp;amp;&amp;amp; !_initialized) {
          _nameController.text = product.name;
          _initialized = true;
        }
        return Scaffold(
          appBar: AppBar(title: const Text('상품 편집')),
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              controller: _nameController,
              onChanged: (v) =&amp;gt;
                  ref.read(currentProductProvider.notifier).updateTitle(v),
              decoration: const InputDecoration(labelText: '상품명'),
            ),
          ),
        );
      },
      loading: () =&amp;gt; const Scaffold(
          body: Center(child: CircularProgressIndicator())),
      error: (e, _) =&amp;gt; Scaffold(
          body: Center(child: Text('오류: $e'))),
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;4.-주의사항&quot; data-ke-size=&quot;size26&quot;&gt;4. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;invalidateself-vs-state-asyncvalue.data&quot; data-ke-size=&quot;size23&quot;&gt;invalidateSelf() vs state = AsyncValue.data()&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이 둘의 차이가 헷갈릴 수 있어요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&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;code&gt;ref.invalidateSelf()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;build()&lt;/code&gt;를 다시 실행 (로딩 상태 거침)&lt;/td&gt;
&lt;td&gt;DB/API에서 최신 데이터를 다시 가져올 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;state = AsyncValue.data(value)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;로딩 없이 즉시 상태 교체&lt;/td&gt;
&lt;td&gt;낙관적 업데이트, 로컬 계산으로 충분할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 상품 삭제 후 목록 갱신 &amp;mdash; DB에서 다시 가져와야 하므로 invalidateSelf
Future&amp;lt;void&amp;gt; deleteProduct(String id) async {
  await ref.read(productRepositoryProvider).delete(id);
  ref.invalidateSelf();  // build() 재실행 &amp;rarr; 로딩 상태 잠깐 보임
}

// 상품 수정 &amp;mdash; 어떤 항목이 바뀌는지 알고 있으므로 낙관적 업데이트
Future&amp;lt;void&amp;gt; updateProduct(Product updated) async {
  final current = state.valueOrNull ?? [];
  state = AsyncValue.data(
    current.map((p) =&amp;gt; p.id == updated.id ? updated : p).toList(),
  );  // 로딩 없이 즉시 UI 갱신
  await ref.read(productRepositoryProvider).update(updated);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;build-가-실행되는-타이밍&quot; data-ke-size=&quot;size23&quot;&gt;build()가 실행되는 타이밍&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;build()&lt;/code&gt;가 언제 실행되는지 정확히 알아야 해요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;처음 감시될 때&lt;/b&gt; &amp;mdash; &lt;code&gt;ref.watch(productNotifierProvider)&lt;/code&gt; 처음 실행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;ref.invalidateSelf()&lt;/code&gt; 호출 시&lt;/b&gt; &amp;mdash; 메서드에서 명시적 호출&lt;/li&gt;
&lt;li&gt;&lt;b&gt;의존하는 Provider가 바뀔 때&lt;/b&gt; &amp;mdash; &lt;code&gt;build()&lt;/code&gt; 안에서 &lt;code&gt;ref.watch()&lt;/code&gt;한 Provider가 바뀔 때&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;@override
Future&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; build() async {
  // sortOrderProvider가 바뀌면 build()가 자동으로 재실행
  final sort = ref.watch(sortOrderProvider);
  return ref.read(productRepositoryProvider).fetchAll(sortBy: sort);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;build()&lt;/code&gt; 안에서는 &lt;code&gt;ref.watch()&lt;/code&gt;를 쓰고, 메서드 안에서는 &lt;code&gt;ref.read()&lt;/code&gt;를 써요. 메서드 안에서 &lt;code&gt;ref.watch()&lt;/code&gt;를 쓰면 런타임 에러가 나요.&lt;/p&gt;
&lt;h3 id=&quot;메서드-안에서-state를-읽을-때&quot; data-ke-size=&quot;size23&quot;&gt;메서드 안에서 state를 읽을 때&lt;/h3&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;Future&amp;lt;void&amp;gt; pinProduct(String id) async {
  // state.valueOrNull &amp;mdash; AsyncValue.data() 상태일 때만 값이 있어요
  final current = state.valueOrNull;
  if (current == null) return;  // 로딩 중이거나 에러 상태면 건너뜀

  final updated = current.map((p) =&amp;gt;
    p.id == id ? p.copyWith(isPinned: !p.isPinned) : p
  ).toList();

  await ref.read(productRepositoryProvider).batchUpdate(updated);
  state = AsyncValue.data(updated);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;5.-성능-구조-팁&quot; data-ke-size=&quot;size26&quot;&gt;5. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 id=&quot;낙관적-업데이트로-ux-개선&quot; data-ke-size=&quot;size23&quot;&gt;낙관적 업데이트로 UX 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 응답을 기다리지 않고 먼저 UI를 업데이트하면 사용자 입장에선 훨씬 빠르게 느껴져요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Future&amp;lt;void&amp;gt; togglePin(String id) async {
  final current = state.valueOrNull ?? [];

  // 1. 먼저 UI 갱신
  state = AsyncValue.data(
    current.map((p) =&amp;gt; p.id == id ? p.copyWith(isPinned: !p.isPinned) : p).toList(),
  );

  try {
    // 2. 백엔드에 반영
    final target = current.firstWhere((p) =&amp;gt; p.id == id);
    await ref.read(productRepositoryProvider).update(
      target.copyWith(isPinned: !target.isPinned),
    );
  } catch (_) {
    // 3. 실패하면 원래 상태로 롤백
    state = AsyncValue.data(current);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;asyncnotifier-vs-futureprovider-언제-뭘-쓸까&quot; data-ke-size=&quot;size23&quot;&gt;AsyncNotifier vs FutureProvider &amp;mdash; 언제 뭘 쓸까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기준이 명확하면 선택이 쉬워져요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&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;FutureProvider&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 추가/수정/삭제가 있음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AsyncNotifier&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;여러 곳에서 같은 데이터를 수정&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AsyncNotifier&lt;/code&gt; (단일 진실 원천)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;화면 단위 임시 상태&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AsyncNotifier.autoDispose&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;에러-복구-패턴&quot; data-ke-size=&quot;size23&quot;&gt;에러 복구 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 상태에서 재시도 버튼을 제공하는 게 좋아요.&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;error: (e, _) =&amp;gt; Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      const Icon(Icons.error_outline, size: 48, color: Colors.red),
      const SizedBox(height: 8),
      Text('$e'),
      const SizedBox(height: 16),
      ElevatedButton.icon(
        icon: const Icon(Icons.refresh),
        label: const Text('다시 시도'),
        onPressed: () =&amp;gt; ref.invalidate(productNotifierProvider),
      ),
    ],
  ),
),&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;6.-정리&quot; data-ke-size=&quot;size26&quot;&gt;6. 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;AsyncNotifier&lt;/code&gt; &amp;mdash; 상태 + 액션을 한 클래스에서 관리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build()&lt;/code&gt; &amp;mdash; 초기 데이터 로딩, &lt;code&gt;ref.watch()&lt;/code&gt;로 의존 선언 가능&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.invalidateSelf()&lt;/code&gt; &amp;mdash; &lt;code&gt;build()&lt;/code&gt; 재실행, DB에서 최신 데이터를 다시 가져올 때&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state = AsyncValue.data(value)&lt;/code&gt; &amp;mdash; 로딩 없이 즉시 상태 교체, 낙관적 업데이트&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state.valueOrNull&lt;/code&gt; &amp;mdash; 현재 데이터를 null-safe하게 추출&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autoDispose&lt;/code&gt; &amp;mdash; 화면이 닫힐 때 자동 정리, 에디터 같은 임시 상태에 사용&lt;/li&gt;
&lt;li&gt;메서드 안에선 &lt;code&gt;ref.read()&lt;/code&gt;, &lt;code&gt;build()&lt;/code&gt; 안에선 &lt;code&gt;ref.watch()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다음 편 예고&lt;/b&gt;: Provider를 쓰다 보면 &lt;code&gt;ref.watch()&lt;/code&gt;, &lt;code&gt;ref.read()&lt;/code&gt;, &lt;code&gt;ref.listen()&lt;/code&gt; 중 어떤 걸 써야 할지 헷갈리는 순간이 와요. 각각 어떻게 다르고 어떤 상황에서 쓰는지, 그리고 &lt;code&gt;select()&lt;/code&gt;로 불필요한 리빌드를 막는 방법을 3편에서 정리할게요.&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;b&gt;시리즈 연결&lt;/b&gt;다음 글에서는 ref.watch / ref.read / ref.listen 차이 완전 정리를 이어서 다룹니다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Mobile/Flutter</category>
      <category>AsyncNotifier</category>
      <category>CRUD</category>
      <category>DART</category>
      <category>Flutter</category>
      <category>riverpod</category>
      <category>모바일앱개발</category>
      <category>상태관리</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/12</guid>
      <comments>https://tech-vault-lior.tistory.com/12#entry12comment</comments>
      <pubDate>Sun, 14 Jun 2026 20:48:19 +0900</pubDate>
    </item>
    <item>
      <title>[Flutter Riverpod] 1편. Provider 종류와 기본 사용법</title>
      <link>https://tech-vault-lior.tistory.com/11</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
&lt;div class=&quot;db-eyebrow&quot;&gt;Flutter Riverpod 실무 시리즈 &amp;middot; 1편&lt;/div&gt;
&lt;h1&gt;[Flutter Riverpod] 1편. Provider 종류와 기본 사용법&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod Provider 종류, ProviderScope, ConsumerWidget, NotifierProvider, FutureProvider, StreamProvider 기본기를 실무 구조로 정리합니다.&lt;/p&gt;
&lt;div class=&quot;db-meta&quot;&gt;버전 기준: flutter_riverpod ^2.5.1 &amp;middot; Dart 3.x &amp;middot; Flutter 3.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-env&quot;&gt;&lt;span&gt;Flutter&lt;/span&gt;&lt;span&gt;Dart 3.x&lt;/span&gt;&lt;span&gt;flutter_riverpod ^2.5.1&lt;/span&gt;&lt;span&gt;Notifier 기반 수동 Provider 정의 기준&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;b&gt;이 글에서 다루는 내용&lt;/b&gt;Riverpod Provider 종류, ProviderScope, ConsumerWidget, NotifierProvider, FutureProvider, StreamProvider 기본기를 실무 구조로 정리합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.&lt;/div&gt;
&lt;div class=&quot;db-series&quot;&gt;
&lt;div class=&quot;db-series-title&quot;&gt;Flutter Riverpod 실무 시리즈&lt;/div&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li class=&quot;db-current&quot;&gt;1편. Provider 종류와 기본 사용법&lt;/li&gt;
&lt;li&gt;2편. AsyncNotifier로 비동기 CRUD 상태 관리하기&lt;/li&gt;
&lt;li&gt;3편. ref.watch / ref.read / ref.listen 차이 완전 정리&lt;/li&gt;
&lt;li&gt;4편. family 패턴으로 매개변수 기반 Provider 만들기&lt;/li&gt;
&lt;li&gt;5편. invalidate 전략과 Provider 의존성 체이닝&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;nav class=&quot;db-toc&quot;&gt;
&lt;div class=&quot;db-toc-title&quot;&gt;목차&lt;/div&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#1.-소개&quot;&gt;1. 소개&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2.-기본-사용-방법&quot;&gt;2. 기본 사용 방법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3.-provider-종류별-사용-방법&quot;&gt;3. Provider 종류별 사용 방법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4.-실무-적용-방법-쇼핑몰-앱-전체-예제&quot;&gt;4. 실무 적용 방법 &amp;mdash; 쇼핑몰 앱 전체 예제&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5.-주의사항&quot;&gt;5. 주의사항&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6.-성능-구조-팁&quot;&gt;6. 성능 / 구조 팁&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7.-정리&quot;&gt;7. 정리&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/nav&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;ProviderScope
  └─ Provider / NotifierProvider / FutureProvider / StreamProvider
       └─ ConsumerWidget에서 ref.watch로 사용&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;db-good&quot;&gt;&lt;b&gt;실무 적용 포인트&lt;/b&gt;처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/동기 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1편: Provider 종류와 기본 사용법 &amp;larr; 현재 글&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편: AsyncNotifier &amp;mdash; 비동기 상태의 기준점&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편: ref.watch / ref.read / ref.listen &amp;mdash; 세 가지 차이를 제대로 알자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4편: family 패턴 &amp;mdash; 같은 Provider, 다른 데이터&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5편: invalidate 전략과 Provider 의존성 체이닝&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;1.-소개&quot; data-ke-size=&quot;size26&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flutter 앱이 조금만 커지면 상태 관리 문제가 바로 터져요. &lt;code&gt;setState&lt;/code&gt;는 금방 한계가 오고, 여러 화면에서 같은 데이터를 써야 할 때 어떻게 전달할지 막막해지죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Provider 패키지를 써보셨다면 알 텐데, BuildContext에 강하게 의존해서 위젯 트리 밖에서 접근하기가 불편하고 테스트 짜기도 귀찮아요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod은 그 불편함들을 정면으로 해결해요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;BuildContext 없이도&lt;/b&gt; Provider에 접근 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;컴파일 타임&lt;/b&gt;에 의존성 오류 검출&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동일한 Provider&lt;/b&gt;를 여러 화면에서 재사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트에서&lt;/b&gt; Provider를 쉽게 오버라이드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동 dispose&lt;/b&gt; &amp;mdash; 화면이 닫히면 상태도 정리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈는 &lt;code&gt;flutter_riverpod: ^2.5.1&lt;/code&gt; 기준으로 작성했어요. 1.x 문서나 예전 자료를 보고 계셨다면 API가 많이 바뀌었으니 이 시리즈로 환승을 추천해요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;2.-기본-사용-방법&quot; data-ke-size=&quot;size26&quot;&gt;2. 기본 사용 방법&lt;/h2&gt;
&lt;h3 id=&quot;설치&quot; data-ke-size=&quot;size23&quot;&gt;설치&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;# pubspec.yaml
dependencies:
  flutter_riverpod: ^2.5.1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 생성을 쓸 계획이 있다면 이것도 함께 추가해요.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;dependencies:
  riverpod_annotation: ^2.3.5

dev_dependencies:
  riverpod_generator: ^2.4.3
  build_runner: ^2.4.11&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;db-info&quot;&gt;&lt;b&gt;실무 메모&lt;/b&gt;이 시리즈에서는 코드 생성 없이 수동 Provider 정의 방식을 써요. 생성기를 쓰지 않아도 충분히 깔끔하게 쓸 수 있고, 동작 방식을 이해하는 데는 수동 방식이 더 명확해요.&lt;/div&gt;
&lt;h3 id=&quot;providerscope-설정&quot; data-ke-size=&quot;size23&quot;&gt;ProviderScope 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 최상단에 &lt;code&gt;ProviderScope&lt;/code&gt;를 감싸줘야 해요. 모든 Provider의 인스턴스가 여기 저장돼요.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;void main() {
  runApp(const ProviderScope(child: MyApp()));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 초기화(Firebase, DB 등)가 있다면 &lt;code&gt;ProviderScope&lt;/code&gt; 전에 처리해요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  await AppDatabase.instance.initialize();
  runApp(const ProviderScope(child: MyApp()));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ProviderScope&lt;/code&gt;는 앱 전체에서 딱 한 번만 써요. 테스트에서 Provider를 오버라이드할 때도 &lt;code&gt;ProviderScope&lt;/code&gt;의 &lt;code&gt;overrides&lt;/code&gt; 파라미터를 사용해요.&lt;/p&gt;
&lt;h3 id=&quot;consumerwidget-vs-consumerstatefulwidget&quot; data-ke-size=&quot;size23&quot;&gt;ConsumerWidget vs ConsumerStatefulWidget&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod에서 &lt;code&gt;ref&lt;/code&gt;를 쓰려면 일반 위젯 대신 Consumer 계열을 써야 해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ConsumerWidget&lt;/b&gt; &amp;mdash; 로컬 상태가 필요 없을 때&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;class ProductCard extends ConsumerWidget {
  final String productId;
  const ProductCard({super.key, required this.productId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref로 Provider에 접근
    final product = ref.watch(productProvider(productId));
    return Text(product.name);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ConsumerStatefulWidget&lt;/b&gt; &amp;mdash; &lt;code&gt;initState&lt;/code&gt;, &lt;code&gt;dispose&lt;/code&gt;가 필요하거나 로컬 상태를 함께 쓸 때&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;class ProductListScreen extends ConsumerStatefulWidget {
  const ProductListScreen({super.key});

  @override
  ConsumerState&amp;lt;ProductListScreen&amp;gt; createState() =&amp;gt; _ProductListScreenState();
}

class _ProductListScreenState extends ConsumerState&amp;lt;ProductListScreen&amp;gt; {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    // initState에서도 ref를 쓸 수 있어요
    // 단, ref.watch는 build()에서만 &amp;mdash; 여기선 ref.read만
    ref.read(analyticsProvider).logScreenView('product_list');
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final products = ref.watch(productsProvider);
    // ...
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;3.-provider-종류별-사용-방법&quot; data-ke-size=&quot;size26&quot;&gt;3. Provider 종류별 사용 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod의 Provider는 용도에 따라 여러 종류가 있어요. 어떤 걸 써야 할지 헷갈린다면 아래 표를 참고해요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&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;code&gt;Provider&lt;/code&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;NotifierProvider&lt;/code&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;FutureProvider&lt;/code&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;StreamProvider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;실시간 데이터 스트림&lt;/td&gt;
&lt;td&gt;Firebase 같은 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AsyncNotifierProvider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;복잡한 비동기 상태 + 메서드&lt;/td&gt;
&lt;td&gt;2편에서 자세히 다룸&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;provider-서비스-싱글톤&quot; data-ke-size=&quot;size23&quot;&gt;Provider &amp;mdash; 서비스 싱글톤&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값이 변하지 않는 서비스나 Repository 객체를 만들 때 써요. 다른 Provider를 의존할 때는 &lt;code&gt;ref.watch()&lt;/code&gt;로 가져와요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 외부 서비스
final apiServiceProvider = Provider&amp;lt;ApiService&amp;gt;((ref) {
  return ApiService(baseUrl: 'https://api.example.com');
});

// apiServiceProvider에 의존하는 Repository
final productRepositoryProvider = Provider&amp;lt;ProductRepository&amp;gt;((ref) {
  final api = ref.watch(apiServiceProvider);
  return ProductRepository(api);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ref.watch(apiServiceProvider)&lt;/code&gt;로 의존하면, &lt;code&gt;apiServiceProvider&lt;/code&gt;가 바뀔 때 &lt;code&gt;productRepositoryProvider&lt;/code&gt;도 자동으로 재생성돼요. 물론 &lt;code&gt;Provider&lt;/code&gt;는 값이 바뀌지 않으니까 이 경우엔 거의 일어나지 않지만, 패턴을 일관되게 쓰는 게 좋아요.&lt;/p&gt;
&lt;h3 id=&quot;notifierprovider-동기-상태&quot; data-ke-size=&quot;size23&quot;&gt;NotifierProvider &amp;mdash; 동기 상태 관리&lt;/h3&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;class SelectedCategoryNotifier extends Notifier&amp;lt;String?&amp;gt; {
  @override
  String? build() =&amp;gt; null;

  void select(String id) =&amp;gt; state = id;
}

final selectedCategoryIdProvider =
    NotifierProvider&amp;lt;SelectedCategoryNotifier, String?&amp;gt;(
  SelectedCategoryNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;enum SortOrder { newest, priceAsc, priceDesc }

class SortOrderNotifier extends Notifier&amp;lt;SortOrder&amp;gt; {
  @override
  SortOrder build() =&amp;gt; SortOrder.newest;

  void change(SortOrder order) =&amp;gt; state = order;

  void reset() =&amp;gt; state = SortOrder.newest;
}

final sortOrderProvider =
    NotifierProvider&amp;lt;SortOrderNotifier, SortOrder&amp;gt;(
  SortOrderNotifier.new,
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI에서는 &lt;code&gt;ref.watch()&lt;/code&gt;로 읽고, &lt;code&gt;.notifier&lt;/code&gt;의 메서드로 상태를 변경해요.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;final selectedId = ref.watch(selectedCategoryIdProvider);

ref.read(selectedCategoryIdProvider.notifier)
   .select('electronics');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 값 하나라도 변경 의도가 드러나도록 메서드로 감싸두면, 화면 코드가 상태 변경 규칙을 직접 알 필요가 없어져요.&lt;/p&gt;
&lt;h3 id=&quot;futureprovider-비동기-데이터-페칭&quot; data-ke-size=&quot;size23&quot;&gt;FutureProvider &amp;mdash; 비동기 데이터 페칭&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 호출이나 DB 쿼리처럼 비동기로 데이터를 가져올 때 써요. Riverpod이 로딩/에러 상태를 자동으로 관리해줘요.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;final categoriesProvider = FutureProvider&amp;lt;List&amp;lt;Category&amp;gt;&amp;gt;((ref) async {
  final repo = ref.watch(productRepositoryProvider);
  return repo.fetchCategories();
});

final productsProvider = FutureProvider&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt;((ref) async {
  final repo = ref.watch(productRepositoryProvider);
  final sortOrder = ref.watch(sortOrderProvider);  // 정렬 순서가 바뀌면 재실행
  return repo.fetchAll(sortBy: sortOrder);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sortOrderProvider&lt;/code&gt;처럼 &lt;code&gt;ref.watch()&lt;/code&gt;로 의존을 걸어두면, 의존하는 값이 바뀔 때 FutureProvider가 자동으로 다시 실행돼요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI에서는 &lt;code&gt;when()&lt;/code&gt;으로 세 가지 상태를 처리해요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;final productsAsync = ref.watch(productsProvider);

return productsAsync.when(
  data: (products) =&amp;gt; ListView.builder(
    itemCount: products.length,
    itemBuilder: (_, i) =&amp;gt; ProductTile(products[i]),
  ),
  loading: () =&amp;gt; const Center(child: CircularProgressIndicator()),
  error: (error, stack) =&amp;gt; Center(child: Text('오류가 발생했어요: $error')),
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터만 필요하고 로딩/에러는 무시할 때는 &lt;code&gt;valueOrNull&lt;/code&gt;을 써요.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;final products = ref.watch(productsProvider).valueOrNull ?? [];&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;streamprovider-실시간-스트림&quot; data-ke-size=&quot;size23&quot;&gt;StreamProvider &amp;mdash; 실시간 스트림&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Firebase Auth 상태나 Firestore 실시간 업데이트처럼 값이 외부에서 계속 들어올 때 써요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 로그인 상태 스트림
final authStateProvider = StreamProvider&amp;lt;User?&amp;gt;((ref) {
  return FirebaseAuth.instance.authStateChanges();
});

// 현재 로그인 유저만 뽑아쓸 때
final currentUserProvider = Provider&amp;lt;User?&amp;gt;((ref) {
  return ref.watch(authStateProvider).valueOrNull;
});

// 장바구니 실시간 업데이트
final cartStreamProvider = StreamProvider&amp;lt;Cart&amp;gt;((ref) {
  final user = ref.watch(currentUserProvider);
  if (user == null) return Stream.value(Cart.empty());
  return FirebaseFirestore.instance
      .collection('carts')
      .doc(user.uid)
      .snapshots()
      .map(Cart.fromSnapshot);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;currentUserProvider&lt;/code&gt;처럼 &lt;code&gt;valueOrNull&lt;/code&gt;로 현재 값을 동기적으로 꺼내쓰는 패턴은 실무에서 자주 써요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;4.-실무-적용-방법-쇼핑몰-앱-전체-예제&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 적용 방법 &amp;mdash; 쇼핑몰 앱 전체 예제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카테고리별 상품 목록 화면을 만드는 예제예요.&lt;/p&gt;
&lt;details class=&quot;db-fold&quot;&gt;
&lt;summary&gt;전체 코드 보기&lt;/summary&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// ---- providers/api_providers.dart ----

final apiServiceProvider = Provider&amp;lt;ApiService&amp;gt;((ref) {
  return ApiService(baseUrl: 'https://api.shop.example.com');
});

final productRepositoryProvider = Provider&amp;lt;ProductRepository&amp;gt;((ref) {
  final api = ref.watch(apiServiceProvider);
  return ProductRepository(api);
});


// ---- providers/product_providers.dart ----

// 로그인 상태
final authStateProvider = StreamProvider&amp;lt;User?&amp;gt;((ref) {
  return FirebaseAuth.instance.authStateChanges();
});

final currentUserProvider = Provider&amp;lt;User?&amp;gt;((ref) {
  return ref.watch(authStateProvider).valueOrNull;
});

// 카테고리 목록
final categoriesProvider = FutureProvider&amp;lt;List&amp;lt;Category&amp;gt;&amp;gt;((ref) {
  return ref.watch(productRepositoryProvider).fetchCategories();
});

// 선택된 카테고리
class SelectedCategoryNotifier extends Notifier&amp;lt;String?&amp;gt; {
  @override
  String? build() =&amp;gt; null;

  void select(String id) =&amp;gt; state = id;

  void clear() =&amp;gt; state = null;
}

final selectedCategoryIdProvider =
    NotifierProvider&amp;lt;SelectedCategoryNotifier, String?&amp;gt;(
  SelectedCategoryNotifier.new,
);

// 정렬 순서
enum SortOrder { newest, priceAsc, priceDesc }

class SortOrderNotifier extends Notifier&amp;lt;SortOrder&amp;gt; {
  @override
  SortOrder build() =&amp;gt; SortOrder.newest;

  void change(SortOrder order) =&amp;gt; state = order;

  void reset() =&amp;gt; state = SortOrder.newest;
}

final sortOrderProvider =
    NotifierProvider&amp;lt;SortOrderNotifier, SortOrder&amp;gt;(
  SortOrderNotifier.new,
);

// 선택된 카테고리의 상품 (family 패턴 &amp;mdash; 4편에서 자세히)
final productsByCategoryProvider =
    FutureProvider.family&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt;((ref, categoryId) {
  final repo = ref.watch(productRepositoryProvider);
  final sort = ref.watch(sortOrderProvider);

  return repo.fetchByCategory(categoryId, sortBy: sort);
});


// ---- screens/product_list_screen.dart ----

class ProductListScreen extends ConsumerWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final selectedId = ref.watch(selectedCategoryIdProvider);
    final categoriesAsync = ref.watch(categoriesProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('쇼핑몰'),
        actions: [
          PopupMenuButton&amp;lt;SortOrder&amp;gt;(
            onSelected: (order) =&amp;gt; ref
                .read(sortOrderProvider.notifier)
                .change(order),
            itemBuilder: (_) =&amp;gt; const [
              PopupMenuItem(
                value: SortOrder.newest,
                child: Text('최신순'),
              ),
              PopupMenuItem(
                value: SortOrder.priceAsc,
                child: Text('가격 낮은 순'),
              ),
              PopupMenuItem(
                value: SortOrder.priceDesc,
                child: Text('가격 높은 순'),
              ),
            ],
          ),
        ],
      ),
      body: Column(
        children: [
          categoriesAsync.when(
            data: (categories) =&amp;gt; SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: categories.map((category) {
                  return ChoiceChip(
                    label: Text(category.name),
                    selected: category.id == selectedId,
                    onSelected: (_) =&amp;gt; ref
                        .read(selectedCategoryIdProvider.notifier)
                        .select(category.id),
                  );
                }).toList(),
              ),
            ),
            loading: () =&amp;gt; const LinearProgressIndicator(),
            error: (_, __) =&amp;gt; const SizedBox.shrink(),
          ),
          Expanded(
            child: selectedId == null
                ? const Center(child: Text('카테고리를 선택해주세요'))
                : ref.watch(productsByCategoryProvider(selectedId)).when(
                    data: (products) =&amp;gt; ListView.builder(
                      itemCount: products.length,
                      itemBuilder: (_, index) =&amp;gt; ProductTile(products[index]),
                    ),
                    loading: () =&amp;gt;
                        const Center(child: CircularProgressIndicator()),
                    error: (error, _) =&amp;gt; Center(child: Text('오류: $error')),
                  ),
          ),
        ],
      ),
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;5.-주의사항&quot; data-ke-size=&quot;size26&quot;&gt;5. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;ref.watch를-이벤트-핸들러-안에서-쓰면-안-돼요&quot; data-ke-size=&quot;size23&quot;&gt;ref.watch를 이벤트 핸들러 안에서 쓰면 안 돼요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 초반에 가장 많이 하는 실수예요. &lt;code&gt;ref.watch()&lt;/code&gt;는 &quot;이 값이 바뀔 때 나를 다시 실행해줘&quot;라는 의미예요. 그런데 버튼 콜백 안에서 쓰면 Riverpod이 이걸 추적할 방법이 없고, 경고 없이 이상하게 동작해요.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 잘못된 예
ElevatedButton(
  onPressed: () {
    // ❌ 이벤트 핸들러 안에서 ref.watch 금지
    final repo = ref.watch(productRepositoryProvider);
    repo.deleteProduct(id);
  },
  child: const Text('삭제'),
)

// 올바른 예
ElevatedButton(
  onPressed: () {
    // ✅ 이벤트 핸들러에서는 ref.read
    final repo = ref.read(productRepositoryProvider);
    repo.deleteProduct(id);
  },
  child: const Text('삭제'),
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;futureprovider는-자동으로-캐시돼요&quot; data-ke-size=&quot;size23&quot;&gt;FutureProvider는 자동으로 캐시돼요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ref.watch(productsProvider)&lt;/code&gt;를 여러 위젯에서 써도 API는 한 번만 호출돼요. Riverpod이 결과를 캐시하기 때문이에요. 그런데 데이터가 실제로 바뀌었을 때는 어떻게 할까요?&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 강제로 다시 실행시키기
ref.invalidate(productsProvider);

// 또는 즉시 실행하고 새 값 받기
await ref.refresh(productsProvider.future);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 5편 invalidate 전략에서 자세히 다뤄요.&lt;/p&gt;
&lt;h3 id=&quot;autodispose를-써야-하는-경우&quot; data-ke-size=&quot;size23&quot;&gt;autoDispose를 써야 하는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 Provider는 앱 생명주기 내내 살아있어요. 화면별로 임시 상태라면 &lt;code&gt;autoDispose&lt;/code&gt;를 붙여서 화면이 닫힐 때 자동으로 정리되게 해야 해요.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 검색 결과 &amp;mdash; 검색 화면을 떠나면 자동 정리
final searchResultsProvider =
    FutureProvider.autoDispose.family&amp;lt;List&amp;lt;Product&amp;gt;, String&amp;gt;((ref, query) {
  return ref.watch(productRepositoryProvider).search(query);
});

// 상품 상세 &amp;mdash; 상세 화면을 닫으면 자동 정리
final productDetailProvider =
    FutureProvider.autoDispose.family&amp;lt;Product, String&amp;gt;((ref, id) {
  return ref.watch(productRepositoryProvider).findById(id);
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;6.-성능-구조-팁&quot; data-ke-size=&quot;size26&quot;&gt;6. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 id=&quot;provider-계층을-명확하게-나눠요&quot; data-ke-size=&quot;size23&quot;&gt;Provider 계층을 명확하게 나눠요&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;서비스 레이어   &amp;rarr; ApiService, FirebaseService, DatabaseService
Repository 레이어 &amp;rarr; ProductRepository, UserRepository
상태 레이어    &amp;rarr; Notifier, AsyncNotifier, FutureProvider
UI 레이어     &amp;rarr; ConsumerWidget, ref.watch&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나눠두면 테스트에서 Repository만 모킹하거나, 서비스만 교체하기 쉬워요.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 테스트에서 Repository를 Mock으로 교체
await tester.pumpWidget(
  ProviderScope(
    overrides: [
      productRepositoryProvider.overrideWithValue(MockProductRepository()),
    ],
    child: const ProductListScreen(),
  ),
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;select-로-불필요한-리빌드-줄이기&quot; data-ke-size=&quot;size23&quot;&gt;select()로 불필요한 리빌드 줄이기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장바구니에 10개 항목이 있을 때 개수만 표시하는 위젯이라면, 장바구니 전체가 바뀔 때마다 리빌드할 필요가 없어요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 장바구니 전체를 감시 &amp;mdash; 하나만 바뀌어도 리빌드
final cart = ref.watch(cartProvider);
Text('${cart.items.length}개');

// select()로 개수만 감시 &amp;mdash; 개수가 바뀔 때만 리빌드
final count = ref.watch(cartProvider.select((c) =&amp;gt; c.items.length));
Text('$count개');&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;7.-정리&quot; data-ke-size=&quot;size26&quot;&gt;7. 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ProviderScope&lt;/code&gt; &amp;mdash; 앱 최상단에 딱 한 번, 모든 Provider의 저장소&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Provider&lt;/code&gt; &amp;mdash; 불변 서비스, 싱글톤 객체&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NotifierProvider&lt;/code&gt; &amp;mdash; 동기 상태와 명시적 메서드 관리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FutureProvider&lt;/code&gt; &amp;mdash; 비동기 페칭 + 캐싱, &lt;code&gt;when()&lt;/code&gt;으로 상태 처리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;StreamProvider&lt;/code&gt; &amp;mdash; 실시간 스트림, &lt;code&gt;.valueOrNull&lt;/code&gt;로 현재 값 추출&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ConsumerWidget&lt;/code&gt; / &lt;code&gt;ConsumerStatefulWidget&lt;/code&gt; &amp;mdash; &lt;code&gt;ref&lt;/code&gt;를 쓰려면 이걸로&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.watch()&lt;/code&gt; &amp;mdash; build() 안에서만, 이벤트 핸들러엔 &lt;code&gt;ref.read()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autoDispose&lt;/code&gt; &amp;mdash; 화면 단위 임시 상태는 붙여주기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다음 편 예고&lt;/b&gt;: 단순 데이터 페칭을 넘어 생성/수정/삭제 메서드까지 포함된 완전한 상태 객체가 필요할 때 어떻게 할까요? 2편에서 &lt;code&gt;AsyncNotifier&lt;/code&gt;를 다뤄요. &lt;code&gt;build()&lt;/code&gt;가 어떻게 동작하는지, &lt;code&gt;invalidateSelf()&lt;/code&gt;로 상태를 갱신하는 방법, &lt;code&gt;state = AsyncValue.data(...)&lt;/code&gt;로 낙관적 업데이트 하는 법까지 실무 예제로 설명할게요.&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;b&gt;시리즈 연결&lt;/b&gt;다음 글에서는 AsyncNotifier로 비동기 CRUD 상태 관리하기를 이어서 다룹니다.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Mobile/Flutter</category>
      <category>DART</category>
      <category>Flutter</category>
      <category>riverpod</category>
      <category>모바일앱개발</category>
      <category>상태관리</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/11</guid>
      <comments>https://tech-vault-lior.tistory.com/11#entry11comment</comments>
      <pubDate>Mon, 8 Jun 2026 22:02:49 +0900</pubDate>
    </item>
    <item>
      <title>[RestSharp 실전 시리즈] 3편. 에러 처리와 그리드 CRUD 저장 패턴</title>
      <link>https://tech-vault-lior.tistory.com/10</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
&lt;div class=&quot;db-eyebrow&quot;&gt;RESTSHARP PRACTICAL SERIES &amp;middot; 3편&lt;/div&gt;
&lt;h1&gt;[RestSharp 실전 시리즈] 3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무형 RestSharp API 통신 패턴 정리&lt;/p&gt;
&lt;div class=&quot;db-meta&quot;&gt;.NET Framework 4.7.2 &amp;middot; RestSharp 110.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;b&gt;핵심 요약&lt;/b&gt; RestSharp 기반 CRUD API 통신, JWT 인증, 에러 처리, DataTable 저장 패턴을 실무 중심으로 정리합니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt;&lt;br /&gt;1편. RestSharp 기본 설정 &amp;middot; GET 요청 &amp;middot; 서버 응답 구조&lt;br /&gt;2편. POST &amp;middot; PUT &amp;middot; DELETE와 JWT 인증 &lt;b&gt;&lt;br /&gt;3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/b&gt; &amp;larr; 현재 글&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편과 2편에서 API 호출 기본기를 다뤘는데, 응답 처리를 &lt;code&gt;response.IsSuccessful&lt;/code&gt;과 &lt;code&gt;jObject[&quot;scs&quot;]&lt;/code&gt; 체크 정도만 하고 넘어갔어요. 실무에서는 그걸로 충분하지 않아요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임아웃이 났을 때, 인증 토큰이 만료됐을 때, 서버가 500을 던졌을 때 각각 다르게 처리해야 하거든요. 에러 처리를 제대로 안 해두면 사용자 입장에서는 아무 반응 없이 그냥 멈춘 것처럼 보여요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 두 가지를 다뤄요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;2단계 에러 처리&lt;/b&gt; &amp;mdash; HTTP 레벨 실패와 비즈니스 레벨 실패를 나눠서 처리하는 패턴&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그리드 CRUD 저장&lt;/b&gt; &amp;mdash; &lt;code&gt;DataTable&lt;/code&gt;의 &lt;code&gt;RowState&lt;/code&gt;를 활용해서 변경된 행만 골라 insert/update/delete로 분류한 다음 한 번에 서버로 보내는 패턴&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 패턴을 조합하면 실무 화면 대부분은 커버할 수 있어요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 기본 사용 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러가 나는 지점 두 곳&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 호출에서 실패는 두 단계로 발생해요.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1단계] HTTP 레벨 실패
  &amp;rarr; 타임아웃, 네트워크 단절
  &amp;rarr; 서버가 4xx / 5xx 반환

[2단계] 비즈니스 레벨 실패
  &amp;rarr; HTTP 200 OK이지만 처리 실패
  &amp;rarr; scs: false, code: &quot;ERR_DUPLICATE&quot;, msg: &quot;이미 등록된 항목입니다.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;response.IsSuccessful&lt;/code&gt;로 1단계를 걸러내고, &lt;code&gt;(bool)jObject[&quot;scs&quot;]&lt;/code&gt;로 2단계를 걸러내는 게 기본 구조예요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 응답 DTO&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 에러(4xx/5xx)가 났을 때 서버가 내려보내는 에러 구조를 파싱하기 위한 DTO예요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class ApiErrorDto
{
    public string scs  { get; set; }
    public string code { get; set; }
    public string msg  { get; set; }
    public string args { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 실무 적용 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계 에러 처리 전체 패턴&lt;/h3&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private void SaveProduct(ProductDto dto)
{
    RestClient client = new RestClient(&quot;https://api.example.com&quot;);
    RestRequest req   = new RestRequest(&quot;/products&quot;, Method.Post);
    req.AddStringBody(JsonConvert.SerializeObject(dto), ContentType.Json);
    req.AddHeader(&quot;Authorization&quot;, &quot;Bearer &quot; + AppSession.Token);

    RestResponse response = client.Execute(req);

    // ── 1단계: HTTP 레벨 체크 ──────────────────────────────
    if (!response.IsSuccessful)
    {
        // 타임아웃
        if (response.ResponseStatus == ResponseStatus.TimedOut)
        {
            MessageBox.Show(&quot;요청 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.&quot;);
            return;
        }

        // 서버가 4xx/5xx 에러 응답을 내려준 경우
        if (!string.IsNullOrEmpty(response.Content))
        {
            ApiErrorDto errorDto = JsonConvert.DeserializeObject&amp;lt;ApiErrorDto&amp;gt;(response.Content);
            MessageBox.Show(ApiMessage.GetMessage(errorDto.code));
        }
        else
        {
            MessageBox.Show(&quot;서버에 연결할 수 없습니다.&quot;);
        }
        return;
    }

    // ── 2단계: 비즈니스 레벨 체크 ────────────────────────────
    JObject jObject = JObject.Parse(response.Content);

    if (!(bool)jObject[&quot;scs&quot;])
    {
        string code = jObject[&quot;code&quot;].ToString();

        // 인증 만료 &amp;rarr; 로그인 화면으로
        if (code == &quot;ERR_AUTH&quot;)
        {
            MessageBox.Show(&quot;인증이 만료되었습니다. 다시 로그인해 주세요.&quot;);
            ShowLoginForm();
            return;
        }

        MessageBox.Show(jObject[&quot;msg&quot;].ToString());
        return;
    }

    // ── 성공 처리 ────────────────────────────────────────────
    MessageBox.Show(&quot;저장되었습니다.&quot;);
    SearchProducts();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 코드 &amp;rarr; 메시지 변환 유틸리티&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 내려보낸 에러 코드를 그대로 사용자에게 보여주면 안 돼요. 친절한 메시지로 변환하는 유틸리티를 만들어 두세요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public static class ApiMessage
{
    private static readonly Dictionary&amp;lt;string, string&amp;gt; _messages =
        new Dictionary&amp;lt;string, string&amp;gt;
    {
        { &quot;ERR_AUTH&quot;,       &quot;인증이 만료되었습니다. 다시 로그인해 주세요.&quot; },
        { &quot;ERR_FORBIDDEN&quot;,  &quot;접근 권한이 없습니다.&quot; },
        { &quot;ERR_DUPLICATE&quot;,  &quot;이미 등록된 항목입니다.&quot; },
        { &quot;ERR_NOT_FOUND&quot;,  &quot;해당 데이터를 찾을 수 없습니다.&quot; },
        { &quot;ERR_VALIDATION&quot;, &quot;입력값을 확인해 주세요.&quot; },
    };

    public static string GetMessage(string code)
    {
        return _messages.TryGetValue(code, out string msg)
            ? msg
            : &quot;처리 중 오류가 발생했습니다. (&quot; + code + &quot;)&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그리드 CRUD 저장 &amp;mdash; DataRowState 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서 &lt;code&gt;AcceptChanges()&lt;/code&gt;를 꼭 호출해야 한다고 했는데, 이 이유가 여기서 나와요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DataTable&lt;/code&gt;은 각 행의 상태를 자동으로 추적해요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RowState&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;code&gt;Added&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;신규 추가&lt;/td&gt;
&lt;td&gt;조회 직후(AcceptChanges 전), 사용자가 행 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unchanged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;변경 없음&lt;/td&gt;
&lt;td&gt;AcceptChanges() 호출 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Modified&lt;/code&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;Deleted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;삭제됨&lt;/td&gt;
&lt;td&gt;사용자가 행 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태를 이용해서 변경된 행만 골라내는 유틸리티예요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;public static class GridUtils
{
    public static Dictionary&amp;lt;string, List&amp;lt;DataRow&amp;gt;&amp;gt; GetChangedRows(DataTable dataTable)
    {
        var insertList = new List&amp;lt;DataRow&amp;gt;();
        var updateList = new List&amp;lt;DataRow&amp;gt;();
        var deleteList = new List&amp;lt;DataRow&amp;gt;();

        // GetChanges()는 변경된 행만 담긴 새 DataTable을 반환해요
        DataTable changes = dataTable.GetChanges();

        if (changes == null) // 변경 없으면 null 반환
            goto done;

        foreach (DataRow row in changes.Rows)
        {
            if (row.RowState == DataRowState.Added)
            {
                insertList.Add(row);
            }
            else if (row.RowState == DataRowState.Modified)
            {
                updateList.Add(row);
            }
            else if (row.RowState == DataRowState.Deleted)
            {
                // Deleted 상태의 행은 현재 값에 접근하면 예외가 나요
                // Original 버전으로 읽어서 새 행에 복사해야 해요
                DataRow deletedRow = changes.NewRow();
                for (int i = 0; i &amp;lt; changes.Columns.Count; i++)
                    deletedRow[i] = row[i, DataRowVersion.Original];
                deleteList.Add(deletedRow);
            }
        }

        done:
        return new Dictionary&amp;lt;string, List&amp;lt;DataRow&amp;gt;&amp;gt;
        {
            { &quot;insert&quot;, insertList },
            { &quot;update&quot;, updateList },
            { &quot;delete&quot;, deleteList }
        };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DataRowState.Deleted&lt;/code&gt; 상태의 행에서 &lt;code&gt;row[&quot;컬럼명&quot;]&lt;/code&gt;으로 직접 값을 읽으면 &lt;code&gt;DeletedRowInaccessibleException&lt;/code&gt;이 나요. 반드시 &lt;code&gt;row[컬럼인덱스, DataRowVersion.Original]&lt;/code&gt;로 읽어야 해요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DataRow &amp;rarr; DTO 변환 (리플렉션)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경된 행 목록을 서버로 보내려면 DTO 리스트로 변환해야 해요. 리플렉션을 활용하면 컬럼명과 프로퍼티명이 일치하는 경우 자동으로 매핑할 수 있어요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public static class ConvUtils
{
    public static T GetItem&amp;lt;T&amp;gt;(DataRow row) where T : new()
    {
        T obj = Activator.CreateInstance&amp;lt;T&amp;gt;();
        Type type = typeof(T);

        foreach (DataColumn col in row.Table.Columns)
        {
            foreach (var prop in type.GetProperties())
            {
                if (prop.Name != col.ColumnName) continue;
                if (row[col.ColumnName] is DBNull)  { prop.SetValue(obj, null); continue; }

                string typeName = prop.PropertyType.FullName;

                if      (typeName.Contains(&quot;String&quot;))  prop.SetValue(obj, Convert.ToString(row[col.ColumnName]));
                else if (typeName.Contains(&quot;Int64&quot;))   prop.SetValue(obj, Convert.ToInt64(row[col.ColumnName]));
                else if (typeName.Contains(&quot;Int32&quot;))   prop.SetValue(obj, Convert.ToInt32(row[col.ColumnName]));
                else if (typeName.Contains(&quot;Double&quot;))  prop.SetValue(obj, Convert.ToDouble(row[col.ColumnName]));
                else if (typeName.Contains(&quot;Decimal&quot;)) prop.SetValue(obj, Convert.ToDecimal(row[col.ColumnName]));
                else if (typeName.Contains(&quot;Boolean&quot;)) prop.SetValue(obj, Convert.ToBoolean(row[col.ColumnName]));
                else if (typeName.Contains(&quot;DateTime&quot;))prop.SetValue(obj, Convert.ToDateTime(row[col.ColumnName]));
            }
        }
        return obj;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그리드 저장 전체 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 유틸리티들을 조합한 실제 저장 버튼 코드예요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private void btnSave_Click(object sender, EventArgs e)
{
    DataTable dt = (DataTable)gridProduct.DataSource;

    // 변경된 행 분류
    var changed = GridUtils.GetChangedRows(dt);

    if (changed[&quot;insert&quot;].Count == 0
     &amp;amp;&amp;amp; changed[&quot;update&quot;].Count == 0
     &amp;amp;&amp;amp; changed[&quot;delete&quot;].Count == 0)
    {
        MessageBox.Show(&quot;변경된 내용이 없습니다.&quot;);
        return;
    }

    // DataRow &amp;rarr; DTO 변환
    var insertDtos = changed[&quot;insert&quot;].Select(r =&amp;gt; ConvUtils.GetItem&amp;lt;ProductDto&amp;gt;(r)).ToList();
    var updateDtos = changed[&quot;update&quot;].Select(r =&amp;gt; ConvUtils.GetItem&amp;lt;ProductDto&amp;gt;(r)).ToList();
    var deleteDtos = changed[&quot;delete&quot;].Select(r =&amp;gt; ConvUtils.GetItem&amp;lt;ProductDto&amp;gt;(r)).ToList();

    // 요청 조립
    JObject reqParam = new JObject();
    reqParam.Add(&quot;insertList&quot;, JsonConvert.SerializeObject(insertDtos));
    reqParam.Add(&quot;updateList&quot;, JsonConvert.SerializeObject(updateDtos));
    reqParam.Add(&quot;deleteList&quot;, JsonConvert.SerializeObject(deleteDtos));

    RestClient client = new RestClient(&quot;https://api.example.com&quot;);
    RestRequest req   = new RestRequest(&quot;/products/batch&quot;, Method.Post);
    req.AddBody(reqParam.ToString(), ContentType.Json);
    req.AddHeader(&quot;Authorization&quot;, &quot;Bearer &quot; + AppSession.Token);

    RestResponse response = client.Execute(req);

    if (!response.IsSuccessful)
    {
        MessageBox.Show(&quot;저장 중 오류가 발생했습니다.&quot;);
        return;
    }

    JObject jObject = JObject.Parse(response.Content);
    if ((bool)jObject[&quot;scs&quot;])
    {
        MessageBox.Show(&quot;저장되었습니다.&quot;);
        SearchProducts(); // 새로 조회하면 AcceptChanges 포함
    }
    else
    {
        MessageBox.Show(jObject[&quot;msg&quot;].ToString());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 주의사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의 1 &amp;mdash; Deleted 행 읽기는 반드시 Original 버전으로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 모르면 처음에 진짜 당황해요. &lt;code&gt;DataRowState.Deleted&lt;/code&gt; 상태의 행은 이미 &quot;삭제 예약&quot;된 상태라 현재 값에 접근하면 예외가 터져요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;// 잘못된 방법 &amp;mdash; DeletedRowInaccessibleException 발생
string name = row[&quot;name&quot;].ToString(); // 예외!

// 올바른 방법 &amp;mdash; Original 버전으로 읽기
string name = row[&quot;name&quot;, DataRowVersion.Original].ToString();
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유틸리티 메서드에서 처리해 주면 호출하는 쪽에서는 신경 쓰지 않아도 돼요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의 2 &amp;mdash; 저장 후 AcceptChanges 타이밍&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 성공 후 &lt;code&gt;dt.AcceptChanges()&lt;/code&gt;를 직접 호출하는 대신, 서버에서 최신 데이터를 다시 조회하는 방식이 더 안전해요. 서버에서 자동으로 채워주는 필드(생성일시, 생성자 등)가 있을 수 있거든요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 권장 &amp;mdash; 저장 후 새로 조회
if ((bool)jObject[&quot;scs&quot;])
{
    MessageBox.Show(&quot;저장되었습니다.&quot;);
    SearchProducts(); // 조회 메서드 안에서 AcceptChanges() 포함
}

// 비권장 &amp;mdash; 직접 AcceptChanges (서버 반영 값이 누락될 수 있음)
// dt.AcceptChanges();
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의 3 &amp;mdash; &lt;code&gt;GetChanges()&lt;/code&gt;가 null을 반환해요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DataTable.GetChanges()&lt;/code&gt;는 변경된 행이 없으면 &lt;code&gt;null&lt;/code&gt;을 반환해요. &lt;code&gt;null&lt;/code&gt; 체크를 빠뜨리면 NullReferenceException이 나요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;DataTable changes = dataTable.GetChanges();

if (changes == null) return; // 변경 없음 &amp;mdash; 반드시 체크

foreach (DataRow row in changes.Rows)
{
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 호출 공통 메서드로 추출하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 API 호출에서 에러 처리 코드가 반복된다면, 공통 메서드로 추출하면 유지보수가 훨씬 편해져요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public static class ApiClient
{
    private static readonly string BaseUrl = ConfigManager.GetServerUrl();

    public static JObject Execute(string endpoint, Method method, object body = null)
    {
        RestClient client = new RestClient(BaseUrl);
        RestRequest req   = new RestRequest(endpoint, method);

        if (body != null)
            req.AddStringBody(JsonConvert.SerializeObject(body), ContentType.Json);

        req.AddHeader(&quot;Authorization&quot;, &quot;Bearer &quot; + AppSession.Token);

        RestResponse response = client.Execute(req);

        // 1단계 체크
        if (!response.IsSuccessful)
        {
            if (response.ResponseStatus == ResponseStatus.TimedOut)
                throw new TimeoutException(&quot;요청 시간이 초과되었습니다.&quot;);

            if (!string.IsNullOrEmpty(response.Content))
            {
                ApiErrorDto err = JsonConvert.DeserializeObject&amp;lt;ApiErrorDto&amp;gt;(response.Content);
                throw new ApiException(err.code, ApiMessage.GetMessage(err.code));
            }

            throw new ApiException(&quot;ERR_NETWORK&quot;, &quot;서버에 연결할 수 없습니다.&quot;);
        }

        JObject jObject = JObject.Parse(response.Content);

        // 2단계 체크
        if (!(bool)jObject[&quot;scs&quot;])
            throw new ApiException(
                jObject[&quot;code&quot;].ToString(),
                jObject[&quot;msg&quot;].ToString()
            );

        return jObject;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;// 호출하는 쪽은 이렇게 깔끔해져요
try
{
    JObject result = ApiClient.Execute(&quot;/products&quot;, Method.Get);

    DataTable dt = (DataTable)JsonConvert.DeserializeObject(
        result[&quot;list&quot;].ToString(), typeof(DataTable));
    dt.AcceptChanges();
    gridProduct.DataSource = dt;
}
catch (ApiException ex) when (ex.Code == &quot;ERR_AUTH&quot;)
{
    MessageBox.Show(ex.Message);
    ShowLoginForm();
}
catch (ApiException ex)
{
    MessageBox.Show(ex.Message);
}
catch (TimeoutException ex)
{
    MessageBox.Show(ex.Message);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커스텀 예외 클래스&lt;/h3&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;public class ApiException : Exception
{
    public string Code { get; }

    public ApiException(string code, string message) : base(message)
    {
        Code = code;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;에러 처리&lt;/b&gt; - API 실패는 HTTP 레벨(타임아웃/4xx/5xx)과 비즈니스 레벨(&lt;code&gt;scs: false&lt;/code&gt;) 두 단계로 나뉘어요 - &lt;code&gt;response.IsSuccessful&lt;/code&gt;로 1단계, &lt;code&gt;jObject[&quot;scs&quot;]&lt;/code&gt;로 2단계를 순서대로 확인해요 - 에러 코드는 사용자 친화적 메시지로 변환해서 보여줘요 - &lt;code&gt;ERR_AUTH&lt;/code&gt; 같은 인증 에러는 별도 분기로 처리해서 로그인 화면으로 보내야 해요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그리드 CRUD 저장&lt;/b&gt; - 조회 후 &lt;code&gt;AcceptChanges()&lt;/code&gt;를 호출해야 이후 변경 추적이 올바르게 동작해요 - &lt;code&gt;DataTable.GetChanges()&lt;/code&gt;로 변경된 행만 추출하고 &lt;code&gt;RowState&lt;/code&gt;로 insert/update/delete 분류해요 - &lt;code&gt;Deleted&lt;/code&gt; 상태의 행은 &lt;code&gt;DataRowVersion.Original&lt;/code&gt;로 값을 읽어야 예외가 안 나요 - 저장 후에는 &lt;code&gt;AcceptChanges()&lt;/code&gt; 직접 호출보다 다시 조회하는 방식이 더 안전해요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구조 팁&lt;/b&gt; - 반복되는 에러 처리는 공통 메서드로 추출하고, 커스텀 예외로 의미를 명확히 해요 - 에러 코드 &amp;rarr; 메시지 변환은 딕셔너리 기반 유틸리티로 한 곳에서 관리해요&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈에서 다룬 패턴들은 .NET WinForms 기반 업무용 앱에서 실제로 사용 중인 방식이에요. GET 조회 &amp;rarr; 그리드 바인딩 &amp;rarr; 사용자 편집 &amp;rarr; 변경 행 저장이라는 흐름이 반복되는 화면이라면 그대로 적용할 수 있어요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt;&lt;br/&gt; 1편. RestSharp 기본 설정 &amp;middot; GET 요청 &amp;middot; 서버 응답 구조&lt;br/&gt; 2편. POST &amp;middot; PUT &amp;middot; DELETE와 JWT 인증 &lt;br/&gt;&lt;b&gt;3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/b&gt; &amp;larr; 현재 글&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;b&gt;다음 시리즈&lt;/b&gt; RestSharp 실전 시리즈 다음 글도 함께 확인해보세요.&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Csharp</category>
      <category>jwt</category>
      <category>NewtonsoftJson</category>
      <category>restapi</category>
      <category>RestSharp</category>
      <category>WinForms</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/10</guid>
      <comments>https://tech-vault-lior.tistory.com/10#entry10comment</comments>
      <pubDate>Sun, 7 Jun 2026 19:30:52 +0900</pubDate>
    </item>
    <item>
      <title>[RestSharp 실전 시리즈] 2편. POST &amp;middot; PUT &amp;middot; DELETE와 JWT 인증</title>
      <link>https://tech-vault-lior.tistory.com/9</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;

&lt;div class=&quot;db-hero&quot;&gt;
  &lt;div class=&quot;db-eyebrow&quot;&gt;RESTSHARP PRACTICAL SERIES · 2편&lt;/div&gt;
  &lt;h1&gt;[RestSharp 실전 시리즈] 2편. POST · PUT · DELETE와 JWT 인증&lt;/h1&gt;
  &lt;p&gt;실무형 RestSharp API 통신 패턴 정리&lt;/p&gt;
  &lt;div class=&quot;db-meta&quot;&gt;.NET Framework 4.7.2 · RestSharp 110.x&lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;db-summary&quot;&gt;
&lt;strong&gt;핵심 요약&lt;/strong&gt;
RestSharp 기반 CRUD API 통신, JWT 인증, 에러 처리, DataTable 저장 패턴을 실무 중심으로 정리합니다.
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;시리즈 전체 목차&lt;/strong&gt;
1편. RestSharp 기본 설정 · GET 요청 · 서버 응답 구조
&lt;strong&gt;2편. POST · PUT · DELETE와 JWT 인증&lt;/strong&gt; ← 현재 글
3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;1. 소개&lt;/h2&gt;

&lt;p&gt;1편에서 GET 요청으로 데이터를 읽어오는 방법을 다뤘는데, 실무에서는 쓰기 작업이 훨씬 복잡해요. 데이터를 Body에 담아 보내야 하고, 인증 토큰도 매 요청에 붙여야 하고, 등록인지 수정인지에 따라 메서드도 달라지죠.&lt;/p&gt;

&lt;p&gt;이번 편에서는 그 부분을 다뤄요.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT 토큰 발급 → 전역 보관 → 모든 요청에 헤더로 추가&lt;/li&gt;
&lt;li&gt;POST · PUT · DELETE 각각의 패턴&lt;/li&gt;
&lt;li&gt;하나의 Save 버튼으로 등록/수정을 처리하는 통합 패턴&lt;/li&gt;
&lt;li&gt;여러 테이블 데이터를 한 번에 묶어서 보내는 복합 요청&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;1편의 서버 응답 규격(&lt;code&gt;scs&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;)을 그대로 사용해요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;2. 기본 사용 방법&lt;/h2&gt;

&lt;h3&gt;JWT 인증 헤더 추가&lt;/h3&gt;

&lt;p&gt;RestSharp에서 요청 헤더는 &lt;code&gt;AddHeader()&lt;/code&gt;로 추가해요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Bearer 방식 (일반적인 JWT 표준)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 커스텀 헤더 방식 (서버 구현에 따라 다름)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;X-AUTH-TOKEN&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;Body 전송 방식 두 가지&lt;/h3&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 방식 1: AddStringBody — 문자열을 그대로 Body로 전송&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 방식 2: AddBody — 객체 또는 문자열을 Body로 전송&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;AddStringBody&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;DTO를 직렬화한 문자열을 전송할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;AddBody&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;JObject처럼 이미 조립된 JSON을 전송할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2&gt;3. 실무 적용 방법&lt;/h2&gt;

&lt;h3&gt;로그인 — 토큰 발급&lt;/h3&gt;

&lt;p&gt;모든 API 요청에 토큰을 붙이려면 먼저 토큰을 발급받아야 해요. 로그인 요청만 헤더 없이 날리고, 응답에서 받은 토큰을 전역에 보관해요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;LoginDto&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;username&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;password&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;TokenResponseDto&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;expires&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Login&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;username&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;password&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/auth/login&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;param&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;param&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;username&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;username&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;param&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;password&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;password&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;param&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// 로그인 요청은 인증 헤더 없음&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSuccessful&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;scs&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TokenResponseDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokenDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DeserializeObject&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TokenResponseDto&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;                &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// 전역에 저장 — 이후 모든 요청에서 사용&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokenDto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;로그인 성공&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;msg&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;POST — 단순 등록&lt;/h3&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;InsertProduct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;     &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txtName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;price&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txtPrice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;category&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cboCategory&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SelectedValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSuccessful&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;scs&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;등록되었습니다.&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SearchProducts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// 목록 새로고침&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;msg&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;POST — 복합 데이터 전송 (JObject 조합)&lt;/h3&gt;

&lt;p&gt;상품 정보와 관련 이미지, 옵션 목록을 한 번의 요청으로 묶어서 보낼 때 &lt;code&gt;JObject&lt;/code&gt;를 조합하는 패턴이에요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;SaveProductWithOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products/detail&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;product&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;       &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;productDto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;insertOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;insertOptionList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;updateOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;updateOptionList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;deleteOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleteOptionList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// ... 응답 처리&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;서버로 전송되는 JSON:&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;product&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;       &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;productId&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;-1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;신상품&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;price&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;15000&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;insertOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;optionName&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;색상&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;optionValue&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;빨강&amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;updateOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;optionId&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;optionValue&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;quot;파랑&amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;deleteOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;quot;optionId&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;등록/수정 통합 Save 패턴&lt;/h3&gt;

&lt;p&gt;실무에서 폼 화면은 &quot;신규 등록&quot;과 &quot;기존 데이터 수정&quot;이 같은 화면인 경우가 많아요. 저장 버튼 하나로 두 케이스를 처리하는 패턴이에요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;btnSave_Click&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sender&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;EventArgs&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// ID가 0 이상이면 수정(PUT), 음수 초기값이면 신규 등록(POST)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;productId&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Put&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSuccessful&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;scs&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;저장되었습니다.&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SearchProducts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;msg&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;PUT — 수정&lt;/h3&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;UpdateProduct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Put&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSuccessful&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;scs&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;수정되었습니다.&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;msg&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;DELETE — 단일 삭제&lt;/h3&gt;

&lt;p&gt;DELETE에서도 Body를 전송할 수 있어요. URL 경로에 ID를 포함하는 방식 외에, DTO로 추가 정보를 같이 보내야 할 때 Body를 사용해요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;DeleteProduct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSuccessful&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;scs&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;삭제되었습니다.&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;msg&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;DELETE — 다중 삭제 (ID 목록 전송)&lt;/h3&gt;

&lt;p&gt;그리드에서 체크된 여러 행을 한 번에 삭제할 때 ID 목록을 배열로 전송해요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;DeleteSelectedProducts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;long&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleteIds&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;long&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gridView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RowCount&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gridView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GetRowCellValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;checked&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;            &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleteIds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;long&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gridView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GetRowCellValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;productId&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleteIds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Count&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;==&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MessageBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Show&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;삭제할 항목을 선택해주세요.&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;   &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products/batch&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleteIds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestResponse&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;w&quot;&gt;    &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// ... 응답 처리&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;서버로 전송되는 Body:&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1001&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1002&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1003&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;hr /&gt;

&lt;h2&gt;4. 주의사항&lt;/h2&gt;

&lt;h3&gt;주의 1 — ID 초기값은 왜 음수(-1)를 쓰나요&lt;/h3&gt;

&lt;p&gt;Save 통합 패턴에서 &lt;code&gt;productId &amp;gt;= 0&lt;/code&gt;으로 신규/수정을 구분했는데, 처음 보면 의아할 수 있어요.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0&lt;/code&gt;을 쓰면 안 되는 이유가 있어요. DB의 AUTO_INCREMENT ID는 보통 1부터 시작하지만, 데이터에 따라 &lt;code&gt;0&lt;/code&gt;이 유효한 ID 값으로 쓰이는 경우도 있거든요. 반면 음수는 실제 DB ID로 절대 나올 수 없으니 &quot;아직 저장 안 된 신규 레코드&quot;를 표현하는 값으로 안전해요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// DTO 초기화 시&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_dto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;productId&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// 신규 상태&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 기존 데이터 로드 시&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;_dto&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DeserializeObject&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ProductDto&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ToString&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;c1&quot;&gt;// productId가 실제 값으로 채워짐&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;주의 2 — DELETE에서 Body 전송이 안 된다고 느낄 때&lt;/h3&gt;

&lt;p&gt;HTTP 표준에서 DELETE에 Body를 포함하는 건 원래 정의되지 않아서, 일부 서버 프레임워크나 중간 프록시에서 무시하거나 거부하는 경우가 있어요.&lt;/p&gt;

&lt;p&gt;서버가 Spring/Express 같은 일반적인 프레임워크라면 대부분 잘 받아요. 하지만 혹시 DELETE Body가 안 받아진다면 서버 설정을 확인해 보는 게 먼저예요. 클라이언트(RestSharp) 쪽에서는 정상 전송하고 있어요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 확실히 하려면 URL에 ID 포함하는 방식이 더 표준적&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products/&amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;productId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// Body 없이 URL로만 처리&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;주의 3 — 토큰 만료를 무시하면 안 돼요&lt;/h3&gt;

&lt;p&gt;토큰을 전역에 보관해서 재사용하는 방식이다 보니, 오래된 토큰으로 요청을 계속 보내는 경우가 생길 수 있어요. 서버에서 &lt;code&gt;scs: false&lt;/code&gt;와 인증 에러 코드를 내려보내줬을 때 로그인 화면으로 되돌리는 처리를 꼭 해야 해요. 이 부분은 3편 에러 처리에서 자세히 다뤄요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;5. 성능 / 구조 팁&lt;/h2&gt;

&lt;h3&gt;공통 헤더는 한 곳에서 처리하세요&lt;/h3&gt;

&lt;p&gt;매 요청마다 &lt;code&gt;req.AddHeader(&quot;Authorization&quot;, ...)&lt;/code&gt; 를 반복하는 건 실수할 여지가 많아요. &lt;code&gt;RestClient&lt;/code&gt;의 &lt;code&gt;DefaultParameters&lt;/code&gt;에 등록해 두면 모든 요청에 자동으로 붙어요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;RestClientOptions&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClientOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;https://api.example.com&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 모든 요청에 자동으로 붙는 기본 헤더&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddDefaultHeader&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;Bearer &amp;quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AppSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// 이후 요청에서는 AddHeader 불필요&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RestRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;/products&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddStringBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ContentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// Authorization 헤더는 자동으로 포함됨&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;단, 토큰이 갱신될 때마다 &lt;code&gt;RestClient&lt;/code&gt; 인스턴스도 다시 만들어야 해요. 요청마다 새로 만드는 방식이라면 매번 헤더 추가도 크게 문제없어요.&lt;/p&gt;

&lt;h3&gt;복합 요청은 단위 테스트가 중요해요&lt;/h3&gt;

&lt;p&gt;JObject를 직접 조립해서 보내는 복합 요청은 오타 하나로 서버에서 null 오류가 날 수 있어요. JSON 키 이름을 서버 API 스펙과 정확히 맞추고, 가능하면 단위 테스트로 JSON 구조를 검증해 두는 게 좋아요.&lt;/p&gt;

&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// 키 이름을 상수로 관리하면 실수를 줄일 수 있어요&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KEY_PRODUCT&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;       &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;product&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KEY_INSERT_OPTIONS&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;insertOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KEY_UPDATE_OPTIONS&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;updateOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KEY_DELETE_OPTIONS&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;quot;deleteOptions&amp;quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KEY_PRODUCT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;        &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;productDto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;reqParam&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KEY_INSERT_OPTIONS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JsonConvert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerializeObject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;insertOptionList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;hr /&gt;

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

&lt;ul&gt;
&lt;li&gt;JWT 토큰은 로그인 후 전역에 보관하고, 모든 요청에서 &lt;code&gt;AddHeader()&lt;/code&gt;로 추가해요&lt;/li&gt;
&lt;li&gt;Body 전송은 &lt;code&gt;AddStringBody&lt;/code&gt;(DTO 직렬화) 또는 &lt;code&gt;AddBody&lt;/code&gt;(JObject 조합) 둘 다 사용 가능해요&lt;/li&gt;
&lt;li&gt;신규/수정 통합 Save 패턴은 ID 초기값을 음수(-1)로 두고 &lt;code&gt;&amp;gt;= 0&lt;/code&gt; 조건으로 분기해요&lt;/li&gt;
&lt;li&gt;복수 테이블 데이터는 JObject로 묶어서 한 번의 POST로 전송할 수 있어요&lt;/li&gt;
&lt;li&gt;DELETE에도 Body 전송이 가능하지만, 서버 설정에 따라 동작이 달라질 수 있어요&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;strong&gt;다음 편 예고&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;3편은 이 시리즈의 마무리예요. 솔직히 가장 중요한 내용이기도 해요. HTTP 실패와 비즈니스 실패를 나눠서 처리하는 2단계 에러 처리 패턴, 그리드에서 변경된 행만 골라서 insert/update/delete로 분류한 다음 한 번에 서버로 보내는 배치 저장 패턴까지 다뤄요. 이 두 패턴을 알면 실무 화면의 80%는 커버할 수 있어요.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;strong&gt;시리즈 전체 목차&lt;/strong&gt;
1편. RestSharp 기본 설정 · GET 요청 · 서버 응답 구조
&lt;strong&gt;2편. POST · PUT · DELETE와 JWT 인증&lt;/strong&gt; ← 현재 글
3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/p&gt;


&lt;div class=&quot;db-next&quot;&gt;
&lt;strong&gt;다음 시리즈&lt;/strong&gt;
RestSharp 실전 시리즈 다음 글도 함께 확인해보세요.
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Backend/C#</category>
      <category>Csharp</category>
      <category>jwt</category>
      <category>restapi</category>
      <category>RestSharp</category>
      <category>WinForms</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/9</guid>
      <comments>https://tech-vault-lior.tistory.com/9#entry9comment</comments>
      <pubDate>Sun, 7 Jun 2026 18:58:07 +0900</pubDate>
    </item>
    <item>
      <title>[RestSharp 실전 시리즈] 1편. 기본 설정 &amp;middot; GET 요청 &amp;middot; 서버 응답 구조</title>
      <link>https://tech-vault-lior.tistory.com/8</link>
      <description>&lt;div class=&quot;db-post&quot;&gt;
&lt;div class=&quot;db-hero&quot;&gt;
&lt;div class=&quot;db-eyebrow&quot;&gt;RESTSHARP PRACTICAL SERIES &amp;middot; 1편&lt;/div&gt;
&lt;h1&gt;[RestSharp 실전 시리즈] 1편. 기본 설정 &amp;middot; GET 요청 &amp;middot; 서버 응답 구조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무형 RestSharp API 통신 패턴 정리&lt;/p&gt;
&lt;div class=&quot;db-meta&quot;&gt;.NET Framework 4.7.2 &amp;middot; RestSharp 110.x&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&lt;b&gt;핵심 요약&lt;/b&gt; RestSharp 기반 CRUD API 통신, JWT 인증, 에러 처리, DataTable 저장 패턴을 실무 중심으로 정리합니다.&lt;/div&gt;
&lt;div class=&quot;db-summary&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1편. RestSharp 기본 설정 &amp;middot; GET 요청 &amp;middot; 서버 응답 구조&lt;/b&gt; &amp;larr; 현재 글&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편. POST &amp;middot; PUT &amp;middot; DELETE와 JWT 인증&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.NET WinForms나 콘솔 앱에서 REST API를 호출해야 할 때, &lt;code&gt;HttpClient&lt;/code&gt;를 날것으로 쓰면 생각보다 코드가 꽤 길어져요. 요청 직렬화, 헤더 세팅, 응답 역직렬화... 매번 같은 보일러플레이트를 반복하다 보면 슬슬 귀찮아지죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RestSharp&lt;/b&gt;은 이 번거로움을 줄여주는 .NET용 REST 클라이언트 라이브러리예요. 쿼리 파라미터 조립, JSON Body 전송, 헤더 관리 등을 훨씬 간결하게 처리할 수 있어서 실무에서 많이 쓰여요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈에서는 실제 업무용 데스크탑 앱에서 사용 중인 패턴을 기반으로, &quot;바로 가져다 쓸 수 있는&quot; 수준의 코드를 공유할게요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;환경&lt;/b&gt;: .NET Framework 4.7.2 / RestSharp 110.x / Newtonsoft.Json&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예제 도메인&lt;/b&gt;: 상품 관리 시스템 (상품 목록 조회, 등록, 수정, 삭제)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 기본 사용 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설치&lt;/h3&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;Install-Package RestSharp
Install-Package Newtonsoft.Json
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RestClient와 RestRequest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RestSharp의 핵심은 두 객체예요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;RestClient client = new RestClient(&quot;https://api.example.com&quot;);
RestRequest req   = new RestRequest(&quot;/products&quot;, Method.Get);

RestResponse response = client.Execute(req);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&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;code&gt;RestClient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP 연결 담당. 베이스 URL, 타임아웃 등 연결 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RestRequest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;개별 요청 담당. 엔드포인트, 메서드, 파라미터, Body, 헤더&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까 &lt;code&gt;RestClient&lt;/code&gt;는 한 번 만들어두고 재사용하는 연결 설정이고, &lt;code&gt;RestRequest&lt;/code&gt;는 요청마다 새로 만드는 개별 요청이에요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 실무 적용 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 응답 구조 먼저 이해하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 API 응답을 공통 envelope 구조로 감싸는 경우가 많아요. 클라이언트 코드가 왜 그렇게 작성됐는지 이해하려면 이 구조를 먼저 알아야 해요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;clojure&quot;&gt;&lt;code&gt;{
  &quot;scs&quot;:  true,
  &quot;code&quot;: &quot;0&quot;,
  &quot;msg&quot;:  &quot;처리되었습니다.&quot;,
  &quot;data&quot;: { ... },
  &quot;list&quot;: [ ... ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&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;code&gt;scs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bool&lt;/td&gt;
&lt;td&gt;&lt;b&gt;비즈니스 성공 여부&lt;/b&gt; &amp;mdash; HTTP 200이어도 false일 수 있어요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;결과 코드 (에러 시 &lt;code&gt;ERR_AUTH&lt;/code&gt; 같은 식별 코드)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;msg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;사용자에게 보여줄 메시지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;object&lt;/td&gt;
&lt;td&gt;단일 레코드 반환 시 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;array&lt;/td&gt;
&lt;td&gt;목록 반환 시 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 200을 받았다고 성공한 게 아니에요. &lt;code&gt;scs&lt;/code&gt; 필드를 반드시 확인해야 진짜 성공 여부를 알 수 있어요. 이게 이 시리즈 전체에서 가장 중요한 포인트예요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GET 요청 &amp;mdash; 목록 조회&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 목록을 조회하는 전체 흐름이에요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;private void SearchProducts()
{
    RestClient client = new RestClient(&quot;https://api.example.com&quot;);
    RestRequest req   = new RestRequest(&quot;/products&quot;, Method.Get);

    // 쿼리 파라미터 추가 &amp;rarr; /products?name=노트북&amp;amp;category=전자 형태로 자동 조립
    req.AddParameter(&quot;name&quot;,     txtSearchName.Text);
    req.AddParameter(&quot;category&quot;, cboCategory.SelectedValue.ToString());

    RestResponse response = client.Execute(req);

    if (response.IsSuccessful)
    {
        JObject jObject = JObject.Parse(response.Content);
        if ((bool)jObject[&quot;scs&quot;])
        {
            DataTable dt = (DataTable)JsonConvert.DeserializeObject(
                jObject[&quot;list&quot;].ToString(), typeof(DataTable));

            dt.AcceptChanges(); // 반드시 호출! (이유는 주의사항 섹션에서 설명)
            gridProduct.DataSource = dt;
        }
        else
        {
            MessageBox.Show(jObject[&quot;msg&quot;].ToString());
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AddParameter()&lt;/code&gt;를 쓰면 URL 쿼리 스트링을 직접 조립할 필요가 없어요. 파라미터만 추가하면 GET 요청에서 자동으로 붙어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GET 요청 &amp;mdash; 단일 레코드 조회&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리드에서 특정 행을 선택했을 때 상세 정보를 가져오는 패턴이에요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private void LoadProductDetail(long productId)
{
    RestClient client = new RestClient(&quot;https://api.example.com&quot;);
    RestRequest req   = new RestRequest(&quot;/products/&quot; + productId, Method.Get);

    RestResponse response = client.Execute(req);

    if (response.IsSuccessful)
    {
        JObject jObject = JObject.Parse(response.Content);
        if ((bool)jObject[&quot;scs&quot;])
        {
            // data 필드를 DTO로 역직렬화
            ProductDto dto = JsonConvert.DeserializeObject&amp;lt;ProductDto&amp;gt;(
                jObject[&quot;data&quot;].ToString());

            txtName.Text  = dto.name;
            txtPrice.Text = dto.price.ToString();
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO는 JSON 필드명과 프로퍼티명을 일치시켜야 역직렬화가 자동으로 돼요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class ProductDto
{
    public long   productId { get; set; }
    public string name      { get; set; }
    public int    price     { get; set; }
    public string category  { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 주의사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의 1 &amp;mdash; &lt;code&gt;IsSuccessful&lt;/code&gt;만 믿으면 안 되는 경우가 있어요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;response.IsSuccessful&lt;/code&gt;은 HTTP 상태 코드만 확인하는 게 아니에요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// RestSharp 내부 구현
public bool IsSuccessful =&amp;gt;
    (int)StatusCode &amp;gt;= 200 &amp;amp;&amp;amp; (int)StatusCode &amp;lt;= 299
    &amp;amp;&amp;amp; ResponseStatus == ResponseStatus.Completed;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ResponseStatus.Completed&lt;/code&gt;까지 확인하는 게 포인트예요. 타임아웃이나 네트워크 오류가 나면 HTTP 상태 코드 자체가 없는 상태(&lt;code&gt;TimedOut&lt;/code&gt;, &lt;code&gt;Error&lt;/code&gt;)가 되거든요. 상태 코드만 체크하면 이런 케이스를 그냥 통과시킬 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 커스텀 확장 메서드를 만들어 두고 쓰는 방식이 더 안전해요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;public static class RestSharpExtensions
{
    public static bool IsSuccessful(RestResponse response)
    {
        return (int)response.StatusCode &amp;gt;= 200
            &amp;amp;&amp;amp; (int)response.StatusCode &amp;lt;= 399
            &amp;amp;&amp;amp; response.ResponseStatus == ResponseStatus.Completed;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;200~299만 성공으로 볼지, 리다이렉트(3xx)도 포함할지는 서버 설계에 맞게 기준을 정하면 돼요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의 2 &amp;mdash; &lt;code&gt;AcceptChanges()&lt;/code&gt; 빠뜨리면 저장 로직이 망가져요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 모르면 나중에 진짜 당황하게 돼요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON을 &lt;code&gt;DataTable&lt;/code&gt;로 역직렬화하면 &lt;b&gt;모든 행의 RowState가 &lt;code&gt;Added&lt;/code&gt;&lt;/b&gt; 상태예요. 그리드에 바인딩하고 사용자가 아무것도 건드리지 않아도 &quot;신규 추가된 행&quot;으로 인식되는 거예요.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;역직렬화 직후:   모든 행 &amp;rarr; RowState = Added
AcceptChanges(): 모든 행 &amp;rarr; RowState = Unchanged  &amp;larr; 이 상태가 되어야 정상
사용자 수정 후:  수정된 행 &amp;rarr; RowState = Modified
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편에서 변경된 행만 골라서 저장하는 패턴을 다루는데, &lt;code&gt;AcceptChanges()&lt;/code&gt;를 빠뜨리면 그 패턴 전체가 오동작해요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;DataTable dt = (DataTable)JsonConvert.DeserializeObject(
    jObject[&quot;list&quot;].ToString(), typeof(DataTable));

dt.AcceptChanges(); // 이 한 줄이 없으면 나중에 저장할 때 전부 Insert로 들어가요

gridProduct.DataSource = dt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 URL은 중앙에서 관리하세요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API URL을 각 폼마다 하드코딩하면 서버 주소가 바뀔 때 전부 찾아서 고쳐야 해요. 환경 설정 파일이나 전역 상수에서 읽어오는 방식을 쓰는 게 훨씬 나아요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;; 설정 파일 (app.ini 또는 appsettings.json)
[SERVER]
URL=https://api.example.com/
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 전역에서 읽어서 사용
string baseUrl = ConfigManager.GetServerUrl(); // &quot;https://api.example.com/&quot;

RestClient client = new RestClient(baseUrl);
RestRequest req   = new RestRequest(&quot;products&quot;, Method.Get);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타임아웃은 케이스별로 따로 설정하세요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대용량 조회나 배치 처리처럼 오래 걸릴 수 있는 요청에만 타임아웃을 늘려줘요. 전체에 길게 잡으면 진짜 오류가 났을 때 사용자가 너무 오래 기다리게 돼요.&lt;/p&gt;
&lt;div class=&quot;codehilite&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 일반 요청: 기본 타임아웃 사용
RestClient client = new RestClient(&quot;https://api.example.com&quot;);

// 오래 걸리는 요청: 개별로 설정
RestClientOptions options = new RestClientOptions(&quot;https://api.example.com&quot;)
{
    MaxTimeout = 2 * 60 * 1000 // 2분 (밀리초 단위)
};
RestClient clientLong = new RestClient(options);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;RestClient&lt;/code&gt; 인스턴스 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 앱이라면 &lt;code&gt;RestClient&lt;/code&gt;(내부적으로 &lt;code&gt;HttpClient&lt;/code&gt; 기반)를 싱글톤으로 유지하는 게 맞아요. 계속 새로 생성하면 소켓 고갈(Socket Exhaustion) 문제가 생길 수 있거든요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 WinForms 데스크탑 앱처럼 동시 요청 수가 적고 각 폼이 독립적으로 동작하는 경우라면 요청마다 새로 생성해도 실용적으로는 문제없어요. 단, 성능이 중요한 상황이라면 재사용을 고려하는 게 좋아요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;AddParameter()&lt;/code&gt;를 쓰면 쿼리 스트링을 직접 조립하지 않아도 돼요&lt;/li&gt;
&lt;li&gt;서버 응답은 HTTP 성공 + &lt;code&gt;scs&lt;/code&gt; 필드, 두 단계로 확인해야 해요&lt;/li&gt;
&lt;li&gt;&lt;code&gt;response.IsSuccessful&lt;/code&gt;은 HTTP 상태 코드와 &lt;code&gt;ResponseStatus.Completed&lt;/code&gt;를 함께 체크해요&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DataTable&lt;/code&gt;로 역직렬화 후 &lt;b&gt;반드시 &lt;code&gt;AcceptChanges()&lt;/code&gt;&lt;/b&gt; 호출해요 &amp;mdash; 나중에 저장 로직의 기반이 돼요&lt;/li&gt;
&lt;li&gt;서버 URL은 설정 파일에서 중앙 관리, 타임아웃은 케이스별로 개별 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다음 편 예고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서는 본격적으로 데이터를 쓰는 작업을 다뤄요. JWT 토큰을 발급받아서 모든 요청에 인증 헤더로 붙이는 방법, 그리고 POST &amp;middot; PUT &amp;middot; DELETE 각각의 패턴과 실무에서 자주 쓰이는 등록/수정 통합 Save 패턴까지 정리할게요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1편. RestSharp 기본 설정 &amp;middot; GET 요청 &amp;middot; 서버 응답 구조&lt;/b&gt; &amp;larr; 현재 글&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편. POST &amp;middot; PUT &amp;middot; DELETE와 JWT 인증&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편. 에러 처리와 그리드 CRUD 저장 패턴&lt;/p&gt;
&lt;div class=&quot;db-next&quot;&gt;&lt;b&gt;다음 시리즈&lt;/b&gt; RestSharp 실전 시리즈 다음 글도 함께 확인해보세요.&lt;/div&gt;
&lt;footer class=&quot;db-footer&quot;&gt;&lt;/footer&gt;&lt;/div&gt;</description>
      <category>Backend/C#</category>
      <category>CRUD</category>
      <category>Csharp</category>
      <category>jwt</category>
      <category>NewtonsoftJson</category>
      <category>restapi</category>
      <category>RestSharp</category>
      <category>WinForms</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/8</guid>
      <comments>https://tech-vault-lior.tistory.com/8#entry8comment</comments>
      <pubDate>Tue, 2 Jun 2026 16:34:19 +0900</pubDate>
    </item>
    <item>
      <title>Apache POI 차트 고급편 &amp;mdash; Pie&amp;middot;Doughnut&amp;middot;Area&amp;middot;Scatter&amp;middot;OOXML 제어</title>
      <link>https://tech-vault-lior.tistory.com/7</link>
      <description>&lt;article class=&quot;poi-post&quot;&gt;
&lt;section class=&quot;poi-hero&quot;&gt;
&lt;div class=&quot;eyebrow&quot;&gt;Spring Boot &amp;times; Apache POI 실무 시리즈 06/6&lt;/div&gt;
&lt;h1&gt;Apache POI 차트 고급편 &amp;mdash; Pie&amp;middot;Doughnut&amp;middot;Area&amp;middot;Scatter&amp;middot;OOXML 제어&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 자주 만나는 Excel 처리 요구사항을 Spring Boot와 Apache POI 기준으로 정리한 시리즈입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;div class=&quot;series-box&quot;&gt;
&lt;p class=&quot;series-title&quot; data-ke-size=&quot;size16&quot;&gt;Apache POI 실무 시리즈&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;엑셀 읽기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;엑셀 쓰기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;대용량 엑셀 읽기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;대용량 엑셀 쓰기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;차트 Bar/Line&lt;/li&gt;
&lt;li class=&quot;current&quot;&gt;&amp;nbsp;기타 차트/XML 제어 &amp;larr; 현재 글&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;poi-summary&quot;&gt;&lt;b&gt;이 글에서 다루는 내용&lt;/b&gt;&lt;br /&gt;기타 차트/XML 제어를 Spring Boot 프로젝트에서 실무적으로 적용하는 방법, 자주 발생하는 함정, 구조화 패턴을 함께 정리합니다.&lt;/div&gt;
&lt;figure class=&quot;poi-diagram&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;post-06-diagram.png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;260&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4ODRT/dJMcagMrmIZ/ehRwmqfNNXpRaYdAZk5tdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4ODRT/dJMcagMrmIZ/ehRwmqfNNXpRaYdAZk5tdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4ODRT/dJMcagMrmIZ/ehRwmqfNNXpRaYdAZk5tdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4ODRT%2FdJMcagMrmIZ%2FehRwmqfNNXpRaYdAZk5tdK%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;1000&quot; height=&quot;260&quot; data-filename=&quot;post-06-diagram.png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;260&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;figure class=&quot;poi-diagram&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figcaption&gt;Apache POI 차트 고급편 &amp;mdash; Pie&amp;middot;Doughnut&amp;middot;Area&amp;middot;Scatter&amp;middot;OOXML 제어 흐름 요약&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;div class=&quot;info-box&quot;&gt;이 글은 예제 코드보다 실무에서 놓치기 쉬운 포인트를 중심으로 정리했습니다. 필요한 코드는 그대로 복사해서 프로젝트 구조에 맞게 분리해 사용하면 됩니다.&lt;/div&gt;
&lt;h2 id=&quot;소개&quot; data-ke-size=&quot;size26&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시리즈 마지막 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5편에서 바 차트와 꺾은선 차트의 기본 흐름을 잡았으니, 이번엔 &lt;b&gt;파이(Pie), 도넛(Doughnut), 영역(Area), 분산형(Scatter)&lt;/b&gt; 차트를 다룹니다. 그리고 POI의 고수준 API로는 표현이 안 되는 부분을 &lt;b&gt;OOXML XML을 직접 제어&lt;/b&gt;해서 처리하는 방법도 정리할게요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 이 편에서 다루는 내용은 POI 차트 API의 가장 어두운 부분입니다. API가 일관성이 없고, 차트 유형마다 지원 수준이 달라서 &amp;ldquo;왜 이게 안 되지?&amp;rdquo; 하는 순간이 분명 옵니다. 그 부분까지 같이 짚어드릴게요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;파이pie-차트&quot; data-ke-size=&quot;size26&quot;&gt;2. 파이(Pie) 차트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파이 차트는 비율을 보여줄 때 쓰는 가장 흔한 유형 중 하나입니다.&lt;/p&gt;
&lt;h3 id=&quot;데이터-준비&quot; data-ke-size=&quot;size23&quot;&gt;데이터 준비&lt;/h3&gt;
&lt;div id=&quot;cb1&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;Sheet sheet = workbook.createSheet(&quot;파이차트&quot;);

// 헤더
Row header = sheet.createRow(0);
header.createCell(0).setCellValue(&quot;카테고리&quot;);
header.createCell(1).setCellValue(&quot;비율&quot;);

// 데이터
String[] categories = {&quot;의류&quot;, &quot;전자&quot;, &quot;식품&quot;, &quot;가구&quot;, &quot;기타&quot;};
double[] values     = {35.0, 25.0, 20.0, 12.0, 8.0};

for (int i = 0; i &amp;lt; categories.length; i++) {
    Row row = sheet.createRow(i + 1);
    row.createCell(0).setCellValue(categories[i]);
    row.createCell(1).setCellValue(values[i]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;파이-차트-생성&quot; data-ke-size=&quot;size23&quot;&gt;파이 차트 생성&lt;/h3&gt;
&lt;div id=&quot;cb2&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;private void createPieChart(XSSFSheet sheet, int dataRows) {

    XSSFDrawing drawing = sheet.createDrawingPatriarch();
    XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 13, 20);

    XSSFChart chart = drawing.createChart(anchor);
    chart.setTitleText(&quot;카테고리별 매출 비율&quot;);
    chart.setTitleOverlay(false);

    XDDFChartLegend legend = chart.getOrAddLegend();
    legend.setPosition(LegendPosition.RIGHT);

    // 파이 차트는 축이 없으므로 null 전달
    XDDFDataSource&amp;lt;String&amp;gt; categories = XDDFDataSourcesFactory.fromStringCellRange(
        sheet, new CellRangeAddress(1, dataRows, 0, 0));
    XDDFNumericalDataSource&amp;lt;Double&amp;gt; values = XDDFDataSourcesFactory.fromNumericCellRange(
        sheet, new CellRangeAddress(1, dataRows, 1, 1));

    XDDFPieChartData data = (XDDFPieChartData) chart.createData(
        ChartTypes.PIE, null, null);
    data.setVaryColors(true);

    XDDFPieChartData.Series series =
        (XDDFPieChartData.Series) data.addSeries(categories, values);
    series.setTitle(&quot;매출 비율&quot;, null);
    series.setExplosion(0L);

    chart.plot(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바/꺾은선과 비교했을 때 가장 큰 차이가 &lt;b&gt;축이 없다&lt;/b&gt;는 점이에요. &lt;code&gt;chart.plot(data)&lt;/code&gt;에 축을 안 넘기면 됩니다.&lt;/p&gt;
&lt;h3 id=&quot;조각-분리explosion-효과&quot; data-ke-size=&quot;size23&quot;&gt;조각 분리(Explosion) 효과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 조각을 강조하고 싶을 때 분리 효과를 줄 수 있습니다.&lt;/p&gt;
&lt;div id=&quot;cb3&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;XDDFPieChartData.Series series =
    (XDDFPieChartData.Series) data.addSeries(categories, values);
series.setExplosion(10L); // 10% 분리&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 이 설정은 모든 조각에 일괄 적용됩니다. 특정 조각만 분리하려면 저수준 XML을 건드려야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;도넛doughnut-차트&quot; data-ke-size=&quot;size26&quot;&gt;3. 도넛(Doughnut) 차트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도넛 차트는 파이 차트의 변형으로, 가운데가 뚫려 있는 형태입니다. POI의 고수준 API에 &lt;code&gt;DoughnutChartData&lt;/code&gt;가 없기 때문에, XML을 직접 다뤄야 합니다.&lt;/p&gt;
&lt;div id=&quot;cb4&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 도넛 차트는 XDDF에 아직 고수준 API가 없어
// CT* 클래스로 직접 XML을 구성합니다
private void createDoughnutChart(XSSFSheet sheet, int dataRows) {

    XSSFDrawing drawing = sheet.createDrawingPatriarch();
    XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 13, 20);
    XSSFChart chart = drawing.createChart(anchor);

    chart.setTitleText(&quot;도넛 차트&quot;);
    chart.setTitleOverlay(false);

    XDDFChartLegend legend = chart.getOrAddLegend();
    legend.setPosition(LegendPosition.RIGHT);

    CTPlotArea plotArea = chart.getCTChart().getPlotArea();
    CTDoughnutChart doughnutChart = plotArea.addNewDoughnutChart();

    CTDPtSer ser = doughnutChart.addNewSer();
    ser.addNewIdx().setVal(0);
    ser.addNewOrder().setVal(0);

    CTSerTx tx = ser.addNewTx();
    tx.addNewV().setStringValue(&quot;매출 비율&quot;);

    CTStrRef catStrRef = ser.addNewCat().addNewStrRef();
    catStrRef.setF(&quot;'도넛차트'!$A$2:$A$6&quot;);

    CTNumRef valNumRef = ser.addNewVal().addNewNumRef();
    valNumRef.setF(&quot;'도넛차트'!$B$2:$B$6&quot;);

    CTHoleSize holeSize = doughnutChart.addNewHoleSize();
    holeSize.setVal((short) 50);

    doughnutChart.addNewDLbls().addNewShowVal().setVal(true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도넛 차트는 이렇게 XML을 직접 만들어야 합니다. 번거롭지만 한 번 패턴을 잡아두면 재사용할 수 있어요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;영역area-차트&quot; data-ke-size=&quot;size26&quot;&gt;4. 영역(Area) 차트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꺾은선 차트에서 선 아래 영역이 채워지는 형태입니다. 추세를 보여줄 때 꺾은선보다 시각적으로 더 강조되는 느낌을 줍니다.&lt;/p&gt;
&lt;div id=&quot;cb5&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;private void createAreaChart(XSSFSheet sheet, int dataRows) {

    XSSFDrawing drawing = sheet.createDrawingPatriarch();
    XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 14, 22);

    XSSFChart chart = drawing.createChart(anchor);
    chart.setTitleText(&quot;누적 방문자&quot;);
    chart.setTitleOverlay(false);

    XDDFChartLegend legend = chart.getOrAddLegend();
    legend.setPosition(LegendPosition.BOTTOM);

    XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM);
    XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
    leftAxis.setCrosses(AxisCrosses.AUTO_ZERO);

    XDDFDataSource&amp;lt;String&amp;gt; categories = XDDFDataSourcesFactory.fromStringCellRange(
        sheet, new CellRangeAddress(1, dataRows, 0, 0));
    XDDFNumericalDataSource&amp;lt;Double&amp;gt; values = XDDFDataSourcesFactory.fromNumericCellRange(
        sheet, new CellRangeAddress(1, dataRows, 1, 1));

    XDDFAreaChartData data = (XDDFAreaChartData) chart.createData(
        ChartTypes.AREA, bottomAxis, leftAxis);

    XDDFAreaChartData.Series series =
        (XDDFAreaChartData.Series) data.addSeries(categories, values);
    series.setTitle(&quot;방문자&quot;, null);

    chart.plot(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바 차트나 꺾은선 차트랑 구조가 거의 동일해서 가장 쉬운 편입니다. &lt;code&gt;XDDFAreaChartData&lt;/code&gt;로만 바꿔주면 돼요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;분산형scatter-차트&quot; data-ke-size=&quot;size26&quot;&gt;5. 분산형(Scatter) 차트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 값 사이의 상관관계를 보여줄 때 씁니다. 카테고리 없이 X, Y 모두 숫자를 씁니다.&lt;/p&gt;
&lt;div id=&quot;cb6&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 분산형 데이터 예시 &amp;mdash; X: 광고비, Y: 매출
// X    Y
// 100  800
// 200  1500
// 300  2100
// 400  3200
// 500  4100

private void createScatterChart(XSSFSheet sheet, int dataRows) {

    XSSFDrawing drawing = sheet.createDrawingPatriarch();
    XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 14, 22);

    XSSFChart chart = drawing.createChart(anchor);
    chart.setTitleText(&quot;광고비 vs 매출&quot;);
    chart.setTitleOverlay(false);

    // 분산형은 양쪽 모두 XDDFValueAxis
    XDDFValueAxis bottomAxis = chart.createValueAxis(AxisPosition.BOTTOM);
    XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
    leftAxis.setCrosses(AxisCrosses.AUTO_ZERO);

    XDDFNumericalDataSource&amp;lt;Double&amp;gt; xValues = XDDFDataSourcesFactory.fromNumericCellRange(
        sheet, new CellRangeAddress(1, dataRows, 0, 0));
    XDDFNumericalDataSource&amp;lt;Double&amp;gt; yValues = XDDFDataSourcesFactory.fromNumericCellRange(
        sheet, new CellRangeAddress(1, dataRows, 1, 1));

    XDDFScatterChartData data = (XDDFScatterChartData) chart.createData(
        ChartTypes.SCATTER, bottomAxis, leftAxis);
    data.setStyle(ScatterStyle.LINE_MARKER);

    XDDFScatterChartData.Series series =
        (XDDFScatterChartData.Series) data.addSeries(xValues, yValues);
    series.setTitle(&quot;광고비 vs 매출&quot;, null);
    series.setSmooth(false);

    chart.plot(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산형에서 바 차트와 다른 점은 &lt;b&gt;X축도 &lt;code&gt;ValueAxis&lt;/code&gt;&lt;/b&gt;라는 거예요. 카테고리(문자열)가 아니라 숫자값이기 때문에 &lt;code&gt;CategoryAxis&lt;/code&gt; 대신 &lt;code&gt;ValueAxis&lt;/code&gt;를 씁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;저수준-xml-직접-제어&quot; data-ke-size=&quot;size26&quot;&gt;6. 저수준 XML 직접 제어&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI 고수준 API로 안 되는 것들이 꽤 있습니다. 예를 들면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;선 색상, 굵기 변경&lt;/li&gt;
&lt;li&gt;마커(점) 모양 변경&lt;/li&gt;
&lt;li&gt;데이터 레이블 위치 조정&lt;/li&gt;
&lt;li&gt;그리드 라인 스타일&lt;/li&gt;
&lt;li&gt;차트 배경색&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 건 &lt;code&gt;CT&lt;/code&gt; 접두사가 붙은 저수준 XML 객체를 직접 다뤄야 합니다.&lt;/p&gt;
&lt;h3 id=&quot;꺾은선-색상-변경&quot; data-ke-size=&quot;size23&quot;&gt;꺾은선 색상 변경&lt;/h3&gt;
&lt;div id=&quot;cb7&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 차트를 만들고 plot()까지 호출한 다음에 저수준 접근
CTPlotArea plotArea = chart.getCTChart().getPlotArea();
CTLineChart lineChart = plotArea.getLineChartList().get(0);
CTLineSer series = lineChart.getSerList().get(0);

// 선 색상 설정 (빨간색: #FF0000)
CTShapeProperties spPr = series.addNewSpPr();
CTSolidColorFillProperties solidFill = spPr.addNewLn().addNewSolidFill();
solidFill.addNewSrgbClr().setVal(new byte[]{(byte)0xFF, 0x00, 0x00});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;데이터-레이블-표시&quot; data-ke-size=&quot;size23&quot;&gt;데이터 레이블 표시&lt;/h3&gt;
&lt;div id=&quot;cb8&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;CTLineChart lineChart = plotArea.getLineChartList().get(0);
CTDLbls dLbls = lineChart.addNewDLbls();
dLbls.addNewShowVal().setVal(true);       // 값 표시
dLbls.addNewShowLegendKey().setVal(false);
dLbls.addNewShowCatName().setVal(false);
dLbls.addNewShowSerName().setVal(false);
dLbls.addNewShowPercent().setVal(false);
dLbls.addNewShowBubbleSize().setVal(false);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;마커점-없애기-선만-표시&quot; data-ke-size=&quot;size23&quot;&gt;마커(점) 없애기 &amp;mdash; 선만 표시&lt;/h3&gt;
&lt;div id=&quot;cb9&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;CTLineSer ser = lineChart.getSerList().get(0);
CTMarker marker = ser.addNewMarker();
CTMarkerStyle markerStyle = marker.addNewSymbol();
markerStyle.setVal(STMarkerStyle.NONE); // 마커 없음&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;바-차트-색상-변경-시리즈별&quot; data-ke-size=&quot;size23&quot;&gt;바 차트 색상 변경 (시리즈별)&lt;/h3&gt;
&lt;div id=&quot;cb10&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;CTPlotArea plotArea = chart.getCTChart().getPlotArea();
CTBarChart barChart = plotArea.getBarChartList().get(0);
CTBarSer series = barChart.getSerList().get(0);

CTShapeProperties spPr = series.addNewSpPr();
CTSolidColorFillProperties fill = spPr.addNewSolidFill();
fill.addNewSrgbClr().setVal(new byte[]{0x00, (byte)0x70, (byte)0xC0}); // 파란색&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;차트-유형별-지원-상태-정리&quot; data-ke-size=&quot;size26&quot;&gt;7. 차트 유형별 지원 상태 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI 5.3.0 기준으로 각 차트 유형의 고수준 API 지원 상태입니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr class=&quot;header&quot;&gt;
&lt;th&gt;차트 유형&lt;/th&gt;
&lt;th&gt;고수준 API&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;바(Bar)&lt;/td&gt;
&lt;td&gt;✅ 지원&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BarChartData&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;꺾은선(Line)&lt;/td&gt;
&lt;td&gt;✅ 지원&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LineChartData&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;영역(Area)&lt;/td&gt;
&lt;td&gt;✅ 지원&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XDDFAreaChartData&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;파이(Pie)&lt;/td&gt;
&lt;td&gt;✅ 지원&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XDDFPieChartData&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;분산형(Scatter)&lt;/td&gt;
&lt;td&gt;✅ 지원&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XDDFScatterChartData&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;도넛(Doughnut)&lt;/td&gt;
&lt;td&gt;❌ 미지원&lt;/td&gt;
&lt;td&gt;XML 직접 제어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;거품형(Bubble)&lt;/td&gt;
&lt;td&gt;❌ 미지원&lt;/td&gt;
&lt;td&gt;XML 직접 제어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;방사형(Radar)&lt;/td&gt;
&lt;td&gt;❌ 미지원&lt;/td&gt;
&lt;td&gt;XML 직접 제어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;주식형(Stock)&lt;/td&gt;
&lt;td&gt;❌ 미지원&lt;/td&gt;
&lt;td&gt;XML 직접 제어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;혼합형(Combo)&lt;/td&gt;
&lt;td&gt;❌ 미지원&lt;/td&gt;
&lt;td&gt;XML 직접 제어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미지원 유형이 꽤 많습니다. 도넛처럼 자주 쓰는 것도 직접 XML을 다뤄야 하는 상황이라, 복잡한 차트 요구사항이 있다면 다음 대안도 고려해보세요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;복잡한-차트-현실적인-대안들&quot; data-ke-size=&quot;size26&quot;&gt;8. 복잡한 차트, 현실적인 대안들&lt;/h2&gt;
&lt;h3 id=&quot;대안-1-템플릿-파일-활용&quot; data-ke-size=&quot;size23&quot;&gt;대안 1: 템플릿 파일 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈에서 여러 번 언급했는데, 차트 스타일이 복잡할수록 템플릿 방식이 훨씬 현실적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엑셀에서 원하는 차트를 완벽하게 만들어두고, 데이터 범위만 연결해두면 POI에서 데이터만 업데이트해도 차트가 자동으로 갱신됩니다.&lt;/p&gt;
&lt;div id=&quot;cb11&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 템플릿에 이미 완성된 차트가 있고
// 데이터만 업데이트하면 차트가 자동 갱신
try (InputStream template = getClass().getResourceAsStream(&quot;/templates/chart.xlsx&quot;);
     XSSFWorkbook workbook = new XSSFWorkbook(template)) {

    XSSFSheet sheet = workbook.getSheetAt(0);

    for (int i = 0; i &amp;lt; data.size(); i++) {
        XSSFRow row = sheet.getRow(i + 1);
        if (row == null) row = sheet.createRow(i + 1);
        row.createCell(0).setCellValue(data.get(i).getCategory());
        row.createCell(1).setCellValue(data.get(i).getValue());
    }

    workbook.write(response.getOutputStream());
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;대안-2-echartschart.js로-이미지-생성-후-삽입&quot; data-ke-size=&quot;size23&quot;&gt;대안 2: ECharts/Chart.js로 이미지 생성 후 삽입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 차트를 PNG 이미지로 렌더링하고, 그 이미지를 엑셀에 삽입하는 방법입니다. 차트 자유도가 훨씬 높아요.&lt;/p&gt;
&lt;div id=&quot;cb12&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 1. 이미지 생성 (외부 라이브러리 활용)
byte[] chartImageBytes = chartRenderer.renderAsPNG(chartConfig);

// 2. 엑셀에 이미지 삽입
int pictureIdx = workbook.addPicture(chartImageBytes, Workbook.PICTURE_TYPE_PNG);
CreationHelper helper = workbook.getCreationHelper();
ClientAnchor anchor = helper.createClientAnchor();
anchor.setCol1(3); anchor.setRow1(0);
anchor.setCol2(12); anchor.setRow2(20);

Drawing&amp;lt;?&amp;gt; drawing = sheet.createDrawingPatriarch();
drawing.createPicture(anchor, pictureIdx);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엑셀 내장 차트는 아니지만, 이미지라서 어떤 형태든 표현이 가능하다는 장점이 있습니다.&lt;/p&gt;
&lt;h3 id=&quot;대안-3-jfreechart&quot; data-ke-size=&quot;size23&quot;&gt;대안 3: JFreeChart&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 환경에서 차트 이미지를 생성하는 라이브러리입니다. 차트 유형과 스타일 지원이 POI보다 훨씬 풍부해요.&lt;/p&gt;
&lt;div id=&quot;cb13&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// JFreeChart로 차트 생성 후 이미지로 변환
JFreeChart chart = ChartFactory.createPieChart(
    &quot;카테고리별 비율&quot;, dataset, true, true, false);

// 이미지로 변환
byte[] imageBytes = ChartUtils.encodeAsPNG(chart.createBufferedImage(800, 600));

// POI로 엑셀에 삽입
int pictureIdx = workbook.addPicture(imageBytes, Workbook.PICTURE_TYPE_PNG);
// ... 앵커 설정 후 삽입&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;주의사항&quot; data-ke-size=&quot;size26&quot;&gt;9. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;차트-만들고-나서-꼭-열어서-확인하세요&quot; data-ke-size=&quot;size23&quot;&gt;차트 만들고 나서 꼭 열어서 확인하세요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI로 만든 차트는 코드에서 오류 없이 돌아도 엑셀을 실제로 열었을 때 깨지거나 비어있는 경우가 있습니다. 특히 저수준 XML을 건드렸을 때 이런 일이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Excel에서 파일 열고 &amp;rarr; 파일 &amp;rarr; 정보 &amp;rarr; 문제 확인을 눌러보면 어떤 부분이 손상됐는지 알 수 있어요.&lt;/p&gt;
&lt;h3 id=&quot;엑셀-버전별로-차트-렌더링이-다를-수-있습니다&quot; data-ke-size=&quot;size23&quot;&gt;엑셀 버전별로 차트 렌더링이 다를 수 있습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LibreOffice, Google Sheets에서 열면 MS Excel과 차트가 다르게 보이는 경우가 있습니다. 특히 저수준 XML로 스타일을 건드렸을 때 호환성 문제가 생기기 쉬워요. 최종 결과물을 MS Excel에서 꼭 확인해보세요.&lt;/p&gt;
&lt;h3 id=&quot;poi-버전마다-차트-api가-조금씩-다릅니다&quot; data-ke-size=&quot;size23&quot;&gt;POI 버전마다 차트 API가 조금씩 다릅니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터넷에서 찾은 POI 차트 예제 코드가 안 되는 경우, 버전이 다른 경우가 많아요. 이 시리즈는 5.3.0 기준이지만, 구버전(3.x, 4.x) 코드는 메서드 이름이나 패키지가 다를 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;시리즈-전체-정리&quot; data-ke-size=&quot;size26&quot;&gt;10. 시리즈 전체 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6편에 걸쳐 다룬 내용을 한눈에 정리합니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;&lt;colgroup&gt; &lt;col style=&quot;width: 16%;&quot; /&gt; &lt;col style=&quot;width: 37%;&quot; /&gt; &lt;col style=&quot;width: 45%;&quot; /&gt; &lt;/colgroup&gt;
&lt;thead&gt;
&lt;tr class=&quot;header&quot;&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 class=&quot;odd&quot;&gt;
&lt;td&gt;1편&lt;/td&gt;
&lt;td&gt;엑셀 읽기, 셀 타입 처리&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XSSFWorkbook&lt;/code&gt;, &lt;code&gt;WorkbookFactory&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;2편&lt;/td&gt;
&lt;td&gt;엑셀 쓰기, 스타일/포맷/다운로드&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CellStyle&lt;/code&gt;, &lt;code&gt;DataFormat&lt;/code&gt;, &lt;code&gt;SXSSFWorkbook&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;3편&lt;/td&gt;
&lt;td&gt;대용량 읽기, 스트리밍 파싱&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SheetContentsHandler&lt;/code&gt;, &lt;code&gt;XSSFSheetXMLHandler&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;4편&lt;/td&gt;
&lt;td&gt;대용량 쓰기, 슬라이딩 윈도우&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SXSSFWorkbook&lt;/code&gt;, &lt;code&gt;dispose()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;odd&quot;&gt;
&lt;td&gt;5편&lt;/td&gt;
&lt;td&gt;바/꺾은선 차트&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XDDFBarChartData&lt;/code&gt;, &lt;code&gt;XDDFLineChartData&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;even&quot;&gt;
&lt;td&gt;6편&lt;/td&gt;
&lt;td&gt;파이/분산형/영역 차트, XML 제어&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XDDFPieChartData&lt;/code&gt;, &lt;code&gt;CTPlotArea&lt;/code&gt;, &lt;code&gt;CT*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apache POI가 처음에는 진입 장벽이 있지만, 구조를 이해하고 나면 패턴이 반복됩니다. 이 시리즈가 실무에서 엑셀 기능을 구현할 때 도움이 됐으면 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt; &lt;br /&gt;1. 엑셀 읽기 &lt;br /&gt;2. 엑셀 쓰기 &lt;br /&gt;3. 대용량 엑셀 읽기 (SAX / Event API) &lt;br /&gt;4. 대용량 엑셀 쓰기 (SXSSFWorkbook) &lt;br /&gt;5. 차트 삽입하기 &amp;mdash; 바(Bar) &amp;middot; 꺾은선(Line) &lt;br /&gt;6. &lt;b&gt;차트 삽입하기 &amp;mdash; 그 외 차트 유형&lt;/b&gt; &amp;larr; 현재 글&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/article&gt;</description>
      <category>Backend/Java</category>
      <category>ApachePOI</category>
      <category>backend</category>
      <category>Excel</category>
      <category>java</category>
      <category>springboot</category>
      <category>xlsx</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/7</guid>
      <comments>https://tech-vault-lior.tistory.com/7#entry7comment</comments>
      <pubDate>Mon, 25 May 2026 16:49:13 +0900</pubDate>
    </item>
    <item>
      <title>Apache POI로 엑셀 차트 만들기 &amp;mdash; Bar&amp;middot;Line 차트</title>
      <link>https://tech-vault-lior.tistory.com/6</link>
      <description>&lt;article class=&quot;poi-post&quot;&gt;
&lt;section class=&quot;poi-hero&quot;&gt;
&lt;div class=&quot;eyebrow&quot;&gt;Spring Boot &amp;times; Apache POI 실무 시리즈 05/6&lt;/div&gt;
&lt;h1&gt;Apache POI로 엑셀 차트 만들기 &amp;mdash; Bar&amp;middot;Line 차트&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 자주 만나는 Excel 처리 요구사항을 Spring Boot와 Apache POI 기준으로 정리한 시리즈입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;div class=&quot;series-box&quot;&gt;
&lt;p class=&quot;series-title&quot; data-ke-size=&quot;size16&quot;&gt;Apache POI 실무 시리즈&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;엑셀 읽기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;엑셀 쓰기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;대용량 엑셀 읽기&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;대용량 엑셀 쓰기&lt;/li&gt;
&lt;li class=&quot;current&quot;&gt;&amp;nbsp;차트 Bar/Line &amp;larr; 현재 글&lt;/li&gt;
&lt;li class=&quot;&quot;&gt;&amp;nbsp;기타 차트/XML 제어&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;poi-summary&quot;&gt;&lt;b&gt;이 글에서 다루는 내용&lt;/b&gt;&lt;br /&gt;차트 삽입하기 Bar/Line를 Spring Boot 프로젝트에서 실무적으로 적용하는 방법, 자주 발생하는 함정, 구조화 패턴을 함께 정리합니다.&lt;/div&gt;
&lt;figure class=&quot;poi-diagram&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;post-05-diagram.png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;260&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUAmMw/dJMcadB2j6G/qK3Z6wb5WnEe5eKKAuXB81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUAmMw/dJMcadB2j6G/qK3Z6wb5WnEe5eKKAuXB81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUAmMw/dJMcadB2j6G/qK3Z6wb5WnEe5eKKAuXB81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUAmMw%2FdJMcadB2j6G%2FqK3Z6wb5WnEe5eKKAuXB81%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;1000&quot; height=&quot;260&quot; data-filename=&quot;post-05-diagram.png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;260&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;figure class=&quot;poi-diagram&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figcaption&gt;Apache POI로 엑셀 차트 만들기 &amp;mdash; Bar&amp;middot;Line 차트 흐름 요약&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;div class=&quot;info-box&quot;&gt;이 글은 예제 코드보다 실무에서 놓치기 쉬운 포인트를 중심으로 정리했습니다. 필요한 코드는 그대로 복사해서 프로젝트 구조에 맞게 분리해 사용하면 됩니다.&lt;/div&gt;
&lt;h2 id=&quot;소개&quot; data-ke-size=&quot;size26&quot;&gt;1. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI로 차트를 만드는 건 솔직히 좀 번거롭습니다. 스타일이나 데이터 쓰기보다 훨씬 많은 객체를 다뤄야 하고, 차트 관련 API가 굉장히 저수준이라 처음 보면 어디서부터 시작해야 할지 막막하기도 해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 구조를 한 번 파악하고 나면 반복 패턴이 있어서 익숙해집니다. 이번 편에서는 &lt;b&gt;바(Bar) 차트와 꺾은선(Line) 차트&lt;/b&gt;를 중심으로 차트 삽입의 전체 흐름을 잡아드릴게요.&lt;/p&gt;
&lt;h3 id=&quot;차트-관련-핵심-클래스-구조&quot; data-ke-size=&quot;size23&quot;&gt;차트 관련 핵심 클래스 구조&lt;/h3&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;XSSFChart              &amp;rarr; 차트 객체 자체
  XSSFChartLegend      &amp;rarr; 범례
  XDDFCategoryAxis     &amp;rarr; 카테고리(X) 축
  XDDFValueAxis        &amp;rarr; 값(Y) 축
  XDDFDataSource       &amp;rarr; 데이터 범위 (XDDFDataSourcesFactory)
  XDDFBarChartData     &amp;rarr; 바 차트 데이터
  XDDFLineChartData    &amp;rarr; 꺾은선 차트 데이터&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차트를 만들 때는 이 객체들을 조립하는 흐름입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;기본-개념&quot; data-ke-size=&quot;size26&quot;&gt;2. 기본 개념&lt;/h2&gt;
&lt;h3 id=&quot;차트-생성-전체-흐름&quot; data-ke-size=&quot;size23&quot;&gt;차트 생성 전체 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI에서 차트를 만드는 순서는 항상 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 데이터를 시트에 먼저 씁니다
2. 차트를 그릴 영역(Drawing)을 잡습니다
3. 앵커(차트 위치/크기)를 설정합니다
4. 차트 객체를 생성합니다
5. 축(카테고리축, 값축)을 설정합니다
6. 데이터 시리즈를 연결합니다
7. 차트를 그립니다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭔가 많아 보이지만, 3~4번까지는 거의 똑같이 쓰이는 코드라 템플릿화해두면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;바bar-차트-만들기&quot; data-ke-size=&quot;size26&quot;&gt;3. 바(Bar) 차트 만들기&lt;/h2&gt;
&lt;h3 id=&quot;데이터-준비&quot; data-ke-size=&quot;size23&quot;&gt;데이터 준비&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차트는 시트의 데이터를 참조하는 방식입니다. 먼저 시트에 데이터를 쓰고, 그 셀 범위를 차트에 연결합니다.&lt;/p&gt;
&lt;div id=&quot;cb3&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 시트에 데이터를 먼저 씁니다
Sheet sheet = workbook.createSheet(&quot;차트&quot;);

// 헤더
Row header = sheet.createRow(0);
header.createCell(0).setCellValue(&quot;월&quot;);
header.createCell(1).setCellValue(&quot;매출&quot;);

// 데이터
String[] months = {&quot;1월&quot;, &quot;2월&quot;, &quot;3월&quot;, &quot;4월&quot;, &quot;5월&quot;, &quot;6월&quot;};
int[] sales    = {1200000, 1500000, 1100000, 1800000, 2100000, 1700000};

for (int i = 0; i &amp;lt; months.length; i++) {
    Row row = sheet.createRow(i + 1);
    row.createCell(0).setCellValue(months[i]);
    row.createCell(1).setCellValue(sales[i]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;바-차트-생성-전체-코드&quot; data-ke-size=&quot;size23&quot;&gt;바 차트 생성 전체 코드&lt;/h3&gt;
&lt;div id=&quot;cb4&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;public void createBarChart(Sheet sheet, int dataRows) {

    XSSFSheet xssfSheet = (XSSFSheet) sheet;

    // 1. Drawing 영역 &amp;mdash; 차트가 그려지는 캔버스
    XSSFDrawing drawing = xssfSheet.createDrawingPatriarch();

    // 2. 앵커 &amp;mdash; 차트의 위치와 크기 지정
    // createAnchor(dx1, dy1, dx2, dy2, col1, row1, col2, row2)
    // col, row는 셀 기준 좌표
    XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 12, 20);

    // 3. 차트 객체 생성
    XSSFChart chart = drawing.createChart(anchor);
    chart.setTitleText(&quot;월별 매출&quot;);
    chart.setTitleOverlay(false);

    // 4. 범례 설정
    XDDFChartLegend legend = chart.getOrAddLegend();
    legend.setPosition(LegendPosition.BOTTOM);

    // 5. 축 설정 (XDDF: chart에서 직접 생성)
    XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM);
    XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
    leftAxis.setCrosses(AxisCrosses.AUTO_ZERO);

    // 6. 데이터 소스 &amp;mdash; 셀 범위 참조
    XDDFDataSource&amp;lt;String&amp;gt; categories = XDDFDataSourcesFactory.fromStringCellRange(
        xssfSheet, new CellRangeAddress(1, dataRows, 0, 0)); // 월

    XDDFNumericalDataSource&amp;lt;Double&amp;gt; values = XDDFDataSourcesFactory.fromNumericCellRange(
        xssfSheet, new CellRangeAddress(1, dataRows, 1, 1)); // 매출

    // 7. 차트 데이터 생성 (XDDF: createData + ChartTypes)
    XDDFBarChartData data = (XDDFBarChartData) chart.createData(
        ChartTypes.BAR, bottomAxis, leftAxis);
    data.setBarDirection(BarDirection.COL); // 세로 막대 (Column)

    // 8. 시리즈 추가
    XDDFBarChartData.Series series =
        (XDDFBarChartData.Series) data.addSeries(categories, values);
    series.setTitle(&quot;매출&quot;, null);

    // 9. 차트 그리기
    chart.plot(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;가로-세로-방향-전환&quot; data-ke-size=&quot;size23&quot;&gt;가로 / 세로 방향 전환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI의 바 차트 기본값이 가로 막대(Horizontal)라서, 우리가 흔히 쓰는 세로 막대(Column)로 만들려면 방향을 명시해야 합니다.&lt;/p&gt;
&lt;div id=&quot;cb5&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;XDDFBarChartData data = (XDDFBarChartData) chart.createData(
    ChartTypes.BAR, bottomAxis, leftAxis);

// 세로 막대 (Column Chart)
data.setBarDirection(BarDirection.COL);

// 가로 막대 (Bar Chart)
data.setBarDirection(BarDirection.BAR);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;전체-사용-예&quot; data-ke-size=&quot;size23&quot;&gt;전체 사용 예&lt;/h3&gt;
&lt;div id=&quot;cb6&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;@Component
public class BarChartExcelWriter {

    public Workbook createSalesReport() {
        Workbook workbook = new XSSFWorkbook();
        Sheet sheet = workbook.createSheet(&quot;월별매출&quot;);

        // 데이터 작성
        writeData(sheet);

        // 차트 삽입 (6행의 데이터)
        createBarChart(sheet, 6);

        return workbook;
    }

    private void writeData(Sheet sheet) {
        Row header = sheet.createRow(0);
        header.createCell(0).setCellValue(&quot;월&quot;);
        header.createCell(1).setCellValue(&quot;매출&quot;);

        String[] months = {&quot;1월&quot;, &quot;2월&quot;, &quot;3월&quot;, &quot;4월&quot;, &quot;5월&quot;, &quot;6월&quot;};
        int[] sales = {1200000, 1500000, 1100000, 1800000, 2100000, 1700000};

        for (int i = 0; i &amp;lt; months.length; i++) {
            Row row = sheet.createRow(i + 1);
            row.createCell(0).setCellValue(months[i]);
            row.createCell(1).setCellValue(sales[i]);
        }
    }

    public void createBarChart(Sheet sheet, int dataRows) {
        XSSFSheet xssfSheet = (XSSFSheet) sheet;
        XSSFDrawing drawing = xssfSheet.createDrawingPatriarch();
        XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 12, 20);

        XSSFChart chart = drawing.createChart(anchor);
        chart.setTitleText(&quot;월별 매출&quot;);
        chart.setTitleOverlay(false);

        XDDFChartLegend legend = chart.getOrAddLegend();
        legend.setPosition(LegendPosition.BOTTOM);

        XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM);
        XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
        leftAxis.setCrosses(AxisCrosses.AUTO_ZERO);

        XDDFDataSource&amp;lt;String&amp;gt; categories = XDDFDataSourcesFactory.fromStringCellRange(
            xssfSheet, new CellRangeAddress(1, dataRows, 0, 0));
        XDDFNumericalDataSource&amp;lt;Double&amp;gt; values = XDDFDataSourcesFactory.fromNumericCellRange(
            xssfSheet, new CellRangeAddress(1, dataRows, 1, 1));

        XDDFBarChartData data = (XDDFBarChartData) chart.createData(
            ChartTypes.BAR, bottomAxis, leftAxis);
        data.setBarDirection(BarDirection.COL);

        XDDFBarChartData.Series series =
            (XDDFBarChartData.Series) data.addSeries(categories, values);
        series.setTitle(&quot;매출&quot;, null);

        chart.plot(data);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;꺾은선line-차트-만들기&quot; data-ke-size=&quot;size26&quot;&gt;4. 꺾은선(Line) 차트 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바 차트 구조를 이해했다면 꺾은선 차트는 거의 동일합니다. &lt;code&gt;XDDFBarChartData&lt;/code&gt; &amp;rarr; &lt;code&gt;XDDFLineChartData&lt;/code&gt;, &lt;code&gt;BarChartSeries&lt;/code&gt; &amp;rarr; &lt;code&gt;LineChartSeries&lt;/code&gt;로 바뀌는 게 핵심이에요.&lt;/p&gt;
&lt;h3 id=&quot;단일-시리즈-꺾은선-차트&quot; data-ke-size=&quot;size23&quot;&gt;단일 시리즈 꺾은선 차트&lt;/h3&gt;
&lt;div id=&quot;cb7&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;private void createLineChart(Sheet sheet, int dataRows) {

    XSSFSheet xssfSheet = (XSSFSheet) sheet;
    XSSFDrawing drawing = xssfSheet.createDrawingPatriarch();
    XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 0, 14, 22);

    XSSFChart chart = drawing.createChart(anchor);
    chart.setTitleText(&quot;월별 방문자&quot;);
    chart.setTitleOverlay(false);

    XDDFChartLegend legend = chart.getOrAddLegend();
    legend.setPosition(LegendPosition.BOTTOM);

    XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM);
    XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
    leftAxis.setCrosses(AxisCrosses.AUTO_ZERO);

    XDDFDataSource&amp;lt;String&amp;gt; categories = XDDFDataSourcesFactory.fromStringCellRange(
        xssfSheet, new CellRangeAddress(1, dataRows, 0, 0));
    XDDFNumericalDataSource&amp;lt;Double&amp;gt; values = XDDFDataSourcesFactory.fromNumericCellRange(
        xssfSheet, new CellRangeAddress(1, dataRows, 1, 1));

    // BarChartData 대신 XDDFLineChartData
    XDDFLineChartData data = (XDDFLineChartData) chart.createData(
        ChartTypes.LINE, bottomAxis, leftAxis);

    XDDFLineChartData.Series series =
        (XDDFLineChartData.Series) data.addSeries(categories, values);
    series.setTitle(&quot;방문자&quot;, null);
    series.setSmooth(false);
    series.setMarkerStyle(MarkerStyle.NONE);

    chart.plot(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;복수-시리즈-추가-여러-선&quot; data-ke-size=&quot;size23&quot;&gt;복수 시리즈 추가 (여러 선)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 여러 항목을 비교하는 차트가 많죠. 시리즈를 여러 개 추가하면 됩니다.&lt;/p&gt;
&lt;div id=&quot;cb8&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 데이터 예시
// 월  | PC방문자 | 모바일방문자
// 1월 | 5000    | 8000
// 2월 | 5500    | 9200

XDDFLineChartData data = (XDDFLineChartData) chart.createData(
    ChartTypes.LINE, bottomAxis, leftAxis);

XDDFDataSource&amp;lt;String&amp;gt; categories = XDDFDataSourcesFactory.fromStringCellRange(
    xssfSheet, new CellRangeAddress(1, dataRows, 0, 0)); // 월

// PC 방문자 시리즈 (B열)
XDDFNumericalDataSource&amp;lt;Double&amp;gt; pcValues = XDDFDataSourcesFactory.fromNumericCellRange(
    xssfSheet, new CellRangeAddress(1, dataRows, 1, 1));
XDDFLineChartData.Series pcSeries =
    (XDDFLineChartData.Series) data.addSeries(categories, pcValues);
pcSeries.setTitle(&quot;PC 방문자&quot;, null);
pcSeries.setSmooth(false);

// 모바일 방문자 시리즈 (C열)
XDDFNumericalDataSource&amp;lt;Double&amp;gt; mobileValues = XDDFDataSourcesFactory.fromNumericCellRange(
    xssfSheet, new CellRangeAddress(1, dataRows, 2, 2));
XDDFLineChartData.Series mobileSeries =
    (XDDFLineChartData.Series) data.addSeries(categories, mobileValues);
mobileSeries.setTitle(&quot;모바일 방문자&quot;, null);
mobileSeries.setSmooth(false);

chart.plot(data);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;앵커-좌표-이해하기&quot; data-ke-size=&quot;size26&quot;&gt;5. 앵커 좌표 이해하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차트를 원하는 위치에 정확히 배치하려면 앵커 좌표 개념을 알아야 합니다.&lt;/p&gt;
&lt;div id=&quot;cb9&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;XSSFClientAnchor anchor = drawing.createAnchor(
    dx1, dy1,   // 시작점의 셀 내 오프셋 (0~1023)
    dx2, dy2,   // 끝점의 셀 내 오프셋 (0~1023)
    col1, row1, // 시작 셀 (0-based)
    col2, row2  // 끝 셀 (0-based)
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말하면, &lt;code&gt;col1, row1&lt;/code&gt;이 차트의 좌상단 셀이고 &lt;code&gt;col2, row2&lt;/code&gt;가 우하단 셀입니다. &lt;code&gt;dx&lt;/code&gt;, &lt;code&gt;dy&lt;/code&gt;는 셀 안에서의 세밀한 오프셋인데, 보통 0으로 써도 됩니다.&lt;/p&gt;
&lt;div id=&quot;cb10&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 예: D열(3) 0행부터 M열(12) 20행까지 차트 배치
drawing.createAnchor(0, 0, 0, 0, 3, 0, 12, 20);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 A열~B열에 있을 때 차트를 옆(D열~M열)에 배치하는 패턴을 많이 씁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;주의사항&quot; data-ke-size=&quot;size26&quot;&gt;6. 주의사항&lt;/h2&gt;
&lt;h3 id=&quot;차트는-xssfworkbook에서만-됩니다&quot; data-ke-size=&quot;size23&quot;&gt;차트는 XSSFWorkbook에서만 됩니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;SXSSFWorkbook&lt;/code&gt;은 차트를 지원하지 않습니다. 차트가 필요하다면 &lt;code&gt;XSSFWorkbook&lt;/code&gt;을 써야 해요. 대용량 데이터 + 차트가 같이 필요한 경우라면 데이터 시트는 SAX로 읽어서 일반 &lt;code&gt;XSSFWorkbook&lt;/code&gt;에 채워 넣는 방식으로 우회해야 합니다.&lt;/p&gt;
&lt;h3 id=&quot;데이터가-시트에-있어야-차트가-작동합니다&quot; data-ke-size=&quot;size23&quot;&gt;데이터가 시트에 있어야 차트가 작동합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POI의 차트는 시트의 셀 범위를 참조합니다. 차트에 직접 데이터를 주입하는 방식이 아니라, 셀에 데이터가 있어야 엑셀을 열었을 때 차트가 제대로 보입니다.&lt;/p&gt;
&lt;div id=&quot;cb11&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 직접 값을 넣는 방식 &amp;mdash; POI 생성 시엔 보이지만
// 엑셀에서 열면 차트가 비어있을 수 있습니다 (비권장)
XDDFDataSourcesFactory.fromArray(new Double[]{1500000.0}); // 비권장

// 셀 범위를 참조하는 방식이 올바른 방법입니다 (권장)
XDDFNumericalDataSource&amp;lt;Double&amp;gt; values = XDDFDataSourcesFactory.fromNumericCellRange(
    sheet, new CellRangeAddress(1, 6, 1, 1)); // 권장&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;poi-차트-api는-저수준입니다&quot; data-ke-size=&quot;size23&quot;&gt;POI 차트 API는 저수준입니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말하면 POI의 차트 API는 직관적이지 않아요. 특히 세밀한 스타일 설정(선 색상, 마커 모양, 그리드 라인 등)은 &lt;code&gt;CT&lt;/code&gt; 접두사가 붙은 저수준 XML 객체를 직접 건드려야 합니다.&lt;/p&gt;
&lt;div id=&quot;cb12&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 예: 선 색상 변경 (저수준 API)
CTLineChart lineChart = chart.getCTChart().getPlotArea().getLineChartList().get(0);
CTLineSer ser = lineChart.getSerList().get(0);
CTShapeProperties spPr = ser.addNewSpPr();
CTSolidColorFillProperties fill = spPr.addNewLn().addNewSolidFill();
fill.addNewSrgbClr().setVal(new byte[]{(byte)0xFF, (byte)0x00, (byte)0x00}); // 빨간색&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분이 복잡하다면 6편에서 다루는 다른 접근 방식도 참고해보세요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;성능-구조-팁&quot; data-ke-size=&quot;size26&quot;&gt;7. 성능 / 구조 팁&lt;/h2&gt;
&lt;h3 id=&quot;차트-생성-로직은-별도-클래스로-분리하세요&quot; data-ke-size=&quot;size23&quot;&gt;차트 생성 로직은 별도 클래스로 분리하세요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차트 코드는 길어지기 마련이라, &lt;code&gt;ChartCreator&lt;/code&gt; 같은 별도 클래스로 분리해두면 관리가 훨씬 편합니다.&lt;/p&gt;
&lt;div id=&quot;cb13&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;@Component
public class ChartCreator {

    public void insertBarChart(XSSFSheet sheet, String title,
                               CellRangeAddress catRange,
                               CellRangeAddress valRange,
                               int col1, int row1, int col2, int row2) {

        XSSFDrawing drawing = sheet.createDrawingPatriarch();
        XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, col1, row1, col2, row2);

        XSSFChart chart = drawing.createChart(anchor);
        chart.setTitleText(title);
        chart.setTitleOverlay(false);

        XDDFChartLegend legend = chart.getOrAddLegend();
        legend.setPosition(LegendPosition.BOTTOM);

        XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM);
        XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
        leftAxis.setCrosses(AxisCrosses.AUTO_ZERO);

        XDDFDataSource&amp;lt;String&amp;gt; categories =
            XDDFDataSourcesFactory.fromStringCellRange(sheet, catRange);
        XDDFNumericalDataSource&amp;lt;Double&amp;gt; values =
            XDDFDataSourcesFactory.fromNumericCellRange(sheet, valRange);

        XDDFBarChartData data = (XDDFBarChartData) chart.createData(
            ChartTypes.BAR, bottomAxis, leftAxis);
        data.setBarDirection(BarDirection.COL);

        XDDFBarChartData.Series series =
            (XDDFBarChartData.Series) data.addSeries(categories, values);
        series.setTitle(title, null);

        chart.plot(data);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 id=&quot;템플릿-방식이-더-편할-수-있습니다&quot; data-ke-size=&quot;size23&quot;&gt;템플릿 방식이 더 편할 수 있습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차트 스타일을 POI 코드로 세밀하게 제어하는 건 생각보다 많이 번거롭습니다. 실무에서는 미리 차트 포맷을 잡아둔 &lt;code&gt;.xlsx&lt;/code&gt; 템플릿 파일을 열고, 데이터만 채워 넣는 방식이 오히려 빠를 때가 많아요.&lt;/p&gt;
&lt;div id=&quot;cb14&quot; class=&quot;sourceCode&quot;&gt;
&lt;pre class=&quot;sourceCode java&quot;&gt;&lt;code&gt;// 템플릿 파일에 이미 차트가 있고, 데이터만 업데이트
try (InputStream is = getClass().getResourceAsStream(&quot;/templates/chart-template.xlsx&quot;);
     Workbook workbook = new XSSFWorkbook(is)) {

    Sheet sheet = workbook.getSheetAt(0);

    // 데이터만 덮어씁니다 &amp;mdash; 차트는 자동으로 업데이트됩니다
    for (int i = 0; i &amp;lt; salesData.size(); i++) {
        Row row = sheet.getRow(i + 1);
        row.getCell(1).setCellValue(salesData.get(i));
    }

    workbook.write(response.getOutputStream());
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엑셀에서 직접 만든 예쁜 차트 스타일 그대로 내려줄 수 있어서 디자인 완성도가 훨씬 높아집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;정리&quot; data-ke-size=&quot;size26&quot;&gt;8. 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편 핵심 정리입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;차트 생성 순서: 데이터 쓰기 &amp;rarr; Drawing 영역 &amp;rarr; 앵커 &amp;rarr; 차트 객체 &amp;rarr; 축 &amp;rarr; 데이터 시리즈 &amp;rarr; plot&lt;/li&gt;
&lt;li&gt;바 차트는 &lt;code&gt;XDDFBarChartData&lt;/code&gt;, 꺾은선은 &lt;code&gt;XDDFLineChartData&lt;/code&gt;를 쓰고 나머지 흐름은 동일합니다&lt;/li&gt;
&lt;li&gt;세로 막대 차트로 만들려면 &lt;code&gt;STBarDir.COL&lt;/code&gt;을 명시해야 합니다&lt;/li&gt;
&lt;li&gt;여러 시리즈는 &lt;code&gt;addSeries()&lt;/code&gt;를 여러 번 호출하면 됩니다&lt;/li&gt;
&lt;li&gt;차트는 &lt;code&gt;XSSFWorkbook&lt;/code&gt;에서만 되고 &lt;code&gt;SXSSFWorkbook&lt;/code&gt;은 안 됩니다&lt;/li&gt;
&lt;li&gt;세밀한 스타일 조정이 필요하면 템플릿 방식이 더 현실적입니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편은 &lt;b&gt;그 외 차트 유형&lt;/b&gt;입니다. 파이(Pie), 도넛(Doughnut), 영역(Area), 분산형(Scatter) 차트와 함께, 차트 XML을 직접 다루는 방법도 정리합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시리즈 전체 목차&lt;/b&gt; &lt;br/&gt;1. 엑셀 읽기 &lt;br/&gt;2. 엑셀 쓰기 &lt;br/&gt;3. 대용량 엑셀 읽기 (SAX / Event API) &lt;br/&gt;4. 대용량 엑셀 쓰기 (SXSSFWorkbook) &lt;br/&gt;5. &lt;b&gt;차트 삽입하기 &amp;mdash; 바(Bar) &amp;middot; 꺾은선(Line)&lt;/b&gt; &amp;larr; 현재 글 &lt;br/&gt;6. 차트 삽입하기 &amp;mdash; 그 외 차트 유형&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class=&quot;next-post&quot;&gt;&lt;b&gt;다음 글&lt;/b&gt;&lt;br /&gt;다음 편에서는 기타 차트/XML 제어를 다룹니다.&lt;/div&gt;
&lt;/article&gt;</description>
      <category>Backend/Java</category>
      <category>ApachePOI</category>
      <category>backend</category>
      <category>Excel</category>
      <category>java</category>
      <category>springboot</category>
      <category>xlsx</category>
      <author>Lior</author>
      <guid isPermaLink="true">https://tech-vault-lior.tistory.com/6</guid>
      <comments>https://tech-vault-lior.tistory.com/6#entry6comment</comments>
      <pubDate>Sun, 24 May 2026 13:54:17 +0900</pubDate>
    </item>
  </channel>
</rss>