현재 상황: 코드에서 본 의문점
프로젝트를 열어보면 이런 구조를 만나게 됩니다:
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 연결이 필요)
• 데이터 소스 변경 시 모든 화면 수정 필요
• 캐싱이나 에러 처리 로직 추가가 어려움
• 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는 단순히 데이터를 가져오는 클래스가 아니라, 비즈니스 로직과 데이터 소스 사이의 계약입니다. 이 계약을 통해 각 계층이 독립적으로 발전할 수 있습니다.
• 데이터 소스 독립성: UI는 데이터가 어디서 오는지 몰라도 됨
• 테스트 가능성: Mock을 사용한 단위 테스트 가능
• 유연한 전환: 더미 → API 전환이 한 줄로 가능
• 공통 로직 중앙화: 캐싱, 에러 처리를 Repository에서 일괄 처리
• 의존성 관리: Clean Architecture의 의존성 규칙 준수
Repository는 단순히 데이터를 가져오는 클래스가 아니라, 비즈니스 로직과 데이터 소스 사이의 계약입니다. 이 계약을 통해 각 계층이 독립적으로 발전할 수 있습니다.