공유된 기술노트 · 넥스트JS
자바스크립트 Promise에 대해서 제대로 이해하기- 스토리가 있는 뉴렉쌤의 설명
1. 자바스크립트가 사용하는 API의 두 얼굴: 동기(Sync)와 비동기(Async)
기본적으로 자바스크립트는 단일 스레드 이벤트 루프 기반의 환경을 사용합니다. 이 특성 때문에 '시간이 걸리는 작업'(파일 입출력, 네트워크 요청 등)을 처리하는 방식이 성능과 직결됩니다.
자바스크립트의 실행 환경인 Node.js 는 내장 모듈(예: fs)은 크게 두 가지 형태 동기형과 비동기형의 함수를 제공합니다.
1-1. 동기형 (Synchronous) 함수
- 특징: 작업이 끝날 때까지 현재 실행 컨텍스트를 블로킹(Blocking) 합니다. “쉽게 말하면 이 라인 작업이 끝날 때까지 다음 라인 실행하지 마세요.~”
- 이름 규칙: 함수명 뒤에
Sync가 붙습니다 (예:writeFileSync). - 장점: 코드가 직관적이고,
try...catch로 예외 처리가 쉽습니다. - 단점: 작업 중 Node.js 이벤트 루프가 멈추어 다른 요청을 처리할 수 없습니다.
1-2. 비동기형 (Asynchronous) 함수
- 특징: 작업을 시작하면 즉시 다음 코드로 넘어가고, 작업이 완료되면 콜백(Callback) 또는 Promise를 통해 결과를 전달합니다.
- 이름 규칙:
Sync접미사가 없습니다 (예:writeFile). - 장점: 이벤트 루프를 블로킹하지 않아 서버의 응답성이 유지됩니다.
- 단점: 코드의 흐름이 단절되어 가독성이 떨어질 수 있음(콜백 지옥 문제).
2. 동기형으로 시작하기: 단순하지만 위험한 선택
가장 먼저 동기형 함수를 사용해 파일을 읽고 쓰는 과정을 살펴봅니다.
VSCode(다른 편집기여도 상관없습니다.)를 열고 실습예제를 위한 폴더를 만들고 그 안에 다음 이름으로 파일을 추가합니다.
파일명: ex1-sync.js
const fs = require('fs');
function handler() {
const content = '안녕하세요! Node.js 동기식 메서드를 이용한 파일 쓰기 예제입니다.';
try {
// 1. 파일 쓰기 (블로킹)
// fs.writeFileSync(파일명, 내용, 인코딩)
fs.writeFileSync('example_sync.txt', content, 'utf8');
console.log('파일이 성공적으로 저장되었습니다!');
// 2. 파일 읽기 (블로킹)
// fs.readFileSync(파일명, 인코딩)
const data = fs.readFileSync('example_sync.txt', 'utf8');
console.log('파일 내용:');
console.log(data);
} catch (err) {
console.error('파일을 쓰는 중 오류가 발생했습니다:', err);
}
}
handler();
console.log("hello~");문제점: 블로킹의 대가
위 코드는 정상적으로 동작하지만, 만약 example_sync.txt가 1GB인 대용량 파일이거나, 네트워크 지연이 발생하는 API 호출이었다면 어떻게 될까요?
fs.writeFileSync가 실행되는 동안 Node.js 프로세스는 완전히 멈춥니다.- 이 사이에 다른 클라이언트의 요청이 들어와도 처리할 수 없습니다.
console.log("hello~")는 파일 작업이 끝난 후에만 출력됩니다.
결론: 서버 환경에서는 블로킹을 피해야 하므로, 비동기 처리가 필수적입니다.
3. 콜백(Callback)을 이용한 비동기 처리
전통적으로 자바스크립트는 블로킹을 제거하기 위해 콜백 함수를 이용해서 비동기화를 구현하고 있었습니다. 작업을 백그라운드에서 작업하게 하고 그 작업이 완료되면 해당 콜백이 실행되도록 하는 방식이죠.
콜백함수를 이용한 비동기함수의 동작방식을 이용해보기 위해 다음 파일을 생성하고 내용을 붙여넣기 합니다.
파일명: ex2-async-callback.js
const fs = require('fs');
function handler() {
const content = '안녕하세요! Node.js 비동기식 메서드를 이용한 파일 쓰기 예제입니다.';
// 1. 파일 쓰기 (비동기)
// fs.writeFile(파일명, 내용, 인코딩, 콜백함수)
fs.writeFile('example_sync.txt', content, 'utf8', function (err) {
// 파일 쓰기가 완료되는 시점에 호출되는 영역
if (err) {
console.error('쓰기 오류:', err);
return;
}
console.log('파일이 성공적으로 저장되었습니다!');
// 2. 파일 읽기 (비동기) - 쓰기 콜백 내부에서 호출
fs.readFile('example_sync.txt', 'utf8', (err, data) => {
if (err) {
console.error('읽기 오류:', err);
return;
}
console.log('파일 내용:');
console.log(data);
});
});
// 이 코드는 fs.writeFile가 완료되기 전에 즉시 실행됩니다!
console.log("안녕? (비동기 시작)");
}
handler();
console.log("hello~ (마지막 로그)");실행 결과의 순서
hello~ (마지막 로그)안녕? (비동기 시작)파일이 성공적으로 저장되었습니다!파일 내용: ...
문제점: 콜백 지옥 (Callback Hell)
위 코드는 수직 방향으로 단계별로 실행되는 코드 흐름이 아니라서 코드의 흐름이 파악하기 힘들어졌습니다. 콜백함수를 사용하게 되면서 두 단계의 작업(쓰기 -> 읽기)만으로도 코드가 오른쪽으로 중첩되는 흐름을 가지게 되었습니다.
다음 그림은 그 실행 흐름을 이전 코드와 비교하고 있습니다.

- 3단계, 4단계... 작업이 추가되면 중첩 깊이가 깊어지고 코드가 '피라미드' 모양으로 변형됩니다.
- 에러 처리가 복잡해지며, 코드의 가독성과 유지보수성이 급격히 떨어집니다.
해결책이 필요했습니다. "동기 코드처럼 위에서 아래로 읽기 쉽게, 하지만 비동기로 동작하는 방식"이 바로 Promise입니다.
4. 프라미스(Promise)를 이용한 비동기 처리
콜백의 중첩 문제를 해결하기 위해 도입된 것이 바로 Promise입니다. Promise를 사용하면 콜백으로 인한 중첩이 깊어지는 문제를 지우고, 마치 동기 방식처럼 위에서 아래로 순서대로 코드를 작성할 수 있습니다.
다만, await 키워드를 사용하려면 반드시 비동기 함수(async function) 내부에서만 사용할 수 있습니다. 자바스크립트는 이 비동기 블록을 쉽게 만들 수 있도록 async 키워드를 제공합니다.
파일명: ex3-async-promise.js
const fs = require('fs').promises;
// 비동기로 호출되는 함수여야만... await를 사용할 수 있어요
async function handler() {
const content = '안녕하세요! Node.js 동기식 메서드를 이용한 파일 쓰기 예제입니다.';
try {
// fs.writeFileSync(파일명, 내용, 인코딩)
await fs.writeFile('example_sync.txt', content, 'utf8');
console.log('파일이 성공적으로 저장되었습니다!');
// fs.readFileSync(파일명, 인코딩)
const data = await fs.readFile('example_sync.txt', 'utf8');
console.log('파일 내용:');
console.log(data);
} catch (err) {
console.error('파일을 쓰는 중 오류가 발생했습니다:', err);
}
}
handler();
console.log("hello~");async: 함수가 비동기임을 선언하고, 함수의 반환값이 Promise가 되도록 만듭니다.await: Promise가 해결(resolve)될 때까지 대기합니다. 이로 인해 코드의 흐름이 동기적으로 보이며,try...catch로 에러 처리가 깔끔해집니다.
5. 비동기 함수를 포함하는 어떤 함수도 결국은 콜백의 늪을 벗어날 수 없다
async/await가 모든 문제를 해결하는 것처럼 보이지만, 함수를 생성하는 입장에서 보면 여전히 콜백의 한계가 존재합니다.
만약 우리가 "시간이 걸리는 작업(예: 피자 굽기)"을 수행하는 함수를 직접 만들 때, 그 작업이 끝났다는 사실을 호출자에게 알려주기 위해 콜백 함수를 매개변수로 받아야 합니다. 이것이 바로 "콜백의 늪"입니다.
파일명: ex4-callback-param.js
function order() {
console.log("주문 완료");
return 3;
}
// cook 함수는 비동기 작업(3초 대기)을 수행합니다.
// 작업 완료 후 결과를 전달하기 위해 callback 매개변수가 필수적입니다.
function cook(amount, callback) {
console.log("피자 주문이 들어왔습니다. 요리를 시작하겠습니다.");
// 요리를 하는 시간이 오래 걸리 수 있음....
// 비동기 함수를 사용하는 경우의 예시
setTimeout(() => {
console.log(`갓 구운 페페로니 피자 ${amount}개가 완성`);
// 작업 완료 시 콜백을 실행하여 결과를 전달
callback();
}, 3000);
}
let amount = order();
cook(amount, () => {
// 이 코드는 비동기가 포함된 cook 함수가 실행된 후에 실행되어야 합니다.
console.log("결제금액은 얼마입니다.");
console.log("피자 잘 받았습니다. 맛있게 먹겠습니다.");
});문제점 분석
- 콜백 매개변수 필수:
cook함수를 만들 때, 작업 완료 후 실행할 로직을 외부에서 주입받기 위해callback인자가 필요합니다. - 가독성 저하: 호출하는 쪽(
cook(amount, () => {...}))에서도 중첩된 구조가 생깁니다. - 확장성 부족: 만약
cook이후에 또 다른 비동기 작업이 이어진다면, 콜백의 중첩은 계속 깊어집니다.
해결책: 함수가 콜백을 매개변수로 받지 않고, Promise 객체를 반환하도록 수정하면, 호출하는 쪽에서는 await 또는 .then()으로 깔끔하게 처리할 수 있습니다.
6. Promise 반환과 await를 이용한 콜백 탈출
이제 cook 함수를 수정하여 콜백 매개변수를 제거하고, 대신 Promise를 반환하도록 변경해봅니다. 이렇게 하면 호출하는 쪽에서는 async/await를 사용하여 콜백의 중첩 없이 순차적인 코드를 작성할 수 있습니다.
파일명: ex5-promise-return.js
function order() {
console.log("주문 완료");
return 3;
}
// cook 함수가 Promise를 반환하도록 수정
function cook(amount) {
console.log("피자 주문이 들어왔습니다. 요리를 시작하겠습니다.");
// Promise를 반환합니다.
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`갓 구운 페페로니 피자 ${amount}개가 완성`);
// 작업 성공 시 resolve 호출
resolve([{ name: 'Pepperoni', price: 15000 }]);
}, 3000);
});
}
// async 함수를 만들어 await 사용
async function handler() {
let amount = order();
// cook 함수는 이제 Promise를 반환하므로 await로 결과를 기다릴 수 있습니다.
const pizzas = await cook(amount);
// 이 코드는 cook 함수가 실행된 후에 실행됩니다.
console.log("결제금액은 얼마입니다.");
console.log(`총 금액: ${pizzas[0].price * amount}원`);
console.log("피자 잘 받았습니다. 맛있게 먹겠습니다.");
}
handler();- 장점:
cook함수는 순수하게 "작업 수행"에만 집중하고, 결과는 Promise로 전달합니다. - 호출 측:
await cook(amount)한 줄로 결과를 받을 수 있어 콜백 지옥이 사라집니다.
7. Promise 체인(.then)을 이용한 미들웨어 시스템
await가 가장 직관적이지만, Promise 체인(.then) 방식은 데이터 파이프라인이나 미들웨어 시스템에서 매우 유용합니다. 각 .then() 블록은 이전 단계의 결과를 받아 처리하고, 다음 단계로 전달하는 구조를 가집니다.
파일명: ex6-promise-chain.js
function order() {
console.log("주문 완료");
return 3;
}
function cook(amount) {
console.log("피자 주문이 들어왔습니다. 요리를 시작하겠습니다.");
return new Promise((resolve) => {
setTimeout(() => {
console.log(`갓 구운 페페로니 피자 ${amount}개가 완성`);
// 배열 형태로 반환하여 체인 처리 가능
resolve([{ name: 'Pepperoni', price: 15000 }, { name: 'Cheese', price: 12000 }]);
}, 3000);
});
}
let amount = order();
let promise = cook(amount);
promise
.then(pizzas => {
// 1단계: 첫 번째 피자 객체 추출
console.log("1단계: 피자 목록 수신");
return pizzas[0];
})
.then(pizza => {
// 2단계: 피자 이름 추출 (예: 인코딩, 변환 등)
console.log("2단계: 이름 추출 ->", pizza.name);
return pizza.name;
})
.then((name) => {
// 3단계: 최종 처리
console.log("3단계: 최종 결과 ->", name);
console.log("결제금액은 얼마입니다.");
console.log("피자 잘 받았습니다. 맛있게 먹겠습니다.");
})
.catch((err) => {
// 에러 처리
console.log("ㅜㅜ 안대~~~~요~~", err);
});await vs .then 체인 비교
| 구분 | async/await |
.then() 체인 |
|---|---|---|
| 가독성 | 동기 코드와 유사하여 직관적 | 데이터 흐름(파이프라인)이 명확함 |
| 에러 처리 | try...catch로 블록 단위 처리 |
.catch()로 전체 체인 에러 처리 |
| 활용 분야 | 일반적인 비즈니스 로직, 순차적 처리 | 데이터 변환, 미들웨어, 파이프라인 처리 |
| 단점 | 중첩이 깊어지면 여전히 복잡해질 수 있음 | 코드가 길어지면 읽기 어려울 수 있음 |
- 미들웨어 시스템: Express.js의 미들웨어나 데이터 처리 파이프라인처럼, 이전 단계의 결과를 변환하여 다음 단계로 전달하는 구조에서는
.then()체인이 더 명확한 경우가 많습니다.
8. 기존 콜백 함수를 Promise로 쉽게 변환하기: util.promisify
기존에 작성된 콜백 기반 함수를 직접 new Promise로 감싸는 것이 번거롭다면, Node.js 내장 모듈 util의 promisify 함수를 사용할 수 있습니다.
- 동작 원리: 콜백 기반 함수를 받아서, Promise를 반환하는 새로운 함수를 생성합니다.
- 주의:
fs모듈처럼 이미 Promise 버전(fs.promises)이 제공되는 경우promisify를 사용할 필요가 없습니다. 하지만 제3자 라이브러리나 직접 작성한 콜백 함수를 변환할 때 유용합니다.
파일명: ex7-promisify.js
const util = require('util');
// 기존 콜백 기반 함수
function oldCook(amount, callback) {
console.log("피자 주문이 들어왔습니다. (콜백 방식)");
setTimeout(() => {
console.log(`갓 구운 페페로니 피자 ${amount}개가 완성`);
callback(null, [{ name: 'Pepperoni', price: 15000 }]); // (err, data) 형식
}, 3000);
}
// promisify로 변환
const promisifiedCook = util.promisify(oldCook);
async function handler() {
let amount = 3;
// 변환된 함수는 Promise를 반환하므로 await로 사용 가능
const pizzas = await promisifiedCook(amount);
console.log("결과:", pizzas);
}
handler();- 장점: 기존 콜백 코드를 수정하지 않고도 Promise 기반 코드로 쉽게 마이그레이션할 수 있습니다.
- 단점: 함수의 내부 로직이 복잡하거나, 콜백이 여러 번 호출되는 경우(예: 스트림)에는
promisify가 동작하지 않을 수 있습니다.
9. 모든 Promise가 끝난 후 실행: Promise.all
여러 개의 비동기 작업이 동시에 시작되고, 모두 완료된 후 결과를 처리해야 하는 경우가 있습니다. 예를 들어, 여러 API에서 데이터를 동시에 가져와서 합산하는 경우입니다.
Promise.all([p1, p2, p3]): 전달된 모든 Promise가 성공적으로 완료되면, 그 결과들을 배열로 반환합니다.- 주의: 전달된 Promise 중 하나라도 실패(reject) 하면,
Promise.all도 즉시 실패합니다.
파일명: ex8-promise-all.js
function fetchPizzaData() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("피자 데이터 수신");
resolve({ name: 'Pepperoni', price: 15000 });
}, 2000);
});
}
function fetchDrinkData() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("음료 데이터 수신");
resolve({ name: 'Cola', price: 3000 });
}, 3000);
});
}
async function handler() {
console.log("주문 시작: 피자 + 음료");
// 두 개의 비동기 작업이 동시에 시작됩니다.
const [pizza, drink] = await Promise.all([
fetchPizzaData(),
fetchDrinkData()
]);
// 두 작업 모두 완료된 후 이 코드가 실행됩니다.
console.log("모든 데이터 수신 완료!");
console.log(`총 결제 금액: ${pizza.price + drink.price}원`);
}
handler();- 장점: 순차적으로
await를 사용하는 것보다, 독립적인 비동기 작업들을 병렬로 실행하여 성능을 최적화할 수 있습니다. 그 외에도 여러 동작이 모두 완료되는 시점에 다음 동작으로 이어가기 때문에 여러 작업이 모두 끝나면 실행되어야 할 코드가 있을 경우도 유용합니다. - 활용 예시: 페이지 로드 시 여러 API 호출, 파일 여러 개를 동시에 읽기, 데이터베이스 쿼리 병렬 실행 등.
이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.