Published on

ES6) Promise부터 함수형 프로그래밍까지

Authors
  • avatar
    Name
    Hoehyeon Jung
    Twitter

Promise

이 글은 Promise에 대한 사용법보다 발생 배경 및 callback 함수와의 차이점 및 함수형 프로그래밍에 대한 이론적인 내용을 주로 다루고자 합니다. 따라서 Promise에 대한 구체적인 사용법이나 개념 이해가 필요하신 분은 게시글 맨 밑의 참조부분을 확인해주시길 바랍니다.


자바스크립트는 기본적으로 non-block, single thread 기반이기 때문에 어떤 부분에서 함수의 동작이 오래걸린다고 해서 동작을 기다리지 않고 다음 코드로 넘어간다. 이러한 비동기 코드를 순차적으로 제어하기 위해 ES6 이전에는 callback 함수를 이용했다.

// ES5
  var xhr = new XMLHttpRequest();
  var loginData = {email: 'gonewbie@github.com', password: 'p@ssw0rd'};
  // callback function
  xhr.onload = function() {
    if (xhr.status === 200 || xhr.status === 201) {
      console.log(xhr.responseText);
      var token = JSON.parse(xhr.responseText).token;
      // -- nested ajax ----
      var xhr2 = new XMLHttpRequest();
        xhr2.setRequestHeader('Authorization', token);
        xhr2.onload = function() { // callback function
          if (xhr2.status === 200 || xhr2.status === 201) {
            console.log(xhr2.responseText);
          } else {
            console.error(xhr2.responseText);
          }
        };
        xhr2.open('GET', 'https://blahblah.blah/api/profile');
      // ------------------
    } else {
      console.error(xhr.responseText);
    }
  };
  xhr.open('POST', 'https://blahblah.blah/api/login');
  xhr.send(JSON.stringify(loginData)); // 폼 데이터 객체 전송

하지만 이렇게 콜백 함수의 depth가 깊어지면 callback을 벗어나기도 힘들어지는 callback hell과 더불어 코드 가독성이 현저하게 떨어지게 된다. 이를 해결하기 위해 나타난 개념이 promise이다. promise란, 실행 시간이 어느 정도 걸리는 함수를 wrapping하여 성공, 실패 여부에 따른 리턴을 결정한다. ES6에 정식적으로 spec으로 추가 되기 전에는 bluebird.js,jQuery의 ajax 등에서 별도로 구현하곤 했다. 이후에 ES6에 정식으로 도입된 promise가 표준으로 도입됨으로써 별도의 라이브러리 없이 vanilla js로 구현이 가능해졌다. 아래는 promise를 기반으로 작성된 fetch API로 수정된 AJAX이다.

fetch('https://blahblah.blah/api/login', {
  method: 'POST',
  body: {email: 'gonewbie@github.com', password: 'p@ssw0rd'}
}).then(data => {
  console.log(data);
  fetch('https://blahblah.blah/api/profile', {
    headers: {
      Authorization: data.token,
    }
  })
}).then(profile => {
  console.log(profile);
}).catch(error => {
  console.error(error);
});

fetch는 Promise로 작성된 AJAX API

Promise가 뭣이 중헌데?

Promise는 callback으로 비동기함수를 제어하는 데에서 발생할 수 있는 문제들에 대해 개발자들에게 더 우아한 해결책을 제시해준다.

depth를 1로 관리

var login = function(id, callback) {
  if (id === 'gonewbie') {
    callback(null, {nickname: 'gonewbie', token: 'sdf.data.dsfsdf'});
  } else {
    callback(`${id} is not found`, null);
  }
}
var getProfile = function(data, callback) {
  var token = data.token;
  var datas = token.split('.')[1];
  if (datas === 'data') {
    callback(null, {bio: 'blahblah', image: '...'});
  } else {
    callback(`${data} is not found`, null);
  }
}

login('gonewbie', function(err, data) {
  console.log(data);
  getProfile(data, function(err, data) {
    console.log(data);
    // .. 비동기 함수의 depth가 깊어질수록 실수 발생 가능성 증가
  });
});

ES5까지는, 코드 베이스가 커지고 이에 따라 비동기 함수의 순서를 관리하기 위해 callback 함수를 중첩되게(nested) 입력하는 경우가 많게 되었다. 하지만, 이러한 depth의 깊이 증가는 가독성을 떨어뜨리고 depth를 잘못 파악하여 callback 함수의 주도권을 잘못 파악하게 되는 문제가 발생할 수 있다. ES6에 들어서 도입된 Promise는 이러한 시간이 소요되는 함수에 대해 Promise로 wrapping하여 depth를 관리하기 용이하게 된다. Promise는 nested하게 되더라도 기본적으로 depth는 1로 유지되며, 이러한 것의 장점은 개발자는 depth를 관리해줄 필요가 없다는 것이다. 이렇듯 Promise의 depth가 1인 것은 method chaining이 가능하다는 것인데 이것에 대한 장점은 후술하겠다.

에러 처리의 우아함

var login = function (id, callback) {
  if (id === 'gonewbie') {
    callback(null, {nickname: 'gonewbie', token: 'sdf.data.dsfsdf'});
  } else {
    callback(`${id} is not found`, null);
  }
}

login('gonewbie', function (err, data) {
  if (err) console.error(err);
  else console.log(data);
  // business logic
  // ...
  throw new Error('unexpected Error'); // Uncaught Error...
});
login('kimchi', function (err, data) {
  if (err) console.error(err);
  else console.log(data);
});

기존에 callback 함수가 포함된 비동기 함수에서 에러를 처리할 때 가장 큰 문제점은 callback 함수 내에서 error가 발생할 때, 이를 catch하는 것이 어렵다는 것이다. Promise.prototype.then내부 로직은 callback 함수의 에러 처리와 유사하게 .then(onFulfilled, onRejected) 이루어져 있다. 첫 번째 인자로 받는 함수는 Promise가 fulfilled인 상태일 때 수행하는 함수이고, 두 번째 인자가 있을 경우 rejected인 상태일 때 수행하는 함수이다. 하지만 이렇게 then에서 이전 Promise의 fulfill/reject 여부에 따라 구분하여 해결할 수 있다.

var p1 = new Promise(function(resolve, reject) {
  resolve("성공!");
  // 또는
  // reject("오류!");
});

p1.then(function(value) {
  console.log(value); // 성공!
}, function(reason) {
  console.log(reason); // 오류!
});

하지만 Promise.prototype.then에서도 에러가 발생할 수 있다. 이 때는 어떻게 에러 처리를 해야 할까? Promise.prototype.then을 잘 살펴보면 그 답을 알 수 있다. Promise.prototype 함수들은 모두 Promise 객체를 다시 반환한다. 즉 반환된 결과에 다시 Promise의 메서드들을 쓸 수 있다는 것이다. 이는 추후에도 다룰 method chaining에 관련된 개념이다. 여기서 우리가 주목할 것은 Promise.prototype.catch 메서드이다. 이 메서드는 이전에 chaining된 Promise 중에서 rejected된 응답이 있을 경우 이를 catch해서 처리하는 함수이다. 따라서, Promise chaining 중에 발생한 오류를 한 번에 포착하여 처리할 수도 있고, then으로 반환된 Promise 객체 내 오류까지 잡을 수 있다. 따라서, catch에 에러 처리를 모두 일임하고, Promise와 then에서는 resolve, reject에 관련된 처리만 하는 것이 바람직하다.

만능 해결사, Method Chaining

자바스크립트의 Array는 강력한 built-in 함수들이 많이 내장되어 있다. 우리가 흔히 알고 있는 map, filter, reduce와 같은 고차함수들은 함수형 프로그래밍의 특징 중의 하나인 선언적이라는 특징을 살릴 수 있다. 게다가, 입력과 반환이 모두 Array이기 때문에 Array에 관련된 built-in method를 연속해서 쓸 수 있다. 만일 반환되는 값이 Array가 아니라면 map, filter, reduce 등을 연속해서 쓸 수 없을 것이다. 아래는 method chaining을 이용하여 배열 내 짝수들의 합의 2배를 구하는 함수의 예이다.

const isEven = val => val % 2 === 0;
const doubleNumber = val => val * 2;
const addAll = (acc, cur) => acc + cur;

[1, 2, 3, 4, 5, 6]
.filter(isEven)
.map(doubleNumber)
.reduce(addAll, 0);

이렇듯 method를 이어서 연속적으로 쓰는 것을 method chaining이라고 한다. method chaining에 대해 이렇게 설명하는 것은 Promise 역시 이러한 특징을 살릴 수 있기 때문이다. Promise로 작성된 함수의 return 값은 Promise로 wrapping되어 반환된다. 즉, 입력과 출력이 모두 Promise이기 때문에 method chaining이 가능한 것이다. Promise에는 then, catch, finally 와 같은 method를 사용할 수 있다.

const login = (id) => new Promise((resolve, reject) => {
  setTimeout(() => {
    let user = {};
    if(id === 'gonewbie') user = {nickname: 'gonewbie', token: 'sdf.data.dsfsdf'};
    else reject(`${id} is not found`);
    console.log(user);
    resolve(user);
  }, 1000);
});
const getProfile = (data) => new Promise((resolve, reject) => {
  setTimeout(() => {
    let profile = {};
    const token = data.token;
    const sendedData = token.split('.')[1];
    if (sendedData === 'data') profile = {bio: 'blahblah', image: '...'};
    else reject(`${data} is not found`);
    console.log(profile);
    resolve(profile);
  }, 1000);
});

// login
login('gonewbie')
.then(getProfile)
.catch(console.error)
.finally(() => console.log('job is done'));

login('kimchi')
.then(getProfile)
.catch(console.error)
.finally(() => console.log('job is done'));

Promise로 선언된 함수는 실제 호출될 경우 pending 상태의 Promise를 반환한다. 이후 then method를 만나거나 ES7 이후 도입된 await를 만나면 resolve 혹은 reject 상태가 된다. 또한 위에서 언급된 내용들처럼, chaining을 통해 Promise 객체를 단일 depth로 반환하고, catch를 통한 에러 처리가 가능한 점이 있다.

Promise 합성하기

Promise를 method chaining를 통해 함수들을 좀 더 선언적으로 사용할 수 있는 측면을 이전에 살펴보았다. 여기에 함수를 합성하는 방법을 통해 좀 더 함수형 프로그래밍에 접근해보자. 이전에 스터디에도 다뤘듯이 함수를 합성하는 방법으로 흔히 reduce를 활용하여 다음과 같이 작성한다.

const pipe = (...functions) =>
  initialValue =>
    functions.reduce(
      (acc, func) => func(acc),
      initialValue
    );
const isEven = arr => arr.filter(elem => elem % 2 === 0);
const doubleNumber = arr => arr.map(elem => elem * 2);
const addAll = arr => arr.reduce((acc, cur) => acc + cur, 0);

const getTotalDoubledEvenNum = pipe(isEven, doubleNumber, addAll);
const result = getTotalDoubledEvenNum([1,2,3,4,5,6]);
console.log(result); // 24

여기서 custom하게 작성한 pipe함수는 함수를 합성하기만 할 뿐 실제 실행 시에 동작하는 lazy evaluation의 특징이 있다. Promise도 이와 같이 합성이 가능하다.

const pipePromise = (...functions) => initialValue =>
  functions.reduce(
    (acc, func) => Promise.resolve(acc).then(func),
    initialValue
  );
const login = (id) => new Promise((resolve, reject) => {
  setTimeout(() => {
    let user = {};
    if(id === 'gonewbie') user = {nickname: 'gonewbie', token: 'sdf.data.dsfsdf'};
    else reject(`${id} is not found`);
    console.log(user);
    resolve(user);
  }, 1000);
});
const getProfile = (data) => new Promise((resolve, reject) => {
  setTimeout(() => {
    let profile = {};
    const token = data.token;
    const sendedData = token.split('.')[1];
    if (sendedData === 'data') profile = {bio: 'blahblah', image: '...'};
    else reject(`${data} is not found`);
    console.log(profile);
    resolve(profile);
  }, 1000);
});

const loginAndGetProfiles = pipePromise(login, getProfile);
loginAndGetProfiles('gonewbie');

loginAndGetProfiles('kimchi');

pipePromise함수를 통해 Promise를 왼쪽에서 오른쪽으로 합성하였다. 물론 입력 값도 Promise여야 하므로 값을 wrapping해야 하는 점이 문제지만 Promise 로직을 합성하고 lazy하게 평가하며 재사용성을 높일 수 있게 되었다. 오예!

Promise는 monad인가?

Promise는 기본적으로 값을 Promise 객체로 wrapping하며 depth도 우아하게 관리하며 성공과 실패에 대해 동일한 Promise 객체에서 처리한다. 이를 통해 실패(Left)와 성공(Right) 값을 가지는 Either Monad의 요소를 가진다고 볼 수도 있다. 이는 멀티 패러다임 언어이자 함수형 프로그래밍이 가능한 Scala에 구현된 Either와 비교하여 동작을 확인해보자.

constructor

Promise를 resolve, reject 생성자를 통해 초기화하는 것은 scala의 Right, Left에 대응된다 볼 수 있다. Javascript

// javascript
const r = Promise.resolve(1);
const l = Promise.reject(0);
// scala
val r: Either[Int, Int] = Right(1)
val l: Either[Int, Int] = Left(0)

Promise.prototype.then(f)

// javascript
r.then(x => x + 10);  // Resolve(11)
l.then(x => x + 10);  // Reject(0)

// nested promise
r.then(x => Promise.resolve(3));  // Resolve(3)
r.then(x => Promise.reject(3));  // Reject(3)
l.then(x => Promise.resolve(3));  // Reject(0)
l.then(x => Promise.reject(3));   // Reject(0)
// scala
r.map(_ + 10);  // Right(11)
l.map(_ + 10);  // Left(0)

// nested Either
r.flatMap((x: Int) => Right[Int, Int](3));  // Right(3)
r.flatMap((x: Int) => Left[Int, Int](3));   // Left(3)
l.flatMap((x: Int) => Right[Int, Int](3));  // Left(0)
l.flatMap((x: Int) => Left[Int, Int](3));   // Left(0)

then으로 들어오는 함수는 우리가 Array.prototype.map으로 익숙한 map 고차함수와 같이 mapping하는 함수이다. 만일 Promise 내에 Promise가 들어오거나 scala에서는 Either내에 Either가 들어오는 등 nested한 형태로 데이터가 들어올 경우 flatMap과 같은 역할을 한다. 여기서 주목할 점은 Promise던 Either건 Reject, Left 등의 에러에 해당하는 형태가 들어오면 이를 전파(propagation)하여 해당 에러 데이터로 값을 감싼다.

Promise.prototype.catch(f)

// javascript
r.catch(x => x + 10);  // Resolve(1)
l.catch(x => x + 10);  // Resolve(10)

r.catch(x => Promise.resolve(x + 10));  // Resolve(1)
r.catch(x => Promise.reject(x + 10));   // Resolve(1)
l.catch(x => Promise.resolve(x + 10));  // Resolve(10)
l.catch(x => Promise.reject(x + 10));   // Reject(10)
// scala
r.left.flatMap(((x: Int) => x + 10) andThen Right[Int, Int]);  // Right(1)
l.left.flatMap(((x: Int) => x + 10) andThen Right[Int, Int]);  // Right(10)

r.left.flatMap(x => Right[Int, Int](x + 10));  // Right(1)
r.left.flatMap(x => Left[Int, Int](x + 10));   // Right(1)
l.left.flatMap(x => Right[Int, Int](x + 10));  // Right(10)
l.left.flatMap(x => Left[Int, Int](x + 10));   // Left(10)

catch를 통해 Reject 상태의 Promise를 처리하게 되면 Promise는 Resolve 상태하게 된다. 또한 Resolve 상태의 값은 catch를 통해 값이 변하지 않으므로 내부 데이터는 초기값 1이 유지된다. 하지만 nested한 Reject Promise는 catch를 통해 Resolve되지 않고 Reject상태의 Promise를 반환한다.

Scala에서는 catch에 상응하는 left.flatMap 함수를 통해 depth 1에서는 오류가 나던 안나던 처리가 완료된 Right에 값이 감싸져 데이터를 준다. Scala 역시 Right의 값에서는 데이터가 유지되고 Left의 경우 처리된 값이 Right으로 감싸져 나온다. Scala도 JS와 마찬가지로 nested된 Left Monad에서는 Left로 감싸진 값을 반환하게 된다.

그럼 Promise도 Monad인가?

Warning! 현재 분류는 수학적인 개념 및 함수형 개념이 약간 섞여 있습니다. 수학에 S자도 생각하기 싫으신 분은 다음 분류로 넘어가셔도 됩니다.

우선 결론부터 말하자면 Promise는 Monad가 아니다! 이유는 아래 stackoverflow에 자세히 나와 있다.

가장 upvote를 많이 받은 답을 기준으로 간단하게 살펴보면 우선 Promise는 Functor가 아닌데 그 이유는 Functor의 composition preservation law를 위반하기 때문이다. 수학적인 개념으로 보면 Promise는 Functor이다.라는 명제의 반례가 하나 이상일 경우 해당 명제가 거짓이라는 것을 반례를 통해 증명한 것이다.

promise.then(x => g(f(x))) !== promise.then(f).then(g)

위의 예가 맞지 않기 때문에 Functor가 아닌 것이다. 우리가 궁금한 것은 Functor가 아니라 Monad인데요? 하지만, Promise는 Applicative도 심지어 Monad도 아니다. 아래의 법칙을 만족하지 않기 때문이다.

Promise.resolve(g(x)) !== Promise.resolve(x).then(g)

Applicative 중 하나인 Pointed Functor의 natural transform law를 위반하기 때문에 Applicative, Monad도 만족하지 않는다.

Functor, Applicatives, Monad 개념 참고

그럼 어쩌란 말인가?

JS는 기본적으로 multi paradigm 언어이다. 절차 지향, 객체 지향, 함수형 프로그래밍을 모두 구현 가능한 만큼 함수형을 완벽하게 구사하는 것이 불가능하기도 하고 그것이 JS의 주 목적은 아닐 것이다. 함수형을 엄격하게 수행하기 위해서는 지금 당장 JS를 버리고 Haskell을 배워야 할 것이다. 하지만 고차함수, Currying 등을 오래전부터 지원해왔고 이러한 요소들을 통해 함수형 프로그래밍이 가능하다. 함수형 프로그래밍을 통해, 함수를 재사용 가능한 가장 작은 요소로 분리하여 재사용을 통해 문제를 해결하는 Divide and Conquer 원칙으로 문제를 해결할 수 있다. 실제로 웹 프론트엔드 라이브러리 React나 상태 관리 저장소 Redux 등은 함수형 프로그래밍 요소도 제공하는 등 실제로도 주변에서 많이 볼 수 있다.

const todos = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ]
    case 'TOGGLE_TODO':
      return state.map(todo =>
        (todo.id === action.id)
          ? {...todo, completed: !todo.completed}
          : todo
      )
    default:
      return state
  }
}

export default todos

위는 Redux의 TODO App 예제의 reducer 중 하나이다. action의 type은 Algebraic Data Type(이하 ADT) 중 Sum Type이다. Sum Type은 enum과 같이 type이 여러 개 중 하나를 만족하도록 하는 것이다. 즉, action의 type 역시 위 3개의 조건 중 하나로 구성되어 있다는 것을 알 수 있다. 또한 return으로 반환하는 state는 ...(spread 연산자)를 통해 새로 배열을 선언하여 다시 반환되는 것을 알 수 있다. 여기에는, 함수형 프로그래밍에서 흔히 쓰이는 Immutable한 데이터 처리 방식을 따랐다. JS는 기본적으로 객체와 같은 type은 참조를 하기 때문에 객체 내부 데이터를 일일히 따져서 비교하기 보다는 새로 선언되어 다른 메모리에 할당된 데이터를 파악하여 state를 관리하는 방식을 채택한 것이다.

즉, 우리가 사용하는 framework이나 library들이 FP를 기반으로 작성되기도 하고 실무에서도 FP를 이용하여 DRY(Don't Repeat Yourself)하게 작성된 코드를 볼 수 있을 것이다. 따라서 FP에 대해 막연한 두려움을 가지기 보단, 자신이 아는 개념을 활용해서 차근차근 이해하고 이를 코드에 적용하는 것이 좋다.

마치며..

Promise의 정말 기본적인 동작부터 Promise를 이용한 선언적 프로그래밍, 함수 합성과 이를 기반으로 함수형 프로그래밍의 원론적인 지식을 가볍게 다뤄보았다. Promise는 비동기 함수를 우아하게 처리하는 훌륭한 요소인 만큼 FP 요소들과 접목해서 사용하면 좀 더 강력하고 읽기 쉬운 코드 작성이 가능할 것이다. 마지막으로, 함수형 프로그래밍 어렵지 않습니다. 덜 익숙한 것일 뿐.

참조