[JavaScript] 비동기 처리의 의미, 콜백 패턴의 문제점, Promise의 등장 배경

이은진·2021년 3월 9일
1

JavaScript Study

목록 보기
20/24

기능 구현만을 위해 기계적으로 썼던 fetch, then 등 자바스크립트의 비동기 처리를 위한 문법을 다시 들여다보고 기본을 다잡고자 정리해보려 한다. 비동기 처리란 무엇이고, 왜 콜백 헬과 같은 문제가 발생했을까?

1. 동기 vs 비동기 의미 짚고 넘어가기

1.1. 동기 VS 비동기

동기 : 요청을 보낸 후 기다렸다가 해당 응답을 받아야 다음 동작을 실행(ex.은행)
비동기 : 요청을 보낸 후 응답에 관계 없이 다음 동작을 실행(ex.카페 진동벨)

1.2. 비동기처리란?

비동기란 특정 코드의 연산이 끝날 때까지 코드의 실행이 끝나지 않은 상태에서, 순차적으로 다음 코드를 먼저 실행하는 JS의 특성이다. 요청에 대한 결과를 기다리지 않고 다음 코드를 실행한다는 것이다. 동기와 비동기 개념을 공부할 때 가장 많이 보이는 예시 중 setTimeout 함수가 있다. 콜백함수와 시간을 인자로 받는 이 함수가 어떻게 작동하는지 알아보자.

console.log('시작')

setTimeout(() => {
  console.log('중간')
}, 3000)

console.log('끝')

setTimeout 함수의 두 번째 인자로 3초를 입력했기 때문에, 콘솔에는 차례로 시작, , 그리고 3초 뒤에 중간 순으로 찍힐 것이다. 그런데 한 가지 궁금한 점이 생긴다. JS를 공부하면서 JS는 싱글쓰레드(Single-Thread) 언어라고 알게 되었는데 왜 setTimeout의 콜백함수가 실행되기까지 기다리지 않고 을 콘솔에 먼저 찍는 걸까?

-> 싱글스레드와 멀티스레드의 차이 이해하기

자바스크립트는 웹 브라우저나 Node.js의 자바스크립트 엔진에서 실행이 되는데, 여기에는 자바스크립트를 돌리는 1개의 Thread가 존재한다. 그런데, 이 엔진 뿐만 아니라 비동기식 처리 모델인 Web API라는 것이 함께 동작한다. 여기에서 setTimeout이나 데이터를 가져오는 fetch함수 등 시간이 소요되는 코드를 처리한다. 자바스크립트 엔진 thread와는 따로 작동하면서 콜백함수를 자바스크립트 엔진으로 보낸다. 이와 관련하여 콜스택, 이벤트루프, 태스크큐, 마이크로태스크 큐 등에 대해서는 따로 포스팅할 것이다.

여하튼 JS는 스크립트가 실행될 때 효율적인 수행을 위해 처리 시간이 많이 걸리는 코드를 기다리지 않고 다음 코드를 비동기적으로 실행한다. 그래서 setTimeout의 콜백함수까지 기다리지 않고 콘솔에 '끝'을 먼저 찍는 것이다. 그런데 만약, 이와 반대로 이전 코드의 처리를 기다렸다가 다음 코드를 순차적으로 실행해야 한다면 어떻게 해야 할까? 여기에서 자바스크립트의 비동기 처리라는 개념이 나온다. 비동기 처리를 위해 개발자들이 몇 년 전까지 어떻게 고군분투했는지 알아보고, ES6 이후에 등장한 프로미스로 어떻게 그 어려움을 해결했는지 알아보자.

2. 비동기 처리를 위해 과거에 사용한 콜백 패턴

ES6 이전에는 비동기 처리를 위해서 콜백 패턴을 이용했다. 그러나 콜백 헬로 인해 가독성이 나쁘고, 비동기 처리 중 발생한 에러 처리가 쉽지 않아서 한계가 있었다. 콜백 패턴의 단점 두 가지를 차근차근 알아보자.

2.1. 콜백 헬

콜백 헬, 콜백 지옥이라고도 불리는 이 현상은 자바스크립트에서 동기적인 코드 수행을 위해 코드를 짜다 보니 콜백 함수를 한 함수 내에서 계속 중첩해서 작성하다 보니 생기는 현상이다. 왜 이렇게 코드를 짜야 했는지 알아보자.

문제1. 비동기 처리 결과를 외부에 반환하지 못함

  const get = url => {
	const xhr = new XMLHttpRequest() // XMLHttpRequest 객체생성
  xhr.open('GET', url) // HTTP요청 초기화
  xhr.send() // HTTP요청 전송
  
  xhr.onload = () => { // onload 이벤트 핸들러 프로퍼티에 바인딩하고 종료
    if (xhr.status === 200) {
      return JSON.parse(xhr.response)
    }
    console.error(`${xhr.status} ${xhr.statusText}`)
  }
}

const response = get('https://jsonplaceholder.typicode.com/posts/1')
console.log(response) // undefined

get 함수에서는 XMLHttpRequest 객체를 만들어서 데이터를 요청하고 받아와서, 응답이 200이 뜨면 파싱한 데이터를 반환해 준다. 그런데 변수 response에 해당 반환값을 넣고 콘솔에 찍어 보면 undefined가 나온다. 왜 이런 현상이 발생할까?

이 현상은 자바스크립트 코드가 어떻게 동작하며 실행 콘텍스트가 어떤 순서로 생성되는지 보면 알 수 있다. 먼저 전역 콘텍스트가 콜스택에 들어가고 -> get 함수가 호출이 되면 get 함수의 실행 콘텍스트가 생성이 된다. -> 그리고 XMLHttpRequest 객체가 생성이 되고, HTTP 요청을 초기화하고, HTTP 요청을 전송한다. -> 그 다음 xhr.onload 이벤트 핸들러 프로퍼티에 이벤트 핸들러를 바인딩한다. -> 그리고 이벤트가 실행되기 전에 get 함수가 종료된다. -> get 함수 종료 후 console.log 함수가 실행되고, 아무 값도 할당받지 못한 response 변수는 undefined 값을 가진다. -> 모든 실행 컨텍스트가 콜스택에서 비워지고 난 후 이벤트 핸들러가 실행된다.

앞서 비동기 처리의 개념에서 알아본 것처럼, 자바스크립트는 비동기로 동작하게 도와주는 Web API 때문에 get 함수 내부의 onload 이벤트 핸들러가 비동기로 동작하게 된다. 이벤트 핸들러는 시간이 많이 소요되기 때문에 다른 코드를 먼저 실행시키고 나서 실행이 된다. 이렇게 비동기로 동작하는 이벤트 핸들러는 get 함수가 종료된 이후에 실행되기 때문에 get함수는 XMLHttpRequest로 받아 온 데이터를 반환할 수가 없다.

문제2. 비동기 처리 결과를 상위 스코프의 변수에 할당할 수 없음

let todos;

const get = url => {
  const xhr = new XMLHttpRequest()
  xhr.open('GET', url)
  xhr.send()
  
  xhr.onload = () => {
    if (xhr.status === 200) {
      todos = JSON.parse(xhr.response)
    } else {
      console.error(`${xhr.status} ${xhr.statusText}`)
    }
  }
}

get('https://jsonplaceholder.typicode.com/posts/1')
console.log(todos) // undefined

get 함수를 만들어서 전역에서 선언한 변수 todos에 응답 받은 값을 담아서, 그 변수의 값을 콘솔에 찍어보는 코드를 작성해 보자. 역시 여기서도 XMLHttpRequest 객체를 생성하여 데이터를 백에서 받아오고 응답이 200이면 todos에 해당 값을 할당해주고 있다. 그런데 또다시 콘솔에는 undefined가 찍힌다. 위의 문제1번과 똑같은 이유로 변수에 비동기 처리 결과를 할당하지 못하고 있다.

코드 실행 과정을 살펴보면, 전역 콘텍스트가 콜스택에 쌓이고 -> 이후 get 함수 실행 과정에서 get 함수의 실행 콘텍스트가 콜스택에 쌓이고 -> 이벤트 핸들러가 바인딩된다 -> 이벤트 핸들러가 실행되지 않은 상태에서 함수는 종료하고 get 함수의 실행 콘텍스트가 콜스택에서 제거되고 -> 전역 콘텍스트의 console.log가 실행된다. -> 콘솔에는 역시 undefined가 찍히고, 전역 콘텍스트가 콜스택에서 제거된다. -> 그제서야 이벤트 핸들러가 콜스택으로 들어와 실행된다.

이렇게 코드 실행의 효율성을 위해 비동기적 처리를 가능하게 해 주는 Web API 때문에 오히려 비동기 함수가 처리 결과를 외부에 반환할 수도 없고 상위 스코프의 변수에 할당할 수도 없다. 백에서 받아 온 결과를 범용적으로 사용할 수가 없기 때문에 해당 함수 안에서 모든 것을 처리해야 하는데, 여기서 문제가 생긴다. 비동기 처리가 성공했을 시, 그 값을 가지고 다른 처리를 하기 위해 콜백 함수를 사용해야 하는데, 그 콜백 함수에서 나온 값을 처리하기 위해서 그 안에서 또 다시 콜백 함수를 호출해야 하는 일이 발생한다.

후속의 후속의 후속 처리를 위해 콜백 헬 발생

// (...)

get('/step1', a => {
  get(`step2${a}`, b => {
    get(`step2${b}`, c => {
      get(`step2${c}`, d => {
        console.log(d)
      })
    })
  })
})

GET 요청을 통해 서버로부터 응답 받은 값을 사용하여 -> 또다시 GET 요청을 하고 -> 그로부터 받은 응답을 사용하여 다시 GET 요청을 하는 코드다. 이러한 방식으로 후속 처리를 하게 되면, 콜백 함수 호출이 중첩되어 복잡도가 높아지고 가독성도 떨어지는 현상이 발생하고 이를 콜백 헬 이라고 한다.

2.2. 에러 처리에 한계가 있음

try {
  setTimeOut(() => {throw new Error('Error!!!')}, 1000)
} catch (e) {
  // 에러를 캐치하지 못함
  console.log('캐치한 에러', e)
}

// try 코드 블록에서 에러가 발생하면 catch문의 err 변수에 전달되고 catch 코드블록 실행.

코드의 복잡도가 높아진다고 해도 원하는 대로 비동기 처리를 할 수 있으면 실행 자체에 문제는 없을 것이다. 하지만 위와 같이 콜백을 중첩해서 사용하면 데이터는 처리할 수 있을지언정 에러 처리에는 한계가 발생한다. 위는 try에서 실행한 코드에 대해 catch로 에러를 캐치하는 간단한 코드다. 그런데 setTimeout 에서 에러를 일부러 발생시켰는데도 콘솔에는 에러가 찍히지 않는다. 이것도 위에서 살펴본 콜백함수의 실행 콘텍스트와 연관이 깊다.

코드의 실행 과정을 살펴보면 try 실행 컨텍스트에서 setTimeout 함수를 호출하고 있고 -> setTimeout 함수는 Web API에 1초 후 콜백 함수를 실행하도록 타이머만 설정해 놓고 그냥 종료된다. -> setTimeout 실행 컨텍스트가 콜스택에서 제거된다. -> 이후 모든 콜스택이 비워진 후, setTimeout의 콜백 함수가 타이머 만료 후 실행이 된다.

catch 코드 블록에서 에러를 캐치하려면 try 코드 블록의 setTimeout 함수에서 에러가 발생해야 하는데 setTimeout 함수의 콜백 함수가 실행될 때, setTimeout 함수는 이미 콜스택에 없다. 즉 콜백함수를 불러낸 게 setTimeout이 아니게 된다는 의미다. 따라서 setTimeout 함수의 콜백 함수가 발생시킨 에러를 catch 블록에서 캐치를 못한다.

정리하자면 자바스크립트에서 비동기 처리를 위해 콜백 패턴을 이용할 경우, 콜백 헬 문제가 생기거나 에러 처리가 어렵다는 문제가 발생한다. 이를 극복하기 위해 ES6에서 프로미스가 도입되었다. 프로미스에 대한 내용은 다음 글에서 정리하려 한다.

profile
빵굽는 프론트엔드 개발자

1개의 댓글

comment-user-thumbnail
2021년 4월 23일

안녕하세요 17기 이정민입니다 ! 취업준비차 이론 공부중에 블로그 참고 많이했습니다 감사합니다 : )

답글 달기