그 무한한 욕망을 위해서

Vue의 반응형 시스템에 대한 고찰

井蛙不可以語海

우물 속에 있는 개구리에게는 바다에 대해 설명할 수가 없다 _ 장자

한때 프레임워크 없이 자바스크립트만으로도 프론트엔드 개발을 할 수 있지 않을까, 생각한 적이 있었다. 이제는 프레임워크가 왜 필요한지 알지만, 왜 존재하는지는 잘 몰랐다.

Vue의 반응형 시스템에 대해 조사하면서 ES6에서 정식으로 도입된 Proxy API에 대해 알게 되었고, Vue 팀이 왜 Proxy 를 사용했는지, 그리고 Proxy 는 무엇을 위해서 생겨났는지를 알게 되었다. 그러면서 자연히 프로그래밍 언어, 프레임워크, 개발자의 관계에 대해 깊게 생각해볼 수 있었다.

우리가 원하는 것

1년 전에 JSP와 연결된 클라이언트 코드를 수정하는 일을 맡은 적이 있다. jQuery와 자바스크립트로 써진 코드를 순수 자바스크립트로 전환하는 일이었다. 다른 건 다 괜찮았는데, 상태를 관리하는 게 문제였다. 아주 사소한 상태 관리인데도 순수 자바스크립트로는 어떻게 상태를 관리할 수 있는지 몰라서, 서버 사이드에서 필요한 상태를 '미리’ 전달받을 수 있게 조치해서 해결했던 기억이 난다. 그 시절의 나는 아주 단순하게 상태 관리가 필요하면 ‘그건 React나 Vue가 할 수 있는 일’이라고 퉁치고 넘어갔었다.

내가 상태 관리에 대한 경험을 미리 꺼낸 것은 상태 관리가 어쩌면 현대의 개발자들에게 가장 중요한 클라이언트 제어 방식 중 하나란 생각 때문이다. 고대의 웹은 어떠했던가? 유저가 어떤 버튼을 클릭하면 몇 초를 기다리는 건 물론이요, 페이지 전체를 새로고침하는 일이 빈번해서 화면 깜박임(Flickering)이 수시로 발생했다. 메모리(자바스크립트 엔진의 힙 메모리)에 상태를 비롯한 데이터를 저장하면 유저가 클릭과 같은 행동을 하는 즉시 변화값이 브라우저에 렌더링 되어 1ms 단위로 유저와 상호작용하게 된다.

상태 관리를 위해 Vue 팀이 최초에 선택했던 건 Object.defineProperty 였다. Object.defineProperty 는 자바스크립트에서 메타프로그래밍을 지원하는 API 중 하나다. 후에 더 자세히 다룰 Proxy API의 조상격인 API로, 자바스크립트 오브젝트, 즉 자바스크립트 객체에 새로운 속성을 부여하고 이미 존재하는 속성을 수정할 수도 있다.

메타프로그래밍이란, ‘프로그램을 프로그래밍’하는 걸 의미한다. 그럼 자바스크립트의 Class 문법에서 Class 내부에 메서드를 정의하는 건 메타프로그래밍일까 아닐까? 답은 ‘메타프로그래밍이 아니다’다. 메타프로그래밍은 단순히 기능을 추가하는 프로그래밍을 의미하지 않는다. 기능 추가를 넘어 아예 새로운 동작 방식을 창조하는 것에 가깝다.

const person = {
  name: 'tyange'
}

console.log(person.name); // 'tyange'

이런 간단한 코드를 예로 들어보자. 일반적으로 자바스크립트 문법에서는 위의 코드에서처럼 객체의 프로퍼티에 접근하면 해당 프로퍼티의 값을 반환한다.

그런데 만약 personname 프로퍼티에 접근할 때 실제 name 프로퍼티의 값이 무엇이든 ‘chulsoo’라는 문자열을 반환하도록 한다면 어떨까?

const person = {
  name: 'tyange'
};

Object.defineProperty(person, 'name', {
  get() {
    return 'chulsoo';
  }
});

console.log(person.name);  // 'chulsoo'

이런 ‘동작 방식의 변화’는 단순히 name 이란 프로퍼티 앞에 ‘Kim’이라는 문자열을 붙여서 반환하라는 식의 '기능 추가’하고는 완전히 다르다. 자바스크립트 객체의 동작 자체를 바꿨기 때문이다. 바로 위의 코드에서 볼 수 있듯이 Object.defineProperty 는 타겟이 되는 객체의 동작 방식 자체를 바꿀 수 있도록 해준다. 잠시 Vue2의 Options API를 떠올려보자. Options API에서는 data 라는 이름의 필드를 사용할 수 있다. 이 data 프로퍼티가 변화하는 것을 감지하기 위해 아래와 같이 구현할 수 있다.

const data = {};

Object.defineProperty(data, 'name', {
  get() {
    console.log('name 읽힘');
    return this._name;
  },
  set(value) {
    console.log('name 변경됨');
    this._name = value;
  }
});

data.name = 'Kim';  // "name 변경됨"
data.name;          // "name 읽힘"

이 구현에서 get 메서드와 set 메서드의 동작 방식을 변경한 것을 알 수 있다. 원래는 단순한 값의 반환과 할당이었으나 이제는 값을 반환하기 전과 값에 새로운 값을 할당하기 전에 로그를 출력하게 되었다.

왜 이런 식의 동작 방식 변화가 필요한 걸까? 우리가 원하는 진짜 상태 관리는 단순히 상태로 설정한 값 자체를 가지는 것을 뜻하지 않는다. 대개 '상태’로 지정된 값이 있다면 그 값을 추적하길 원한다. 문제는 이런 추적 기능이 자바스크립트 언어의 본연의 기능으로 존재하냐는 것이다. 이를테면 객체에 붙일 수 있는 옵저버 비슷한 게 있나? 만들어내려면 얼마든지 만들어내겠지만 자바스크립트가 이런 기능을 기본적으로 제공해주진 않는다. 그래서 자바스크립트 언어의 기능 확장이 가능한 Object.definePropertyProxy 와 같은 메타프로그래밍을 위한 API가 추가되었고, Vue 팀은 최초의 Vue 반응형 시스템 구현에 Object.defineProperty 를 사용한 것이다.

새 술은 새 부대에

Object.defineProperty 가 Vue 팀이 만족할 만큼 완벽한 것이었다면, 우리가 Proxy 를 마주할 일도 없었을지 모른다. 하지만 Object.defineProperty 는 분명한 단점이 있었다.

  • 새로운 속성 추가를 감지하지 못해서 Vue.set이라는 별도 API를 사용해야 했음.
  • 배열 인덱스 직접 수정이나 length 변경도 감지하지 못함.
  • Map, Set 같은 컬렉션 타입을 지원 안 함.
  • 초기화 시점에 모든 속성을 재귀적으로 순회하며 getter/setter를 정의해야 해서 성능 오버헤드 발생.

ES6 ProxyObject.defineProperty 의 단점을 모두 극복한 API로 평가된다.

문제definePropertyProxy
새 속성 추가❌ 감지 못함 ($set 필요)✅ 자동 감지
배열 인덱스❌ 감지 못함✅ 자동 감지
배열 length❌ 감지 못함✅ 자동 감지
Map, Set❌ 지원 불가✅ 완전 지원
초기화 성능❌ 느림 (전체 순회)✅ 빠름 (지연 평가)
메모리 사용❌ 많음 (모든 속성)✅ 적음 (필요할 때만)
속성 삭제❌ 감지 못함✅ 자동 감지

Proxy 로의 전환이 마냥 쉽지는 않았던 것 같다. Proxy 는 모든 프로퍼티 접근을 가로채기 때문에 추적이 필요 없는 경우를 정교하게 구분해야 했다. Vue 팀은 이를 위해 깊은 반응성으로 변환되지 않도록 하는 shallowRef, shallowReactive 같은 API들을 추가했다. Proxy 를 이용한 반응형 시스템을 구축하면서 이와 연계된 effect 시스템도 재설계해야만 했다. Vue 팀은 의존성 추적 로직 전체를 다시 썼고, 다시 작성된 effect 시스템은 @vue/reactivity 패키지로 독립되었다.

한 가지 중요한 사실은, Vue3의 반응형 시스템의 주축이 되는 refreactive 는 구현 방식에 차이가 있다는 것이다. 내가 계속 언급한 Proxyreactive 구현에는 사용되었지만 ref 에는 사용되지 않았다. ref 는 일반적인 자바스크립트의 Class 문법을 이용해서 구현되었고, reactiveProxyReflect , WeakMap 까지 ES6의 최신 문법들을 사용해 만들어졌다. 이렇게 구축된 Vue의 반응형 시스템은 Vue 프레임워크가 더 직관적인 API와 더 나은 성능을 제공하는 단초가 되었다.

우물 밖 세상은 바다였다

나는 거꾸로 생각해보게 되었다. 만약에 자바스크립트 언어 자체에 반응형 시스템이 내장되어 있었다면 어땠을까? 아마 현존하는 거의 프론트엔드 프레임워크 중 대부분이 만들어지지 않았을 것이다. 그렇다면 프로그래밍 언어의 설계에서부터 이런 기능을 미리 예상하고 넣을 순 없었을까?

언어에 모든 것을 담을 수 없는 이유는 간단하다. 그건 인간의 욕망이 무한하기 때문이다. 자바스크립트는 브라우저, 서버, 모바일, IoT를 아우른다. 만약 언어가 특정 방식을 강제한다면, 그것은 다른 영역의 족쇄가 된다. Python에 Django와 Flask가, Java에 Spring과 Micronaut이, Rust에 Actix와 Tokio가 각각 존재하는 이유다. 언어는 Proxy 같은 메타프로그래밍 도구만 제공하고, 나머지는 개발자에게 맡긴다.

이에 따라 언어는 가능한 한 중립적이어야 하지만, 동시에 너무 보편적인 패턴은 표준화를 통해 성능과 상호운용성을 얻는다. Promise가 그랬고, async/await가 그랬으며, 이제 TC39에서는 Signals를 표준에 추가하는 것을 논의하고 있다. 거의 모든 프론트엔드 프레임워크가 반응형 프로그래밍을 필요로 하는 상황에서, 이를 언어 차원에서 지원하자는 제안이다. 하지만 이것조차 강제가 아닌 선택지의 확장일 뿐이다. 개발자는 표준화된 Signals를 쓸 수도, Proxy로 완전히 새로운 방식을 만들 수도 있다. Vue의 반응형 시스템은 자바스크립트라는 바다를 항해하는 한 척의 배에 가깝다. 이 글을 쓰면서 Vue 팀이 이 모든 의사 결정 과정을 어떻게 헤쳐나갔을지 상상해보곤 했다. 어디로 가는지 알 수 없을 때, 그들은 무엇을 기준으로 자신들의, 팀의 위치를 파악했을까. 배가 어디로 향할지는 LLM이 뒤덮은 2025년에도 여전히, 인간의 몫인 것 같다.

#Vue #ES6 #Proxy #reactivity