2026.01.29 (목)
MVVM에서 클린 아키텍처로 .. gogo
클린 아키텍처 마스터를 위해 기존에 MVVM 형식의 만들어진 날씨 앱을 클린 아키텍처를 사용해서 리펙토링 해보았다..!
master..🌟


강의에 나온 이 그림을 보고 리팩토링을 시작했다 🌟

data/dto/weather_dto.dart 서버 데이터(JSON) 형식을 그대로 담는 객체
domain/entity/weather.dart 앱 UI와 로직에서 실제로 사용할 순수 데이터 모델을 정리
data/data_source/weather_data_source.dart
data/data_source/weather_data_source_impl.dart
외부 API 통신 인터페이스 및 실제 구현체(http.Client 사용)
domain/repository/weather_repository.dart 파일에서 도메인이 필요로 하는 데이터 기능을 인터페이스로 선언
data/repository/weather_repository_impl.dart 파일에서 Data Source를 사용해 DTO를 받고, 이를 Entity로 변환하여 반환
domain/use_case/fetch_weather_usecase.dart 파일에서 사용자의 핵심 동작(날씨 조회)을 단일 책임 클래스로 만들기
main.dart 등 앱 전체의 각 레이어의 의존성을 주입하기 위한 Riverpod 설정
데이터 소스 Provider, 레포지토리 Provider, 유스케이스 Provider
presentation/home/home_view_model.dart 파일에서 Use Case를 호출하여 UI 상태를 관리
presentation/ui/home_screen.dart 파일에서 ViewModel의 상태를 감시(watch)하여 화면에 렌더링
UI에 뿌려주기까지 성공..!
서버에서 주는 데이터(DTO)와 내가 만드는 앱이 실제로 쓰고 싶은 데이터(Entity)를 분리한다.
class WeatherDto {
final double temperature;
final double windspeed;
final double winddirection;
final int weathercode;
final int isDay;
final String time;
const WeatherDto({
required this.temperature,
required this.windspeed,
required this.winddirection,
required this.weathercode,
required this.isDay,
required this.time,
});
factory WeatherDto.fromJson(Map<String, dynamic> json) {
return WeatherDto(
temperature: (json['temperature'] as num).toDouble(),
windspeed: (json['windspeed'] as num).toDouble(),
winddirection: (json['winddirection'] as num).toDouble(),
weathercode: json['weathercode'] as int,
isDay: json['is_day'] as int,
time: json['time'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'temperature': temperature,
'windspeed': windspeed,
'winddirection': winddirection,
'weathercode': weathercode,
'is_day': isDay,
'time': time,
};
}
}
class Weather {
final double latitude;
final double longitude;
final double temperature;
final double windSpeed;
final int weatherCode;
final bool isDay;
final String time;
const Weather({
required this.latitude,
required this.longitude,
required this.temperature,
required this.windSpeed,
required this.weatherCode,
required this.isDay,
required this.time,
});
}
외부 세계(API)와 직접 통신하는 계층
Interface
abstract interface class WeatherDataSource {
Future<CurrentWeatherDto> fetchWeather(double lat, double lng);
}
Implementation
class WeatherDataSourceImpl implements WeatherDataSource {
final http.Client _client;
WeatherDataSourceImpl([http.Client? client])
: _client = client ?? http.Client();
@override
Future<CurrentWeatherDto> fetchWeather(double lat, double lng) async {
final url = Uri.parse(
...생략
domain/repository/weather_repository.dart
abstract interface class WeatherRepository {
Future<Weather> fetchWeather(double lat, double lng);
}
data/repository/weather_repository_impl.dart
class WeatherRepositoryImpl implements WeatherRepository {
final WeatherDataSource _dataSource;
WeatherRepositoryImpl(this._dataSource);
@override
Future<Weather> fetchWeather(double lat, double lng) async {
final dto = await _dataSource.fetchWeather(lat, lng);
return Weather(
(... 생략)
};
}
}
앱의 핵심 기능을 클래스화하고 의존성을 주입하기
domain/use_case/fetch_weather_usecase.dart
class FetchWeatherUsecase {
final WeatherRepository _repository;
FetchWeatherUsecase(this._repository);
Future<Weather> call(double lat, double lng) {
return _repository.fetchWeather(lat, lng);
}
}
main.dart (Provider 정의)
// 데이터 소스 Provider
final weatherDataSourceProvider = Provider<WeatherDataSource>((ref) {
return WeatherDataSourceImpl();
});
// 레포지토리 Provider
final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
return WeatherRepositoryImpl(ref.read(weatherDataSourceProvider));
});
// 유스케이스 Provider
final fetchWeatherUsecaseProvider = Provider<FetchWeatherUsecase>((ref) {
return FetchWeatherUsecase(ref.read(weatherRepositoryProvider));
});
presentation/home/home_view_model.dart
final homeViewModelProvider = AsyncNotifierProvider<HomeViewModel, Weather>(HomeViewModel.new);
class HomeViewModel extends AsyncNotifier<Weather> {
@override
Future<Weather> build() {
return _fetchWeather();
}
Future<Weather> _fetchWeather() {
final useCase = ref.read(fetchWeatherUsecaseProvider);
return useCase.call(37.57, 126.98);
}
Future<void> fetchWeather() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetchWeather());
}
}
presentation/ui/home_screen.dart
Widget build(BuildContext context, WidgetRef ref) {
final weatherState = ref.watch(homeViewModelProvider);
return Scaffold(
body: weatherState.when(
data: (weather) => Text('기온: ${weather?.temperature}'),
loading: () => CircularProgressIndicator(),
error: (e, st) => Text('에러 발생'),
),
);
}
처음에는 파일 개수도 많아지고 구조가 복잡해 보여서 과연 이게 효율적인가 라는 의문이 들었지만 작업을 마치고, TIL 작성 하려고 다시 보니 각 클래스가 자신의 일만 명확하게 수행하는 모습을 확인할 수 있었다아
외부 환경(API, DB, UI)이 어떻게 변하든 앱의 핵심 로직(날씨를 가져온다)은 변하지 않아야 할 것..!
모든 데이터 흐름은 정해진 통로 (Repository -> UseCase)를 거쳐야 되니까 코드가 예측 가능할 거 같ㅌ다.
도메인을 가장 안쪽에 보호함으로써 외부의 변화가 내부로 침투할 수 없는 구조를 만들었다.
인터페이스를 통해 "무엇을 할지"만 정의하고, "어떻게 할지"는 외부에서 주입한다. 덕분에 테스트가 쉬워지고 코드 간의 결합도가 낮아진다.
🌟
아직 테스트 작업을 못 해봤다.. 테스트에 용이한 클린 아키텍처지만 파일 분리하고 layer 작업 후 바로 에러 잡기 급급했던 거 같다. 다음에는 테스트도 함께 사용해서 클린 아키텍처 마스터를.. 해 ㅂ