주요 콘텐츠로 건너뛰기

코드 생성에 대하여

코드 생성이란 도구를 사용해 코드를 대신 만들어 내는 방식을 말합니다. Dart에서는 애플리케이션을 "컴파일"하기 위해 추가 단계가 필요하다는 단점이 있습니다. 다만 Dart 팀이 이 문제의 해결책을 연구하고 있으므로, 가까운 미래에 해결될 수도 있습니다.

Riverpod에서 코드 생성이란 "provider"를 정의하는 문법을 약간 바꾸는 것을 말합니다. 예를 들어 다음과 같이 작성하는 대신,

final fetchUserProvider = FutureProvider.autoDispose.family<User, int>((
ref,
userId,
) async {
final json = await http.get('api/user/$userId');
return User.fromJson(json);
});

코드 생성을 사용하면 다음과 같이 작성합니다.


Future<User> fetchUser(Ref ref, {required int userId}) async {
final json = await http.get('api/user/$userId');
return User.fromJson(json);
}

Riverpod에서 코드 생성은 전적으로 선택 사항입니다. 코드 생성 없이도 Riverpod을 얼마든지 사용할 수 있습니다. 그러면서도 Riverpod은 코드 생성을 적극 지원하며, 사용을 권장합니다.

Riverpod의 코드 생성기를 설치하고 사용하는 방법은 시작하기 페이지를 참고하세요. 문서 사이드바에서 코드 생성을 활성화하는 것도 잊지 마세요.

코드 생성을 사용해야 할까요?​

Riverpod에서 코드 생성은 선택 사항입니다. 그렇다면 코드 생성을 써야 할지 말아야 할지 궁금할 수 있습니다.

답은 이미 다른 용도로 코드 생성을 사용하고 있다면 쓰세요입니다. (Freezed, json_serializable 등) Dart 팀이 "macros"라는 기능을 개발하던 시기에는 코드 생성이 Riverpod을 사용하는 권장 방식이었습니다. 하지만 안타깝게도 macros는 취소되었습니다.

코드 생성은 여러 이점이 있지만, 현재로서는 여전히 꽤 느립니다. Dart 팀이 코드 생성 성능을 개선하고 있지만, 언제 적용될지, 얼마나 나아질지는 아직 알 수 없습니다. 따라서 프로젝트에서 아직 코드 생성을 사용하지 않고 있다면, Riverpod만을 위해 코드 생성을 도입할 가치는 크지 않을 것입니다.

한편 많은 애플리케이션이 이미 Freezed나 json_serializable 같은 패키지로 코드 생성을 사용하고 있습니다. 이런 경우라면 프로젝트에 코드 생성 환경이 이미 갖춰져 있을 가능성이 높으므로, Riverpod에서도 쉽게 사용할 수 있습니다.

코드 생성을 사용하면 어떤 이점이 있나요?​

"Riverpod에서 코드 생성이 선택 사항이라면, 굳이 왜 써야 하지?"라는 의문이 들 수 있습니다.

여느 패키지와 마찬가지로, 개발을 더 편하게 하기 위해서입니다. 예를 들면 다음과 같은 이점이 있습니다.

  • 더 읽기 쉽고 유연하며, 배우기도 쉬운 문법
    • provider의 종류를 고민할 필요가 없습니다. 로직만 작성하면 Riverpod이 가장 적합한 provider를 골라 줍니다.
    • 더 이상 "지저분한 전역 변수"를 정의하는 것처럼 보이지 않습니다. 대신 사용자 정의 함수/클래스를 정의합니다.
    • provider에 매개변수를 제한 없이 전달할 수 있습니다. Family를 사용해 위치 매개변수 하나만 전달하는 대신, 어떤 매개변수든 전달할 수 있습니다. 이름 있는 매개변수, 선택적 매개변수는 물론 기본값까지 사용할 수 있습니다.
  • Riverpod으로 작성한 코드의 상태를 유지하는 hot-reload(stateful hot-reload)
  • 디버거가 활용하는 추가 메타데이터를 생성하므로 디버깅이 더 쉬워집니다.

문법​

provider 정의하기:​

코드 생성으로 provider를 정의할 때는 다음 사항을 기억해 두면 좋습니다.

  • provider는 어노테이션을 붙인 함수나 어노테이션을 붙인 클래스로 정의할 수 있습니다. 둘은 거의 같지만, 클래스 기반 provider는 외부 객체가 provider의 상태를 변경(부수 효과)할 수 있도록 public 메서드를 둘 수 있다는 장점이 있습니다. 함수형 provider는 build 메서드만 있는 클래스 기반 provider를 간단히 쓰기 위한 문법적 설탕이므로, UI에서 상태를 변경할 수 없습니다.
  • Dart의 모든 비동기 기본 타입(Future, FutureOr, Stream)을 지원합니다.
  • 함수에 async를 붙이면 provider가 오류/로딩 상태를 자동으로 처리하고 AsyncValue를 노출합니다.
함수형
(public 메서드로
부수 효과를 수행할 수 없음)
클래스 기반
(public 메서드로
부수 효과를 수행할 수 있음)
동기

String example(Ref ref) {
return 'foo';
}

class Example extends _$Example {

String build() {
return 'foo';
}

// Add methods to mutate the state
}
비동기 - Future

Future<String> example(Ref ref) async {
return Future.value('foo');
}

class Example extends _$Example {

Future<String> build() async {
return Future.value('foo');
}

// Add methods to mutate the state
}
비동기 - Stream

Stream<String> example(Ref ref) async* {
yield 'foo';
}

class Example extends _$Example {

Stream<String> build() async* {
yield 'foo';
}

// Add methods to mutate the state
}

autoDispose 활성화/비활성화:​

코드 생성을 사용하면 provider는 기본적으로 autoDispose입니다. 즉, 연결된 리스너(ref.watch/ref.listen)가 하나도 남지 않으면 스스로 폐기됩니다. 이 기본 설정이 Riverpod의 철학에 더 잘 맞습니다. 처음에 코드 생성을 사용하지 않는 방식에서는 package:provider에서 마이그레이션하는 사용자를 배려해 autoDispose가 기본적으로 꺼져 있었습니다.

autoDispose를 끄고 싶다면 어노테이션에 keepAlive: true를 전달하면 됩니다.

// AutoDispose provider (keepAlive is false by default)

String example1(Ref ref) => 'foo';

// Non autoDispose provider
(keepAlive: true)
String example2(Ref ref) => 'foo';

provider에 매개변수 전달하기(family):​

코드 생성을 사용하면 provider에 매개변수를 전달하기 위해 더 이상 family 수식자에 의존할 필요가 없습니다. 대신 provider의 메인 함수가 이름 있는 매개변수, 선택적 매개변수, 기본값을 포함해 원하는 만큼 매개변수를 받을 수 있습니다. 다만 이 매개변수들은 여전히 일관된 ==를 가져야 한다는 점에 유의하세요. 즉, 값을 캐시하거나 매개변수가 ==를 오버라이드해야 합니다.

함수형클래스 기반

String example(
Ref ref,
int param1, {
String param2 = 'foo',
}) {
return 'Hello $param1 & $param2';
}

class Example extends _$Example {

String build(
int param1, {
String param2 = 'foo',
}) {
return 'Hello $param1 & $param2';
}

// Add methods to mutate the state
}

코드 생성을 사용하지 않는 방식에서 마이그레이션하기:​

코드 생성을 사용하지 않는 방식에서는 provider의 종류를 직접 정해야 합니다. 코드 생성 방식으로 옮길 때 각 provider에 대응하는 형태는 다음과 같습니다.

Provider
이전
final exampleProvider = Provider.autoDispose<String>(
(ref) {
return 'foo';
},
);
이후

String example(Ref ref) {
return 'foo';
}
NotifierProvider
이전
final exampleProvider = NotifierProvider.autoDispose<ExampleNotifier, String>(
ExampleNotifier.new,
);

class ExampleNotifier extends Notifier<String> {

String build() {
return 'foo';
}

// Add methods to mutate the state
}
이후

class Example extends _$Example {

String build() {
return 'foo';
}

// Add methods to mutate the state
}
FutureProvider
이전
final exampleProvider = FutureProvider.autoDispose<String>((ref) async {
return Future.value('foo');
});
이후

Future<String> example(Ref ref) async {
return Future.value('foo');
}
StreamProvider
이전
final exampleProvider =
StreamProvider.autoDispose<String>((ref) async* {
yield 'foo';
});
이후

Stream<String> example(Ref ref) async* {
yield 'foo';
}
AsyncNotifierProvider
이전
final exampleProvider =
AsyncNotifierProvider.autoDispose<ExampleNotifier, String>(
ExampleNotifier.new,
);

class ExampleNotifier extends AsyncNotifier<String> {

Future<String> build() async {
return Future.value('foo');
}

// Add methods to mutate the state
}
이후

class Example extends _$Example {

Future<String> build() async {
return Future.value('foo');
}

// Add methods to mutate the state
}
StreamNotifierProvider
이전
final exampleProvider =
StreamNotifierProvider.autoDispose<ExampleNotifier, String>(() {
return ExampleNotifier();
});

class ExampleNotifier extends StreamNotifier<String> {

Stream<String> build() async* {
yield 'foo';
}

// Add methods to mutate the state
}
이후

class Example extends _$Example {

Stream<String> build() async* {
yield 'foo';
}

// Add methods to mutate the state
}