Dart의 built_value: 불변 객체 모델 만들기

Dart의 built_value: 불변 객체 모델 만들기

지난주에 built_collection에 대해 글을 썼어요. 마지막에 이런 말을 했죠: 불변 컬렉션을 제대로 활용하려면 불변 값이 필요하다고. 그래서 오늘은 바로 그것, built_value를 다룰게요. 이건 제가 Dart Developer Summit에서 발표한 내용 뒤에 있는 두 번째 핵심 조각이에요.

이 글은 David Morgan님이 2016년 11월 30일에 작성한 글로, 읽는 데 약 8분 정도 걸려요.

출처: Dart's built_value for Immutable Object Models

본문

값 타입(Value Types)

built_value 패키지는 여러분이 **자신만의 값 타입(Value Type)**을 정의하기 위한 것이에요. 이 용어는 정확한 의미가 있지만, 여기서는 동등성(equality)이 오직 값에만 기반하는 타입이라는 뜻으로 편하게 쓰겠어요. 숫자를 생각해보면 돼요. 내 3은 네 3과 같아요.

게다가 내 3은 언제나 네 3과 같아요. 4로 바뀌거나, null이 되거나, 완전히 다른 타입이 될 수 없어요. 값 타입은 자연스럽게 불변이에요. 그래서 상호작용하고 추론하기가 단순하죠.

다 너무 추상적으로 들리죠? 값 타입이 도대체 뭐에 좋을까요? 알고 보면 아주 많아요. 정말 많아요. 제가 자주 하는 주장인데, 현실 세계를 모델링하는 데 쓰는 어떤 클래스든 값 타입이어야 한다고 봐요. 이렇게요:

var user1 = new User(name: "John Smith");
var user2 = new User(name: "John Smith");

print(user1 == user2);

무엇이 출력될까요? 핵심은, 두 인스턴스 모두 현실 세계의 어떤 사람을 가리키기로 되어 있다는 거예요. 값이 동일하니 같은 사람을 가리켜야 하고, 따라서 같다고 간주되어야 해요.

그럼 불변성은 어떨까요? 이런 걸 생각해봐요:

user1.nickname = 'Joe';

"User"의 닉네임을 업데이트한다는 건 무슨 뜻일까요? 수많은 변경을 암시할 수 있어요. 아마 웹 페이지의 환영 문구가 닉네임을 쓴다면 그것도 업데이트되어야 하고, 어딘가 저장소가 있다면 그것도 업데이트되어야 해요. 그러면 두 가지 큰 문제가 생겨요:

  • "user1"에 대한 참조를 누가 갖고 있는지 모른다. 값이 그들 밑에서 방금 바뀌었는데, 그들이 어떻게 쓰느냐에 따라 예측할 수 없는 효과가 무수히 생길 수 있어요.
  • "user2"나 비슷한 값을 가진 사람은 이제 뒤처진(out of date) 값을 들고 있다.

불변성은 두 번째 문제를 해결해주진 못하지만, 첫 번째 문제는 없애줘요. 즉, 예측할 수 없는 업데이트가 없고 명시적인 업데이트만 있다는 뜻이에요:

var updatedUser = new User(name: "John Smith", nickname: "Joe");
saveToDatabase(updatedUser); // Database will notify frontend.

핵심은, 변경이 **명시적으로 발행되기 전까지는 국소적(local)**이라는 거예요. 이는 추론하기 쉬운 단순한 코드로 이어지고, 그 덕분에 올바르면서도 빠르게 만들기 좋아요.

값 타입의 문제점

그럼 당연한 질문이 나오죠. 값 타입이 이렇게 유용하다면 왜 어디서나 볼 수 없을까요?

안타깝게도 구현하기가 엄청 고역스러워서 그래요. Dart와 대부분의 객체 지향 언어에서는 많은 보일러플레이트 코드가 필요해요. Dart Developer Summit 발표에서 필드 두 개짜리 단순한 클래스 하나가 보일러플레이트 때문에 슬라이드 한 장을 꽉 채우는 걸 보여드린 적이 있어요.

built_value 소개

우리에게 필요한 건 언어 기능 — 논의하면 재밌지만 당장 나올 것 같진 않은 — 이거나, 어떤 형태의 **메타프로그래밍(metaprogramming)**이에요. 그리고 Dart에는 이미 아주 멋진 메타프로그래밍 방법이 있다는 걸 알게 됐죠. 바로 source_gen이에요.

목표는 분명해요. 값 타입을 정의하고 사용하기를 너무 쉽게 만들어서, 값 타입이 말이 되는 곳이라면 어디든 쓸 수 있게 하는 거예요.

먼저 이 문제를 source_gen으로 어떻게 접근하는지 잠깐 살펴볼게요. source_gen 도구는 여러분이 직접 관리하는 소스 옆에 새 파일로 생성된 소스를 만들어요. 그래서 생성된 구현을 위한 자리를 남겨둬야 해요. 즉 추상 클래스가 필요해요:

abstract class User {
  String get name;

  @nullable
  String get nickname;
}

이 정도면 구현을 생성하기에 충분한 정보예요. 관례상 생성된 코드는 "$"로 시작해서, 비공개(private)이고 생성된 것임을 표시해요. 그래서 생성된 구현은 "$User"라고 불러요. 그 구현이 "User"를 상속할 수 있게, 이를 위한 비공개 생성자 "_"가 있을 거예요:

=== user.dart ===

abstract class User {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User() = UserImpl;
}

=== user.g.dart is generated by source_gen ===

class _$User extends User {
  String name;
  String nickname;

  _$User() : super._();
}

Dart의 "part" 문을 써서 생성된 코드를 끌어와야 해요:

=== user.dart ===

library user;

part 'user.g.dart';

abstract class User {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User() = _$User;
}

=== user.g.dart is generated by source_gen ===

part of user;

class _$User extends User {
  String name;
  String nickname;

  _$User() : super._();

  // Generated implementation goes here.
}

점점 답에 가까워지고 있어요! 코드를 생성하고 손으로 작성한 코드에 끼워 넣는 방법을 얻었어요. 이제 다시 재미있는 부분으로 돌아가죠. 여러분이 손으로 써야 할 것과 built_value가 생성해야 할 것은 무엇일까요?

먼저 필드에 값을 실제로 지정하는 방법이 빠져 있어요. 이름 있는 선택적 파라미터(named optional parameters)를 생각해볼 수 있죠:

factory User({String name, String nickname}) = _$User;

그런데 여기엔 단점이 두 가지 있어요. 생성자에서 모든 필드 이름을 반복해야 하고, 모든 필드를 한 번에 설정하는 방법만 제공돼요. 값을 조금씩 조금씩 만들어 가고 싶다면 어떻게 해야 할까요?

다행히 **빌더 패턴(builder pattern)**이 구원해줘요. 이미 컬렉션에서 이 패턴이 얼마나 잘 작동하는지 봤죠. 캐스케이드 연산자 덕분이에요. 빌더 타입이 있다고 가정하면, 생성자에 그것을 쓸 수 있어요. 빌더를 파라미터로 받는 함수를 요청하는 방식으로요:

abstract class User {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User([updates(UserBuilder b)]) = _$User;
}

조금 놀랍지만, 인스턴스화를 위한 아주 단순한 문법으로 이어져요:

var user1 = new User((b) => b
    ..name = 'John Smith'
    ..nickname = 'Joe');

기존 값에서 새 값을 만드는 건 어떨까요? 전통적인 빌더 패턴은 빌더로 변환하는 "toBuilder" 메서드를 제공해요. 그런 다음 업데이트를 적용하고 "build"를 호출하면 되죠. 하지만 대부분의 사용 사례에선 "rebuild" 메서드를 쓰는 게 더 좋은 패턴이에요. 생성자처럼 빌더를 받는 함수를 인자로 받고, 간편한 인라인 업데이트를 제공해줘요:

var user2 = user.rebuild((b) => b
    ..nickname = 'Jojo');

그래도 잠시 동안 빌더를 들고 있고 싶은 경우가 있으니 "toBuilder"는 여전히 필요해요. 그래서 모든 값 타입에 메서드 두 개를 두고 싶어요:

abstract class Built<V, B> {
  // Creates a new instance: this one with [updates] applied.
  V rebuild(updates(B builder));

  // Converts to a builder.
  B toBuilder();
}

이들의 구현을 직접 쓸 필요는 없어요. built_value가 생성해줘요. 그냥 "Built를 구현한다"고 선언하면 돼요:

library user;

import 'package:built_value/built_value.dart';

part 'user.g.dart';

abstract class User implements Built<User, UserBuilder> {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User([updates(UserBuilder b)]) = _$User;
}

정말 그게 전부예요! 값 타입이 정의되고, 구현이 생성되고, 쓰기 쉬워졌어요. 물론 생성된 구현은 필드만 있는 게 아니에요. "operator==", "hashCode", "toString" 그리고 필수 필드에 대한 null 검사도 제공해요.

한 가지 중요한 세부 사항은 건너뛰었는데, "빌더 타입이 있다고 가정하면"이라고 한 부분이에요. 물론 우리는 코드를 생성하고 있으니 답은 간단해요. 우리가 생성해줄게요. "User"에서 참조하는 "UserBuilder"는 "user.g.dart"에서 만들어져요.

빌더에 직접 코드를 쓰고 싶지 않다면요. 빌더를 커스터마이즈하는 건 충분히 합리적인 욕구예요. 그걸 원한다면 빌더에 같은 패턴을 적용해요. 추상으로 선언하고, 비공개 생성자와 생성된 구현에 위임하는 factory를 갖추면 돼요:

abstract class UserBuilder extends Builder<V, B> {
  @virtual
  String name;

  @virtual
  String nickname;

  // Parses e.g. John "Joe" Smith into username+nickname.
  void parseUser(String user) {
    ...
  }

  UserBuilder._();
  factory UserBuilder() => _$UserBuilder;
}

"@virtual" 애노테이션은 "package:meta"에서 오고, 생성된 구현이 필드를 오버라이드할 수 있게 하는 데 필요해요. 이제 빌더에 유틸리티 메서드를 추가했으니, 필드에 값을 할당하듯 인라인으로 사용할 수 있어요:

var user = new User((b) => b..parseUser('John "Joe" Smith'));

빌더를 커스터마이즈하는 사용 사례는 비교적 드물지만, 매우 강력할 수 있어요. 예를 들어, 공통 필드를 설정하기 위한 공통 인터페이스를 빌더들이 구현하게 해서, 서로 바꿔 쓸 수 있게 하고 싶을 수도 있어요.

중첩 빌더(Nested Builders)

아직 보여드리지 않은 built_value의 주요 기능이 하나 있어요. 바로 **중첩 빌더(nested builder)**예요. built_value 필드가 built_collection이나 다른 built_value를 담고 있으면, 기본적으로 빌더 안에서 중첩 빌더로 사용할 수 있어요. 즉, 구조 전체가 가변일 때보다 더 쉽게 깊이 중첩된 필드를 업데이트할 수 있어요:

var structuredData = new Account((b) => b
    ..user.name = 'John Smith'
    ..user.nickname = 'Joe'
    ..credentials.email = '[email protected]'
    ..credentials.phone.country = Country.us
    ..credentials.phone.number = '555 01234 567');

var updatedStructuredData = structuredData.rebuild((b) => b
    ..credentials.phone.country = Country.switzerland
    ..credentials.phone.number = '555 01234 555');

왜 구조가 가변일 때보다 "더 쉬운" 걸까요?

첫째, 모든 빌더가 제공하는 "update" 메서드 덕분에 원할 때마다 새로운 스코프에 들어갈 수 있어요. 캐스케이드 연산자를 "다시 시작"해서, 원하는 업데이트를 간결하고 인라인으로 만들 수 있죠:

var updatedStructuredData = structuredData.rebuild((b) => b
    ..user.update((b) => b
        ..name = 'Johnathan Smith')
    ..credentials.phone.update((b) => b
        ..country = Country.switzerland
        ..number = '555 01234 555'));

둘째, 중첩 빌더는 필요할 때 자동으로 생성돼요. 예를 들어, built_value의 벤치마크 코드에는 Node라는 타입을 정의해요:

abstract class Node implements Built<Node, NodeBuilder> {
  @nullable
  String get label;
  @nullable
  Node get left;
  @nullable
  Node get right;

  Node._();
  factory Node([updates(NodeBuilder b)]) = _$Node;
}

빌더의 자동 생성 덕분에 우리가 원하는 어떤 트리 구조든 인라인으로 만들 수 있어요:

var node = new Node((b) => b
  ..left.left.left.right.left.right.label = 'I’m a leaf!'
  ..left.left.right.right.label = 'I’m also a leaf!');

var updatedNode = node.rebuild((b) => b
  ..left.left.right.right.label = 'I’m not a leaf any more!'
  ..left.left.right.right.right.label = 'I’m the leaf now!');

벤치마크 언급을 했죠? 업데이트할 때 built_value는 업데이트가 필요한 구조의 일부만 복사하고 나머지는 재사용해요. 그래서 빠르고 메모리도 효율적이에요.

하지만 트리만 만들 수 있는 건 아니에요. built_value를 쓰면 완전히 타입이 지정된 불변 객체 모델이 여러분 손안에 있어요. 효율적인 불변 트리만큼 빠르고 강력하죠. 타입이 지정된 데이터, "Node" 예시 같은 커스텀 구조, 그리고 built_collection의 컬렉션을 섞어 쓸 수 있어요:

var structuredData = new Account((b) => b
    ..user.update((b) => b
        ..name = 'John Smith')
    ..credentials.phone.update((b) => b
        ..country = Country.us
        ..number = '555 01234 567')
    ..node.left.left.left.account.update((b) => b
        ..user.name = 'John Smith II'
        ..user.nickname = 'Is lost in a tree')
    ..node.left.right.right.account.update((b) => b
        ..user.name = 'John Smith III'));

이것들이 바로 제가 "대부분의 데이터는 값 타입이어야 한다"고 주장할 때 말하는 값 타입들이에요!

built_value에 대해 더 알아보기

built_value가 필요한지, 쓰면 어떤 모습인지 다뤘어요. 아직 더 남아 있어요. built_value는 enum처럼 행동하는 클래스를 위한 EnumClass와, 서버/클라이언트 통신과 데이터 저장을 위한 JSON 직렬화도 제공해요. 그것들은 다음 글에서 다룰게요.

그 다음엔 built_value를 서버와 클라이언트가 있는 엔드 투 엔드 시스템에서 쓰는 chat 예제를 파고들 거예요.

더 알아보기