Java

[Java] 객체지향 설계 5원칙 - SOLID

테런 2023. 8. 6. 22:16
  • SOLID 원칙
로버트 마틴(Robert C. Martin)은 소프트웨어 개발자와 컨설턴트로서, 객체 지향 설계에 관한 많은 경험과 지식을 가지고 있습니다. 객체 지향 설계의 원칙 중에서 가장 잘 알려진 것은 SOLID 원칙으로, 다음과 같이 5가지 원칙으로 요약할 수 있습니다.

 

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

클래스는 단 하나의 책임만 가져야 합니다. 즉, 한 클래스는 하나의 변경 이유만을 가져야 하며, 클래스의 모든 기능은 해당 책임을 수행하는 데 집중되어야 합니다.

예를 들어, 도서 관리 시스템에서 도서를 표현하는 Book 클래스
// Book 클래스는 도서를 표현하는 역할만 수행합니다.
public class Book {
    private String title;
    private String author;
    private int pageCount;

    // 도서의 제목을 반환하는 메서드
    public String getTitle() {
        return title;
    }

    // 도서의 저자를 반환하는 메서드
    public String getAuthor() {
        return author;
    }

    // 도서의 페이지 수를 반환하는 메서드
    public int getPageCount() {
        return pageCount;
    }

    // 도서 정보를 설정하는 메서드
    public void setTitle(String title) {
        this.title = title;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public void setPageCount(int pageCount) {
        this.pageCount = pageCount;
    }
}​

위의 예시 코드에서 Book 클래스는 단일 책임 원칙을 준수합니다. 클래스가 도서를 표현하는 역할만 수행하며, 도서의 제목, 저자, 페이지 수와 관련된 기능만을 포함하고 있습니다. 다른 책임, 예를 들어 도서 데이터베이스에 접근하거나 도서 출력을 위한 기능은 해당 클래스에서 분리되어 다른 클래스에서 구현되어야 합니다.

SRP 준수하는 설계를 하면 코드를 이해하기 쉽고, 유지보수 확장이 편리해집니다. 만약 다른 책임을 가진 코드가 추가되어야 한다면, 해당 기능에 대한 새로운 클래스를 만들어서 Book 클래스의 책임을 변경하지 않고 확장할 있습니다.

 

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

소프트웨어 개체(클래스, 모듈 등)는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다. 즉, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 합니다.

예를 들어, 도서 할인 기능을 확장할 수 있는 BookDiscount 클래스
// Book 클래스는 도서를 표현하는 역할만 수행합니다.
public class Book {
    private String title;
    private String author;
    private int pageCount;

    // 도서의 가격
    private double price;

    // 생성자와 게터/세터 생략

    // 도서의 할인 가격을 계산하는 메서드
    public double calculateDiscountedPrice() {
        // 기본적인 가격 계산 로직
        double discountedPrice = price * 0.9; // 10% 할인

        // 추가적인 할인 로직을 수행하는 클래스가 있다면, 그 클래스를 호출하여 확장
        DiscountStrategy discountStrategy = new BookDiscountStrategy();
        discountedPrice = discountStrategy.applyDiscount(discountedPrice);

        return discountedPrice;
    }
}

// 도서 할인에 대한 전략을 추상화하는 인터페이스
interface DiscountStrategy {
    double applyDiscount(double price);
}

// 도서 할인 전략 중 하나를 구현한 클래스
class BookDiscountStrategy implements DiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        // 도서 할인 전략에 따라 가격을 수정하여 반환
        // 예를 들어, 특정 조건에 따라 추가 할인을 적용할 수 있음
        if (price > 100) {
            return price - 20; // 20달러 할인
        } else {
            return price;
        }
    }
}

// BookDiscountStrategy를 상속받아 새로운 도서 할인 전략을 추가하는 클래스
class NewBookDiscountStrategy extends BookDiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        // 새로운 도서 할인 전략을 적용하여 가격을 수정하여 반환
        // 새로운 할인 전략에 따라 추가 로직을 구현
        // 예를 들어, 또 다른 조건에 따라 할인 적용
        if (price > 50) {
            return price - 10; // 10달러 할인
        } else {
            return price;
        }
    }
}​

 

위의 예시 코드에서 Book 클래스는 할인에 대한 로직을 추상화한 DiscountStrategy 인터페이스를 사용하여 확장 가능한 설계를 보여줍니다. Book 클래스는 할인 계산을 위해 BookDiscountStrategy를 사용하고 있습니다.

이러한 구조를 통해 새로운 할인 전략을 추가하기 위해 BookDiscountStrategy를 상속한 NewBookDiscountStrategy 클래스를 만들어 새로운 할인 로직을 구현하고, calculateDiscountedPrice() 메서드에서 해당 클래스를 사용하여 확장할 수 있습니다. 이렇게 함으로써 기존 코드를 수정하지 않고도 새로운 할인 전략을 추가할 수 있습니다.

 

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

서브타입(하위 클래스)은 언제나 기반 타입(상위 클래스)으로 교체 가능해야 합니다. 즉, 어떤 클래스가 상위 클래스의 인터페이스를 구현하고 있을 때, 이를 사용하는 코드는 상위 클래스를 사용하는 것과 동일한 결과를 가져야 합니다.

예를 들어, 도형(Shape)과 직사각형(Rectangle) 클래스
// Shape 클래스는 도형을 표현하는 추상 클래스입니다.
abstract class Shape {
    // 도형의 넓이를 계산하는 추상 메서드
    public abstract double calculateArea();
}

// Rectangle 클래스는 Shape 클래스를 상속받아 직사각형을 표현하는 클래스입니다.
class Rectangle extends Shape {
    private double width;
    private double height;

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

    // 직사각형의 넓이를 계산하는 메서드 구현
    @Override
    public double calculateArea() {
        return width * height;
    }

    // 추가적인 직사각형 관련 메서드 생략
}

// LSP 원칙을 준수하는 예시
public class LSPExample {
    // 도형의 넓이를 출력하는 메서드
    public static void printArea(Shape shape) {
        System.out.println("도형의 넓이: " + shape.calculateArea());
    }

    public static void main(String[] args) {
        Shape rectangle = new Rectangle(4, 5);
        printArea(rectangle); // 출력: 도형의 넓이: 20.0

        // 상위 클래스 타입으로 대체 가능한 것을 확인합니다.
        // LSP를 준수하므로 Rectangle은 Shape로 교체 가능합니다.

        // 예시로서 동작하지 않는 경우:
        // Rectangle rectangle = new Shape(); // 이렇게는 불가능합니다.
    }
}​

위의 예시 코드에서 Shape 클래스는 추상 클래스로서 도형을 표현하고 있습니다. Rectangle 클래스는 Shape 클래스를 상속받아 직사각형을 구체적으로 표현하고 있으며, calculateArea() 메서드를 구현하여 직사각형의 넓이를 계산합니다.

printArea(Shape shape) 메서드는 Shape 타입의 매개변수를 받아 해당 도형의 넓이를 출력하는 메서드입니다. 이 때, printArea 메서드 내부에서는 실제로 Rectangle 객체를 인자로 전달하여도 정상적으로 동작합니다. 이는 LSP를 준수하는 것을 의미합니다. 즉, 상위 클래스인 Shape의 인터페이스를 구현한 Rectangle은 언제나 상위 클래스로 대체 가능합니다.

LSP를 준수하는 경우, 코드가 유연해지고 코드의 재사용성과 확장성이 높아집니다.

 

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

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 합니다. 즉, 인터페이스는 클라이언트에 특화된 작은 단위로 분리되어야 합니다.

예를 들어, 프린터(Printer) 인터페이스와 스캐너(Scanner) 인터페이스
// 프린터(Printer) 인터페이스
interface Printer {
    void print();
}

// 스캐너(Scanner) 인터페이스
interface Scanner {
    void scan();
}

// 멀티펑션 장치(MultiFunctionDevice) 클래스는 Printer와 Scanner 인터페이스를 구현합니다.
class MultiFunctionDevice implements Printer, Scanner {
    @Override
    public void print() {
        System.out.println("문서를 출력합니다.");
    }

    @Override
    public void scan() {
        System.out.println("문서를 스캔합니다.");
    }
}

// 프린터(Printer)만 필요한 클라이언트 클래스
class PrinterClient {
    private Printer printer;

    public PrinterClient(Printer printer) {
        this.printer = printer;
    }

    public void printDocument() {
        printer.print();
    }
}

// 스캐너(Scanner)만 필요한 클라이언트 클래스
class ScannerClient {
    private Scanner scanner;

    public ScannerClient(Scanner scanner) {
        this.scanner = scanner;
    }

    public void scanDocument() {
        scanner.scan();
    }
}

// ISP를 준수하는 예시
public class ISPExample {
    public static void main(String[] args) {
        MultiFunctionDevice multiFunctionDevice = new MultiFunctionDevice();

        PrinterClient printerClient = new PrinterClient(multiFunctionDevice);
        ScannerClient scannerClient = new ScannerClient(multiFunctionDevice);

        printerClient.printDocument();
        scannerClient.scanDocument();
    }
}​

 

위의 예시 코드에서 Printer 인터페이스와 Scanner 인터페이스가 각각 프린터와 스캐너에 특화된 작은 단위로 분리되어 있습니다. MultiFunctionDevice 클래스는 이 두 인터페이스를 함께 구현하여 프린터와 스캐너 기능을 제공합니다.

PrinterClient 클래스와 ScannerClient 클래스는 각각 프린터와 스캐너만 필요한 클라이언트 클래스를 나타냅니다. 이렇게 인터페이스를 작은 단위로 분리함으로써, 각 클라이언트는 자신이 필요로 하는 기능만을 사용할 수 있으며, 사용하지 않는 다른 기능에 의존하지 않습니다.

ISP를 준수하는 설계는 각 인터페이스가 작고 응집력 있게 정의되어 있기 때문에 코드를 이해하기 쉽고, 의존성이 적어지면서 유지보수성과 재사용성이 향상됩니다.

 

5. DIP (Dependency Inversion Principle - 의존성 역전 원칙)

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 되며, 둘 다 추상화(인터페이스 등)에 의존해야 합니다. 즉, 의존성은 추상화에 의해 이루어져야 합니다.

예를 들어, 전구(Light)와 전구 컨트롤러(LightController) 클래스
// 전구(Light) 인터페이스
interface Light {
    void turnOn();
    void turnOff();
}

// 전구를 제어하는 전구 컨트롤러(LightController) 클래스
class LightController {
    private Light light;

    public LightController(Light light) {
        this.light = light;
    }

    public void turnLightOn() {
        light.turnOn();
    }

    public void turnLightOff() {
        light.turnOff();
    }
}

// 전구를 구체적으로 구현한 클래스
class LightBulb implements Light {
    @Override
    public void turnOn() {
        System.out.println("전구를 켭니다.");
    }

    @Override
    public void turnOff() {
        System.out.println("전구를 끕니다.");
    }
}

// DIP를 준수하는 예시
public class DIPExample {
    public static void main(String[] args) {
        // 전구를 구체적으로 생성
        Light lightBulb = new LightBulb();

        // 전구 컨트롤러는 인터페이스에 의존하여 동작
        LightController lightController = new LightController(lightBulb);

        // 전구 컨트롤러를 사용하여 전구를 제어
        lightController.turnLightOn(); // 출력: 전구를 켭니다.
        lightController.turnLightOff(); // 출력: 전구를 끕니다.
    }
}​

 

위의 예시 코드에서 Light 인터페이스를 사용하여 전구를 추상화하고 있습니다. LightController 클래스는 Light 인터페이스에 의존하여 구현되었습니다. 이로 인해 LightController는 Light 인터페이스의 구현에 직접 의존하고 있으며, LightBulb 클래스와 같은 구체적인 구현체에는 의존하지 않습니다.

이렇게 하면 LightController는 어떤 구체적인 Light 구현체를 사용하더라도 동작할 수 있습니다. 새로운 종류의 전구를 추가하거나 다른 전구를 사용해도 LightController의 코드를 수정할 필요가 없습니다. DIP를 준수하는 설계는 클래스 간의 결합도를 낮추고, 유연성과 확장성을 높이며, 코드의 재사용성을 향상시킵니다.

이러한 SOLID 원칙을 따르면 객체 지향 소프트웨어의 유지보수성, 확장성, 이해하기 쉬운 코드 등을 향상시킬 수 있습니다. 따라서 소프트웨어 설계 시 이러한 원칙을 염두에 두는 것이 중요합니다.