비동기 프로그래밍과 에러 핸들링

Taemin Jang·2024년 7월 24일
0

Javascript

목록 보기
14/14

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)

순수 함수는 순수 함수 그 자체가 가장 좋고 에러를 발생시키게만 만들어주면 됩니다.

에러가 발생할 것 같다고 해서 순수 함수 내에 미리 에러 핸들링을 하는 것은 불필요한 것이고, 해당 함수의 사용을 제약하는 것이며, 에러를 숨기는 행동입니다.

에러 핸들링은 부수효과를 일으키는 코드 주변에 작성하는 것이 좋습니다.

profile
하루하루 공부한 내용 기록하기

0개의 댓글

관련 채용 정보