왜 인터페이스를 먼저 정의하는가?
Repository를 만들 때 왜 인터페이스(abstract class)부터 만들까요? 바로 코드를 작성하면 안 될까요?
계약을 먼저 정하는 이유
인터페이스는 "계약서"와 같습니다. 집을 지을 때 설계도를 먼저 그리듯, 코드도 "무엇을 할지"를 먼저 정의하고 "어떻게 할지"는 나중에 구현합니다.
인터페이스의 장점:
• 교체 가능성: 구현 방식을 바꿔도 사용하는 쪽은 수정 불필요
• 팀 협업: 인터페이스만 정해두면 동시에 개발 가능
• 테스트: Mock 구현체로 쉽게 테스트
• 명확한 의도: 무엇을 하는지 한눈에 파악
인터페이스의 장점:
• 교체 가능성: 구현 방식을 바꿔도 사용하는 쪽은 수정 불필요
• 팀 협업: 인터페이스만 정해두면 동시에 개발 가능
• 테스트: Mock 구현체로 쉽게 테스트
• 명확한 의도: 무엇을 하는지 한눈에 파악
실제 시나리오
당신이 팀 리더고, 3명의 개발자와 함께 일한다고 상상해봅시다:
• A는 UI 개발
• B는 비즈니스 로직
• C는 API 연동
인터페이스를 먼저 정의하면, A와 B는 C가 API를 완성하기 전에도 개발을 시작할 수 있습니다. Mock 구현체를 사용하면 되니까요.
• A는 UI 개발
• B는 비즈니스 로직
• C는 API 연동
인터페이스를 먼저 정의하면, A와 B는 C가 API를 완성하기 전에도 개발을 시작할 수 있습니다. Mock 구현체를 사용하면 되니까요.
QuoteRepository 인터페이스 설계
주식 시세 앱의 Repository를 설계해봅시다.
1단계: 필요한 기능 파악
먼저 "무엇이 필요한가?"를 생각합니다:
• 실시간 시세 조회
• 과거 시세 조회
• 관심종목 관리
• 시세 알림 설정
• 실시간 시세 조회
• 과거 시세 조회
• 관심종목 관리
• 시세 알림 설정
2단계: 인터페이스 정의
Dart
// domain/repositories/quote_repository.dart
abstract class QuoteRepository {
// 실시간 시세
Stream<Quote> watchQuote(String symbol);
Future<Quote?> getLatestQuote(String symbol);
// 과거 데이터
Future<List<Quote>> getHistoricalQuotes(
String symbol, {
required DateTime from,
required DateTime to,
});
// 관심종목
Future<List<String>> getWatchlist();
Future<void> addToWatchlist(String symbol);
Future<void> removeFromWatchlist(String symbol);
// 알림
Future<void> setPriceAlert(String symbol, double targetPrice);
Future<List<PriceAlert>> getActiveAlerts();
}
설계 원칙
1. 기술 중립적 언어 사용
Dart
// ❌ 나쁜 예 - 기술이 드러남
abstract class QuoteRepository {
Future<http.Response> fetchQuoteFromAPI(String symbol);
Future<Quote> queryQuoteFromSQLite(String symbol);
}
// ✅ 좋은 예 - 무엇을 하는지만 표현
abstract class QuoteRepository {
Future<Quote?> getLatestQuote(String symbol);
}
2. 단일 책임 원칙
Dart
// ❌ 너무 많은 책임
abstract class SuperRepository {
Future<Quote> getQuote(String symbol);
Future<User> getUser(String userId);
Future<Order> placeOrder(OrderRequest request);
}
// ✅ 한 가지 도메인에 집중
abstract class QuoteRepository { /* 시세 관련만 */ }
abstract class UserRepository { /* 사용자 관련만 */ }
abstract class OrderRepository { /* 주문 관련만 */ }
3. 반환 타입은 도메인 모델
Dart
// ❌ DTO 반환
abstract class QuoteRepository {
Future<QuoteDto> getQuote(String symbol);
}
// ✅ Entity 반환
abstract class QuoteRepository {
Future<Quote> getQuote(String symbol);
}
QuoteRepositoryImpl 구현 상세
이제 실제 구현을 해봅시다. 여기서는 API, 캐시, 오프라인 지원 등 현실적인 문제를 다룹니다.
기본 구현
Dart
// data/repositories/quote_repository_impl.dart
class QuoteRepositoryImpl implements QuoteRepository {
final QuoteApiClient _apiClient;
final QuoteLocalStorage _localStorage;
final NetworkInfo _networkInfo;
QuoteRepositoryImpl({
required QuoteApiClient apiClient,
required QuoteLocalStorage localStorage,
required NetworkInfo networkInfo,
}) : _apiClient = apiClient,
_localStorage = localStorage,
_networkInfo = networkInfo;
@override
Future<Quote?> getLatestQuote(String symbol) async {
try {
// 1. 캐시 확인 (30초 유효)
final cached = await _localStorage.getCachedQuote(symbol);
if (cached != null && cached.age < Duration(seconds: 30)) {
return cached.toEntity();
}
// 2. 네트워크 확인
if (!await _networkInfo.isConnected) {
// 오프라인: 캐시된 데이터라도 반환
return cached?.toEntity();
}
// 3. API 호출
final dto = await _apiClient.fetchQuote(symbol);
// 4. 캐시 저장
await _localStorage.cacheQuote(dto);
// 5. Entity 변환 후 반환
return dto.toEntity();
} catch (e) {
// 에러 시 캐시 반환
final cached = await _localStorage.getCachedQuote(symbol);
return cached?.toEntity();
}
}
Dart
@override
Stream<Quote> watchQuote(String symbol) {
// WebSocket 스트림과 폴백 결합
return _apiClient
.connectWebSocket(symbol)
.handleError((error) {
// WebSocket 실패 시 폴링으로 전환
return Stream.periodic(
Duration(seconds: 5),
(_) => getLatestQuote(symbol),
).whereType<Quote>();
})
.map((dto) => dto.toEntity());
}
}
핵심 포인트
1. 다중 데이터 소스 조합
• 캐시 → API → 폴백 순서로 시도
• 각 소스의 장단점 활용
2. 에러 복구 전략
• 네트워크 실패 시 캐시 사용
• WebSocket 실패 시 폴링으로 전환
3. DTO ↔ Entity 변환
• Data Layer에서만 DTO 사용
• Domain Layer로는 Entity 반환
• 캐시 → API → 폴백 순서로 시도
• 각 소스의 장단점 활용
2. 에러 복구 전략
• 네트워크 실패 시 캐시 사용
• WebSocket 실패 시 폴링으로 전환
3. DTO ↔ Entity 변환
• Data Layer에서만 DTO 사용
• Domain Layer로는 Entity 반환
다양한 구현체 전환
Repository 패턴의 진정한 힘은 구현체를 쉽게 바꿀 수 있다는 점입니다.
1. 개발용 더미 구현체
Dart
class DummyQuoteRepository implements QuoteRepository {
@override
Future<Quote?> getLatestQuote(String symbol) async {
// 고정된 더미 데이터 반환
await Future.delayed(Duration(milliseconds: 500)); // 네트워크 지연 시뮬레이션
return Quote(
symbol: symbol,
price: 100.0 + Random().nextDouble() * 50,
change: Random().nextDouble() * 10 - 5,
timestamp: DateTime.now(),
);
}
@override
Stream<Quote> watchQuote(String symbol) {
// 1초마다 랜덤 가격 생성
return Stream.periodic(Duration(seconds: 1), (_) {
return Quote(
symbol: symbol,
price: 100.0 + Random().nextDouble() * 50,
change: Random().nextDouble() * 10 - 5,
timestamp: DateTime.now(),
);
});
}
}
2. Firebase 구현체
Dart
class FirebaseQuoteRepository implements QuoteRepository {
final FirebaseFirestore _firestore;
final FirebaseDatabase _realtimeDb;
@override
Future<Quote?> getLatestQuote(String symbol) async {
final doc = await _firestore
.collection('quotes')
.doc(symbol)
.get();
if (!doc.exists) return null;
return Quote.fromJson(doc.data()!);
}
@override
Stream<Quote> watchQuote(String symbol) {
return _realtimeDb
.ref('quotes/$symbol')
.onValue
.map((event) => Quote.fromJson(event.snapshot.value));
}
}
3. WebSocket 구현체
Dart
class WebSocketQuoteRepository implements QuoteRepository {
final WebSocketManager _wsManager;
@override
Stream<Quote> watchQuote(String symbol) {
// 구독 메시지 전송
_wsManager.send({
'action': 'subscribe',
'symbols': [symbol],
});
// 해당 심볼 메시지만 필터링
return _wsManager.messages
.where((msg) => msg['symbol'] == symbol)
.map((msg) => Quote.fromJson(msg));
}
}
실제 프로젝트 적용
Provider를 통한 구현체 주입
Dart
// di/providers.dart
final quoteRepositoryProvider = Provider<QuoteRepository>((ref) {
// 환경에 따라 다른 구현체 사용
if (kDebugMode && useDummyData) {
return DummyQuoteRepository();
}
if (useFirebase) {
return FirebaseQuoteRepository(
firestore: ref.read(firestoreProvider),
realtimeDb: ref.read(realtimeDbProvider),
);
}
// 기본: API + 캐시
return QuoteRepositoryImpl(
apiClient: ref.read(apiClientProvider),
localStorage: ref.read(localStorageProvider),
networkInfo: ref.read(networkInfoProvider),
);
});
환경별 설정
Dart
// config/environment.dart
class Environment {
static QuoteRepository getQuoteRepository() {
switch (flavor) {
case 'development':
return DummyQuoteRepository();
case 'staging':
return QuoteRepositoryImpl(
apiClient: StagingApiClient(),
localStorage: LocalStorage(),
networkInfo: NetworkInfo(),
);
case 'production':
return WebSocketQuoteRepository(
wsManager: ProductionWebSocketManager(),
);
default:
throw UnimplementedError();
}
}
}
테스트에서 활용
Dart
// test/quote_test.dart
void main() {
test('시세 조회 테스트', () async {
// Mock 구현체 사용
final mockRepo = MockQuoteRepository();
when(mockRepo.getLatestQuote('AAPL'))
.thenAnswer((_) async => Quote(
symbol: 'AAPL',
price: 150.0,
change: 2.5,
timestamp: DateTime.now(),
));
final quote = await mockRepo.getLatestQuote('AAPL');
expect(quote?.price, 150.0);
});
}
Repository 전환의 실제 사례
사례 1: 백엔드 마이그레이션
회사가 Firebase에서 자체 서버로 이전한다고 가정해봅시다.
변경 전:
Dart
final quoteRepositoryProvider = Provider<QuoteRepository>((ref) {
return FirebaseQuoteRepository();
});
변경 후:
Dart
final quoteRepositoryProvider = Provider<QuoteRepository>((ref) {
return ApiQuoteRepository(); // 한 줄만 변경!
});
UI와 비즈니스 로직은 전혀 수정하지 않아도 됩니다.
사례 2: A/B 테스트
두 가지 데이터 소스의 성능을 비교하고 싶을 때:
Dart
final quoteRepositoryProvider = Provider<QuoteRepository>((ref) {
final user = ref.read(userProvider);
// 사용자 ID로 A/B 그룹 분류
if (user.id.hashCode % 2 == 0) {
return WebSocketQuoteRepository(); // A그룹: WebSocket
} else {
return PollingQuoteRepository(); // B그룹: 폴링
}
});
사례 3: 점진적 마이그레이션
새로운 API를 단계적으로 적용:
Dart
class HybridQuoteRepository implements QuoteRepository {
final QuoteRepository _oldRepo;
final QuoteRepository _newRepo;
final FeatureFlag _featureFlag;
@override
Future<Quote?> getLatestQuote(String symbol) async {
if (_featureFlag.isEnabled('new_quote_api')) {
try {
return await _newRepo.getLatestQuote(symbol);
} catch (e) {
// 새 API 실패 시 구 API로 폴백
return await _oldRepo.getLatestQuote(symbol);
}
}
return await _oldRepo.getLatestQuote(symbol);
}
}
정리
Repository 패턴에서 인터페이스와 구현체를 분리하는 것은 단순한 설계 패턴이 아니라 미래의 변경에 대비하는 투자입니다.
핵심 원칙:
• 인터페이스는 "무엇을" 정의
• 구현체는 "어떻게" 실현
• 사용하는 쪽은 인터페이스만 의존
• 구현체는 언제든 교체 가능
이 패턴을 사용하면:
• 새로운 기술 도입이 쉬워집니다
• 테스트가 간단해집니다
• 팀 협업이 원활해집니다
• 코드 유지보수가 편해집니다
처음엔 인터페이스를 만드는 것이 번거롭게 느껴질 수 있지만, 프로젝트가 성장하면서 그 가치를 실감하게 될 것입니다.
핵심 원칙:
• 인터페이스는 "무엇을" 정의
• 구현체는 "어떻게" 실현
• 사용하는 쪽은 인터페이스만 의존
• 구현체는 언제든 교체 가능
이 패턴을 사용하면:
• 새로운 기술 도입이 쉬워집니다
• 테스트가 간단해집니다
• 팀 협업이 원활해집니다
• 코드 유지보수가 편해집니다
처음엔 인터페이스를 만드는 것이 번거롭게 느껴질 수 있지만, 프로젝트가 성장하면서 그 가치를 실감하게 될 것입니다.