본문 바로가기

TIL(Today i learned)

[TIL-2022.09.29] keydown 한글입력 에러해결 feat. compositionEnd

1.알고리즘 문제풀기 : 

8시30분-9시30분 아침 알고리즘 스터디 진행 

 

2.해시태그 인풋 만들기 : 

오늘은 아침에 마저 해시태그 인풋을 만들었다. 타입때문에 잠깐 애를 먹고 있다. 

      if (tags) setTags([...tags, value]);
      else setTags([value]);

이런식으로 set해주게 만들었지만, 뭔가 부족하다고 생각한다. 조금 더 고급스러운 방법은 없을까. 

 

3.selectionEnd 속성 :

 

evergreen이라는 ui 라이브러리에서 selectionEnd라는 input 이벤트 속성을 사용하는 것을 발견했다. 이것이 뭔가 확인해보니, input이나 textarea 내부에서 마우스커서의 위치가 어디었는지를 확인하게 해주는 그런 이벤트 속성이었다. evergreen은 이 속성을 다음과 같이 사용하고 있었다. 

    const handleKeyDown = event => {
      const { selectionEnd, value } = event.target
      const key = GET_KEY_FOR_TAG_DELIMITER[tagSubmitKey]

      if (event.key === key) {
        event.preventDefault()
        addTags(value)
      } else if (event.key === 'Backspace' && selectionEnd === 0) {
        handleBackspaceToRemove(event)
      }
    }

그러니까 키다운 이벤트가 일어나면, 값과 마지막 커서가 어디에 있었는지를 받아온다. 만약 key가 " , " 나 " Enter " 이었다면, addTags를 해준다. 그게 아니라면 Backspace가 일어났고 그 Backspace를 했을 때의 커서 위치가 input의 맨 처음에 닿아있다면 태그를 지워준다. 그러니까 Backspace라고 무조건 지우는 것이 아니라, Backspace를 했는데 맨 끝까지 다 지운 경우에만 태그를 지워주는 방식으로 동작하는 것 같다. 

 

 

4.onChange를 감지하는 방법  : 

부모 컴포넌트에서 onChange를 내려줘서 TagInput에 입력된 tags 값들을 가져와야 한다. 그런데, Velog와 Evergreen은 그 onChange를 호출하는 방법이 다르다. 

Velog 같은 경우에는 useEffect를 이용해서, tags가 변경되었을 때, onChange 안에 tags 값들을 넣어준다. 

// velog-clinet

useEffect(() => {
    if (tags.length === 0) return;
    onChange(tags);
  }, [tags, onChange]);

 

Evergreen의 경우에는 onBlur, onKeyDown 이벤트 둘 중 하나가 있어났을 때 부모에게서 받아온 onChange를 동작시킨다.  

// addTags에 부모에서 받아온 onChange 함수가 있다.

const handleKeyDown = event => {
      const { selectionEnd, value } = event.target
      const key = GET_KEY_FOR_TAG_DELIMITER[tagSubmitKey]

      if (event.key === key) {
        event.preventDefault()
        addTags(value)
      } else if (event.key === 'Backspace' && selectionEnd === 0) {
        handleBackspaceToRemove(event)
      }
    }

특히 onBlur 같은 경우에는 재미있게도, document.activeElement와 requestAnimationFrame을 사용한다. 

    const handleBlur = event => {
      const container = event.target

      requestAnimationFrame(() => {
        if (!container.contains(document.activeElement)) {
          if (addOnBlur && inputValue) {
            addTags(inputValue)
            setInputValue('')
          }

          setIsFocused(false)
        }
      })

      safeInvoke(onBlur, event)
    }

우선 event.target의 contains 속성이 뭔지 알아야 할 것 같다. 우선 event.target은 현재 이벤트가 일어난 대상 객체를 가져온다. 분명 그 대상 객체는 하나의 Node다. 그리고 Node가 contains라는 프로퍼티를 가지고 있다. 

https://developer.mozilla.org/en-US/docs/Web/API/Node/contains

 

Node.contains() - Web APIs | MDN

The contains() method of the Node interface returns a boolean value indicating whether a node is a descendant of a given node, that is the node itself, one of its direct children (childNodes), one of the children's direct children, and so on.

developer.mozilla.org

MDN에 의하면 contains는 대상객체가 contains의 파라미터로 받아온 그 노드를 가지고 있는가를 불리언값으로 반환해준다고 한다. 

 

그리고 document.activeElement같은 경우에는 현재 포커스 받은 Element가 무엇인지를 반환해준다는 것이다. 

https://developer.mozilla.org/ko/docs/Web/API/Document/activeElement

 

DocumentOrShadowRoot.activeElement - Web API | MDN

Document와 ShadowRoot (en-US) 인터페이스의 activeElement 읽기 전용 속성은 DOM과 섀도우 DOM 내에서 현재 포커스를 받은 Element 객체를 반환합니다. 이 속성은 DocumentOrShadowRoot 믹스인 (en-US)에서 상속받습니

developer.mozilla.org

 

그러면 아래의 구절이 의미하는 바는, 현재 blur 이벤트가 일어난 input은 마우스가 포커스 된 어떤 Element의 부모인지를 묻는 것이다. 

!container.contains(document.activeElement)

그래서 만약에 현재 마우스가 포커스된 그 Element의 부모가 아니라면 그 아래의 구문을 실행할 것이다. 

나머지 if문이 체크하는 것은 이 인풋을 사용하는 사용자가 onBlur시에 tag를 더할 것인지 설정을 해주었는지를 묻고 있고, onChange를 통해서 들어온 inputValue가 존재하는지를 묻는 것이다. 그렇게하고 나서, tag를 추가하는 방식으로 이루어진다. tag를 추가하는 것도 곧바로 하는 것이 아니라, requestAnimationFrame을 통해서 추가한다. requestAnimationFrame은 다음 리페인트 작업이 이루어지기 전에 콜백함수로 넘겨진 함수를 실행하도록 하는 방법이다. 부드러운 애니메이션을 구사하기 위해서 사용되며, setInterval 보다 성능이 좋다. 

 

아무래도 나는 onBlur 이벤트시에는 태그를 추가하도록 하지 않을 것 같다. 

 

그러면 지금 useEffect 안에서 tags가 변경되었을 때, onChange안에 값을 넣어주는 방법과, keyDown시에 onChange안에 값을 넣어주는 방법이 있다. 아무래도 내 생각에는 keyDown시에 값을 넣어주는 것이 좋을 것 같다. 우선 내 기준에선 굳이 useEffect를 이용할 필요가 없다. 

 

evergreen을 참고해서 onChange 시에 받아온 values에서 새로 들어온 value를 concat하는 방법으로 넣어주기로 결정했다. 

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Backspace' && value === '') {
      console.log('backspace가 입력되었습니다.');
      return;
    }
    const keys = [',', 'Enter'];
    if (keys.includes(e.key)) {
      e.preventDefault();
      onChange(values.concat(value));
    }
  };

 

오케이 이제 뭔가 값이 들어가긴 한다. Enter를 눌렀을 때, 다음과 같이 그림이 나온다. 그런데 한번 입력하면 태그가 2개가 나온다. 왜 태그가 2개가 나오는 것일까? 

 

5.한글로 입력했을 때 글자가 2개 나오는 에러 : 

한 번 입력에 2개 나오는 태그

어디서 발생하는 문제인지를 찾았다. 후... 이것저것 시도해보다가 어쩌다가 발견한 것인데, 영어로 입력한 경우에는 태그가 하나만 생성되는데 한글로 입력하면 태그가 두개로 생성된다. 이건 정말 도대체 무슨일인지 모르겠다. 그래도 좋은 것은 트러블 슈팅할 거리를 찾았다. 요거 아주 제대로 트러블 슈팅을 해보자. 

 

우선 아래처럼 키다운이 호출될때 몇번 호출되는지 확인해보려고 했다. 

    if (keys.includes(e.key)) {
      console.log('키다운이 호출되었습니다.');
      e.preventDefault();
      onChange(values.concat(value));
    }

확인을 해보니 영어로 입력하고 엔터를 눌렀을 때는 키다운이 한번만 호출되지만, 한글로 입력했을 때는 키다운이 두번 호출된다. 

 

조금 검색을 해보니 다음과 같은 자료를 찾을 수 있었다. 

https://levelup.gitconnected.com/javascript-events-handlers-keyboard-and-load-events-1b3e46a6b0c3

 

JavaScript Events Handlers — Keyboard and Load Events

In JavaScript, events are actions that happen in an app. They’re triggered by various things like inputs being entered, forms being…

levelup.gitconnected.com

https://medium.com/square-corner-blog/understanding-composition-browser-events-f402a8ed5643

 

Understanding Composition Browser Events

What’s an IME, and why do I care?

medium.com

한글로 검색해서 나오는 블로그들에 의하면, keypress를 사용하면 한번만에 해결이 된다는 글이 많았다. 그러나 keypress는 deprecated된 이벤트이다. keypress는 사용하지 않을 것이다.

 

해당 에러는 IME 의 동작방식 때문에 발생하는 에러인 것 같다. IME는 Input Method Editor의 약자이다. 이것은 운영체제 레벨에서 동작하는 프로그램이다. 이것은 특정 문자를 다른 언어로 변환시켜준다. 예를 들어 우리가 한글을 입력하고 해당 한글에 해당하는 한자를 입력하고 싶을 때, IME이 동작한다. 아마도 이 문자가 변환되는 작업이 이루어지는 동안에 진행되는 핵심 작업이 composition인 것 같다. composition이란 무엇이다! 라고 정의내려주는 곳을 찾기가 어렵다. 아무튼 compostion 작업이 끝난 후에 Enter가 진행되어야만 원하는대로 작동할 수 있다. 이것을 어떻게 구현할 수 있는지는 아래의 자료들을 참고하면 도움이되는 듯하다. 

 

https://github.com/eyesofkids/react-compositionevent/blob/master/uncontrolled/Cinput.js

https://ko.reactjs.org/docs/events.html#composition-events

 

아무튼 아래와 같은 방식으로 composition을 체크해주었고, 그 덕분에 한글을 입력할 때도 문제없이 하나의 단어만 입력할 수 있게 되었다. 

export const TagInput = ({ onChange, values = emptyArray }: Props) => {
  const [value, setValue] = useState('');
  const [isOnComposition, setIsOnComposition] = useState(false);

  const composition = (event: any) => {
    if (event.type === 'compositionend') {
      setIsOnComposition(false);
      return;
    }
    setIsOnComposition(true);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Backspace' && value === '') {
      console.log('backspace가 입력되었습니다.');
      return;
    }
    const keys = [',', 'Enter'];

    if (!isOnComposition && keys.includes(e.key)) {
      console.log('키다운이 호출되었습니다.');
      e.preventDefault();
      onChange(values.concat(value));
    }
  };

  const onChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  const eventProps = {
    onChange: onChangeInput,
    onCompositionStart: composition,
    onCompositionUpdate: composition,
    onCompositionEnd: composition,
  };

  return (
    <Container>
      {values && values.map((tag, idx) => <Tag key={idx}>{tag}</Tag>)}
      <Input onKeyDown={onKeyDown} {...eventProps} />
    </Container>
  );
};

오늘 이 컴포지션 에러를 해결하기 위해서 거의 3시간 정도 붙어있었던 것 같다..😨 

 

이렇게 에러를 해결하고 차근차근, 

1)엔터 후 입력창에 남아있는 value 제거.

2)해시태그 중복문자체크

3)해그 클릭할 경우 태그 제거. 

4)composition 이벤트에 대한 타입 정의 

 

6.TagInput 테스트 코드 작성 : 

 

velog-client의 테스트코드를 보고 참고하면서 작성했다. Partial 타입 문법이 나왔는데, 매번 볼 때마다 찾아보게 되는 것 같다. 

Partial 문법은 정의된 타입에 대해서 부분집합으로 적용가능하도록 해주는 그런 문법이다. 

interface Address {
  email: string;
  address: string;
}

type MyEmail = Partial<Address>;
const me: MyEmail = {}; // 가능
const you: MyEmail = { email: "noh5524@gmail.com" }; // 가능
const all: MyEmail = { email: "noh5524@gmail.com", address: "secho" }; // 가능

다시 테스트 코드로 돌아와서. 

getByText와 queryByText의 차이에 대해서 찾아보게 되었다. 어제 찾아봤던 내용인데, 다시 검색해서 찾아본다. 

https://testing-library.com/docs/queries/about/

여기에 기록되어있듯이, 

getBy나 queryBy는 동일하게 특정 node를 찾는다. 그런데 둘의 차이가 있다면, 찾는 노드가 없을때이다. getBy는 찾는 노드가 없을 때 throw error한다. 그런데 queryBy는 null을 반환한다. 때문에 없는 노드를 찾아야하는 경우에는 queryBy를 사용하는 것이 적합하다. 

 

useState 모킹 : 

테스트 코드를 작성하는 중, 현재 나의 컴포넌트가 부모에서 내려주는 useState를 사용중이었다. 그런데, 따지고보면 부모 컴포넌트에서 기능을 내려줘야하는 측면이 있었다. 태그를 지우는 경우에 부모 컴포넌트에서 useState를 잘못내려주면 기능이 제대로 동작하지 않게 된다. 다행히 이런 사실을 테스트 코드를 작성하면서 알게 되었다. 그렇다면 이 태그인풋 자체만으로도 기능이 잘 작동하도록 수정을 해줘야 한다. 

음... 수정을 해주었는데도, 테스트가 잘 작동하지 않는다ㅜ 뭐가 문제일까.. 우선 다음에 다시 처리를 해야할 것 같다. 

https://gist.github.com/natterstefan/b9748dfe75f8ca177e25e50b3f466e9f

 

 

 

7.모달 컴포넌트 CSS 작성 : 

모달컴포넌트의 CSS를 작성했다. 그 내부에 있는 버튼, 이미지 업로드 버튼 등등의 작업을 했다.

모달컴포넌트 작성중

아직 해시태그 인풋은 기능은 구현해놓고, 스타일은 작성하지 않은 상태다.