Outdated/Column

[OOP] 객체지향 설계 원칙 - SOLID

해달 2020. 2. 27. 08:00

목차

1. SOLID 원칙이란?

2. 단일 책임 원칙

3. 개방 폐쇄 원칙

4. 리스코프 치환 원칙

5. 인터페이스 분리 원칙

6. 의존성 역전 원칙 

7. 마치며

8. 참고자료



1. SOLID 원칙이란?

좋은 설계란 무엇일까? 본인은 시스템에 예상하지 못한 변경사항이 발생하더라도 유연하게 대처할 수 있는 시스템 구조를 만드는 것이라고 생각한다. 다시 말해 시스템에 새로운 요구사항이나 변경사항이 있을 때, 영향을 받는 범위가 적은 구조를 만드는 것이다. 하지만 좋은 설계를 한다는 것은 말만큼 쉽지 않다. 다행히도 이미 여러 선배 개발자분들에 의해서 여러가지 설계 기법과 원칙이 소개되었다.


SOLID 원칙은 그 중 하나다. SOLID 원칙소프트웨어를 설계함에 있어 이해하기 쉽고, 유연하고, 유지보수가 편하도록 도와주는 5가지의 원칙이다. 이름의 유래는 각 5가지 원칙의 앞글자를 따온 것이다. 이제 하나하나씩 알아보도록 하자.

2. 단일 책임 원칙

정의

단일 책임 원칙(SRP; Single Responsibility Principle)은 모든 클래스는 단 한 가지의 책임을 부여받아, 수정할 이유가 단 한 가지여야 함을 의미한다. 즉, 클래스에 속해있는 멤버들과 메소드는 모두 공통적으로 하나의 서비스를 위해 필요하다는 것이다. 단일 책임 원칙은 다른 원칙을 적용하는 기초기 때문에 꼭 적용해야 하는 원칙이다.


적용

단일 책임 원칙을 어떻게 적용할 수 있을까? 먼저 혼재되어 있는 각 책임을 각각의 개별 클래스로 분할하고, 분리된 클래스 간에 관계의 복잡도를 줄인다. 만약 추출해도 비슷한 책임을 갖고 있다면 부모 클래스로 추출한다. 혹은 필드나 메소드를 옮길 수도 있다. 또, 클래스 이름을 해당 클래스의 책임을 나타낼 수 있게 올바르게 지어야 한다.


예시

본 예시는 참고자료 4번에서 가져왔다. 다음의 클래스를 보자.

Guitar 클래스의 멤버를 유심히 보자. 여기서 차후 변화할 수 있는 요소는 무엇인가? 고유정보인 serialNumber를 제외하고 나머지 정보들은 모두 특성 정보군으로 변경이 발생할 수 있는 부분이다. 다시 말해 이 정보군에 변화가 발생하면 항상 이 클래스를 수정해야 하는 부담이 생기므로 따로 클래스로 추출하는 것이 바람직하다. 따로 추출하면 아래와 같이 된다.

serialNumber를 제외한 나머지 멤버를 GuitarSpec으로 추출함으로써 앞으로 변동이 생겨도 더이상 Guitar 클래스를 수정할 필요가 없게 된다.

3. 개방 폐쇄 원칙

정의

개방 폐쇄 원칙(OCP; Open-Closed Principle)은 소프트웨어의 구성요소(컴포넌트, 클래스, 모듈, 함수)가 확장에 대해서는 유연하여야 하지만 수정에 대해서는 폐쇄적이어야 함을 의미한다. 다시 말해 새 기능이 필요할 때 기존에 작성하고 테스트했던 코드를 수정하지 않고 추가할 수 있어야 한다는 것이다.


효과

개방 폐쇄 원칙을 무시하고 프로그램을 작성하면 객체 지향 프로그래밍의 가장 큰 장점인 유연성, 재사용성, 유지보수성 등을 얻을 수 없다. 다시 말해 관리와 재사용이 가능한 코드를 만드는 기반이라고 할 수 있다. 따라서 반드시 지켜야 할 원칙이다.


적용

개방 폐쇄 원칙을 적용하기 위한 중요 메커니즘은 추상화[각주:1] 다형성이다. 즉, 변경(확장)될 것과 변하지 않을 것을 엄격히 구분해 인터페이스를 정의하고 구체적인 타입 대신에  인터페이스에 의존하도록 코드를 작성한다. 상속보다는 포함 관계를 활용하는 것이 이 원칙을 실현하기 쉽다.


인터페이스를 설계할 때 주의할 점이 있는데, 가능하면 변경되어서는 안되므로 여러 경우의 수에 대한 고려와 예측이 필요하며, 잘못 분리하면 관계가 더 복잡해질 수 있으므로 적당한 추상화 레벨을 선택해야 한다.


예시

아까의 Guitar 예시를 생각해보자. 우리는 분명 Guitar에서 변경될 수 있는 부분을 찾아 GuitarSpec으로 잘 분리하였다. 하지만, 그 외 바이올린, 비올라, 첼로 등 여러 악기들도 다뤄야 한다면 이전처럼 매번 새로운 요소를 만들기는 힘들 것이다.

대신에 Guitar와 추가 될 다른 악기들을 추상화하여 분리한다면 앞으로 어떤 악기가 추가되더라도 기존 코드의 수정은 최대한 줄어들어 결합도는 줄이고, 응집도는 높일 수 있을 것이다.


4. 리스코프 치환 원칙

정의

리스코프 치환 원칙(LSP; Liskov Substitution Principle)은 상위 타입은 항상 하위 타입으로 대체할 수 있어야 함을 의미한다. 다시 말해 어떤 하위 객체에 접근할 때 그 상위 객체의 인터페이스로 접근하더라도 아무런 문제가 없이 일관성 있는 행동을 해야 한다. 메소드의 사전 조건과 사후 측면에서 보면 사전 조건은 축소되지 않아야 하며, 사후 조건은 확대되지 않아야 한다.[각주:2] 이를 준수하지 않으면 하위 객체를 상위 클래스 타입으로 활용할 때, 당장은 괜찮을 수 있어도 나중에 문제가 발견될 수 있다. 


효과

리스코프 치환 원칙은 다형성과 확장성을 극대화할 수 있으며, 개방 폐쇄 원칙을 구성한다.


적용

객체 간 is-a 관계일 때만 상속 관계로 모델링한다. 하위 클래스의 공통된 연산을 인터페이스로 제공하고, 이들을 구분할 수 있는 멤버를 둔다. 하위 클래스는 확장만 수행해야 하며, 상위 클래스의 책임을 무시할 수 없다. 만약 하위 클래스가 상속 받은 기능 외에 필요한 게 있다면 구체화(implement)를 이용한다.


만약 상속 관계로 모델링 하였는데 LSP에 위배되면 다음과 같은 검토가 필요하다.

  • 부모 클래스의 설계를 바꾼다.

  • 부모 자식 관계 대신에 형제 관계로 모델링한다. 다시 말해 상속 대신 합성(composition)을 사용한다.

  • 상속 관계 대신에 포함 관계로 바꾼다.[각주:3] 관련된 기법으로 의존성 주입(dependency injection)이 있다.


두번째는 가장 단순한 방법으로 하위 클래스를 만들지 않고, 대신 객체를 생성하는 팩토리 클래스를 두는 것이다.


예시

가장 대표적인 예제로 직사각형, 정사각형 예시가 있다.



위의 두 클래스는 is-a 관계를 만족하기 때문에 충분히 상속 관계로 모델링 할 수 있다. 이 Rectangle 타입을 활용하는 아래와 같은 함수가 있다고 가정해보자.


void Process(Rectangle& r)

{

    int w = r.getWidth();

    r.setHeight(10);

 

    std::cout << "Expected Area = " << (w * 10) << ", got " << r.GetArea() << std::endl;

}

 

Square s(5);

Process(s); // Expected Area = 50, got 100


작위적인 예제긴 하나 is-a 관계가 꼭 프로그램에서까지 적용되진 않는다는 것을 보여준다. 관계 맺음은 객체의 역할과 객체 간 사이에 공유하는 연산이 있는지 그리고 이들 연산이 어떻게 다른지 등을 종합적으로 검토해야 한다. 여기에 두번째 방법을 적용해보자.


class RectangleFactory

{

    static Rectangle CreateRectangle(int width, int height);

    static Rectangle CreateSquare(int size);

};

 

// Rectangle에 추가되는 메소드

bool Rectangle::IsSquare() const

{

    return width == height;

}


또 한 가지 염두해둘 것은 혼동될 여지가 없고 트레이드 오프를 고려한 것이라면 상속 관계로 모델링 하지 않을 수도 있다.

5. 인터페이스 분리 원칙

정의

인터페이스 분리 원칙(ISP; Interface Segregation Principle)은 필요하지 않는 요소를 구현하도록 강요하거나 사용하지 않는 요소에 의존하도록 만들면 안 된다는 것을 의미한다. 다시 말해 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스만을 사용해야 한다. 인터페이스의 단일 책임을 강조한다고도 볼 수 있다.


효과

인터페이스 분리 원칙은 단일 책임 원칙과 비슷해 단일 책임 원칙의 효과와 비슷하다.


적용

인터페이스의 크기를 최소화한다. 한 덩어리의 복잡한 인터페이스를 목적에 따라 구분하여 나눠, 실제 필요한 인터페이스만 구현할 수 있게 한다. 분리하는 방법은 두 가지가 있는데 하나는 상속을 이용하는 방법과 다른 하나는 위임을 이용하는 방법이 있다. 전자의 경우 상속 받는 클래스의 성격을 설계 시점에 규정하기 때문에 제공되는 서비스의 성격이 제한되며, 후자는 다른 클래스의 기능을 사용해야 한다.


예시

이 예시는 참고자료 5번에서 가져왔다. 핸드폰을 모델링하려고 할 때, 옛날 3G폰과 현재 스마트폰은 모두 전화, 문자, 알람, 계산기 등의 기능이 있으므로 다음과 같이 클래스 다이어그램을 작성하였다.


하지만, 인터페이스 분리 원칙을 만족하려면 다음과 같이 각 인터페이스로 나눠 구현하도록 설계해야 한다.


6. 의존성 역전 원칙 

정의

의존성 역전 원칙(DIP; Dependency Inversion Principle)은 상위 모듈이 하위 모듈에 종속성을 가져서는 안 되며, 양쪽 모두 추상화에 의존해야 함을 의미한다. 풀어 설명하자면 하위 레벨 모듈의 변경이 상위 레벨 모듈의 변경을 요구하는 구조적 문제에서 발생하는 위계관계를 끊는 것이다. 실제 사용 관계는 바뀌지 않으며, 추상을 매개로 메시지를 주고 받음으로써 관계를 최대한 느슨하게 만드는 원칙이다.


효과

이 원칙이 지켜지면 복잡한 컴포넌트들의 관계를 단순화하고 컴포넌트 간의 커뮤니케이션을 효율적이게 해, 구성에 대한 설정이 편리해지고 모듈을 테스트하는 것도 쉽다


적용

먼저 해야할 것은 추상적인 계층을 만드는 것이다. 상위 레벨의 계층이 하위 레벨의 계층을 바로 의존하게 하는 것이 아니라 이 둘 사이에 추상화된 계층을 둬 이 추상 레벨을 의존하게 한다. 이를 통해 상위 레벨의 모듈은 하위 레벨의 모듈로의 의존성에서 벗어나 그 자체로 재사용 되고 확장성도 보장 받을 수 있다.


오늘날 이 원칙을 지키는 가장 인기 있고 우아한 방법은 의존성 주입(DI; Dependency Injection) 테크닉을 활용하는 것이다. 이 방식의 장점은 인터페이스 인스턴스의 타입을 바꿀 때 단 한 곳만 수정하면 모든 곳에 적용된다. 또, 의존성 주입과 함께 얘기되는 것들이 있는데,  프레임워크에 제어의 역할을 건냄으로써 클라이언트 코드가 신경쓰는 것을 줄이는 제어의 역전[각주:4](IoC; Inversion of Control)이 있다.


예시

의존성이 역전된다는 의미가 잘 와닿지 않을 수 있다. 아래 그림을 보면 추상 계층을 둬 의존성이 역전됨을 확인할 수 있다.


7. 마치며

이제까지 소프트웨어를 객체지향적으로 설계하는 원칙인 SOLID를 살펴보았다. 한편, 이와 관련된 또다른 원칙으로 관심사의 분리(SoC; Separation of Concerns)가 있다. 관심사의 분리를 적용하면 자연스럽게 단일 책임 원칙, 개방 폐쇄 원칙, 인터페이스 분리 원칙을 달성하게 되니, 관심있는 분은 관련 내용을 한 번 살펴보기를 권한다. SOLID 원칙을 잘 적용해서 모두 좋은 설계를 할 수 있었으면 좋겠다.

8. 참고자료


  1. 그래디 부치(Grady Booch)는 추상화를 “다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징”이라고 정의했다. 이를 고려하여 인터페이스를 작성해야 한다. [본문으로]
  2. 이를 계약에 의한 설계(DBC; Design By Contract)라 한다. [본문으로]
  3. 리팩토링 기법 중 상속을 위임으로 대체(replace inheritance with delegation)를 일컫는다. [본문으로]
  4. 제어의 역전을 위한 훅 메소드(hook method)라는 기법도 있다. 상위 클래스에서 디폴트 기능을 정의해두거나 비워뒀다가 하위 클래스에서 선택적으로 재정의 할 수 있도록 만들어둔 메소드를 말한다. 하위 클래스는 이를 이용해 기능의 일부를 확장할 수 있다. [본문으로]