본문 바로가기
  • ANALOG CODE
  • AnalogCode
개발

자바스크립트 비동기 처리의 변화(callback, Promise, async/await)

by 아날로그코더 2023. 4. 9.
반응형

NodeJS로 백엔드 서버를 오랫동안 만들어 봤다면 자바스크립트의 비동기 처리때문에 코드가 지저분해지는 경험을 해봤을 것이다. 지금이야 aync/await 로 인해 수월해졌지만, 자바스크립트 콜백헬(Callback Hell) 시절에는 정말 NodeJS로 개발을 포기하고 싶은 생각이 들 정도였다.
 
프론트엔드는 그나마 백엔드보다 콜백헬로 떨어지는 수심이 깊지가 않아서 콜백헬의 구렁텅이에서 허우적거리는 상황이 많지가 않았는데, 백엔드는 그야말로 정말 콜백 지옥의 구렁텅이였다. 이것을 Javascript 표준을 제정하는 단체도 분명히 인지하고 있었을거라 생각한다. 그래서 비동기 처리를 개선할 수 있는 새로운 표준을 만들어왔고 지금의 async/await에 이르렀다.

 

 

비동기처리란?

일단 비동기 처리란 무엇인지를 먼저 알고 시작하자.
비동기는 동기의 반대개념이다. 그럼 동기란 무엇인가?

 

동기(Synchronous) vs 비동기 (Asynchronous)

 

- 동기 (Synchronous)

synchronous
동시 발생(존재)하는

동시에 발생한다는 의미이다. 우리는 동기화라는 단어도 자주 들어서 느낌이 올것이다.
그럼 무엇이 동시에 발생한다는 것일까?
 
아래의 코드에서 getUser동기식 함수(Synchronous function)라고 해보자.

let user = getUser(id) // getUser()는 동기식 함수
let friends = getFriends(user)

getUser()가 동기식이라는 것은 함수의 실행과 출력 결과가 동시에 발생한다는 것이다.

첫번째 라인에서 getUser()의 실행과 결과가 모두 발생한다. 첫번째 라인이 끝나면 user 변수에 결과가 존재하게 된다.

getUser(id)와 user는 동기화가 된다고 하면 이해가 쉬울거 같다.
그러면 결과로 받은 user를 다음 라인의 getFriends(user) 에서 사용이 가능하다.

 

- 비동기 (Asynchronous)

asynchronous
동시에 존재(발생)하지 않는

비동기는 동기와 반대로 비동기식 함수의 실행과 출력 결과가 동시에 발생하지 않는다. 출력 결과가 언제 발생할지 모르는 것이다.
이번에는 아래코드에서 getUser비동기 함수(Asynchronous Function)라고 하자.

let user = getUser(id) // getUser() 비동기 함수
let profile = getProfile(user)

getUser(id)의 실행과 함수의 출력 결과가 동시에 존재하지 않는다.  함수 실행은 시작하지만 함수의 출력 결과는 언제 발생할지 알수가 없다. 그래서 이 코드는 잘못된 코드이다.
getUser(id)를 실행을 하지만 출력 결과가 언제 발생할지 모르기 때문에 user변수와 동기화를 하기 위해 기다리지 않고 바로 다음 코드로 넘어간다. 그래서 user 변수에 getUser(id)의 응답 결과를 받을 수가 없다.
 
비동기 처리를 위해서는 순서대로 처리되는 동기식 코드와는 다르게 프로그래밍을 해야한다.
 

Javascript의 비동기 처리

자바스크립트에서는 비동기 함수가 존재한다. I/O가 일어나는 모든 함수가 비동기 함수이다. 파일, 네트워크 I/O 등과 같이 요청을 하면 언제 끝나는지 알 수없는 것들은 모두 비동기 처리이다. 비동기 처리에서 모든 처리가 완료되었을때 사용자가 원하는 코드를 처리할 수 있게 해주어야만 프로그램을 원하는데로 진행시킬 수가 있다. 그럼 이걸 자바스크립트에서는 어떻게 제공을 해줄까?

 

자바스크립트는 비동기 처리를 위해 3가지의 방법을 제공해주고 있다. 이 3가지 방법을 하나씩 살펴보겠다.
1세대, 2세대, 3세대는 순서대로 기억하기 쉽게 내가 개인적으로 붙인 용어이다.
 

1. 콜백 (Callback)

최초의 1세대 비동기 처리 방식이다. 이것 때문에 콜백헬(Callback Hell)이란 용어가 탄생했다.
비동기 함수에서 콜백함수인자로 받아서 요청이 완료되었을 때 콜백함수를 실행하는 방식이다.

getUser (id, function (error, user) {
	if (error) {
		// 에러 처리
	} else {
		// 성공 처리
	}
})

위의 코드와 같이 콜백함수를 비동기함수의 인자로 넘겨서 언제인지는 알수없지만 처리가 끝나는 타이밍에 콜백함수를 실행하도록 해주는 것이다. 이렇게보면 편리한 것 같지만 절대 그렇지가 않다.
 
백엔드 API로 회원가입 API를 만든다고 가정해보자.

function signup(user, profile, cb) {
    // 이미 존재하는 유저인지 체크
    checkUser (user, function(exist) {
        if (!exist) {
            // 유저 추가
            addUser(user, function(error) {
                if (!error) {
                    // 프로필 추가
                    addProfile(profile, function (error) {
                        if (!error) {
                            // 가입안내 메일 발송
                            sendEmail(user.email, function (error){
                                // 완료 응답 처리
                                cb(error)
                            })
                        }
                    })
                }
            })
        }
    })
}

위의 함수들은 모두 DB처리를 하거나 메일발송을 하는 작업이고 I/O 처리가 필요한 비동기함수라고 해보자.
이렇게 비동기 함수를 연속으로 처리해야하는 상황이라면 콜백함수에서 비동기 함수를 호출하는 상황이 계속해서 파고 들어가게 된다.
이 콜백의 콜백이 더 많은 단계로 깊숙이 들어가다보면 코드가 마치 지옥속으로 들어가서 다시는 못빠져 나올 것만 같은 두려움마저 든다.

 

아래는 NodeJS v9.x 의 파일 쓰기 API이다. 마지막에 callback 을 인자로 받는 걸 볼 수 있다.

Node.js v9.11.2

Node.js v9.x 까지는 비동기 함수가 callback 방식으로 구현되어 있다.
 

2. 프로미스 (Promise)

콜백헬을 해결하기 위해 나온 2세대 비동기 처리 방식이다. Promise가 나오고 나서는 콜백의 깊숙한 지옥을 드디어 탈출할 수 있었다.

Promise란 무엇인가? 한글로 풀이하면 "약속"이다.


일단 아래와 같이 사용한다.

let myPromise = new Promise((resolve, reject) => {
	// 비동기 처리
	asyncFunc(param, function cb(err, result) {
		if (err) {
			reject(err)
		}
		else {
			resolve(result)
		}
	})
})

myPromise.then(function(result) {
	// 성공 처리
}).catch(function(err) {
	// 에러 처리
})

Promise 객체는 미래에 결과를 주겠다는 약속을 의미한다. Promise 객체를 통해 비동기 처리가 완료된 시점의 처리기를 연결할 수 있는 것이다. Promise를 생성할때 함수를 하나 넘겨주는데 이때 파라미터로 resolve, reject가 있다. 
resolve를 호출하면 비동기 처리 완료상태가 되고 then()을 이용하여 결과를 받아서 처리할수가 있다.
reject를 호출하면 비동기 처리 실패상태가 되고 catch()를 이용하여 실패 결과를 받아서 처리할 수 있다.
 
그런데 이렇게 코드를 짜는거나 콜백함수를 넘겨서 코드를 짜는거랑 별 차이가 없는거 아니냐라는 의문이 들 것이다.
Promise를 사용해도 결국 아래처럼 콜백헬과 모양새가 같아질 것만 같다.

let promise1 = new Promse()

promise1.then(function() {
	let promise2 = new Promise()
	promise2.then(function() {
		let promise3 = new Promise()
		promise3.then(function() {
			// 자꾸 깊어지는거 뭥미???
		})
	})
})

맞다. 이렇게 된다. 하지만 이렇게 코드를 만들지는 않는다.

 
프로미스 체인 (Promise Chaining)

우리에게는 프로미스 체인이라는 것이 있다. 프로미스를 체인처럼 직렬로 연결하는 방식이다.
프로미스 체인을 사용해서 위의 코드를 바꿔보면 아래와 같이 된다.

let promise1 = new Promse()

promise1.then(function() {
	let promise2 = new Promise()
	return promise2
}).then(function() {
	let promise3 = new Promise()
	return promise3
}).then(function() {
	// 성공
}).catch(function(err) {
	// 에러
})

드디어 코드 들여쓰기가 갈수록 깊어지는 상황이 사라졌다.
이제는 비동기처리가 필요한 함수를 Promise로 만들어서 순서대로 체인을 만들어주면 되는 것이다.
이제 콜백헬로부터는 해방이다.
 

 

아래는 NodeJS v18.x 의 Promise 방식의 파일 쓰기 API이다. 

리턴값을 보면 Promise를 리턴하는 걸 볼 수 있다.

 

3. async / await

async/await는 ECMAScript 2017 에서 추가된 문법이다. 자바스크립트의 3세대 비동기 처리방식이다.
async/await 의 출현으로 비동기 처리 방식의 정점을 찍었다고 생각한다. 비동기식 코드를 동기식 코드처럼 만들 수 있게 해준다.
아래 예제 코드를 보자.

async signup(user, profile) {
	try {
		let exist = await checkUser(user)
		if (!exist) {
			await addUser(user)
			await addProfile(profile)
			await sendMail(user)
		}
 	} catch (err) {
		// 에러 처리
	}
}

비동기 함수에 await를 붙여서 호출하면 마치 동기식 코드처럼 동작한다. 이 얼마나 깔끔한 방식인가!
프로미스 체인도 지금 생각해보면 굉장히 지저분한 코드이다.


단 async/await를 제대로 사용하려면 Promise를 알아야 한다. await 가 하는 역할이 결국은 Promise.then을 기다리는 것과 거의 동일하기 때문이다.
 
그래서 우리는 await를 사용하는 비동기 함수를 만들기 위해서는 Promise를 리턴하는 함수를 만들면 된다.

function getUser(id) {
	return new Promise((resolve, reject) => {
		db.query(query, function(err, row) {
        	if (err) {
				reject(err)
			} else {
				resolve(row)
			}
		}
	})
}

async main() {
	let user = await getUser(id)
}

async/await가 나오기 전에 이미 대부분의 라이브러리들이 비동기 API에서 Promise 를 리턴하도록 되어 있기때문에 이것을 그대로 await방식으로 호출해서 사용할 수가 있다. 하위 호환성을 유지하면서 async/await를 사용할 수 있는 것이다.

 

NodeJS v18.x 의 Promise 방식의 파일 쓰기 API를 async/await 방식으로 호출해보면

아래와 같이 await를 써서 호출하면 되는 것이다.

await fs.write(data)


지금은 대부분의 개발에서 async/await를 사용해서 개발한다.

 

마무리

자바스크립트의 비동기 처리방식을 순서대로 알아보았다. 

  • 콜백 (Callback)
  • 프로미스 (Promise)
  • Async / Await

결국은 모든게 이어져있는 것이나 다름없다.

 

- 콜백함수Promise를 리턴하는 함수로 감싸고,

- Promise를 리턴하는 함수Async / Await 방식으로 사용하는 것이다.

 


 

반응형

댓글