안녕하세요. 저는 LINE+ ABC Studio에서 앱을 개발하고 있는 윤기영입니다. 최근 운영 중이던 앱의 규모가 점점 커지면서 기존 구조로는 앱을 유지 보수하거나 확장하기 어려워졌습니다. 이를 극복하기 위해 여러 아키텍처 중 클린 아키텍처를 도입했는데요. 그 과정을 여러분께 소개하고자 합니다.
클린 아키텍처는 소프트웨어 엔지니어 로버트 C. 마틴(Robert C. Martin)이 제안한 소프트웨어 설계 원칙을 지킨 아키텍처를 말합니다. 계층화된 구조를 사용하고 확장과 테스트가 용이해 대형 프로젝트에 적용하기 적합한 아키텍처입니다.
본문에서는 프로젝트의 규모가 점점 커지면서 발생한 여러 문제들을 해결하기 위해 총 6단계를 거쳐 앱의 구조가 진화하며, 그 결과 클린 아키텍처의 모습을 갖춥니다. 이 과정을 쉽게 이해할 수 있도록 예제 앱 코드와 함께 각 단계마다 실생활에서 발생할 수 있는 문제를 예시로 제시하고 이를 해결하기 위해 예제 앱의 구조가 어떻게 변화하며 발전해 나가는지 살펴보겠습니다. 예제 앱은 Flutter지만 이 글에서 소개하는 방법은 Flutter뿐 아니라 다른 모든 종류의 앱 개발에 적용할 수 있습니다.
그럼 6단계 중 첫 번째 아키텍처부터 살펴보겠습니다.
첫 번째 아키텍처
예제 앱의 첫 번째 아키텍처는 다음과 같습니다. View에서 직접 서버로 데이터를 요청하여 화면을 구성합니다.
다음은 서버에서 글을 가져와 화면을 표시하는 PostListViewState
클래스입니다.
class PostListViewState extends State<PostListView> {
List<Post> _posts = [];
// 1. 데이터를 받아와 저장합니다.
void fetchPosts() async {
final posts = await httpClient.getPostList();
setState(() {
_posts = posts;
});
}
// 2. 화면을 표시합니다.
Widget build(BuildContext context) {
return ListView.builder(
itemBuilder: (context, index) {
return PostCell(_posts[index]);
},
);
}
}
문제 발생!
이 아키텍처에서는 데이터를 받아오는 부분과 화면을 구성하는 부분이 모두 PostListViewState
클래스에 포함돼 있습니다. 작은 프로젝트라면 별문제가 되지 않겠지만 프로젝트의 규모가 커지면 다음과 같은 몇 가지 문제가 발생합니다.
- 해석의 어려움: 하나의 클래스에 여러 기능을 섞어 놓을 경우 클래스의 전체 내용을 파악해야만 코드를 이해할 수 있습니다. 코드를 쉽게 파악할 수 있도록 '화면과 관련된 코드' 혹은 '데이터를 처리하는 코드' 정도로 클래스를 분리해야 합니다. 하나의 클래스는 한 종류의 기능만을 제공해야 가독성이 높아지고 쉽게 이해할 수 있습니다.
- 잦은 수정: 클래스의 역할이 많아지면 수정해야 할 이유도 많아집니다. 클래스를 수정할 때마다 클래스 단위로 테스트할 텐데요. 위
PostListViewState
클래스의 경우 단순히 텍스트 크기를 변경하는 UI 변경 작업만 진행해도 데이터를 받아오는 부분까지 다시 테스트해야 하는 번거로운 상황이 발생합니다. 따라서 역할과 각 역할에 대한 책임을 명확히 분리하는 것이 중요합니다. 그래야 수정할 때 해당 역할의 코드만 수정하고 테스트할 수 있습니다. - 테스트의 어려움: 위 코드에서 데이터를 가져오는 부분과 화면이 갱신되는 부분을 테스트하기 위한 코드를 만들고자 할 때 현재 상태에서는 테스트 코드가 들어갈 자리를 찾기가 쉽지 않습니다.
두 번째 아키텍처: 화면과 데이터 분리
두 번째 아키텍처는 첫 번째 아키텍처에서 화면을 다루는 부분과 데이터를 다루는 부분을 분리해 구현했습니다.
아래와 같이 두 개의 클래스로 코드를 분리해 첫 번째 아키텍처에서 지적된 문제점을 해결했습니다. 아래 코드에서는 Provider
를 사용해 ViewModel
을 구현했지만, Provider
뿐 아니라 Riverpod
나 Bloc
등 어느 것을 사용해도 무방합니다.
// View
class PostListView extends StatelessWidget {
const PostListView({super.key});
Widget build(BuildContext context) {
final posts = context.select((PostListViewModel viewModel) => viewModel.posts);
return ListView.builder(
itemBuilder: (context, index) {
return PostCell(posts[index]);
},
);
}
}
// ViewModel
class PostListViewModel extends ChangeNotifier {
List<Post> posts = [];
void fetchPosts() async {
posts = await httpClient.getPostList();
notifyListeners();
}
}
이 상태에서 앱에 기능을 더 추가해 보겠습니다. 글 목록 화면과 글 상세 화면을 추가하면서 View와 ViewModel 세트가 하나 더 추가됐습니다.
이어서 즐겨찾기 기능을 추가하면서 PostList
와 PostDetail
에 즐겨찾기를 실행하는 코드가 추가됐습니다.
문제 발생!
즐겨찾기를 추가하자 다음과 같은 또 다른 문제점이 등장했습니다.
- 코드 중복: 즐겨찾기 기능이 두 군데에 중복돼 구현되면서 수정할 때 양쪽을 다 수정해야 하는 번거로운 상황이 발생했습니다. 위 코드는 예제라서 단 2줄만 작성했지만, 실제 상황에서는 훨씬 많은 양의 코드가 중복될 것입니다.
- 동기화 문제:
PostDetail
화면에서 즐겨찾기 후PostList
화면으로 돌아올 때 즐겨찾기 기능 실행 여부를 반영하는 코드를 짜기가 까다롭습니다. 게다가 즐겨찾기가PostDetail
에만 추가된다는 보장이 없으며, 추후 관련 기능을 추가할 때마다 영향 받는 화면을 모두 찾아서 동기화하는 기능을 함께 추가해야 합니다.
세 번째 아키텍처: Repository 추가
두 번째 아키텍처에서 발생한 중복 구현과 화면 갱신 문제를 해결하기 위해 데이터를 중앙에서 관리하는 Repository
컴포넌트를 추가해 Model과 함께 '데이터 레이어'로 구성했습니다. 이제부터 View
와 ViewModel
을 합쳐 '프레젠테이션 레이어'라고 부르겠습니다.
Post
데이터와 즐겨찾기 기능은 Repository
로 옮겨 중앙에서 관리합니다. 이렇게 데이터 소스를 한 군데서 관리하면 데이터의 일관성이 높아져 관리가 편해집니다.
// 새로 추가된 Repository
class PostRepository {
// 글 목록 데이터 요청
Stream<List<Post>> getPostList() {
...
}
// 글 데이터 요청
Stream<Post> getPost(String postId) {
...
}
// 즐겨찾기 추가
void setFavorite(String postId) {
...
}
class PostListViewModel extends ChangeNotifier {
void fetch() async {
postList = await repository.getPostList();
}
void setFavorite(String postId) {
repository.setFavorite(postId);
}
class PostDetailViewModel extends ChangeNotifier {
void fetch() async {
post = await repository.getPostDetail(postId);
}
void setFavorite() {
repository.setFavorite(postId);
}
프레젠테이션 레이어에서는 Repository
에 데이터를 요청하기도 하고, Repository
로부터 데이터가 변경됐다는 통지를 받을 수도 있습니다. 위 코드에서 데이터 리턴 타입이 Stream
인 것을 확인할 수 있는데요. Stream
을 사용하면 Repository
내 데이터가 변경됐을 때 프레젠테이션 레이어로 통지돼 화면이 업데이트됩니다. 추후 즐겨찾기 기능을 탑재한 다른 화면을 추가하더라도 이전 코드를 수정할 필요 없이 데이터가 화면에 잘 반영될 것입니다(이와 같은 효과를 낼 수 있는 몇 가지 기법을 제 이전 글인 간편하게 서버 데이터를 로딩하는 Fetcher, Swift로 구현하기에서 보다 자세히 다루고 있으니 참고하시기 바랍니다).
웬만한 중소형 프로젝트까지는 3단계 아키텍처만으로도 충분히 대응할 수 있습니다. 4단계 이상부터는 코드 보다 인터페이스가 더 많아지는 듯한 느낌을 받아 '굳이 이럴 필요가 있을까?'라는 생각이 들 수 있는데요. 따라서 4단계부터는 많은 인원이 수많은 화면과 로직을 함께 만들어 내는 상황이라는 것을 염두에 두어야 그 필요를 체감할 수 있습니다. 샘플 예제 코드만으로는 그 상황의 복잡함을 보여드리는 것에 한계가 있어서 배경 설명도 계속 붙이겠습니다.
문제 발생!
이제 프로젝트의 규모를 점점 더 키워보겠습니다. 대형 프로젝트를 진행하다 보면 다음과 같은 상황들이 발생할 수 있습니다.
- 테스트: 제작이 완료된
View
를 테스트해 보고 싶습니다. 항목이 1,000개가 넘는 목록을 표시하거나 다양한 데이터 타입을 모두 표현하는 데 문제가 없는지 확인하고 싶습니다. 하지만 개발 기간 동안 서버에서는 오직 한 가지 타입의 데이터만 제공되고 있습니다. View
를 먼저 제작: 서버가 정해지지 않은 상태에서View
를 먼저 개발해야 하는 상황입니다. 이런 상황에서는 초기에는 테스트용 데이터를 생성하는Repository
를 사용하다가 프로젝트 막바지쯤에 실제Repository
로 교체해야 하는데요. 이때View
코드를 수정하는 일이 일정에 영향을 미치지 않아야 하며,View
를 수정하지 않고 진행할 수 있는 방법은 없는지 고민해야 합니다.
이런 상황들을 염두에 두고 프로젝트의 구조를 더 발전시켜 보겠습니다.
네 번째 아키텍처: Repository를 인터페이스와 구현체로 분리
이 아키텍처에서는 Repository
를 아래와 같이 인터페이스와 구현체로 분리합니다.
프레젠테이션 레이어에서는 RepositoryImpl
를 직접 참조하지 않으므로 다른 구현체로 교체하더라도 별다른 영향이 없습니다. 또한 테스트를 목적으로 목(mock) 구현체를 사용할 수도 있습니다.
다음은 예시 코드입니다.
// interface
abstract class PostRepository {
Stream<List<Post>> getPostList();
Stream<Post> getPost(String postId);
void setFavorite(String postId);
}
// 구현체
class PostRepositoryImpl implements PostRepository {
@override
Stream<Post> getPost(String postId) {
...
@override
Stream<List<Post>> getPostList() {
...
@override
void setFavorite(String postId) {
...
문제 발생!
그런데 잠깐, 이 아키텍처가 모델 변화에도 괜찮을까요? 서버의 모델에 맞춰 프레젠테이션 레이어를 구현하면 모델이 변경될 때마다 프레젠테이션 레이어의 코드를 변경해야 합니다. 필드명이 변경될 수도 있고, 상황에 따라 모델이 분리되거나 합쳐질 수도 있는데요. 그런 경우 엄청난 양을 수정해야 할 수도 있습니다. 또한 이와 같이 프레젠테이션 레이어를 다시 작성하면 테스트도 다시 해야 합니다.
다섯 번째 아키텍처: 모델을 내부용과 외부용으로 분리
이 아키텍처에서는 데이터를 서버에서 가져온 데이터와 내부에서 사용하는 데이터로 분리합니다. 이에 따라 데이터 구조가 도메인 레이어의 Entity
와 데이터 레이어의 Model
의 두 가지로 나뉘는데요. 이렇게 나누면 외부 모델에 상관없이 프레젠테이션 레이어를 구현할 수 있고, 프레젠테이션 레이어 구현 완료 후 Model
을 변경해도 프레젠테이션 레이어의 코드를 변경할 필요가 없습니다.
도메인 레이어의 Entity
에는 아래와 같이 비즈니스 로직을 넣는 것도 가능해졌습니다.
class UserProfile {
final String userId;
final String name;
// 이메일 주소를 검증하는 비즈니스 로직
bool get isValidEmail => RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(email);
문제 발생!
앱의 규모가 더욱 커지면서 글쓰기와 동영상 시청, 계정 관리 기능이 추가됐습니다. 그에 따라 Repository
가 계속 늘어났는데요. 이처럼 다양한 Repository
가 추가되면 각 Repository
가 어떤 기능을 제공하고 어디서 호출하고 있는지 파악하기 어렵습니다.
이에 따라 많은 개발자들이 자신이 원하는 기능을 구현하기 위해 어느 Repository
의 어느 메서드를 호출해야 할지 모르 는 상황이 발생하고, Repository
개발자들은 누가 자신을 호출하는지 파악하는 게 어려워집니다. 이를 개선하기 위한 마지막 여섯 번째 아키텍처를 살펴보겠습니다.
여섯 번째, 최종 아키텍처
여섯 번째 아키텍처에서는 ViewModel
이 Repository
를 직접 참조하지 않고 UseCase
를 이용합니다. 이렇게 하면 의존성이 명확해지고 관리하기도 편해집니다. 비즈니스 로직을 한 곳에 집중시켜 관리하기 때문에 앱의 흐름을 파악하는 이정표가 될 수 있습니다.
아래는 UseCase
샘플 코드입니다.
class LoginUseCase {
Future<UserProfile> call(Params params) {
return auth.login(params)
}
}
class GetPostListUseCase {
Future<List<Post>> call(Params params) {
return post.getPostList(params)
}
}
class GetPostDetailUseCase {
Future<Post> call(Params params) {
return post.getPost(params)
}
}
...
하나의 UseCase
에서 다양한 Repository
의 메서드를 순차적으로 호출하는 것도 가능합니다. 만약 이와 같은 코드가 여러 개의 ViewModel
에 존재했다면 코드가 중복됐을 텐데요. UseCase
를 사용함으로써 중복 코드 발생이 방지됐고 재사용성이 좋아졌습니다.
class LoginUseCase {
Future<UserProfile> call(LoginParams params) async {
// 1. 사용자 자격 증명 확인
final authResult = await repository.authenticate(params);
if (!authResult) {
return UserProfile.authError();
}
// 2. 사용자 프로필 정보 가져오기
final userProfile = await repository.getUserProfile(params);
if (userProfile == null) {
return UserProfile.authError();
}
// 3. 사용자 설정 가져오기
final userSettings = await repository.getUserSettings(params);
if (userSettings == null) {
return UserProfile.authError();
}
return Right(userProfile);
}
}
프로젝트의 최종 구조는 아래와 같습니다.
lib/
├── main.dart
├── core/
│ ├── error/
│ └── utils/ // StringUtil, NumberUtil, NetworkUtil 등이 들어있습니다.
│ ├── usecases/ // 여러 곳에서 공통으로 사용할 수 있는 UseCase
│ └── widgets/ // 여러 곳에서 공통으로 사용할 수 있는 Widget
├── features/
│ ├── auth/
│ │ ├── data/ // Repository 구현체와 Model이 포함돼 있으며, 데이터를 저장 및 관리하고 데이터 소스(서버, 로컬 DB)와 통신합니다.
│ │ │ ├── datasources/
│ │ │ ├── models/
│ │ │ ├── repositories/
│ │ ├── domain/ // UseCase와 Entity, Repository 인터페이스가 포함돼 있으며 비즈니스 로직을 처리합니다.
│ │ │ ├── entities/
│ │ │ ├── repositories/
│ │ │ ├── usecases/
│ │ ├── presentation/ // View와 ViewModel이 포함돼 있으며 사용자 인터페이스와 상호작용합니다.
│ │ │ ├── pages/
│ │ │ └── widgets/
│ ├── post/
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ ├── models/
│ │ │ ├── repositories/
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ ├── repositories/
│ │ │ ├── usecases/
│ │ ├── presentation/
│ │ │ ├── pages/
│ │ │ └── widgets/
│ ├── video/
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ ├── models/
│ │ │ ├── repositories/
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ ├── repositories/
│ │ │ ├── usecases/
│ │ ├── presentation/
│ │ │ ├── pages/
│ │ │ └── widgets/
마치며
클린 아키텍처가 모든 문제의 정답은 아닙니다. 규모가 작은 프로젝트에서는 오히려 복잡성을 증가시켜 독이 될 수 있습니다. 저희의 경우 화면 페이지가 10개가 될 때까지는 View
- ViewModel
- Repository
의 세 가지 레이어만으로도 충분히 커버할 수 있었습니다.
그러나 다수의 인원이 여러 복잡한 기능을 개발하는 대형 프로젝트에서는 View
- ViewModel
- Repository
의 세 가지 레이어만 사용하는 아키텍처로는 하나씩 불거져 나오는 문제를 해결할 수 없어 한계에 부딪히는데요. 이때 클린 아키텍처를 도입하면 비즈니스 로직과 데이터 접근, UI 코드가 명확히 분리돼 각 모듈을 독립적으로 변경할 수 있고, 테스트 작성이 용이해집니다. 저희는 앞으로도 이 구조를 이용해 프로젝트의 품질을 유지하고, 변화에 유연하게 대응할 수 있을 것으로 기대합니다.