본문 바로가기
책 내용 정리/[책] 개발자가 반드시 정복해야 할 객체지향과 디자인 패턴

[5] 설계 원칙 : SOLID

by 문자메일 2023. 3. 1.

https://link.coupang.com/a/QoNwV

 

개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴

COUPANG

www.coupang.com

본문의 내용은 위 책에서 정리한 내용 일부이며, 자세히 알고 싶은 분들은 위 책을 구매해서 보시는 것을 추천드립니다.

씹고 뜯고 맛보고 즐겨도 내용이 지루하지 않고, 정말 돈이 아깝지 않은 책!

 

 

 

이전에서 객체 지향의 기본 내용들인 책임 할당, 캡슐화, 다형성과 추상화, 조립을 통한 재사용을 알아보았음.

위 내용 학습하면서 객체 지향 설계가 어떻게 소프트웨어의 변경을 유연하게 할 수 있는지 알 수 있었음.

 

SOLID 설계 원칙은 아래의 다섯 가지 원칙으로 구성된다.

  • 단일 책임 원칙 (Single reponsbility principle, SRP)
  • 개방-폐쇄 원칙 (Open-closed principle, OCP)
  • 리스코프 치환 원칙 (Liskov substitution principle, LSP)
  • 인터페이스 분리 원칙 (Interface segregation principle, ISP)
  • 의존 역전 원칙 (Dependency inversion principle, DIP)

 

 

1. 단일 책임의 원칙

객체 지향의 기본은 책임을 객체에 할당하는 것에 있음.

객체를 객체로 존재하게 하는 이유가 책임인데, 단일 책임의 원칙은 이 책임과 관련된 규칙이다.

 

단일 책임의 원칙의 규칙은 '클래스는 단 한 개의 책임을 가져야 한다' 이다.

 

클래스가 여러 책임을 갖게 되면 그 클래스는 각 책임마다 변경되는 이유가 발생하기 때문에, 클래스가 한 개의 이유로만 변경되려면 클래스는 한 개의 책임만을 가져야 한다. 그렇기에 단일 책임의 원칙은 '클래스를 변경하는 이유는 단 한 개여야 한다' 라고도 표현할 수 있다.

 

그러나 서로 다른 이유로 변경되는 것을 알아차리려면 많은 프로그래밍 경험이 필요하고, 그렇기에 단일 책임의 원칙을 처음부터 잘 지키기란 쉽지 않다. 그렇다면 어떻게 단일 책임의 원칙을 지킬 수 있을까? 책에서 제시하는 방법은 어떤 클래스의 메서드들을 실행하는 클라이언트(클래스)가 누구인지 확인해 보는 것을 제시한다.

클래스 사용하는 클라이언트들이 서로 다른 메서드를 사용한다면 그 메서드들은 각각 다른 책임에 속할 가능성이 높고, 따라서 책임 분리 후보가 될 수 있다.

2. 개방 폐쇄 원칙

개방 폐쇄 원칙변경의 유연함과 관련된 원칙다. (추상화와 다형성으로 구현)

기존 기능을 확장하기 위해 기존 코드를 수정해 주어야 한다면, 기능을 추가하는 것이 점점 힘들어진다. 

즉, 확장에는 닫히고 변경에는 열리는 반대 상황이 발생하게 된다.

 

원칙

  • 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

위 말을 풀면

  • 기능을 변경하거나 확장할 수 있으면서
  • 그 기능을 사용하는 코드는 수정되면 안 된다.

이전 추상화 부분에서 작성한 예시 코드 구조인데, 이런 구조일 때 개방 폐쇄 원칙을 구현할 수 있다.

새로운 ByteSource 구현한 구현체를 생성만 하면 FlowController 코드는 변경 없이 기능을 적용할 수 있기 때문이다. ( 물론 이때 FlowController에서 직접 ByteSource의 concreate class를 직접 생성하지 않게 구현되어 있다는 가정일 때 참이다, 새로 추가한 concreate class를 생성하는 책임을 가진 객체에서는 당연하게도 변화 존재 할 것)

 

위 구조가 개방-폐쇄 원칙을 구현할 수 있는 이유는 확장(변화)되는 부분을 추상화해서 표현했기 때문이다.

 

위 구조 예시에서 보듯이, 개방 폐쇄 원칙은 변화되는 부분을 추상화(ByteSource 인터페이스) 함으로써, 사용자 (FlowController) 입장에서 변화를 고정시킨다. 이것을 통해 개발자가 ByteSource 기능을 concreate class로 확장하더라도 기능을 사용하는 관계 client 에서의 변화는 닫히게 된다.

 

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

  1. 다운 캐스팅을 한다
  2. 비슷한 if-else 블록이 존재한다.

 

개방 폐쇄의 원칙은 변화가 예상되는 것을 추상화해서 변경의 유연함을 얻도록 해준다. 이 말은 변화되는 부분을 추상화하지 못하면 개방 폐쇄의 원칙을 지킬 수 없게 되며 기능 확장이나 변경을 점점 어렵게 만들 것이다.

따라서 코드에 대한 변화 요구가 발생하면, 변화와 관련된 구현을 추상화하여 개방 폐쇄 원칙에 맞게 수정할 수 있는지 확인하는 습관이 중요하다.

-> 처음 설계 단계에서부터 변화되는 부분을 완전히 미리 예측할 수 없으니, 추후 변화되는 부분이 발견되면 리팩토링하는 방향으로 조언해 주는 것으로 생각됨

 

3. 리스코프 치환 원칙

개방 폐쇄 원칙은 추상화와 다형성(상속)을 이용해서 구현했는데, 리스코프 치환 원칙은 개방 폐쇄의 원칙을 받쳐 주는 다형성에 관한 원칙을 제공한다.

 

리스코프 치환 원칙

  • 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

리스코프 치환 원칙이 제대로 지켜지지 않으면 다형성에 기반한 개방 폐쇄 원칙 역시 지켜지지 않는다.

 

 

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

 

리스코프 치환 원칙은 기능의 명세(또는 계약)에 대한 내용이다.

 

기능 실행의 계약과 관련해서 흔히 발생하는 위반 사례로는 다음과 같은 것들이 있다.

  • 명시된 명세에서 벗어난 값을 리턴한다.
    • 리턴 값은 0이나 그 이상을 리턴하도록 정의되어 있는데, 하위 타입에서 음수를 리턴하는 경우
  • 명시된 명세에서 벗어난 exception을 발생한다.
    • IOException만 발생시킨다고 정의했는데 IllegalArgumentException을 발생시키는 경우
  • 명시된 명세에서 벗어난 기능을 수행한다.
    • 직사각형, 정사각형 예제

 

또한 리스코프 치환 원칙은 확장에 대한 것이다.

리스코프 치환 원칙을 어기면 개방 폐쇄 원칙을 어길 가능성이 높아진다.

 

package chapter5;

public class Coupon1 {
	public int calculateDiscountAmount(Item item) {
		if (item instanceof SpecialItem) // 리스코프 치환 원칙 위반
			return 0;
		
		return item.getPrice() * discountRate;
	}

}

위 코드의 문제점은 Item 타입을 사용하는 코드는 SpecialItem 타입이 존재하는지 알 필요 없이 오직 Item 타입만 사용해야 한다.

그런데 instanceof 연산자를 사용해서 SpecialItem 타입인지의 여부를 확인하고 있고, 이것은 하위 타입인 SpecialItem이 상위 타입인 Item을 완벽하게 대체하지 못하는 상황이 발생하고 있음을 보여준다. (리스코프 치환 원칙 위배)

 

 

리스코프 치환 원칙을 어기게 된 이유는 Item에 대한 추상화가 덜 되었기 때문.

변화 되는 부분인, 할인 가능 여부 판단하는 isDiscountAvailable() 를 Item 클래스에 추가하고, 하위 클래스인 SpecialItem 클래스에서 오버라이딩해서 재정의 한다.

그러고 이 부분을 Coupon 클래스의 메서드에 아래처럼 적용하면, 리스코프 치환 원칙도 지키고, 개방 폐쇄 원칙도 지킬 수 있다. 

 

package chapter5;

public class Item {
	// 변화되는 기능을 상위 타입에 추가
	public boolean isDiscountAvailable() {
		return true;
	}
	...
}




package chapter5;

public class SpecialItem extends Item{
	// 하위 타입에서 알맞게 오버라이딩
	
	@Override
	public boolean isDiscountAvailable() {
		return false;
	}
	...
}



package chapter5;

public class Coupon2 {
	public int calculateDiscountAmount(Item item) {
		if(!item.isDiscountAvailable()) { // instanceof 연산자 사용 제거
			return 0;
		}
		
		return item.getPrice() * discountRate;
	}
}

 

 

4. 인터페이스 분리 원칙

인터페이스 분리 원칙은 아래와 같다.

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

 

5. 의존 역전 원칙

의존 역전 원칙은 리스코프 치환 원칙과 함께 개방 폐쇄 원칙을 따르는 설계를 만들어 주는 기반이 된다.

 

의존 역전 원칙의 정의

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

고수준 모듈 정의 : 어떤 의미있는 단일 기능을 제공하는 모듈

저수준 모듈 정의 : 고수준 모듈의 기능의 구현하기 위해 필요한 하위 기능의 실제 구현

 

고수준 모듈 저수준 모듈
바이트 데이터를 읽어와
암호화 하고
결과 바이트 데이터를 쓴다
파일에서 바이트 데이터를 읽어온다
AES 알고리즘으로 암호화 한다.
파일에 바이트 데이터를 쓴다

 

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

고수준 모듈은 상대적으로 큰 틀(상위 수준) 에서 프로그램을 다룬다면, 저수준 모듈은 각 개별 요소(상세)가 어떻게 구현될지에 대해서 다룬다.

프로젝트 초기에 요구 사항이 어느 정도 안정화되면 이후부터는 큰 틀에서 프로그램이 변경되기 보다는 상세 수준에서의 변경이 발생할 가능성이 높아진다.

 

예를 들어 상품의 가격을 결정하는 정책을 생각해 보면, 상위 수준에서 다음과 같은 결정이 내려질 수 있다.

  • 쿠폰을 적용해서 가격 할인을 받을 수 있다.
  • 쿠폰은 동시에 한 개만 적용 가능하다.

위는 고수준 모듈의 정책이다. 상세 내용으로 들어가 보면 일정 금액 할인 쿠폰에서 비율 할인 쿠폰 등 다양한 쿠폰이 존재할 수 있다. 상위 수준에서 쿠폰 정책은 한 번 안정화되면 쉽게 변하지 않지만, 쿠폰은 상황에 따라 다양한 종류가 추가될 수 있다.

 

여기서 쿠폰을 이용한 가격 계산 모듈이 개별적인 쿠폰 구현에 의존하게 되면, 새로운 쿠폰 구현이 추가되거나 변경될 때마다, 가격 계산 모듈이 변경되는 상황이 발생한다.

 

지금까지 객체 지향 내용 정리하면서 생각된 점은, 프로그램을 구현할 때 잦은 변경이 일어나는 부분에서 추상화 된 상위 개념(인터페이스)에 의존하는 방식으로 프로그래밍 하는 것이 아닌 실제 구현에 의존하게 되면 위 이미지 예시 코드에서처럼 변경사항이 생길때 마다, 그 객체를 사용하는 외부 클라이언트에서 변경된 기능을 사용하기 위해 그 클래스를 생성하게 되면서 외부 클라이언트에서도 변경사항이 같이 발생하게 된다.

 

객체 지향에서는 기능 추가 시 변경이 외부 클라이언트로 전파되는 것을 막기 위한 수단으로 추상화와 다형성, 캡슐화를 이용하고, 생성자나 setter를 이용한 Dependency Injection 또는 서비스 로케이터 방법으로 클래스에서 객체 조립 (재사용) 하여 구현을 한다.

 

 

위의 구조 예시에서 처럼 고수준 모듈인 FlowController와 저수준 모듈인 FileByteSource 모두 추상화 타입인 ByteSource에 의존함으로써, 고수준 모듈 코드에서의 변경 없이 사용할 저수준 모듈의 구현을 변경할 수 있는 유연함을 얻게 되었다.

의존 역전 원칙은 리스코프 치환 원칙과 함께 개방 폐쇄 원칙을 따르는 설계를 만들어 주는 기반이 된다.

 

 

 

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

위 이미지 구조에서 처럼, ByteSource 인터페이스는 저수준 모듈보다 고수준 모듈인 FlowController 입장에서 만들어지는데, 이것은 고수준 모듈이 저수준 모듈에 의존했던 상황이 역전되어, 저수준 모듈이 고수준 모듈에 의존하게 된다는 것을 의미한다. 이 맥락에서 이 원칙의 이름이 의존 역전 원칙이 되게 된다.

 

소스 코드 상에서의 의존은 역전되었지만 런타임에서의 의존은 아래와 같이 고수준 모듈의 객체에서 저수준 모듈의 객체로 향한다. ( 어떻게 생각해 보면 당연한 것인 것 같음.)

 

 

위 이미지에서 볼 수 있듯이, 의존 역전 원칙은 런타임에서의 의존이 아닌 소스 코드의 의존을 역전시킴으로써 변경의 유연함을 확보할 수 있도록 만들어 주는 원칙이다. 런타임에서의 의존을 역전시키는 것은 아니다.

 

 

SOLID 정리

SOLID 원칙을 한 마디로 정의하면 변화에 유연하게 대처할 수 있는 설계 원칙이다.

 

단일 책임 원칙과 인터페이스 분리 원칙은 객체가 커지지 않도록 막아준다.

 

리스코프 치환 원칙과 의존 역전 원칙은 개방 폐쇄 원칙을 지원한다.

 

또한 SOLID 원칙은 사용자 입장에서의 기능 사용을 중시한다.

 

댓글