Repository Pattern 학습
학습하려는 이유
현재 Flutter로 개발 중인 프로젝트 ‘풀밭’은 현재 Firebase의 Cloud Firestore를 활용해 클라이언트와 서버 간 데이터 송수신 및 저장을 담당하고 있다.
위 이미지는 플러터 공식 문서에 있는 MVVM 아키텍처이고 아래는 풀밭의 아키텍처이다.
[View] <----> [ViewModel] <----> [Repository] <----> [Service (Firebase)]
풀밭에서는 User, Notice, Recruit, Post, Campus 등 다양한 데이터베이스 컬렉션을 사용하며, 이에 따라 여러 개의 store를 다루는 코드가 구현되어 있다.
하지만 만약 추후에 Firebase가 아닌 AWS, Supabase 등 다른 백엔드 서비스로 데이터 소스를 변경해야 하는 상황이 발생한다면, 현재의 구조에서는 서비스 관련 코드 전반을 모두 수정해야 한다.
이는 유지보수성과 확장성 측면에서 매우 비효율적이며, 코드의 결합도가 높아 새로운 데이터 소스 도입 시 많은 리스크가 발생한다.
이러한 문제를 해결하고자 Repository 패턴을 도입하였다.
하지만 인터페이스를 만들지 않아 기술을 명세하지 않았고 실제 구현체와 추상화 부분이 분리가 되지 않았다.
이에 아키텍처에 더 맞는 코드를 작성하려고 한다.
Repository Pattern
Repository 패턴은 데이터 접근 로직을 비즈니스 로직과 분리하기 위해 사용하는 소프트웨어 디자인 패턴이다.
Flutter에서 Repository는 데이터 소스(예: 원격 API, 로컬 데이터베이스 등)와 앱의 비즈니스 계층(예: ViewModel, Controller) 사이에서 중간자 역할을 한다.
이를 통해 데이터가 어디서 오든 동일한 인터페이스로 데이터를 제공하며, 데이터 소스가 변경되어도 앱의 나머지 코드에 미치는 영향을 최소화할 수 있다
장점 및 특징
- 데이터 접근 로직과 비즈니스 로직의 분리가 있어 유지보수와 확장성에 좋다.
- 다양한 데이터 소스(REST API, Firebase 등) 추상화
- Repository는 데이터 소스(Provider, DAO 등)로부터 데이터를 받아와 도메인 계층에 전달한다.
- ViewModel 은 Repository를 통해 데이터에 접근한다. (현재 풀밭 아키텍처)
- 데이터 소스가 변경되더라도 Repository만 수정하면 되므로, 상위 계층 코드는 그대로 유지할 수 있다
추상화되지 않은 코드 (리펙토링 전)
아래 코드는 FirebaseStoreRecruitService 클래스가 별도의 추상화 계층 없이 곧바로 Firebase의 구현체에 의존하고 있는 전형적인 예시다.
비즈니스 로직에서 직접적으로 Firebase의 API를 호출하고 있어, 데이터 소스가 변경될 경우 관련된 모든 코드에 영향을 미치게 된다.
class FirebaseStoreRecruitService {
FirebaseStoreRecruitService(this._store);
final firebase_store.FirebaseFirestore _store;
/// Recruit Collection 생성 및 Recruit 데이터 추가
Future<Result<Recruit, Exception>> createRecruitDB(Recruit recruit, GFUser.User user) async {
try {
await _store.collection('Recruit').doc(recruit.id).set(recruit.toMap());
return Success(recruit);
} catch (e) {
print(e);
return Failure(Exception('recruit 데이터 생성 실패: $e'));
}
}
<--생략-->
위의 코드에서는 데이터 소스(Firebase)가 변경될 경우, 해당 서비스 클래스를 사용하는 모든 코드에서 수정이 필요하다.
새로운 데이터 소스(AWS, Supabase 등)로의 전환이나 추가가 어렵다.
따라서, 이러한 문제를 해결하기 위해서는 서비스 계층을 추상화하고, 데이터 소스에 대한 의존성을 분리하는 리팩토링이 필요하다.
인터페이스 의존 코드 (리팩토링 후)
// 추상클래스
abstract class RecruitServiceInterface {
/// 모집 글 목록 조회
Future<Result<List<Recruit>, Exception>> getRecruitList();
/// 모집 글 생성
Future<Result<Recruit, Exception>> createRecruitDB(Recruit recruit, Client.User user);
/// 종료된 모집 글 생성
Future<Result<Recruit, Exception>> createDeadRecruitDB(Recruit recruit);
/// 모집 글 데이터 이동 -> 종료된 모집 글
Future<Result<void, Exception>> moveAndDeleteCollection(String recruitId);
/// 특정 모집 글 조회
Future<Result<Recruit, Exception>> getRecruit(String recruitId, Client.User user);
/// 모집 글 채팅방 입장 처리
Future<Result<Recruit, Exception>> entryRecruitRoom(String recruitId, String userId);
/// 모집 글 채팅방 퇴장 처리
Future<Result<Recruit, Exception>> outRecruitRoom(String recruitId, String userId);
/// 모집 글 신고
Future<Result<Recruit, Exception>> reportRecruit(String recruitId, String userId, String reason);
/// 신고 데이터 생성
Future<Result<void, Exception>> createReportDB(String? commentId, Report report);
/// 모집 글 삭제
Future<Result<void, Exception>> deleteRecruitDB(String recruitId, Client.User user);
}
class RecruitRepository {
final RecruitServiceInterface storeService;
<--생략-->
}
// 외부에서 주입
final recruitRepositoryProvider = Provider<RecruitRepository>((ref) {
return RecruitRepository(
storeService: FirebaseStoreRecruitService(FirebaseFirestore.instance),
firebaseStorageService: FirebaseStorageService(FirebaseStorage.instance),
);
});
이 구조는RecruitRepository가 오직 인터페이스(RecruitServiceInterface)에만 의존하고,
구현체(FirebaseStoreRecruitService)는 외부에서 주입받기 때문에
상위 모듈이 하위 모듈의 구체적인 구현에 의존하지 않는다 즉 의존성 역전 원칙(DIP)을 잘 지키는 코드 구조다
구현체 코드
NoticeServiceInterface를 implements하고 있으므로 NoticeServiceInterface의 프로퍼티와 메서드를 정의하지 않으면 오류가 발생한다.
class FirebaseStoreNoticeService implements NoticeServiceInterface {
FirebaseStoreNoticeService(this._store);
final firebase_store.FirebaseFirestore _store;
@override
Future<Result<Notice, Exception>> createNoticeDB(Notice notice, Client.User user) async {
try {
if (user.campus == '익명' || user.userType == getUserTypeName(UserType.student)) {
return Failure(Exception('인증 되지 않은 사용자입니다.'));
}
final campus = user.campus;
await _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.doc(notice.id)
.set(notice.toMap());
return Success(notice);
} catch (e) {
return Failure(Exception('notice 데이터 생성 실패: $e'));
}
}
@override
Future<Result<void, Exception>> deleteNoticeDB(String noticeId, Client.User user) async {
try {
if (user.campus == '익명' || user.userType == getUserTypeName(UserType.student)) {
return Failure(Exception('인증 되지 않은 사용자입니다.'));
}
final campus = user.campus;
await _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.doc(noticeId)
.delete();
return Success(null);
} catch (e) {
return Failure(Exception('notice 데이터 삭제 실패: $e'));
}
}
@override
Future<Result<Notice, Exception>> updateNoticeDB(Notice notice, Client.User user) async {
try {
if (user.campus == '익명' || user.userType == getUserTypeName(UserType.student)) {
return Failure(Exception('인증 되지 않은 사용자입니다.'));
}
final campus = user.campus;
await _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.doc(notice.id)
.update(notice.toMap());
return Success(notice);
} catch (e) {
return Failure(Exception('notice 데이터 업데이트 실패: $e'));
}
}
@override
Future<Result<List<Notice>, Exception>> getNoticeList(Client.User? user) async {
try {
final campus = user?.campus == '익명' ? '관악' : user!.campus;
final query = _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.orderBy('created_at', descending: true)
.limit(15);
final querySnapshot = await query.get();
final noticeList = querySnapshot.docs
.map((doc) => Notice.fromMap({...doc.data(), 'id': doc.id}))
.toList();
return Success(noticeList);
} catch (e) {
return Failure(Exception('공지사항 데이터 가져오기 실패: $e'));
}
}
@override
Future<Result<List<Notice>, Exception>> getNextNoticeList(
List<Notice>? lastNotice,
Client.User user
) async {
try {
final campus = user.campus == '익명' ? '관악' : user.campus;
var query = _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.orderBy('created_at', descending: true)
.limit(10);
if (lastNotice != null && lastNotice.isNotEmpty) {
final lastDoc = await _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.doc(lastNotice.last.id)
.get();
query = query.startAfterDocument(lastDoc);
}
final querySnapshot = await query.get();
final newNotices = querySnapshot.docs
.map((doc) => Notice.fromMap({...doc.data(), 'id': doc.id}))
.toList();
return Success([...lastNotice ?? [], ...newNotices]);
} catch (e) {
return Failure(Exception('getNextNoticeList 함수 에러: $e'));
}
}
@override
Future<Result<Notice, Exception>> getNotice(String noticeId, Client.User user) async {
try {
final campus = user.campus == '익명' ? '관악' : user.campus;
final docSnapshot = await _store
.collection('Campus')
.doc(campus)
.collection('Notice')
.doc(noticeId)
.get();
if (docSnapshot.exists) {
final data = docSnapshot.data();
if (data != null) {
return Success(Notice.fromMap({...data, 'id': docSnapshot.id}));
}
}
return Failure(Exception('데이터가 존재하지 않습니다.'));
} catch (e) {
return Failure(Exception('공지사항 데이터 가져오기 실패: $e'));
}
}
}
전체 코드는 깃허브에서 볼 수 있다.
'Flutter' 카테고리의 다른 글
[Flutter] RiverPod - Loading 상태 처리하기(with Lottie & Skeleton) (1) | 2025.01.14 |
---|---|
[Flutter] RiverPod - Data 상태 관리하기 (0) | 2025.01.09 |
[Flutter] Riverpod 학습 - Performing side effects & Passing arguments to your requests (0) | 2025.01.07 |
[Flutter] RiverPod - provider 알아보기(with 네트워크 요청) (0) | 2025.01.06 |
[Flutter] RiverPod 학습 - State management (0) | 2025.01.03 |