> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Vue 3 ref 조건을 바꿨는데 목록이 그대로일 때: computed로 조건부 항목 표시
- URL: https://mlog.me/vue-ref-conditional-array-computed/
- Published: 2026-11-03T07:09:54.000Z
- Updated: 2026-11-03T07:09:54.000Z
- Description: Vue 3에서 ref 값을 바꿔도 조건부 배열이 갱신되지 않는 원인을 재현합니다. computed로 표시 목록을 계산하고 .value, v-for key, 원본 데이터 유지까지 확인합니다.
- Author: mLog
- Tags: 개발, vue, javascript

체크박스를 누르면 `isShow.value`는 바뀌는데, 목록의 두 번째 항목은 처음 상태로 남아 있을 수 있다. 이때는 배열을 `ref`로 감쌌는지뿐 아니라 **목록에 넣을 항목을 언제 계산했는지** 확인해야 한다.

`ref`는 값을 반응형 상태로 만든다. 그러나 배열을 만들 때 실행한 `filter()`까지 자동으로 다시 실행하는 것은 아니다. 원본 데이터와 표시 조건으로 목록을 계속 계산하려면 `computed` 안에 필터를 둔다.

이 글은 Vue 3의 `<script setup>` 예제다. 설명을 위해 만든 작은 재현 코드이며, 실제 운영 장애를 겪었다는 기록은 아니다.

## 1\. 원인 재현: 조건을 읽은 시점에 목록이 결정됐다

먼저 다음 코드를 보자.

```js
import { ref } from 'vue'

const isShow = ref(false)
const rows = ref([
  { id: 'base', label: '기본 항목', optional: false },
  { id: 'extra', label: '추가 항목', optional: true }
])

// 이 줄을 실행할 때 한 번 필터링한 결과를 저장한다.
const once = ref(
  rows.value.filter(row => !row.optional || isShow.value)
)

isShow.value = true

console.log(isShow.value)                 // true
console.log(once.value.map(row => row.id)) // ['base']

```

`once`를 선언할 때 `isShow.value`는 `false`다. 필터를 통과한 것은 `base`뿐이다. 이후 `isShow.value`를 바꿔도 앞서 끝난 `filter()` 호출이 다시 실행되지는 않는다.

반대로 `true`로 시작해 두 항목을 넣었다면, 나중에 `false`로 바꿔도 `once`에는 두 항목이 남는다. **초기 조건에 따른 구성 결과**를 보관한 셈이다.

여기서 “배열을 ref로 만들어도 반응성이 없다”라고 결론 내리면 안 된다. 이번 재현에서는 `rows.value[0].label`을 바꾸자 `once`가 참조하는 같은 객체의 이름도 바뀌었다. 문제가 된 것은 객체 전체의 반응성이 아니라, **조건이 바뀔 때 목록 구성도 다시 계산해야 한다는 연결이 없는 것**이다.

처음 한 번 선택한 항목을 의도적으로 보관하려는 코드라면 이런 초기화가 맞을 수 있다. 사용자가 조건을 바꿀 때마다 표시 항목도 바뀌어야 하는 화면인지부터 구분하자.

## 2\. 해결: 원본 목록과 화면에 보여줄 목록을 나눈다

다음 파일은 그대로 재현에 사용한 `ConditionalList.vue`다.

```vue
<script setup>
import { computed, ref } from 'vue'

const isShow = ref(false)
const rows = ref([
  { id: 'base', label: '기본 항목', optional: false },
  { id: 'extra', label: '추가 항목', optional: true }
])

const visibleRows = computed(() =>
  rows.value.filter(row => !row.optional || isShow.value)
)
</script>

<template>
  <label>
    <input type="checkbox" v-model="isShow" />
    추가 항목 표시
  </label>
  <ul>
    <li v-for="row in visibleRows" :key="row.id">
      {{ row.label }}
    </li>
  </ul>
</template>

```

이 코드에서 각 값의 역할은 분명하다.

- `rows`: 원본 데이터 두 항목.
- `isShow`: 추가 항목의 표시 여부.
- `visibleRows`: 현재 원본과 조건을 바탕으로 계산한 표시 목록.

`optional: false`인 기본 항목은 항상 포함한다. `optional: true`인 추가 항목은 `isShow.value`가 참일 때 포함한다. 이것은 예제의 업무 규칙이지, Vue에 내장된 `optional` 기능은 아니다.

Vue의 computed는 계산 중 읽은 반응형 의존성을 추적하고 결과를 캐시한다. 의존성이 바뀌면 다음에 결과가 필요할 때 갱신할 수 있다. 이 예제는 표시 조건을 getter 안에서 읽으므로, 템플릿이 사용하는 목록도 조건을 따라 바뀐다. [Vue 공식 computed 안내](https://ko.vuejs.org/guide/essentials/computed?ref=mlog.me)

로컬에서 체크박스의 `change` 이벤트를 보내 확인한 결과는 다음과 같다.

1. 최초 상태 `false`: 기본 항목만 표시.
2. 체크한 상태 `true`: 기본 항목과 추가 항목 표시.
3. 다시 해제한 상태 `false`: 기본 항목만 표시.
4. 다시 체크한 상태 `true`: 두 항목 표시.

이 과정에서 원본 `rows`의 두 항목은 유지됐다. 목록에서 감추는 것과 원본에서 데이터를 삭제하는 것을 구분할 수 있다.

## 3\. JavaScript에서는 ref 객체와 .value를 구분한다

다음 두 조건은 다르다.

```js
const isShow = ref(false)

console.log(Boolean(isShow)) // true: ref 객체 자체
console.log(isShow.value)    // false: 객체가 담은 값

```

따라서 JavaScript 필터에서 `row.optional ? isShow : true`처럼 작성하면, 추가 항목의 조건 자리에 불리언 값 대신 객체가 들어간다. 이 예제에서는 `isShow.value`를 읽어야 한다.

앞의 템플릿에서 `v-model="isShow"`, `v-for="row in visibleRows"`처럼 `.value`를 생략한 것은 템플릿의 최상위 ref 자동 언래핑 규칙 때문이다. JavaScript와 템플릿을 같은 방식으로 읽지 말자. 중첩 객체나 배열 안의 ref에는 별도 주의사항도 있으므로, 모든 위치에서 `.value`를 생략해도 된다는 뜻은 아니다. [Vue 공식 반응성 기본](https://ko.vuejs.org/guide/essentials/reactivity-fundamentals?ref=mlog.me)

`let isShow = false`로 바꾸는 것도 같은 해결이 아니다. 일반 불리언의 변경 자체는 Vue가 추적하는 반응형 변경이 아니다. 이번 검증에서 일반 불리언만 읽는 computed는 처음 계산한 뒤 그 불리언을 바꿔도 이전 결과를 반환했다. 다른 반응형 의존성 때문에 우연히 재계산되는 상황과 혼동하지 않아야 한다.

## 4\. 화면 필터로 원본을 지우지 않는다

표시 여부만 바꾸려는 상황에서 아래처럼 원본 자체를 덮어쓰면, 제외한 데이터를 나중에 다시 표시할 재료가 없어질 수 있다.

```js
// 실제 삭제가 목적일 때와 단순 숨김이 목적일 때를 구분한다.
rows.value = rows.value.filter(row => !row.optional)

```

앞의 완성 예제는 원본을 유지한 채 새로운 배열을 표시 결과로 반환한다. `filter()`는 원본 배열에서 항목을 제거하는 연산이 아니다. 다만 내부 객체까지 깊게 복사하는 것도 아니다. 이번 검증에서도 필터 결과의 기본 항목과 원본 기본 항목은 같은 객체였다. [Vue 공식 리스트 렌더링: 배열 변경과 교체](https://ko.vuejs.org/guide/essentials/list?ref=mlog.me)

서버에서 새로운 목록을 받는 구조라면, 응답을 적절히 검증한 뒤 `rows.value`를 새 데이터로 바꾸고 표시 목록은 계속 `computed`에서 계산할 수 있다. 이번 로컬 테스트는 네트워크 요청 대신 고정된 새 배열을 대입해 이 동작을 확인했다. 실제 API 연동을 검증한 것은 아니다.

여러 데이터 원본에서 화면용 컬럼을 구성하는 큰 흐름은 [PostgreSQL 동적 컬럼을 Spring Boot·Vue·IBSheet로 연결하는 글](https://mlog.me/postgresql-dynamic-columns-spring-boot-vue-ibsheet/)과 함께 볼 수 있다. 이 글의 필터는 일반 Vue 목록용이며, 외부 그리드에 데이터를 전달하는 API는 해당 컴포넌트의 규약을 따라야 한다.

## 5\. v-if·key·watch가 각각 해결하는 문제를 구분한다

같은 요소에 `v-for`와 `v-if`를 놓고 `v-if`에서 반복 변수 `row`를 읽는 방식은 피하자. Vue 3에서는 같은 노드의 `v-if`가 먼저 평가되므로 그 반복 변수에 접근할 수 없다. 지금처럼 목록을 필터링하려면 `visibleRows`를 먼저 계산해 `v-for`에 전달하면 된다. [Vue 공식 v-if와 v-for 안내](https://ko.vuejs.org/guide/essentials/list?ref=mlog.me#v-for-with-v-if)

`key`는 목록 항목의 정체성을 Vue에 알려주는 값이다. `:key="row.id"`를 넣어도 처음 한 번 실행한 필터가 다시 계산되는 것은 아니다. 삭제 후 다른 행의 입력값이 남는 문제라면 [Vue 동적 폼의 index key와 안정적인 ID](https://mlog.me/vue-dynamic-form-index-key-stable-id/)를 별도로 확인하자.

또한 원본 데이터가 남는다고 해서 목록에서 제거된 자식 컴포넌트의 로컬 상태까지 보존되는 것은 아니다. 이번 예제는 텍스트 `li`이며, 입력 폼의 편집 상태 보존까지 검증하지 않았다.

`watch`로 조건을 감시하며 별도 배열을 수동 갱신하는 방식도 만들 수는 있다. 하지만 이 예제처럼 원본과 표시 조건만으로 결과가 정해지는 경우에는 `computed` 한 곳에 규칙을 두면 두 배열을 맞춰 관리할 필요가 없다. computed getter 안에서는 서버 요청이나 다른 상태 변경을 함께 실행하지 않는다. [Vue 공식 computed 모범 사례](https://ko.vuejs.org/guide/essentials/computed?ref=mlog.me#best-practices)

계산 결과인 `visibleRows.value`에 새 배열을 직접 할당하거나, 그 배열을 `splice()`로 편집하는 설계도 피하자. getter 전용 computed의 값 할당 제한과, 반환된 객체가 깊게 동결되는지는 서로 다른 문제다. 여기서는 결과를 읽기 전용으로 취급하고 `rows` 또는 `isShow`를 수정한다.

## 6\. 적용 후 확인할 순서

1. 조건의 최초 값이 `false`인 경우와 `true`인 경우를 각각 확인한다.
2. 조건을 양방향으로 바꾸며 목록의 실제 항목을 확인한다.
3. 원본 배열을 새 데이터로 교체해도 같은 표시 규칙이 유지되는지 확인한다.
4. 숨겼다가 다시 표시할 때 원본 데이터가 사라지지 않았는지 확인한다.
5. DOM을 자동 검사한다면 상태 변경 직후가 아니라 `await nextTick()` 이후에 확인한다.

Vue는 상태 변경 후 DOM 갱신을 묶어서 처리한다. `nextTick()`은 그 갱신 이후를 기다리는 용도다. 초기 필터 결과를 반응형 계산으로 바꿔 주는 함수는 아니다. [Vue 공식 DOM 업데이트 타이밍](https://ko.vuejs.org/guide/essentials/reactivity-fundamentals?ref=mlog.me#dom-update-timing)

메뉴나 버튼 표시 조건에 응용하더라도 숨김 자체를 접근 권한 검사로 사용하지는 않는다. 서버 검증은 [Vue disabled와 개발자 도구 조작을 다룬 글](https://mlog.me/vue-disabled-devtools-server-validation/)에서 별도로 설명한다.

## 검증 환경과 범위

2026-11-03, Node.js v24.22.1과 Vue 3.5.44에서 로컬 검증 9개를 실행해 모두 통과했다. 검증 항목은 초기 조건의 양방향 변경, 포함된 객체의 속성 변경, 원본 배열 교체, 행 조건 변경, 숨김·표시 반복, ref 객체의 참/거짓 판단, 일반 불리언의 한계, 실제 SFC 렌더링이다.

SFC 검증은 `@vue/compiler-sfc` 3.5.44로 위 파일을 컴파일하고 Vue 런타임을 jsdom 26.1.0에 마운트했다. 체크박스 이벤트 뒤 `nextTick()`을 기다려 `li` 내용이 1→2→1→2개로 바뀌는지 확인했다.

실제 브라우저, 업무 애플리케이션, 서버 통신, SSR, IBSheet 통합은 이번 검증 범위에 포함하지 않았다. 성능 개선 수치나 운영 장애 복구 결과도 측정하지 않았다.

재현 코드와 실제 실행 결과는 글 아래의 ZIP 파일에 담았다. 압축을 푼 폴더에서 `npm ci --ignore-scripts --no-audit --no-fund`와 `npm test`로 실행할 수 있다. 의존성 설치에는 네트워크 연결이 필요하다.

## 공식 근거

다음 공식 문서를 2026-11-03 확인했다.

- [Vue — computed](https://ko.vuejs.org/guide/essentials/computed?ref=mlog.me): 의존성·캐싱·getter와 파생 상태 관리.
- [Vue — 리스트 렌더링](https://ko.vuejs.org/guide/essentials/list?ref=mlog.me): 필터링·배열 교체·key·v-if와 v-for.
- [Vue — 반응성 기본](https://ko.vuejs.org/guide/essentials/reactivity-fundamentals?ref=mlog.me): ref·템플릿 언래핑·DOM 갱신 시점.
- [Vue — 반응성 API 코어](https://ko.vuejs.org/api/reactivity-core?ref=mlog.me): ref와 computed API 정의.

[Vue 조건부 배열·computed 재현 코드Vue SFC, 고정 의존성, 9개 로컬 검증 결과와 실행 안내. Node.js와 의존성 다운로드가 필요합니다.vue-computed-conditional-list-repro-20261103.zip13 KBdownload-circle](https://mlog.me/content/files/2026/11/vue-computed-conditional-list-repro-20261103.zip "Download")