다크 모드
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;를 실행할 때 일어나는 내부 동작:
currentPostProxy의get title을 호출하여 현재 시점의 원시 문자열 값(예:'Vue 3 아키텍처')을 꺼냅니다.- 꺼내진 원시 문자열을 새로운 로컬 변수
title에 복사 할당합니다. - 이 순간부터
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의 위기를 직접 체험하고 진단해 보겠습니다.