Tuesday, August 29, 2023

Dart로 구현하는 함수형 코드의 미학

현대 소프트웨어 개발의 복잡성은 끊임없이 증가하고 있습니다. 동시성 문제, 예측 불가능한 상태 변화, 그리고 이로 인해 발생하는 미묘한 버그들은 개발자들을 끊임없이 괴롭힙니다. 이러한 혼돈 속에서, 수학적 명료함과 예측 가능성에 뿌리를 둔 프로그래밍 패러다임, 바로 '함수형 프로그래밍(Functional Programming, FP)'이 강력한 대안으로 떠오르고 있습니다. Dart는 본래 객체지향 언어로 시작했지만, 현대적 언어의 흐름에 발맞춰 함수형 프로그래밍의 강력한 기능들을 대거 수용하며 진화해왔습니다. 이 글에서는 Dart를 사용하여 함수형 프로그래밍의 핵심 철학을 이해하고, 실제 코드에 어떻게 우아하게 녹여낼 수 있는지 심도 있게 탐구합니다.

우리는 단순히 map이나 where 같은 메서드를 사용하는 것을 넘어, 함수형 프로그래밍이 왜 코드의 안정성과 가독성을 극적으로 향상시키는지, 그 근본적인 원리부터 파헤칠 것입니다. 순수 함수, 불변성, 고차 함수와 같은 핵심 개념들이 Dart 언어의 구체적인 기능들과 어떻게 맞물려 돌아가는지, 그리고 이를 통해 어떻게 더 견고하고 유지보수하기 쉬운 애플리케이션을 구축할 수 있는지에 대한 여정을 시작하겠습니다.

제1장: 함수형 프로그래밍의 철학적 기반

함수형 프로그래밍을 단순히 코딩 기술의 하나로 접근하기 전에, 그 밑바탕에 깔린 철학을 이해하는 것이 중요합니다. 함수형 프로그래밍은 '어떻게' 할 것인지를 나열하는 명령형 프로그래밍(Imperative Programming)과 달리, '무엇'을 할 것인지를 서술하는 선언형 프로그래밍(Declarative Programming)의 한 형태입니다. 그 핵심에는 '계산(computation)을 수학적 함수의 평가(evaluation)로 취급한다'는 아이디어가 자리 잡고 있습니다.

수학에서 함수 f(x) = x + 1은 입력 x가 주어지면 항상 x + 1이라는 동일한 결과를 내놓습니다. 함수 자신이 호출되었다는 사실 외에는 외부 세계에 어떤 영향도 미치지 않으며, 그 결과는 오직 입력값에만 의존합니다. 함수형 프로그래밍은 이러한 수학적 함수의 특성을 코드에 그대로 적용하고자 합니다.

순수 함수(Pure Functions): 예측 가능성의 초석

함수형 프로그래밍의 가장 핵심적인 개념은 바로 순수 함수입니다. 순수 함수는 다음 두 가지 조건을 반드시 만족해야 합니다.

  1. 동일한 입력에 대해 항상 동일한 출력을 반환한다 (Same input, same output).
  2. 함수 외부에 어떠한 관찰 가능한 부작용(Side Effect)도 일으키지 않는다.

예를 들어, 두 정수를 더하는 함수는 순수 함수입니다.

int add(int a, int b) {
  return a + b;
}

add(2, 3)은 언제, 어디서, 몇 번을 호출하더라도 항상 5를 반환합니다. 이 함수는 외부 변수를 읽거나 수정하지 않으며, 파일을 쓰거나 네트워크 요청을 보내지도 않습니다. 오직 입력받은 ab에만 의존합니다.

반면, 다음 함수는 순수 함수가 아닙니다.

int value = 10;

// 순수하지 않은 함수 (Impure Function)
int addWithGlobalValue(int a) {
  value += a; // 외부 상태(value)를 변경하는 부작용 발생
  return value;
}

addWithGlobalValue(5)를 처음 호출하면 15를 반환하지만, 두 번째 호출하면 전역 변수 value가 변경되었기 때문에 20을 반환합니다. 이처럼 결과가 외부 상태에 의존하고, 호출 시점에 따라 달라질 수 있으므로 순수 함수가 아닙니다. 이런 함수는 코드의 동작을 예측하기 어렵게 만들고, 특히 동시성 환경에서 심각한 버그의 원인이 됩니다.

순수 함수가 보장하는 또 다른 중요한 특성은 참조 투명성(Referential Transparency)입니다. 이는 함수 호출을 그 함수의 결괏값으로 언제든지 대체해도 프로그램의 동작에 아무런 영향을 주지 않는다는 의미입니다. 예를 들어, add(2, 3)은 항상 5이므로, 코드 내의 모든 add(2, 3)을 숫자 5로 바꿔도 전체 로직은 동일하게 동작합니다. 이러한 특성은 코드의 추론을 매우 쉽게 만들고, 컴파일러 최적화나 캐싱 전략에도 유리하게 작용합니다.

불변성(Immutability): 변화를 통제하는 기술

불변성은 생성된 후에는 그 상태를 변경할 수 없는 데이터의 특성을 의미합니다. 함수형 프로그래밍에서는 데이터의 직접적인 수정을 지양하고, 대신 변경이 필요할 때 원본 데이터의 복사본을 만들어 원하는 부분을 수정한 뒤 새로운 데이터를 반환하는 방식을 선호합니다.

Dart에서는 finalconst 키워드를 통해 불변성을 강제할 수 있습니다.

  • final: 변수가 단 한 번만 할당될 수 있도록 합니다. 변수 자체는 런타임에 결정될 수 있지만, 한번 할당되면 재할당이 불가능합니다.
  • const: 변수뿐만 아니라 그 값 자체를 컴파일 타임 상수로 만듭니다. 즉, 컴파일 시점에 그 값을 완전히 알고 있어야 합니다.
void main() {
  final String name = 'Dart';
  // name = 'Flutter'; // Error: The final variable 'name' can only be set once.

  final List<int> numbers = [1, 2, 3];
  // numbers = [4, 5, 6]; // Error: 재할당 불가
  numbers.add(4); // 하지만 리스트 내부의 상태는 변경 가능!
  print(numbers); // [1, 2, 3, 4]
}

여기서 중요한 점은 final은 변수의 재할당을 막을 뿐, 객체 내부의 상태 변경(mutation)까지는 막지 못한다는 것입니다. 진정한 불변성을 위해서는 객체 자체가 변경 불가능하도록 설계되어야 합니다. 예를 들어, Dart의 `List` 대신 불변 컬렉션을 제공하는 패키지(예: `package:fast_immutable_collections`)를 사용하거나, 클래스를 설계할 때 모든 필드를 `final`로 선언하고 상태를 변경하는 메서드 대신 새로운 인스턴스를 반환하는 메서드를 제공해야 합니다.

불변성은 다음과 같은 엄청난 이점을 제공합니다.

  • 예측 가능성 향상: 데이터가 어디선가 예기치 않게 변경될 걱정이 없으므로 코드의 흐름을 추적하기가 훨씬 쉬워집니다.
  • 동시성 프로그래밍 단순화: 여러 스레드가 동시에 데이터에 접근하더라도 데이터가 변경되지 않으므로, 복잡한 잠금(lock) 메커니즘 없이도 안전하게 데이터를 공유할 수 있습니다(race condition 방지).
  • 시간 여행 디버깅(Time-travel debugging): 상태 변경이 새로운 객체 생성으로 이루어지므로, 애플리케이션의 모든 상태 변화를 기록하고 이전 상태로 쉽게 돌아가 버그의 원인을 파악할 수 있습니다. 이는 Redux와 같은 상태 관리 패턴의 핵심 원리이기도 합니다.

제2장: Dart 언어로 함수형 프로그래밍 구현하기

Dart는 함수형 패러다임을 효과적으로 지원하기 위한 다양한 언어적 기능을 갖추고 있습니다. 이러한 기능들을 이해하고 활용하는 것이 Dart로 함수형 코드를 작성하는 첫걸음입니다.

일급 객체로서의 함수 (First-Class Functions)

Dart에서 함수는 일급 객체(First-Class Citizen)입니다. 이는 함수를 다른 값(정수, 문자열, 객체 등)과 동등하게 취급할 수 있다는 의미입니다. 구체적으로 다음이 가능합니다.

  • 변수에 함수를 할당할 수 있다.
  • 다른 함수의 인자로 함수를 전달할 수 있다.
  • 다른 함수에서 결과값으로 함수를 반환할 수 있다.
void sayHello(String name) {
  print('Hello, $name!');
}

String whisper(String message) {
  return '...${message.toLowerCase()}...';
}

void main() {
  // 1. 변수에 함수 할당
  var greet = sayHello;
  greet('World'); // "Hello, World!"

  // 2. 함수를 인자로 전달
  void processMessage(String message, String Function(String) modifier) {
    print(modifier(message));
  }
  processMessage('THIS IS LOUD', whisper); // "...this is loud..."

  // 3. 함수를 반환
  Function multiplier(int factor) {
    return (int number) => number * factor;
  }
  var doubleIt = multiplier(2);
  var tripleIt = multiplier(3);

  print(doubleIt(10)); // 20
  print(tripleIt(10)); // 30
}

위 예제의 `multiplier` 함수는 클로저(Closure)의 좋은 예입니다. 반환된 익명 함수 `(int number) => number * factor;`는 자신이 생성될 때의 환경(lexical scope), 즉 `factor`라는 변수를 '기억'하고 있습니다. 이 덕분에 `doubleIt`은 `factor`가 2인 상태를, `tripleIt`은 `factor`가 3인 상태를 유지할 수 있습니다. 클로저는 함수형 프로그래밍에서 상태를 함수 내에 은닉하고, 특정 컨텍스트를 가진 함수를 동적으로 생성하는 데 매우 유용하게 사용됩니다.

고차 함수 (Higher-Order Functions)

함수를 인자로 받거나 함수를 반환하는 함수를 고차 함수라고 합니다. Dart의 `Iterable`(List, `Set` 등이 구현하는 인터페이스)은 수많은 유용한 고차 함수를 제공하며, 이를 통해 데이터 컬렉션을 선언적이고 함수적인 스타일로 다룰 수 있습니다.

가장 대표적인 고차 함수들은 다음과 같습니다.

  • map<T>(T Function(E e) f): 컬렉션의 각 요소를 주어진 함수 `f`에 적용하여 새로운 요소로 변환한 뒤, 그 결과들로 구성된 새로운 `Iterable`을 반환합니다.
  • where(bool Function(E element) test): 컬렉션의 각 요소에 대해 주어진 조건 함수 `test`를 실행하고, 그 결과가 `true`인 요소들만으로 구성된 새로운 `Iterable`을 반환합니다.
  • reduce(E Function(E value, E element) combine): 컬렉션의 요소들을 순회하며 누적 연산을 수행합니다. 첫 번째 요소가 초기 누적값(`value`)이 되고, 이후 각 요소(`element`)를 순서대로 결합 함수 `combine`에 적용하여 최종적으로 하나의 값을 반환합니다. 빈 리스트에 사용하면 에러가 발생합니다.
  • fold<T>(T initialValue, T Function(T previousValue, E element) combine): `reduce`와 유사하지만, 초기값 `initialValue`를 직접 지정할 수 있습니다. 따라서 빈 리스트에도 안전하게 사용할 수 있으며, 반환 타입이 원본 컬렉션의 요소 타입과 달라도 됩니다.

이러한 함수들을 사용하면 복잡한 데이터 처리를 명령형 `for` 루프 없이 매우 간결하고 가독성 높게 표현할 수 있습니다.

예시: 짝수만 골라 제곱한 후, 그 합계를 구하기

명령형 스타일 (Imperative Style):

void main() {
  final numbers = [1, 2, 3, 4, 5, 6];
  int sum = 0;
  for (var number in numbers) {
    if (number % 2 == 0) {
      int squared = number * number;
      sum += squared;
    }
  }
  print(sum); // 56 (4*4 + 6*6 = 16 + 36)
}

함수형 스타일 (Functional Style):

void main() {
  final numbers = [1, 2, 3, 4, 5, 6];
  
  final sum = numbers
      .where((n) => n % 2 == 0)   // [2, 4, 6]
      .map((n) => n * n)         // [4, 16, 36]
      .reduce((sum, n) => sum + n); // 4 + 16 + 36 = 56
  
  print(sum);
}

두 코드의 차이점은 명확합니다. 명령형 스타일은 `sum`이라는 가변 상태를 만들고, 루프를 돌면서 직접 상태를 변경합니다. 반면, 함수형 스타일은 중간 상태 변수 없이, 데이터의 흐름을 파이프라인처럼 연결하여 '무엇'을 원하는지를 명확하게 서술합니다. 각 단계(where, map, reduce)는 불변성을 유지하며 이전 단계의 결과를 받아 새로운 결과를 생성해 다음 단계로 전달합니다. 이는 코드의 의도를 훨씬 명확하게 만들고, 각 단계를 독립적으로 테스트하기도 용이하게 합니다.

제3장: 실전 함수형 프로그래밍 패턴

기본적인 고차 함수에 익숙해졌다면, 이제 더 정교하고 강력한 함수형 패턴들을 통해 코드의 추상화 수준을 한 단계 높일 수 있습니다.

함수 합성 (Function Composition)

함수 합성은 두 개 이상의 함수를 조합하여 새로운 함수를 만드는 기술입니다. 수학에서 `h(x) = f(g(x))`와 같은 개념입니다. 즉, 한 함수의 출력이 다른 함수의 입력으로 이어지는 연쇄적인 구조를 만듭니다.

Dart에는 내장된 합성 함수가 없지만, 직접 쉽게 만들 수 있습니다.

// B를 C로 변환하는 함수와 A를 B로 변환하는 함수를 받아
// A를 C로 변환하는 새로운 함수를 반환한다.
C Function(A) compose<A, B, C>(C Function(B) f, B Function(A) g) {
  return (A x) => f(g(x));
}

void main() {
  String exclaim(String s) => '$s!';
  String toUpperCase(String s) => s.toUpperCase();

  final loudlyExclaim = compose(exclaim, toUpperCase);

  print(loudlyExclaim('hello')); // HELLO!
}

함수 합성을 사용하면 작고 재사용 가능한 순수 함수들을 레고 블록처럼 조립하여 복잡한 로직을 구축할 수 있습니다. 이는 코드의 모듈성을 높이고, 각 단위 기능에 대한 테스트를 용이하게 합니다.

커링 (Currying)

커링은 여러 개의 인자를 받는 함수를, 각각 하나의 인자만 받는 함수의 연쇄(chain)로 변환하는 기술입니다. 예를 들어, `f(a, b, c)`라는 함수를 `f'(a)(b)(c)` 형태로 바꾸는 것입니다.

Dart에서는 클로저를 이용하여 커링을 흉내 낼 수 있습니다.

// 원본 함수: 두 숫자를 더함
int add(int a, int b) => a + b;

// 커링된 버전
Function(int) curriedAdd(int a) {
  return (int b) => a + b;
}

void main() {
  final add5 = curriedAdd(5); // a=5가 부분 적용된 새로운 함수 생성

  print(add5(10)); // 15
  print(add5(20)); // 25
}

커링의 진정한 힘은 함수의 부분 적용(Partial Application)을 가능하게 한다는 데 있습니다. `curriedAdd(5)`를 통해 우리는 `add` 함수에 인자 `5`를 미리 '고정'시킨 `add5`라는 특화된 함수를 얻었습니다. 이처럼 일부 인자를 미리 채워 넣어 새로운 함수를 만들어내는 기법은 코드의 재사용성을 극대화하고, 특정 컨텍스트에 맞는 헬퍼 함수를 동적으로 생성하는 데 매우 유용합니다.

함수형 오류 처리: `Either` 타입

전통적인 오류 처리는 예외(exception)를 던지거나 `null`을 반환하는 방식에 의존합니다. 하지만 예외는 순수 함수의 참조 투명성을 깨뜨리는 대표적인 부작용이며, `null`은 `NullPointerException`의 근원이 되어 코드의 안정성을 해칩니다.

함수형 프로그래밍에서는 오류 또한 '값(value)'으로 다루려는 접근 방식을 취합니다. 이를 위해 자주 사용되는 패턴이 `Either` 타입입니다. `Either`는 이름 그대로 '둘 중 하나'를 의미하며, 성공적인 결과(보통 `Right`) 또는 실패 결과(보통 `Left`)를 담는 컨테이너 역할을 합니다.

Dart 3의 `sealed class`를 사용하면 `Either` 타입을 타입 안전하게 구현할 수 있습니다.

sealed class Either<L, R> {
  // 생성자를 private으로 만들어 외부에서 상속/구현 방지
  const Either._();

  // Either 타입에 대한 연산을 편리하게 해주는 헬퍼 메서드
  T fold<T>(T Function(L left) onLeft, T Function(R right) onRight);
}

class Left<L, R> extends Either<L, R> {
  final L value;
  const Left(this.value) : super._();

  @override
  T fold<T>(T Function(L left) onLeft, T Function(R right) onRight) => onLeft(value);
}

class Right<L, R> extends Either<L, R> {
  final R value;
  const Right(this.value) : super._();

  @override
  T fold<T>(T Function(L left) onLeft, T Function(R right) onRight) => onRight(value);
}

// 사용 예제: 문자열을 숫자로 파싱하는 함수
Either<String, int> parseIntSafely(String s) {
  try {
    return Right(int.parse(s));
  } catch (e) {
    return Left('"$s"는 유효한 숫자가 아닙니다.');
  }
}

void main() {
  final result1 = parseIntSafely('123');
  final result2 = parseIntSafely('abc');

  result1.fold(
    (error) => print('실패: $error'),
    (number) => print('성공: ${number * 2}'),
  ); // 성공: 246

  result2.fold(
    (error) => print('실패: $error'),
    (number) => print('성공: ${number * 2}'),
  ); // 실패: "abc"는 유효한 숫자가 아닙니다.
}

`parseIntSafely` 함수는 더 이상 예외를 던지지 않습니다. 대신 성공과 실패의 가능성을 모두 내포하는 `Either`라는 정직한 타입을 반환합니다. 이 함수를 사용하는 쪽에서는 `fold` 메서드를 통해 성공(`Right`) 케이스와 실패(`Left`) 케이스를 모두 처리하도록 강제받게 되므로, 오류 처리를 잊어버리는 실수를 원천적으로 방지할 수 있습니다. 이처럼 함수형 오류 처리는 코드의 안정성과 명시성을 크게 향상시킵니다.

제4장: 함수형 프로그래밍과 실제 애플리케이션 개발

이러한 함수형 원칙들은 학문적인 개념에 그치지 않고, Flutter UI 개발이나 서버 개발과 같은 실제 Dart 애플리케이션 개발에 매우 긍정적인 영향을 미칩니다.

Flutter와 함수형 상태 관리

Flutter는 선언형 UI 프레임워크입니다. 이는 "UI는 현재 상태(state)의 함수"라는 개념, 즉 `UI = f(state)`라는 아이디어에 기반합니다. 개발자는 상태가 주어졌을 때 UI가 어떻게 보여야 하는지만을 기술하고, 상태가 변경되면 프레임워크가 알아서 UI를 다시 그립니다. 이러한 패러다임은 함수형 프로그래밍의 원칙과 완벽하게 일치합니다.

인기 있는 Flutter 상태 관리 라이브러리인 BLoC(Business Logic Component)나 Riverpod는 함수형 원칙을 적극적으로 채택합니다.

  • 불변 상태 (Immutable State): 상태 객체를 직접 수정하는 대신, 이벤트가 발생하면 현재 상태와 이벤트를 바탕으로 새로운 상태 객체를 생성하여 UI를 업데이트합니다. 이는 상태 변화를 예측 가능하게 만들고 디버깅을 용이하게 합니다.
  • 순수 함수 (Pure Functions): 상태를 변화시키는 로직(예: BLoC의 `on<Event>`)은 외부 세계에 의존하지 않는 순수 함수로 작성하는 것이 권장됩니다. 이를 통해 비즈니스 로직을 UI로부터 분리하고 독립적으로 테스트할 수 있습니다.

함수형 프로그래밍을 이해하면 왜 이러한 상태 관리 패턴들이 불변성을 강조하는지, 그리고 그것이 어떻게 더 안정적이고 확장 가능한 Flutter 앱으로 이어지는지를 깊이 있게 파악할 수 있습니다.

서버사이드 Dart와 함수형 프로그래밍

서버 애플리케이션은 수많은 동시 요청을 처리해야 하며, 안정성과 예측 가능성이 매우 중요합니다. 함수형 프로그래밍의 원칙들은 이러한 요구사항을 충족시키는 데 큰 도움을 줍니다.

  • 상태 없는(Stateless) 로직: 순수 함수를 중심으로 API 로직을 구성하면 각 요청을 독립적으로 처리할 수 있습니다. 서버 인스턴스 간에 공유되는 가변 상태가 없으므로, 로드 밸런싱을 통해 수평적으로 확장하기가 매우 용이합니다.
  • 안전한 동시성 처리: 불변 데이터 구조를 사용하면 여러 요청이 동시에 동일한 데이터에 접근하더라도 데이터 오염의 위험 없이 안전하게 처리할 수 있습니다.
  • 파이프라인 아키텍처: HTTP 요청을 처리하는 과정을 미들웨어 함수의 연속적인 파이프라인으로 구성할 수 있습니다. 각 미들웨어는 요청 객체를 받아 특정 작업을 수행한 뒤 다음 미들웨어로 전달하는 작은 함수로, 함수 합성과 유사한 아이디어입니다. 이는 코드의 재사용성과 모듈성을 높입니다.

마무리하며: 새로운 관점으로 코드 바라보기

지금까지 Dart를 통해 함수형 프로그래밍의 핵심 철학부터 구체적인 구현 패턴, 그리고 실제 애플리케이션에서의 활용까지 폭넓게 살펴보았습니다. 함수형 프로그래밍은 단순히 새로운 문법이나 라이브러리를 배우는 것이 아니라, 문제에 접근하고 코드를 구성하는 방식에 대한 근본적인 관점의 전환을 요구합니다.

모든 코드를 100% 함수형으로 작성해야 한다는 강박을 가질 필요는 없습니다. 중요한 것은 함수형 프로그래밍이 제공하는 '순수성', '불변성', '조합성'이라는 강력한 무기들을 이해하고, 이를 자신의 코드 베이스에 점진적으로 도입하여 코드의 품질을 향상시키는 것입니다. 작은 순수 헬퍼 함수를 작성하는 것부터 시작하여, 컬렉션 처리 시 `for` 루프 대신 고차 함수를 사용해보고, 더 나아가 상태 관리나 오류 처리에 함수형 패턴을 적용해볼 수 있습니다.

Dart는 객체지향과 함수형 패러다임의 장점을 모두 취할 수 있는 유연한 언어입니다. 이 글이 여러분의 Dart 여정에 새로운 영감을 주고, 더 깨끗하고, 더 안정적이며, 더 예측 가능한 코드를 작성하는 데 도움이 되기를 바랍니다. 함수형 프로그래밍의 세계는 깊고 넓습니다. `fpdart`와 같은 라이브러리를 탐험하며 더 깊은 개념들을 학습해 나가는 것도 훌륭한 다음 단계가 될 것입니다.


0 개의 댓글:

Post a Comment