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
와 같은 데이터 전역 관리 라이브러리를 쓸 수 있다.pinia
와vuex
는 데이터를 중앙에서 관리하기에 데이터 변경을 추적하기 쉽다.- 하지만
pinia
와vuex
데이터를 “중앙”에서 관리하는 게 목적이기에, 남용시 성능이 저하될 수 있다. - 또한 데이터를 중앙 관리하면 어떤 도메인에서든 데이터를 변경할 수 있게 되는데, 이 경우 데이터의 범위가 의도와 달리 넓어진다.
2) provider pattern을 쓰면 어떨까?
provider pattern
에는 vue에서 제공하는provide
,inject
를 사용하는 방법이다.- 이 패턴의 핵심은 바로 상위 컴포넌트에서
provide
로 값을 전달하고, 이 값을 필요로 하는 하는 하위 컴포넌트에서inject
를 사용해 값에 접근하는 것이다!
- 그럼,
provide
와inject
는 어떻게 사용해야할까? 각각의 정의와 예시를 살펴보자
(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 더 잘 쓰는 법
😮
provide
와inject
를 사용하면 여러 컴포넌트를 거치지 않고 데이터를 바로 전달할 수 있다!
그렇다면 이provider pattern
을 어떻게 써야 관리가 잘 될까?
1) provide 키를 심볼키로 관리하기
provide
를 쓸 때는 꼭! 키를 지정해야하는데, 이때 키가 String이 될 수 있지만 심볼이 될 수도 있다.String
타입으로 키를 지정해도 되지만, 큰 프로젝트에서는 어떤 키가 있는지 여부와 키가 중복되어 값이 수정되지 않았는지 파악하기 어렵다.- 하지만 심볼키를 사용하게 된다면, 키를 고유하게 유지하면서 실수로 값을 덮어쓰는 경우도 막을 수 있다.
- 키의 무결성과 안정성을 보장할 수 있다는 말이다..!
- 심볼키를 지정할 때는
Object.freeze
와Symbol
두가지를 사용할 수 있는데, 우선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 };
}
- 그 다음,
useLogin
을import
하여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
을 예방해보면 어떨까?
참고자료
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Symbol
- https://vuedose.tips/the-new-provide-inject-in-vue-3
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
- https://v3-docs.vuejs-korea.org/guide/reusability/composables.html#what-is-a-composable
- https://v3-docs.vuejs-korea.org/guide/components/provide-inject.html#provide-inject
반응형
'개발 기술 > 개발 이야기' 카테고리의 다른 글
구글 검색 엔진과 SEO을 알아보자!(with. SEO 측정 방법) (2) | 2023.11.12 |
---|---|
[Vue] 컴포저블에서 props의 반응성 유지하기(feat. toRef, unref) (0) | 2023.10.29 |
팝오버를 라이브러리 없이 웹 API로 구현하는 법, Popover API (0) | 2023.09.17 |
DocumentFragment 객체로, 성능 좋게 DOM 조작하기 (0) | 2023.08.07 |
Page Visibility API, 유저가 페이지를 보고 있는지 알려줘! (0) | 2023.07.23 |
댓글