Home [OOP] SOLID 원칙
Post
Cancel

[OOP] SOLID 원칙

본문은 “개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴”(최범균 저)을 읽고 정리한 내용입니다.


SOLID 원칙이란 객체지향적으로 설계하는 데에 기본이 되는 설계 원칙이다. 각 원칙에 대해 알아보자.


1. 단일 책임 원칙 (Single Responsibility Principle; SRP)

클래스는 단 한 개의 책임을 가져야 한다.

클래스가 여러 책임을 가지게 되면, 그 클래스는 각 책임마다 변경되는 이유가 발생한다. 따라서 클래스가 하나의 이유로만 변경되기 위해서는 한 개의 책임만을 가져야 한다. 즉, 클래스를 변경하는 이유는 단 하나여야 한다.


객체의 책임이 많아질수록 발생하는 현상은 다음과 같다.

  1. 코드를 절차지향적으로 만들어, 한 책임의 구현 변경에 의해 다른 책임과 관련된 코드가 변경될 가능성이 높아져 변경이 어려워진다.
  2. 재사용을 어렵게 한다.


그렇다면 어떻게 책임을 적절히 나눌 수 있을까? 우선, 책임의 단위는 “변화되는 부분”과 관련된다는 것에 유의한다. 즉, 서로 다른 이유로 바뀌는 책임들이 한 클래스에 함께 포함되어 있다면, 이 클래스는 단일 책임 원칙을 어기고 있다고 볼 수 있는 것이다.

이때, 서로 다른 이유로 바뀌는 책임들이 한 클래스에 존재한다는 것을 알기 위해서는 메서드를 실행하는 주체가 누구인지 확인하면 된다. 어떤 클래스의 사용자들이 서로 다른 메서드를 사용한다면, 그 메서드들은 각각 다른 책임에 속할 가능성이 높아 책임 분리 후보가 될 수 있다.


2. 개방-폐쇄 원칙 (Open-Closed Principle; OCP)

(사용되는 기능의) 확장에는 열려있어야 하고, (기능을 사용하는 코드의) 변경에는 닫혀있어야 한다.

정리하면,

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

이 원칙을 구현하는 두 가지 방법은 다음과 같다.

  1. 확장되는 부분(= 변화되는 부분)을 추상화한다.

    이는 추상화를 다룰 때, 어떤 인터페이스를 상속받은 클래스를 구현함으로써 기능을 추가하는 예시에서 확인한 적이 있었다. 즉, 새로운 기능을 확장하면서도, 기능을 사용하는 기존 코드는 변경되지 않도록 하는 방법이다.

  2. 상속, 즉 다형성을 통해 하위 클래스에서 일부 구현을 오버라이딩한다.

    이는 템플릿 메서드 패턴에서 사용되는 방법으로, 상위 클래스에서 실행할 기본 코드를 만들고 하위 클래스에서 필요에 따라 확장해 나가는 패턴이다.


추상화와 다형성을 통해 개방 폐쇄 원칙을 구현하기 때문에, 이 두 가지가 제대로 지켜지지 않은 코드는 개방-폐쇄 원칙을 어기게 된다. 이 원칙을 어기는 코드의 전형적인 특징은 다음과 같다.

  1. 다운 캐스팅을 한다.

    예를 들어, 특정 타입인 경우에 별도 처리를 하는 메서드를 작성한다면 해당 클래스가 확장될 때 함께 수정되기 때문에 변경에 닫혀있지 않다. instanceof와 같은 타입 확인 연산자를 사용하여 타입 별로 다른 동작을 수행하도록 하고 있다면, 개방-폐쇄 원칙이 지켜지지 않고 있을 가능성이 높다.

  2. 비슷한 if-else 블록이 존재한다.

    새로운 조건을 추가할 경우, 코드를 변경해야 하기 때문에 닫혀있지 않다.


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


3. 리스코프 치환 원칙 (Liskov Substitution Principle; LSP)

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

이 원칙은 개방-폐쇄 원칙을 뒷받침하는 다형성에 관한 원칙을 제공한다. 즉, 이 원칙이 제대로 지켜지지 않으면 다형성에 기반한 개방-폐쇄 원칙 역시 지켜지지 않는다.


리스코프 치환 원칙이 지켜지지 않는 대표적인 예시는 다음과 같다.

  1. 직사각형-정사각형 문제

    리스코프 치환 원칙을 설명할 때 자주 사용되는 대표적인 예이다. 이는 직사각형과 정사각형은 개념적으로 상속 관계에 있는 것처럼 보일지라도, 실제 구현에서는 상속 관계가 아닐 수도 있다는 것을 보여준다. 리스코프 치환 원칙이 지켜지지 않는 경우라면, 직사각형과 정사각형을 각각 독립적인 클래스로 구현해야 한다.

  2. 상위 타입에서 지정한 리턴 값의 범위에 해당하지 않는 값을 리턴하는 경우


리스코프 치환 원칙은 기능의 명세(또는 계약)확장에 관한 것이다.

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

  • 명시된 명세에서 벗어난 을 리턴한다.
  • 명시된 명세에서 벗어난 익셉션을 발생한다.
  • 명시된 명세에서 벗어난 기능을 수행한다.


또한, 확장된 클래스를 instanceof 등의 연산자로 판단해야 하는 경우, 상위 타입만을 사용해서 프로그래밍 할 수 없다는 것이기에 리스코프 치환 원칙이 지켜지지 않는 것이다. 그리고 새로운 종류의 하위 타입이 생길 때마다 상위 타입을 사용하는 코드를 수정해줘야 할 가능성을 높이게 되므로, 개방-폐쇄 원칙을 지킬 수 없도록 만든다.

이러한 경우에는 추상화가 덜 되었기 때문에 리스코프 치환 원칙을 어기게 된 것이며, 이후에 비슷한 요구가 발생할 수 있는 가능성이 높으므로 변화되는 부분을 상위 타입에 추가함으로써 instanceof 연산자를 사용하지 않고 상위 클래스만 사용하도록 구현할 수 있다.


4. 인터페이스 분리 원칙 (Interface Segregation Principle; ISP)

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

이 원칙은 C나 C++과 같이 컴파일과 링크를 직접 수행해야 하는 언어를 사용할 때 장점이 잘 드러난다. 이러한 언어에서는 하나의 인터페이스를 여러 클라이언트에서 사용하는 경우, 해당 인터페이스에 변경이 발생한다면 그것을 사용하는 모든 코드를 다시 컴파일해서 오브젝트 파일을 만들어야 하기 때문이다. 따라서 인터페이스를 각 클라이언트가 필요로 하는 인터페이스들로 분리함으로써, 각 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않도록 해야 한다.


인터페이스 분리 원칙은 이러한 소스 재컴파일 문제뿐만 아니라, 단일 책임 원칙과도 관련된다. 클라이언트 입장에서 사용하는 기능만 제공하도록 인터페이스를 분리함으로써 한 기능에 대한 변경의 여파를 최소화할 수 있다. 그리고 더 나아가, 인터페이스와 콘크리트 클래스의 재사용성을 높여 주는 효과를 가진다.


인터페이스 분리 원칙은 “클라이언트 입장”에서 인터페이스를 분리하는 것임에 유의해야 한다. 이는 다음과 같은 의존의 양면성 때문이다.

A가 B를 의존하는 경우, B의 변화로 인해 A가 변경되지만, 반대로 A의 요구에 의해 B가 변경된다.

따라서 각 클라이언트가 사용하는 기능을 중심으로 인터페이스를 분리함으로써, 클라이언트로부터 발생하는 인터페이스 변경의 여파가 다른 클라이언트에 미치는 영향을 최소화할 수 있다.


5. 의존 역전 원칙 (Dependency Inversion Principle; DIP)

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

여기에서 고수준 모듈과 저수준 모듈이 뜻하는 바는 다음과 같다.

  • 고수준 모듈이란 어떤 의미 있는 단일 기능을 제공하는 모듈이다.

    ex) 바이트 데이터를 읽어와 암호화하고 결과 바이트 데이터를 쓴다.

  • 저수준 모듈은 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현이다.

    ex) 파일에서 바이트 데이터를 읽어온다, AES 알고리즘으로 암호화한다, 파일에 바이트 데이터를 쓴다.

즉, 고수준 모듈은 상대적으로 큰 틀에서 프로그램을 다루며, 저수준 모듈은 각 개별 요소가 어떻게 구현될지에 대해 다루는 것이다.

실제 프로젝트에서는 요구 사항이 어느정도 안정화되면 이후부터는 큰 틀에서 프로그램이 변경되기 보다는 상세 수준에서의 변경이 발생할 가능성이 높아진다. 고수준 모듈은 한 번 안정화되면 쉽게 변하지 않지만, 저수준 모듈은 상황에 따라 변경될 수 있는 것이다. 따라서 고수준 모듈이 저수준 모듈의 구현에 의존하게 되면, 저수준 모듈이 변경될 때마다 고수준 모듈까지 함께 변경되게 된다. 이러한 상황을 방지하기 위한 원칙이 바로 의존 역전 원칙인 것이다.


의존 역전 원칙은 이러한 문제를 저수준 모듈이 고수준 모듈을 의존하게 만듦으로써 해결하며, 이는 추상화로 해결한다.

추상 타입을 도입함으로써 고수준 모듈에서 저수준 모듈을 직접적으로 사용하지 않도록 한다!

고수준 모듈과 저수준 모듈이 모두 추상 타입에 의존하게끔 함으로써, 고수준 모듈의 변경 없이 저수준 모듈을 변경할 수 있는 유연함을 얻을 수 있다. 즉, 의존 역전 원칙은 리스코프 치환 원칙과 함께 개방 폐쇄 원칙을 따르는 설계를 만들어 주는 기반이 된다.


예를 들어, 고수준 모듈 FlowController와 저수준 모듈 FileDataReader가 추상 타입 ByteSource 인터페이스에 의존하는 경우를 살펴보자.

저수준 모듈 FileDataReaderByteSource 인터페이스를 implements 함으로써 추상 타입에 의존하며, 고수준 모듈 FlowControllerByteSource 타입을 사용함으로써 추상 타입에 의존한다. 이때, ByteSource 타입은 고수준 모듈 FlowController 입장에서 만들어지기 때문에, 고수준 모듈이 저수준 모듈에 의존했던 상황이 역전되어 저수준 모듈이 고수준 모듈에 의존하게 되는 것이다.

반면, 소스코드 상에서의 의존은 역전되었지만 런타임에서의 의존은 여전히 고수준 모듈의 객체에서 저수준 모듈의 객체로 향하게 된다. 이처럼 의존 역전 원칙은 런타임의 의존이 아닌 소스 코드의 의존을 역전시킴으로써 변경의 유연함을 확보할 수 있도록 만들어 주는 원칙이며, 런타임에서의 의존을 역전시키는 것은 아니다.


또한, 의존 역전 원칙은 타입의 소유도 역전시킨다. 즉, ByteSource 인터페이스를 통해 데이터 읽기 기능을 위한 타입을 고수준 모듈이 소유하게 되면, 상세 구현을 담고 있는 각 패키지를 독립적으로 배포할 수 있게 되는 것이다. 만약 타입의 소유가 고수준 모듈로 이동하지 않고 저수준 모듈에 위치했다면, 다른 상세 구현을 사용하는 경우일지라도 기능상 필요 없는 구현도 필요하게 된다.

이처럼 의존 역전 원칙은 개방 폐쇄 원칙을 클래스 수준뿐만 아니라 패키지 수준까지 확장시켜준다.


6. 정리

  1. 단일 책임 원칙(S)인터페이스 분리 원칙(I)은 객체가 커지지 않도록 막아준다.
    • 객체가 많은 기능을 가지게 되면 객체가 가진 기능의 변경 여파가 그 객체의 다른 기능에까지 번지게 되며, 이는 다른 기능을 사용하는 클라이언트에게까지 영향을 준다.
    • 객체가 단일 책임을 가지게 하고 클라이언트마다 다른 인터페이스를 사용하도록 함으로써 한 기능의 변경이 다른 곳에까지 미치는 영향을 최소화할 수 있고, 결국 기능 변경을 보다 쉽게 할 수 있도록 만들어준다.
  2. 리스코프 치환 원칙(L)의존 역전 원칙(D)개방 폐쇄 원칙(O)을 지원한다.
    • 개방 폐쇄 원칙은 변화되는 부분을 추상화하고 다형성을 이용함으로써 기능 확장을 하면서도 기존 코드를 수정하지 않도록 만들어 준다.
    • 변화되는 부분을 추상화할 수 있도록 도와주는 원칙은 의존 역전 원칙이다.
    • 다형성을 도와주는 원칙은 리스코프 치환 원칙이다.
  3. SOLID 원칙은 사용자 입장에서의 기능 사용을 중시한다.
    • 인터페이스 분리 원칙을 통해 클라이언트 입장에서 인터페이스를 분리한다.
    • 의존 역전 원칙을 통해 저수준 모듈을 사용하는 고수준 모듈 입장에서 추상화 타입을 도출하도록 유도한다.
    • 리스코프 치환 원칙을 통해 사용자에게 기능 명세를 제공하고, 그 명세에 따라 기능을 구현할 것을 약속한다.
This post is licensed under CC BY 4.0 by the author.

[Better Way #51] 합성 가능한 클래스 확장이 필요하면 메타클래스보다는 클래스 데코레이터를 사용하라

[Algorithm] 유클리드 호제법(Euclidean Algorithm): 최대공약수(GCD)와 최소공배수(LCM) 구하기