객체 지향과 디자인 패턴 : 네이버 도서
네이버 도서 상세정보를 제공합니다.
search.shopping.naver.com
[4. 재사용: 상속보단 조립]
상속의 단점
상속을 사용하면 상위 클래스에 구현된 기능의 재사용을 쉽게 할 수 있는 것은 분명하다. 하지만 거기에는 몇 가지 문제점이 있다.
1) 상위 클래스 변경의 어려움 : 상속을 하게 되면 상위 클래스의 변경을 어렵게 만든다. 상위 계층을 따라 상위 클래스의 변경이 하위 클래스에도 영향을 주기 때문에 최악의 경우 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있다. 클래스 계층도가 점점 커질수록 상위 클래스를 변경하는 것은 점점 어려워진다.
2) 클래스의 불필요한 증가 : 유사한 기능을 확장하는 과정에서 클래스의 개수가 불필요하게 증가할 수 있다. 다중상속을 할 수 없는 자바에서는 한 개의 클래스만 상속받고 다른 기능은 별도로 구현해야 한다. 필요한 기능의 조합이 증가할수록 상속을 통한 기능 재사용을 하면 클래스의 개수가 함께 증가하게 된다.
3) 상속의 오용 : 상속 그 자체를 잘못 사용하는 경우이다. ArrayList 클래스를 상속 받았지만 기존 메서드인 add() 를 사용하지 않고 자체적으로 put() 이라는 더해주는 메서드를 사용했다고 하자. 다른 개발자들이 이 클래스를 사용했을 때 똑같이 더하는 기능인 add() 를 사용해버린다면 put() 메서드 안에 있는 예외 처리는 무시된 채 그저 리스트에 더해주기만 하는 기능이 실행 될 것이다.
조립을 이용한 재사용
이러한 상속의 단점을 해소하기 위한 방법은 바로 객체 조립을 이용하는 것이다. 보통 객체 조립은 필드에서 다른 객체를 참조하는 방식으로 구현된다. 한 객체가 다른 객체를 조립해서 필드로 갖는다는 것은 다른 객체의 기능을 사용한다는 의미를 내포한다.
필드 참조를 사용하면 해당 객체의 기능을 그대로 사용할 수 있기 때문에 불필요한 클래스가 증가하지 않는다. 또한 해당 객체를 이용해서 기능을 구현하기 때문에 상속을 잘못 사용해서 발생했던 문제도 해결된다. 또 다른 장점은 런타임에 조립 대상 객체를 교체할 수 있다는 것이다.
하지만 그에 반해 단점도 있다. 상대적으로 런타임 구조가 복잡해진다는 것이다. 또 다른 단점은 상속보다 구현이 더 어렵다는데에 있다. 하지만 장기적 관점에서 구현/구조의 복잡함보다 변경의 유연함을 확보하는데에서 오는 장점이 더 크기 때문에 기능을 재사용해야 할 경우 상속보다는 조립의 방법이 더 우선되어야 한다.
위임
위임은 보통 내가 할 일을 다른 객체에게 넘긴다는 의미이며 조립 방식을 통해 위임을 구현한다. 위임은 조립과 마찬가지로 요청을 위임할 객체를 필드로 연결한다. 하지만 꼭 필드로 정의해야 하는 것은 아니다. 위임의 의도는 다른 객체에게 내가 할 일을 넘긴다는데 있으므로 객체를 새로 생성해서 요청을 전달한다고 해도 위임이라는 의미에서 벗어나는 것은 아니다.
상속은 언제 사용할까?
상속을 사용할 때에는 재사용의 관점이 아닌 기능의 확장의 관점에서 상속을 적용해야 한다. 이 때에는 명확한 IS-A 관계가 성립되어야 한다. 상속을 이용한 클래스 계층의 특징은 하위로 내려갈수록 상위 클래스의 기본적인 기능을 그대로 유지하면서 그 기능을 확장해나간다는 것이다. 최상위 클래스에서 제공하는 기능에 추가적으로 기능을 제공할 경우에 상속을 사용한다. 하지만 상속을 사용한다고 하더라도 이후에 클래스 개수가 불필요하게 증가하거나, 상위 클래스 변경이 어려워지는 등의 문제가 발생하면 조립으로 전환하는 것을 고려해야 한다.
[5. 설계 원칙: SOLID]
SOLID 설계 원칙 5가지
1) 단일 책임 원칙 (Single responsibility principle; SRP)
: 객체 지향의 기본은 책임을 객체에 할당하는 것이다. 이 때 클래스는 단 한개의 책임만을 가져야 한다. 클래스가 여러 책임을 갖게 되면 그 클래스는 각 책임마다 변경되는 이유가 발생하기 때문에 클래스가 한 개의 이유로만 변경되려면 클래스는 한 개의 책임만을 가져야 한다. 즉, 클래스를 변경하는 이유는 단 한 개여야 한다.
단일 책임 원칙을 위배하게 되면 한 책임의 변화가 다른 책임의 코드에 영향을 주게 된다. 책임의 개수가 많아질수록 한 책임의 기능 변화가 다른 책임에 주는 영향은 비례해서 증가하게 되는데, 이는 결국 코드를 절차지향적으로 만들어 변경을 어렵게 만든다.
2) 개방-폐쇄의 원칙 (Open-closed principlel; OCP)
: 개방-폐쇄의 원칙은 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 것이다. 이 말은 기능을 확장하면서도 기능을 사용하는 기존 코드에는 영향을 미치지 말아야 한다는 의미이다. 개방 폐쇄 원칙을 구현하는 다른 방법은 상속을 이용하는 것이다. 상속은 상위 클래스의 기능을 그대로 사용하면서 하위 클래스에서 일부 구현을 오버라이딩 할 수 있게 해준다.
개방 폐쇄 원칙이 깨질 때는 추상화와 다형성이 제대로 지켜지지 않은 코드를 작성할 때이다.
- 다운 캐스팅을 한 경우, instanceof 와 같은 타입 확인 연산자가 사용된다면 개방 폐쇄 원칙을 지키지 않을 가능성이 높다. 타입 캐스팅 후 실행하는 메서드가 변화 대상인지 확인해봐야 한다.
- 비슷한 if-else 블록이 존재하는 경우 추상화 할 수 있는지 확인해봐야 한다.
개방-폐쇄 원칙은 유연함과 관련된 원칙이다. 기존 기능을 확장하기 위해 코드를 수정해야 한다면 새로운 기능을 추가하는 것이 점점 더 힘들어진다. 변화되는 부분을 추상화하여 사용자 입장에서는 코드 변화가 없어야 한다.
3) 리스코프 치환 원칙 (Liskov substitution principle; LSP)
: 개방-폐쇄 원칙을 받쳐주는 다형성에 관한 원칙을 제공한다. 리스코프 치환 원칙에서는 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
public void someMethod(SuperClass sc) {
sc.someMethod();
}
somMethod(new SubClass()); // 하위 타입 객체를 전달해도 정상적으로 동작해야 함.
리스코프 치환 원칙은 기능의 명세에 대한 내용이다. 기능 실행의 명세와 관련해서 흔히 발생하는 위반 사례는 아래와 같다.
- 명시된 명세에서 벗어난 값을 리턴한다. (상위 클래스의 리턴 타입이 0 이상인데, 하위 클래스에서 음수를 리턴)
- 명시된 명세에서 벗어난 익셉션을 발생한다. (명위 클래스의 익셉션이 IOException 인데, 하위 클래스에서 IllegalArgumentException 을 발생 시킴)
- 명시된 명세에서 벗어난 기능을 수행한다. (이 명세에 기반해 구현한 코드는 비정상적으로 동작할 수 있으므로 하위 클래스는 상위 클래스에서 정의한 명세를 벗어나지 않는 범위 내에서 구현해야 한다.)
또한 리스코프 치환 원칙은 확장에 관한 것이다. 리스코프 치환 원칙을 어기면 개방-폐쇄 원칙을 어길 가능성이 높아진다.
public class Coupon {
public int calculate(Item item) {
if (item instanceof SpecialItem) { //LSP 위반 발생
return 0;
}
return item.getPrice() * discountRate;
}
}
instanceof 연산자가 사용된다면 리스코프 치환 원칙이 위반된다고 볼 수 있다. 클라이언트가 instanceof 를 사용한다는 것은 상위 타입만을 이용해서 프로그래밍을 할 수 없다는 것을 뜻한다. 이는 하위 타입이 상위 타입을 대체할 수 없으므로 새로운 하위 타입이 추가될때마다 코드를 수정해야할 가능성이 높아지는 것이다.
4) 인터페이스 분리 원칙 (Interface segregation principle; ISP)
: 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다는 것이다. 클라이언트는 자신이 사용하는 메서드에만 의존해야 한다. 인터페이스 분리를 하지 않으면 한 멤버 변수에 대한 시그니처의 변경이 발생한 경우 모든 코드를 재컴파일해야 할 수 있다. 각 클라이언트가 필요로 하는 인터페이스들로 분리함으로써, 각 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않도록 만들어야 한다.(클라이언트 간의 영향 최소화)
자바에서는 JVM이 .class 파일을 로딩하는 과정에서 동적으로 링크과정을 거치기 때문에 '사용하지 않는 인터페이스 변경에 의해 발생하는 소스 재컴파일'문제는 발생하지 않는다.
하지만 용도에 맞게 인터페이스를 분리하는 것은 단일 책임 원칙과 연결된다. 따라서 자바를 사용할지라도 클라이언트 입장에서 사용하는 기능만 제공하도록 인터페이스를 분리해줘야한다.
인터페이스 분리 원칙은 클라이언트 입장에서 인터페이스를 분리하는 원칙이다. 각 클라이언트가 사용하는 기능을 중심으로 인터페이스를 분리함으로써, 클라이언트로부터 발생하는 인터페이스 변경의 여파가 다른 클라이언트에 미치는 영향을 최소화 한다.
5) 의존 역전 원칙 (Dependency inversion principle; DIP)
: 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
고수준 모듈은 어떤 의미 있는 단일 기능을 제공하는 모듈이다.(큰 틀)
저수준 모듈은 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현이다.(개별 요소)
저수준 모듈이 변경되더라도 고수준 모듈은 변경되지 않아야한다. 이를 위해 저수준 모듈이 고수준 모듈을 의존하게 만들어서 해결한다. 즉 추상 타입을 만들어서 이를 해결한다. 고수준 모듈은 추상 타입에 의존하도록하고, 저수준 모듈 또한 추상 타입에 의존하도록 한다. 이런 경우 저수준 모듈이 변경되더라도 고수준 모듈에는 변화가 없다.
의존 역전 원칙은 소스코드의 의존을 역전시키는 것이지 런타임에서의 의존을 역전시키는 것은 아니다.(런타임에서는 고수준 모듈이 저수준 모듈에 의존)
의존 역전 원칙은 타입의 소유도 역전시킨다. 추상 타입을 고수준 모듈이 소유하게 되면서 다른 구현체가 생기더라도 필요없는 기능을 사용하지 않을 수 있다.
SOLID 총 정리
단일 책임 원칙과 인터페이스 분리 원칙은 객체가 커지지 않도록 막아준다. 객체가 많은 기능을 가지게 되면, 객체가 가진 기능의 변경 여파가 그 객체의 다른 기능에까지 번지게 되고, 이는 다시 다른 기능을 사용하는 클라이언트에게까지 영향을 준다. 객체가 단일 책임을 갖게 하고 클라이언트마다 다른 인터페이스를 사용하게 함으로써 한 기능의 변경이 다른 곳에게까지 미치는 영향을 최소화할 수 있고 이는 결국 기능 변경을 보다 쉽게 할 수 있도록 만들어 준다.
리스포크 치환 원칙과 의존 역전 원칙은 개방-폐쇄 원칙을 지원한다. 개방-폐쇄 원칙은 변화되는 부분을 추상화하고 다형성을 이용함으로써 기능 확장을 하면서도 기존 코드를 수정하지 않도록 만들어 준다. 여기서 변화되는 부분을 추상화할 수 있도록 도와주는 원칙이 바로 의존 역전 원칙이고, 다형성을 도와주는 원칙이 바로 리스코프 치환 원칙인 것이다.
또한 SOLID 원칙은 사용자 입장에서 기능 사용을 중시한다. 인터페이스 분리 원칙은 클라이언트 입장에서 인터페이스를 분리하고 있으며 의존 역전 원칙 역시 저수준 모듈을 사용하는 고수준 모듈 입장에서 추상화 타입을 도출하도록 유도한다. 또한 리스코프 치환 원칙은 사용자에게 기능 명세를 제공하고, 그 명세에 따라 기능을 구현할 것을 약속한다.
[6. DI 와 서비스 로케이터]
어플리케이션 영역과 메인 영역
- 어플리케이션 영역 : 고수준 정책 및 저수준 구현을 포함하는 영역
- 메인 영역 : 어플리케이션 영역의 객체 초기화, 의존 처리, 실행을 담당 (어플리케이션 영역에서 사용할 하위 수준 모듈을 변경하고 싶다면 메인 영역만 수정).
모든 의존은 메인 영역에서 어플리케이션 영역으로 나간다. 반대 방향의 의존은 없어야 한다. 메인 역영은 다음과 같은 작업을 수행한다.
- 어플리케이션 영역에서 사용될 객체 생성
- 각 객체간의 의존 관계 설정
- 어플리케이션 실행
DI (Dependency Injection) 를 이용한 의존 객체 사용
DI 는 필요한 객체를 직접 생성하거나 찾지 않고 외부에서 의죈하는 객체를 넣어주는 방식이기 때문에 '의존 주입' 이라고 부른다. DI 를 통해 의존 객체를 관리할 때는 객체를 생성하고 각 객체들을 의존 관계에 따라 연결해주는 조립 기능이 필요하다. DI 를 적용하기 위해서 의존 객체를 전달받을 수 있는 방법은 2가지이다.
- 생성자 방식 : 생성자를 통해 의존 객체를 전달받는 방식이다. 객체를 생성하는 시점에 필요한 모든 의존 객체를 준비할 수 있어서 객체 생성 시점에 의존 객체가 정상인 지 확인할 수 있다. 의존 객체가 먼저 생성되어 있어야 사용 가능한 방법이다.
- 설정 메서드 방식 : 메서드를 이용해 의존 객체를 전달받는다. 객체를 생성한 뒤에 의존 객체를 주입하게 되는데, 의존할 객체가 나중에 생성된다면 이 방법을 사용하면 된다. 의존 객체를 설정하지 못한 상태에서 객체를 사용하게 되면 NPE 오류가 발생한다.
// 생성자 방식
public Deliver(Rider rider) {
this.rider = rider;
}
// 메서드 방식
public void deliver(Rider rider) {
this.rider = rider;
}
DI 와 테스트
DI는 의존 객체를 Mock 객체로 쉽게 대체할 수 있도록 함으로써 단위 테스트를 할 수 있게 해준다. (구현되지 않은 클래스를 사용해야할 때 Mock객체로 생성 후 주입 해준다.) 기존의 다른 코드를 변경할 필요가 없어진다.
서비스 로케이터를 통한 의존 객체 사용
프로그램 개발 환경이나 사용하는 프레임워크의 제약으로 인해 DI를 적용할 수 없는 경우가 있다. 이 때 사용할 수 있는 방법 중 하나가 서비스 로케이터를 사용하는 것이다. 서비스 로케이터는 어플리케이션에서 필요로 하는 객체를 제공하는 책임을 갖는다. 따라서 의존 대상이 되는 객체의 getter를 제공해준다. 메인 영역에서 서비스 로케이터가 제공할 객체를 초기화 해준다.
하지만 서비스 로케이터의 단점은 동일 타입의 객체가 다수 필요할 경우, 각 객체 별로 제공 메서드(getter)를 만들어 주어야 한다는 점이다. 이 경우 로케이터를 사용하는 클래스에서 get할 객체에 의존하게 된다. 다른 구현으로 바꿔주어야 할 때 로케이터를 사용하는 클래스도 수정되어야하므로 개방-폐쇄 원칙을 어기게 된다. 하지만 DI 를 사용하면 구현 객체가 변경되더라도 사용하는 쪽 객체에는 영향이 가지 않는다.
두 번째로는 인터페이스 분리 원칙을 어긴다. 필요하지 않은 타입의 객체까지 의존 관계가 생기기 때문에 다른 의존 객체에 의해 발생되는 수정에 의해 영향을 받게 된다.
'독서&그 외' 카테고리의 다른 글
브라우저는 어떻게 동작하는가? (0) | 2022.11.18 |
---|---|
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴' 을 읽고 (7장, 주요 디자인 패턴) (0) | 2022.10.12 |
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴' 을 읽고 (1장~3장) (0) | 2022.10.10 |
'그림으로 배우는 HTTP & Network Basic' 10장~11장 (0) | 2022.10.07 |
'그림으로 배우는 HTTP & Network Basic' 7장~9장 (0) | 2022.10.06 |
댓글