귤 스터디 - challenge 5 회고록

바질·2025년 6월 25일

과제 수행 기간

2025.04.28 ~ 2025.05.25

  • 과제 수행: 2주
  • 과제 리뷰: 1주
  • 과제 회고: 1주

요구사항

Form API 스펙 설계

동작 화면

Form 라이브러리를 설계하여 시현하는 영상

설명

React-Hook-Form처럼 Form API를 설계하는 과제를 받았다. 이번 과제는 라이브러리를 설계하는 것인만큼, 리액트에서만 사용하는 것이 아닌 다양한 환경에서 사용할 수 있도록 ts로 작업했다. 그리고 그렇게 하는 것이 통상적이라 생각했다. 현재 과제를 살펴보면, 리액트로 구현된 과제이니 리액트로 개발하는 것이 쉬울 거라 생각했지만, 솔직히 라이브러리를 떠올리면, .js 라는 확장자가 떠올라 선택했다.

직접 코드를 구현하면서 수정하는 것보다 설계를 꼼꼼하게 해놓고 들어가는 편이 더 수월해보여 간만에 노트를 꺼냈다.

너무 많은 것을 하려는 생각은 버리고, 기본적인 것부터 챙기고자 했다.

따라서, 내가 만들 Form 라이브러리는 크게 3가지를 지원한다.

  1. 단일 스텝 form
    1. 회원가입, 로그인 등 하나의 페이지에서 form을 submit 함.
  2. 멀티 스텝 form
    1. 절차가 복잡한 회원가입 등 단계별로 절차가 있고 여러 개의 페이지에서 form을 submit 함
  3. 이미지 처리(파일 업로드)
📝

단일스텝 form을 구현하느라(Proxy를 이용한) 시간을 상당히 소모하여, 과제 기간 중 구현하지는 못했다. 그러나 어떻게 구현할 것인지에 대한 내용은 아래에 작성해보겠다.

구현한 전체 구조 요약

상태 관리 구조

  • gyulFormCore: 폼의 핵심 구현체로 ( values, errors, isLoading, fields ) 를 객체로 관리한다.
type StateType = {
  values: Record<string, any>;
  errors: Record<string, string>;
  fields: FieldsType;
  isLoading: boolean;
};
  • useGyulForm: 리액트에서 gyulFormCore를 사용하기 위한 어뎁터로, forceRedner를 유도하여 강제 리렌더링을 유발한다.
values: {username:"kim",email:"test@test.com", password:"1234", ...}
errors: {email:"이미 존재하는 이메일입니다", confirmPassword:"비밀번호가 동일하지 않습니다",...} 

register 함수

  • onChange를 사용하여 입력값 반영
  • name, required, minLength, maxLength , onChange를 반환한다.
  • options를 옵셔널로 받아 pattern, minLength, maxLength, equalsTo 등의 유효성 조건을 제공한다.
      <input
        {...register('email', {
          required: '이메일은 필수입니다',
          pattern: {
            value: /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/,
            message: '이메일 형식이 올바르지 않습니다',
          },
        })}
      />

유효성 검사

  • 들어오는 필드는 fields 객체에 저장
  • 검사 결과는 errors 객체에 저장하여 true , false , string 으로 반환
type FieldsType = {
  required?: boolean;
  pattern?: (string | RegExp)[];
  maxLength?: number;
  minLength?: number;
  equalsTo?: [string, string];
  validate?: (value: any) => true | string | Promise<true | string>;
};

fields: {
	required: true,
	pattern: [/^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/, "이메일 형식이 올바르지 않습니다"],
	...
}

// 유효성 검사 통과 => 별다른 반환값 없음
// 유효성 검사 실패 => errors 객체에 input name과 문자열을 key/value 값으로 저장

errors:{
	email: "이메일 형식이 올바르지 않습니다",
	...
}

handleSubmit 함수

// 데이터 구조
{username:"kim", email:"test", password:"1234", confirmPassword:"1234"}

입력한 데이터를 전송하기 위해 handleSubmit 함수를 제공한다. preventDefault()를 사용하여, 사용자는 사용할 필요가 없으며, form 을 전송하는 과정에서 isLoading, 콜백함수 를 지원한다. 즉, form 데이터를 서버에 전송하는 로직을 콜백 함수로 전달하면, handleSubmit 함수는 콜백 함수를 실행한 뒤, 성공과 실패를 반환한다. 성공 시에는 input 입력값을 초기화한다. 실패 시에는 에러를 반환하게 된다.

또한, form 데이터를 전송하기 전, fields에 저장한 유효성 조건들을 다시 실행하여, 검사를 진행하고, true 결과가 나왔을 때만 콜백 함수를 실행한다.

  const handleSubmit = (
    fn: (values?: any) => Promise<void> | void
  ): ((e: FormEvent<HTMLFormElement>) => void) => {
    return async (e?: FormEvent<HTMLFormElement>) => {
      e?.preventDefault?.();
      handler.set(rawState, 'isLoading', true);

      const valid = validateAllFields();
      if (!valid) throw new Error('유효하지 않은 값입니다.');

      try {
        await fn();
        reset();
      } catch (error) {
        return error;
      } finally {
        handler.set(rawState, 'isLoading', false);
      }
    };
  };

어려웠던 포인트

리액트 외부 상태 감지 및 강제 렌더링

📝

useGyulFormCore 내부의 값이 변경되어도 React가 감지하지 못하는 이유.

리액트에서는 UI를 변경하려면, setState() 함수를 호출하면 된다. 반대로 말하면, setState() 함수를 호출하기 전까지 변경사항이 반영되지 않는다는 것인데, 이러한 특성이 내가 구현한 코드에 문제가 되었다.

📝

.ts에서 변화를 감지하는 법과 리액트에서 이를 캐치하는 방법.

데이터가 변경되면, 변화를 감지할 수 있어야 한다. 정확히는, 데이터가 변경되었을 때 어떠한 동작을 해야 하기 때문에 내부적으로 변화 감지 시스템이 필요했다. 나는 이를 위해 Proxy를 선택했다. 데이터를 객체로 저장하고 있는데 객체 속성이 변경되면, 이 변화를 중간에서 가로채기 때문에 그 시점에 원하는 로직을 실행할 수 있었다.

또한, 이 변화를 리액트에서도 캐치해야 한다. 그러나 리액트가 외부 시스템에 변화가 일어난 사실을 알 수 없기 때문에, 강제 렌더링을 사용했다.

📝

리액트에서 변화를 캐치할 수 있는 core.subscribe 방식의 설계

 // gyulFormCore.ts
 
let subscribers: (() => void)[] = [];
const notify = () => subscribers.forEach((cb) => cb());

// useGyulForm.tsx
const [, forceRender] = useState(0);
const core = useGyulFormCore();

  useEffect(() => {
    const cb = () => forceRender((prev) => prev + 1);
    core.subscribe(cb);

    return () => {
      core.unSubscribe(cb);
    };
  }, [core]);

setState() 함수를 호출하면, 리렌더링 되는 것처럼, set 함수에 notify() 함수를 호출한다. 그리고, 이 함수에는 forceRender( setState() ) 함수가 들어있어, 사실상 의미 없는 로직을 실행한다. 그러면 리액트는 UI를 갱신하기 때문에 UI에 변화가 생긴다. subscribers 배열에는 forceRender 함수가 들어있다. gyulFormCore.ts에서 사용하는 로직이 어쩐지 리덕스의 createStroe listeners 와 유사한 것 같다. (콜백 함수를 저장하여 차례로 실행함)

유효성 검사(validate)의 구조 설계

📝

validate 의 구조 설계

여러가지를 생각해봤었다. 사용자 입장에서 아무런 값도 보내지 않았을 때, 기본적인 validate를 제공한다면 어떨까? 동시에, 사용자가 지정한 validate가 있다면, 그걸 우선적으로 사용하도록 한다. 그리고 마지막 form submit을 하면, 전체적인 validate를 한번 더 진행하고 전송하는 걸로 설계했다.

onChange로 받는 결과는 도중에 수정될 가능성이 있다. 한글 같은 경우, 알파벳과 다르기 때문에 자음이나 모음, 글자 한 개가 누락되는 경우가 있다. (onInput 으로 바꾸면 괜찮아지긴 한다) 그리고 사용자가 개발자 도구를 켜서 허용되지 않은 입력값을 넣었을 때, 체크하기 위함이기도 하다.

📝

validate 기능과 pattern 기능을 분리하여 구성

배열 안에 함수로 유효성 검사 로직을 받아 순차적인 실행을 할 계획이었다. 그러나 배열 안에서 단순한 처리와 복잡한 처리를 분리하고 실행하려면, 구현이 까다로워질 것 같았다. 값이 함수가 아니라 단순한 정규식이라면, 들어온 데이터와 비교해서 반환해줘야한다. 차라리, pattern과 validate를 구분하여 받는다면, 이메일이나 연락처, 최대/최소 숫자 같은 단순한 정규식 처리는 pattern에서 처리할 수 있다. 반대로 동적이고 복잡한 검사나 비동기를 활용한 검사는 validate에서 함수 형태로 받아 실행하는 쪽이 낫다.

못했던 부분을 직접 구현해본다면.

단일 스텝과 멀티 스텝

페이지가 여러차례 걸쳐 진행되는 거라면, 전역에서의 상태 관리가 필요하다.

(예를 들어, 약관 페이지의 동의 내용이 form 데이터에 포함된다면)

전역 상태 관리 툴을 사용할 수도 있지만, 간단하게 로컬 스토리지를 이용하는 것도 방법이다. 들어오는 조건에 따라 분기 처리를 하여, 데이터 값이 변화할 때마다 새롭게 로컬 스토리지에 저장한 뒤, 폼이 제출되면, 해당 데이터는 삭제한다.

const {register, values,errors,handleSubmit} = useGyulForm();
// 단일 스텝

const {register, values,errors,handleSubmit} = useGyulForm("sign");
// 멀티 스텝

멀티 스텝을 지원하는 form 라이브러리는 단일 스텝과 동일한 구조를 가지므로 분기 처리와 새로고침이나 페이지가 달라져도 데이터를 저장하여 가지고 있는 것만 해결하면 된다고 생각한다.

이미지 처리

이미지는 form 데이터로 전송하기 위해서, form 객체를 사용해야 한다. 따라서 객체로 저장한 데이터를 Object.keys, forEach 를 통해 하나하나 form 객체에 넣어주어야 한다. 또한, 이미지 확장자를 어디까지 허용할 것인지, 용량은 어디까지 받을 것인지, 파일은 하나만 받는지, 여러개를 받는지도 고려해야 한다.

추가적으로 생각해본다면, 이미지를 미리보기 하여 보여줄 수도 있다. 이 편이 사용자 경험은 더 좋을 거 같다. 다만 그렇게 하기 위해서는 캔버스의 활용이 필요하다. 좀 더 고급 내용을 넣자면, 이미지 크기 자르기 같은 것도 좋을 것이다…

느낀 점

🔚 회고

  • 변화 감지 / 강제 렌더링을 직접 설계하면서, 프록시와 구독 기반 강제 렌더링 구조의 중요성을 알게 되었다. 그리고 리액트는 굉장히 잘 만들어진 라이브러리라는 것도.
  • 사용성을 위해, 어디까지 제공해주는 것이 좋고, 자유롭게 두는 것이 좋을지, 어떻게 하면 학습 곡선을 낮출 수 있을 지에 대해 고민해보았다. 라이브러리에서 지정하여 제공하는 것보다, 사용자가 선택하여 커스텀 할 수 있도록 하는 것이 좋다고 생각했다.
  • 라이브러리는 많은 부분을 보완하여 제공하기보다, 추상화하고 설계에 대해 고민한 뒤, 사용자에 맞는 최소한의 부분만 제공하는 것이 낫다.

라이브러리 설계

이번 과제의 목표는 설계였고, README Driven Development를 해보면 좋을 것 같다고 했다. 항상 라이브러리를 써보기만 한 나는, 어떤 것부터 시작해야 하는지 감이 잘 오지 않았다. 접해봤던 React-Form-Hook을 떠올리며, 그것과 유사하게 구현하기 시작했다. 종이에 어디까지 개발을 할 것인지 정하고, 3가지 주제를 목표로 선택했다. 시간이 부족해 1가지만 완성했지만, 1가지라 하더라도 신경 쓸 것이 많았다.

사용자 경험

중점으로 둔 것은 사용자 편의성이었다. 사용자가 어떤 것을 필요로 할 지, 어떤 기능이 있다면 좋을지 생각했다. 그래서 유효성 검사를 기본 제공하기로 생각했다가 철회했다. 기본 제공이라는 것이 가볍게 사용하는 사람에게는 좋겠지만, 기본적으로 방해가 될 때가 많다. 또, 기본 제공과 더하여 다른 조건을 붙이고 싶은 케이스, 아예 커스텀하고 싶은 케이스 같은 상황에서 기본 제공하는 유효성 조건과 병합할 것인지 기존의 조건을 삭제하고 커스텀을 우선으로 할 것인지 고려해야 했기 때문이다. 병합하는 것은 사용자가 생각했을 때, 맞지 않은 동작일 것 같았고, 그렇다면 기본 유효성을 제거하는 것이 타당하다고 판단했다. 필요하다면, 조건을 전달하는 것으로 가볍게 사용하는 사용자, 복잡한 커스텀이 필요한 사용자 모두 사용할 수 있다. (가볍게 사용하려는 목적이더라도 유효성 조건은 전부 다르기 때문이다)

라이브러리의 목적

라이브러리는 모든 것을 포함할 필요가 없다고 보았다. 전부를 제공하기 보단, 필요한 부분, 적은 부분만 보완하고 제공한 뒤, 나머지는 사용자가 채워넣으면 된다. 예를 들어, 타입을 지정하는 것, zod 같은 라이브러리를 쓴다면 추정은 쉬워지지만, zod를 사용하지 않는 사용자는 힘들 것이다. 또한 모든 기능을 제공한다고 해서 모든 사용자가 그걸 전부 사용하는 것도 아니다. 때로는 사용자가 직접 커스텀하고 개발해서 사용하는 편이 좋을 수도 있다. 그래서 유효성 조건을 커스텀하게 하고, 콜백 함수를 받아 결과값만 리턴하는 식으로 설계해봤다.

form 라이브러리가 하는 일이라면, 데이터 값을 정리해서 사용자에게 보기 쉽도록 제공하는 것이 아닐까?

0개의 댓글