예전, React가 DOM을 업데이트하는 과정을 정리했을 때에 나는 무언가 react에 대해서 다 깨달은 것이라고 스스로 과신하고 있었다.
그런데, 요 근래 깨달은 것은 나는 그냥 수박 겉핥기 식으로 react를 알고 있었다는 점을 알게 된 것이다.
그것은 최근 작성한 react의 useEffect 비동기 호출과 dependency때에도 느끼긴 했는데 정말 알았다고 생각했을 때가 가장 위험한 때라는 것이 바로 개발의 세계인 것이라 조금씩 이해하고 있다.
여튼, 이번에 깨달은 것은 react의 useRef와 랜더링 과정에 대한 한가지의 작은 이해를 까먹기 전에 정리하고자 한다.
공식 문서에 따르면 useRef는 초기에 인자로 전달된 값으로 초기화된, 변경 가능한 객체라고 설명하고 있다.
여기서 변경 가능하다는 의미는 초기값이 변동할 수 있다는 것을 의미한다.
일반 변수와 다른점은 위에서도 설명하듯, useRef에 저장된 값은 전 생애주기에 걸쳐서 유지된다고 말하고 있다.
즉, 매번 컴포넌트 함수가 호출될 때마다 일반 변수처럼 새로운 값으로 초기화되는 것과는 달리 useRef로 저장되는 값은 메모리에 캐시처럼 저장된 채로 변경하기 전까지는 계속 그 값을 참조한다는 것을 의미한다. (전체 react 앱의 클로져에 기록된 채로 다시 꺼내와진다고 생각하면 이해하기 쉬울 것 같다)
그런데 여기서 주의할 점이 있다.
useRef가 호출되어서 반환되는 객체는 말 그대로 순수 자바스크립트 객체이고, 이 객체의 레퍼런스 주소가 메모리에 저장되어 있는 상태이다.
그런데, ref가 반환한 객체의 내부 값은 변동이 가능하다. 그렇다면 내부 값이 변동이 되었을 때 react는 이것을 감지하고 리랜더링할까?
아니다.
설령 내부 값이 변경되었다 하더라도 ref 반환객체 자체의 레퍼런스 주소는 여전히 메모리에서 저장되어 있는 값을 똑같이 계속 그대로 참조되고 있기 때문에 변했다고 인식하지 않는다. ( 순수 자바스크립트 객체의 내부값이 변경될 수 있다는 것과 일맥상통한다. 내부값은 변해도, 전체 객체의 레퍼런스 주소는 그대로 유지되고 있다. )
그래서 아래와 같은 케이스를 확인해보면 좋다.
function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
console.log(canvasRef.current) //무슨 결과를 보여줄까?
return (
<Main>
<ImageWrapper>
<Canvas ref={canvasRef} />
<img src={Back} alt="" />
</ImageWrapper>
</Main>
);
}
개인적으로 나는 React는 자바스크립트와 별개의 무언가처럼 느끼는 감이 있었는데, 결국 react의 기반은 자바스크립트이다. 모든것은 그러므로 자바스크립트가 돌아가는 원리대로 이해하려고 노력해야 한다.
기존에 공부했던, react는 함수 컴포넌트를 호출하면 babel이 이것을 트랜스파일하여 createElement호출로 변환하고 이를 통해 나온 결과물인 jsx객체를 render의 인자에 넣어 virtual dom을 형성하고 dom을 조작하여 페인팅을한다... 와 같은 뭔가 긴 과정은 일단 생략하자.
일단 뭔진 몰라도 App을 호출하게 되면 무슨 일이 벌어질까?
함수를 호출하는 것이므로 토큰분해 후 AST를 만들어서 이를 기반으로 실행 컨텍스트를 만든 뒤 콜스텍에 전달하는 과정이 있을 것이다.
실행 컨텍스트 내부에서는 상수 canvasRef 에게 할당을 시작하려고 할 것이고, 이때 할당되어야 하는 값은 useRef가 리턴하는 순수 자바스크립트 객체이다. 이 순간까지는 아직 {current:null} 로 초기화된 상태이다.
그 뒤에 스코프 체인으로 올라가 window에서 console 프로퍼티를 찾고, 프로토타입 체인을 통해 객체 내에서 log 메서드를 찾아 호출한다.
호출된 log 함수 역시 실행 컨텍스트를 만들고 인자로 전달되는 canvasRef.current 를 콘솔창에 띄우는 작업을 할 것이다.
이 순간의 canvasRef.current는 null이기 때문에 당연히 null이 찍힌다.
이후, return을 통해 무언가가 반환되는데, 이때 반환되는 것은 styled component, 즉 함수이다.
이 함수에는 props인 children을 통해 내부에 있는 존재를 전달한다. 이것은 또다른 함수 컴포넌트일수도, 혹은 react에서 제공하는 html element props 객체가 될 수도 있다.
html element props 객체가 무슨 말이냐면, 예를들어 hi 하고 설정했을 경우 이것은 트랜스파일 될 때에 html의 button element가 가지고 있을 props를 보유한 객체로 전환된다는 의미이다.
여튼, Canvas 역시 styled component, 즉 함수이고 인자로 ref를 받아 전달한다. 사실 styled component 자체도 내부구조를 살펴보면 property로 받은 값들을 리턴하는 순수 html element의 attribute에 집어넣고 있다. 여기서는 "canvas"가 될 것이다.
여튼 중요한건 Canvas의 ref로 전달되는 것이 바로 아까 위에서 말했던 useRef의 반환객체라는 점이다.
const canvasRef = useRef<HTMLCanvasElement>(null);
.
.
.
return (
.
.
.
<Canvas ref={canvasRef}/>
)
함수 컴포넌트 역시 클로져를 가지고 있으므로 이때의 상수, canvasRef가 리턴값 내부에 존재하는 "Canvas ref={canvasRef}" 에 의해 참조되어 있는 상태이다.
아까 위에서 언뜻 이야기했던 createElement 함수가 호출되어 이 반환값이 사용이 될 때 ref가 canvasRef와 연결되므로, 메모리에 저장되어 있는, 그러니까 클로져에 기억되고 있었던 canvasRef 상수의 객체 내부 current가 업데이트되게 된다.
즉, useRef의 리턴되는 객체를 함수 컴포넌트 몸체 내에서 그대로 활용하려고 하면 에러를 내겠지만,
한번 리턴값이 전달되어 마운트가 된 이후에의 작업에 대해서는 current가 새롭게 초기화된 상태이므로 이 값을 이용할 수 있다는 점이다.
이때 주의할점은, 함수 컴포넌트 내의 리턴값 자체의 코드들 역시 그 순간 자체에서는 canvasRef가 업데이트 되기 전의 스냅샷을 기억할 것이므로 그냥 사용하면 에러를 낸다.
이로 인해 보통은 새로 도입된 optional chaining을 활용하는 편이 좋다.
function App() : JSX.Element {
const canvas = useRef<HTMLCanvasElement>(null);
return (
<Main>
<button
onClick={() => {
const ctx = (canvas.current as HTMLCanvasElement)?.getContext("2d");
// ctx?.fillRect(25, 25, 100, 100);
// ctx?.clearRect(45, 45, 60, 60);
ctx?.strokeRect(25, 25, 100, 100);
}}
>
draw
</button>
<ImageWrapper>
<Canvas id={"canvas"} ref={canvas} />
<img src={Back} alt="" />
</ImageWrapper>
</Main>
);
}
위의 button 컴포넌트의 onClick에 있는 물음표 역할이 바로 "앞의 값이 존재하면 다음탐색, 아니라면 undefined"
를 해주는 것이다. 즉, 마치 if문과 같은 느낌처럼 ctx가 존재하면 ~ 이후를 해라 라는 느낌처럼 사용할 수 있다.
이때 optional chaining에 대해서도 한가지 주의할점이, undefined가 되어도 상관이 없는 문맥에서 사용해야 된다는 점이다. 예를들어, onClick 함수 내부에서 undfined로 코드줄이 평가된다 하더라도 크게 문제될건 없기 때문에 상관없지만, 이 optional chaining 값을 어딘가에 저장한다거나 해서 이 변수를 사용하는듯한 행위를 했을 때 이것이 undefined라면 문제가 되는 상황이라면 사용해서는 안된다.
뭔가 정리한답시고 썼는데 더 장황해진 느낌이다 (...)
그러나 오늘 내가 깨달은 내용은 아래와 같다