본문으로 건너뛰기

Getting Started with Provide/Inject in Vue.js

· 약 9분

Vue의 강력한 Provide/Inject API를 통해 props와 events를 깊게 살펴보겠습니다.
이 글에서는 props drilling을 피하고, 타입 안정성과 고급 패턴을 활용해 더 깔끔하고 유지 보수하기 쉬운 컴포넌트를 만드는 방법을 소개합니다.

정보

이 글은 Getting Started with Provide/Inject in Vue.js를 번역한 글입니다.

Vue.js의 Provide/Inject API는 컴포넌트 디자인을 한단계 더 높은 수준으로 끌어올려주는 강력한 기능 중 하나입니다. Vue.js 생태계의 많은 라이브러리들이 '마법'같은 기능을 제공하기 위해 이 API를 사용하고 있습니다.

한가지 예시로 Tab 컴포넌트를 들수 있습니다. 일반적으로 아래와 같은 Tab 컴포넌트를 접하게 됩니다.

<Tab>
<TabPanel title="Tab 1">
<p>Tab 1 content</p>
</TabPanel>
<TabPanel title="Tab 2">
<p>Tab 2 content</p>
</TabPanel>
<TabPanel title="Tab 3">
<p>Tab 3 content</p>
</TabPanel>
</Tab>

또다른 예시로 여러개의 accordion 아이템들을 제어하기 위한 accordion 컴포넌트를 들 수 있습니다.

<Accordion>
<AccordionItem title="Accordion 1">
<p>Accordion 1 content</p>
</AccordionItem>
<AccordionItem title="Accordion 2">
<p>Accordion 2 content</p>
</AccordionItem>
<AccordionItem title="Accordion 3">
<p>Accordion 3 content</p>
</AccordionItem>
</Accordion>

위의 예시를 살펴보면, Tab이나 Accordion 컴포넌트가 자식 컴포넌트를 제어 하는 방식이나, 서로 통신하는 방식에 의문을 가질 수 있습니다. 우리가 예시코드에서 발견할 수 있는 유일한 관계는 자식 컴포넌트를 포함하는 부모컴포넌트 밖에 없기 때문입니다.

🌃 Provide/Inject가 동작하는 방식

Provide/Inject API는 provideinject 두개의 함수들로 구성이 되어 있으며, vue 패키지로 부터 임포트해 사용합니다.

🎆 Provide

provide 함수를 호출하는 컴포넌트들은 "제공자(provider)"로 볼 수 있으며, 컴포넌트 트리의 깊이와 무관하게 자식 컴포넌트들에게 값들을 전달할 수 있습니다.

컴포넌트 트리상에 많은 provider들이 존재할 수 있기 때문에, 각각의 제공되는 값들은 고유한 키들로 구분됩니다.

import { provide } from 'vue';

provide('key', 'value');

한가지 명심해야할 점은 provide 함수는 반드시 컴포넌트의 setup 함수 내에서 호출되어야 합니다. 예를들어 onMounted 이나 watch 또는 이벤트 핸들러 내부에서 비동기적으로 호출하면 동작하지 않을 수 있습니다.

아래 예시들은 컴포넌트 내부에서 provide를 호출하는 방법을 보여줍니다.

import { provide } from 'vue';

export default {
setup() {
// ✅ setup 함수 내부에서 호출했기 때문에 정상적으로 동작합니다.
provide('key', 'value');
},
};
<script setup>
import { provide } from 'vue';
// ✅ setup 블록 내부에서 호출했기 때문에 정상적으로 동작합니다.
provide('key', 'value');
</script>
<script setup>
import { onMounted, watch, computed, provide, inject } from 'vue';

// ❌ lifecycle hook 내부에서 호출하면 동작하지 않습니다.
onMounted(() => {
provide('key', 'value');
});

watch(
() => props.value,
(value) => {
// ❌ watch 함수 내부에서 호출하면 동작하지 않습니다.
provide('key', 'value');
},
);

const computedValue = computed(() => {
// ❌ computed 함수 내부에서 호출하면 동작하지 않습니다.
return inject('key');
});

function handleClick() {
// ❌ 이벤트 핸들러 내부에서 호출하면 동작하지 않습니다.
provide('key', 'value');
}
</script>

지금까지 provide 함수를 호출하는 방법을 살펴보았습니다. 이제 컴포넌트 내부에서 값을 주입(inject)하는 방법을 살펴보겠습니다.

🎆 Inject

Provider 컴포넌트의 값을 받기 위해서는 자식 컴포넌트가 inject 함수를 호출해야 합니다. 원하는 값의 키를 인자로 받아 연관있는 값을 반환 합니다.

import { inject } from 'vue';

inject('key'); // returns value

만약 키를 찾을 수 없는 경우, injectundefined를 반환하고 콘솔에 경고를 출격합니다.

inject('key'); // return undefined and warn

두번째 인자로 fallback 값을 전달함으로써 경고를 제거할 수 있습니다. 이 경우 키를 찾을 수 없는 경우에도 경고는 노출되지 않습니다.

// 키를 찾지 못하는 경우 'fallback value'를 반환합니다.
inject('key', 'fallback value');

지금까지 provideinject 함수의 기본적인 사용방법을 살펴보았고, 이제 어떻게 유용하게 사용할 수 있는지 살펴보겠습니다.

🌃 Props Drilling

컴포넌트 디자인에 사용되는 훌륭한 규칙이 있습니다. 바로 'props는 아래로, events는 위로' 입니다. 이는 자식 컴포넌트에게 props를 아래로 전달하고, 자식 컴포넌트로 부터 발생되는 이벤트를 부모가 수신한다는 의미입니다.

애플리케이션을 만들때 항상 마주치는 문제중 하나는 동일한 props와 값들이 다양한 레벨의 컴포넌트들에게 전달해야하는 경우입니다.

이는 번거로울 수 있으며 컴포넌트 트리를 평탄화하도록 강제합니다. 이는 유지보수성이 높고 재사용 가능한 컴포넌트를 만드는데 이상적이지 않습니다.

디자인 시스템의 모든 컴포넌트에 theme props를 전달해야하는 경우를 생각해보겠습니다.

이는 theme props가 해당 props를 필요로하는 모든 컴포넌트에 전달되어야 하며, 사용하지는 않더라도 그 자식 컴포넌트가 필요한 경우 전달되어야 함을 뜻합니다.

이제 왜 이것이 "props drilling"이라고 불리는지 알 수 있을 것입니다. theme props가 컴포넌트 트리의 아래로 "drilling" 되고 있습니다.

이 경우 provideinject를 사용해야 합니다. theme를 root에서 제공하면 애플리케이션의 모든 컴포넌트는 props정의 없이 해당 값을 주입(inject)해 사용할 수 있습니다.

<script setup>
import { provide } from 'vue';

provide('theme', 'dark');
</script>

위와 같이 주입한 후, 아래와 같이 theme를 애플리케이션의 어떤 컴포넌트에서나 접근해 사용할 수 있습니다.

<script setup>
import { inject } from 'vue';

const theme = inject('theme');
</script>

위에서 살펴본 예시는 컴포넌트간 양방향 통신(two-way communication)이 필요없는 경우에 유용합니다. 만약 필요한 경우는 어떻게 해야할까요? 컴포넌트 트리상에서 깊이를 알 수 없는 자식 컴포넌트가 provider 컴포넌트와 통신해야하는 경우는 어떻게 해야할까요?

초반에 자식 컴포넌트들에 전달하고 싶은 어떤 값도 전달 할 수 있다고 언급했었고, 그 값에 반응형도 포합됩니다.

🌃 Accordion 컴포넌트 만들기

처음에 살펴보았던 Accordion 컴포넌트를 만들어 봅시다.

Accordion 컴포넌트가 알아야 하는 것은 어떤 accordion이 선택됐는지 입니다.

AccordionItem 컴포넌트의 경우는 어떨까요? 각각의 AccordionItem은 자신이 숨겨졌는지 보여야하는지를 알아야 합니다. 이는 현재 선택된 아이템의 값에 접근해 결정할 수 있습니다.

다시말해, 어떤 accordion 아이템이 선택되었는지만 전달하면 된다는 뜻 입니다. id로 표현할 수 있지만, 단순하게 각각의 아이템에 title을 고유한 식별자로 사용하겠습니다.

먼저, Accordion 컴포넌트를 만들어보겠습니다. 컴포넌트의 스타일은 여러분에게 맡기고, 여기서는 accordion 컴포넌트의 가장 간단한 구조만 살펴보겠습니다.

<script setup>
import { provide, ref } from 'vue';

const selected = ref('');

provide('selected', selected);
</script>

<template>
<div>
<slot>
</div>
</template>

이제 AccordionItem 컴포넌트를 만들어 봅시다.

<script setup>
import { inject } from 'vue';

const props = defineProps({
title: {
required: true,
type: String,
},
});

const selected = inject('selected');

function onClick() {
selected.value = props.title;
}
</script>

<template>
<div>
<div @click="onClick">
<!-- content의 title 현재 선택된 값과 일치할때 노출 -->
<div v-if="title === selected">
<slot />
</div>
</div>
</div>
</template>

조금 복잡해졌지만 특별한건 없습니다. 주입(inject)된 selectedAccordionItem 컴포넌트에 전달된 title props와 비교하여 일치하는 경우, 컨텐츠를 노출하고 그렇지 않으면 숨깁니다.

이게 다입니다! 애플리케이션에서 사용할 수 있는 Accordion 컴포넌트를 만들었습니다. 우리가 할것은 반응형 값을 컴포넌트 트리에 전달하는 것 입니다.

결과물은 여기에서 확인할 수 있으며, 깔끔하게 보이기위해 스타일을 추가했습니다.

글을 마무리 짓기전에 몇가지 주의사항과 팁들을 살펴보겠습니다.

🌃 여러개의 값 전달하기

만약 사용자의 인증정보처럼 여러개의 값들을 자식 컴포넌트에 전달하고 싶은 경우는 어떻게 할까요?

우리는 원하는 것은 무엇이든 전달할 수 있지 않나요? 보통 함수에서 여러개의 값들을 반환하기 위해 어떻게 했는지 스스로에게 질문해보세요. 객체나 배열을 반환함으로써 여러개의 값을 반환했습니다.

따라서 인증정보를 하나의 객체로 묶어 자식 컴포넌트들에게 전달하면 됩니다.

<script setup>
import { provide, ref, computed } from 'vue';

const user = ref({
name: 'John Doe',
email: 'john.doe@example.com',
authToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
});

const isAuthenticated = computed(() => !!user.value?.authToken);

provide('auth', {
user,
isAuthenticated,
});
</script>

<script setup>
import { injected } from 'vue';

const { user, isAuthenticated } = injected('auth');
</script>

🌃 Symbol을 키로 사용하기

평문을 키로 사용하는 경우는 대규모 애플리케이션에서 충돌이 일어날 수 있습니다. 동일한 키가 중복될 수 있기 때문입니다.

충돌을 피하기 위한 한가지 방법은 고유성을 보장받을 수 있는 ES6의 Symbols을 키로 사용하는 것입니다.

const key1 = Symbol('key');
const key2 = Symbol('key');

key1 === key2; // false

provide(key1, 'value1');
provide(key2, 'value2');

inject(key1); // 'value1'
inject(key2); // 'value2'

실제로 Symbol을 사용하는 것은 좀 더 복잡할 수 있습니다. Symbol은 항상 유일하기 때문에, provide/inject를 하기 위해 키를 export 해야 합니다.

예를들어 애플리케이션에서 사용하는 모든 Symbol들을 내보내는 injectionKeys.js 파일을 관리할 수 있습니다.

export const VALUE_KEY = Symbol('selectedAccordionItem');

그런 다음 해당 파일을 임포트하여 값을 전달 및 주입합니다

// In provide component
import { provide } from 'vue';
import { VALUE_KEY } from './injectionKeys';

provide(VALUE_KEY, 'example');
// In child component
import { inject } from 'vue';
import { VALUE_KEY } from './injectionKeys';

inject(VALUE_KEY); // 'example'

🌃 타입 안정성

Provide/Inject는 Vue3의 새로운 기능이 아닙니다. Vue2부터 계속 존재했습니다. 하지만 Vue3에서는 타입 안정성을 제공합니다.

사용하는 키의 타입을 정의하기 위해 InjectionKey 타입을 사용할 수 있습니다. InjectionKey 타입은 제공하려는 값이나 주입받는 값의 타입을 인자로 전달받는 제네릭 타입입니다.

아래의 예시에서는 symbol을 키로 사용했으며 string 타입을 전달했습니다.

import type { InjectionKey } from 'vue';

export const VALUE_KEY: InjectionKey<string> = Symbol('selectedAccordionItem');

이제 selectedAccordionItem key에 대한 값을 제공할 때 string 타입만 전달 가능합니다.

// ✅
provide(VALUE_KEY, 'example');

// ❌
provide(VALUE_KEY, 123);

값을 주입할 때도, 전달하려는 값이 문자열로 추론됩니다. 다만 주입은 옵셔널이기 때문에 undefined일 수 있습니다.

// string | undefined
const selected = inject(VALUE_KEY);

타입 안정성은 fallback 값에도 적용되기 때문에 주입된 값의 타입과 fallback 값의 타입이 동일해야 합니다.

// ✅
inject(VALUE_KEY, 'example');

// ❌
inject(VALUE_KEY, 123);

🌃 Provider 강제하기

가끔, Provider 컴포넌트 없이 단독으로는 사용할 수 없는 컴포넌트가 있습니다. 예를들어, AccordionItem은 반드시 Accordion 컴포넌트의 자식으로 있어야 정상적으로 동작합니다.

<Accordion>
<!-- ... -->
</Accordion>

<!-- 정상적으로 동작하지 않음 -->
<AccordionItem title="Accordion 1">
<p>Accordion 1 content</p>
</AccordionItem>

이는 개발자에게 치명적일 수 있는데, 이런 문제들은 런타임에 발생하여 발견하기 어려울 수 있고 발견하더라도 명확하지 않을 수 있기 때문입니다.

이 문제를 해결하기 위해서 provider를 찾지 못할 경우 명시적으로 에러를 발생시키는 방법이 있습니다.

import { inject } from 'vue';

const selected = inject('selected');

if (!selected) {
throw new Error('Selected value not found');
}

이렇게 함으로써 AccordionItem 컴포넌트를 Accordion 밖에서 사용할 수 없게 됩니다.

Provider를 찾지 못하면 오류를 발생시키는 inject 함수보다 조금더 엄격한 유틸리티 함수를 아래와 같이 만들 수도 있습니다.

import { inject } from 'vue';

const NOT_FOUND = Symbol('NOT_FOUND');

export function injectStrict(key) {
const value = inject(key, NOT_FOUND);

if (value === NOT_FOUND) {
throw new Error(`No provider found for key "${key.toString()}"`);
}

return value;
}

위 예시에서 symbol을 fallback으로 전달한것을 볼 수 있는데, 이는 provider를 찾지못해 노출되는 경고를 끄기 위함입니다(어찌되었든 오류를 발생시키기 때문입니다.). Provider를 찾지 못할 경우 symbol을 반환하게 되고 이를 확인해 오류를 발생시킵니다.

🌃 수정 막기

수정가능한 반응형 상태를 모든 자식 컴포넌트들에게 노출시키는것은 위험합니다. 유효성 검사나 복잡한 상태 변경을 관리하기 위해 상태가 어떻게 변경되는지를 제어하고 싶을 수 있습니다.

한가지 예시로는 로그인한 사용자의 정보가 있는 auth 객체를 들 수 있습니다. 자식 컴포넌트들에게 전달은 하고 싶지만 수정은 막고 싶습니다.

이 경우 vue에서 제공하는 readonly 함수를 사용해 값을 읽기 전용으로 만들 수 있습니다. 추가로 상태를 안전하고 제어가능한 상황에서 변경할 수 있는 함수를 제공하는 방법도 있습니다.

import { provide, readonly, ref } from 'vue';

const user = ref({
name: 'John Doe',
email: 'john.doe@example.com',
authToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
});

function signOut() {
user.value = null;
}

provide('user', {
user: readonly(user),
signOut,
});

이제 어떤 컴포넌트도 user 객체를 수정할 수 없습니다. 대신 컴포넌트들은 로그아웃하기 위해 반드시 provider 컴포넌트에서 제공하는 signOut 함수를 호출해야 합니다.

provide/inject를 이용해 할 수 있는 것들이 많지만, 이쯤에서 마무리 짓겠습니다.

🌃 결론

Provide/Inject는 재사용성과 유지보수성이 높은 컴포넌트를 만드는데 강력한 기능입니다. Vue 생태계의 많은 라이브러리들이 유연하고 재사용하기 쉬운 컴포넌트를 만들기 위해 사용하고 있습니다.