객체지향 5가지 원칙

김민우·2024년 2월 25일
0

잡동사니

목록 보기
21/22

클라이언트-서버


객체지향 패러다임은 객체마다 적절한 책임과 역할을 부여하여 객체간 협력을 통해 문제를 해결해나가는 프로그래밍 기법이다.

이 협력 과정에서 객체는 클라이언트, 서버 둘 중 하나를 담당하게 된다. 아래 예시를 통해 알아보자.

public class Movie {
	private Money fee;
    private DiscountPolicy discountPolicy;
    
	...
    
    public Money caculateMovieFee(Screening screening) {
    	return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

Movie.caculateMovieFee() 메서드가 호출된 경우 MovieDiscountPolicy의 메서드를 호출하는데 이를 메시지를 보낸다 라고 한다.

그림으로 표현하면 다음과 같다. DiscountPolicy 는 클라이언트(Movie)가 모르는 로직을 수행하고 알맞는 응답값을 반환하는데 이를 메시지를 수신한다 라고 한다.

즉, 클라이언트는 메시지를 보내는 객체이고 서버는 클라이언트로부터 전달받은 메시지를 처리하여 적절한 응답을 보내는 객체다.

SRP (단일 책임 원칙)


모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다.

객체지향적인 설계가 왜 중요한가? 라는 말에 답변을 한다면 나는 유지보수와 확장에 유리하기 때문이라고 답할 것 같다.

이는 모든 객체지향 설계를 포괄할 수 있는 문장은 아니다. 만약, 여러 로직에 대해 책임이 있는 객체가 있다고 생각해보자. 이 객체가 변경이 된다면 어떻게 될까?

이와 협력하는 대부분 객체 내부 코드는 빨간 줄로 도배될 것이다. 결국 A라는 객체를 수정함으로써 B, C 등 다양한 객체도 수정해야되는 상황이 되버린 것이다. 이는 유지보수가 좋다고 말할 수 있을까?

SRP는 이러한 변경의 파급효과를 최소화할 수 있는 기반을 제공한다. 각 객체는 하나의 책임을 부여하고 이를 캡슐화하여 맡은 책임에 대한 변경이 외부로 퍼저나가지 않게끔 해야 된다는 것이다.

OCP (개방-폐쇄 원칙)


소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다

할인 정책이 다음 2가지가 있다고 생각해보자.

  • 고정 가격 할인 정책
    e.g. 1,000원 고정 할인
  • 비율 할인 정책
    e.g. 10% 할인

클라이언트는 할인 정책(서버)에 메시지를 보내 할인된 값을 받는 상황을 생각해보자. 할인 정책의 경우 확장 가능성이 높다.

정책이 계속 추가될 때마다 클라이언트의 코드가 변경된다면 유연한 설계라 볼 수 없다. "할인 정책 추가" 라는 변경이 "모든 할인 정책의 계산" 에 영향을 주는 건 어색하다.

다형성을 이용하여 아래와 같이 설계를 한다면 클라이언트 코드(Movie)를 수정하기 않고 DiscountPolicy의 구현 클래스를 추가하는 것 만으로 "할인 정책 추가"가 가능하다.

변경에 닫혀있고라는 말은 클라이언트 코드에 대한 변경을 의미한다.
즉, 클라이언트 코드를 수정하지 않으면서 기능 확장이 가능해야 한다는 것을 뜻하며 이에 사용되는 매커니즘은 다형성이라 볼 수 있다. 더 나아가 다형성이 구현되는 원리는 동적 바인딩, 캐스팅이 있다.

DIP (의존관계 역전 원칙)


상위 모듈은 하위 모듈에 의존해선 안된다.

일단 상위/하위가 어떤 뜻인지 살펴보자.

  • 상위 : 일반적, 추상적
  • 하위 : 구체적

코드 수정 관점

우리가 영화 예매 가격에 할인 정책이 적용된다는 것을 설명해보자. 이 때, 할인 정책은 앞서 언급한 고정 가격 할인 정책, 비율 할인 정책 2가지가 있다.

  • 영화 예매 가격에 고정 가격 할인, 비율 할인을 적용할 수 있고 이들 중 1가지만 적용이 가능하다.

만약, 할인 정책이 더 많아진다면 이렇게 일일히 모든 할인 정책을 나열하여 설명하는 건 번거로울 것이다. 우리는 아래와 같이 설명할 수 있다.

  • 영화 예매 시 할인 정책을 적용할 수 있다.
  • 할인 정책은 고정 가격 할인, 비율 할인 2가지가 있다.

우리가 누군가에게 비지니스 로직을 설명할 땐 이처럼 일반적이고 추상적인 수준으로 설명하는게 서로 편하다. 이러한 수준을 상위 수준이라고 한다.

Java에선 인터페이스나 추상 클래스를 상위 수준, 이에 대한 구현 클래스를 하위 수준으로 보면 된다.

절차지향적인 코드를 작성할 때 보통 상위 모듈이 하위 모듈을 의존하게 되는게 DIP는 이 의존성을 반대로 해야 하는 것을 뜻하므로 역전이라는 말을 사용한다.

앞서 OCP에서 보여준 설계는 사실 DIP를 만족하는 설계였다. 이유는 구현 클래스가 인터페이스를 의존하기 때문이다.

하위 수준 변경은 상위 수준에 비해 변경이 자주 일어난다. 이 때 마다 상위 수준를 수정해야 한다면 굉장히 번거로울 것이다.

컴파일 관점

여기서 언급하는 모듈은 Java에서 패키지를 생각하면 좋을 것 같다. 왜 굳이 클래스가 아닌 모듈이란 말을 사용했을까? 우리는 의존성을 고려할 때 코드 수정이라는 관점 외에 컴파일 측면도 고려해야 하기 때문이다.

패키지 내의 클래스 1개가 변경된다면 패키지 전체가 재배포된다. 이로 인해 DiscountPolicy 가 포함된 패키지의 구현 클래스들이 변경된다면 컴파일은 의존성을 타고 어플리케이션 코드 전체로 번져갈 것이다.

따라서, 이러한 구성(불필요한 클래스들을 같은 패키지에 두는 것)은 전체적인 빌드 시간을 가파르게 상승시킨다.

이처럼 상위/하위 수준 별로 모듈을 구성하여 빌드 시간을 최적화해야 한다. Movie로 부터 구체적인 사항을 완전히 분리했다. 인터페이스의 소유권을 서버가 아닌 클라이언트에 위치시킨다는 것을 명심하자.

ISP (인터페이스 분리 원칙)


인터페이스는 클라이언트의 기대에 따라 분리한다.

아래와 같은 요구 사항을 클래스로 표현한다 생각해보자.

  • 펭귄은 새이다.
  • 펭귄은 날 수 없다.

일반적으로 펭귄 ⊂ 새 이므로 Bird를 부모 타입, Penguin을 자식 타입으로 설계할 것이다.

public interface Bird {
	void fly();
}
public class Penguin implements Bird {
	@Override
    public void fly() { ... }
}

Bird 객체와 협업하는 클라이언트는 당연하게fly() 메서드를 호출하여 메시지를 보낼 것이다.

public class Client {
	public void func(Bird bird) {
    	bird.fly();
    }
}

그러나, 펭귄은 날 수 없지 않은가? 그렇다면 Penguinfly() 메서드 재정의를 어떻게 해야할까?

다양한 방법이 있겠지만 아래 3가지 방법이 있을 것이다.

  • 클라이언트에서 instanceof 로 현재 파라미터가 Penguin 인스턴스인지 확인
  • 오버라이딩시 fly() 메서드 본문을 비어 놓기
    @Override
    public void fly() {}
  • 오버라이딩시 fly() 메서드에서 예외 발생시키기
 @Override
 public void fly() {
 	throw new IllegalArgumentException();
 }

instanceof 는 결국 협업 대상의 내부를 확인하는 꼴이므로 캡슐화가 약해진다. 나머지 2개의 방법은 클라이언트 입장에서 전혀 예상치 못한 동작을 하게 된다.

방금같은 문제가 발생한 가장 큰 이유는 ISP를 준수하지 않았기 때문이다. 실세계에서 펭귄이 새라고 한들 객체지향 세계에선 행동이 같지 않으면 부모/자식 관계로 둘 수 없다.

이처럼 행동을 기준으로 인터페이스를 분리해야 한다. 이렇게 인터페이스를 세분화하면 비대한 인터페이스가 만들어지는 것을 방지할 수 있다. 또한, 행동은 철저히 클라이언트 관점에서 생각해야 한다.

추후 변경이 발생한다면 이를 담당하는 인터페이스만 수정하면 나머지 클라이언트 코드는 수정할 필요가 없을 것이다.

LSP (리스코프 치환 원칙)


서브타입은 그것의 기반 타입(부모 클래스)에 대해 대체 가능해야 한다.

우리는 다형성을 활용하여 OCP를 준수했다. LSP는 이러한 OCP를 만족하기 위한 사전 조건으로 생각해도 무방하다.

말 그대로 업캐스팅 시 자식 인스턴스가 부모 인스턴스를 완전히 대체할 수 있어야 한다는 것이다. 자식이 부모의 모든 인스턴스를 가지고 추상 메서드는 오버라이딩하니깐 당연히 지켜지는 느낌이든다. 아래 예시를 살펴보자.

위반 사례

Java에서 Stack<E>Vector<E>의 자식 클래스로 구현되있다.

Stack.java

public class Stack<E> extends Vector<E> {
    public Stack() {
    
    }

    public E push(E item) {
        addElement(item);
        return item;
    }

    public synchronized E pop() {
        E obj;
        int len = size();

        obj = peek();
        removeElementAt(len - 1);

        return obj;
    }

    public synchronized E peek() {
        int len = size();

        if (len == 0)
            throw new EmptyStackException();
        return elementAt(len - 1);
    }

    public boolean empty() {
        return size() == 0;
    }

	...
}

가변 크기의 배열을 통해 Stack을 구현하므로 Vector<E>를 상속받는게 당연하게 보인다. 문제는 클라이언트가 이를 사용하면서 발생한다.

Stack<String> stack = new Stack<>();
stack.push("Hello");
stack.push("World");
stack.add(1, "Java");  // ??? 

뜬금없이 add() 라는 메서드가 호출되어 Stack의 LIFO 구조를 망가뜨렸다. 부모 클래스의 퍼블릭 메서드로 인해 자식 클래스의 내부 규칙이 와해된 상황이다.

추가로, 앞서 ISP에서 언급한 Penguin, Bird의 사례도 LSP 위반 사례에 해당한다.

클라이언트는 협력하는 대상이 부모인지 자식인지 모르고 메서드를 호출한다. 따라서, 자식 인스턴스가 부모 인스턴스로 완전히 대체되어야 한다.

여기서 핵심은 단순히 메서드 오버라이딩을 한다고 LSP가 지켜지는건 아니라는 것이다. 앞서 Penguinfly() 메서드를 오버라이딩을 했지만 클라이언트 기대와 전혀 다른 동작을 하도록 했다.

철저히 클라이언트가 협력하는 대상이 자식인지 부모인지 전혀 모르게 구현을 해야한다.

OCP의 사전 조건

앞서 MovieDiscountPolicy 관계를 살펴보자.

클라이언트(Movie)는 DiscountPolicy와 협력하고 있다. 이 때, DiscountPolicy의 구현체는 모두 DiscountPolicy를 대체할 수 있어 Movie 입장에선 어떤 구현체인지 모르고 협력을 진행하게 된다. 어떤 구현체와 협력하든 전혀 문제가 없다.

결국에 LSP가 지켜지지 않는 설계는 OCP를 지키지 못할 확률이 매우 높아진다. 자식이 부모를 대체하지 못하면 결국 클라이언트 코드(여기선 Movie 코드) 를 수정(앞서 언급한 instanceof 등)해야 되기 때문이다.

0개의 댓글