날씨 앱을 클린 아키텍처로 리펙토링 🌈

박진·2026년 1월 29일

2026.01.29 (목)
MVVM에서 클린 아키텍처로 .. gogo


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


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

☔️ 리팩토링 작업 순서

1. DTO 분리

data/dto/weather_dto.dart 서버 데이터(JSON) 형식을 그대로 담는 객체

2. Entity 정의

domain/entity/weather.dart 앱 UI와 로직에서 실제로 사용할 순수 데이터 모델을 정리

3. Data Layer 작업

data/data_source/weather_data_source.dart
data/data_source/weather_data_source_impl.dart
외부 API 통신 인터페이스 및 실제 구현체(http.Client 사용)

4. Repository 정의

domain/repository/weather_repository.dart 파일에서 도메인이 필요로 하는 데이터 기능을 인터페이스로 선언

5. Repository 구현

data/repository/weather_repository_impl.dart 파일에서 Data Source를 사용해 DTO를 받고, 이를 Entity로 변환하여 반환

6. Use Case

domain/use_case/fetch_weather_usecase.dart 파일에서 사용자의 핵심 동작(날씨 조회)을 단일 책임 클래스로 만들기

7. Provider 정의

main.dart 등 앱 전체의 각 레이어의 의존성을 주입하기 위한 Riverpod 설정
데이터 소스 Provider, 레포지토리 Provider, 유스케이스 Provider

8. ViewModel 연결

presentation/home/home_view_model.dart 파일에서 Use Case를 호출하여 UI 상태를 관리

9. UI 바인딩

presentation/ui/home_screen.dart 파일에서 ViewModel의 상태를 감시(watch)하여 화면에 렌더링

UI에 뿌려주기까지 성공..!


🌦 단계별 구현

▶️ 데이터의 구분 (DTO vs Entity)

서버에서 주는 데이터(DTO)와 내가 만드는 앱이 실제로 쓰고 싶은 데이터(Entity)를 분리한다.

DTO (Data Transfer Object) : 서버의 입맛 (JSON 구조와 동일)

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,
    };
  }
}

Entity : 앱의 입맛 (비지니스 로직에 최적화)

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,
  });
}

▶️ Data Source 레이어

외부 세계(API)와 직접 통신하는 계층

data/data_source/weather_data_source.dart

Interface

abstract interface class WeatherDataSource {
  Future<CurrentWeatherDto> fetchWeather(double lat, double lng);
}

data/data_source/weather_data_source_impl.dart

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(
    ...생략

▶️ Repository 레이어

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(
 (... 생략)
     };
   }
 }

▶️ Use Case & Provider (실행 및 조립)

앱의 핵심 기능을 클래스화하고 의존성을 주입하기
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 Layer

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('에러 발생'),
    ),
  );
}

🌤 레이어 작업을 하는 이유

  • Data Layer
    데이터를 어떻게 가져올 것인가? (네트워크, 로컬 DB 등)
  • Domain Layer
    우리 앱은 어떤 일을 하는가? (가장 중요하며 순수해야 함)
  • Presentation Layer
    사용자에게 어떻게 보여줄 것인가? (UI, 상태 관리 등)

처음에는 파일 개수도 많아지고 구조가 복잡해 보여서 과연 이게 효율적인가 라는 의문이 들었지만 작업을 마치고, TIL 작성 하려고 다시 보니 각 클래스가 자신의 일만 명확하게 수행하는 모습을 확인할 수 있었다아

🌞 왜 클린 아키텍처인가?

⚡️ 도메인 중심 설계 (Domain-Centric)

외부 환경(API, DB, UI)이 어떻게 변하든 앱의 핵심 로직(날씨를 가져온다)은 변하지 않아야 할 것..!
모든 데이터 흐름은 정해진 통로 (Repository -> UseCase)를 거쳐야 되니까 코드가 예측 가능할 거 같ㅌ다.
도메인을 가장 안쪽에 보호함으로써 외부의 변화가 내부로 침투할 수 없는 구조를 만들었다.

⚡️ 의존성 역전 (Dependency Inversion)

인터페이스를 통해 "무엇을 할지"만 정의하고, "어떻게 할지"는 외부에서 주입한다. 덕분에 테스트가 쉬워지고 코드 간의 결합도가 낮아진다.

🌟
아직 테스트 작업을 못 해봤다.. 테스트에 용이한 클린 아키텍처지만 파일 분리하고 layer 작업 후 바로 에러 잡기 급급했던 거 같다. 다음에는 테스트도 함께 사용해서 클린 아키텍처 마스터를.. 해 ㅂ

0개의 댓글