All Articles

확장과 유지보수가 쉬운 Form 만들어보기

확장성높은 Form 작성하기

Form 작성시 고민이 되는 부분은 api 상세 명세가 나오지 않았는데 마크업을 작성해야 한다는 것이다.

screen 6.png

위와 같은 Form을 만든다고 할때

  • Response → data로 변환하는 과정
  • data → Request 변환하는 과정

특히 이미 마크업을 다 해놓은 상태에서 response가 다른 구조로 들어올 예정이라면, 혹여 같은 구조로 들어온다고 하더라도 프론트에서 한번 정제해서 사용하는게 무조건 필요하다.


내가 생각한 Form 요구사항


1. 수정과 확장이 용이해야 한다.


Admin 페이지에서 콘텐츠를 등록하는 Form은 변경될 가능성이 굉장히 높다고 생각했다. 클라이언트가 갑자기 콘텐츠에 추가 정보들을 더 넣고 싶을 수도 있고, 아예 필드를 제거해버리고 싶을 수도 있다. 그때마다 기능들이 덕지덕지 붙은 함수들을 수정하는 건 무척 고역이다.

2. 에러 처리가 쉬워야 한다.

Form들은 각각의 제목에 길이제한이 있을 수 있고, 꼭 필요한 필드는 작성이 되었는지 확인을 해야 한다.

어떤 필드는 체크박스이고, 어떤 필드는 라디오 버튼일 수도 있다. 에러처리를 쉽게 만들어야 문제가 생겼을때 쉽게 확인할 수 있다.

3. 깔끔하게 작성해야 한다. (짧게)

최대한 단일책임원칙의 설계로 함수를 만들어야 하고 후에 수정할때 어렵지 않도록 짧게 작성할 수 있으면 좋다.


확장성이 높지 않고, 수정하기가 어려운 코드 예시

const updateContents = (payload) => {
  const { name, title, contents, additonalText, businesshours } = payload;
  const isAllRequiredFiledChecked =
    name && title && contents && additonalText && businesshours;

  if (!isAllRequiredFiledChecked) {
    throw new Error("어떤 필드가 누락되었습니다.");
  }
  const result = await axios.post("/api/admin/contents/update", {
    name,
    title,
    contents,
    additonalText,
    businesshours,
  });

  return result.data;
};

위처럼 작성하는 것은 그렇게 좋지 못하다고 느꼈다.

  • 오류 처리가 어려움: name이 20글자를 초과한다거나 필수 값인 라디오에서 체크가 안되거나 하면 수정이 어렵다.
  • 확장과 수정이 용이하지 못함: updateContents라는 함수내에서 title 항목을 수정하면 의도치 않게 관련되지 않은 다른 필드가 영향을 받을 수 있다. 이는 코드파악에 걸리는 시간이 걸어지는 것을 뜻하며, 유지보수의 어려움으로 직결된다.(캡슐화의 부족)
  • 매우 길어질 우려가 있다: Form에 들어가는 내용이 30개라면 어떨까? 한번에 request body에 30개를 보내면 그만큼 updateContents 함수의 길이가 길어질 수 밖에 없고 이는 수정하는 사람으로 하여금 많은 시간을 들여서 코드를 파악하게 만든다.


최대한 확장성 높게 만들어보자.

일단 Form을 작성 당시에 response가 어떻게 오는지 알 수가 없는 경우가 있다. 또 사전에 미리 명세를 맞춰놨다고 하더라도 수시로 변경될수도 있다. 그렇다고 마크업을 미리 하지 않고 있는 것도 문제이다.

1. 렌더링용 커스텀 form Object를 만든다.

const defaultFormData: FormData = {
  title: {
    name: "콘텐츠명",
    value: null,
    required: true,
    options: {
      limit: 20,
    },
  },
  city: {
    name: "도시",
    value: null,
    required: true,
    options: {
      list: CITES,
    },
  },
  displayStatus: {
    name: "전시 상태",
    value: null,
    required: true,
    options: {
      list: [
        { name: "전시중", value: true },
        { name: "전시 안함", value: false },
      ],
    },
  },
};

이렇게 렌더링용으로 사용할 Object를 하나 정의하고 이 안에 들어갈 Form들을 미리 만들어둔다.
👉🏻👉🏻 (사실 Form 레코드도 원본이 변경되는 것을 방지하기 위해 아예 캡슐화하여 복사본을 전달하는 것도 좋다.)
이러면 아래와 같이 Vue 기준으로 Form Object를 받아서 바인딩 해줄 수 있다.

<v-form @submit.prevent>
  <section class="form-item">
    <label class="required"> {{ title.name }} </label>
    <v-text-field
      outlined
      maxlength="20"
      dense
      :value="title.value"
      counter
      @change="updateTitle"
    />
  </section>
  <section class="form-item">
    <label class="required"> {{ city.name }} </label>
    <v-select
      :value="city.value"
      :items="CITY_SELECT_ITEM_LIST"
      class="select-option"
      outlined
      @change="selectCity"
    />
  </section>
</v-form>

<script lang="ts">
  // 단순하게 예시만..
  Vue.extend({
    props: {
      formObj: {
        type: Object,
        default: () => {},
      },
    },
  });
</script>

Response→ 커스텀 form object으로 변환해줄 Class 정의

생각했던 대로 response가 오지 않았다고 하자. 그럴 경우 이렇게 받은 response를 이전에 작성해둔 form object에 맞게끔 변환을 시켜주자.

export class ContentsResponseForm {
  private _formData: FormData;
  private readonly _data: FORM_DATA_API_RESPONSE_TYPE;
  constructor(data: FORM_DATA_API_RESPONSE_TYPE) {
    this._data = data;
    this._formData = cloneDeep(defaultFormData);
  }

  private processConvert() {
    // 변환해주는 함수들을 전부 넣어준다.
    this.setCity();
    this.setTitle();
    this.setTags();
    this.setWeight();
    this.setDescription();
    this.setPhone();
    this.setAddress();
  }

  get converted() {
    //public으로는 converted로 변환된 값을 받게끔 추상화해주었다.
    this.processConvert();
    return this._formData;
  }

  private setCity() {
    const { city } = this._data;
    this._formData.city.value = value;
  }

  private setTitle() {
    const { subject } = this._data;
    this._formData.title.value = subject;
  }
  //... 기타 form들
}

response를 받는 부분에서 바로 변환 작업을 하게 되며 변환한 데이터를 Vue 코드에 전달하게 되는 것이다.

async fetchContentsDetail =(아이디) =>{
  const result = await this.$axios.get(`api url ${아이디}`)
  const responseData = new ContentsResponseForm(result.data)
  return responseData.converted
}

위에는 변환 작업을 바로 해서 정제된 데이터를 바로 보내주도록 한 예시이다.
굳이 api에서 바로 변환작업을 안해도 된다. Vue fetch, created시에 받아온 데이터를 정제해서 바인딩 해도 된다.

커스텀 Form Object → Request

이제 Form을 전부 입력해서 서버로 request 요청을 하는 작업에서는 위에서 response 데이터를 form으로 바꾸었던 변환작업을 반대로 거치면 된다.

export class ContentRequestForm {
  private _requestForm;
  private _data: ContentsState;
  constructor(storeData: ContentsState) {
    this._requestForm = {};
    this._data = storeData;
  }

  get converted() {
    this.processConvert();
    return this._requestForm;
  }

  private processConvert() {
    this.setSubject();
    this.setCityId();
    this.setActive();
    this.setWeight();
    this.setKeywords();
    //... 기타.
  }

  private setCityId() {
    const { city } = this._data;
    this._requestForm.cityId = city.value;
  }

  private setSubject() {
    const { title } = this._data;
    this._requestForm.subject = title.value;
  }

  private setActive() {
    const { displayStatus } = this._data;
    this._requestForm.active = displayStatus.value;
  }
}
// Request 보내는 부분
async createContentsEntity () {
    const currentStateCopy = cloneDeep(this.state.contents)
    const contentRequestForm = new ContentRequestForm(currentStateCopy)

    await this.$axios.$post(
      endpoints.createContents(),
      contentRequestForm.converted,
    )
  },

보내는 부분에서는 마찬가지로

  1. ContentRequestForm 클래스를 선언하고
  2. 변환작업으로 받은 contentRequestForm.converted 를 서버로 보내준다.

에러 핸들링하기

이렇게 작성해 놓으면 에러처리하기가 정말 쉬워진다. 예외처리하는 코드들을 전부 개별 함수에서 목적에 맞게 처리할 수 있고, 수정하는 부분에서도 흐름을 따라갈 수 있어서 어렵지 않게 처리할 수 있다.

class ContentsRequestForm {
  //... 중간 생략
  private setSubject() {
    const { title } = this._data;
    if (title.value === null) {
      throw new Error("제목은 필수입니다");
    }

    if (title.value.length > 20) {
      return new Error("제목은 20자 이내로 입력해주세요");
    }
    this._requestForm.subject = title.value;
  }
}

후에 title을 처리하는 부분에서 문제가 생겼다면 이것저것 뒤섞인 짬뽕코드를 확인할 필요없이 setSubject함수만 보면 된다.

확장의 용이함

가장 큰 장점은 확장하기가 쉽다는 것이다. 예를 들어 클라이언트가 콘텐츠에 전화번호를 삽입하고 싶다고 한다면 formData, Requset와 ResponseForm에서 각각 추가해주기만 하면 그만이다.

class ContentResponseForm {
  private processConvert(){
      this.setSubject()
      this.setCityId()
      this.setActive()
      ....
      this.setTelInfo() // 전화번호 추가
    }
    private setTelInfo(){. // 전화번호 추가
      const { telInfo } = this._data
      this._formData.phone.value = telInfo
    }
}

class ContentRequestForm {
    private processConvert(){
      this.setSubject()
      this.setCityId()
      this.setActive()
      ....
      this.setTelInfo() // 전화번호 추가
    }

    private setTelInfo(){. // 전화번호 추가
      const { phone } = this._data
      this._requestForm.telInfo = phone.value
    }
}

마무리

JS에서 제공하는 클래스를 잘 활용한다면 지금 여기쓰인 Form뿐만이 아니라 캡슐화가 필요한 다양한 분야에서 확장성 있게 설계할 수 있다.

특히나 배송비 계산, 상품 가격 계산 등은 수시로 바뀌기 굉장히 쉽다. 그리고 이런 녀석들은 돈과 직접 관련된 로직인 경우가 많을 것이다.

단일책임원칙이 적용된 함수를 설계하고 최대한 캡슐화를 통해 수정시 영향을 받지 않도록 해야 하며,
더 나아가서 내가 아닌 다른 사람이 수정을 하더라도 어렵지 않게 작성하는 것이 필요하다. (그리고 경험상 결국엔 자기 자신이 수정할 일이 더 많다.)