[Flutter] #3. 프로젝트 기본 구조 - Clean architecture

Evey
jumpit
Published in
25 min readMay 18, 2022

앞서 점핏 앱은 클린 아키텍쳐의 개념을 참고하여 개발되었다고 하였는데, 이 글의 내용은 클린 아키텍쳐가 어떻게 우리 프로젝트에 이롭게 작용하는지, 때론 타협하거나 포기하고 싶지만 왜 그러지 않는 것이 나은지에 대한 이야기들이 될 것입니다.

이 그림은 많이 보셨을 겁니다.

로버트C마틴의 blog.cleancoder.com 이나, 그의 저서인 Clean Architecture 책에서도 볼 수 있는 그림입니다.

비즈니스 수행의 흐름은 바깥 원 계층을 타고 안쪽으로 들어오고, 다른 방향으로 다시 나가는 과정을 오브젝트 레이어로 구성하게 되는데,

그림의 중첩된 원에서 “안쪽 계층은 바깥 계층의 행동을 몰라야 한다” 라는 것이 주요한 논지입니다.

여기서 “몰라야 한다” 라는 말에 두 가지 의미가 있는데,

첫번째로 정말 바깥 원의 요소가 뭐가 됐든 상관이 없어야 한다는 의미가 있고,

두번째로는 규약만 동일하다면 다른 모듈로 교체가 가능하다..

라는 의미이기도 합니다.

즉, 우리가 단지 클래스 인스턴스 소유(has)관계만 바라보고 설계할 때에는 dependency의 방향이 그림대로 흘러가긴 쉽지 않은데,

interface를 이용한 의존성 역전을 일으킨다면 화살표 방향 대로의 dependency에 위배 되지는 않도록 끊어내는 게 가능할 수 있습니다.

이렇게 엔티티를 관통하는 흐름을, 앞서 설명한 MVVM구조의 모바일 앱에 대입해 보면 이런 형태가 됩니다.

1. Domain

Domain 계층은 비즈니스를 구성하는 주요한 규칙 들이고, 아주 작고 단순한 코드들로 구성되어 있지만, 그 중요도는 다른 계층의 그것에 못지 않은 것입니다.

만일 서버가 불필요하게 잡다한 데이터를 보내주어도, 혹은 UI가 비즈니스 룰에 비해 매우 복잡한 것 같더라도, Domain계층에는 영향을 주지 않습니다.

서비스의 전제가 되는 핵심적인 데이터를 Entity로, 비즈니스의 핵심적인 기능을 UseCase로 단순화하여 기술 함으로써 우리는 작고 확고한 규칙에서 부터 서비스를 파생시켜 나갈 수 있습니다.

  • Entity : 비즈니스 최소 자원
class LoginEntity {
bool isSuccess;
String message;
String accessToken;
String refreshToken;

LoginEntity({
required this.isSuccess,
required this.message,
required this.accessToken,
required this.refreshToken,
});
}
  • UseCase : 비즈니스 최소 기능
class LoginUseCaseImpl implements UseCase {
LoginRepository loginRepository;

LoginUseCaseImpl(this.loginRepository);

@override
Future perform(Map<String, dynamic> param) async {
LoginEntity entity = await loginRepository.getLogin(param);
return entity;
}
}
  • Repository : Data Layer로부터 Entity를 받을 수 있도록 규약된 인터페이스
abstract class LoginRepository {
RemoteDataSource dataSource;

LoginRepository(this.dataSource);

Future<LoginEntity> getLogin(Map<String, dynamic> param);
}

2. Data

Data 계층은 주로 짧게 보면 서비스 주체와 맞닿는 DataSource(static variable, storage, API, DB 뭐든 될 수 있음), 그리고 DataSource에 요청되는 각종 메소드의 목록인 Repository의 구현체로 구성되었습니다.

Domain 계층은 바깥의 인터페이스에 대해 아는 바가 없어야 합니다.

그러므로 Repository가 수행한 동작의 결과가 UseCase로 리턴 되기 전에, Repository의 구현체가 외부를 통해 가져온 데이터를 VO를 통해 Entity형식으로 데이터를 가공해 리턴 해 주어야 합니다.

  • RepositoryImpl : Repository의 구현체로, 구현체는 Data layer에 존재하며 VO의 규격을 알고 있다. Entity를 반환해 준다.
class LoginRepositoryImpl implements LoginRepository {
@override
RemoteDataSource dataSource;

LoginRepositoryImpl(this.dataSource);

@override
Future<LoginEntity> getLogin(Map<String, dynamic> param) async {
var res = await dataSource.request(HttpMethod.POST, ApiUrl.LOGIN, param);

var responseModel =
LoginPostVO.fromJson(json.decode(utf8.decode(res.bodyBytes)));

LoginEntity entity = LoginEntity(
isSuccess: responseModel.status == 200,
message: responseModel.message ?? "",
accessToken: responseModel.data?.accessToken ?? "",
refreshToken: responseModel.data?.refreshToken ?? "",
);
return entity;
}
}
  • RemoteDataSource : 원격저장소에서 데이터를 받아오는 기능들의 규칙이 되는 interface.
enum HttpMethod { POST, PUT, GET, DELETE }

abstract class RemoteDataSource {
Future<http.Response> request(
HttpMethod httpMethod,
String path,
Map<String, dynamic> param,
);
}
  • VO (value object) : API와의 데이터 전송 규격. 점핏 서비스는 request 전송 규격이 Map to query string 혹은 Map to JSON으로 단일 규격화 되어 있어 ResponseVO만 기술합니다.
class LoginPostVO {
String? message;
int? status;
Data? data;

LoginPostVO({this.message, this.status, this.data});

LoginPostVO.fromJson(Map<String, dynamic> json) {
message = json['message'];
status = json['status'];
data = json['data'] != null ? new Data.fromJson(json['data']) : null;
}

Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['message'] = this.message;
data['status'] = this.status;
if (this.data != null) {
data['data'] = this.data!.toJson();
}
return data;
}
}

class Data {
String? accessToken;
String? refreshToken;

Data({this.accessToken, this.refreshToken});

Data.fromJson(Map<String, dynamic> json) {
accessToken = json['accessToken'];
refreshToken = json['refreshToken'];
}

Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['accessToken'] = this.accessToken;
data['refreshToken'] = this.refreshToken;
return data;
}
}
  • HttpRequest : RemoteDataSource의 구현체로, HTTP통신을 통해 데이터를 보내고 응답을 수신한다.
class HttpRequest implements RemoteDataSource {
@override
Future<Response> request(
HttpMethod httpMethod, String path, Map<String, dynamic> param) async {
Map<String, String> header;
header = {"Content-Type": "application/json"};

late http.Response res;
try {
switch (httpMethod) {
case HttpMethod.POST:
res = await postRequest(path, param, header);
break;
case HttpMethod.PUT:
res = await putRequest(path, param, header);
break;
case HttpMethod.GET:
res = await getRequest(path, param, header);
break;
case HttpMethod.DELETE:
res = await deleteRequest(path, param, header);
break;
}
Log.d("http_log responseHeader << $path ${res.headers}");
Log.d("http_log responseBody << $path ${utf8.decode(res.bodyBytes)}");

return requestTail(
httpMethod,
res,
path,
param,
);
} catch (error) {
rethrow;
}
}
...중략
}

3. Presentation

Presentation 계층은 실제 사용자와 맞닿는 동작을 담당합니다.

View와 ViewModel이 이에 해당 하겠죠, 이 부분의 동작은 위에서 설명한 ViewModel의 역할과, UI가 주를 이룹니다.

대체로 개발 기간에서 가장 큰 일정을 차지하는 부분이기도 하죠.

  • View : 생명주기, 레이아웃, 이벤트 핸들링, 렌더링
class LoginScreen extends StatefulWidget {
const LoginScreen({Key? key}) : super(key: key);
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
// explain: View should be passive. ViewModel has business logic.
var loginViewModel = it<LoginViewModel>();
@override
void initState() {
Log.i("LoginScreen initState");
loginViewModel.viewState.stream.listen((event) {
...중략
});
loginViewModel.loadState(LoginViewState.START);
super.initState();
}
@override
void dispose() {
Log.i("LoginScreen dispose");
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xffcccccc),
body: Container(
width: getScreenWidth(context),
margin: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
...중략
),
);
}
Widget buildAutologinSwitch() {
return Row(
children: [
loginViewModel.isAutoLogin.ui(
builder: (context, autoLogin) => CustomSwitch(
value: autoLogin.data ?? false,
onChanged: (value) {
loginViewModel.onToggleAutoLogin();
})),
const SizedBox(width: 6),
const Text("auto login"),
],
);
}
Widget buildValidationText(BuildContext context) {
return SizedBox(
width: getScreenWidth(context),
child: loginViewModel.pwText.ui(
builder: (context, pw) => Text(
// passwordValidation(pw.hasData ? pw.data ?? "" : ""),
passwordValidation("${loginViewModel.pwText.val}"),
style: const TextStyle(color: Color(0xffff5555)),
),
),
);
}
Widget buildSignInButton() {
return InkWell(
onTap: () {
loginViewModel.signIn();
},
child: Container(
color: const Color(0xffccaacc),
width: 150,
height: 50,
child: const Center(child: Text("SIGN IN")),
),
);
}
}
  • ViewModel : 비즈니스로직 처리, observable 보유 및 갱신
enum LoginViewState {
START,
LOGIN_COMPLETED,
LOGIN_ERROR,
LOGIN_FAILED,
}
class LoginViewModel implements ViewModelInterface {
// section: view state
final viewState = ArcSubject<LoginViewState>();
@override
void loadState(state) {
Log.i("LoginViewModel loadState $state");
state = state as LoginViewState;
viewState.val = state;
}
@override
disposeAll() {
Log.i("LoginViewModel disposeAll");
viewState.close();
}
// section: autologin switch
var isAutoLogin = true.sbj;
onToggleAutoLogin() => isAutoLogin.val = !isAutoLogin.val; // section: textfiled input
var idText = "".sbj;
onChangedId(String str) => idText.val = str; var pwText = "".sbj; onChangedPw(String str) => pwText.val = str; // section: sign in
UseCase signInUseCase = it<LoginUseCaseImpl>();
signIn() async {
Log.i("LoginViewModel signIn");
Map<String, dynamic> param = {};
param['id'] = idText.val;
param['pw'] = pwText.val;
try {
LoginEntity result = await signInUseCase.perform(param);
if (result.isSuccess) {
result.refreshToken.sbj.save("refreshToken");
result.accessToken.sbj.save("accessToken")?.then((isSuccess) {
if (isSuccess) {
TemporaryData.isLogin = true;
loadState(LoginViewState.LOGIN_COMPLETED);
} else {
loadState(LoginViewState.LOGIN_ERROR);
Log.w("LoginViewModel signIn >>> get login error on save token");
}
});
} else {
loadState(LoginViewState.LOGIN_ERROR);
Log.w("LoginViewModel signIn >>> get login error from api");
}
} catch (error, stack) {
loadState(LoginViewState.LOGIN_FAILED);
Log.e(error, stack);
}
}
}

Dependency, 우리말로 의존성이라고 자주 명명되는 이것은, 객체지향에서 상당히 중시되는 개념 중 하나 입니다.

B라는 클래스의 변경이 A의 활동에 영향을 미친다, 그러면 A가 B에 의존성이 있다고 이야기 합니다.

위 자료에서 설명하듯 다이어그램으로 표시하는 관계유형은 6가지 정도인 것을 확인할 수 있습니다.

개발자에 따라서는 클래스 간 관계를 간략히 말할 때에는 association을 dependency와 퉁쳐 5가지로 보는 분도 있고, inheritance/realization, aggregation/composition을 각 is 와 has 하나씩 으로 퉁쳐 is, has, use 3가지로 축약하여 이야기를 하는 분도 있고, 심지어 일부 개발자 분들 중에는 has와 is 2가지로 다 설명 가능하다고 말씀하는 분들도 계십니다.

그럼 의존성의 방향은 어떻게 될까요? 각 관계별로 의존성의 방향만 보겠습니다.

이 그림에서 눈여겨 볼 것이 과연 무엇 일까요? 바로 상속과 구현 에서는 의존의 방향이 역전 된다는 것입니다.

이것을 의존성 역전이라고 부릅니다. 면접 단골 질문인 SOLID 원칙에서 나오는 바로 그 이야기 입니다.

우리는 이것을 아키텍쳐에 적용함으로써 각 계층 간의 관계를 어느정도 느슨하게 풀어낼 수 있습니다.

아키텍쳐 계층 간 의존도가 적다는 것은, A계층과 B계층을 아주 별개의 프로그램으로 보고 설계하는 것이 가능하며, 유사시 B를 다른 프로그램으로 교체를 하더라도 그로 인해 A가 변경 되어야만 하는 것은 아니라는 것을 의미합니다.

계층 간 의존도를 낮추고 모듈의 변경이 가능하도록 하는 예시를 들어 봅시다.

우리는 서버 API와의 HTTP통신에 Repository패턴을 사용하기 위해 각 Repository의 구현체인 RepositoryImpl이라는 클래스를 작성합니다.

이 RepositoryImpl은 통신을 위해 HttpRequest라는 클래스의 객체를 생성하고 사용합니다.

그러나 저는 유닛 테스트를 위해, 가짜 통신모듈인 MockHttpRequest를 사용하고 싶다고 가정해 보겠습니다.

이때 HttpRequest는 Repository와 강하게 의존관계가 형성되어 있기 때문에 HttpRequest대신 MockHttpRequest를 생성하도록 RepositoryImpl의 코드를 바꾸고, MockHttpRequest 의 메소드를 호출하도록 코드를 변경해야 합니다.

그래서 이 문제를 해결하기 위해, RepositoryImpl은 HttpRequest와 MockHttpRequest가 모두 따르는 공통 규칙인 RemoteDataSource 인터페이스를 통해 HttpRequest를 생성합니다.

인터페이스를 통해 의존성이 역전 되었으므로 HttpRequest 내부 구현이 변한다고 해서 RepositoryImpl의 코드가 변할 일은 없습니다.

심지어 HttpRequest가 MockHttpRequest로 교체된다고 하더라도 생성 코드 한줄만 변하면 됩니다.

이렇게 해서 하위 레이어의 잦은 변경에 견디는 구조가 어느 정도는 만들어 졌습니다.

그러나 여전히 RepositoryImpl은 HttpRequest를 생성해야 합니다.

RepositoryImpl과 HttpRequest의 관계는 미약하지만 남아있는 겁니다.

그러면 이 문제를 해결하기 위해 HttpRequest의 생성을 다른 객체에 위임해 봅시다.

첫번째 방법은 관계상 상위 레이어에 위임하는 겁니다.

그러나 Repository의 상위 레이어는 UseCase인데, 개발자는 UseCase가 의존성을 가지는 걸 허용하고 싶지 않습니다.

그래서 더 상위 레이어 까지 끌고 올라갑니다. ViewModel에서 생성을 해 보겠습니다.

UseCase useCase = UseCaseImpl(RepositoryImpl(HttpRequest()));

이렇게 생성자 에서의 의존성 주입을 통해 HttpRequest의 owner는 ViewModel이 되었습니다.

이제 RepositoryImpl 클래스와 HttpRequest 클래스는, RemoteDataSource 인터페이스 규약을 고쳐야 하지 않는 한 서로의 변경에 아무 영향이 없게 되었습니다.

그러나 이것도 어딘지 찜찜합니다.

결국 HttpRequest를 MockHttpRequest로 통째로 교체하기 위해서는, 업무적으로 크게 관련없는 레이어인 ViewModel의 코드가 단 한 줄 이라도 변경되어야 하는 상황이 오는 겁니다.

이럴 때 사용되는 것이 바로 DI tool이나 Service Locator입니다.

이들은 의존성을 관리하는 하나의 컴포넌트를 생성하고(DI 툴은 그마저도 빌드 과정에서 자동 작성) 거기로부터 필요한 클래스에 의존성을 주입해 주는 제 3자 역할을 해 줍니다.

이것을 이용하여 HttpRequest를 대신 생성하고, RepositoryImpl에 RemoteDataSource로써 주입 하였습니다.

이제 우리는 RepositoryImpl과 HttpRequest의 직접적 관계를 끊어내는 데 성공했습니다.

위 패턴을 각 레이어에 적용하면 이런 구조도가 나오게 됩니다.

이렇게 코드가 역할별로 잘 나누어 지면 무엇이 좋아질까요?

  1. 디자인 일정이 밀리는 상황
  • 기획 : 2/4 ~ 2/8
  • 디자인 : 2/10 ~ 2/14
  • API : 2/6 ~ 2/12

>> 디자인 일정 꼬였네? 못해도 14까지 기다릴까? (X)

>> Domain, Data, Presentation 레이어 순서대로 작업하면 되겠군? (O)

2. 서버 개발이 밀리는 상황

  • 기획 : 2/4 ~ 2/8
  • 디자인 : 2/8 ~ 2/12
  • API : 2/20 ~ 2/24

>> 아 레이아웃 각만 잡아놓고 24일까지 멍때려야 되나? (X)

>> Domain, Presentation 레이어 먼저 작업하고, Data layer는 API 설계 초안 나오는 대로 VO부터 넣어야겠다. RemoteDataSource를 상속한 MockHttpRequest를 의존성 주입하여 API 개발서버 배포 전까지 우리가 먼저 개발하고 있으면 되겠군? (O)

업무 진행을 바라보는 관점이 여유로워지고, 계획이 구체적으로 변한다.

“코드를 경영하라”

누군가 물었습니다.

“주니어 개발자와 시니어 개발자의 차이가 뭔가요?”

애초에 시니어가 무슨 기준이냐, 연차 높으면 시니어냐 부터 시작해서 여러 기준들이 있지만..

제 개인적인 의견을 말씀 드리자면, 시니어 개발자는 기획 리뷰를 받으며 이미 머리속으로 코드 다 나왔고, 주니어 개발자들과 함께 일정을 짜고 역할을 부여하여 이변이 없는 한 뚝딱뚝딱 만들어 준수할 수 있는 개발자를 말한다고 생각합니다.

그런데 그렇다고 해도, 그들의 역할은 이게 끝 일까요?

저는 누군가

“어떻게 하면 개발도 측정 가능하게 할 수 있을까요” 라고 물으면,

“코드를 경영하라” 고 합니다.

코드도 여러 역할들이 존재하고 리소스를 분배하는 회사라고 생각하고 경영하면 되는 겁니다.

우리의 리소스, 즉 개발자와 그들의 시간은 한정적이고 정량수치화가능한 부분입니다.

그런데 앞서 설명한 아키텍쳐적인 구분이 없다면, 그들에게 측정가능한 업무와 역할을 부여하는 것이 어렵습니다.

통상적인 업무분배에서 아무리 크더라도 같은 기능단위를 여럿이 작업하기 어려운 이유 중 하나는, B를 변경하면 A도 변경해야 하는 코드를 짜고 있기 때문입니다.

무자비한 conflict와 잦은 코드 리뷰를 감수해야 하기 때문에 결국 피하게 되는 부분이, 아키텍쳐의 변경으로 어느 정도 해소가 될 수 있습니다.

개발 부서 외 타 부서와의 협업 문제에서도 그러합니다.

코드를 회사 내 조직의 역할에 대응시키면, 각 코드의 역할별로 이슈가 발생하거나 변경이 필요할 때에 누구와 의사결정을 논해야 하는지 명확해 집니다.

예를 들어 기획자와 디자이너는 모든 Domain 규칙(최소 기능, 최소 데이터)을 알고 있습니다.

그러나 그들이 과연 서버에서 오는 API response 규격까지 알아야 할까요? 그렇지는 않을 겁니다.

그것과 비슷하게 코드에서 사용되는 데이터에도 제약을 걸어 보는 겁니다.

Data layer위로 다시 데이터가 리턴(혹은 콜백)되어 타고 올라갈 때에는 Entity로 형변환을 하고 View에서 VO를 직접적으로 사용하지 않도록 분리하는 겁니다.

나 혼자 짤 거지만, 마치 각 계층별로 다른 사람이 짤 것처럼 말이죠.

Entity는 VO와 다른 독립적인 개념 입니다.

많은 clean architecture 예제에서 VO가 Entity를 상속받도록 하는 이유는 데이터 뎁스가 깊어질 경우 Entity 형변환의 피로도 때문일 겁니다.

이 부분에 대한 결정은 모바일 조직의 방향성이 Domain driven 이냐, Data driven 이냐에 따라 융통성 있게 결정하면 될 것 같습니다.

이번 포스팅 에서는, MVVM 패턴과 클린 아키텍쳐 개념들이 점핏 보일러플레이트에 어떻게 영향을 주었는지 설명하는 시간을 가져 보았습니다.

설명한 구조와 유사하게 구성된 Jumpit boilerplate를 통해 실제 코드로 구성된 예시를 보고 싶다면, github 코드를 참고해 주세요.

예제 앱의 동작은, 처음 켜지고 난 뒤 my 탭으로 이동하게 되면 로그인이 되지 않았다고 판단하여 로그인 화면으로 이동하게 되고, 로그인 화면에서 id/password를 입력받아 로그인 처리를 해 주는 것이 전부입니다.

이 과정에서 pw의 validation을 하는 팁 이라거나, 기타 헬퍼나 유틸, 자주 쓰이는 UI 컴포넌트를 어떻게 관리하는지 이런저런 작은 팁들이 있습니다.

테스트에 관한 부분은 아직 들어가 있지 않습니다.

추후 다른 팁들에 대한 글을 적을 때에 공개 코드는 변경될 수 있습니다.

자랑스럽지는 않은 저의 코드를 공개하는 데 까지 많은 고민이 있었습니다.

어떤 형태로 전달해야 신입이나 주니어 개발자들이 이해할 수 있을지 배려해 보려고 했는데, 의도대로 되었는지는 모르겠네요.

긴 글 읽어주셔서 감사합니다.

--

--