개발

Repository 구현 실전: 인터페이스와 구현체 분리

더미 데이터부터 실제 API까지, 구현체를 자유자재로 교체하는 방법

2025년 9월 18일
30분 읽기
Repository 구현 실전: 인터페이스와 구현체 분리

왜 인터페이스를 먼저 정의하는가?

Repository를 만들 때 왜 인터페이스(abstract class)부터 만들까요? 바로 코드를 작성하면 안 될까요?

계약을 먼저 정하는 이유

인터페이스는 "계약서"와 같습니다. 집을 지을 때 설계도를 먼저 그리듯, 코드도 "무엇을 할지"를 먼저 정의하고 "어떻게 할지"는 나중에 구현합니다.

인터페이스의 장점:

교체 가능성: 구현 방식을 바꿔도 사용하는 쪽은 수정 불필요
팀 협업: 인터페이스만 정해두면 동시에 개발 가능
테스트: Mock 구현체로 쉽게 테스트
명확한 의도: 무엇을 하는지 한눈에 파악

실제 시나리오

당신이 팀 리더고, 3명의 개발자와 함께 일한다고 상상해봅시다:

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 반환

다양한 구현체 전환

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 패턴에서 인터페이스와 구현체를 분리하는 것은 단순한 설계 패턴이 아니라 미래의 변경에 대비하는 투자입니다.

핵심 원칙:

• 인터페이스는 "무엇을" 정의
• 구현체는 "어떻게" 실현
• 사용하는 쪽은 인터페이스만 의존
• 구현체는 언제든 교체 가능

이 패턴을 사용하면:

• 새로운 기술 도입이 쉬워집니다
• 테스트가 간단해집니다
• 팀 협업이 원활해집니다
• 코드 유지보수가 편해집니다

처음엔 인터페이스를 만드는 것이 번거롭게 느껴질 수 있지만, 프로젝트가 성장하면서 그 가치를 실감하게 될 것입니다.
#Flutter
#Repository Pattern
#Clean Architecture
#Interface
#Implementation