Overview

브랜디에서는 웹, 어드민(FMS 쪽)을 새로 제작할 경우 기존 서비스에서 만든 컴포넌트들을 최대한 활용하고, 기존에 없었을 경우 필요하면 새로운 컴포넌트를 제작하거나 스타일도 일일이 작업자가 직접 다 구현하는 형태로 구축하고 있습니다.

그러나 작년 8월에 셀피팀과 프로젝트를 진행하게 되면서 일정이 빠듯하고 다들 입사하신 지 얼마 안 되기도 했고 Vue.js 환경을 분석하는데도 시간이 빠듯한 상황이다 보니 컴포넌트를 분석할 시간을 최소화하고자 Bootstrap-vue를 도입하여 작업하는 방향으로 진행하게 되었는데요.

이번 주제는 이렇게 제공된 컴포넌트를 활용해서 어드민의 프론트 작업을 진행하면서 있었던 일과, 프로젝트를 진행하면서 생긴 시각과 관점 변화, 그로 인해 생겨난 결과물들의 일부에 대해 공유해보고자 합니다.


이미 Bootstrap-vue는 기능까지 다 제공하고 있는데 왜 컴포넌트를 또 만들어야 하죠?

셀피 어드민 작업이 진행된 지 얼마 후 다음과 같은 의견들이 있었습니다.

어드민에서 검색 영역이나 검색 결과 영역이 컴포넌트로 제작되었으면 좋겠어요.
반복적으로 사용되는 input들은 컴포넌트로 만들어지면 좋겠어요.


처음에는 이에 대해 부정적이었는데, 이유는 다음과 같았습니다.

  1. 검색 결과를 보여주는 영역이 버튼/조회, 결과 테이블, 페이지네이션 이렇게 포함되어 있다고 하면 버튼/조회 영역의 UI 또는 기능이 제각각이다. (첨부한 이미지 외에도 3가지 이상의 다른 케이스가 추가로 존재) 상단의 버튼, 조회에 해당하는 영역에 대한 처리가 어렵기 때문에 검색 결과 전체를 컴포넌트화 하는 건 무리로 보인다.
11
조회 건수 위에 버튼이나 notice 문구가 들어가는 형태
11
전체 조회 건수와 버튼이 있는 형태
11
결과 테이블에 checkbox가 들어가는 형태
  1. <b-table>자체가 컴포넌트인데 그거를 또 새로운 컴포넌트로 만들면 불필요한 겹 포장 같다.

     <!-- 자식 컴포넌트가 있다라고 가정하면 -->
     <template>
       <b-table :items="items" :fields="fields" />
     </template>
     <script>
     export default {
       name: 'ResultTable',
       props: {
         items: {
           type: Array,
           default: Array,
         },
         fields: {
           type: Array,
           default: Array,
         },		
       },
     }
     </script>
        
     <!-- 부모 컴포넌트 -->
     <template>
       <MainContainer>
         <!-- 의견은 이런식으로 자식 컴포넌트로 분리하자는 이야기인데 -->
         <ResultTable :items="items" :fields="fields" />
         <!--  빼고 이렇게 사용해도 마찬가지 아닌가 라는게 당시에  생각 -->
         <b-table :items="items" :fields="fields"></b-table>
       </MainContainer>
     </template>
     <script>
     export default {
       name: 'ResultTable',
       data() {
         return {
           items: [],
           fields: [],
         }
       },
     }
     </script>
    

3. 단순하게 데이터 리스트 정보를 그대로 보여주면 모를까, 필요에 따라 보이는 데이터 영역을 커스텀 해야 하는데, items와 fields를 연동한다 해도 추가작업은 필연적이다.

4. Number만 허용하는 Input이 필요하다면, <b-form-input>에서 props로 타입 지정을 할 수 있으니 여기에 number만 붙이면 된다. 굳이 만들어야 할까?

요약하면,

  1. UI나 레이아웃 관점으로 봤을 땐 뭔가 만들기 모호함
  2. Bootstrap-vue에 없는 UI이거나 기능을 새롭게 만들어야 한다면 몰라도 제목과 같이 이미 Bootstrap-vue에서 이미 기능을 제공 중인(<b-table>,<b-form-input>)과 같은 것들에 대해서 굳이 컴포넌트를 추가로 만들어야 할까?

가 저의 생각이었습니다.


그렇다면 그로부터 수개월이 지난 지금은 어떨까요?

athena


Bootstrap이 우리 프로젝트에 필요한 모든 기능을 다 제공하는 건 아니다.

처음에는 검색영역과 그 결과를 보여주는 영역, 간혹 신규로 등록하기 위해 입력하는 페이지 정도로 구분되어 있었지만, 그런 페이지들에 대한 작업이 거듭될수록 중복되는 기능과 레이아웃 들이 보이기 시작했습니다.

11
b-table에 체크 박스가 추가된 이것들이 한 페이지에 무려 3개, 4개씩 나온다든지. (하지만 b-table 컴포넌트에는 이 체크 박스와 관련된 옵션이 기본적으로 존재하지는 않는다. 결국 기능을 추가로 구현해야 됨)
11
옵션별 추가금액이나 소재 함유량처럼 입력되는 최대 숫자가 제한이 있는 경우. <input type=”number”> 에서 min, max와 같은 속성을 제공하고 있지만 이 min과 max에 들어간 기준값들은 화살표로 up/down 시에는 적용이 돼도 입력 후 바로 focusOut 처리를 하는 경우에는 제한할 수 없다.
// 옵션별 추가금액 input 영역
<b-input-group size="sm">
    <b-input-group-prepend>
       <b-button
           variant="secondary"
           size="sm"
           @click="
           item.optionPrice =
               Number(item.optionPrice) - 500 < 0 ? 0 : Number(item.optionPrice) - 500
           "
       >
           <span class="sr-only">차감</span>-
       </b-button>
    </b-input-group-prepend>
    <b-form-input
    v-model.trim="item.optionPrice"
    type="number"
    :step="500"
    :min="0"
    :max="5000"
    placeholder="0"
    size="sm"
    @blur="item.optionPrice > 5000 ? (item.optionPrice = 5000) : item.optionPrice"
    >
    </b-form-input>
    <b-input-group-append>
      <b-button
          variant="secondary"
          size="sm"
          @click="
          item.optionPrice =
              Number(item.optionPrice) + 500 > 5000 ? 5000 : Number(item.optionPrice) + 500
          "
      >
          <span class="sr-only">증가</span>+
      </b-button>
    </b-input-group-append>
</b-input-group>

// 할인율 input 영역
<b-input-group append="%" size="sm">
    <b-form-input
    v-model.trim="item.ratio"
    :readonly="!item.isChecked"
    :min="0"
    :max="100"
    :step="1"
    type="number"
    placeholder="0"
    @blur="item.ratio > 100 ? (item.ratio = 100) : item.ratio"
    ></b-form-input>
</b-input-group>
여러분은 지금, 미리 컴포넌트를 만들지 않고 촉박한 일정에 다급하게 계산할 수 있는 기능을 만든다는 이유로 method도 사용하지 않고 인라인으로 적나라하게 click 이벤트와 blur에 계산식을 넣은 정신 나간 코드를 보고 계십니다.
11
This is Pagook.
컴포넌트 문서가 잘 작성되어 있다고 믿었는데, 샘플을 가져다 쓰는 게 전부는 아니구나. 아~~~ 이제 그만 모든 검색리스트 페이지에 똑같은 method를 무의미하게 복붙하기 싫다. 저 입력창 같은 코드 언제까지 반복할 건데?


다행히 이걸 10년 뒤에 생각하지 않아서 다행입니다.

물론 처음부터 파악해서 만들었으면 더 좋았겠지만, 괜찮습니다. 지금부터 하나씩 만들어가면 됩니다.


그래서 이 프로젝트에선 어떤 걸 컴포넌트로 만들면 좋을까

셀피 어드민을 작업하면서 쌓인 경험을 토대로, 본인 포함 실제 작업자들이 작업하면서 셀피 어드민에 도입되면 좋을 컴포넌트 + MDM 신규 제작 시 있으면 좋은 컴포넌트에 대해 전체적으로 정리 및 의견 취합을 해봤습니다.

  1. Header, Footer, GNB, LNB, BreadCrumb와 같은 레이아웃 요소에 해당하는 컴포넌트
  2. 화면 작업 시
  3. Number만 입력 가능한 input (기능적인 부분)
  4. Confirm이나 Alert 용도로 활용하는 모달
  5. Textarea의 글자수 제한이 있을 경우 현재의 글자수를 체크하고 placeholder로 글자수 제한에 대한 정보를 자동으로 제공하는 컴포넌트
  6. 위에서 언급되었던 검색 리스트 페이지의 검색결과 관련 컴포넌트 (검색영역은 종명님이 이미 만듬)
  7. 날짜 없이 연도, 월만으로 검색할 수 있는 컴포넌트

(이하 생략)


1, 2, 4, 5번은 정말 무난하게 큰 이슈 없이 만든 거라 생략하고, 3번을 제작하는 과정에서의 있었던 일에 대해 공유를 해보겠습니다. (6, 7번도 공유할만한 거리는 있지만 한참 현재 진행형이라 패스)


내가 편리할 것이라 생각했지만 남들에게도 편리한 건 아니다.

처음 3번을 제작할 때의 목적이나 취지는 Number만 입력이 가능한 Input 컴포넌트가 아닌 통합형 Input 컴포넌트로, Number를 비롯한 일반 Input에 대한 validator 검사도 해주고, 정렬도 옵션 하나만 넣어주면 알아서 다 해주는 모든 걸 다 해낼 수 있는 만능 컴포넌트였습니다.

<template>
  <b-form-group
    :class="{ 'form-inline': displayInline }"
    :valid-feedback="validFeedback"
    :invalid-feedback="invalidFeedback"
    :disabled="disabled"
  >
    <b-form-select v-if="select" v-model="selected" class="mr-2" :options="options" :size="size"></b-form-select>
    <input
      ref="input"
      :class="[inputClassName, { 'is-invalid': state === false && value, 'is-valid': state && value }]"
      :value="value"
      :type="type"
      :readonly="plaintext || readonly"
      :placeholder="placeholder"
      :required="required"
      :style="{ width: !inline && !width ? null : width }"
      :maxlength="maxlength"
      :autofocus="autofocus"
      @input="updateInput"
    />
    <b-form-text
      v-if="infoText"
      :inline="displayInline && !infoBlock"
      :class="{
        'ml-1': displayInline && !infoBlock && infoType !== 'notice',
        'ml-2': displayInline && !infoBlock && infoType === 'notice',
      }"
      text-variant="black"
    >
      <b-icon v-if="infoType === 'notice'" icon="exclamation-circle" size="sm" aria-hidden="true"></b-icon>
      
    </b-form-text>
  </b-form-group>
</template>

<script>
export default {
  name: 'CustomInput',
  props: {
    type: {
      type: String,
      default: 'text',
      validator(v) {
        return v === 'text' || v === 'password'
      },
    },
    value: {
      type: [String, Number],
      default: '',
    },
    readonly: {
      type: Boolean,
      default: false,
    },
    disabled: {
      type: Boolean,
      default: false,
    },
    maxlength: {
      type: [String, Number],
      default: '',
    },
    autofocus: {
      type: Boolean,
      default: false,
    },
    plaintext: {
      type: Boolean,
      default: false,
    },
    onlyNumber: {
      type: Boolean,
      default: false,
    },
    decimal: {
      type: [String, Number],
      default: 0,
    },
    infoText: {
      type: String,
      default: '',
    },
    infoType: {
      type: String,
      default: '',
    },
    infoBlock: {
      type: Boolean,
      default: false,
    },
    width: {
      type: String,
      default: '',
    },
    size: {
      type: String,
      default: 'md',
      validator(v) {
        return v === 'md' || v === 'lg' || v === 'sm'
      },
    },
    inline: {
      type: Boolean,
      default: () => {
        return false
      },
    },
    select: {
      type: Boolean,
      default: false,
    },
    options: {
      type: Array,
      default: () => {
        return [
          {
            text: '선택',
            value: null,
          },
        ]
      },
    },
    state: {
      type: Boolean,
      default: () => {
        return null
      },
    },
    placeholder: {
      type: String,
      default: '',
    },
    invalidFeedback: {
      type: String,
      default: '',
    },
    validFeedback: {
      type: String,
      default: '',
    },
    required: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      selected: null,
    }
  },
  // 이하 생략
}
</script>
props가 무려 22개.


처음에는 Input 하나만을 고려했던 것에서 selectbox가 추가되고, 이런 기능들을 전부 넣어주면 더 다양하게 활용할 수 있고 편리하겠지 라고 생각하면서 추가했던 props들이 어느 시점부터 너무 많아지니 이게 정말 편리한 걸까 라는 의문이 생겼습니다.

그렇게 맞이한 첫 번째 코드리뷰는…..

11
웹툰 미생 39수. 여기서는 기획과 관련한 이야기지만, 개발에서도 통용되는 조언이다.


네, 정말 다시 생각해도 시원하게 말아먹었습니다.

원래 자신의 코드에 대한 방향성을 말로 표현하기 어려운 법인데 스스로 보면서도 컴포넌트를 만드는건 더 편리하려고 만드는건데 이게 정말 편리한 게 맞아? 라는 의문을 품은 시점에선 더더욱 다른 사람을 설득시키기 어렵죠.

그래도 다행인 건, 같이 해당 리뷰를 진행해주신 셀피팀 분들이 해당 컴포넌트의 방향성에 대해서 제안을 하면서 큰 줄기를 만들게 되었다는 점과 이 정도로 시원하게 말아 먹었다면 저 컴포넌트를 제작하는 데 들어간 시간에 대해 아까워할 필요 없이 새로 다시 원점에서 시작하자는 점에서는 소득이 있었습니다.


  1. Number와 일반 Input은 분리하자.
  2. 기존 props에 스타일과 관련된 요건도 많았던 만큼, 스타일 관련 props도 size 외엔 다 제외하고 순수하게 Input 그 자체 만을 보고 작업하자.


이를 토대로 Number로 한정했을 때 주로 사용될 만한 요건들을 파악하고자 셀피 어드민을 다시 한번 검토하면서 공통적인 사용처를 확인했습니다.

  1. 단위가 굳이 표기되지 않아도 숫자만 입력을 허용하는 Input (ex. 전화번호, 계좌번호 같은 것들)
  2. 단위도 함께 표기되어야 하는 Input
athena
  1. 증감 계산도 함께 제공되는 Input
athena

부트스트랩에서 이와 같은 레이요소를 모두 충족하는 기본 컴포넌트로 b-input-group 이 있어, 이것을 커스텀 하여 위의 기능들을 제공하는 Number 전용 Input을 만드는 것까지 확정했습니다.


목적과 방향성이 명확해졌다면, 다시 한번 만들어보자.

숫자만 입력 가능하게 하는 가장 흔한 방법으로는 <input type="number">가 있습니다.

그러나 <input type="number"> 를 사용할 경우 브라우저가 알아서 별다른 기능을 추가하지 않아도 소수점이나 마이너스, 숫자만 입력할 수 있도록 하는 편리한 이점이 있지만, 사용 시 발생하는 대표적인 이슈로 다음과 같은 사항들이 있습니다.

  1. 자동으로 발생하는 화살표 ui
  2. Firefox에서는 숫자뿐만 아니라 한글, 영어도 입력이 가능한 이슈
  3. 앞자리가 0일 경우, 자동으로 0을 삭제시키는 이슈

위의 3가지 중 가장 큰 이슈는 2번이었는데, 처음에는 2번의 경우에는 크로스 브라우징 이슈가 있는 사항이다보니 type="number" 대신 type="text" 를 이용해서 입력할 때 소수점이나 마이너스 등을 체크해서 입력을 제한 두는 방향으로 기능을 직접 구현하는 방향으로 모색했습니다.

athena

결론부터 말씀드리면, 1일 정도 직접 구현하다가 <input type="number"> 을 사용하는 것으로 방향을 선회했습니다.

  1. 대다수의 only 숫자 입력 허용은 event.keyCode를 활용한 것들인데, 한글 입력 방지는 인지하지 못하는 경우가 많았음
  2. 어드민 내에서 input 입력을 한다는 건 결국 저장/검색/수정과 관련한 같이 추가적인 동작을 해야 하므로 관련해서 validator 체크를 하면 한글이 입력 되도 이슈를 최소화 할 수 있음
  3. (결정적으로) 현재 한국 내 브라우저 점유율 현황을 보면 Firefox는 현재 맥에서만 서비스 중인 Safari나, 올해 6월이면 서비스 종료 예정인 IE 11보다도 낮음.
11
최근 2021년 12월까지 약 1년 간의 한국 내 데스크탑 브라우저 점유율을 보면 파이어폭스는 2%도 안된다.

4. 현재 상황에서는 이 1.79%의 점유율을 갖고 있는 Firefox에 대한 이슈 해결을 위해 이 컴포넌트에 시간을 투자하기보다 다른 컴포넌트에 투자하는 게 훨씬 낫다.

그렇게 기존의 코드와 비교해서 style과 관련된 코드를 제외하고 type이 number로 고정이 된 뒤에 다음과 같은 결과물이 나왔습니다.

<template>
  <b-input-group :size="size" :style="`width: ${width}`" :append="append">
    <b-input-group-prepend v-if="showButton">
      <b-button
        variant="dark"
        :disabled="readonly"
        :aria-label="`${step} 감소 버튼`"
        @click="calculateValue(step * -1)"
      >
        <b-icon-dash title="감소 아이콘" />
      </b-button>
    </b-input-group-prepend>
    <input
      ref="input"
      type="number"
      :value="value"
      :class="inputClassName"
      :min="min"
      :max="max"
      :step="step"
      :readonly="plaintext || readonly"
      :placeholder="placeholder"
      @input="updateInput"
      @blur="blurInput"
    />
    <b-input-group-append v-if="showButton">
      <b-button variant="dark" :disabled="readonly" :aria-label="`${step} 증가 버튼`" @click="calculateValue(step)">
        <b-icon-plus title="증가 아이콘" />
      </b-button>
    </b-input-group-append>
  </b-input-group>
</template>

<script>
export default {
  name: 'NumberInput',
  props: {
    value: {
      type: [String, Number],
      default: '',
    },
    min: {
      type: Number,
      default: null,
    },
    max: {
      type: Number,
      default: null,
    },
    width: {
      type: String,
      default: '',
    },
    size: {
      type: String,
      default: 'md',
      validator(v) {
        return v === 'md' || v === 'lg' || v === 'sm'
      },
    },
    placeholder: {
      type: String,
      default: '',
    },
    readonly: {
      type: Boolean,
      default: false,
    },
    plaintext: {
      type: Boolean,
      default: false,
    },
    step: {
      type: Number,
      default: 1,
    },
    append: {
      type: String,
      default: '',
    },
    center: {
      type: Boolean,
      default: false,
    },
    control: {
      type: Boolean,
      default: false,
    },
  },
	// 이하 생략
}
</script>

스타일과 관련한 옵션이 삭제되고 해당 컴포넌트에 대한 역할이 많이 간소화되면서 기존의 코드와 비교해서 props의 수가 절반 가까이 줄어들었고, bootstrap 내에서도 많이 사용하는 속성들만 포함돼서 작업자가 굳이 하나하나 사용 목적을 외울 필요가 사라졌습니다.

수정된 것을 기반으로 진행한 2차 리뷰에서는 이건 확실히 우리가 필요했고, 편리하게 쓸 수 있다는 확신이 있었던 만큼 첫 리뷰에 비해서는 긍정적인 분위기로 진행됐습니다.

(이때 일반 input에 대해서는 어떻게 할 것인지에 대한 질문이 있었는데, 이미 부트스트랩에서 충분히 props나 샘플로 사용 방법에 대해 레퍼런스 문서를 제공 중이고, 기존에 이슈 중 하나로 제기된 select나 input 인라인 형태로 구현하는 것들과 관련해서는 참고할 수 있는 화면을 제공해주는 방향으로 가기로 협의했습니다.)


좋은 컴포넌트는 결국 반복적인 피드백을 통해 만들어진다.

컴포넌트에 대한 방향성과 관련해서 리뷰가 끝났으니, 본격적으로 해당 코드에 대한 피드백이 이루어져야 할 시간입니다.

셀피 팀에서는 바쁜 와중에도 서로의 코드에 대해 리뷰 후 피드백해 주는 문화가 안착되어 있는데요. 이 컴포넌트에 대해서도 CodeCommit 을 활용하여 서로의 코드에 대해 피드백하는 시간을 가졌습니다.

<template>
<!-- 대략 생략 -->
</template>
 
<script>
 export default {
   name: 'NumberInput',
   props: {
     value: {
       type: [String, Number],
       default: '',
     },
     min: {
       type: Number,
       default: null,
     },
     max: {
       type: Number,
       default: null,
     },
     // ... 중략
     step: {
       type: Number,
       default: 1,
     },
   },
   computed: {
     inputValue: {
       get() {
         return this.value !== null ? Number(this.value) : null
       },
       set(newValue) {
         this.$emit('input', newValue)
       },
     },
     minValid() {
       return this.min !== null
     },
     maxValid() {
       return this.max !== null
     },
   },
   methods: {
     calculateValue(step) {
       const checkValue = this.inputValue + step
 
       if (this.minValid && checkValue < this.min) {
         this.inputValue = this.min
         return
       }
 
       if (this.maxValid && checkValue > this.max) {
         this.inputValue = this.max
         return
       }
 
       this.inputValue = checkValue
     },
     blurInput() {
       if (
         (this.minValid && this.maxValid && this.inputValue >= this.min && this.inputValue <= this.max) ||
         (!this.minValid && this.inputValue < this.min) ||
         (!this.maxValid && this.inputValue > this.max)
       )
         return
 
       if (this.minValid && this.inputValue < this.min) {
         this.inputValue = this.min
       }
 
       if (this.maxValid && this.inputValue > this.max) {
         this.inputValue = this.max
       }
     },
     // 이하 생략
   },
 }
</script>

위 코드에서 가장 크게 문제된 사항은 다음과 같습니다.

  1. method나 computed 변수가 직관적이지 않다.
athena

2. 하나의 if 문에 조건이 많다. (이 부분은 아래 이미지에도 있듯이 조건들을 정직하게 다 넣다 보니 굉장히 길어진 느낌이 없지 않아 있습니다.)

athena

피드백을 토대로, 네이밍을 좀 더 직관적으로 변경하고 조건을 간소화해서 2차로 피드백을 진행했습니다.

<template>
<!-- 생략 -->
</template>
 
<script>
 export default {
   // ... 생략
   methods: {
     isLessThanMin(isStep) {
       if (isStep) {
         return this.min !== null && this.inputValue + isStep < this.min
       } else {
         return this.min !== null && this.inputValue < this.min
       }
     },
     isMoreThanMax(isStep) {
       if (isStep) {
         return this.max !== null && this.inputValue + isStep > this.max
       } else {
         return this.max !== null && this.inputValue > this.max
       }
     },
     calculateValue(step) {
       if (this.isLessThanMin(step)) {
	         this.inputValue = this.min
         return
       }
 
       if (this.isMoreThanMax(step)) {
         this.inputValue = this.max
         return
       }
 
       this.inputValue += step
     },
     blurInput() {
       if (this.isLessThanMin()) {
         this.inputValue = this.min
         return
	     }
       if (this.isMoreThanMax()) {
         this.inputValue = this.max
         return
	     }
     },
     // 이하 생략
   },
 }
</script>

처음의 코드에 비해 정말 많이 개선되었지만, 아직도 일부 조건들이 중복되고 있습니다.

athena
athena

추가로 받은 피드백 내용을 적용한 끝에 다음과 같은 컴포넌트가 나왔습니다.

<template>
   <b-input-group :size="size" :style="`width: ${width}`" :append="append">
     <b-input-group-prepend v-if="showButton">
       <b-button
         variant="dark"
         :disabled="readonly"
         :aria-label="`${step} 감소 버튼`"
         @click="calculateValue(step * -1)"
       >
         <b-icon-dash title="감소 아이콘" />
       </b-button>
     </b-input-group-prepend>
     <input
       ref="input"
       type="number"
       :value="value"
       :class="inputClassName"
       :min="min"
       :max="max"
       :step="step"
       :readonly="plaintext || readonly"
       :placeholder="placeholder"
       @input="updateInput"
       @blur="blurInput"
     />
     <b-input-group-append v-if="showButton">
       <b-button variant="dark" :disabled="readonly" :aria-label="`${step} 증가 버튼`" @click="calculateValue(step)">
         <b-icon-plus title="증가 아이콘" />
       </b-button>
     </b-input-group-append>
   </b-input-group>
 </template>
 
 <script>
 export default {
   name: 'NumberInput',
   props: {
     value: {
       type: [String, Number],
       default: '',
     },
     min: {
       type: Number,
       default: null,
     },
     max: {
       type: Number,
       default: null,
     },
     width: {
       type: String,
       default: '',
     },
     size: {
       type: String,
       default: 'md',
       validator(v) {
         return v === 'md' || v === 'lg' || v === 'sm'
       },
     },
     placeholder: {
       type: String,
       default: '',
     },
     readonly: {
       type: Boolean,
       default: false,
     },
     plaintext: {
       type: Boolean,
       default: false,
     },
     step: {
       type: Number,
       default: 1,
     },
     append: {
       type: String,
       default: '',
     },
     center: {
       type: Boolean,
       default: false,
     },
     control: {
       type: Boolean,
       default: false,
     },
   },
   computed: {
     inputValue: {
       get() {
         return this.value !== null ? Number(this.value) : null
       },
       set(newValue) {
         this.$emit('input', newValue)
       },
     },
     inputClassName() {
       let className = 'form-control'
       if (this.center) {
         className += ' text-center'
       }
       if (this.plaintext) {
         className = 'form-control-plaintext'
       }
       return className
     },
     showButton() {
       return this.control && !this.plaintext && !this.append
     },
   },
   methods: {
     updateInput(event) {
       const { value } = event.target
       this.$emit('input', value)
     },
     isLessThanMin(value) {
       return this.min !== null && value < this.min
     },
     isMoreThanMax(value) {
       return this.max !== null && value > this.max
     },
     calculateValue(step) {
       const newValue = this.inputValue + step
       if (this.isLessThanMin(newValue)) {
         this.inputValue = this.min
         return
       }
       if (this.isMoreThanMax(newValue)) {
         this.inputValue = this.max
         return
       }
       this.inputValue = newValue
     },
     blurInput() {
       this.calculateValue(0)
     },
     focus() {
       this.$refs.input.focus()
     },
   },
 }
 </script>
 
 <style lang="scss" scoped>
 /* arrow ui를 숨기기 위한 스타일 작업 */
 input::-webkit-outer-spin-button,
 input::-webkit-inner-spin-button {
   -webkit-appearance: none;
   margin: 0;
 }
 
 input[type='number'] {
   -moz-appearance: textfield;
 }
	 </style>

처음의 코드와 비교해서 method 별로 역할이 분명해지고 무엇보다 blurInput의 그 수많은 조건이 사라진 것을 확인 할 수 있습니다.

여러 차례의 리뷰를 통해 처음 컴포넌트의 목적과 방향성(만능 Input 컴포넌트 → Number 전용 컴포넌트)이 바뀌었고, 코드가 여러차례의 피드백을 통해 간결하게 바뀌었습니다. 만일 PR이나 코드 리뷰 등의 과정이 없었거나, 혹은 그것을 진행하더라도 적당히 넘어갔다면 제일 처음에 작성했던 저 컴포넌트의 내용이 반영되어 올라갔겠죠.

혼자서도 발상과 설계는 할 수 있겠지만, 결국 좋은 컴포넌트를 만들 때 가장 필요한 건, 여러 사람의 관심과 반복적인 피드백입니다.


잘 완성된 것 같은 컴포넌트도 직접 사용해봐야 문제를 발견한다.

수차례에 걸쳐 피드백을 받아 개선된 이 컴포넌트를 사용할 차례입니다.

처음에 분명 알고 있었지만, 컴포넌트 적용 단계에서 수정되지 않거나 적용되지 않았던 사항들이 있습니다.

  1. <input type="number">를 사용 시 앞자리가 0일 경우, 자동으로 0을 삭제시키는 케이스
  2. <input type="number"> 에서는 <input type="text"> 처럼 maxlength 속성을 사용할 수 없는 케이스
athena

전화번호 입력은 위에서 언급된 2가지 문제를 모두 포함하고 있는 문제입니다.

Number일 경우 자동으로 맨 앞자리에 0을 지우는 케이스를 해결하는 방법 중 가장 많이 제시되는 케이스로는 Number를 String으로 변환하는 것이 방법이 있습니다.

그리고 Value를 String으로 받는다면, 허용한 길이만큼 자르게 해서 최대 길이를 제한할 수 있게 되겠죠.

위의 이슈를 해결하기 위해 0을 허용하는 옵션과 숫자의 최대길이에 대한 값을 추가로 넣어줍니다.

 export default {
   name: 'NumberInput',
   props: {
		// 상단에 중복되는 props 생략
    allowFirstZero: {
      type: Boolean,
      default: false,
    },
    maxLength: {
      type: [String, Number],
      default: '',
    }, 
	},
	computed: {
		inputValue: {
		  get() {
	        if (this.allowFirstZero) {
	          return this.value
	        }
	        return this.value !== null ? Number(this.value) : null
	    },
	    set(newValue) {
	        this.$emit('input', newValue)
	    },
	  },
	},
	// 중략
	methods: {
		updateInput(event) {
      let { value } = event.target
      if (this.allowFirstZero && this.maxLength) {
        value = value.substr(0, this.maxLength)
				// 아래의 구문이 없을 경우, Vue.js 에서의 값은 제한적으로 보이지만 input에서는 계속 값이 입력됨
        this.$refs.input.value = value
      }
      this.$emit('input', value)
    },
	},
}
11
10자리로 제한하고 테스트. 제한한 글자 수만큼만 허용이 되는 것을 확인할 수 있다.
계속 입력하고 있는지의 여부는 input에 커서가 깜박이지 않는 것으로 구분이 가능하다.


추가로 테스트하는 과정에서 input에 value 값이 비어 있더라도 blur가 될 때 자동으로 0이란 숫자가 붙는 문제가 발견되었습니다.

athena

focusing이 벗어날 경우, input에 Value 값이 없다면, 계산하지 않도록 return 처리하는 구문을 추가해줍니다.

blurInput() {
      if (this.allowFirstZero || !this.inputValue) {
        return
      }
      this.calculateValue(0)
},

이제 전화번호나 계좌번호와 같이 0으로 시작하면서 길이가 11~12자인 경우에도 편리하게 사용할 수 있도록 개선되었습니다.


Conclusion

이 컴포넌트는 앞으로도 여러 사람의 지속적인 사용을 토대로 계속 개선점을 찾아 나가게 될 것입니다.

그동안은 레이아웃 구조를 잡을 때만 컴포넌트를 어떻게 설계하면 좋을지 생각했던 저도, 기능 측면에서 어떤 것들을 컴포넌트화해야 할지 좀 더 시야가 넓어지게 되어 좋았습니다.

항상 시간을 들여 프론트에 대해서도 관심을 가져주시고 피드백도 디테일하게 해주시는 우석님, 종명님, 창원님께 감사드리고, 초기에 컴포넌트 작업을 진행할 때 여러 가지로 많은 조언을 주셨던 기성님께도 감사드립니다.


추가

브랜디는 셀피팀 뿐만 아니라 다른 팀이나 파트에서도 서로의 코드에 대해 리뷰하고 피드백해주는 문화가 있습니다. 어느 팀에 가도 좋으니 많이들 오셔서 즐겁게 같이 개발하면 좋겠습니다.

(다시 읽어도 정말 좋은 21년도 빌더왕 성현님의 랩스 글 홍보 - 웹서비스팀 개발문화 만들기)


참조


최한솔 | 풀필먼트개발실 풀필먼트개발팀
브랜디, 오직 예쁜 옷만