[번역] Vue의 반응성 시스템은 당신의 생각보다 더 강력합니다.

Photo by Bruno Oliveira on Unsplash

원문: https://levelup.gitconnected.com/vue-reactivity-system-is-more-powerful-than-you-think-37b1924d681b

Vue 3의 컴포지션 API와 개선된 반응성 시스템은 웹개발자들을 웃게 할 것입니다. 진짜루요.

최근 저는 대학교 과제로 앱을 개발하는 것에서부터 웹개발자로서 프리랜싱을 하는데까지 Vue를 사용해왔습니다. 저는 이제 Vue에 중독됐죠.

단순함과 진보적인 원칙을 바탕으로 하는 Vue는 프런트엔드 개발을 위해 많은 기능을 제공합니다. 핵심이 되는 요소는 바로 반응성 시스템입니다.

왜 반응성이 필요한가요?

프런트엔드 개발은 단순합니다. 간단히 말하면, 데이터가 동적이든 정적이든 백엔드에서 받아온 데이터를 보여주기만 하면 됩니다. 나머지는 그저 인터페이스가 ✨눈에 잘 들어오고✨, 사용자에게 안정적인 사용경험을 제공하는 것이죠. 적어도 "사용하기에 괜찮다" 정도의 말만으로도 충분합니다.

바닐라 자바스크립트를 이용해 로직을 구현하는 것도 괜찮습니다. 하지만 어떤 콜백이나, 조건, 이벤트가 발생했을 때 동적인 데이터를 화면에서 업데이트하게 되면 이야기가 달라집니다. 요약하면, 단순한 카운터 앱을 만들 때, 바닐라 자바스크립트를 이용하면 다음처럼 하게 될 것입니다.

원문에서 사용된 stackblitz iframe을 velog에서 정상적으로 렌더하지 않아, 링크와 스니펫으로 대신합니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <script src="https://cdn.tailwindcss.com"></script>
    <title>Simple Counter</title>
  </head>
  <body>
    <div class="w-full mt-8 flex justify-center">
      <span id="counter" class="text-3xl font-bold"></span>
    </div>
    <div class="w-full mt-4 flex flex-row space-x-4 justify-center">
      <button
        id="decrement"
        class="
          text-lg
          font-semibold
          bg-gray-500
          hover:bg-gray-600
          transition
          py-1
          px-4
          text-white
          rounded
          drop-shadow-xl
        "
      >
        -
      </button>
      <button
        id="increment"
        class="
          text-lg
          font-semibold
          bg-gray-500
          hover:bg-gray-600
          transition
          py-1
          px-4
          text-white
          rounded
          drop-shadow-xl
        "
      >
        +
      </button>
    </div>
    <div class="w-full mt-4 flex flex-row space-x-4 justify-center">
      <button
        id="reset"
        class="
          text-md
          font-medium
          bg-red-500
          hover:bg-red-600
          transition
          py-1
          px-4
          text-white
          rounded
          drop-shadow-xl
        "
      >
        Reset
      </button>
    </div>
  </body>

  <script type="text/javascript">
    document.addEventListener('DOMContentLoaded', function () {
      const counter = document.querySelector('span#counter');
      const incrementButton = document.querySelector('button#increment');
      const decrementButton = document.querySelector('button#decrement');
      const resetButton = document.querySelector('button#reset');

      let count = 0;

      if (counter) {
        // set counter initial value
        updateCounter();

        // set buttons onclick callback
        incrementButton.onclick = function () {
          count++;
          updateCounter();
        };
        decrementButton.onclick = function () {
          count--;
          updateCounter();
        };
        resetButton.onclick = function () {
          count = 0;
          updateCounter();
        };
      }

      function updateCounter() {
        // update the counter view
        counter.innerHTML = count;
      }
    });
  </script>
</html>

바닐라 자바스크립트를 사용하면, 보일러 플레이트 코드가 많이 필요합니다. DOM이 준비되길 기다려야 하고, DOM 컴포넌트를 쿼리해야 하고, onclick 이벤트 핸들러를 구성해야 하고, 그러고 나서 이벤트가 발생할 때 직접 view를 업데이트 해야 합니다.

맞습니다, 예시가 너무 간단할 수 있습니다. 하지만 복잡한 피쳐와 로직을 갖는 복잡한 앱을 상상해보세요. 단순하면서도 당연한 일을 하기 위해 너무 많은 노력이 들지 않나요?

이제 같은 기능을 Vue로 구현하면 어떻게 되는지 확인해봅시다. (Vite와 Typescript를 함께 사용했습니다)

원문에서 사용된 stackblitz iframe을 velog에서 정상적으로 렌더하지 않아, 링크와 스니펫으로 대신합니다.

<script setup lang="ts">
import { ref } from 'vue';
import PrimaryButton from './PrimaryButton.vue';
import DangerButton from './DangerButton.vue';

const count = ref(0);
</script>

<template>
  <div class="w-full mt-8 flex justify-center">
    <span ref="counter" class="text-3xl font-bold">{{ count }}</span>
  </div>
  <div class="w-full mt-4 flex flex-row space-x-4 justify-center">
    <primary-button ref="decrementButton" title="-" @click="count--" />
    <primary-button ref="incrementButton" title="+" @click="count++" />
  </div>
  <div class="w-full mt-4 flex flex-row space-x-4 justify-center">
    <danger-button ref="resetButton" title="Reset" @click="count = 0" />
  </div>
</template>

참고로, 위의 예시는 Vue의 컴포지션 API를 이용해 작성했습니다. 컴포지션 API는 더 이른 버전의 Vue에서 소개된 옵션 API의 후계자이죠. 요약하자면, 컴포지션 API는 더 튼튼하고, 단순하고, 콜백이 적은 API로 Vue 코드를 작성하게 해줍니다. 이 API들 사이의 차이는 다른 글을 통해 설명하겠습니다.

src/components/Counter.vue에서 다음 setup으로 똑같은 카운터 기능을 추가한 것을 보세요:

<script ... >
  ...
  
  const count = ref(0);
</script>
<template>
  ...
  <span>{{ count }}</span>
  <primary-button title="-"     @click="count--" />
  <primary-button title="+"     @click="count++" />
  <danger-button  title="Reset" @click="count = 0" />
</template>

ref@click 이벤트 바인딩과 함께 사용하는 것만으로도 같은 일을 할 수 있습니다. 나머지는 Vue의 반응성 시스템이 알아서 해줄 것입니다.

간단하지만 재밌죠. 그거 아세요? ref는 아주 멋진 놈입니다. 당신의 생각 이상으로 강력한 일을 할 수 있죠. src/components/Video.vue 파일을 살펴보세요.

<script ... >  
  ...
  const video = ref<HTMLVideoElement>();

  onMounted(async () => {
    await setupVideo(video);
    video.value.play();
  });
  
  async function setupVideo(video: HTMLVideoElement) {
    video.value.width = 640;
    video.value.height = 320;
    video.value.allowfullscreen = true;
    video.value.controls = true;
    video.value.type = "video/webm";
    video.value.src = " ... ";
  }
</script>
<template>
  <video ref="video" />
</template>

컴포지션 API의 ref는 정적 타입을 가진 DOM 요소에 별도의 설정없이 바인드될 수 있습니다. 그냥 HTML의 ref 태그명과 요소 타입과 같은 이름의 ref 변수를 선언하기만 하면 됩니다. 대부분의 DOM 타입은 HTMLElement를 상속받기 때문에 이 타입을 사용하면 됩니다.

이전 버전의 Vue는, ref를 가진 요소에 this.$refs를 통해 접근할 수 있습니다. 이 방법은 Vue 3에서는 더 이상 권장되지 않습니다. 특히 타입스크립트를 사용하고 있다면 정적 타이핑 이슈를 피하기 위해서요.

아직 안끝났습니다. ref는 활용되는 것에 비해 평가가 너무 박합니다. 하지만 더 어려운 얘기를 하기 전에, Vue에서의 상태 관리에 대해 이론적인 얘기를 해보죠.

상태 관리자를 만나보세요

Photo by CartoonNetworkLA on Tenor

Vue, React, 기타 등등의 프런트엔드 프레임워크를 시도해봤다면, "상태 관리"라는 용어를 쉽게 들을 수 있었을 것입니다. 아직 생소하다면, 간략히 설명해보겠습니다.

기술적으로 모든 Vue 컴포넌트 인스턴스는 이미 반응형 상태를 "관리"합니다. 위의 예시에서 설명한 것과 같이, ref로 컴포넌트 인스턴스의 상태를 관리한 것이죠.

"컴포넌트"는 다음 요소를 가지는 독립적인 단위입니다:

  • 상태는 앱을 구동시키는 데이터 저장소입니다.
  • view는 상태의 선언적 매핑입니다.
  • 액션은 view에서 일어나는 사용자의 입력에 대한 반응으로 상태를 바꿀 수 있는 방법입니다.


######"일방향 데이터 흐름" 개념의 간단한 모식도

예시로 사용된 간단한 카운터로 생각하면, 액션은 사용자가 'increment' 또는 'decrement', 'reset' 버튼을 누를 때 실행됩니다. 상태는 count 변수이고, view는 사용자에게 보이는 카운트 텍스트입니다.

이 과정이 "상태 관리"입니다.

하지만, 공통의 상태를 갖는 다수의 컴포넌트가 생길 때 상태관리의 난이도가 올라갑니다:

  • 다수의 view가 하나의 상태에 의존할 수도 있습니다.
  • 서로 다른 view에서의 액션이 하나의 상태 조각에 변경을 가해야 할수도 있습니다.

반응성 API를 이용한 상태 관리

Photo by CartoonNetworkLA on Tenor

때때로, 앱의 크기가 커지면서, 컴포넌트 레벨 상태를 넘어서 글로벌 앱의 상태를 관리할 필요가 생깁니다. 즉, 각 컴포넌트가 앱의 글로벌 상태로부터 값을 가져오거나 글로벌 상태를 변경할 수 있게 됩니다.

Vue의 반응성 API를 활용하면 이 기능을 쉽게 구현할 수 있습니다. 다수의 컴포넌트 인스턴스에서 공유해야 하는 상태가 있다면, ref로 반응형 객체를 만든 다음, 이를 여러 컴포넌트에서 가져와 사용할 수 있습니다.

Vue에서 그런 기능을 구현하려면, 그저 store 인스턴스를 다음처럼 만들면 됩니다:

// store.ts
import { ref } from 'vue'

export const store = ref({
  count: 0,
  increment() {
    this.count++
  }
})

그리고 Vue 컴포넌트에서 사용하면 되죠.

<!-- ComponentA.vue -->
<script setup lang="ts">
import { store } from './store'
</script>

<template>
  <button @click="store.increment()">
    From A: {{ store.count }}
  </button>
</template>

또 다른 Vue 컴포넌트에서도 사용할 수 있습니다.

<!-- ComponentB.vue -->
<script setup lang="ts">
import { store } from './store'
</script>
<template>
  <button @click="store.increment()">
    From B: {{ store.count }}
  </button>
</template>

이제 어디서 store가 변경이 됐든 간에, <ComponentA><ComponentB> 모두 view를 자동으로 업데이트할 것입니다. 단일 데이터 저장소(single source of truth)가 생긴 것이죠. 이는 store를 가져다 쓰는 컴포넌트라면 store 객체의 increment()를 호출하는 등의 방법으로 상태를 변경할 수 있다는 뜻이기도 합니다.

버튼의 클릭 핸들러에 store.increment()처럼 괄호를 넣은 것을 주목하세요. 컴포넌트의 메소드가 아니기 때문에, 적절한 this 컨텍스트를 가지고 메소드를 호출하기 위해 반드시 괄호가 필요합니다.

상태 관리 라이브러리

간단한 시나리오에서는 반응성 API만으로도 상태관리를 할 수 있겠지만, 규모있는 프로덕션 애플리케이션에서는 고려해야 하는 것들이 많습니다:

  • 팀 협업을 위한 더 강력한 컨벤션.
  • 타임라인, 컴포넌트 탐색, 시간여행 디버깅 등을 위한 Vue DevTools와의 연계.
  • HMR (Hot Module Replacement).
  • 서버 사이드 렌더링 지원.

Photo by turtlepirate223 on Tenor

위의 요구사항들을 고려해, PiniaVuex와 같은 Vue의 상태 관리 라이브러리를 사용할 수 있습니다.

Pinia는 위의 니즈를 모두 충족하는 상태 관리 라이브러리입니다. Vue 코어 팀이 관리하며, Vue 2와 Vue 3 모두와 호환됩니다. 기존 사용자들은 이전 공식 상태관리 라이브러리였던 Vuex에 익숙할 수도 있겠습니다.

결론

간단한 시나리오에서는 ref라는 반응성 API로도 쉽게 상태 관리자를 만들 수 있습니다. 그리고 복잡한 유즈 케이스와 HMR, 서버사이드 렌더링 지원 등을 위해서, Pinia나 Vuex 같은 Vue 상태 관리 라이브러리를 사용할 수도 있습니다. 전혀 문제 없는 방법입니다.

Vue의 반응성 API는 사용하기 쉽고 재밌습니다. 하지만 반응성 API에 대해선 아직 얘기할 게 많이 남아있습니다. 다음 글을 쓰는 것이 정말 기대되네요.

좋은 웹페이지 즐겨찾기