개발 기술/개발 이야기

[Vue] prop drilling을 예방하는 Provider Pattern 알아보기

by GicoMomg (Lux) 2023. 9. 30.

0. 들어가기에 앞서…

  • Vue와 React는 컴포넌트 기반으로 작동하며, 컴포넌트 간에 데이터를 전달할 때 props를 사용한다.
  • props는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달할 때 사용하는데, 자식 컴포넌트는 전달받은 데이터를 사용하여 뷰를 업데이트한다.

<!-- ParentComponent.vue -->

<ChildComponent :result="result" />
<!-- ChildComponent.vue -->

<script setup lang="ts">

const props = defineProps({
    result: {
        type: Object,
        required: true,
    }
})

</script>

 

  • 그러나 때로는 여러 개의 중간 컴포넌트를 거쳐야 하는 경우가 있다.
  • 예를 들어, 부모 컴포넌트에서 데이터를 전달할 때 그 데이터를 사용하는 컴포넌트가 매우 깊은 계층에 위치해 있다면, 중간 컴포넌트를 거치는 과정이 필요한 것이다.
  • 이 경우에는 중간 컴포넌트로 데이터를 전달하기 위해 props를 여러 계층을 거쳐 넘기는데 이를 Prop drilling이라고 한다.
  • 이번 시간에는 Prop drilling 의 단점과 이를 예방할 수 있는 provider pattern에 대해 알아보겠다.



1. prop drilling이 발생했다!

1) Prop drilling이 뭔가요?

  • 하위 컴포넌트에 데이터를 전달할 때 여러 개의 컴포넌트를 거쳐야 하는 경우가 발생한다.
  • 이 경우를 바로 Prop drilling이라고 하는데, 명칭 그대로 드릴로 구멍을 뚫은 것처럼 props 데이터를 전달하기 위해 여러 개의 하위 컴포넌트를 거쳐야 하는 경우이다.

 

  • Prop drilling으로 간주하는 범위는 개발자마다 다를 수 있으나, props 데이터를 사용하지 않고 전달만 하는 중간 컴포넌트가 여러개인 되는 경우 Prop drilling으로 볼 수 있다.
  • 본인은 3개 이상의 중간 컴포넌트를 거치면 데이터 추적이 어려워져 이 경우 prop drilling으로 간주하지만, 각 팀별로 정의하는 기준은 다를 수 있다.

❓ 그런데, Prop drilling이 어떤 문제가 있기에 이 현상을 예방하려는 걸까?
prop drilling의 단점 3가지를 알아보자.

 

2) Prop drilling의 단점

(1) 복잡성

  • props를 여러 컴포넌트를 거쳐 전달하면, 사용치 않은 데이터가 컴포넌트에 선언되기에 코드 복잡도가 올라간다.
  • 또한, 특정 컴포넌트에서 props를 전달만 하고 사용하지 않는다면, 필요치 않는 코드라고 생각되어 삭제될 여지가 높다.

(2) 유지 보수의 어려움

  • props 데이터를 변경할 시, 중간 컴포넌트에는 이상이 없는지 확인하는 절차가 필요하다.
  • 만약 어떤 컴포넌트는 props 데이터를 전달만 하고 또 다른 컴포넌트는 props데이터를 재가공해서 전달하고 있다면 props 데이터 변경에 따른 사이드이펙트가 발생할 수 있다.

(3) 디버깅의 어려움

  • 데이터가 여러 컴포넌트를 통해 전달되면 문제가 발생한 곳을 파악하기 어려울 수 있다.
  • 만약 이슈가 E 컴포넌트에서 발생했다면, 우리는 E 컴포넌트를 먼저 확인한다.
  • 그런데, 문제가 발생한 데이터가 A 컴포넌트에서 비롯된 거라면 데이터의 시작점을 찾는 과정이 동반된다.



2. prop drilling을 예방하는 방법

1) vuex, pinia와 같은 전역 데이터를 쓰면 어떨까?

  • prop drilling을 예방하기 위해, vuex, pinia와 같은 데이터 전역 관리 라이브러리를 쓸 수 있다.
  • piniavuex는 데이터를 중앙에서 관리하기에 데이터 변경을 추적하기 쉽다.
  • 하지만 piniavuex 데이터를 “중앙”에서 관리하는 게 목적이기에, 남용시 성능이 저하될 수 있다.
  • 또한 데이터를 중앙 관리하면 어떤 도메인에서든 데이터를 변경할 수 있게 되는데, 이 경우 데이터의 범위가 의도와 달리 넓어진다.

 

2) provider pattern을 쓰면 어떨까?

  • provider pattern에는 vue에서 제공하는 provide, inject를 사용하는 방법이다.
  • 이 패턴의 핵심은 바로 상위 컴포넌트에서 provide로 값을 전달하고, 이 값을 필요로 하는 하는 하위 컴포넌트에서 inject를 사용해 값에 접근하는 것이다!

  • 그럼, provideinject는 어떻게 사용해야할까? 각각의 정의와 예시를 살펴보자

 

(1) provide, 값을 전달한다.

  • provide는 데이터를 컴포넌트 트리 전체에서 사용할 수 있게 한다.
  • provide를 사용하면 중간 컴포넌트를 거치지 않고 데이터를 전달할 수 있다.
  • provide는 두 개의 인수를 받는데, 첫번째는 키, 두번째는 값이다.
provide(키, 값);

 

  • 만약 컴포넌트 트리에 message키를 가진 값을 전달하고 싶다면, 아래와 같이 사용할 수 있다.
<script setup>
// ParentComponent.vue 

import { provide } from 'vue'

provide('message', 'hello!');
</script>

 

(2) inject, 전달한 값에 접근한다.

  • provide로 전달한 값은 하위 컴포넌트에서 어떻게 사용할 수 있을까?
  • 하위 컴포넌트에서 inject 를 사용하면, provide한 데이터를 참조할 수 있다!
  • 아래 예시는 inject를 사용해 provide한 값에 접근한 모습이다.
<script setup>
// ChildComponent.vue

import { inject } from 'vue'

const message = inject('message'); 

console.log(message); // hello!
</script>

 

  • 이렇게 provide, inject를 사용하면 여러 컴포넌트를 거치지 않고 데이터를 바로 전달할 수 있다.



3. provider pattern 더 잘 쓰는 법

😮 provideinject를 사용하면 여러 컴포넌트를 거치지 않고 데이터를 바로 전달할 수 있다!
그렇다면 이 provider pattern을 어떻게 써야 관리가 잘 될까?

1) provide 키를 심볼키로 관리하기

  • provide를 쓸 때는 꼭! 키를 지정해야하는데, 이때 키가 String이 될 수 있지만 심볼이 될 수도 있다.
  • String 타입으로 키를 지정해도 되지만, 큰 프로젝트에서는 어떤 키가 있는지 여부와 키가 중복되어 값이 수정되지 않았는지 파악하기 어렵다.
  • 하지만 심볼키를 사용하게 된다면, 키를 고유하게 유지하면서 실수로 값을 덮어쓰는 경우도 막을 수 있다.
  • 키의 무결성과 안정성을 보장할 수 있다는 말이다..!
  • 심볼키를 지정할 때는 Object.freezeSymbol 두가지를 사용할 수 있는데, 우선 Object.freeze방식부터 살펴보자.

(1) Object.freeze로 키 선언하기

  • Object.freeze()는 객체의 수정을 막는 함수이다.
  • 만약 Object.freeze()로 선언한 객체의 값을 수정하거나 삭제하려고한다면 에러가 발생한다.
const obj = {'a': 12, 'b': 13};

Object.freeze(obj);
obj.a = 44;          // {'a': 12, 'b': 13}  - 변경 ❌
obj.c = 11;          // {'a': 12, 'b': 13}  - 추가 ❌
delete obj.a;        // {'a': 12, 'b': 13}  - 삭제 ❌

 

  • 이 함수를 사용해서 별도 파일에서 provide키를 관리하면, 키를 유니크하게 관리할 수 있다.
// provider-symbol.ts

export const symbols = Object.freeze({
    loginService: 'loginService',
    customerService: 'customerService',
  ..
})
<script lang="ts" setup>
// parentComponent.vue

import { provide } from 'vue';
import { symbols } from '@/provider-symbol';  // symbol 파일을 import해서

provide(symobols.loginService, { /* 어떤_데이터 */ });     // symbol키 사용하기 
</script>
<script lang="ts" setup>
// ChildComponent.vue

import { inject } from 'vue'
import { symbols } from '@/provider-symbol'

inject(symbols.loginService);
</script>

 

(2) Symbol로 키 선언하기

  • 두 번째 방법은 Symbol()을 사용하는 방법이다.
  • Symbol()은 고유한 심볼 값을 생성하는 함수로, 매번 호출마다 새로운 심볼이 생성된다.
const sym1 = Symbol();
const sym2 = Symbol("foo");
const sym3 = Symbol("foo");

 

  • 그렇기 때문에 이름이 같은 심볼이라도 비교했는데 다른 값으로 인식하기에,
    만약 심볼 값이 같은지 확인하고 싶다면 Symbol.keyFor(), Symbol.for()를 사용해야한다.
Symbol("foo") === Symbol("foo"); // false

Symbol.keyFor(Symbol.for("foo")) === "foo"; // true

 

  • 이처럼 심볼은 유일성을 보장하기에, 다른 값과 충돌하지 않고 고유한 식별자로 사용할 수 있다.
  • 이러한 특징을 이용해 provide 키를 선언할 때 활용할 수 있다.
// provider-symbol.ts

export const loginService = Symbol('loginService');
export const customerService = Symbol('customerService');
<script lang="ts" setup>
// ParentComponent.vue

import { provide } from 'vue'
import { loginService } from '@/provider-symbol'  // symbol 파일을 import해서

provide(loginService, { /* 어떤_데이터 */ });       // symbol키 사용하기 
</script>
<script lang="ts" setup>
// ChildComponent.vue

import { inject } from 'vue'
import { loginService } from '@/provider-symbol'

inject(loginService);
</script>

 

(3) Object.freeze, Symbol로 키 선언하기

  • 마지막으로 이 두가지를 합쳐서, 키값을 유니크하게 유지하면서 키의 변경도 막을 수도 있다.
// provider-symbol.ts

export const symbols = Object.freeze({
    loginService: Symbol('loginService'),
    customerService: Symbol('customerService'),
  ..
})



2) provide 로직을 composable로 분리하기

(1) composable이 필요한 순간은?

  • provide를 사용하여 데이터에 대한 로직을 값으로 전달할 수 있다.
  • 아래 코드는 symobols.loginService 키에 유저 로그인 로직을 넘긴 모습이다.
  • 예시의 경우, 구체적인 로직을 정의하지 않고 함수만 대략적으로 구성하여 보기엔 복잡해보이지 않는다.
<script lang="ts" setup>
// parentComponent.vue

import { provide } from 'vue';
import { symbols } from '@/provider-symbol'; 

provide(symobols.loginService, () => { 
  // 이 로직을 provide의 값으로 넘기고 있다.

    const userInfo = reactive({});

  function login(email, pw) { ... }

    function checkEmail(email) { ... }

    function checkPassward(pw) { ... }

  return { userInfo, login, checkEmail, checkPassward };
});   
</script>

❓ 하지만 만약 데이터 로직이 너무 길어서 컴포넌트에서 선언했을 때 로직 구분이 어렵다면?

  • 이때 바로 컴포저블을 사용하는 것이다!
  • 컴포저블은 로직을 캡슐화하기에, 코드의 재사용성과 유지 보수성을 향상시킬 수 있다.
  • 유명한 컴포저블 예시는 vueuse 인데, 그 중 코드가 간단한 useLocalStorage를 가져와봤다.
// vueuse의 useLocalStorage 내부 코드

export function useLocalStorage(key, initialValue, options: = {}) {
  const { window = defaultWindow } = options;

  return useStorage(key, initialValue, window?.localStorage, options)
}
  • useLocalStorage를 보면, 컴포저블 함수명을 useXXX 형태로 명명했으며, 특정 데이터에 대한 로직을 함수로 묶어둔 걸 알 수 있다.
  • 그럼 컴포저블을 provide와 어떻게 함께 사용할 수 있는지 살펴보자.

 

(2) provide, composable 사용 예시

  • 우선 provide로 넘기는 로직을 별도 파일로 분리해야한다.
<script lang="ts" setup>
// parentComponent.vue

import { provide } from 'vue';
import { symbols } from '@/provider-symbol'; 

provide(symobols.loginService, () => { 
  // 아래 로직을 별도 파일로 분리해야함
    const userInfo = reactive({});

  function login(email, pw) { ... }

    function checkEmail(email) { ... }

    function checkPassward(pw) { ... }

  return { userInfo, login, checkEmail, checkPassward };
});   
</script>

 

  • 분리한 코드는 로그인 처리 로직이기에 useLogin으로 명명하고 파일명도 use-login으로 설정한다.
// use-login.ts
// 데이터 로직을 담은 컴포저블

import { reactive, computed } from 'vue';

export function useLogin() {
      const userInfo = reactive({});

  function login(email, pw) { ... }

    function checkEmail(email) { ... }

    function checkPassward(pw) { ... }

  return { userInfo, login, checkEmail, checkPassward };
}

 

  • 그 다음, useLoginimport하여 provide로 넘겨주면 된다!
<script lang="ts" setup>
// parentComponent.vue

import { provide } from 'vue';
import { symbols } from '@/provider-symbol'; 
import { useLogin } from '@/use-login'; 

provide(symobols.loginService, useLogin);   
</script>

 

3) 키값이 없을 때 예외처리해두기

  • provider pattern을 사용할 때 발생하는 실수 중 하나가 유효하지 않은 키에 접근하는 경우이다.
  • 이를 예방하기 위해서, inject에서 예외처리를 하는 방법이 있다.
function requireInjection(key, value) {
    const resolved = inject(key, value);
    if (!resolved) {
    throw new Error(`${key} was not provided.`);
  }
    return resolved;
}
<script lang="ts" setup>
// parentComponent.vue

import { provide } from 'vue';
import { symbols } from '@/provider-symbol'; 
import { useLogin } from '@/use-login'; 
import { requireInjection } from '@/utils'

requireInjection(symobols.loginService, useLogin);   
</script>

 

  • 혹은 inject 키가 없다면 provide를 강제하는 방법도 있다.
function requireInjection(key, value) {
    const resolved = inject(key, value);
    return resolved ? resolved : provide(key, value);
}



3. 마치며…

  • 이번 시간에는 Vue에서 prop drilling을 예방하기 위해 Provider Pattern을 사용하는 방법에 대해 알아보았다. Provider Pattern은 컴포넌트 간에 데이터를 효율적으로 공유하기 위한 패턴이다.
  • prop drilling은 여러 개의 하위 컴포넌트를 거쳐 데이터를 전달해야 하는 번거로움이 있었다. 이로 인해 유지보수성과 디버깅이 어려워지는 문제가 발생했는데, Provider Pattern을 이용해 데이터를 필요로 하는 컴포넌트로 직접 주입시킬 수 있어 이 문제를 예방할 수 있었다.
  • 만약 프로젝트의 크기가 크다면 Provider Pattern을 사용해 prop drilling을 예방해보면 어떨까?




참고자료

반응형

댓글