Vue 동적 폼에서 중간 행을 삭제했는데 입력값이 바뀔 때: index key와 안정적인 ID
동적 입력 폼에서 두 번째 행을 지웠는데 세 번째 행의 입력칸에 두 번째 값이 남는 경우가 있다. 배열을 확인하면 삭제는 맞게 처리됐는데 화면만 다른 내용을 보여 준다. 이럴 때 배열 삭제 코드뿐 아니라 각 행의 key와 입력값을 보관하는 위치를 함께 확인해야 한다.
추가·삭제·정렬되는 입력 행에는 위치 대신 안정적인 행 ID를 key로 사용한다. 다만 이것만으로 부모 값과 자식의 로컬 초안이 자동 동기화되지는 않는다. 이번 예제는 두 문제를 분리해서 재현한다.
2026-09-29 Node.js 24.19.0, Vue 3.5.43, jsdom 26.1.0에서 7개 검사를 실행했다. 실제 Vue 렌더러를 사용했지만 DOM은 Node 환경에서 모의했다. 실제 브라우저의 포커스·한글 조합 입력·모바일 키보드·IBSheet·서버 저장 연동까지 검증한 결과는 아니다. 아래 데이터는 설명용 인공 샘플이며 실제 운영 장애를 겪었다는 기록도 아니다.
배열의 위치와 행의 정체성은 다르다
다음 세 행이 있다고 하자.
const rows = ref([
{ id: 'a', value: 'A' },
{ id: 'b', value: 'B' },
{ id: 'c', value: 'C' }
])
두 번째 행을 제거하면 배열은 a, c 순서가 된다. 인덱스는 0, 1로 다시 매겨진다. 이때 c는 같은 행이지만 위치가 2에서 1로 바뀌었다.
<!-- 문제가 드러나는 비교용 코드 -->
<RowDraft
v-for="(row, index) in rows"
:key="index"
:item="row"
/>
index key를 사용하면 이전 key 1의 컴포넌트가 이제 c의 데이터를 받는다. 그 컴포넌트 안에 별도 입력 초안이 남아 있으면 행 라벨은 c인데 입력값은 B인 조합이 만들어질 수 있다.
Vue의 리스트 렌더링 문서는 자식 상태나 임시 DOM 상태가 있는 목록에서 key로 항목의 정체성을 알려 주도록 설명한다. key는 표시용 행 번호가 아니다.
자식이 값을 한 번 복사하면 차이가 잘 드러난다
비교용 RowDraft.vue는 부모 값을 처음에만 복사한다. 저장 전까지 자식이 임시 편집값을 보관하는 상황을 작게 표현한 예제다.
<script setup>
import { ref } from 'vue'
const props = defineProps({
item: { type: Object, required: true }
})
const draft = ref(props.item.value)
</script>
<template>
<label>
{{ item.id }}
<input v-model="draft" />
</label>
</template>
c의 입력값을 C edited로 바꾸고 b를 삭제한다. 이번 검사에서는 다음 차이를 확인했다. 검증 코드는 같은 구조를 h()와 value/input 이벤트로 구성했으며, 위 SFC 파일 자체를 브라우저에서 실행한 것은 아니다.
| 구성 | b 삭제 후 보이는 결과 |
|---|---|
| index key + 자식 로컬 draft | a=A, c=B |
| id key + 자식 로컬 draft | a=A, c=C edited |
| index key + 부모 값 직접 바인딩 | a=A, c=C edited |
세 번째 결과도 중요하다. index key라고 해서 바인딩된 입력값이 항상 잘못 표시되는 것은 아니다. 부모의 현재 값을 input에 계속 전달하면 이 사례의 화면 값은 맞을 수 있다. 그렇다고 자식 내부 상태나 포커스까지 안전하다는 뜻은 아니다. 이번 실험은 포커스를 측정하지 않았다.
key="index"라는 문자열 상수도 피해야 한다. 모든 형제에 같은 문자열을 주는 것과 :key="index"로 각 위치를 주는 것은 서로 다른 문제다. 전자는 중복 key, 후자는 변하는 위치를 정체성으로 쓰는 문제다. key API 문서는 같은 부모 아래의 key가 고유해야 한다고 설명한다.
key를 안정적인 ID로 바꾼다
이미 고유하고 변하지 않는 행 ID가 있다면 그것을 사용할 수 있다.
<RowDraft
v-for="row in rows"
:key="row.id"
:item="row"
/>
이번 역순 정렬 검사에서도 index key는 c=A, b=B, a=C edited가 됐고, id key는 c=C edited, b=B, a=A를 유지했다. 로컬 편집값이 위치에 남는지, 원래 행을 따라가는지의 차이다.
다음 후보를 무조건 고유 ID라고 간주하면 안 된다.
- 표시 이름: 같은 이름의 항목이 있거나 사용자가 수정할 수 있다.
- 배열 길이: 삭제 후 추가하면 이미 존재하는 번호와 겹칠 수 있다.
- 렌더링 중 생성한 난수: 렌더링마다 다른 항목으로 인식될 수 있다.
- 아직 저장하지 않은 행의 DB ID: 여러 행이 모두 null일 수 있다.
ID는 행 생성 때 한 번 부여하고, 같은 편집 행이 유지되는 동안 바꾸지 않는 편이 명확하다. 서버의 영속 ID와 브라우저 편집 세션의 ID를 분리하면 저장 전후도 설명하기 쉽다.
부모가 입력값을 소유하는 기본 구성
다음은 새 행 추가·삭제·역순 정렬을 제공하는 작은 Vue 3 SFC 설계 예제다. clientKey는 이 컴포넌트 인스턴스의 편집 목록에서만 사용한다. 시스템 전체에서 고유한 ID나 보안 토큰으로 쓰는 값이 아니다.
<script setup>
import { ref } from 'vue'
let sequence = 0
function makeRow(value = '', serverId = null) {
return {
clientKey: `draft-${++sequence}`,
serverId,
value
}
}
const rows = ref([
makeRow('A', 101),
makeRow('B', 102),
makeRow('C', 103)
])
function addRow() {
rows.value.push(makeRow())
}
function removeRow(clientKey) {
rows.value = rows.value.filter(row => row.clientKey !== clientKey)
}
function reverseRows() {
rows.value = [...rows.value].reverse()
}
function buildPayload() {
return rows.value.map(({ serverId, value }) => ({
id: serverId,
value
}))
}
</script>
<template>
<section>
<div v-for="row in rows" :key="row.clientKey">
<label :for="`value-${row.clientKey}`">값</label>
<input :id="`value-${row.clientKey}`" v-model="row.value" />
<button type="button" @click="removeRow(row.clientKey)">삭제</button>
</div>
<button type="button" @click="addRow">추가</button>
<button type="button" @click="reverseRows">역순</button>
<pre>{{ buildPayload() }}</pre>
</section>
</template>
서버 전송을 실행하는 코드가 아니라 전송할 객체 모양을 보여 주는 예제다. 이 UI용 카운터는 컴포넌트를 새로 만들면 초기화된다. 여러 폼의 HTML input ID가 같은 문서에 함께 존재한다면 폼별 접두사 등으로 문서 내 ID 충돌도 피해야 한다.
서버가 신규 행의 ID를 반환하더라도 같은 편집 행이라면 serverId만 갱신하고 clientKey는 유지하는 방식이 가능하다. 서버 응답을 어떤 신규 행과 연결할지 식별 규약도 별도로 정해야 한다. 응답 배열 순서가 같을 것이라고 가정해서 임의로 맞추지 않는다.
위 전체 SFC의 추가 버튼·접근성·서버 저장까지 실행 검증했다고 주장하지 않는다. 이번 자동 검사는 key와 바인딩의 차이를 분리한 7개 시나리오에 한정한다.
Vue 폼 바인딩 문서는 JavaScript의 현재 상태를 값의 기준으로 둔다. 부모가 값을 소유하도록 정했다면 입력·검증·저장도 같은 상태를 바라보도록 구성한다.
안정적인 key도 로컬 초안 동기화를 대신하지 않는다
부모가 c의 값을 C server로 교체하는 검사를 추가했다. id key를 사용하더라도 로컬 draft 구조는 C edited를 유지했다. 반면 부모 값을 직접 바인딩한 구성은 C server를 표시했다.
이는 key의 실패가 아니다. ref(props.item.value)는 처음 값으로 ref를 만들 뿐, 이후 prop 갱신을 계속 복사하는 식이 아니다. Vue Props 문서도 초기값을 로컬 상태로 사용하는 경우 후속 prop 변경과 분리됨을 설명한다.
따라서 화면 요구를 먼저 정해야 한다.
| 요구 | 설계 방향 |
|---|---|
| 부모 값이 항상 기준 | 부모 상태에 바인딩하거나 자식의 update 이벤트를 부모가 처리 |
| 취소 가능한 행별 편집 | 자식 draft와 변경 여부를 명시적으로 관리 |
| 외부 새 데이터 도착 | 저장되지 않은 편집값과 충돌할 때 덮어쓸지 확인할 정책 정의 |
| 사용자가 명시적으로 초기화 | 해당 행의 draft를 재설정하는 동작 구현 |
모든 prop 변경을 watch해서 draft에 덮어쓰면 사용자가 입력 중인 값을 잃을 수 있다. 반대로 prop을 한 번 복사하고 끝내면 서버 재조회가 반영되지 않을 수 있다. key 선택과 편집 충돌 정책은 서로 다른 결정이다.
화면과 데이터에서 어디가 잘못됐는지 나눠 확인한다
화면이 틀어지면 먼저 삭제 직후 부모 배열의 ID·값을 확인한다. 부모 배열은 맞는데 행 라벨과 입력값의 짝이 다르면 key나 자식 로컬 상태를 점검한다. 부모 배열 자체에서 항목이 빠졌다면 그 이전의 변환·조회 과정을 살펴야 한다.
PostgreSQL JSONB 전개 시 행 누락 가이드는 DB 조회 단계에서 기본 행이 사라지는 경우를 다룬다. API 응답부터 누락된 문제를 Vue key 변경으로 해결할 수는 없다.
일반 Vue 행 컴포넌트의 key는 IBSheet 내부 행 식별자를 자동으로 설정하지 않는다. 그리드 라이브러리의 데이터 갱신·행 ID 규약은 별도로 확인해야 한다. 데이터 항목부터 화면 컬럼까지의 설계는 PostgreSQL 동적 컬럼·Spring Boot·Vue·IBSheet 글과 함께 볼 수 있다.
클라이언트 key를 유지해도 서버가 수정 가능한 행인지 판단하는 권한 검증은 필요하다. key는 인증 정보가 아니며 사용자가 보낸 ID만으로 업데이트를 허용해서는 안 된다. 관련 원칙은 Vue disabled와 서버 검증에 정리했다.
실행 기록과 적용 전 확인
글 끝에 첨부한 재현 묶음의 verify.mjs는 Vue 렌더러에 입력 이벤트를 전달하고 nextTick() 이후 DOM 값을 읽는다. results.json에 실제 버전과 일곱 결과를 기록했다. 별도 폴더에서 아래 명령으로 실행한다. 의존성 다운로드에는 네트워크가 필요하다.
npm ci --ignore-scripts
node verify.mjs
실제 서비스에서는 첫 행·중간 행·마지막 행 삭제와 재정렬을 확인하고, 저장 전 신규 행 여러 개 및 저장 후 서버 ID 부여도 점검한다. 한글 조합 중 삭제, 포커스 유지, 날짜 선택기 같은 외부 위젯은 브라우저에서 따로 확인해야 한다. 이번 jsdom 검사로 그런 상호작용까지 보장하지 않는다. Vue의 테스트 가이드도 테스트 종류와 검증 범위를 구분한다.
행의 위치, 화면 컴포넌트의 정체성, 값의 저장 위치를 각각 추적하면 문제를 좁힐 수 있다. 안정적인 key로 행을 구별하고, 입력값을 부모가 소유할지 로컬 초안으로 유지할지 명시하는 것이 이 예제의 수정 기준이다.