9 분 소요

객체지향 개발 원리 SOLID

로버트 마틴이 주장한 5가지 설계 원칙으로 좀 더 유지보수하기 쉽고, 유연하고 확장이 쉬운 소프트웨어를 만들 수 있습니다.

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

There should never be more than one reason for a class to change.

정의

작성된 클래스하나의 책임만을 갖도록 설계해야 한다. 여기서 책임이란 클래스 변경 이유입니다. 변경 이유가 1개가 아니라면 SRP를 지키지 않은것입니다.

SRP를 적용하면 책임영역이 확실해지기 때문에 한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 있습니다. 책임을 적절히 분배하여 코드의 가독성이 향상되고 유지보수가 용이합니다.

적용 방법

리팩토링에서 소개하는 대부분의 위험상황에 대한 해결방법은 직/간접적으로 SRP 와 관련이 있습니다. 항상 코드를 최상으로 유지한다는 리팩토링의 근본정신도 항상 객체들의 책임을 최상의 상태로 분배한다는 것에서 비롯되기 때문입니다.

여러 원인에 의한 변경(Divergent change): Extract Class 를 통해 혼재된 각 책임을 각각의 개별 클래스로 분할하여 클래스 당 하나의 책임만을 맡도록 하는 것입니다. 여기서 관건은 책임만 분리하는 것이 아니라 분리된 두 클래스간의 관계의 복잡도를 줄이도록 설계하는 것입니다. 만약 Extract Class 된 각각의 클래스들이 유사하고 비슷한 책임을 중복해서 갖고 있다면 Extract Superclass를 사용할 수 있습니다. Extract된 각 클래스들의 공통 요소를 부모 클래스로 정의하여 부모 클래스에 위임하는 기법입니다. 따라서 각각의 Extract Class들의 유사한 책임들은 부모에게 위임하고 다른 책임들은 각 클래스에서 정의할 수 있습니다.

산탄총 수술(Shotgun surgery): Move Field, Move Method를 통해 책임을 기존의 클래스에 모으거나, 새로운 클래스를 만들어 해결할 수 있습니다. 즉 산발적으로 여러 곳에 분포된 책임들을 한 곳에 모으면서 설계를 깨끗하게 합니다. 응집도를 높이는 작업입니다.

적용 사례

public class PhoneBook {
    public void addNumber() {}
        
    public void printInXml() {}
}

위 클래스의 변경 이유는 2가지가 됩니다.

  1. 전화번호부 편집방식이 달라지면 변경(010-1234-5678 / 01012345678)
  2. 출력 형식이 달라지면 변경(xml 형태로 출력 / pdf형태로 출력)

따라서 SRP를 지키려면 전화번호부 출력 클래스, 편집 클래스로 분리해야 합니다.

public class PhoneBook{
    public void addNumber{}
}

public class PhoneBookPrinterXml{
    private PhoneBook phoneBook;
    
    public void print(){}
}    

적용 이슈

클래스의 이름은 해당 클래스의 책임을 나타낼 수 있어야 합니다. 무조건 책임을 분리한다고 SRP가 적용되는 것은 아닙니다. 각 개체 간의 응집도가 있으면 병합이 순 작용의 수단이 되고 결합도가 있으면 분리가 순 작용의 수단이 됩니다. (무조건 분리가 아니라 상황에 맞게 응집도를 높이고 결합도를 낮추는 방식으로 진행)

OCP(개방 폐쇄의 원칙, Open Closed Principle)

You should be able to extend a classes behavior, without modifying it.

정의

기존 코드를 수정하지 않으면서 새로운 기능을 추가할 수 있도록 설계.

소프트웨어 구성요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려있고, 변경에는 닫혀있어야 한다는 원리입니다. 이것은 요구사항의 변경이나 추가사항이 발생하더라도, 기존 구성요소는 수정이 일어나지 말아야 하며, 기존 구성요소를 쉽게 확장해서 재사용할 수 있어야 한다는 뜻입니다.

OCP는 관리가능하고 재사용 가능한 코드를 만드는 기반이고, OCP를 가능하게 하는 중요 메커니즘은 추상화다형성입니다. OCP는 객체지향의 장점을 극대화하는 아주 중요한 원리입니다.

적용 방법

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

적용 사례

앞에서 설명한 PhoneBook, PhoneBookPrinterXml의 경우 만약 XML 형태로 출력하는게 아닌 다른 방식으로 출력한다면 새로운 클래스를 만들어야 합니다. json형태로 출력한다면 PhoneBookPrinterJson 과 같은 클래스를 계속 만들어내고 이를 이용하려면 결국 PhoneBookPrinterXml에서 PhoneBookPrinterJson 으로 기존 코드를 수정해야합니다. OCP를 지키려면 PhoneBookPrinterJson과 같은 구체적인 클래스를 이용하는 것이 아니라 인터페이스를 이용해야 합니다. (다형성을 이용)

publbic interface PhoneBookPrinter {
    public void print(){}
}

public class PhoneBookJson implements PhoneBookPrinter{
    @Override
    public void print(){}
}

적용 이슈

확장되는 것과 변경되지 않는 모듈을 분리하는 과정에서 크기 조절에 실패하면 오히려 관계가 더 복잡해질 수 있습니다.

인터페이스는 가능한 변경되어서는 안됩니다. 따라서 인터페이스를 정의할 때 여러 경우의 수에 대한 고려와 예측이 필요합니다. 하지만 과도한 예측은 불필요한 작업을 만듭니다.

인터페이스 설계에서 적당한 추상화 레벨을 선택해야 합니다. 그래디 부치에 의하면 ‘추상화란 다른 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징’ 이라고 정의하고 있습니다. 즉 이 행위에 대한 본질적인 정의를 통해 인터페이스를 식별해야 합니다.

LSP(리스코브 치환의 원칙, Liskov Substitution Principle)

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

정의

일반화 관계를 적절하게 사용했는지 점검하는 원칙으로 부모 클래스 인스턴스에서 자식 클래스 인스턴스로 변경(다형성)해도 문제 없어야 합니다. 부모 클래스가 제공하는 오퍼레이션과 자식 클래스에서 제공하는 오퍼레이션 간에는 행위적 일관성이 있도록 설계가 되어야 합니다. 서브 타입은 기반 타입이 약속한 규약(메서드가 던지는 예외까지 포함, 인터페이스)을 지켜야 합니다. 상속은 구현 상속(extends)이든 인터페이스 상속(implements)이든 궁극적으로 다형성을 통한 확장성 획득을 목표로 합니다. LSP 원리도 서브클래스가 확장에 대한 인터페이스를 준수해야 함을 의미합니다.

상속을 통한 재사용은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한되어야 합니다. 그 외의 경우에는 합성(composition)을 이용한 재사용을 해야 합니다. 다형성을 통한 확장 효과를 얻기 위해서는 서브 클래스가 기반 클래스와 클라이언트 간의 규약(인터페이스)를 어겨서는 안됩니다. 이 구조는 다형성을 통한 확장의 원리인 OCP를 제공하게 합니다.

A: 부모 클래스

B, C: 자식 클래스

B/C is a A

B/C is a kind of A

  선조건 후조건
부모 클래스 pre post
자식 클래스 pre’ post’

pre가 만족되면 pre’가 만족되어야 한다.

post’가 만족되면 post가 만족되어야 한다.

<참고> 선조건 - (메소드)연산의 시작 전에 참으로 가정되는 조건. (연산이 시작할 때의 상황을 기술) 보통 데이터 무결성 준수도 함께 이루어 진다. 후조건 - (메소드)연산의 종료 후 참인 것이 보장되는 조건. (연산이 끝난 후의 상황을 기술) - 클라이언트가 선조건을 만족시키면 연산은 후조건의 만족을 보장한다. ### 적용 방법 1. 만약 두 개체가 똑같은 일을 한다면 둘을 하나의 클래스로 표현하고 이들을 구분할 수 있는 필드를 둡니다. 2. 똑같은 연산을 제공하지만, 약간씩 다르게 한다면 공통의 인터페이스를 만들고 둘이 이를 구현합니다.(인터페이스 상속) 3. 공통된 연산이 없다면 완전 별개인 2개의 클래스를 만듭니다. 4. 만약 두 개체가 하는 일에 추가적으로 무언가를 더 한다면 구현 상속을 사용합니다. ### 적용 사례 Collection 프레임워크 ### 적용 이슈 1. 다형성을 위한 상속 관계가 필요 없다면 Replace with Delegation을 합니다. 상속은 깨지기 쉬운 기반 클래스 등을 지니고 있으므로 IS-A 관계가 성립되지 않습니다. LSP를 지키기 어렵다면 상속대신 합성(composition)을 사용하는 것이 좋습니다. 2. 상속 구조가 필요하다면 Extract Subclass, Push Down Field, Push Down Method 등의 리팩토링 기법을 이용하여 LSP를 준수하는 상속 계층 구조를 구성합니다. 3. IS-A 관계가 성립한다고 프로그램에서 까지 그런것은 아닙니다. 이들 간의 관계 맺음은 이들의 역할과 이들 사이에 공유하는 연산이 있는지, 그리고 이들 연산이 어떻게 다른지 등을 종합적으로 검토해 봐야 합니다. 4. Design by Contract(서브 클래스에서는 기반 클래스의 사전 조건과 같거나 더 약한 수준에서 사전 조건을 대체할 수 있고, 기반 클래스의 사후 조건과 같거나 더 강한 수준에서 사후 조건을 대체할 수 있다.)적용: 기반 클래스를 서브 클래스로 치환 가능하게 하려면 받아들이는 선 조건에서 서브 클래스 제약사항이 기반 클래스의 제약 사항보다 느슨하거나 같아야 합니다. 만약 서브 클래스의 제약 조건이 더 강하다면 기반 클래스에서 실행되던 것이 서브 클래스로 실행 되지 않을 수 있습니다.(기반 클래스에서 받아들이던걸 만족하면서 서브 클래스로 확장되면서 추가로 더 받아들입니다.) 반면 서브 클래스의 후 조건은 같거나 더 강해야 합니다. 서브 클래스의 후조건이 더 약하다면 기반 클래스의 후조건이 통과시키지 않는 상태를 통과시킬 수도 있기 때문입니다. 즉, 받아들이는 것은 기반 클래스에서 되는것들 통과시키게 더 많이 받아들일 수 있게 약하거나 같아야 하고 후 조건은 기반 클래스에서 통과 시키지 않은 것들을 통과시키지 않고 더 세게 걸러내도록 강하거나 같은 수준이어야 합니다. 더 많이 받음, 더 적게 내보냄 ## ISP(인터페이스 분리 원칙, Interface Segregation Principle) Clients should not be forced to depend upon interfaces that they do not use. ### 정의 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리입니다. 즉, 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스만을 사용해야 합니다. ISP를 하나의 인터페이스 보다는 여러 개의 구체적인 인터페이스가 낫다고 정의 할 수 도 있습니다. 만약 어떤 클래스를 이용하는 클라이언트가 여러 개고 이들이 해당 클래스의 특정 부분집합만을 이용한다면, 이들을 따로 인터페이스로 빼내어 클라이언트가 기대하는 메시지만을 전달할 수 있도록 합니다. **SRP가 클래스의 단일책임을 강조한다면 ISP는 인터페이스의 단일책임을 강조합니다.** 하지만 ISP는 어떤 클래스 혹은 인터페이스가 여러 책임 혹은 역할을 갖는 것을 인정합니다.(여러 인터페이스 상속, 구현가능) 이러한 경우 ISP가 사용되는데 SRP가 클래스 분리를 통해 변화에의 적응성을 획득하는 반면, ISP에서는 인터페이스 분리를 통해 같은 목표에 도달합니다. ### 적용 방법 1. 클래스 인터페이스를 통한 분리 - 클래스의 상속을 이용하여 인터페이스를 나눌 수 있습니다. 이러한 구조는 클라이언트에게 변화를 주지 않을 뿐 아니라 인터페이스를 분리하는 효과를 갖습니다. 하지만 거의 모든 객체지향 언어에서는 상속을 이용한 확장은 상속받는 클래스의 성격을 디자인 시점에 규정해 버립니다. 따라서 인터페이스를 상속받는 순간 인터페이스에 예속되어 제공하는 서비스의 성격이 제한됩니다. 2. 객체 인터페이스를 통한 분리 - 위임(Delegation)을 이용하여 인터페이스를 나눌 수 있습니다. 위임이란, 특정 일의 책임을 다른 클래스나 메소드에 맡기는 것입니다. 만약 다른 클래스의 기능을 사용해야 하지만 그 기능을 변경하고 싶지 않다면(상속의 경우 상속받는 순간 클래스 기능이 부모에 예속되므로 불가능) 상속 대신 위임을 사용 합니다. ### 적용 사례 Java Swing의 JTable JTable 클래스에는 많은 메소드들이 있습니다. 컬럼 추가, 리스너 부착 등 여러 역할이 하나의 클래스 안에 혼재되어 있지만 JTable 입장에서는 모두 제공해야 하는 역할입니다. 이러한 역할을 JTable의 하나의 인터페이스에 넣는다면 리스너 부착만 필요한 경우에도 필요없는 다른 기능까지 구현해야 합니다. ISP 적용을 통해 인터페이스를 분리하여 리스너 부착만 원한다면 해당 인터페이스만 구현하는 방식으로 사용 가능합니다. JTable은 ISP가 제안하는 방식으로 모든 인터페이스 분리를 통해 특정 역할만을 이용할 수 있도록 해줍니다. Accessible, TableMoldelListener 등 여러 인터페이스 구현을 통해 서비스를 제공합니다. 이처럼 인터페이스를 분리하여 JTable의 경우 필요한 인터페이스를 구현하고 이벤트 처리는 리스너만 필요하므로 리스너 인터페이스를 이용하면 각자 필요한 기능만 구현할 수 있습니다. ### 적용 이슈 1. 기존에 구현된 클라이언트에 변경을 주지 말아야 합니다. 2. 두 개 이상의 인터페이스가 공유하는 부분의 재사용을 극대화 합니다. 3. 서로 다른 성격의 인터페이스를 명백히 분리합니다. ## DIP(의존성 역전 원칙, Dependency Inversion Principle) A. High level modules should not depend upon low level modules. Both should depend upon abstractions. B. Abstractions should not depend upon details. Details should depend upon abstractions. 상위 모듈은 하위 모듈의 구현 내용에 의존하면 안됩니다. 상위 모듈과 하위 모듈 모두 추상화된 내용에 의존해야 합니다. 추상화된 것은 구체적인 것에 의존하면 안됩니다. 구체적인 것이 추상화된 것에 의존해야 합니다. **상위 모듈**: 다른 클래스를 사용하는 주된 클래스 **하위 모듈**: 사용되는 클래스 상위 모듈은 보통 프로그램의 메인 흐름에 좀더 가깝고 **하위 모듈**은 상대적으로 좀더 멀리 있습니다. ### 정의 의존 관계의 역전(Dependency Inversion) 이란 구조적 디자인에서 발생하던 하위 레벨 모듈의 변경이 상위 레벨 모듈의 변경을 요구하는 위계관계를 끊는 의미의 역전입니다.(하나의 모듈이 변경되었을때 이를 사용하는 다른 모듈이 변경되지 않도록 하는것) **실제 사용 관계는 바뀌지 않으며, 추상을 매개로 메시지를 주고 받음으로써 관계를 최대한 느슨하게 만드는 원칙**(매개변수로 구체적인 클래스가 아닌 인터페이스 이용하여 다형성을 이용하는 것 생각)입니다. DIP의 키워드는 **IOC, 확장성, 훅 메소드**(슈퍼 클래스에서 디폴트 기능을 정의해두거나 비워뒀다가 서브 클래스에서 선택적으로 오버라이딩 할 수 있도록 만들어둔 메소드입니다.) 입니다. 이 세가지 요소가 조합되어 복잡한 컴포넌트들의 관계를 단순화하고 컴포넌트 간의 커뮤니케이션을 효율적이게 합니다. 이를 위해 Callee 컴포넌트(ex: 프레임워크) 는 Caller 컴포넌트들이 등록할 수 있는 인터페이스를 제공합니다. 따라서 자연스럽게 Callee는 Caller들의 컨테이너 역할이 됩니다. Callee 컴포넌트는 Caller 컴포넌트가 확장(구현)할, 그리고 IoC를 위한 훅 메소드 인터페이스를 정의합니다. Caller 컴포넌트는 정의된 훅 메소드를 구현합니다. 이후 다음과 같은 시나리오가 전개됩니다. Caller는 Callee에 자신을 등록합니다. Callee는 Caller에게 정보를 제공할 적당한 시점에 Caller의 훅 메소드를 호출합니다. 이때가 IoC가 시점입니다. DIP는 비동기적으로 커뮤니케이션이 이루어져도 될(이뤄져야 할) 경우, 컴포넌트 간의 커뮤니케이션이 복잡할 경우, 컴포넌트 간의 커뮤니케이션이 비효율적일 경우(빈번하게 확인해야 하는)에 사용됩니다. DIP는 복잡하고 지난한 컴포넌트 간의 커뮤니케이션 관계를 단순화하기 위한 원칙입니다. A(상위 모듈)가 B(하위 모듈)를 의존한다. B가 변경되면 A도 변화에 따라 영향을 받는다. ### 적용 방법 Layering 잘 구조화된 객체지향 아키텍처들은 각 레이어마다 잘 정의되고 통제되는 인터페이스를 통한 긴밀한 서비스들의 집합을 제공하는 레이어들로 구성되어 있습니다. 이것은 단순히 레이어를 통한 구조화만을 뜻하는 것이 아니라 Transitive Dependency가 발생했을 때 상위 레벨의 레이어가 하위 레벨의 레이어를 바로 의존하게 하는 것이 아니라 이 둘 사이에 존재하는 추상레벨을 통해 의존해야 할 것을 말하고 있습니다. 이를 통해서 상위 레벨의 모듈은 하위 레벨의 모듈로의 의존성에서 벗어나 그 자체로 재사용되고 확장성도 보장 받을 수 있습니다. ![solid](https://user-images.githubusercontent.com/59478159/204720037-d0a708e1-5961-4dc6-af7a-0c260b25512c.png) <참고> 전이적 의존성(Transitive Dependency): 어떤 아티팩트를 의존성으로 추가하면, 그 아티팩트가 가지고 있는 의존성이 함께 딸려옵니다. 그렇게 '딸려온' 의존성을 Transitive Dependency라고 합니다. A -> B, B -> C : A -> C로 A -> C의 의존관계가 생겼습니다. reference [참고1](https://www.nextree.co.kr/p6960/)

태그: ,

카테고리:

업데이트:

댓글남기기