개발

Repository 추상화와 DTO/Entity 분리: 현실적인 사용 가이드

과도한 추상화를 피하고 진짜 필요한 경우에만 적용하는 방법

2025년 9월 19일
18분 읽기
Repository 추상화와 DTO/Entity 분리: 현실적인 사용 가이드

Clean Architecture의 함정: 과도한 추상화

Clean Architecture를 학습하면서 "모든 Repository는 추상화해야 한다", "DTO와 Entity는 반드시 분리해야 한다"는 원칙을 배웁니다. 하지만 이를 무분별하게 적용하면 오히려 복잡도만 증가하고 개발 생산성이 떨어집니다.

실무에서는 정말 필요한 경우에만 이런 패턴을 적용하는 것이 현명합니다.

1. Repository 추상화: 언제 해야 하고 언제 하지 말아야 하는가

기본 접근법: 구체 클래스로 시작

대부분의 경우 추상화 없이 구체 클래스로 시작하는 것이 좋습니다.
Dart
class UserRepository {
  final ApiClient _apiClient;
  
  UserRepository(this._apiClient);
  
  Future<User> getUser(String id) async {
    final response = await _apiClient.get('/users/$id');
    return User.fromJson(response.data);
  }
  
  Future<void> saveUser(User user) async {
    await _apiClient.post('/users', user.toJson());
  }
}

// Provider 등록
final userRepositoryProvider = Provider((ref) {
  return UserRepository(ref.read(apiClientProvider));
});

// 사용
final user = await ref.read(userRepositoryProvider).getUser('123');
장점:
• 빠른 개발 속도
• 코드 추적이 쉬움
• 보일러플레이트 최소화
• 디버깅이 간단함

추상화가 진짜 필요한 경우

케이스 1: 온라인/오프라인 모드 지원

Dart
// ✅ 실제로 여러 구현체가 필요한 경우
abstract class DataRepository {
  Future<List<Data>> getData();
  Future<void> saveData(Data data);
}

class OnlineDataRepository implements DataRepository {
  @override
  Future<List<Data>> getData() async {
    // REST API 호출
    final response = await apiClient.get('/data');
    return response.data.map((json) => Data.fromJson(json)).toList();
  }
  
  @override
  Future<void> saveData(Data data) async {
    await apiClient.post('/data', data.toJson());
  }
}

class OfflineDataRepository implements DataRepository {
  @override
  Future<List<Data>> getData() async {
    // SQLite에서 조회
    final db = await database;
    final maps = await db.query('data');
    return maps.map((map) => Data.fromJson(map)).toList();
  }
  
  @override
  Future<void> saveData(Data data) async {
    final db = await database;
    await db.insert('data', data.toJson());
  }
}

// 런타임에 구현체 선택
final dataRepositoryProvider = Provider<DataRepository>((ref) {
  final isOnline = ref.watch(connectivityProvider);
  
  return isOnline 
      ? OnlineDataRepository(ref.read(apiClientProvider))
      : OfflineDataRepository(ref.read(databaseProvider));
});

케이스 2: 여러 결제 시스템 지원

Dart
// ✅ 실제로 구현체가 여러 개 필요
abstract class PaymentRepository {
  Future<PaymentResult> processPayment(PaymentRequest request);
}

class StripePaymentRepository implements PaymentRepository {
  @override
  Future<PaymentResult> processPayment(PaymentRequest request) async {
    final charge = await stripeApi.createCharge(
      amount: request.amount,
      currency: request.currency,
      source: request.cardToken,
    );
    
    return PaymentResult(
      success: charge.status == 'succeeded',
      transactionId: charge.id,
    );
  }
}

class PayPalPaymentRepository implements PaymentRepository {
  @override
  Future<PaymentResult> processPayment(PaymentRequest request) async {
    final payment = await paypalApi.createPayment(request);
    
    return PaymentResult(
      success: payment.state == 'approved',
      transactionId: payment.id,
    );
  }
}

// 사용자 선택에 따라 구현체 결정
final paymentRepositoryProvider = Provider<PaymentRepository>((ref) {
  final paymentMethod = ref.watch(selectedPaymentMethodProvider);
  
  switch (paymentMethod) {
    case PaymentMethod.stripe:
      return StripePaymentRepository();
    case PaymentMethod.paypal:
      return PayPalPaymentRepository();
    default:
      throw UnimplementedError();
  }
});

추상화하지 말아야 하는 경우

Dart
// ❌ 이런 경우는 추상화 불필요
class NewsRepository {
  Future<List<Article>> getArticles() async {
    final response = await http.get('/articles');
    return (response.data as List)
        .map((json) => Article.fromJson(json))
        .toList();
  }
}

// "나중에 SQLite로 바꿀 수도 있으니까" ← 대부분 안 바뀜
// "테스트를 위해서" ← Mock을 직접 만들면 됨

2. DTO/Entity 분리: 언제 해야 하고 언제 하지 말아야 하는가

기본 접근법: 하나의 모델로 통합

API 응답과 앱에서 사용하는 구조가 유사하다면 하나의 모델로 충분합니다.
Dart
class User {
  final String id;
  final String name;
  final String email;
  final String role;
  
  User({
    required this.id,
    required this.name,
    required this.email,
    required this.role,
  });
  
  // JSON 직렬화
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'],
      name: json['name'],
      email: json['email'],
      role: json['role'],
    );
  }
  
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'role': role,
    };
  }
  
  // 비즈니스 로직도 포함 가능
  bool get isAdmin => role == 'admin';
  String get displayName => name.isEmpty ? email.split('@')[0] : name;
}

// Repository에서 직접 사용
class UserRepository {
  Future<User> getUser(String id) async {
    final response = await apiClient.get('/users/$id');
    return User.fromJson(response.data);
  }
}

분리가 필요한 경우

케이스 1: API 스펙과 앱 구조가 다른 경우

Dart
// API 응답 (snake_case, 복잡한 구조)
class UserApiDto {
  final String user_id;
  final String full_name;
  final String email_address;
  final String account_type;
  final String registration_date;
  final Map<String, dynamic> user_preferences;
  
  UserApiDto.fromJson(Map<String, dynamic> json)
    : user_id = json['user_id'],
      full_name = json['full_name'],
      email_address = json['email_address'],
      account_type = json['account_type'],
      registration_date = json['registration_date'],
      user_preferences = json['user_preferences'];
}

// 앱에서 사용하는 모델 (camelCase, 비즈니스 로직)
class User {
  final String id;
  final String name;
  final String email;
  final UserRole role;
  final DateTime createdAt;
  final UserPreferences preferences;
  
  User({
    required this.id,
    required this.name,
    required this.email,
    required this.role,
    required this.createdAt,
    required this.preferences,
  });
  
  // 비즈니스 로직
  bool get isAdmin => role == UserRole.admin;
  bool get isRecentUser => DateTime.now().difference(createdAt).inDays < 30;
  String get displayName => name.isEmpty ? email.split('@')[0] : name;
  bool get hasCustomTheme => preferences.theme != ThemeType.default_;
}

// 변환 로직
extension UserApiDtoExtension on UserApiDto {
  User toEntity() {
    return User(
      id: user_id,
      name: full_name,
      email: email_address,
      role: _parseRole(account_type),
      createdAt: DateTime.parse(registration_date),
      preferences: UserPreferences.fromJson(user_preferences),
    );
  }
  
  UserRole _parseRole(String roleString) {
    switch (roleString.toUpperCase()) {
      case 'ADMINISTRATOR': return UserRole.admin;
      case 'PREMIUM_USER': return UserRole.premium;
      case 'BASIC_USER': return UserRole.basic;
      default: return UserRole.guest;
    }
  }
}

케이스 2: 복잡한 데이터 조합이 필요한 경우

Dart
// 여러 API 호출 결과를 조합하는 경우
class OrderApiDto {
  final String orderId;
  final String userId;
  final List<String> productIds;
  final double totalAmount;
}

class ProductApiDto {
  final String productId;
  final String name;
  final double price;
}

class UserApiDto {
  final String userId;
  final String name;
  final String email;
}

// 조합된 엔티티
class OrderDetail {
  final String orderId;
  final User customer;
  final List<Product> products;
  final double totalAmount;
  final DateTime orderDate;
  
  OrderDetail({
    required this.orderId,
    required this.customer,
    required this.products,
    required this.totalAmount,
    required this.orderDate,
  });
  
  // 계산된 속성들
  int get itemCount => products.fold(0, (sum, product) => sum + product.quantity);
  bool get isExpensive => totalAmount > 100000;
  String get statusText => isExpensive ? '고액 주문' : '일반 주문';
}

// Repository에서 조합 로직
class OrderRepository {
  Future<OrderDetail> getOrderDetail(String orderId) async {
    // 1. 주문 정보 조회
    final orderResponse = await apiClient.get('/orders/$orderId');
    final orderDto = OrderApiDto.fromJson(orderResponse.data);
    
    // 2. 고객 정보 조회
    final userResponse = await apiClient.get('/users/${orderDto.userId}');
    final userDto = UserApiDto.fromJson(userResponse.data);
    
    // 3. 상품 정보들 조회
    final productDtos = <ProductApiDto>[];
    for (final productId in orderDto.productIds) {
      final productResponse = await apiClient.get('/products/$productId');
      productDtos.add(ProductApiDto.fromJson(productResponse.data));
    }
    
    // 4. 엔티티로 조합
    return OrderDetail(
      orderId: orderDto.orderId,
      customer: userDto.toEntity(),
      products: productDtos.map((dto) => dto.toEntity()).toList(),
      totalAmount: orderDto.totalAmount,
      orderDate: DateTime.now(),
    );
  }
}

분리하지 말아야 하는 경우

Dart
// ❌ API와 앱 구조가 거의 동일한 경우
API Response: {
  "id": "123", 
  "name": "John", 
  "email": "john@example.com",
  "role": "user"
}

App Model: {
  "id": "123", 
  "name": "John", 
  "email": "john@example.com",
  "role": "user"
}
// → 하나의 모델로 충분

3. 실무 의사결정 가이드

Repository 추상화 체크리스트

다음 질문들을 통해 추상화 필요성을 판단하세요:

추상화를 고려해야 하는 경우 ✅
• 현재 구현체가 2개 이상인가?
• 3개월 내에 다른 구현체를 만들 계획이 확실한가?
• 온라인/오프라인 모드를 지원해야 하는가?
• 여러 외부 서비스를 조건부로 사용해야 하는가?

2개 이상 해당되면 추상화를 고려하세요.

추상화하지 말아야 하는 경우 ❌
• "나중에 바뀔 수도 있으니까"라는 막연한 이유
• 단순히 테스트를 위해서
• HTTP API만 사용하는 일반적인 앱
• 구현체가 1개뿐인 경우

DTO/Entity 분리 체크리스트

다음 기준으로 분리 필요성을 판단하세요:

분리를 고려해야 하는 경우 ✅
• API 스펙과 앱 모델의 필드명이 다른가?
• API 응답을 가공해서 사용해야 하는가?
• 여러 API 응답을 조합해야 하는가?
• 복잡한 비즈니스 로직이 포함되는가?

2개 이상 해당되면 분리를 고려하세요.

분리하지 말아야 하는 경우 ❌
• API 응답과 앱 모델 구조가 동일한 경우
• 단순한 필드명 변경만 필요한 경우
• 복잡한 로직이 없는 단순 데이터 전달용

4. 점진적 도입 전략

1단계: 단순하게 시작

Dart
class UserRepository {
  Future<User> getUser(String id) async {
    final response = await apiClient.get('/users/$id');
    return User.fromJson(response.data);
  }
}

class User {
  // JSON 직렬화 + 비즈니스 로직 모두 포함
  factory User.fromJson(Map<String, dynamic> json) { }
  Map<String, dynamic> toJson() { }
  bool get isAdmin => role == 'admin';
}

2단계: 필요시 추상화

Dart
// 실제로 온라인/오프라인 구현이 필요해지면
abstract class UserRepository {
  Future<User> getUser(String id);
}

class OnlineUserRepository implements UserRepository {
  @override
  Future<User> getUser(String id) async {
    // HTTP API 구현
  }
}

class OfflineUserRepository implements UserRepository {
  @override
  Future<User> getUser(String id) async {
    // SQLite 구현
  }
}

3단계: 필요시 분리

Dart
// API 스펙이 복잡해지면
class UserApiDto {
  // API 응답 구조
  final String user_id;
  final String full_name;
  final String account_type;
}

class User {
  // 비즈니스 모델
  final String id;
  final String name;
  final UserRole role;
}

extension UserApiDtoExtension on UserApiDto {
  User toEntity() {
    return User(
      id: user_id,
      name: full_name,
      role: _parseRole(account_type),
    );
  }
}

5. 실제 프로젝트 예시

소규모 프로젝트 (MVP/스타트업)

Dart
// ✅ 단순한 구조로 시작
class ProductRepository {
  final ApiClient _apiClient;
  
  ProductRepository(this._apiClient);
  
  Future<List<Product>> getProducts() async {
    final response = await _apiClient.get('/products');
    return (response.data as List)
        .map((json) => Product.fromJson(json))
        .toList();
  }
  
  Future<Product> getProduct(String id) async {
    final response = await _apiClient.get('/products/$id');
    return Product.fromJson(response.data);
  }
}

class Product {
  final String id;
  final String name;
  final double price;
  final String imageUrl;
  
  Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
  });
  
  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'],
      name: json['name'],
      price: json['price'].toDouble(),
      imageUrl: json['imageUrl'],
    );
  }
  
  // 비즈니스 로직
  bool get isExpensive => price > 100000;
  String get formattedPrice => '₩${price.toStringAsFixed(0)}';
}

대규모 프로젝트 (복잡한 요구사항)

Dart
// ✅ 복잡한 요구사항에 따른 추상화
abstract class ProductRepository {
  Future<List<Product>> getProducts();
  Future<Product> getProduct(String id);
}

class ApiProductRepository implements ProductRepository {
  @override
  Future<List<Product>> getProducts() async {
    final response = await apiClient.get('/products');
    return (response.data as List)
        .map((json) => ProductApiDto.fromJson(json).toEntity())
        .toList();
  }
}

class CacheProductRepository implements ProductRepository {
  @override
  Future<List<Product>> getProducts() async {
    final cached = await cache.get('products');
    if (cached != null) {
      return (cached as List)
          .map((json) => Product.fromJson(json))
          .toList();
    }
    
    // 캐시 없으면 API 호출
    final products = await apiRepository.getProducts();
    await cache.set('products', products.map((p) => p.toJson()).toList());
    return products;
  }
}

// DTO/Entity 분리
class ProductApiDto {
  final String product_id;
  final String product_name;
  final double unit_price;
  final String thumbnail_url;
  final String category_code;
  final bool is_available;
  
  ProductApiDto.fromJson(Map<String, dynamic> json)
    : product_id = json['product_id'],
      product_name = json['product_name'],
      unit_price = json['unit_price'],
      thumbnail_url = json['thumbnail_url'],
      category_code = json['category_code'],
      is_available = json['is_available'];
  
  Product toEntity() {
    return Product(
      id: product_id,
      name: product_name,
      price: unit_price,
      imageUrl: thumbnail_url,
      category: _parseCategory(category_code),
      isAvailable: is_available,
    );
  }
  
  ProductCategory _parseCategory(String code) {
    switch (code) {
      case 'ELEC': return ProductCategory.electronics;
      case 'CLTH': return ProductCategory.clothing;
      case 'BOOK': return ProductCategory.books;
      default: return ProductCategory.others;
    }
  }
}

6. 흔한 실수들과 해결법

실수 1: 모든 Repository를 추상화

Dart
// ❌ 불필요한 추상화
abstract class UserRepository {
  Future<User> getUser(String id);
}

abstract class ProductRepository {
  Future<List<Product>> getProducts();
}

abstract class CategoryRepository {
  Future<List<Category>> getCategories();
}

// 모든 Repository가 구현체 1개씩만 가짐...

// ✅ 필요한 것만 추상화
class UserRepository {
  // 단순한 HTTP API만 사용
}

class ProductRepository {
  // 단순한 HTTP API만 사용
}

abstract class PaymentRepository {
  // 실제로 여러 구현체 필요
}

실수 2: 모든 모델을 DTO/Entity로 분리

Dart
// ❌ 불필요한 분리
class UserDto {
  final String name;
  final String email;
}

class User {
  final String name;  // 똑같은 구조...
  final String email;
}

// ✅ 구조가 같으면 통합
class User {
  final String name;
  final String email;
  
  factory User.fromJson(Map<String, dynamic> json) { }
  Map<String, dynamic> toJson() { }
  
  // 비즈니스 로직 포함
  bool get hasValidEmail => email.contains('@');
}

결론

Repository 추상화와 DTO/Entity 분리는 강력한 패턴이지만, 무분별하게 사용하면 오히려 복잡도만 증가합니다.

핵심 원칙:

YAGNI (You Aren't Gonna Need It) 준수
실제 필요가 생겼을 때 도입
API 스펙과 앱 구조의 차이가 분리 기준
점진적 도입으로 복잡도 관리

실무 가이드라인:

• 시작은 항상 단순하게 (구체 클래스 + 통합 모델)
• 실제 필요가 생기면 점진적으로 확장
• 팀의 개발 속도와 유지보수성의 균형점 찾기
• "나중에"가 아닌 "지금 당장" 필요한지 판단

좋은 아키텍처는 복잡함을 숨기는 것이 아니라, 복잡함을 적절히 관리하는 것입니다. 과도한 추상화보다는 실용적인 접근법을 택하는 것이 더 나은 결과를 가져옵니다.
#Flutter
#Clean Architecture
#Repository Pattern
#DTO
#Entity