본문 바로가기

FrontEnd

Vue3 컴포넌트 디자인 패턴 : Props

반응형
컴포넌트 디자인 패턴은
컴포넌트를 개발하면서 마주치게 되는 특정 문제를 해결하면서
소프트웨어의 유지보수성 / 재사용성을 높이는 개발을 위한 도구다.

이전 시리즈 링크 :

2022.12.21 - [분류 전체보기] - Vue3 컴포넌트 디자인 패턴 소개

 

TL;DR

props는 컴포넌트의 일관성(오케스트레이션)을 위해 사용하면 좋다.

(특히 마크업 스트럭처 컨트롤은 X)

하위 컴포넌트나 htmlElement에 전달하는 경우는 직접 element를 노출하는 방법(slot)을 쓰는 것이 좋다


우리는 보통 컴포넌트를 설계할 때, props 데이터를 중심으로 사고한다.

이는 입출력이 기본인 함수의 멘탈 모델에 익숙하기 때문이다.

Mobie Poster 컴포넌트
컴포넌트의 구현과 컨슈밍


Props 정의 : 모범 사례

배열 문법

prop을 정의시 많은 개발자가 배열 구문을 사용한다.
<script>
export default {
  name: 'Movie',
  props: ['title', 'length', 'watched']
}
</script>
<template>
	<section>
		<h1>{{ title }}</h1>
		<p>{{ length }} <span v-if="watched">✅</span><p>
	</section>
</template>
이 정의에는 아무런 문제가 없으며 프로덕션에서 잘 동작하지만 이 방법에는 몇 가지 함정이 있다.
  • prop 명은 처음엔 직관적이지만 종종 잘못 해석하면 버그로 이어질 수 있다.
    • 만약 필수로 제공해야 하는 속성을 제공하지 않는다면?
  • 각 prop의 정의가 애매하다
    • length는 숫자여야 할까? string이어야 할까?
    • 어떤 포맷의 string이어야 할까? (05:23 / 5hour 23min)
    • 감상여부(watched)는 boolean인가? Y/N인가?

이 컴포넌트는 prop 렌더링만 담당한다
더 복잡한 논리가 관련된 경우 어떤 종류의 버그가 나타날지 알 수 없다.

 

프로덕션에서 보고 싶지 않은 화면

Prop에 대한 좀 더 명확한 설명을 제공하면서, 개발자가 이해하기 쉬운 형태를 알아보자.

객체 문법

대부분의 시나리오에서 배열 대신 객체 구문을 사용하여 prop을 정의해야 한다.
객체 구문을 사용하면 props 정의의 근본에 해당하는 세 가지 주요 prop을 정의할 수 있다.

  • type: 데이터 타입
  • required: 필수여부(중요값)
  • default: 기본값
<script>
export default {
	props: {
		length: {
			type: Number,
			required: true,
			default: 90
		}
	}
}
</script>

정의 가능한 기본 자바스크립트 데이터 타입들은 다음과 같다.

  • String
  • Number
  • Boolean
  • Array
  • Object
  • Date
  • Function
  • Symbols
 
유니언 데이터 타입을 정의하려는 경우 배열 구문을 사용한다.
<script>
export default {
	props: {
		length: {
			type: [Number, String],
			required: true,
			default: 90
		}
	}
}
</script>
디폴트 속성이 있으면 required는 필요없다.
<script>
export default {
	props: {
		length: {
			type: Number,
			default: 90
		}
	}
}
</script>

prop은 컴포넌트를 사용하는 방법에 대한 자세한 사양을 제공하는 데 유용하지만,
개발자 측에 유연성을 허용하지 않는 단점도 가지고 있다.

Props: Custom Validation

기존 props 정의 방법의 단점

props의 요구사항은 생각보다 더 복잡할 수 있다.

예를 들어 img src 의 포맷이 다르다던가 하는 일이 발생할 수 있다.

 

📄 BaseBanner.vue

export default {
  props: {
    image: {
      type; String,
      default: '/images/placeholder.png'	
    }
  }
}

image 프롭에 다음과 같은 값이 들어오면...?

  • /images/movie-poster.pn
  • /imagesmovie-poster.png
  • images/movie-poster.png
단순히 이미지 prop이 문자열임을 강제하는 것 이상으로 다음을 확인하고 싶다고 가정해 보자
  • 이미지는 반드시 루트의 image 디렉터리에 있어야 한다.
  • 이미지는 반드시 PNG 혹은 JPEG 포맷이어야 한다.
보통 다음과 같이 할 수 있다.
  • prop을 이용해 계산된 속성을 만든다.
  • 해당 속성을 검증하고 오류 시 로그를 출력한다.
하지만 그보다 더 일찍 props를 검증할 수 있다면 어떨까?
커스텀 검증 함수를 사용해보자

Custom Validation for Props

prop 정의에 validator 속성을 적용할 수 있다.

📄 MoviePoster.vue

export default {
  props: {
    image: {
      type: String,
      default: '/images/placeholder.png',
      // Validator takes an anonymous function 
      // that receives the passed-down value
      // as its first argument
      validator: propValue => {
        // Return a Boolean that will serve as your validation
        const propExists = propValue.length > 0

	return propExists
      }  
    }
  }
}

요구사항 1 : 이미지는 반드시 루트의 image 디렉터리에 있어야 한다.

export default {
  props: {
    image: {
      type; String,
      default: '/images/placeholder.png'	
      validator: propValue => {
         const hasImagesDirectory = propValue.indexOf('/images/') > -1

	 return hasImagesDirectory
      }
    }
  }
}

요구사항 2 : 이미지는 반드시 PNG 혹은 JPEG 포맷이어야 한다.

export default {
  props: {
    image: {
      type; String,
      default: '/images/placeholder.png'	
      validator: propValue => {
         const hasImagesDirectory = propValue.indexOf('/images/') > -1
	 const isPNG = propValue.endsWith('.png')
	 const isJPEG = propValue.endsWith('.jpeg') || propValue.endsWith('.jpg')
	 const hasValidExtension = isPNG || isJPEG

	 return hasImagesDirectory && hasValidExtension
       }
     }
  }
}

타입스크립트에서 사용하는 법 : Vue Playground

 

Vue SFC Playground

 

sfc.vuejs.org


Prop의 한계

당신이 만드는 컴포넌트

해당 컴포넌트가 어떤 방식으로 탄생하는지 설명하기 전에, 컴포넌트를 아래처럼 개발하고 있지 않은가?

그렇다면 반드시 해당 문단을 읽어야 한다.

<template>
  <h1>My App</h1>
  <BaseButton 
    text="Submit" 
    :isLoading="loading"
    iconLeftName="left-arrow"
    iconRightName="right-arrow"
    :isLoadingLeft="loadingLeft"
    :isLoadingRight="loadingRight"
  />
</template>

props 중심 개발은 컴포넌트 디버깅 및 유지보수를 어렵게 만든다.

Props는 데이터를 하위 컴포넌트로 전달할 때 매우 유용한 기술이다.
그러나 애플리케이션의 규모가 커지고 컴포넌트가 커짐에 따라 prop에만 의존하면 생각보다 더 많은 문제가 발생할 수 있다.

버튼 컴포넌트 확장 예제

심플한 버튼이 있다고 가정해보자.

해당 버튼의 기능을 확장해보자

  • 지난 라운드를 기반으로 다음 라운드마다 새로운 요구 사항이 주어진다.
  • 템플릿 블록이 BaseButton.vue 및 App.vue에서 어떻게 보일지 상상해 보자
  • 모든 스타일은 별도로 정의된 CSS 클래스(예: .button, .icon 등)에서 제공된다

요구사항 #1 : 사용자 정의 텍스트

버튼이 상위 컴포넌트에서 지정한 사용자 정의 텍스트를 표시할 수 있어야 한다.

요구사항 #2: 버튼 텍스트 오른쪽에 아이콘을 추가한다.

아래와 같은 컴포넌트를 버튼 텍스트 오른쪽에 추가하려면?

<app-icon :icon="iconName" />

요구사항 #3: 양 쪽 모두에 아이콘을 추가할 수 있어야 한다.

요구사항 #4: 컨텐츠를 로딩 스피너로 변경

아래와 같은 스피너 컴포넌트가 주어진다.

<LoadingSpinner />

해당 스피너를 이용해 로딩 상태를 표시해보자.

요구사항 #5: 아이콘 하나만 로딩스피너로 변경

무지성 Props 도배의 함정(A Common Descent into Props Madness)

보통 아래와 같이 개발을 시작한다.

📄App.vue

<template>
  <h1>My App</h1>
  <BaseButton text="Submit" />
</template>

📄BaseButton.vue

<template>
  <button class="button">
    {{ text }}
  </button>
</template>
<script>
export default {
  props: {
    text: {
      type: String,
      required: true
    }
  }
}
</script>

그러나 각 요구 사항이 계속 서로를 기반으로 구축됨에 따라
오래지 않아 다음과 같은 모습이 된다.

📄App.vue

<template>
  <h1>My App</h1>
  <BaseButton 
    text="Submit" 
    :isLoading="loading"
    iconLeftName="left-arrow"
    iconRightName="right-arrow"
    :isLoadingLeft="loadingLeft"
    :isLoadingRight="loadingRight"
  />
</template>

📄BaseButton.vue

<template>
  <button type="button" class=“nice-button“>
    <LoadingSpinner v-if="isLoading" />
    <template v-else>
      <template v-if="iconLeftName">
        <LoadingSpinner v-if="isLoadingLeft" />
        <AppIcon v-else :icon=“iconLeftName” />
      </template>
      {{ text }}
      <template v-if="iconRightName">
        <LoadingSpinner v-if="isLoadingRight" />
        <AppIcon v-else :icon=“iconRightName” />
      </template>
    </template>
  </button>
</template>
<script>
export default {
  // Props shortened to Array syntax for brevity
  props: [
    'text', 
    'iconLeftName', 
    'iconRightName', 
    'isLoading', 
    'isLoadingLeft', 
    'isLoadingRight'
  ]
}
</script>
😵이렇게 코드를 작성했다면, 당신에 대해선 잘 모르지만, 당신 동료들이 당신 코드를 리뷰하기 상당히 골치아플 것이다.😵

무지성 Props 도배의 문제점

방금 솔루션은 "props 기반 솔루션"이라고 부를 수 있는 것이다.
즉, 각각의 새로운 요구 사항을 해결하기 위한 전략은 단순하다.
  • 원하는 동작을 제어하는 ​​새로운 prop을 정의하고 (control props)
  • 기존 템플릿 및 데이터 모델에 쌓는 것이다.
이것이 잘못된 것인가? 절대 아니다.
해당 컴포넌트는 요구사항을 충족하고 사용자가 예상한 대로 화면에 나타난다.
 
하지만, 시간이 지남에 따라 코드를 읽기가 어려워지고
유지 보수가가 매우 어려워지기까지 오래 걸리지 않는다.
이는 요구사항이 점차 추가되기에 필연적이다.
 
결과적으로 개발 팀이 props 기반 솔루션에서 볼 수 있는 몇 가지 일반적인 문제는 다음과 같다.
 
 
  1. 컴포넌트에 대한 사전 경험이 없는 신규 개발자는 새로운 기능을 추가하거나 코드를 디버그하기 위해 복잡한 조건의 미로를 탐색해야 한다
  2. 많은 prop은 동작을 이해하기 위해 파악해야 하는 거대한 설정 덩어리다.
    • 컴포넌트는 직관성을 상실한다.
    • prop이 제대로 문서화되지 않았다면 재앙이다
  3. 복잡한 컴포넌트는 유사한 대체 컴포넌트를 낳는다.
    • 데드라인이 고품질 코드보다 우선하기 때문
    • 재사용을 포기하고 대체 컴포넌트를 개발하기 시작하면 관리가 어려워짐

간단하고 깔끔한 해결 방법

prop에만 의존하는 대신 다음 컴포넌트 설계 기술인 slot을 사용할 때가 되었다.

📄App.vue

<template>
  <h1>My App</h1>
  <BaseButton>Submit <AppIcon name="arrow-right" /></BaseButton>
</template>

📄BaseButton.vue

<template>
  <button class="button">
    <slot />
  </button>
</template>

단일 슬롯 요소를 사용하면 컴포넌트의 코드 한 줄을 변경하지 않고도 모든 요구 사항을 충족하였다.
수정이 필요없으니 버그의 가능성이 줄어든다! (기존 코드 깨짐 x) 

정리

  • props는 컴포넌트의 일관성 규칙을 정의하는 매우 강력한 기술이다.
  • 하지만 컴포넌트 기능 추가 시 prop에 너무 많이 의존하면 복잡성이 증가하고 유연하지 않은 컴포넌트가 생성될 수 있다.

참조

https://catchts.com/validators

 

TypeScript blog

TypeScript blog

catchts.com

https://www.uplift.ltd/posts/validating-routes-with-typescript/

 

Validating routes with TypeScript

We're always looking for ways to make code changes safer. At Uplift, we lean on TypeScript to help us keep projects maintainable and we try to push as many problems to the type layer as possible. Even something as small as a URL change can break a site in

www.uplift.ltd

 

반응형