다크 모드
Chapter 10. '좋아요' 낙관적 업데이트와 Angular Signals 롤백 메커니즘
"좋아요 하트를 눌렀을 때 0.5초 동안 아무 반응이 없다면 사용자는 버튼을 연타하기 시작한다. 네트워크 응답을 기다리지 않고 화면 숫자를 즉시 올리되, 서버 통신 실패 시 스냅샷으로 빛의 속도로 되돌리는 낙관적 UI와 연타 방어막을 구축하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| 낙관적 업데이트 (Optimistic UI) | 서버 성공을 가정하고 화면 신호를 즉시 갱신한 뒤 백그라운드 통신 | 10.1~10.2 |
| 스냅샷 롤백 (Snapshot Rollback) | HTTP 실패 시 백업해 둔 이전 수치(previousLikeCount)로 신호를 복구 | 10.2 [코드 10-1] |
pendingPostIds (Signal<ReadonlySet<number>>) | 진행 중인 요청 ID를 반응형 Set 신호로 추적하여 연타를 막고 버튼을 비활성화 | 10.2 해부 박스 |
MSW HttpResponse.error() 롤백 검증 | 네트워크 단절을 시뮬레이션해 롤백 동작을 자동 테스트로 고정 | 10.3 [코드 10-3] |
DraftStore (effect() + localStorage) | 글 작성 중 실수로 탭을 닫거나 새로고침해도 초안을 자동 보존하는 스토어 | 10.4 [코드 10-4] |
TIP
🏁 이 장의 시작 파일frontend/src/app/board-app/board-app.component.ts와 post.store.ts가 9장 완료 상태에서 시작합니다.
이 장에서 작성·수정하는 파일:
frontend/src/app/stores/post.store.ts(pendingPostIds신호 및toggleLikeOptimistic액션 추가)frontend/src/app/stores/draft.store.ts(effect()기반 자동 임시저장 초안 스토어)frontend/src/app/board-app/board-app.component.ts(좋아요 열·연타 방지 버튼 및 초안 스토어 연동)frontend/tests/unit/post.store.spec.ts(낙관적 업데이트 및 롤백 단위 테스트)
10.1 [좋아요] 버튼에 숨겨진 답답함과 경쟁 상태(Race Condition)
동료의 유용한 기술 글을 보고 [❤️ 좋아요] 버튼을 누를 때, 전통적인 비관적(Pessimistic) 업데이트는 다음과 같이 동작합니다:
- 사용자가
[❤️ 5]버튼 클릭 - 서버 응답 대기(300~500ms) 동안 화면에 아무 변화 없음
- 사용자가 "안 눌렸나?" 싶어 3번 더 광클 (중복 POST 요청 발생!)
- 뒤늦게 응답들이 순서 없이 도착하며 화면 숫자가 엉망이 됨
⚡ 비유: 개발자의 '선조치 후보고' (낙관적 업데이트와 스냅샷 롤백)
낙관적 업데이트는 뻔뻔하지만 유능한 비서의 '선조치 후보고' 기술입니다. 사용자가 하트를 누르자마자 0.001초 만에 화면 숫자를5 → 6으로 시원하게 먼저 올려주고, 잠금장치를 채운 뒤 조용히 백엔드 서버에 보고서를 올립니다.
만약 백엔드 서버가 불타고 있어서(500 Internal Server Error또는 오프라인) 보고가 반려되면 어떻게 할까요? 주머니에 몰래 적어둔 **이전 숫자 메모(previousLikeCount = 5)**를 꺼내 아무 일도 없었던 것처럼6 → 5로 되돌려 놓습니다!
10.2 [실습 1] PostStore에 연타 방어와 낙관적 롤백 액션 구현
단순히 일반 new Set<number>() 변수만 쓰면 중복 클릭은 막을 수 있지만, 템플릿의 버튼에 [disabled]="store.isLikePending(row.id)"를 바인딩했을 때 버튼 모양이 반응형으로 바뀌지 않습니다.
따라서 우리는 private readonly _pendingPostIds = signal<ReadonlySet<number>>(new Set()) 신호로 잠금 상태를 관리하여 동시성 방어와 반응형 UI 비활성화를 동시에 해결합니다!
[코드 10-1] PostStore에 추가하는 낙관적 업데이트 코드 (frontend/src/app/stores/post.store.ts)
typescript
// frontend/src/app/stores/post.store.ts (9장 코드에 추가·확장)
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';
@Injectable({ providedIn: 'root' })
export class PostStore {
private readonly http = inject(HttpClient);
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>('');
// 🌟 10장 추가: 현재 좋아요 요청이 진행 중인 게시글 ID 집합을 Signal로 관리!
private readonly _pendingPostIds = signal<ReadonlySet<number>>(new Set());
readonly posts = this._posts.asReadonly();
readonly totalElements = this._totalElements.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly pendingPostIds = this._pendingPostIds.asReadonly();
readonly topPopularPosts = computed(() =>
[...this._posts()]
.sort((a, b) => b.likeCount - a.likeCount)
.slice(0, 3)
);
// 특정 게시글이 현재 좋아요 통신 중인지 확인하는 헬퍼 메서드 (템플릿 바인딩용)
isLikePending(postId: number): boolean {
return this._pendingPostIds().has(postId);
}
// ... (9장의 fetchPosts, createPost, removePost 동일) ...
// 🌟 10장 핵심 액션: 낙관적 좋아요 업데이트 + 스냅샷 롤백 + 연타 완벽 방어
async toggleLikeOptimistic(postId: number): Promise<void> {
// 1. 중복 클릭(연타) 방어: 이미 진행 중인 게시글이면 즉시 무시!
if (this._pendingPostIds().has(postId)) {
return;
}
const target = this._posts().find((p) => p.id === postId);
if (!target) {
return;
}
// 2. 실패 시 롤백을 위한 이전 수치 스냅샷 백업
const previousLikeCount = target.likeCount;
// 3. 진행 중 Set 신호에 postId 등록 (새 Set 인스턴스를 생성해야 신호 변경이 감지됨!)
this._pendingPostIds.update((prev) => {
const next = new Set(prev);
next.add(postId);
return next;
});
// 4. [선제적 반영] 서버 응답을 기다리지 않고 화면 숫자 즉시 +1
this._posts.update((list) =>
list.map((p) =>
p.id === postId ? { ...p, likeCount: p.likeCount + 1 } : p
)
);
try {
// 5. 백그라운드 실제 API 호출 및 서버 확정 값으로 동기화
const updated = await firstValueFrom(
this.http.post<PostResponse>(`/api/posts/${postId}/like`, {})
);
this._posts.update((list) =>
list.map((p) =>
p.id === postId ? { ...p, likeCount: updated.likeCount } : p
)
);
} catch (err) {
// 6. 🌟 [스냅샷 롤백] 통신 실패 시 백업해 둔 previousLikeCount로 즉시 복구!
this._posts.update((list) =>
list.map((p) =>
p.id === postId ? { ...p, likeCount: previousLikeCount } : p
)
);
// 사용자 알림은 호출한 컴포넌트가 처리하도록 에러를 다시 던짐
throw err;
} finally {
// 7. 성공하든 실패하든 반드시 잠금 해제!
this._pendingPostIds.update((prev) => {
const next = new Set(prev);
next.delete(postId);
return next;
});
}
}
}🆕 처음 보는 문법: 왜 Set이나 배열을 수정할 때 new Set(prev)로 새로 만드나요?
Angular의 signal()은 기본적으로 Object.is(이전값, 새값) 참조 비교를 통해 값이 바뀌었는지 판단합니다(2장 복습).
만약 prev.add(postId); return prev;처럼 기존 Set 객체의 참조를 그대로 반환하면, Angular는 "같은 Set 객체네? 안 바뀌었구나!"라고 판단해 템플릿을 다시 그리지 않습니다. 따라서 배열은 .map() / .filter()로, Set은 new Set(prev)로 **새로운 참조(불변성, Immutability)**를 만들어 반환해야 OnPush 컴포넌트가 빛의 속도로 화면을 갱신합니다.
[코드 10-2] BoardAppComponent에 좋아요 열과 연타 방어 버튼 장착
typescript
// frontend/src/app/board-app/board-app.component.ts 의 변경 부분
// 1. columns 배열에 'likeCount' 열 추가
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: '130px', align: 'center' },
{ key: 'likeCount', header: '좋아요', width: '100px', align: 'center' } // ➕ 추가
];
// 2. 템플릿의 <ng-template #cellTemplate> 내부 @switch (columnKey)에 'likeCount' 케이스 추가
/*
@case ('likeCount') {
<button
type="button"
class="btn-like"
[disabled]="store.isLikePending(row.id)"
(click)="$event.stopPropagation(); handleLike(row.id)"
>
❤️ {{ row.likeCount }}
</button>
}
*/
// 3. 컴포넌트 메서드 추가
async handleLike(postId: number): Promise<void> {
try {
await this.store.toggleLikeOptimistic(postId);
} catch {
alert('네트워크 장애로 좋아요 반영에 실패하여 이전 숫자로 롤백되었습니다.');
}
}💡 [확인하기 - 오프라인 모드로 롤백 직접 체험하기]:
- 브라우저 개발자 도구(F12) → Network 탭을 열고 상단 Throttling 드롭다운을 **
Slow 3G**로 바꿉니다.[❤️ 좋아요]버튼을 클릭하면 서버 응답이 오기 전에 숫자가 즉시 1 올라가고, 응답이 끝날 때까지 버튼이 비활성화([disabled])되어 광클이 막히는 것을 확인합니다.- Throttling을 **
Offline**으로 바꾸고 다시 클릭하면 숫자가 올라갔다가 즉시 원래 숫자로 롤백되며 안내창이 뜹니다!
10.3 [실습 2] 롤백 메커니즘을 Vitest + MSW 단위 테스트로 고정하기
눈으로 확인한 롤백과 연타 방어 로직을 자동 테스트로 못 박아 두면, 나중에 동료가 리팩터링하다가 실수로 롤백 코드를 지워도 0.2초 만에 알아차릴 수 있습니다.
[코드 10-3] frontend/tests/unit/post.store.spec.ts
typescript
// frontend/tests/unit/post.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { PostStore } from '../../src/app/stores/post.store';
import { PostResponse } from '../../src/app/models/post.model';
const mockInitialPost: PostResponse = {
id: 1,
title: '낙관적 업데이트 테스트 글',
author: '김개발',
content: '테스트 본문입니다.',
category: 'DEV',
createdAt: '2026-11-01',
viewCount: 10,
likeCount: 5
};
const server = setupServer(
http.get('/api/posts', () =>
HttpResponse.json({
content: [{ ...mockInitialPost }],
totalElements: 1,
totalPages: 1,
pageNumber: 1,
pageSize: 10
})
)
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('PostStore.toggleLikeOptimistic 단위 테스트', () => {
let store: PostStore;
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), PostStore]
});
store = TestBed.inject(PostStore);
await store.fetchPosts({}, true);
});
it('서버 응답 전에 즉시 +1 하고, 응답 성공 시 서버의 확정 수치로 보정한다', async () => {
server.use(
http.post('/api/posts/:id/like', () =>
HttpResponse.json({ ...mockInitialPost, likeCount: 10 })
)
);
const promise = store.toggleLikeOptimistic(1);
// 🌟 응답 도착 전 동기 구간 검증: 이미 5 -> 6으로 증가 및 잠금 활성화!
expect(store.posts()[0].likeCount).toBe(6);
expect(store.isLikePending(1)).toBe(true);
await promise;
// 응답 완료 후 서버 확정 값(10) 반영 및 잠금 해제 검증
expect(store.posts()[0].likeCount).toBe(10);
expect(store.isLikePending(1)).toBe(false);
});
it('네트워크 장애 발생 시 이전 스냅샷 수치(5)로 즉시 롤백하고 잠금을 해제한다', async () => {
server.use(
http.post('/api/posts/:id/like', () => HttpResponse.error())
);
const promise = store.toggleLikeOptimistic(1);
expect(store.posts()[0].likeCount).toBe(6); // 선제 반영
await expect(promise).rejects.toThrow();
// 🌟 실패 즉시 원래 값 5로 롤백 & 잠금 해제 확인!
expect(store.posts()[0].likeCount).toBe(5);
expect(store.isLikePending(1)).toBe(false);
});
});10.4 [실습 3] effect() 기반 DraftStore: 글 작성 중 탭 닫힘 방어
열심히 기술 질문 본문을 20줄 작성하다가 실수로 브라우저 새로고침(F5)을 누르면 작성 중이던 글이 모두 날아갑니다. Angular의 **effect()**를 활용하면 초안 신호가 바뀔 때마다 자동으로 localStorage에 동기화하는 DraftStore를 단 40줄로 만들 수 있습니다.
[코드 10-4] 초안 영속화 스토어 (frontend/src/app/stores/draft.store.ts)
typescript
// frontend/src/app/stores/draft.store.ts
import { Injectable, effect, signal } from '@angular/core';
import { PostCategory } from '../models/post.model';
export interface PostDraft {
category: PostCategory;
title: string;
author: string;
content: string;
}
const DRAFT_STORAGE_KEY = 'BOARD_APP_POST_DRAFT_V1';
const INITIAL_DRAFT: PostDraft = {
category: 'DEV',
title: '',
author: '',
content: ''
};
@Injectable({ providedIn: 'root' })
export class DraftStore {
private readonly _draft = signal<PostDraft>(this.loadFromStorage());
readonly draft = this._draft.asReadonly();
constructor() {
// 🌟 _draft 신호가 변경될 때마다 자동으로 localStorage에 영속화!
effect(() => {
const current = this._draft();
if (typeof localStorage !== 'undefined') {
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(current));
}
});
}
updateField<K extends keyof PostDraft>(key: K, value: PostDraft[K]): void {
this._draft.update((prev) => ({ ...prev, [key]: value }));
}
clearDraft(): void {
this._draft.set({ ...INITIAL_DRAFT });
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(DRAFT_STORAGE_KEY);
}
}
private loadFromStorage(): PostDraft {
if (typeof localStorage === 'undefined') {
return { ...INITIAL_DRAFT };
}
const raw = localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return { ...INITIAL_DRAFT };
try {
return { ...INITIAL_DRAFT, ...JSON.parse(raw) };
} catch {
return { ...INITIAL_DRAFT };
}
}
}🔬 심화(건너뛰어도 됨): 왜 PostStore 전체를 localStorage에 저장하지 않고 DraftStore를 분리했나요?
만약 PostStore 전체(_posts, _loading)를 localStorage에 자동 저장하면, 데이터 로딩 도중(_loading: true) 사용자가 새로고침했을 때 loading: true 상태가 그대로 복원되어 로딩 스피너가 영원히 멈추지 않는 버그가 발생합니다. 또한 서버 데이터는 다른 사용자에 의해 계속 바뀌므로 브라우저 영구 저장소에 박아두면 오래된 데이터(Stale Data)가 보입니다. 따라서 서버 캐시(PostStore)는 메모리에만 두고, 순수 클라이언트 작성 초안(DraftStore)만 localStorage에 영속화하는 것이 아키텍처 모범 사례입니다.
10.5 [직접 해보기] 실습 미션 💪
🎯 미션: 좋아요 연타 방어가 실제로 중복 호출을 막는지 테스트 추가하기
[코드 10-3] post.store.spec.ts에 세 번째 테스트(it)를 추가하여, store.toggleLikeOptimistic(1)을 await 없이 연속으로 두 번 호출했을 때 실제 서버 API(POST /api/posts/1/like)는 단 1번만 호출되고 likeCount도 +2가 아닌 +1만 올라가는지 검증해 보세요.
💡 미션 정답 코드 확인하기
typescript
it('응답 대기 중 동일 게시글의 좋아요를 연타해도 API는 단 1번만 호출된다', async () => {
let apiCallCount = 0;
server.use(
http.post('/api/posts/:id/like', () => {
apiCallCount++;
return HttpResponse.json({ ...mockInitialPost, likeCount: 6 });
})
);
// 첫 번째 클릭이 끝나기 전에 두 번째 클릭 연타!
const p1 = store.toggleLikeOptimistic(1);
const p2 = store.toggleLikeOptimistic(1);
expect(store.posts()[0].likeCount).toBe(6); // +2가 아니라 +1만 반영됨!
await Promise.all([p1, p2]);
expect(apiCallCount).toBe(1); // 🌟 백엔드 호출은 정확히 1회!
});10.6 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- 낙관적 업데이트는 **"이전 값 스냅샷 백업 → 잠금 설정 → 화면 신호 즉시 +1 → API 호출 → 실패 시 스냅샷으로 롤백"**의 5단계로 구현합니다.
- 진행 중인 요청 ID를
signal<ReadonlySet<number>>로 관리하면 중복 클릭에 의한 경쟁 상태(Race Condition)를 막으면서 템플릿 버튼의[disabled]상태까지 반응형으로 제어할 수 있습니다. - 작성 중인 폼 초안은
DraftStore로 분리하고 생성자의effect()를 통해localStorage와 자동 동기화합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/app/stores/post.store.ts | _pendingPostIds Set 신호, isLikePending, toggleLikeOptimistic 추가 |
src/app/stores/draft.store.ts | effect() + localStorage 기반 초안 영속화 스토어 작성 |
src/app/board-app/board-app.component.ts | 좋아요 컬럼 및 handleLike 연동 |
tests/unit/post.store.spec.ts | 낙관적 증가, 서버 보정, 네트워크 에러 롤백 단위 테스트 작성 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
| 한 번 네트워크 에러가 난 뒤로 해당 글의 좋아요 버튼이 영원히 비활성화됨 | _pendingPostIds에서 ID를 지우는 코드를 try 블록 안에만 넣음 | 반드시 finally 블록에서 next.delete(postId)를 실행해 잠금 해제 |
_pendingPostIds에 값을 넣었는데 버튼 [disabled]가 바뀌지 않음 | 기존 Set에 .add()만 하고 같은 Set 참조를 그대로 반환함 | new Set(prev)로 새 Set 인스턴스를 만들어 반환 |
| 좋아요 버튼 클릭 시 상세 모달까지 같이 열림 | 행(<tr>) 클릭 이벤트로 버블링됨 | 버튼에 (click)="$event.stopPropagation(); handleLike(row.id)" 적용 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
toggleLikeOptimistic의catch블록에서 롤백을 수행한 뒤 왜throw err;로 에러를 다시 던져야 하나요?this._posts.update(...)에서target.likeCount++처럼 기존 객체를 직접 수정하지 않고.map()으로 새 객체({ ...p, likeCount: ... })를 만드는 이유는 무엇인가요?DraftStore에서effect()를 일반 메서드가 아닌constructor()안에서 호출한 이유는 무엇인가요?
셀프 체크 정답
- 스토어는 "데이터를 원래대로 되돌리는 것"까지 책임지고, 사용자에게
alert나 토스트로 실패 사실을 알리는 것은 호출한 컴포넌트의try / catch가 처리하도록 역할을 분리했기 때문입니다. - 불변성(Immutability)을 유지해 새로운 배열·객체 참조를 만들어야
ChangeDetectionStrategy.OnPush컴포넌트와 Signals 의존성 그래프가 변경을 정확히 감지하기 때문입니다. - Angular의
effect()는 의존성 주입 컨텍스트(Injection Context, 대표적으로 클래스constructor) 안에서 생성되어야 스토어의 수명과 함께 안전하게 등록·관리되기 때문입니다.
다음 챕터로 이어지는 질문
이제 게시판은 빠른 캐싱과 즉각적인 좋아요 UX까지 갖추었습니다. 그런데 로그인하지 않은 외부인이 주소창에 /write나 관리자 경로를 직접 입력해 침입하려 한다면 어떻게 막아야 할까요?
이어지는 Chapter 11에서는 클래스 없이 순수 함수로 동작하는 함수형 라우터 가드(CanActivateFn), AuthStore, 그리고 안전한 returnUrl 자동 복귀 아키텍처를 구축합니다.