헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.
어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴
우리 주변에서 볼 수 있는 어댑터의 역할은 전원 소켓의 인터페이스를 플러그에서 필요로 하는 인터페이스로 바꿔 준다고 할 수 있다. 객체지향 어댑터도 똑같이 어떤 인터페이스를 클라이언트에서 요구하는 형태로 적응시키는 역할을 한다.
어떤 소프트웨어 시스템에서 새로운 업체에서 제공한 클래스 라이브러리를 사용해야 하는데 그 업체에서 사용하는 인터페이스가 기존에 사용하던 인터페이스와 다르다고 가정해 보자.
https://codingsmu.tistory.com/59
그런데 기존 코드를 바꿔서 이 문제를 해결할 수 없는 상황이고, 업체에서 공급받은 클래스도 변경할 수 없다면 어떻게 해야 할까?? 바로 새로운 업체에서 사용하는 인터페이스를 기존에 사용하던 인터페이스에 적응시켜 주는 클래스를 만들면 된다.
https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴
어댑터는 기존 시스템에서 사용하던 인터페이스를 구현해서 새로운 업체에서 제공한 클래스에 요구 내역을 전달할 수 있다. 어댑터는 클라이언트로부터 요청을 받아서 새로운 업체에서 제공하는 클래스를 클라이언트가 받아들일 수 있는 형태의 요청으로 변환해 주는 중개인 역할을 하는 것이다.
어댑터를 어떻게 사용하는지 한번 살펴보자
public interface Duck {
void quack();
void fly();
}
public class MallardDuck implements Duck {
@Override
public void quack() {
System.out.println("quack");
}
@Override
public void fly() {
System.out.println("fly");
}
}
public interface Turkey {
void gobble();
void fly();
}
public class WildTurkey implements Turkey {
@Override
public void gobble() {
System.out.println("gobble");
}
@Override
public void fly() {
System.out.println("short fly");
}
}
Duck 객체가 모자라서 Turkey 객체를 대신 사용해야 하는 상황이라고 가정해 보자. 물론 인터페이스가 다르기에 Turkey 객체를 바로 사용할 수는 없다. 이 때 필요한 것이 어댑터이다.
// 우선 적응시킬 형식의 인터페이스를 구현해야 한다. 즉 클라이언트에서 원하는 인터페이스를 구현해야 한다.
public class TurkeyAdapter implements Duck {
private final Turkey turkey;
public TurkeyAdapter(Turkey turkey) { // 그리고 기존 형식 객체의 레퍼런스가 필요한다.
this.turkey = turkey;
}
@Override
public void quack() {
turkey.gobble();
}
/*
두 인터페이스에 모두 fly가 있지만 turkey의 fly() 메소드를 Duck의 fly() 메소드에 대응시키도록 작성
*/
@Override
public void fly() {
for (int i = 0; i < 5; i++) {
turkey.fly();
}
}
}
public class DuckTestDrive {
public static void main(String[] args) {
Duck duck = new MallardDuck();
Turkey turkey = new WildTurkey();
Duck turkeyAdapter = new TurkeyAdapter(turkey);
System.out.println(" turkey said that");
turkey.gobble();
turkey.fly();
System.out.println("\n duck said that");
testDuck(duck);
System.out.println("\n turkeyAdapter said that");
testDuck(turkeyAdapter);
}
private static void testDuck(Duck duck) {
duck.quack();
duck.fly();
}
}
=======================================================
turkey said that
gobble
short fly
duck said that
quack
fly
turkeyAdapter said that
gobble
short fly
short fly
short fly
short fly
short fly
https://codingsmu.tistory.com/59
이제 어댑터가 어떤 식으로 작동하는지 살펴보자
어댑터 구현은 타깃 인터페이스로 지원해야 하는 인터페이스의 크기에 비례해서 복잡해진다. 하지만 다른 대안이 없다. 클라이언트에서 호출하는 부분을 새로운 인터페이스에 맞춰서 고치려면 정말 많은 부분을 고려해야 하고, 코드도 많이 고쳐야 한다. 이런 방법보다는 모든 변경 사항을 캡슐화할 어댑터 클래스 하나만 제공하는 방법이 더 나을 것이다.
어댑터 패턴은 하나의 인터페이스를 다른 인터페이스로 변환하는 용도로 쓰인다. 하나의 어댑터에서 타깃 인터페이스를 구현하려고 2개 이상의 어댑티를 감싸야 하는 상황도 생길 수 있다. 사실 이런 내용은 퍼사드 패턴과 관련이 있으므로 퍼사드 패턴 때 다시 보자.
이런 상황에서는 두 인터페이스를 모두 지원하는 다중 어댑터(Two Way Adapter)를 만들면 된다. 다중 어댑터로 필요한 인터페이스를 둘 다 구현해서 어댑터가 기존 인터페이스와 새로운 인터페이스 역할을 할 수 있게 하면 된다.
이제 어댑터 패턴의 정의를 알아보자.
💡 어댑터 패턴은 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.이 패턴을 사용하면 호환되지 않는 인터페이스를 사용하는 클라이언트를 그대로 활용할 수 있다. 인터페이스를 변환해 주는 어댑터를 만들면 되기 때문이다. 이러면 클라이언트와 구현된 인터페이스를 분리할 수 있으며, 변경 내역이 어댑터에 캡슐화되기에 나중에 인터페이스가 바뀌더라도 클라이언트를 바꿀 필요가 없다.
https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴
클라이언트
어댑터
어댑티
어댑터 패턴은 여러 객체지향 원칙을 반영하고 있다. 어댑티를 새로 바뀐 인터페이스로 감쌀 때는 Composition을 사용한다. 이런 접근번은 어댑티의 모든 서브클래스에 어댑터를 쓸 수 있다는 장점이 있다.
그리고 어댑터 패턴은 클라이언트를 특정 구현이 아닌 인터페이스에 연결한다. 서로 다른 백엔드 클래스로 변환시키는 여러 어댑터를 사용할 수도 있다. 이렇게 인터페이스를 기준으로 코딩했기에 타깃 인터페이스만 제대로 유지한다면 나중에 다른 구현을 추가하는 것도 가능하다.
사실 어댑터에는 두 종류가 있다. 하나는 객체 어댑터, 다른 하나는 클래스 어댑터이다.
지금까지 본 예제와 다이어그램 모두 객체 어댑터에 해당하는 내용들이다. 그렇다면 클래스 어댑터란 무엇이고 왜 살펴보지 않았을까? 클래스 어댑터 패턴을 쓰려면 상속이 필요한데 자바에서는 다중 상속이 불가능하므로 자바에서는 불가능하다. 하지만 다중 상속이 가능한 언러를 사용하다 보면 클래스 어댑터를 써야 할 때도 있으니 클래스 다이어그램을 살펴보자
https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴
어댑티를 적응시킬때 구성을 사용하는 대신, 어댑터를 어댑티와 타깃 클래스의 서브클래스로 만든다. 상속을 사용하는 클래스 어댑터에 비해 객체 어댑터는 composition을 사용하므로 상속을 통한 코드 분량을 줄이지는 못하지만, 어댑티한테 필요한 일을 시키는 코드만 작성하면 되기 때문에 작성해야할 코드가 적고 유연성을 확보할 수 있다.
https://swk3169.tistory.com/255
Enumeration을 리턴하는 elements() 메소드가 구현되어 있었던, 초기 컬렉션 형식(Vector, Stack, Hashtable 등)은 Enumeration 인터페이스를 이용하면 컬렉션 내에서 각 항목이 관리되는 방식에는 신경 쓸 필요 없이 컬렉션의 모든 항목에 접근이 가능하다.
최근에는 Enumeration과 마찬가지로 컬렉션에 있는 일련의 항목들에 접근할 수 있게 해 주면서 항목을 제거할 수 도 있게 해 주는 iterator라는 인터페이스를 이용하기 시작했다.
Enumeration 인터페이스를 사용하는 구형 코드를 다뤄야 할 때도 가끔 있지만 새로운 코드를 만들 때는 Iterator만 사용하는 것이 좋다. 이때 어댑터 패턴을 적용해보자.
https://codingsmu.tistory.com/59
클래스 다이어그램은 다음과 같다. 먼저 타깃 인터페이스를 구현하고, 어댑티 객체로 구성된 어댑터를 구현해야 한다. hasNext()와 next() 메소드는 타깃에서 업대티로 바로 연결된다.
https://codingsmu.tistory.com/59
remove()는 어떻게 처리할까?
이처럼 메소드가 일대일로 대응되지 않는 상황에서는 어댑터를 완벽하게 적용할 수 없다. 클라이언트는 예외 발생 가능성을 염두에 두고 있어야 하기 때문이다. 하지만 클라이언트에서 주의를 기울이고, 어댑터 문서를 잘 만들어 두면 괜찮을 것이다.
public class EnumerationIterator implements Iterator<Object> {
private final Enumeration<?> enumeration;
public EnumerationIterator(Enumeration<?> enumeration) {
this.enumeration = enumeration;
}
@Override
public boolean hasNext() {
return enumeration.hasMoreElements();
}
@Override
public Object next() {
return enumeration.nextElement();
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
}
지금까지 어댑터 패턴을 써서 어떤 클래스의 인터페이스를 클라이언트가 원하는 인터페이스로 변환하는 방법을 어댑터 패턴을 이용하여 구현했다.
이제 조금 다른 이유로 인터페이스를 변경하는 또 다른 패턴을 알아보자. 바로 퍼사드 패턴이다.
퍼사드 패턴은 인터페이스를 단순하게 바꾸려고 인터페이스를 변경하다. 하나 이상의 클래스 인터페이스를 깔끔하면서도 효과적인 퍼사드(겉모양, 외관)으로 덮어주기 때문이다.
해당 패턴 모두 객체를 감싸고 있는 공통점을 가지고 있다. 하지만 모두 사용하는 용도가 다르다.
패턴을 알아보기 전에 영화나 tv 시리즈 몰아보기가 유행에 따라 각광받고 있는 홈시어터를 구축해보자. 스트리밍 플레이어, 프로젝터, 자동 스크린, 서라운드 음향, 팝콘 기계 등 클래스들이 서로 복잡하게 얽혀 있다.
https://invincibletyphoon.tistory.com/22
이제 영화를 보려고 하지만 영화를 보려면 몇 가지 일을 더해야 한다.
이제 이 작업들을 처리하기 위한 어떤 클래스와 메소드가 필요한지 살펴보자.
popper.on();
popper.pop();
lights.dim(10);
screen.down();
projector.on();
projector.setInput(player);
projector.wideScreenMode();
amp.on();
amp.setDvd(player);
amp.setSurroundSound();
amp.setVolume(5);
player.on();
player.play(movie);
클래스가 6개나 필요하고, 만약 영화가 끝나면 어떻게 해야할까?, 방금 했던 일을 전부 역순으로 처리해야 하지 않을까? 다른 라디오나 시스템이 업그레이드하면 이런 복잡한 작동 방법을 또 배워야 하지 않을까?
퍼사드 패턴
으로 간단하게 처리할 수 있는지 알아보자쓰기 쉬운 인터페이스를 제공하는 퍼사드 클래스를 구현함으로써 복잡한 시스템을 훨씬 편리하게 사용할 수 있다. 물론 기존의 시스템을 직접 건드리고 싶다면 기존 인터페이스를그대로 사용하면 된다.
하나의 서브시스템
으로 간주한다.퍼사드 클래스는 서브시스템 클래스를 캡슐화하지 않는다. 서브시스템의 기능을 사용할 수 있는 간단한 인터페이스를 제공할 뿐이다. 클라이언트에서 특정 인터페이스가 필요하다면 서브시스템 클래스를 그냥 사용
하면 된다. 이점이 퍼사드 클래스의 대표적인 장점이다. 단순화된 인터페이스를 제공하면서도, 클라이언트에서 필요로 한다면 시스템의 모든 기능을 사용
할 수 있도록 해줍니다.
퍼사드는 단순화된 서브시스템의 기능을 활용하게 해주는 일 외에도 ‘스마트
’한 기능을 알아서 추가한다. 예를 들어, 홈시어터 퍼사드는 새로운 행동을 구현하지는 않지만, 팝콘을 튀기기 전에 팝콘 기계를 켜야 한다는 사실을 알고 있습니다. 그래서 팝콘 기계를 알아서 킨다. 그리고 각 구성 요소를 켜고 적절한 모드를 선택하는 것도 알아서 잘할 정도로 ‘스마트’하다.
그렇지 않다. 특정 서브시스템에 대해 만들 수 있는 퍼사드의 개수에는 제한이 없다.
퍼사드를 사용하면 클라이언트 구현과 서브시스템을 분리
할 수 있다. 예를 들어 홈시어터 시스템을 업그레이드 하기로 가정해보자. 이런 경우 인터페이스가 크게 달라질 수 있을 것이다. 만약 클라이언트를 퍼사드로 만들었다면 클라이언트 코드는 고칠 필요 없이 퍼사드만 바꾸면 된다.
그렇지 않다. 어댑터 패턴은 하나 이상의 클래스 인터페이스를 클라이언트에서 필요로 하는 인터페이스로 변환한다. 클라이언트가 여러 클래스를 사용할 수도 있기 대문이다. 반대로 퍼사드도 꼭 여러 클래스를 감싸야만 하는 건 아니다. 아주 복잡한 인터페이스를 가지고 있는 단 하나의 클래스에 대한 퍼사드를 만들 수도 있다.
어댑터와 퍼사드의 차이점은 감싸는 클래스의 개수에 있는 것이 아니라 용도
에있다. 어댑터 패턴은 인터페이스를 변경해서 클라이언트에서 필요로 하는 인터페이스로 적응
시키는 용도로 쓰인다. 반면 퍼사드 패턴은 어떤 서브시스템에 대한 간단한 인터페이스를 제공
하는 용도로 쓰인다.
public class HomeTheaterFacade {
// composition 부분, 사용하고자 하는 서브시스템의 모든 구성 요소가 인스턴스 변수 형태로 저장된다.
private final Amplifier amp;
private final Tuner tuner;
private final StreamingPlayer player;
private final Projector projector;
private final TheaterLights lights;
private final Screen screen;
private final PopcornPopper popper;
public HomeTheaterFacade(Amplifier amp, Tuner tuner, StreamingPlayer player,
Projector projector,
TheaterLights lights, Screen screen, PopcornPopper popper) {
this.amp = amp;
this.tuner = tuner;
this.player = player;
this.projector = projector;
this.lights = lights;
this.screen = screen;
this.popper = popper;
}
public void watchMovie(String movie) {
System.out.println("영화 볼 준비 중");
popper.on();
popper.pop();
lights.dim(10);
screen.down();
projector.on();
projector.wideScreenMode();
projector.setInput(player);
amp.on();
amp.setDvd(player);
amp.setSurroundSound();
amp.setVolume(5);
player.on();
player.play(movie);
}
public void endMovie() {
System.out.println("홈시어터 끄는 중");
popper.off();
lights.on();
screen.up();
projector.off();
amp.off();
player.stop();
player.off();
}
}
public class HomeTheaterTestDrive {
public static void main(String[] args) {
// 구성 요소 초기화
// 지금은 구성 요소를 직접 생성하지만 보통은 클라이언트에 퍼사드가 주어지므로 직접 구성 요소를 생성하지 않아도 된다.
Amplifier amp = new Amplifier();
Tuner tuner = new Tuner();
StreamingPlayer player = new StreamingPlayer();
Projector projector = new Projector();
TheaterLights lights = new TheaterLights();
Screen screen = new Screen();
PopcornPopper popper = new PopcornPopper();
HomeTheaterFacade homeTheater = new HomeTheaterFacade(
amp,
tuner,
player,
projector,
lights,
screen,
popper
);
// 단순화된 인터페이스를 사용
homeTheater.watchMovie("king kong");
homeTheater.endMovie();
}
}
퍼사드 패턴을 사용하려면 어떤 서브시스템에 속한 일련의 복잡한 클래스를 단순하게 바꿔서 통합한 클래스를 만들어야 한다. 다른 패턴과 달리 퍼사드 패턴은 상당히 단순한 편이다. 복잡한 추상화 같은 게 필요 없다. 하지만 퍼사드 패턴을 사용하면 클라이언트와 서브시스템이 서로 긴밀하게 연결되지 않아도 되고, 최소 지식 객체지향 원칙을 준수하는데도 도움이 된다.
퍼사드 패턴의 정의는 다음과 같다.
💡 퍼사드 패턴은 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.여기서 가장 중요한 점은 패턴의 용도이다. 정의를 보면 퍼사드 패턴은 단순화된 인터페이스로 서브시스템을 더 편리하게 사용하려고 쓰인다는 사실을 알 수 있다. 퍼사드 패턴의 클래스 다이어그램에서도 이 사실을 확인할 수 있다.
https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴
최소 지식 원칙(Principle of Least Knowledge)에 따르면 객체 사이의 상호자용은 될 수 있으면 아주 가까운 ‘친구’ 사이에서만 허용하는 편이 좋다. 이 원칙은 보통 다음과 같이 정의될 수 있다.
💡 진짜 절친에게만 이야기 해야 한다.그런데 이게 정확히 무슨 소리일까? 시스템을 디자인할 때 어떤 객체든 그 객체와 상호작용을 하는 클래스의 개수와 상호작용 방식에 주의를 기울여야 한다는 뜻이다.
이 원칙을 잘 따르면 여러 클래스가 복잡하게 얽혀 있어서, 시스템의 한 부분을 변경했을 때 다른 부분까지 줄줄이 고쳐야 하는 상황을 미리 방지할 수 있다. 여러 클래스가 서로 복잡하게 의존하고 있다면 관리하기도 힘들고, 남들이 이해하기 어려운 불안정한 시스템이 만들어진다.
그런데 어떻게 하면 여러 객체와 친구가 되는 것을 피할 수 있을까?
이 원칙은 친구를 만들지 않는 4개의 가이드라인을 제시한다.
해당 가이드라인에 따르면 다른 메소드를 호출해서 리턴받은 객체의 메소드를 호출하는 일도 바람직 하지 않다. 따라서 꽤 까다로운 가이드라인이다. 메소드를 호출한 결과로 리턴받은 객체에 들어있는 메소드를 호출하면 다른 객체의 일부분에 요청하게 되고, 직접적으로 알고 지내는 객체의 수가 늘어나는 단점
이 있다.
이러한 상황에서 최소 지식 원칙을 따르려면 객체가 대신 요청하도록 만들어야 한다. 그러면 그 객체의 한 구성 요소를 알고 지낼 필요가 없어지고 친구의 수를 줄이는 데도 도움이 된다.
public float getTemp() {
Thermometer thermometer = station.getThermometer();
return thermometer.getTemperature();
}
public float getTemp() {
return station.getTemperature();
}
다음은 자동차를 나타내는 Car 클래스이다. 이 클래스르 살펴보면 최소 지식 원칙을 따르면서 메소드를 호출하는 방법을 어느 정도 파악할 수 있다.
public class Car {
Engine engine; // 해당 클래스의 구성요소, 구성요소의 메소드는 호출해도 된다.
public Car(Engine engine) {
this.engine = engine;
}
public void start(Key key) { // 매개변수로 전달된 객체의 메소드는 호출 가능하다.
Doors doors = new Doors(); // 새로운 객체를 생성, 해당 객체의 메소드 호출 가능
boolean authorized = key.turns(); // 매개변수로 전달된 객체
if (authorized) {
engine.start(); // 이 객체의 구성 요소를 대상으로 메소드 호출 가능
updateDashboardDisplay(); // 객체 내에 있는 메소드 호출 가능
doors.lock(); // 직접 생성하거나 인스턴스를 만든 객체의 메소드 호출 가능
}
}
private void updateDashboardDisplay() {
// update display
}
}
데메테르의 법칙과 최소 지식 원칙은 완전히 똑같은 말이다. 하지만 좀 더 직관적이고 법칙이라는 단어가 없는 최소 지식 원칙을 선호한다. 모든 원칙은 상황에 따라서 적절하게 따라야 한다.
물론 존재한다. 이 원칙을 잘 따르면 객체 사이의 의존성을 줄일 수 있으며 소프트웨어 관리가 더 편해지지만, 메소드 호출을 처리하는 ‘래퍼’ 클래스
를 더 만들어야 할 수도 있다. 그러면 시스템이 복잡해지고, 개발 시간도 늘어나고, 성능도 떨어진다.