[Spring Boot] SOLID 원칙

k·2024년 2월 4일

spring boot

목록 보기
1/4

🪨SOLID 원칙이란?

로버트 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯가지 기본원칙이다.

✋다섯가지 기본 원칙

1. SRP (Single Responsibility Principle)

단일 책임 원칙은 "한 클래스는 하나의 책임만 가져야한다." 라는 원칙이다. 이는 한 클래스가 난해하게 여러가지의 기능을 수행해서는 안된다는 것이다.

쉬운 예시로 들 수 있는 것은, 맥가이버 칼이다.

이는 여러 가지 도구들이 하나에 군집되어 있는 구조를 띄고 있다. 하지만 단일 책임의 원칙은 이러한 형태가 되지않게 각 도구들을 따로 구성해주게 된다.

굳이 "왜 이런 식으로 단일 기능을 수행해야 할까?"

각 도구는 해당 기능만 제대로 수행하면 된다. 굳이 다른 기능까지 고려하는 것은 매우 효율이 떨어지는 행위라는 것이다.

군집되어 있는 구조에서는 하나를 변경하기 위해 다른 것까지 변경되는 연쇄 작용이 생겨날 수도 있다. 위에 말한 효율적이지 못한 행위가 되게 된다. 그렇기에 이런식으로 하나로 군집시켜 구성하는 것은 좋은 방식이 아니라는 것이다.


위의 사진을 보면 조금 더 쉽게 이해가 될 것이다.

  • 가계부 클래스
    가계부 클래스는 가계부를 관리하는 기능만을 가지면 된다.

  • 메일 서비스 클래스
    기존의 가계부 클래스에서 파생되어 온 것인데, 원래 이러한 구조가 유지보수 측면에서 훨씬 효율적이다. 하지만 이 부분도 아래 DIP 원칙에 따라서 변경되어야 할 부분이 존재한다.

2. OCP (Open / Closed Principle)

개방 폐쇄 원칙은 "기존의 구조 변경 없이, 기능 확장을 할 수 있어야 한다." 라는 원칙이다.

네모 세모 동그라미를 그리려고 한다고 하자.
하나의 클래스에 네모 세모 동그라미를 그리는 기능을 다 추가해보자.

아래는 코드이다.

public class ShapeDrawer {
    public void drawCircle() {
        // 동그라미을 그리는 코드
    }
    public void drawSquare() {
        // 네모를 그리는 코드
    }
    public void drawTriangle() {
        // 세모를 그리는 코드
    }
}

근데 이때, 문제가 발생한다.

다른 도형을 추가하기 위해서 ShaperDrawer를 수정해야한다.
즉, 전체적인 로직을 건드려야한다는 것이다.

아래의 코드를 보자.

public interface Shape {
    void draw();
}

public class Circle implements Shape {
    @Override
    public void draw() {
        // 동그라미을 그리는 코드
    }
}

public class Square implements Shape {
    @Override
    public void draw() {
        // 네모을 그리는 코드
    }
}

public class Triangle implements Shape {
    @Override
    public void draw() {
        // 세모를 그리는 코드
    }
}

이러한 형태로 구현하면, 추가하기 위해서는 Shape를 구현하여 만들면 된다.

즉, 확장할 때는 자유롭지만, 수정할 때는 내부적인 로직을 건들이지 않는다.

이러한 것들이 유지보수 측면으로 효율적으로 만들어 준다.

3. LSP (Liskov Substitution Principle)

리스코프 치환 원칙은 "프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야한다." 라는 원칙이다.

이는 펭귄으로 간단하게 예를 들어 볼 수 있을 것 같다.
그 전에 먼저 알아야 할 점은,

펭귄은 조류이다.

그렇다. 펭귄은 조류다.
하지만, 일반적인 조류의 특성인 날으는(fly) 특성을 가지고 있지않은 조류이다. 이 점을 생각하고 아래의 코드를 보자.

class Bird {
    void fly() {
        System.out.println("날으는 중~");
    }
}

class Penguin extends Bird {
    // 안타깝게도 펭귄은 날으는 특성을 상속 받지 못한다.
}

Bird myBird = new Penguin();
myBird.fly(); // Penguin 클래스는 fly()를 구현하지 않았지만, 호출되어 버그 발생 가능

쉽게 말해서 상위 클래스와 하위 클래스의 특성이 정확하게 일치되어야한다 는 것을 알 수 있다.

이 것을 극복하는 방법은 2가지이다.

  1. 펭귄을 꼭 구현 할 필요가 없으면 배제한다.

    이 방법은 당연하게도 좋은 방법은 아니다.

  2. 필자는 펭귄을 꼭 구현하고 싶다. 그렇기에 날 수 있는 조류와 날 수 없는 조류를 구분하기로 했다.

    이 방법을 기준으로 아래에 설명을 할 것이다.

interface Bird {
    void makeSound();
}

class FlyingBird implements Bird {
    @Override
    public void makeSound() {
        System.out.println("우는 소리~~");
    }

    void fly() {
        System.out.println("날으는 중");
    }
}

class Penguin implements Bird {
    @Override
    public void makeSound() {
        System.out.println("펭귄 우는 소리~~");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird flyingBird = new FlyingBird();
        flyingBird.makeSound();
        
        Bird penguin = new Penguin();
        penguin.makeSound();
    }
}

위처럼 구성하게 된다면, 기본 Bird 클래스는 조류의 최소한의 공통된 특성을 담은 class이고, 나는 조류를 위해서 해당 특성을 가진 FlyingBird 클래스를 만든다.

즉, 비둘기와 같이 날 수 있는 조류 클래스를 만들고 싶을 때는 FlyingBird 클래스를 상속받으면 된다.

이렇게 되면, 상위 클래스와 하위 클래스의 특성이 일치하기 때문에 전혀 문제가 되지않는다.

  • 그래서 결론은??

    쉽게 말해, 결론은 상위 클래스는 모든 클래스의 부분집합이다.

    그렇기에 최소한의 공통된 특성을 담아 구성하게 되면 별다른 이상없이 코드를 구성할 수있다라는 것이다.

4. ISP (Interface Segregation Principle)

인터페이스 분리 원칙은 자신이 이용하지 않는 메소드에 의존하지 않아야한다. 라는 원칙이다.

고양이와 강아지로 예를 들어보자.
고양이와 강아지의 특성에서 공통점과 차이점이 존재한다.

  • 차이점
    • 고양이는 산책을 하지않는다.
    • 강아지는 산책을 한다.
  • 공통점
    • 고양이와 강아지는 운다.

위의 차이점이 존재한다고 가정하고 코드를 구성해보자.

interface Animal {
    void 울음();
    void 산책(); 
}

class Cat implements Animal {
    @Override
    public void 울음() {
        System.out.println("야옹");
    }

    @Override
    public void 산책() {
        // 고양이는 산책을 하지 않으므로 아무 동작도 수행하지 않음
    }
}

class Dog implements Animal {
    @Override
    public void 울음() {
        System.out.println("멍멍");
    }

    @Override
    public void 산책() {
        System.out.println("Dog is walking");
    }
}

위 코드에서 비효율적인 부분이 있다고 느꼈으면 정상이다. 고양이는 산책하지않는다고 했지만, Animal이라는 특성으로 구현이 되다보니, 산책이라는 요소가 추가 되었다.

하지만, 결론적으로는 고양이는 산책을 하지않기 때문에 아무런 동작을 하지않는다. 여기서 비효율적이라 생각했을 것이다.

만약이를 ISP 원칙을 따라 변경하게 되면 아래와 같다.

// SoundProducer 인터페이스
interface SoundProducer {
    void 울음();
}

// Walkable 인터페이스
interface Walkable {
    void 산책();
}

class Cat implements SoundProducer {
    @Override
    public void 울음() {
        System.out.println("야옹");
    }
}

// Dog 클래스는 Animal을 구현
class Dog implements SoundProducer, Walkable{
    @Override
    public void 울음() {
        System.out.println("멍멍");
    }

    @Override
    public void 산책(){
        System.out.println("Dog is walking");
    }
}

기존의 Animal 인터페이스의 특성을 분리하여, 각자 필요한 특성만 가지고, 불필요한 특성에 대해서 의존성을 가지지않게 구성하였다.

이러한 형태도 조금 더 유지보수에 효율적인 코드를 짤 수 있으며, 조금 더 다양한 형태의 객체 구현에 도움을 줄 수 있다.

5. DIP (Dependency Inversion Principle)

의존 역전 원칙은 어떠한 객체를 참조하기 위해서는 직접적인 참조가 아니라, 그 객체의 상위 요소를 참조해야 한다. 라는 원칙이다.

생각하기 쉽게 햄치즈 샌드위치로 예를 들겠다.

햄치즈 샌드위치를 만드는 가게가 있다.
해당 가게는 손님에게 빵의 종류, 햄의 종류, 치즈의 종류 를 고를 수 있게 했다고 생각해보자.

위의 상황을 객체를 통해서 만들어 보면 아래와 같다.

  • 요구 사항
    • 빵 : 통밀빵, 베이글빵
    • 치즈 : 모짜렐라치즈, 크림치즈
    • 햄 : 스팸, 베이컨
  • 샌드위치 1을 만들려고한다.
    • 구성요소 : 베이글빵, 모짜렐라 치즈, 베이컨

샌드위치 1 그자체를 객체로써 만들어야한다.

//DIP 적용 X
class 베이글빵 {}
class 모짜렐라치즈 {}
class 베이컨 {}
class 샌드위치1 {
    private 베이글빵 bread;
    private 모짜렐라치즈 cheeze;
    private 베이컨 ham;

    public 샌드위치1() {
        this.bread = new 베이글빵(); // 구체 클래스에 의존
        this.cheese = new 모짜렐라치즈(); // 구체 클래스에 의존
        this.ham = new 베이컨(); // 구체 클래스에 의존
    }
}

샌드위치 내에서 의존성을 주입 받아서 샌드위치 1을 생성할 수 있다. 이는 다른 종류의 샌드위치를 만들 때도 매우 편리하다.

//DIP 적용 O
interface 빵 {}
interface 치즈 {}
interface 햄 {}

class 베이글빵 implements 빵 {}
class 모짜렐라치즈 implements 치즈 {}
class 베이컨 implements 햄 {}

class 샌드위치 {
    private 빵 bread;
    private 치즈 cheese;
    private 햄 ham;

    public 샌드위치(빵 bread, 치즈 cheese, 햄 ham) {
        this.bread = bread;
        this.cheese = cheese;
        this.ham = ham;
    }
}

샌드위치 1
main(){
	샌드위치 sandwitch = new 샌드위치(new 베이글빵,new 모짜렐라치즈,new 베이컨);
}

아래 그림으로 조금 더 쉽게 확인 해볼 수 있다.

이러한 형태로 구성하면, 추가적인 재료가 생기고 다른 구성의 샌드위치를 먹고싶어도 그 해당 상황에 맞게 구성할 수 있게 되어 확장성이 증가하게 된다.

초반 SRP 에서 메일을 보내는 것 또한 인터페이스로 분리해서 DIP 구성형태를 띄게끔하면 유지보수 측면에서 매우 효율적이다.

하지만 이렇게 주입하는 것이 항상 옳지는 않다.

추가적인 구성요소가 절대 생기지않고, 내부의 내용이 불변한다고 가정하면, 굳이 DIP 원칙에 따라 인터페이스(빵, 치즈, 햄)을 중간에 두지않아도 된다.

✍️정리하게 된 이유

SOLID 라는 것은 객체지향 프로그래밍에 근간이 되는 설계 기법이다.

앞으로 스프링부트를 통한 웹을 만들 것인데, 이러한 형태를 따라서 좀 더 유지보수에 특화되어 있는 효율적인 프로젝트를 만들기 위해서 정리했다.

profile
You must do the things you think you cannot do

0개의 댓글