클로저란 무엇이고 JS와 React에서 클로저는 어떻게 사용되고 있을까?
Table of contents
서문 : 왜 클로저를 알아야 할까?
리액트의 클래스형 컴포넌트에 대한 이해가 자바스크립트의 클래스, 프로토타입, this에 달려있다면, 함수형 컴포넌트에 대한 이해는 클로저에 달려 있다. 함수형 컴포넌트의 구조와 작동 방식, 훅의 원리, 의존성 배열 등 함수형 컴포넌트의 대부분의 기술이 모두 클로저에 의존하고 있기 때문에 함수형 컴포넌트 작성을 위해서는 클로저에 대해 이해하는 것이 필수다. -모던 리액트 딥 다이브 p 59-
리액트를 더 잘 이해하고, JS 프레임워크 판도 변화에 상관없이 빠르게 적응하기 위해서 클로저에 대해서 정리해보기로 하였다. 우선 클로저란 무엇인지 MDN에서의 정의를 해석해보며 이해하고, 바닐라 JS 클로저 예시를 통해 클로저를 사용하는 이유와 이점에 대해 다뤄본다. 그리고 리액트에서 클로저가 어떻게 사용되고 있는지 여러 리액트 훅 예제와 함께 알아도록 한다.
1. 클로저란?
클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합입니다. 즉, 클로저는 내부 함수에서 외부 함수의 범위에 대한 접근을 제공합니다. JavaScript에서 클로저는 함수 생성 시 함수가 생성될 때마다 생성됩니다. -MDN-
클로저는 함수에서만 일어나는 함수의 동작입니다. 함수를 다루지 않는다면 클로저는 적용되지 않습니다. 객체는 클로저를 가질 수 없고 클래스도 클로저를 가질 수 없습니다. 오직 함수에만 클로저가 있습니다. -You Don't Know JS Yet p 242-
자바스크립트에서 함수의 상위 스코프에 대한 참조, 즉 함수 자신이 정의된 위치의 스코프는 함수 정의가 평가되는 시점에 정적으로 결정된다. 이러한 함수의 상위 스코프에 대한 참조의 결정 방식을 렉시컬 스코프라고 부른다. MDN에서는 "클로저는 주변 상태(렉시컬 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합" 라고 클로저에 대해 정의한다. 이렇게만 봐서는 참 와닿지 않는 설명이다. 하나씩 풀어서 해석해보자.
1.1 클로저의 정의와 해석
(모던 자바스크립트 딥 다이브 24.2, 24.3절 참고)
"주변 상태(렉시컬 환경)에 대한 참조": 주변 상태(렉시컬 환경)은 결국 자신이 정의된 위치(스코프)의 환경을 말하는 것이며, 함수 정의가 평가되어 함수 객체를 생성할 때, 함수 객체의 내부 슬롯 [[Environment]]
에 상위 스코프에 대한 참조값을 저장함으로서 함수는 자신의 상위 스코프와 그 변수들을 기억하고 있다. 그러므로, MDN의 클로저 정의에서 "주변 상태(렉시컬 환경)에 대한 참조" 라는 것은 함수 자신이 정의된 위치인 함수 자신의 상위 스코프에 대한 참조값을 말하는 것임을 알 수 있다.
나머지 부분도 해석해 보자. "렉시컬 환경에 대한 참조와 함께 묶인 함수"가 무슨 뜻일까? "함께 묶인(포함된) 함수": 클로저는 함수와 그 함수가 정의될 때의 렉시컬 환경을 포함하고 있기 때문에, 이 두 요소가 함께 묶여 있는 것을 의미한다. 즉, 함수는 자신이 생성될 때의 환경을 "포함"하고 있으며, 이 환경에 속한 변수들에 접근할 수 있다.
정리하자면, '함수와 함수 자신이 정의된 상위 스코프에 대한 참조의 조합'을 클로저라고 한다고 해석할 수 있다. 그렇다면 여기서 "모든 함수는 다 이렇게 동작하는데, 그럼 모든 함수는 클로저 아닌가?" 라는 의문이 생기는데, 일반적으로 클로저라고 하는 경우는 외부 함수의 변수(자유 변수)를 참조하고, 외부 함수보다 메모리에서 오래 유지되는 중첩 함수를 말한다.
function outer() {
const val = 1;
function inner(n) {
return n + val;
}
return inner;
}
const innerFunc = outer(); // 외부 함수 outer의 생명주기는 여기서 끝난다.
// 그럼에도 외부 함수의 지역변수 val의 값이 여전히 유효하다.
const res1 = innerFunc(2);
const res2 = innerFunc(3);
console.log(res1); // 3
console.log(res2); // 4
위 예제의 inner
함수는 외부 함수 outer
내부의 지역 변수인 val
을 참조하고 있다. inner
함수는 자신의 내부 슬롯 [[Environment]]
에 상위 스코프인 outer
함수의 렉시컬 환경에 대한 참조가 저장되어 있다.
함수 내부에 존재하는 지역변수의 생명주기는 그 함수의 생명주기와 동일하다. 함수가 호출되고 함수 코드가 모두 실행되고 종료되면 생명주기가 끝나 가비지 컬렉터에 의해 메모리에서 제거되므로 함수 내부의 지역변수를 참조할 수가 없다. 하지만 누군가 스코프를 참조하고 있으면 이 스코프는 소멸되지 않고 생존하게 된다. 즉 메모리에 여전히 남아있게 된다.
실행 컨텍스트 관점에서 이야기하자면, outer
함수가 종료되어 outer
함수 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되었지만, inner
함수가 outer
함수 내부의 지역 변수를 참조중이므로 outer
함수의 렉시컬 환경은 소멸하지 않고 inner
함수 객체의 [[Environment]]
내부 슬롯을 통해 여전히 참조가 이어진다. 이것이 클로저가 가능한 이유이다.
다시 인용문에서, MDN의 클로저 정의 뒤에 오는 부연 설명 " JavaScript에서 클로저는 함수 생성 시 함수가 생성될 때마다 생성됩니다." 라는 말은 무슨 뜻일까?
function divider(num1) {
return function divideBy(num2) {
return num1 / num2;
}
}
const divide10By = divider(10);
console.log(divide10By(2)); // 5
const divide12By = divider(12);
console.log(divide12By(3)); // 4
// You Don't Know JS Yet 예제 코드 참고
// https://github.com/getify/You-Dont-Know-JS/blob/2nd-ed/scope-closures/ch7.md#adding-up-closures
위 예제의 외부 함수 divider
가 실행될 때 마다 내부 함수 divideBy
의 새로운 인스턴스가 생성된다. 클로저는 함수가 생성될 때 마다 생성된다고 하였다. 따라서 내부 함수 divideBy
의 인스턴스 divide10By
, divide12By
가 생성될 때 마다 새로운 클로저가 생성되기 때문에 콘솔 출력에서
// 화살표 함수도 클로저를 형성한다.
const outer = () => {
const val = 1;
const inner = (n) => {
return n + val;
};
return inner;
};
const innerFunc = outer();
const res = innerFunc(2);
console.log(res); // 3
이전 예제를 화살표 함수로 바꾸어도 동일하게 동작한다. 즉 화살표 함수도 클로저를 형성한다.
1.2 클로저를 사용하는 이유 - 최소 노출의 원칙(POLE)
소프트웨어 공학, 특히 정보 보안 분야에서 많이 사용되는 최소 권한의 법칙(POLP: principle of least privilege) 이란 설계원칙은 시스템 구성요소에는 최소한의 권한을 부여하고 접근과 노출 역시 최소화해야 함을 말하는 원칙이다. 이 POLP를 약간 변형한 최소 노출의 원칙(POLE : principle of least exposure)에 대해 알아보자. 최소 노출의 원칙(POLE)은 시스템 수준의 컴포넌트 설계보다는 조금 더 낮은 코드 수준에서 변수/함수의 스코프를 가능한 작고 깊게 중첩된 스코프에 두어 필요한 만큼만 노출하고 나머지는 가능한 비공개로 스코프에 감추는 설계 원칙이다. 최소 노출의 원칙을 준수함으로서 어떤 이점이 있으며, 어떻게 식별자를 가능한 작고 깊게 중첩된 스코프에 선언할 수 있을까?
1.2.1 최소 노출 원칙(POLE) 적용의 이점
(참고 : 책 You Don't Know JS Yet : 6.1절 최소 노출의 원칙 / p 217)
1. 이름 충돌 방지
다른 코드에서 동일한 이름의 식별자가 사용되어 예기치 못한 동작이 발생하는 것을 방지할 수 있다.
2. 암묵적 결합으로 인한 예기치 못한 작동 방지
비공개로 두어야 하는 변수나 함수가 노출되어 내부 코드에 악의적인 접근으로 인한 오작동 및 보안사고를 방지한다. 암묵적 결합이란 의도치 않은 상태(값)의 변경을 말한다.
3. 의도하지 않은 종속성 방지
식별자가 불필요하게 노출되어 다른 코드에서 이를 참조하는 구현을 협업자가 추가하여 의도하지 않은 종속성이 생기는 일을 막을 수 있다.
1.2.2 식별자를 가능한 작고 깊게 중첩된 스코프에 선언하는 방법
(참고 : 책 모던 자바스크립트 딥 다이브 14.3절 전역 변수의 사용을 억제하는 방법 / p 204)
- 네임스페이스 객체 : 재사용되는 변수나 함수들을 하나의 객체로 캡슐화
- IIFE : 스코프를 제한하고자 하는 코드들을 IIFE(즉시 실행 함수)로 감싸 IIFE의 지역 변수, 함수로 스코프를 제한하는 방법이다.
- 모듈 패턴 : 관련이 있는 변수와 함수를 모아 IIFE로 감싸 하나의 모듈로 만든 형태를 모듈 패턴이라고 한다. ==모듈 패턴은 클로저를 기반으로 동작==하며, 반환하는 객체 내부에 포함하느냐 여부에 따라 외부에 노출되는 퍼블릭 멤버와 외부에 노출되지 않는 프라이빗 멤버를 구분하여 ==정보 은닉의 목적으로 코드를 캡슐화==할 수 있다.
- ES6 모듈 : ES6 모듈은 파일 자체의 독자적인 모듈 스코프를 제공한다. 따라서 모듈 내에서 선언한 식별자들은 전역 변수/함수나 전역 객체 window의 프로퍼티가 되지 않는다. 즉 하나의 index.html에 여러
type="module"
인<script>
가 여럿이 있더라도 이들은 하나의 (전역)스코프를 공유하지 않는다.- 하지만 일반적으로 아래의 이유들로 인해 ES6 모듈 대신 Webpack, Rollup 등 모듈 번들러를 사용한다.
- 브라우저 호환성 : 아직 모든 브라우저가 ES6 모듈 기능을 완벽하게 지원하지 못하며, 일부 구형 브라우저는 아예 ES6 모듈을 전혀 지원하지 않는다.
- 최신 JS 문법과 기능 : 아직 브라우저에서 지원하지 않는 최신 문법과 기능들을 사용하려면 브라우저가 지원하는 구 버젼의 JS 코드로 소스 코드를 트랜스파일링 해야 한다.
- 성능 최적화 : Webpack, Rollup 등 모듈 번들러들은 여러 모듈 파일을 하나의 파일로 결합하거나 몇 개의 청크로 분할하여 네트워크 요청의 수를 줄이므로 애플리케이션의 로딩 시간을 개선할 수 있다.
- 코드 최소화 및 압축 : 모듈 번들러를 사용하면 번들링 과정에서 코드를 최소화하고 압축하여 전송해야 할 파일 크기를 줄여 애플리케이션의 성능 향상에 기여할 수 있다.
- 모듈의 종속성 관리 : 모듈 번들러를 사용하면 다양한 외부 라이브러리와 의존하는 여러 모듈에서 필요한 코드만 포함시켜 최종 번들을 생성하므로 종속성을 쉽게 관리할 수 있다.
- 개발 환경과 도구 : 번들러에서 제공하는 핫 모듈 리로딩(HMR), 소스 맵, 코드 분할 등을 사용하여 디버깅과 개발 과정을 더 효율적으로 만들어 줄 수 있다.
- 하지만 일반적으로 아래의 이유들로 인해 ES6 모듈 대신 Webpack, Rollup 등 모듈 번들러를 사용한다.
- 모듈 번들러 사용 : Webpack 등 모듈 번들러를 사용하여 코드를 더 작은 모듈로 분할하고 함께 묶을 수 있으므로, 이를 통해 이름 충돌(네임스페이스 오염)을 최소화할 수 있다.
각 항목들에 대한 세부 설명은 주제에서 벗어나는 내용이므로 여기에서 구체적인 설명은 생략한다. 위 내용 중에서 모듈 패턴은 클로저 기반으로 동작한다고 언급하였다. 아래에서 코드를 통해 모듈 패턴이란 무엇이고, 클로저를 통해 어떻게 정보 은닉을 위한 캡슐화를 하는지 알아보자.
1.2.3 모듈 패턴
const Counter = (function () {
// 반환되는 객체에 추가하지 않은 식별자는 외부에서 접근할 수 없는 private member가 된다.
let num = 0;
// 반환되는 객체에 추가한 프로퍼티는 외부에 노출되는 public member가 된다.
return {
increase: () => ++num,
decrease: () => --num,
};
})();
// num은 외부에서 접근할 수 없는 private 멤버이다.
console.log(Counter.num); // undefined
// increase, decrease 프로퍼티는 외부에서 접근 가능한 public 멤버이다.
console.log(Counter.increase()); // 1
console.log(Counter.decrease()); // 0
모듈 패턴이란 관련이 있는 변수나 함수를 모아 즉시 실행 함수로 감싸 하나의 모듈로 만든 형태를 말한다. 즉시 실행 함수의 반환값인 객체에 추가한 프로퍼티는 외부에서 접근 가능한 public 멤버가 되며, 반환값인 객체에 추가하지 않은 변수나 함수는 인스턴스나 즉시 실행 함수 외부에서 접근할 수 없는 private 멤버가 된다.
함수는 함수 코드가 평가되어 함수 객체가 생성될 때 자신의 상위 스코프에 대한 참조가 결정되고 이를 함수 객체의 내부 슬롯 [[Environment]]
에서 기억한다. 위 예제 코드의 increase
와 decrease
메서드는 자신의 상위 스코프에 대한 참조인 즉시 실행 함수의 렉시컬 환경을 기억하고 있기 때문에 즉시 실행 함수가 종료된 후에도 즉시 실행 함수 내부의 변수 num
을 참조하고 변경할 수 있다.
모듈 패턴은 SDK/플러그인 형태의 레거시 js
코드를 작업할 때도 종종 등장하므로 알아두면 좋다. (참고 : 자바스크립트 플러그인 제작과 디자인 패턴)
2. VanillaJS 클로저 예시
2.1 이벤트 핸들러의 콜백함수도 클로저다
(책 You Don't Know JS Yet p 252 예제를 참고하여 일부 변형)
const submitBtn = document.createElement('button');
submitBtn.innerText = '제출';
document.body.appendChild(submitBtn);
function listenForClicks(btn,label) {
btn.addEventListener("click",function onClick(){
console.log(
`외부 함수가 종료되어도 이벤트 핸들러의 콜백함수는 ${ label }의 값을 기억하고 있다`
);
});
}
listenForClicks(submitBtn,"외부함수의 매개변수");
// 외부 함수가 종료되어도 이벤트 핸들러의 콜백함수는 외부함수의 매개변수의 값을 기억하고 있다
위 예제 코드에서는 화면에 버튼 하나를 추가하고, onClick
이벤트 발생 시 동작할 이벤트 핸들러를 추가하는 함수를 정의하고 호출한다. 함수가 호출되어 함수 내부 코드가 모두 실행되어 종료되면 함수의 생명주기가 끝나 실행 컨텍스트 스택에서 제거되고 가비지 컬렉터에 의해 메모리에서 해제되어야 한다.
그런데 위 예제에서는 listenForClicks
함수 호출 이후에도 버튼을 클릭할 때 마다 동일하게 '외부 함수가 종료되어도 이벤트 핸들러의 콜백함수는 외부함수의 매개변수의 값을 기억하고 있다' 는 콘솔 출력이 계속 실행된다. 이는 addEventListner
메서드의 두번째 인수인 콜백함수가 외부 함수 listenForClicks
의 매개변수 label
을 참조하고 있기 때문에 listenForClicks
함수의 렉시컬 환경은 메모리에서 해제되지 않고 유지되고 있기 때문이다.
클로저는 오로지 함수에만 존재한다. 객체나 클래스는 클로저를 가질 수 없다. 또한 ==클로저를 관찰하려면 반드시 함수를 호출해야 한다.== (책 You Don't Know JS Yet p 242 참고)
2.2 fetch 함수도 클로저다
(참고 : What is a closure? Example use cases in JavaScript and React)
비동기로 동작하는 코드에서도 클로저가 흔하게 사용된다. 아래의 예시는 fetch
함수를 사용하여 사용자 인증 토큰을 사용해 API에서 데이터를 요청하는 상황을 다룬다. 클로저를 사용하여 인증 토큰을 내부 상태로 유지하고, 이를 이용해 API 요청을 수행하는 함수를 생성한다.
// 외부 함수: API 요청을 수행하는 함수를 생성하는 팩토리 함수
function createApiRequester(apiUrl, authToken) {
// 클로저: apiUrl과 authToken을 기억하는 내부 함수
return async function(path) {
const url = `${apiUrl}/${path}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${authToken}` // 클로저를 통해 authToken에 접근
}
});
if (!response.ok) {
throw new Error(`API 요청 실패: ${response.statusText}`);
}
return response.json(); // 응답 본문을 JSON 형태로 파싱
};
}
// 사용 예
const apiUrl = 'https://example.com/api';
const authToken = 'your_auth_token_here';
// createApiRequester 함수를 사용하여 특정 API URL과 인증 토큰을 사용하는 요청 함수를 생성
const requestToApi = createApiRequester(apiUrl, authToken);
// 생성된 함수를 사용하여 API의 특정 경로에 요청
requestToApi('users/1').then(userData => {
console.log(userData); // 사용자 데이터 출력
}).catch(error => {
console.error(error);
});
이 예시에서 createApiRequester
함수는 클로저를 생성하는 팩토리 함수이다. 이 함수는 apiUrl
과 authToken
매개변수로 인수를 전달받아 이 두 값을 기억하는 클로저인 익명함수를 생성하여 반환한다. 따라서 변수 requestToApi
의 할당문이 평가되어 팩토리 함수 createApiRequester
가 생성한 클로저 함수가 할당될 때 apiUrl
과 authToken
의 값을 기억하고 있다.
따라서 코드 하단부분의 requestToApi
호출 시 apiUrl
과 authToken
의 값에 계속 접근할 수 있기 때문에 값을 매번 인수로 전달할 필요가 없어 코드가 더 깔끔해지고 관리가 용이해진다.
3. 리액트에서의 클로저
- 참고
- Is there any reason to use a closure in React?
- 책 모던 리액트 딥 다이브 p 65 - 리액트에서의 클로저
글 서문의 인용문에서 "함수형 컴포넌트의 구조와 작동 방식, 훅의 원리, 의존성 배열 등 함수형 컴포넌트의 대부분의 기술이 모두 클로저에 의존하고 있기 때문에 함수형 컴포넌트 작성을 위해서는 클로저에 대해 이해하는 것이 필수다." 고 하였다.
3.1 useState
와 클로저
리액트 함수 컴포넌트의 훅에서 클로저의 원리를 사용하는 대표적인 훅 중 하나가 useState
이다. useState
함수의 호출 이후에도 set
함수가 useState
내부의 최신 값을 계속해서 확인할 수 있는 이유는 useState
내부에서 클로저를 활용하기 때문이다.
useState
가 호출될 때 반환되는 배열의 두번째 요소인 set
함수는 상태를 업데이트하는 역할을 하며, useState
호출 시 생성된 환경을 "기억"하는 클로저다. 따라서 set
함수가 호출될 때마다 해당 상태의 최신 값을 참조하고 업데이트할 수 있도록 한다. 재렌더링이 일어날 때 마다 함수 컴포넌트의 코드가 다시 실행되므로 클로저도 다시 생성되므로 state의 최신 값을 기억하게 되는 것이다.
아래에서 코드 예시를 통해 확인해보자.
import { useState } from 'react';
function Counter() {
// useState 훅을 사용해 count 상태와 이 상태를 업데이트하는 setCount 함수를 선언
const [count, setCount] = useState(0); // useState 호출은 여기에서 끝났다.
const handleIncrement = () => {
// 클로저를 사용하여 이전(기존) 상태를 기반으로 상태 업데이트
setCount((prev) => prev + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
export default Counter;
상태 초기화:
useState(0)
호출로count
라는 이름의 상태를 초기값0
으로 설정한다. 호출의 반환값인 배열을 구조분해할당하여count
에는 앞서 지정한 상태의 초기값인0
을,setCount
에는count
상태를 업데이트할 수 있는 권한을 가지는set
함수를 담는다.setCount
클로저:setCount
함수는useState
에 의해 생성된 환경을 기억하는 클로저다. 이 클로저는count
상태 변수에 접근할 수 있으며,handleIncrement
함수가 호출될 때마다count
의 최신 값을 참조하여 업데이트할 수 있다.상태 업데이트: 사용자가 "Increment" 버튼을 클릭하면
handleIncrement
함수가 실행된다. 이 함수 내부에서setCount
를 호출하여count
상태를 현재 값에서 1 증가시킨다. 여기서 중요한 점은setCount
가 실행될 때마다count
상태의 "최신" 값에 접근하고 이를 업데이트한다는 것이다. 이러한 동작은setCount
가 클로저이기 때문에 가능하다.
위 예시의 useState
훅을 통해 생성된 setCount
함수는 해당 컴포넌트의 count
상태에 대한 참조를 유지하며, 이를 통해 상태를 안전하게 업데이트할 수 있다. 이 클로저는 useState
가 호출될 때 생성된 특정 환경(컨텍스트)을 "기억"하고, 이 환경 내의 변수(여기서는 count
상태)를 필요할 때마다 접근하여 업데이트할 수 있도록 한다.
3.2 useEffect
와 클로저
(참고 : React: Closures in effects)
useEffect
훅은 리액트 컴포넌트의 상태나 props의 변경에 반응하여 부수 효과(side effects)를 실행하는 데 클로저의 개념을 활용한다. useEffect
를 사용할 때, 그 안에서 정의한 콜백함수는 자신이 정의된 시점의 컴포넌트 상태와 props에 접근할 수 있다. 이는 함수가 해당 변수들에 대한 클로저를 형성하기 때문이다.
컴포넌트가 재렌더링되어 useEffect
가 호출될 때마다 (의존성 배열에 나열된 요소들의 값이 변경될 때마다), 콜백 함수가 다시 생성되고 요소들의 최신 상태인 값을 "기억"한다. 클로저 덕분에 생성될 때의 상태 값을 "기억"하여, 실행될 때마다 현재 상태 값에 접근할 수 있는 것이다. 이를 통해 항상 state와 props의 최신 값을 사용할 수 있도록 보장한다.
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleIncrement = () => {
// 클로저를 사용하여 이전 상태를 기반으로 상태 업데이트
setCount((prev) => prev + 1);
};
useEffect(() => {
console.log(`현재 카운트는: ${count}입니다.`);
// 의존성 배열에 count를 추가하여 count 상태가 변경될 때마다 useEffect 실행
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
export default Counter;
최초 렌더링 시
useEffect
의 콜백 함수는 상태를 '기억'하는 클로저로 동작한다. 위 코드 예시에서 useEffect
의 콜백 함수는 최초 렌더링 때 클로저를 형성하여 자신이 정의될 당시의 환경의 count
상태를 닫아두어(closed) 자신이 정의될 당시 count
상태의 값(0
)에 접근할 수 있다.
이후 상태 변경으로 인한 재렌더링 시
의존성 배열의 요소로 count
를 포함시켜, count
상태가 변경될 때마다 useEffect
내의 콜백함수가 실행되도록 한다. 상태 변경으로 인한 재렌더링 시 콜백함수가 다시 생성되면서 자신이 정의된 당시의 count
상태값을 기억하는 새로운 클로저가 형성된다. 이를 통해 count
상태의 최신 값을 useEffect
가 "기억"하고 사용할 수 있도록 보장한다.
3.3 useMemo
와 클로저
(참고 : 책 모던 리액트 딥 다이브 3.1.3절 useMemo - p 208)
useMemo
는 복잡한 계산의 결과값을 메모이제이션해두고 이 값을 반환하는 훅이다. 이 훅은 의존성 배열에 명시된 값들이 변경되었을 때만 메모이제이션된 값을 다시 계산한다. 여기서 useMemo
의 콜백함수가 클로저를 형성하여 자신이 정의된 시점의 상위 스코프의 변수에 접근할 수 있게 해주며, 계산된 값은 해당 함수 스코프 내에서 '기억'된다.
import { useState, useMemo } from 'react';
function UserList({ users }) {
const [filter, setFilter] = useState('');
// 사용자 입력에 따라 사용자 목록을 필터링하고 알파벳 순으로 정렬하는 메모이제이션된 값
const filteredAndSortedUsers = useMemo(() => {
console.log('사용자 목록 필터링 및 정렬 중...');
return users.filter(user => user.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
}, [users, filter]);
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="사용자 검색..."
/>
<ul>
{filteredAndSortedUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
이 예제에서 filteredAndSortedUsers
를 계산하는 함수는 useMemo
훅 내부에 콜백함수로 정의되어 있다. 이 함수는 users
와 filter
라는 외부 스코프의 변수에 접근한다. 여기서 클로저가 중요한 역할을 한다:
- 클로저와 변수 접근:
useMemo
의 콜백 함수는 클로저를 형성하여, 함수가 정의된 시점의users
와filter
변수에 접근할 수 있다. 즉 콜백 함수가 형성한 클로저로 외부 스코프의 변수를 "기억"하고, 필요할 때마다 해당 값을 사용할 수 있다. - 성능 최적화:
useMemo
를 사용함으로써filter
값이나users
배열이 변경될 때만 사용자 목록을 다시 필터링하고 정렬한다. 입력 값이 변하지 않으면,useMemo
는 이전에 메모이제이션된 값을 재사용하여 불필요한 계산을 방지한다. 이는 특히 복잡한 계산이나 대규모 데이터셋 처리에 있어 성능에 큰 이점을 준다.
이 예시에서 볼 수 있듯이, useMemo
와 클로저를 활용하면 계산 비용이 큰 작업을 최적화하고, 리액트 컴포넌트의 성능을 향상시킬 수 있다. 클로저는 함수가 자신이 생성될 때의 환경에 있는 변수들에 계속해서 접근할 수 있게 해주며, useMemo
는 이러한 클로저의 특성과 함께 작동하여 메모이제이션을 효율적으로 수행한다.
3.4 useCallback
과 React.memo
그리고 클로저
- 참고
- 왜 useCallback, React.memo, useMemo를 사용할까?(리랜더링 줄이기 전략)
- React.memo() 현명하게 사용하기
- 책 : 모던 리액트 딥 다이브 - 2.4절 렌더링은 어떻게 일어나는가?
3.4.1 useCallback
useCallback
은 함수 자체를 메모이제이션하는 데 사용된다. 특히 이벤트 핸들러나 props로 자식 컴포넌트에 전달되는 함수에 유용하게 사용할 수 있다. useCallback
으로 생성된 함수는 의존성 배열의 값들이 변경될 때까지 동일한 함수 참조를 유지한다. 즉 클로저를 통해 함수가 생성될 당시의 환경을 '기억'하면서도, 불필요한 재생성을 방지하여 성능을 개선할 수 있다.
3.4.2 React.memo
React.memo
는 컴포넌트를 감싸 메모이제이션 된 컴포넌트를 만드는 React API이다. 컴포넌트가 React.memo()
로 래핑되면 React는 컴포넌트를 렌더링하고 그 결과를 기억해둔다. 그리고 이후 재렌더링이 일어날 때 컴포넌트의 props
가 변하지 않는다면 이전에 메모이제이션 해둔 컴포넌트를 그대로 재사용한다. (참고 : React.memo() 현명하게 사용하기)
3.4.3 리액트의 렌더링 프로세스
(참고 : react.dev/learn/render-and-commit)
리액트에서의 렌더링이란 리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미한다. 만약 컴포넌트가 props와 state와 같은 상태값을 가지고 있지 않다면 오직 해당 컴포넌트가 반환하는 JSX 값에 기반해 렌더링이 일어나게 된다. -모던 리액트 딥 다이브 p 172-
useCallback
과 React.memo
를 함께 사용하여 리액트 앱의 성능 최적화를 이루는 방법에 대해 알아보기 전에 리액트의 렌더링 프로세스에 대해 먼저 이해할 필요가 있다.
- 브라우저에서의 렌더링 : HTML과 CSS 리소스를 기반으로 웹 페이지에 필요한 UI를 그리는 과정
리액트에서의 렌더링 : 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정
리액트에서의 렌더링의 의미와 브라우저에서의 렌더링의 의미가 다르므로 이 둘을 구분해서 이해해야 한다. 리액트에서의 렌더링이란 초기 렌더링 또는 state나 props의 변경으로 촉발된 재렌더링 시 브라우저 DOM을 업데이트할 범위를 결정하고 반영하는 과정을 말한다. 리액트의 렌더링 프로세스Render 페이즈와 Commit 페이즈 두 단계로 구분된다.
1. Render Phase
- 초기 렌더링일 경우 : React는 Root Component를 호출하여 전체 엘리먼트들에 대한 가상돔을 만든다.
- 리렌더링일 경우 : React는 이전 렌더링 결과와 비교하여 변경이 필요한 함수 컴포넌트를 호출한다.
초기 렌더링 또는 state나 props의 변경으로 렌더링 프로세스가 트리거되면 렌더 단계가 시작된다. 렌더 단계에서는 컴포넌트를 실행하여 이 결과와 이전 가상돔을 비교하여 변경이 필요한 컴포넌트를 체크하는 단계이다. 브라우저의 DOM을 직접 업데이트 하기 전에 가상돔을 통해 업데이트를 미리 실행하여 변경이 필요한 부분만 변경하도록 하여 브라우저 DOM을 직접 조작하는 리플로우, 리페인트 비용을 줄이는 것이다.
이 때 비교하는 것은 크게 type, props, key
이 세가지로, 이 중 하나라도 변경될 경우 변경이 필요한 컴포넌트로 체크해 둔다. 만약 렌더 페이즈가 일어나 변경 사항을 계산했는데 아무런 변경 사항이 감지되지 않을 경우 커밋 단계는 생략되어 브라우저의 DOM 업데이트는 일어나지 않을 수 있다.
2. Commit Phase
- 초기 렌더링일 경우 : React는
appendChild()
DOM API를 사용하여 생성한 모든 DOM 노드를 화면에 표시한다. - 리렌더링일 경우 : React는 렌더 단계에서 계산했던 필요한 가장 최소한의 변경사항을 실제 DOM에 반영하여 DOM이 가장 최신의 렌더링 결과와 일치하도록 한다.
커밋 단계는 렌더 단계에서 감지한 변경 사항을 실제 DOM에 반영하는 과정이다. 만약 변경사항이 없다면 커밋 단계는 이루어지지 않을 수 있다. 커밋 단계 이후부터 브라우저 렌더링 단계가 시작된다.
아래 예제에서 useCallback
과 React.memo
를 사용하여 어떻게 렌더링 단계를 최적화할 수 있는지 알아보도록 하자.
(예제 코드와 설명은 왜 useCallback, React.memo, useMemo를 사용할까?(리랜더링 줄이기 전략) 영상의 코드를 참고하였음.)
import { useState, useCallback, useEffect } from 'react';
import ManyRendering from './ManyRendering';
function App() {
const [state, setState] = useState(0);
const memoizedCallback = useCallback(() => {
const heavyFunc = () => { /* ... */ };
return heavyFunc;
}, []);
useEffect(() => {
setTimeout(() => {
setState(1);
console.log('updated');
}, 1000);
}, []);
return (
<div>
<ManyRendering heavyFunc={memoizedCallback} />
</div>
);
}
export default App;
App
컴포넌트는 함수를 메모이제이션 해둔 memoizedCallback
을 ManyRendering
컴포넌트에게 heavyFunc
라는 이름의 props
로 내려주고 있다. 여기에서 useCallback
훅으로 래핑하여 props
로 전달하는 함수를 메모이제이션 해두었기 때문에 App
컴포넌트의 useEffect
훅이 실행되어 state 변경으로 리렌더링이 촉발되어 렌더 페이즈가 시작되더라도, props
는 변하지 않았기 때문에 커밋 단계는 생략될 것이다. 그럼 렌더 페이즈에 대한 최적화는 어떻게 하였을까?
import { memo } from 'react';
const ManyRendering = ({ heavyFunc }) => {
return (
<>
{Array.from({ length: 100 }, (_, i) => {
if (i === 99) console.log('rendering last item: ', i);
return (
<div key={i} onClick={heavyFunc}>
Hello, world!
</div>
);
})}
</>
);
};
export default memo(ManyRendering);
ManyRendering
컴포넌트는 React.memo
를 사용하여 메모이제이션 된 컴포넌트이다. 따라서 재렌더링이 일어나더라도, props
가 변하지 않았다면 ManyRendering
컴포넌트의 재렌더링은 일어나지 않는다. App
컴포넌트에서 이미 heayFunc
함수를 useCallback
으로 메모이제이션 하여 props
로 전달했기 때문에 props
도 변하지 않을 것이다. 따라서 렌더 페이즈를 유발하지 않는다.
마치며
클로저에 대한 개념과 자바스크립트와 리액트에서 클로저가 어떻게 활용되고 있는지 알아보았다. 더 깊게 다루지 못한 부분들에 대해 참고 리스트를 아래에 남겨두었다. 또한 리액트 19에서 메모이제이션 관련 훅들의 사용방식이 어떻게 변경되는지도 다시 확인해 보도록 해야겠다.
- 함께 보면 좋을 것들
- 리액트에서의 오래된 클로저(Stale Closure) 이슈 관련
- JS와 메모리 관리