TL; DR

진리의 사바사 케바케!
그러나 판단 기준은 있다

1. useEffect
useEffect의 내부에 넣은 콜백함수는
비동기로 처리되기 때문에,
브라우저가 CRP* 중 painting단계까지 마무리 된 이후, 실행됨.

큰 변화를 가져오는 명령의 경우,
해당 콜백함수의 실행에 의해 re-render되거나 re-paint 되면서
이전의 상태가 살짝 스쳐지나간 뒤, 최종 화면 조작의 결과를 볼 수 있음.

그러나, 경미한 변화를 가져오는 화면 조작 명령의 경우
함수 자체는 어쨌건 실행은 되지만,
브라우저의 렌더링 최적화 매커니즘에 의해
화면의 re-render나 re-paint의 진행을
추후 rendering 시점으로 미뤄버릴 수 있음.

2. useLayoutEffect
useLayoutEffect의 내부에 넣은 콜백함수는
동기로 처리되어서,
브라우저가 CRP 중 reflow(layout 계산)단계를 마치고,
painting으로 넘어가기 전에 실행됨.

이 덕분에 경미한 변화를 가져오는 명령이라 할지라도
painting 직전에 계산이 되기 때문에,
첫 paint가 완료되는 시점에 변화를 즉각 확인할 수 있음.


따라서,
화면을 즉각 조작하고 싶은데,
그 조작내용이 너무 경미해서 반영이 안된다?하면
useLayoutEffect의 콜백 함수로 명령문을 넣는 것이 좋음.

※ 그러나,
useLayoutEffect의 동기적 실행 특성이 브라우저 성능 저하로 이어질 수 있으므로,
매우 주의해서 정말정말정말 필요할 때만 써야 함!!!

CRP = Critical Rendering Path (브라우저에 화면이 그려지기까지의 과정)

 

이번 글은 매우 길다.
시행착오도 많았고, 이해하고 있어야 할 개념도 많았기 때문..

정녕 읽어보실 건가요?

그렇다면 심호흡 한번
쓰읍-하!

 

[ 목차 ]


0. 원했던 결과
  - .selectionRange() API로 커서 효과를 만들자

1. 문제의 코드
  - onClick에서 실행했더니 안 된다

2. 시행착오
  - setTimeout을 써볼까?
  - useEffect를 써볼까?

3. 문제 해결
  - useLayoutEffect를 써보자

 

0. 원했던 결과

- 그저 커서 깜빡이를 만들고 싶은 것 뿐인데...

모두기상 어플을 개선하던 중
유저 경험을 끌어올리기 위해,

글수정 버튼을 누르면
기존 텍스트의 마지막 글자 뒤에
커서가 깜빡이는 효과가 나타나면 좋겠다는 생각을 했다.

아래 화면처럼.

그러나 내가 작성한 textarea.setSetSelectionRange API는
화면상에 어떤 변화도 가지고 오지 않았다.

그렇게 나의 원인찾아 삼만리가 시작됐다.


1. 문제의 코드

- onClick event로 DOM을 직접 조작했는데, 아무일도 일어나지 않는다..

연필 버튼을 선택하면
.setSelectionRange()가 실행될 수 있도록,
onClick event handler를 다음과 같이 작성해주었다.

  const enableInput = () => {
    if (textAreaRef.current) {
      const textArea = textAreaRef.current;
      textArea.focus();
      textArea.setSelectionRange(textArea.value.length, textArea.value.length);
    }
    
    setIsAbleInput(true);
    
    console.log('Button clicked');
  };

분명 console.log도 찍히고,
함께 바꿔준 isAbleInput state 값도 정상적으로 바뀌는데

.setSelectionRange()의 커서 깜빡이는 효과만은 일어나지 않았다.

Stackoverflow와 각종 github issue들을 확인한 결과,
setTimeout() 함수를 활용하면 문제를 해결할 수 있었다.


2. 시행착오

2-1. setTimeout()으로 .setSelectionRange()를 작업 queue에 새로 쌓아주자!

setTimeout()의 콜백함수로 .setSelectionRage를 추가해주라는 조언이 많았다.

onClick event를 심는 과정에서
.setSelectionRange가 먼저 발동을 하면서,
그 효과가 UI에 보이지 않을 수 있기 때문이라는 것이다.

  const enableInput = () => {
    if (textAreaRef.current) {
      setIsAbleInput(true);

      setTimeout(() => {
        textArea.focus();
        textArea.setSelectionRange(
          textArea.value.length,
          textArea.value.length,
        );
      }, 0);
    }
  };

위처럼 setTimeout에 .setSelectionRange API를 심어주고,
delay를 0으로 설정하게 되면,

.setSelectionRange는 비동기로 처리되어,
event listener가 잘 장착된 이후, 나중에 실행된다.

잠깐, delay를 0으로 설정하는데 어떻게 나중으로 미뤄지는가?!

delay 시간과 관계없이,
일단 setTimeout의 콜백 함수로 쓰인 작업이라면,
무조건 그동안 쌓여있었던 브라우저 macro task queue의 맨뒤에 쌓이게 된다.

운이 좋아서 매크로와 마이크로 작업 queue에 다른 작업이 없었고,
콜스택도 비어있다면 바로 실행되는 것이고,

뭐 다양하게 쌓여있었다면
앞선 것이 다~ 끝나고 나서
본인 차례에 당도하면 실행된다.

그런데 delay에 특정 시간까지 주어진다면,
특정 시간 이후에 작업 queue에 줄 서러 들어가는 것이고,
없으면 그냥 곧바로 줄을 서는 것이다.

이걸 단번에 이해하게 해준 PintOS scheduling 파트를 공부한 과거의 나에게 무한 감사를..

 

일단 실행은 잘됐다.

그러나 setTimeout을 쓰는 게 왠지 너무 찜찜하다..
queue에 일단 집어넣고 기다린다는 것 자체가
운명에 맡기는 행위니까
결과의 일관성이 없다...

.setSelectionRange의 운명은 내가 쥐고 싶은데 말이다...

2-2. useEffect의 콜백함수로 .setSelectionRange를 실행하자!

event listener장착 과정에서 생기는 문제라면,
그것이 다끝난 시점에
효과를 적용해주면 해결될일이 아니겠는가?

나는 .setSelectionRange의 운명을
그놈의 onClickHandler에서 통제되는
isAbleInput에 맡기기로 했다.

연필 버튼을 누르는 onClickHandler에서
isAbleInput state값을 바꿔주고,

그것을 의존성 배열로 갖게 만든 useEffect의 콜백함수로
.setSelectionRange를 쓰는 것이다.

  useEffect(() => {
    if (isAbleInput && textAreaRef.current) {
      const textArea = textAreaRef.current;

      textArea.focus();
      textArea.setSelectionRange(textArea.value.length, textArea.value.length);

    }
  }, [isAbleInput]);

처음엔 잘 됐다.

그래, isAbleInput 요 state가 바뀌었다는 것은
이미 onClick event listener가 잘 장착된 이후인데다가,

useEffect는 비동기함수니까
이것저것 앞선 모든게 다 끝난 뒤 잘 작동하겠지!!

좋았어....!!!
setTimeout 잘가라~~~~~~
도 잠시..

좀 이따가 다른 작업하다 돌아와서 한번 해봤더니,
효과가 갑자기 또 나타나지 않는 거 아닌가???????

미쳐버려 진짜.....

useEffect에 다시 setTimeout을 넣었다...
이젠 정말 다른 작업하다가 넘어와도 잘 됐다..

그치만 이럴거면 걍 onClickHandler에 setTimeout 넣는거랑 뭐가 다른가...?

정녕 이것이 최선인가..?

왜 나의 useEffect 작동 방식에 대한 가설이 틀렸는지
도무지 이해가 되지 않았다.

그래서 Claude와 대화를 시작했다.

핵심은 
브라우저의 렌더링 최적화 매커니즘
에 있었다.

2-3. useEffect의 콜백으로 쓰면, 왜 즉시 적용이 되었다 안되었다 하는가?

내가 작성한 .setSelectionRange(글자수, 글자수)는
화면 모두가 그대로인데
커서 깜빡이만 슬쩍 생기는 효과를 가져온다.

브라우저는
이 효과가 굉장히 미미하여,
이것 하나를 위해
화면을 그리는 큰 렌더링 작업을 하기엔
너무 비효율적이라고 판단을 할 수도 있다.

특히 useEffect 내부의 콜백함수는
항상 렌더링이 모두 마무리된 후에
비동기로 실행이 된다.

브라우저 입장에서는
아니, 방금 렌더링 다 끝내놨더니만...
이제와서 그 쪼끄만 작업하자고 또 렌더링하라고..?
하는 상황인 것이다

그렇게 미미한 작업으로 인식된
.setSelectionRange의 적용 순서는
다음 렌더링 시점으로 미뤄지게 된다.

이것이 브라우저의 렌더링 최적화 매커니즘 때문에 일어나는 현상이다.

어떤 작업이 미미한 것으로 인식되어
적용 시점이 미뤄질지 아닐지의 여부는
그 때의 브라우저의 상태에 따라 달라진다.

그래서 어쩔 땐 setTimeout 없이
useEffect의 콜백함수로만 존재하고 있어도
즉시 적용이 되는데,
어쩔 땐 되지 않았던 것..

그럼 어떻게 해야 하는가?

모든 렌더링 단계가 마무리되기 전에!
.setSelectionRange를 실행해야 한다.

어떻게? 아래처럼!


3. 문제 해결

- useLayoutEffect로 painting 이전 단계에서 미미한 효과를 실행해주자

리액트 공식 문서에 따르면,

useLayoutEffect는
브라우저가 화면을 repaint하기 전에 실행하는 useEffect 라고 보면 된다 한다.


잠깐,

repaint가 뭔데..?

브라우저가 화면을 그리는 전체 과정을
Critical Rendering Path(CRP)라고 한다.

1. DOM Tree 생성
2. CSSOM Tree 생성
3. Render Tree 생성
4. Layout 계산 (reflow)
5. Paint

이 전체 과정에서 Paint만
다시 진행되는 것이 repaint!

useEffect는 paint가 마무리 된 이후에
내부의 콜백함수가 실행되고,

useLayoutEffect는 앞선 CRP 과정이 진행된 뒤,
마지막으로 paint가 진행되기 바로 직전에!
내부의 콜백함수가 실행되는 것이다.

따라서,
useEffect의 내부 콜백함수로서 실행되던
.setSelectionRange가 겪었던
브라우저 렌더링 최적화에 의한 소수자 차별(?)은

useLayoutEffect에서는 발생하지 않는다.

모든 렌더링이 마무리되기 전에 동기적으로
useLayoutEffect의 콜백함수가 실행되어,
.setSelectionRange의 존재가 확인되고

아무리 미미하더라도,
해당 내용까지 반영된 상태로
painting이 진행되기 때문이다.

 

최종 코드

  useLayoutEffect(() => {
    if (isAbleInput && textAreaRef.current) {
      const textArea = textAreaRef.current;
      textArea.focus();
      textArea.setSelectionRange(textArea.value.length, textArea.value.length);
    }
  }, [isAbleInput]);

 

 

다만,
useLayoutEffect는 동기적으로 실행된다는 특성 때문에
브라우저의 성능을 저하할 수 있는 가능성을 가지고 있으므로

간단한 연산이 필요한 경우에만 사용하고,
웬만해서는 useEffect를 사용하라는

리액트 공식 문서의 당부가 있었다!

 

 

끗~

+ Recent posts