함수형 프로그래밍과 테스트

Posted on

이전에 어느 개발자 분에게 OOP 대신 함수형 프로그래밍을 사용하는 이유가 뭐냐는 질문을 받은 적 있는데 당시에 짧게 이야기하다보니 명확하게 그 이유를 설명하기가 어렵더군요. 이야기를 끝내고 돌아오는 길에 개념을 정리할 필요가 있겠다는 생각이 들어 글로 작성해 보았습니다.

객체지향

함수형 프로그래밍은 객체지향 개념에 익숙한 개발자가 보기에 조금 난해할 수 있는 구조를 가지고 있습니다.

기본적으로 객체지향은 클래스와 인스턴스 그리고 인터페이스를 기반으로 데이터를 캡슐링하고 절차지향적인 코드가 갖고 있는 이슈를 근본적으로 뜯어 고치는 방향으로 발전했다면 함수형 프로그래밍은 기본적으로 불변하는 데이터와 멱등성을 바탕으로 절차지향 프로그래밍을 부분 보완하는 방향을 갖고 있습니다.

아래 코드는 객체지향 방식의 기본적인 구조입니다.

// Person.js

class Person {
  name = 'anonymous';

  setName = (newName) => {
    this.name = newName;
  }

  getName = () => {
    return name;
  }

  greeting = () => {
    return `Hi, my name is ${this.getName()}.`;
  }
}

export default Person;

객체지향 프로그래밍은 getter/setter 등을 활용하여 프로퍼티에 직접적인 접근을 제한하고 인스턴스 메서드를 활용하여 값을 조작하도록 유도합니다. 이런 개발방식을 통해 자연스럽게 데이터에 성격을 부여하게 되고 개발자로 하여금 구조화된 코드를 작성하도록 유도하고 있죠.

const Person from './Person';

const person = new Person();

person.setName('dongwook');

person.greeting(); // `Hi, my name is dongwook.`

객체지향을 지향하는 많은 개발자 분들이 알고 있듯이 코드를 구조화할 수 있다는 장점은 개발 생산성 측면에서 여러 긍정적인 효과를 가져옵니다. 기존 절차지향적인 방식에서 불가능했던 수십, 수백명의 개발자들과 공동작업을 가능하게 해주고 재활용 가능한 라이브러리를 만들어 중복된 코드를 획기적으로 줄일 수 있습니다.

그렇게 객체지향은 엔터프라이즈급 서비스를 구현하는데 있어서 필수 개발 개념으로 받아들여지기 시작했고 오늘날 많은 언어에서 이 개념을 대부분 지원하고 있습니다.

테스트

객체지향이 과연 올바른 개발 방법론인가에 대해 많은 개발자들이 의문을 표시하기 시작한 것은 코드 레벨에서 테스트하는 방식이 개발자 사이에서 대중화된 이후 부터 입니다.

최근 몇년 사이에 테스트의 중요성이 높아지면서 각 언어별로 코드를 테스트할 수 있는 프레임워크들이 생겨났고 근래에 대부분 언어들은 하나 이상의 대표 테스트 모듈들을 갖고 있습니다. 더군다나 golang 같이 최근 만들어진 언어는 자체적인 테스트 모듈을 내장하고 있기도 합니다.

테스트는 방법론에 있어서도 많은 발전이 있었는데 그 중 비교적 개념이 잘 정리된 것으로 종단(End-to-End) 테스트와 단위(Unit) 테스트가 있습니다. 종단 테스트는 기본적으로 코드를 보지 않고 UI 레이어에서 테스트를 진행하는 방식을 일컫고 단위 테스트는 작은 단위의 함수를 테스트하면서 촘촘한 안전망을 구축하는 것을 기본으로 하고 있죠.

이런 맥락에서 종단 테스트는 실제 서비스가 동작하는 환경에서 테스트를 진행하기 때문에 매우 이상적인 시나리오이기는 합니다. 하지만 현실적으로 디버깅이 어렵다는 것과 비용이 매우 비싸다는 단점이 있습니다. 그리고 테스트가 실패하더라도 그 이슈를 추적하기 어렵기 때문에 실제로 이 방식이 합리적으로 잘 운영되려면 개발조직 내에 전문적인 테스트 엔지니어 또는 좋은 DevOps 환경이 필요합니다.

결과적으로 이 모든 조건을 충족한다 하더라도 종단 테스트는 여전히 비용이 비싸기 때문에 기본적으로 회원가입, 로그인 등과 같이 서비스의 핵심적인 기능 위주로 신중하게 진행되는 경향이 있고 이 방식 만으로 모든 비지니스 로직을 커버하는 것은 굉장히 어렵습니다. (여기서 말하는 비용이란 소비되는 시간, 노력, 기회비용 등을 합한 것입니다.)

그에 비해 단위 테스트는 상대적으로 비용이 매우 저렴합니다. 코드 레벨에서 작은 단위로 테스트를 진행할 수 있을 뿐더러 대부분의 언어에서 하나 이상의 유닛 테스트 프레임워크를 지원하고 있어 접근성이 낮아 도입이 쉽습니다. 또 도입이 쉽기 때문에 많은 개발자들이 사용하고 있고 많은 개발자들이 사용하고 있는 덕분에 기술적인 측면에서 종단 테스트 라이브러리보다 유닛 테스트 관련 프레임워크들이 잘 발전되어 있는 것을 알 수 있습니다.

또한 잘 짜여진 유닛 테스트는 종단 테스트에서 진행할 수 없었던 여러 사이드 이펙트를 사전에 방지할 뿐더러 단순히 테스트를 넘어 대상을 문서화해주고 스펙을 프리징해주기 때문에 함수가 변경될 때마다 중요한 안정장치로 활용될 수 있습니다.

그리고 위와 같은 맥락에서 함수형 프로그래밍은 유닛 테스트 상황에서 매우 유연한 개발을 가능하게 해줍니다.

객체지향과 함수형 프로그래밍

함수형 프로그래밍의 장점을 단순히 글로 설명하기 보다는 앞서 보여드렸던 객체지향 코드와 함수형 프로그래밍 코드를 비교해가면서 설명하는 것이 좋을 것 같습니다.

앞서 작성해 보았던 객체지향 방식의 Person 클래스를 함수형 프로그래밍으로 재작성해 보고 이에 대한 유닛 테스트 코드를 비교해보면서 함수형 프로그래밍이 어떤 장점이 있는지 설명해 보겠습니다.

/**
 * Person.js
 *
 * @sample
 * const { getName, setName, greeting } from './Person';
 *
 * const person = {};
 * const changed = setName(person);
 *
 * greeting(changed, getName) // `Hi, my name is dongwook.`
 */


export const setName = (person = {}, newName = 'anonymous') => ({ ...person, name: newName });
export const getName = (person = {}) => person.name;
export const greeting = (person = {}, getNameFunc) => `Hi, my name is ${getNameFunc(person)}.`;

가장 눈에 띄는 부분은 객체지향 방식의 코드에서 기존에 인스턴스 메서드로 감싸져 있던 함수들이 모두 밖으로 나와 구조상 수평적인 관계로 바뀌었다는 점일 겁니다. 그리고 바로 이 부분이 OOP를 지향하는 개발자 분들에게 상당히 난해할 수 있는 구조이기도 합니다.

또한 기존 객체지향 코드에서는 함수 간 상호관계가 비교적 분명하게 드러나 구조 파악이 비교적 쉬웠던 것에 비해서 함수형 프로그래밍은 함수 간의 관계가 상당히 불분명해 보입니다. 그러다보니 이 방식에 익숙하지 않은 개발자 입장에서 위같은 코드는 파편화된 것처럼 느껴질 수 있고 상당히 혼란스럽죠.

함수형 프로그래밍 방식의 장점에 대해 보다 잘 설명하기 위해서는 이에 대한 테스트 코드를 작성해 나가면서 설명할 필요가 있는 것 같습니다.

함수형 프로그래밍 테스트

describe('Person.js', () => {
  it('#setName()', () => {
    const person = { name: 'dongwook' };

    expect(setName(person, 'riverleo')).toEqual({ name: 'riverleo' });
  });

  it('#getName()', () => {
    const person = { name: 'dongwook' };

    expect(getName(person)).toEqual('dongwook');
  });

  it('#greeting()', () => {
    const person = { name: 'dongwook' };
    const getNameFunc = person => person.name;

    expect(gretting(person, getNameFunc)).toBe('Hi my name is riverleo');
  });
});

객체지향 테스트

describe('Person.js', () => {
  it('.setName()', () => {
    const person = new Person();

    person.setName('dongwook');

    expect(person.getName()).toBe('dongwook');
  });

  it('.greeting()', () => {
    const name = 'dongwook';
    const person = new Person();

    expect(person.greeting()).toBe('Hi, my name is anonymous.');

    person.setName(name);

    expect(person.greeting()).toBe(`Hi, my name is ${name}.`);
  });
});

여기까지는 두 테스트 방식 모두 큰 차이는 없어 보입니다. 둘 다 적절한 테스트 커버리지를 보장하고 있고 화이트 케이스에 대해 안정성도 잘 보장해 주고 있습니다.

하지만 현재 구조에서 추가적인 요구사항이 들어왔다는 가정을 했을 때 객체지향과 함수형 프로그래밍이 상당히 다른 방식의 양상을 보여줍니다.

이 부분은 실제 코드가 변경되는 과정을 보면서 어떤 차이가 있는지 확인하는 것이 좋을 것 같습니다. 추가 요구사항으로 기존 코드에서 이전에 작성했던 이름을 기록해 뒀다가 사용해야 하는 경우가 생겼다고 가정해보고 코드를 수정해 볼게요.

// Person.js

 class Person {
   name = 'anonymous';
+  previousName = undefined;
+  isTemporarySave = false;

   setName = (newName) => {
     this.name = newName;
+    this.previousName = this.name;
+    this.isTemporarySave = true;

+    // 서버에 저장되기 전까지는 이전에 사용하는 이름을 사용합니다.
+    return someKindOfHTTPRequestPromise({ name: this.name }).then(() => {
+      this.isTemporarySave = false;
+    });
   }
 
*  getName = () => {
+    if (this.isTemporarySave) {
+      return this.previousName;
+    }
 
     return this.name;
   }

   greeting = () => {
     return `Hi, my name is ${this.getName()}.`;
   }
 }

이제 setName()getName() 함수는 기존보다 복잡한 로직을 갖게 되었고 그에 따라 greeting() 함수 또한 기존 함수들과의 의존도가 더 커지게 되었습니다. 아마 이것은 초기에 모델을 설계한 개발자가 의도했던 것은 아닐 수도 있지만 개발을 해나가다 보면 초기 설계자의 의도와 다르게 변경되는 일은 굉장히 자주 등장하는 일 중 하나입니다. (개인적으로 이런 식으로 코드가 뿌리 내리는 과정을 통해 조금씩 레거시가 생겨나고 기술부채가 등장한다고 생각합니다.)

계속해서 코드를 봤을 때 가장 중요한 변화는 이제 greeting() 함수를 올바르게 사용하기 위해서는 기존 setName() 그리고 getName() 함수와 갖는 연관관계를 명확히 이해하고 있어야 한다는 점입니다. 기존에는 person.name 식으로도 프로퍼티에 접근할 수 있었지만 코드 상 프로퍼티에 대한 캡슐링이 강화됐기 때문에 앞으로 getName() 함수를 거치지 않으면 올바른 값을 기대하기 어려워 졌습니다.

그에 따른 테스트의 변경도 생각보다 광범위해집니다.

describe('Person.js', () => {
  it('.setName()', async () => {
    const person = new Person();

+   const promise = person.setName('dongwook');

+   expect(person.getName()).toBe('anonymous');

+   await promise;

    expect(person.getName()).toBe('dongwook');
  });

+ it('.getName()', () => {
+   const person = new Person();

+   person.name = 'riverleo';
+   person.previousName = 'dongwook';

+   expect(person.getName()).toBe('riverleo');
+   person.isTemporarySave = true;

+   expect(person.getName()).toBe('dongwook');
+ });

  it('.greeting()', async () => {
    const name = 'dongwook';
    const person = new Person();

    expect(person.greeting()).toBe('Hi, my name is anonymous.');

    const promise = person.setName(name);

+   expect(person.greeting()).toBe('Hi, my name is anonymous.');

+   await promise;

    expect(person.greeting()).toBe(`Hi, my name is ${name}.`);
  });
});

테스트 코드의 변화에서 가장 눈에 띄는 특징 가운데 하나는 변경된 인스턴스 함수와 연관성을 가진 로직 전체에 걸쳐 테스트 코드의 변경이 필요해졌다는 점입니다.

좀 더 거시적으로 위 특징을 고려했을 때 테스트에 대한 수정은 이 정도 수준에서 끝나지 않을 수도 있습니다. 만약 Person 클래스가 더 많은 곳에서 사용되고 있을 경우 테스트 코드는 Person 클래스를 참조하고 있는 수만큼 필요할 수도 있습니다.

함수형 프로그래밍과 테스트

이와 반대로 함수형 프로그래밍을 사용한 로직은 테스트 케이스가 복잡해지지 않도록 사전에 방지할 수 있습니다. 앞서 객체지향 코드에서 이루어졌던 변경을 함수형 프로그래밍 예제에도 동일하게 적용해 본다면 아래와 같을 수 있습니다.

// Person.js

  export const setName = (person = {}, newName = 'anonymous') => (
    someKindOfHTTPRequestPromise({ name: newName }).then(() => ({
      ...person,
      name: newName,
      previousName: person.name,
    });
  );
  export const getName = (person = {}) => person.name;
+ export const getPreviousName = (person = {}) => person.previousName;
  export const greeting = (person = {}, getNameFunc) => `Hi, my name is ${getNameFunc(person)}.`;

그리고 테스트 코드는 아래와 같이 변경됩니다.

describe('Person.js', () => {
  it('.setName()', async () => {
    const person = { name: 'dongwook' };

+   expect(await setName(person, 'riverleo')).toEqual({
+     name: 'riverleo',
+     previousName: person.name,
+   });
  });

  it('.getName()', () => {
    const person = { name: 'dongwook' };

    expect(getName(person)).toEqual('dongwook');
  });

+ it('.getPreviousName()', () => {
+   const person = { previousName: 'dongwook' };

+   expect(getPreviousName(person)).toEqual('dongwook');
+ });

  it('.greeting()', () => {
    const person = { name: 'dongwook' };
    const getNameFunc = person => person.name;

    expect(gretting(person, getNameFunc)).toBe('Hi my name is riverleo');
  });
});

보다시피 함수형 프로그래밍 안에서는 기존 함수가 변경되더라도 구조적으로 함수 간 의존성이 적은 구조를 갖고 있기 때문에 기존 greeting() 함수의 테스트 결과를 수정할 필요가 없습니다. 이 뿐만 아니라 추가적인 요구사항에 대한 스펙도 별도의 함수로 분리해 작성할 수 있고 발생 가능한 사이드 이펙트에 대해 개발자의 컨트롤이 가능해 집니다.

정리

개인적으로 함수형 프로그래밍이 더 나은 방식인지 아니면 객체지향이 더 나은 방식인지에 대해 묻는 것은 소모적인 논쟁에 불과하고 또 불필요하다 생각합니다. 중요한 것은 비지니스 요구사항에 따라 개발자 스스로가 이런 가치판단을 할 수 있는지가 가장 중요한 것이 아닐까 싶어요. 또한 이런 관점에서 절차 지향적인 개발 방식도 상황에 따라 적절하게 활용 가능하다고 생각합니다.

다만 우리가 함수형 프로그래밍에 대해 어떻게 받아들일 것이냐 생각한다면 비지니스 상으로 사이드 이펙트를 최소화하고 중요한 로직들에 충분한 안전장치를 유지하고 싶다면 함수형 프로그래밍 방식을 적용하고 이에 대한 적절한 유닛 테스트를 기록하는 것이 필요하다고 생각합니다.