Dart에서의 SOLID 원칙: 견고한 Flutter 앱 구축

발행: (2026년 2월 9일 오전 02:00 GMT+9)
12 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역하고 싶은 본문 내용을 알려주시면, 요청하신 대로 마크다운 형식과 코드 블록을 유지하면서 한국어로 번역해 드리겠습니다.

Source:

Flutter에서 SOLID 원칙

Flutter 개발이 빠르게 진행되는 환경에서는 비즈니스 로직과 UI 코드를 뒤섞기 쉽습니다. 앱이 성장함에 따라 이러한 “빠른 수정”은 단일 변경으로 무관한 기능까지 깨지는 경직된 코드베이스를 만들게 됩니다.

SOLID 원칙은 Dart 코드를 유연하고, 테스트 가능하며, 확장 가능하게 유지하도록 돕는 다섯 가지 설계 지침입니다. 아래는 각 원칙을 Flutter에 맞춰 살펴본 내용입니다.

1. 단일 책임 원칙 (SRP)

“클래스는 하나의, 그리고 오직 하나의, 변경 이유만을 가져야 한다.”

Flutter에서 가장 흔한 SRP 위반은 “갓 위젯” — API 호출, 비즈니스 로직, 복잡한 UI 렌더링을 모두 담당하는 단일 StatefulWidget입니다.

해결 방법 – 로직을 Controller 또는 BLoC 로 분리하고 UI는 StatelessWidget 에 두세요.

결과 – UI를 렌더링하지 않고도 비즈니스 로직을 테스트할 수 있습니다.

❌ BAD – SRP 위반

class User {
  String name;
  String email;

  User(this.name, this.email);

  // Responsibility 1: User data validation
  bool isValidEmail() {
    return email.contains('@') && email.contains('.');
  }

  // Responsibility 2: Database operations
  void saveToDatabase() {
    print('Saving user to database...');
    // Database logic here
  }

  // Responsibility 3: Email notifications
  void sendWelcomeEmail() {
    print('Sending welcome email to $email...');
    // Email sending logic here
  }

  // Responsibility 4: Logging
  void logUserActivity(String activity) {
    print('[$name] $activity');
    // Logging logic here
  }
}

✅ GOOD – SRP 적용

// Responsibility: Hold user data
class User {
  final String name;
  final String email;

  User(this.name, this.email);
}

// Responsibility: Validate user data
class UserValidator {
  bool isValidEmail(String email) =>
      email.contains('@') && email.contains('.');

  bool isValidName(String name) => name.isNotEmpty && name.length >= 2;
}

// Responsibility: Handle database operations for users
class UserRepository {
  void save(User user) {
    print('Saving user ${user.name} to database...');
    // Database logic here
  }

  User? findByEmail(String email) {
    print('Finding user by email: $email');
    // Database query logic here
    return null;
  }
}

// Responsibility: Send email notifications
class EmailService {
  void sendWelcomeEmail(User user) {
    print('Sending welcome email to ${user.email}...');
    // Email sending logic here
  }

  void sendPasswordResetEmail(User user) {
    print('Sending password reset email to ${user.email}...');
    // Email sending logic here
  }
}

// Responsibility: Log application activities
class Logger {
  void logUserActivity(String userName, String activity) {
    final timestamp = DateTime.now();
    print('[$timestamp] [$userName] $activity');
    // Logging logic here
  }

  void logError(String error) {
    print('[ERROR] $error');
    // Error logging logic here
  }
}

2. 개방/폐쇄 원칙 (OCP)

“소프트웨어 엔터티는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.”

새로운 결제 방식을 추가해야 할 때 기존 PaymentProcessor 클래스를 수정해서는 안 됩니다.

해결 방법추상 클래스(인터페이스)를 사용해 계약을 정의하고, 구체 구현이 이를 확장하도록 합니다.

❌ BAD – OCP 위반

class PaymentProcessor {
  void processPayment(String paymentType, double amount) {
    if (paymentType == 'creditCard') {
      print('Processing credit card payment of \$$amount');
      // Credit card processing logic
    } else if (paymentType == 'paypal') {
      print('Processing PayPal payment of \$$amount');
      // PayPal processing logic
    } else if (paymentType == 'stripe') {
      print('Processing Stripe payment of \$$amount');
      // Stripe processing logic
    }
    // Every time we add a new payment method, we need to modify this class!
  }
}

✅ GOOD – OCP 적용

// Abstract base class – CLOSED for modification
abstrac

### 3. 리스코프 치환 원칙 (LSP)  

> *“슈퍼클래스의 객체는 그 서브클래스 객체로 교체해도 애플리케이션이 깨지 않아야 한다.”*  

Dart에서 `Square`가 `Rectangle`을 상속받지만 `Square`의 너비를 설정하면 높이도 함께 변경되는 경우, 서브클래스가 기본 클래스와 같은 동작을 하지 않게 되며 이는 전형적인 LSP 위반 사례입니다.

#### ❌ BADLSP 위반 (Square / Rectangle)  

```dart
class Rectangle {
  double width;
  double height;

  Rectangle(this.width, this.height);

  void setWidth(double w) => width = w;
  void setHeight(double h) => height = h;

  double area() => width * height;
}

// Square inherits from Rectangle but forces width == height
class Square extends Rectangle {
  Square(double side) : super(side, side);

  @override
  void setWidth(double w) {
    width = w;
    height = w; // Enforces square shape – breaks LSP
  }

  @override
  void setHeight(double h) {
    width = h;
    height = h; // Enforces square shape – breaks LSP
  }
}

✅ GOOD – LSP를 준수하는 설계

// Define a shape contract that does not assume mutable dimensions
abstract class Shape {
  double area();
}

// Rectangle implements Shape
class Rectangle implements Shape {
  final double width;
  final double height;

  Rectangle(this.width, this.height);

  @override
  double area() => width * height;
}

// Square implements Shape independently (no inheritance from Rectangle)
class Square implements Shape {
  final double side;

  Square(this.side);

  @override
  double area() => side * side;
}

// Client code can use any Shape without caring about its concrete type
void printArea(Shape shape) {
  print('Area: ${shape.area()}');
}

남은 SOLID 원칙들(인터페이스 분리 원칙 & 의존성 역전 원칙)에도 동일한 정리 방식을 적용하여 Flutter 코드베이스를 견고하고 유지보수하기 쉽게 만들어 보세요.

Dart의 SOLID 원칙

아래는 세 가지 SOLID 원칙—리코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 의존성 역전 원칙(DIP)—에 대한 잘못된 구현과 올바른 구현을 간결하게 보여주는 예시입니다. 코드는 Dart로 유지되며 원본 내용의 구조를 그대로 보존합니다.

1. 리코프 치환 원칙 (LSP)

“슈퍼클래스의 객체는 서브클래스 객체로 교체해도 프로그램의 정확성에 영향을 주어서는 안 된다.”

❌ 나쁨 – LSP 위반

class BadQuadrilateral {
  double _width;
  double _height;

  BadQuadrilateral(this._width, this._height);

  double get width => _width;
  double get height => _height;

  set width(double value) => _width = value;
  set height(double value) => _height = value;

  double get area => _width * _height;
}

// VIOLATION: Square changes the behavior of BadQuadrilateral
class BadSquare extends BadQuadrilateral {
  BadSquare(double side) : super(side, side);

  // VIOLATION: Setting width also changes height!
  @override
  set width(double value) {
    _width = value;
    _height = value; // side‑effect that violates LSP
  }

  // VIOLATION: Setting height also changes width!
  @override
  set height(double value) {
    _width = value;   // side‑effect that violates LSP
    _height = value;
  }
}

// This breaks when we substitute Square for BadQuadrilateral
void demonstrateBadLSP() {
  BadQuadrilateral rect = BadQuadrilateral(5, 10);
  print('Rectangle: ${rect.width} x ${rect.height} = ${rect.area}'); // 5 x 10 = 50

  rect.width = 20;
  print('After width change: ${rect.width} x ${rect.height} = ${rect.area}'); // 20 x 10 = 200

  // Now substitute with Square – BREAKS!
  BadQuadrilateral square = BadSquare(5);
  print('\nSquare: ${square.width} x ${square.height} = ${square.area}'); // 5 x 5 = 25

  square.width = 20;
  // Expected: 20 x 5 = 100 (if it truly behaved like BadQuadrilateral)
  // Actual:   20 x 20 = 400 (BROKEN!)
  print('After width change: ${square.width} x ${square.height} = ${square.area}');
}

✅ 좋음 – LSP 준수 설계

abstract class Shape {
  double get area;
  String get description;
}

class GoodRectangle implements Shape {
  final double width;
  final double height;

  GoodRectangle(this.width, this.height);

  @override
  double get area => width * height;

  @override
  String get description => 'Rectangle: $width x $height';
}

class GoodSquare implements Shape {
  final double side;

  GoodSquare(this.side);

  @override
  double get area => side * side;

  @override
  String get description => 'Square: $side x $side';
}

void demonstrateGoodLSP() {
  void printShapeInfo(Shape shape) {
    print('${shape.description} = Area: ${shape.area}');
  }

  // Both work perfectly when treated as Shape
  Shape rect = GoodRectangle(5, 10);
  Shape square = GoodSquare(5);

  printShapeInfo(rect);
  printShapeInfo(square);
}

2. 인터페이스 분리 원칙 (ISP)

“클라이언트는 사용하지 않는 인터페이스에 의존하도록 강요받아서는 안 된다.”

❌ 나쁨 – 비대해진 인터페이스

abstract class SmartHomeDevice {
  void turnOn();
  void turnOff();
  void setTemperature(double temp); // Not all devices need this
}

✅ 좋음 – 분리된 인터페이스

abstract class Switchable {
  void turnOn();
  void turnOff();
}

abstract class Thermostatic {
  void setTemperature(double temp);
}

이제 LightBulbSwitchable만 구현하고, ThermostatSwitchableThermostatic을 모두 구현할 수 있습니다.

3. 의존성 역전 원칙 (DIP)

“구체적인 구현이 아니라 추상화에 의존해야 한다.”

❌ 나쁨 – 구체 클래스에 직접 의존

class FirebaseDatabaseService {
  Future<List<String>> fetchUsers() async {
    print('Firebase: Fetching users');
    await Future.delayed(const Duration(seconds: 1));
    return ['User1', 'User2', 'User3'];
  }

  Future<void> saveUser(String name) async {
    print('Firebase: Saving user $name');
    await Future.delayed(const Duration(seconds: 1));
  }
}

✅ 좋은 – 추상화에 의존

e) async {
    print('Firebase: Saving user $name');
    await Future.delayed(const Duration(milliseconds: 500));
  }
}

// BAD: ViewModel depends directly on the concrete Firebase implementation
class BadUserViewModel {
  final FirebaseDatabaseService _dbService = FirebaseDatabaseService();

  Future<List<String>> getUsers() async => await _dbService.fetchUsers();

  Future<void> addUser(String name) async => await _dbService.saveUser(name);
}

✅ 좋은 – 추상화에 의존

// Abstractions (interfaces)
abstract class DatabaseService {
  Future<List<String>> fetchUsers();
  Future<void> saveUser(String name);
  Future<void> deleteUser(String name);
}

// Concrete implementation #1
class FirebaseDatabase implements DatabaseService {
  @override
  Future<List<String>> fetchUsers() async {
    print('Firebase: Fetching users');
    await Future.delayed(const Duration(seconds: 1));
    return ['Alice', 'Bob', 'Charlie'];
  }

  @override
  Future<void> saveUser(String name) async {
    print('Firebase: Saving user $name');
    await Future.delayed(const Duration(milliseconds: 500));
  }

  @override
  Future<void> deleteUser(String name) async {
    print('Firebase: Deleting user $name');
    await Future.delayed(const Duration(milliseconds: 500));
  }
}

// Concrete implementation #2 (easy to swap!)
class LocalDatabase implements DatabaseService {
  final List<String> _users = ['User1', 'User2'];

  @override
  Future<List<String>> fetchUsers() async {
    print('Local: Fetching users');
    return List.from(_users);
  }

  @override
  Future<void> saveUser(String name) async {
    print('Local: Saving user $name');
    _users.add(name);
  }

  @override
  Future<void> deleteUser(String name) async {
    print('Local: Deleting user $name');
    _users.remove(name);
  }
}

// GOOD: ViewModel depends on the abstraction, not the concrete class
class GoodUserViewModel {
  final DatabaseService _dbService;

  // Dependency injection (constructor injection shown here)
  GoodUserViewModel(this._dbService);

  Future<List<String>> getUsers() async => await _dbService.fetchUsers();

  Future<void> addUser(String name) async => await _dbService.saveUser(name);

  Future<void> removeUser(String name) async => await _dbService.deleteUser(name);
}
// BAD: ViewModel directly depends on a concrete implementation
class UserViewModel {
  final DatabaseService _databaseService = DatabaseService();

  Future<List<String>> getUsers() async {
    return await _databaseService.fetchUsers();
  }

  Future<void> addUser(String name) async {
    await _databaseService.saveUser(name);
  }

  void removeUser(String name) {
    _users.remove(name);
  }
}

// GOOD: ViewModels depend on abstractions
class UserViewModel {
  final DatabaseService _databaseService;

  UserViewModel(this._databaseService); // Dependency injection

  Future<List<String>> getUsers() async {
    return await _databaseService.fetchUsers();
  }

  Future<void> addUser(String name) async {
    await _databaseService.saveUser(name);
  }
}

결론

Dart에서 SOLID를 적용하는 것은 교조적인 규칙을 따르는 것이 아니라 변화 비용을 줄이는 것입니다. Flutter 컴포넌트가 분리되고 전문화되도록 함으로써, 시장의 요구에 맞춰 빠르게 진화할 수 있는 앱을 구축하게 됩니다.

Back to Blog

관련 글

더 보기 »