안녕하세요. kernel360 크루 양상원입니다.
객체지향 설계를 위해 필요한 원칙들에 대해 이야기하려고 합니다.

SOLID이 필요한 이유

Java를 공부하면서 객체지향 특징과 원칙에 대해 한 번쯤 들어보셨을 것이라 생각합니다.
오늘은 좋은 객체지향 설계를 하기 위해 꼭 필요한 원칙인 SOLID에 대해서 이야기 해보도록 해보겠습니다.
클린 코드로 유명한 Robert C Martin이 2000년 논문 “디자인 원칙과 디자인 패턴”에서 개념을 소개하였습니다.
이후 Michael Feathers가 SOLID라는 약어를 사용하며 더욱 발전하게 되었습니다.
이 다섯 가지 원칙 덕분에 객체지향 프로그래밍의 세계에 큰 변화를 일으켰습니다.

그렇다면 SOLID는 무엇이며, 더 나은 코드를 작성하는데 어떤 도움이 될까요?

  • 유지 보수가 쉽고 유연한 소프트웨어 개발을 장려합니다.
  • 애플리케이션 규모가 커짐에 따라 복잡성을 줄여줍니다.

이제 SOLID란 무엇인지에 대해 알아보도록 하겠습니다.


SOLID

  1. 단일 책임 원칙 (SRP : Single Responsibility Principle)
  2. 개방 폐쇄 원칙 (OCP : Open Closed Principle)
  3. 리스코프 치환 원칙 (LSP : Liskov Substitution Principle)
  4. 인터페이스 분리 원칙 (ISP : Interface Segregation Principle)
  5. 의존관계 역전 원칙 (DIP : Dependency Inversion Principle)

단어만 보았을 때, 바로 이해하기 어려울 수 있습니다. 다음 섹션에서 각 원칙이 무엇인지 자세히 알아보겠습니다.
추가적으로 원칙들에 대해 코드를 통해서 쉽게 이해를 할 수 있도록 해보겠습니다.


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

  • 하나의 클래스는 하나의 책임만 가져야 한다는 원칙
  • 하나의 책임이라는 것은 상당히 모호한 개념일
    • 문맥과 상황에 따라서 달라질 수 있다.
    • 크게 나눌 수도 있고, 작게 나눌 수도 있다.
  • 중요한 기준은 변경이다.
  • 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.

이 원칙이 더 나은 소프트웨어 개발에 어떤 도움이 될까요?

  1. 테스트(Testing) - 하나의 책임이 있는 클래스는 테스트 케이스 수가 훨씬 적습니다.
  2. 낮은 결합(Lower Coupling) - 단일 클래스에서 기능이 적을수록 종속성이 적습니다.
  3. 정리(Organization) - 잘 정리된 소규모 클래스는 모놀리식 클래스보다 검색하기 쉽습니다.

이제 코드를 통해서 알아보도록 하겠습니다.

// 복합기는 프린트, 스캔, 복사하는 3개의 책임을 가짐.
public class MultiPrinter {

    void print() {
        System.out.println("Printing");
    }

    void scan() {
        System.out.println("Scanning");
    }

    void copy() {
        System.out.println("Copy");
    }
}

예를 들어 복합기를 표현하는 클래스를 살펴봅시다.
복합기의 경우 프린트, 스캔, 복사하는 3가지의 책임을 가지고 있습니다.
사용하는 입장에서는 여러 기능을 가지고 있어 편하다고만 생각이 들 수 있습니다.
개발자 입장에서는 이러한 책임을 분리할 필요가 있습니다. 여러 책임에 종속되기 때문에 종속성을 줄일 필요가 있습니다.
현재는 코드가 적어서 크게 문제 없어보이지만, 서비스가 커지고 코드 수가 늘어나게 되면 복합기 클래스가 상당히 복잡해질 수 있습니다.

그렇다면, 어떻게 분리해야 할까요?
하나의 클래스가 하나의 책임만 가지도록 하면 됩니다.
구체적으로 프린트 클래스는 프린트, 스캔 클래스는 스캔, 복사 클래스는 복사하는 책임만 가지면 됩니다.

// 프린트의 책임만 가지게 됨
public class Printer {

  void print() {
    System.out.println("Printing");
  }
}

// 스캔의 책임만 가지게 됨
public class Scanner {

  void scan() {
    System.out.println("Scanning");
  }
}

// 복사의 책임만 가지게 됨
public class Copier {

    void copy() {
        System.out.println("Copy");
    }
}

이렇게 분리하게 되어 SRP 원칙을 지키게 되었고, 그에 따라 테스트하기 쉬워지고, 종속성을 줄일 수 있었습니다.


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

  • 확장에는 열려있으나 변경에는 닫혀 있다는 원칙
  • 확장을 하려면 기존 코드에 변경이 일어나야 하지 않을까?
  • 이 문제는 다형성을 활용하여 해결
public class BadOrderService {
  private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
}

할인 정책을 가지고 예제를 구성하였습니다.
고정 할인 정책과 비율 할인 정책으로 나누었습니다.
DiscountPolicy는 인터페이스, FixDiscountPolicy, RateDiscountPolicy는 구현체입니다.
위 코드만 보았을 때, 다형성을 활용하여 확장성이 있고, 확장에 열려있다고 볼 수 있습니다.
그러나, 고정 할인 정책에서 비율 할인 정책으로 변경해야 한다면 어떨까요?

public class BadOrderService {
  // OCP 위반
  private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}

BadOrderService의 구현 클래스를 변경이 일어나게 됩니다. 변경에 닫혀있다는 OCP 원칙을 지키지 못하게 되었습니다.
그렇다면, 이를 해결하기 위한 방안에 대해서 이야기하도록 하겠습니다.
해결책으로는 생성자를 이용하여 외부에서 객체를 주입받으면 됩니다. 이를 Dependency Injection 이라고 부릅니다.

public class OrderService {

    private final DiscountPolicy discountPolicy;

    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

public class Main {

  public static void main(String[] args) {
    DiscountPolicy discountPolicy = new FixDiscountPolicy();
    OrderService orderService = new OrderService(discountPolicy);
  }
}

DiscountPolicy에 대한 부분을 보면 OrderService에서 구현체를 지정해주지 않습니다.
이 방식이 DI라고 불리는 방식입니다.
이렇게 하면 다형성을 활용하여 확장에는 열려있게 되고, 변경에는 닫혀있게 되어 OCP 원칙이 지켜지게 됩니다.
실제로 구현체를 넣어주는 부분은 사용하는 클래스에서 넣어주면 됩니다.

이러한 OCP 원칙을 더 잘 활용할 수 있게 해주는 것이 Spring Framework입니다.

@Service
public class OrderService {

    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

@Controller
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
}

OrderService@Service 어노테이션을 붙이면 스프링이 해당 클래스를 스프링 컨테이너에 생성을 해서 사용하는 곳에 주입해줍니다.
객체를 직접 생성하는 번거로움을 스프링이 대신해주기 때문에 개발의 편의성, 편리성을 증가시킬 수 있습니다.


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

  • 객체는 프로그램의 정확성을 깨지 않으면서 하위 인스턴스로 바꿀 수 있어야 한다는 원칙
  • 인터페이스를 구현한 구현체는 인터페이스 규약을 모두 지켜야 한다.
  • 이 원칙은 다형성을 지키기 위한 원칙
public interface Car {
    void brake();
}

Car라는 인터페이스가 있습니다. 브레이크라는 기능은 차량을 멈추는데 사용됩니다.

public class BadCar implements Car{

    int speed = 0;

    @Override
    public void brake() {
        speed++;
    }
}

public class GoodCar implements Car{

  int speed = 0;

  @Override
  public void brake() {
    speed = 0;
  }
}

BadCar, GoodCar 모두 Car를 구현한 클래스입니다.
BadCar의 경우 인터페이스 규약을 지키지 않았기 때문에 LSP 위반입니다. 브레이크를 밟아도 차량이 정지하지 않습니다.
GoodCar의 경우 브레이크를 밟았을 때 정지한다는 인터페이스 규약을 지켰습니다.


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

  • 큰 인터페이스를 작은 인터페이스 여러 개로 분리해야 한다는 원칙
  • 인터페이스를 분리하면 인터페이스가 명확해지고, 대체 가능성이 높아짐
// 사육사
public interface BearKeeper {
    void washTheBear(); // 씻기기
    void feedTheBear(); // 먹이주기
    void petTheBear(); // 쓰다듬기
}

해당 인터페이스는 여러 기능들을 포함하고 있기 때문에, 구현체에서 모두 구현해줘야 하는 문제가 있습니다.
이 문제를 해결하기 위해 인터페이스를 분리해보겠습니다.

public interface BearCleaner {
  void washTheBear();
}

public interface BearFeeder {
  void feedTheBear();
}

public interface BearPetter {
    void petTheBear();
}

이렇게 인터페이스를 분리해보았습니다.
분리해서 생기는 이점에 대해서 아래 코드를 통해 다뤄 보겠습니다.

// 사육사
public class BearCarer implements BearCleaner, BearFeeder{

  @Override
  public void washTheBear() {
    // 곰 씻기기
  }

  @Override
  public void feedTheBear() {
    // 곰 먹이주기
  }
}

// 무모한 일은 무모한 사람에게만 맡기면 된다.
public class CrazyPerson implements BearPetter {

    @Override
    public void petTheBear() {
        // 쓰다 듬기
    }
}

인터페이스를 분리하면, 필요한 인터페이스, 기능만 골라서 구현할 수 있게 됩니다.


의존관계 역전 원칙(DIP : Dependency Inversion Principle)

  • 추상화에 의존해야지, 구체화에 의존해서는 안된다는 원칙
  • 인터페이스에 의존해야하고, 구현 클래스에 의존해서는 안된다는 원칙
  • 의존성 주입(DI)의 경우 이 원칙을 따른다.
public class OrderService {

  private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
}

image

위 코드의 경우 DiscountPolicy라는 인터페이스에 의존하고, 동시에 FixDiscountPolicy라는 구현 클래스에도 의존합니다.
그렇기 때문에 DIP 원칙을 위반하게 됩니다.
이를 개선하기 위해 어떤 방안이 있을까요?
OCP 원칙에서 다루었던 방식을 그대로 활용하면 됩니다.

public class OrderService {

    private final DiscountPolicy discountPolicy;

    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

image

이렇게 DI, 생성자 주입을 활용하여 구체 클래스에 의존하지 않고, 인터페이스에만 의존하도록 변경하였습니다.
DIP 원칙이 지켜짐과 동시에 OCP 원칙도 같이 지켜지게 되었습니다.

@Service
public class OrderService {

    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

Spring은 OCP, DIP 원칙을 지킬 수 있도록 도와줍니다.
그렇기 때문에 Spring을 이용해서 개발을 한다면 SOLID 원칙에 대해 이해해야 하고, 어떤 원칙을 주로 사용하는지 알아야 합니다.
편리하다는 이유로 스프링을 사용하는 것이 아닌 스프링이 주는 이점에 대해서 생각해보는 시간을 가지면 좋을 것 같습니다.

SOLID 원칙에 대해 이해하는데 도움이 되었길 바랍니다. 긴 글 읽어주셔서 감사합니다.

참고 자료

https://www.baeldung.com/solid-principles
https://modulabs.co.kr/blog/oop-solid/
https://product.kyobobook.co.kr/detail/S000000935360
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B8%B0%EB%B3%B8%ED%8E%B8/dashboard