자바스크립트 디자인 패턴: RORO

객체로 받고 객체로 반환하는 편리함에 대하여

해외 사이트를 돌아다니다가 Elegant patterns in modern JavaScript: RORO라는 글을 보게 되었다. 사실 그 글을 쓴 분이 창시한 패턴은 아니고, 이미 예전부터 사용되고 있던 패턴이긴 하다. 유명한 예로는 jQuery의 $.ajax 함수가 있다. 하지만 디자인 패턴이라는 게 대개 그렇듯이 처음 사용한 사람은 알기 어렵고, 이름은 선점하는 사람이 유리하다. Ajax가 그러했듯이.

RORO는 간단하게 "객체로 받고 객체로 반환한다(Receive an object, return an object)"는 말을 줄인 것이다. ES2015에서 지원하는 구조분해 할당(destructuring assignment) 덕분에 가능해졌는데, 원글을 쓴 Bill Sourour는 이 패턴에 다음과 같은 장점이 있다고 했다.

  • 명명된 인수(Named parameter)
  • 더 명료한 인수 기본값
  • 더 많은 정보 반환
  • 함수 합성의 용이함

이 글에서는 위 장점을 살펴보면서 댓글에 언급된 여러 문제도 함께 살펴보도록 한다.

명명된 인수

알다시피 자바스크립트에는 Python, C# 등에서 볼 수 있는 명명된 인수 기능이 없다.  예를 들어 다음과 같은 코드가 있다고 생각해보자(출처: 위키피디아).

window.addNewControl("Title", 20, 50, 100, 50, true);

이 코드를 명명된 인수를 사용하여 Python 형식으로 바꾸어 보면 다음과 같다.

window.addNewControl(
  title="Title",
  xPosition=20,
  yPosition=50,
  width=100,
  height=50,
  drawingNow=true
);

물론 명명된 인수 기능이 없는 편이 더 깔끔해 보일 수는 있지만, 둘 중 코드를 보고 바로 의도를 파악할 수 있는 읽기 편한 코드는 어느 쪽일까? 당연히 명명된 인수가 있는 쪽일 것이다. 문제는 자바스크립트는 명명된 인수 기능을 지원하지 않는다는 것이다.

하지만 RORO 패턴을 사용하면 이 기능을 흉내낼 수 있다. 위의 addNewControl 메서드의 경우, 자바스크립트로는 보통 다음과 같이 표현한다.

function addNewControl(title, xPosition, yPosition, width, height, drawingNow){ ... }

이렇게 정의한 함수는 첫 번째 방식과 같이 사용될 것이다. 하지만 RORO 패턴을 사용하면 다음과 같이 표현할 수 있다.

function addNewControl( {
  title,
  xPosition,
  yPosition,
  width,
  height,
  drawingNow
} ) { ... }

그리고 이렇게 정의한 함수는 다음과 같이 호출한다.

addNewControl( {
  title: 'Title',
  xPosition: 20,
  yPosition: 50,
  width: 100,
  height: 50,
  drawingNow: true,
} );

기본적으로 지원하는 언어에 비해서도 중괄호 한쌍을 더해준 것 외에 사용법이 특별히 더 불편하지는 않다. 반면 함수를 호출하는 코드는 훨씬 더 읽기가 좋아졌다. 게다가 이 방식대로라면 굳이 인수의 순서를 정해두지 않아도 된다. 따라서 원한다면 다음과 같이 호출하는 것도 가능하다.

addNewControl( {
  title: 'Title',
  drawingNow: true,
  width: 100,
  xPosition: 20,
  height: 50,
  yPosition: 50,
} );

구조분해 할당의 기능 덕분에 Objective-C나 Swift에서 처럼 외부에 사용하는 인수 이름과 함수 내부에서 사용할 이름을 달리 할 수도 있다.

function addNewControl( {
  title,
  width: w,
  height: h,
  xPosition: x,
  yPosition: y,
  drawingNow,
} ){
 console.log( w, h ); // width와 height로 전달받은 값 출력
 ...
};

위와 같이 함수를 정의하면 함수 내부에서는 w를 사용해서 width로 전달받은 값을 참조할 수 있게 된다.

더 명료한 인수 기본값

이제 인수를 하나도 전달 안하고 호출하는 경우를 생각해보자.

addNewControl();

앞서 정의한 함수는 위와 같이 호출하면 Cannot desctructure ...라는 에러가 발생한다. 구조분해를 실행할 대상이 undefined가 되기 때문이다. 이 때는 다음과 같이 빈 객체를 기본값으로 설정해주면 된다.

function addNewControl( {
  title,
  width,
  height,
  xPosition,
  yPosition,
  drawingNow,
} = {} ){
 ...
};

이제 다시 실행해보면 문제가 발생하지 않는 것을 알 수 있다.

각 인수값에 기본값을 설정하고 싶을 때는 인수 이름 뒤에 = 기본값을 추가하면 된다. 예를 들어 widthheight에 각각 100이라는 값을 기본값으로 설정하고 싶다면 다음과 같이 작성한다.

function addNewControl( {
title,
width = 100,
height = 100,
xPosition,
yPosition,
drawingNow,
} = {} ){
...
};

앞서 살펴본 함수 내/외부의 이름을 다르게 사용하면서 동시에 기본값도 설정하고 싶다면 다음과 같이 사용한다.

function addNewControl( {
  title,
  width: w = 100,
  height: h = 100,
  xPosition,
  yPosition,
  drawingNow,
} = {} ){
  ...
};

더 많은 정보 반환

장점 중 하나라서 소제목을 넣기는 했는데 사실 굳이 설명하지 않아도 되는 내용이라고 생각한다. 자바스크립트는 함수에서 한 가지 값만 반환할 수 있기 때문에 객체를 사용하면 더 많은 정보를 반환할 수 있다.

예전같으면 이렇게 반환된 정보를 사용하기 번거로웠겠지만 ES2015 덕분에 반환받으면서 바로 원하는 데이터에 접근하는 것이 가능해졌다.

let { control, showing, parent } = addNewControl( {
title: 'Title',
width: 300,
height: 150,
} );

함수 합성의 용이함

합성 함수란 두 개 이상의 함수를 합성하여 만들어 낸 함수이다. 대충 수식으로는 다음과 같이 표현할 수 있다.

compose(f, g) (x) = f(g(x))

자바스크립트에서는 다음과 같이 함수를 합성하는 함수(위에서 compose의 역할)를 다음과 같이 작성할 수 있다.

function pipe(...fns) {
  return param => fns.reduce(
    (result, fn) => fn(result),
    param
  );
}

fns에는 합성할 함수 객체가 전달되고 reduce 메서드의 정의에 따라 전달된 함수는 왼쪽에서 오른쪽으로 순차적으로 실행되고 함수의 인수로는 바로 직전 함수의 결과가 전달되며, 마지막 함수가 반환한 값이 합성 함수의 반환값이 된다. 예를 들어, 다음과 같이 합성 함수를 만들었다고 가정해보자. (원문에 있던 예제를 조금 변형했다)

const saveUser = pipe(validate, normalize, persist);
saveUser( userInfo );

saveUser 함수에 전달된 userInfo 값은 순서대로 validate, normalize, persist를 거치게 된다. 합성 함수를 만들지 않았다면 다음과 같은 형태로 실행되었을 것이다.

persist( normalize( validate( userInfo ) ) );

모든 함수가 객체를 인수로 받고 또 객체로 반환한다는 규칙이 지켜진다면 각 함수는 인수로 전달되는 객체에서 자신에게 필요한 값만 취한 다음 나머지 값은 그대로 반환할 수 있다. 예를 들어 normalize 함수는 여러 값 중 이메일 주소와 사용자 이름만 다룬다고 하면 나머지 값은 ...rest 패턴으로 처리할 수 있다.

function normalize( { email, username, ...rest } ) {
  // 정규화 동작을 여기서 실행
  return {
    email,     // 정규화된 이메일 주소
    username,  // 정규화된 사용자 이름
    ...rest    // 관련없는 값은 그대로 반환
  };
}

원문에 수록된 예제를 보면 함수의 합성이 어떤 식으로 이루어지고 왜 RORO 패턴에서는 합성이 더 용이한 지 알 수 있다. 아마 오류인 것 같은데 원래 글의 예제에는 인수 정의 부분에 중괄호가 빠져있다. 또한 아직까지 Object...rest 지원은 Stage 3에 있으므로 사용시 주의가 필요하다.

function validate( {
  id,
  firstName,
  lastName,
  email = requiredParam(),
  username = requiredParam(),
  pass = requiredParam(),
  address,
  ...rest
} ) {
  // 유효성 검사를 여기서 실행
  return {
    id,
    firstName,
    lastName,
    email,
    username,
    pass,
    address,
    ...rest
  };
}

function normalize( {
  email,
  username,
  ...rest
} ) {
  // 정규화 동작을 여기서 실행
  return {
    email,
    username,
    ...rest
  };
}

async function persist( {
  upsert = true,
  ...info
} ) {
  // userInfo를 데이터베이스에 저장
  return {
    operation,
    status,
    saved: info
  };
}

function saveUser(userInfo) {
  return pipe(
    validate,
    normalize,
    persist
  )(userInfo);
}

보너스: 필수 인수

위 예제를 보면 requiredParam()이 있는데 이름에서 짐작하다시피 반드시 필요한 인수를 검사하고 이 인수가 없으면 에러를 발생하는 함수이다. RORO 패턴에서 기본값을 어떻게 할당했는지 생각해보자.

function addNewControl( {
  title,
  width = 100,
  height = 100,
  xPosition,
  yPosition,
  drawingNow,
 } = {} ){
 ...
 };

여기서는 기본값에 리터럴 값만 사용했지만 사실 계산된 값, 즉 함수 실행의 결과도 기본값으로 사용할 수 있다. 따라서 만약 관련 함수가 존재한다면 위 함수는 다음과 같이 정의할 수도 있다.

function addNewControl( {
  title,
  width = getDefaultWidth(),
  height = getDefaultHeight(),
  xPosition,
  yPosition,
  drawingNow,
 } = {} ){
 ...
 };

기본값의 특성상 getDefaultWidth()getDefaultHeight() 함수는 전달된 widthheight 값이 존재한다면 실행되지 않는다. 이해를 돕기 위해 Babel 컴파일러를 사용해 위 함수를 ES5 스타일로 바꾸어 보면 다음과 같다.

function addNewControl() {
  var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
      title = _ref.title,
      _ref$width = _ref.width,
      width = _ref$width === undefined ? getDefaultWidth() : _ref$width,
      _ref$height = _ref.height,
      height = _ref$height === undefined ? getDefaultHeight() : _ref$height,
      xPosition = _ref.xPosition,
      yPosition = _ref.yPosition,
      drawingNow = _ref.drawingNow;
};

위 코드에서 width에 값을 할당하는 width = _ref$width ... 부분을 살펴보면 전달된 값이 없을 때, 즉 전달된 width의 값이 undefined일 때만  getDefaultWidth()를 실행하고 있음을 알 수 있다.

이러한 특성을 이용하여 기본값으로 실행시 무조건 에러를 발생하는 함수를 할당해두면 필수 인수가 전달되지 않았을 때 에러를 표시할 수 있다. 원문에서는 다음과 같이 reuiqredParam 함수를 정의했다.

function requiredParam (param) {
  const requiredParamError = new Error(
   `Required parameter, "${param}" is missing.`
  );

  // preserve original stack trace
  if (typeof Error.captureStackTrace === ‘function’) {
    Error.captureStackTrace(
      requiredParamError,
      requiredParam
    );
  }

  throw requiredParamError;
}

만약 앞서 정의한  addNewControl 함수에서 title을 필수값으로 설정하고 싶다면 다음과 같이 사용할 수 있다.

function addNewControl( {
  title = requiredParam('title'),
  width = getDefaultWidth(),
  height = getDefaultHeight(),
  xPosition,
  yPosition,
  drawingNow,
} = {} ){
 ...
};

이제 이 함수는 전달된 객체에 title 값이 정의되어 있지 않으면 에러를 발생할 것이다.

문서화의 문제

모든 인수를 객체 하나에 뭉쳐서 받게 된다면 문서화가 어려워지지는 않을까?

충분히 할 수 있을만한 질문인데 원래 글을 쓴 Bill도 비슷한 이유로 이 패턴을 싫어했었다고 한다. 그랬다가 ES2015와 함께 구조분해 문법을 자유롭게 사용할 수 있는 환경이 되었고, 또한 에디터도 많은 발전을 이루어서 이 패턴을 사용해도 되겠다고 판단했다.

예를 들어, 이 글에서 사용한 예제의 경우 VS Code에서 실행하면 다음과 같이 자동완성이 된다.

VS Code의 자동완성 기능은 RORO 패턴도 잘 지원한다.

마치며

사실 이 패턴은 비록 부분적이라도 많은 사람이 사용하고 있는 패턴이다. 하지만 다른 디자인 패턴이 그러하듯 만능은 아니며 적재적소에 사용될 때 가장 좋다.

예를 들어 위에 소개한 requiredParam은 굳이 RORO 패턴을 사용하지 않아도 의미를 충분히 담을 수 있었다. 상황에 따라 객체로 받을 필요가 없을 수도 있고, 객체로 반환할 필요가 없을 수도 있다. 그렇기에 RORO 패턴이라는 용어를 만든 원글 작성자 역시 모든 곳에 사용하고 있지 않으며 자신이 작성한 함수 대부분이 객체를 인수로 받고 상당수가 객체를 반환한다고 했다. 다시 말해 전부는 아니라는 것이다. 특히 isPositiveInteger와 같은 단정문(assertion)에는 사용하지 않는다고 한다.

성능 문제도 있을 수 있다. 구조분해를 거치려면 함수 실행에 부하가 조금이라도 더 늘어나기 마련이기 때문이다. 

개인적으로는 이 패턴이 이해하기도 쉽고 자바스크립트라는 언어의 특성도 잘 살렸다고 생각한다. 모든 곳에 필요하지는 않겠지만 필요한 곳에서는 상당히 유용할 것으로 기대한다.

  1. 우연히 지나가면서 원글을 읽은적이 있는데,

    더욱 쉽게 설명해주신 것 같네요~ 감사합니다!

댓글을 남겨주세요

This site uses Akismet to reduce spam. Learn how your comment data is processed.