FEConf) 유인동님의 ES6+ 비동기 프로그래밍과 실전 에러 핸들링 영상을 정리한 내용입니다.
이미지의 높이를 더하는 코드를 작성해보려고 합니다.
const imgs = [
{name: 'image1', url:'https://picsum.photos/id/1/5000/3333'},
{name: 'image2', url:'https://picsum.photos/id/2/5000/3333'},
{name: 'image3', url:'https://picsum.photos/id/3/5000/3333'},
{name: 'image4', url:'https://picsum.photos/id/4/5000/3333'}
]
function f1() {
imgs
.map(({url}) => {
let img = new Image();
img.src = url;
return img;
})
.map(img => img.height)
.forEach(height => console.log(height))
}
f1();
이미지의 높이를 가져오기 위해 new Image()
로 이미지를 생성해줬습니다. 그리고 img.height
의 값을 로그로 찍어보지만 가져올 수 없습니다.
이유는 이미지 생성은 했지만, 이미지를 불러오진 않았기 때문입니다.
const loadImage = url => {
let img = new Image();
img.src = url;
img.onload = () => console.log(img.height)
return img
}
loadImage(imgs[0].url); // 3333
이미지의 높이를 구할 수 있는 시점은 img.onload
즉, 이미지가 로드 되었을 때 가져올 수 있게 됩니다.
이 시점을 외부에 알려줘야 이미지들의 높이를 더할 수 있는데, 이를 Promise
를 사용하면 해결할 수 있습니다.
const loadImage = url => new Promise(resolve => {
let img = new Image();
img.src = url;
img.onload = () => resolve(img)
return img
})
loadImage(imgs[0].url).then(img => console.log(img.height)); // 3333
이렇게 resolve를 사용하면 쉽게 구할 수 있습니다.
그럼 이제 만들어논 loadImage로 변경해주면 잘 동작할까요?
const imgs = [
{name: 'image1', url:'https://picsum.photos/id/1/5000/3333'},
{name: 'image2', url:'https://picsum.photos/id/2/5000/3333'},
{name: 'image3', url:'https://picsum.photos/id/3/5000/3333'},
{name: 'image4', url:'https://picsum.photos/id/4/5000/3333'}
]
const loadImage = url => new Promise(resolve => {
let img = new Image();
img.src = url;
img.onload = () => resolve(img)
return img
})
function f1() {
imgs
.map(({url}) => loadImage(url))
.map(img => img.height)
.forEach(height => console.log(height))
}
f1(); // undefined * 4
loadImage는 Promise
객체를 반환해서 undefined가 찍히게 됩니다.
이를 해결하기 위해 async await
을 사용하면 어떨까요?
function f1() {
imgs
.map( async ({url}) => await loadImage(url))
.map(img => img.height)
.forEach(height => console.log(height))
}
f1(); // undefined * 4
// ====================
function f1() {
imgs
.map( async ({url}) => {
const img = await loadImage(url);
return img.height;
})
.forEach(async height => console.log(await height)) // 3333 * 4
}
분명 Promise
객체에 async await
을 해주면 풀리는 걸로 알고 있는데 결과는 그대로 undefined만 찍히게 됩니다.
forEach안에서도 async await
을 해주면 높이가 잘 찍히게 됩니다.
그럼 이제 이미지들의 높이 합을 구해볼까요.
async function f1() {
const total = await imgs
.map( async ({url}) => {
const img = await loadImage(url);
return img.height;
})
.reduce(async (total, height) => await total + await height, 0);
console.log(total) // 13332
}
이렇게 코드를 작성하면 동작은 하지만 이 코드는 아슬아슬하게 동작하는 코드입니다.
왜 아슬아슬한 코드일까요?
만약 imgs 중 이미지 url 하나를 수정하고 코드를 실행하면 에러도 안나고 결과 값도 찍히지도 않는 이상한 동작을 하게 됩니다.
이럴 때 에러 핸들링(try-catch문
)을 사용해줘야 합니다.
에러를 잡으려면 에러가 발생해야하는데, 위 코드는 에러가 발생하지 않는 코드입니다.
const imgs = [
{name: 'image1', url:'https://picsum.photos/id/1/5000/3333'},
{name: 'image2', url:'https://picsum.photo/id/2/5000/3333'}, // 수정한 URL
{name: 'image3', url:'https://picsum.photos/id/3/5000/3333'},
{name: 'image4', url:'https://picsum.photos/id/4/5000/3333'}
]
const loadImage = url => new Promise((resolve, reject) => {
let img = new Image();
img.src = url;
img.onload = () => resolve(img);
img.onerror = () => reject(img);
return img
})
async function f1() {
const total = await imgs
.map( async ({url}) => {
try{
const img = await loadImage(url);
return img.height;
} catch(error) {
console.error(error)
}
})
.reduce(async (total, height) => await total + await height, 0);
console.log(total) // NaN
}
f1();
이미지의 url이 올바르지 않아 에러가 발생하면 이벤트를 onerror
로 받고 reject
로 에러를 발생시키고 try-catch문에서 에러를 잡아서 NaN
이라는 값이 찍혔습니다.
만약 에러가 났을 때 NaN
이 아닌 0이 찍히게 만든다면 다음처럼 수정하면 됩니다.
const loadImage = url => new Promise((resolve, reject) => {
let img = new Image();
console.log('이미지 로드', url)
img.src = url;
img.onload = () => resolve(img);
img.onerror = () => reject(img);
return img
})
async function f1() {
try{
const total = await imgs
.map( async ({url}) => {
try{
const img = await loadImage(url);
return img.height;
} catch(error) {
console.error(error);
throw error;
}
})
.reduce(async (total, height) => await total + await height, 0);
console.log(total)
} catch(error) {
console.log(0);
}
}
f1();
2번째 이미지로 인해 에러가 발생했고, 에러 핸들링을 했는데도 불구하고 그 뒤에 있는 코드까지 실행이 되고 있습니다.
이 코드는 에러 핸들링은 했지만 버그가 있는 코드로써 이전 코드들 보다 더 심각한 코드가 되었습니다.
만약 get으로 호출하는 이미지가 아닌 post와 같은 side effect
를 일으켜서 문제가 될 수 있고, 또한 get이어도 많은 양의 코드를 호출한다면 충분히 부하를 일으킬 수 있습니다.
// 제너레이터로 map 추상화
function* map(f, iter) {
for(const a of iter) {
// 부수효과를 내부에서 일으키지 않고 바깥으로 발생 시키기
yield a instanceof Promise ? a.then(f) : f(a);
}
}
async function f2() {
let acc = 0;
for (const a of map((img) => img.height, map(({url}) => loadImage(url), imgs))) {
acc = acc + await a;
}
console.log(acc);
}
f2(); // 13332
제너레이터로 map을 추상화시키고 yield
키워드를 통해 부수효과를 내부에서 일으키지 않고 바깥으로 발생 시키도록 작성했습니다.
또한 a가 Promise일 경우 함수 합성을 통해 비동기와 동기일 때 처리해줬습니다.
위 코드를 한 번 더 개선해보겠습니다.
// 제너레이터로 map 추상화
function* map(f, iter) {
for(const a of iter) {
// 부수효과를 내부에서 일으키지 않고 바깥으로 발생 시키기
yield a instanceof Promise ? a.then(f) : f(a);
}
}
async function reduceAsync(f, acc, iter) {
for (const a of iter) {
acc = f(acc, await a);
}
return acc;
}
const f2 = (imgs) =>
reduceAsync((a,b) => a + b, 0,
map((img) => img.height,
map(({url}) => loadImage(url), imgs)))
f2(imgs);
reduceAsync
라는 순수 함수를 만들어서 좀 더 안정적인 코드가 되었습니다.
또한 async await
을 없애고 함수 표현식으로 간결하게 가독성을 높이고, imgs를 인자로 제한해줬습니다.
에러가 나는 2번째 이미지 까지 동작을 하고 그 이후에는 동작하지 않으며, 에러를 발생시키고 있습니다.
async function f2(imgs) {
try {
console.log(
await reduceAsync((a,b) => a + b, 0,
map((img) => img.height,
map(({url}) => loadImage(url), imgs))))
} catch (error) {
console.error(error);
}
}
f2(imgs);
에러가 발생한다고해서 순수 함수 내에 try-catch문으로 에러 핸들링을 하기도 하는데 이 방법이 좋다고 생각하지 않습니다.
즉, 에러 핸들링을 하지 않는 코드가 좋은 코드라고 생각합니다.
async function f2(imgs) {
await reduceAsync((a,b) => a + b, 0,
map((img) => img.height,
map(({url}) => loadImage(url), imgs))))
}
f2(imgs).catch(_ => 0).then(log); // 에러가 안나는 코드 (13332)
f2(imgs1).catch(_ => 0).then(log); // 잘못된 인자로 에러가 나는 코드 (0)
순수 함수는 순수 함수 그 자체가 가장 좋고 에러를 발생시키게만 만들어주면 됩니다.
에러가 발생할 것 같다고 해서 순수 함수 내에 미리 에러 핸들링을 하는 것은 불필요한 것이고, 해당 함수의 사용을 제약하는 것이며, 에러를 숨기는 행동입니다.
에러 핸들링은 부수효과를 일으키는 코드 주변에 작성하는 것이 좋습니다.