[Flutter] 앱 개발을 위한 클린 아키텍처 기본기 다지기 - 1단계

2025. 5. 2. 16:33·Flutter/Basic Knowledge

- LLM Model(ChatGPT, Claude)을 통해 생성된 정보를 바탕으로 제가 읽고 공부하며 수정한 글입니다.

- 클린 아키텍쳐를 Flutter 프로젝트에 적용해보기 위한 기초 공부 글 입니다.

- 제가 첨언한 부분은 기울여져 있습니다.


TL;DR

클린 아키텍처는 Flutter 앱을 유지보수하기 쉽고, 테스트 가능하며, 확장성 있게 만드는 설계 방법론이야. 로버트 마틴이 제안한 이 방법론은 비즈니스 로직을 UI나 외부 요소로부터 독립시켜서 앱의 핵심 가치를 보호해. SOLID 원칙을 기반으로 하고, 계층 구조를 통해 의존성 방향을 제어함으로써 Flutter 프로젝트의 복잡성을 관리하고 장기적 유지보수성을 향상시킬 수 있어.

-> 목표: Flutter 앱을 유지보수하기 쉽고, 테스트 가능하며, 확장성 있게 만드는것

-> 내용: SOLID 원칙, 계층 구조를 통해 의존성의 방향 제어가 중요한 내용인 것 같음.

목차

  1. 클린 아키텍처 - 왜 필요한가?
  2. SOLID 원칙 - 클린 아키텍처의 기초
  3. 클린 아키텍처 구조
  4. Flutter에서의 적용 고려사항
  5. 마무리

클린 아키텍처 - 왜 필요한가?

Flutter로 앱을 만들다 보면 처음엔 다 좋아. 몇 개의 위젯으로 시작해서 빠르게 화면을 만들고, 기능도 척척 돌아가지. 하지만 시간이 지나면서 기능이 추가되고, 상태 관리가 복잡해지고, 외부 API도 연동하다 보면... 어느 순간 코드가 스파게티가 되어버려. 작은 변경사항 하나 수정하려고 여러 파일을 뒤져야 하고, 테스트도 어렵고, 새로운 기능 추가는 더더욱 고통스러워지지.

이런 문제를 해결하기 위해 로버트 C. 마틴(일명 Uncle Bob)이 제안한 게 바로 클린 아키텍처야. 2017년 '클린 아키텍처' 책에서 체계화된 이 개념의 핵심은 내 앱의 중요한 비즈니스 로직을 외부 의존성으로부터 보호하는 것이야.

🔥 핵심 원칙: "프레임워크에 의존하지 말고, 프레임워크가 너에게 의존하게 만들어라."

Flutter 앱 개발에서 이게 무슨 의미냐면, UI(위젯), 상태 관리 라이브러리, HTTP 클라이언트, 로컬 데이터베이스 등이 바뀌더라도 앱의 핵심 비즈니스 로직은 그대로 유지될 수 있도록 설계하라는 거야.

 

이해를 돕기 위한 예시를 보여드리겠습니다.

// 도메인 계층의 인터페이스 (추상화)
abstract class UserRepository {
  Future<User> getUserById(String id);
  Future<List<User>> getAllUsers();
  Future<void> saveUser(User user);
}

// 비즈니스 로직 (유스케이스)
class GetUserProfileUseCase {
  final UserRepository repository; // <-- 구체적인 구현체가 아닌 인터페이스에 의존
  
  GetUserProfileUseCase(this.repository);
  
  Future<User> execute(String userId) async {
    final user = await repository.getUserById(userId);
    if (user.isActive) {
      return user;
    } else {
      throw InactiveUserException();
    }
  }
}

 

자 이제 이런 상황에서 데이터베이스나 API를 바꿔야 하는 상황이 왔다고 가정해볼게요

// 원래 Firebase를 사용하는 구현체
class FirebaseUserRepository implements UserRepository {
  final FirebaseFirestore _firestore;
  
  FirebaseUserRepository(this._firestore);
  
  @override
  Future<User> getUserById(String id) async {
    final doc = await _firestore.collection('users').doc(id).get();
    return UserModel.fromFirebase(doc);
  }
  
  // 다른 메서드 구현...
}

// 새로운 REST API를 사용하는 구현체
class ApiUserRepository implements UserRepository {
  final HttpClient _client;
  
  ApiUserRepository(this._client);
  
  @override
  Future<User> getUserById(String id) async {
    final response = await _client.get('/users/$id');
    return UserModel.fromJson(response.data);
  }
  
  // 다른 메서드 구현...
}


데이터 소스를 Firebase에서 REST API로 완전히 교체하더라도, GetUserProfileUseCase의 코드는 한 줄도 변경할 필요가 없습니다!

 

 

클린 아키텍처의 주요 목표

  1. 유지보수성: 코드 수정이 필요할 때 영향 범위를 최소화
  2. 테스트 용이성: UI나 데이터베이스 없이도 비즈니스 로직 테스트 가능
  3. 확장성: 새로운 기능 추가가 기존 코드에 미치는 영향 최소화
  4. 프레임워크 독립성: Flutter나 특정 패키지가 업데이트되더라도 핵심 로직은 보존
 
// 😱 나쁜 예: 비즈니스 로직이 UI와 혼합됨
class LoginScreen extends StatefulWidget {
  @override
  _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  
  void _login() async {
    // 비즈니스 로직이 UI 코드에 직접 포함됨
    if (!_emailController.text.contains('@')) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('올바른 이메일 형식이 아닙니다')),
      );
      return;
    }
    
    // HTTP 요청 직접 수행
    final response = await http.post(
      Uri.parse('https://api.myapp.com/login'),
      body: {
        'email': _emailController.text,
        'password': _passwordController.text,
      },
    );
    
    if (response.statusCode == 200) {
      // 로그인 성공 처리
      Navigator.pushReplacement(context, 
        MaterialPageRoute(builder: (_) => HomeScreen()));
    } else {
      // 오류 처리
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('로그인 실패: ${response.body}')),
      );
    }
  }
  
  @override
  Widget build(BuildContext context) {
    // UI 코드
  }
}

위 코드의 문제점:

  • 비즈니스 로직(이메일 유효성 검사, 로그인 처리)이 UI 코드와 뒤섞여 있음
  • HTTP 클라이언트에 직접 의존하여 테스트하기 어려움
  • 네비게이션 로직이 비즈니스 로직과 결합됨

SOLID 원칙 - 클린 아키텍처의 기초

SOLID 원칙은 클린 아키텍처의 기반이 되는 객체 지향 설계 원칙이야. Flutter에서도 이 원칙들을 적용하면 더 견고한 앱을 만들 수 있어.

단일 책임 원칙(Single Responsibility Principle, SRP)

한 클래스는 오직 하나의 변경 이유만 가져야 한다.

🔥 핵심 질문: "이 클래스를 변경해야 할 이유는 무엇인가? 그 이유가 둘 이상이라면, 클래스를 분리하라."

// 😱 나쁜 예: 여러 책임을 가진 클래스
class User {
  final String email;
  final String password;
  
  User(this.email, this.password);
  
  bool validateEmail() {
    return email.contains('@');
  }
  
  Future<bool> save() async {
    // 데이터베이스에 사용자 저장
    final db = await openDatabase('my_app.db');
    await db.insert('users', {'email': email, 'password': password});
    return true;
  }
  
  Future<void> sendWelcomeEmail() async {
    // 이메일 전송 로직
    await http.post(
      Uri.parse('https://api.myapp.com/send-email'),
      body: {'to': email, 'template': 'welcome'},
    );
  }
}

// 😎 좋은 예: 단일 책임을 가진 클래스들
class User {
  final String email;
  final String password;
  
  User(this.email, this.password);
  
  bool isEmailValid() {
    return email.contains('@');
  }
}

class UserRepository {
  Future<bool> saveUser(User user) async {
    // 데이터베이스에 사용자 저장
    return true;
  }
}

class EmailService {
  Future<void> sendWelcomeEmail(String email) async {
    // 이메일 전송 로직
  }
}

 

내 경험상, 이러한 것들은 Service, Repository 레이어 코드들은 CRUD의 범주 내에서 거의 묶이는 것 같음. 이외에는 사람마다 묶는 법은 다 다른 것 같은데 대부분 기능 혹은 화면 별로 묶는 경우가 많은 듯 싶다.

 

여기서 강조하는건 코드를 클래스로 묶을 때, 하나의 클래스가 하나의 책임 (데이터 모델로서의 책임, 정보 저장 로직 및 환영 메세지 전송 책임 -> 중복) 만 가져야 한다는 것이다.

 

즉, 클래스를 새로 생성한다면 이 클래스는 어떤 역할을 갖는지, 그리고 역할을 2개 이상 갖고 있지는 않는지 검토하는 습관을 들이면 좋을. 것 같다.

 

개방-폐쇄 원칙(Open-Closed Principle, OCP)

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

🔥 핵심 포인트: "기능을 추가할 때 기존 코드를 수정하지 않고도 할 수 있어야 한다."

 
// 😱 나쁜 예: OCP 위반
class PaymentProcessor {
  Future<bool> processPayment(String type, double amount) async {
    if (type == 'credit_card') {
      // 신용카드 결제 처리
      return await processCreditCardPayment(amount);
    } else if (type == 'paypal') {
      // PayPal 결제 처리
      return await processPayPalPayment(amount);
    }
    // 새로운 결제 방식을 추가하려면 이 클래스를 수정해야 함
    return false;
  }
  
  Future<bool> processCreditCardPayment(double amount) async {
    // 신용카드 결제 처리 로직
    return true;
  }
  
  Future<bool> processPayPalPayment(double amount) async {
    // PayPal 결제 처리 로직
    return true;
  }
}

// 😎 좋은 예: OCP 준수
abstract class PaymentMethod {
  Future<bool> processPayment(double amount);
}

class CreditCardPayment implements PaymentMethod {
  @override
  Future<bool> processPayment(double amount) async {
    // 신용카드 결제 처리 로직
    return true;
  }
}

class PayPalPayment implements PaymentMethod {
  @override
  Future<bool> processPayment(double amount) async {
    // PayPal 결제 처리 로직
    return true;
  }
}

// 새로운 결제 방식을 추가할 때 기존 코드 수정 없이 확장 가능
class BlockchainPayment implements PaymentMethod {
  @override
  Future<bool> processPayment(double amount) async {
    // 블록체인 결제 처리 로직
    return true;
  }
}

// 결제 처리 클래스
class PaymentProcessor {
  final PaymentMethod paymentMethod;
  
  PaymentProcessor(this.paymentMethod);
  
  Future<bool> processPayment(double amount) {
    return paymentMethod.processPayment(amount);
  }
}

 

위의 코드를 보면 역할 분리는 잘 했지만, 기능을 추가하기 위해서는 기존의 class의 내용을 변경해야한다.

테스트 관점에서 봤을 때도 이런 식의 확장이 훨씬 깔끔하고 안전하다는 것을 알 수 있다.

(초기 프로젝트는 보통 MVP 모델이라 배포 이후 업데이트가 굉장히 많은데 ,이런 식으로 기능 추가를 한다면 test도 편리하고 명확한 구조를 가지고 프로젝트를 확장할 수 있을 것 같다)

 

이 부분 또한 클래스를 만들 때, 미래에 확장이 될 기능이면 OCP를 적용해서 구현해야겠는 생각을 해야겠다.

 

 

리스코프 치환 원칙(Liskov Substitution Principle, LSP)

상위 타입 객체를 하위 타입 객체로 대체해도 프로그램의 정확성이 유지되어야 한다.

🔥 핵심 원칙: "서브클래스는 기반 클래스의 계약을 반드시 지켜야 한다."

 

// 😱 나쁜 예: LSP 위반
class Rectangle {
  int width;
  int height;
  
  Rectangle(this.width, this.height);
  
  void setWidth(int width) {
    this.width = width;
  }
  
  void setHeight(int height) {
    this.height = height;
  }
  
  int getArea() {
    return width * height;
  }
}

class Square extends Rectangle {
  Square(int size) : super(size, size);
  
  @override
  void setWidth(int width) {
    super.setWidth(width);
    super.setHeight(width); // 정사각형이므로 높이도 같이 변경
  }
  
  @override
  void setHeight(int height) {
    super.setHeight(height);
    super.setWidth(height); // 정사각형이므로 너비도 같이 변경
  }
}

// 문제 상황:
void main() {
  Rectangle rect = Square(5);
  rect.setWidth(4);
  rect.setHeight(5);
  // rect.getArea()는 20이 아닌 25가 됨 (예상과 다른 결과)
}

// 😎 좋은 예: 공통 인터페이스 사용
abstract class Shape {
  int getArea();
}

class Rectangle implements Shape {
  int width;
  int height;
  
  Rectangle(this.width, this.height);
  
  void setWidth(int width) {
    this.width = width;
  }
  
  void setHeight(int height) {
    this.height = height;
  }
  
  @override
  int getArea() {
    return width * height;
  }
}

class Square implements Shape {
  int size;
  
  Square(this.size);
  
  void setSize(int size) {
    this.size = size;
  }
  
  @override
  int getArea() {
    return size * size;
  }
}

 

Rectangle(직사각형)을 확장해서 Square(정사각형)을 구현한 경우, getArea와 같은 함수에서 로직이 달라지기 때문에 Square에서 Rectangle의 getArea 함수를 사용하면 원하는 결과 값이 나오지 않게 된다.

 

때문에 이처럼 상속 관계에 호환이 안된다면 Interface를 만들어서 구현을 하는 편이 LSP를 지켜 구현했다고 할 수 있다.

 

인터페이스 분리 원칙(Interface Segregation Principle, ISP)

클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강요받으면 안 된다.

🔥 핵심 포인트: "뚱뚱한 인터페이스보다 작고 특화된 여러 인터페이스가 낫다."

 
// 😱 나쁜 예: 뚱뚱한 인터페이스
abstract class SmartDevice {
  void turnOn();
  void turnOff();
  void connectToWifi();
  void playMusic();
  void setAlarm();
  void makeCall();
}

// 구현 클래스는 불필요한 메서드까지 모두 구현해야 함
class SmartSpeaker implements SmartDevice {
  @override
  void turnOn() {
    // 구현
  }
  
  @override
  void turnOff() {
    // 구현
  }
  
  @override
  void connectToWifi() {
    // 구현
  }
  
  @override
  void playMusic() {
    // 구현
  }
  
  @override
  void setAlarm() {
    // 구현
  }
  
  @override
  void makeCall() {
    // 스피커는 전화 기능이 없지만 구현해야 함!
    throw UnimplementedError('스피커는 전화를 걸 수 없습니다');
  }
}

// 😎 좋은 예: 분리된 인터페이스
abstract class PowerDevice {
  void turnOn();
  void turnOff();
}

abstract class WifiEnabled {
  void connectToWifi();
}

abstract class MusicPlayer {
  void playMusic();
}

abstract class AlarmDevice {
  void setAlarm();
}

abstract class CallingDevice {
  void makeCall();
}

// 필요한 인터페이스만 구현
class SmartSpeaker implements PowerDevice, WifiEnabled, MusicPlayer, AlarmDevice {
  @override
  void turnOn() {
    // 구현
  }
  
  @override
  void turnOff() {
    // 구현
  }
  
  @override
  void connectToWifi() {
    // 구현
  }
  
  @override
  void playMusic() {
    // 구현
  }
  
  @override
  void setAlarm() {
    // 구현
  }
}

class SmartPhone implements PowerDevice, WifiEnabled, MusicPlayer, AlarmDevice, CallingDevice {
  // 모든 기능 구현
  @override
  void makeCall() {
    // 전화 걸기 구현
  }
  
  // 나머지 메서드 구현...
}

 

생각해보면 프로젝트에서 Interface를 만들어 사용한 경험부터 전무하지만, 이런 식으로 Interface를 각각 구분하여 하나의 Class에 implement 한다면 mocking(test) 및 유지보수 시에 강력할 것이라고 생각한다.

 

나는 코드의 재활용성에 대해서만 생각을 해서 이런 내용을 잘 적용하지 않았는데, 생각해보니 분리의 목적이 재상용성에만 있지는 않다는 것을 깨닫게 된 것 같다.

 

나도 리펙토링을 진행하며 하나의 repository나 service 레이어에 여러 함수가 뒤엉키며 고생했던 경험이 있기 때문에... 꼭 적용을 해보고 후기를 남기도록 하겠다.

 

 

의존성 역전 원칙(Dependency Inversion Principle, DIP)

고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.

🔥 핵심 원칙: "구체적인 구현보다 추상화에 의존하라. 의존성은 항상 추상화를 향해야 한다."

이 원칙은 클린 아키텍처에서 가장 중요한 원칙 중 하나로, 계층 간 의존성 방향을 제어하는 핵심이야.

 
dart
// 😱 나쁜 예: 직접적인 의존성
class UserService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  
  Future<User> login(String email, String password) async {
    // Firebase 인증 사용
    final userCredential = await _auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
    
    // Firestore에서 사용자 데이터 가져오기
    final doc = await _firestore.collection('users').doc(userCredential.user!.uid).get();
    
    return User(
      id: userCredential.user!.uid,
      name: doc.data()?['name'] ?? '',
      email: email,
    );
  }
}

// 😎 좋은 예: 추상화에 의존
// 인터페이스 정의
abstract class AuthService {
  Future<String> login(String email, String password);
}

abstract class UserRepository {
  Future<User> getUserById(String id);
}

// 고수준 모듈 (비즈니스 로직)
class UserUseCase {
  final AuthService _authService;
  final UserRepository _userRepository;
  
  UserUseCase(this._authService, this._userRepository);
  
  Future<User> login(String email, String password) async {
    final userId = await _authService.login(email, password);
    return await _userRepository.getUserById(userId);
  }
}

// 저수준 모듈 (구체적인 구현)
class FirebaseAuthService implements AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  
  @override
  Future<String> login(String email, String password) async {
    final userCredential = await _auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
    return userCredential.user!.uid;
  }
}

class FirestoreUserRepository implements UserRepository {
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  
  @override
  Future<User> getUserById(String id) async {
    final doc = await _firestore.collection('users').doc(id).get();
    return User(
      id: id,
      name: doc.data()?['name'] ?? '',
      email: doc.data()?['email'] ?? '',
    );
  }
}

 

이제 여기에 의존성 역전 원칙을 이용해서 위의 코드를 바탕으로 예시 코드를 만들어보았다.

// main.dart - 의존성 주입 및 사용 예시
void main() {
  // 의존성 설정
  final authService = FirebaseAuthService();
  final userRepository = FirestoreUserRepository();
  
  // 비즈니스 로직에 의존성 주입
  final userUseCase = UserUseCase(authService, userRepository);
  
  // 앱 실행
  runApp(MyApp(userUseCase: userUseCase));
}

class MyApp extends StatelessWidget {
  final UserUseCase userUseCase;
  
  const MyApp({Key? key, required this.userUseCase}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '클린 아키텍처 예시',
      home: LoginPage(userUseCase: userUseCase),
    );
  }
}

// login_page.dart - UI 계층
class LoginPage extends StatefulWidget {
  final UserUseCase userUseCase;
  
  const LoginPage({Key? key, required this.userUseCase}) : super(key: key);
  
  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLoading = false;
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('로그인')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _emailController,
              decoration: InputDecoration(labelText: '이메일'),
              keyboardType: TextInputType.emailAddress,
            ),
            SizedBox(height: 12),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: '비밀번호'),
              obscureText: true,
            ),
            SizedBox(height: 24),
            _isLoading
                ? CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: _login,
                    child: Text('로그인'),
                  ),
          ],
        ),
      ),
    );
  }
  
  Future<void> _login() async {
    setState(() {
      _isLoading = true;
    });
    
    try {
      // UserUseCase를 통해 로그인 - 구체적인 구현에 의존하지 않음
      final user = await widget.userUseCase.login(
        _emailController.text, 
        _passwordController.text
      );
      
      // 로그인 성공 처리
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(
          builder: (_) => HomePage(user: user),
        ),
      );
    } catch (e) {
      // 오류 처리
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('로그인 실패: ${e.toString()}')),
      );
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }
}

// home_page.dart - 로그인 성공 후 페이지
class HomePage extends StatelessWidget {
  final User user;
  
  const HomePage({Key? key, required this.user}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('홈')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('안녕하세요, ${user.name}님!'),
            Text('이메일: ${user.email}'),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 로그아웃 처리 등...
              },
              child: Text('로그아웃'),
            ),
          ],
        ),
      ),
    );
  }
}

// 테스트 예시 - 비즈니스 로직 테스트
void main() {
  test('로그인 성공 테스트', () async {
    // Mock 객체 생성
    final mockAuthService = MockAuthService();
    final mockUserRepository = MockUserRepository();
    
    // 테스트할 유스케이스
    final userUseCase = UserUseCase(mockAuthService, mockUserRepository);
    
    // Mock 동작 설정
    when(mockAuthService.login('test@example.com', 'password123'))
        .thenAnswer((_) async => 'user123');
        
    when(mockUserRepository.getUserById('user123'))
        .thenAnswer((_) async => User(
          id: 'user123', 
          name: '테스트 사용자', 
          email: 'test@example.com'
        ));
    
    // 테스트 실행
    final user = await userUseCase.login('test@example.com', 'password123');
    
    // 검증
    expect(user.id, equals('user123'));
    expect(user.name, equals('테스트 사용자'));
    expect(user.email, equals('test@example.com'));
    
    // Mock 호출 검증
    verify(mockAuthService.login('test@example.com', 'password123')).called(1);
    verify(mockUserRepository.getUserById('user123')).called(1);
  });
}

 

진짜 깔끔한 구조와 mock testing 방식이다..... 물론 DI와 관련해서는 여러 방법이 있지만 Flutter 기준 Riverpod 혹은 get_it 을 활용하여 따로 전용 클래스와 파일을 만들어서 관리하지만 해당 예시에서는 그냥 main에 러프하게 구현하였다.

 

여기서 나는 의문을 하나 품었다. 의존성 역전? 뭘 역전해? 어떻게 역전을 하는건데?

 

UI 계층 → 비즈니스 로직 계층 → 데이터 액세스 계층

 원래는 의존성이 '위에서 아래로' 흐릅니다. 상위 계층이 하위 계층에 직접 의존하는 구조죠

// 전통적인 방식
class LoginScreen {
  // LoginScreen이 직접 데이터베이스 구현에 의존
  final FirebaseAuth auth = FirebaseAuth.instance;
  
  void login() {
    // Firebase 코드 직접 사용
  }
}

이렇게 구현하면 로그인 기능은 무조건 Auth를 구현한 DB Access 방식에 의존적이게 되어버리는 것이죠. 여기에서는 Firebase에 의존적이게 되었네요.

 

의존성 역전 원칙을 적용하면 아래와 같은 그림이 되어야 합니다.

UI 계층 → 인터페이스 ← 데이터 액세스 계층
             ↑
      비즈니스 로직 계층
 

아래는 예시 코드 입니다.

// 비즈니스 로직 계층에서 인터페이스 정의
abstract class AuthService {
  Future<String> login(String email, String password);
}

// 비즈니스 로직 (인터페이스에 의존)
class LoginUseCase {
  final AuthService authService; // 구체적 구현이 아닌 인터페이스에 의존
  
  LoginUseCase(this.authService);
  
  Future<void> execute(String email, String password) async {
    await authService.login(email, password);
  }
}

// 데이터 액세스 계층에서 비즈니스 로직 계층의 인터페이스를 구현
class FirebaseAuthService implements AuthService {
  @override
  Future<String> login(String email, String password) async {
    // 구현...
  }
}


보이시나요... 비지니스 모델에서 이러이러한 기능이 필요하니 인터페이스를 이러이러한 인터페이스가 필요해. 하고 그냥 사용해버립니다. 이에 맞게 데이터 엑세스 계층에서는 비즈니스 로직이 필요한 Interface에 의존하여 적용을 하게 됩니다.

 

즉 데이터 엑세스 계층에서 구현을 해두고 비즈니스 모델에서 골라써! 이런 방식이 아니라, 비즈니스 모델이 이런것들이 필요하니 데이터 계층이 이를 따르도록 의존성을 역전 시켰다는 말입니다.

 

이런식의 구조를 사용하는 이유는

1. 테스트 용이성

2. 코드 유연성과 교체 용이성

3. 병렬 개발 가능

4. 확장성

정도가 있는데 아마 전부 엮여 있는 내용일거에요.

풀어서 설명해보자면

1. interface를 분리해서 테스트 시에 독립적으로 테스트가 가능하고,

2. 코드를 수정할 일이 생겼을 때 interface 별로 수정할 수 있고,

3. 비즈니스 모델을 구현하는 사람과 interface를 구현하는 사람이 interface만 합의한다면 병렬 개발이 가능하고,

4. db 엑세스 방식을 늘리고 싶은 경우 interface만 추가적으로 구현하여 데이터 엑세스 레이어를 추가하면 되기 때문에,

 

이러한 이점을 얻기 위해서 의존성을 역전한다고 생각하면 될 것 같습니다. (이외에도 엄청나게 많은 이점에 대해 설명합니다.)

 

 

클린 아키텍처 구조

이제 클린 아키텍처의 실제 구조를 살펴볼 차례야. 로버트 마틴의 클린 아키텍처는 동심원 구조로 표현되며, 안쪽에서 바깥쪽으로 의존성 방향이 흐르도록 설계돼.

계층 구조

  1. 엔티티 (Entities): 비즈니스 객체와 핵심 규칙
  2. 유스케이스 (Use Cases): 애플리케이션의 비즈니스 로직
  3. 인터페이스 어댑터 (Interface Adapters): 외부 요소와 내부 로직 간의 변환 계층
  4. 프레임워크와 드라이버 (Frameworks & Drivers): UI, 데이터베이스, 외부 API 등

🔥 가장 중요한 규칙: "의존성은 항상 바깥쪽에서 안쪽으로 향해야 한다. 내부 원은 외부 원에 대해 아무것도 알면 안 된다."

Flutter 프로젝트에서는 이 구조를 다음과 같이 적용할 수 있어:

lib/
├── core/              # 공통 코드, 유틸리티, 상수 등
├── domain/            # 엔티티 + 유스케이스 + 리포지토리 인터페이스
│   ├── entities/      # 비즈니스 모델
│   ├── repositories/  # 리포지토리 인터페이스
│   └── usecases/      # 비즈니스 로직
├── data/              # 인터페이스 어댑터 계층의 일부
│   ├── repositories/  # 리포지토리 구현
│   ├── datasources/   # 로컬/원격 데이터 소스
│   └── models/        # DTO(Data Transfer Objects)
└── presentation/      # UI 계층
    ├── pages/         # 화면
    ├── widgets/       # 재사용 가능한 위젯
    ├── blocs/         # 상태 관리(Bloc/Provider 등)
    └── navigation/    # 라우팅


[클린 아키텍쳐만 적용했을 때의 폴더링, MVVM/MVC 등 디자인 패턴을 적용했을 때는 달라질 수 있음]

아키텍쳐와 패턴의 차이는 아키텍쳐는 프로젝트 전체를 구성하는 구조를 의미하고,
디자인 패턴은 UI와 비즈니스 모델 간의 상호작용 방식을 정의하는 것처럼 더 작은 범위의 구체적인 문제를 해결하기 위해 적용하는 것으로 presentation 파트에 대부분 적용됩니다.


Flutter에서의 적용 고려사항

Flutter에서 클린 아키텍처를 적용할 때 몇 가지 특별히 고려해야 할 사항들이 있어:

상태 관리와의 통합

Flutter에서는 다양한 상태 관리 라이브러리를 사용할 수 있어. BLoC, Provider, Riverpod, GetX 등에 관계없이, 어떤 라이브러리를 사용하든 클린 아키텍처의 원칙은 지켜야 해.

🔥 핵심 포인트: "상태 관리는 presentation 계층의 일부다. 절대로 domain 계층에 상태 관리 라이브러리가 침투하지 않도록 해야 한다."


의존성 주입 (Dependency Injection)

클린 아키텍처에서 의존성 주입은 필수적이야. Flutter에서는 get_it, injectable, provider 등의 라이브러리로 DI를 구현할 수 있어.

// get_it을 사용한 DI 예시
final serviceLocator = GetIt.instance;

void setupDependencies() {
  // 외부 라이브러리
  serviceLocator.registerLazySingleton<http.Client>(() => http.Client());
  
  // 데이터 소스
  serviceLocator.registerLazySingleton<UserRemoteDataSource>(
    () => UserRemoteDataSourceImpl(client: serviceLocator())
  );
  
  // 리포지토리
  serviceLocator.registerLazySingleton<UserRepository>(
    () => UserRepositoryImpl(remoteDataSource: serviceLocator())
  );
  
  // 유스케이스
  serviceLocator.registerLazySingleton(
    () => GetUserProfile(serviceLocator())
  );
  
  // BLoC
  serviceLocator.registerFactory(
    () => UserProfileBloc(getUserProfile: serviceLocator())
  );
}

🔥 핵심 포인트: "의존성 주입은 클린 아키텍처의 심장이다. 계층 간 결합도를 낮추고 테스트 용이성을 높이는 핵심 기술이다."

 

비동기 처리

Flutter 앱에서는 대부분의 데이터 액세스가 비동기적으로 이루어져. 이를 클린하게 처리하는 방법도 알아야 해.

// 유스케이스에서의 비동기 처리 예시
class GetUserProfile {
  final UserRepository repository;
  
  GetUserProfile(this.repository);
  
  // 비동기 실행
  Future<Either<Failure, User>> execute(String userId) async {
    try {
      final user = await repository.getUserProfile(userId);
      return Right(user);
    } on ServerException {
      return Left(ServerFailure());
    } catch (e) {
      return Left(UnknownFailure());
    }
  }
}

 

오류 처리

클린 아키텍처에서는 오류 처리도 체계적으로 접근해야 해. 도메인 계층에서는 Exception보다는 실패 모델(Failure)을 사용하는 것이 좋아.

// 실패 모델 정의
abstract class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  const ServerFailure([String message = '서버 오류가 발생했습니다.']) : super(message);
}

class CacheFailure extends Failure {
  const CacheFailure([String message = '캐시 오류가 발생했습니다.']) : super(message);
}

class ValidationFailure extends Failure {
  const ValidationFailure([String message = '입력이 유효하지 않습니다.']) : super(message);
}

🔥 핵심 원칙: "도메인 계층은 외부 시스템의 구체적인 오류 유형에 의존해서는 안 된다. 대신 비즈니스 관점의 실패 모델을 사용하라."

마무리

Flutter 앱에 클린 아키텍처를 적용하는 것은 처음에는 많은 보일러플레이트 코드와 추가 노력이 필요해 보일 수 있어. 하지만 이런 투자는 앱이 성장함에 따라 큰 이득으로 돌아와.

 

* 보일러플레이트 코드: 동일하거나 유사한 구조의 코드가 발생하는 것을 의미하는데 이는 실제 구현 부분의 코드가 늘어난다기 보다는 interface나 class를 더 많이 쪼개는 클린 아키텍쳐 특성 상 발생한다는 것을 의미함.


🔥 핵심 교훈: "아키텍처는 초기 개발 속도를 위해 희생하는 것이 아니라, 장기적인 개발 속도를 유지하기 위한 투자다."



Flutter 앱에 클린 아키텍처를 적용하면 다음과 같은 이점을 얻을 수 있어:

  1. 테스트 용이성: 각 계층을 독립적으로 테스트할 수 있어 높은 코드 커버리지를 달성하기 쉬워져
  2. 유지보수성: 코드 변경의 영향 범위가 제한되므로 버그 발생 가능성이 줄어들어
  3. 확장성: 새로운 기능을 추가할 때 기존 코드를 거의 수정하지 않고도 가능해
  4. 프레임워크 독립성: Flutter나 패키지가 업데이트되더라도 핵심 비즈니스 로직은 보존돼

클린 아키텍처의 원칙들을 내 것으로 만들고, Flutter 프로젝트의 특성에 맞게 적용하는 과정에서 융통성을 발휘하는 것이 중요해. 모든 프로젝트가 똑같은 구조를 가질 필요는 없어. 중요한 것은 계층 간 의존성 방향과 관심사 분리의 원칙을 지키는 거야.

 

 

다음 2편은 실제 구현 기법/패턴을 공부해서 정리하겠습니다.

'Flutter > Basic Knowledge' 카테고리의 다른 글

[Flutter] Riverpod - DI  (0) 2025.05.10
[Flutter] Riverpod - 상태 관리  (0) 2025.05.08
[Dart] Stream 이란?  (2) 2024.01.22
[Dart] Asynchronous & Future  (1) 2024.01.12
[Dart] 단일 상속과 Mixin  (1) 2024.01.10
'Flutter/Basic Knowledge' 카테고리의 다른 글
  • [Flutter] Riverpod - DI
  • [Flutter] Riverpod - 상태 관리
  • [Dart] Stream 이란?
  • [Dart] Asynchronous & Future
halfcodx
halfcodx
iOS, Flutter Developer
  • halfcodx
    Mins_Programming
    halfcodx
  • 전체
    오늘
    어제
    • 분류 전체보기 (28)
      • My App (1)
        • NostelgiAlbum (0)
        • Ro_ad (1)
        • Growith (0)
      • Retrospect (2)
      • iOS (9)
        • iOS (6)
        • Swift (2)
      • Python (5)
        • Module (3)
        • Knowledge (2)
      • Flutter (10)
        • Basic Knowledge (10)
        • Implementation (0)
  • 블로그 메뉴

    • Home
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    오블완
    파이썬
    DART
    combine 이란
    ios combine
    requests
    Python
    티스토리챌린지
    Flutter
    combine
  • hELLO· Designed By정상우.v4.10.1
halfcodx
[Flutter] 앱 개발을 위한 클린 아키텍처 기본기 다지기 - 1단계
상단으로

티스토리툴바