Dart 3.1 & Dart 3에서 함수형 스타일 프로그래밍에 대한 회고

Dart 3.1 & Dart 3에서 함수형 스타일 프로그래밍에 대한 회고

패턴 매칭(pattern matching)과 완전 검사(exhaustive) 스위치가 함께 어우러져, Dart의 객체지향 핵심과 자연스럽게 합쳐지는 함수형 스타일 데이터 모델을 가능하게 해요. 이 글은 Dart 3의 주요 기능들을 다시 살펴보면서, 그것들이 Dart 코드를 작성하고 구조화하는 방식을 어떻게 완전히 바꿀 수 있는지 이야기할게요.

출처: Dart 3.1 & a retrospective on functional style programming in Dart 3

본문

오늘 우리는 5월의 주요 릴리스인 Dart 3.0 이후 첫 안정 릴리스인 Dart 3.1을 배포해요. Dart 3.1에는 몇 가지 사소한 업데이트와 3.0에서 도입된 class modifiers(자세한 내용은 changelog에서 확인하세요)를 더 활용하기 위한 몇 가지 API 조정이 포함돼요. 하지만 대부분은 다가오는 릴리스에서 베타와 안정 버전을 볼 수 있길 바라는 새 로드맵 항목에 시간을 쓰고 있어요. 그에 관한 더 자세한 소식은 앞으로 계속 기대해 주세요!

그래서 전통적인 릴리스 게시글 대신, Dart 3의 주요 기능 일부를 다시 살펴보며 그것이 Dart 코드를 작성하고 구조화하는 방식을 어떻게 완전히 바꿀 수 있는지, 어떤 경우에는 크게 개선할 수 있는지 이야기할게요.

데이터를 어떻게 모델링하나요?

객체지향(OO) 언어와 함수형 언어는 여러 면에서 다르지만, 각 패러다임이 데이터를 모델링하는 방식이야말로 둘을 가르는 정의적인 특징이라고 말할 수 있어요. 구체적으로는, 관련 데이터의 다양한 변형과 그 변형들에 대한 연산을 어떻게 모델링할지의 문제죠.

하지만 "이 데이터를 어떻게 모델링해야 할까?"는 새 프로젝트를 시작할 때 의식적으로 많이 생각하지 않는 문제예요. 우리는 데이터에 가장 적합한 모델을 기준으로 언어를 선택하기보다는, 사용하는 언어 유형에 흔한 데이터 모델링 패러다임을 기본값으로 따르는 경향이 있죠.

OO 언어를 사용한다면 클래스 계층과 서브타입에 대한 연산으로 데이터를 모델링할 거예요. 특정 함수형 언어를 사용한다면, 클래스 계층 모델에 해당하는 것은 대수적 데이터 타입(algebraic data type) 모델이고, 서브타입에 대한 연산에 해당하는 것은 패턴 매칭으로 그것들을 스위치하는 것이에요.

Dart는 객체지향 언어이지만 시간이 지나면서 함수형 기능을 꾸준히 통합해 왔고, 데이터 모델링에 더 멀티패러다임적인 접근을 허용해요. 가장 최근에는 Dart 3가 패턴 매칭, switch의 새 기능, sealed 타입을 추가했어요. 이러한 기능들은 Dart에서 대수적 데이터 타입을 구현하는 것을 가능하게 해서, Dart의 객체지향 핵심의 역량을 계속 최대한 활용하면서 함수형 스타일 코드를 쓸 수 있게 해줘요.

Dart 같은 멀티패러다임 언어는 단일 줄 표현식에서 전체 클래스 계층에 이르기까지 무엇이든 설계하는 방법을 선택할 수 있는 도구와 기회를 줘요. 프로젝트에 가장 적합한 모델이나, 개인 취향에 맞는 모델을 고를 수 있죠. 최선의 결정을 돕기 위해, 이 글은 각 패러다임의 구조와 강점을 따로 요약하고, 함수형 스타일로 쓰는 것이 가장 유리한 몇 가지 고전적인 객체지향 설계를 Dart 3의 새 기능으로 리팩터링하는 방법을 알려줄게요.

객체지향 접근

서로 다른 데이터 타입에 특화된 연산이 있을 때, OO 언어의 표준적인 구성 방식은 기본 클래스에 메서드를 만들고, 그 기본 클래스를 오버라이드해 각자의 고유한 동작을 정의하는 서브클래스 집합을 만드는 거예요. 각 서브클래스는 데이터와 연산을 선언부 안의 한 곳에 함께 둡니다.

레시피를 모델링하는 이 (고수준 유사 코드) 예시를 볼게요. 재료와 단계를 레시피와 함께 결합한 레시피 객체를 가지는 것이 말이 되죠. 레시피 기본 클래스에는 각 레시피가 고유한 요구 사항으로 오버라이드하는 조리 메서드를 위한 함수가 몇 개 있을 거예요.

인스턴스 메서드가 있는 클래스 계층은 기존 코드를 건드리지 않고 새 서브클래스를 추가하기 쉽게 해줘요. 이 방식은 Flutter처럼 계수가 셀 수 없이 많은 위젯이 모두 Widget 클래스를 확장하는 일부 도메인에 완벽하게 맞아요. 각 위젯은 정의 안에서 필요한 동작을 고유하게 확장하고 오버라이드할 수 있죠. 자신의 위젯에 특화된 동작을 추가하려고 모든 위젯 서브타입이 메서드를 어떻게 정의하는지 알 필요는 절대 없어요.

함수형 접근(대수적 데이터 타입)

함수형 스타일 아키텍처는 OO 아키텍처의 반대라고 생각할 수 있어요. 한 타입에 대한 모든 코드를 한 곳에 두는 대신(OO 인스턴스 메서드를 서브클래스 선언에 두는 것), 한 연산에 대한 모든 코드를 한 곳에 둡니다(타입을 스위치해 동작을 정의하는 함수형 방식). 그렇다면 계층의 모든 서브타입이 연산을 어떻게 정의하는지 아는 것이 언제 말이 되는 걸까요? 몇 가지 이유가 있을 수 있어요.

  • 타입들에 걸친 같은 연산의 동작 변형을 추가하고, 유지하고, 이해하는 것이 코드에서 나란히 있을 때 더 쉬울 때.
  • 서브클래스를 직접 수정할 수 없지만, 각각에 특화된 새 동작을 정의하고 싶을 때.
  • 다른 타입에 대한 어떤 연산의 동작 변형이, 그들이 연산을 수행하는 타입보다 서로 더 관련이 있을 때.

때로는 명확하지만, 대부분은 단지 관점의 전환이에요. 레시피 예시를 다시 생각해 볼게요. 예를 들어 오븐 설명서의 관점에서 보면, 각 레시피에 대해 굽기(bake) 지침을 한 곳에 모아두는 것이 훨씬 더 합리적일 거예요.

이 예시에서 프로그램 구조는 bake 연산에 초점을 맞춰요. bake가 수행하는 타입이 무엇이든 간에 그것은 같은 함수의 서로 다른 가능한 출력에 불과하고, bake는 그것이 수행하는 타입들로부터 독립적이에요.

이것이 대수적 데이터 타입 모델이에요(수학적 집합 이론에서 따온 "algebraic"이라는 이름). 함수형 언어의 핵심 구성 모델이죠. 마치 클래스 계층이 OO 언어에 핵심인 것처럼요. 대수적 데이터 타입은 모든 타입에 대한 동작을 연산별로 묶어서 동작을 데이터로부터 분리해요. 그리고 이제 Dart 3로 대수적 데이터 타입을 응집력 있게 구현하는 것이 가능해졌어요!

객체지향 대수적 데이터 타입 모델링하기

함수형 언어는 일반적으로 sum type의 case들을 패턴 매칭해 각 변형에 동작을 할당하는 방식으로 대수적 데이터 타입을 구현해요. Dart 3는 switch case에서의 패턴 매칭으로 같은 것을 성취하고, OO 서브타이핑이 이미 sum type을 자연스럽게 모델링한다는 사실을 활용해요. 이를 통해 Dart에 자연스럽게 녹아드는 객체를 사용해 진정한 멀티패러다임 대수적 데이터 타입을 구현할 수 있어요.

다음 섹션들은 Dart에서 대수적 데이터 타입 모델을 설계하는 방법을, 같은 기능의 pre-Dart 3 예시와 함께 보여줄게요.

  • 먼저 객체 패턴을 스위치해 타입 기반 연산 변형을 함께 묶는 방법을 설명할게요.
  • 그런 다음 한 걸음 물러서서, 객체가 가질 수 있는 모든 가능한 서브타입에 대해 switch가 동작을 정의하도록 보장하는 새 sealed 클래스 수식어로 서브클래스 자체를 어떻게 설계하는지 살펴볼게요.

타입들에 걸쳐 동작 묶기

Dart 언어의 개별 부분들(statement, class, literal 등)은 각각 클래스 계층에서 자신만의 정의를 갖지만, 모두 여러 시스템(parser, formatter, compiler 연산 같은)의 연산을 받아요. 언어의 모든 요소에 적용되는 모든 함수가 그 요소들의 선언부 안에 정의되어야 한다면 언어 구현이 얼마나 혼란스러울지 상상해 보세요! 대략 이렇게 보일 거예요.

Dart의 내부 코드는 이런 이유로 이미 타입 정의에서 함수를 분리하는 함수형 접근에 자연스럽게 기울고 있어요. Dart의 analyzer에 있는 annotation_verifier 라이브러리를 볼게요. 여기에는 애노테이션(@override@deprecated 같은)이 붙어 있는 코드 부분에 따라 그 애노테이션의 동작을 정의하는 함수들이 포함돼 있어요(예: @override가 필드와 달리 클래스에 어떻게 영향을 주는지).

하지만 동작을 타입별로 할당하는 것은 처음에 동작 분리 결정을 내리는 것만큼 간단하지 않아요. 타입별로 동작을 정의하는 표준적인 방법은 체인된 if-else 문을 쓰는 것인데, annotation verifier에서 많이 볼 수 있어요. Dart 3 기능을 전혀 쓰지 않고 작성한 다음 검증 함수를 볼게요. 이 함수는 다른 애노테이션인 @visibleForTemplate의 연쇄 효과에서 제외되는 최근에 기여된 @visibleOutsideTemplate 애노테이션의 동작을 검증해요.

이 함수는 정교한 체인된 if-else 문을 사용해, 애노테이션의 조부모(grandparent)가 특정 종류의 선언(ClassDeclaration, EnumDeclaration, MixinDeclaration 중 하나)인지 검사한 다음 그 타입에 기반해 동작을 정의해요.

Dart 3를 사용하면 switch case의 객체 패턴으로 이 구조를 선언적 스타일로 크게 리팩터링할 수 있고, 더 짧고 읽기 쉬워져요. 그리고 원저자가 정확히 그렇게 했어요! 16줄의 체인된 if-else 문이 7줄의 switch 문으로 줄었죠.

여기서 각 case는 grandparent의 정적 타입에 매치되는 객체 패턴이에요. if (object is Type && object.property != null)이라고 말하는 대신, 각 case는 객체의 패턴이 Type(propertyOfType) 패턴과 매치하는지 확인해요. 게다가 객체가 객체 패턴과 매치하면 암묵적으로 null이 아님을 요구하므로, 명시적 null 검사가 필요 없어요!

객체 패턴은 중첩된 변수 패턴도 포함할 수 있는데, 매치하는 같은 코드 줄에서 객체의 프로퍼티 값을 추출(또는 구조 분해, destructure)하게 해줘요. (:var metadata) 문법은 "이 getter와 같은 이름의 새 변수를 매치하고 선언해라"는 뜻이에요. 이렇게 metadata 변수가 마지막 for 루프의 스코프에 들어오는 거예요. 꽤 간결하죠!

for 루프가 이제 각 case 사이에서 공통이라는 점을 주목하세요. 각 타입의 declaredElement 프로퍼티는 사실 다른 타입 InterfaceElement의 서로 다른 서브타입이에요(classElement, enumElement, mixinElement 중 하나). 그래서 pre-Dart 3 체인된 if-else 문은 각 if 절에서 metadata를 별도로 반복해, metadata가 가질 수 있는 각 가능한 타입에 대해 final annotation이 타입 안전하도록 보장했어요.

이제 리팩터링된 구조는 각 case에 깊게 중첩된 객체 패턴을 사용해 metadata를 그 상위 타입인 InterfaceElement로 업캐스트(upcast)해요. 이렇게 하면 metadata를 반복하는 단일 공유 for 루프가 case들에 걸쳐 타입 안전해져요.

객체 패턴을 스위치하는 것은 서브타입을 간결하게 검사하고 값을 구조 분해하는 능력 때문에 Dart의 대수적 데이터 타입 구현에 중요해요. 좋은 부작용은 한 줄의 코드가 동시에 여러 가지를 보장할 수 있다는 거예요. 다시 말해, 이 리팩터링의 각 case 패턴은 동시에 다음을 검증해요.

  • 객체가 ClassDeclaration, EnumDeclaration, MixinDeclaration 타입 중 하나라는 것.
  • 객체가 declaredElement 프로퍼티를 갖는다는 것.
  • declaredElementmetadata 프로퍼티를 갖는다는 것.
  • metadataInterfaceElement 타입이라는 것.
  • 문제의 객체나 프로퍼티 중 어느 것도 null이 아니라는 것.

이것은 Dart 3가 OO 언어의 너무 많은 미묘함을 고려해 패턴을 얼마나 철저히 구현했는지 보여주는 완벽한 예시이고, 객체지향 대수적 데이터 타입을 Dart에서 실제로 실현 가능한 설계 옵션으로 만듭니다.

객체 패턴에 대한 타입 테스트는 동작을 타입에서 분리하는 데 훌륭해요. 하지만 OO 서브타이핑의 기능 하나가 빠져 있어요. 바로 새 서브타입을 선언하면서 그 상위 타입의 추상 메서드 중 하나에 대한 동작을 정의하지 않으면 컴파일러가 알려주는 기능이죠. 더 이상 타입 선언의 인스턴스 메서드를 다루지 않을 때, Dart의 대수적 데이터 타입 모델은 어떻게 같은 안전 보장을 구현할 수 있을까요? 답은 완전성 검사(exhaustiveness checking) 에 있어요.

완전성 검사

함수형 언어의 대수적 데이터 타입 구현은 열거 가능한(enumable) sum type을 사용해요. 즉 컴파일러는 스위치되고 있는 타입의 모든 가능한 변형을 항상 알고 있어요. 그러면 컴파일러는 switch에서 case가 빠졌는지, 따라서 일부 값이 다뤄지지 않고 그 switch를 통과할 가능성이 있다는 것을 알려줄 수 있어요.

이를 완전성 검사라고 해요. 기술적으로는 enum과 boolean 같은 열거 가능한 타입에 대해 항상 Dart에 존재해 왔어요. 그런 타입들은 바뀔 수 없는 가능한 값 집합을 갖고 있고, 그것을 스위치하면 컴파일러는 하나를 놓쳤을 때 알려주고 경고해요. default 절을 사용하는 것은 또 다른 종류의 유사 완전성(pseudo exhaustiveness)이에요. default는 명시적으로 다뤄지지 않은 모든 case를 매치하므로, 컴파일러가 모든 잠재적 타입이 실제로 다뤄졌는지 알지 못하지만 switch를 완전하다고 간주하게 만들어요.

앞서 말했듯이, 우리는 Dart 버전의 대수적 데이터 타입 모델링에 sum type 대신 서브타입을 사용하고 싶었어요. 하지만 Dart의 클래스는 어떤 라이브러리에서든 확장될 수 있으므로, 외부 라이브러리에 서브클래스가 선언되었는지 알 수 없어서 컴파일러가 클래스의 서브타입을 완전히 열거하는 것은 불가능해요.

이 문제를 해결하고 Dart의 대수적 데이터 타입 구현을 완성하기 위해, 우리는 Dart 3에 sealed 클래스 수식어를 추가했어요. sealed 클래스는 자신의 라이브러리(그 정의가 포함된 파일)에서만 확장되거나 구현될 수 있어요. 이렇게 하면 컴파일러가 가능한 모든 서브타입을 항상 알고 있어 완전히 열거 가능하게 해줘요.

다음은 3.1 릴리스의 일부로 Dart SDK에 들어간 실제 리팩터링 예시예요. FileSystemEvent를 sealing해서 그 서브타입을 완전하게 스위치할 수 있게 하는 거예요. 마음 단단히 먹으세요, 리팩터링은 어렵거든요... 농담이에요, 전혀 어렵지 않았어요! 다만 기존 클래스 계층을 sealing하는 것은 파괴적인 변경(breaking change)이라는 점을 알아야 해요. 더 오래된 Dart 버전을 대상으로 하는 코드는 그 클래스를 구현하거나 확장할 수 없으므로, 항상 의존성을 확인하고 어딘가에서 클래스를 서브타이핑하는 사용자에게 알려야 해요.

FileSystemEvent를 sealing하면 FileSystemEntity.watch가 만들어내는, FileSystemEvent의 서브타입에 해당하는 이벤트들을 완전하게 스위치할 수 있게 돼요. 이 이벤트 스트림을 듣고 체인된 if-else 문으로 발생하는 이벤트의 타입에 따라 동작을 결정하는 것이 일반적이에요.

하지만 기본 클래스를 sealing하는 것은 이전 섹션의 _checkVisibleOutsideTemplate 예시처럼 객체 패턴을 스위치하게 해줄 뿐만 아니라, 그 과정에서 default case 없이도 그 타입에 대해 생길 수 있는 모든 가능한 값을 다루고 있다는 것도 보장해줘요.

FileSystemEvent를 확장하는 새 서브타입, 예를 들어 FileSystemSyncEvent가 추가되면 컴파일러는 그것을 인지하게 돼요. FileSystemEvent와 같은 라이브러리에만 추가될 수 있기 때문이죠. 클래스 계층이 sealed라서 컴파일러는 그 인스턴스에 대한 어떤 switch든 완전해야 한다고 요구하고, 다루지 않은 case가 있으면 (라이브러리 소유자가 아니라 switch를 쓴 사용자에게) 오류를 생성해 경고해요.

The type 'FileSystemEvent' is not exhaustively matched by the switch cases since it doesn't match 'FileSystemSyncEvent'

sealed 클래스와 객체 패턴 스위치를 결합하면, Dart에서 완전한 객체지향 대수적 데이터 타입 스타일의 프로그램 아키텍처를 구현할 수 있어요.

보너스 함수형 기능

위의 완전한 switch 예시에는 대수적 데이터 타입을 가능하게 해주는 것보다 더 많은 Dart 3 함수형 기능이 포함돼 있어요. switch가 _fileListener 함수의 return 문 오른쪽에 있다는 걸 주목하세요 — 이것이 Dart 3의 새 switch expression이에요. 표현식과 함수에 전반적으로 중점을 두는 것은 함수형 언어의 핵심 요소예요. Dart 3는 값을 만들 수 있고 표현식이 허용되는 어디든 갈 수 있는 switch 표현식을 만들었어요.

이전 예시의 _fileListener는 무엇을 반환하는 걸까요? 그것은 record예요. 또 다른 Dart 3의 새 기능이자 함수형 프로그래밍과도 관련이 있죠. Records는 함수에서 여러 이질적인(heterogeneous) 값을 반환하게 해줘요. Dart에서 함수의 유용성을 확장하고, (서로 다른 타입의 여러 값을 타입을 잃지 않고 반환하는 유일한 다른 방법이었던) 커스텀 클래스에 대한 의존에서 더 멀어지게 해줘요.

정리

Dart에서 대수적 데이터 타입을 다음과 같이 모델링할 수 있어요.

  • sealed 클래스의 인스턴스와 그 서브타입들에 대해 switch하는 함수를 작성하고,
  • switch case에서 각 서브타입의 동작 변형을 정의하기.

객체 패턴을 스위치하면 모든 연산을 간결한 방식으로 함께 유지할 수 있고, 완전성 검사는 어떤 타입에 대한 동작 정의가 빠져 있으면 컴파일러가 알려주도록 보장해요. 그리고 이 모든 것은 Dart가 이미 사용하는 객체지향 클래스 위에 구축되어 있어요.

가장 좋은 점은, 객체지향이나 함수형 스타일 중 하나를 선택해야 할 필요가 없다는 거예요. 두 패러다임이 함께 어우러져서, 정의하는 연산에 가장 잘 맞는 스타일을 쓸 수 있어요.

기존 클래스 계층을 약간의 수정만으로 더 함수형으로 만들 수 있고, 같은 클래스 계층 안에서 인스턴스 메서드와 대수적 데이터 타입의 사용을 섞을 수도 있어요. 동작을 타입에 밀접하게 결합하는 것이 말이 되는지, 아니면 다른 타입들의 동작을 한 함수에 묶는 것이 말이 되는지, 가장 말이 되는 스타일을 쓰면 돼요.

이 소개가 함수형 프로그래밍에 대한 관심을 불러일으키고 새 Dart 3 기능을 시도해 보게 하길 바라요. 누가 알겠어요, 어쩌면 곧 누군가로부터 최초의 완전 함수형 스타일 Dart 프로그램을 보게 될지도 몰라요!

자료

Dart 안팎의 함수형 프로그래밍에 대해 더 배우려면 다음 자료를 확인해 보세요.

더 알아보기