
지난 게시글에서 도메인 기반 Entity 설계를 했고 이에 대한 Trade-off를 분석해보며 Presentation Layer 에서 도메인의 Entity 에 의존적 이라는 문제점을 보완하기 위해 Param 에 대한 개요 정도 언급을 하였다. 간단하게 Param의 정의를 다시 리마인드하자면 비즈니스 로직은 없고 UI 로부터 수집한 데이터를 저장 용도로 만든 객체인데, 이번 게시글에서는 해당 Param 을 이용하여 위의 문제점을 어떻게 보완했는지, 이번 아키텍처에서 Trade-off는 무엇인지, 추가적으로 보완해야 할 점이 무엇인지 등 다뤄보도록 하겠다.

Param을 도입한 클린 아키텍처에 대한 컴포넌트 다이어그램은 다음과 같다. '프로필 생성 과정'과 같이 화면으로부터 입력을 받는 예시에서는 Param 을 통해 내가 필요한 데이터들만 종합을 해서 수집을 하고 이를 Presentation 전용 Mapper 를 통해 Entity 로 변환한 뒤, Bloc 에서 Domain Layer에 있는 usecase 함수 파라미터에 해당 Entity 를 사용할 수 있다.
이전 게시글에서 Domain 기반 Entity 설계의 단점으로
- Thread safe 하지 않다. -> Entity가 가변객체기 때문에
- 해당 Entity 를 사용할 경우 매번 필드마다 null 체크가 필요하다. -> Entity가 가변객체기 때문에
- API Request 에 사용되는 데이터 필드 수가 Entity 가 가지고 있는 필드 수보다 적기 때문에 화면에서 입력받지 않아도 되는 데이터들에 대해서는 메모리 낭비이다.
- Presentation Layer 가 Domain Layer의 Entity 에 너무 의존적이다.
다음 4가지를 언급했는데 Param을 도입함으로써 Entity 를 불변 객체로 사용할 수 있고, 이전 설계보다 Presentation Layer 에서 Domain Layer의 Entity 의 의존성을 줄일 수 있는 효과가 있다.
백문이 불여일견. 우선 코드로 알아보자.
class ProfileCreateParam {
/// 입력 받아야 하는 13개 필드들
final bool traveler;
final bool male;
final int residenceYear;
final String birth;
final String nickname;
final String residenceCountryCode;
final String residenceStateCode;
final String residenceCityCode;
final String introduction;
final String profilePhotoUrl;
final String backgroundPhotoUrl;
final List<String> interests;
final List<String> expertises;
/// 불변 객체로 설계 (기본값 설정)
const ProfileCreateParam({
this.traveler = false,
this.male = false,
this.residenceYear = 0,
this.birth = "",
this.nickname = "",
this.residenceCountryCode = "",
this.residenceStateCode = "",
this.residenceCityCode = "",
this.introduction = "",
this.profilePhotoUrl = "",
this.backgroundPhotoUrl = "",
this.interests = const [],
this.expertises = const [],
});
/// `copyWith()`을 활용한 값 변경 (기존 값 유지)
ProfileCreateParam copyWith({
bool? traveler,
bool? male,
int? residenceYear,
String? birth,
String? nickname,
String? residenceCountryCode,
String? residenceStateCode,
String? residenceCityCode,
String? introduction,
String? profilePhotoUrl,
String? backgroundPhotoUrl,
List<String>? interests,
List<String>? expertises,
}) {
return ProfileCreateParam(
traveler: traveler ?? this.traveler,
male: male ?? this.male,
residenceYear: residenceYear ?? this.residenceYear,
birth: birth ?? this.birth,
nickname: nickname ?? this.nickname,
residenceCountryCode: residenceCountryCode ?? this.residenceCountryCode,
residenceStateCode: residenceStateCode ?? this.residenceStateCode,
residenceCityCode: residenceCityCode ?? this.residenceCityCode,
introduction: introduction ?? this.introduction,
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
backgroundPhotoUrl: backgroundPhotoUrl ?? this.backgroundPhotoUrl,
interests: interests ?? List.from(this.interests),
expertises: expertises ?? List.from(this.expertises),
);
}
}
Param 은 다음과 같이 불변 객체로 설계할 수 있다. Entity 는 불변 객체를 사용하기 때문에 Param 은 가변 객체로 설정해도 되긴 하지만, 이전 게시글에서 '가변 객체 설계 방식의 문제점'에 대해 언급한 바가 있기 때문에 마찬가지로 불변 객체로 설계하였다. 또한 해당 코드에서 생성자 부분을 보면 타입 별 기본 값으로 초기화를 했는데, 이는 개발자 취향 차이인 것 같다. 필자처럼 (1) 생성자에 기본값으로 초기화 하거나, (2)required this.필드 를 통해 생성단계에서 초기화 하는 방법이 있는데, 개인적으로 전자의 방법이 좀 더 편해서 이를 채택했다. 후자의 방법일 경우 프로필 생성 과정을 예시로 총 4개의 화면에서 13개의 데이터 입력을 받아야 하는데, 각 UI 별로 입력값을 저장할 변수가 필요하고, 화면이 넘어갈 때마다 수집했던 데이터들을 모두 넘겨줘야 한다. 이러한 방법보다는 UI에서 수집하면 바로 해당 param 의 필드를 변경하고 화면마다 해당 객체를 넘겨주는 방식이 더 편리하게 다가왔다.
그러나 Update 인 경우는 다르다. 보통 Update 화면에서는 서버에서 데이터를 불러오거나 이전 화면에서 기존 데이터를 저장하고 있는 객체를 넘겨주기 때문에 후자의 방법을 통해 param 을 초기에 생성해주고 생성자에 해당 객체의 필드들로 초기화를 하는 방법이 더 간편하다.
/// class ProfileCreateView 생략
...
class _ProfileCreateViewState extends State<ProfileCreateView> {
/// 다른 변수들 생략
...
/// Data Field -> Param
ProfileCreateParam param = ProfileCreateParam();
/// 해당 화면에서 사용할 bloc
late ProfileCreateBloc profileCreateBloc;
void initState() {
profileCreateBloc = BlocProvider.of<ProfileCreateBloc>(context);
...
}
void dispose() {
profileCreateBloc.close();
...
}
Widget build(BuildContext context) {
...
return getBodyWidget();
}
/// Scaffold 의 body 에 호출될 메소드
Widget getBodyWidget() {
...
/// 자기소개
IntroductionTextField(
...
onChanged: (value) {
/// introduction 값 업데이트
setState(() {
param = param.copyWith(introduction: value);
});
},
),
...
/// 프로필 설정 완료 버튼 => 버튼 클릭 시 API 호출
TextButton(
onPressed: () => requestProfileCreate(),
child: ...
)
}
/// 다른 함수들 생략
...
/// Bloc에 이벤트 Dispatch
void requestProfileCreate() {
profileCreateBloc.add(CreateProfile(param: param);
}
}
다음은 프로필 생성 화면을 이루는 코드이다. 여기서 IntroductionTextField 라는 위젯을 통해 입력값을 받게 되는데 이 때 Param 의 copyWith() 를 통해 값을 업데이트할 수 있고, 만일 필수 입력값들이 모두 수집된 상태에서 '프로필 설정 완료' 버튼을 눌렀을 때 requestProfileCreate()를 호출함으로써 해당 param을 Bloc에 전달할 수 있다. 이와 같이 사용했을 때 별도의 Request 용 Entity 를 생성하거나 Domain 기반 Entity 를 활용할 필요 없이, 필요한 데이터들만 수집할 수 있는 객체인 Param을 이용하면 이전 게시글들에서 발생했던 문제를 해결할 수 있다.
class ProfileCreateBloc extends Bloc<ProfileCreateEvent, ProfileCreateState> {
/// UseCase 의존성 주입
final createProfileUseCase = serviceLocator<CreateProfileUseCase>();
...
/// View 전용 Mapper
final mapper = ViewProfileMapper();
ProfileCreateBloc() : super(ProfileCreateInitial()) {
on<CreateProfile>(_createProfileRequested);
...
}
Future<void> _createProfileRequested(
CreateProfile event, Emitter<ProfileCreateState> emit) async {
emit(ProfileCreateLoading());
...
/// Mapper: Param -> Entity 로 변환 이후 use case 함수 호출
final result
= await createProfileUseCase.execute(mapper.mapCreateParamToEntity(event.param));
result.when(success: (data) {
return emit(ProfileCreateSuccess(data));
}, failure: (error) {
return emit(ProfileCreateFailure(error));
});
}
...
}
해당 Bloc 에서는 CreateProfileUseCase 의존성 주입을 받아 해당 함수를 호출하는데, 위의 화면에서 수집했던 Param 을 Event 를 통해 전달받고 Mapper 를 통해 Entity 로 변환하여 함수를 호출하는 형식이다. 여기서 등장하는 Mapper 는 '화면 전용 Mapper'로, Param 을 Entity 로 변환하는 역할을 한다.
단, 이 때 int userId 와 같이 ProfileCreateParam 에 없는 필드의 경우 default 값을 넣어주면 된다. 해당 데이터의 경우 서버로부터 받아와야 하는 데이터기 때문에 생성 요청을 보내는 유스케이스의 경우 해당 데이터에 default 값을 넣어도 비즈니스 로직에는 전혀 지장을 주지 않는다.
class ViewProfileMapper {
/// [Profile] 도메인 기반 Entity
Profile mapCreateParamToEntity(ProfileCreateParam param)
=> Profile(
residenceCountryCode: param.residenceCountryCode,
residenceStateCode: param.residenceStateCode,
residenceCityCode: param.residenceCityCode,
...
/// Default 값 할당하기
userId: -1,
hostValue: 0.0,
guestValue: 0.0,
...
);
...
}
위의 과정을 통해 Presentation Layer 에서 Param 이 다음과 같이 사용 될 수 있음을 알아보았다. 이로써 Param 은 비즈니스 로직은 없고 UI 로부터 수집한 데이터를 저장 용도로 만든 객체이며 지난 회고록(클린 아키텍처 회고록(2))에서 언급했던 Domain 기반의 Entity 를 Presentation Layer 에서 데이터 수집용으로 이용하는 것보다 더 효율적이고 명확한 설계 방식임을 알 수 있다.
이는 몇 가지 중요한 이점을 제공한다:
- 의존성 감소
: Param 객체를 도입함으로써 Presentation Layer가 Domain Layer의 Entity에 직접적으로 의존하지 않게 된다. 이는 아키텍처의 계층 간 결합도를 낮추어 유지보수성과 확장성을 높여준다.- 유연성 향상
: 화면에서 필요한 데이터만을 수집하고 관리할 수 있기 때문에, UI 요구사항이 변경되더라도 Domain Entity에 불필요한 영향을 주지 않는다. 이는 특히 다양한 입력 양식이나 화면 단계가 존재하는 복잡한 폼 구조에서 유리하다.- 메모리 효율성
: Entity가 가지고 있는 모든 필드를 메모리에 유지할 필요 없이, 화면에 필요한 데이터만을 수집하기 때문에 메모리 낭비를 줄일 수 있다. 특히, 대규모 데이터를 다루거나 다양한 입력 필드가 있는 경우 이점이 더욱 크다.- 불변성 유지 및 안전성 확보
: Param 객체도 불변 객체로 설계함으로써 데이터의 일관성과 안정성을 보장할 수 있다. 이는 특히 상태 관리(예: Bloc)에서 예기치 않은 값 변경으로 인한 버그를 방지하는 데 도움이 된다.
그러나 이러한 설계 방식에도 몇 가지 단점이나 고려해야 할 점이 존재한다.
- Mapper 관리의 복잡성
: Param → Entity 변환을 위한 별도의 Mapper 클래스를 작성해야 하므로 코드의 양이 증가하고 관리 포인트가 늘어난다. 특히 Entity의 필드가 복잡해질수록 Mapper 코드도 점차 복잡해질 수 있다.
: Param -> Entity 를 변환하는 View 전용 Mapper, Model <-> Entity 변환하는 Data layer 에서 사용하는 Mapper 총 두 개가 필요하다.- 중복 코드 가능성
: 비슷한 형태의 Param과 Mapper가 여러 개 만들어질 경우, 코드의 중복이 발생할 수 있다. 이를 해결하기 위해 공통된 로직을 추출하거나, 제너릭 기반의 변환 유틸리티를 만들어 관리하는 것이 필요하다.- 테스트 범위 증가
: 새로운 계층(Param, Mapper)이 도입되면서 테스트해야 할 범위가 넓어진다. 각각의 Mapper 변환 로직에 대한 단위 테스트를 작성해야 하며, 이는 개발 리소스를 추가로 요구한다.
결론적으로, Param 객체의 도입은 클린 아키텍처에서 Presentation Layer와 Domain Layer의 의존성을 줄이는 효과적인 방법이며, 복잡한 입력 처리나 데이터 수집에 있어 명확하고 직관적인 흐름을 제공한다. 앞으로의 개발에서는 이 구조를 기반으로 확장성과 유지보수성을 더욱 강화할 수 있는 방법들을 지속적으로 고민해야 할 것이다.