async 키워드를 사용해 정의하고 언제나 프로미스를 반환한다. async function()은 await 키워드가 비동기 코드를 호출할 수 있게 해주는 함수이다. 명시적으로 프로미스를 반환하지 않아도 암묵적으로 반환값을 resolve하는 프로미스를 반환한다.
await 키워드
프로미스가 settled 상태(비동기 처리가 수행된 상태)가 될 때까지 대기하다가 settled 상태가 되면 프로미스가 resolve한 처리 결과를 반환한다. await 키워드는 반드시 async 함수 안에서, 프로미스 앞에서 사용해야 한다.
에러처리
try…catch 문을 사용할 수 있다. 콜백 함수를 인수로 전달받는 비동기 함수와는 달리 프로미스를 반환하는 비동기 함수는 명시적으로 호출할 수 있기 때문에 호출자가 명확하다. async 함수 내에서 catch 문을 사용해서 에러 처리를 하지 않으면 async 함수는 발생한 에러를 reject하는 프로미스를 반환한다. 따라서 async 함수를 호출하고 Promise.prototype.catch 후속 처리 메서드를 사용해 에러를 캐치할 수도 있다.
자바스크립트 엔진은 코드의 가장 위부터 아래로 순차적으로 코드를 실행하고, 엔진은 이 코드를 기계코드로 바꾸고 실행시키는 역할을 한다. 그리고 이 엔진은 Execution context stack, Heap으로 구성되어 있다.
실행 컨텍스트
렉시컬 환경: 식별자(변수, 함수, 클래스 등의 이름 등) 등록, 스코프 관리 실행 컨텍스트 stack: 코드 실행 순서 관리 렉시컬 환경을 제공하고, 실행 컨텍스트 stack으로 코드 실행 순서와 그 결과값을 관리하는 것을 실행 컨텍스트라 한다. 모든 코드는 실행 컨텍스트를 통해 실행되고 관리된다. LIFO: Last In First Out. 실행컨텍스트의 최상단에는 언제나 현재 실행 중인 코드의 실행 컨텍스트가 쌓이고, 다음 코드 실행시 코드의 실행 컨텍스트는 이 위에 쌓인다. 즉 언제나 최상단에는 가장 최신의 실행 컨텍스트가 존재하고, 컨텍스트 실행이 종료되면 가장 위에 있는 컨텍스트부터 stack에서 제거된다.
자바스크립트 엔진은 소스 코드를 평가와 실행의 단계로 나눈다.
평가(creation Phase): 런타임 이전. 실행 컨텍스트 생성, 변수, 함수 등의 선언문을 먼저 실행하여 실행컨텍스트의 스코프에 등록
실행(Execution Phase): 런타임. 선언문을 제외한 소스코드가 순차적으로 실행. 코드를 실행하면 필요한 정보(변수, 함수의 참조 등)을 실행 컨텍스트의 스코프에서 검색하여 취득. 취득한 후 식별자를 변경한 것과 같은 변경된 결과는 또 실행 컨텍스트의 스코프에 등록됨.
변수는 선언-초기화의 2단계를 거친다. 소스 코드의 평가단계에서 선언문을 먼저 실행하여 변수의 존재를 자바스크립트 엔진에게 알리고, 앞으로 값을 할당할 것을 대비하여 undefined로 초기화하고 메모리 공간을 확보해둔다. 이렇게 런타임 이전에 선언문에 대한 실행이 이루어지기 때문에 변수 선언 이전에 값을 참조해도 언제나 undefined 출력되는 변수 호이스팅이 발생한다. 소스 코드가 실행되는 시점에는 변수가 실행 컨텍스트의 스코프 상에 존재하는지 확인하고, 존재한다면 변수에 값을 할당하고, 이 결과를 실행 컨텍스트에 등록한다.
소스코드에 따라 각각의 실행 컨텍스트를 생성한다.
전역 코드: 전역에 존재하는 소스코드, 전역 스코프, 전역 실행 컨텍스트. 전역에 정의된 함수, 클래스 등의 내부 코드는 포함안함.
함수 코드: 함수 내부에 존재하는 소스코드, 지역 스코프, 함수 실행 컨텍스트. 함수 내부에 중첩된 함수, 클래스 등의 내부 코드는 포함하지 않고, 지역 변수, 매개변수, arguments 객체를 관리
eval 코드
모듈 코드
실행 컨텍스트가 하는 일
전역 코드 평가 - 실행
순차적으로 전역 코드를 실행하다가 함수가 호출되면 순서를 변경하여 함수 내부로 진입한다.
함수 코드 평가 - 실행
위의 평가-실행 과정에서 가장 중요한 키워드는 스코프를 기억하고 스코프 체인을 형성, 식별자를 등록하고 관리, 코드 실행 순서를 관리하는 것이다.
전역 실행 컨텍스트 생성
과정
함수 실행 컨텍스트 생성
과정
전역 객체 생성
코드 평가 이전에 생성됨.
함수 코드 제어권 확보
전역 코드를 실행하다가 함수호출을 만나면 제어권이 함수 내부로 이동
전역 코드 평가
소스코드 로드 후 전역 코드 평가
함수 코드 평가
전역 실행 컨텍스트 생성
실행 컨텍스트 stack에 push됨.
함수 실행 컨텍스트 생성
실행 컨텍스트 stack에 push됨.
전역 렉시컬 환경 생성
전역 렉시컬 환경과 전역 실행 컨텍스트의 렉시컬 환경을 바인딩.
함수 렉시컬 환경 생성
함수 렉시컬 환경과 함수 실행 컨텍스트 바인딩
전역 환경 레코드 생성
객체 환경 레코드(Object Environment Record): var 키워드로 선언한 전역 변수와 함수 선언문으로 정의한 전역 함수, 빌트인 전역 프로퍼티와 빌트인 전역 함수, 표준 빌트인 객체를 관리
함수 환경 레코드 생성
매개변수, arguments객체, 함수 내부에서 선언한 지역 번수 및 중첩 함수 등록 및 관리
선언적 환경 레코드(Declarative Environment Record): let, const 키워드로 선언한 전역 변수를 관리
객체 환경 레코드 생성
BindingObject라고 부르는 객체와 연결되어 전역 객체의 프로퍼티와 메서드가 됨. (var 키워드, 함수 선언문으로 정의한 전역함수의 경우에만)
var 키워드: 전역 객체에 변수 식별자 등록 후 undefined를 바인딩 -> 변수 호이스팅
함수 선언문: 전역 객체에 변수 식별자 등록 후 함수 객체를 할당 -> 함수 호이스팅
선언적 환경 레코드 생성
let, const 키워드로 선언한 전역 변수를 등록하고 관리. 전역 객체의 프로퍼티가 되지 않음.
this 바인딩
전역 환경 레코드, 함수 환경 레코드에만 존재. 전역 환경 레코드에 바인딩되어 있는 객체 반환.
this 바인딩
this는 함수 호출 방식에 따라 결정됨. (일반 함수로 호출: this = 전역객체)
외부 렉시컬 환경에 대한 참조 결정
현재 평가 중인 소스코드의 렉시컬 환경, 즉 상위 스코프를 통해 스코프 체인 구성. 전역 렉시컬 환경을 스코프 체인의 종점이므로 null이 할당됨.
함수와 함수가 선언된 렉시컬 환경의 조합 렉시컬 환경은 자신의 외부 렉시컬 환경에 대한 참조를 통해 상위 렉시컬 환경과 연결된다. 그리고 이는 함수 정의가 평가되는 시점에 함수가 정의된 위치에 따라 결정된다. 함수의 값이 반환되어 이미 완료된 외부 함수의 변수를 참조하는 함수 자신을 포함하고 있는 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 외부 함수 밖에서 중첩 함수를 호출하더라도 외부 함수의 지역 변수에 접근할 수 있는 함수
렉시컬 스코프
클로저를 이해하기 위한 핵심은 아래와 같다. 자바스크립트가 어떻게 변수의 유효범위를 지정하는가? 스코프에 대한 이해가 선행되어야 클로저에 대해 이해할 수 있다. 스코프는 식별자가 유효한 범위를 말한다.
동적 스코프: 함수 정의 시점에는 함수가 어디서 호출될지 알 수 없고, 함수가 호출되는 시점에 동적으로 상위스코프가 결정된다.
렉시컬 스코프(정적 스코프): 함수 호출 위치와 상관없이 함수 정의 시점에 상위 스코프가 정적으로 결정된다.
자바스크립트는 실행 컨텍스트의 렉시컬 환경(코드가 실행된 곳, 그리고 그 주변의 환경)을 단방향으로 연결한 스코프 체인을 따라 변수를 참조하는 코드의 스코프에서 상위 스코프로 이동하며 변수를 검색한다. 여기서 중요한 점은 상위로 이동하기 때문에 상위 스코프에서 유효한 변수를 하위에서 참조할 수 있지만 그 반대는 불가하다는 것이다.
이를 정리해서 말하자면 자바스크립트는 함수 호출이 아닌 함수 정의 시점에 스코프 체인을 따라 상위 스코프로 이동하면서 변수가 선언된 곳을 찾아서 참조한다.
1 2 3 4 5 6 7 8 9 10 11 12 13
const x = 1
functionouterFunc() { const x = 10 innerFunc() }
functioninnerFunc() { console.log(x) // 1 }
outerFunc() innerFunc()
위 함수의 실행 결과는 innerFunc()의 상위 스코프가 무엇이냐에 따라 다른 결과를 갖는다.
함수가 호출된 곳 기준(동적 스코프): 이 기준으로 하면 innerFunc()의 상위 스코프는 outerFunc함수의 지역 스코프, 그리고 전역 스코프이다.
함수가 정의된 곳 기준(정적 스코프): 이 기준에 따르면 innerFunc(), outerFunc() 모두 전역에서 정의되었고, 전역에서 선언된 함수는 전역 코드 실행 이전에 먼저 평가되어 함수 객체를 생성하고, 자신이 정의된 스코프인 전역 스코프를 상위 스코프로 사용한다. 따라서 이 경우 상위 스코프는 전역 스코프이다.
자바스크립트는 정적 스코프를 따르기 때문에 이 경우 console.log에 찍히는 값은 1이다. 왜냐하면 함수가 어디서 호출되었느냐와 상관없이 자신이 정의된 전역 스코프를 상위 스코프로 여기기 때문이다.
하지만 아래와 같은 경우 innerFunc()의 결과값은 1이 아닌 10이다. 그 이유는 innerFunc()가 전역에 선언된 것이 아니라 outerFunc()라는 외부함수 내에 선언된 중첩함수이기 때문이다.
전역 변수는 전역 스코프를 가지고, 어디서나 참조할 수 있지만 함수와 같은 지역 변수는 함수 몸체 내부를 지역 스코프로 가지고, 자신이 선언된 지역과 하위 지역인 중첩함수에서만 참조할 수 있다. 자바스크립트는 스코프 체인을 따라 자신이 정의된 곳에서 상위로 이동하며 변수를 검색하는데 innerFunc()는 중첩함수이므로 이 함수의 상위 스코프는 전역이 아닌 innerFunc()를 감싸고 있는 outerFunc()가 된다. 즉, 이와 같이 전역에 선언된 것이 아닌 중첩함수는 계층적 구조를 가지기 때문에 내부 함수는 외부 함수의 값에 접근할 수 있어서 10이라는 값이 출력된다.
1 2 3 4 5 6 7 8 9 10 11 12 13
const x = 1
functionouterFunc() { const x = 10
functioninnerFunc() { console.log(x) // 10 }
innerFunc() }
outerFunc()
함수의 호출과 렉시컬 스코프
자바스크립트는 함수가 정의된 곳을 기준으로 상위 스코프를 결정하는데, 정의된 환경과 호출된 환경을 다를 수 있다. 따라서 반드시 함수는 자신이 정의된 환경, 즉 상위 스코프의 참조를 저장한다. 이를 통해 함수의 호출이 어디에서 이루어지든 이와 상관없이 자신이 기억하고 있는 상위 스코프의 식별자를 참조하고, 식별자에 바인딩된 값을 변경한다.
실행 컨텍스트: 자바스크립트는 싱글스레드 언어이므로 한 번에 하나의 실행 컨텍스트만 실행한다.
전역 코드: global 실행 컨텍스트에서 실행
함수 코드: 함수 실행 컨텍스트에서 실행
실행 컨텍스트의 흐름
global 실행 컨텍스트 생성
전역 코드가 평가되는 시점에 함수 객체를 생성
함수 객체의 내부 슬롯 [[Environment]]에 전역 코드 평가 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경에 대한 참조를 저장
함수 호출
함수 실행 컨텍스트 생성
함수 렉시컬 환경 생성
Environment Record
this 바인딩
OuterLexicalEnvironment Reference OuterLexicalEnvironment Reference에 렉시컬 환경에 대한 참조가 할당. 즉 함수의 상위스코프에 대한 참조를 저장.
클로저와 렉시컬 환경
1 2 3 4 5 6 7 8 9 10 11 12 13
functionmakeFunc() { var name = 'Mozilla' functiondisplayName() { alert(name) } return displayName }
var myFunc = makeFunc() //myFunc변수에 displayName을 리턴함 //유효범위의 어휘적 환경을 유지 myFunc() //리턴된 displayName 함수를 실행(name 변수에 접근)
위 함수에서 makeFunc()가 실행되어 displayName을 리턴하게 되면, 함수의 호출이 종료되어 생명 주기가 다하였기 때문에 더이상 makeFunc()함수 내부의 지역변수에 접근할 수 없을 것으로 생각된다. 그러나 클로저는 이와 같은 생명 주기가 끝난 외부의 변수도 참조할 수 있다. 외부함수인 makeFunc()가 종료되었으나 myFunc에 makeFunc()를 할당하였기 때문에 myFunc()를 호출하면 displayName을 리턴하고, 이미 함수의 실행이 끝나 접근할 수 없을 것이라 생각했던 name 변수의 값이 return 된다.
이처럼 중첩함수가 외부함수보다 더 오래 유지되는 경우 외부 함수 밖에서 중첩 함수를 호출하더라도 외부 함수의 지역 변수에 접근할 수 있고, 이를 클로저라 한다. 단, 중첩 함수가 외부 함수보다 더 오래 유지되는데, 상위 스코프의 어떠한 식별자도 참조하지 않는 경우는 클로저가 아니다.
클로저의 작동 원리
앞서 클로저는 함수와 함수가 선언된 렉시컬 환경의 조합이라고 하였기 때문에 이 점을 다시 생각해보면 클로저의 작동원리가 명확해진다.
다시 말해 함수가 선언(정의)된 위치의 스코프와 이 시점에 정적으로 결정된 상위 스코프(렉시컬 스코프)의 렉시컬 환경을 클로저라 한다.
makeFunc(): 함수 선언문. 런타임 이전에 먼저 평가되어 함수 객체 생성. 이때 자신이 정의된 스코프인 전역 스코프를 렉시컬 환경에 기록한다.
name: 전역변수. 런타임 이전에 평가되어 자신의 상위 스코프인 makeFunc()를 렉시컬 환경에 기록한다.
displayName(): 함수 선언문. 런타임 이전에 평가되어 자신의 상위스코프인 name 변수와 외부함수 makeFunc()를 렉시컬 환경에 기록한다.
myFunc: 자신의 상위 스코프인 makeFunc()를 렉시컬 환경에 기록한다.
각각의 코드들은 실행될 때 실행 컨텍스트에서 실행(전역 - 전역실행컨텍스트, 함수 - 함수 실행컨텍스트)되고, 코드 실행을 위해 실행 컨텍스트를 생성할 때 렉시컬 환경을 생성한다. 그래야 함수 내부에 있는 변수들을 저장할 수 있기 때문이다. 이 렉시컬 환경은 Environment Record, OuterLexicalEnvironment Reference, this 바인딩으로 구성되어 있는데, OuterLexicalEnvironment Reference에 렉시컬 환경에 대한 참조가 할당된다. 즉 함수의 상위스코프에 대한 참조를 저장하고 있다.
이렇게 렉시컬 환경에 각각의 함수, 변수들에 대한 정보가 저장되어 있는 상태에서 코드를 위에서 부터 아래로 한 줄씩 실행한다. 가장 상단에 있는 makeFunc()는 displayName을 리턴하면서 생명 주기가 끝이 난다. 생명 주기가 끝난다는 것은 실행 컨텍스트 스텍에서 pop된다는 의미이다. 하지만 makeFunc()의 렉시컬 환경은 여전히 남아있다. myFunc가 makeFunc()를 상위 스코프로 기억하고 있기 때문이다. makeFunc()의 중첩함수인 displayName() 역시 상위 스코프인 name 변수를 기억하고 있다. 이렇게 참조되고 있는 경우 가비지 컬렉션의 대상이 되지 않으므로 그대로 참조값이 유지된다.
그리고 myFunc가 makeFunc()를 호출하면 displayName을 리턴하고, myFunc()를 호출하는 시점에는 displayName()이 실행되고, displayName은 name 변수에 접근하여 alert(name)이 성공적으로 실행된다.
const [state, setState] = useState(initialState); -> 상태 유지값과 그 값을 갱신하는 함수 반환 첫 번째 렌더링: state(state) = initialState의 값 -> initialState는 첫 번째 렌더링에만 사용되고, 그 이후로 무시된다. setState(newState); -> state를 갱신할 때 사용 -> 새 state의 값을 받아서 리렌더링 큐에 등록 두 번째 렌더링(리렌더링): setState()로 갱신된 state 값
//함수 컴포넌트에서 state의 사용 import React, { useState } from'react';
functionExample() { //함수 컴포넌트는 this를 가질 수 없으므로 useState hook을 직접 호춡 // 새로운 state 변수를 선언하고, 이것을 count라 부른다. const [count, setCount] = useState(0);
useState(): useState의 호출은 state 변수 선언을 의미. 함수가 종료되면 사라지는 일반 변수와 달리 React에 의해 사라지지 않는 특징이 있다. (class 컴포넌트의 this.state와 동일한 기능)
useState(인자):
class 컴포넌트: <button onClick={() => this.setState({ count: this.state.count + 1 })}> 이와 같이 객체로 인자를 넘겨주어야했다. 그러나 함수 컴포넌트에서는 객체 뿐 아니라 숫자, 문자도 넘겨줄 수 있다.
함수형 컴포넌트: state의 초기값. 첫 번째 렌더링 시 딱 한 번만 사용된다.다음 렌더링을 하는 동안 useState는 현재 state를 준다.
useState의 반환값: state 변수, 해당 변수를 갱신할 수 있는 함수의 두 가지 쌍.
state 가져오기
class 컴포넌트: this.state.count로 state를 가져온다.
함수형 컴포넌트: count를 직접 사용한다.
state 갱신
class 컴포넌트: this.setState() 호출.
함수형 컴포넌트: this 호출이 필요 없음.
1 2 3 4 5 6 7 8 9 10
//class 컴포넌트 <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> //함수형 컴포넌트 //이미 setCount, count 변수를 가지고 있다. <button onClick={() => setCount(count + 1)}> Click me </button>
useState의 변수 선언
const [count, setCount] = useState(0);
배열 디스트럭처링을 이용하여 state 변수, 해당 변수를 갱신할 수 있는 함수로 구성된 두 가지 쌍을 가진 배열을 반환하도록 정의한다. 이 배열의 첫번째 요소는 초기값, 두 번째 요소는 초기값으로 설정된 변수를 갱신해주는 함수이다. 아래와 같은 구조로 fake useState함수를 구성해보고, useState 함수에 인자 0을 전달하여 useState(0)와 같이 호출하면 [0,ƒ]의 값이 반환된다. 각각을 살펴보면, 배열의 첫번 째 요소인 0은 초기값이고, 두 번째 요소인 ƒ는 첫 번째 요소에 전달된 초기값을 전달받아서 새로운 값으로 갱신해 주는 함수이다. 이와 같은 useState hook은 컴포넌트 내에서 여러 개 선언할 수 있고, 개별적으로 갱신할 수 있다. 단, 렌더링 될 때 useState가 사용된 순서대로 실행된다.
1️⃣Reset count: 클릭했을 때 0으로 설정되어야 하므로 setCount(newValue)의 일반적인 형식 사용
2️⃣/3️⃣: setCount(newValue)를 했을 때 newValue는 갱신되기 이전의 값을 바탕으로 - 또는 + 되어야 함. 즉 setCount((갱신되기 이전 값) => 갱신되기 이전 값 -1 또는 +1 )와 같이 함수로 갱신되기 이전 값을 넘겨준 후 그 값에서 - 또는 +를 하여 newValue로 갱신한다. 이때 만약 이와 같은 함수가 이전 값을 받아서 +나 -를 수행했는데도 현재의 값과 동일한 값을 반환한다면 리렌더링이 이루어지지 않는다.
class의 setState는 자동으로 객체를 합쳐서 갱신하는데, useState는 그렇지 않다. 대신 스프레드 문법을 사용(e.g. return {...prevState, ...updatedValues};)하여 class와 같은 동작을 수행할 수 있다.
useEffect(didUpdate); 함수 컴포넌트에서 side effects를 수행할 수 있게 함. 명령형 또는 어떤 effect를 발생하는 함수를 인자로 받음. -> 화면이 렌더링 된 후 수행 기본적으로 useEffect는 렌더링 이후 수행되지만 dependency array에 특정 값을 추가하여 이 값이 변경되었을 때만 실행되게 만들 수 있다.
side effects: 데이터 가져오기, 구독(subscription) 설정하기, 수동으로 리액트 컴포넌트의 DOM을 직접 조작하고 수정하는 것 등의 모든 기능들.
정리가 필요하지 않은 effects: DOM을 업데이트한 뒤 추가로 코드를 실행해야 하는 경우(e.g. 네트워크 리퀘스트, DOM 수동 조작, 로깅 등은 정리(clean-up) 등)
정리가 필요한 effects
class의 componentDidMount, componentDidUpdate, componentWillUnmount와 같은 목적으로 제공되지만, 하나의 API로 통합된 것과 같음.
정리가 필요하지 않은 effects
리액트가 DOM을 업데이트한 뒤 추가로 코드를 실행해야 하는 경우. (e.g. 네트워크 리퀘스트, DOM 수동 조작, 로깅 등)
class 컴포넌트
render()는 side effect를 발생시키지 않음. 이펙트는 DOM을 업데이트하고 난 이후 발생. 따라서 side effect를 componentDidMount와 componentDidUpdate 둠. 그래서 class 안에서 두 개의 생명주기 메서드에 같은 코드가 중복되기도 한다. 그 이유는 componentDidMount인지 componentDidUpdate와 상관없이 렌더링 이후에 항상 같은 코드가 수행되어야 하기 때문이지만 class 컴포넌트에서는 이러한 기능을 하는 메서드를 지원하지 않으므로 아래 코드와 같이 각각의 생명주기 메서드 안에서 함수를 호출해야 한다..
useEffect hook은 리액트에게 컴포넌트가 렌더링 된 후 어떤 일을 수행해야하는지 알려준다. effect를 기억해두었다가 DOM 업데이트 수행 후 불러낸다. useEffect는 컴포넌트 안에서 호출되기 때문에 아래 예제와 같이 count 변수(또는 그 어떤 prop)에도 접근할 수 있다. 함수 범위 안에 존재하기 때문이다.
class 컴포넌트에서는 렌더링 이후 어떤 일을 수행할 것인지에 대해 componentDidMount와 componentDidUpdate로 각각 정의해주었다. 렌더링 이전에 마운트 되었을 때와 업데이트 될 때의 상황에 대해 정의했기 때문에 componentDidMount는 마운트 될 때만 수행되고, componentDidUpdate는 업데이트 될 때만 수행된다. useEffect는 effect 안에 렌더링 이후 이루어질 작업에 대한 내용을 정의하는 방식이므로 componentDidMount와 componentDidUpdate를 effect 함수 내부에서 한 번에 처리한다. 따라서 첫 번째 렌더링과 이후 업데이트가 발생할 때마다 수행된다. 두 가지 경우 모두 effect가 수행되는 시점은 이미 DOM이 업데이트 된 시점인 렌더링 이후이다.
위의 예제에서 처럼 class 컴포넌트, 함수형 컴포넌트에서의 effect 사용에 대해서 아래와 같이 각각 console.log를 찍어보면 렌더링 이후 발생하는 일에 대해 더욱 명확히 알 수 있다.
사용자가 버튼을 클릭하여 업데이트 되는 시점: componentDidMount의 ‘componentDidMount’는 더이상 출력되지 않고, componentDidUpdate의 ‘componentDidUpdate’가 버튼에 클릭 이벤트가 발생할 때마다 console창에 출력된다. useEffect의 ‘useEffect’는 마운트 시점과 동일하게 계속 console창에 출력된다.
componentDidMount,componentDidUpdate,componentWillUnmount로 렌더링 이후 이루어질 작업을 각각의 함수로 따로 정의
componentDidMount는 마운트 될 때, componentDidUpdate는 업데이트 될 때,componentWillUnmount는 언마운트될 때만 수행
DOM 업데이트 이후
componentWillUnmount를 사용하여 정리
마운팅, 업데이트 방식으로 effect 수행
함수형
useEffect를 컴포넌트 안에서 불러서 렌더링 이후 이루어질 작업에 대해 정의
렌더링 이후 매번 수행(첫번째 렌더링 이후 업데이트 될 때마다)
DOM 업데이트 이후
useEffect() => {내부에서 return() => {언마운트 될 때 정리해야할 함수 반환}}
렌더링 이후 effect 발생. 렌더링 시점에 이미 DOM은 업데이트 되어있음.(componentDidMount,componentDidUpdate가 동시에 실행되는 것과 유사). clean-up 함수를 사용할 경우 이전 effect는 다음 effect 실행 전에 정리됨.
정리가 필요한 effects
외부 데이터에 구독(subscription)을 설정해야 하는 경우와 같이 메모리 누수가 발생하지 않도록 정리(clean-up)가 필요한 경우
함수 컴포넌트 위의 class 컴포넌트에서 componentDidMount(), componentWillUnmount()로 분리된 생명주기 메서드 내에 동일한 effect 관련 코드가 있다. 이처럼 subscribe와 unsubscribe는 일반적으로 밀접한 관련을 맺고 있기 때문에 함수형 컴포넌트에서 useEffect는 이 두 가지 설정을 class에서 처럼 분리하지 않고 함께 다룰 수 있도록 한다. useEffect 내에서 함수를 반환하면 그 함수를 정리가 필요할 때 실행시키는 것이다.
useEffect(() => { functionhandleStatusChange(status) { setIsOnline(status.isOnline); } ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); // effect 이후에 어떻게 정리(clean-up)할 것인지 표시 // cleanup이라는 이름 대신 다른 변수, 또는 화살표 함수를 사용하여도 무방함. returnfunctioncleanup() { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; });
리액트가 정리하는 시점은 언제? 컴포넌트가 마운트해제될 때. 하지만 effect는 렌더링이 실행될 때마다 실행되므로 다음 차례의 effect를 실행하기 전에 이전의 렌더링에서 파생된 effect도 정리한다.
effect 사용 팁
Multiple Effect 서로 관련이 없는 로직을 분리하여 관련 있는 로직끼리 묶어서 결합할 수 있다. 이렇게 하면 코드가 훨씬 간결해지고, 코드가 하는 일이 무엇인지에 따라 관련있는 것들을 묶어서 로직을 구성하므로 가독성이 좋아진다. 주의할 점은 여러 effect를 사용할 경우, 지정된 순서에 맞춰 적용한다는 것이다.
class 컴포넌트 class 컴포넌트에서는 effect 정리가 componentWillUnmount()를 사용하여 마운트가 해제될 때만 실행된다. 그런데 class 컴포넌트에서 componentDidUpdate를 제대로 수행하지 않을 경우 componentWillUnmount()가 제대로 수행되지 않는 버그가 발생한다. 위의 FriendStatusWithCounter 예시에서 만약 componentDidUpdate()가 없었다고 가정한다면 마운트 이후 업데이트가 발생했을 때 이에 대한 처리를 할 로직이 없는 채 마운트 해제 시 subscribe가 해제된다. friend prop에 변화가 없다면 별 문제 없을 수 있지만 만약 friend prop이 화면에 표시되어 있는 동안 변한다면 friend의 subscribe를 해지하지 못하고 계속해서 화면에 표시하게 된다. 마운트 해제가 일어나더라도 unsubscribe 정확한 타겟 아이디에 수행하지 못하고 잘못된 id에 대해 수행하게 될 수도 있다. 결론적으로 이러한 버그를 발생시키지 않으려면 반드시 componentDidUpdate()를 사용해야 한다는 것이다.
함수형 컴포넌트의 useEffect useEffect는 class에서처럼 생명주기 메서드에 의해 업데이트가 발생하는 것이 아니라 렌더링 될 때마다 실행된다. 즉 componentDidUpdate()와 같은 별도의 메서드를 사용하지 않아도 렌더링 될 때마다 업데이트가 실행되고, componentWillUnmount()를 사용하지 않아도 return에 함수를 반환하면 다음의 effect를 적용하기 이전의 effect는 정리된다.
렌더링 이후 effect를 정리하는 것은 때때로 성능 저하를 발생시키는 경우가 있다. 이를 개선하려면?
class 컴포넌트: componentDidUpdate에서 prevProps나 prevState와의 비교를 통해 문제 해결
1 2 3 4 5 6
componentDidUpdate(prevProps, prevState) { // prevProps와 prevState가 같지 않을 때는 다른 값이므로 업데이트 if (prevState.count !== this.state.count) { document.title = `You clicked ${this.state.count} times`; } }
함수형 컴포넌트: dependency array useEffect의 두 번째 인수에 배열을 넘겨서 특정 값이 변경되지 않을 경우 건너뛰도록 설정한다. 아래와 같이 사용한다면 count의 값에 변화가 있을 때만 effect가 실행되고, 그렇지 않을 경우 건너뛴다. 단 두 번째 인수의 배열은 컴포넌트 범위 내에서 바뀌는 값들과 effect에 의해 사용되는 값들을 모두 포함한다. 즉 useEffect 내부에서 의존성을 가지고 있는 값들은 모두 이 배열에 포함되어야 하므로 이를 의존성 배열(dependency array)라 한다.
1 2 3
useEffect(() => { document.title = `You clicked ${count} times`; }, [count]); // count가 바뀔 때만 effect를 재실행
[] : 배열에 아무것도 넘기지 않으면 마운트 될 때 단 한 번만 실행된다. 업데이트 시에 호출되지 않는다. React에게 props나 state에서 가져온 어떤 값에도 의존하지 않으므로 effect를 다시 실행할 필요가 없다는 것을 알려주기 때문이다. 이렇게 하면 props와 state는 항상 초기값을 가진다.
[value]: 배열 안에 특정 값이 있으면 이 값이 설정되거나 바뀔 때마다 effect가 재실행된다. useEffect 내부에서 사용하는 state, props, 함수 등이 있다면 반드시 이 배열 안에 넣어줘야 한다. 민약 의존성을 지닌 값들이 있는데 이 deps 안에 넣는 것을 생략하는 경우 useEffect 에 등록한 함수가 실행 될 때 최신 props 또는 상태를 가르키지 않게 된다.
리액트는 코드 재사용에 대한 논의가 많았다. 컴포지션 -> 컴포넌트의 합성을 통한 재사용 희망
클래스 컴포넌트가 가진 단점이 많았다. 보일러 플레이트 너무 많기 때문이다.
라이프 사이클 세부적으로 관리해야하는 것이 많았다.
컴포넌트 재사용도 쉽지 않았다.
컴포넌트 사이에서 상태와 관련된 로직을 재사용하기 어려움 class 컴포넌트가 주류로 사용되던 시기에 React 컴포넌트를 재사용하기 쉬울 것이라고 생각했으나 하나의 컴포넌트 안에 UI와 기능과 관련된 로직들이 함께 섞여있으니 재사용이 어려웠다. 그래서 Presentational 컴포넌트와 Container 컴포넌트패턴을 사용하였는데 간략히 설명하자면 Presentational 컴포넌트에는 UI관련 로직을 담고, Container 컴포넌트에서는 기능과 관련된 로직을 담는 것이다. 그런데 Container 컴포넌트는 Presentational 컴포넌트와 달리 기본적인 UI 뿐 아니라 state나 effect 또는 특정 기능을 위한 로직 등을 가지고 있기 때문에 이 패턴 역시 컴포넌트의 재사용이 어렵다.
그래서 render props, HOC를 통해 컴포넌트를 재구성하여 재사용을 쉽게 하려고 했지만 개발자 도구를 열면 래퍼 지옥(wrapper hell)이 발생하는 문제가 있었다. 이는 코드의 depth를 깊어지게 할 뿐 아니라 render props, HOC 등의 여러가지 로직이 여기저기 사용되면서 코드가 복잡해지고 추적이 어려워진다. 이렇게 되면 테스트와 재사용이 어려워진다.
복잡하고 중복되는 로직 아래의 class 컴포넌트에서는 데이터를 가져오기, subscibe/unsubscribe를 수행, state 관리 등 다양한 로직이 life cycle 메서드 내에 흩어져 있다. 게다가 같은 로직을 각각의 생명주기 메서드에서 중복하여 사용하기도 한다. 이렇게 복잡하고 중복되는 로직이 많은 컴포넌트는 이해하기 어렵고, 가독성이 떨어지는 복잡한 코드를 만들어낸다.
class의 this가 의미하는 것은? javascript의 this는 다른 언어가 의미하는 this와 다르기 때문에 혼란을 주고, class 컴포넌트 내부에서 this를 바인딩 할 때 그 this가 무엇을 가르키는 지에 대해 실수할 가능성이 높다. 그리고 이 this를 바인딩하기 위해 코드 곳곳에 this 키워드를 사용해야한다. 이러한 class의 문제가 props, state, 그리고 top-down 데이터 흐름을 읽기 어렵게 한다.
결론
이처럼 class 컴포넌트가 가지고 있는 문제점을 해결하면서 더욱 간결하고 명료한 React 사용을 위해 hook이 나오게 된다. useEffect, useState, custum hook 등의 hook은 컴포넌트 내에 class 없이 React의 기능을 사용할 수 있도록 한다.
hook이란?
함수 컴포넌트에서 React state와 생명주기 기능(lifecycle features)을 연동(hook into)할 수 있게 해주는 함수
hook 사용 규칙
최상위(at the top level)에서만 Hook을 호출해야 함. 반복문, 조건문, 중첩된 함수 내에서 Hook 실행 금지 최상위에서 호출해야 컴포넌트가 렌더링 될 때마다 동일한 순서로 hook이 호출되는 것이 보장됨.
일반 JavaScript 함수 내에서 호출 금지. 오직 React 함수 컴포넌트 내에서 또는 custom hook 내에서만 Hook을 호출.
hook을 사용할 때는 반드시 위의 2가지 규칙을 지켜야 한다. 그 이유는 React가 hook이 호출되는 순서에 의존하기 때문이다.
functionForm() { //useState1️⃣ // 1. name이라는 state 변수를 사용 const [name, setName] = useState('Mary')
//useEffect1️⃣ // 2. Effect를 사용해 폼 데이터를 저장 useEffect(functionpersistForm() { localStorage.setItem('formData', name) })
//useState2️⃣ // 3. surname이라는 state 변수를 사용 const [surname, setSurname] = useState('Poppins')
//useEffect2️⃣ // 4. Effect를 사용해서 제목을 업데이트 useEffect(functionupdateTitle() { document.title = name + ' ' + surname })
// ... }
위와 같이 한 컴포넌트 안에서 여러 개의 useState와 useEffect를 사용할 때, React는 hook이 호출되는 순서에 따라서 실행된다. 따라서 Form 컴포넌트가 실행되면 useState1️⃣(1) -> useEffect1️⃣(2) -> useState2️⃣(3) -> useEffect2️⃣(4)의 순서로 차례대로 실행되며 동작한다. 하지만 만약 조건문을 사용하여 그 내부에서 useEffect1️⃣을 아래와 같이 사용하면 아래와 같이 에러가 발생한다.
React Hook “useEffect” is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks
이와 같이 조건문 안에서 useEffect를 쓰면 첫 번째 렌더링에서 name은 Mary이기 때문에 if에 선언된 조건이 true가 되고, hook이 동작한다. 그러나 그 다음 렌더링에서는 사용자가 form을 제출한 후 초기화 되기 때문에 if의 조건이 name === ""가 되므로 false가 된다. 따라서 if 조건문 안에 있는 useEffect hook을 건너뛰게 된다. 이렇게 되면 useState1️⃣(1) -> useEffect1️⃣(2) -> useState2️⃣(3) -> useEffect2️⃣(4)의 순서대로 hook이 실행되지 않는다. 따라서 useState1️⃣(1) -> skip -> useState2️⃣(2) -> useEffect2️⃣(3)으로 순서가 엉키게 된다. React는 hook이 호출되는 순서에 따라서 실행되므로 이렇게 순서대로 호출되지 않는 경우 에러가 발생하는 것이다.
DOM은 HTML이 파싱되어 브라우저가 이해할 수 있는 자료구조인 DOM을 생성하는 것을 말한다. 브라우저의 렌더링 트리 생성과정은 다음과 같다.
HTML 마크업을 처리하고 DOM 트리를 빌드
CSS 마크업을 처리하고 CSSOM 트리를 빌드
DOM 및 CSSOM을 결합하여 렌더링 트리를 형성
렌더링 트리에서 레이아웃(또는 리플로우)을 실행
각 노드의 기하학적 형태를 계산한다. (각 객체의 정확한 위치와 크기 계산)
이 과정을 거치면 각 노드들은 스크린의 좌표와 위치가 주어진다.
개별 노드를 화면에 페인트
픽셀을 화면에 렌더링
렌더링 된 요소들에 색을 입히며 스크린에 원하는 정보를 나타낸다.
DOM에 변화가 있을 때마다 위와 같은 과정을 반복하는데, DOM 조작이 자주, 많이 발생하면 렌더링 시간이 오래 걸리게 되고, 불필요한 연산이 많아진다.
virtual DOM (VDOM)
UI의 이상적인 또는 “가상”적인 표현을 메모리에 저장하고 ReactDOM과 같은 라이브러리에 의해 “실제” DOM과 동기화하는 프로그래밍 개념이다. 즉 실제로 DOM을 제어하는 방식이 아니라 중간에 가상의 DOM인 Virtual DOM을 두어 개발의 편의성(DOM을 직접 제어하지 않음)과 성능(배치 처리로 DOM 변경)을 개선하는 방식이다.
React Reconciliation
React는 엘리먼트라는 React 앱의 가장 작은 단위로 화면에 표시할 내용을 기술한다. 이는 ReactDOM.render()를 통해 화면에 렌더링된다. 이 엘리먼트들은 React DOM에서 트리 형태로 관리되고 표현되는데, 이때 엘리먼트 트리의 변경 전/후를 비교하여 변화가 있는 부분만 찾아서 업데이트 한다. 이처럼 React의 state나 props에 변화가 있을 React DOM에서 변경전후를 비교하여 VDOM을 실제 DOM과 동기화하는 과정을 React Reconciliation이라 한다.
결론
사용자가 브라우저에 어떠한 액션을 취하거나, 개발자가 DOM에 접근하여 조작하는 경우 매번 렌더링 트리를 생성하여 리플로우와 리페인트를 거쳐 DOM에 변경사항을 적용하는 과정을 반복하게 된다. 이는 변화가 발생했을 때마다 이를 DOM에 적용하기 위해 새로운 레이아웃을 계산해야하는 불필요한 연산이 초래되고, 매번 렌더링 트리를 만들어 렌더링이 이루어지는 리렌더링 과정을 거쳐야 하므로 비효율적이다. 그래서 매번 리플로우와 리렌더링 과정을 거치지 않도록, 변경된 사항의 전후를 비교하여 변경된 부분에 대해서만 업데이트를 해주는 개념이 React의 렌더링 개념이며, 이 업데이트가 발생하는 React의 DOM을 virtual DOM이라 한다. 그리고 virtual DOM에서 이루어진 업데이트를 실제 DOM과 동기화하는 과정을 React Reconciliation이라 한다.
자바스크립트는 비동기 처리를 위한 하나의 패턴으로 콜백 함수를 사용한다. 하지만 전통적인 콜백 패턴은 콜백 헬로 인해 가독성이 나쁘고 비동기 처리 중 발생한 에러의 처리가 곤란하며 여러 개의 비동기 처리를 한번에 처리하는 데도 한계가 있다. ES6에서는 비동기 처리를 위한 또 다른 패턴으로 프로미스(Promise)를 도입했다. 프로미스는 전통적인 콜백 패턴이 가진 단점을 보완하며 비동기 처리 시점을 명확하게 표현할 수 있다는 장점이 있다.
프로미스의 생성
Promise 생성자 함수를 new 연산자와 함께 호출하면 프로미스(Promise 객체: 비동기 처리 상태와 처리 결과를 관리)를 생성한다. Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패, 그 결과 값을 나타낸다. 또한 비동기 액션이 종료된 이후, 성공했을 때의 value나 실패 이유를 처리하기 위한 handler를 연결할 수 있도록 한다. 이처럼 프로미스를 사용하면 비동기 메서드에서도 동기 메서드처럼 최종 value를 반환할 수 있다. 다만 즉시 최종 value를 반환하지는 않고, 비동기 메서드가 프로미스를 반환하면 프로미스가 미래의 어떤 시점에 받을 value를 제공한다.
1 2 3 4 5 6 7 8 9
// 프로미스 생성 const promise = newPromise((resolve, reject) => { // Promise 함수의 콜백 함수 내부에서 비동기 처리를 수행한다. if (/* 비동기 처리 성공 */) { resolve('result'); } else { /* 비동기 처리 실패 */ reject('failure reason'); } });
프로미스의 상태
프로미스가 생성된 후 기본적으로 pending 상태를 가지고, 비동기 처리 수행의 성공 또는 실패에 따라 아래와 같이 상태가 변경된다. 그리고 필연적으로 아래 상태에 따라 resolved 되거나 unresolved된다.
[사진출처 - MDN]
대기(pending): 비동기 처리가 이행하거나 거부되지 않은 초기 상태.
이행(fulfilled): 비동기 처리가 성공적으로 완료됨.
거부(rejected): 비동기 처리가 실패함.
settled: 비동기 처리가 수행되었고, pending이 아니면서 fulfilled 또는 rejected일 때를 말한다. settled는 상태가 아니다. 표현의 편의를 위한 언어적 표현일 뿐이다. 일단 settled 상태가 되면 더는 다른 상태로 변화할 수 없다.
states
meaning
value
condition
fates
fulfilled
비동기 처리가 수행된 상태 (성공)
처리결과값
resolve 호출
resolved
rejected
비동기 처리가 수행된 상태 (실패)
에러
reject 호출
resolved
pending
비동기 처리 수행 전, fulfilled도 아니고, rejected도 아닌 상태
undefined
프로미스 생성직후 기본 상태
unresolved or resolved
프로미스의 후속 처리 메서드
프로미스가 fulfilled 상태이거나 rejected 상태 일 때 이에 대한 후속 처리가 필요하다. 이처럼 프로미스의 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수를 선택적으로 호출하고, 모든 후속 처리 메서드는 프로미스를 반환하며 비동기로 동작한다.
Promise.prototype.then
언제나 Promise를 return하고, 두 개의 콜백 함수를 인수로 전달받는다. 그리고 Promise가 이행하거나 거부했을 때, 각각에 해당하는 핸들러 함수(onFulfilled나 onRejected)가 비동기적으로 실행된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
p.then(onFulfilled, onRejected);
p.then( function (value) { // 이행 // onFulfilled: Promise가 수행될 때 호출되는 Function // 인수: 이행 값(fulfillment value) }, function (reason) { // 거부 // onRejected: Promise가 거부될 때 호출되는 Function // 인수: 거부 이유(rejection reason) } );
반환값
① fulfilled 상태: 비동기 처리 성공(이행). 프로미스의 비동기 처리 결과를 콜백함수의 인수로 받음.
후속처리 메서드인 then, catch, finally는 언제나 프로미스를 반환하므로 연속적으로 호출할 수 있다. 이를 프로미스 체이닝이라 한다. 만약 후속 처리 메서드의 콜백 함수가 프로미스가 아닌 값을 반환하더라도 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성하여 반환한다.
// #fruits 요소의 하위 요소인 li 요소를 클릭한 경우 캡처링 단계의 이벤트를 캐치한다. // 그러나 이벤트 핸들러 어트리뷰트/프로퍼티 방식으로 등록하였기 때문에 캡처링 단계를 캡처하지 못한다. $fruits.onclick = (e) => { console.log(`이벤트 단계: ${e.eventPhase}`); // 3: 버블링 단계 console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement] console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement] console.log(event.composedPath()); };
addEventListener 메서드 방식으로 등록하면 타깃 단계, 버블링 단계, 캡처링 단계를 선별적으로 캐치할 수 있다. 캡처링 단계의 이벤트를 캐치하려면 addEventListener 메서드의 3번째 인수로 true를 전달한다. 3번째 인수를 생략하거나 false를 전달하면 타깃 단계와 버블링 단계의 이벤트만 캐치할 수 있다.
1️⃣click: 버블링 단계의 이벤트를 캐치한다. event.bubbles = true;
2️⃣mouseleave: 이 이벤트는 버블링을 통해 전파되지 않으므로 버블링 단계의 이벤트를 캐치하지 못한다. event.bubbles = false;
3️⃣mouseover: mouseleave를 mouseover로 변경하면 버블링 단계의 이벤트를 캐치할 수 있다. event.bubbles = true;
이벤트 위임
이벤트는 전파되므로 이벤트 타깃은 물론 상위 DOM에서도 캐치할 수 있다. 이 점을 이용하여 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 대신 하나의 상위 DOM 요소에 이벤트 핸들러를 등록하는 방법이다. 이렇게 하면 동적으로 하위 DOM 요소를 추가하더라도 여기에 이벤트 핸들러를 일일이 등록할 필요가 없다.
// 모든 내비게이션 아이템(li 요소)에 이벤트 핸들러를 등록한다. document.getElementById("apple").onclick = activate; document.getElementById("banana").onclick = activate; document.getElementById("orange").onclick = activate; </script> </body> </html>
만약 이벤트 위임을 하지 않으면 위와 같이 이벤트 핸들러 등록이 필요한 모든 요소에 일일이 이벤트 핸들러를 등록해주어야 한다. 이는 메모리 누수와 같은 성능 저하의 원인이 되고, 유지보수에도 적합하지 않다. 이벤트 위임의 장점은 같은 코드를 이벤트 위임으로 변경한 아래 예제에서 확인할 수 있다.
/* 상위 요소에 이벤트 핸들러를 등록할 때, 이벤트 타깃이 내가 기대한 DOM요소가 아닐 수도 있기 때문에 matches로 해당하는 이벤트 타깃이 있는지 확인한다. matches: 인수로 전달된 선택자에 의해 특정 노드가 탐색 가능한지 확인한다. */ if (!target.matches('#fruits > li')) return;
// 이벤트 위임: 상위 요소(ul#fruits)는 하위 요소의 이벤트를 캐치할 수 있다. /* 이벤트 객체의 currentTarget: 언제나 $fruits 요소 target 프로퍼티: 실제로 이벤트를 발생시킨 DOM 요소 두 가지가 서로 다른 DOM 요소를 가리킬 수도 있으므로 확실하게 하기 위해 $fruits에 이벤트를 바인딩한다. */ $fruits.onclick = activate; </script> </body> </html>
비동기 함수 내부에서 비동기로 동작하는 코드가 있다면 코드가 완료되지 않았더라도 기다리지 않고 즉시 종료된다. 즉, 비동기 함수 내부의 비동기로 동작하는 코드는 비동기 함수가 종료된 후에 완료된다.따라서 비동기 함수 내부의 비동기로 동작하는 코드는 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당해도 기대한 대로 동작하지 않는다.
서버의 응답 결과를 콘솔에 출력하는 get 함수 ✅
get 함수: 비동기 함수 (비동기로 동작하는 코드인 이벤트 핸들러 onload를 포함하고 있음.)
onload: 비동기로 동작
처리 순서: get 함수 호출 -> GET 요청을 서버에 전송 -> onload 이벤트 핸들러 등록 ->undefined반환 -> 즉시 종료
// id가 1인 post를 취득 get("https://jsonplaceholder.typicode.com/posts/1");
서버의 응답 결과를 반환하는 get 함수 ✅
get 함수: 비동기 함수 (비동기로 동작하는 코드인 이벤트 핸들러 onload를 포함하고 있음.)
onload: 비동기로 동작
처리순서: get 함수 호출 -> GET 요청을 서버에 전송 -> onload 이벤트 핸들러 등록 -> 종료 -> undefined반환
반환문인 return JSON.parse(xhr.response);은 onload 이벤트 핸들러의 반환문이지 get 함수의 반환문이 아니다. 따라서 get 함수에 대한 명시적인 반환문이 없으므로 undefined를 반환한다. 즉 onload 이벤트 핸들러의 반환값은 캐치할 수 없다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// 서버의 응답 결과를 반환하는 코드 ✅ // GET 요청을 위한 비동기 함수 constget = (url) => { const xhr = new XMLHttpRequest(); xhr.open("GET", url); xhr.send();
xhr.onload = () => { if (xhr.status === 200) { // ① 서버의 응답을 반환한다. returnJSON.parse(xhr.response); } console.error(`${xhr.status}${xhr.statusText}`); }; };
// ② id가 1인 post를 취득 const response = get("https://jsonplaceholder.typicode.com/posts/1"); console.log(response); // undefined
만약 get 함수의 상위에 변수를 선언한 후 onload 이벤트 내부에서 서버의 응답 결과를 할당하더라도 여전히 결과는 undefined이다. 그 이유는 처리 순서가 보장되지 않기 때문이다.
// GET 요청을 위한 비동기 함수 constget = (url) => { const xhr = new XMLHttpRequest(); xhr.open("GET", url); xhr.send();
xhr.onload = () => { if (xhr.status === 200) { // ① 서버의 응답을 상위 스코프의 변수에 할당한다.✅ todos = JSON.parse(xhr.response); } else { console.error(`${xhr.status}${xhr.statusText}`); } }; };
// id가 1인 post를 취득 get("https://jsonplaceholder.typicode.com/posts/1"); console.log(todos); // ② undefined
위와 같이 get 함수의 상위에 전역 변수가 있고, onload 이벤트 내에서 서버의 응답 결과를 할당한 경우 처리과정은 아래와 같다.
get 함수 호출-> get 함수 평가 및 실행 컨텍스트 생성 -> 콜 스택에 push -> 코드 실행 -> xhr.onload에 이벤트 핸들러 바인딩 -> get 함수 종료 -> get 함수 콜 스텍에서 pop -> ②console.log 호출 및 실행 -> console.log의 실행 컨텍스트 생성 -> 콜 스택에 push -> 서버로부터 응답 도착 -> load 이벤트 발생 ->
xhr.onload의 이벤트 핸들러는 즉시 실행되지 않는다. load 이벤트 발생 시 태스크 큐에서 대기하가다 콜 스텍이 비었을 때 콜 스텍으로 push 되어 실행된다. 즉 console.log가 종료된 후에야 실행되므로 예상했던 서버의 응답결과가 console.log에 출력되지 않고, undefined가 호출된다.
xhr.onload에 이벤트 핸들러 task Queue에 push -> 콜 스텍에 있는 모든 실행 컨텍스트 pop됨 -> 이벤트 루프 -> 콜 스텍에 push-> 이벤트 핸들러 실행
위와 같이 비동기 함수는 세 가지 문제가 있다.
비동기 처리 결과를 외부에 반환할 수 없다.
상위 스코프의 변수에 할당할 수 없다.
서버로부터 데이터를 받아오기 전에 데이터를 화면에 표시하려고 하면 오류가 발생
따라서 서버에 대한 응답을 처리하는 비동기 함수의 처리 결과는 비동기 함수 내부에서 수행해야 하고, 이를 위해 비동기 함수에 콜백 함수를 전달해서 처리한다. 그러나 콜백 패턴도 비동기 함수 처리 결과 -> 비동기 함수 호출과 같은 패턴이 반복된다면 콜백 함수가 중첩되어 복잡해지는 다음과 같은 문제점을 가지고 있다.
콜백 헬
에러 처리의 한계
콜백 헬
비동기 처리를 위해 콜백 함수를 연달아 사용할 경우 콜백 헬이 발생하여 가독성을 떨어뜨리고, 유지 보수를 어렵게 한다. 다음과 같이 서버로부터 응답받은 데이터를 활용하여 연속으로 get 요청을 보낼 경우 콜백 헬이 발생한다.
try 구문에서 콜백함수가 에러를 발생 시키는데 이 에러는 catch 블록에서 캐치되지 않는다. 에러는 호출자 방향으로 전파되는데, setTimeout 함수의 콜백함수를 호출한 것은 setTimeout이 아니기 때문에 이 함수의 콜백함수가 발생시킨 에러는 캐치되지 않는다.