Vue 컴포넌트를 수 없이 만들면서 어떤 종류의 컴포넌트에 Provider 패턴을 적용하면 좋을 지에 대해서 고민해본 글입니다. 머릿 속에서만 존재하던 내용을 글로써 꺼낼 수 있게 도와주신 분에게 감사의 인사를 드립니다. React 부분은 아직 배우는 단계라 잘못된 부분이 있을 수 있습니다.
Vue, React와 같은 모던 프론트엔드 프레임워크의 개발 방법론은 대부분 컴포넌트와 상태 관리에 대해 초점을 두고 있다. 컴포넌트와 컴포넌트 간의 데이터 상호작용이 중요한데, React의 경우는 컴포넌트 함수를 실행하면서 JSX를 평가하고 화면에 렌더링되고, Vue의 경우는 양방향 바인딩을 통해 템플릿에 데이터를 렌더링하곤 한다.
방식은 다르지만 중점적인 것은 렌더링된 데이터를 부모 컴포넌트로부터 자식 컴포넌트에 어떻게 전달할지가 가장 중요하다. 일반적으로는 컴포넌트가 prop를 통해 데이터를 아래로 전달한다. React의 경우 부모의 상태의 setter를 자식에 전달하여 상호작용하고, Vue의 경우 Proxy로 구현된 반응형 패턴으로 인해 양방향 바인딩이 지원되어 emit을 통해 데이터를 올리게 된다.
트리 구조로 이루어진 컴포넌트에서 간단한 컴포넌트 구조의 데이터 전달은 prop, emit을 통해 전달이 비교적 쉽다. 현실세계를 빗데어 표현해보자면 하나의 대가족이 있고 유산을 전달하는 입장에서 직계 자손이지만 후대의 자손에게 또는 친척의 경우에 전달하는 것은 어려울 것이다. 이처럼 컴포넌트끼리 데이터 상태를 전달하는 것 또한 마찬가지이다.
상태를 전달하면서 단순히 전달 역할만 하는 컴포넌트가 생겨날 수 밖에 없는데, 이런 props drilling을 방지하기 위해 Vuex나 Pinia 같은 상태 관리 라이브러리가 컴포넌트 영역 외에서 데이터를 쥐고 있는 역할을 한다.
상태 관리 라이브러리 없이 그리고 불필요하게 중간 컴포넌트를 거치지 않고 데이터를 전달해주는 방식이 존재한다. 즉, Vue와 React에 모두 기본적으로 Provider Pattern 방식이 있다는 뜻이다.
비교를 한다면 다음과 같다.
Vue 2.2.0 버전 이후로 provide, inject가 추가되었으며, Vue 3의 provide(), inject()는 React의 createContext(), useContext()와 사용법이 비슷하다.
Provider Pattern을 사용하면 다음과 같은 장점이 존재한다.
테마변경이나 다국어 변경과 같이 간단한 동작이지만, 프로젝트 전역에 영향을 미치는 부분에 활용하면 좋을 것 같다. 하지만 그런 뻔한 부분 외에 실제로 프로젝트에 적용한 예시는 다음과 같다.
체크박스 그룹의 코드는 대략적으로 다음과 같을 것이다. 즉석으로 작성한 코드라서 작동안할 수 있다. 일종의 수도코드라고 생각하면 된다.
// CheckboxGroup.vue
<template>
<div
class="checkbox-group"
role="group"
>
<slot />
</div>
</template>
<script lang="ts" setup>
const { computed, provide } from 'vue';
type CheckboxValue = 'string' | 'Number' | 'Boolean' | 'Symbol';
interface Props {
modelValue: CheckboxValue[];
}
interface Emit {
(e: 'update:modelValue', value: CheckboxValue): void;
}
const props = withDefault(defineProps<Props>(), {
modelValue: () => [],
});
const emit = defineEmits<Emit>()
const mv = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val);
});
provide('CheckboxGroupProvider', mv);
</script>
체크박스 코드는 대략적으로 다음과 같을 것이다.
// Checkbox.vue
<template>
<label class="checkbox">
<input
v-model="mv"
type="checkbox"
...
/>
...
</label>
</template>
<script setup lang="ts">
const { computed, inject } from 'vue';
type CheckboxValue = 'string' | 'Number' | 'Boolean' | 'Symbol';
interface Props {
modelValue: CheckboxValue;
}
interface Emit {
(e: 'update:modelValue', value: CheckboxValue): void;
}
defineProps<Props>();
const emit = defineEmits<Emit>()
const mv = inject(
'CheckboxGroupProvider',
computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val)
})
);
</script>
위의 Vue 3의 Provider Pattern를 적용한 체크박스 코드의 핵심은 다음과 같다.
mv
를 넣는다. 그 뒤 inject() 함수에서 사용된다. 이렇게 구성한다면 <CheckboxGroup />
과 <Checkbox />
컴포넌트 사이에 어떠한 컴포넌트가 존재하더라도 부모-자식 컨텍스트 사이에서는 데이터를 넘길 수 있다.<Checkbox />
컴포넌트가 존재할 수 있으며, <CheckboxGroup />
컴포넌트 하위에 어떠한 위치에 <Checkbox />
컴포넌트가 있더라도 상호작용하여 반응할 수 있다. 이 구조에서 All Check나 Indeterminate 기능을 구현할 수 있다.<CheckboxGroup />
컴포넌트와 <Checkbox />
컴포넌트가 Provider, Injector로서 같이 동작할 수 있지만, Vue의 Inject() 함수 두 번째 파라미터(default값)를 활용하여 독립적으로 <Checkbox />
컴포넌트 만을 사용할 수 있다. 만약 inject() 함수에 반환된 값은Provider key가 없다면 두 번째 파라미터 default 코드로서 역할을 하는데, 이 위치에 반응형 변수를 넣어 Injector(Consumer) 역할을 하는 컴포넌트를 독립적인 컴포넌트로 사용할 수 있다.Provider 패턴을 안좋은 시선으로 바라보시는 분도 존재하고 실제로 공식문서에도 provide를 사용할 때 주의하라는 내용도 존재한다.
중간 컴포넌트를 거치지 않는 부분에서는 장점으로 바라볼 수 있으나, 상태 관리 라이브러리를 사용하지 않는다는 부분 때문에 데이터 관리적인 측면에서 불편하다는 단점도 존재한다. 개인적으로는 격리된 컨텍스트가 명확하다면 비즈니스 로직이 없는 컴포넌트에서는 블랙박스처럼 사용하고 활용하는데 있어 매력적인 패턴이라고 생각한다.
컴포넌트를 구성하는 패턴은 여러가지가 존재하지만 무조건 정답은 없으며, 프로젝트에 패턴을 적용하는 로직을 오용, 남용하지 않는 것이 가장 중요하다고 생각한다.