해당 글은 Javascript의 스코프, 실행컨텍스트 등 전반적인 자바스크립트 코드의 실행 관련 지식이 있다는 것을 전제로 작성되었습니다.
들어가며...
React Hooks을 이해하기 위해서는 클로저에 대한 개념이 필요하다. Hooks를 살펴보기에 앞서 간단하게 클로저에 대해 알아보자.
클로저를 단편적으로 설명하고 이해하기는 어렵다. 클로저는 JavaScript에서 코드가 동작하는 다양한 개념들을 바탕으로 이해해야 하기 때문이다. 아래는 클로저와 관련된 개념이다. 일급객체, 렉시컬 스코프, 함수의 변수와 생명주기는 클로저를 이해하기 위해 필수적인 개념이다. 해당 키워드들에 대해 살펴보자.
• 일급객체
함수형 프로그래밍에서는 함수를 일급객체로 취급한다. 일급객체란, 변수에 함수를 값으로 할당하는 것을 말한다. 함수 표현식으로 이해할 수 있겠다.
• 렉시컬 스코프
렉시컬 스코프라는 개념은 lexical이라는 단어에서 이해를 시작해 볼 수 있겠다. 단어 'lexical'을 검색해보면 단순히 '단어의, 어휘의'라는 뜻을 나타낸다. 하지만 뉘앙스적인 부분에서 미묘한 차이가 있다. 영영사전에서 해당 단어를 검색하면 다음과 같다.
: of or relating to words or the vocabulary of a language as distinguished from its grammar and construction
출처: Merriam-Webster
특히, 유심히 살펴보아야할 부분은 relating to words or the vocabulary of a language 이다. 다시 말하면, 단순히 어떤 단어의 뜻이 아니라 특정 부분과 관련있는 단어, 어휘를 가리키는 것이다. 이를 바탕으로 렉시컬 스코프의 뜻을 이해하기 위해서는 해당 스코프가 생성되었을때의 상황과 맥락을 고려해야한다것은 알 수 있다.
렉시컬 스코프의 정의의 요점은 함수를 어디에서 정의했는지에 따라 해당 함수의 상위스코프를 결정한다는 것이다. 해당 함수가 실행되는 위치가 중요한 것이 아니라 생성되는 위치에 따라 식별자를 참조할 수 있는 범위(스코프)를 결정한다는 것이다. 함수의 정의가 실행될 때 정적으로 결정되기 때문에 정적 스코프(static scope)라고도 불린다.
• 함수와 변수의 생명주기
함수와 변수는 생명과 같이 생명 주기가 있다. 시작과 끝이 있다는 말이다. 특히, 함수 내부에 선언된 지역 변수들은 함수가 종료됨과 동시에 종료된다. 하지만 해당 지역변수를 다른 곳(외부 함수의 외부)에서 참조하고 있는 경우, 해당 지역 변수를 포함한 함수가 종료되었다고 할지라도 해당 지역 변수는 사라지지 않고 계속 참조할 수 있다.
클로저란?
그렇다면 클로저는 무엇일까?
클로저는 특정한 상황을 가정한다. 클로저의 정의는 다음과 같다.
외부 함수보다 중첩 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이때 중첩함수를 클로저라 부른다.
클로저의 뜻은 '폐쇄, 닫힘' 인데, 단어의 뜻을 개념을 적용해 보면 중첩 함수에서 사용 중인 외부 함수의 변수와 중첩 함수가 서로 어떤 닫힌 공간에 함께 있는 것으로 이해할 수 있다. 클로저에 의해 참조되고 있는 변수를 '자유변수'라 하는데 클로저 함수가 해당 자유 변수와 함께 폐쇄되었다라는 뜻이다.
외부 함수보다 중첩함수가 더 오래 유지된다는 말의 의미는 앞서 설명한 함수, 변수의 생명 주기와 관련이 있다. 일반적으로 중첩 함수의 생명 주기는 외부 함수의 생명 주기보다 짧다. (JavaScript의 코드 실행 순서는 콜 스택으로 관리하기 때문이다. 해당 내용에 대한 깊이알고 싶다면 '실행컨텍스트'에 대해 조사해보자.) 하지만 이러한 일반적인 경우와 달리 중첩 함수가 외부 함수보다 오래 살아남는 특수한 경우가 발생하기도 한다. 바로 이러한 상황이 함수를 변수에 할당하여 실행하는 경우에 발생하는데 이것은 함수를 '일급 객체'로 취급하는 것에 기인한다고 볼 수 있다. 예를 들어 다음과 같은 경우이다.
const x = 1;
function outer() {
const x = 10;
const inner = function() {
console.log(x);
};
return inner;
}
const innerFunc = outer();
innerFunc();
//출처: 모던 자바스크립트 Deep Dive
외부 함수 outer 내부에 중첩 함수가 있고 outer 함수를 innerFunc라는 변수에 담아 함수를 실행하고 있다. 일반적인 함수와 변수의 생명주기를 생각해보면, outer 함수가 실행되고 나면 outer 함수의 생명주기가 종료되기 때문에 outer 함수의 지역 변수인 x도 메모리 상에서 사라진다. 하지만 해당 코드의 결과는 x의 값이 10으로 찍힌다. 그 이유는 outer 함수의 내부에 inner라는 변수에 담긴 함수와 outer 함수의 변수인 x( x = 10)와 클로저를 형성(inner 함수를 클로저라고 한다.)하기 때문이다. 그렇기 때문에 생명 주기가 끝난 외부 함수의 변수를 참조할 수 있다.
Hook이란 무엇이며 왜 도입했는가?
hook의 도입 이유에 대해 이해하기 전에 클래스형 컴포넌트와 함수형 컴포넌트에 대해 이해할 필요가 있다. hook 도입 이전에는 클래스형 컴포넌트에서만 state와 라이프사이클을 이용한 상태관리를 할 수 있었다. 하지만 hook의 도입으로 함수형 컴포넌트에서도 해당 기능과 비슷하게 상태를 관리할 수 있게 되었다.
React 공식문에서 따르면, 그 외에도 컴포넌트간 상태로직 재사용 어려움, 복잡한 컴포넌트의 복잡성등의 문제 등을 언급하기도 한다. 클래스 문법을 직접사용하며 불편한 점을 느껴봤다면 조금 더 hook의 필요성에 대해 절실히 느낄 수 있었을 것 같다. 추후 클래스를 사용하여 React 코드를 작성해보는 시간을 갖으면 좋을것 같다.
React Hooks의 동작원리
React Hooks는 어떤 원리로 동작할까? JavaScript 코드를 사용하여 hooks을 구현하면서 그 원리를 파악해보자. 해당 부분에서는 대표적인 React Hooks인 useState, useEffect 를 구현하겠다.
useState
useState Hook은 React에 내장된 기본적인 hook으로 상태값(state)과 해당 상태의 값을 업데이트 하는 setter 함수로 구성되어있다. 기본적인 원리는 해당 state의 초기값을 설정하고 setter 함수의 인자로 state를 업데이트 하고 싶은 값을 넣어 setter를 실행시키면 해당 state가 그 값으로 업데이트 되는 원리이다. 그리고 useState 함수는 state와 setter 함수를 배열에 담아 반환한다. 해당 내용을 코드로 작성해보자.
const ReactHooks = (() => {
const useState = (initialValue) => {
let state = initialValue;
const setterFunction = (newValue) => {
state = newValue
};
return [state, setterFunction];
};
return {
useState
};
})();
여기서 눈에 띄는 점은 ReactHooks 함수를 즉시실행함수로 만들었다는 점이다. 이 부분은 앞서 살펴본 클로저를 형성하기 위한 방법이므로 추후 다시 살펴보자.
이제 작성한 ReactHooks 함수를 React 컴포넌트에서 사용하는 것처럼 실행하는 코드를 작성하자.
const { useState } = ReactHooks;
const Component = () => {
const [counter, setCounter] = useState(1);
console.log(counter);
if (counter !== 2) {
setCounter(2); //setter 함수를 통해서 값을 업데이트 한다
}
};
Component(); //1
Component(); //1 원하는 기대값은 2이지만 계속해서 1이 반환된다...
Component를 실행하면 콘솔에는 의도한대로 1이 찍힌다. 하지만 Component를 2번, 3번 실행시켜보면, 계속해서 1이 찍히는것을 볼 수 있다. Component를 실행할때마다 count의 값이 1씩 증가하는 것을 기대하며 코드를 작성했지만 이와는 다른 결과를 보여준다. 그 이유는 무엇일까?
그 원인은 Component를 실행할 때마다 변수 state의 값이 initialValue로 초기화 되기 때문이다. 변수 state가 useState 함수 내부에 위치하기 때문에 Component를 실행할때마다 useState 함수가 실행되어 초기값이 할당되기 때문이다. 그렇다면 어떻게 변수 state를 초기화 시키지 않고 그 이전의 값으로 계속 유지할 수 있을까?
이때 적용되는 것이 바로 '클로저'이다. 앞서 클로저는 외부 함수 내에 있는 중첩 함수를 가리킨다고 했고 특히, 외부 함수보다 중첩 함수가 더 오래 유지되는 경우, 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있는데 해당 중첩 함수를 클로저라 부른다고 했다. 보통 클로저를 구현하는 방법은 외부 함수의 외부로 중첩 함수를 반환하여 외부 함수의 변수(이곳에서는 변수 state)와 중첩 함수간의 참조 관계를 형성하는 것을 통해 클로저를 구현할 수 있다. 해당 내용을 적용하여 코드를 수정해보자.
const ReactHooks = (() => {
let state; // 변수 state가 실행될 때마다 초기화 되지 않도록 useState 함수의 상위 스코프에 선언한다.
const useState = (initialValue) => {
if (state === undefined) {
state = initialValue;
}
const setterFunction = (newValue) => {
state = newValue
};
return [state, setterFunction];
};
return {
useState,
};
})();
//실행 부분
const { useState } = ReactHooks;
const Component = () => {
const [counter, setCounter] = useState(1);
console.log(counter);
if (counter !== 2) {
setCounter(2);
}
};
Component();
Component();
해당 코드를 실행시켜보면 의도한대로 작동하는 것을 알 수 있다. 추가로 즉시실행함수에 대해 덧붙이자면, 즉시실행함수는 한 번만 실행 되기 때문에 ReactHooks 함수가 호출 될때마다 state 변수가 초기화될 일은 없다. 또한 클로저를 통해서 외부에서 변수 state에 직접적으로 접근할 수 없기때문에 좀 더 안정적인 코드라 할 수 있겠다.
하지만 여기서 한가지 중요한 문제가 있다. 실제로 React에서 사용되는 상태값들은 하나가 아니라는 점이다. React를 통해 서 만들어지는 대부분의 애플리케이션은 무수히 많은 상태값을 갖게 된다. 따라서 관리해야할 상태 값들은 배열의 형태로 관리하는 것이 바람직할 것 같다. 실제로 해당 내용을 반영하여 코드를 작성해보자.
const ReactHooks = (() => {
let state = []; //여러개의 상태를 관리하기 위해 state를 배열의 형태로 만든다
let index = 0; //이곳에 index를 선언하여 사용하면 클로저가 형성되기 때문에
//useState 함수를 실행해도 index 값이 0으로 초기화 되지 않는다
const useState = (initialValue) => {
const localIndex = index;
index++;
if (state[localIndex] === undefined) {
state[localIndex] = initialValue;
}
const setterFunction = (newValue) => {
state[localIndex] = newValue
};
return [state[localIndex], setterFunction];
};
const resetIndex = () => {
index = 0;
};
return {
useState,
resetIndex,
};
})();
//실행 부분
const { useState, resetIndex } = ReactHooks;
const Component = () => {
const [counter, setCounter] = useState(1);
console.log(counter);
if (counter !== 2) {
setCounter(2);
}
};
Component();
resetIndex();
Component();
resetIndex 함수를 사용하여 변수 index를 초기화하는 이유는 하나의 상태는 하나의 인덱스를 통해 관리하는 React Hooks의 특징 때문이다. 위의 예제코드에서 counter 상태는 인덱스 0에서 그 값이 계속해서 관리되어야한다. 하지만 변수 index를 초기화 시키지 않으면 계속해서 index의 값이 증가하므로 Component를 실행할 때마다 state 배열에는 상태 값이 추가된다. 따라서 같은 상태를 업데이트 하는 경우에는 동일한 인덱스를 사용해야 하므로 컴포넌트를 렌더링 하는 과정마다 인덱스를 초기화 해주어야 한다. 실제로 React에서 컴포넌트 렌더링 사이 마다 인덱스를 초기화 하는 작업이 진행된다.
정리하자면 다음과 같다.
React Hooks는 상태를 배열을 통해 관리하기 때문에 배열에 담기는 상태의 인덱스를 관리하는 것이 매우 중요하다.(hook은 단순한 배열이라 볼 수 있다. 해당글 참고 React hooks: not magic, just arrays) 그렇기에 React Hooks 사용 관련 규칙 중 hook을 반복문, 조건문 등의 문법 내부에서 사용하는 것을 지양하도록 하는데 이는 조건문과 반복문 내에서 hook을 사용할 경우, 의도치 않게 배열의 인덱스에 영향을 주어 발생할 오류를 방지하기 위한 것이라 할 수 있겠다.
또한 여기에서 변수 index를 사용하지 않고 localIndex를 새로 선언하여 사용한 이유는 index를 사용할 경우, state의 값이 잘못된 index로 관리되기 때문이다. 위와 같은 맥락이라 할 수 있다.
Hooks 사용의 규칙
1) React 함수 내에서만 사용해야 할 것!
2) React 함수 최상위에서 호출할 것! (조건문, 반복문, 중첩 함수 내에서 hook을 호출 지양)
useEffect
다음으로 useEffect을 구현해보자.
useEffect hook의 특징은 콜백함수와 의존성 배열을 인자로 받아 콜백 함수를 실행하는 hook이다. 의존성 배열을 빈 배열로 넣는다면 콜백함수가 렌더링시 최초 1회 실행되고 특정 상태 값을 넣는 경우 해당 상태가 변경될 때마다 콜백함수를 실행시킨다.
const ReactHooks = (() => {
let hooks = []; // 기존의 state라는 변수명 보다는 hooks라는 변수명이 적절하다.
let index = 0;
//...생략
const useEffect = (callback, dependencyArray) => {
let hasChanged = true;
const oldDependencise = hooks[index];
if (oldDependencise) {
hasChanged = false;
dependencyArray.forEach((dependency, index) => {
const oldDependency = oldDependencise[index];
const areTheSame = Object.is(dependency, oldDependency);
if (!areTheSame) {
hasChanged = true;
}
});
}
if (hasChanged) { // 상태값이 변했다면,
callback(); // 콜백함수 실행
}
hooks[index] = dependencyArray;
index++;
};
return {
useState,
resetIndex,
useEffect,
};
})();
//실행 부분
const { useState, useEffect, resetIndex } = ReactHooks;
const Component = () => {
const [counter, setCounter] = useState(1);
const [name, setName] = useState("Thomas");
console.log(counter);
console.log(name);
useEffect(() => {
console.log("useEffect");
}, [name]);
if (counter !== 2) {
setCounter(2);
}
if (name !== "Jack" && counterValue === 2) {
setName("Jack")
}
};
Component();
resetIndex();
Component();
resetIndex();
Component();
모든 hook은 동일한 배열을 공유하고 hook의 선언 순서에 따라 배열 안에서 바뀌기 때문에 전체적인 관점에서 변수 state를 hook으로 바꾸는게 적절하겠다. 또한 useState에서와 마찬가지로 인자로 들어온 dependencyArray를 hooks 배열에 담아 관리한다.
초기에 렌더링이 발생할때는, hook 배열 안에는 useEffect의 dependencyArray 값이 존재하지 않기 때문에 콜백함수가 작동한다. 이는 useEffect hook이 초기 렌더링에 최초 1회 작동하는 것과 같은 작동 방식이다. 콜백 함수가 실행되면 해당 dependencyArray를 hooks 배열에 저장하고 index를 증가시키는데 이는 useState에서와 마찬가지로 다른 hook들과 공유하는 배열 hook에서 값의 중복을 방지하기 위함이다. 다시 한 번 렌더링이 발생하면, 해당 index가 초기화되고 해당 index에 dependencyArray 값이 이미 존재하기때문에 해당 dependencyArray가 기존에 있었던 의존성 배열의 값과 같은지 확인하는 작업으로 넘어간다. 만약 dependencyArray와 이전의 oldDependency 가 같지 않다면 hasChanged의 값을 true 바꾸면서 콜백함수가 실행되도록 한다.
hasChanged 라는 변수를 사용하여 state의 값이 바뀌었는지 체크해준다. hasChanged가 true 이면 인자로 들어온 콜백함수를 실행시킨다.
useState, useEffect 의 최종 구현코드는 다음과 같다.
//Hook 선언부분
const ReactHooks = (() => {
let hooks = [];
let index = 0;
const useState = (initialValue) => {
const localIndex = index;
index++;
if (hooks[localIndex] === undefined) {
hooks[localIndex] = initialValue;
}
const setterFunction = (newValue) => {
hooks[localIndex] = newValue
};
return [hooks[localIndex], setterFunction];
};
const resetIndex = () => {
index = 0;
};
const useEffect = (callback, dependencyArray) => {
let hasChanged = true;
const oldDependencise = hooks[index];
if (oldDependencise) {
hasChanged = false;
dependencyArray.forEach((dependency, index) => {
const oldDependency = oldDependencise[index];
const areTheSame = Object.is(dependency, oldDependency);
if (!areTheSame) {
hasChanged = true;
}
});
}
if (hasChanged) {
callback();
}
hooks[index] = dependencyArray;
index++;
};
return {
useState,
resetIndex,
useEffect,
};
})();
//실행 부분
const { useState, useEffect, resetIndex } = ReactHooks;
const Component = () => {
const [counter, setCounter] = useState(1);
const [name, setName] = useState("Thomas");
console.log(counter);
console.log(name);
useEffect(() => {
console.log("useEffect");
}, [name]);
if (counter !== 2) {
setCounter(2);
}
if (name !== "Jack" && counter === 2) {
setName("Jack")
}
};
Component();
resetIndex();
Component();
resetIndex();
Component();
참고자료
https://www.youtube.com/watch?v=1VVfMVQabx0
https://www.youtube.com/watch?v=LlvBzyy-558
https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e
'FRONTEND' 카테고리의 다른 글
[React] Learning React Chapter 5 - 웹팩과 바벨 part 1 (0) | 2022.08.22 |
---|---|
[React] Learning React Chapter 4 - 리액트 엘리먼트 (0) | 2022.08.21 |
[JavaScript] 이벤트 위임 (0) | 2022.08.01 |
[React] React는 왜 사용할까?(+ Redux는 왜 사용할까?) (0) | 2022.08.01 |
[React] React Testing Library(RTL) 활용한 테스트 코드 작성 - Part 1 (0) | 2022.08.01 |