본문 바로가기

WEB

못말리는 Input 달래주기 1편 (숫자 입력)

안녕하세요! WEB 김채현입니다.

BEAT는 공연 정보, 공연진 정보, 예매 정보 등 다양한 정보를 입력해야 하기 때문에 Input 컴포넌트가 많이 쓰이는 서비스이고, Input 공통 컴포넌트 제작을 맡게 되었습니다.

 

입력하는 항목들이 가격, 수량, 이름 등 다양한 종류가 있어서 `filter`를 `prop`으로 주어서 각 항목에 맞게 필터링을 하도록 구현했습니다.

 // 값 입력될 때
  const handleOnInput = (e: ChangeEvent<HTMLInputElement>) => {
    const inputName = e.target.name;
    let inputValue = e.target.value;

    if (filter) {
      inputValue = filter(inputValue);
    }
    if (maxLength && inputValue.length > maxLength) {
      inputValue = inputValue.slice(0, maxLength);
    }

    const newEvent = {
      ...e,
      target: {
        ...e.target,
        name: inputName,
        value: inputValue,
      },
    } as ChangeEvent<HTMLInputElement>;

    onChange(newEvent);
  };
<S.TextFieldInput
          ref={inputRef}
          value={value}
          name={name}
          onChange={handleOnInput}
          maxLength={maxLength}
          placeholder={placeholder}
          $narrow={narrow}
          type={isPasswordVisible ? "text" : "password"}
          {...rest}
 />
export const numericFilter = (value: string) => {
  return value.replace(/[^0-9]/g, "");
};

 

그 중에서 문제가 된 필터는바로 이 필터입니다.

정규 표현식 `[^0-9]` `\D` `\d` 로 숫자만 입력하도록 `replace`로 구현했습니다.

input에 한글이랑 영어, 특수문자를 입력했을 때 입력이 아예 되지 않고, 숫자만 입력되도록 구현이 잘 되었습니다.

 

⚠️ 중간에 한글을 작성하면 이슈 발생

그런데 문제가 있었습니다.

만약 숫자를 입력하다가 한글을 입력하게 된다면??

이상하게 다른 것들은 그냥 입력 방지만 되는데 한글만 입력하면 기존에 입력했던 숫자가 사라지는 문제가 발생했습니다.

 

심지어 모바일에서는 제대로 작동되는데 왜 노트북에서 한글만 입력하면 이런 문제가 발생하는 것일까요??

 

그 이유는 한글 입력 방식과 브라우저의 이벤트 처리 방식의 차이 때문입니다.

한글 입력은 조합형 문자 입력을 사용하기 때문에, 한글 자모가 입력되는 과정에서 여러 번의 이벤트가 발생하게 됩니다.

이 과정에서 한글 자모가 입력될 때마다 `handleOnInput` 함수가 호출되고, `numericFilter`가 적용되는데

한글이 필터링에 의해 제거되면서 기존에 입력된 숫자값도 함께 사라지게 된다고 합니다.

 

이러한 문제를 해결하기 위해 블로그에서 많이 다루는 방법으로는

`style="ime-mode:disabled;"`인데 모든 브라우저에서 적용이 되지는 않는다고...

`onkeyup`을 사용하는 방법도 있는데 이 방법은 텍스트가 아예 입력이 되지 않는게 아니라 나타났다가 삭제되는 문제가 있었습니다. 또한 복붙은 처리가 되지 않습니다....🥹

 

따라서 저는 트러블 슈팅을 기록하게 되었습니다.

 

💡 composition 이벤트

조합형 문자에서 발생한 이벤트는 composition으로 해결할 수 있지 않을까라고 생각했습니다.

composition 이벤트는 텍스트 입력 도중 조합형 문자를 처리하기 위해 사용되는 이벤트로 특히 한글, 중국어, 일본어 같은 조합형 문자 입력에서 중요한 역할을 하는 이벤트입니다.

  • compositionstart 이벤트
    • 사용자가 조합형 문자 입력을 시작할 때 발생
    • 사용자가 한글 자모를 입력하기 시작하면 이 이벤트가 트리거
  • compositionend 이벤트
    • 사용자가 조합형 문자 입력을 완료할 때 발생합니다
    • 사용자가 '가'를 완성했을 때 이 이벤트가 트리거
const [isComposing, setIsComposing] = useState(false);
<S.TextFieldInput
          ref={inputRef}
          value={value}
          name={name}
          onChange={handleOnInput}
          onCompositionStart={handleCompositionStart}
          onCompositionEnd={handleCompositionEnd}
          maxLength={maxLength}
          placeholder={placeholder}
          $narrow={narrow}
          type={isPasswordVisible ? "text" : "password"
          {...rest}
        />
const handleCompositionStart = () => {
    setIsComposing(true);
  };

  const handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
    setIsComposing(false);
    handleOnInput(e as unknown as ChangeEvent<HTMLInputElement>);
  };
const handleOnInput = (e: ChangeEvent<HTMLInputElement>) => {
    if (isComposing) {
      return;
    }

    const inputName = e.target.name;
    let inputValue = e.target.value;

    if (filter) {
      inputValue = filter(inputValue);
    }
    if (maxLength && inputValue.length > maxLength) {
      inputValue = inputValue.slice(0, maxLength);
    }

    const newEvent = {
      ...e,
      target: {
        ...e.target,
        name: inputName,
        value: inputValue,
      },
    } as ChangeEvent<HTMLInputElement>;

    onChange(newEvent);
  };

 

따라서 이렇게 코드를 추가했습니다.

숫자를 입력하고 한글을 입력해도 숫자가 지워지지 않습니다!!

 

그런데 또 다시 발생한 문제는 한글을 입력한 후에는 숫자를 입력해도 입력이 되지 않았습니다...

compositionend가 제가 생각한대로 한글을 멈추고 숫자를 입력한다고 끝나는게 아니었던것이 해결이 되지 못한 이유라고 생각했습니다.(아닐수도...)

 

🎉 requestAnimationFrame으로 비동기적으로 필터링 적용

브라우저와의 차이가 문제라면 브라우저의 렌더링 사이클과 동기화를 시키면 되지 않나..?

`requestAnimationFrame`을 사용해서 비동기적으로 필터링을 적용했습니다.

 

사실 동아리에서 아티클 발표를 위해서 웹 애니메이션 최적화 공부하면서 `requestAnimationFrame`을 처음 알게 되었는데,

이것을 이렇게 쓰게 될 줄은 몰랐습니다....ㅎㅎㅎ

 

`requestAnimationFrame` 함수는 시스템이 프레임을 그릴 준비가 되면 애니메이션 프레임을 호출하는 함수입니다. 

주로 웹 애니메이션이나 그래픽 작업할 때 주로 사용하는 함수입니다.

하지만 UX 개선을 하기 위해서 사용하기도 합니다.

 

 setTimeout이나 setInterval을 사용하지 않는 이유??

세팅한 시간을 보장하기도 어렵고 메모리 누수의 노출 위험이 있기 때문입니다.

requestAnimationFrame을 사용하면

브라우저 렌더링의 관련된 태스크 처리를 위해 Animation Frame에서 진행할 수 있습니다.

requestAnimationFrame은 callback을 통해 최적화도 가능하다고 합니다.

 

  const [inputValue, setInputValue] = useState(value as string); // 현재 입력값
  const prevValueRef = useRef(value as string); // 이전 입력값
  const rafRef = useRef<number | null>(null); // requestAnimationFrame ID
 
  useEffect(() => {
    setInputValue(value as string);
    prevValueRef.current = value as string;
  }, [value]);

  const handleOnInput = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setInputValue(newValue);

      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current); // 이전 requestAnimationFrame이 있으면 취소
      }

      rafRef.current = requestAnimationFrame(() => { // 새로운 requestAnimationFrame 요청
        let filteredValue = newValue;
        if (filter) {
          if (newValue.length < prevValueRef.current.length) {
            // 삭제라면 전체 값 필터링
            filteredValue = filter(newValue);
          } else {
            // 추가라면 새로 추가된 부분만 추출해서 필터링 후 합치기
            const addedPart = newValue.slice(prevValueRef.current.length);
            const filteredAddedPart = filter(addedPart);
            filteredValue = prevValueRef.current + filteredAddedPart;
          }
        }

        if (maxLength && filteredValue.length > maxLength) {
          filteredValue = filteredValue.slice(0, maxLength);
        }

        const newEvent = {
          ...e,
          target: {
            ...e.target,
            name: name,
            value: filteredValue,
          },
        } as ChangeEvent<HTMLInputElement>;

        onChange(newEvent);
        prevValueRef.current = filteredValue; // 이전 입력 값을 현재 값으로 업데이트
        setInputValue(filteredValue); // 현재 입력값을 필터링된 값으로 업데이트
      });
    },
    [onChange, filter, maxLength, name]
  );
<S.TextFieldInput
          ref={inputRef}
          value={value}
          name={name}
          onChange={handleOnInput}
          maxLength={maxLength}
          placeholder={placeholder}
          $narrow={narrow}
          type={isPasswordVisible ? "text" : "password"}
          {...rest}
        />

IME 조합 상태(isComposing) 대신 실제 입력값을 가져와서 이전 값과 애니메이션 프레임 참조를 사용해서 컨트롤하는 방식으로 코드를 변경했습니다.

`requestAnimationFrame`을 사용해서 입력이 즉시 반영되고 필터링은 다음 프레임에서 처리해서 비동기적으로 필터링을 적용했습니다.

 

1. 새로운 입력값을 상태에 저장한다.

2. 이전 `requestAnimationFrame`을 취소하고 새로운 `requestAnimationFrame`을 요청한다.

    ➡️ 필터링 및 입력 값 처리를 비동기적으로!!

3. 입력값의 변화가 삭제이면 전체 값을 필터링, 추가이면 새로 추가된 부분만 필터링한다.

4. 필터링 후의 값을 업데이트한다.

 

++) 생각해보니까 비동기적으로 필터를 적용하는데 굳이 새로 추가된 부분만 따로 추출할 필요가 없을 것 같았습니다. 그래서 로직을 조금 더 간단하게 바꿨는데 잘 작동하는 것을 확인할 수 있었습니다.

        if (filter) {
          filteredValue = filter(newValue);
        }

 

아무 이슈없이 완벽하게 숫자만 입력되는 Input 구현 완료~~

복붙도 어림없지~~

 

헤헤 이렇게 정리했으니까 다음에 Input 구현할 때는 빠르게 할 수 있을 것 같습니다.

그럼 안녕~~🖐🏻

 

프로필 이미지

WEB_김채현

공연 등록하기를 맡은 FE 개발자 김채현입니다.