JS-21 비동기 처리 (22/12/21)

nazzzo·2022년 12월 21일
0

Javascript


1. 비동기 코드

비동기 코드란 실행 순서가 정해져 있지 않은 코드를 의미합니다
바꿔 말하면 언제 실행될지 알 수 없다는 말과 같습니다

이것은 우리가 일반적으로 코드를 작성할 때 사용하는 동기 코드와는 달리,
코드의 실행이 순차적으로 진행되지 않을 수 있어서
코드의 흐름을 예측하기 어렵기 때문입니다

이러한 비동기 코드는 주로 서버와 통신할 때 사용됩니다
서버는 보통 1대 다수를 상대하기 때문에
이와 관련한 작업을 일반적인 동기 코드로 작성할 경우
전체 작업이 완료될 때까지 클라이언트 측의 대기시간의 텀이 한없이 길어질 수 있습니다
이를 피하기 위해서는 작업을 비동기적으로 처리해야 합니다


(비동기 코드의 연산은 일반적으로 브라우저의 자바스크립트 엔진이 처리합니다)


비동기 코드를 사용해야만 하는 이상
이것을 다룰 방법에 대해서도 알아야겠죠
우리는 이러한 비동기 코드들의 발동시점을 특정하기 위해
조건을 거는 등의 여러 기법을 활용해서 처리할 수 있습니다


socket.on('data', (chunk)=>{
    console.log(chunk)
})

net.createServer((socket)=>{
    // '데이터가 넘어왔을 때' 다음 콜백함수가 실행됩니다
    socket.on('data',(chunk)=>{
        const req = request(chunk)
        if() {
            // 어쩌고 저쩌고...
        }
    })
})

(↑ TCP 통신에서 살펴본 비동기 예제...)


클라이언트 측에서 서버에 코드를 전달할 때는
그것이 언제 도착할 지를 특정할 수 없기 때문에
비동기 코드로 작성할 수 밖에 없습니다

하지만 이런 작업을 하다보면 자연스레 코드 안에서 코드,
또 그 안에서 코드가 실행되는 중첩 구조가 형성되면서
점점 코드의 뎁스(depth)가 커지게 되는데
이러한 현상을 '콜백 지옥(Callback Hell)'이라고 부릅니다


*비동기 처리과정에서 콜백 함수는 어떤 작업을 수행한 후 그 결과를 처리하기 위해 사용됩니다



2. callback


const taskA = () => {
  setTimeout(() => {
    console.log("A Clear");
  }, 3000);
};

const taskB = () => {
  setTimeout(() => {
    console.log("B Clear");
  }, 2000);
};

const taskC = () => {
  setTimeout(() => {
    console.log("C Clear");
  }, 1000);
};

위 코드를 바탕으로 taskA가 클리어되면 taskB를 요청하고,
taskB가 클리어되면 taskC를 요청하는 구조의 코드를 작성해야 한다면
어떻게 해야 할까요?


const task A = () => {
  setTimeout(() => {
    console.log("A Clear");
    taskB()
  }, 3000);
};

const taskB = () => {
  setTimeout(() => {
    console.log("B Clear");
    taskC()
  }, 2000);
};

const taskC = () => {
  setTimeout(() => {
    console.log("C Clear");
  }, 1000);
};

// > A Clear
// B Clear
// C Clear

↑ 이것으로 원하는 결과는 얻었습니다

하지만 taskA를 실행하면 무조건 taskB와 taskC가 순차적으로 실행된다는 것,
하나의 함수만 출력할 수는 없다는 문제가 생깁니다
다시 말해서 위와 같은 코드는 확장성이 크게 떨어지는 해결법입니다


이런 문제에 대응하기 위해 사용할 수 있는 것이 바로 콜백(callback)입니다

const taskA = (callback) => {
  setTimeout(() => {
    console.log("A Clear");
    callback()
  }, 3000);
};

const taskB = (callback) => {
  setTimeout(() => {
    console.log("B Clear");
    callback()
  }, 2000);
};

const taskC = (callback) => {
  setTimeout(() => {
    console.log("C Clear");
    callback()
  }, 1000);
};

// 호출
taskA(() => {
    taskB(()=>{
        taskC(()=>{
            console.log("end")
        })
    }) 
})

// >
// A Clear
// B Clear
// C Clear
// end

↑ 이렇게 함수 안에 호출용 콜백을 끼워넣는 것만으로도
보다 확장성이 높은 코드가 만들어집니다

또환 위와 같은 방식을 사용하면
taskA > taskB > 다시 taskA를 실행하는 것도 가능합니다

taskA(()=> {
    taskB(()=>{
        taskA(()=>())
    })
})



단, 위에서도 언급했다시피 콜백을 끼워넣고 호출하는 방법은
호출 단계에서 '가독성이 크게 떨어진다'는 문제를 피할 수 없습니다


↓ 콜백지옥 예시

// taskA req&res
// taskA req&res
// taskC req&res
// taskB req&res
// taskA req&res
// taskB req&res

taskA(() => {
  taskA(() => {
    taskC(() => {
      taskB(() => {
        taskA(() => {
          taskB(() => {
            console.log("end");
          });
        });
      });
    });
  });
});

위와 같은 코드는 가독성이 떨어지고, 코드를 수정하기도 어렵습니다

이제 이러한 콜백지옥을 피하기 위해 탄생한
Promise와 async/await에 대해 알아볼 차례입니다


3. Promise


new Promise((resolve, reject)=>{
  // 비동기 코드를 담을 코드 블럭입니다
})

프로미스는 자바스크립트의 비동기 처리에 사용되는 객체입니다

주된 목적은 함수의 사용(호출)을 보다 깔끔하게 하기 위함이며,
리턴 타입이 객체이기 때문에 보통 프로미스 객체라고 불립니다


const pr = new Promise((resolve, reject) => {
	setTimeout(() => {
		resolve('OK');
	}, 1000);
});

pr.then(n => {
	console.log(n);
});
// > 1초 뒤 OK 출력

1) Promis는 resolve(성공)와 reject(실패)라는 두 가지 인자를 받습니다

  • 성공할 코드는 resolve()의 인자값으로 넣습니다
  • 실패의 가능성이 있는 코드는 reject()에 담습니다
    (HTTP 상태코드를 통해서도 알 수 있듯이 실패한 요청에 대해서도
    응답을 받을 수는 있기 때문에 여기서 인자를 구분짓는 것은 사용자 입장에서
    그것이 성공의 응답인지, 실패의 응답인지를 구분하기 위함입니다)

2) 프로미스 객체를 호출할 때는 then, catch, finally라는
세 가지 메서드를 이용할 수 있습니다

const a = () => {
    return new Promise ((resolve, reject) => {
        reject(new Error('failed'))
    })
} 
a().then().catch((err)=>{console.log(err)})
  • resolve 인자가 실행될 경우 then 메서드가 발동합니다
  • reject 인자가 실행될 경우 catch 메서드가 발동합니다
  • finally 메서드를 사용하면 프로미스가 처리될 때
    성공과 실패 여부에 관계없이 지정된 콜백 함수가 실행됩니다

3) 프로미스 객체에는 기본적으로 '상태'라는 것이 존재합니다

console.log(pr) 
// Promise { state : <pending> result : <undefined> }
// Promise { state : <fulfilled> result : 'OK' }
// Promise { state : <rejected> result : 'OK' }
  • pending : 아직 비동기 코드가 완료되지 않은 상태입니다 (요청중)
  • 작업이 완료되면 fulfilled 상태가 되고 (then() 실행완료),
    작업이 실패하면 rejected 상태가 됩니다 (catch() 실행완료)

여기서 잠깐!

const a = () => {
    return new Promise ((resolve, reject) => {
        reject(new Error('failed'))
    })
} 
a().then().catch((err)=>{console.log(err)})

// > Promise {<fulfilled>: undefined}

위 코드를 실행했을 때 반환되는 프로미스 객체의 상태는
rejected가 아닌 fulfilled입니다

그렇다면 코드의 실행 결과에서 Promise 객체의 상태가
fulfilled로 출력된 이유는 무엇일까요?

이는 then 메서드의 첫번째 인자인 콜백 함수(resolve())가
정의되지 않은 상태에서 then 메서드가 호출되었기 때문입니다

앞서 살펴본대로 then 메서드의 첫번째 인자는
작업이 성공적으로 완료된 경우에 호출되는 콜백 함수입니다
이 콜백 함수가 정의되지 않고 then 메서드가 호출된 경우에는
프로미스 객체의 상태가 fulfilled로 출력된다는 것을 알아둡시다



그러면 앞서 callback hell을 유발한 예제를 다시 살펴보겠습니다

const taskA = () => {
  return new Promise((resolve, reject)=>{
    setTimeout(()=> {
      resolve(" A Clear")
    },3000)
  })
}

const taskB = () => {
  return new Promise((resolve, reject)=>{
    setTimeout(()=> {
      resolve("B Clear")
    },2000)
  })
}

const taskC = () => {
  return new Promise((resolve, reject)=>{
    setTimeout(()=> {
      resolve("C Clear")
    },1000)
  })
}



// 프로미스 체이닝
console.time("x")
taskA().then((data)=> {
  console.log(data)
  return taskA()
})
.then((data)=>{
  console.log(data)
  retrun taskC()
})
.then((data)=>{
  console.log(data)
  retrun taskB()
})
.then((data)=>{
  console.log(data)
  retrun taskA()
})
.then((data)=>{
  console.log(data)
  retrun taskB()
})
.then((data)=> {
  console.log(data)
  console.timeEnd("x")
})

// > 결과는 순차적으로 실행됩니다

출력 결과는 콜백을 끼워넣었을 때와 같습니다

하지만 프로미스 객체를 사용할 경우, 위와 같이 함수 몇개를 실행하더라도
호출부의 뎁스가 변하지 않다는 장점을 가지게 됩니다
이것이 프로미스 객체를 사용하는 주된 목적입니다



4. async/await

프로미스 객체를 사용할 경우, 콜백을 통한 비동기 처리에 비해서
함수 호출이 보다 나아질 지는 몰라도 사용 방식이 까다롭다는 문제점은
여전히 남아 있습니다

객체를 생성하는 과정도 번거롭고, 함수의 호출 또한 코드 뎁스가 얕다고는 해도
다소 번잡하다는 느낌을 지울 수 없습니다


이제 이러한 프로미스의 단점을 해결해줄 async / await에 대해 알아볼 차례입니다

async : 함수 앞에 사용되며 그 함수 내부에서 await 키워드를 사용할 수 있게 해줍니다
await : async 키워드로 생성된 함수에 대해 비동기 작업이 완료될 때까지 기다리게 합니다

async / await프로미스 객체를 기반으로 구현되어 있습니다
asyncawait 역시 코드의 가독성을 높이기 위해 탄생한 기능이며,
프로미스 객체를 보다 쉽게 사용할 수 있도록 해줍니다

그러면 async / await의 사용법에 대해 알아보겠습니다


  1. async를 사용하면 프로미스 객체를 보다 쉽게 생성할 수 있습니다
const taskA = async()=> {
  return ' A Clear'
}

console.log(taskA()) 
// > Promise {<fulfilled>: ' A Clear'} 
// 프로미스 객체가 반환됩니다


taskA().then((data)=> {
  console.log(data)
})
// >  A Clear
// async를 써서 만든 프로미스 객체에도 then이나 cath 메서드를 쓸 수 있습니다

  1. awaitasync를 써서 생성한 함수의 비동기 작업이 끝날 때까지
    기다렸다가 다음 코드를 실행하도록 합니다
const init = async () => {
  const result1 = await taskA() // 3초 후
  console.log(result1)
  const result2 = await taskA() // 3초 후
  console.log(result2)
  const result3 = await taskC() // 1초 후
  console.log(result3)
  const result4 = await taskB() // 2초 후
  console.log(result4)
  const result5 = await taskA() // 3초 후
  console.log(result5)
  const result6 = await taskB() // 1초 후
  console.log(result6)
}

init()
// > A Clear 
// > A Clear 
// > C Clear 
// > B Clear 
// > A Clear 
// > B Clear 

  1. awaitasync 함수 안에서만 사용할 수 있습니다
const taskA = async()=> {
  return setTimeout(()=> {
      console.log(" A Clear")
    },3000)
}

let rst = 'AAA'
const init = async() => {
  const result = await taskA()
  rst = result
}

init()
console.log(rst) 

// > 
// AAA 즉시 실행 (console.log(rst)에 대한 결과)
// 3초 뒤  A Clear 출력 (init() 호출 결과)



+) 굳이 Promise 문법을 활용하는 것이 나은 순간들도 있습니다
아래 예제와 같이 프로미스 객체를 묶어서 일괄처리해야 할 경우에는
Promise.all 메서드를 사용하는 것을 추천합니다

// Promise.all([Promise{}, Promise{}, Promise{}...])
// 프로미스 객체들을 모아주는 역할을합니다
const init = async () => {
  console.time("x")
  const result = await Promise.all([taskA(), taskB(), taskC()])
  console.log(result)
  console.timeEnd("x")
}
init()

// > 3초 후 [' A Clear', 'B Clear', 'C Clear']



+) 추가 예제 코드

const example = async() => {
  const successPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Hello world!');
    }, 1000);
  });

  const failurePromise = new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('에러 발생!'));
    }, 2000);
  });

  try {
    const result = await successPromise;
    console.log(result);
  } catch (error) {
    console.error(error);
  }

  try {
    const result = await failurePromise;
    console.log(result);
  } catch (error) {
    console.error(error);
  }
}

example();
// > 1초 뒤 'Hello world!' 출력
// > 1초 뒤 'Error: 에러 발생!' 출력

0개의 댓글