Light Blue Pointer
본문 바로가기
TIL(CS)

OOP의 SOLID 원칙

by 개발바닥곰발바닥!!! 2024. 5. 28.

OOP의 SOLID 원칙

S : Single Responsibility Principle

클래스는 하나의 책임만 가져야 하며, 클래스가 변경되는 이유는 단 하나뿐이어야 한다

하나의 클래스가 하나의 기능만 담당하도록 하여 코드의 응집도를 높이고, 유지보수를 용이하게 한다

SRP 위반 예시

Invoice 클래스가 여러 책임(기능)을 가지고 있다.

Invoice 클래스는 청구서의 데이터 저장, 청구서의 프린트, 그리고 총액 계산을 모두 담당하고 있다.

class Invoice {
    private double amount;

    public Invoice(double amount) {
        this.amount = amount;
    }

    public double getAmount() {
        return amount;
    }

    // 총액 계산
    public double calculateTotal() {
        return amount * 1.1; // 예를 들어 세금 10% 추가
    }

    // 데이터베이스에 저장
    public void saveToDatabase() {
        System.out.println("Saving invoice to database");
        // 데이터베이스 저장 로직
    }

    // 청구서 프린트
    public void printInvoice() {
        System.out.println("Printing invoice");
        // 청구서 출력 로직
    }
}

public class SingleResponsibilityViolation {
    public static void main(String[] args) {
        Invoice invoice = new Invoice(100);
        System.out.println("Total: " + invoice.calculateTotal());
        invoice.saveToDatabase();
        invoice.printInvoice();
    }
}

Invoice 클래스가 청구서의 계산, 저장, 출력 기능을 모두 담당하고 있어 책임이 많다

클래스가 변경되는 이유가 여러 가지가 되어 유지보수하기 어렵고, 코드가 복잡해진다

SRP 준수 예시

아래 예시에서는 Invoice, InvoicePrinter, InvoiceSaver 클래스로 각각의 책임을 분리하여 SRP를 준수한다

class Invoice {
    private double amount;

    public Invoice(double amount) {
        this.amount = amount;
    }

    public double getAmount() {
        return amount;
    }

    // 총액 계산
    public double calculateTotal() {
        return amount * 1.1; // 예를 들어 세금 10% 추가
    }
}

class InvoicePrinter {
    public void printInvoice(Invoice invoice) {
        System.out.println("Printing invoice with amount: " + invoice.getAmount());
        // 청구서 출력 로직
    }
}

class InvoiceSaver {
    public void saveToDatabase(Invoice invoice) {
        System.out.println("Saving invoice with amount: " + invoice.getAmount() + " to database");
        // 데이터베이스 저장 로직
    }
}

public class SingleResponsibility {
    public static void main(String[] args) {
        Invoice invoice = new Invoice(100);
        System.out.println("Total: " + invoice.calculateTotal());

        InvoiceSaver saver = new InvoiceSaver();
        saver.saveToDatabase(invoice);

        InvoicePrinter printer = new InvoicePrinter();
        printer.printInvoice(invoice);
    }
}

Invoice 클래스는 청구서의 데이터와 계산 책임만 가지고 있다

InvoicePrinter 클래스는 청구서 출력 책임만, InvoiceSaver 클래스는 청구서 저장 책임만 가지고 있다

각 클래스는 하나의 책임만 가지므로, 변경되는 이유가 명확하고 유지보수하기 쉽다

OCP : Open/Closed Principle

소프트웨어 요소(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하지만, 변경에는 닫혀 있어야 한다

which means 기존 코드를 수정하지 않고도 기능을 확장할 수 있어야 한다

OCP 위반 예시

Shape 클래스의 새로운 도형을 추가할 때마다 AreaCalculator 클래스의 코드를 수정해야 해서 OCP를 위반하는 예시이다

class Rectangle {
    double width;
    double height;

    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

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

class Circle {
    double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    double area() {
        return Math.PI * radius * radius;
    }
}

class AreaCalculator {
    double calculateArea(Object shape) {
        if (shape instanceof Rectangle) {
            Rectangle rectangle = (Rectangle) shape;
            return rectangle.area();
        } else if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            return circle.area();
        }
        return 0;
    }
}

public class OpenClosedPrincipleViolation {
    public static void main(String[] args) {
        AreaCalculator calculator = new AreaCalculator();
        Rectangle rectangle = new Rectangle(10, 20);
        Circle circle = new Circle(10);

        System.out.println("Rectangle area: " + calculator.calculateArea(rectangle));
        System.out.println("Circle area: " + calculator.calculateArea(circle));
    }
}

새로운 도형을 추가할 때마다 AreaCalculator 클래스의 코드를 수정해야 한다

코드의 확장성을 저해하고, 유지보수를 어렵게 만든다

OCP 준수 예시

아래 예시에서는 Shape 인터페이스를 사용하여 새로운 도형을 추가하더라도 AreaCalculator 클래스의 코드를 수정할 필요가 없도록 한다

interface Shape {
    double area();
}

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

    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

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

class Circle implements Shape {
    private double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

class Triangle implements Shape {
    private double base;
    private double height;

    Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double area() {
        return 0.5 * base * height;
    }
}

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

public class OpenClosedPrinciple {
    public static void main(String[] args) {
        AreaCalculator calculator = new AreaCalculator();
        Shape rectangle = new Rectangle(10, 20);
        Shape circle = new Circle(10);
        Shape triangle = new Triangle(10, 5);

        System.out.println("Rectangle area: " + calculator.calculateArea(rectangle));
        System.out.println("Circle area: " + calculator.calculateArea(circle));
        System.out.println("Triangle area: " + calculator.calculateArea(triangle));
    }
}

새로운 도형을 추가하더라도 AreaCalculator 클래스의 코드를 수정할 필요가 없다

각 도형 클래스는 Shape 인터페이스를 구현하여 자신의 면적 계산 로직을 제공한다

추상화(인터페이스)를 통해 새로운 기능을 추가할 때 기존 코드를 수정할 필요가 없도록 하여, 코드의 확장성과 유지보수성이 높아진다

LSP : Liskov Substitution Principle

자식 클래스는 언제나 자신의 부모 클래스를 대체할 수 있어야 한다

LSP 위반 예시

부모 클래스 타입(Bird)으로 서브클래스(Ostrich)를 대체했을 때 프로그램의 예상 동작이 변경되어 LSP위반이다

Bird 클래스는 fly 메서드를 제공하며, 이 메서드는 새가 날 수 있다는 가정을 전제로 한다. 하지만 **Ostrich**는 날 수 없는 새로, fly 메서드를 호출하면 예외를 발생시킨다.

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

class Sparrow extends Bird {
    // 정상적으로 부모 클래스의 메서드를 사용할 수 있음
}

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

public class BirdTest {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        sparrow.fly(); // 정상 출력

        Bird ostrich = new Ostrich();
        try {
            ostrich.fly(); // 예외 발생
        } catch (UnsupportedOperationException e) {
            System.out.println(e.getMessage());
        }
    }
}

LSP 준수 예시

abstract class Bird {
    abstract void move();
}

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

class NonFlyingBird extends Bird {
    @Override
    public void move() {
        System.out.println("Walking");
    }
}

class Sparrow extends FlyingBird {
    // 정상적으로 부모 클래스의 메서드를 사용할 수 있음
}

class Ostrich extends NonFlyingBird {
    // 정상적으로 부모 클래스의 메서드를 사용할 수 있음
}

public class BirdTest {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        sparrow.move(); // 정상 출력

        Bird ostrich = new Ostrich();
        ostrich.move(); // 정상 출력
    }
}

ISP: Interface Segregation Principle

인터페이스는 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 분리되어야 한다는 원칙

시스템의 유연성이 높아진다

ISP 위반 예시

Worker 인터페이스가 여러 기능을 포함하고 있어, 모든 구현체가 사용하지 않는 메서드를 구현해야 한다

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

class Human implements Worker {
    @Override
    public void work() {
        System.out.println("Human working");
    }

    @Override
    public void eat() {
        System.out.println("Human eating");
    }
}

class Robot implements Worker {
    @Override
    public void work() {
        System.out.println("Robot working");
    }

    @Override
    public void eat() {
        // 로봇은 eat 메서드를 사용할 필요가 없으므로, 불필요한 구현
        throw new UnsupportedOperationException("Robots don't eat");
    }
}

Robot 클래스는 eat 메서드를 구현할 필요가 없지만, Worker 인터페이스를 구현하기 위해 이를 포함해야 한다

이는 불필요한 메서드 구현을 강제하고, 코드의 유연성을 떨어뜨린다

ISP 준수 예시

아래 예시에서는 Worker 인터페이스를 분리하여 각각의 구현체가 필요한 인터페이스만 구현하도록 수정하여 ISP를 준수한다

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Human implements Workable, Eatable {
    @Override
    public void work() {
        System.out.println("Human working");
    }

    @Override
    public void eat() {
        System.out.println("Human eating");
    }
}

class Robot implements Workable {
    @Override
    public void work() {
        System.out.println("Robot working");
    }
}

public class InterfaceSegregation {
    public static void main(String[] args) {
        Workable humanWorker = new Human();
        humanWorker.work();

        Eatable humanEater = new Human();
        humanEater.eat();

        Workable robotWorker = new Robot();
        robotWorker.work();
    }
}

Human 클래스는 Workable과 Eatable 인터페이스를 모두 구현하여, 작업과 식사 기능을 모두 포함한다

Robot 클래스는 Workable 인터페이스만 구현하여, 필요한 기능만 포함한다

각 클래스는 자신이 필요한 인터페이스만 구현하므로, 불필요한 메서드 구현을 피할 수 있다

따라서 코드의 유연성과 가독성을 높이고, 유지보수를 쉽게 만든다

SRP와 ISP의 차이점 SRP : 클래스 분리 ISP: 인터페이스 분리, 책임을 분리하는 측면에서 비슷해 보인다…

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

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

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다

저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다

고수준 모듈  어떤 의미 있는 단일 기능을 제공하는 모듈 (interface, 추상 클래스)

저수준 모듈 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현 (메인클래스, 객체)

저수준 클래스는 빈번하게 변경되고, 새로운 것이 추가될 때마다 고수준 클래스가 영향을 받기 쉬우므로 의존관계를 역전시켜야 함

DIP 위반 예시

DataHandler 클래스가 특정 데이터베이스 구현 (MySQLDatabase)에 직접 의존하고 있어서 DIP가 위반된다

class MySQLDatabase {
    public void save(String data) {
        System.out.println("Saving data to MySQL database");
    }
}

class DataHandler {
    private MySQLDatabase database;

    public DataHandler() {
        this.database = new MySQLDatabase();
    }

    public void save(String data) {
        database.save(data);
    }
}

public class DependencyInversionViolation {
    public static void main(String[] args) {
        DataHandler handler = new DataHandler();
        handler.save("Important data");
    }
}

DataHandler 클래스가 MySQLDatabase 클래스에 직접 의존하고 있어, 데이터베이스 구현을 변경하려면 DataHandler 클래스를 수정해야 한다

유연성을 떨어뜨리고, 테스트가 어려워지며, 유지보수를 어렵게 만든다

DIP 준수 예시

아래 예시에서는 DataHandler 클래스가 Database 인터페이스에 의존하도록 변경하여 DIP를 준수한다.

interface Database {
    void save(String data);
}

class MySQLDatabase implements Database {
    @Override
    public void save(String data) {
        System.out.println("Saving data to MySQL database");
    }
}

class PostgreSQLDatabase implements Database {
    @Override
    public void save(String data) {
        System.out.println("Saving data to PostgreSQL database");
    }
}

class DataHandler {
    private Database database;

    public DataHandler(Database database) {
        this.database = database;
    }

    public void save(String data) {
        database.save(data);
    }
}

public class DependencyInversion {
    public static void main(String[] args) {
        Database mySQLDatabase = new MySQLDatabase();
        DataHandler handler = new DataHandler(mySQLDatabase);
        handler.save("Important data");

        Database postgreSQLDatabase = new PostgreSQLDatabase();
        handler = new DataHandler(postgreSQLDatabase);
        handler.save("Critical data");
    }
}

DataHandler 클래스는 Database 인터페이스에 의존하므로, 데이터베이스 구현을 쉽게 교체할 수 있게 되었다

DataHandler 클래스를 변경하지 않고도 새로운 데이터베이스 구현체를 추가할 수 있다

코드의 유연성과 재사용성이 높아지며, 테스트가 용이해진다