JavaScript에서 비동기 처리는 웹 개발에서 필수적인 개념이다. 비동기 처리를 효과적으로 관리하기 위한 다양한 방법들이 존재하며, 그중 대표적인 것이 콜백(callbacks), 프로미스(Promises), async/await이다.
1. 콜백 함수 (Callbacks)
콜백 함수는 프로그래밍에서 다른 함수에 인자로 전달되는 함수이다.
이 함수는 특정 이벤트가 발생하거나 특정 작업이 완료된 후 "나중에 호출"된다.
콜백 함수의 핵심은 웹 API에 작업을 전달하고, 해당 작업이 완료되었을 때 특정 함수(콜백 함수)를 실행하여 결과를 처리하는 것이다. 이 과정을 이벤트 루프라고 한다.
이 과정에서 2가지 핵심 요소를 만족하게 된다.
- 비동기 처리
- 웹 API 호출, 파일 읽기, 타이머 설정 등 시간이 걸리는 작업은 비동기적으로 처리된다.
- 즉, 작업이 완료될 때까지 기다리지 않고 다른 작업을 계속 수행한다.
- 콜백 함수는 이러한 비동기 작업이 완료되었을 때 실행될 코드를 담고 있다.
- 제어권 전달
- 함수를 호출할 때 콜백 함수를 인수로 전달하면, 호출된 함수는 작업 완료 후 콜백 함수를 실행하여 결과를 처리한다.
- 어떤 함수(A)가 다른 함수(B, 콜백 함수)를 인자로 받아 내부에서 실행할 때, 함수 A는 함수 B의 실행 시점을 결정한다.
- 즉, 함수 A가 함수 B의 실행을 "제어"하는 것이다.
- 이것을 "제어권을 함수 A가 가지고 있다" 또는 "제어권을 함수 A에게 넘겨준다" 라고 표현 할 수 있다.
실질적으로는 간단한 개념이다.
// 3초 후에 "Hello, world!"를 출력하는 콜백 함수
setTimeout(function() {
console.log("Hello, world!");
}, 3000);
// 1초마다 현재 시간을 출력하는 콜백 함수
setInterval(function() {
console.log(new Date());
}, 1000);
여기서 function() {console.log("Hello, world!");} 라는 함수는 setTimeout()이라는 함수의 콜백함수이고 setTimeout()함수에 적힌 대로 3000 밀리세컨드(3초) 뒤에 실행되게 된다.
이처럼 콜백 함수를 인수로 받은 함수가 콜백함수의 실행 시점을 제어(3초 뒤에 실행)하기 때문에 제어권을 전달한다는 소리다.
ES6 프로미스 (Promises)
ES6의 프로미스(Promise)는 자바스크립트에서 비동기 처리를 더 효과적으로 관리하기 위해 도입된 객체이다.
콜백(callback) 함수의 단점을 보완하며 비동기 작업의 성공 또는 실패를 명확하게 처리할 수 있도록 도와준다.
프로미스는 비동기 작업의 최종 완료(또는 실패)와 그 결과값을 나타내는 객체이다.
프로미스의 상태
프로미스는 세 가지 상태를 가진다.
- 대기(Pending): 초기 상태, 아직 성공하거나 실패하지 않은 상태
- 이행(Fulfilled): 작업이 성공적으로 완료된 상태
- 거부(Rejected): 작업이 실패한 상태
const myPromise = new Promise((resolve, reject) => {
// 비동기 작업 수행
if (/* 작업 성공 */) {
resolve(결과); // 성공 시 호출
} else {
reject(에러); // 실패 시 호출
}
});
- 대기(Pending) 상태
- 프로미스가 생성되면 처음에는 "대기" 상태입니다.
- 이 상태에서는 아직 성공이나 실패가 결정되지 않았습니다.
- 콘솔에 "1. 프로미스 생성 (대기 상태)" 출력
- 이행(Fulfilled) 상태
- 비동기 작업이 성공적으로 완료되면 "이행" 상태로 변경됩니다.
- resolve()가 호출되며 결과값을 전달합니다.
- .then() 메서드로 성공 결과를 처리할 수 있습니다.
- 콘솔에 "2. 성공! (이행 상태로 변경)" 출력
- 거부(Rejected) 상태
- 비동기 작업이 실패하면 "거부" 상태로 변경됩니다.
- reject()가 호출되며 에러 정보를 전달합니다.
- .catch() 메서드로 에러를 처리할 수 있습니다.
- 콘솔에 "2. 실패! (거부 상태로 변경)" 출력
주요 메서드
- .then(): 프로미스가 성공했을 때 실행
- .catch(): 프로미스가 실패했을 때 실행
- .finally(): 성공/실패와 관계없이 항상 실행
예시
function fetchData() {
return new Promise((resolve, reject) => {
// 비동기 작업 (예: API 요청)
setTimeout(() => {
const success = true; // 성공 또는 실패를 임의로 설정
if (success) {
resolve("데이터 가져오기 성공!"); // 성공 시 resolve 호출
} else {
reject("데이터 가져오기 실패!"); // 실패 시 reject 호출
}
}, 1000); // 1초 후 실행
});
}
fetchData()
.then((result) => {
console.log(result); // 성공 시 결과 출력
})
.catch((error) => {
console.error(error); // 실패 시 에러 출력
});
일단 기본적으로 fetchData() 함수가 호출되게 되면 Promise를 반환하여 내부 코드가 실행된다.
return new Promise() 형식을 사용하지 않으면 어떻게 되는가?
일반적인 형태의 프로미스 함수를 사용하는 방법은 2가지 표현 방법이 있다.
- return new Promise((resolve, reject) => {
- return new Promise(function(resolve, reject) {
이 2가지 표현 외의 형식 외에 일반적인 형태의 프로미스 함수를 사용할 수 있는 방법은 없다.
여기서 return 키워드는 함수 내부의 값을 외부로 반환하는 역할을 한다.
여기서 return을 사용하지 않으면 함수 내부의 값이 외부에 전달되지 않아서 undefined 값이 나오게 된다.
function add(a, b) {
a + b; // a와 b를 더한 값을 반환
}
console.log(add(2,3)); //undefined 반환
반대로 new Promise((resolve, reject) => {} 구문만 사용해서 내부에서만 프로미스 구문을 사용하는 것은 가능하다.
이 경우엔 외부로 return이 안되기 때문에 외부에서 .then, .catch 구문을 사용하는 것은 불가능하다.
- setTimeout으로 1000초 후 실행되도록 타이머가 설정되었고, 이것이 프로미스 내부에서 1번째 실행되는 코드이다.
- 1000초 후 const success = true;로 상수가 선언된다. 이것이 2번째 실행되는 코드이다.
- if (success) 조건에 따라 resolve() 또는 reject() 함수가 호출된다. 이것이 3번째 실행되는 코드이다.
- resolve()는 프로미스를 "이행(fulfilled)" 상태로 변경하고, 성공 결과를 전달한다.
- reject()는 프로미스를 "거부(rejected)" 상태로 변경하고, 실패 이유를 전달한다.
- resolve와 reject는 반드시 코드상에 존재해야 하며, 이들이 호출되어야 프로미스의 상태가 변경되고 .then() 또는 .catch()가 실행된다.
- 성공 또는 실패 판단 후, 프로미스의 상태에 따라 .then() 또는 .catch() 콜백 함수가 실행된다.
- 프로미스의 상태가 "이행(fulfilled)"이면 .then() 콜백 함수가 실행되고, "거부(rejected)"이면 .catch() 콜백 함수가 실행된다. 이것이 4번째 실행되는 코드 이다.
때문에 promise는 resolve와 then 부분의 코드가 반드시 필요하다.
.then을 작성하지 않으면 어떻게 되는가?
- .then()을 작성하지 않으면 다음과 같은 상황이 발생한다.
- 성공 결과 무시 : 프로미스가 이행(fulfilled)되더라도 resolve()를 통해 전달된 성공 결과를 처리할 수 없다. 결과적으로 성공 결과는 무시되고, 아무런 동작도 수행되지 않는다.
- 오류 처리 부재 : .catch()도 작성하지 않았다면 프로미스가 거부(rejected)되더라도 오류를 처리할 수 없다. 따라서 오류가 발생하더라도 어떤 오류가 발생했는지 알 수 없고, 오류 처리 로직을 실행할 수 없다.
- 프로미스 상태 변화 관찰 불가 : 프로미스의 상태 변화(이행 또는 거부)에 따른 어떠한 동작도 정의하지 않았으므로, 프로미스의 상태 변화를 관찰할 수 없다. 즉, 비동기 작업이 완료되었는지, 성공했는지, 실패했는지 등을 알 수 없다.
- 메모리 누수 가능성 : 오래 실행되는 프로미스에서 .catch()를 작성하지 않으면 거부된 프로미스에 대한 오류 처리가 이루어지지 않아 메모리 누수가 발생할 수 있다.
프로미스 체이닝(Promise Chaining)
프로미스 체이닝(Promise Chaining)은 JavaScript에서 비동기 작업을 순차적으로 처리하는 기법이다.
마치 상속의 개념처럼 then과 catch에 새로운 프로미스를 반환하여 비동기 작업을 순차적으로 실행하는 것이다.
간단하게 말하면 중첩 if문의 비슷하다고 할 수 있다.
function 작업1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('작업 1 완료');
resolve('작업 1 결과');
}, 1000);
});
}
function 작업2(결과1) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('작업 2 완료:', 결과1);
resolve('작업 2 결과');
}, 1000);
});
}
function 작업3(결과2) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('작업 3 완료:', 결과2);
resolve('작업 3 결과');
}, 1000);
});
}
작업1()
.then(작업2)
.then(작업3)
.then((최종결과) => {
console.log('최종 결과:', 최종결과);
})
.catch((오류) => {
console.error('오류 발생:', 오류);
});
작업1() 의 프로미스 성공 실패 여부를 확인하고 .then을 호출할때 작업2를 할당한다. 다시 여기서 성공 실패 여부를 확인하고 성공하면 .then에 작업3을 할당하고 다시 성공 실패 여부를 확인하여 성공하면 then에 최종결과를 할당한다.
이처럼 하나의 결과 후에 다른 작업을 시작하게 만들었기 때문에 연결된 구조와 같아서 체인 구조라고 한다.
단 연결된 프로미스 중 하나라도 실패하면 catch 구문으로 넘어가게 된다. 때문에 여러 조건 중 하나라도 만족하지 못하면 false가 되는 중첩 if문과 비슷하다고 할 수 있다.
프로미스 체이닝을 사용하는 이유
- 비동기 작업의 순차적 처리: 여러 비동기 작업을 순서대로 처리해야 할 때 코드를 간결하고 가독성 높게 작성할 수 있다.
- 콜백 지옥(Callback Hell) 해결: 콜백 함수가 중첩되어 코드가 복잡해지는 콜백 지옥 문제를 해결하고, 비동기 코드를 동기 코드처럼 쉽게 이해하고 관리할 수 있도록 한다.
- 오류 처리의 효율성: .catch() 메서드를 사용하여 여러 비동기 작업에서 발생하는 오류를 한 번에 처리할 수 있다.
.catch()를 여러개 체인을 하는 경우는 없나?
.catch를 여러개 체인 하는 경우는 일반적으로 드물지만 보통 다음과 같은 경우에 사용된다.
1. 오류 처리 분리
- 각 .then() 블록에서 발생하는 오류를 분리하여 처리하고 싶을 때 .catch()를 여러 번 사용할 수 있다.
- 이를 통해 각 단계별로 다른 오류 처리 로직을 적용할 수 있다.
2. 오류 복구 및 체인 재개
- .catch() 블록에서 오류를 처리하고 새로운 프로미스를 반환하여 체인을 다시 이어갈 수 있다.
- 이 경우, 다음 .then() 또는 .catch() 블록이 실행된다.
3. 특정 오류 유형 처리
- 발생하는 오류의 유형에 따라 다른 처리를 하고 싶을 때 .catch()를 여러 번 사용하여 특정 오류 유형에 대한 처리 로직을 분리할 수 있다.
function 작업1() {
return new Promise((resolve, reject) => {
// ...
});
}
function 작업2(결과1) {
return new Promise((resolve, reject) => {
// ...
});
}
작업1()
.then(작업2)
.then((결과2) => {
// ...
})
.catch((오류1) => {
// 작업1 또는 작업2에서 발생한 오류 처리
console.error('작업 1 또는 2 오류:', 오류1);
// 오류 복구 시도 및 체인 재개
return 복구함수();
})
.then((복구결과) => {
// 복구 성공 후 실행
})
.catch((오류2) => {
// 복구 실패 또는 이전 then 블록에서 발생한 오류 처리
console.error('복구 실패 또는 다음 작업 오류:', 오류2);
});
- 작업1() 또는 작업2()에서 오류가 발생하면 첫 번째 .catch() 블록이 실행된다.
- 첫 번째 .catch() 블록에서 오류를 복구하고 새로운 프로미스를 반환하면 다음 .then() 블록이 실행된다.
- 복구 실패 또는 다음 .then() 블록에서 오류가 발생하면 두 번째 .catch() 블록이 실행된다.
복구함수도 프로미스 문이어야 then, catch 작업이 이어진다. 만약에 프로미스문이 아니라면 그대로 해당 함수를 실행하고 프로미스 과정에서 이탈하게 된다. 즉 then(복구결과) catch(오류2) 부분이 아예 실행되지 않는다.
프로미스와 이벤트 루프
프로미스는 콜백 함수이다. 즉 비동기 작업을 수행하기 위한 함수이기 때문에 반드시 이벤트 루프 과정을 거친다.
즉 당장에는 코드 실행을 미루고 외부의 저장 공간에서 코드가 대기하게 된다.
그리고 그렇게 작업이 대기하는 공간은 태스크 큐(Task Queue)와 마이크로태스크 큐(Microtask Queue)로 나뉜다.
- 태스크 큐(Task Queue): setTimeout, setInterval, 사용자 이벤트 등의 비동기 작업 콜백 함수들을 저장하는 큐이다.
- 마이크로태스크 큐(Microtask Queue): 프로미스(Promise)의 .then(), .catch(), .finally() 콜백 함수들과 async/await 함수들의 콜백 함수들을 저장하는 큐이다.
- 마이크로태스크 큐는 태스크 큐보다 우선순위가 높다.
- 즉, 이벤트 루프는 콜 스택이 비어 있을 때 먼저 마이크로태스크 큐의 작업을 모두 처리하고, 그다음에 태스크 큐의 작업을 처리한다.
- 따라서 프로미스의 콜백 함수들은 현재 실행 중인 스크립트가 완료된 후, 다른 비동기 작업 콜백 함수들보다 먼저 실행다.
- 이러한 동작 방식 때문에 프로미스는 비동기 작업을 순차적으로 처리하고 결과를 예측 가능하게 전달하는 데 효과적이다.
만약에 setTimeout() 안에 promise()가 있는 경우는 어떻게 처리 되는가?
setTimeout() 안에 프로미스가 있는 경우, 다음과 같은 순서로 실행된다.
- 태스크 큐에 setTimeout() 등록
- setTimeout() 함수는 먼저 태스크 큐에 등록된다.
- 이는 지정된 시간(delay) 후에 setTimeout()의 콜백 함수를 실행하기 위한 스케줄링이다.
- setTimeout() 콜백 함수 실행
- 지정된 시간이 지나면 이벤트 루프는 태스크 큐에서 setTimeout()의 콜백 함수를 꺼내 콜 스택에 넣고 실행한다.
- 이때 setTimeout()의 콜백 함수 내부의 프로미스 생성 및 비동기 작업이 시작된다.
- 프로미스 결과 처리 및 마이크로태스크 큐 등록
- 프로미스의 비동기 작업이 완료되면 (.then() 또는 .catch() 호출), 해당 콜백 함수가 마이크로태스크 큐에 등록된다.
- 이 시점에서 프로미스의 결과에 따라 실행될 콜백 함수들이 마이크로태스크 큐에 추가된다.
- 마이크로태스크 큐 실행
- 현재 실행 중인 모든 동기 코드와 setTimeout() 콜백 함수가 완료되면, 이벤트 루프는 마이크로태스크 큐에 있는 모든 작업을 처리한다.
- 따라서 프로미스의 .then() 또는 .catch() 콜백 함수가 먼저 실행된다.
즉 1. 태스크 큐에 setTimeout() 등록 → 2. setTimeout() 콜백 함수 실행 → 3. 프로미스 결과 처리 및 마이크로태스크 큐 등록 → 4. 마이크로태스크 큐 실행 순으로 진행된다.
3. async/await
자바스크립트의 async/await는 비동기 코드를 동기 코드처럼 작성할 수 있게 해주는 문법이다.
이 문법은 Promise를 기반으로 작동하며, 비동기 작업의 결과를 더 쉽게 처리하고 코드의 가독성을 높이는 데 도움을 준다.
async 함수
- async 키워드는 함수 선언 앞에 붙여서 해당 함수가 비동기 함수임을 나타낸다.
- async 함수는 항상 Promise를 반환한다.
- 만약 함수가 명시적으로 값을 반환하면, 그 값은 Promise.resolve(value) 형태로 감싸져 반환된다.
- async 함수 내부에서는 await 키워드를 사용할 수 있다.
await 연산자
- await 키워드는 async 함수 내부에서만 사용할 수 있다.
- await 키워드는 Promise 앞에 붙여서 Promise가 완료(resolve 또는 reject)될 때까지 함수의 실행을 일시 중지한다.
- Promise가 resolve되면, await 표현식은 Promise의 결과값을 반환한다.
- Promise가 reject되면, await 표현식은 예외를 발생시킨다.
사용법
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
} catch (error) {
console.error('데이터를 가져오는 중 오류가 발생했습니다:', error);
}
}
fetchData();
async 키워드는 함수를 비동기 함수로 선언할 때 함수 선언 앞에 딱 한 번만 붙인다.
async 키워드를 붙이면 해당 함수는 다음과 같은 특징을 갖게 된다.
- 항상 Promise를 반환
- async 함수는 명시적으로 Promise를 반환하지 않더라도 항상 Promise를 반환한다.
- 함수 내부에서 반환하는 값은 Promise.resolve(value) 형태로 Promise로 감싸져 반환된다.
- await 키워드 사용 가능
- async 함수 내부에서는 await 키워드를 사용하여 비동기 작업의 완료를 기다릴 수 있다.
- await 키워드는 Promise 앞에 붙여서 Promise가 완료될 때까지 함수의 실행을 일시 중지하고, Promise의 결과값을 반환한다.
async
기존에는 프로미스 함수를 사용하기 위해서 함수 바로 안쪽에 return new Promise((resolve, reject) => { 코드를 사용해야 했다. 그것을 애초에 함수의 앞쪽에 async를 붙이는 것으로 대체했다.
try { ... } catch (error) { ... }:
- try-catch 구문은 오류 처리에 사용된다. try 블록 안의 코드를 실행하고, 오류가 발생하면 catch 블록으로 이동하여 오류를 처리한다.
await
Promise가 완료될 때까지 코드 실행을 일시 중지시킨다. 즉, 비동기 작업이 끝날 때까지 기다린다.
'자바 스크립트(java script) > 자바 스크립트 기초' 카테고리의 다른 글
심볼 타입 (0) | 2025.03.27 |
---|---|
ES6 클래스(class) (0) | 2025.03.26 |
다형성(Polymorphism) (0) | 2025.03.25 |
객체 지향 프로그래밍(OOP, Object-Oriented Programming) (0) | 2025.03.25 |
얕은 비교, 깊은 비교 (0) | 2025.03.24 |