제너레이터란?
제너레이터는 ES6에서 도입된 특수한 형태의 함수로, 코드 블록의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 독특한 기능을 제공한다. 일반적인 함수와는 달리, 제너레이터는 함수의 실행 흐름을 세밀하게 제어할 수 있는 다양한 특성을 가지고 있다.
함수 실행 제어권 양도
제너레이터 함수의 가장 큰 특징은 함수의 실행 제어권을 함수 외부로 양도할 수 있다는 점이다. 일반 함수는 한번 실행이 시작되면 종료될 때까지 제어권이 함수 내부에 있으며, 함수 호출자는 실행에 개입할 수 없다. 반면 제너레이터 함수는 실행 중간에 제어권을 함수 호출자에게 양도할 수 있어, 함수 호출자가 실행을 일시 중지하거나 재개하는 것이 가능하다. 이러한 특성은 'yield' 키워드를 통해 구현되며, 이는 함수의 실행 흐름을 더욱 유연하게 만든다.
양방향 상태 교환
또한 제너레이터는 함수 호출자와 양방향으로 상태를 주고받을 수 있는 특별한 기능을 가지고 있다. 일반 함수는 매개변수를 통해 입력을 받고 return 문을 통해 결과를 반환하는 단방향 통신만 가능하다. 게다가 함수가 실행되는 동안에는 외부에서 함수의 상태를 변경하거나 새로운 값을 전달하는 것이 불가능하다. 하지만 제너레이터는 실행 도중에도 함수 호출자와 상태를 주고받을 수 있어, 함수의 실행 과정에 동적으로 개입할 수 있다.
제너레이터 객체 반환
제너레이터 함수의 또 다른 중요한 특징은 호출 시 반환되는 값의 형태다. 일반 함수를 호출하면 함수 코드가 실행되고 결과값이 즉시 반환되지만, 제너레이터 함수를 호출하면 함수 코드를 실행하는 대신 제너레이터 객체가 반환된다. 이 제너레이터 객체는 이터러블이면서 동시에 이터레이터인 특별한 객체로, 이를 통해 함수의 실행을 단계적으로 제어할 수 있다.
제너레이터 함수의 정의
제너레이터 함수는 일반 함수와 구문적으로 구별되는 특별한 선언 방식을 가지고 있다. function 키워드 뒤에 애스터리스크(*)를 붙여 선언하며, 함수 내부에는 반드시 하나 이상의 yield 표현식이 포함되어야 한다. 이러한 기본적인 구조를 바탕으로 제너레이터 함수는 다양한 방식으로 정의될 수 있다.
제너레이터 함수를 정의하는 방법은 크게 네 가지가 있다.
// 제너레이터 함수 선언문
function* genDecFunc() {
yield 1;
}
// 제너레이터 함수 표현식
const genExpFunc = function* () {
yield 1;
};
// 제너레이터 메서드
const obj = {
* genObjMethod() {
yield 1;
}
};
// 제너레이터 클래스 메서드
class MyClass {
* genClsMethod() {
yield 1;
}
}
여기서 애스터리스크(*)의 위치는 function 키워드와 함수 이름 사이라면 어디에 위치해도 문법적으로 유효하다. 하지만 코드의 일관성을 유지하기 위해서는 function 키워드 바로 뒤에 붙이는 것이 권장된다. 이는 가독성을 높이고 유지보수를 용이하게 만드는 중요한 컨벤션이다.
제너레이터 함수를 정의할 때 주의해야 할 중요한 제약사항들이 있다.
- 화살표 함수로 정의할 수 없다.
- new 연산자와 함께 생성자 함수로 호출할 수 없다.
이러한 제약은 제너레이터의 특수한 동작 방식 때문에 존재한다.
// 화살표 함수로 정의 시도 (에러 발생)
const genArrowFunc = * () => {
yield 1;
}; // SyntaxError: Unexpected token '*'
// new 연산자와 함께 사용 시도 (에러 발생)
function* genFunc() {
yield 1;
}
new genFunc(); // TypeError: genFunc is not a constructor
이러한 제약사항들은 제너레이터의 고유한 특성을 유지하고, 잘못된 사용을 방지하기 위해 설계된 것이다.
제너레이터 객체
제너레이터 함수를 호출하면 일반적인 함수 실행과는 다르게 동작한다. 함수 코드를 실행하는 대신 제너레이터 객체를 생성하여 반환하는데, 이 객체는 이터러블이면서 동시에 이터레이터라는 특별한 성질을 가진다. 이는 제너레이터 객체가 Symbol.iterator 메서드를 상속받는 이터러블이면서, value와 done 프로퍼티를 갖는 이터레이터 리절트 객체를 반환하는 next 메서드를 보유한 이터레이터임을 의미한다.
다음 코드는 제너레이터 객체의 기본적인 특성을 보여준다.
function* genFunc() {
yield 1;
yield 2;
yield 3;
}
// 제너레이터 객체 생성
const generator = genFunc();
// 제너레이터 객체가 이터러블임을 확인
console.log(Symbol.iterator in generator); // true
// 제너레이터 객체가 이터레이터임을 확인
console.log('next' in generator); // true
제너레이터 객체는 일반적인 이터레이터보다 더 많은 기능을 제공한다.
next 메서드 외에도 return과 throw 메서드를 추가로 가지고 있으며, 각각의 메서드는 다음과 같이 동작한다.
- next 메서드
- 제너레이터 함수의 yield 표현식까지 코드 블록을 실행한다.
- yield된 값을 value 프로퍼티 값으로, false를 done 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환한다.
- 이를 통해 함수의 실행을 단계적으로 제어할 수 있다.
- return 메서드
- 인수로 전달받은 값을 value 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환한다.
- 이는 제너레이터 함수의 실행을 즉시 종료하고 전달받은 값을 최종 결과로 반환하는 역할을 한다.
- throw 메서드
- 인수로 전달받은 에러를 발생시킨다.
- undefined를 value 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환한다.
- 이는 제너레이터 내부에서 에러 처리를 가능하게 만든다.
다음 아래 예제는 이러한 메서드들의 실제 동작을 보여준다.
function* genFunc() {
try {
yield 1;
yield 2;
yield 3;
} catch (e) {
console.error(e);
}
}
const generator = genFunc();
console.log(generator.next()); // {value: 1, done: false}
console.log(generator.return('End!')); // {value: "End!", done: true}
console.log(generator.throw('Error!')); // {value: undefined, done: true}
이러한 특징들로 인해 제너레이터는 비동기 처리, 반복 처리 등 다양한 용도로 활용될 수 있다.
제너레이터의 일시 중지와 재개
제너레이터는 yield 키워드와 next 메서드를 통해 함수의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 독특한 기능을 제공한다. 일반 함수는 호출 이후 제어권을 함수가 독점하지만, 제너레이터는 함수 호출자에게 제어권을 양도하여 필요한 시점에 함수 실행을 재개할 수 있다는 점이 큰 차이점이다.
제너레이터 함수를 호출하면 코드 블록이 실행되는 것이 아니라 제너레이터 객체를 반환한다. 이 제너레이터 객체는 이터러블이면서 동시에 이터레이터인 특별한 객체로, next 메서드를 갖는다. 제너레이터 객체의 next 메서드를 호출하면 제너레이터 함수의 코드 블록을 실행하는데, 일반 함수처럼 모든 코드를 한 번에 실행하는 것이 아니라 yield 표현식까지만 실행한다.
yield 키워드는 두 가지 중요한 역할을 한다.
- 제너레이터 함수의 실행을 일시 중지시킨다.
- yield 키워드 뒤에 오는 표현식의 평가 결과를 제너레이터 함수 호출자에게 반환한다.
다음 예제를 통해 제너레이터의 실행 과정을 자세히 살펴보자.
function* genFunc() {
console.log('첫 번째 호출');
yield 1;
console.log('두 번째 호출');
yield 2;
console.log('세 번째 호출');
yield 3;
console.log('네 번째 호출');
return 'end';
}
const generator = genFunc();
// 제너레이터 객체의 next 메서드를 호출하면서 실행을 단계별로 제어한다
console.log(generator.next());
// 첫 번째 호출
// {value: 1, done: false}
console.log(generator.next());
// 두 번째 호출
// {value: 2, done: false}
console.log(generator.next());
// 세 번째 호출
// {value: 3, done: false}
console.log(generator.next());
// 네 번째 호출
// {value: "end", done: true}
위 예제의 실행 과정을 단계별로 상세히 살펴보면 제너레이터의 동작 방식을 더 명확하게 이해할 수 있다. 각각의 next 메서드 호출은 다음과 같은 과정을 거친다.
- 최초 next 호출 시에는 첫 번째 yield 표현식까지의 코드가 실행된다. 콘솔에 '첫 번째 호출'이 출력되고, yield 1에서 실행이 중단된다. 이때 value 프로퍼티에는 yield된 값인 1이 할당되고, 아직 함수가 완전히 종료된 것이 아니므로 done 프로퍼티는 false가 된다.
- 두 번째 next 호출에서는 중단되었던 지점부터 실행을 재개하여 두 번째 yield까지 진행된다. '두 번째 호출'이 출력되고 yield 2에서 다시 중단된다. 마찬가지로 value에는 2가, done에는 false가 할당된다.
- 세 번째 next 호출도 동일한 패턴으로 진행되어 '세 번째 호출'이 출력되고 value는 3, done은 false가 된다.
- 마지막 next 호출에서는 더 이상의 yield 표현식이 없으므로 함수가 return문까지 실행된다. '네 번째 호출'이 출력되고, value에는 반환값인 'end'가 할당되며, 함수가 완전히 종료되었으므로 done은 true가 된다.
제너레이터는 이러한 기본적인 동작 외에도 next 메서드에 인수를 전달할 수 있는 추가적인 기능을 제공한다. 이 기능은 제너레이터 함수와 호출자 사이의 양방향 통신을 가능하게 만든다.
next 메서드에 인수를 전달하는 예제를 통해 더 자세히 살펴보자.
function* genFunc() {
// 첫 번째 next 호출 시에는 여기까지 실행된다
const x = yield 1;
// 두 번째 next 호출 시 전달된 인수가 x에 할당된다
const y = yield (x + 10);
// 세 번째 next 호출 시 전달된 인수가 y에 할당된다
return x + y; // 최종 반환값
}
const generator = genFunc();
let result = generator.next();
console.log(result); // {value: 1, done: false}
result = generator.next(10); // x에 10이 할당된다
console.log(result); // {value: 20, done: false}
result = generator.next(20); // y에 20이 할당된다
console.log(result); // {value: 30, done: true}
여기서 주목할 점은 yield 표현식의 실행 순서와 값의 할당 시점이다. yield는 두 가지 역할을 순차적으로 수행한다.
- yield 키워드 뒤의 값(이 경우 1)을 반환하고 함수를 중지한다.
- 다음 next 호출 시 전달된 인수를 yield 표현식의 결과값으로 반환한다.
따라서 const x = yield 1; 이라는 한 줄의 코드는 실제로는 두 번의 next 호출에 걸쳐 실행된다. 첫 번째 next()에서는 1을 반환하고 함수가 중지되며, x에 대한 값 할당은 이루어지지 않는다. 두 번째 next(10)가 호출될 때 비로소 10이 x에 할당된다.
즉, 이 예제의 실행 과정을 조금 더 자세히 분석해보면 제너레이터의 고급 기능을 이해할 수 있다.
- 첫 번째 next() 호출 시에는 제너레이터 함수가 첫 번째 yield 표현식까지만 실행된다. 이때 x 변수는 아직 값이 할당되지 않은 상태로 남는다. yield 1의 결과로 {value: 1, done: false}가 반환된다.
- 두 번째 next(10) 호출에서는 인수로 전달된 10이 첫 번째 yield의 반환값이 되어 x에 할당된다. 그리고 실행이 재개되어 두 번째 yield까지 진행된다. 이때 x + 10, 즉 20이 yield되어 {value: 20, done: false}가 반환된다.
- 세 번째 next(20) 호출에서는 전달된 20이 두 번째 yield의 반환값이 되어 y에 할당된다. 이후 return문이 실행되어 x + y, 즉 30이 반환값이 되고 함수가 종료되므로 {value: 30, done: true}가 반환된다.
이처럼 제너레이터는 next 메서드와 yield 표현식을 통해 함수 호출자와 함수의 상태를 주고받을 수 있다. 이는 비동기 처리를 동기 처리처럼 구현할 수 있게 해주는 중요한 특성이다.
제너레이터의 활용
이터러블의 구현
제너레이터는 이터레이션 프로토콜을 구현할 때 그 진가를 발휘한다. 이터러블을 구현할 때 일반적인 방식을 사용하면 Symbol.iterator 메서드와 next 메서드를 직접 구현해야 하지만, 제너레이터를 사용하면 yield 키워드 하나로 간단하게 이터러블을 구현할 수 있다.
예를 들어, 무한한 피보나치 수열을 생성하는 이터러블을 구현한다고 가정해보자. 먼저 일반적인 방식으로 구현하면 다음과 같이 복잡한 코드가 된다.
const infiniteFibonacci = (function () {
let [pre, cur] = [0, 1];
return {
[Symbol.iterator]() {
return this; // 이터러블이면서 이터레이터인 객체를 반환
},
next() {
[pre, cur] = [cur, pre + cur];
// 무한 수열이므로 done 프로퍼티는 생략
return { value: cur };
}
};
}());
// 이터러블 사용 예시
for (const num of infiniteFibonacci) {
if (num > 10000) break;
console.log(num); // 1, 2, 3, 5, 8, ..., 6765
}
이 코드에서는 이터러블 프로토콜을 준수하기 위해 Symbol.iterator 메서드를 구현하고, 이터레이터 프로토콜을 준수하기 위해 next 메서드를 구현해야 한다. 또한 클로저를 사용하여 상태를 유지해야 하므로 코드가 복잡해진다.
반면 제너레이터를 사용하면 다음과 같이 훨씬 간단하고 직관적으로 구현할 수 있다.
const infiniteFibonacci = (function* () {
let [pre, cur] = [0, 1];
while (true) {
[pre, cur] = [cur, pre + cur];
yield cur;
}
}());
// 사용 방법은 동일
for (const num of infiniteFibonacci) {
if (num > 10000) break;
console.log(num); // 1, 2, 3, 5, 8, ..., 6765
}
제너레이터를 사용한 구현이 더 간단한 이유는 다음과 같다.
- 제너레이터 객체는 이미 이터러블이면서 이터레이터이므로 Symbol.iterator 메서드를 직접 구현할 필요가 없다.
- yield 키워드를 사용하면 next 메서드를 직접 구현하지 않아도 이터레이터 프로토콜을 준수하는 객체를 생성할 수 있다.
- 함수의 실행을 일시 중지하고 재개하는 기능이 자동으로 제공되므로 상태 관리가 훨씬 간단해진다.
- 무한 수열과 같은 값을 표현할 때 while(true) 루프와 yield를 조합하여 명확하게 표현할 수 있다.
비동기 처리
제너레이터는 비동기 처리를 동기 처리처럼 구현할 수 있게 해주는 강력한 기능을 제공한다. 제너레이터의 next 메서드와 yield 표현식을 통해 함수 호출자와 함수의 상태를 주고받을 수 있는 특성을 활용하면, 프로미스의 후속 처리 메서드인 then/catch/finally 없이도 비동기 처리를 동기 처리처럼 구현할 수 있다.
다음은 제너레이터를 사용하여 비동기 처리를 동기 처리처럼 구현한 예제다.
// 제너레이터 실행기
const async = generatorFunc => {
const generator = generatorFunc(); // 제너레이터 객체 생성
const onResolved = arg => {
const result = generator.next(arg); // 제너레이터 함수 실행
return result.done
? result.value // 제너레이터 함수 실행 완료
: result.value.then(res => onResolved(res)); // 재귀 호출
};
return onResolved;
};
// 제너레이터 함수
(async(function* fetchTodo() {
const url = 'https://jsonplaceholder.typicode.com/todos/1';
const response = yield fetch(url);
const todo = yield response.json();
console.log(todo);
// {userId: 1, id: 1, title: "delectus aut autem", completed: false}
})());
이 예제의 실행 과정을 단계별로 자세히 살펴보자.
- async 함수가 호출되면, 전달받은 제너레이터 함수 fetchTodo를 실행하여 제너레이터 객체를 생성한다. 이 객체는 상위 스코프의 generator 변수에 저장되며, onResolved 함수를 통해 접근할 수 있다.
- onResolved 함수가 처음 호출되면 generator.next()를 실행하여 fetchTodo 함수의 첫 번째 yield 문까지 코드를 실행한다. 이때 fetch(url)이 반환한 프로미스가 result.value에 할당된다.
- result.done이 false이므로, result.value(프로미스)의 then 메서드를 호출하여 프로미스가 resolve된 Response 객체를 onResolved 함수에 전달하면서 재귀적으로 호출한다.
- 두 번째 onResolved 함수 호출에서는 Response 객체를 next 메서드에 인수로 전달한다. 이 값이 첫 번째 yield 문의 결과값으로 response 변수에 할당되고, 두 번째 yield 문까지 실행된다.
- 같은 방식으로 response.json()이 반환한 프로미스가 resolve된 todo 객체를 세 번째 onResolved 함수 호출의 인수로 전달한다.
- 마지막으로 todo 객체가 제너레이터 함수의 todo 변수에 할당되고 console.log로 출력된다. 이후 더 이상의 yield 문이 없으므로 제너레이터 함수가 종료된다.
이러한 방식으로 비동기 처리를 동기 처리처럼 구현할 수 있지만, 실제 개발에서는 다음과 같은 이유로 제너레이터보다 async/await를 사용하는 것이 권장된다.
- 코드 가독성: async/await는 더 직관적이고 이해하기 쉬운 문법을 제공한다.
- 에러 처리: async/await는 try/catch 문을 사용하여 동기 코드와 동일한 방식으로 에러를 처리할 수 있다.
- 더 낮은 복잡도: 제너레이터 실행기와 같은 추가적인 코드 없이도 비동기 처리를 구현할 수 있다.
만약 제너레이터를 사용해야 하는 특별한 상황이라면, 직접 제너레이터 실행기를 구현하기보다는 co 라이브러리와 같은 검증된 라이브러리를 사용하는 것이 안전하고 효율적이다. co 라이브러리는 제너레이터를 사용한 비동기 처리를 더욱 쉽고 안정적으로 구현할 수 있게 해준다.
async/await
ES8(ECMAScript 2017)에서는 제너레이터의 복잡성을 개선하여 비동기 처리를 더욱 간단하고 직관적으로 구현할 수 있는 async/await가 도입되었다. async/await는 프로미스를 기반으로 동작하는 새로운 비동기 처리 패턴으로, 기존 프로미스의 후속 처리 메서드들인 then/catch/finally를 체이닝하는 방식에서 벗어나 마치 동기 코드처럼 프로미스를 다룰 수 있게 해준다.
async/await를 사용하면 비동기 처리의 순서가 코드의 작성 순서와 동일하게 되어 코드의 흐름을 이해하기 쉽고, 디버깅도 용이해진다.
// 프로미스 체이닝 방식
function fetchTodo() {
const url = 'https://jsonplaceholder.typicode.com/todos/1';
fetch(url)
.then(response => response.json())
.then(todo => console.log(todo))
.catch(error => console.error(error));
}
// async/await 방식
async function fetchTodo() {
const url = 'https://jsonplaceholder.typicode.com/todos/1';
const response = await fetch(url);
const todo = await response.json();
console.log(todo);
}
위의 예제는 기존 프로미스 방식과 async/await 방식의 차이를 보여준다.
async 함수
async 함수는 async 키워드를 사용하여 정의되며, 항상 프로미스를 반환하는 특별한 함수다. 함수가 정상적으로 완료되면 반환값을 resolve하는 프로미스를 반환하고, 함수 실행 중 에러가 발생하면 해당 에러를 reject하는 프로미스를 반환한다. 이는 명시적으로 프로미스를 반환하지 않는 경우에도 마찬가지다.
async function foo() {
return 'success'; // Promise.resolve('success')와 동일
}
async function bar() {
throw new Error('error'); // Promise.reject(new Error('error'))와 동일
}
foo().then(console.log); // 'success' 출력
bar().catch(console.error); // Error: error 출력
함수가 명시적으로 프로미스를 반환하지 않더라도 async 함수는 암묵적으로 반환값을 resolve하는 프로미스로 감싸서 반환한다. 이는 async 함수의 반환값을 항상 then 메서드로 처리할 수 있음을 의미한다.
async 함수는 다양한 방식으로 정의할 수 있는데, 모든 정의 방식에서 일관되게 프로미스를 반환하는 특성을 유지한다. 함수 선언문, 함수 표현식, 화살표 함수, 메서드, 클래스 메서드 등 자바스크립트의 모든 함수 정의 방식에서 async를 사용할 수 있다.
// 함수 선언문으로 정의한 async 함수
async function foo(n) {
return n; // 반환값 n은 Promise.resolve(n)으로 래핑된다
}
// 함수 표현식으로 정의한 async 함수
const bar = async function(n) {
const result = await someAsyncOperation();
return result;
};
// 화살표 함수로 정의한 async 함수
const baz = async n => {
const result = await someAsyncOperation();
return n + result;
};
// 메서드로 정의한 async 함수
const obj = {
async foo(n) {
const result = await someAsyncOperation();
return n + result;
}
};
// 클래스 메서드로 정의한 async 함수
class MyClass {
async bar(n) {
const result = await someAsyncOperation();
return n + result;
}
}
여기서 주목할 점은 클래스의 constructor 메서드는 async 함수가 될 수 없다는 것이다. constructor는 인스턴스를 생성하고 초기화하기 위한 특수한 메서드로, 반드시 인스턴스를 반환해야 한다.
class MyClass {
async constructor() { } // SyntaxError: Class constructor may not be an async method
}
하지만 async 함수는 언제나 프로미스를 반환하므로, 이는 constructor의 기본 동작과 충돌한다.
await 키워드
await 키워드는 프로미스가 settled 상태(비동기 처리가 완료된 상태)가 될 때까지 코드의 실행을 일시 중지하고, 프로미스가 settle된 후에 그 결과값을 반환하는 특별한 연산자다. 이 키워드는 반드시 async 함수 내부에서만 사용할 수 있으며, 프로미스가 아닌 값을 대상으로 사용하면 그 값을 그대로 반환한다.
await의 동작 방식을 이벤트 루프 관점에서 살펴보면 더 깊이 있는 이해가 가능하다. await를 만나면 다음과 같은 과정이 발생한다.
- await 표현식이 평가되어 프로미스를 반환한다.
- 이 프로미스가 settled 상태가 될 때까지 현재 실행 중인 async 함수의 실행이 일시 중지된다.
- 함수의 실행 컨텍스트가 콜 스택에서 제거되고, 이벤트 루프는 다른 태스크들을 처리할 수 있게 된다.
- 프로미스가 settled 상태가 되면 이벤트 루프는 중단된 async 함수의 실행을 재개한다.
이러한 메커니즘 덕분에 await는 비동기 작업을 기다리는 동안 다른 코드의 실행을 차단하지 않는다.
다음 예제를 통해 await 키워드의 동작 방식을 자세히 살펴보자.
const getGithubUserName = async id => {
// fetch 함수는 프로미스를 반환한다
const res = await fetch(`https://api.github.com/users/${id}`);
// response.json() 메서드도 프로미스를 반환한다
const { name } = await res.json();
console.log(name);
};
getGithubUserName('user123');
이 코드의 실행 과정을 단계별로 살펴보면 다음과 같다.
- fetch 함수가 HTTP 요청을 보내고 프로미스를 반환한다.
- await 키워드는 이 프로미스가 settled 상태가 될 때까지 함수의 실행을 일시 중지한다.
- 서버로부터 응답이 도착하여 프로미스가 settled 상태가 되면, 프로미스가 resolve한 Response 객체가 res 변수에 할당된다.
- response.json() 메서드가 호출되어 역시 프로미스를 반환한다.
- 두 번째 await는 이 프로미스가 settled 상태가 될 때까지 대기한다.
- JSON 파싱이 완료되어 프로미스가 settled 상태가 되면, 파싱된 객체에서 name 속성을 구조 분해 할당으로 추출한다.
await 키워드를 사용할 때 주의해야 할 중요한 점은 모든 프로미스에 무조건 await를 사용하는 것이 항상 최선은 아니라는 것이다. 다음 예제는 이러한 상황을 잘 보여준다.
async function foo() {
// 각각의 비동기 처리가 순차적으로 실행되어 총 6초가 소요된다
const a = await new Promise(resolve => setTimeout(() => resolve(1), 3000));
const b = await new Promise(resolve => setTimeout(() => resolve(2), 2000));
const c = await new Promise(resolve => setTimeout(() => resolve(3), 1000));
console.log([a, b, c]); // [1, 2, 3]
}
foo(); // 총 6초가 소요된다
위 코드에서 세 개의 비동기 처리는 서로 독립적이며 순서에 의존성이 없다. 하지만 각 await 키워드가 프로미스의 처리를 기다리느라 전체 실행 시간이 불필요하게 늘어난다. 이런 경우에는 Promise.all을 사용하여 비동기 처리를 병렬로 수행하는 것이 효율적이다.
async function foo() {
// Promise.all을 사용하여 병렬로 처리하면 가장 오래 걸리는 작업의 시간만큼만 소요된다
const res = await Promise.all([
new Promise(resolve => setTimeout(() => resolve(1), 3000)),
new Promise(resolve => setTimeout(() => resolve(2), 2000)),
new Promise(resolve => setTimeout(() => resolve(3), 1000))
]);
console.log(res); // [1, 2, 3]
}
foo(); // 3초만 소요된다
물론 Promise.all 사용 시 주의할 점이 있다. Promise.all은 모든 프로미스가 성공적으로 완료될 때까지 기다리지만, 단 하나의 프로미스라도 reject되면 전체가 reject된다. 이는 모든 비동기 작업이 성공해야 하는 경우에는 적합하지만, 일부 실패를 허용해야 하는 경우에는 문제가 될 수 있다.
async function foo() {
const results = await Promise.allSettled([
fetch('https://api.example.com/data1'),
fetch('https://api.example.com/data2'),
fetch('https://api.example.com/data3')
]);
// 각 프로미스의 결과를 개별적으로 처리할 수 있다
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`요청 ${index + 1} 성공:`, result.value);
} else {
console.log(`요청 ${index + 1} 실패:`, result.reason);
}
});
}
위와 같은 경우에는 Promise.allSettled를 사용하는 것이 더 적절할 수 있다.
그리고 별개로 비동기 처리의 순서가 중요한 경우에는 모든 프로미스에 await를 사용하여 순차적으로 처리해야 한다.
async function bar(n) {
// 첫 번째 비동기 처리의 결과가 두 번째 비동기 처리에 필요하다
const a = await new Promise(resolve => setTimeout(() => resolve(n), 3000));
// 두 번째 비동기 처리는 첫 번째 결과인 a를 사용한다
const b = await new Promise(resolve => setTimeout(() => resolve(a + 1), 2000));
// 세 번째 비동기 처리는 두 번째 결과인 b를 사용한다
const c = await new Promise(resolve => setTimeout(() => resolve(b + 1), 1000));
console.log([a, b, c]); // [1, 2, 3]
}
bar(1); // 순차적 처리가 필요하므로 6초가 소요된다
위 코드는 이전 비동기 처리의 결과값을 다음 비동기 처리에 사용해야 하는 경우의 예시다.
에러 처리
비동기 처리에서의 에러 처리는 예전부터 까다로운 문제였다. 특히 콜백 패턴에서는 에러 처리가 매우 곤란했는데, 이는 에러가 발생하는 시점과 에러를 캐치하는 시점에 차이가 있기 때문이다. 에러는 호출자 방향으로 전파되는데, 비동기 함수의 콜백 함수를 호출하는 것은 비동기 함수가 아니기 때문에 try...catch 문으로 에러를 캐치할 수 없었다.
try {
setTimeout(() => {
throw new Error('Error!');
}, 1000);
} catch (error) {
// 에러를 캐치하지 못한다
console.error(error);
}
async/await는 이러한 문제를 해결해준다. async/await를 사용하면 동기 처리처럼 try...catch 문을 사용하여 에러를 처리할 수 있다. 이는 프로미스를 반환하는 비동기 함수가 명시적으로 호출되어 호출자가 명확하기 때문에 가능한 것이다.
const foo = async () => {
try {
const wrongUrl = 'https://wrong.url';
const response = await fetch(wrongUrl);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error); // TypeError: Failed to fetch
}
};
foo();
위 예제의 catch 블록은 다음과 같은 다양한 에러를 모두 캐치할 수 있다.
- HTTP 통신 과정에서 발생하는 네트워크 에러
- try 블록 내부의 모든 문에서 발생하는 동기적인 에러
- await한 프로미스가 reject되면서 발생하는 에러
또한 async 함수 내부에서 try...catch 문을 사용하지 않으면, async 함수는 발생한 에러를 reject하는 프로미스를 반환한다. 이 경우에는 Promise.prototype.catch 메서드를 사용하여 에러를 처리할 수 있다.
const foo = async () => {
const wrongUrl = 'https://wrong.url';
// 에러를 try...catch로 처리하지 않으면 프로미스가 reject된다
const response = await fetch(wrongUrl);
const data = await response.json();
return data;
};
// 프로미스의 후속 처리 메서드를 사용해 에러를 처리할 수 있다
foo()
.then(console.log)
.catch(console.error); // TypeError: Failed to fetch
이처럼 async/await는 기존의 비동기 처리 방식들이 가지고 있던 여러 문제점들을 해결하며, 더욱 직관적이고 가독성 높은 코드를 작성할 수 있게 해준다. 특히 에러 처리에 있어서 동기 코드와 동일한 방식을 사용할 수 있다는 점은 큰 장점이다.
실제 애플리케이션에서는 에러를 더 체계적으로 처리해야 한다.
class APIError extends Error {
constructor(message, status) {
super(message);
this.name = 'APIError';
this.status = status;
}
}
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new APIError('API 요청 실패', response.status);
}
const data = await response.json();
return data;
} catch (error) {
if (error instanceof APIError) {
// API 관련 에러 처리
if (error.status === 404) {
alert('사용자를 찾을 수 없습니다.');
} else if (error.status === 403) {
alert('접근 권한이 없습니다.');
}
// 에러 로깅
logError({
type: 'API_ERROR',
message: error.message,
status: error.status,
userId
});
} else if (error instanceof TypeError) {
// 네트워크 에러 처리
alert('네트워크 연결을 확인해주세요.');
logError({
type: 'NETWORK_ERROR',
message: error.message
});
} else {
// 예상치 못한 에러 처리
alert('예상치 못한 에러가 발생했습니다.');
logError({
type: 'UNEXPECTED_ERROR',
message: error.message
});
}
// 에러 전파
throw error;
}
}
위는 참고에 가까운 실무에서 사용할 수 있는 에러 처리 패턴의 예시다.
요약
제너레이터의 기본 개념과 특징
제너레이터는 ES6에서 도입된 특수한 함수로, 함수의 실행을 중간에 멈추고 재개할 수 있는 독특한 기능을 제공한다. 일반 함수와 구별되는 세 가지 중요한 특징을 가진다.
1. 함수 실행 제어권 양도
- 일반 함수는 호출 시 제어권이 함수 내부로 넘어가 종료될 때까지 외부에서 개입이 불가능하다.
- 제너레이터는 실행 중 제어권을 외부로 양도할 수 있으며, 필요할 때 다시 실행을 재개할 수 있다.
2. 양방향 상태 교환
- 일반 함수는 매개변수로 값을 받고 return으로 결과를 반환하는 단방향 통신만 가능하다.
- 제너레이터는 함수 실행 중에도 외부와 값을 주고받을 수 있다.
3. 제너레이터 객체 반환
- 일반 함수는 호출 시 함수 코드를 실행하고 값을 반환한다.
- 제너레이터 함수는 호출 시 제너레이터 객체를 반환하며, 이는 이터러블이면서 이터레이터인 특수한 객체다.
제너레이터 함수의 정의와 실행
제너레이터 함수 정의 방법
// 함수 선언문
function* genFunc() {
yield 1;
}
// 함수 표현식
const genFunc = function* () {
yield 1;
};
// 메서드 정의
const obj = {
* genMethod() {
yield 1;
}
};
// 클래스 메서드
class MyClass {
* genMethod() {
yield 1;
}
}
제너레이터 함수의 제약사항
- 화살표 함수로 정의할 수 없다.
- new 연산자와 함께 생성자 함수로 사용할 수 없다.
- 클래스의 constructor 메서드로 사용할 수 없다.
제너레이터의 실행 제어
yield와 next의 동작 방식
function* genFunc() {
console.log('첫 번째 작업');
yield 1;
console.log('두 번째 작업');
yield 2;
console.log('마지막 작업');
return 3;
}
const generator = genFunc();
이 제너레이터는 다음과 같은 단계로 실행된다.
- 첫 번째 next() 호출:
- '첫 번째 작업' 출력
- yield 1에서 멈춤
- {value: 1, done: false} 반환
- 두 번째 next() 호출:
- '두 번째 작업' 출력
- yield 2에서 멈춤
- {value: 2, done: false} 반환
- 세 번째 next() 호출:
- '마지막 작업' 출력
- return 3으로 함수 종료
- {value: 3, done: true} 반환
제너레이터의 양방향 통신
제너레이터는 next 메서드에 인수를 전달하여 제너레이터 함수와 값을 주고받을 수 있다.
function* genFunc() {
const x = yield 1; // x에는 두 번째 next 호출 시 전달한 값이 할당됨
const y = yield (x + 1);// y에는 세 번째 next 호출 시 전달한 값이 할당됨
return x + y; // 최종 반환값
}
const generator = genFunc();
console.log(generator.next()); // {value: 1, done: false}
console.log(generator.next(10)); // {value: 11, done: false}
console.log(generator.next(20)); // {value: 30, done: true}
async/await
async/await는 프로미스를 기반으로 동작하는 ES8의 비동기 처리 패턴이다. 제너레이터보다 간단하고 직관적인 코드 작성이 가능하다.
async 함수의 특징
async function fetchData() {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data; // Promise로 감싸져서 반환됨
}
- async 함수는 항상 프로미스를 반환한다.
- 명시적인 프로미스 반환이 없어도 값을 프로미스로 감싸서 반환한다.
- 함수 내부에서 에러가 발생하면 reject하는 프로미스를 반환한다.
await 키워드의 동작
async function processData() {
try {
// 각 프로미스가 처리될 때까지 대기
const response = await fetch('/api/data');
const data = await response.json();
// 데이터 처리
return data;
} catch (error) {
// 에러 처리
console.error('Error:', error);
throw error;
}
}
await는 프로미스가 settled 상태가 될 때까지 실행을 일시 중지한다.
병렬 처리 최적화
async function fetchMultipleData() {
const [data1, data2, data3] = await Promise.all([
fetch('/api/data1').then(r => r.json()),
fetch('/api/data2').then(r => r.json()),
fetch('/api/data3').then(r => r.json())
]);
return { data1, data2, data3 };
}
독립적인 비동기 작업들은 Promise.all을 사용하여 병렬로 처리할 수 있다.
에러 처리
async function handleErrors() {
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('에러 발생:', error);
// 에러 처리 로직
throw error; // 필요한 경우 에러를 다시 던짐
}
}
async/await는 try/catch를 사용한 동기적 에러 처리가 가능하다.
제너레이터와 async/await 비교
코드 가독성
- 제너레이터: 복잡한 이터레이터 로직과 yield 키워드로 인해 코드가 복잡해질 수 있다.
- async/await: 동기 코드와 유사한 형태로 작성되어 이해하기 쉽다.
에러 처리
- 제너레이터: 에러 처리를 위해 별도의 로직이 필요하다.
- async/await: try/catch를 사용한 직관적인 에러 처리가 가능하다.
사용 사례
- 제너레이터: 이터러블 구현, 복잡한 상태 관리가 필요한 경우.
- async/await: 일반적인 비동기 처리, HTTP 요청 처리 등.
실무 적용 시 고려사항
성능 최적화
- 불필요한 await 사용 피하기.
- 독립적인 비동기 작업은 Promise.all 활용.
- 에러 처리 전략 수립.
브라우저 지원
- 구형 브라우저 지원이 필요한 경우 폴리필 사용.
- 트랜스파일러를 통한 코드 변환 고려.
테스트
- 비동기 코드 테스트를 위한 전략 수립.
- 모의 객체(Mock)를 활용한 테스트 케이스 작성.
예상문제 [🔥]
https://github.com/junh0328/prepare_frontend_interview?tab=readme-ov-file
제너레이터란 뭔가요? 일반 함수와는 어떤 차이가 있죠?
제너레이터는 ES6에서 도입된 특수한 함수로, 코드 블록의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 독특한 기능을 제공합니다.
일반 함수와는 세 가지 중요한 차이점이 있습니다.
첫째, 함수의 제어권 측면에서 차이가 있습니다. 일반 함수는 호출되면 함수 코드를 모두 실행하고 종료되어 제어권이 함수 내부로만 있지만, 제너레이터는 실행 중간에 yield 키워드를 통해 제어권을 외부로 양도할 수 있습니다.
둘째, 데이터 전달 방식에서 차이가 있습니다. 일반 함수는 매개변수로 입력을 받고 return으로 결과를 반환하는 단방향 통신만 가능하지만, 제너레이터는 함수 실행 중에도 next 메서드를 통해 외부와 데이터를 주고받을 수 있는 양방향 통신이 가능합니다.
셋째, 반환 값에서 차이가 있습니다. 일반 함수는 호출 시 함수 코드를 실행하고 값을 반환하지만, 제너레이터 함수는 호출 시 제너레이터 객체를 반환합니다. 이 객체는 이터러블이면서 이터레이터인 특별한 객체로, next, return, throw 메서드를 통해 실행을 제어할 수 있습니다.
요약
제너레이터는 ES6에서 도입된 특수한 함수입니다. 일반 함수와의 주요 차이점은 세 가지가 있는데, 첫째로 yield 키워드를 통해 함수의 실행을 중간에 멈추고 재개할 수 있고, 둘째로 함수 실행 중에도 외부와 데이터를 주고받을 수 있으며, 셋째로 호출 시 제너레이터 객체를 반환한다는 점입니다. 이러한 특징 덕분에 비동기 처리나 이터러블 구현 등에 유용하게 사용됩니다.
제너레이터의 구조
제너레이터는 function* 키워드로 선언하며, 함수 내부에는 하나 이상의 yield 표현식이 포함되어야 합니다. yield는 제너레이터의 실행을 일시 중지시키고 값을 반환하는 키워드입니다.
제너레이터 객체는 next(), return(), throw() 세 가지 메서드를 가집니다. next() 메서드는 제너레이터를 다음 yield 표현식까지 실행하고, value와 done이라는 두 프로퍼티를 가진 객체를 반환합니다. value는 yield된 값이고, done은 함수가 끝났는지를 나타내는 불리언 값입니다.
예를 들어, 다음과 같은 제너레이터가 있다고 하면...
function* generator() {
yield 1;
yield 2;
return 3;
}
const gen = generator();
next()를 호출할 때마다 다음과 같이 동작합니다.
- 첫 번째 next(): {value: 1, done: false}
- 두 번째 next(): {value: 2, done: false}
- 세 번째 next(): {value: 3, done: true}
또한 제너레이터는 next() 메서드를 통해 값을 주고받을 수 있는데, 이는 제너레이터의 양방향 통신을 가능하게 합니다.
async/await 가 뭔가요? 기존의 Promise와는 어떤 차이가 있죠? 🔥
async/await는 ES8에서 도입된 비동기 처리 패턴으로, 프로미스를 기반으로 동작하지만 더 직관적인 코드 작성을 가능하게 합니다.
async/await의 기본 구조를 설명하자면, async는 함수 앞에 붙이는 키워드로, 이 함수가 항상 프로미스를 반환하도록 만듭니다. await는 async 함수 내부에서만 사용할 수 있으며, 프로미스가 처리될 때까지 실행을 일시 중지하고 결과를 기다립니다.
기존 Promise와의 주요 차이점은 다음과 같습니다.
첫째, 코드 가독성 측면에서 큰 차이가 있습니다. Promise는 then 체이닝으로 인해 코드가 깊어지고 복잡해질 수 있지만, async/await는 마치 동기 코드를 작성하는 것처럼 직관적으로 작성할 수 있습니다.
예를 들어, Promise를 사용한 코드는...
fetch('url')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
...이러한데, async/await를 사용하면 다음과 같이 변합니다.
async function fetchData() {
try {
const response = await fetch('url');
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
둘째, 에러 처리 방식이 다릅니다. Promise는 catch 메서드를 통해 에러를 처리하지만, async/await는 일반적인 try-catch 구문을 사용할 수 있어 더 직관적인 에러 처리가 가능합니다.
셋째, 디버깅 측면에서도 차이가 있습니다. Promise 체인에서는 디버거를 걸기가 까다롭지만, async/await는 일반 동기 코드처럼 디버거를 사용할 수 있습니다.
요약
async/await는 ES8에서 도입된 비동기 처리 패턴입니다. Promise를 기반으로 하지만, then 체이닝 대신 동기 코드와 유사한 형태로 작성할 수 있어 가독성이 매우 좋습니다. async 함수는 항상 Promise를 반환하고, await는 Promise가 처리될 때까지 실행을 일시 중지합니다. 특히 에러 처리도 일반적인 try-catch 문을 사용할 수 있어 더 직관적입니다.
Promise와 async/await의 차이점 한 줄 요약 🔥
Promise는 비동기 처리를 위한 객체이고 then 체이닝 방식으로 구현하는 반면, async/await는 Promise를 기반으로 하되 동기 코드와 유사한 형태로 비동기 처리를 구현할 수 있게 해주는 문법적 설탕(Syntactic sugar)입니다.
'🧱 프론트엔드 주제 > JavaScript' 카테고리의 다른 글
[모던 자바스크립트 Deep Dive] 48장 - 모듈 (0) | 2024.11.30 |
---|---|
[모던 자바스크립트 Deep Dive] 47장 - 에러 처리 (0) | 2024.11.30 |
[모던 자바스크립트 Deep Dive] 45장 - Promise (0) | 2024.11.26 |
[모던 자바스크립트 Deep Dive] 44장 - REST API (2) | 2024.11.24 |
[모던 자바스크립트 Deep Dive] 43장 - Ajax (1) | 2024.11.19 |