2015년 이후에 웹 개발을 해본 적이 있다면, 아마도 Promise라는 개념에 대해 들어본 적이 있을 것입니다. Promise가 현대 프론트엔드 코드베이스에서 어디에나 퍼져 있다고 주장하는 것도 과언이 아닙니다. 하지만 많은 웹 개발자들, 특히 프론트엔드 개발 경험이 많지 않은 이들은 Promise의 내부 작동 방식을 충분히 이해하지 못한 채 사용해왔습니다. 이로 인해 Promise의 수많은 오용과 그에 따른 버그가 발생했습니다. 이 글에서는 Promise가 어떻게 만들어졌는지, Promise가 어떻게 작동하는지, 그리고 프론트엔드 개발 경험이 많지 않다는 가정 하에 어떻게 사용해야 하는지 설명해 드리겠습니다.
동시성: 모든 것의 시작이다
아직도 첫 프로그래밍 수업을 기억하시나요? 아마 선생님께서 컴퓨터가 코드를 한 줄씩 실행한다고 가르쳐주셨을 겁니다. 컴퓨터는 앞에 있는 줄을 다 실행하지 않으면 다음 줄로 넘어가지 않습니다. 우리는 흔히 이런 순차적인 코드 실행 방식을 동기식이라고 표현합니다. 논리적 단순성과 가독성 같은 장점도 분명히 있습니다. 하지만 중요한 단점도 있습니다: 낭비가 많다는 점입니다.
코드에 다른 컴퓨팅 시스템에서 데이터를 가져와야 하는 연산이 포함되어 있다고 가정해 봅시다. 데이터 검색은 시간이 많이 걸리지만, 코드를 반드시 계산할 필요는 없습니다. 코드가 동기식이라면, 실행하는 시스템은 데이터 검색이 완료될 때까지 유휴 상태에 들어가야 하므로 시간과 계산 자원이 낭비됩니다.
비동기성: 동시성의 더 시간 효율적인 대체
동기식 프로그래밍의 시간 비효율성을 해결하기 위해 프로그래밍 분야의 선구자들은 비동기성 개념을 고안했습니다. 동기성과 달리, 비동기성은 시간 소모적이고 계산 집약적이지 않은 연산을 주 프로그램 흐름에서 분리하여 후속 연산 처리를 위한 공간을 만들 수 있게 합니다. 이러한 새로운 프로그래밍 방식은 특히 대량의 시스템 간 통신이 필요한 현대 컴퓨터 프로그램의 시간 성능을 크게 향상시켰습니다.
회상: 우리는 여전히 비동기성에서 순차성이 필요하다
비동기성이라는 훌륭한 개념을 알게 되었으니, 모든 연산을 병렬로 실행할 수 있을까요? 답은 아니오입니다. 왜냐하면 어떤 연산은 다른 연산에 의존적이며, 의존성이 완료될 때까지 시작할 수 없기 때문입니다. 따라서 분기된 연산이 완료 시 의존자를 트리거할 수 있도록 하는 방법을 찾아야 합니다. 즉, 비동기 프로그래밍에서 순차성을 표현해야 한다는 뜻입니다. 이에 대응하여 컴퓨터 과학 분야의 선구자들이 콜백 함수를 고안했습니다. 콜백 함수는 다른 함수에 넘길 수 있는 함수입니다. 그 다른 함수에서는 콜백 함수가 특정 연산을 마친 후 콜백 함수를 실행할 수 있습니다. 콜백 함수의 도움으로, 시간이 많이 드는 연산을 안전하게 제쳐두고, 콜백 함수에 명시된 종속 연산은 완료 후 시작된다는 것을 잘 알고 있습니다.
다음은 콜백 함수를 어떻게 사용하는지 예시입니다. 데이터베이스에서 사용자 정보를 가져와야 한다고 가정해 봅시다. 사용자 정보를 받은 후에는 사용자 인터페이스에 표시해야 합니다. 위 두 연산의 순차적 관계를 아래 코드를 사용하여 설명할 수 있습니다:
function getUserInfo(id, callback) {
setTimeout(() => {
만약 (typeof id === "number") {
const userInfo = {
id,
이름: '아담 스미스',
나이: 30세
};
callback(userInfo); 검색된 사용자 정보를 사용하여 콜백 함수를 호출하기
} 그렇지 않으면 {
새로운 Error("Invalid ID")
}
}, 2000); 2초 지연 시뮬레이션
}
function displayUserInfo(userInfo) {
console.log(사용자정보)
}
console.log('사용자 정보 가져오기 중...'); 즉시 실행
getUserInfo(1, displayUserInfo)
보시다시피, 'displayUserInfo'는 콜백 함수입니다. 이를 'getUserInfo'에 전달하는데, getUserInfo는 먼저 데이터베이스에서 사용자 정보('setTimeout'로 시뮬레이션됨)를 가져온 후, 얻은 사용자 정보를 인자로 사용하여 제공된 콜백 함수를 호출합니다. 이렇게 하면 사용자 정보가 준비된 후에야 프로그램이 'displayUserInfo'를 호출할 것임을 확실히 알 수 있습니다.
콜백 헬: 콜백 함수의 피할 수 없는 함정
콜백은 연산 간 순차적 관계를 표현하는 방법을 제공합니다. 그럼에도 불구하고, 이를 잘못 사용하면 콜백 헬이라는 끔찍한 상황이 발생할 수 있습니다. callbackhell.com 에 따르면, "콜백 헬의 원인은 사람들이 자바스크립트를 시각적으로 실행하는 방식으로 작성하려고 할 때 발생합니다." 다음은 그 모습을 설명하기 위한 예시입니다.
function1(function2 () {
일부 시간 소모 작업
function3(function4 () {
일부 시간 소모 작업
function5(function6 () {
일부 시간 소모 작업
})
})
})
보시다시피, 'function1'을 호출할 때 'function2'를 콜백으로 전달합니다. 'function1'의 실행이 끝나면 'function2'가 실행을 시작합니다. 'function2' 내부에서도 'function3'를 호출하고 'function4'를 콜백으로 전달하는 비슷한 상황이 있습니다. 'function4'도 같은 문제가 발생합니다. 이처럼 복잡한 코드 구조는 코드 실행 순서를 시각적으로 따라가는 콜백 작성에서 비롯되며, 이 과정에 더 많은 함수가 포함되면 더 심해질 수 있습니다. 이는 코드 가독성과 유지보수 용성 모두를 저해합니다.
구출을 약속해!
"콜백 지옥을 피할 방법이 있을까요?"라고 물을 수도 있습니다. 코드를 모듈화해 볼 수 있습니다. 하지만 콜백 함수와 이를 사용하는 함수들을 완전히 분리할 수는 없습니다. 더 정교한 대안이 있을까요? Promise를 소개하겠습니다! JavaScript에서 Promise는 시간이 많이 걸리는 연산의 최종 완료 또는 실패와 그 결과를 나타냅니다. 함수들을 서로 중첩할 필요를 없애주어 읽기 쉽고 유지보수 가능한 코드를 만들 수 있게 해줍니다. 다음으로 Promise가 이를 어떻게 구현하는지 보여드리겠습니다.
약속 101: 뭔가를 돌려주겠다고 약속하지만, 100% 확신은 없어요
먼저 간단한 '약속' 객체를 만드는 방법을 살펴보겠습니다.
const promise = 새로운 약속((resolve, reject) => {
어떤 코드든
만약 (isFulfilled) {
결의(결과)
} 그렇지 않으면 {
reject(오류)
}
})
'Promise' 객체를 구성할 때, 우리는 함수를 인수로 생성자에 전달합니다. 이 함수 자체는 두 개의 함수를 인자로 받아들입니다. 첫 번째 인자는 'resolve' 함수입니다. 우리는 'resolve' 함수를 호출하고, 이 함수에서의 비동기 연산 결과를 인자로 사용하여 이후 연산에서 이 결과를 활용할 수 있도록 합니다. 두 번째 인수는 'reject' 함수입니다. 비동기 연산이 실패하면 오류 메시지를 인자로 호출합니다.
이제 데이터베이스에서 사용자 정보를 얻은 후 Promise로 사용자 정보를 표시하는 이전 예시를 다시 작성해 보겠습니다.
function getUserInfo(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
만약 (typeof id === "number") {
const userInfo = {
id,
이름: '아담 스미스',
나이: 30세
};
resolve(userInfo); 가져온 사용자 정보로 약속을 해석하기
} 그렇지 않으면 {
const error = 새로운 오류("Invalid ID")
reject(error) // 오류가 있는 약속 거부
}
}, 2000); 2초 지연 시뮬레이션
})
}
function displayUserInfo(userInfo) {
console.log(사용자정보)
}
console.log('사용자 정보 가져오기 중...'); 즉시 실행
getUserInfo(1)
.then((userInfo) => displayUserInfo(userInfo))
.catch((error) => console.log(error))
.finally(() => console.log("완료"))
'getUserInfo'에서 우리가 가한 첫 눈에 띄는 수정은 이제 원래 코드를 함수로 랩핑하고, 함수로 새로운 'Promise' 객체를 구성한 뒤 'Promise' 객체를 반환한다는 점입니다. 또한, 콜백 함수를 'resolve' 함수로 대체하여 'getUserInfo'를 콜백 함수와 분리하는 데 도움을 줍니다. 'getUserInfo'가 다음 단계에 제공할 수 있는 인수로 'resolve' 함수를 호출하는 것만 신경 쓰면 됩니다. 특정 콜백 함수를 호출하는 데 필요한 추가 매개변수를 더 이상 고려할 필요가 없습니다. 또 다른 눈에 띄는 변화는 오류가 발생했을 때 'getUserInfo'에서 'reject' 함수를 직접 던지는 대신 호출한다는 점입니다.
'getUserInfo'를 호출할 때는 더 이상 콜백 함수를 전달할 필요가 없습니다. 'getUserInfo'는 'Promise' 객체를 반환합니다. 'Promise' 객체에 대해 'then' 메서드를 호출하고, 'Promise' 객체가 성공적으로 해결된 후 호출할 함수를 전달합니다. 이 함수는 'getUserInfo'의 'resolve' 함수를 호출하는 동일한 인수를 사용합니다. 오류 처리의 경우, 'Promise' 객체에 대해 'catch' 메서드를 호출하고, 이를 'getUserInfo'의 'reject' 함수에 전달하는 오류 처리 함수를 사용합니다. 결국 'finally' 메서드에 전달된 함수는 'Promise' 객체가 정착(이행 또는 거부)될 때 실행됩니다.
'then' 메서드의 강력한 점은 메서드 체인(method chaining)이라는 패턴으로 'then' 메서드를 여러 번 호출할 수 있다는 점입니다. 'then' 메서드의 체인 호출은 'then' 메서드에 전달하는 모든 연산의 순서성을 강조하여 코드를 더 깔끔하고 읽기 쉽게 만듭니다. 다음은 예시입니다.
function multiplyByTwo(input) {
return new Promise((resolve, reject) => {
만약 (typeof input === "number") {
const output = input * 2
resolve(출력)
} 그렇지 않으면 {
const error = 새로운 오류("Invalid Input")
reject(오류)
}
})
}
곱셈ByTwo(1)
.then((input) => 곱하기(input))
.then((input) => 곱하기(input))
.then((input) => 곱하기(input))
.then((input) => 곱하기(input))
.then((input) => console.log(input))
보시다시피, 'then' 메서드를 사용해 다섯 번의 'multiplyByTwo' 호출을 연쇄 연결했는데, 이 호출들은 첫 번째와 마지막 호출을 제외하고 이전 호출의 출력을 입력으로 받아 다음 호출로 전달합니다. 위 코드 예제가 콘솔에 몇 개를 쓰는지 맞출 수 있나요?
이 블로그에서는 비동기 프로그래밍에서 왜 Promise를 사용해야 하는지, 그리고 Promise를 어떻게 사용하는지 다뤘습니다. 저는 동기식 프로그래밍과 비동기식 프로그래밍의 차이점을 설명하는 것으로 시작했습니다. 그 후 비동기 프로그래밍에서 콜백 함수의 사용과 그 불가피한 단점을 시연했습니다. 마지막으로, Promise 사용의 장점과 여러 예시를 소개했습니다. 이 글이 Promise의 이유와 방법에 대해 이해하는 데 도움이 되었기를 바랍니다.