form의 도메인 컨텍스트 관리에 대한 고민

김채은·2023년 11월 25일
2
post-thumbnail

들어가며

공통 컴포넌트를 만들 때 가장 중요한 요소 포스팅에서 inputattributesvalidate를 한번에 모아서 관리하는 객체를 만들었다.

const SignInInputs = {
  email: {
    attributes: {
      placeholder: "이메일을 입력해주세요.",
    },
    validate: {
      required: {
        value: true,
        message: "이메일을 입력해주세요.",
      },
      pattern: {
        value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
        message: "올바른 이메일 형식이 아닙니다.",
      },
    },
  },
  password: {
    attributes: {
      placeholder: "비밀번호를 입력해주세요.",
      type: "password",
    },
    validate: {
      required: {
        value: true,
        message: "비밀번호를 입력해주세요.",
      },
      minLength: {
        value: 8,
        message: "최소 8자 이상의 비밀번호를 입력해주세요.",
      },
      maxLength: {
        value: 64,
        message: "비밀번호는 64자를 초과하면 안됩니다.",
      },
      pattern: {
        value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
        message: "영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요",
      },
    },
  },
};

최종 형태는 위와 같이 나왔었는데, Object로 관리하다보니 강제성이 없어서 오류가 발생하기 쉬웠다.

이처럼 validate에 유효하지 않은 key가 들어가도 아무런 제약이 없어서, 렌더링 시 오류가 발생하면 파악이 어려울 것으로 예상된다.

객체에 타입을 지정해주면 되지만, 로그인, 회원가입, 편지 작성 등 비슷한 형태의 객체들을 일관성있게 관리하고 싶었다. 또한 변경 가능성에 유연하게 대처할 수 있도록 여러가지 기능들을 추가하는 게 좋을 거란 생각이 들었다.

이번 포스팅은 form의 도메인 컨텍스트를 strict하게 관리하는 객체를 만들면서 고민한 과정을 담은 짧은 글이 될 것이다.

첫 번째 방법

어떻게 관리할지 고민하다가, class를 사용해서 관리하면 좋을 것이라는 생각을 했다.

SRP를 지키기 위해서 input의 HTML Element와 관련된 attributes와 React Hook Form 관련된 validate를 분리하는 것을 고안했다.

class InputElement{
	private name: string;
  	private attributes: InputHTMLAttributes<HTMLInputElement>;
  
  	constructor(name: string, attributes: InputHTMLAttributes<HTMLInputElement>){
      this.name = name;
      this.attributes = attributes;
    }
}

class InputValidate{
  private validate: RegisterOptions;
  
  constructor(validate: RegisterOptions){
    this.validate = validate;
  }
}

const EmailInputElement = new InputElement('email', {placeholder: "..."});
const EmailInputValidate = new InputValidate({maxLength: {...}};

하지만 로그인 창 뿐만 아니라 회원가입 창에서 이메일, 비밀번호, 비밀번호 확인, 닉네임 등 다양한 값을 받으려면, 각각 인풋에 대해 객체를 두 개씩 생성해서 관리해야 되기 때문에 코드의 양이 너무 늘어나게 된다.

실제 복잡한 로직이 담긴 코드라면 최대한 분리를 하는 게 맞겠지만, 사실 내가 만들려고 하는 객체는 도메인과 관련된 데이터를 담아두는 상수와 같은 역할을 하기 때문에 이런 식의 관리는 과도하다는 생각이 들었다.

두 번째 방법

객체를 리턴하는 함수를 만든다. 인자로 데이터를 넘겨주며 형식에 안맞는 경우 에러를 발생시킨다.

const InputContext = (
	name: string, 
    attributes: InputHTMLAttributes<HTMLInputElement>, 
    validate: RegisterOptions
) => {
	return {[name]: {attributes, validate}}
}

const EmailInputContext = InputContext('email', {...}, {...});
const PasswordInputContext = InputContext('password', {...}, {...});

이렇게 하면 이메일 인풋 관련 객체를 두 개에서 하나로 줄일 수 있다. 하지만 이렇게 만든 객체를 각각 사용하는 게 아니라, 하나의 폼 객체로 모아서 관리해야 한다. 기존의 객체와 형식을 맞추려면 이렇게 작성해야 한다.

const FormContext = {...EmailInputContext, ...PasswordInputContext}

이러한 형태 역시 보일러 플레이트가 많이 발생한다는 생각이 들었다. 이런 반복 작업이 생각보다 개발자를 스트레스 받게 하기 때문에 좀 더 간단한 방법이 없을까 고민했다.

세 번째 방법

form context라는 클래스를 두고 그 안에 input 정보를 등록하는 방식이다.

interface InputContextReturn {
  [name: string]: {
    attributes: InputHTMLAttributes<HTMLInputElement>;
    validate?: RegisterOptions;
  };
}

class FormContext {
  private context: InputContextReturn;

  constructor() {
    this.context = {};
  }

  // [1]
  makeInputContext(
    name: string,
    attributes: InputHTMLAttributes<HTMLInputElement>,
    validate?: RegisterOptions
  ) {
    return { [name]: { attributes, validate } };
  }
  
  // [2]
  register(
    name: string,
    attributes: InputHTMLAttributes<HTMLInputElement>,
    validate?: RegisterOptions
  ) {
    this.context = {
      ...this.context,
      ...this.makeInputContext(name, attributes, validate),
    };
  }
  
  getContext() {
    return this.context;
  }
}
  1. makeInputContext: 들어온 데이터들을 올바른 형식의 객체로 리턴해준다.
  2. register: 데이터를 받아 context에 넣는다.
const SignInFormContext = new FormContext();

SignInFormContext.register(
  "email",
  {
    placeholder: "이메일을 입력해주세요.",
  },
  {
    required: {
      value: true,
      message: "이메일을 입력해주세요.",
    },
    ...
  }
);

SignInFormContext.register("password", {...}, {...});
<Form
onSubmit={signIn}
inputs={SignInFormContext.getContext()}
buttonText={"로그인"}
>
  <div>로그인</div>
</Form>

위와 같이 사용하면 여러가지 폼에 대해서 같은 형태를 가지는 인스턴스를 생성하여 관리할 수 있다. 또, Form 컴포넌트에서 요구하는 데이터 형식이 바뀌더라도 makeInputContext 또는 register만 변경하면, 기존 데이터에 대한 수정이 필요없다.

타입에 맞지 않는 데이터가 들어오면 다음과 같이 에러가 발생한다.

문제점

FormContext 안에서 private인 context를 리턴할 때 해당 객체를 그대로 주면 외부에서 SignInFormContext.getContext().name = "something"과 같이 수정할 수 있다.

class FormContext{
  private context;
  
  //...
  
  getContext() {
    return this.context;
  }
}

이 문제를 해결하기 위해 deepCopy라는 유틸 함수로 this.context를 감싸서 리턴해줬다.

interface CopyObject {
  [key: string]: any;
}

function deepCopy(obj: CopyObject) {
  var clone: CopyObject = {};
  for (var key in obj) {
    if (typeof obj[key] == "object" && obj[key] != null) {
      clone[key] = deepCopy(obj[key]);
    } else {
      clone[key] = obj[key];
    }
  }

  return clone;
}

하지만 정규식 validation을 잡아내지 못하는 오류가 발생했다. typeof 연산자가 RegExp형의 type을 "object"로 반환해서 다음의 2번처럼 빈 오브젝트를 리턴해버린다.

object 탐색 조건에 RegExp인 경우를 제외해줬더니 제대로 동작했다. 자바스크립트 오브젝트는 너무 다양하기 때문에 이러한 상황들이 생길 때마다 꼼꼼히 체크해야 한다.

function deepCopy(obj: CopyObject) {
  var clone: CopyObject = {};
  for (var key in obj) {
    if (
      typeof obj[key] == "object" &&
      obj[key] != null &&
      !(obj[key] instanceof RegExp)
    ) {
      clone[key] = deepCopy(obj[key]);
    } else {
      clone[key] = obj[key];
    }
  }

  return clone;
}

마치며

'다음엔 이걸 좀 개선해봐야겠다'고 생각한 것을 생각만 하지 않고 시도해서 결과를 냈다는 것이 뿌듯하다.

프론트엔드 개발을 하다보니 객체지향 프로그래밍에 대해 깊게 생각해볼 기회가 많이 없었던 것 같다. 하지만 도메인 컨텍스트와 컴포넌트를 분리하는 작업을 하면서 도메인 데이터를 어떻게 규칙성 있게, 효율적으로 관리할까 고민을 하다보니 자연스럽게 클래스 도입을 생각하게 됐다. 그리고 직접 작성해보며 변경 가능성과 재사용성, 보일러 플레이트를 줄이는 방안 등에 대해 고민하는 과정이 의미있었다.

이것을 발판 삼아서 객체지향에 대해서 좀 더 공부하다보면 지금 작성한 코드가 부끄럽게 느껴지는 날이 올지도 모르겠다. 그렇다고 해도 고민해온 과정들의 가치가 사라지는 건 아니니까... 이 글은 여기 늘 남아있을 것!

해당 포스트에 대한 의견이나 조언이 있으시면 댓글로 나누어주세요! 감사합니다.

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

0개의 댓글