객체지향 설계 원칙 (Principles of Object Oriented Design)

yeoro·2021년 7월 9일
0
post-thumbnail
post-custom-banner

❓ 설계 원칙을 적용해야 하는 이유?

입증된 객체지향 설계 원칙들을 사용하면 유지보수가 용이하고, 유연하고 확장이 쉬운 소프트웨어를 만들 수 있다. 이 원칙들은 표준화 작업에서부터 아키텍처 설계에 이르기까지 다양하게 적용된다.

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

정의

작성된 클래스는 하나의 기능만 가지며 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는 데 집중해야 한다. 클래스를 변경하는 이유 또한 단 한개여야 한다.

특징

  • 책임 영역이 확실해지기 때문에 책임의 변경이 자유로움
  • 책임을 적절히 배분하여 코드의 가독성 향상, 유지보수 용이

적용

여러 원인에 의한 변경 (Divergent Change)

  • Extract Class를 통해 혼재된 각 책임을 각각의 개별 클래스로 분할하여 클래스 당 하나의 책임만 맡도록 하는 것
  • 책임만 분리하는 것이 아니라 분리된 두 클래스 간의 관계의 복잡도를 줄이도록 설계
  • 만약 Extract Class된 각각의 클래스들이 비슷한 책임을 중복해서 갖는다면 Extract Superclass 사용
  • 클래스들의 공유되는 요소를 부모 클래스로 정의하여 부모 클래스에 위임하여 유사한 책임들은 부모에게, 다른 책임들은 각자에게 정의

산탄총 수술 (Shotgun Surgery)

  • 필드나 메소드를 책임을 기존의 어떤 클래스 혹은 새로운 클래스에 옮기는 것
  • 산발적으로 여러 곳에 분포된 책임들을 한 곳에 모아 응집성을 높이는 작업

예시

위 그림은 SRP 적용 전 클래스이다. 여기서 serialNumber는 변화요소가 아닌 고유정보이며 동종의 다른 클래스와 구분되는 정보이다.
price, maker, type, model 등은 특성 정보군으로 변경이 발생할 수 있는 부분이며 변화 요소이다. 따라서 특성 정보군에 변화가 발생하면 항상 해당 클래스를 수정해야하는 부담이 발생한다.
위 그림은 SRP 적용 후의 클래스이다. 변화가 예상되는 특성 정보군을 분리하였다. 변경이 일어나면 GuitarSpec 클래스만 변경하면 되므로 변화에 의해 변경되는 부분을 한 곳에서 관리할 수 있다.

클래스는 자신의 이름이 나타내는 일을 해야 한다. 올바른 클래스 이름은 해당 클래스의 책임을 나타낼 수 있는 가장 좋은 방법이다.


📚 개방 폐쇄 원칙 (Open Close Principle)

정의

소프트웨어의 구성요소(클래스, 컴포넌트, 모듈, 함수)확장에는 열려있고, 변경에는 닫혀있어야 한다.

이는 변경을 위한 비용은 줄이고 확장을 위한 비용은 극대화 해야 한다는 의미이다.
즉, 요구사항의 변경이나 추가사항이 발생하더라도 기존 구성요소는 수정이 일어나지 않아야 하며, 기존 구성요소를 쉽게 확장해서 재사용할 수 있어야 한다는 뜻이다.

OCP는 관리 가능하고 재사용 가능한 코드를 만드는 기반이며, 객체지향의 장점을 극대화하는 중요한 원리이다.

적용

  • 변경(확장)될 것과 변하지 않을 것을 구분한다.
  • 이 두 모듈이 만나는 지점에 인터페이스를 정의한다.
  • 구현에 의존하기보다는 정의한 인터페이스에 의존하도록 코드를 작성한다.

예시

위 그림은 SRP를 적용한 클래스 다이어그램이다. Guitar에서 변경이 예상되는 부분을 GuitarSpec 클래스를 만들어 따로 모았다. 변화를 극소화 시켰지만, 여기서도 변경이 발생할 수 있다.
예를 들어, Guitar 이외에도 Violin, Cello 등 다른 악기들을 다루어야 하는 상황이 발생했다면, 위와 같이 일일이 매번 새로운 악기와 요소들을 만들어 가야할까?
위 그림은 OCP가 적용된 클래스 다이어그램이다. 여기서는 추가 될 악기들의 공통 속성을 모두 담을 수 있는 StringInstrument라는 인터페이스를 생성했다.
이처럼 새로운 악기가 추가 되면서 변경이 발생할 수 있는 부분을 추상화 하여 분리했다. 이를 통해 코드의 수정을 최소화하여 결합도는 줄이고 응집도는 높이는 효과를 볼 수 있다.

고려사항

  • 확장되는 모듈과 아닌 모듈을 분리하는 과정에서 크기 조절에 실패하면 오히려 관계가 복잡해진다.

  • 인터페이스는 가능하면 변경되어서는 안 된다. 따라서 인터페이스를 정의할 때 여러 경우의 수에 대한 고려와 예측이 필요하다.

  • 인터페이스 설계에서 적당한 추상화 레벨을 선택해야 한다.


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

정의

서브 타입은 언제나 기반 타입과 호환될 수 있어야 한다. 달리 말하면 서브 타입은 기반 타입이 약속한 규약을 지켜야 한다.

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

적용

  • 두 개체가 똑같은 일을 한다면 둘을 하나의 클래스로 표현하고 이들을 구분할 수 있는 필드를 둔다.
  • 똑같은 연산을 제공하지만, 약간씩 다르다면 공통의 인터페이스를 만들고 구현한다. (인터페이스 상속)
  • 공통된 연산이 없다면 완전 별개인 2개의 클래스를 만든다.
  • 두 개체가 하는 일에 추가적으로 무언가를 더 한다면 구현 상속을 사용한다.

예시

Arrays.asList()

위의 코드를 실행시켜 보면 다음과 같은 에러가 발생한다.
여기서 발생한 예외는 Arrays.asList(infoValues)가 반환한 List 구현체가 불변 객체이고, List 인터페이스의 add() 메소드를 지원하지 않아서 발생한다.
위와 같이 수정하면 문제를 해결할 수 있다.

에러가 발생하는 이유

Arrays.asList()는 배열을 ArrayList 타입으로 변환해주지만, 이 ArrayList는 바로 아래 선언된 Arrays 클래스의 내부 클래스인 ArrayList 이다.
또한, 이 ArrayList는 AbstractList를 상속받고 있는데, AbstractList는 List를 상속받고 있지만 add() 메서드 기능을 재구현 하지 않았고 UnsupportedOperationException 예외를 발생한다.

이처럼 구현 클래스가 기반 클래스의 메서드를 거부하면 기반 클래스는 구현 클래스를 대체할 수 없게 된다.

컬렉션 프레임워크 (Collection Framework)

void f(){
	LinkedList list = new LinkedList();
	modify(list);
}

void modify(LinkedList list) {
	list.add();
	doSomething(list);
}

위와 같은 코드는 List만 사용할 것이면 문제가 없다. 하지만 속도 개선을 위해 HashSet을 사용해야 한다면 LinkedList를 HashSet으로 바꿔주어야 한다.

void f(){
	Collection collection = new HashSet();
	modify(collection);
}

void modify(Collection collection) {
	collection.add();
	doSomething(collection);
}

LinkedList와 HashSet은 모두 Collection 인터페이스를 상속하고 있으므로 위와 같이 수정할 수 있다. Collections 생성 부분만 수정한다면 어떤 Collection 구현 클래스든 사용할 수 있게 된다.

위의 예시는 LSP와 OCP가 적용되었다.
우선, 컬렉션 프레임워크가 LSP를 준수하고 있기 때문에 Collection 인터페이스를 통한 작업이 제대로 수행될 수 있었다.
또한, modify()는 변화에 닫혀 있으면서 컬렉션의 변경과 확장에는 열려있는 구조(OCP)이다.

만약 Collection 인터페이스가 지원하지 않는 연산이라면 한 단계 계층 구조를 내려가야 하는데, 이럴 때도 ArrayList, LinkedList 등이 아닌 List를 사용하면 된다.

트레이드 오프 (Trade-off)

Collection list = new LinkedList();
list = new Collections.unmodifiableCollection(list);

Collections의 unmodifiableCollection 메서드를 이용하면 불변 컬렉션 객체를 만들 수 있다. 하지만 이 객체에 대해 add() 혹은 remove() 등을 호출하게 되면 위에서 보았던 UnsupportedOperationException가 발생한다.

이는 계층 구조의 폭주와 LSP 위반 중에 후자를 택한 트레이드 오프의 결과이다.
LSP를 적용하기 위해 Wrapper를 이용하지 않는 다면 이 계층 구조는 2배로 커진다. Collection 인터페이스가 ModifiableCollection과 UnodifiableCollection으로 나누어져야 하고, 이를 구현하는 모든 서브 클래스들 또한 2배가 될 것이다.

때로는 이러한 트레이드 오프도 결정할 수 있는 개발자의 능력이 필요하다.


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

정의

인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다.
각 클라이언트가 필요로 하는 인터페이스들을 분리함으로써, 각 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않도록 해야 한다.

SRP가 클래스의 단일책임을 강조한다면 ISP는 인터페이스의 단일책임을 강조한다.

적용

클래스 인터페이스를 통한 분리

  • 클래스의 상속을 이용하여 인터페이스를 나눌 수 있다.
  • 클라이언트에게 변화를 주지 않고 인터페이스를 분리하는 효과를 갖는다.

객체 인터페이스를 통한 분리

  • 위임(Delegation)을 이용하여 인터페이스를 나눌 수 있다.
  • 위임이란, 특정 일의 책임을 다른 클래스나 메소드에 맡기는 것이다.
  • 만약 다른 클래스의 기능을 사용해야 하지만 그 기능을 변경하고 싶지 않다면 상속 대신 위임을 사용한다.

예시

Java Swing JTable

JTable 클래스에는 굉장히 많은 메소드가 있다. JTable은 ISP가 제안하는 방식으로 모든 인터페이스를 분리하여 특정 역할만 이용할 수 있도록 해준다.

Accessible, CellEditorListener, TableModelListener 등 여러 인터페이스 구현을 통해 서비스를 제공한다. JTable은 자신을 이용하는 모든 서비스가 필요로 하는 객체에게는 기능을 전부 노출하지만, 이벤트 처리와 관련해서는 여러 리스너 인터페이스를 통해 해당 기능만 노출한다.


📚 의존 역전 원칙 (DIP, Dependency Inversion Principle)

정의

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

즉, 저수준 모듈이 변경돼도 고수준 모듈은 변경할 필요가 없다.

실제 사용 관계는 바뀌지 않으며, 추상을 매개로 관계를 최대한 느슨하게 만드는 원칙이다.

적용

Layering

잘 구조화된 객체지향 아키텍처들은 각 레이어마다 잘 정의되고 통제되는 인터페이스를 통한 긴밀한 서비스들의 집합을 제공하는 레이어들로 구성되어 있다.

따라서 Transitive Dependency가 발생했을 때 상위 레벨의 레이어가 하위 레벨의 레이어를 바로 의존하는 것이 아니라 둘 사이에 존재하는 추상레벨을 통해 의존해야 한다. 이를 통해 상위레벨 모듈은 하위레벨 모듈로의 의존성에서 벗어나 그 자체로 재사용되고 확장성도 보장받는다.


[참고]

post-custom-banner

0개의 댓글