조종 다음은 개발
article thumbnail

🔥 SOLID

백엔드 개발자라면 SOLID에 대해 들어본 적이 있을 것이다.

나도 많이 들어보고 대충은 알고 있었는데 한 번은 제대로 정리해볼 필요가 있을 것 같았다.

그래서 이번기회에 클린 코드로 유명한 로버트 마틴이 정한 좋은 객체 지향 설계의 5가지 원칙을 정리하고자 한다.

  • SRP: 단일 책임 원칙(single responsibility principle)
  • OCP: 개방-폐쇄 원칙 (Open/closed principle)
  • LSP: 리스코프 치환 원칙 (Liskov substitution principle)
  • ISP: 인터페이스 분리 원칙 (Interface segregation principle)
  • DIP: 의존관계 역전 원칙 (Dependency inversion principle)

1️⃣ SRP 단일 책임 원칙

하나의 클래스는 하나의 책임만 가져야한다는 원칙이다.

 

그런데 하나의 책임이라는 것이 상당히 모호하다.

사람마다 같은 코드를 보고 책임이 크다고 생각할 수 있고, 작다고도 생각할 수 있다.

또한 문맥과 상황에 따라 책임을 정하는 기준이 달라진다.

 

그래서 중요한 기준이 되는 것이 변경이다.

변경이 있을 떄 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이라고 볼 수 있다.

예) UI 변경, 객체의 생성과 사용 분리

 

SRP를 위반하는 예제

책임을 하나만 갖는다는 것이 어떤 의미인지 잘 안 느껴질 수 있기 때문에 예시를 통해서 그 의미를 확인하자.

public class User {
    private String username;
    private String email;

    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }

    public void saveToDatabase() {
        // 데이터베이스에 사용자 정보 저장
        // ...
    }

    public void sendEmail(String subject, String message) {
        // 이메일 보내기
        // ...
    }

    public void generateReport() {
        // 보고서 생성
        // ...
    }
}

위 코드는 단일 책임 원칙(SRP)을 위배한 코드이다.

위의 코드에서 User 클래스는 데이터베이스 저장, 이메일 전송, 보고서 생성과 같은 여러 책임을 가지고 있다.

이는 SRP를 위반하며, 클래스가 여러 이유로 변경될 수 있는 문제를 야기할 수 있다.

SRP를 지키는 예제

public class User {
    private String username;
    private String email;

    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }

    // 다른 클래스로 분리된 기능들
}

public class DatabaseSaver {
    public void saveToDatabase(User user) {
        // 데이터베이스에 사용자 정보 저장
        // ...
    }
}

public class EmailSender {
    public void sendEmail(User user, String subject, String message) {
        // 이메일 보내기
        // ...
    }
}

public class ReportGenerator {
    public void generateReport(User user) {
        // 보고서 생성
        // ...
    }

위는 단일 책임 원칙을 지킨 코드이다.

기존에 User 객체에서 모든 책임을 가지고 있던 것과는 달리 각각의 역할에 대한 책임을 별도의 객체에서 관리해주고 있다.

이제 각 클래스는 하나의 책임만을 가지고 있으며, 변경이 발생할 때 해당 클래스만 수정하면 된다.

이렇게 하면 코드의 유지보수가 더 쉬워지고, 각 클래스는 명확한 역할을 수행하게 된다.

 

2️⃣ OCP 개방-폐쇄 원칙

소프트웨어 요소는 확장에는 열려 있어야 하나 변경에는 닫혀 있어야 한다는 원칙이다.

 

코드의 변경 없이 확장할 수 있어야 한다는 말이다.

이게 어떻게 가능할까? 바로 다형성을 활용하면 된다.

 

OCP를 위반하는 예제

예제를 통해 어떻게 확장에는 열려있고 변경에는 닫혀있는 코드를 만들 수 있는지 확인해 보자.

다음은 OCP를 위반하는 간단한 예시이다.

public class Rectangle {
    public double width;
    public double height;

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

public class AreaCalculator {
    public double calculateArea(Rectangle rectangle) {
        return rectangle.width * rectangle.height;
    }
}

위의 코드에서 AreaCalculator 클래스는 Rectangle 클래스에 종속되어 있다.

이는 OCP를 위반한다. 새로운 도형(예: 삼각형)이 추가된다면 어떨까??

public double calculateArea(Triangle triangle) {
    return triangle.width * triangle.height;
}

해당 도형에 대한 계산 함수를 위해 위 같이 오버로딩으로 된 함수가 추가해야 할 것이다.

여기서 계속해서 도형이 추가된다면 그에 맞게 함수도 계속 추가해줘야 할 것이다.

즉, AreaCalculator 객체가 지속해서 변경되어야 한다.

 

OCP를 지키는 예제

OCP를 지키기 위해서는 다형성을 활용하면 된다.

public interface Shape {
    double calculateArea();
}

public class Rectangle implements 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;
    }
}

public class Circle implements Shape {
    private double radius;

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

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

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

위와 같이  Shape 인터페이스를 도입하여 각 도형에 대한 계산 메서드를 추상화한다.  

AreaCalculator 클래스는 Shape를 인자로 받아서 계산한다.

이렇게 하면 새로운 도형이 추가될 때 AreaCalculator를 수정할 필요 없이 해당 도형 클래스만 추가하면 된다.

즉, 확장에는 열려있으면서 변경에는 닫혀있는 코드를 작성할 수 있다.

 

3️⃣ LSP 리스코프 치환 원칙

자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다는 원칙이다.

 

이는 상속 관계에 있는 클래스 간에는 호환성이 유지되어야 함을 의미한다.

올바른 상속을 위해 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권고하는 원칙이다.

 

이게 무슨 말인지 잘 이해가 안 될 수 있다.

예를 들어 자동차라는 부모 클래스가 있다면, 자동차를 상속받는 자식 클래스는 액셀을 밟으면 앞으로 가야 한다.

만약 뒤로 가게 구현하면 LSP를 위반하는 것이다. 

왜냐하면 자동차 클래스를 상속받는 자식 클래스들은 부모 클래스의 기능(액셀을 밟으면 앞으로 가는 기능)을 기대하기 때문이다.

이와 같이 자식 클래스가 부모 클래스의 방향을 온전히 따르도록 권고한 규칙이 리스코프 치환 원칙이다.

 

LSP를 위반하는 원칙

아직 의미가 잘 이해가 안 될 수 있다. 예제를 통해서 다시 확인해 보자.

public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int calculateArea() {
        return width * height;
    }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

위의 코드에서 Square 클래스는 Rectangle 클래스를 상속받고 있다.

그러나 Square 클래스에서 setWidth와 setHeight 메서드를 오버라이드하여 높이와 너비를 항상 동일하게 설정하도록 했다.

이로 인해 LSP가 위반되었다.

 

Square 클래스는 부모 클래스의 기대와는 다르게 동작하며, 따라서 Square 객체는 Rectangle 객체로 대체할 수 없다.

Rectangle으로 기대되는 다양한 너비와 높이의 설정이 Square에서는 동일한 값으로 강제되기 때문이다.

 

다시 설명하자면, 부모 클래스인 직사각형 객체는 다양한 너비와 높이의 설정이 되도록 기대된다.

그런데 직사각형 객체를 상속받은 정사각형 객체는 다양한 너비와 높이를 설정할 수 없다.

즉, 자식 클래스가 부모 클래스의 방향성을 온전히 따르지 않고 있다.

그래서 LSP 원칙을 위배한다고 보는 것이다.

 

LSP를 지키는 예제

LSP를 지키도록 리팩터링 한 코드는 다음과 같다.

public class Shape {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int calculateArea() {
        return width * height;
    }
}

public class Rectangle extends Shape {
    // 추가적인 기능이나 속성은 여기에 추가
}

public class Square extends Shape {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

이렇게 하면 RectangleSquare는 모두 Shape를 상속받아 공통적으로 사용할 수 있게 되어 LSP를 지키게 된다.

클래스 간의 상속 관계에서 서브 클래스는 기본적으로 슈퍼 클래스의 행동을 확장하거나 오버라이드할 수 있어야 한다.

4️⃣ ISP 인터페이스 분리 원칙

객체는 자신이 사용하는 메서드에만 의존해야 한다는 원칙이다.

 

클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안 된다는 원칙이다.

즉, 하나의 큰 인터페이스보다는 여러 개가 범용 인터페이스가 낫다는 원칙이다.

ISP를 위반하는 예제

public interface Computer {
    void calculate();
    void doGame();
}

class LaboratoryComputer implements Computer {

    @Override
    public void calculate() {
        //
    }

    @Override
    public void doGame() {
        // 사용하지 않음
    }
}

class HomeComputer implements Computer {

    @Override
    public void calculate() {
        //
    }

    @Override
    public void doGame() {
        //
    }
}

위 코드에서 Computer는 calculate와 doGame 메서드를 가지고 있다.

그런데 Computer를 상속받는 LaboratoryComputer(연구소 컴퓨터)는 doGame를 사용하지 않을 것이다.

그런데도 LaboratoryComputer는 인터페이스 Computer를 구현하기 때문에 doGame를 오버라이딩해야 한다.

즉, 사용하지도 않을 메서드를 구현하게 된다.

이는 ISP를 위반한다고 볼 수 있다.

 

ISP를 지키는 예제

public interface Computable {
    void calculate();

}

public interface Gameable {
    void doGame();
}

class LaboratoryComputer implements Computable {

    @Override
    public void calculate() {
        //
    }
}

class HomeComputer implements Computable, Gameable {

    @Override
    public void calculate() {
        //
    }

    @Override
    public void doGame() {
        //
    }
}

이렇게 하면 각 인터페이스는 특정한 책임에 집중하고 있다.

Computer 인터페이스를 분리하여 ComputableGameable 두 개의 작은 인터페이스로 나눠지면서, 각 클래스는 자신이 필요로 하는 인터페이스만 구현하도록 되어 ISP를 지키게 된다.

 

공용된 인터페이스를 여러 개의 인터페이스로 나누면서 인터페이스가 명확해지고, 대체 가능성이 높아진다.

5️⃣ DIP 의존관계 역전 원칙

구체화에 의존하지 않고 추상화에 의존해야 한다는 원칙이다.

 

쉽게 이야기해서 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻이다.

역할(Rold)에 의존해야 한다는 것과 같은 의미이다.

 

간단하게 예를 들어서 설명하자면 연극을 예시로 들 수 있다.

로미오와 줄리엣 연극이 있다고 할 때, 차은우가 수지하고만 공연 연습을 했다고 하자.

그런데 공연 당일에 수지에서 한소희로 배우가 변경되었다.

그래도 공연을 진행하는데 전혀 문제가 없어야 한다.

 

배우가 바뀌었다고 공연 진행에 문제가 발생하면 안 된다.

줄리엣 역할에 누가 오더라도 공연을 할 수 있어야 한다.

즉, 역할에 의존해야 하지 구현체에 의존하면 안 된다는 의미이다.

 

DIP를 위반하는 예제

public class ChaEunWoo{
    public void play() {
        System.out.println("차은우: 눈부셔");
    }
}

public class SuJi {
    public void play() {
        System.out.println("수지: 눈부셔");
    }
}

public class Theater {
    public void play(ChaEunWoo romeo, SuJi juliet) {
        romeo.play();
        juliet.play();
        ...
    }
}

위와 같이 공연이 구체적인 구현체(배우)에 의존하고 있는 경우 DIP를 위배한다고 본다.

만약 배우가 변경된다면 공연 객체에 변경도 발생하게 된다.

 

DIP를 지키는 예제

public interface Romeo {
    void play();
}

public interface Juliet {
    void play();
}

public class ChaEunWoo implements Romeo {
    @Override
    public void play() {
        System.out.println("차은우: 눈부셔");
    }
}

public class SuJi implements Juliet {
    @Override
    public void play() {
        System.out.println("수지: 눈부셔");
    }
}

public class Theater {
    public void play(Romeo romeo, Juliet juliet) {
        romeo.play();
        juliet.play();
        ...
    }
}

위와 같이 역할에 의존하게 된다면 배우가 변경되어도 공연 객체의 수정 필요 없이 새로운 객체를 넘겨주기만 하면 된다.

이렇게 하면 시스템의 유연성이 향상되고, 변경이 발생할 때 영향을 최소화할 수 있다.

profile

조종 다음은 개발

@타칸

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!