akizora.dev
Back to Garden
VueReactJavaScriptFrontendArchitecture

양방향의 환상과 단방향의 진실: Vue v-model과 React useState의 데이터 흐름

UI는 사용자의 입력을 화면에 즉각 렌더링해야 하고, 애플리케이션은 그 값을 상태로 보관하여 비즈니스 로직에 반영해야 합니다. 여기서 중요한 것은 "입력값과 상태를 어떻게 결합할 것인가"보다 상태가 어떤 경로를 거쳐 변경되는지 명확히 설명하고 추적할 수 있는가입니다.

Vue의 v-model과 React의 useState는 이 문제를 서로 다른 문법으로 해결합니다. 하나는 값을 양방향으로 결합하는 것처럼 보이고, 다른 하나는 값과 변경 함수를 분리하여 명시적으로 드러냅니다. 겉모습만 보면 두 프레임워크를 '양방향'과 '단방향'의 대립 구도로 구분하기 쉽지만, 두 방식 모두 "값은 아래로 내려가고, 변경 요청은 위로 올라가는(Props down, Events up)" 단방향 데이터 흐름의 원칙을 동일하게 따릅니다. 차이는 그 구조를 코드 표면에 얼마나 드러내는가에 있습니다.

다이어그램을 렌더링하고 있어요...

v-model은 값을 직접 공유하지 않습니다

Vue에서 <input v-model="name" />은 사실 아래 두 줄의 표준 바인딩을 간결하게 축약한 문법입니다.

  • :value="name" (부모의 상태를 input의 value로 전달)
  • @input="name = $event.target.value" (사용자 입력을 감지하여 부모 상태 갱신)

사용자가 글자를 입력하면 브라우저 이벤트가 발생하고, 이벤트 핸들러가 상태를 갱신하며, 변경된 상태가 다시 화면에 반영됩니다. DOM과 상태가 하나의 메모리 값을 공유하는 것이 아니라, 값을 내려보내는 통로와 이벤트를 올려보내는 통로를 단일 문법으로 결합해 둔 것입니다.

컴포넌트 단위로 확장하면 이 단방향 구조가 더욱 명확하게 드러납니다.

<CustomInput v-model="name" />은 내부적으로 다음과 같이 처리됩니다.

<CustomInput
  :model-value="name"
  @update:model-value="name = $event"
/>

자식 컴포넌트는 부모가 전달한 modelValue를 읽기 전용으로 참조합니다. 값을 변경하고자 할 때는 부모의 값을 직접 수정하지 않고, update:modelValue 이벤트를 부모에게 전달합니다. 부모가 이벤트를 수신하여 자신의 상태를 갱신하면, 새롭게 갱신된 값이 다시 자식에게 전달됩니다.

쉽게 비유하자면 '용돈 요청' 의 구조와 같습니다. 자식이 부모의 지갑에 직접 손을 넣어 돈을 꺼내지 않고 "용돈이 필요합니다"라는 요청(이벤트) 을 보내면, 지갑의 잔액을 변경하는 최종 결정은 부모 가 내립니다. 그리고 변경된 금액을 자식에게 다시 건네주는 형태입니다.

결국 v-model은 상태를 임의로 변경하는 통로가 아니라, 부모가 값을 내려주고(Props down), 자식이 변경 요청을 올리는(Events up) 단방향 규약을 한 줄로 축약한 문법에 해당합니다. 상태 변경의 최종 결정권은 언제나 부모에게 존재합니다.


편의성은 복잡성을 완전히 제거하지 않습니다

v-model은 값 전달과 입력 이벤트 수신이라는 반복적인 보일러플레이트 코드를 효과적으로 줄여줍니다. 단순한 텍스트 입력, 체크박스, 셀렉트 박스에서는 가독성이 높고 작성이 간결합니다.

<script setup>
import { ref } from "vue";
 
const name = ref("");
</script>
 
<template>
  <input v-model="name" placeholder="이름을 입력하세요" />
</template>

동일한 동작을 명시적으로 풀어서 작성하면 다음과 같습니다.

<script setup>
import { ref } from "vue";
 
const name = ref("");
</script>
 
<template>
  <input
    :value="name"
    @input="name = $event.target.value"
    placeholder="이름을 입력하세요"
  />
</template>

단순한 입력창에서는 축약 문법이 동작을 은폐하기보다 반복적인 코드를 깔끔하게 덜어주는 역할을 수행합니다.

그러나 복잡한 비즈니스 규칙 이 개입하기 시작하면 접근 방식을 달리해야 합니다. 단순한 On/Off 스위치라면 전선 하나로 충분하지만, 밝기 조절·타이머·원격 제어가 결합된 시스템이라면 별도의 제어 회로가 필요한 것과 같습니다.

공백 정규화, 권한 검증, 서버 유효성 체크, 되돌리기(Undo) 등의 정밀한 로직이 요구된다면 단순 바인딩을 지양하고 명확한 상태 변경 규칙 을 수립해야 합니다. 이러한 로직은 computed setter나 명시적 이벤트 핸들러, 독립된 액션 함수로 격리하는 것이 안전합니다. 비즈니스 규칙을 단순 입력 문법 뒤로 숨겨버리면 시스템의 변경 흐름을 추적하기 어려워지기 때문입니다.


useState는 변경 경로를 코드에 명시적으로 기록합니다

React의 제어 컴포넌트는 상태값과 상태 변경 함수를 분리하여 다룹니다.

function NameInput() {
  const [name, setName] = useState("");
 
  return (
    <input
      value={name}
      onChange={(event) => setName(event.target.value)}
      placeholder="이름을 입력하세요"
    />
  );
}

다소 장황해 보일 수 있으나, 상태가 화면에 렌더링되고 다시 갱신되는 전 과정을 단계별로 투명하게 드러냅니다.

name은 현재 렌더링 시점에 평가된 불변의 상태값입니다. 사용자가 키를 입력하면 브라우저가 change 이벤트를 발생시키고, onChange 핸들러가 새 문자열을 추출하여 setName에 전달합니다. setName은 다음 렌더링 상태를 예약하며, React는 새로운 상태를 기반으로 컴포넌트를 다시 실행하여 변경된 name을 UI에 반영합니다.

setName을 호출해도 변수가 즉시 바뀌지 않는 구조적 이유

setName을 호출한 직후 name 변수의 값이 즉시 변경되지 않는 이유는, React의 렌더링 모델이 '스냅샷(Snapshot)' 기반으로 동작하기 때문입니다.

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  setName(event.target.value);
  alert(name); // 방금 입력한 값이 아니라, 현재 렌더링 시점에 캡처된 이전 상태가 출력됩니다.
}

현재 실행 중인 이벤트 핸들러는 '이미 렌더링된 스냅샷' 속의 변수를 참조하고 있습니다. setName은 즉각적인 변수 할당이 아니라 "다음 렌더링에 이 값을 적용해 달라"는 상태 갱신 예약 입니다.

동일한 이벤트 루프 내에서 이전 상태를 기반으로 연속적인 계산을 수행해야 한다면, 클로저 변수 참조 대신 업데이터 함수(Updater Function)를 사용하는 것이 안전합니다.

setCount((previousCount) => previousCount + 1);
setCount((previousCount) => previousCount + 1);

이와 같이 작성하면 직전 갱신 결과를 순차적으로 인계받아 처리하므로 정확하게 2가 증가합니다.

불변성(Immutability)을 유지해야 하는 이유

객체나 배열을 상태로 관리할 때 불변성 을 준수해야 하는 이유 역시 스냅샷 원리와 직결됩니다.

비유하자면 '원본 서류의 수정'과 '새 서류 복사본의 제출' 의 차이입니다.

기존 객체의 속성을 직접 변경하고(user.name = "민수") 해당 참조를 그대로 전달하면, 원본 서류에 화이트를 칠해 제출한 것과 같습니다. React는 메모리 주소 비교(얕은 비교)를 통해 변경 여부를 판단하므로, 참조가 동일하면 변경이 없다고 판단하여 렌더링을 생략할 수 있습니다.

user.name = "민수";
setUser(user); // 동일 참조 전달: React가 변경 사항을 감지하지 못할 수 있습니다.

따라서 변경 사항을 반영한 새로운 복사본 객체 를 생성하여 전달해야 합니다.

setUser((previousUser) => ({
  ...previousUser,
  name: "민수",
}));

불변성은 추상적인 이론이 아니라, 상태의 변화를 '기존 메모리의 변형'이 아닌 '새로운 값의 생성' 으로 표현하여 시스템이 변화를 명확하고 안정적으로 감지하도록 만드는 규약입니다.


차이는 데이터 흐름이 아니라 노출 방식에 있습니다

흔히 "Vue는 복잡성을 숨기고, React는 복잡성을 드러낸다"고 이야기합니다. 여기서 복잡성이란 코드가 난해하다는 뜻이 아니라, 화면과 상태를 동기화하기 위해 필연적으로 수반되는 4단계의 기계적인 배선 과정 을 의미합니다.

  1. 값 주입: 현재 상태를 화면 요소의 value로 전달 (:value, value={...})
  2. 이벤트 감지: 사용자의 입력을 이벤트로 포착 (@input, onChange={...})
  3. 상태 갱신: 이벤트에서 새 값을 추출하여 상태 저장소에 변경 요청 (name = $event, setName(...))
  4. 화면 동기화: 변경된 상태를 기준으로 컴포넌트 재렌더링
다이어그램을 렌더링하고 있어요...
  • Vue의 v-model (자동 변속기): 반복적인 배선 과정을 프레임워크 문법 뒤로 캡슐화합니다. 운전자가 가속 페달만 밟으면 기어가 자동으로 전환되듯 생산성이 높고 코드가 간결합니다.
  • React의 useState (수동 변속기): 모든 연결 과정을 일반 JavaScript 코드로 코드 표면에 드러냅니다. 클러치와 기어를 직접 조작하듯 타이핑은 늘어나지만, 데이터가 어떤 함수를 거쳐 전이되는지 그 경로를 완벽하게 제어할 수 있습니다.

규모가 커질수록 '단일 소유권'이 중요해집니다

시스템의 규모가 확장되고 다수의 컴포넌트, 비동기 API, 캐시 레이어가 결합될 때 가장 중요한 원칙은 단일 소유권(Single Ownership), 즉 '단 하나의 원본 장부' 원칙 입니다.

동일한 사실을 나타내는 데이터는 반드시 단 하나의 원본 상태에서 관리되어야 합니다. 하위 컴포넌트는 해당 데이터를 조회하여 렌더링할 뿐이며, 변경이 필요할 때는 상태 소유자에게 명확한 의도가 담긴 요청(이벤트 또는 액션)을 전달해야 합니다.

이 원칙이 확립되면 오류가 발생했을 때 변경 경로를 역추적하기가 매우 수월해집니다. setName은 국소적인 상태 갱신 통로이며, update:modelValue는 컴포넌트 경계를 넘나드는 표준 통로입니다. 도메인이 복잡해지면 이 통로는 saveProfile, approveOrder와 같이 비즈니스 의미가 담긴 명시적 액션 함수로 발전합니다.


결론: 문법보다 중요한 데이터 흐름의 원칙

Vue를 사용하든 React를 사용하든 프레임워크 자체가 소프트웨어의 아키텍처 품질을 보장하지는 않습니다.

  • 상태마다 명확한 단일 소유자가 정의되어 있는가?
  • 하향식 데이터 전달과 상향식 변경 요청이 명확히 분리되어 있는가?
  • 상태 전이 과정에 명확한 의도와 검증 로직이 부여되어 있는가?

이 세 가지 규율을 확립하는 것이 본질입니다. 두 문법을 대결 구도로 보기보다, 단 하나의 원본과 추적 가능한 상태 변화를 구현하기 위한 도구 로 바라볼 때 더욱 견고한 프론트엔드 아키텍처를 설계할 수 있습니다.