개발을 할 때 ‘어떻게 하면 유연한 컴포넌트를 만들 수 있을까?’ 하는 질문을 끊임없이 던지곤 한다. 회사에서 프로젝트 시작 전에 설계에 관한 회의를 하고 방향성을 잡는다. 그럼에도 불구하고, 막상 프로젝트 중반쯤 되면 초반에 설계했던 구조가 많이 바뀌어있다. 구조가 변경되는 것은 어쩔 수 없는 일이지만, 변경 비용이 크지 않다면? 그만큼 설계를 잘 했다는 이야기니까 얼마나 뿌듯한가? 이번에 공부한 내용은 요즘 최대의 관심사이자, 프론트엔드 개발자라면 누구나 궁금해 할 내용. 바로 컴포넌트의 설계에 대한 이야기다.

컴포넌트란

컴포넌트란 무엇일까? 필자가 정의한 컴포넌트는 재 사용이 가능하게 UI 단위로 만든 벽돌 같은 것이다.
‘컴포넌트란?’ 이라고 검색하면 나오는 수많은 개발 블로그에서 말하는 말도 똑같다. ‘독립적인’, ‘재사용 가능한’, ‘UI 로 나눠진’ 이 말들이 가리키는 것인 한 가지다. 여기저기서 사용하는데 불편함이 없어야 한다는 것.

하지만 우리는 만들다보면 곧 고민에 빠지게 된다. 이게 맞게 만들고 있는건가…?
바로 위에서 UI 단위로 만든다고 이야기 헀는데, 그러면 기능 단위로 만들면 안되는건가?? 도대체 어떻게 나누는 것이 잘 나눠진 컴포넌트라고 할 수 있는가?

컴포넌트를 잘 만드는 방법

컴포넌트를 잘 만들어보자. 예시로 간단하게 만들 수 있는 계산기를 만들자. 계산기는 똑같이 생긴 버튼이 있다. 각각의 숫자 버튼과 연산자 버튼으로 나눌 수 있다. 계산기에서 어떻게 하면 잘 컴포넌트를 나눌 수 있을까?

가장 먼저 UI로 작은 단위를 만들어서 버튼의 스타일 중복을 피할것이다.

<template>
  <button class="btn-component" @click="$emit('handleClick', label)">
     {{ label }}
  </button>
</template>

<script>
export default {
  name: 'ButtonComponent',
  props: {
    label: {
      type: String,
      default: '',
    }
  },
}
</script>
// style 생략...

가장 작은 단위의 UI 컴포넌트 모음을 디자인 시스템이라고 한다. 디자인 시스템을 구축하면 통일성 있는 UI에 한 발 가까워진다. 뿐만 아니라 유지보수성도 좋기 때문에 많은 회사에서 자체 디자인 시스템을 사용하거나, UI 프레임워크를 사용한다. UI 프레임워크는 디자인의 통일성 뿐만 아니라 프레임워크에서 제공하는 여러 기능도 사용 할 수 있다는 장점이 있다.

// 컴포넌트 사용
<template>
  <main class="page">
    <section class="calculator-wrapper">
      <div class="calculator">
        <template v-for="number in CALCULATOR_NUMBERS" :key="number"         @handleClick="handleNumber" >
          // const CALCULATOR_NUMBERS = Array.from({ length: 10 }, (_, i) => String(i));
          <button-component :style="`grid-area: number_${number}`" :label="number" />
        </template>
      </div>
    </section>
  </main>
</template>
// style, script 생략...

계산기

게산기에는 계산을 할 수 있는 연산자 버튼이 필요하다.
우리는 이미 버튼 컴포넌트를 만들었으니 이것을 활용해서 연산자 버튼을 만들 수 있다.

<template>
  <main class="page">
    <section class="calculator-wrapper">
      <div class="calculator">
        <template v-for="number in CALCULATOR_NUMBERS" :key="number" @handleClick="handleNumber" >
          <button-component :style="`grid-area: number_${number}`" :label="number" />
        </template>
      </div>
      <div class="operator">
        <template v-for="operator in OPERATORS" :key="operator">
          // const OPERATORS = ['+', '-', 'x', '÷' ]
          <button-component :label="operator" @handleClick="handleOperator" />
        </template>
      </div>
    </section>
  </main>
</template>
// style, script 생략...

이제 기능을 만들 차례다. 연산자 배열에 (OPERATORS) 에 넣었던 연산 기능을 만들어주었다.

<template>
  <main class="page">
    <section class="result">
      <div v-for="number in expressions" :key="number">{{ number }}</div>
      {{ result }}
    </section>
    <section class="calculator-wrapper">
      <div class="calculator">
        <template v-for="number in CALCULATOR_NUMBERS" :key="number">
          <button-component :style="`grid-area: number_${number}`" 
            @handleClick="handleCalculator" :label="number" />
        </template>
      </div>
      <div class="operator">
        <template v-for="operator in OPERATORS" :key="operator">
          <button-component :label="operator" @handleClick="handleCalculator" />
        </template> 
      </div>
    </section>
  </main>
</template>

<script lang="ts">
import ButtonComponent from './ButtonComponent.vue'
import { ref, defineComponent, type Ref } from 'vue'

const CALCULATOR_NUMBERS = Array.from({ length: 10 }, (_, i) => String(i));
const OPERATORS = ['C', '+', '-', 'x', '÷', '=']

export default defineComponent({
  components: { ButtonComponent },
  setup() {
    const result:Ref<number> = ref(null)
    const expressions:Ref<string[]> = ref([])

    function handleCalculator(value:string) {
      if (value === 'C') {
        expressions.value.pop();
      } else if (value === '=') {
        try {
          result.value = `=${String(eval(expressions.value.join('')))}`;
        } catch (error) {
          result.value = 'Error';
        }
      } else {
        expressions.value.push(value);
      }
    }

    return {
      CALCULATOR_NUMBERS,
      OPERATORS,

      expressions,
      result,

      handleCalculator,
    }
  },
})
</script>
// style 생략...

계산기완성

간단한 예시지만 연산자가 더 많고 복잡한 연산을 하거나, 혹은 도메인에 따라 예외 처리를 해야한다면? 코드의 길이는 더 길어지고, 유지보수도 힘들어질 것이다. 어떻게 하면 유지보수성이 좋은 구조를 만들 수 있을까?
좋은 구조를 만드려면 컴포넌트가 잘 나누어져야 한다. 컴포넌트를 나누는 방법에는 여려가지가 있겠지만, 필자는 무언가 반복되는 것이 두 개 이상일 경우, 혹은 UI 가 비슷해도 목적이 다를 경우 컴포넌트로 분리하는 편이다.

예를 들어 지금의 계산기 페이지와 다른 페이지에 연산자 버튼들이 사용되어야 한다면? 두 페이지에 각각 연산자 버튼들을 추가 하고, 또 그에 맞는 계산 연산 기능을 추가하여 중복된 코드를 작성해야 할 것이다.
중복을 피할 방법은 기능이 담긴 컴포넌트를 새로 만든다면 중복을 줄이고 유지보수성을 높일 수 있다. ‘계산’ 이라는 목적을 가진 컴포넌트를 만들어 위의 예제를 더 나은 구조로 바꿔보자.

연산을 모아둔 컴포넌트

<template>
  <button-component 
    v-for="operator in operators" :key="operator"
    class="operator-btn" 
    :label="operator" 
    @handleClick="handleCalculator" 
  />
</template>

<script lang="ts">
import ButtonComponent from './ButtonComponent.vue'
import { PropType, defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'OperatorButton',
  props: {
    operators: {
      type: Array as PropType<string[]>,
      default: () => ['C', '+', '-', 'x', '÷'],
    },
    modelValue: {
      type: Array as PropType<string[]>,
      default: () => []
    },
  },
  components: { ButtonComponent },
  setup(props, { emit }) {
    const expression = ref(props.modelValue)
    function handleCalculator(value: string) {
      if (value === 'C') {
        expression.value.pop()
      } else if (!props.operators.includes(expression.value[expression.value.length - 1])) {
        expression.value.push(value);
      }
      emit('update:modeValue', expression.value)
    }
    return {
      handleCalculator
    }
  }
})
</script>
// style 생략...

결과값을 얻는 컴포넌트

<template>
  <button-component class="result-btn" label="=" @handleClick="handleResult" />
</template>

<script lang="ts">
import ButtonComponent from './ButtonComponent.vue'
import { PropType, defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'ResultButton',
  props: {
    modelValue: {
      type: Array as PropType<string[]>,
      default: () => []
    },
  },
  components: { ButtonComponent },
  setup(props, { emit }) {
    const expression = ref(props.modelValue)
    function handleResult() {
      try {
        expression.value.push(`=${String(eval(expression.value.join('')))}`)
      } catch (error) {
        expression.value.push('Error')
      } finally {
        emit('update:modeValue', expression.value)
      }
    }
    return {
      handleResult
    }
  }
})
</script>
// style 생략...

View 페이지

<template>
  <main class="page">
    <section class="result">
      <div v-for="number in expressions" :key="number">{{ number }}</div>
    </section>
    <section class="calculator-wrapper">
      <div class="calculator">
        <template v-for="number in CALCULATOR_NUMBERS" :key="number">
          <button-component :style="`grid-area: number_${number}`" 
            @handleClick="handleCalculator" :label="number" />
        </template>
      </div>
      <div class="operator">
        <result-button 
          v-model="expressions" 
          @update:modelValue="(val) => expressions = val"
        />
        <operator-button 
          v-model="expressions" 
          @update:modelValue="(val) => expressions = val" 
        />
      </div>
    </section>
  </main>
</template>

<script lang="ts">
import ButtonComponent from './ButtonComponent.vue'
import CalculatorButton from './CalculatorButton.vue'
import ResultButton from './ResultButton.vue'
import { ref, defineComponent, type Ref } from 'vue'

const CALCULATOR_NUMBERS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']

export default defineComponent({
  components: { ButtonComponent, OperatorButton, ResultButton },
  setup() {
    const expressions:Ref<string[]> = ref([])
    function handleCalculator(value:string) {
      expressions.value.push(value);
    }

    return {
      CALCULATOR_NUMBERS,
      expressions,
      handleCalculator,
    }
  },
})
</script>
// style 생략...

연산 기능을 가진 버튼, 결과를 내는 버튼, 숫자를 누르는 버튼을 분리했다. 이로써 어떠한 예외처리를 해도, 추가 연산을 넣어도, 하나의 파일만 수정하면 한 번에 수정을 할 수 있는 편리한 구조가 완성되었다.

SOLID원칙

컴포넌트를 잘 나누기 위해서는 객체지향의 특징인 SOLID 원칙에 맞는지 끊임없이 생각해야한다. 그래서 SOLID원칙이 뭐냐?

  • SRP : Single Responsibility Principle (단일 책임 원칙)
  • OCP : Open/Closed Principle (개방 폐쇄 원칙)
  • LSP : Liskov Substitution Principle (리스코프 치환 원칙)
  • ISP : Interface Segregation Principle (인터페이스 분리 원칙)
  • DIP : Dependency Inversion Principle (의존관계 역전 원칙)

이 다섯가지를 통틀어 SOLID원칙이라고 하는데, 실무에서 접하지 않는다면 이해하기가 어렵다고 생각한다. 구글에는 많은 글들이 있고 글을 읽을 때마다, 가지고 있는 생각들이 다 달라서 헷갈리기 일쑤였다. 하지만 컴포넌트를 잘 나누기 위해선 단일 책임 원칙만큼은 잘 이해하고 있어야 한다고 생각한다.

SRP 단일 책임 원칙 (Single Responsibility Principle)

하나의 책임이란 하나의 기능 담당과도 같은 말이다. 즉, 컴포넌트는 하나의 기능을 담당하여 하나의 책임을 가지고 있어야 한다는 말이 되기도 한다. 어떤 기준으로 기능을 나눠야 할까? 기준은 그 기능의 결합도응집도이다. 한 컴포넌트의 기능을 수정 했을 때, 여러곳을 손봐야 한다면 잘못된 구조를 짠 것이다. 위의 예시로 보자면, 연산 기능 버튼 컴포넌트와 숫자 버튼 컴포넌트, 연산 결과를 나타내는 기능만 가진 컴포넌트로 나누어져 있다. 만약 연산자에 나머지를 구하는 연산자를 추가한다면? OperatorButton 컴포넌트의 props만 변경해주면 쉽게 추가할 수 있다. 게다가 이렇게 나눠진 컴포넌트는 테스트틑 할 때도 기능 하나씩 테스트 할 수 있기 때문에 수월해진다.

마치며

사실 필자가 만든 예제가 올바른 예제인지는 모르겠다. 하지만 평소에 많이 고민하고 있었던 부분을 최대한 간단하고 쉬운 예제로 전달하려다 보니, 계산기 예제가 떠올랐다. 또한 SOLID 원칙은 많은 개발자들의 블로그 글이 있으니 꼭 다른 분들의 것도 읽어보길 바란다.

예제 코드 플레이그라운드

reference