Day.8의 콜백 함수 다음 단계, Promise와 async/await.
"비동기 작업이 끝난 뒤의 결과를 어떻게 받을까"에 대한 두 번째, 세 번째 답을 배운 날이다.
학습 아홉 번째 날이다. 오늘 배운 내용은 이렇다.
resolve와 rejectthen, catch와 체이닝try/catchstopPropagation||, &&로 값 고르기 (리액트 문법)map, forEach, filterPromise는 비동기 작업(서버 요청, 타이머 등)의 결과를 나중에 받아서 처리하기 위해 쓰는 객체다. 상태 관리가 명확하고, then을 이어 붙이는 체이닝 문법이라 코드 읽기가 편하다.
const promise = new Promise((resolve, reject) => {
// 성공: resolve(data) → 보통 성공했을 때 호출
// 실패: reject(error) → 실패했을 때 호출
});
new Promise()의 인자로 비동기 작업을 진행할 콜백 함수를 넣는다. 이 함수를 executor라고 하고, 매개변수로 받는 resolve와 reject가 Promise의 상태를 바꾸는 스위치다.
| 상태 | 의미 |
|---|---|
pending | 대기 중. resolve도 reject도 아직 호출되지 않은 초기 상태 |
fulfilled | 성공. resolve가 호출됨 |
rejected | 실패. reject가 호출됨 |
const promise = new Promise(() => {
setTimeout(() => {
console.log('hi');
}, 3000);
});
console.log(promise);
// Promise {<pending>}
// (3초 뒤) hi
resolve와 reject를 받지도, 호출하지도 않았으므로 이 Promise는 영원히 pending이다. 3초 뒤 hi가 출력되지만, Promise의 상태는 바뀌지 않는다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('hi');
resolve('안녕'); // 성공 상태로 바꾸고, 결과값으로 '안녕'을 전달
}, 3000);
});
setTimeout(() => {
console.log(promise);
}, 3000);
// hi
// Promise {<fulfilled>: '안녕'}
두 타이머가 모두 3초지만, Promise 안의 타이머가 먼저 등록되어 있어서 먼저 실행된다. resolve('안녕')이 호출되어 상태가 fulfilled가 되고, 그 뒤에 아래 타이머가 상태를 출력한다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('hi');
reject('reject'); // 실패 상태로 바꾸고, 실패 이유로 'reject'를 전달
}, 3000);
});
setTimeout(() => {
console.log(promise);
}, 3000);
// hi
// Promise {<rejected>: 'reject'}
reject("reject")는 단순히 값을 반환하는 게 아니라, reject 스위치를 작동시키면서 그 값을 실패 이유로 전달하는 것이다.
이 코드를 콘솔에서 실행하면
Uncaught (in promise) reject라는 빨간 오류도 함께 뜬다. 실패한 Promise를catch로 처리하지 않았기 때문이다. 바로 아래에서catch를 다룬다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const n = 100;
if (typeof n === 'number') {
resolve(n + 100); // 숫자이면 성공: 200
} else {
reject('너 숫자 아닌데'); // 숫자가 아니면 실패
}
}, 3000);
});
setTimeout(() => {
console.log(promise); // Promise {<fulfilled>: 200}
console.log(typeof(promise)); // object
}, 4000);
Promise는 3초가 걸리는 작업이고, 확인하는 타이머는 4초다. 3초에 resolve(200)이 호출되어 이미 fulfilled가 된 뒤에 출력하므로 성공으로 보인다. 만약 확인하는 타이머가 2초였다면 아직 작업이 끝나기 전이라 pending으로 보였을 것이다. typeof(promise)는 object다. Promise도 객체이다.
상태를 직접 출력해 보는 방식은 불편하다. 보통은 성공했을 때 실행할 함수와 실패했을 때 실행할 함수를 등록한다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const n = 100;
if (typeof n === 'number') {
resolve(n + 100);
} else {
reject('너 숫자 아닌데');
}
}, 3000);
});
promise.then((value) => {
console.log(value); // 3초 뒤 200
});
then은 "그 후에"라는 뜻이다. Promise가 성공했을 때 실행할 콜백을 등록하는 메소드다. executor에서 resolve를 호출하면, then에 전달한 콜백이 실행되면서 resolve에 넘긴 값이 매개변수 value로 들어온다.
실행 순서를 정리하면 이렇다.
new Promise가 실행되고 executor가 실행되어 setTimeout이 예약된다. (3초 대기)promise.then(...)이 등록되어 resolve나 reject를 기다린다.setTimeout이 실행된다. n이 숫자이므로 resolve(200)이 호출된다.pending에서 fulfilled로 바뀐다.then()이 실행되면서 value로 200을 받아 출력한다.필기에서는 이 예제의
n을'100'(문자열)으로 적어 놓고 결과를 200으로 설명했는데,'100'은typeof가string이라서 reject로 가고then은 실행되지 않는다. 위 코드는 숫자100으로 고쳤다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const n = null;
if (typeof n === 'number') {
resolve(n + 100);
} else {
reject('너 숫자 아닌데');
}
}, 3000);
});
promise.catch((error) => {
console.log(error); // 3초 뒤 '너 숫자 아닌데'
});
catch는 then과 반대로 Promise의 reject, 즉 실패값을 받아 실행되는 함수를 등록한다. reject에 전달한 값이 error로 들어온다. null의 typeof는 'object'라서 숫자 조건을 통과하지 못하고 reject로 간다.
then을 이어 붙여서 앞 단계의 결과를 다음 단계로 넘길 수 있다. 각 then이 return한 값이 다음 then의 value가 된다.
const myPromise = new Promise((resolve) => resolve('foo'));
myPromise
.then((value) => `${value} and bar`)
.then((value) => `${value} and bar again`)
.then((value) => `${value} and again`)
.then((value) => `${value} and again`)
.then((value) => {
console.log(value); // foo and bar and bar again and again and again
})
.catch((err) => {
console.error(err); // 중간에 어디서든 실패하면 여기로
});
위에서 아래로 읽으면 순서대로 일이 진행되는 것이 보인다. 중간 어느 단계에서 문제가 생기든 맨 끝의 catch 하나가 처리한다. Day.8의 콜백 함수로 같은 일을 하려면 콜백 안에 콜백을 계속 넣어야 해서 코드가 안쪽으로 파고드는데, 체이닝은 이를 평평하게 펴 준다.
필기 제목에 별표가 잔뜩 붙은 부분이다. "초 중요!"
async function test() {
return {
name: 'gildong',
id: 'hong'
};
}
console.log(test());
// Promise {<fulfilled>: {name: 'gildong', id: 'hong'}}
함수 앞에 async를 붙이면 그 함수의 반환값을 자동으로 Promise로 감싸 준다. 위 함수는 기다릴 코드가 없고 즉시 끝까지 실행되므로, 바로 fulfilled 상태의 Promise가 반환된다.
async function test() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
name: 'gildong',
id: 'hong'
});
}, 2000);
});
}
console.log(test()); // Promise {<pending>}
test().then(result => console.log(result)); // 2초 뒤 {name: 'gildong', id: 'hong'}
console.log(test())는 Promise 객체 자체를 출력한 것이다. 내부 Promise가 아직 기다리는 중이라 pending으로 보인다..then(result => ...)의 result는 resolve에 넘긴 객체 값이다. 2초 뒤 fulfilled가 되면서 값이 나온다.async function test() {
await new Promise((resolve) => setTimeout(resolve, 2000));
// 그냥 2초 동안 기다리는 장치다
return {
name: 'aa',
age: '123'
};
}
async function test2() {
const data = await test(); // test()가 끝날 때까지 기다렸다가 결과를 data에 대입
console.log(data);
}
test2(); // 2초 뒤 {name: 'aa', age: '123'}
await는 Promise가 끝날 때까지(결과값을 받을 때까지) 기다린다.setTimeout(resolve, 2000)은 "2초 뒤에 resolve를 호출해라"라는 뜻이다. 값 없이 시간만 보내는 용도로 쓸 수 있다.async가 필요한 경우는 두 가지다. ① 함수 안에서 await를 쓸 때, ② 함수가 Promise를 반환하게 만들고 싶을 때.await는 async 함수 안에서만 쓸 수 있다. 그래서 위의 test2도 async로 선언했다.
function test() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('1초 후 완료');
}, 1000);
});
}
async function start() {
console.log('시작');
const result = await test(); // Promise가 끝날 때까지 기다린 뒤, resolve로 전달된 문자열을 result에 저장
console.log(result);
console.log('끝');
}
start();
// 시작
// (1초 뒤) 1초 후 완료
// 끝
await 덕분에 비동기 코드인데도 위에서 아래로 순서대로 읽힌다. console.log('끝')은 await가 끝나기 전까지 실행되지 않는다.
체이닝에서는 성공이면 then, 실패이면 catch였다. async/await에서는 성공이면 try 안, 실패(reject)이면 catch 안으로 간다.
console.log('홍길동');
function profile(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id == 1) {
resolve({ id: 1, name: '김길동' });
} else {
reject('프로필 없음');
}
}, 1000);
});
}
console.log('이길동');
여기까지의 출력은 홍길동, 이길동이다. profile()을 호출하기 전이라 동기 코드만 실행된다.
id를 100으로 주면 reject가 호출되므로 catch가 실행된다.
profile(100)
.then((user) => console.log(user))
.catch((error) => console.log(error));
// 홍길동
// 이길동
// (1초 뒤) 프로필 없음
profile(1)이면 then이 실행되어 {id: 1, name: '김길동'}이 출력된다.
async function start() {
try { // 예외가 날 수도 있는 코드
const pro = await profile(100);
console.log(pro);
} catch (error) { // 예외가 발생하면 여기서 처리
console.log(error); // 프로필 없음
}
}
start();
| 방식 | 성공 | 실패 |
|---|---|---|
| 체이닝 | .then() | .catch() |
| async/await | try { } | catch (error) { } |
await한 Promise가 reject되면 그 지점에서 예외가 던져지고, catch 블록으로 넘어간다.같은 일을 세 가지 방식으로 구현해 보면 차이가 보인다. 3초 뒤 'hi'를 전달받아 출력하는 문제다.
① 콜백
function fun1(callback) {
setTimeout(() => { callback('hi'); }, 3000);
}
fun1((msg) => {
console.log(msg);
});
② Promise
function fun2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve('hi'), 3000);
});
}
fun2().then((value) => console.log(value));
③ async/await
async function start() {
const msg = await fun2();
console.log(msg);
}
start();
| 방식 | 결과를 받는 법 | 특징 |
|---|---|---|
| 콜백 | 함수를 인자로 넘기고, 완료 시점에 호출 | 단계가 많아지면 콜백 안에 콜백이 겹친다 |
| Promise | then, catch로 연결 | 체이닝으로 평평하게 쓸 수 있다 |
| async/await | await로 기다려서 변수에 받음 | 동기 코드처럼 위에서 아래로 읽힌다 |
커피 예제(Day.8의 coffee)도 같은 방식으로 바꿔 본다.
// Promise를 반환하는 함수
function test() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('latte');
}, 2000);
});
}
async function coffee() {
let coffee2 = await test();
console.log(coffee2); // 2초 뒤 latte
}
coffee();
필기의
test는async function test()로 되어 있었는데, 이미new Promise를 직접 반환하고 있으므로async를 붙이지 않아도 결과가 같다. 위에서는 뺐다.
수업 문제에서 필기에 "내가 푼 것"과 "강사님이 푼 것"을 나란히 적어 놓은 부분이 있다.
// 내가 푼 것
const fun1 = new Promise((resolve, reject) => {
setTimeout(() => resolve('hi'), 3000);
});
fun1.then((value) => console.log(value));
// 강사님이 푼 것
function fun2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve('hi'), 3000);
});
}
둘 다 hi가 나오지만 차이가 있다. const fun1 = new Promise(...)은 변수에 대입하는 순간 작업이 시작되고, 호출할 때마다 새로 실행되는 것이 아니다. fun2()처럼 함수로 감싸면 호출하는 시점에 Promise가 새로 만들어져 필요할 때 필요한 만큼 실행할 수 있다. async/await로 바꿀 때도 await fun2()처럼 함수 호출 형태가 자연스럽다.
<style>
#d1 {
border: 3px solid black;
padding: 20px;
}
#d1 a {
text-decoration: none;
border: 3px solid black;
}
</style>
<div id="d1">
<a class="a1" href="#none">태그링크</a>
</div>
<script>
let d1 = document.querySelector('#d1');
let a1 = document.querySelector('.a1');
// div를 클릭하면 테두리 색을 blue로 (고전 이벤트 방식)
d1.onclick = function () {
this.style.borderColor = 'blue';
};
// a1을 클릭하면 red
a1.addEventListener('click', function (e) {
e.stopPropagation(); // 전파 막기
this.style.borderColor = 'red';
});
</script>
stopPropagation 줄을 지우고 a를 클릭하면 a의 테두리는 빨강, 부모 div의 테두리도 파랑으로 바뀐다. 처음에는 a만 클릭했는데 왜 부모까지 바뀌는지가 의문이었다.
답은 이벤트 버블링이다. 자식 요소에서 이벤트가 발생하면 그 이벤트가 부모, 그 위 부모로 거품처럼 올라가며 전달된다. a는 d1의 자식이고 d1에 클릭 이벤트가 걸려 있으므로, a를 클릭하면 d1의 onclick도 함께 실행된다.
e.stopPropagation(): 이벤트 전파를 막는다. a를 클릭해도 부모 d1의 이벤트가 실행되지 않는다.Day.8의 To Do List에서 목록 전체(#list)에만 이벤트를 걸어도 안쪽 아이콘 클릭이 처리되었던 이유가 바로 이 버블링 덕분이다. 자식의 이벤트가 부모까지 올라오니까 부모에서 e.target으로 받을 수 있었다.
||와 &&로 값 고르기 (리액트 문법)||와 &&는 Day.2에서 조건식에 쓰는 논리 연산자로 배웠다. 리액트에서는 이것을 조건에 따라 값을 고르는 용도로 쓴다.
아래 값들은 조건에서 모두 false로 인식된다.
false0"" (빈 문자열)nullundefinedNaN|| 연산자const n = null;
const m = n || '문자열';
console.log(m); // 문자열 (왼쪽이 falsy → 오른쪽)
const n2 = 20;
const m2 = n2 || '문자열';
console.log(m2); // 20 (왼쪽이 truthy → 왼쪽)
값이 없을 때 기본값을 주는 용도로 쓸 수 있다.
&& 연산자const n3 = null;
const m3 = n3 && '문자열';
console.log(m3); // null (왼쪽이 falsy → 왼쪽)
const n4 = 100;
const m4 = n4 && '문자열';
console.log(m4); // 문자열 (왼쪽이 truthy → 오른쪽)
두 연산자는 true/false만 돌려주는 것이 아니라 피연산자 중 하나를 그대로 돌려준다. 조건 && 보여줄 것은 "조건이 참일 때만 보여준다"는 뜻으로 쓰이고, 리액트에서 화면 일부를 조건부로 보여줄 때 자주 쓰인다고 한다.
배열을 반복하는 방법을 하나씩 비교해 본다.
const members = ['tom', 'jack', 'amy', 'lisa'];
// 1. for in
for (let i in members) {
console.log(members[i]);
}
// 2. map: for 문보다 간단하게 루프하고, 결과를 새 배열로 돌려준다
const members2 = members.map((i) => {
return i;
});
console.log(members2); // ['tom', 'jack', 'amy', 'lisa'] (새 배열)
// map의 두 번째 인자는 인덱스
members.map((i, m) => console.log(i + ',' + m));
// tom,0 / jack,1 / amy,2 / lisa,3
// 3. forEach
members.forEach((a, b, c) => {
console.log(a); // a: 값, b: 인덱스, c: 원본 배열
});
i가 값이라는 것은 members.map((i) => console.log(i + '!'))처럼 문자열을 붙여 보면 tom!, jack!...으로 나와서 확인할 수 있다.
| 메소드 | 반환값 | 용도 |
|---|---|---|
forEach | undefined | 요소를 하나씩 꺼내서 일을 시킬 때 |
map | 새로운 배열 | 요소를 변환해서 새 배열을 만들 때 |
filter | 조건에 맞는 요소만 담은 새 배열 | 조건에 일치하는 값만 추출할 때 |
forEach는 결과를 돌려주지 않고, map은 콜백이 return한 값들로 새 배열을 만든다. 위에서 map으로 값을 출력만 한 것은 forEach로 쓰는 편이 의도에 맞다.
const pro = ['길동', '철수', '영희'];
const pro2 = pro.map((i) => {
if (i === '철수') {
return i;
} else {
return `${i}~~`;
}
});
console.log(pro2); // ['길동~~', '철수', '영희~~']
콜백이 return한 값이 새 배열의 같은 위치에 들어간다. 원본 pro는 그대로다.
// 6이면 +10, 숫자가 아니면 '숫자아님'
const num2 = [6, 6, '6', 6, '6'];
const num3 = num2.map((i) => {
if (i === 6) {
return i + 10;
} else {
return '숫자아님';
}
});
console.log(num3); // [16, 16, '숫자아님', 16, '숫자아님']
===로 비교했기 때문에 문자열 '6'은 숫자 6과 다르다고 판정된다. Day.1에서 배운 ==와 ===의 차이가 그대로 결과에 나타난 것이다.
const num = [1, 2, 3, 4, 5];
const odd = num.filter((i) => {
return i % 2 == 1; // 홀수만
});
console.log(odd); // [1, 3, 5]
filter는 콜백이 true를 반환한 요소만 모아서 새 배열로 만든다.
오늘의 큰 줄기는 비동기 결과를 받는 방법이 콜백 → Promise → async/await로 발전한다는 것이다.
pending에서 시작해서 resolve가 호출되면 fulfilled, reject가 호출되면 rejected가 된다.then은 성공, catch는 실패를 처리하고, 체이닝으로 이어 붙일 수 있다.async 함수는 항상 Promise를 반환하고, await는 async 함수 안에서 Promise가 끝날 때까지 기다린다.try/catch로 처리한다.stopPropagation()으로 막을 수 있다.||와 &&는 true/false가 아니라 피연산자 중 하나를 그대로 반환한다.map은 새 배열을 만들고, forEach는 아무것도 반환하지 않으며, filter는 조건에 맞는 요소만 남긴다.Tags: JavaScript Promise async await 비동기 이벤트버블링 map filter 개발자 학습기록