객체 지향 프로그래밍의 5원칙: S.O.L.I.D 원칙 (SOLID Principles)

ClydeHan·2024년 8월 26일
2

S.O.L.I.D 원칙

SOLID Principles Image

이미지 출처: smashingtips.com

SOLID 원칙은 객체 지향 프로그래밍에서 높은 품질의 소프트웨어를 개발하기 위한 다섯 가지 중요한 원칙을 가리키는 용어다. 이 원칙들은 소프트웨어를 더 이해하기 쉽고, 유지보수가 용이하며, 유연하게 확장할 수 있도록 설계하는 데 도움을 준다. SOLID 원칙은 특히 대규모 시스템 설계와 다양한 디자인 패턴을 이해하고 적용하는 데 필수적인 개념이다.

SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙( SRP, OCP, LSP, ISP, DIP )을 말한다.
인용: Inpa Dev 👨‍💻:티스토리


📌 객체 지향 프로그래밍(OOP, Object-Oriented Programming)이란?

객체 지향 프로그래밍에 대해선 이전 포스트에서 아주 구체적으로 다뤘다. 해당 키워드에 대한 지식이 없다면, 바로 아래 링크에서 꼭 읽어볼 것을 추천한다.

[Computer Science] 객체 지향 프로그래밍 정복하기 (OOP, Object-Oriented Programming)


📌 SOLID 원칙 개요

💡 S: 단일 책임 원칙 (Single Responsibility Principle, SRP)

💡 O: 개방 폐쇄 원칙 (Open Closed Principle, OCP)

💡 L: 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

💡 I: 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

💡 D: 의존 역전 원칙 (Dependency Inversion Principle, DIP)

이 원칙들은 서로 독립적이지만, 개념적으로 연관되어 있다. 하나의 원칙을 지키는 것이 다른 원칙의 준수로 이어지며, 그 결과 더 나은 객체 지향 설계를 구현할 수 있다.


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

💡 원칙 설명

단일 책임 원칙은 클래스는 오직 하나의 책임만 가져야 한다는 원칙이다. 여기서 책임이란 '하나의 기능 담당'을 의미하며, 하나의 클래스는 하나의 기능을 중심으로 설계되어야 한다. 이렇게 하면 기능 변경 시 수정해야 할 코드가 줄어들고, 코드의 유지보수성이 향상된다.


💡 적용 사례

예를 들어, ReportGenerator라는 클래스가 보고서의 생성, 저장, 그리고 출력 기능을 모두 담당하고 있다면, 이 클래스는 SRP 원칙을 위반하고 있는 것이다. 각 기능을 별도의 클래스로 분리하여, ReportGenerator는 보고서 생성만 담당하고, 저장은 ReportSaver, 출력은 ReportPrinter라는 클래스로 각각 나누는 것이 SRP 원칙에 부합하는 설계이다.

class ReportGenerator {
    public String generateReport() {
        // 보고서 생성 로직
    }
}

class ReportSaver {
    public void saveReport(String report) {
        // 보고서 저장 로직
    }
}

class ReportPrinter {
    public void printReport(String report) {
        // 보고서 출력 로직
    }
}

이렇게 분리하면, 저장 방법이 바뀌어도 ReportGeneratorReportPrinter 클래스는 수정할 필요가 없게 되어 유지보수가 쉬워진다.


💡 SRP의 장점

  • 변경에 유연함: 하나의 클래스가 하나의 책임만 가지기 때문에, 변경이 일어날 때 그 영향을 최소화할 수 있다.
  • 코드 가독성 향상: 각 클래스가 하나의 책임을 가지고 있기 때문에, 코드가 더 직관적이고 이해하기 쉬워진다.
  • 테스트 용이성: 각 클래스가 하나의 책임만 수행하므로, 테스트 범위가 명확해지고 단위 테스트가 용이해진다.

📌 개방 폐쇄 원칙 (Open Closed Principle, OCP)

💡 원칙 설명

개방 폐쇄 원칙은 클래스는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다는 원칙이다. 즉, 새로운 기능이 필요할 때 기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 설계해야 한다.


💡 적용 사례

기능을 확장하면서 기존 코드를 변경하지 않으려면, 주로 추상화를 사용한다. 예를 들어, 여러 종류의 도형(원, 사각형, 삼각형 등)을 그리는 프로그램을 설계한다고 가정해보자. OCP 원칙에 따라 Shape라는 추상 클래스를 정의하고, 각 도형 클래스는 이 Shape 클래스를 상속받아 자신만의 draw() 메서드를 구현한다.

abstract class Shape {
    abstract void draw();
}

class Circle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing a Circle");
    }
}

class Rectangle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing a Rectangle");
    }
}

class Triangle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing a Triangle");
    }
}

이제 새로운 도형이 추가되더라도 기존의 Shape 클래스나 다른 도형 클래스들을 수정할 필요 없이, 새로운 클래스만 추가하면 된다.


💡 OCP의 장점

  • 유연한 확장성: 기능을 확장할 때 기존 코드를 수정할 필요가 없으므로, 시스템의 안정성이 높아진다.
  • 유지보수 비용 절감: 코드 수정이 최소화되기 때문에, 새로운 기능 추가 시 발생할 수 있는 오류나 버그를 줄일 수 있다.

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

💡 원칙 설명

리스코프 치환 원칙은 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙이다. 즉, 하위 클래스의 인스턴스는 언제나 상위 클래스의 인스턴스를 대신할 수 있어야 하며, 프로그램의 동작은 정상적으로 유지되어야 한다.


💡 적용 사례

LSP 원칙은 다형성을 보장하기 위한 중요한 원칙이다. 예를 들어, Bird라는 상위 클래스와 이를 상속받는 PenguinSparrow라는 하위 클래스가 있다고 가정해보자. 모든 Bird 클래스는 fly() 메서드를 가지고 있지만, 펭귄은 날 수 없기 때문에 fly() 메서드를 오버라이딩하여 예외를 던진다면, LSP 원칙을 위반하게 된다.

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

class Sparrow extends Bird {
    @Override
    void fly() {
        System.out.println("Sparrow flying...");
    }
}

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

이 경우, Penguin 클래스를 Bird 타입으로 대체할 수 없으므로, LSP 원칙에 위배된다. 이를 해결하기 위해 Bird 클래스를 상속받는 것이 아니라, 새로운 NonFlyingBird 클래스를 정의하여 Penguin을 이 클래스에서 상속받게 설계할 수 있다.


💡 LSP의 장점

  • 다형성 보장: LSP를 지키면 다형성을 안전하게 사용할 수 있으며, 상위 클래스 타입으로 객체를 대체하는 경우에도 예기치 않은 오류가 발생하지 않는다.
  • 안전한 코드 재사용: 하위 클래스가 상위 클래스의 기능을 확장하되, 기본적인 동작은 보장되기 때문에 코드의 안정성과 재사용성이 높아진다.

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

💡 원칙 설명

인터페이스 분리 원칙은 인터페이스는 그 인터페이스를 사용하는 클라이언트에 맞게 최소한으로 분리되어야 한다는 원칙이다. 즉, 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않도록, 인터페이스를 작고 구체적으로 나누어야 한다.


💡 적용 사례

한 인터페이스가 너무 많은 메서드를 제공하면, 그 인터페이스를 구현하는 클래스는 필요 없는 메서드까지 구현해야 하는 불편함이 생긴다. 예를 들어, Worker라는 인터페이스가 eat(), work(), sleep() 메서드를 가지고 있다고 가정해보자. 이 인터페이스를 로봇(Robot) 클래스가 구현하려고 할 때, 로봇은 eat()sleep() 메서드가 필요 없을 수 있다. 이 경우 인터페이스를 분리하여 문제를 해결할 수 있다.

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class HumanWorker implements Workable, Eatable, Sleepable {
    @Override
    public void work() { /* 작업 로직 */ }

    @Override
    public void eat() { /* 식사 로직 */ }

    @Override
    public void sleep() { /* 수면 로직 */ }
}

class RobotWorker implements Workable {
    @Override
    public void work() { /* 작업 로직 */ }
    // 로봇은 eat()과 sleep() 메서드가 필요 없음
}

이렇게 하면 각 클래스는 자신이 필요한 메서드만 구현하게 되어 더 깔끔하고 유연한 설계를 할 수 있다.


💡 ISP의 장점

  • 유연한 인터페이스: 인터페이스가 작고 명확하기 때문에, 필요하지 않은 기능에 대해 클래스가 의존하지 않게 된다.
  • 유지보수 용이성: 인터페이스 변경이 최소화되며, 변경 시 그 영향을 받는 클래스의 수를 줄일 수 있다.

💡 SRP와 ISP의 차이점

SRP와 ISP는 비슷한 목적을 가지고 있지만, 그 적용 대상과 방식이 다르다. SRP는 클래스나 모듈의 책임을 명확히 하고, ISP는 인터페이스를 작고 구체적으로 만들어 클라이언트가 불필요한 의존성을 가지지 않도록 한다.


📌 의존 역전 원칙 (Dependency Inversion Principle, DIP)

💡 원칙 설명

의존 역전 원칙은 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙이다. 즉, 구체적인 클래스에 의존하지 말고, 추상화된 인터페이스나 상위 클래스에 의존하도록 설계해야 한다.


💡 적용 사례

예를 들어, Button 클래스가 Lamp 클래스를 직접 제어한다고 가정해보자. 이 경우 Button 클래스는 Lamp 클래스에 강하게 결합되어 있으므로, DIP 원칙을 위반하고 있다.

class Lamp {
    public void turnOn() {
        System.out.println("Lamp is on");
    }

    public void turnOff() {
        System.out.println("Lamp is off");
    }
}

class Button {
    private Lamp lamp;

    public Button(Lamp lamp) {
        this.lamp = lamp;
    }

    public void press() {
        if (/* some condition */) {
            lamp.turnOn();
        } else {
            lamp.turnOff();
        }
    }
}

DIP 원칙을 적용하면, Button 클래스는 Lamp 클래스가 아니라 Switchable이라는 추상화된 인터페이스에 의존하게 된다.

interface Switchable {
    void turnOn();
    void turnOff();
}

class Lamp implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("Lamp is on");
    }

    @Override
    public void turnOff() {
        System.out.println("Lamp is off");
    }
}

class Button {
    private Switchable device;

    public Button(Switchable device) {
        this.device = device;
    }

    public void press() {
        if (/* some condition */) {
            device.turnOn();
        } else {
            device.turnOff();
        }
    }
}

이제 Button 클래스는 Lamp 클래스뿐만 아니라 Fan이나 TV와 같은 다른 Switchable 장치들도 제어할 수 있게 되었다.


💡 DIP의 장점

  • 결합도 감소: 모듈 간의 결합도가 낮아져, 모듈이 독립적으로 변경될 수 있다.
  • 유연성 향상: DIP를 적용하면 구체적인 클래스가 아니라 인터페이스에 의존하기 때문에, 다양한 구현체로 쉽게 변경할 수 있다.

참고문헌


0개의 댓글