Promises와 Async/Await를 사용한 비동기 JavaScript 마스터하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
웹 개발의 세계에서 JavaScript는 최고입니다. 단일 스레드 특성은 특정 프로그래밍 측면을 단순화하지만 서버에서 데이터를 가져오거나, 데이터베이스에서 읽거나, 사용자 입력을 처리하는 것과 같이 시간이 걸리는 작업을 처리할 때 고유한 어려움을 안겨줍니다. 이러한 작업을 메인 스레드에서 차단하면 사용자 인터페이스가 멈추고 응답이 없는 환경이 초래될 것입니다. 이는 현대 애플리케이션에서는 절대 피해야 할 사항입니다. 바로 여기서 비동기 프로그래밍이 등장하여 애플리케이션이 전체 사용자 인터페이스를 중단하지 않고 시간이 오래 걸리는 작업을 수행할 수 있도록 합니다.
수년간 콜백은 비동기 처리를 위한 주요 메커니즘이었지만, 유명한 "콜백 지옥" 또는 "파멸의 피라미드"로 이어졌습니다. 즉, 매우 중첩되고 읽기 어렵고 유지 관리하기는 더욱 어려운 코드입니다. 이러한 문제를 인지한 JavaScript는 우아하고 강력한 구성 요소인 Promise와 이후 async/await를 도입하면서 발전했습니다. 이러한 발전은 동시 JavaScript 작성 방식을 근본적으로 변화시켜 코드를 더 읽기 쉽고, 유지 관리하기 쉬우며, 강력하게 만들었습니다. 기본 원칙을 이해하고 모범 사례를 마스터하는 것은 단순히 있으면 좋은 것이 아니라 성능과 확장 가능한 애플리케이션을 구축하는 모든 진지한 JavaScript 개발자에게 중요한 기술입니다. 이 글에서는 Promise와 async/await를 명확히 설명하고 핵심 메커니즘, 구현 세부 정보 및 실제 적용을 탐구하여 더 깨끗하고 효과적인 비동기 코드를 작성하는 데 도움을 줄 것입니다.
Promise: 현대 비동기 처리의 기초
async/await
를 자세히 살펴보기 전에, async/await
는 기본적으로 Promise 위에 쌓인 구문 설탕이기 때문에 Promise의 개념을 이해하는 것이 필수적입니다.
Promise란 무엇인가?
핵심적으로 Promise는 비동기 작업의 최종 완료 또는 실패 및 그 결과 값을 나타내는 객체입니다. 현실 세계의 약속이라고 생각하십시오. 요청(예: "데이터를 가져다주겠다")을 하고, 미래의 어느 시점에 그 약속은 이행되거나(데이터를 성공적으로 가져옴) 거부될 것입니다(오류로 인해 데이터를 가져오는 데 실패함).
Promise는 다음 세 가지 상태 중 하나를 가질 수 있습니다.
- Pending: 초기 상태; 이행되지도 거부되지도 않았습니다. 비동기 작업이 아직 진행 중입니다.
- Fulfilled (또는 Resolved): 작업이 성공적으로 완료되었고 Promise에는 결과 값이 있습니다.
- Rejected: 작업이 실패했으며 Promise에는 실패 이유(오류 객체)가 있습니다.
Promise가 이행되거나 거부되면 settled된 것으로 간주됩니다. settled된 Promise는 상태를 다시 변경할 수 없습니다. 불변합니다.
Promise 생성 및 사용
Promise
생성자를 사용하여 Promise를 생성하며, 이 생성자는 executor
함수를 인수로 받습니다. executor 함수 자체는 resolve
와 reject
라는 두 개의 인수를 받으며, 이는 Promise의 상태를 변경하기 위해 호출하는 함수입니다.
const myAsyncOperation = (shouldSucceed) => { return new Promise((resolve, reject) => { // 비동기 작업 시뮬레이션, 예를 들어 네트워크 요청 setTimeout(() => { if (shouldSucceed) { resolve("데이터를 성공적으로 가져왔습니다!"); // Promise 이행 } else { reject(new Error("데이터를 가져오는 데 실패했습니다.")); // Promise 거부 } }, 1000); // 1초 지연 시뮬레이션 }); }; // --- Promise 소비 --- // 사례 1: Promise가 성공적으로 해결됨 myAsyncOperation(true) .then((data) => { console.log("성공:", data); // 출력: 성공: 데이터를 성공적으로 가져왔습니다! }) .catch((error) => { console.error("오류 (발생하지 않아야 함):", error.message); }); // 사례 2: Promise가 거부됨 myAsyncOperation(false) .then((data) => { console.log("성공 (발생하지 않아야 함):", data); }) .catch((error) => { console.error("오류:", error.message); // 출력: 오류: 데이터를 가져오는 데 실패했습니다. });
then()
메서드는 Promise가 이행될 때 실행될 콜백을 등록하는 데 사용됩니다. 선택적으로 onFulfilled
콜백을 받습니다. catch()
메서드는 then(null, onRejected)
의 축약형이며 Promise가 거부될 때 콜백을 등록하는 데 사용됩니다. 잠재적 오류를 처리하고 처리되지 않은 Promise 거부를 방지하기 위해 항상 .catch()
블록을 포함하는 것이 중요합니다.
Promise 체이닝
콜백보다 Promise의 가장 중요한 이점 중 하나는 체이닝할 수 있다는 것입니다. then()
콜백이 값을 반환하면 체인의 다음 then()
이 해당 값을 받습니다. 중요한 것은 then()
콜백이 다른 Promise를 반환하면 체인은 진행하기 전에 중첩된 Promise가 해결될 때까지 기다린다는 것입니다. 이것은 깊게 중첩된 비동기 작업을 효과적으로 평탄화합니다.
function step1() { console.log("1단계: 시작..."); return new Promise((resolve) => setTimeout(() => resolve("1단계 결과"), 1000)); } function step2(prevResult) { console.log(`2단계: "${prevResult}" 수신. 추가 작업 중... `); return new Promise((resolve) => setTimeout(() => resolve("2단계 결과"), 1500)); } function step3(prevResult) { console.log(`3단계: "${prevResult}" 수신. 마무리 중... `); // 이 단계에서 오류가 발생하면 catch 블록이 처리합니다. // throw new Error("3단계에서 문제가 발생했습니다."); return Promise.resolve("최종 결과!"); // 동기적 값으로 직접 해결 } step1() .then(step2) // step2는 step1의 결과 받음 .then(step3) // step3는 step2의 결과 받음 .then((finalResult) => { console.log("모든 단계 완료:", finalResult); // 출력: 모든 단계 완료: 최종 결과! }) .catch((error) => { console.error("처리 중 오류 발생:", error.message); }); // .finally() 메서드가 나중에 도입되어 Promise가 이행되었는지 거부되었는지에 관계없이 실행될 콜백을 등록할 수 있습니다. 정리 작업에 유용합니다.
finally()
메서드는 나중에 도입되어 Promise가 이행되었든 거부되었든 관계없이 실행될 콜백을 등록할 수 있게 해줍니다. 정리 작업에 유용합니다.
Async/Await: 비동기 코드 단순화
Promise는 비동기 코드를 크게 개선했지만, ES2017에서 async/await
의 도입은 비동기 코드를 거의 동기적으로 보이게 하고 느끼게 하는 또 다른 수준의 구문적 우아함을 가져왔습니다.
async
함수
async
함수는 async
키워드로 선언된 함수입니다. 암시적으로 Promise를 반환합니다. 함수가 Promise가 아닌 값을 반환하면 해당 값으로 해결되는 Promise로 래핑됩니다. 오류를 발생시키면 반환된 Promise가 거부됩니다.
async function greet() { return "안녕하세요, async 세계!"; // 이 값은 해결된 Promise로 래핑됩니다. } greet().then(message => console.log(message)); // 출력: 안녕하세요, async 세계! async function throwErrorExample() { throw new Error("이것은 async 오류입니다!"); // 이것은 반환된 Promise를 거부시킵니다. } throwErrorExample().catch(error => console.error(error.message)); // 출력: 이것은 async 오류입니다!
await
연산자
await
연산자는 async
함수 내에서만 사용할 수 있습니다. await
하는 Promise가 settled(이행 또는 거부)될 때까지 async
함수의 실행을 일시 중지합니다. Promise가 이행되면 await
는 해결된 값을 반환합니다. Promise가 거부되면 await
는 거부된 값을 오류로 발생시키며, 이는 try...catch
블록을 사용하여 포착할 수 있습니다.
function fetchData() { return new Promise(resolve => { setTimeout(() => resolve({ id: 1, name: "Async Item" }), 2000); }); } function processData(data) { return new Promise((resolve, reject) => { setTimeout(() => { if (data && data.name) { resolve(`처리됨: ${data.name.toUpperCase()}`); } else { reject(new Error("처리할 데이터가 잘못되었습니다.")); } }, 1000); }); } async function performOperations() { try { console.log("데이터 가져오기 시작..."); // await는 fetchData()가 해결될 때까지 여기서 실행을 일시 중지합니다. const data = await fetchData(); console.log("데이터 가져옴:", data); console.log("데이터 처리 시작..."); // await는 processData()가 해결될 때까지 여기서 일시 중지됩니다. const processedResult = await processData(data); console.log("처리 결과:", processedResult); return processedResult; } catch (error) { console.error("오류 발생:", error.message); // 오류를 다시 발생시키거나 기본값/Promise.reject를 반환할 수 있습니다. throw error; } } performOperations() .then(finalResult => console.log("전체 성공:", finalResult)) .catch(overallError => console.error("전체 실패:", overallError.message)); // 오류 시나리오 예시 async function performOperationsWithError() { try { console.log("잘못된 데이터 처리 시도 중..."); const invalidData = null; // 잘못된 데이터 가져오기 시뮬레이션 const processedResult = await processData(invalidData); // 이것은 거부될 것입니다. console.log("처리 결과:", processedResult); // 이 줄은 도달할 수 없습니다. } catch (error) { console.error("performOperationsWithError에서 오류 포착:", error.message); } } performOperationsWithError();
await
주위의 try...catch
블록은 오류를 처리하는 데 필수적이며, Promise의 catch()
블록 동작을 복제합니다.
내부 동작: async/await
및 이벤트 루프
async/await
는 새로운 동시성 기본 요소를 도입하는 것이 아니라 Promise와 JavaScript 이벤트 루프 위에 쌓인 구문 변환일 뿐입니다. await
표현식이 발생하는 경우:
async
함수의 실행이 일시 중지됩니다.await
하는 Promise는 마이크로태스크 큐(I/O 작업의 경우 매크로태스크 큐)에 배치됩니다.- 호출하는 함수나 이벤트 루프에 제어권이 반환되어 다른 동기 코드나 보류 중인 작업을 실행할 수 있게 합니다.
- 대기하던 Promise가 settled되면
then
핸들러(await
가 암묵적으로 생성)가 마이크로태스크 큐에 추가됩니다. - JavaScript 호출 스택이 비어 있으면 이벤트 루프가 마이크로태스크를 가져오고
async
함수는 일시 중지된 지점에서 해결된 값으로 재개하거나 발생된 오류를 발생시켜 실행을 재개합니다.
이러한 논블로킹 특성은 단일 스레드 JavaScript 환경에서 응답성을 유지하는 데 매우 중요합니다.
Promise 및 Async/Await 모범 사례
1. 항상 오류 처리
Promise: .catch()
블록으로 Promise 체인을 항상 끝내세요. 처리되지 않은 Promise 거부는 사일런트 실패 또는 Node.js 애플리케이션을 충돌시킬 수 있는 처리되지 않은 Promise 거부 경고/오류로 이어질 수 있습니다.
fetch("/api/data") .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error("데이터 가져오기 실패:", error)); // 필수!
Async/Await: await
호출을 try...catch
블록으로 감싸세요.
async function getData() { try { const response = await fetch("/api/data"); if (!response.ok) { // HTTP 오류 확인 (예: 404, 500) throw new Error(`HTTP 오류! 상태: ${response.status}`); } const data = await response.json(); console.log(data); } catch (error) { console.error("데이터 가져오기 실패:", error); } }
2. 가독성을 위해 async/await
선호
순차적인 비동기 작업의 경우 async/await
는 여러 .then()
호출을 연결하는 것보다 일반적으로 더 깨끗하고 읽기 쉬운 코드를 제공합니다.
나쁨 (콜백 지옥):
getData(function(a) { getOtherData(a, function(b) { processData(b, function(c) { console.log(c); }); }); });
더 나음 (Promise):
getData() .then(a => getOtherData(a)) .then(b => processData(b)) .then(c => console.log(c)) .catch(error => console.error(error));
최고 (async/await
):
async function performCombinedOperation() { try { const a = await getData(); const b = await getOtherData(a); const c = await processData(b); console.log(c); } catch (error) { console.error(error); } }
3. Promise.all
로 병렬 작업 실행
하나 이상의 독립적인 비동기 작업이 완료되어야 진행할 수 있다면, 종속성이 진정으로 없는 한 순차적으로 await
하지 마세요. Promise.all()
을 사용하여 병렬로 실행하세요. 이렇게 하면 성능이 크게 향상됩니다.
async function getMultipleData() { try { // 이 두 개의 독립적인 fetch는 병렬로 실행됩니다. const [usersResponse, productsResponse] = await Promise.all([ fetch("/api/users"), fetch("/api/products") ]); const users = await usersResponse.json(); const products = await productsResponse.json(); console.log("사용자:", users); console.log("제품:", products); } catch (error) { console.error("fetch 중 하나가 실패했습니다:", error); } }
Promise.all
은 Promise의 이터러블(예: 배열)을 받으며, 모든 Promise가 이행되면 입력 Promise의 결과 배열을 동일한 순서로 반환하는 새 Promise를 반환합니다. 입력 Promise 중 하나라도 거부되면 Promise.all
은 즉시 첫 번째로 거부된 Promise의 이유로 거부됩니다.
Promise.allSettled()
는 모든 Promise의 결과를 알고 싶을 때(성공했든 실패했든) 또 다른 유용한 메서드입니다. 각 결과에 대한 설명 객체( status: 'fulfilled' | 'rejected'
, value
또는 reason
)의 배열을 반환합니다.
4. 동기 작업을 위해 async
함수 피하기
일관성을 위해 모든 곳에 async
를 사용하는 것이 유혹적일 수 있지만, 함수가 실제로 비동기 작업을 수행하지 않는다면(즉, await
를 사용하거나 Promise를 반환하지 않는 경우), async
로 선언하지 마세요. 반환 값을 Promise로 래핑하여 불필요한 오버헤드를 추가합니다.
5. 컨텍스트(this
키워드)에 주의
기존 함수 표현식으로 then()
또는 catch()
를 사용할 때 this
컨텍스트가 변경될 수 있습니다. 화살표 함수는 원래 범위의 this
컨텍스트를 유지하므로 클래스 메서드 내의 Promise 콜백에 더 적합한 경우가 많습니다. async/await
는 함수 본문으로 코드를 변환하면 이 문제를 자연스럽게 피하는 경우가 많습니다.
class MyService { constructor() { this.baseUrl = "/api"; } // 좋음: then() 콜백에 화살표 함수 사용 fetchDataArrow() { return fetch(`${this.baseUrl}/data`) .then(response => response.json()) // 'this'는 MyService 인스턴스를 올바르게 참조합니다. .then(data => console.log(data)) .catch(error => console.error(error)); } // 또한 좋음: async/await는 본질적으로 함수 본문 내에서 'this'를 잘 처리합니다. async fetchDataAsync() { try { const response = await fetch(`${this.baseUrl}/data`); // 'this'는 MyService 인스턴스를 올바르게 참조합니다. const data = await response.json(); console.log(data); } catch (error) { console.error(error); } } }
6. 경쟁 조건을 위해 Promise.race
고려
Promise.race()
는 Promise의 이터러블을 받아, 해당 이터러블의 Promise 중 하나가 해결되거나 거부되는 즉시 해당 Promise의 값이나 이유로 해결되거나 거부되는 Promise를 반환합니다. 이는 타임아웃과 같은 시나리오에 유용합니다.
function timeout(ms) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error("요청 시간 초과!")), ms) ); } async function fetchWithTimeout(url, ms) { try { const response = await Promise.race([ fetch(url), timeout(ms) ]); const data = await response.json(); console.log("시간 내에 데이터 가져옴:", data); } catch (error) { console.error(error.message); // 너무 느리면 "요청 시간 초과!" 표시 } } fetchWithTimeout("/api/slow-data", 3000); // 3초 내에 가져오기 시도
결론
Promise와 async/await
는 JavaScript에서 비동기 프로그래밍을 근본적으로 변화시켜, 복잡한 "콜백 지옥"에서 훨씬 더 읽기 쉽고, 유지 관리하기 쉬우며, 강력한 패러다임으로 전환했습니다. Promise는 최종 값과 오류를 처리하기 위한 기본 메커니즘을 제공하며, async/await
는 이 기본 위에 구축되어 인지 부하를 크게 줄여주는 동기식과 유사한 구문을 제공합니다. 이러한 도구를 마스터하는 것은 단순히 더 우아한 코드를 작성하는 것 이상입니다. 그것은 현대 웹의 복잡한 비동기 특성을 비난적으로 처리할 수 있는 응답성 있고 효율적이며 오류 복원력 있는 JavaScript 애플리케이션을 구축하는 것입니다. Promise와 async/await
를 수용하고 JavaScript 개발에서 새로운 수준의 명확성과 강력함을 잠금 해제하십시오.