Post View

자바스크립트의 프로미스(Promise)와 비동기란?

Promise(프로미스)를 이해하기 위해서는 동기와 비동기의 차이에 대해서 이해해야합니다.

Process가 실행되면 순차적으로 코드가 실행되는데 이런 코드를 동기적으로 동작한다고 하며 한번에 한가지의 일만 처리할 수 있습니다.

그렇다면 비동기는 어떤걸까요?
비동기는 한번에 하나의 프로세스만을 실행하는게 아니라 동시에 여러가지의 동작을 처리할 수 있게 해줍니다.

그런데 한번에 여러가지 일을 처리하면 더 효율적으로 동작하겠지만, 대신 그만큼 어떤식으로 동작할지 상태를 가늠하기가 어려워집니다.

많은 프로그래밍 언어들(C, Java, C#, python 등)은 기본적인 동작 시 동기적인 방식으로 동작하며, 비동기 방식을 이용하기 위해서는 명시적으로 Thread를 사용해서 처리해야합니다.

public class ThreadExample {
    /**
     * 1초씩 늘어나며 순서대로 실행되는 쓰레드를 생성한다.
     * 1초, 2초, 3초 순으로 실행되며 6초 후 Thread End 메시지가 출력된다.
     */
    public static void main(String[] args) {
        for(int i = 1; i <= 3; i++) {
            Thread t = new TempThread(i * 1000);

            t.start();
        }

        System.out.println("Thread End");
    }
}

class TempThread extends Thread {
    private int sleepTime;

    TempThread(int sleepTime) {
        super();

        this.sleepTime = sleepTime;
    }

    public void run() {
        try {
            Thread.sleep(sleepTime);
            System.out.println("sleepTime : " + sleepTime);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

위의 코드는 아래와 같이 동작합니다.

위와 같은 경우에는 개발자가 직접 비동기적인 동작을 인식하고 처리하기 때문에 비동기로 인한 문제가 발생하는 일이 Javascript에 비해서 적습니다.

하지만 Javascript는 별도의 처리 없이 비동기로 처리되기 때문에 처음 비동기 처리를 진행하는 경우 많이 헤메게 되는데요, 이유는 Javascript가 Single Thread로 동작하기 때문인데 브라우저는 이것을 Multi Thread처럼 동작하게 하기 위해서 별도의 처리를 합니다.

그래서 Javascript를 가지고 비동기 방식을 이용할 수 있는거죠. 이런 방식으로 구현되다 보니 세부적인 동작방식은 방식은 다른 언어의 Thread 처리와 다르므로 자세한 사항은  https://prohannah.tistory.com/59를 참고하시기 바랍니다.

그럼 이번에는 Javascript의 비동기 예제를 확인해보겠습니다.

// 원하는 작업: 1초 뒤 i의 값을 1 더한 뒤 출력한다.
function asyncExample1() {
	let i = 0;

	setTimeout(function() {
		console.log("setTimeout start")
		i++;
	}, 1000);

	console.error(i); // i : 0
}

해당 로직이 동기로 동작한다고 생각하면 var i = 0이 실행된 후 i가 1초 뒤 1이되고, 그 다음 1이 출력될 것이라고 생각할 수 있습니다.

하지만 예제에서 보이는 setTimeout 함수는 비동기로 동작하기 때문에 console.error(0);이 먼저 출력되고 그 다음 "setTimeout start"가 출력되는 것을 확인할 수 있습니다.

처음 Javascript를 시작할 때에는 왜 이런 현상이 발생하는지 인지하지 못해 위처럼 오류가 나거나 setTimeout 내부 callback function(콜백 함수)에 처리해야할 로직을 넣는 경우가 자주 발생합니다.

function asyncExample2() {
	let i = 0;

	setTimeout(function() {
		i++;
		console.log(i);
	}, 1000);
}

하지만 이런 문제가 계속 되다 보니, callback 함수 내에 또 다른 callback 함수가 중첩되면서 callback hell(콜백 헬)이라는 문제가 발생게 됩니다.

function asyncExample3() {
	let i = 0;

	setTimeout(function() {
		i++;
		console.log(i); // i : 1

		setTimeout(function() {
			i++;
			console.log(i); // i : 2

			setTimeout(function() {
				i++;
				console.log(i); // i : 3
	
				setTimeout(function() {
					i++;
					console.log(i); // i : 4
				}, 1000);
			}, 1000);
		}, 1000);
	}, 1000);

	console.error(i); // i : 0;
}

callback hell이란 중첩되는 callback으로 인해 코드의 깊이가 점점 깊어지면서 가독성이 떨어지는 코드를 말하는데요,
이처럼 비동기 코드가 발생할 때마다 점점 복잡하고 이해하기 힘든 코드가 만들어지면서 Javascript에서는 이러한 문제를 해결하기 위해 Promise라는 것을 사용합니다.

Promise를 사용하면 내부에서 실행된 비동기 로직이 끝났을 때 resolve나 reject 메서드를 통해 성공/실패의 결과를 돌려줄 수 있으며, 해당 결과값을 연결(chaining)된 then/catch 로직으로 전달할 수 있습니다.

아래의 예제를 보면서 이해해보겠습니다.

function asyncExample4() {
	new Promise(function(resolve, reject) {
	  let i = 0;

		setTimeout(function() {
			i++;
			console.log(i);
			resolve(i); // i : 1
		}, 1000);
	}).then(function(result) { // result : 1
		return new Promise(function(resolve, reject) {
			setTimeout(function() {
				result++;
				console.log(result);
				resolve(result); // result : 2
			}, 1000);
		});
	}).then(function(result) { // result : 2
		return new Promise(function(resolve, reject) {
			setTimeout(function() {
				result++;
				console.log(result);
				resolve(result); // result : 3
			}, 1000);
		});
	}).then(function(result) { // result : 3
		return new Promise(function(resolve, reject) {
			setTimeout(function() {
				result++;
				console.log(result);
				resolve(result); // result : 4
			}, 1000);
		});
	}).then(function(result) {
		console.log("result : " + result); // "result : 4"
	}).catch(function(err) {
		console.error(err);
	});
}

이 예제에서는 비동기 로직을 탈 때마다 i가 1씩 올라가는 로직입니다.
처음 setTimeout에서 1이 더해진 i는 resolve를 통해 다음 then의 result 값으로 전달됩니다.
그리고 then 내에서 다시한번 비동기 처리를 하기 위해서는 한번 더 Promise 객체를 리턴하는 방식을 연결해가면서 사용합니다.

이렇게 프로미스를 사용함으로써 callback hell 문제는 제거 되었습니다.
하지만 여전히 코드가 한눈에 들어오는 것 같지는 않습니다.

function asyncExample5() {
  let i = 0;

	countUp(i) // resolve(1);
	.then(countUp) // resolve(2);
	.then(countUp) // resolve(3);
	.then(countUp) // reject("fail");
	.catch(function(err) {
		console.error("catch1 : " + err);
	}).then(function(result) { // result : undefined
		console.log("catch1 after then1 : " + result);
	}).catch(function(err) {
		console.error("catch2 : " + err);
	});
}

function countUp(count) {
	return new Promise(function(resolve, reject) {
		console.log(count);

		setTimeout(function() {
			count++;

			if(count % 4 === 3) {
				reject("fail");
			}

			resolve(count);
		}, 1000);
	});
}

조금이라도 가독성을 높이기 위해 countUp을 별도로 분리해봤지만, Promise의 특성상 동작 방식과 일반적인 코딩방법과 다르기 때문에 여전히 동작을 한눈에 이해하기 어려우며, 여러가지 문제도 존재합니다.

countUp이 4번째 실행 될 때 reject("fail")이 실행되고 다음에 나오는 "catch1" 부분을 실행합니다.
여기까지는 일반적인 try/catch와 비슷한 흐름이지만, 이 다음 동작은 종료가 아닌 "catch1 after then1"의 실행입니다.

catch method가 실행되더라도 method가 연결되어있기 때문에 바로 다음 chaning된 method인 then이 실행되는거죠.
그런데 catch method 내에는 리턴되는 값이 없는 상태(undefined)이므로, 다음 then의 result 값도 undefined가 됩니다.

게다가 Promise를 사용하는 경우 catch1에서 바로 로직을 종료하고 싶다면 별도의 처리를 해주어야 합니다.

function asyncExample6() {
        let i = 0;

	new Promise(function(resolve, reject) {
		console.log("promise start");

		throw new Error("end");
	}).catch(function(err) {
		if(err.message === "end") {
			throw err;
		}

		console.error("catch1 : " + err);
	}).then(function(result) {
		console.log("catch1 after then1");
	}).catch(function(err) {
		if(err.message === "end") {
			console.log("promise end");
			return;
		}

		console.error("catch2 : " + err);
	});
}

위의 경우 promise start 중에 로직을 종료하는 경우 "throw new Error("end");"와 같이 강제로 예외를 발생시켜줍니다.
이 경우 가장 가까운 catch 메서드를 실행하는데 여기서 err.message가 end라면 해당 err를 또 한번 예외 처리합니다.
이런식으로 chaning 되어있는 모든 catch에 처리를 해야 로직 진행 도중 중지가 가능합니다.

그럼 이번엔 마지막으로 바뀐 async/await를 사용해보겠습니다.

async function asyncExample7() {
	let i = 0;

	try {
		try {
			for(let j = 0; i < 4; i++) {
				i = await countUp(i);
			}
		} catch(err) {
				console.log("catch1 : " + err);
		}
	
		console.log("catch1 after then1");
	} catch(err) {
		console.log("catch2 : " + err);
	}
}

function countUp(count) {
	return new Promise(function(resolve, reject) {
		console.log(count);

		setTimeout(function() {
			count++;

			if(count % 4 === 3) {
				reject("fail");
			}

			resolve(count);
		}, 1000);
	});
}

이 예제는 asyncExample5를 async/await를 이용하여 동일하게 구현한 방법입니다.
Promise로 구현한 경우에는 반복문을 사용하기 어렵기 때문에 연속된 4번의 countUp(i)를 호출 했지만 여기서는 for문을 통해 4번 반복될 수 있도록 구현했습니다.

그리고 Promise에서의 catch와 같이 catch가 동작한 뒤에도 then 부분이 되어야 하므로, try/catch 내부에 한번 더 try/catch가 있는 것으로 처리하였습니다.

조금 더 기본적인 형태에 가까워졌죠?

async function asyncExample8() {
	let i = 0;

	try {
		console.log("promise start");
		i = await countUp(i);
		i = await countUp(i);
		return; // throw new Error("end"); 없이 프로세스 동작을 중단시킬 수 있다.
	} catch(err) {
		console.log("catch1 : " + err);
	}

	try {
		console.log("catch1 after then1");
	} catch(err) {
		console.log("catch2 : " + err);
	}
}

function countUp(count) {
	return new Promise(function(resolve, reject) {
		console.log(count);

		setTimeout(function() {
			count++;

			if(count % 4 === 3) {
				reject("fail");
			}

			resolve(count);
		}, 1000);
	});
}

그리고 일반적인 형태로 동작하다 보니 로직이 동작하는 도중에 종료를 하는 것도 return 키워드를 통해 쉽게 가능합니다.
단, async/await는 단순히 Promise를 기반으로 만들어진 키워드이기 때문에 위의 countUp처럼 실제 비동기로 동작되는 로직은 Promise로 처리해야합니다.

Javascript에서는 자주 사용되지만 이해하기 어려운 Promise와 async/await에 대해서 정리해보았습니다.

Comments