본문 바로가기

책 리뷰/함수형 자바스크립트

함수형 자바스크립트 [8장]

※ 함수형 자바스크립트 라는 책을 한 장씩 리뷰해보도록 하겠습니다. 어느덧 마지막 장 입니다.

 

 

Chapter 8. 비동기 이벤트와 데이터를 관리

 

" 함수형 프로그래머는 함수형 프로그래밍이 엄청나게 유익하다고 주장합니다. 기존 프로그램보다 한 자릿수나 더 짧은 함수형 프로그램이 생산성은 외려 한 자릿수 더 생산적이라고 하지요 "

 

이번 장에서는 프로그램의 실행을 그대로 좇아가지 않는 코드에서 비동기 데이터 흐름과 관련된, 실세계의 자바스크립트 난제들을 리액티브 프로그래밍을 활용해 ES6 프로미스와 함수형 프로그램을 접목시켜 콜백 코드를 우아한 표현식으로 변경하는 방법을 볼 것입니다.

 

8.1 골칫덩이 비동기 코드

 

자바스크립트 프로그램은 사용자의 필요에 따라 여러 비동기 요청을 동시에 전송해 데이터를 페이지에 로드하는 경우가 많습니다. 이러한 경우 대부분 비차단 비동기 호출 코드를 구현해 문제를 해결합니다. 하지만 아래와 같은 문제점을 동반합니다.

  • 함수 간에 일시적 의존 관계 형성
  • 콜백 피라미드의 늪
  • 동기/비동기 코드의 호환되지 않는 조합

 

8.1.1 함수 간에 일시적 의존 관계가 형성

 

AJAX 요청을 통해 학생 정보를 가져오는 비동기 함수가 있다고 가정할 때, 이러한 원격 요청은 아직 처리되지 않아 null 인 상태를 갖고 있으면 그다음 액션이 정상적으로 일어나지 않습니다. 이벤트 순서가 올바르게 발생하도록 보장혀려면 비동기 코드와 그다음 액션 사이에 일시적인 관계를 설정할 수밖에 없습니다.

일시적 결합 (일시적 응집) 은 어떤 함수를 논리적으로 묶어 실행할 때 발생합니다. 데이터가 도착할 때까지 혹은 다른 함수가 실행될 때까지 기다려야하는 경우이며, 이 순간 부수효과가 발생합니다.

 

8.1.2 콜백 피라미드의 늪에 빠짐

 

처리 시간이 오래 걸리는 프로세스를 기다리는 도중 UI 를 차단하지 않기 위해 콜백을 사용하는데, 이 콜백을 받는 함수는 값을 반환하는 대신 제어의 역전을 실천합니다. 아래 예시)

var students = null;
getJSON('/students',
  function (students) {
    showStudents(students);
  },
  function (error) {
    console.log(error.meessage);
  }
);

하지만 이런 식의 제어 역전 구조는 함수형 프로그램의 설계 사상과 정면으로 배치됩니다. 이미 중첩된 콜백에 비동기 로직을 더할 경우 상황은 더욱 악화됩니다. 조금 과한 예제를 보겠습니다.

var selector = document.querySelector;

selector('#search-button').addEventListener('click',
  function (event) {
    event.preventDefault();
    
    let ssn = selector('#student-ssn').value;
    
    if (!ssn) {
      console.log('warning');
      return;
    } else {
      getJSON(`/students/${ssn}`, function (info) {
        selector('#student-info').innerHTML = info;
        selector('#student-info').addEventListener('mouseover', function() {
          getJSON(`/students/${info.ssn}/grades`, function (grades) {
            // ... 다른 로직들 ...
          });
        });
      })
      .fail(function() {
        console.log('error');
      });
    }
});

일부러 코드를 따라가기 어렵게 만든 예제이지만, 자바스크립트에서 비동기를 콜백으로만 처리하던 때에 이러한 '콜백 지옥' 혹은 '비운의 크리스마스 트리' 는 자주 볼 수 있었습니다.

 

8.1.3 연속체 전달 스타일

 

중첩된 콜백 함수는 읽기도 어렵지만 자기 스코프 및 자신이 중첩된 함수의 변수 스코프를 감싼 클로저를 만듭니다. 어떤 함수를 다른 함수에 중첩하는 건, 그 함수가 어떤 일을 달성하기 위해 자신의 외부 변수에 직접 접근해야 할 경우에만 의미가 있습니다. 이러한 중첩된 콜백 함수 코드는 연속체 전달 스타일 (CPS) 로 바꾸어 개선할 수 있습니다.

바로 위에서 본 예제를 연속체 전달 스타일로 리팩토링 해보겠습니다.

var selector = document.querySelector;

selector('#search-button').addEventListener('click', handleClickEvent);

const processGrades = function (grades) {
  // ... 다른 로직 ...
};

const handleMouseMovement = () => 
  getJSON(`/students/${info.ssn}/grades`, processGrades);

const showStudent = function (info) {
  selector('#student-info').innerHTML = info;
  selector('#student-info').addEventListener('mouseover', handleMouseMovement);
};

const handleError = error => console.log(error.message);

const handleClickEvent = function (event) {
  event.preventDefault();
  
  let ssn = selector('#student-ssn').value;
  if (!ssn) {
    alert('warning');
    return;
  } else {
    getJSON(`/students/${ssn}`, showStudent).fail(handleError);
  }
};

여러 내부 콜백 함수를 개별 함수 또는 람다 표현식으로 나눈게 전부입니다. CPS 는 비차단 프로그램의 조각들을 개별 컴퍼넌트로 분리하기 위한 프로그래밍 스타일이며, 함수형 프로그래밍의 중간형태라고 할 수 있습니다. 여기서 콜백함수는 현재 연속체 라고 부르며, 함수 자체를 호출자에게 반환값으로 돌려줍니다.

CPS 는 컨텍스트 스택의 효율이 좋다는 강점이 있습니다. CPS 코딩은 코드에 잔존하는 일시적 의존 관계를 없애고, 비동기 흐름을 선형적인 함수 평가 형태로 둔갑시킵니다. 하지만 이보다 더 좋은 방법은, 비동기 연산을 일급 객체로 만드는 것입니다.

 

8.2 비동기 로직을 프로미스로 일급화

 

함수형 프로그램이라면 아래와 같은 특성을 지녀야 합니다.

  • 합성과 무인수 프로그래밍을 이용
  • 중첩된 구조를 보다 선형적으로 눌러 폄
  • 일시적 결합은 추상하기 때문에 개발자는 신경쓰지 않음
  • 여러 콜백 대신, 단일 함수로 에러 처리 로직을 통합

위의 특성들은 모나드가 할 일과 같으며, 프로미스 또한 모나드 입니다. (오래 걸리는 계산을 모나드로 감싸는 개념) 다만 이전의 모나드와 달리 프로미스는 오래 걸리는 계산이 끝날 때까지 기다렸다가 미리 매핑한 함수를 실행합니다. 비동기 호출에서의 문제를 프로미스라는 자료구조로 해결하는 것이며, 복잡한 계산이나 DB/서버로부터 데이터 조회 또는 파일 IO 작업등의 값이나 함수를 프로미스로 감싸는 것입니다.

 

프로미스의 상태는 언제나 보류(pending), 이룸(fulfilled), 버림(rejected), 귀결(settled) 중 하나입니다. 처음은 보류 상태로 시작해 오리 걸리는 작업 결과에 따라 이룸 또는 버림상태로 분기하고, 프로미스가 문제없이 이루어지면 귀결된 상태라 봅니다. null 체크하는 if-else 중첩문을 Maybe 모나드로 제거했던 것처럼 프로미스를 통해 중첩된 콜백 함수를 일련의 작업들로 전환할 수 있습니다.

 

기본적인 프로미스 객체 구성 방식입니다.

var fetchData = new Promise(function(resolve, reject) {
  if (success) resolve(result);
  else reject(new Error('Error'));
});

이러한 프로미스를 적용한 간단한 예제를 보겠습니다.

var Scheduler = (function () {
  let delayedFn = _.bind(setTimeout, undefined, _, _);
  
  return {
    delay5: _.partial(delayedFn, _, 5000),
    delay10: _.partial(delayedfn, _, 10000),
    delay: _.partial(delayedFn, _, _)
  };
})();

var promiseDemo = new Promise(function(resolve, reject) {
  Scheduler.delay5(function () {
    resolve('success'); // 프로미스 귀결
  });
});

promiseDemo.then(function(status) {
  console.log('5초 후 상태: ' + status); // 5초 후 프로미스 귀결
});

 

8.2.1 미래의 메서드 체인

 

프로미스 객체는 then 메서드를 지닙니다. Maybe.map(f) 처럼 Promise.then(f) 는 데이터 변환 작업을 서로 체이닝하고 여러 함수를 제때 모아 함수 사이의 일시적인 결합을 추상하는 용도로 쓰입니다.

API 를 프로미스화 하면 기존 콜백보다 훨씬 코드를 다루기 쉬워 여러모로 좋습니다. 데이터 조회뿐 아니라 오래 걸리는 어떤 종류의 작업도 프로미스로 감쌀 수 있기 때문에 then 메서드를 구현한 객체 (데너블) 와 병용할 수 있습니다.

 

프로미스는 비동기 흐름을 숨기지만 시간 관념은 then 으로 분명히 드러내기 때문에 프로미스화 한 함수를 다른 프로미스 함수로 쉽게 변경 가능하고 그럼에도 동작은 똑같습니다. 이런 수준의 유연성을 위치 투명성 이라고 합니다.

 

프로미스는 또한 브라우저의 다중 접속 기능을 활용하여 한 번에 여러 데이터 항목 조회도 가능한데, 데이터를 조회하는 순서나 어느 요청이 더 빨라야 하는지를 고려할 필요가 없이 동시에 작업을 진행하는 경우 Promise.all() 을 사용하면 됩니다.

 

8.2.2 동기/비동기 로직을 합성

 

이부분은 예시로 보겠습니다.

//fetchStudentDBAsync :: DB -> String -> Promise(student)
const fetchStudentDBAsync = R.curry(function (db, ssn) {
  return find(db, ssn);
});

// findStudentAsync :: String -> Promise
const findStudentAsync = fetchStudentDBAsync(db); // 함수 합성을 위해 저장소 객체 커리

// then :: f -> Thenable -> Thenable
const then = R.curry(function (f, thenable) { // 데너블형에도 연산을 체이닝할 수 있게 만듬
  return thenable.then(f);
});

// catchP :: f -> Promise -> Promise
const catchP = R.curry(function (f, promise) { // 프로미스 객체에 에러 처리 로직 부여
  return promise.catch(f);
});

// errorLog :: Error -> void
const errorLog = _.partial(logger, 'console', 'basic', 'ShowStudentAsync', 'Error'); // 콘솔에 에러 수준의 로거 만듬

// 동기/비동기 로직 합성
const showStudentAsync = R.compose(
  catchP(errorLog), // 에러가 나면 여기서 잡힘
  then(append('#student-info')), // then 은 모나드 map 함수와 같음
  then(csv),
  then(R.props(['ssn', 'firstname', 'lastname'])),
  chain(findStudentAsync), // 동기/비동기 코드가 맞물려 굴절되는 지점
  map(checkLengthSsn),
  lift(cleanInput));

 

8.3 느긋한 데이터 생성

 

ES6 의 가장 강력한 특성 중 하나는 어떤 함수를 끝까지 실행하지 않아도 데이터 제공을 잠시 중단한 상태로 다른 함수들과 더불어 작동시키는 능력입니다. 제너레이터 함수는 function* 라고 표기하는, 언어 수준에서 지원되는 장치입니다. 이 함수는 새로운 키워드인 yield 를 만나면 잠시 밖으로 나갔다가 자신의 보관된 컨텍스트를 찾아 다시 돌아오는 동작을 합니다.

R.range(1, Infinity).take(1); // -> [1]
R.range(1, Infinity).take(3); // -> [1,2,3]

느긋한 평가 언어라면 위처럼 임의의 크기로 리스트 생성이 가능하지만, 자바스크립트는 조급한 평가 언어라 R.range 실행시 함수 스택이 넘쳐 에러가 발생합니다. 제너레이터는 함수를 호출하는 시점에 내부적으로 이터레이터 객체를 생성하여 느긋함을 부여하고, 이터레이터는 매번 yield 를 호출할 때마다 데이터를 돌려줍니다.

제너레이터를 사용해 긴 숫자 리스트 중 처음 세 원소만 취하는 예시입니다.

function *range(start = 0, finish = Number.POSITIVE_INFINITY) {
  for (let i = start ; i < finish ; i++) {
    yield i;
  }
}

const num = range(1);
num.next().value; // -> 1
num.next().value; // -> 2
num.next().value; // -> 3

 

8.3.1 제너레이터와 재귀

 

일반 함수 호출과 마찬가지로 제너레이터도 다른 제너레이터를 호출할 수 있습니다. 제너레이터는 for..of 루프문으로 반복할 수 있기 때문에 다른 제너레이터에게 위임하는 건 두 컬렉션을 병합한 전체 컬렉션을 반복하는 것과 비슷합니다.

 

8.3.2 이터레이터 프로토콜

 

제너레이터는 또 다른 ES6 기능인 이터레이터와 밀접한 관계가 있습니다. 제너레이터 함수는 내부적으로 이터레이터 프로토콜에 따라 yield 키워드로 값을 반환하는 next() 메서드가 구현된 Generator 객체를 반환합니다. 아래는 Generator 객체의 속성입니다.

  • done : 제일 마지막에 이터레이터가 전달되면 ture, 그외에는 false (false 는 이터레이터가 아직 다른 값을 생산할 수 있음을 의미)
  • value : 이터레이터가 반환한 값

이터레이터와 이터러블의 자세한 사용법은 이 링크 를 참고하면 좋을 듯 합니다. 자바스크립트에는 내부 속성 @@iterator 를 이용해 이터러블 객체로 취급할 수 있는 것들 (배열, 문자열 등) 이 많습니다.

var iter = ['S', 't', 'r', 'e', 'a', 'm'][Symbol.iterator]();
iter.next().value; // S
iter.next().value; // t

var iter2 = 'Stream'[Symbol.iterator]();
iter2.next().value; // S
iter2.next().value; // t

이벤트나 값들을 이산적인 순차열로 만드는 스트림으로 바라보는 습관을 기르는 것이 좋습니다.

 

8.4 RxJS 를 응용한 함수형 리액티브 프로그래밍

 

RxJS 라는 리액티브 라이브러리를 사용하면 비동기 프로그램과 이벤트 기반 프로그램을 우아하게 엮을 수 있습니다. 함수형 프로미스 기반의 예제와 비슷하지만 더 높은 수준의 추상화를 제공하며 더 강력한 연산을 제공합니다.

 

8.4.1 옵저버블 순차열로서의 데이터

 

옵저버블구독 가능한 모든 객체를 가리킵니다. 리액티브 프로그래밍은 모든 데이터 제공원을 Rx.Observable 객체를 통해 옵저버블 스트림이라는 단일 개념으로 일원화합니다. 스트림이란 시간의 흐름에 따라 발생하는 이벤트의 순차열입니다. 값을 추출하려면 구독은 필수입니다.

Rx.Observable.range(1,3)
  .subscribe(
    x => console.log(`다음: ${x}`),
    err => console.log(`에러: ${err}`),
    () => console.log('완료!')
  );
  
// output
다음: 1
다음: 2
다음: 3
완료!

어떤 자료형이라도 Rx.Observable 을 이용해서 스트림으로 바꿀 수 있으며, 다루는 방식 또한 동일합니다. Rx.Observable 로 어떤 옵저버블 객체라도 감싸거나 승급하면 관찰된 값에 상이한 함수를 매핑, 적용해서 원하는 출력을 얻을 수 있는데 이는 결국 모나드입니다.

 

8.4.2 함수형 리액티브 프로그래밍

 

Rx.Observable 객체는 map, of, join 등 최소한의 모나드 인터페이스에 해당하는 구현체와 스트림 조작에 특화된 메서드를 가지고 있습니다. 아래 예시)

Rx.Observable.of(1,2,3,4,5)
  .filter(x => x % 2 !== 0)
  .map(x => x * x)
  .subscribe(x => console.log(`다음: ${x}`));

// output
다음: 1
다음: 9
다음: 25

스트림을 이용하면 코드를 선언적으로, 연산을 체이닝하는 형태로 작성하게 되며, 리액티브 프로그래밍은 이렇게 함수형 프로그래밍과 유사한 경향이 있어 함수형 리액티브 프로그래밍 (FRP) 이란 용어로도 불립니다.

하나의 예시를 더 보겠습니다.

// SSN 필드 입력값이 올바른지 검증하는 코드
document.querySelector('#student-ssn')
  .addEventListener('change', function (event) {
    let value = event.target.value;
    value = value.replace(/^\s*|\-|\s*$/g, '');
    console.log(value.length !== 9 ? '맞음' : '틀림'));
});
// -> 444 맞음
// -> 444-44-4444 틀림

// 위 코드를 FRP 로 리팩토링
Rx.Observable(fromEvent(
  document.querySelector('#student-ssn'), 'change')
  .map(x => x.target.value)
  .map(cleanInput)
  .map(checkLengthSsn)
  .subscribe( // 검증 결과가 Either.Right / Either.Left 중 어느 쪽인지 체크
    ssn => ssn.isRight ? console.log('Valid') : console.log('Invalid'));

 

8.4.3 RxJS 와 프로미스

 

RxJS 는 모든 프로미스/A+ 호환 객체를 옵저버블 순차열로 변환 가능합니다. 아래 예시)

Rx.Observable.fromPromise(getJSON('/students'))
  .map(R.sortBy(R.compose(R.toLower, R.prop('firstname')))) // 모든 학생 객체 이름 순으로 정렬
  .flatMapLatest(student => Rx.Observable.from(student)) // 하나의 학생 객체 배열을 옵저버블 순차열로 변경
  .filter(R.pathEq(['address', 'country'], 'US')) // 미국에 살지 않는 학생 필터링
  .subscribe(
    student => console.log(student.fullname), // 결과 출력
    err => console.log(err)
  );

위 예제에서는 에러 처리 로직을 subscribe 에 집중 시켰습니다. 이러한 방법을 앞으로의 실무 코드에 충분히 적용 가능할 것입니다.

 

8.5 Summary

 

  • 프로미스는 콜백 중심적인 설계를 함수형으로 해결하는 방안
  • 미래의 함수를 프로미스로 합성, 체이닝하면 일시적으로 의존 관계가 형성된 코드의 잡다한 저수준 로직 추상화 가능
  • 제너레이터는 비동기 코드에 접근하는 또 다른 방안으로, 느긋한 이터레이터로 데이터를 쓸 수 있는 시점에 내어주는 장치
  • 함수형 리액티브 프로그래밍은 프로그램의 추상화 수준을 높여 이벤트를 논리적 독립된 단위로 다룰 수 있게 함

 

 

출처 : 함수형 자바스크립트(책)