Study/기타

Clean Architecture 클린아키텍처, 객체지향 디자인 5원칙 SOLID[5부]

코딩 잘 할거얌:) 2024. 3. 23. 00:51
반응형

이번에는 객체지향 디자인 5원칙에 대해서 알아보도록 하자.

원래 포스팅의 순서는 Folder구조를 잡고, 각 폴더 안에 들어가는 파일 class들의 예시를 보여주려고 했다. 하지만 객체지향 디자인 5원칙을 설명하지 않고는 작성하기가 힘들다 생각이 되어 이것부터 먼저 포스팅을 하도록 하겠다.


SOLID란 무엇인가.

 

SOLID는 위의 이미지로 유추할 수 있듯이, 5개의 원칙에서 앞에 한 글자씩 따온 것이다. 

Robert C Martin(Uncle Bob)이 제시한 객체지향설계에 대한 원칙을 5원칙으로 정리를 한 것이다. Uncle Bob, 익숙한 이름일 것이다. Clean Architecture를 제안한 아키텍처이다. 클린아키텍처는 헥사고날(hexagonal) 아키텍처라고 하기도하고 포트와 어댑터(Ports and Adapters) 패턴이라고도 한다.

https://sites.google.com/site/unclebobconsultingllc/getting-a-solid-start

 

Clean Coder - Getting a SOLID start.

Posted by Uncle Bob on 02/12/2009 I am often asked: “How should I get started with SOLID principles?” Given the recent interest and controversy about the issue, it’s probably time I gave a written answer. First things first. You can read about the So

sites.google.com

SOLID라는 것은 객체지향설계를 위한 5원칙을 앞의 문자만 따서 온 것이다.

1. Single Responsibility Principle (SRP) 단일 책임의 원칙

소프트웨어 구성요소(클래스, 모듈, 함수 등)가 하나의 기능 또는 책임만을 가져야 한다는 내용이다. 즉 클래스나 모듈이 여러가지 목적을 수행하거나 다양한 책임을 지니지 않도록 하는 것을 의미한다.

코드로 우선 살펴보도록 하자.

우리가 앱을 개발할때, 로그인을 필수적으로 하는 경우가 많다. 로그인을 하면 서버에서는 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)을 주는데, 액세스 토큰을 api 헤더에다가 넣어서 통신을 하다가 만료가 되면, 리프레시 토큰으로 액세스토큰을 새로 발급받는 형식으로 진행이 된다.

그래서 로그인하는 기능과 액세스 토큰을 새로 발급받는 메소드는 서로 다른 역할이기 때문에 다른 메서드를 이용해야 한다. 이 개념을 가지고 예시코드를 살펴보자.

// Single Responsibility Principle를 준수하지 않는 클래스

// 사용자 로그인과 액세스 토큰을 모두 관리하는 클래스
class User {
  void login(String username, String password) {
    print('로그인: $username');
    // 로그인 로직
  }

  void refreshAccessToken(String refreshToken) {
    print('액세스 토큰 재발급');
    // 액세스 토큰 재발급 로직
  }
}


// Single Responsibility Principle를 준수하는 클래스

// 로그인을 관리하는 클래스
class LoginManager {
  void login(String username, String password) {
    print('로그인: $username');
    // 로그인 로직
  }
}

// 액세스 토큰을 관리하는 클래스
class AccessTokenManager {
  void refreshAcessToken(String refreshToken) {
    print('액세스 토큰 재발급');
    // 액세스 토큰 재발급 로직
  }
}

void main() {
  var loginManager = LoginManager();
  var accessTokenManager = AccessTokenManager();

  loginManager.login('john_doe', 'password123');
  accessTokenManager.refreshToken('refresh_token_value');
}

위 예시에서 단일 책임의 원칙에서 위배되는 코드를 먼저 보자.

우선 UserManager Class에서 login과 refreshAccessToken이라는 두 메소드가 있다. 이 두 메서드는 하나의 클래스에서 로그인과 액세스 토큰을 새로 발급받는 두 가지의 기능이 존재한다. 즉 유저 클래스에서 "로그인" 기능과 "액세스 토큰 업데이트" 하는 두 가지 책임이 "유저" 클래스에서 존재한다. 따라서 좋은 코드라고 할 수 없다.

반면에 아래쪽에 있는 단일 책임의 원칙을 지키는 코드를 보자.

LoginManger 클래스와 AccessTokenManger클래스가 각각 존재하고, 각각 클래스안에 login메서드와 refreshAcessToken이라는 메서드가 존재한다. 즉 각 클래스가 하나의 책임만을 가지는 내용이다. 따라서 단일 책임 원칙을 준수한다고 할 수 있다.

2. Open/Closed Principle 개방-폐쇄 원칙

개방-폐쇄 원칙은 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에는 열려있어야 하지만 수정이는 닫혀있어야 한다는 원칙이다. 즉, 기존의 코드를 변경하지 않고도 새로운 기능을 추가할 수 있어야 한다는 의미이다.

소프트웨어의 유지 보수를 향상시키고 코드의 재사용을 높이며, 새로운 요구 사항이나 변경이 발생하면 기존 코드를 변경하지 않고 새로운 기능을 추가하여 확장할 수 있게 한다.

  1. 추상화와 다형성 활용
  2. 인터페이스와 추상 클래스 사용
  3. 패턴 사용 (Strategy Pattern, Observer Pattern, Decorator Pattern 등)

이 세가지를 생각하며 코드를 작성하면 아주 지키기 쉬운 원칙이다. 바로 예시코드를 살펴보자

// Open/Closed Principle를 준수하지 않는 예제

// 정사각형 클래스
class Square {
  final double side;
  final double totalDegree;
  final int cornerCount;

  Square(this.side, this.totalDegree, this.conerCount);

  double area() {
    return side * side;
  }
  
  int totalDegree() {
  	return 180 * (conerCount-2);
  }
}


// Open/Closed Principle를 준수하는 예제

// 도형 추상 클래스
abstract class Shape {
  double area();
  
  int totalDegree();
}

// 원 클래스
class Circle implements Shape {
  final double radius;

  Circle(this.radius);

  @override
  double area() {
    return 3.14 * radius * radius;
  }
  
  @override
  int totalDegree() {
    return 360; // 원의 경우 내각의 합은 360도
  }
}

// 직사각형 클래스
class Rectangle implements Shape {
  final double width;
  final double height;

  Rectangle(this.width, this.height);

  @override
  double area() {
    return width * height;
  }

  @override
  int totalDegree() {
    return 360; // 직사각형의 경우 내각의 합은 360도
  }
}

개방-폐쇄 원칙을 준수하지 않는 경우의 코드를 보자. Square의 클래스를 정의하지만, 새로운 도형을 추가할 때에는 Square코드를 상속받기가 쉽지 않다. 또한 새로운 기능이 추가될 때 마다도 코드를 변경해야 한다. 따라서 개방-폐쇄 원칙을 준수하지 않는 코드는 (다른 클래스를) 확장에는 닫혀있고, (다른 클래스의) 수정에 열려있다고 할 수 있다.

아래에 개방-폐쇄 원칙을 준수하는 코드를 보자. Shape라는 추상클래스를 정의하고, Circle과 Rectangle은 Shape에 상속을 받아서 정의를 하게되었다. 이런 구조에서는 새로운 도형, 삼각형을 추가한다 하더라도 기존의 코드를 변경하지 않고 triangle 클래스를 정의할 수 있고, 메서드들을 override로 새로 정의할 수 있다. 이렇게 (추가적인 클래스) 확장에는 열려있고 (다른 클래스의) 수정에는 닫혀있다고 할 수 있다.

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

상위 클래스의 객체를 하위 클래스의 객체로 대체하여도 프로그램의 기능이 변경되지 않아야 한다. 즉 상속 관계에서 하위클래스는 상위클래스를 완전히 대체할 수 있어야 한다는 뜻이다.

간단하게 말하자면, 상위클래스를 정의하면 모든 하위클래스들은 정의된 상위클래스에 선언이 될 수 있어야한다는 뜻이다. 좀 더 풀어말하자면, 상위 클래스의 인스턴스를 사용하는 코드에서는 하위 클래스의 인스턴스로 대체해도 프로그램의 동작이 변하지 않아야 한다.

  List<Shape> shapes = [
    Rectangle(5, 10),
    Square(4),
  ];

위의 예시코드에서 이렇게 정의할 수 있어야한다는 뜻이다. shapes에 Shape를 상속받은 Rectangle과 Square가 들어갈 수 있도록 한다는 뜻이다.

사실 dart언어에서는 추상화를 정상적으로 하였다면 리스코프 치환 원칙을 위반하는 경우는 거의 없다. 왜냐하면 오버로딩이 없기 때문이다. 하지만 Java언어는 오버로딩이 가능하기 때문에, 오버로딩하여 매개변수를 변경하거나 리턴타입을 바꾼다면, 리스코프 치환 원칙을 위반할 수 있다.

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

인터페이스 분리 원칙은 클라이언트가 사용하지 않는 메소드에 의존하지 않아야 한다.라는 원칙으로, 거대한 인터페이스를 작은 인터페이스들로 분리하는 것을 의미한다. 즉 인터페이스가 클라이언트에게 필요한 기능만 제공하도록 하는 것을 의미한다.

예를들어 파일관리 인터페이스가 있다고 가정하자. 그러면 파일관리 인터페이스 안에는 "파일관리"에 관련된 것만 정의가 되어있어야 한다. 혹시나 폴더에 관련된 내용이 있다면 인터페이스 분리 원칙을 위배한 것이라고 할 수 있다.

// ISP 위배 예시: 하나의 인터페이스에 파일과 폴더 관리에 대한 메서드가 함께 정의되어 있음

abstract class FileManager {
  void createFile(String fileName);
  void deleteFile(String fileName);
  void createFolder(String folderName);
  void deleteFolder(String folderName);
}

class FileSystem implements FileManager {
  @override
  void createFile(String fileName) {
    print('Creating file: $fileName');
  }

  @override
  void deleteFile(String fileName) {
    print('Deleting file: $fileName');
  }

  @override
  void createFolder(String folderName) {
    print('Creating folder: $folderName');
  }

  @override
  void deleteFolder(String folderName) {
    print('Deleting folder: $folderName');
  }
}


// ISP 준수 예시: 파일과 폴더 관리에 대한 인터페이스들이 분리됨

abstract class FileManager {
  void createFile(String fileName);
  void deleteFile(String fileName);
}

abstract class FolderManager {
  void createFolder(String folderName);
  void deleteFolder(String folderName);
}

5. Dependency Inversion 의존성 역전 원칙

의존성이란, 하나의 모듈, 클래스 또는 함수가 다른 모듈, 클래스 또는 함수에 의존하는 관계를 의미한다. 즉 다른 요소가 다른 요소를 사용하기 위해 해당요소에 종속되어 있다는 것을 말한다.

존성을 주입하는 방법은

  • 생성자 주입(Constructor Injection)
class UserManager {
  final AuthService authService;

  UserManager(this.authService);
}
  • 세터 주입(Setter Injection)
  • 인터페이스 주입(Interface Injection)
  • 변수 주입(Variable Injection)
  • 환경 변수 주입(Environment Variable Injection)

이 있다. 이중 가장 많이 사용하는건 생성자 주입법이다.

고수준모듈은 저수준모듈에 의존하면 안되며, 둘 다 추상화에 의존해야 한다는 원칙이다. 저수준 모듈이라고 하면 데이터베이스와 연결되거나 api통신하는 부분을 의미하며, 고수준 모듈은 애플리케이션의 핵심 비즈니스 로직을 다루는 부분이라고 생각하면 좋다. 즉 고수준의 모듈, 애플리케이션의 핵심 비즈니스 로직을 다루는 모듈은 저수준모듈, 데이터베이스와 연결되는 모듈에게 의존하면 안 된다는 뜻이다. 
그러면 어떻게 의존하냐, 추상 클래스에 의존할 수 있도록 한다.

즉,

  • 고수준 모듈이 추상화에 의존하도록 구현한다.
  • 저수준 모듈을 추상화하고, 고수준 모듈에 주입한다.
  • 인터페이스를 통해 의존성을 주입하고, 실제 구현은 이를 구현하는 클래스에서 한다.

예시를 들어보자

// 고수준 모듈이 의존하는 인터페이스
abstract class MessageSender {
  void sendMessage(String message);
}

// 저수준 모듈이 구현하는 인터페이스
class SendService implements MessageSender {
  @override
  void sendMessage(String message) {
    // 메시지를 전송하는 로직
    print('Message sent: $message');
  }
}

// 고수준 모듈이 의존하는 저수준 모듈의 인터페이스를 주입
class ChatService {
  final MessageSender messageSender;

  ChatService(this.messageSender);

  void sendMessage(String message) {
    // 채팅 메시지를 전송하는 로직
    print('Chat message sent: $message');
    // 전송된 메시지를 전송 서비스를 통해 전송
    messageSender.sendMessage(message);
  }
}

void main() {
  // SendService를 주입하여 ChatService 인스턴스 생성
  var sendService = SendService();
  var chatService = ChatService(sendService);

  // 메시지 전송
  chatService.sendMessage('Hello, world!');
}

ChatService 클래스는 MessageSender 인터페이스에만 의존하고있다. 그리고 SendService의 클래스의 인스턴스를 주입받아서 사용하게 되어 의존성 역전 원칙을 준수하게 된다.

이제 진짜로 이 모든것들을 적용해서 Flutter에서 Clean architecture를 기준으로 어떻게 구현을 하면 되는지 살펴보도록 하자.

728x90