개발

Repository 패턴: 데이터 계층 추상화의 필요성

왜 QuoteRepository와 QuoteRepositoryImpl로 나누어야 할까?

2025년 9월 18일
15분 읽기
Repository 패턴: 데이터 계층 추상화의 필요성

현재 상황: 코드에서 본 의문점

프로젝트를 열어보면 이런 구조를 만나게 됩니다:
Dart
// domain/repositories/quote_repository.dart
abstract class QuoteRepository {
  Stream<Quote> subscribeQuotes(List<String> symbols);
  Future<Quote?> getLatestQuote(String symbol);
}

// data/repositories/quote_repository_impl.dart
class QuoteRepositoryImpl implements QuoteRepository {
  final ScenarioEngine _scenarioEngine;

  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    // 실제 구현...
  }
}
왜 QuoteRepository와 QuoteRepositoryImpl로 나누어져 있을까요? 왜 abstract class를 만들고, 그것을 implements하는 번거로운 과정을 거칠까요?

문제: 데이터 소스 직접 의존의 위험성

Repository 패턴 없이 코드를 작성하면 이렇게 됩니다:
Dart
class WatchlistScreen extends StatefulWidget {
  @override
  State<WatchlistScreen> createState() => _WatchlistScreenState();
}

class _WatchlistScreenState extends State<WatchlistScreen> {
  List<Quote> quotes = [];

  @override
  void initState() {
    super.initState();
    // UI가 데이터 소스를 직접 알고 있음
    if (kDebugMode) {
      // 개발 중에는 더미 데이터
      ScenarioEngine().quoteStream.listen((quote) {
        setState(() => quotes.add(quote));
      });
    } else {
      // 운영에서는 실제 API
      WebSocketClient().connect('wss://api.trading.com').listen((data) {
        setState(() => quotes.add(Quote.fromJson(data)));
      });
    }
  }
}
이 코드의 문제점:

• UI 로직과 데이터 로직이 섞여있음
• 테스트가 거의 불가능 (WebSocket 연결이 필요)
• 데이터 소스 변경 시 모든 화면 수정 필요
• 캐싱이나 에러 처리 로직 추가가 어려움

Repository 패턴의 핵심

Repository는 데이터 접근을 추상화하는 계층입니다. UI는 데이터가 어디서 오는지 몰라도 되고, 오직 Repository 인터페이스만 알면 됩니다.

인터페이스 정의 (추상화)

Dart
// domain/repositories/quote_repository.dart
abstract class QuoteRepository {
  /// 실시간 시세 스트림 구독
  Stream<Quote> subscribeQuotes(List<String> symbols);

  /// 특정 종목의 최신 시세 조회
  Future<Quote?> getLatestQuote(String symbol);

  /// 관심종목 목록 조회
  Future<List<String>> getWatchlist();
}
이 인터페이스는 "무엇을" 할 수 있는지만 정의합니다. "어떻게" 하는지는 구현체의 몫입니다.

구현체 작성 (구체화)

Dart
// data/repositories/quote_repository_impl.dart
class QuoteRepositoryImpl implements QuoteRepository {
  final ScenarioEngine _scenarioEngine;
  final List<String> _watchlist = ['AAPL', 'GOOGL', 'MSFT'];

  QuoteRepositoryImpl(this._scenarioEngine);

  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    // ScenarioEngine의 더미 데이터를 사용
    return _scenarioEngine.quoteStream
        .where((quoteDto) => symbols.contains(quoteDto.symbol))
        .map((quoteDto) => quoteDto.toEntity());
  }

  @override
  Future<Quote?> getLatestQuote(String symbol) async {
    final price = _scenarioEngine.getCurrentPrice(symbol);
    if (price == null) return null;

    return Quote(
      symbol: symbol,
      last: price,
      bid: price * 0.9999,
      ask: price * 1.0001,
      volume: 1000,
      timestamp: DateTime.now(),
    );
  }

  @override
  Future<List<String>> getWatchlist() async {
    return List.from(_watchlist);
  }
}

왜 인터페이스와 구현을 분리하는가?

1. 교체 가능성

개발 단계별로 다른 데이터 소스를 사용할 수 있습니다:
Dart
// 개발용: 더미 데이터
class DummyQuoteRepository implements QuoteRepository {
  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    return Stream.periodic(Duration(seconds: 1), (_) {
      return Quote(
        symbol: symbols.first,
        last: Random().nextDouble() * 100,
        timestamp: DateTime.now(),
      );
    });
  }
}

// 운영용: 실제 API
class ApiQuoteRepository implements QuoteRepository {
  final ApiClient _apiClient;

  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    return _apiClient
        .connectWebSocket('/quotes')
        .map((json) => Quote.fromJson(json));
  }
}

// 테스트용: 고정된 값
class MockQuoteRepository implements QuoteRepository {
  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    return Stream.value(
      Quote(symbol: 'AAPL', last: 150.0, timestamp: DateTime.now())
    );
  }
}

2. 의존성 역전

Dart
// UI는 추상화(인터페이스)에만 의존
class WatchlistViewModel {
  final QuoteRepository repository;  // 구체적인 구현을 모름

  WatchlistViewModel(this.repository);

  Stream<List<Quote>> getWatchlistQuotes() {
    // repository가 Dummy인지, API인지, Mock인지 모르고 상관없음
    return repository.getWatchlist().asStream().asyncExpand((symbols) {
      return repository.subscribeQuotes(symbols);
    });
  }
}

3. 테스트 용이성

Dart
// 테스트 코드
void main() {
  test('관심종목 시세가 업데이트되면 UI가 갱신된다', () async {
    // Given: Mock Repository 준비
    final mockRepo = MockQuoteRepository();
    final viewModel = WatchlistViewModel(mockRepo);

    // When: 시세 구독
    final quotes = await viewModel.getWatchlistQuotes().first;

    // Then: 예상값 검증
    expect(quotes.first.symbol, 'AAPL');
    expect(quotes.first.last, 150.0);
  });
}
실제 네트워크 연결 없이도 테스트가 가능합니다.

Riverpod을 통한 Repository 주입

Dart
// di/providers.dart
final quoteRepositoryProvider = Provider<QuoteRepository>((ref) {
  final engine = ref.read(scenarioEngineProvider);

  // 환경에 따라 다른 구현체 반환
  const environment = String.fromEnvironment('ENV', defaultValue: 'dev');

  switch (environment) {
    case 'dev':
      return QuoteRepositoryImpl(engine);  // 더미 데이터
    case 'prod':
      return ApiQuoteRepository(ApiClient());  // 실제 API
    default:
      return QuoteRepositoryImpl(engine);
  }
});

// UI에서 사용
class WatchlistScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Provider를 통해 Repository 획득
    final repository = ref.read(quoteRepositoryProvider);

    // 어떤 구현체인지 모르지만 사용 가능
    return StreamBuilder<Quote>(
      stream: repository.subscribeQuotes(['AAPL']),
      builder: (context, snapshot) {
        // UI 렌더링
      },
    );
  }
}

Repository 패턴의 실제 이점

1. 단계적 마이그레이션

Dart
// Phase 1: 더미 데이터로 시작
class QuoteRepositoryImpl implements QuoteRepository {
  final ScenarioEngine _scenarioEngine;
  // 더미 구현
}

// Phase 2: API 추가, 더미는 폴백
class QuoteRepositoryImpl implements QuoteRepository {
  final ScenarioEngine _scenarioEngine;
  final ApiClient? _apiClient;

  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    if (_apiClient != null) {
      return _apiClient.getQuotes(symbols).handleError((error) {
        // API 실패 시 더미 데이터로 폴백
        return _scenarioEngine.quoteStream;
      });
    }
    return _scenarioEngine.quoteStream;
  }
}

2. 캐싱 레이어 추가

Dart
class CachedQuoteRepository implements QuoteRepository {
  final QuoteRepository _remote;
  final Map<String, Quote> _cache = {};

  @override
  Future<Quote?> getLatestQuote(String symbol) async {
    // 캐시 확인
    if (_cache.containsKey(symbol)) {
      final cached = _cache[symbol]!;
      final age = DateTime.now().difference(cached.timestamp);

      if (age.inSeconds < 5) {
        return cached;  // 5초 이내면 캐시 반환
      }
    }

    // 원격 조회 후 캐시
    final quote = await _remote.getLatestQuote(symbol);
    if (quote != null) {
      _cache[symbol] = quote;
    }
    return quote;
  }
}

3. 에러 처리 중앙화

Dart
class ResilientQuoteRepository implements QuoteRepository {
  final QuoteRepository _primary;
  final QuoteRepository _fallback;

  @override
  Stream<Quote> subscribeQuotes(List<String> symbols) {
    return _primary.subscribeQuotes(symbols).handleError((error) {
      // 에러 로깅
      logger.error('Primary repository failed', error);

      // 폴백으로 전환
      return _fallback.subscribeQuotes(symbols);
    });
  }
}

정리

Repository 패턴을 사용하는 이유:

데이터 소스 독립성: UI는 데이터가 어디서 오는지 몰라도 됨
테스트 가능성: Mock을 사용한 단위 테스트 가능
유연한 전환: 더미 → API 전환이 한 줄로 가능
공통 로직 중앙화: 캐싱, 에러 처리를 Repository에서 일괄 처리
의존성 관리: Clean Architecture의 의존성 규칙 준수

Repository는 단순히 데이터를 가져오는 클래스가 아니라, 비즈니스 로직과 데이터 소스 사이의 계약입니다. 이 계약을 통해 각 계층이 독립적으로 발전할 수 있습니다.
#Flutter
#Clean Architecture
#Repository Pattern
#Design Patterns
#Dart