객체지향 개발 5개 원칙 SOLID

짱쫑·2021년 12월 21일
1
post-thumbnail

● SRP ( 단일책임의 원칙 : Single Responsibility Princile )

           < 객체는 단 한개의 책임(기능)을 가져야 한다 >

클래스는 한가지 기능만 가지며 한가지 책임을 수행하는데 집중되어야 하는것이 SRP의 원칙이다.

클래스가 수행할 수 있는 책임이 많아지면 결합도가 높아지고 이는 코드 가독성을 떨어뜨리고 유지보수 비용을 증가시킨다. SRP원칙을 따르면 클래스를 여러가지로 분할하여 유연하게 설계할 수 있고, 수정해야할 코드도 적어지는 장점이 있다. 여러 객체들이 하나의 책임만 갖도록 잘 분배한다면 시스템에 변화가 생기더라도 그 영향을 최소화 할 수 있다.

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

● OCP ( 개방 - 폐쇄의 원칙 : Open - Closed Principle )

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

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

OCP에 만족하는 설계를 할 때 변경되는 것이 무엇인지에 초점을 맞추고, 자주 변경되는 내용은 수정하기 쉽게 설계하고 변경되지 않아야 하는 것은 수정되는 내용에 영향을 받지 않게 하는 것이 포인트다. 이를 위해 자주 사용하는 것이 인터페이스(Interface)이다.

ex)

class BigMac {
    var patty: BigMacPatty = BigMacPatty()
    var bun: SesameBread = SesameBread()
    var information: BigMacInformation = BigMacInformation()
}

class BigMacInformation {
    var brand = "Macdonald"
    var price = 3000
    var calorie = 525f
}

위와같이 빅맥을 구성했지만 다른 햄버거가 추가돼야 한다는 클라이언트의 요구사항이 생겼다.

그래서 Interface와 Abstract Class에 의존하도록 설계하여 클래스의 구조를 변경해본다.

interface Bun {}

interface Patty {}

interface Information {
    var brand: String
    var price: Int
    var calorie: Float
}

abstract class Hamburger {
    abstract var bun: Bun
    abstract var patty: Patty
    abstract var information: Information
}

앞으로도 추가될 햄버거에 대해 공통적으로 사용이 가능한 인터페이스들을 먼저 정의하여 예상되는 항목들을 미리 정의 함으로써 변경하는 부분을 최소화했다. 또한 인터페이스들을 구현할 추상클래스인 Hamburger를 만들어 관리가 가능한 틀 안에 가두어 캡슐화를 하였다.

class BigMacPatty: Patty {...}
class SesameBread: Bun {...}
class BigMacInformation: Information {
    override var brand = "Macdonald"
    override var price = 3000
    override var calorie = 525f
}

각 인터페이스를 구현하는 클래스를 만들어준 뒤 추상화 클래스를 상속받아 만든 여러가지 햄버거들을 다음과 같이 구현한다.

class BigMac: Hamburger() {
    override var patty: Patty = BigMacPatty()
    override var bun: Bun = SesameBread()
    override var information: Information = BigMacInformation()
}

class ShanghaiSpicyChickenBurger: Hamburger() {
    override var patty: Patty = ChickenPatty()
    override var bun: Bun = SanghaiBun()
    override var information: Information = SanghaiInformation()
}

추상화에 의존함은 곧 핵심적인 부분만 남기고 불필요한 부분은 자기 클래스의 context에 적합한 내용들로 채워 넣어 구체화한다.

햄버거라는 공통 추상화를 상속받아 만들어 각 의존하는 클래스들을 인터페이스로 묶는 이점은

- 상하이치킨버거와 그 하위 의존 클래스를 추가하는 것만으로 기능확장 가능
- 클래스 추가가 있었지만 기존코드의 수정 없음

OCP는 관리가능(Maintainable)하고, 재사용(Reusable) 가능한 코드를 만드는 기반이다. 잘 설계된 코드는 기존의 코드 변경 없이 확장이 가능하다. OCP를 가능하게 하는 중요 메커니즘은 추상화와 다형성이다. 추상화와 다형성을 가능케하는 키 메커니즘이 상속이다. 추상 기반 클래스의 순수 가상 함수로부터 클래스를 파생 시킴으로써 추상화된 다형성 인터페이스를 만들어 낼 수 있다.

● LSP ( 리스코브 치환의 원칙 : The Liskov Substitution Principle )

< 서브 타입은 언제나 자신의 기반(상위) 타입으로 교체할 수 있어야 한다 >

부모클래스와 자식클래스 사이의 행위에는 일관성이 있어야 한다는 원칙이다. 객체 지향 프로그래밍에서 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용해도 문제가 없어야 한다는 말이다.(언제든지 상호 호환될 수 있다는 말)

상속 관계에서는 일반화 관계(IS-A)가 성립해야 한다. 일반화 관계에 있다는 것은 일관성이 있다는 것이다. 따라서 리스코프 치환 원칙은 일반화 관계에 대해 묻는 것이라 할 수 있다.

ex)

public static void main(){
	Parent parent = new Parent();
    Content content = parent.GetContent();
    content.WriteLog();
}

public class Parent{
	public virtual Content GetContent(){
    	Content content = new Content();
        return content;
    }
}

public class Child : Parent{
	public override object GetContent(){
    	return null;
    }
}

public class Content{
	public void WriteLog(){
    }
}

Parent 클래스를 만들고 Content 클래스를 리턴하는 GetContent함수를 추가했다.
GetContent는 무슨 일이 있어도 최소한 디폴트 값을 가진 null이 아닌 값을 리턴하도록 기획

리스코브의 원칙은 새로운 객체지향 프로그래밍 언어에 채용된 시그니처에 관한 표준적인 요구사항을 강제한다

- 하위형에서 메소드 인수의 반공변성
- 하위형에서 반환형의 공변성
- 하위형에서 메소드는 상위형 메소드에서 던져진 예외의 하위형을 제외하고 새로운 예외를 던지면 안된다
- 하위형에서 선행 조건은 강화될 수 없다
- 하위형에서 후행 조건은 약화될 수 없다
- 하위형에서 상위형의 불변 조건은 반드시 유지되어야 한다.

변성이 무엇인지 알아보러 가자 -> 보러가자

  • 하위형에서 선행조건은 강화될 수 없다

하위형은 자식클래스를 말한다. 선행 조건은 사전 조건이라고도 하는데, 사전적 의미로 함수가 오류 없이 실행되기 위한 모든 조건을 정의한 것이라고한다. 대충 함수를 처리할 때, 전달된 파라미터 값이 옳지 그른지 체크하는 것을 말한다.

public void Method(int data){
	//값이 음수여선 안됨
    if(data < 0){
    	throw new ArgumentOutOfRangeException("data", "데이터 값은 음수이면 안됩니다");
    }
	//코드
}

이와 같이 data 파라미터가 제대로 된 값이 들어오는지 확인하는 것을 선행조건이라 한다.

강화된 조건은 선행조건을 추가하는 것을 말하는데 조건을 더 까다롭게 만든다라고 생각할 수 있다.

public int Method_A(int data){
	int result;
    
    //실행코드
    
    if(result < 0){
    	result = 0;
    }
    return result;
}

public void main Method_B(Money money, int data){
	//실행코드
    
    if(money.Amount < 0){
    	throw new ArgumentOutOfRangeException("money", "money.Amount결과값이 음수이다");
    }
}

두 함수가 바로 후행조건의 예시이다.

이처럼 함수 종료시점에 전달될 객체값이 유효한 값인지 검사하는 것을 말한다.

그럼 약화는?

약화된다는 뜻은 후행조건이 완화되는것을 말한다

public int Method_A{int data){
	int result;
    
    //실행코드
    
    return result;
}

음수조건을 제거해서 후행조건을 완화시켰다.

음수조건을 리턴함으로써 Method_A를 호출하는 코드가 오작동을 일으키도록 만들건데, 후행 조건을 완화시키는 건 선행조건을 추가하는 것과 같이 프로그램 코드에서 문제를 일으키게 만들기때문에 하면 안된다.

  • 하위형에서 상위형의 불변조건은 반드시 유지되어야 한다.

불변조건을 불변데이터라 생각하면 될 것 같다.

부모클래스에 있는 데이터에 정의한 값의 조건은 하위형에서도 계속 유지되어야 한다는 것이다.

public class Parent[
	public int Data;{
    	get{
             return _data;
        }
        set{
        	if(value < 0){
           		_data = 0;
                return;
            }
            _data = value;
        }
    }
    protected int _data;
}

위 코드는 _data 값이 항상 0이나 양수를 갖도록 하고 있다.

그러나 자식클래스에서 다른 함수를 만들어 _data값을 바꾼다고 하면

public class Child : parent{
	public void Method(int data){
    	_data = data;
    }
}

자식클래스에서 _data를 아무런 조건 처리 없이 그대로 덮어쓰기를 하고 있다.

이렇게 되면 부모클래스에서 외부로부터 유지하던 불변성이 깨져버려 프로그램에 문제를 일으키게 된다.

그래서 부모클래스를 구현할 시에 개방 폐쇄 원칙을 잘 지키거나, 자식클래스를 구현하는 사람이 불변성을 깨뜨리지 않도록 해야한다.

  • 하위형에서 메소드는 상위형 메소드에서 던져진 예외의 하위형을 제외하고 새로운 예외를 던지면 안된다.

자식클래스의 함수에서 부모클래스에 있는 함수가 던지는 예외를 제외하고 다른 예외를 던지지 말라는 말이다.

즉 사전에 약속하지 않은 예외를 던지면 기존 프로그램 코드는 해당 예외를 캐치할 수 없어서(처리할 수 없어서) 문제를 일으킬 수 있다는 것이다.

  • 하위형에서 메소드 인수의 반공변성 / 반환형의 공변성

공변성과 반공변성은 각각 리턴 값과 파라미터와 관련이 깊다.
리턴값과 파라미터가 공변성과 반공변성을 반드시 지켜줘야 작동에 문제가 없기 때문이다.

공변성과 리턴값에 대한 예시이다.

public static void Main(){
	Func<Parent> method1 = () => new Child();
    
    Parent parent = method1();
}

public class Parent{}
public class Child : Parent{}

위 예시는 리턴값의 공변성을 지원하는 Func에 대한 예시이다.
Child에서 Parent로 형 변환이 가능해서, Func가 호출되어도 문제가 없다.
그러나 아래와 같이 리턴값에 반공변성을 지원해주면 코드는 문제가 된다.

public static void Main(){
	Func<Parent> method1 = () => new Child_B();
    Func<Child_A> method2 = method1;
    
    Child_A child = method2();
}

public class Parent { }
public class Child_A : Parent { }
public class Child_B : Parent { }

기본적으로 C#에서 Func<T>에 공변성(out 키워드)을 적용시켰기 때문에 컴파일이 되지 않는 코드지만 만약 Func<T>가 Func<Parent>에서 Func<Child_A>로 반공변성까지 지원한다면 함수 호출 시 리턴값에 문제가 발생할 수 있다는 것이다

반공변성과 파라미터에 대해 보자면

public static void Main(){
	Action<Parent> parentAction = (parent) => {};
    Action<Child> childAction = parentAction;
    
    childAction(new Child());
}
public class Parent{}
public class Child : Parent{}

위 예시는 파라미터의 반공변성을 지원하는 Func<T>에 대한 예시이다.

파라미터 타입이 Parent에서 Child로 바뀌어도 상관없다. 실제로 호출되면 Parent로 형 변환되어 파라미터 값이 들어올것이기 때문이다. 그러나 아래와 같이 파라미터에 공변성을 지원해주면 코드는 문제가 된다.

public static void Main(){
	Action<Child_A> childAction = (child) => { };
    Action<Parent> parentAction = childAction;
    
    parentAction(new Child_B());
}

public class Parent { }
public class Child_A : Parent { }
public class Child_B : Parent { }

이것도 마찬가지로 C#에서 Action에 공변성(in 키워드)을 적용시켰기 때문에 컴파일이 되지 않는 코드지만, 만약 Action가 Action<Child_A>에서 Func로 반공변성까지 지원한다면 함수 호출 시 파라미터를 전달할 때 문제가 발생할 수 있다는 것을 알 수 있다.

  • 자식 클래스를 구현하는데 함수의 공변성과 반공변성을 왜 따지나?
    왜 치환 원칙에서 어려운 공변성과 반공변성에 대한 조건을 요구하는가?

이유는 부모클래스에서 자식클래스로 치환했을 때 작동에 문제가 없으려면 프로그램 코드에 노출되어 호출되는 함수 또한 공변성과 반공변성 또한 보장되어야 하기 때문이다. 특히 C#에서는 제네릭 타입을 지원하기 때문에, 이에 대한 개념이 필요한 것이다. 다행히 C#에는 in/out 키워드가 있기 때문에, 공변성과 반공변성 적용에 고민할 필요는 없다.

※ 개인적으로 SOLID 5단계 중 이 리스코브 치환 원칙을 이해하는 것이 너무너문ㅇㅁ룬ㅇ리ㅏㅁ ㄴㅇ어려웠다.

뭔가 정의가 머릿속에서 정리되지도 않을 뿐더러 처음 듣고 보는 공변성, 반공변성까지 있으니 죽을 맛이다. 구글링을 통해 여러 사이트와 개발자들의 블로그를 서칭하며 참조하여도 음... 이런 개념이구나 하는 느낌도 없었다ㅠㅠㅠ 지속적으로 보면서 이해하도록 해야할듯 싶다. 이부분은 참고했던 블로거분의 기고문을 그대로 긁어왔다.

● ISP ( 인터페이스 분리의 원칙 : Interface Segregation Principle )

< 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않아야 한다 >

특정 클라이언트가 의미하는 것은 어떤 객체를 사용하려할 때 그 객체의 일부 기능만을 사용하는 클라이언트를 의미한다고 볼 수 있다. 특정 틀라이언트는 일부 기능 이외에는 사용하지 않기 때문에 이외의 기능에 대해 알고 있을 필요가 없다. 복합기를 예로 들면, 복합기는 여러가지 기능을 가지고 있다. 인쇄, 복사 팩스 기능을 가진 복합기를 예로 들어보자

public interface AllInOneDevice{
	void print();
    void copy();
    void fax();
}

public class SmartMachine implements AllInOneDevice{
	@Override
    public void print(){
    	System.out.println("print");
    }
    
    @Override
    public void copy(){
    	System.out.println("copy");
    }
    
    @Override
    public void fax() {
        System.out.println("fax");
    }
}    

복합기는 인터페이스에 정의된 기능을 모두 수행할 필요가 있기에 내부에 메소드가 구현이 되어있다.

하지만 인쇄 기능만 있으면 되는 인쇄기를 정의도니 인터페이스를 이용하여 구현하면

package solid.isp.before;

public class PrinterMachine implements AllInOneDevice {
    @Override
    public void print() {
        System.out.println("print");
    }

    @Override
    public void copy() {
        throw new UnsupportedOperationException();
    }

    @Override
    public void fax() {
        throw new UnsupportedOperationException();
    }
}

이와 같을 것이다. 인쇄의 역할을 담당하는 print는 override되어있지만 나머지 기능은 구현할 필요가 없기때문에 UnsupportOperationException을 발생시키고 있다.

이런 경우는 인터페이스만 알고 있는 클라이언트는 printer에서 copy기능이 구현되어 있는지 안되어있는지 모르기 때문에 예상치 못한 오류를 만날 수 있다.

이렇게 구현된 객체는 자신에게 필요없는 책임(copy, fax)를 가지고 있는데, SRP원칙도 어기고 있는 것을 볼 수 있다.

ISP에도 나와있듯 하나의 인터페이스를 분리하여 여러개의 인터페이스로 나누는 것이다.

AllInOneDevice를 나누면 3개로 나눌 수 있다.

public interface PrinterDevice {
    void print();
}

public interface CopyDevice {
    void copy();
}

public interface FaxDevice {
    void fax();
}
그리고 복합기가 필요하다면 다음과 같이 3개의 인터페이스를 전부 구현해 주면됩니다.

public class SmartMachine implements PrinterDevice, CopyDevice, FaxDevice {
    @Override
    public void print() {
        System.out.println("print");
    }

    @Override
    public void copy() {
        System.out.println("copy");
    }

    @Override
    public void fax() {
        System.out.println("fax");
    }
}

마찬가지로 특정 기능만을 필요로 하는 객체가 있다면
- 필요한 인터페이스만 이용하여 구현한다.

// 구현한 객체
public class PrinterMachine implements PrinterDevice {
  @Override
  public void print() {
    System.out.println("print");
  }
}

// 클라이언트가 사용할 경우
@DisplayName("하나의 기능만을 필요로 한다면 하나의 인터페이스만 구현하도록 하자")
@Test
void singleInterface() {
    PrinterDevice printer = new SmartMachine();
    printer.print();
}
- SmartMachine을 해당 인터페이스로 업캐스팅한다.
 @DisplayName("특정 기능만 클라이언트에게 노출시킬수 있다.")
 @Test
 public void singleFunction() {
    PrinterDevice printer = new SmartMachine();

    printer.print();
}

ISP에서 주의해야할 점은 기존 클라이언트에 변화를 주지 않으면서 인터페이스만을 분리하여 구현한다는점을 다시 상기하고, 인터페이스를 분리함으로써 의존성을 낮춰 리팩토링 및 구조변경에 용이하게 만든다는점!!!

● DIP ( 의존성 역전의 원칙 : Dependency Inversion Principle )

< 고수준 모듈은 저수준 모듈에 의존하여선 안되며 두 모듈 다 추상화에 의존해야한다 >
< 추상화는 세부사항에 의존하여선 안되며 세부사항은 추상화에 따라 달라진다 >

DIP는 의존관계를 맺을 떄 변화하기 쉬운 것 또는 자주 변화하는것 보단 변화하기 어려운것이나 변화가 거의 없는 것에 의존하라는 원칙이다.

고수준의 이라함은 정책의사결정과 업무모델을 포함하는 층, 재사용하기 원하는것들, 애플리케이션의 본질을 담는 층, 고객의 요구사항 중 내재하는 추상화, 구체적인 것들이 변경되더라도 바뀌지않는 진실의 핵심적인 내용들이 있는 층을 상위수준 층이라한다.

하위수준의 모듈은 구체적인 모듈을 의미하고, 하위수준의 구체적인 모듈에 영향을 주어야 하는 것들은 정책을 결정하는 상위수준의 모듈이여야 한다. 즉, 상위수준의 모듈의 변경이 발생했을 때 하위수준의 모듈이 수정, 확장이 이루어지게 되도록 층을 구성해야한다.

변하기 쉬운것과 어려운것을 구분하는 것은 정책, 전략과 같은 큰 흐름이나 개념 같은 추상적인 것은 변하기 어려운것에 해당하고 구체적인 방식, 사물 등과 같은 것은 변하기 쉬운것으로 구분한다.

객체지향 관점에서는 이와 같이 변하기 어려운 추상적인 것들을 표현하는 수단으로 추상클래스와 인터페이스가 있다.

DIP를 만족하려면 어느 클래스가 도움을 받을 때 구체적인 클래스보다는 인터페이스나 추상클래스와 의존관계를 맺도록 설계해야 한다. DIP를 만족하는 설계는 변화에 유연한 시스템이 된다.

< 인터페이스 = 변하지 않는 것 // 구체적 클래스 = 변하기 쉬운것 >

객체 의존성이란 ?

  • A타입의 변수를 생성하고, 이에 A를 상속받는 B객체를 초기화하면 이는 B객체에 의존성을 가진다.

DI ( Dependency Injection ) : 의존성 주입

IOC ( Inversion Of Control ) : 제어의 역전

이 둘은 같은 의미로 사용되는데, 객체 자체가 아닌 Framework에 의해 객체의 의존성이 주입되는 설계 패턴이다.

외부에서 넘겨주는 무언가가 IOC컨테이너에 객체를 (외부)생성 후 이 외부 객체들을 객체에 주입(DI)하는 것이다.

외부에서 넘겨주는 역할을 하는것은

- Spring = Container    //     Dagger = Component, Module

생성자를 통해 전달 할 수 있고, 멤버 변수를 통해 전달할 수도 있다.

DI가 필요한 이유 ?

  • 의존성 파라미터를 생성자에 작성하지 않아도 되기 때문에 코드를 줄일 수 있고, Interface에 구현체를 쉽게 교체할 수 있다. 상황이 변할 때마다 유용하게 적용시킬 수 있다.
profile
不怕慢, 只怕站

0개의 댓글