Priv's Blog

3. SOLID 원칙 본문

Dev. Study Note/Design Pattern

3. SOLID 원칙

Priv 2022. 7. 3. 23:07


 

 

1. 단일 책임 원칙

1.1) 책임

객체지향 설계 관점에서 단일 책임 원칙이 말하는 '책임'의 기본 단위는 객체이다.

즉, 객체는 하나의 책임만을 가져야 한다는 것이다.

책임이란, '해야 하는 것' 또는 '할 수 있는 것'으로 간주한다.

객체는 다른 어떤 객체보다도 가장 잘 수행할 수 있는 일을 책임으로 할당받아야 한다.

또한 객체는 책임에 수반되는 모든 일을 자신만이 수행해야 한다.

  • 책임 == 해야 하는 것 == 할 수 있는 것 == 해야 하는 것을 잘할 수 있는 것

예를 들어, Student 클래스가 수강 과목을 추가/조회하고, 데이터베이스에 객체 정보를 저장/읽는 작업도 처리하고, 성적표와 출석부에 출력하는 일도 실행한다고 가정한다.

// 코드 3-1

public class Student {
    public void getCourses() { ... }
    public void addCourse(Course course) { ... }
    public void save() { ... }
    public Student load() { ... }
    public void printOnReportCard() { ... }
    public void printOnAttendanceBook() { ... }
}

코드 3-1을 보면 Student 클래스가 너무 많은 책임을 수행하고 있음을 알 수 있다.

이 중에서 Student 클래스가 가장 잘 수행할 수 있는 것은 수강 과목 추가 및 조회에 대한 일이다.

데이터베이스에 학생 정보를 저장하거나, 읽는 일, 성적표와 출석부에 출력하는 일은 다른 클래스가 더 잘할 수 있다.

즉, 단일 책임 원칙에 따르도록 만들기 위해서는 Student 클래스는 수강 과목 추가 및 조회에 대한 일만 수행하도록 만들고 나머지 일들은 다른 클래스에서 처리하도록 코드를 수정해야 한다.

 

1.2) 변경

단일 책임 원칙을 따르면서 실효성이 있는 설계가 되려면 책임을 좀 더 현실적인 개념으로 파악할 필요가 있다.

설계 원칙을 학습하는 이유는 예측하지 못한 변경사항이 생겨도 유연하게 대처할 수 있는 시스템 구조 설계하기 위해서다.

즉, 좋은 시스템 구조를 설계하기 위해서는 변경사항에 대해 영향을 받는 부분을 최대한 줄여야 한다는 것이다.

위에서 살펴본 Student 클래스는 언제 변경되어야 할까?

이에 답하려면 먼저 Student 클래스가 변경되어야 하는 이유를 찾아보아야 한다.

  • 데이터베이스 스키마가 변경된다면, Student 클래스도 변경되어야 하는가?
  • 학생이 지도 교수를 찾는 기능이 추가된다면, Student 클래스가 영향을 받는가?
  • 학생 정보를 성적표, 출석부 이외의 형식으로 출력해야 한다면 어떻게 해야 하는가?

 

1.3) 책임 분리

책임을 많이 지고 있다면 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높다.

현재 수강 과목을 조회하는 getCouse 메서드는 데이터베이스에서 학생 정보를 가져오는 load 메서드 중 어딘가에 연결될 수도 있다.

학생이 수강 과목을 추가하는 addCourse 메서드는 데이터베이스에 학생 정보를 갱신하는 save 메서드가 연결될 수도 있다.

이처럼 Student 클래스는 여러 책임을 수행하기 때문에 Student 클래스의 도움을 필요로 하는 코드도 많아지고, 결합도 강해진다.

즉, Student 클래스에 변경사항이 생긴다면 이 클래스를 사용하는 모든 코드들도 그에 따른 영향을 받게 된다는 것이다.

이와 같이 어떤 변화가 있을 때, 기존 시스템에 영향을 주는지를 평가하는 테스트를 회귀 테스트라고 부른다.

이 회귀 테스트에 소모되는 비용을 줄이기 위해서는 단일 책임 원칙을 지키고, 변경 사유가 될 수 있는 것을 하나로 만들어야 한다.

이를 책임 분리라고 부른다.

 

1.4) 산탄총 수술

지금까지는 한 클래스가 여러 책임을 가진 상황이었지만, 하나의 책임이 여러 클래스에 분산되어 있는 경우도 존재한다.

이때도 단일 책임 원칙을 기반으로 설계를 변경해야 하는 경우가 생긴다.

이러한 경우를 '산탄총 수술'이라고 표현한다.

즉, 어떠한 변경이 있을 때, 하나가 아닌 여러 클래스를 변경해야 한다는 것이다.

산탄총 수술이 위험한 이유는 변경과 관련된 모든 클래스들을 일일이 다 뒤져야 하기 때문이다.

여러 개의 클래스에 책임이 분산되어 있기 때문에 그중 하나만 문제가 발생해도 프로그램을 에러를 발생시킨다.

시스템의 핵심 기능(하나의 책임) 안에 포함되는 부가 기능(여러 클래스로 분리)이 존재하는 '횡단 관심' 속성에서 이러한 문제가 쉽게 발생한다.

산탄총 수술 문제를 해결하기 위해서는 부가 기능들을 별도의 클래스로 분리해 책임을 담당하게 만들어야 한다.

즉, 여러 곳에 흩어져 있는 공통 책임을 한 곳에 모으면서 응집도를 높여야 한다.

하지만 독립 클래스를 이렇게 구현해도, 구현된 기능을 호출하고 사용하는 코드는 해당 기능을 사용하는 코드 어딘가에 포함될 수밖에 없다.

 

1.5) 관심 지향 프로그래밍

횡단 관심 문제를 해결하는 방법으로 관심 지향 프로그래밍 기법이 있다.

이는 횡단 관심을 수행하는 코드를 에스펙트라는 특별한 객체로 모듈화 하고, 위빙이라는 작업을 통해 모듈화 한 코드를 핵심 기능에 끼워 넣는 것이다.

이를 통해 기존의 코드는 전혀 건드리지 않고도 시스템 핵심 기능에서 필요한 부가 기능을 끼워 넣어 효과적으로 사용할 수 있다.

 


 

2. 개방-폐쇄 원칙

개방-폐쇄 원칙은 기존의 코드를 변경하지 않고도 기능을 추가할 수 있어야 한다는 원칙이다.

개방-폐쇄 원칙을 위반하지 않도록 하기 위해서는 새로운 기능을 추가할 때, 무엇이 변하는지, 무엇이 변하지 않는 것인지를 명확하게 구분해야 한다.

변해야 하는 것은 쉽게 변할 수 있어야 하고, 변하지 말아야 하는 부분은 변하는 것에 영향을 받지 않게 만들어야 한다.

또 다른 점은 클래스를 변경하지 않고도 대상 클래스의 환경을 변경할 수 있는 설계가 되어야 한다.

이 부분은 특히나 단위 테스트를 수행할 때 매우 중요하다.

네트워크를 통해 웹 서비스를 사용한다고 가정하면, 데이터베이스나 웹 서버를 설치해야 테스트가 가능하다.

하지만 단위 테스트는 빠른 시간 안에 자주 테스트를 수행해야 하므로, 시간이 많이 소요되는 데이터베이스 설치와 웹 서버 설치는 기피할 수밖에 없다.

즉, 테스트 대상 기능이 사용하는 실제 외부 서비스를 모방한 더미 객체를 만들어 테스트 효율성을 높여야 하는 것이다.

 


 

3. 리스코프 치환 원칙

자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행할 수 있어야 한다는 원칙이다.

리스코프 치환 원칙을 만족하면, 프로그램에서 부모 클래스의 인스턴스 대신에 자식 클래스의 인스턴스로 대체해도 프로그램의 의미는 변하지 않는다.

이를 위해서 부모 클래스, 자식 클래스 사이는 행위가 일관되어야 한다.

리스코프 치환 원칙을 이해하려면 일반화 관계를 다시 생각해보아야 한다.

일반화 관계는 'is a kind of 관계'라고도 표현한다.

예를 들어 원숭이는 포유류이므로, 'Monkey is a kind of Mammalia' 관계가 성립된다.

여기서 부모 클래스(포유류)와 자식 클래스(원숭이)는 그 관계에는 일관성이 존재한다.

즉, '포유류는 알을 낳지 않고 새끼를 낳아 번식한다.'라는 문장에 포유류 대신 원숭이를 넣어도 아무런 문제가 없다는 것이다.

객체지향 관점에서 살펴보면, 부모 클래스의 인스턴스 대신에 자식 클래스의 인스턴스를 별다른 변경 없이 그대로 사용할 수 있을 때 일반화 관계가 성립한다.

자식 클래스가 부모 클래스 인스턴스 행위를 일관성 있게 실행하기 위해서는 부모 클래스의 행위를 더 명확하게 정의할 수단이 필요하다.

// 코드 3-2
public class Bag {
    private int price;
    
    
    public void setPrice(int price) {
        this.price = price;
    }
    
    public int getPrice() {
        return this.price;
    }
}

코드 3-2의 Bag 클래스는 다음과 같이 표현할 수 있다.

"가격은 설정된 가격 그대로 조회된다."

이를 좀 더 형식적으로 작성하면 다음과 같다.

// 모든 Bag 객체 b와 모든 정수 값 p에 대하여
[b.setPrice(p)].getPrice() == p;

이 경우, Bag 클래스의 행위를 손상시키지 않고 일관성 있게 실행하는 클래스를 만들기 위해서는 어떻게 해야 할까?

가장 직접적/직관적인 방법은 부모 클래스에서 상속받은 메서드들이 서브 클래스에서 재정의되지 않도록 만드는 것이다.

// 코드 3-3
public class DiscountedBag extends Bag {
    private double discountedRate = 0;
    
    
    public void setDiscounted(double discountedRate) {
        this.discountedRate = discountedRate;
    }
    
    public void applyDiscount(int price) {
        super.setPrice(price - (int)(discountedRate * price));
    }
}

코드 3-3을 보면 DiscountedBag이 Bag 클래스를 상속받고 있다.

하지만 기존에 있던 Bag 클래스의 모든 기능들은 변경 없이 그대로 상속만 받아 사용하고 있다.

즉, 아래 표와 같이 코드를 실행해 결과를 비교해보면 결과가 동일하다는 것이다.

이는 DiscountedBag 클래스와 Bag 클래스의 상속 관계가 리스코프 치환 법칙을 위반하지 않는다는 것을 의미한다.

여기서 만약 setPrice 메서드를 재정의한다면, 이때도 리스코프 치환 법칙을 따를 수 있을까?

// 코드 3-4
public class DiscountedBag extends Bag {
    private double discountedRate;
    
    
    public void setDiscounted(double discountedRate) {
        this.discountedRate = discountedRate;
    }
    
    public void setPrice(int price) {
        super.setPrice(price - (int)(discountedRate * price));
    }
}

위의 코드 3-4는 리스코프 치환 법칙을 위반한다.

DiscountedBag 클래스의 구현이 Bag 클래스의 행위와 일관되지 않기 때문이다.

피터 코드의 상속 규칙 중, 서브 클래스가 슈퍼 클래스의 책임을 무시하거나, 재정의하지 않고 확장만 수행한다는 규칙은 슈퍼 클래스의 메서드를 재정의하지 말라는 것과 같은 의미이다.

즉, 피터 코드의 상속 규칙을 지키는 것(재정의를 하지 않는 것)이 리스코프 치환 법칙을 지키는 방법 중 하나인 것이다. (상속하되, 재정의 금지)

 


 

4. 의존 역전 원칙

객체 사이에 서로 도움을 주고받으면 의존 관계가 발생한다.

의존 역전 원칙은 의존 관계를 맺을 때의 가이드라인을 생성하는 원칙이다.

즉, 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존하라는 원칙이다.

정책, 전략과 같은 큰 흐름이나 개념 같은 추상적인 것은 변하기 어려운 것, 구체적인 방식, 사물 등과 같은 것은 변하기 쉬운 것으로 구분된다.

아이가 장난감을 가지고 노는 경우, 장난감의 종류는 변하기 쉬운 것이고, 아이가 장난감을 가지고 논다는 사실 자체는 변하기 어려운 것이다.

의존 역전 원칙을 만족하기 위해서는 어떤 클래스가 도움을 받을 때, 구체적인 클래스보다는 인터페이스나 추상 클래스와 의존 관계를 맺도록 설계해야 한다.

이러한 설계는 변화에 유연한 시스템을 만들어줄 수 있다.

 

4.1) 의존성 주입

의존 역전 원칙을 만족하면 의존성 주입이라는 기술로 변화를 쉽게 수용할 수 있는 코드 작성이 가능해진다.

의존성 주입이란, 클래스 외부에서 의존되는 것을 대상 객체의 인스턴스 변수에 주입하는 기술이다.

의존성 주입을 사용하면, 대상 객체를 변경하지 않고도 외부에서 대상 객체의 외부 의존 객체를 변경할 수 있다.

 


 

5. 인터페이스 분리 규칙

인터페이스 분리 규칙이란, 클라이언트 자신이 이용하지 않는 기능에는 영향을 받지 않아야 한다는 규칙이다.

프린터, 팩스, 복사기 기능이 모두 포함된 복합기를 생각해본다면, 복합기 기능을 제공하는 클래스는 매우 비대해질 가능성이 크다.

하지만 이 모든 기능들을 동시에 사용하는 경우는 극히 드물다.

즉, 클라이언트의 요청에 따라 프린터 기능만 사용하거나, 팩스 기능만 사용하는 것처럼 한 번에 한 가지 기능만 사용하는 경우가 더 많을 것이다.

그러므로 프린터 기능의 변경 사항이 있다고 해서 팩스 기능에 영향을 미쳐서는 안 된다.

클라이언트와 무관하게 발생한 변화로 클라이언트가 영향을 받지 않기 위해서는 범용 인터페이스보다는 클라이언트에 특화된 인터페이스를 사용해야 한다.

즉, 인터페이스 분리 규칙은 말 그대로 인터페이스를 클라이언트에 특화되도록 분리하라는 설계 원칙인 것이다.

여기서 단일 책임 원칙을 지킨다고 해서 꼭 인터페이스 분리 규칙을 만족한다고는 할 수 없음에 주의해야 한다.

 


 


수고하셨습니다!


'Dev. Study Note > Design Pattern' 카테고리의 다른 글

6. 싱글턴 패턴  (0) 2022.07.03
5. 스트래티지 패턴  (0) 2022.07.03
4. 디자인 패턴  (0) 2022.07.03
2. 객체지향 원리  (0) 2022.07.03
1. 객체지향 모델링  (1) 2022.07.03
Comments