Backend

SOLID : 객체 지향 설계 5원칙

openDeveloper 2023. 5. 12. 23:46

"SOLID"는 소프트웨어 개발에서 사용되는 객체 지향 프로그래밍과 설계의 원칙들을 가리키는 약어로 아래와 같다.

 

  • SRP(Single Responsibility Principle): 단일 책임 원칙
  • OCP(Open Closed Priciple): 개방 폐쇄 원칙
  • LSP(Listov Substitution Priciple): 리스코프 치환 원칙
  • ISP(Interface Segregation Principle): 인터페이스 분리 원칙
  • DIP(Dependency Inversion Principle): 의존 역전 원칙

 

Single Responsibility Principle (SRP): 각 클래스는 하나의 책임만을 가져야 합니다. 이 원칙은 클래스가 변경되어야 하는 이유가 하나만 있어야 함을 의미합니다.

 

 SRP를 위반하는 클래스를 생각해봅시다. 'Employee'라는 클래스는 직원의 상세 정보를 관리하고, 그 정보를 데이터베이스에 저장하며, 그 정보를 콘솔에 출력하는 기능을 가지고 있다고 가정해 봅시다.

 

public class Employee {
    private String name;
    private String address;
    private int salary;

    // constructor, getters and setters...

    public void saveEmployee() {
        // code to save employee details to database
    }

    public void displayEmployee() {
        // code to print employee details to console
    }
}

이 클래스는 SRP를 위반합니다. 왜냐하면 이 클래스는 두 가지 이유로 변경될 수 있기 때문입니다: 하나는 직원 데이터의 관리 방식이 변경될 때, 다른 하나는 직원 데이터를 표시하는 방식이 변경될 때입니다.

따라서 SRP에 따르면, 이 두 가지 책임은 각각 별도의 클래스로 분리되어야 합니다:

 

public class Employee {
    private String name;
    private String address;
    private int salary;

    // constructor, getters and setters...
}

public class EmployeeDB {
    public void saveEmployee(Employee employee) {
        // code to save employee details to database
    }
}

public class EmployeeConsoleReporter {
    public void displayEmployee(Employee employee) {
        // code to print employee details to console
    }
}

 각 클래스는 각자의 책임에만 집중하며, 변경의 이유가 하나뿐인 클래스를 유지할 수 있습니다. 이것이 바로 Single Responsibility Principle입니다.


Open-Closed Principle (OCP): 소프트웨어의 개체(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하지만, 코드의 변경에는 닫혀 있어야 합니다. 즉, 기존의 코드를 변경하지 않고도 새로운 기능을 추가할 수 있어야 합니다.

 

public class Rectangle {
    public double height;
    public double width;
}

public class AreaCalculator {
    public double calculateRectangleArea(Rectangle rectangle) {
        return rectangle.height * rectangle.width;
    }
}

위 코드에서는 AreaCalculator가 Rectangle의 면적을 계산합니다. 이제 새로운 도형, 예를 들어 원의 면적을 계산해야 한다고 가정해 보겠습니다. 이 경우, AreaCalculator를 수정해야 하는 문제가 생깁니다. 이러한 접근 방식은 OCP를 위반합니다.

OCP를 준수하기 위해, 우리는 도형들이 공통적으로 가질 수 있는 인터페이스를 만들고, 각 도형이 그 인터페이스를 구현하게 만들 수 있습니다. 그리고 AreaCalculator는 이 인터페이스에 의존하도록 만들 수 있습니다.

 

public interface Shape {
    double calculateArea();
}

public class Rectangle implements Shape {
    public double height;
    public double width;

    @Override
    public double calculateArea() {
        return height * width;
    }
}

public class Circle implements Shape {
    public double radius;

    @Override
    public double calculateArea() {
        return Math.PI * Math.pow(radius, 2);
    }
}

public class AreaCalculator {
    public double calculateShapeArea(Shape shape) {
        return shape.calculateArea();
    }
}

이제 새로운 도형이 추가되어도 AreaCalculator 클래스를 변경할 필요가 없습니다. 각 도형 클래스는 Shape 인터페이스를 구현하므로 AreaCalculator는 이 인터페이스에 의존하게 됩니다. 이렇게 하면 코드는 확장에는 열려 있고 수정에는 닫혀 있게 됩니다, 즉 OCP를 만족하게 됩니다.


Liskov Substitution Principle (LSP): 프로그램에서 부모 클래스를 자식 클래스로 바꿔도 프로그램이 정상적으로 작동해야 합니다. 즉, 하위 유형은 그들의 기반(또는 상위) 유형을 대체할 수 있어야 합니다. 

 

먼저, LSP를 위반하는 예를 들어보겠습니다.

 

public class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}

public class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Ostrich can't fly.");
    }
}

Ostrich : 타조는 날 수 없다

 

여기서 Ostrich는 Bird를 상속받았지만, fly 메소드를 호출하면 예외가 발생합니다. 이는 LSP를 위반하는 예입니다. 왜냐하면 Bird 타입의 객체를 Ostrich 타입의 객체로 바꿔도 프로그램이 정상적으로 작동해야 하는데, 그렇지 않기 때문입니다.

이 문제를 해결하기 위해, 우리는 Bird 클래스를 두 개의 서브 클래스로 분리할 수 있습니다: FlyingBird와 NonFlyingBird. Ostrich는 NonFlyingBird를 상속받고, 나머지 새들은 FlyingBird를 상속받을 수 있습니다.

 

public class Bird {
    // common attributes and methods of all birds
}

public class FlyingBird extends Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}

public class NonFlyingBird extends Bird {
    // other methods specific to non-flying birds
}

public class Ostrich extends NonFlyingBird {
    // methods specific to Ostrich
}

public class Sparrow extends FlyingBird {
    // methods specific to Sparrow
}

이렇게 하면, Ostrich 객체를 NonFlyingBird 타입의 객체로 바꿔도 프로그램이 여전히 정상적으로 작동합니다. 이것이 바로 Liskov Substitution Principle입니다.


Interface Segregation Principle (ISP): 많은 클라이언트 전용 인터페이스가 하나의 일반적인 인터페이스보다 낫다는 원칙입니다. 이는 사용자가 필요로 하지 않는 메소드에 의존하지 않도록 하는 것을 목표로 합니다.

 

먼저, ISP를 위반하는 예를 살펴봅시다.

public interface Worker {
    void work();
    void eat();
}

public class HumanWorker implements Worker {
    public void work() {
        // working...
    }

    public void eat() {
        // eating during lunch break...
    }
}

public class RobotWorker implements Worker {
    public void work() {
        // working...
    }

    public void eat() {
        throw new UnsupportedOperationException("Robots can't eat.");
    }
}

여기서 RobotWorker는 eat 메서드를 구현해야 하지만, 실제로는 로봇이 먹는 기능을 수행할 수 없습니다. 이는 ISP를 위반하는 예입니다. 이 문제를 해결하기 위해, 우리는 인터페이스를 더 잘 분리할 수 있습니다:

public interface Worker {
    void work();
}

public interface Eater {
    void eat();
}

public class HumanWorker implements Worker, Eater {
    public void work() {
        // working...
    }

    public void eat() {
        // eating during lunch break...
    }
}

public class RobotWorker implements Worker {
    public void work() {
        // working...
    }
}

Dependency Inversion Principle (DIP): 상위 수준의 모듈은 하위 수준의 모듈에 의존하면 안 됩니다. 모두가 추상화에 의존해야 합니다. 이는 구체적인 클래스보다 인터페이스나 추상 클래스에 의존하도록 함으로써 달성됩니다.

 

 DIP를 위반하는 예를 살펴봅시다:

 

public class EmailService {
    public void sendEmail(String message, String receiver){
        // logic to send email
    }
}

public class Notification {
    private EmailService emailService;

    public Notification(){
        this.emailService = new EmailService();
    }

    public void promote(String message, String receiver){
        this.emailService.sendEmail(message, receiver);
    }
}

위의 코드에서 Notification 클래스는 EmailService 클래스에 직접 의존하고 있습니다. 이는 DIP를 위반하는 것으로, 이 경우 Notification 클래스는 EmailService 클래스의 구현에 강하게 결합되어 있습니다.

이 문제를 해결하기 위해, 우리는 메시지 전송 기능에 대한 인터페이스를 정의하고, Notification 클래스가 이 인터페이스에 의존하게 만들 수 있습니다.

public interface MessageService {
    void sendMessage(String message, String receiver);
}

public class EmailService implements MessageService {
    public void sendMessage(String message, String receiver) {
        // logic to send email
    }
}

public class Notification {
    private MessageService messageService;

    public Notification(MessageService svc){
        this.messageService = svc;
    }

    public void promote(String message, String receiver) {
        this.messageService.sendMessage(message, receiver);
    }
}

이제 Notification 클래스는 EmailService가 아닌 MessageService 인터페이스에 의존하므로, 이메일 뿐만 아니라 다른 메시지 서비스를 사용하려면 Notification 클래스를 변경할 필요가 없습니다. 이것이 바로 Dependency Inversion Principle입니다.