본문 바로가기
프로그래밍/디자인 패턴

객체지향 설계의 5가지 원칙 S.O.L.I.D

by 사바라다 2019. 11. 4.

안녕하세요. 오늘은 객체지향의 5가지 원칙, SOLID에 대해서 알아보도록하겠습니다.

java의 특징은 많이 들어보셨을 것 같습니다. 캡슐화(Encapsulation), 상속(Inheritance), 다형성(Polymorphism) 이렇게 있죠. 이런 특징이 있는 JAVA는 객체지향(OOP) 프로그램의 특징을 가지고 있다고 합니다. 즉, 이런 특징을 이용하여 객체지향의 원칙을 구현할 수 있다는 것입니다. 오늘은 저와 객체지향을 목표로 하는 프로그램이 지켜야 할 5가지 원칙(Principle)에 대해서 알아보도록 하겠습니다.

S - Single responsibility Principle

단일 책임 원칙(Single responsibility principle)이란 모든 메서드 또는 클래스는 단 하나의 책임을 가져야한다는 의미입니다. 요즘 한창 각광받고 있는 MSA에서는 한층 더나가 서비스에 1개의 책임을 가지게 하고 있기도 합니다.

예제

public class BookService() {

    public void findBookById(String query) {
        // DB에서 찾아오기..
        // Log 파일에 찾은 정보 쓰기..
    }

}

위의 클래스에서 메서드는 DB에서 책을 찾아오고 찾아온 정보를 Log파일에 기록하는 2가지의 일을 가지고 있습니다. 단일 책임 원칙은 Method, Class 등에 적용될 수 있으며 Class는 이러한 단일책임원칙을 지키지 못한다고 할 수 있습니다. 이런 method는 각자 일에 맞춰서 나누어 method를 분할하고 사용하는 곳에서 조합하여 사용해야합니다. 단일 책임 원칙에 맞게 수정하면 아래와 같습니다.

public class BookService() {

    public String findBookById(String query) {
        // DB에서 찾아오기..
    }

    public void WriteLog(String log) {
        // Log 파일에 찾은 정보 쓰기..
    }
}

O - Open/closed principle

개방/폐쇄의 원칙(Open/closed principle)이란 소프트웨어의 entity(클래스, 모듈, 메서드)들의 확장은 권장하지만 기존 모듈의 수정은 권장하지 않는다는 의미입니다. 즉 확장은 할 수 있지만 수정은 최소화 하도록 프로그램을 작성해야합니다. OCP원칙은 일반적으로 의존성을 추상화 하는 방법으로 달성할 수 있습니다. 구현된 class를 사용하기 보다는 interface, abstract class을 사용하는 것입니다.

예제

아래 class를 보겠습니다. 아래 Method는 영수증을 모아서 현금으로 계산을 하는 class입니다. 정상적으로 기능하는 Method입니다.

void checkOut(Receipt receipt) {
  // 영수증의 금액 취합
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  // 현금으로 계산
  Payment p = acceptCash(total);
  receipt.addPayment(p);
}

정상적으로 운영하는 도중 신용카드로도 결재할 수 있게해달라는 요건이 추가되었습니다. 우리는 이때 어떻게 이 요건을 달성할 수 있을까요? 가장쉽게 떠오르는 방법이 if를 이용하는 것입니다. 그렇다면 아래와 같이 method를 수정해야합니다.

void checkOut(Receipt receipt, boolean isCash) {
  // 영수증의 금액 취합
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }

  Payment p = null;
  if(isCach) p = acceptCash(total);   // 현금으로 계산
  else p = acceptCreditCard(total);   // 신용카드로 계산

  receipt.addPayment(p);
}

이러한 방식은 제가 지금 설명하고 있는 OCP 원칙을 지키지 못하는 것입니다. 왜냐하면 checkOut이라는 기존메서드를 수정해버렸기 때문입니다. OCP 원칙을 지키는 방식으로 수정해보겠습니다.

interface PaymentMethod {
    Payment acceptPayment(Money total);
}

class PaymentCash {
    public Payment acceptPayment(Money total){
        // 현금 계산 구현
    }
}

class PaymentCreditCard {
    public Payment acceptPayment(Money total){
        // 신용카드 계산 구현
    }
}

void checkOut(Receipt receipt, PaymentMethod method) {
  // 영수증의 금액 취합
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }

  // function을 호출할 때 함께 들어오는 PaymentMethod Type으로 어떻게 계산할지가 정해진다.
  Payment p = method.acceptPayment(total);
  receipt.addPayment(p);
}

위와 같이 구현 후 checkOut을 client가 호출할 때 PaymentMethod의 구현타입인 PaymentCash, PaymentCreditCard 중 하나와 함께 호출한다면 앞으로 비트코인으로 결제하는 방법이 추가된다고 하더라도 checkOut method의 수정없이 PaymentMethod의 구현체를 1개더 생성하여 확장하는 것으로 목표를 달성할 수 있습니다.

L - Liskov Substitution Principle

Liskov Substitution Principle(리스코브 치환 원칙)이란 A is B라는 상속 관계가 있을때, B는 sub class이고 A는 base class입니다. 이때 B의 행위는 A의 행위의 예상할 수 있는 범위내에서 이루어져야 한다는 것입니다.

무슨 말일까요? 대표적인 정사각형은 사각형이다를 구현한 예제에서 답을 찾아봅시다.

예제

class Rectangle {
    private int width;
    private int height;

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

    public int getHeight() {
        return this.height;
    }

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

    public int getWidth() {
        return this.width;
    }

    public int area() {
        return this.width * this.height;
    }
}
class Square extends Rectangle {
    @Override
    public void setHeight(int value) {
        this.width = value;
        this.height = value;
    }

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

위예제를 봅시다. 위의 예제는 정사각형(Square)가 사각형(Rectangle)을 상속 받아서 구현을 하고 있습니다. 훌륭하게 재사용을 하고 있는것 처럼보일 수 있습니다. 하지만 그렇지 않습니다. 아래에서 Client가 사용한다고 해보겠습니다.

    @Test
    public void squreTest() {
        // given
        Rectangle square = new Sqaure();
        square.setWidth(4);
        square.setHeight(5);
        // when
        int area = square.area();
        // then
        assertThat(area, is(20)); // is True? not!!
    }

결과는 무심하게도 실패입니다. 실패하는 이유는 Rectangle에 대한 요소가 Sqaure의 예상범위 밖에 있기 때문입니다.  이렇듯 위의 정사각형은 사각형이다 예제는 LSP를 만족하지 못한다고 할 수 있습니다. 정사각형은 사각형이다의 해결 방법은 상속을 해제하던지 정상적인 동작을 하지 못하는 area를 아래로 Square Class로 가져와 재정의 해야할 것입니다.

LSP는 왜 지켜야 할까요? LSP는 위와 같은 상황이 일어나지 않도록 하는 상속의 룰 로써 최고의 상속 구조 특성을 갖추게 하며, OCP를 위반하지 않도록 인도하는 원칙 이라고 말하고 있습니다.

I - Interface Segregation Principle

인터페이스 분리의 원칙(Interface Segregation Principle)이란 범용적인 인터페이스 보다는 Client가 실제로 사용하는 Interface를 만들어야 한다는 의미로 인터페이스를 사용에 맞게 끔 분리해야한다는 설계원칙입니다.

ISP원칙을 지킴으로써 좋은것은 어떤것일까요? 만약 Interface를 지나치게 범용적으로 구현한다면 그 Interface를 상속받은 Class는 자신이 사용하지 않는 Interface마저 억지로 구현해야합니다. 그리고 사용하지 않는 Interface의 method가 변경되게 되더라도 기존의 class를 변경해야합니다. 이것은 OCP원칙을 위배하기도 합니다.

예제

복합기가 있습니다. 그리고 복합기는 복사, 프린터, 팩스의 기능이 가능합니다. 그렇다면 아래와같이 Inteface를 구성할 수 있습니다.

public interface multifunction {
    void copy();
    void fax();
    void print();
}

이렇게 구성한다고 합시다. interface를 보게되면 복합기가 copy, fax, print를 할 수 있다는 것을 알 수 있습니다. 하지만 사실 복합기는 복사기, 팩스기, 프린터기가 합쳐진 것입니다. 위의 interface로 복사기도 구현할 수 있고 팩스도 구현할 수 있고 프린터도 구현할 수 있습니다. 복사기를 구현해보겠습니다.

public class copyMachine implements multifunction {

    @Override
    public void copy() {
        System.out.println("###복사###")
    }

    @Override
    public void fax() {

    }

    @Override
    public void print() {

    }
}

copy를 하면 정상적으로 동작합니다. 그런데 fax와 print를 보면 그냥 무의마한 method로 남습니다. 만약 interface가 아래와 같이 변경되면 어떻게 될까요?

public interface multifunction {
    void copy();
    void fax(Address from, Address to);
    void print();
}

copyMachine class는 fax를 사용하지 않지만 class를 수정해야만 합니다. 이는 OCP원칙을 위반합니다.

이를 방지하기 위해서는 위의 interface는 아래와 같이 변경해야합니다.

public interface Print {
    void print();
}

public interface Copy {
    void copy();
}

public interface Fax {
    void fax();
}

public interface multifunction extends Print, Copy, Fax{
}

이렇게 하면 각자의 기능에 맞게 사용할 수 있게 됩니다. 이게 바로 ISP입니다.

D - Dependency Inversion Principle

의존성 역전의 원칙(Dependency Inversion Principle) 이란 어떤 Class를 참조해야하는 상황이 생긴다면 그 Class를 직접 참조하는 것이 아니라 그 대상을 추상 클래스로 만들어서 사용하라는 원칙입니다. 단, 해당 추상 클래스는 참조 하는 클래스(상위 모듈)의 목적을 기본으로 합니다.

일반적으로 절차지향적으로 생각할 때 상위모듈이 하위모듈에 의존 적일 수 밖에 없습니다. 왜냐하면 상위 모듈은 하위 모듈의 결과에 따라 좌우되기 때문입니다. 하지만 이러한 방법은 객체지향에서는 정상적인 상태가 아닙니다. 왜냐하면 하위모듈의 변경에 의해서 상위모듈을 연쇄적으로 변경해야하기 때문입니다. 객체 지향에서는 상위모듈을 하위모듈에 독립화를 시킴으로써 이와같은 문제를 해결합니다.

예제

String을 복사하는 Class를 만들어 보겠습니다.

public interface Reader {
    String getString();
}

public interface Writer {
    String putString(String str);
}

public class StringCopier {
    void copy(Reader reader, Writer writer) {
        writer.putString(reader.getString());
    }
}

이렇게 구현했다고 해봅시다. 위의 StringCopier class는 Reader와 Writer를 의존하고 있습니다. 따라서 현재 상태에서의 상위모듈은 StringCopier이며 하위모듈은 Reader와 Writer입니다. 이렇게 추상화된 객체에 의존하고 있습니다. 이렇게 설계했을때의 이점은 Reader와 Writer의 설계에 맞으면 어떠한 구현도 들어올 수 있다는 것입니다. 만약 Reader를 키보드입력으로, Writer를 시스템 출력이라고 한다면 거기에 맞게 아래와 같이 구현하여 의존성을 주입하면 그만입니다.

public Keyboard implements Reader {...}
public SystemWriter implements Writer {...}

만약 Keyboard입력이 아닌 File에서 입력을 받는다 하여도 Reader를 새로 implements하고 Client에서 주입되는 Instance만 변경하면 그만입니다. StringCopier class는 변경되지 않습니다.

마무리

오늘은 이렇게 객체 지향 디자인을 할 때 고려해야할 원칙 5가지에 대해서 알아보았습니다. 객체지향 디자인이 이루고자 하는 목적은 작동하기만 하는 소프트웨어가 아닌 관리가능한, 재사용가능한, 신뢰할 수 있는, 유연한 소프트웨어의 개발이라는 점을 꼭 명시하면 즣을것 같습니다. 여담으로 저는 Clean Code를 적용할 때 위의 5가지 원칙을 기준으로 준수하고자 노력하고 있습니다.

참조

https://jrebel.com/rebellabs/solid-object-oriented-design-principles/

https://vandbt.tistory.com/41

https://vandbt.tistory.com/42

댓글