출처: https://resocoder.com/2020/03/13/flutter-firebase-ddd-course-2-authentication-value-objects/
우리는 이미 도메인 중심 설계에 대한 큰 그림을 마음속에 갖고 있으므로 이제 코딩을 시작할 시간입니다. 우리는 Firebase 앱을 구축하고 있으므로 처음부터 Firestore
및 FirebaseAuth
클래스 사용에 대해 걱정해야 한다고 생각할 수도 있습니다. DDD에서는 전혀 그렇지 않습니다. 가장 중요한 계층인 도메인 계층부터 시작해 보겠습니다. 즉, 인증을 다루겠습니다.
Programmer & Project Safety Announcement
이 튜토리얼 시리즈 전반에 걸쳐 많은 양의 추상화를 목격하게 될 것입니다. 다른 것과 마찬가지로 필요한 것을 골라 선택하고 나머지는 무시하세요.
예를 들어, 앱을 계층으로 분리하고
Exception
대신Failure
를 사용하고 싶지만 검증된ValueObject
(이번 강의에서 이에 대해 설명하겠습니다)를 활용하는 대신 일반적인 방법으로 데이터의 유효성을 검사할 수 있습니다.프레젠테이션 계층
에서 사용자가 입력하는 대로 전달한 다음 익숙한 대로String
과int
를 전달합니다.이 튜토리얼 시리즈는 DDD를 통해 무엇을 할 수 있는지 보여주기 위해 도메인 기반 설계를 종교적으로 따릅니다. 과잉이라고 생각되는 것을 선택하는 것은 모두 당신에게 달려 있습니다. 최종적으로 앱을 어떻게 구축하기로 결정했는지에 관계없이 도메인 중심 설계를 아는 것은 새로운 각도에서 문제를 볼 수 있는 더 나은 프로그래머가 될 것입니다.
이메일과 비밀번호를 사용하여 어떻게 로그인할 수 있나요? 일반적인 방법은 입력된 String
의 유효성을 검사하는 로그인 양식을 갖는 것입니다. 이메일 주소에는 '@' 기호가 있어야 하며 비밀번호는 6자 이상이어야 합니다. 그런 다음 이러한 String
을 인증 서비스(이 경우 Firebase Auth)에 전달합니다.
물론, 이것은 완벽하게 가능하지만 한 가지 중요한 사실을 깨달아야 합니다! 두 개의 매개변수를 받는 함수가 있다고 가정해 봅시다.
Future<void> signIn({
String email,
String password,
}) async {
// Sign in the user
}
다음 인수를 사용하여 이 함수를 호출하는 것이 합리적입니까?
signIn(email: 'pazzwrd', password: 'email@example.com');
물론 그렇지 않습니다. 하지만 비밀번호를 기대하는 매개변수에 이메일 주소를 전달하는 것을 막는 것은 무엇입니까? 결국 그것들은 모두 String
입니다.
가장 먼저 할 수 있는 일은 EmailAddress
와 Password
에 대한 간단한 클래스를 만드는 것입니다. 지금은 두 클래스를 다룰 필요가 없도록 EmailAddress
에만 집중하겠습니다. 어쨌거나, 우리는 대부분 domain/auth 폴더 안에서 작업할 것입니다. 확실하지 않을 때마다 GitHub 저장소를 확인하세요.
import 'package:meta/meta.dart';
class EmailAddress {
final String value;
const EmailAddress(this.value) : assert(value != null);
String toString() => 'EmailAddress($value)';
bool operator ==(Object o) {
if (identical(this, o)) return true;
return o is EmailAddress && o.value == value;
}
int get hashCode => value.hashCode;
}
이는 일반 String
보다 훨씬 더 표현력이 뛰어나며 null인지 아닌지를 즉시 확인합니다. 또한 value equality 수행하기 위해 == 연산자를 오버라이딩하고 합리적인 출력을 얻기 위해 toString()
메서드도 재정의합니다.
하지만 이와 같은 클래스는 확실히 이상적이지 않습니다. EmailAddress
인스턴스가 생기자마자 Password
를 기대하는 함수에 실수로 전달할 수 없습니다. 그들은 두 가지 다른 유형입니다. 하지만 지금 우리가 할 수 있는 일은 다음과 같습니다.
void f() {
const email = EmailAddress('pazzwrd');
// Happily use the email address
}
보시다시피, 우리는 한 가지 문제를 피하고 또 다른 문제를 얻었습니다. EmailAddress
의 인스턴스는 생성자에 String
을 기꺼이 받아들이고 EmailAddress
가 나타내는 "contract"을 이행하지 않으면 아무 일도 일어나지 않은 것처럼 가장합니다. 이것이 바로 우리가 validated value object를 생성하려는 이유입니다.
아마도 TextFormField
에서 String
의 유효성을 검사하는 데 익숙할 것입니다. (그렇지 않고 아직 여기에 계시다면 이 시리즈는 적합하지 않습니다. 기본 사항을 익힌 후 다시 오십시오.) TextFormField
에 유효한 값이 없으면 잘못된 값을 가지고 Form
을 저장하거나 진행할 수 없습니다.
우리는 이 원칙을 완전히 다른 수준으로 끌어올릴 것입니다. 알다시피, 모든 검증이 동일하지는 않습니다. 우리는 그들 모두에 대해 가장 안전한 검증을 수행하려고 합니다. 규칙에 부합하지 않는 상태가 나타날 수 없게 만들 것입니다. 즉, EmailAddress
와 같은 클래스가 TextFormField
에 있는 동안뿐만 아니라 전체 수명 동안 잘못된 값을 보유하는 것이 불가능하도록 만듭니다.
이 코스에서 모든 내용을 다루긴 하겠지만 Scott Wlaschin이 F# 언어로 이 주제를 다룬 기사를 확인하는 것을 추천합니다. 이것들은 나에게 큰 도움이 되었습니다.
인스턴스화 시 유효성을 검사하는 가장 직관적인 방법은 무언가가 제대로 재생되지 않는 경우 Exception
을 발생시켜 유효성 검사 논리를 수행하는 factory
생성자를 만든 다음 마지막으로 프라이빗 생성자를 호출하여 EmailAddress
를 인스턴스화하는 것입니다.
class EmailAddress {
final String value;
factory EmailAddress(String input) {
assert(input != null);
return EmailAddress._(
validateEmailAddress(input),
);
}
const EmailAddress._(this.value);
// toString, equals, hashCode...
}
String validateEmailAddress(String input) {
// Maybe not the most robust way of email validation but it's good enough
const emailRegex =
r"""^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+""";
if (RegExp(emailRegex).hasMatch(input)) {
return input;
} else {
throw InvalidEmailException(failedValue: input);
}
}
class InvalidEmailException implements Exception {
final String failedValue;
InvalidEmailException({ this.failedValue});
}
우리는 확실히 어딘가에 가고 있습니다. 잘못된 이메일 문자열을 EmailAddress
퍼블릭 factory 생성자에 전달하면 InvalidEmailException
이 발생합니다. 그렇습니다. 우리는 규칙에 어긋난 상태를 대표할 수 없게 만듭니다.
하지만 솔직히 말해서, 예외를 던지는 것이 유효하지 않은 값이 validated value objects 내에 유지되는 것을 방지할 수 있는 유일한 방법이라면 이 코스가 제작되진 않았을 것이기 때문에 여러분은 이 게시물을 읽지도 않았을 것입니다. 왜냐구요? 하나의 EmailAddress
를 인스턴스화하기 위해 무엇을 해야 하는지 살펴보겠습니다.
void f() {
try {
final email = EmailAddress('pazzwrd');
} on InvalidEmailException catch (e) {
// Do some exception handling here
}
// If you have multiple validators, remember to catch their exceptions too
}
그렇습니다, 이런 방향으로는 가면 안 됩니다. 검증된 값 객체를 인스턴스화하는 모든 곳에서 이 괴물을 생성하는 것은 금세 고통스럽고 유지 관리할 수 없는 경험이 될 것입니다.
현재 문제는 EmailAddress
클래스가 String
유형의 단일 필드만 보유한다는 사실에서 비롯됩니다. InvalidEmailException
을 발생시키는 대신 클래스 내부에 저장한다면 어떻게 될까요? 그리고 우리는 색다른 방식으로 예외를 사용하고 싶지 않기 때문에 평범하고 오래된 InvalidEmailFailure
클래스를 만들 것입니다.
이렇게 하면 인스턴스화 시 try
및 catch
문으로 코드베이스를 어수선하게 만들지 않을 수 있습니다. 하지만 우리는 여전히 EmailAddress
를 사용할 때 유효하지 않은 값을 처리해야 합니다. 어딘가에서 처리해야겠죠?
그러나 우리는 failure
같은 두 번째 클래스 필드를 생성하고 싶지 않습니다. 제 말은, EmailAddress
를 사용한 모든 곳에 다음을 작성하는 것을 기억하시겠습니까? 그리고 더 중요한 것은, 이 코드가 시행되지 않는다면 굳이 이 코드를 작성하시겠습니까?
void insideTheUI() {
EmailAddress emailAddress;
// ...
if (emailAddress.failure == null) {
// Display the valid email address
} else {
// Show an error Snackbar
}
}
위의 코드는 솔직히 끔찍합니다. 누락된 값을 나타내기 위해 null을 사용합니다. 이는 재난의 비결입니다. union 타입을 사용하여 value
필드와 failure
필드를 하나로 결합하면 어떻게 될까요? 그리고 다른 종류의 union이 아니라 Either
를 사용할 것입니다.
Either
는 우리가 "failures"라고 부르는 것을 처리하는 데 특별히 적합한 dartz 패키지의 union type입니다. 이는 일반적으로Left
와Right
라고 하는 두 값의 합집합입니다. left에는Failure
가 있고 오른쪽에는 올바른 값(예:String
)이 있습니다.
추가적으로, Failures
에 대해서도 union 타입을 도입하고 싶습니다. 현재 유효하지 않은 이메일 주소를 나타내는 "ValueFailure
"는 하나만 있지만 이 강의 전반에 걸쳐 더 많은 오류가 발생할 예정입니다. 여기에서도 union은 ValueFailure
의 가능한 모든 "케이스"를 잊지 않도록 도와줄 것입니다.
일단, 우리는 Either
에 대해 dartz를 사용할 것입니다. 그러나 일반적인 union은 어떻습니까? Dart가 언어 자체에 대수 데이터 유형(?, algebric data types)을 도입할 때까지 선택할 수 있는 여러 옵션이 있습니다. 가장 좋은 방법은 freezed 패키지를 사용하는 것입니다. 이를 pubspec.yaml에 추가하고 freezed는 코드 생성을 사용하므로 다른 종속성도 많이 추가하겠습니다.
dependencies:
flutter:
sdk: flutter
dartz: ^0.9.0-dev.6
freezed_annotation: ^0.7.1
dev_dependencies:
build_runner:
freezed: ^0.9.2
EmailAddress
클래스로 다시 돌아가기 전에 먼저 앞서 언급한 통합을 위해 InvalidEmailException
을 버리겠습니다. validated value object의 모든 실패를 하나의 union(ValueFailure
)으로 그룹화합니다. 이는 기능 전반에 걸쳐 공통적인 것이므로 domain/core 폴더 내에 failures.dart 파일을 생성하겠습니다. 그 동안 "짧은 비밀번호" 실패도 만들어 보겠습니다.
freezed로 수행할 수 있는 다른 모든 작업에 대해 알아보려면 공식 문서나 이전 버전에 대한 저의 튜토리얼을 확인하세요.
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
part 'failures.freezed.dart';
abstract class ValueFailure<T> with _$ValueFailure<T> {
const factory ValueFailure.invalidEmail({
T failedValue,
}) = InvalidEmail<T>;
const factory ValueFailure.shortPassword({
T failedValue,
}) = ShortPassword<T>;
}
앞으로의 강의에서
String
이외의 값도 검증해야 하기 때문에 클래스를 generic 클래스로 만들었습니다.
EmailAddress
가 가질 수 있는 값은 더 이상 단순한 String
이 아닙니다. 대신, Either<ValueFailure<String>, String>
이 됩니다. verifyEmailAddress
함수의 반환 유형도 동일합니다. 하지만, 예외를 throw 하는 대신 Either
의 left
을 return
하겠습니다.
class EmailAddress {
final Either<ValueFailure<String>, String> value;
factory EmailAddress(String input) {
assert(input != null);
return EmailAddress._(
validateEmailAddress(input),
);
}
const EmailAddress._(this.value);
// toString, equals, hashCode...
}
Either<ValueFailure<String>, String> validateEmailAddress(String input) {
const emailRegex =
r"""^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+""";
if (RegExp(emailRegex).hasMatch(input)) {
return right(input);
} else {
return left(ValueFailure.invalidEmail(failedValue: input));
}
}
이제 EmailAddress
객체 내부에 있는 값을 표시해도 의심의 여지가 없습니다. 우리는 기분이 좋든 싫든 발생할 수 있는 ValueFailure
를 처리하기만 하면 됩니다.
void showingTheEmailAddressOrFailure(EmailAddress emailAddress) {
// Longer to write but we can get the failure instance
final emailText1 = emailAddress.value.fold(
(left) => 'Failure happened, more precisely: $left',
(right) => right,
);
// Shorter to write but we cannot get the failure instance
final emailText2 =
emailAddress.value.getOrElse(() => 'Some failure happened');
}
EmailAddress
가 구현되었으며 여기에는 toString
, ==
및 hashCode
오버라이드에 대한 많은 보일러플레이트 코드가 포함되어 있습니다. 우리는 이 모든 것을 Password
클래스에 복제하고 싶지 않습니다. 이것은 슈퍼 클래스를 만들 수 있는 절호의 기회입니다.
이 추상 클래스는 여러 feature에 걸쳐 특정 값 객체를 확장합니다. 추상 클래스를 domain/core 아래에 생성하겠습니다. 그것이 하는 일은 보일러 플레이트를 한곳으로 추출하는 것뿐입니다. 물론 우리는 value
가 모든 유형이 되도록 허용하기 위해 generic에 크게 의존합니다.
abstract class ValueObject<T> {
const ValueObject();
Either<ValueFailure<T>, T> get value;
bool operator ==(Object o) {
if (identical(this, o)) return true;
return o is ValueObject<T> && o.value == value;
}
int get hashCode => value.hashCode;
String toString() => 'Value($value)';
}
이제 EmailAddress
에서 이 클래스를 확장할 수 있습니다. 지금은 그다지 나쁘지 않죠?
class EmailAddress extends ValueObject<String> {
final Either<ValueFailure<String>, String> value;
factory EmailAddress(String input) {
assert(input != null);
return EmailAddress._(
validateEmailAddress(input),
);
}
const EmailAddress._(this.value);
}
또 다른 클래스와 유효성 검사 기능을 만들기 전에 먼저 파일을 정리하겠습니다. feature-specific한 value object는 해당 도메인 feature 폴더 내에 있을 것입니다. EmailAddress
및 Password
의 경우 domain/auth에 있을 것입니다.
유효성 검사 기능에 관해서는 모든 기능을 domain/core 아래의 단일 파일에 넣는 것을 좋아합니다.
우리의 경우 Password
에 대한 유효성 검사 로직은 매우 간단합니다. 비밀번호 input에 대한 길이를 판단할 뿐입니다.
Either<ValueFailure<String>, String> validateEmailAddress(String input) {
// Already implemented
}
Either<ValueFailure<String>, String> validatePassword(String input) {
// You can also add some advanced password checks (uppercase/lowercase, at least 1 number, ...)
if (input.length >= 6) {
return right(input);
} else {
return left(ValueFailure.shortPassword(failedValue: input));
}
}
Password
클래스는 유효성 검사를 제외하고 EmailAddress
와 거의 동일합니다.
class EmailAddress extends ValueObject<String> {
// Already implemented
}
class Password extends ValueObject<String> {
final Either<ValueFailure<String>, String> value;
factory Password(String input) {
assert(input != null);
return Password._(
validatePassword(input),
);
}
const Password._(this.value);
}
겨우 두 개의 value object를 검증했는데 꽤 오랜 시간이 걸렸죠? 사실은 Dart에서 "illegal state가 나타날 수 없게 만드는" 최상의 솔루션을 찾는 전체 과정을 안내했기 때문입니다. ValueObject
슈퍼 클래스를 만들고 무슨 일을 하고 있는지 안다면 validated TodoName
과 같은 항목을 만드는 데는 몇 분도 걸리지 않습니다.
평범한 String
이 될 장소에 이러한 특정한 valie objects를 두는 것의 가장 좋은 점은 아무리 노력해도 엉망이 될 수 없다는 것입니다. 우리는 우리를 안내하기 위해 Dart 타입 시스템을 사용하고 있습니다.
다음 부분에서는 UI
와 인증 백엔드
를 함께 연결하는 애플리케이션 계층에 코드를 작성하겠습니다. 왜 Firebase Auth라고 말하지 않았을까요? 여러분이 상상할 수 있듯이, 우리는 추상화를 사용할 것입니다!