Skip to content

Chapter 09. 상태는 언제 컴포넌트를 떠나야 하는가? (Signals Store 철학) ​

"화면을 이동했다가 뒤로 돌아왔을 때, 왜 매번 0.5초 로딩 스피너가 돌며 백엔드 API를 다시 찔러야 할까? 무거운 Redux 보일러플레이트 없이 Angular Signals만으로 캡슐화된 전역 캐시 스토어를 설계하자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
상태의 3대 분류로컬 UI 신호 vs 라우트 쿼리 상태 vs 전역 도메인 스토어9.2
@Injectable({ providedIn: 'root' })앱 전체에서 단 하나의 인스턴스만 공유되는 싱글톤 스토어 등록9.3 🆕 박스
private WritableSignal + .asReadonly()스토어 외부에서 .set()으로 상태를 임의 조작하지 못하게 잠그는 캡슐화 패턴9.3 [코드 9-1]
쿼리 키(Query Key) & CACHE_TTL_MS동일한 검색·페이지 조건일 때 1분간 네트워크 호출을 건너뛰는 캐싱 전략9.3 [코드 9-1]
@ngrx/signals (signalStore)대규모 팀을 위한 선언적 SignalStore 생태계 비교9.4 🔬 심화 박스

TIP

🏁 이 장의 시작 파일
frontend/src/app/board-app/board-app.component.ts가 8장 완료 상태(snapshots/board-app.ch08.component.ts)에서 시작합니다.
이 장에서 작성·수정하는 파일:

  • frontend/src/app/stores/post.store.ts (전역 게시글 캐싱 스토어)
  • frontend/src/app/board-app/board-app.component.ts (로컬 신호 제거 및 PostStore 주입 연동)

9.1 로컬 상태의 한계와 NgRx 보일러플레이트의 종말 ​

8장까지 작성한 BoardAppComponent 안에는 다음과 같은 로컬 신호들이 들어 있었습니다:

typescript
// BoardAppComponent 내부의 로컬 상태
readonly posts = signal<PostResponse[]>([]);
readonly isLoading = signal(false);

이 신호들은 BoardAppComponent가 화면에 마운트될 때 태어나고, 다른 라우트(예: /login)로 이동해 컴포넌트가 파괴되면 메모리에서 흔적도 없이 사라집니다. 그 결과 두 가지 심각한 문제가 발생합니다:

  1. 불필요한 네트워크 재호출과 깜빡임: 다른 화면에 갔다가 3초 만에 뒤로 가기로 돌아와도 API를 처음부터 다시 호출합니다.
  2. 상태 공유 불가: 상단 GNB 헤더나 사이드바 위젯에서 "현재 인기 게시글 Top 3"를 보여주고 싶어도 데이터가 BoardAppComponent 안에 갇혀 있어 읽을 수 없습니다.

과거 Angular에서는 이를 해결하려고 NgRx(Redux)를 도입해 숫자 하나 전역으로 올리는 데 actions.ts, reducer.ts, effects.ts, selectors.ts 4개 파일을 만들곤 했습니다. 하지만 Angular 22에서는 싱글톤 서비스에 캡슐화된 Signals를 얹는 것만으로 완벽한 반응형 전역 스토어가 완성됩니다.


9.2 상태 분류 3단계 판별 매트릭스 ​

그렇다고 모든 변수를 전역 스토어로 올리면 스토어가 쓰레기통이 됩니다. 상태를 어디에 둘지 결정하는 기준은 다음과 같습니다:

상태 유형관리 위치BoardAppComponent 적용 예시
1. 로컬 UI 상태컴포넌트 내부 signal()모달 열림 여부(isCreateModalOpen), 입력 중인 임시 텍스트
2. 라우트 상태URL Query (ActivatedRoute)검색 키워드, 선택된 카테고리, 현재 페이지 번호 (링크 공유 대상)
3. 전역 도메인 상태Signals Store (PostStore)게시글 목록 캐시, 전체 게시글 수, 로그인 사용자 세션

☕ 비유: 내 바지 주머니(signal) vs 회사 탕비실 냉장고(PostStore)
내가 지금 씹다 만 껌이나 먹던 빵(모달 열림 토글, 인풋 포커스 상태)까지 회사 공용 탕비실 냉장고(PostStore)에 넣어두면 냉장고가 순식간에 난장판이 됩니다.
탕비실 냉장고에는 전 부서원이 함께 마실 음료수와 다과(서버에서 가져온 게시글 캐시, 로그인 세션 정보)만 보관해야 깨끗합니다. 나 혼자 잠깐 쓰고 버릴 것은 내 바지 주머니(컴포넌트 로컬 signal)에 두십시오!


9.3 [실습 1] 캡슐화된 전역 PostStore 구축 (post.store.ts) ​

전역 스토어를 설계할 때 가장 중요한 원칙은 **"읽기(Read)는 누구에게나 열어두되, 쓰기(Write)는 스토어의 액션 메서드를 통해서만 허용한다"**는 캡슐화입니다. 만약 스토어의 posts = signal<PostResponse[]>([])를 public으로 노출하면, 어느 컴포넌트에서나 store.posts.set([])을 호출해 전역 캐시를 망가뜨릴 수 있습니다.

따라서 내부에는 private readonly _posts = signal<PostResponse[]>([])를 두고, 외부에는 읽기 전용 신호인 **readonly posts = this._posts.asReadonly()**만 공개합니다!

[코드 9-1] 전역 게시글 스토어 (frontend/src/app/stores/post.store.ts) ​

typescript
// frontend/src/app/stores/post.store.ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
  PostResponse,
  CreatePostRequest,
  isPageResponse,
  isPostResponse
} from '../models/post.model';

export interface FetchPostsParams {
  page?: number;
  size?: number;
  category?: string;
  keyword?: string;
}

// 1분(60,000ms) 동안 동일 조건의 목록 캐시 유지
const CACHE_TTL_MS = 60_000;

@Injectable({ providedIn: 'root' })
export class PostStore {
  private readonly http = inject(HttpClient);

  // 🌟 1. Private Writable Signals: 스토어 내부에서만 값 변경(.set / .update) 가능!
  private readonly _posts = signal<PostResponse[]>([]);
  private readonly _totalElements = signal<number>(0);
  private readonly _loading = signal<boolean>(false);
  private readonly _error = signal<string | null>(null);
  private readonly _lastFetchedAt = signal<number | null>(null);
  private readonly _lastQueryKey = signal<string>('');

  // 🌟 2. Public Readonly Signals: 외부 컴포넌트는 읽기()만 가능!
  readonly posts = this._posts.asReadonly();
  readonly totalElements = this._totalElements.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();
  readonly lastFetchedAt = this._lastFetchedAt.asReadonly();

  // 🌟 3. Computed Getters: 파생 상태 (좋아요 순 인기글 Top 3)
  readonly topPopularPosts = computed(() =>
    [...this._posts()]
      .sort((a, b) => b.likeCount - a.likeCount)
      .slice(0, 3)
  );

  // 🌟 4. Actions: 쿼리 키 + 1분 TTL 캐싱이 적용된 목록 조회
  async fetchPosts(params: FetchPostsParams = {}, force = false): Promise<void> {
    const normalized = {
      page: params.page ?? 1,
      size: params.size ?? 10,
      category: params.category ?? 'ALL',
      keyword: (params.keyword ?? '').trim()
    };

    const queryKey = JSON.stringify(normalized);
    const now = Date.now();
    const fetchedAt = this._lastFetchedAt();

    const isCacheValid =
      !force &&
      fetchedAt !== null &&
      now - fetchedAt < CACHE_TTL_MS &&
      this._lastQueryKey() === queryKey &&
      this._posts().length > 0;

    if (isCacheValid) {
      console.log('⚡ [PostStore Cache Hit] 1분 이내 동일 조건 캐시 즉시 반환:', queryKey);
      return;
    }

    this._loading.set(true);
    this._error.set(null);

    try {
      let httpParams = new HttpParams()
        .set('page', String(normalized.page))
        .set('size', String(normalized.size));

      if (normalized.category !== 'ALL') {
        httpParams = httpParams.set('category', normalized.category);
      }
      if (normalized.keyword) {
        httpParams = httpParams.set('keyword', normalized.keyword);
      }

      const raw = await firstValueFrom(
        this.http.get<unknown>('/api/posts', { params: httpParams })
      );

      if (isPageResponse(raw, isPostResponse)) {
        this._posts.set(raw.content);
        this._totalElements.set(raw.totalElements);
      } else if (Array.isArray(raw) && raw.every(isPostResponse)) {
        this._posts.set(raw);
        this._totalElements.set(raw.length);
      } else {
        throw new Error('서버 응답 형식이 PageResponse<PostResponse> 계약과 일치하지 않습니다.');
      }

      this._lastFetchedAt.set(Date.now());
      this._lastQueryKey.set(queryKey);
    } catch (err) {
      const message = err instanceof Error ? err.message : '게시글 목록을 불러오지 못했습니다.';
      this._error.set(message);
      throw err;
    } finally {
      this._loading.set(false);
    }
  }

  // 새 게시글 등록 액션 (성공 시 캐시를 강제 갱신하여 최신 목록 반영)
  async createPost(payload: CreatePostRequest): Promise<PostResponse> {
    const created = await firstValueFrom(
      this.http.post<PostResponse>('/api/posts', payload)
    );
    // 새 글이 등록되었으므로 캐시를 무효화하고 현재 조건으로 강제 재조회
    const currentParams: FetchPostsParams = this._lastQueryKey()
      ? JSON.parse(this._lastQueryKey())
      : { page: 1, size: 10 };
    await this.fetchPosts(currentParams, true);
    return created;
  }

  // 게시글 삭제 액션
  async removePost(postId: number): Promise<void> {
    await firstValueFrom(this.http.delete<void>(`/api/posts/${postId}`));
    this._posts.update((list) => list.filter((p) => p.id !== postId));
    this._totalElements.update((total) => Math.max(0, total - 1));
  }

  // 캐시 수동 초기화 액션
  clearCache(): void {
    this._lastFetchedAt.set(null);
    this._lastQueryKey.set('');
  }
}

🆕 처음 보는 문법: .asReadonly(), firstValueFrom, 그리고 @Injectable

  • this._posts.asReadonly(): WritableSignal<T>에서 .set()과 .update() 메서드를 제거한 순수 Signal<T> 뷰를 반환합니다. 컴포넌트에서 this.store.posts.set([])을 시도하면 컴파일 에러가 발생하여 단방향 데이터 흐름이 완벽히 보장됩니다.
  • firstValueFrom(observable$): RxJS Observable의 첫 번째 응답 값을 Promise로 변환해 async / await 문법과 매끄럽게 결합합니다. HTTP 단발성 요청을 스토어 액션에서 다룰 때 가독성이 크게 높아집니다.
  • @Injectable({ providedIn: 'root' }) (및 Angular 22 @Service 별칭): @angular/core의 표준 싱글톤 등록 데코레이터입니다. Angular 22에서는 도메인 서비스와 스토어의 의미를 명확히 드러내기 위해 @Injectable({ providedIn: 'root' })를 그대로 표준으로 사용하며, 별도 설정 없이도 트리 셰이킹(Tree-shaking) 대상이 됩니다.

9.4 [실습 2] BoardAppComponent에 PostStore 연결하기 ​

이제 BoardAppComponent에서 직접 HttpClient와 posts 신호를 들고 있던 코드를 걷어내고, 방금 만든 PostStore를 주입(inject(PostStore))받아 연결합니다. 상단에는 스토어의 topPopularPosts() computed 신호를 활용한 실시간 인기글 Top 3 배너도 함께 띄워 보겠습니다.

[코드 9-2] 스토어와 연결된 BoardAppComponent (frontend/src/app/board-app/board-app.component.ts) ​

typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PostResponse, CreatePostRequest, CategoryFilter } from '../models/post.model';
import { PostStore } from '../stores/post.store';
import { BoardTableComponent, TableColumn } from '../components/board-table.component';
import { PostCreateModalComponent } from '../components/post-create-modal.component';
import { PostDetailModalComponent } from '../components/post-detail-modal.component';

@Component({
  selector: 'app-board-app',
  standalone: true,
  imports: [
    FormsModule,
    BoardTableComponent,
    PostCreateModalComponent,
    PostDetailModalComponent
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="board-container">
      <header class="board-header">
        <div>
          <h2>사내 기술 Q&A 게시판 (Signals Store 캐싱)</h2>
          <span class="badge">총 {{ store.totalElements() }}건</span>
        </div>
        <div class="header-actions">
          <button type="button" class="btn-refresh" (click)="reloadForce()">
            🔄 새로고침 (캐시 무시)
          </button>
          <button type="button" class="btn-primary" (click)="isCreateModalOpen.set(true)">
            ✏️ 새 글 쓰기
          </button>
        </div>
      </header>

      <!-- 🌟 스토어의 computed 신호(topPopularPosts)를 활용한 인기글 배너 -->
      @if (store.topPopularPosts().length > 0) {
        <section class="popular-banner">
          <strong>🏆 실시간 인기 질문 TOP 3:</strong>
          @for (pop of store.topPopularPosts(); track pop.id) {
            <button type="button" class="pop-item" (click)="selectedPost.set(pop)">
              {{ pop.title }} (❤️ {{ pop.likeCount }})
            </button>
          }
        </section>
      }

      <!-- 검색 바 (로컬 UI 신호 바인딩) -->
      <section class="filter-bar">
        <select [ngModel]="selectedCategory()" (ngModelChange)="onCategoryChange($event)">
          <option value="ALL">전체 카테고리</option>
          <option value="DEV">개발(DEV)</option>
          <option value="NOTICE">공지(NOTICE)</option>
          <option value="FREE">자유(FREE)</option>
        </select>

        <input
          type="text"
          [ngModel]="keyword()"
          (ngModelChange)="keyword.set($event)"
          (keyup.enter)="applySearch()"
          placeholder="검색어를 입력 후 Enter..."
        />
        <button type="button" (click)="applySearch()">검색</button>
      </section>

      <!-- 🌟 제네릭 테이블에 스토어의 읽기 전용 신호 직접 바인딩 -->
      <app-board-table
        [items]="store.posts()"
        [columns]="columns"
        [loading]="store.loading()"
        (rowClick)="selectedPost.set($event)"
      >
        <ng-template #cellTemplate let-row let-columnKey="columnKey" let-value="value">
          @switch (columnKey) {
            @case ('category') {
              <span class="cat-chip">{{ row.category }}</span>
            }
            @case ('title') {
              <span class="post-title">{{ row.title }}</span>
              @if (row.likeCount >= 5) {
                <span class="hot-badge">🔥 인기</span>
              }
            }
            @default {
              {{ value }}
            }
          }
        </ng-template>

        <ng-template #actionTemplate let-row>
          <button type="button" class="btn-delete" (click)="handleDeletePost(row.id)">
            삭제
          </button>
        </ng-template>
      </app-board-table>

      <app-post-create-modal
        [isOpen]="isCreateModalOpen()"
        [submitting]="isSubmitting()"
        (closed)="isCreateModalOpen.set(false)"
        (submitted)="handleCreatePost($event)"
      />

      <app-post-detail-modal
        [post]="selectedPost()"
        (closed)="selectedPost.set(null)"
        (deleted)="handleDeletePost($event)"
      />
    </div>
  `,
  styles: [`
    .board-container { max-width: 960px; margin: 24px auto; padding: 0 16px; }
    .board-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
    .header-actions { display: flex; gap: 8px; }
    .badge { font-size: 13px; color: #475569; background: #f1f5f9; padding: 2px 8px; border-radius: 999px; }
    .popular-banner {
      display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
      background: #fffbeb; border: 1px solid #fde68a; padding: 10px 14px;
      border-radius: 8px; margin-bottom: 16px; font-size: 13px;
    }
    .pop-item { background: #fff; border: 1px solid #fcd34d; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
    .filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
    .filter-bar input { flex: 1; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
    .filter-bar select, .filter-bar button { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
    .btn-primary { background: #2563eb; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
    .btn-refresh { background: #f8fafc; border: 1px solid #cbd5e1; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
    .cat-chip { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; background: #e2e8f0; }
    .post-title { font-weight: 600; color: #0f172a; }
    .hot-badge { margin-left: 8px; background: #fef2f2; color: #ef4444; font-size: 11px; padding: 2px 6px; border-radius: 999px; }
    .btn-delete { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
  `]
})
export class BoardAppComponent implements OnInit {
  // 🌟 전역 싱글톤 PostStore 주입
  readonly store = inject(PostStore);
  private readonly createModal = viewChild(PostCreateModalComponent);

  // 로컬 UI 상태는 여전히 컴포넌트 안에 유지!
  readonly selectedCategory = signal<CategoryFilter>('ALL');
  readonly keyword = signal('');
  readonly currentPage = signal(1);
  readonly isCreateModalOpen = signal(false);
  readonly isSubmitting = signal(false);
  readonly selectedPost = signal<PostResponse | null>(null);

  readonly columns: TableColumn<PostResponse>[] = [
    { key: 'id', header: 'ID', width: '70px', align: 'center' },
    { key: 'category', header: '분류', width: '90px', align: 'center' },
    { key: 'title', header: '제목' },
    { key: 'author', header: '작성자', width: '110px', align: 'center' },
    { key: 'createdAt', header: '작성일', width: '140px', align: 'center' }
  ];

  ngOnInit(): void {
    this.loadPosts(false);
  }

  async loadPosts(force = false): Promise<void> {
    try {
      await this.store.fetchPosts(
        {
          page: this.currentPage(),
          size: 10,
          category: this.selectedCategory(),
          keyword: this.keyword()
        },
        force
      );
    } catch {
      alert('게시글 목록을 불러오지 못했습니다.');
    }
  }

  onCategoryChange(category: CategoryFilter): void {
    this.selectedCategory.set(category);
    this.currentPage.set(1);
    this.loadPosts(false);
  }

  applySearch(): void {
    this.currentPage.set(1);
    this.loadPosts(false);
  }

  reloadForce(): void {
    this.loadPosts(true);
  }

  async handleCreatePost(payload: CreatePostRequest): Promise<void> {
    this.isSubmitting.set(true);
    try {
      await this.store.createPost(payload);
      this.isCreateModalOpen.set(false);
      this.createModal()?.resetForm();
    } catch {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      this.isSubmitting.set(false);
    }
  }

  async handleDeletePost(postId: number): Promise<void> {
    if (!confirm('정말 삭제하시겠습니까?')) return;
    try {
      await this.store.removePost(postId);
      this.selectedPost.set(null);
    } catch {
      alert('게시글 삭제에 실패했습니다.');
    }
  }
}
🔬 심화(건너뛰어도 됨): 순수 @Injectable Signals Store vs @ngrx/signals (signalStore)

우리가 작성한 PostStore는 외부 라이브러리 의존성이 전혀 없는 순수 Angular @Injectable Signals Store입니다. 프로젝트의 80% 이상은 이 패턴만으로 충분합니다.
그렇다면 현업에서 **@ngrx/signals (signalStore)**는 언제 도입할까요?

  • 도입 시점: 수십 개의 도메인 스토어에 공통 기능(예: 페이지네이션 상태, 엔티티 정규화 withEntities(), RxJS 스트림 통합 rxMethod)을 플러그인(withCustomFeature())처럼 재사용해야 하는 대규모 엔터프라이즈 프로젝트일 때 유용합니다.
  • 구조 비교:
    typescript
    // @ngrx/signals 스타일 예시
    export const PostSignalStore = signalStore(
      { providedIn: 'root' },
      withState({ posts: [] as PostResponse[], loading: false }),
      withComputed(({ posts }) => ({
        topPopularPosts: computed(() => [...posts()].sort((a, b) => b.likeCount - a.likeCount).slice(0, 3))
      })),
      withMethods((store, http = inject(HttpClient)) => ({
        async fetchPosts() { patchState(store, { loading: true }); /* ... */ }
      }))
    );

이 책에서 직접 구현한 private signal + .asReadonly() + computed 원리를 이해하면 @ngrx/signals도 10분 만에 마스터할 수 있습니다!


9.5 [직접 해보기] 실습 미션 💪 ​

🎯 미션: 카테고리별 게시글 개수를 집계하는 categoryCounts Computed Getter 추가하기 ​

PostStore 안에 현재 로드된 _posts 목록을 기준으로 각 카테고리(DEV, NOTICE, FREE)별 게시글 수가 몇 건인지 계산하는 readonly categoryCounts = computed(...) 신호를 추가해 보세요.

💡 미션 정답 코드 확인하기
typescript
// frontend/src/app/stores/post.store.ts 의 PostStore 클래스 내부에 추가

readonly categoryCounts = computed<Record<PostCategory, number>>(() => {
  const initial: Record<PostCategory, number> = { DEV: 0, NOTICE: 0, FREE: 0 };
  for (const post of this._posts()) {
    initial[post.category] = (initial[post.category] ?? 0) + 1;
  }
  return initial;
});

9.6 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 3줄

  1. 모달 열림 여부나 입력 중인 검색어는 컴포넌트 로컬 signal()에 두고, 화면 간 공유되거나 재마운트 시 유지되어야 하는 도메인 데이터만 전역 PostStore로 승격시킵니다.
  2. 스토어 내부 상태는 private readonly _posts = signal([])로 감추고 외부에는 this._posts.asReadonly()만 노출해 상태 변조를 원천 차단합니다.
  3. 요청 파라미터를 직렬화한 queryKey와 CACHE_TTL_MS(60,000ms)를 결합해 1분 이내 동일 조건 재진입 시 네트워크 요청 없이 즉시 캐시를 반환합니다.

바뀐 파일

파일무엇이 바뀌었나
src/app/stores/post.store.ts캡슐화된 신호, topPopularPosts, 1분 TTL 캐싱 fetchPosts, createPost, removePost 구현
src/app/board-app/board-app.component.ts로컬 posts 제거, PostStore 주입 및 실시간 인기글 배너 연결

자주 하는 실수

증상원인해결
topPopularPosts를 호출했더니 원본 테이블의 행 순서까지 뒤바뀜this._posts().sort(...)로 원본 배열을 제자리 정렬(Mutate)함[...this._posts()].sort(...)처럼 얕은 복사본을 만든 뒤 정렬
새 글을 등록했는데 1분 동안 목록에 안 나타남createPost 후 fetchPosts를 force = false(기본값)로 호출해 옛 캐시가 반환됨등록 성공 직후에는 fetchPosts(params, true)로 캐시를 강제 갱신
컴포넌트에서 const posts = this.store.posts()로 변수에 담아 두었더니 갱신이 안 됨초기화 시점에 신호 값을 한 번 읽어서 일반 상수에 복사함신호 참조 자체(readonly posts = this.store.posts)를 넘기거나 템플릿에서 store.posts()를 호출

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. PostStore에서 _posts 신호를 public으로 열어두지 않고 .asReadonly()를 거쳐 노출하는 이유는 무엇인가요?
  2. 사용자가 'DEV' 카테고리를 조회한 직후 10초 만에 'NOTICE' 카테고리로 드롭다운을 바꾸면 캐시 히트가 발생할까요, 아니면 서버 요청이 나갈까요?
  3. 브라우저에서 F5(새로고침)를 누르면 왜 1분 이내라도 PostStore 캐시가 초기화되나요?
셀프 체크 정답
  1. 외부 컴포넌트가 스토어의 액션을 거치지 않고 임의로 store.posts.set(...)을 호출해 전역 상태를 오염시키는 것을 컴파일 타임에 막기 위해서입니다.
  2. 서버 요청이 나갑니다. _lastQueryKey에 저장된 문자열("category":"DEV")과 새 요청의 queryKey("category":"NOTICE")가 다르기 때문에 isCacheValid가 false가 됩니다.
  3. PostStore는 자바스크립트 메모리(Heap)에 존재하는 싱글톤 인스턴스이므로, 브라우저 탭 전체를 새로고침하면 JS 런타임 메모리가 초기화되기 때문입니다.

다음 챕터로 이어지는 질문
목록 조회 캐싱으로 화면 전환 속도는 빨라졌지만, 게시글의 [❤️ 좋아요] 버튼을 누를 때는 여전히 서버 응답이 올 때까지 0.3~0.5초 동안 숫자가 멈춰 있습니다. 성급한 사용자가 버튼을 광클하면 중복 요청까지 날아갑니다.
이어지는 Chapter 10에서는 서버 응답 전에 화면 숫자를 먼저 올리고 실패 시 스냅샷으로 자동 복구하는 **낙관적 업데이트(Optimistic UI)**와 pendingPostIds Set 신호를 활용한 연타 방어를 구현합니다.

Moon HomeLab Engineering Knowledge Hub