React 비제어 입력으로 타이핑 지연 해결하기

React 비제어 입력으로 타이핑 지연 해결하기

1. 들어가며

제가 담당하는 echo 서비스에는 VoC(Voice of Customer) 게시판이 있습니다. 고객이 요청이나 불편 사항을 글로 남기면 개발자가 상세 화면에서 댓글로 답하고, 상태를 바꿔 가며 처리하는 곳입니다. 어느 날 고객이 VoC 상세 페이지의 댓글 기능에 대한 제보를 남겨주었습니다.

지금 댓글 입력이 너무 느립니다. 다른 곳은 빨리 입력이 됩니다.

제보를 받고 바로 같은 화면을 열어 보았을 땐 재현이 되었습니다. 댓글 입력창에 타이핑을 하면 커서가 멈춰 있다가 글자가 한 번에 입력되었고 그 과정에서 한글 조합이 깨져 오입력되기도 하였습니다. 그런데 같은 날 오후에 같은 페이지로 다시 들어가 보니 버벅임이 사라졌고 이전에 보았던 현상을 다시 마주할 수 없었습니다. 어쩌면 ‘재현 안 됨’으로 이번 버그는 지나칠 뻔했지만 다행히 고객이 첨부한 영상과 당시 제가 직접 목격한 현상을 토대로 이슈를 파악하고 개선할 수 있었습니다.

2. 원인 추정

2-1. 제어 입력과 리렌더 범위

React에서 입력칸을 다루는 가장 흔한 방법은 제어 컴포넌트(controlled component)입니다. 입력값을 state 에 두고 value로 내려 주며, onChange에서 state를 갱신합니다. 문제가 된 VoC 상세 화면도 이 방식이었습니다.

const VocPostDetailDeveloperView = ({ detail, onAddComment, ... }) => {
  const [commentText, setCommentText] = useState('');   // 댓글 입력값이 화면 최상위 state

  const handleCommentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setCommentText(event.target.value);                  // 한 글자마다 화면 전체 리렌더
  };

  return (
    // ... 본문, 첨부, 스냅, 댓글 목록, 상태 버튼 ...
    <TextField multiline value={commentText} onChange={handleCommentChange} />
  );
};

코드만 보면 교과서적인 형태입니다. 다만 commentText가 상세 화면 컴포넌트의 최상위 state라는 점이 문제였습니다. React는 state가 바뀐 컴포넌트와 그 아래 자식들을 다시 렌더링합니다. 키를 하나 누를 때마다 댓글칸만이 아니라 본문, 첨부 목록, 스냅, 댓글 목록, 상태 버튼까지 상세 화면 전체가 다시 그려지고 있었습니다.

평소라면 이것만으로 크게 느려지지는 않습니다. 가상 DOM 비교는 빠르고, 실제로 DOM이 바뀌는 곳은 입력칸 하나뿐이기 때문입니다. 문제는 다시 그려지는 컴포넌트들이 렌더링 중에 부르는 함수 하나가 생각보다 비쌌다는 점입니다.

2-2. 다국어 라벨 함수 label()

Vizend 프런트는 화면 문구를 공용 라이브러리 @vizendjs/accent의 label()로 번역합니다. label('Comment')처럼 쓰면 현재 언어의 라벨 사전에서 번역을 찾아 돌려줍니다. 라벨 사전은 서버에서 받아 브라우저 localStorage에 캐시해 둡니다. 버튼 이름, 상태 이름, 안내 문구가 모두 이 함수를 거치기 때문에 상세 화면이 한 번 렌더링될 때 label()이 적게는 수십 번, 많게는 백 번 넘게 불립니다.

3. 상황 측정

처음에는 첨부 파일이 많은 글이라 화면이 무거워져 이 현상이 발생하는 것이 아닐까 추측하였습니다. 따라서 사용자가 제보한 영상과 유사한 환경을 만들기 위해 로컬 환경에서 화면에 두 개의 동영상을 첨부하고 댓글을 입력해 보았습니다. 하지만 첨부가 없는 글과 비교해 별다른 차이는 없었습니다. 타이핑하는 동안 네트워크 요청이나 첨부 파일을 다시 읽어 오는 동작도 일어나지 않았습니다. 첨부 파일은 원인이 아니었습니다.

그래서 상세 화면 코드를 다시 읽어 보았습니다. 입력값이 화면 최상위 state라는 것은 앞에서 본 대로였고, 눈에 띈 것은 화면 곳곳의 문구를 감싸고 있는 label()이었습니다. 세어 보니 Customer 상세 컴포넌트에 42곳, Developer 상세 컴포넌트에 54곳이 있었습니다. 키를 하나 누를 때마다 화면 전체가 다시 그려지니, 이 호출들도 키 입력마다 전부 다시 실행된다는 뜻이었습니다. 함수가 가볍다면 문제가 없겠지만, accent의 번들 코드를 열어 보니 label()은 호출될 때마다 localStorage에서 라벨 사전을 꺼내 JSON.parse하고 있었습니다.

따라서 키 입력 한 번에 label()이 실제로 몇 번 불리고 그 비용이 얼마인지 재보았습니다. 브라우저 콘솔에서 localStorage.getItem과 JSON.parse를 잠시 감싸 호출 횟수와 걸린 시간을 세고, 입력칸에 글자를 차례로 넣어 키 입력 1회의 평균을 냈습니다. 아래는 첨부도 스냅도 댓글도 없는 가장 가벼운 글에서 잰 결과입니다.

항목

키 입력 1회당

label() 호출

142회

JSON.parse 호출

1,278회

파싱한 문자열 크기

약 15MB

전체 처리 시간 중 JSON.parse 비중

55%

가장 가벼운 글에서조차 글자 하나를 입력할 때마다 라벨 사전을 1,278번 파싱하고 있었고, 처리 시간의 절반 이상이 JSON.parse였습니다.

4. 원인

4-1. label() 한 번이 비쌌습니다

echo-front가 쓰던 accent 7.2.8의 label() 내부를 따라가 보니 캐시가 없었습니다. 번들된 코드를 읽기 쉽게 정리하면, 호출될 때마다 다음 경로를 거쳤습니다.

// accent 7.2.8 — label() 이 호출될 때마다 거치는 경로 (읽기 쉽게 정리)
static getEpisodeLabelMap(context, language) {
  const lang  = this.getLanguage();            // localStorage 'vizend.language' 읽기 + 파싱
  const cache = this.getLabelCache();          // localStorage 'vizend.lingo.labels' 읽기 + JSON.parse
  const index = cache.episodes.findIndex(/* pavilion · episode · 언어 일치 */);
  this.removeLegacyLabel(context);             // localStorage.removeItem 4회
  return { ...cache.episodes[index].labels };  // 라벨 맵을 통째로 복사
}

라벨 사전 전체를 매번 파싱하고 복사하므로 label() 한 번의 비용은 사전 크기에 비례합니다. 브라우저 localStorage의 라벨 사전만 부풀려 가며 같은 화면을 다시 재 보니 label() 호출 수는 동일했으나 사전 크기에 따라 회당 호출 비용이 크게 늘어났습니다.

라벨 사전 크기

라벨 수

키 입력 1회 (수정 전)

11KB

262

33.1ms

262KB

3,930

262.3ms

1,105KB

15,982

1,457.7ms

2,969KB

42,182

4,488.5ms

4-2. 한 글자마다 화면 전체가 리렌더됐습니다

두 번째 원인은 앞서 말했듯이 입력값이 최상위 state 라서 키 하나에 화면 전체가 다시 그려졌고, 그때마다 label()이 화면에 있는 문구 수만큼 불렸던 것이었습니다.

두 원인이 각각 얼마나 차지하는지 보려고, 브라우저 콘솔에서 JSON.parse를 잠시 바꿔 같은 문자열은 한 번만 파싱하도록 만든 뒤 같은 타이핑 테스트를 다시 돌려 보았습니다. accent에 캐시가 생기면 어떻게 될지를 코드 수정 없이 흉내 낸 것입니다. 그 결과 키 입력 1회가 약 63ms에서 18ms로 72% 줄었고, 남은 18ms는 화면 전체를 다시 그리는 비용이었습니다. 결국 지연은 label() 한 번의 비용과, 리렌더 때문에 늘어난 호출 횟수가 곱해져 커지고 있었습니다. 정리하면 키 입력 비용은 대략 이렇게 정해집니다.

키 입력 1회 비용 ≈ label() 1회 비용(라벨 사전 크기) × 다시 그려지는 문구 수(화면 요소 수)
누적 지연      ≈ 키 입력 1회 비용 × 글 길이

같은 코드라도 계정, 언어, 글에 따라 체감이 크게 달라질 수 있는 구조였으며 사전이 작은 환경에서도 글이 길어지면 지연이 발생할 수 있었습니다.

5. 해결

5-1. 1차 시도: 입력값을 자식 컴포넌트로 내리기

처음에는 댓글 작성 영역을 별도 컴포넌트(VocCommentCompose)로 떼어 내고 입력값 state를 그 안에 두었습니다. 키를 눌러도 작은 작성 영역만 다시 그려지니, VoC Developer 상세 화면의 키 입력당 label() 호출은 156회에서 4회로, 시간은 22.7ms에서 3.6ms로 줄었습니다. 그런데 라벨 사전을 부풀린 조건에서 다시 재 보니, 같은 방식으로 고친 VoR 상세 화면은 17.3ms로 목표로 잡은 16ms(60fps 기준 한 프레임)를 넘었습니다. 작성 영역 안에 남은 label() 네 번만으로도 사전이 크면 기준을 넘길 수 있었습니다.

5-2. 2차 시도: 비제어 입력으로 바꾸기

그래서 입력값을 state가 아니라 ref로 들도록 바꿨습니다. 비제어 컴포넌트(uncontrolled component)는 값을 DOM이 들고 있고, React는 필요할 때만 꺼내 읽습니다. 타이핑이 state를 바꾸지 않으니 리렌더가 일어나지 않고, 키 입력당 label() 호출은 0회가 됩니다.

// 코드 예시
export const VocCommentCompose = ({ avatarInitial, onSubmit }: VocCommentComposeProps) => {
  const inputRef = useRef<HTMLTextAreaElement>(null);   // 입력값은 state 가 아니라 ref

  const handleSubmit = async () => {
    const message = (inputRef.current?.value ?? '').trim();
    if (!message || !onSubmit) return;
    await onSubmit(message);
    if (inputRef.current) inputRef.current.value = '';  // 성공했을 때만 DOM 을 직접 비운다
  };

  return (
    <>
      <TextField multiline defaultValue="" inputRef={inputRef}
                 placeholder={label('Add a comment...')} />
      <Button onClick={handleSubmit}>{label('Comment')}</Button>
    </>
  );
};

부모 화면은 무엇을 등록할지(onSubmit)만 넘기고 입력값은 전혀 모릅니다. React.memo로 부모의 리렌더를 막는 방법도 있었지만, 자식마다 메모 경계를 맞춰야 해서 고칠 곳이 많고 깨지기 쉬워 택하지 않았습니다. 세 방식을 같은 부하 조건에서 비교하면 다음과 같습니다.

방식

VoC Developer 상세

사전 크기의 영향

원래 코드 (최상위 state)

162.42ms

정비례

1차: 자식 state

8.19ms

줄었지만 남음

2차: 비제어 (ref)

0.47ms

없음

5-3. 공용 accent 개선

가장 근본적인 해결은 label()에 캐시를 넣는 것입니다. 하지만 accent는 Vizend의 모든 프런트 앱이 쓰는 공용 라이브러리라서 영향이 모든 앱으로 퍼집니다. 그래서 echo-front 선에서 끝낼 수 있는 리렌더 쪽을 먼저 고치고, accent 쪽은 측정 자료를 정리해 담당자에게 전달하려 했습니다. 그러나 확인해 보니 최신 버전(7.2.10)에 localStorage 원문 문자열을 키로 파싱 결과를 메모이즈하는 로직이 이미 들어가 있었습니다. echo-front는 7.2.8에 고정돼 있었기 때문에, 최신 버전으로 올리는 작업을 다음 단계로 남겨 두었습니다.

6. 비제어로 바꾸면서 지킨 동작

비제어로 바꾸면 React가 값을 모르게 되면서, state로 처리하던 동작이 조용히 사라질 수 있습니다. 성능만 고치고 동작은 그대로 두기 위해 다음을 함께 챙겼습니다.

  • 빈 칸이면 등록 버튼 비활성: 입력값 대신 빈 칸인지 여부만 불리언 state로 두고, 그 값이 바뀔 때만 갱신했습니다. 리렌더는 첫 글자 입력과 마지막 글자 삭제 때만 일어납니다.

  • 입력칸 비우기: 등록에 성공했을 때만 inputRef.current.value = ''로 직접 비우고, 실패하면 쓴 글을 그대로 남겼습니다.

  • 중복 등록 방지: React 18 StrictMode에서는 state updater 안의 부수효과가 두 번 실행되므로, 등록 요청을 updater 밖에 두고 댓글이 한 건만 생기는지 확인했습니다.

  • 폼 안의 입력칸: 등록 화면의 Label 칸은 컴포넌트를 쪼개지 않고 react-hook-form 필드로 옮겼습니다. react-hook-form은 기본적으로 비제어라 같은 화면의 제목·본문 칸은 이미 빨랐습니다. MUI TextField는 실제 입력 요소의 ref를 inputRef로 받기 때문에 아래처럼 나눠 넘겼습니다.

const { ref: labelInputRef, ...labelField } = register('label');
<TextField {...labelField} inputRef={labelInputRef} />

7. 마무리

이번 작업에서 가장 크게 배운 것은 입력값을 어디에 두느냐가 렌더링 범위를 정한다는 점입니다. 평범한 제어 입력이었지만 그 state가 화면 최상위에 있었고, 렌더링마다 비용이 드는 함수와 만나면서 키 입력 하나가 수십~수백 ms가 됐습니다. 타이핑처럼 잦은 이벤트는 작성 영역 안에 가두거나 비제어로 두고, 폼이라면 react-hook-form에 맡기는 것이 좋겠습니다. 또한 재현이 안 된다고 바로 닫지 않고 비용이 무엇에 비례하는지부터 잰 것이 원인을 찾는 데 도움이 됐습니다.

Wrio

Site footer