다크 모드
Chapter 10. '좋아요' 낙관적 업데이트(Optimistic Update)와 Pinia 플러그인
"9장에서 구축한
usePostStore와BoardApp.vue에 현대적인 UX를 입혀보자. 500ms의 네트워크 지연을 기다리지 않고 화면을 즉시 바꾸는 낙관적 업데이트와, 탭 닫힘 방어 플러그인의 결합!"
10.1 BoardApp.vue의 [좋아요] 버튼에 숨겨진 답답함
사내 게시판에서 동료의 유용한 기술 글을 보고 [좋아요(❤️)] 버튼을 누를 때, 현재 BoardApp.vue는 전통적인 비관적(Pessimistic) 방식으로 동작합니다:
- 사용자가 [좋아요] 클릭
- 버튼에 멍하니 딜레이 발생
- 400ms 후 Spring Boot 서버에서 200 OK 응답 도착
- 그제서야 숫자가 5에서 6으로 바뀌고 하트가 칠해짐
모바일 사내망이나 와이파이가 느린 환경에서는 "버튼이 안 눌렸나?" 하고 서너 번 연타하게 만듭니다.
10.2 [실습 1] usePostStore에 롤백 가능한 낙관적 액션 추가
우리는 9장에서 만든 usePostStore.ts에 낙관적 업데이트(Optimistic Update) 액션을 추가합니다:
- 클릭한 그 0.001초 즉시 숫자를 5에서 6으로 올림
- 백그라운드에서 Spring Boot 백엔드로 비동기 통신
- 만약 실패(500 에러 / 네트워크 단절)하면? 이전 상태로 즉각 롤백!
[코드 10-1] src/stores/usePostStore.ts에 추가된 낙관적 액션
typescript
// usePostStore.ts 내부
async function toggleLikeOptimistic(postId: number) {
const targetPost = posts.value.find(p => p.id === postId);
if (!targetPost) return;
// 1. 실패 시 롤백을 위해 이전 수치 백업
const previousLikeCount = targetPost.likeCount;
// 2. [선제적 반영] 서버 응답을 기다리지 않고 화면 숫자를 즉시 증가!
targetPost.likeCount++;
try {
// 3. 백그라운드에서 실제 Spring Boot API 호출
const res = await axios.post<Post>(`/api/posts/${postId}/like`);
targetPost.likeCount = res.data.likeCount; // 최종 정합성 보정
} catch (error) {
// 4. [롤백] 실패 시 이전 숫자로 원상 복구!
targetPost.likeCount = previousLikeCount;
alert('네트워크 장애로 좋아요 처리에 실패하여 원래대로 롤백되었습니다.');
}
}이제 BoardApp.vue에서 @click="toggleLikeOptimistic(row.id)"를 호출하는 순간, 네이티브 앱처럼 빛의 속도로 반응하는 극상의 UX가 완성됩니다.
10.3 [실습 2] Pinia 플러그인: 글 작성 중 탭 닫힘 방어
BoardApp.vue에서 열심히 질문 글을 작성하다가 실수로 브라우저 탭을 닫거나 새로고침을 누르면 본문이 날아갑니다.
이를 방지하기 위해, 모든 스토어의 상태 변경을 가로채서 localStorage에 자동 보관하는 Pinia 플러그인을 장착합니다.
[코드 10-2] src/plugins/piniaPersist.ts
typescript
import type { PiniaPluginContext } from 'pinia';
export function piniaLocalPersistPlugin({ store, options }: PiniaPluginContext) {
if ((options as any).persist) {
const storageKey = `BOARD_APP_${store.$id.toUpperCase()}`;
// 1. 앱 기동 시 브라우저 로컬 스토리지에서 이전 작성 글 복원
const fromStorage = localStorage.getItem(storageKey);
if (fromStorage) {
try {
store.$patch(JSON.parse(fromStorage));
} catch (e) {
console.error('스토리지 파싱 에러:', e);
}
}
// 2. 사용자가 글을 타이핑할 때마다 자동으로 로컬 스토리지에 자동 저장
store.$subscribe((_, state) => {
localStorage.setItem(storageKey, JSON.stringify(state));
}, { detached: true });
}
}10.4 다음 챕터로 이어지는 질문
이제 BoardApp.vue는 극상의 사용자 경험(UX)과 데이터 영속성까지 갖추었습니다.
하지만 보안 관점에서 결정적인 문제가 있습니다:
현재 BoardApp.vue의 [게시글 등록] 폼과 [삭제] 버튼은 로그인하지 않은 외부 방문자에게도 무방비로 노출되어 있습니다.
누군가 브라우저 주소창에 /write나 /admin을 직접 치고 들어오면 그대로 화면이 열립니다.
"비로그인 사용자가 글쓰기 URL로 직접 접근하려 할 때, 이를 가로채서 로그인 페이지로 보내고, 로그인 후에는 원래 쓰려던 화면으로 부드럽게 복귀시킬 수는 없을까?"
이어지는 Chapter 11에서는 프론트엔드의 관문(Gateway)인 **Vue Router 네비게이션 가드(AOP)**를 구축하여 보안 인가 파이프라인을 완성해 보겠습니다.