객체 지향 설계 원칙 - SOLID

bp.chys·2020년 4월 2일
5

OOP & Design Pattern

목록 보기
9/17

1. 단일 책임 원칙(SRP)

  • Single Responsiblity Principle
  • 클래스는 단 한 개의 책임을 가져야 한다.
  • 클래스가 여러 책임을 갖게 된다면 그 클래스는 각 책임마다 변경되는 이유가 발생하기 때문에 클래스가 한 개의 이유로만 변경되려면 클래스는 한 개의 책임만을 가져야 한다.
  • 다른 말로 클래스를 변경하는 이유는 단 한 개여야 한다.고도 표현한다.
  • 단일 책임 원칙을 어기면 재사용이 어려워진다.

단일 책임 원칙 위반 시 문제점

public class DataViewer {
    public void display() {
        String data = loadHtml();
        updateGui(data);
    }
    
    public String loadHtml() {
        HttpClient client = new HttpClient();
        client.connect(url);
        return client.getResponse();
    }
    
    private void updateGui(String data) {
        GuiData guiModel = parseDataToGuiData(data);
        tableUI.changeData(guiModel);
    }
    
    private GuiData parseDataToGuiData(String data) {
       ...// 파싱 처리 코드
    }
    ...// 기타 필드 등 다른 코드
}
  • DataViewer 객체는 HTML 응답 문자열을 읽고, 업데이트하고, 변환하고, 보여주는 책임을 가지고 있다. 만약 읽어온 데이터가 String 이 아닌 byte[ ] 라면 updateGui, parseDataToGuiData 등 모두 데이터를 받는 코드가 변경되어야 한다.
  • 이런 연쇄적인 코드 수정은 두 개의 책임(데이터를 읽는 책임과 화면에 보여주는 책임)이 한 클래스에 밀접하게 결합되어 있어서 발생한 증상이다.
  • 책임의 개수가 많아질수록 한 책임의 기능변화가 다른 책임에 주는 영향은 비례해서 증가하게 되는데, 결국 코드를 절차 지향적으로 만들어 변경을 어렵게 만든다.

책임이란 변화에 대한 것

  • 기능 변경 요구가 없을 때 수정에 대한 문제가 없다는 것은, 뒤집어 보면 책임의 단위는 변화되는 부분과 관련된다는 의미가 된다.
  • 기능 요구가 변경되어 함께 수정되어야 할 부분은 별도로 분리되어야 할 책임이라는 것을 알 수 있다.
  • 단일 책임 원칙을 처음부터 잘 지키는 것은 쉽지 않은데, 메서드를 호출하는 것이 누구인지 확인해 보면 된다.
  • 서로 다른 객체에서 한 객체의 각각 다른 메서드를 호출한다면 이 역시 책임이 분리될 필요성이 있다고 볼 수 있다.

2. 개방 폐쇄 원칙(OCP)

  • Open-Closed Principle
  • 확장에는 열려있어야 하고, 변경에는 닫혀 있어야 한다.
  • 다시 말하면, 기능을 변경하거나 확장할 수 있으면서 그 기능을 사용하는 코드는 수정하지 않는다. 로 풀어볼 수 있다.
  • 개방 폐쇄 원칙을 구현하려면 추상화 개념을 잘 사용해야 한다.
  • 기능을 사용하는 코드는 추상화된 타입이나 오퍼레이션으로 고정시켜 놓고 실질적으로 구현된 객체만 추가하여 기능을 확장할 수 있다는 것이다.

기능 확장

  • 다음과 같은 코드에서 만약 압축해서 데이터를 전송하는 기능을 추가하고 싶다면?
  • 한 단계 추상화된 클래스 ResponseSender에서는 코드 변경이 없는 채로, 새로운 객체를 추가하고 메서드를 오버라이드 함으로써 개방-폐쇄 원칙을 지킬 수 있다.
public class ResponseSender {
    private Data data;
    public ResponseSender(Data data) {
        this.data = data;
    }
    
    public Data getData() {
        return data;
    }
    
    public void send() {
        sendHeader();
        sendBody();
    }
    
    protected void sendHeader() {
        //헤더 데이터 전송
    }
    
    protected void sendBody() {
        //텍트트로 데이터 전송
    }
}
public class ZippedResponseSender extends ResponseSender {
    public ZippedResponseSender(Data data) {
        super(data);
    }
    
    @Override
    protected void sendBody() {
        // 데이터 압축 처리
    }
}

개방 폐쇄 원칙이 깨질 때의 주요 증상

  • 추상화와 다형성을 이용해서 개방폐쇄 원칙을 구현하기 때문에, 추상화와 다형성이 제대로 지켜지지 않은 코드는 개방 폐쇄 원칙을 어긴 것이다.
    • 다운 캐스팅을 한다.
    • 비슷한 if-else 블록이 존재한다.
  • 개방 폐쇄 원칙은 변경의 유연함에 대한 것이다. 변화가 예상되는 부분을 추상화하지 못하면 개방 폐쇄 원칙을 지킬 수 없다.

3. 리스코프 치환법칙(LSP)

  • Liskov Substtition Principle
  • 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
  • 다시 말하면 하위타입은 무조건 상위타입을 대체할 수 있어야 한다는 것이다.
  • 이는 컴파일 관점을 넘어서 의미(행위)적으로 대체하는 것을 의미한다.
  • 그렇게 때문에 instanceof 연산자를 사용해서 타입을 확인하는 행위는 전형적인 리스코프 치환원칙을 위반할때 발생하는 증상인 것이다.

리스코프 원칙을 위반했을 때 발생하는 문제

public class Rectangle {
    private int width;
    private int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    public void setHeight(int height) {
        this.height = height;
    }
    public int getWidth() {
        return width;
    }
    public int getHeight() {
        return height;
    }
}
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }
    
    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}
  • Rectangle을 상속받는 Square는 width와 height이 항상 같아야 하기 때문에 메서드를 재정의해서 두 값이 일치하도록 구현한다.
  • 만약 높이와 폭을 비교해서 높이를 더 길게 만들어주는 기능을 Rectangle에 추가한다고 해보자.
public void increaseHeight(Rectangle rec) {
    if (rec.getHeight() <= rec.getWeight()) {
        rec.setHeight(rec.getWidth() + 10);
    }
}
  • 이 메서드를 사용하는 코드는 메서드 실행 후 width보다 height가 더 클거라고 생각한다.
  • 하지만 파라미터로 부모타입을 쓸 수 있는 square가 들어왔다면 메서드가 통할까?
  • square는 setHeight을 하는 순간 width도 같이 변하기 때문에 제대로된 결과를 볼 수 없다.
  • 이렇게 될 경우 square에는 메서드를 지원하지 않기 때문에 별도로 분기해서 예외를 만들어주어야 한다.
public void increaseHeight(Rectangle rec) {
    if (rec instanceof Square) {
        throw new CantSupportSquareException();
    }
    if (rec.getHeight() <= rec.getWeight()) {
        rec.setHeight(rec.getWidth() + 10);
    }
}

리스코프 원칙은 계약과 확장에 대한 것

  • 리스코프 치환원칙은 기능의 명세에 대한 내용이다.
  • 상위타입을 추상화할 때 기준이 상태값이 아닌 구현체가 떠맡게될 책임을 중심으로 추상화를 해야함을 알 수 있다.
  • 기능 실행의 계약과 관련해서 흔히 발생하는 위반 사례로는 담음과 같은 것들이 있다.
    • 명시된 명세에서 벗어난 값을 리턴한다.
    • 명시된 명세에서 벗어난 익셉션을 발생한다.
    • 명시된 명세에서 벗어난 기능을 수행한다.
  • 따라서 Rectangle과 Square처럼 기능 명세가 다른 경우에는 상속 받는 것보다 각각의 객체로 두거나, 기능 명세를 기준으로 추상타입을 더 추가할 수 도 있을 것이다.

4. 인터페이스 분리 원칙(ISP)

  • Interface Segregation Principle
  • 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다.
    (클라이언트는 자신이 사용하는 메서드에만 의존해야 한다.)

  • 의존의 양면성 에서 A가 B에 의존할 경우 B의 변화로 인해 A가 변경되지만, 반대로 A의 요구로 인해 B가 변경됨을 알 수 있었다.
  • 인터페이스를 분리하는 기준이 클라이언트가 된다는 것을 뜻한다.
  • 각 클라리언트가 사용하는 기능을 중심으로 인터페이스를 분리함으로써, 클라이언트로부터 발생하는 인터페이스 변경의 여파가 다른 클라이언트에 미치는 영향을 최소화 할 수 있게 된다.

5. 의존 역전 원칙(DIP)

  • Dependency Inversion Principle
  • 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.

고수준 모듈이 저수준 모듈에 의존할 때의 문제

  • 예를 들어, 상품의 가격을 결정하는 정책을 생각해보자.
  • "쿠폰을 적용해서 가격할인을 받을 수 있다. 쿠폰은 동시에 한개만 적용 가능하다."
  • 이는 고수준 모듈의 정책이다. 여기서 쿠폰을 이용한 가격 계산이 개별적인 쿠폰 구현에 의존하게 되면 어떤일이 벌어질까?
  • 이런 상황은 변경을 어렵게 만든다. 새로운 쿠폰이 추가될 때마다 가격 모듈을 계속 변경해야 할 것이다.
public int calculate() {
...
    if (someCondition) {
        CouponType1 type1 = ....
        ....
    } else {
        // 쿠폰 2 추가에 따라 가격계산 모듈 변경
        CouponType2 type2 = ...
        ....
    }
}

추상화를 통한 변경의 유연함 확보

  • 고수준 모듈이 의존하고 있는 (사용하고 있는) 저수준 모듈을 추상화 하자.
  • 고수준 모듈은 추상타입에 의존하고 있기 때문에, 저수준 모듈이 직접적으로 변경되더라도 고수준 모듈에는 변경을 일어날 필요가 없다.
  • 어차피 인터페이스를 통해서 사용하는 메서드가 고정되어 있기 때문이다.
  • 이러한 변경의 유연함은 앞서 리스코프 치환원칙과 함께 개방 폐쇄 원칙을 따르는 설계를 만들어 주는 기반이 되는 것이다.

소스 코드 의존과 런타임 의존

  • 의존 역전 원칙은 소스코드에서의 의존을 역전시키는 원칙이다.
  • 소스코드에서 고모듈인 FlowController는 추상타입인 ByteSource 에 의존하고 있다. 하지만 런타임 때 실질적으로 의존하고 있는 것은 ByteSource의 구현체인 FileDataReader 객체에 의존하고 있다.
  • ByteSource 인터페이스는 저수준 모듈보다는 고수준 모듈인 FlowController입장에서 만들어지는데, 이것은 고수준 모듈이 저수준 모듈에 의존했던 상황이 역전되어 저수준 모듈이 고수준 모듈에 의존하게 된다는 것을 의미한다.
  • 의존 역전 원칙은 이처럼 런타임의 의존이 아닌 소스코드의 의존을 역전시킴으로써 변경의 유연함을 확보할 수 있도록 원칙이지, 런타임에서의 의존을 역전시키는 것은 아니다.

SOLID 정리

  • SOLID는 한 마디로 정의하면 변화에 유연하게 대처할 수 있는 설계 원칙이다.
  • 단일 책임 원칙과 인터페이스 분리 원칙은 객체가 커지지 않도록 막아준다.
    • 객체가 단일 책임을 가지고 클라이언트 마다 다른 인터페이스를 사용하게 함으로써 한 기능의 변경이 다른곳에 미치는 영향을 최소화할 수 있고 이는 변경을 보다 쉽게 할 수 있도록 만들어 준다.
  • 리스코프 치환 원칙과 의존 역전 원칙은 개방 폐쇄 원칙을 지원한다.
  • 추상화를 도와주는 부분이 의존 역전 원칙이고, 다형성을 도와주는 원칙이 리스코프 치환 원칙인 것이다.

참고자료

  • 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 최범균
profile
하루에 한걸음씩, 꾸준히

6개의 댓글

comment-user-thumbnail
2020년 4월 2일

잘 보고 갑니다~

1개의 답글
comment-user-thumbnail
2020년 4월 2일

잘 보고 갑니당~~ 잘 정리되어있어서 좋아요

1개의 답글
comment-user-thumbnail
2020년 4월 3일

지금까지 읽었던 글 중에 SOLID 원칙을 가장 깔끔하게 정리해 놓은 글이네요. 감사합니다!

1개의 답글