7장. 어댑터 패턴과 퍼사드 패턴
어댑터 패턴
어댑터: 어떤 인터페이스를 클라이언트에서 요구하는 형태로 적응시키는 역할 수행
상황 예시1. 새로운 업체에서 제공한 클래스 라이브러리 사용 필요. 그러나 기존에 사용하던 인터페이스와 다른 경우
→ 새로운 업체에서 사용하는 인터페이스를 기존에 사용하던 인터페이스에 적용시켜 주는 클래스를 만들면 됨
즉, 어댑터는 클라이언트로부터 요청을 받아서 새로운 업체에서 제공하는 클래스를 클라이언트가 받아들일 수 있는 형태의 요청으로 변환해 주는 중개인 역할을 함.
상황 예시2. Duck 객체 대신 Turkey 객체를 대신 사용하는 상황
public interface Duck {
public void quack();
public void fly();
}
public class MallardDuck implements Duck {
public void quack() {
System.out.println("꽥");
}
public void fly() {
System.out.println("날고 있어요!!");
}
}
public interface Turkey {
public void gobble();
public void fly();
}
public class WildTurkey implements Turkey {
public void gobble() {
System.out.println("골골");
}
public void fly() {
System.out.println("짧은 거리를 날고 있어요!");
}
}
public class TurkeyAdapter implements Duck { // 클라이언트에서 원하는 인터페이스 구현
Turkey turkey;
public TurkeyAdapter(Turkey turkey) { // 기존 형식 개체의 레퍼런스
this.turkey = turkey;
}
public void quack() { // gobble() 메소드 호출을 통해 quack() 구현
turkey.gobble();
}
public void fly() { // 오리처럼 날기 위해 반복문으로 구현
for(int i = 0; i < 5; i++) {
turkey.fly();
}
}
}
- 클라이언트는 타겟 인터페이스에 맞게 구현되어 있음
- 어댑터는 타겟 인터페이스를 구현, 내부에 어댑티 인스턴스 포함
- 위의 예제
- 타겟 인터페이스 = Duck
- 어댑티 인터페이스 = Turkey
- 어댑터 = TurkeyAdapter
클라이언트에서 어댑터 사용 방법
- 클라이언트에서 타겟 인터페이스로 메소드 호출 → 어댑터에 요청 전달
- 어댑터는 어댑티 인터페이스로 그 요청을 어댑티에 관한 메소드 호출로 변환
- 클라이언트는 호출 결과를 받지만, 중간에 어댑터가 있는지 알지 못함 → 클라이언트와 어댑티는 서로 분리되어 있음
만약 클라이언트에서 호출하는 부분을 새로운 인터페이스에 맞춰서 고치려면 정말 많은 부분을 고려해야 하고 코드도 엄청 많이 고쳐야 하는데, 모든 변경 사항을 어댑터로 캡슐화하여 관리하면 훨씬 간단해짐
어댑터 구현은 타겟 인터페이스로 지원해야 하는 인터페이스의 크기에 비례하여 복잡해진다.
어댑터 패턴
특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환
인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와줌
- 하나의 어댑터에서 2개 이상의 어댑티를 감싸야 하는 상황도 있을 수 있음 (퍼사드 패턴과 관련)
- 오래된 부분과 새로운 부분이 섞여 있는 경우, 두 인터페이스를 모두 지원하는 다중 어댑터를 만들어 기존 인터페이스와 새로운 인터페이스 역할을 모두 할 수 있다.
장점
- 호환되지 않는 인터페이스를 사용하는 클라이언트를 그대로 활용 가능
- 클라이언트와 구현된 인터페이스를 분리할 수 있음 → 변경 내역이 어댑터에 캡슐화되어 나중에 인터페이스가 바뀌더라도 클라이언트 수정 X
- 어댑티 인터페이스를 구성으로 가지고 있음 → 어댑티의 모든 서브클래스에 어댑터를 쓸 수 있음
- 클라이언트는 어댑터 인터페이스를 가지고 있음 → 여러 어댑터 구현체로 바꿔 끼울 수 있음
어댑터의 종류
객체 어댑터
- 위 예제에서 사용하였던 형태
- 구성 방식을 통해 어댑터 구현
클래스 어댑터
- 어댑티와 타겟 클래스의 서브 클래스를 만들어서 사용 → 다중 상속을 활용함 (자바, 코틀린에서 사용 불가능)
- 상속 방식을 통해 어댑터 구현
실제 예시: Enumeration 을 Iterator 처럼 사용
EnumerationIterator
public class EnumerationIterator implements Iterator<Object> {
Enumeration<Object> enumeration;
public EnumerationIterator(Enumeration<?> enumeration) {
this.enumeration = enumeration;
}
public boolean hasNext() {
return enumeration.hasMoreElements();
}
public Object next() {
return enumeration.nextElement();
}
public void remove() {
throw new UnsupportedOperationExeption();
}
}
메서드가 일대일로 대응되지 않을 경우엔 어댑터를 완벽하게 적용할 수 없다.
- Enumeration 인터페이스에 remove 메서드가 없어서, 이런 경우 런타임 예외를 던지고 해당 내용을 문서로 잘 정리해두자
데코레이터 패턴과 어댑터 패턴
- 데코레이터 패턴 - 인터페이스 변경 없이, 감싸고 있는 객체의 행동과 책임을 확장
- 어댑터 패턴 - 하나의 인터페이스를 다른 인터페이스로 변환
퍼사드 패턴
서브 시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어줌
고수준 인터페이스도 정의하므로 서브 시스템을 더 편리하게 사용할 수 있다.
예제: 홈시어터 만들기
홈 시어터로 영화를 보기 위한 과정
- 팝콘 기계를 켠다.
- 팝콘을 튀기기 시작한다.
- 조명을 어둡게 조절한다.
- 스크린을 내린다.
- 프로젝터를 켠다.
- ... (이하 생략)
이렇게 굉장히 많은 과정을 거쳐야 영화를 볼 준비가 된다. 그러나 여기서 몇 가지 문제점이 생길 수 있다.
- 영화가 끝나면 위의 과정을 전부 역순으로 처리해야 하지 않을까?
- 라디오를 들으려 할 때도 이렇게 복잡할까?
- 시스템이 업그레이드되면 작동 방법이 달라져 또 배워야 하지 않을까?
이런 복잡한 과정을 퍼사드 패턴으로 간단하게 처리할 수 있다.
퍼사드의 작동 방식
- 몇 가지 간단한 메소드만 들어있는 HomeTheaterFacade 클래스를 만든다.
- 퍼사드 클래스는 홈시어터 구성 요소를 하나의 서브시스템으로 간주한다.
- watchMovie() 메소드는 서브시스템의 메소드를 호출해 필요한 작업을 처리
3. 클라이언트 코드는 서브시스템이 아닌 홈시어터 퍼사드에 있는 메소드를 호출한다.
- watchMovie() 메소드만 호출하면 조명, 스트리밍 플레이어, 프로젝터, 앰프, 스크린, 팝콘 기계 등이 알아서 준비된다.퍼사드를 쓰더라도 서브시스템에 여전히 접근 가능
→ 서브시스템의 고급 기능이 필요한 경우 여전히 직접 접근해서 해당 기능을 사용 가능
이처럼 퍼사드를 이용하면 서브시스템에 대해 간단한 인터페이스를 제공할 수 있다.
특징
저수준 기능을 원하는 클라이언트에서도 활용 가능
- 퍼사드 클래스는 서브시스템 클래스를 캡슐화하지 않음
- 단지 서브시스템의 기능을 사용할 수 있는 간단한 인터페이스를 제공할 뿐임 → 필요하면 시스템의 모든 기능 사용 가능
일련의 연관된 작업을 스마트하게 수행 가능
클라이언트 구현과 서브시스템 분리 가능
- 인터페이스를 단순하게 만들고 클라이언트와 구성 요소로 이루어진 서브시스템을 분리하는 역할
특정 서브시스템에 대해 만들 수 있는 퍼사드의 개수 제한이 없음
어댑터 패턴 vs 퍼사드 패턴
어댑터 패턴 - 인터페이스를 다른 인터페이스로 변환하는 용도
퍼사드 패턴 - 인터페이스를 단순하게 만드는 용도
최소 지식 원칙
(Principle of Least Knowledge, 디미터 법칙 )
디자인 원칙: 진짜 절친에게만 이야기해야 한다.
객체 사이의 상호작용은 될 수 있으면 아주 가까운 '친구' 사이에서만 허용하는 편이 좋다.
→ 시스템을 디자인할 때 어떤 객체든 그 객체와 상호작용을 하는 클래스의 개수와 상호작용 방식에 주의를 기울여야 한다
이 원칙을 잘 따르면 여러 클래스가 복잡하게 얽혀 있어서, 시스템의 한 부분을 변경했을 때 다른 부분까지 줄줄이 고쳐야 하는 상황을 미리 방지할 수있음
- 여러 클래스가 서로 복잡하게 의존하고 있다면 관리하기도 힘들고, 남들이 이해하기 어려운 불안정한 시스템이 만들어짐
친구를 만들지 않는 4 개의 가이드라인
- 객채 자체
- 메소드에 매개변수로 전달된 객체
- 메소드를 생성하거나 인스턴스를 만든 객체
- 객체에 속하는 구성 요소
이 가이드라인에 따르면 다른 메소드를 호출해서 리턴받은 객체의 메소드를 호출하는 것은 바람직하지 않다. 다른 객체의 일부분에 요청하게 되어 직접적으로 알고 지내는 객체의 수가 늘기 때문이다.
클린 코드에서는 이러한 현상을 '기차 충돌' 이라고 부름
val outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath()
public float getTemp() {
Thermometer thermometer - station.getThermometer();
return thermometer.getTemperature();
}
public float getTemp() {
return station.getTemperature(); // thermometer에게 요청이 전달되는 메소드를 station 클래스 내부에 추가하여, 의존해야 하는 클래스의 개수를 줄임
}
public class Car {
Engine engine;
public Car() {
// 초기화
}
public void start(Key key) {
Doors doors = new Doors();
boolean authorized = key.turns(); // 매개변수로 전달된 객체의 메소드는 호출해도 된다
if (authorized) {
engine.start(); // 구성 요소의 메소드는 호출해도 된다
updateDashboardDisplay(); // 객체 내의 메소드는 호출해도 된다
doors.lock(); // 직접 생성하거나 인스턴스를 만든 객체의 메소드는 호출해도 된다
}
}
public void updateDashboardDisplay() {
// 디스플레이 갱신
}
}
이 원칙을 잘 따르면 객체 사이의 의존성을 줄이고 소프트웨어 관리를 더 편하게 할 수 있다는 장점이 있지만, 원칙을 적용하기 위해 래퍼 클래스를 계속 만들다 보면 시스템이 복잡해지고 개발시간도 늘어난다.
디자인 패턴과 객체지향 원칙은 상황에 따라 적절하게 적용하는 것이 장땡이다 !
래퍼 클래스 vs 어댑터
래퍼 클래스: 구성 방식으로 여러 객체 변수들을 포함
어댑터도 마찬가지로 구현하게 되는데, 래퍼 클래스와 비교했을 때 어댑터를 사용하려는 의도가 특정 인터페이스를 변환시켜주기 위함 이라는 차이점이 있을 듯 합니다.
뇌 단련 - 객체 어댑터와 클래스 어댑터의 구현상 차이
클래스 어댑터를 사용하면 어댑티와 타겟 클래스 모두를 상속한 클래스를 만들게 되는데, 그러면 어댑티와 타겟 클래스에 있는 메서드 중 어댑터에서 사용하지 않을 메서드도 포함되기 때문에 불필요한 메서드 구현이 포함될 거 같음
그리고 클라이언트에서 클래스 어댑터 구현 객체를 변수로 갖게 되면서, 객체 어댑터처럼 다형성을 이용하여 그때마다 어댑터를 바꿔 끼울 수 없다는 단점이 존재할 듯
→ 객체 어댑터에 비해 유연성이 확연히 떨어질 것으로 예상 !!
그렇다면 클래스 어댑터를 왜 쓰는가?!
클래스 어댑터를 쓰면 어댑터 클래스 인터페이스 내에 어댑티 메서드의 형식을 모두 맞춰줄 필요가 없고(상속하니 어댑티의 메서드를 재사용 가능), 필요에 따라서 어댑티의 행동을 오버라이드 할 수 있다는 장점이 있음
→ 결국 상속과 구성 방식의 각각의 장단점이 그대로 적용되는 듯 하다.