TIL

[TIL - 2024-09-10] 좋은 객체 지향 설계의 5가지 원칙 SOLID

yaho!! 2024. 9. 10. 12:40

서론

스프링은 자바 플랫폼을 위한 오픈 소스 프레임워크로, 대규모 엔터프라이즈 애플리케이션을 빠르게 개발할 수 있도록 도와주는 도구라고 배웠고 저는 스프링이 객체 지향 언어의 강력한 특징을 잘 살려내는 프레임워크라고 이해하였다. 스프링의 핵심 개념인 스프링 컨테이너를 비롯해 스프링을 깊이 이해하기 위해, 객체지향 설계 방법상향식 접근으로 학습해보려고 한다.

 

좋은 객체지향 설계의 5가지 원칙(SOLID)

SOLID는 객체 지향 설계 원칙의 다섯 가지 핵심 개념을 모아둔 약어이다. 이 원칙들은 소프트웨어 설계를 더 유연하고 유지보수하기 쉽게 만드는 데 도움을 줄 수 있다. 

 

선요약

  • SRP (단일 책임 원칙) : 하나의 클래스는 하나의 책임만을 가져야 한다.
  • OCP (개방 폐쇄 원칙) : 클래스는 확장에는 열려있고 변경에는 닫혀있어야 한다.
  • LSP (리스코프 치환 원칙) : 부모타입 객체를 하위타입의 객체로 치환하여도 프로그램은 정상적으로 동작해야 한다.
  • ISP (인터페이스 분리 법칙) : 클라이언트는 자신이 사용하지 않은 인터페이스에 의존하지 않아야 한다.
  • DIP (의존관계 역전 법칙) : 구체적인 구현에 의존하지 않고 추상적인 것의 의존해야 한다.

 

SRP - 단일 책임 원칙

단일 책임 원칙(Single responsibility principle)은 하나의 클래스는 하나의 책임만을 가져야 한다는 원칙이다. 즉, 하나의 클래스는 하나의 역할 또는 기능에만 집중해야 하며 다른 책임은 다른 클래스가 맡아야 한다.

 


왜 단일 책임 원칙을 지켜야 할까?

 

  1. 코드를 이해하기 쉬워진다.
    • 단일 책임 원칙을 따르게 된다면 클래스는 하나의 책임만을 가지고 하나의 기능만을 수행하기 때문에, 명확한 목적을 가지고 있게 된다, 따라서 해당 클래스가 어떤 일을 하는지 쉽게 이해할 수 있게 된다.

  2. 변경의 파급이 작아진다.
    • 여러 책임을 가지고 있는 클래스의 특정 부분을 변경하게 된다면, 다른 부분에 영향을 줄 수 있다. 
    • 하지만 하나의 책임을 가지고 있는 클래스의 특정 부분을 변경할 때에는 다른 부분에 영향을 줄 가능성이 적어진다. 

  3. 클래스의 응집도를 높인다.
    • 응집도가 높다는 것은 클래스 내의 메서드와 변수들이 하나의 목적을 위해 밀접하게 연결되어 있다는 것을 의미한다.
    • 단일 책임 원칙을 따르면 클래스는 하나의 책임에만 집중하게 되므로, 클래스 내부의 메서드와 필드들이 하나의 목표를 위해 일관되게 동작하게 된다. 그 결과, 클래스의 응집도가 자연스럽게 높아진다.

  4. 클래스의 결합도를 낮춘다.
    • 결합도는 클래스나 모듈이 다른 클래스나 모듈에 얼마나 의존하는지를 나타내는 개념이다.
    • 여러 책임을 가진 클래스는 다양한 외부 모듈이나 다른 클래스에 의존할 가능성이 높다.
    • 그러나 단일 책임 원칙을 따르는 클래스는 하나의 책임으로 제한되므로, 외부 클래스에 대한 의존성을 최소화할 수 있다. 결합도가 낮으면 클래스 간 의존성이 줄어들어, 수정과 확장이 훨씬 쉬워진다.


 

따라서 단일 책임 원칙을 따름으로써, 코드의 가독성과 유지보수성을 증가시킬 수 있고 클래스의 응집도를 높이며 결합도를 낮추는 효과를 얻게 된다.

 

 

단일 책임 원칙을 잘 따르지 않는 코드 (SRP 위반 코드)

이 `EmployeeManager` 클래스는 급여를 계산하고, 직원 정보를 데이터베이스에 저장하고, 직원 보고서를 생성하는 서로 다른 책임을 가지고 있다.겉보기에는 `EmployeeManager`가 직원 관리와 관련된 모든 작업을 하는 게 더 자연스러워 보일 수 있다. 실제로도 그런 역할을 가지고 있기 때문이다. 하지만 SRP관점에서 보면 이 클래스는 여러 가지 책임을 동시에 가지고 있기 때문에 SRP 원칙을 위배하고 있는 것이다.

 

class EmployeeManager {
    public void calculatePay() {
        // 급여 계산 로직
    }

    public void saveToDatabase(Employee employee) {
        // 직원 정보를 데이터베이스에 저장하는 로직
    }

    public void generateReport() {
        // 직원 보고서 생성 로직
    }
}

 

단일 책임 원칙의 관점에서 본 문제점:

  1. 급여 계산 (calculatePay):
    • 급여 계산은 `EmployeeManager`의 역할 중 하나로 볼 수 있지만, 이는 급여 관련 로직을 처리하는 별도의 책임이다. 급여 계산은 복잡한 비즈니스 로직이 될 수 있으며, 다른 기능과 독립적으로 처리되는 것이 이상적입니다.
  2. 데이터베이스 저장 (saveToDatabase):
    • 데이터베이스에 저장하는 작업은 급여 계산과는 전혀 다른 저장 책임이다. 직원 정보를 영구적으로 저장하거나 업데이트하는 작업은, 데이터베이스와의 상호작용을 전담하는 클래스가 맡아야 한다.
  3. 보고서 생성 (generateReport):
    • 보고서 생성은 또 다른 책임이다. 보고서 생성 로직은 일반적으로 데이터 처리와 출력 형식에 대한 작업을 포함하므로, 급여 계산이나 데이터베이스 저장과는 별개의 책임으로 간주된다.

비록 EmployeeManager가 직원 관리라는 더 큰 범위에서 책임을 지고 있다고 느껴질 수 있지만, 세부적인 책임은 분리되어야 한다. 각 클래스는 하나의 역할에만 집중해야 하며, 그 책임에 맞는 변화가 있을 때만 수정되도록 설계하는 것이 단일 책임 원칙을 따르는 방식이다.

 

 

단일 책임 원칙을 잘 따르는 코드 (SRP 준수 코드)

이제 단일책임 원칙을 준수하여 코드를 재작성해보았다. 이제 각 클래스가 하나의 책임만 가지고 있게 된다. `PayCalculator`는 급여 계산만, `EmployeeRepository`는 직원 정보를 데이터베이스에 저장하는 역할만, `ReportGenerator`는 보고서 생성만 담당하게 된다. 이를 통해 각 클래스는 명확한 목적을 가지고 있게 되며 기능이 변경될 때 다른 부분에 미치는 영향을 최소화할 수 있게 된다.

class PayCalculator {
    public void calculatePay(Employee employee) {
        // 급여 계산 로직
    }
}

class EmployeeRepository {
    public void saveToDatabase(Employee employee) {
        // 직원 정보를 데이터베이스에 저장하는 로직
    }
}

class ReportGenerator {
    public void generateReport(Employee employee) {
        // 직원 보고서 생성 로직
    }
}

 


OCP - 개방 폐쇄 원칙

개방 폐쇠 원칙(Open - Close principle)이란 클래스는 확장에는 열려(open) 있으나 변경에는 닫혀(close) 있어야 한다는 원칙이다. 즉 새로운 기능을 추가할 때에 기존 코드를 수정하지 않고 확장을 통해 기능을 추가할 수 있어야 한다는 원칙이다.

 


왜 개방 폐쇠 원칙을 따라야 할까?

 

  1. 유지보수성 향상
    • OCP를 지키면 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있기 때문에 코드의 변경으로 인한 부작용을 줄일 수 있다.
    • 새로운 기능이나 요구 사항이 생길 때마다 기존 코드를 계속 수정한다면, 의도치 않은 곳에서 오류가 발생할 수 있다. OCP를 따르면 이런 위험을 최소화할 수 있게 된다.
  2. 확장성 증가
    • 새로운 기능이나 새로운 요구사항이 생기더라도 기존 코드를 변경하지 않고 새로운 클래스를 통해 기능을 확장할 수 있다.

 

즉 OCP 원칙을 지킴으로써, 새로운 기능이나 요구사항을 추가할 때 기존 코드를 변경하지 않고 코드의 안전성을 유지하면서도 기능을 확장할 수 있게 된다.

 

 

OCP를 지키지 않은 코드 (OCP 위반)

예를 들어, `Shape` 클래스를 사용하여 도형의 타입을 구분하고, 그에 따라 도형의 넓이를 계산하는 `AreaCalculato`r 클래스를 작성하였다. 이 코드에서는 새로운 도형을 추가하려면 `calculateArea` 메서드의 기존 코드를 수정해야 한다. 이는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다OCP 원칙을 준수하지 않은 코드라고 말할 수 있다.

class Shape {
    public String type;
}

class AreaCalculator {
    public double calculateArea(Shape shape) {
        if (shape.type.equals("circle")) {
            // 원의 넓이 계산
            return Math.PI * 5 * 5;  // 예시로 반지름 5 사용
        } else if (shape.type.equals("square")) {
            // 정사각형 넓이 계산
            return 10 * 10;  // 예시로 변 길이 10 사용
        }
        return 0;
    }
}

 

 

OCP를 지킨 코드 (OCP 준수)

다음은 OCP를 준수한 코드이다. 새로운 도형을 추가할 때, Shape 인터페이스를 구현하여 새로운 기능을 확장할 수 있다. 이 방식은 기존 코드를 수정하지 않고도 확장이 가능하며, OCP 원칙에 따라 수정 없이 기능을 추가할 수 있다.

interface Shape {
    double calculateArea();
}

class Circle implements Shape {
    private double radius;

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

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

class Square implements Shape {
    private double side;

    public Square(double side) {
        this.side = side;
    }

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

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

 


LSP - 리스코프 치환 원칙

LSP(Liskov Substitution Principle)은 상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램이 정상적으로 작동해야 한다 원칙이다. 쉽게 이야기하여 부모 객체를 사용하는 곳에서 부모 객체 대신 자식 객체를 사용해도 프로그램 동작에 문제가 없어야 한다는 의미이다.

 

좀 더 쉽게 이야기하여 부모 클래스의 객체를 사용하는 코드가 있다면, 그 자리에 자식 클래스의 객체를 넣어도 동일하게 작동해야 한다는 의미이다. 만약 자식 클래스가 부모 클래스의 기능을 변경하거나 기대한 동작을 따르지 않아서 프로그램의 오류가 생긴다면 이는 LSP 원칙을 따르지 않은 것이다. 즉 부모 클래스의 메서드를 재정의 하더라도, 부모 클래스에서 기대하는 기능적 일관성을 깨지 않아야 한다.

 

LSP를 지키지 않은 코드 (LSP 위반)

먼저 LSP 원칙을 지키지 않은 소스 코드이다. 여기서 `Bird` 클래스는 `fly()` 메서드를 통해 날 수 있는 행동을 정의하였고  타조 클래스가 이를 상속받아 구현하였다,  `Ostrich` 클래스 즉, 타조는 새의 한 종류이긴 하지만 날 수 없기 때문에 `fly()` 메서드를 재정의하여 예외를 발생시켰다. 

 

여기서 `Ostrich` 클래스는 부모 클래스의 기대 행동을 따르지 않고 있다. `Bird`를 사용하는 코드에서 `Ostrich` 를 사용하게 된다면 예외를 던지고 프로그램이 비정상적으로 동작하게 될 것이다. 즉, 자식 클래스가 부모 클래스를 대체할 수 없으므로 LSP 원칙을 위배한 것이라고 말할 수 있다. 

class Bird {
    public void fly() {
        System.out.println("새가 날아갑니다.");
    }
}

class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("타조는 날 수 없습니다.");
    }
}

 

 

LSP를 지킨 코드 (LSP 준수)

다음은 LSP 원칙을 준수한 코드이다. LSP를 지키기 위해, 자식 클래스가 부모 클래스의 기대된 행동을 해치지 않도록 설계해야 한다. 이를 위해 Bird 클래스를 추상 클래스로 선언하고, 새들의 이동 방식을 move() 메서드로 추상화하였다. 또한, 날 수 있는 새와 날 수 없는 새로 구분하여 각각의 이동 방식을 구현하여 일관된 기능을 가질 수 있도록 하였다.

abstract class Bird {
    public abstract void move();
}

class FlyingBird extends Bird {
    public void fly() {
        System.out.println("새가 날아갑니다.");
    }

    @Override
    public void move() {
        fly();
    }
}

class Ostrich extends Bird {
    @Override
    public void move() {
        System.out.println("타조가 달립니다.");
    }
}

 


왜 리스코프 치환 원칙을 따라야 할까?

 

  1. 유지보수성 향상
    • LSP를 따르면 자식 클래스가 부모 클래스를 대체할 수 있기 때문에 기존 코드를 변경하지 않고도 자식 클래스를 사용할 수 있다.
  2. 일관성 보장
    • 부모 클래스를 사용하는 곳에서 자식 클래스를 사용할 수 있다면, 동일한 로직에서 다양한 자식 클래스를 사용할 수 있다. 즉, 다형성을 제대로 활용할 수 있게 해 준다.
  3. 유연한 확장성:
    • 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 자식 클래스를 추가하여 기능을 확장할 수 있다. 


 


ISP - 인터페이스 분리 원칙

ISP(Interface segregation principle) 인터페이스 분리 원칙은 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 원칙이다. 즉, 인터페이스는 여러 가지 기능을 한꺼번에 제공하는 거대한 인터페이스가 아닌 작고 구체적인 인터페이스로 분리되어야 한다.

 

한 인터페이스에서 너무 많은 기능을 제공하면, 이를 구현하는 클래스는 필요하지 않은 메서드까지 구현하거나 의존해야 되는 상황이 발생할 수 있다. ISP 이러한 문제를 방지하기 위해 필요한 기능만 제공하도록 설계해야 한다고 하는 원칙을 말한다.

 

클라이언트가 어떤 인터페이스를 구현할 때, 자신이 사용하지 않는 메서드를 포함하고 있다면 이는 인터페이스 분리 원칙을 준수하지 않는 것이라고 말할 수 있다.

 

ISP를 지키지 않은 코드 (ISP 위반)

이 소스 코드에서 `Worker` 인터페이스는 `work()` 와 `eat()` 추상 메서드를 가지고 있다. `HumanWorker` 클래스는 문제없지만 `RobotWorker` 클래스에서 문제가 발생한다 로봇은 밥을 먹지 않기 때문에 `eat()` 메서드가 필요 없다, 즉 자신이 사용하지 않는 인터페이스에 의존하고 있다. 이는 ISP원칙을 따르지 않았다고 할 수 있다.

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

class HumanWorker implements Worker {
    @Override
    public void work() {
        System.out.println("사람이 일을 합니다.");
    }

    @Override
    public void eat() {
        System.out.println("사람이 밥을 먹습니다.");
    }
}

class RobotWorker implements Worker {
    @Override
    public void work() {
        System.out.println("로봇이 일을 합니다.");
    }

    @Override
    public void eat() {
        throw new UnsupportedOperationException("로봇은 밥을 먹지 않습니다.");
    }
}

 

 

ISP를 지킨 코드 (ISP 준수)

`Workable`과 `Eatable` 인터페이스로 기능을 분리하여 ` RobotWorker` 클래스는 자신이 필요로만 하는 `work()` 메서드만 구현하고 `eat()` 메서드를 구현하지 않아도 된다. 이렇게 인터페이스를 작고 구체적인 단위로 나누어 설계함으로써, 각 클래스는 자신이 필요로만 하는 기능만 구현하고 불필요한 의존성을 제거할 수 있게 된다.

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class HumanWorker implements Workable, Eatable {
    @Override
    public void work() {
        System.out.println("사람이 일을 합니다.");
    }

    @Override
    public void eat() {
        System.out.println("사람이 밥을 먹습니다.");
    }
}

class RobotWorker implements Workable {
    @Override
    public void work() {
        System.out.println("로봇이 일을 합니다.");
    }
}

 


왜 인터페이스 분리 원칙을 따라야 할까?

 

  1. 불필요한 구현을 피할 수 있다.
    • ISP 원칙을 따르지 않으면 클래스가 자신이 필요로 하지 않은 메서드 까지고 강제로 구현해야 할 수 있다. ISP는 이를 방지하여 클래스가 실제로 필요로만 하는 기능을 구현할 수 있게 한다.
  2. 변경에 대한 영향을 줄인다.
    • 클라이언트가 사용하지 않는 메서드를 포함하는 큰 인터페이스를 사용하면, 인터페이스가 변경될 때 사용하지 않는 메서드 때문에도 클라이언트가 영향을 받을 수 있다. ISP는 이를 방지하여 변경의 영향을 최소화한다.
  3. 유지보수가 용이해진다.
    • 인터페이스가 작고 구체적일수록, 시스템이 유연하고 유지보수하기 쉬운 구조가 된다.


즉, ISP는 인터페이스를 기능별로 나누어 클래스를 필요하지 않은 메서드에 의존하지 않도록 하는 원칙이다. 이를 통해 코드의 유연성확장성을 높이고, 유지보수가 더 쉬운 시스템을 설계할 수 있다.


DIP - 의존관계 역전 원칙 

DIP(Dependency Inversion Principle)는 구체적인 구현에 의존하지 않고, 추상적인 것에 의존하라는 원칙이다.

 

예를 들어, 스위치가 전구를 켜고 끄는 코드가 있다고 해보자. 스위치가 전구에 대해 너무 구체적인 정보를 알면, 나중에 전구 대신 선풍기 같은 다른 장치를 제어해야 할 때 코드가 복잡해진다. 이때 전구나 선풍기와 같은 구체적인 객체에 의존하지 말고, 대신 전원을 켜고 끄는 인터페이스에 의존하면, 다양한 장치로 쉽게 변경할 수 있다.

 

 

DIP를 지키지 않은 코드 (구체적인 것에 의존)

이 코드에서 Switch는 전구(LightBulb)와만 연결되어 있다. 만약 Switch로 전구 대신 다른 장치(예: 선풍기)를 제어해야 하면, Switch 클래스도 수정해야 한다. 즉, 유연하지 않고 확장하기 어렵다.

class LightBulb {
    public void turnOn() {
        System.out.println("전구가 켜졌습니다.");
    }

    public void turnOff() {
        System.out.println("전구가 꺼졌습니다.");
    }
}

class Switch {
    private LightBulb bulb;

    public Switch(LightBulb bulb) {
        this.bulb = bulb;
    }

    public void operate(boolean on) {
        if (on) {
            bulb.turnOn();
        } else {
            bulb.turnOff();
        }
    }
}

 

 

DIP를 지킨 코드 (인터페이스에 의존)

이제 Switch는 Switchable 인터페이스를 통해 장치(전구, 선풍기)를 제어한다. 즉, 전구든 선풍기든 상관없이 Switch는 동일하게 동작한다. 코드를 작성할 때 구체적인 구현(전구, 선풍기)에 의존하면 나중에 변경이 어렵다. 하지만 인터페이스에 의존하면 언제든지 쉽게 장치를 바꿀 수 있어 유연해진다.

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

class LightBulb implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("전구가 켜졌습니다.");
    }

    @Override
    public void turnOff() {
        System.out.println("전구가 꺼졌습니다.");
    }
}

class Fan implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("선풍기가 켜졌습니다.");
    }

    @Override
    public void turnOff() {
        System.out.println("선풍기가 꺼졌습니다.");
    }
}

class Switch {
    private Switchable device;

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

    public void operate(boolean on) {
        if (on) {
            device.turnOn();
        } else {
            device.turnOff();
        }
    }
}

 

결론

좋은 객체지향의 설계 5가지 원칙(SOLID)를 따르게 되면 소프트웨어가 더 유연하고 더 유지보수 하기 쉬운 구조로 발전할 수 있다는것을 알게되었다. 직접 실습을 통해 코드를 작성하고 기록함으로써 기억에 오래 남을거 같다.