Development/Code

SOLID 원칙 : 객체 지향 프로그래밍 단순화

Danny Seo 2023. 6. 17. 15:56

SOLID 원칙

SOLID는 객체지향 프로그래밍(OOP)에서 다섯 가지 핵심 설계 원칙을 나타내는 약어입니다. 이 원칙들은 개발자가 확장 가능하고 유지보수 가능하며 유연한 소프트웨어 시스템을 만들 수 있도록 돕습니다. 각 SOLID 원칙을 간단한 설명과 함께 이해해보고, 어떻게 적용되는지 예제를 통해 확인해보겠습니다. SOLID 원칙

 

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

단일 책임 원칙은 클래스가 변경되는 이유는 하나만 있어야 한다는 것을 의미합니다. 즉, 클래스는 하나의 책임을 가져야 합니다. 이 원칙은 개발자가 관심사를 분리하고 클래스를 특정 작업에 집중시키도록 도와줍니다. SOLID 원칙

 

예시)

사원 레코드를 관리하는 애플리케이션을 고려해보겠습니다. 초기 구현은 아래와 같습니다:

class Employee {
    private String name;
    private int age;
    private double salary;
    
    // 생성자, 게터, 세터
    public void saveEmployee() {
        // 사원을 데이터베이스에 저장
    }
    
    public void calculateSalary() {
        // 사원 급여 계산
    }
}

 

위의 `Employee` 클래스는 SRP를 위반합니다. 이 클래스는 사원 데이터 및 관련 로직과 데이터베이스 작업을 모두 처리하기 때문입니다. SRP를 따르기 위해 이 책임들을 두 개의 클래스로 분리할 수 있습니다:

 

class Employee {
    private String name;
    private int age;
    private double salary;
    
    // 생성자, 게터, 세터
    public void calculateSalary() {
        // 사원 급여 계산
    }
}

class EmployeeRepository {
    public void saveEmployee(final Employee employee) {
        // 사원을 데이터베이스에 저장
    }
}

이제 `Employee` 클래스는 사원 데이터 및 관련 비지니스 로직에만 집중하고, `EmployeeRepository` 클래스는 데이터베이스 작업을 처리합니다. SOLID 원칙

 

 

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

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

 

예시)

`Shape` 클래스 계층 구조와 다양한 도형의 넓이를 계산하는 `ShapeAreaCalculator` 클래스가 있다고 가정해 봅시다:

abstract class Shape {
    // ...
}

class Circle extends Shape {
    private double radius;
    // 생성자, 게터, 세터
}

class Square extends Shape {
    private double side;
    // 생성자, 게터, 세터
}

class ShapeAreaCalculator {
    public double calculateArea(final Shape[] shapes) {
        double area = 0;
        for (final Shape shape : shapes) {
            if (shape instanceof Circle) {
                Circle circle = (Circle) shape;
                area += Math.PI * Math.pow(circle.getRadius(), 2);
            } else if (shape instanceof Square) {
                Square square = (Square) shape;
                area += Math.pow(square.getSide(), 2);
            }
        }
        return area;
    }
}

위의 `ShapeAreaCalculator` 클래스는 OCP를 위반합니다. 새로운 도형을 추가하려면 `calculateArea()` 메서드를 수정해야 합니다. OCP를 따르기 위해 코드를 다음과 같이 리팩터링할 수 있습니다:

abstract class Shape {
    public abstract double calculateArea();
}

class Circle extends Shape {
    private double radius;
    // 생성자, 게터, 세터

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

class Square extends Shape {
    private double side;
    // 생성자, 게터, 세터

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

class ShapeAreaCalculator {
    public double calculateArea(final Shape[] shapes) {
        double area = 0;
        for (final Shape shape : shapes) {
            area += shape.calculateArea();
        }
        return area;
    }
}

 

이제 새로운 도형을 추가하기 위해서는 `Shape` 클래스를 확장하고 `calculateArea()` 메서드를 구현하기만 하면 됩니다. `ShapeAreaCalculator` 클래스를 수정할 필요가 없습니다.

 

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

리스코프 치환 원칙은 상위 클래스의 객체를 하위 클래스의 객체로 대체해도 프로그램의 정확성에 영향을 주지 않아야 한다는 원칙입니다. LSP는 하위 클래스가 상위 클래스에서 정의된 동작 또는 계약을 위반해서는 안 된다는 것을 강제합니다. SOLID 원칙

 

예시)

`Bird` 클래스 계층 구조와 `fly()` 메서드가 있는 경우를 생각해보겠습니다:

class Bird {
    public void fly() {
        // ...
    }
}

class Eagle extends Bird {
    // ...
}

class Penguin extends Bird {
    // ...
}

 

위의 `Penguin` 클래스는 LSP를 위반합니다. 펭귄은 날지 못하기 때문입니다. LSP를 따르기 위해 코드를 다음과 같이 리팩터링할 수 있습니다:

abstract class Bird {
    // ...
}

class FlyingBird extends Bird {
    public void fly() {
        // ...
    }
}

class Eagle extends FlyingBird {
    // ...
}

class Penguin extends Bird {
    // ...
}

 

이제 `Bird` 클래스 계층 구조는 `fly()` 메서드를 `FlyingBird` 클래스로 분리하여 LSP를 따릅니다. 이렇게 하면 날 수 있는 새와 날지 못하는 새에 대해 하위 클래스를 만들 수 있으며, 기대한 동작을 위반하지 않습니다.

 

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

인터페이스 분리 원칙은 클라이언트가 사용하지 않는 인터페이스에 의존하도록 강제되어서는 안 된다고 말합니다. 다시 말해, 단일하고 크고 거대한 인터페이스보다는 여러 개의 작고 집중된 인터페이스가 더 나은 것입니다. SOLID 원칙

 

예시)

다양한 유형의 프린터를 관리하는 애플리케이션을 고려해보겠습니다:

interface Printer {
    void print();
    void fax();
    void scan();
}

class InkjetPrinter implements Printer {
    public void print() {
        // ...
    }

    public void fax() {
        // ...
    }

    public void scan() {
        // ...
    }
}

class SimplePrinter implements Printer {
    public void print() {
        // ...
    }

    public void fax() {
        // 지원하지 않음
    }

    public void scan() {
        // 지원하지 않음
    }
}

위의 `Printer` 인터페이스는 ISP를 위반합니다. `SimplePrinter` 클래스가 지원하지 않는 메서드를 구현하도록 강제하기 때문입니다. ISP를 따르기 위해 코드를 다음과 같이 리팩터링할 수 있습니다:

interface Printer {
    void print();
}

interface Fax {
    void fax();
}

interface Scanner {
    void scan();
}

class InkjetPrinter implements Printer, Fax, Scanner {
    public void print() {
        // ...
    }

    public void fax() {
        // ...
    }

    public void scan() {
        // ...
    }
}

class SimplePrinter implements Printer {
    public void print() {
        // ...
    }
}

이제 `Printer`, `Fax`, `Scanner` 인터페이스를 분리하여 각 클래스가 필요한 인터페이스만 구현할 수 있게 되었습니다.

 

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

의존성 역전 원칙은 고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 모두 추상화에 의존해야 한다고 말합니다. 또한, 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항은 추상화에 의존해야 합니다. DIP는 소프트웨어 시스템에서의 결합도 감소와 유연성을 촉진합니다. 

 

예시)

이메일과 SMS를 통해 알림을 보내는 애플리케이션을 고려해보겠습니다: SOLID 원칙

class Email {
    public void sendEmail(final String message) {
        // ...
    }
}

class SMS {
    public void sendSMS(final String message) {
        // ...
    }
}

class Notification {
    private Email email;
    private SMS sms;

    public Notification(final Email email, final SMS sms) {
        this.email = email;
        this.sms = sms;
    }

    public void send(final String message) {
        email.sendEmail(message);
        sms.sendSMS(message);
    }
}

위의 `Notification` 클래스는 DIP를 위반합니다. `Email`과 `SMS`의 구체적인 구현에 의존하기 때문입니다. DIP를 따르기 위해 코드를 다음과 같이 리팩터링할 수 있습니다:

interface MessageSender {
    void send(String message);
}

class Email implements MessageSender {
    public void send(String message) {
        // ...
    }
}

class SMS implements MessageSender {
    public void send(String message) {
        // ...
    }
}

class Notification {
    private List<MessageSender> messageSenders;

    public Notification(List<MessageSender> messageSenders) {
        this.messageSenders = messageSenders;
    }

    public void send(String message) {
        for (MessageSender sender : messageSenders) {
            sender.send(message);
        }
    }
}

이제 `Notification` 클래스는 `MessageSender` 추상화에 의존하므로 더 큰 유연성을 가지고 새로운 메시지 전송 방법을 쉽게 추가할 수 있게 되었습니다. SOLID 원칙

 

결론

SOLID 설계 원칙은 객체지향 프로그래밍에서 유지보수 가능하고 확장 가능하며 유연한 소프트웨어 시스템을 만들기 위한 필수적인 지침입니다. 이러한 원칙을 이해하고 적용함으로써 개발자는 더 깨끗하고 효율적인 코드를 작성할 수 있으며, 이는 더 나은 소프트웨어 아키텍처와 생산성의 증가로 이어집니다. SOLID 원칙