Skip to content

Chapter 03. <script setup>과 반응형 유실 문제의 정복 ​

"1장에서 만든 BoardApp.vue에 상세 보기와 수정 기능을 추가해 보자. 그런데 객체를 구조 분해(Destructure)하는 순간, 왜 반응성의 파이프가 끊어지고 마는가?"


3.1 BoardApp.vue에 상세 보기 기능 추가하기 ​

1장에서 만든 BoardApp.vue는 목록 조회와 등록, 삭제만 가능했습니다.
이제 사용자가 목록에서 게시글 제목을 클릭하면 하단에 상세 내용이 펼쳐지고, 제목과 본문을 수정할 수 있는 기능을 추가해 봅시다.

frontend/src/BoardApp.vue 파일을 열고, 아래 코드를 추가합니다:

1) <script setup>에 상세 게시글 상태 및 조회 함수 추가 ​

typescript
// frontend/src/BoardApp.vue 의 <script setup> 내부

// 선택된 상세 게시글 상태 (ID가 0이면 선택되지 않은 상태)
const currentPost = reactive({
  id: 0,
  title: '',
  author: '',
  content: ''
});

// 제목 클릭 시 서버에서 최신 상세 데이터를 가져와 채워넣는 함수
async function selectPost(id: number) {
  try {
    const res = await axios.get<Post>(`/api/posts/${id}`);
    // Object.assign으로 기존 reactive 객체의 속성을 덮어씌움 (반응성 유지)
    Object.assign(currentPost, res.data);
  } catch (err) {
    alert('상세 정보를 불러오지 못했습니다.');
  }
}

2) <template> 테이블 제목에 클릭 이벤트 바인딩 ​

테이블 내부의 게시글 제목 <span>에 클릭 이벤트와 커서 스타일을 추가합니다:

html
<!-- 변경 전: <span class="post-title">{{ post.title }}</span> -->
<span class="post-title clickable" @click="selectPost(post.id)">
  {{ post.title }}
</span>

3) <template> 테이블 하단에 상세 보기 및 수정 뷰 추가 ​

테이블 </table> 태그 바로 아래에 다음 상세 카드 UI를 배치합니다:

html
    <!-- 4. 상세 보기 및 인라인 수정 뷰 -->
    <div v-if="currentPost.id" class="detail-card">
      <div class="detail-header">
        <h3>🔍 게시글 상세 보기 (#{{ currentPost.id }})</h3>
        <button class="btn-close" @click="currentPost.id = 0">닫기</button>
      </div>

      <div class="edit-row">
        <label>제목:</label>
        <input v-model="currentPost.title" type="text" />
      </div>

      <div class="edit-row">
        <label>작성자:</label>
        <span>{{ currentPost.author }}</span>
      </div>

      <div class="edit-row">
        <label>내용:</label>
        <textarea v-model="currentPost.content" rows="4"></textarea>
      </div>
    </div>

💡 [확인하기]:
파일을 저장하고 브라우저 http://localhost:3000에서 아무 게시글 제목을 클릭해 보십시오.
하단에 상세 카드가 짠 하고 펼쳐지며 제목과 내용이 인풋에 바인딩됩니다.


3.2 [직접 터뜨려보는 실습] 사내 실무 버그 1위: 구조 분해 할당(Destructuring)의 함정 ​

상세 화면의 제목을 조작하는 함수를 만들려고 합니다.
많은 개발자가 코드를 간결하게 쓰기 위해 다음과 같이 ES6 구조 분해 할당(Destructuring)을 무심코 시도합니다.

직접 BoardApp.vue에 아래 버그 코드를 작성하고 실행해 보십시오:

typescript
// 🚨 [실험 코드] frontend/src/BoardApp.vue 에 추가해 보기
let { title, content } = currentPost;

function updateTitleDirectly() {
  title = '강제로 바꾼 제목';
  console.log('로컬 변수 title 값:', title);
  console.log('원본 currentPost.title 값:', currentPost.title);
}

그리고 상세 카드 내부에 <button @click="updateTitleDirectly">제목 강제 수정</button>을 두고 버튼을 클릭해 보십시오:

  • 브라우저 화면: 화면의 제목 인풋창과 목록의 제목은 단 1픽셀도 변하지 않습니다!
  • 브라우저 콘솔: 로컬 변수 title 값: 강제로 바꾼 제목, 그러나 원본 currentPost.title 값: (기존 제목) 그대로입니다.

왜 이런 치명적인 일이 벌어지는가? ​

2장에서 우리가 직접 구현했던 Proxy와 track/trigger 원리를 상기해 보십시오.
Proxy는 **"객체의 프로퍼티 접근(get)과 수정(set)"**을 가로채는 껍데기입니다.

자바스크립트 엔진이 let { title } = currentPost;를 실행할 때 일어나는 내부 동작:

  1. currentPost Proxy의 get title을 호출하여 현재 시점의 원시 문자열 값(예: 'Vue 3 아키텍처')을 꺼냅니다.
  2. 꺼내진 원시 문자열을 새로운 로컬 변수 title에 복사 할당합니다.
  3. 이 순간부터 title 변수는 Proxy 껍데기와 완전히 인연이 끊긴 평범한 순수 문자열이 됩니다!

따라서 title = '강제로 바꾼 제목'으로 값을 재할당해도 Proxy의 set 트랩이 전혀 호출되지 않으므로, Vue 반응형 엔진은 값이 바뀌었다는 사실을 알 방법이 없습니다. 이것이 바로 **반응형 유실(Reactivity Loss)**입니다.


3.3 해결책: toRefs와 toRef의 원리 ​

이 문제를 해결하기 위해 Vue 3는 toRefs 유틸리티를 제공합니다.
toRefs는 객체의 각 프로퍼티를 원본 객체와 연결된 개별 ref 객체로 변환해 줍니다:

typescript
import { toRefs } from 'vue';

// ✅ toRefs를 거치면 구조 분해를 해도 안전하다!
const { title, content } = toRefs(currentPost);

function updateTitleSafely() {
  // 이제 title은 ref<string> 타입이므로 .value로 조작 시 원본 Proxy와 화면이 즉시 동기화!
  title.value = 'toRefs로 안전하게 바꾼 제목';
}

toRefs의 내부 동작 원리 ​

toRefs는 마법이 아닙니다.
내부적으로 각 키에 대해 ObjectRefImpl 인스턴스를 만들고, 그 인스턴스의 .value getter/setter가 원본 객체의 속성을 바라보도록 포워딩(Forwarding)해 주는 구조입니다:

typescript
// toRef의 내부 메커니즘
class ObjectRefImpl<T extends object, K extends keyof T> {
  constructor(private _object: T, private _key: K) {}

  get value() {
    return this._object[this._key]; // 원본 Proxy 객체의 getter 호출!
  }

  set value(newVal) {
    this._object[this._key] = newVal; // 원본 Proxy 객체의 setter 호출!
  }
}

3.4 Vue 3.5 신규 표준: Reactive Props Destructuring ​

만약 부모 컴포넌트로부터 post를 props로 전달받는 자식 컴포넌트라면 어떻게 해야 할까요?
과거 Vue 3.4까지는 const { post } = defineProps()를 하면 반응성이 유실되었으나, Vue 3.5부터는 defineProps의 반환값을 바로 구조 분해해도 완벽하게 반응성이 유지되도록 컴파일러 레벨에서 표준화되었습니다.

html
<script setup lang="ts">
// ✅ Vue 3.5 최신 표준: defineProps 구조 분해 시 반응성이 끊어지지 않습니다!
const { post, isSelected = false } = defineProps<{
  post: Post;
  isSelected?: boolean;
}>();

// Vue 3.5 컴파일러가 빌드 타임에 내부적으로 props.post 에 접근하도록 자동 트랜스파일링합니다.

3.5 [실습] 비동기 검색어 감시와 네트워크 낭비 방어 (watch & onCleanup) ​

이제 BoardApp.vue 상단에 실시간 검색창을 두고, 사용자가 검색어를 입력할 때마다 실시간으로 서버 API를 호출하는 기능을 붙여봅시다.

[실험] 전형적인 비동기 경쟁 상태(Race Condition) 버그 ​

typescript
const searchKeyword = ref('');

// ❌ 치명적인 버그 유발 코드
watch(searchKeyword, async (newKeyword) => {
  const res = await axios.get(`/api/posts?keyword=${newKeyword}`);
  posts.value = res.data.items;
});

사용자가 'Spring'을 치다가 빠르게 지우고 'Vue'를 입력하면, 먼저 보낸 'Spring' 요청 A가 네트워크 지연으로 인해 'Vue' 요청 B보다 늦게 도착할 수 있습니다. 그 결과 사용자는 'Vue'를 쳤는데 뒤늦게 도착한 'Spring' 검색 결과가 화면을 덮어써 버리는 **경쟁 상태(Race Condition)**가 발생합니다.

해결책: watch의 onCleanup과 표준 AbortController ​

Vue 3의 watch는 이전 부수 효과가 끝나기 전에 새로운 감시 이벤트가 들어왔을 때 뒷정리를 할 수 있는 onCleanup 콜백을 제공합니다.

BoardApp.vue에 검색 로직을 적용해 봅시다:

typescript
// frontend/src/BoardApp.vue <script setup> 내부
import { ref, reactive, watch, onMounted } from 'vue';

const searchKeyword = ref('');

watch(searchKeyword, (newKeyword, _, onCleanup) => {
  // 이전 비동기 요청을 취소하기 위한 브라우저 표준 AbortController
  const controller = new AbortController();

  // 새로운 검색어가 들어오거나 컴포넌트가 언마운트되면 이전 네트워크 요청을 즉시 강제 취소!
  onCleanup(() => {
    controller.abort();
  });

  axios.get(`/api/posts`, {
    params: { keyword: newKeyword },
    signal: controller.signal // Axios에 AbortController 신호 주입
  }).then(res => {
    posts.value = res.data.items;
  }).catch(err => {
    if (axios.isCancel(err)) {
      console.log('이전 검색 요청이 정상적으로 취소(Abort)되었습니다.');
    }
  });
});

<template> 최상단에 검색창 인풋을 배치합니다:

html
<div class="search-bar">
  <input v-model="searchKeyword" placeholder="실시간 검색어 입력..." />
</div>

💡 [개발자 도구(F12)에서 확인하기]:
브라우저에서 개발자 도구(F12)의 네트워크(Network) 탭을 열고, 검색창에 글자를 빠르게 '가나다라마바사' 타이핑해 보십시오.
이전 요청들이 줄줄이 빨간색 (canceled) 처리되고 오직 마지막 요청의 결과만 안전하게 화면에 반영되는 것을 확인할 수 있습니다!


3.6 리팩터링된 BoardApp.vue 중간 점검과 위기 감지 ​

toRefs와 watch(onCleanup)를 적용하여, BoardApp.vue는 이제 반응성 유실 없이 실시간 검색과 상세 보기/수정까지 안전하게 처리하게 되었습니다.

하지만 기능이 하나씩 추가될 때마다 코드 상태를 보십시오:

  • posts 목록 조회 로직
  • form 등록 로직
  • currentPost 상세 보기 및 수정 로직
  • searchKeyword 실시간 감시 및 비동기 취소 로직

이 모든 상태와 함수들이 BoardApp.vue 단 하나의 파일 안에 무려 300줄 넘게 뒤엉켜가고 있습니다.
여기에 다음 장에서 페이지네이션, 다중 필터 드롭다운, 폼 글자수 유효성 검사까지 붙는다면 어떻게 될까요?

이어지는 Chapter 04에서는 이처럼 하나의 컴포넌트가 모든 책임을 떠안고 800줄로 거대해지는 Fat Component의 위기를 직접 체험하고 진단해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub