API용 클래스 수정자

API용 클래스 수정자 (Class modifiers for APIs)

라이브러리 패키지를 만드는 입장에서는, 사용자들이 내가 export한 타입으로 무엇을 할 수 있는지 조절하고 싶어요. Dart 3.0은 클래스와 믹스인 선언에 붙일 수 있는 몇 가지 새 수정자를 추가했어요. 패키지를 작성하는 사람이라면 이 수정자로 사용자들이 할 수 있는 일을 더 잘 통제할 수 있죠. 패키지를 진화시키기 쉬워지고, 코드 변경이 사용자를 망가뜨릴지 파악하기도 쉬워져요.

출처: Dart 공식 문서

본문

Dart 3.0은 클래스를 믹스인으로 사용하는 것에 대한 파격적 변경(breaking change)도 포함해요. 이 변경이 당신의 클래스를 깨뜨리지 않을 수도 있지만, 당신 클래스의 사용자 를 깨뜨릴 수는 있어요. 이 가이드는 새 수정자를 어떻게 쓰는지, 그리고 그것이 당신의 라이브러리 사용자에게 어떤 영향을 주는지 알 수 있도록 안내해요.

클래스의 mixin 수정자 (The mixin modifier on classes)

가장 중요하게 알아야 할 수정자는 mixin이에요. Dart 3.0 이전의 언어 버전에서는 어떤 클래스든 다른 클래스의 with 절에서 믹스인으로 쓸 수 있었어요. 다음 경우가 아니면 말이죠.

  • non-factory 생성자를 선언한 경우.
  • Object 이외의 클래스를 확장한 경우.

이 때문에, 다른 사람들이 그 클래스를 with 절에서 쓰고 있다는 걸 모른 채 클래스에 생성자나 extends 절을 추가해서 남의 코드를 실수로 깨뜨리기 쉬웠어요.

Dart 3.0은 기본적으로 클래스를 믹스인으로 쓰는 것을 더 이상 허용하지 않아요. 대신 mixin class를 선언해서 그 동작에 명시적으로 동의(opt-in)해야 해요.

mixin class Both {}

class UseAsMixin with Both {}
class UseAsSuperclass extends Both {}

패키지를 Dart 3.0으로 업데이트하고 코드를 전혀 바꾸지 않으면 에러가 보이지 않을 수도 있어요. 하지만 사용자들이 당신의 클래스를 믹스인으로 쓰고 있었다면, 그들을 무심코 깨뜨릴 수 있어요.

클래스를 믹스인으로 마이그레이션하기 (Migrating classes as mixins)

클래스에 non-factory 생성자, extends 절, with 절이 있다면 이미 믹스인으로 쓸 수 없어요. Dart 3.0에서 동작이 바뀌지 않아요. 걱정할 것도, 할 일도 없죠.

실제로 이는 기존 클래스의 약 90%를 설명해요. 믹스인으로 쓸 수 있는 나머지 클래스에 대해서는 무엇을 지원할지 결정해야 해요.

결정을 돕는 몇 가지 질문이 있어요. 첫 번째는 실용적이에요.

사용자를 깨뜨릴 위험을 감수하시겠어요? 답이 단호한 "아니요"라면, 믹스인으로 쓸 수 있는 모든 클래스 앞에 mixin을 붙이세요. 이렇게 하면 API의 기존 동작을 정확히 보존해요.

반대로 이 기회에 API가 제공하는 편의(affordance)를 다시 생각해 보고 싶다면, 그걸 mixin class로 만들지 않는 쪽을 택할 수도 있어요. 두 가지 설계 질문을 고려해 보세요.

사용자들이 그것의 인스턴스를 직접 만들 수 있기를 원하나요? 다시 말해, 그 클래스가 의도적으로 abstract가 아닌가요?

사람들이 그 선언을 믹스인으로 쓸 수 있기를 원하나요? 다시 말해, with 절에서 쓰기를 원하나요?

두 답이 모두 "예"라면 믹스인 클래스로 만들어요. 두 번째 답이 "아니오"면 그냥 클래스로 두세요. 첫 번째 답이 "아니오"이고 두 번째가 "예"라면, 클래스에서 mixin 선언으로 바꾸세요.

마지막 두 선택지(클래스로 남기거나 순수 믹스인으로 바꾸는 것)는 파격적 API 변경이에요. 이렇게 한다면 패키지의 주(major) 버전을 올려야 할 거예요.

다른 옵트인 수정자들 (Other opt-in modifiers)

클래스를 믹스인으로 다루는 건 Dart 3.0에서 패키지 API에 영향을 주는 유일한 중대한 변경이에요. 여기까지 왔다면, 패키지가 사용자에게 허용하는 다른 것을 바꾸고 싶지 않으면 여기서 멈춰도 돼요.

계속해서 아래 설명하는 수정자 중 하나를 쓰면 패키지 API에 대한 파격적 변경이 되어 주 버전 증가가 필요할 수 있다는 점을 명심하세요.

interface 수정자 (The interface modifier)

Dart는 순수 인터페이스를 선언하는 별도 문법이 없어요. 대신, 우연히 추상 메서드만 포함하는 추상 클래스를 선언하죠. 사용자가 패키지 API에서 그 클래스를 보면, 그것이 클래스를 확장해서 재사용할 수 있는 코드를 포함하는지, 아니면 인터페이스로 쓰기 위한 것인지 알지 못할 수 있어요.

클래스에 interface 수정자를 붙이면 그걸 명확히 할 수 있어요. 이 수정자는 클래스가 implements 절에서 쓰이는 건 허용하지만, extends에서 쓰이는 건 막아요.

클래스가 실제로 non-abstract 메서드를 갖고 있더라도 사용자가 그것을 확장하는 걸 막고 싶을 수 있어요. 상속은 소프트웨어에서 가장 강력한 결합 종류 중 하나예요. 코드 재사용을 가능하게 하니까요. 하지만 그 결합은 위험하고 깨지기 쉬워요. 상속이 패키지 경계를 넘으면, 하위 클래스를 깨뜨리지 않고 수퍼클래스를 진화시키기 어려울 수 있어요.

클래스를 interface로 표시하면 사용자가 (또한 abstract로 표시되지 않았다면) 그 클래스를 만들 수 있고 클래스의 인터페이스를 구현할 수 있지만, 그 코드를 재사용하는 건 막아요.

클래스가 interface로 표시되면, 그 제한은 클래스가 선언된 라이브러리 안에서는 무시될 수 있어요. 라이브러리 안에서는 확장해도 자유로워요. 전부 당신의 코드이고 아마 뭘 하는지 알고 있을 테니까요. 제한은 다른 패키지, 그리고 당신 패키지 안의 다른 라이브러리에도 적용돼요.

base 수정자 (The base modifier)

base 수정자는 interface와 다소 반대예요. 클래스를 extends 절에서 쓰거나, 믹스인이나 믹스인 클래스를 with 절에서 쓰는 건 허용해요. 하지만 클래스의 라이브러리 밖 코드가 그 클래스나 믹스인을 implements 절에서 쓰는 건 금지해요.

이렇게 해서 클래스나 믹스인의 인터페이스의 인스턴스가 되는 모든 객체가 실제 구현을 상속하도록 보장해요. 특히 모든 인스턴스가 클래스나 믹스인이 선언하는 모든 비공개 멤버를 포함한다는 뜻이에요. 그렇게 하면 실제로는 발생할 수 있는 런타임 에러를 예방하는 데 도움이 돼요.

이 라이브러리를 고려해 보세요.

class A {
  void _privateMethod() {
    print('I inherited from A');
  }
}

void callPrivateMethod(A a) {
  a._privateMethod();
}

이 코드는 그 자체로는 괜찮아 보이지만, 사용자가 이렇게 다른 라이브러리를 만드는 걸 막는 게 없어요.

import 'a.dart';

class B implements A {
  // No implementation of _privateMethod()!
}

main() {
  callPrivateMethod(B()); // Runtime exception!
}

BAimplements하지만 _privateMethod를 구현하지 않았으니, callPrivateMethod(B())는 런타임 예외를 던져요. 클래스에 base 수정자를 추가하면 이런 런타임 에러를 예방할 수 있어요. interface처럼, base 클래스나 믹스인이 선언된 같은 라이브러리에서는 이 제한을 무시할 수 있어요. 그러면 같은 라이브러리의 하위 클래스는 비공개 메서드를 구현하라는 알림을 받게 되죠. 하지만 다음 절은 적용된다는 점에 주의하세요.

base 전이성 (Base transitivity)

클래스를 base로 표시하는 목표는 그 타입의 모든 인스턴스가 그 타입으로부터 구체적으로 상속하도록 보장하는 거예요. 이를 유지하기 위해 base 제한은 "전염성(contagious)"이 있어요. base로 표시된 타입의 모든 하위 타입은 직접이든 간접이든 구현되는 것을 막아야 해요. 즉, base(또는 다음에 다룰 final이나 sealed)로 표시돼야 하죠.

그래서 타입에 base를 적용하는 건 신중함을 요해요. 그것은 사용자가 당신의 클래스나 믹스인으로 할 수 있는 일뿐 아니라, 그들의 하위 클래스가 제공할 수 있는 편의에도 영향을 줘요. 타입에 base를 붙이고 나면, 그 아래 전체 계층이 구현되는 것이 금지돼요.

엄청 강하게 들리지만, 대부분의 다른 프로그래밍 언어가 항상 그렇게 동작해 왔어요. 대부분은 암묵적 인터페이스가 전혀 없어서, Java, C#이나 다른 언어에서 클래스를 선언하면 사실상 같은 제약을 갖게 되죠.

final 수정자 (The final modifier)

interfacebase의 제약을 모두 원한다면 클래스나 믹스인 클래스를 final로 표시할 수 있어요. 이렇게 하면 라이브러리 밖의 누구도 그것의 어떤 종류의 하위 타입도 만들 수 없게 돼요. implements, extends, with, on 절에서 쓰는 것 모두 금지되죠.

이것은 클래스 사용자에게 가장 제한적이에요. 그들이 할 수 있는 전부는 (abstract로 표시되지 않았다면) 생성하는 것뿐이에요. 그 대가로 클래스 유지자로서 당신은 최소한의 제한을 갖게 돼요. 새 메서드를 추가하고, 생성자를 팩토리 생성자로 바꾸는 등의 일을 하위 사용자를 깨뜨릴 걱정 없이 할 수 있어요.

sealed 수정자 (The sealed modifier)

마지막 수정자인 sealed는 특별해요. 주로 패턴 매칭에서 완전성 검사(exhaustiveness checking)를 가능하게 하기 위해 존재해요. sealed로 표시된 타입의 모든 직접 하위 타입에 대한 case가 switch에 있다면, 컴파일러는 그 switch가 완전하다는 걸 알 수 있어요.

sealed class Amigo {}

class Lucky extends Amigo {}

class Dusty extends Amigo {}

class Ned extends Amigo {}

String lastName(Amigo amigo) => switch (amigo) {
  Lucky _ => 'Day',
  Dusty _ => 'Bottoms',
  Ned _ => 'Nederlander',
};

이 switch는 Amigo의 각 하위 타입에 대한 case를 갖고 있어요. 컴파일러는 모든 Amigo 인스턴스가 그 하위 타입 중 하나의 인스턴스여야 한다는 걸 알기 때문에, switch가 안전하게 완전하고 최종 default case가 필요 없다는 걸 알죠.

이것이 건전하려면 컴파일러가 두 가지 제약을 강제해요.

  • sealed 클래스는 그 자체로 직접 생성 가능할 수 없어요. 그렇지 않으면 어떤 하위 타입의 인스턴스도 아닌 Amigo 인스턴스를 가질 수 있으니까요. 그래서 모든 sealed 클래스는 암묵적으로 abstract이기도 해요.
  • sealed 타입의 모든 직접 하위 타입은 sealed 타입이 선언된 같은 라이브러리에 있어야 해요. 이렇게 해야 컴파일러가 전부 찾을 수 있어요. 어떤 case와도 일치하지 않을 다른 숨은 하위 타입이 주변에 없다는 걸 알죠.

두 번째 제약은 final과 비슷해요. final처럼, sealed로 표시된 클래스는 선언된 라이브러리 밖에서 직접 확장, 구현, 믹스인될 수 없다는 뜻이에요. 하지만 basefinal과 달리 전이적 제약은 없어요.

sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
// This is an error:
class Bad extends Amigo {}

// But these are both fine:
class OtherLucky extends Lucky {}
class OtherDusty implements Dusty {}

물론 sealed 타입의 하위 타입도 제한하고 싶다면, 그들을 interface, base, final, sealed로 표시해서 얻을 수 있어요.

sealedfinal (sealed versus final)

사용자가 직접 하위 타입을 만들지 못하게 하고 싶은 클래스가 있을 때, sealedfinal 중 언제 써야 할까요? 몇 가지 간단한 규칙이 있어요.

  • 사용자가 클래스의 인스턴스를 직접 만들 수 있게 하려면 sealed를 쓸 수 없어요. sealed 타입은 암묵적으로 abstract이니까요.
  • 클래스에 라이브러리 안의 하위 타입이 없다면 sealed를 쓸 이유가 없어요. 완전성 검사 이점이 없으니까요.

그 외에 클래스에 당신이 정의한 하위 타입이 좀 있다면 sealed가 아마 원하는 것일 거예요. 사용자가 클래스에 몇 개의 하위 타입이 있는 걸 보면, 각각을 switch case로 따로 처리하고 컴파일러가 전체 타입이 덮여 있다는 걸 알게 해주는 게 편리하니까요.

sealed를 쓴다는 건 나중에 라이브러리에 하위 타입을 또 추가하면 파격적 API 변경이라는 뜻이에요. 새 하위 타입이 나타나면, 기존 switch 전부가 불완전해져요. 새 타입을 처리하지 않으니까요. enum에 새 값을 추가하는 것과 정확히 같죠.

그 불완전한 switch 컴파일 에러는 사용자에게 유용해요. 새 타입을 처리해야 할 코드의 위치를 사용자의 주목으로 끌어주니까요.

하지만 새 하위 타입을 추가할 때마다 파격적 변경이라는 뜻이기도 해요. 파격적이지 않은 방식으로 새 하위 타입을 추가하는 자유를 원한다면, sealed 대신 final로 수퍼타입을 표시하는 게 나아요. 그러면 사용자가 그 수퍼타입의 값을 switch할 때, 모든 하위 타입에 대한 case가 있어도 컴파일러가 default case를 또 추가하도록 강제해요. 그 default case가 나중에 하위 타입을 더 추가하면 실행되는 부분이 되죠.

요약 (Summary)

API 설계자로서 이 새 수정자들은 사용자가 당신의 코드로 어떻게 작업하는지, 그리고 반대로 당신이 그들의 코드를 깨뜨리지 않고 코드를 어떻게 진화시킬 수 있는지에 대한 통제를 줘요.

하지만 이 선택지들은 복잡성을 동반해요. 이제 API 설계자로서 더 많은 선택을 해야 하니까요. 또한 이 기능들은 새것이어서 아직 모범 사례가 무엇일지 모르는 상태예요. 모든 언어의 생태계는 다르고 다른 요구를 가져요.

다행히 한 번에 전부 알아낼 필요는 없어요. 우리는 기본값을 신중하게 골라서, 아무것도 하지 않아도 클래스가 대체로 3.0 이전에 가졌던 것과 같은 편의를 가지도록 했어요. API를 예전 방식으로 유지하고 싶다면 이미 그것을 지원하던 클래스에 mixin을 붙이면 끝이에요.

시간이 지나 더 세밀한 통제가 필요한 지점을 느끼면 다른 수정자를 적용하는 걸 고려할 수 있어요.

  • interface를 써서 사용자가 클래스의 코드를 재사용하는 건 막고, 그 인터페이스를 다시 구현하는 건 허용하세요.
  • base를 써서 사용자가 클래스의 코드를 재사용하도록 요구하고, 클래스 타입의 모든 인스턴스가 실제 클래스나 하위 클래스의 인스턴스임을 보장하세요.
  • final을 써서 클래스가 확장되는 것을 완전히 막으세요.
  • sealed를 써서 하위 타입 계열에서 완전성 검사에 동의하세요.

그럴 때는 패키지를 게시하면서 주 버전을 올리세요. 이 수정자들은 모두 파격적 변경인 제약을 의미하니까요.

더 알아보기