설명
특정 클래스의 동작을 클라이언트가 원하는 형태로 변환, 인터페이스를 일치시킨다. 위 그림에서, 클라이언트는 Target 인터페이스 형태로 기존 기술인 Adaptee 클래스의 기능을 사용하고 싶다. 이때 Adaptee을 직접 Target에 맞게 수정하는 것은 매우 고단한 작업이며, 만약 레거시 기술이라면 여러 이유로 수정이 불가능할 수도 있다. 이러한 상황에서 외부 모듈 자체를 수정하는 대신 Adapter 클래스를 구현하여 Adaptee의 인터페이스를 Target의 인터페이스에 적응시킨다.
Adapter 패턴은 기존 클래스 Adaptee을 사용하고 싶지만 인터페이스가 맞지 않는 경우, 이 상황에서 Adaptee 자체를 Target 인터페이스에 맞출 수는 없을 때 사용한다.
잘 유지보수 되고 있는 모듈을 사용한다고 생각해보자. 해당 모듈이 오픈소스로 공개되어 있다면 프로젝트 내에서 인터페이스를 수정해서 사용할 수도 있다. 그런데 만약 번들링 또는 컴파일을 마친 완성품만 공개한다면 어떨까? 어떻게든 리버싱하여 내 프로젝트에 맞추겠다는 생각이 아닌 이상 외부 모듈을 내 프로젝트에 맞게 수정하는 것은 힘든 일이다. 특히 해당 모듈이 오픈소스 기반으로 활발하게 발전하고 있다면, 실제 구현부 수준을 고치겠다는 생각은 결코 현명하지 않다.
따라서 이러한 모듈들을 이용하는 가장 좋은 방법은 단순히 Target 인터페이스 형태로 감싸는 것이다. 어댑터 클래스는 일종의 래퍼 클래스로, Adaptee을 직접 상속받거나 내부 필드로서 이용하여 기대하는 동작을 Target 인터페이스로 사용할 수 있도록 연결해준다. 클라이언트가 어댑터에게, 어댑터가 Adaptee에게 메시지를 넘기는 방식으로 동작한다.
구성 요소
- Target: 클라이언트 환경에서 사용되는 인터페이스를 정의하는 인터페이스 또는 클래스이다.
- Adaptee: 사용하고 싶지만 인터페이스가 달라 적응이 필요한 클래스이다.
- Adaptor: Adaptee의 인터페이스 사항을 Target 인터페이스에 대응시키는 클래스로, Adaptee에 정의된 기능을 Target과 연결할 뿐만 아니라, 대응되는 기능이 없을 때 이를 구현하기도 한다 ( 단, 이 구현은 Target 인터페이스와의 호환이 목적이다. 인터페이스 통일이 목적이기 때문.)
- Client: Target 인터페이스를 통해 동작하는 객체이다.
어댑터를 구성하는 방법은 크게 2가지로 나눌 수 있다.
- 상속 기반: Adapter 클래스가 Adaptee와 Target을 동시에 구현하도록 만든다.
- 포함(composition) 기반: Adapter 클래스는 Target을 구현하고 Adaptee는 필드로 가진다.
상속 기반의 어댑터 패턴은 어댑터 클래스와 동작을 구현하는 Adaptee 사이를 긴밀하게 연결한다. 이 경우 간접 호출이 필요 없다는 점이나 Adaptee에 정의된 일부 동작을 오버라이딩 할 수 있다는 일부 특징이 있긴 한데, 어댑터의 역할이 Target과의 인터페이스 차이를 보정하는데 있다는 것을 감안하면 특출난 장점은 아니다. 오히려 Adaptee 클래스의 내부 구현 사항을 알아야 한다는 점에서 자주 사용되지는 않는다고 한다.
여기서 가장 중요한 점은, Adapter 클래스가 Adaptee를 상속한다고 해서 Adaptee의 하위 클래스 역할을 하는 것은 아니라는 점이다. 비록 Adaptee을 상속하기는 하지만, 이는 Adaptee가 가지고 있는 필드와 메서드를 공유하여 Target과 연결하기 위한 것이지, 하위 클래스를 만드는게 목적이 아니다.
반대로 Adaptee 객체를 필드로 두고 Target 인터페이스를 구현하는 포함 기반 어댑터 패턴은 Adaptee의 코드를 재정의하기는 어렵지만, 해당 클래스의 인터페이스만 알고 있어도 사용할 수 있다는 점이 장점이다. 또한 Adapter과 Adaptee가 직접 연결되어 있는 것이 아니기 때문에, Adaptee 객체마다 Adapter을 만들 필요 없이 갈아끼울 수 있게 된다. 상위 클래스에 대해서만 어댑터를 구현하더라도 하위 클래스들이 호환된다는 것도 장점이다.
따라서 adaptee의 동작을 재정의할 필요가 없다면 포함 기반 어댑터 패턴을 적용하는 편이 좋을 것 같다.
구현 고려사항
- Adaptee 클래스를 얼마나 Target에 대응시키는지에 따라 필요한 노력이 달라질 수 있다. 단순히 인터페이스 매칭만 시키는 수준이라면 쉽겠지만, Adaptee에 없는 기능을 만들어야 하는 등의 상황이라면 당연히 복잡해질거다.
- 내가 만든 클래스가 모든 사용자에게 동일한 인터페이스를 제공하기 위해 범용적으로 늘릴 필요는 없다. 애초에 모든 사용자에게 동일한 인터페이스를 제공해야 한다는 가정을 버리고, 해당 사용자가 요구하는 인터페이스에 대한 어댑터 클래스를 만들어서 해결하자.(pluggable adapter)
- 경우에 따라 Target과 Adaptee 양측에서 서로의 인터페이스를 요구하는 경우 두 인터페이스를 모두 구현하는 양방향 적응자(two-way adapter)을 고려할 수 있다.
예시 코드
이번 예시코드는 다음 글의 영감을 받았다.
https://stackoverflow.com/questions/3478225/when-do-we-need-adapter-pattern
when do we need Adapter pattern?
When do we need to go for Adapter pattern? If possible give me a real world example that suits that pattern.
stackoverflow.com
위 글의 첫번째 답변은 답변자가 DVR(Digital Video Recorder)과 관련된 소프트웨어를 작성할 때의 이야기를 담고 있다. 답변자는 모든 DVR과 상호작용할 수 있는 소프트웨어를 만들 필요가 있었다. 이때 각각의 DVR 제조사들은 자신들의 DVR을 제어하기 위한 소프트웨어를 제공했는데, 이들의 인터페이스가 죄다 다른 구조를 가지고 있었다.
이런 상황에서 답변자는 각 DVR을 구분하기 위해 switch-case 문을 사용하는 대신에 자신들의 공통적인 인터페이스를 정의하고, 각각의 DVR 제어 기능들을 어댑터로 연결한 후 팩토리 패턴을 적용했다. 이를 통해 상위 클래스에서는 일관된 방식으로 코드를 구현할 수 있었고, 새로운 DVR이 추가되는 경우 대응되는 어댑터 코드를 작성할 수 있어 확장성이 좋았다고 한다. 이제 위 설명의 코드를 간단하게 구현해보자.
function record(type: string, time: Date) {
switch(type) {
case "OUR":
const c1 = new OurDvrController();
c1.playBack(time);
break;
case "ACom":
const c2 = new AComDvrController();
c2.beginPlayBack(time);
break;
case "BCom":
const c3 = new BComDvrController();
c3.startPlayBack(time.getTime());
break;
case "CCom":
const c4 = new CComDvrController();
const tf = new Intl.DateTimeFormat('en-US', {
timeStyle:"long",
});
const df = new Intl.DateTimeFormat('en-US', {
dateStyle:"long"
});
c4.playBack(df.format(time), tf.format(time));
break;
// 여기서 벤더가 계속 추가되면? Record 말고 start, stop도 바꿔야하면?
// 이렇게 짜는건 미친짓이다.
}
}
class OurDvrController {
playBack(startTime: Date) {
console.log("Our start from!", startTime);
};
record() {
console.log("our record");
// do something
}
}
class AComDvrController {
beginPlayBack(startTime: Date) {
console.log("A start from!", startTime);
//do something
}
videoRecord() {
console.log("A record");
}
}
class BComDvrController {
startPlayBack(timetick: number) {
console.log("B start from!", timetick);
//do something
}
vRec() {
console.log("B record");
}
}
class CComDvrController {
playBack(startDate: string, startTime: string) {
console.log("C start from!", startDate, startTime);
//do something
}
dvRecord() {
console.log("C Record");
}
}
위와 같이 인터페이스가 전혀 통일되지 않은 다양한 DVR 제조사들의 컨트롤러 코드가 있다고 생각해보자. 위와 같은 코드를 if / else 분기로 처리하는 것은 좋은 선택이 아니며, 장기적으로 DVR이 추가될 때마다 코드 전체적으로 변동하는 지점이 너무나도 많을 것이다. 위 코드에서는 record만 명시했지만, 만약 start / stop 등의 기능도 계속 추가되어야 한다면? 하나의 DVR만 추가되어도 수정되는 영역이 너무 광범위해진다!
위 코드를 어댑터 패턴과 단순 팩토리 패턴을 간단하게 적용하여 해결해보자.
export interface DVRController {
playBack: (startTime: Date) => void; // do something
record: () => void;
}
class OurDvrController implements DVRController {
playBack(startTime: Date) {
console.log("Our start from!", startTime);
};
record() {
console.log("our record");
// do something
}
}
//ACom.ts
import { DVRController } from "../index.Cont";
export class AComDvrController {
beginPlayBack(startTime: Date) {
console.log("A start from!", startTime);
//do something
}
videoRecord() {
console.log("A record");
}
}
export class AComDvrAdptController implements DVRController {
private cont: AComDvrController;
constructor(cont: AComDvrController) {
this.cont = cont;
}
playBack(startTime: Date) {
this.cont.beginPlayBack(startTime);
}
record() {
this.cont.videoRecord
}
}
//BCom.ts
import { DVRController } from "../index.Cont";
export class BComDvrController {
startPlayBack(timetick: number) {
console.log("B start from!", timetick);
//do something
}
vRec() {
console.log("B record");
}
}
export class BComDvrAdptController implements DVRController {
private cont: BComDvrController;
constructor(cont: BComDvrController) {
this.cont = cont;
}
playBack(startTime: Date) {
this.cont.startPlayBack(startTime.getTime());
}
record() {
this.cont.vRec();
}
}
//CCom.ts
import { DVRController } from "../index.Cont";
export class CComDvrController {
playBack(startDate: string, startTime: string) {
console.log("C start from!", startDate, startTime);
//do something
}
dvRecord() {
console.log("C Record");
}
}
export class CComDvrAdptController implements DVRController {
private cont: CComDvrController;
constructor(cont: CComDvrController) {
this.cont = cont;
}
playBack(startTime: Date) {
const tf = new Intl.DateTimeFormat('en-US', {
timeStyle: "long",
});
const df = new Intl.DateTimeFormat('en-US', {
dateStyle: "long"
});
this.cont.playBack(df.format(startTime),tf.format(startTime));
}
record() {
this.cont.dvRecord();
}
}
공통 인터페이스인 DVRController을 선언하고, 각각의 컨트롤러들이 해당 인터페이스에 대해 대응될 수 있도록 어댑터 클래스로 연결했다.
export class DVRContFactory {
static getController(type: string): DVRController | null {
switch (type) {
case "OUR":
const c1 = new OurDvrController();
return c1;
case "ACom":
const c2 = new AComDvrAdptController(new AComDvrController());
return c2;
case "BCom":
const c3 = new BComDvrAdptController(new BComDvrController());
return c3;
case "CCom":
const c4 = new CComDvrAdptController(new CComDvrController());
default:
return null;
}
}
}
전체 어댑터 클래스들은 DVRController라는 공통된 인터페이스로 표현될 수 있으므로, DVRControllerFactory을 둬서 각 어댑터의 타입 정보만으로 취급이 가능하도록 만들었다.
import { DVRContFactory } from "./index.Cont.js";
export function main() {
const c1 = DVRContFactory.getController('OUR');
c1?.playBack(new Date());
const c2 = DVRContFactory.getController('ACom');
c2?.playBack(new Date());
const c3 = DVRContFactory.getController('BCom');
c3?.playBack(new Date());
const c4 = DVRContFactory.getController('CCom');
c4?.playBack(new Date());
}
답변을 토대로 대략 추측하여 어떤 방식으로 진행되었을지 복기해봤는데, 얼마나 비슷한 느낌일지는 모르겠다. 다만 실제로 비슷한 기능을 제공하는 서로 다른 제품을 통합하는 과정에 어댑터 패턴이 사용될 수 있음을 느꼈다.
'CS > 디자인패턴' 카테고리의 다른 글
[디자인패턴] Facade 패턴 (0) | 2023.04.30 |
---|---|
[디자인패턴] SOLID 원칙 (0) | 2023.04.27 |
[디자인패턴] Bridge 패턴 (0) | 2023.04.25 |
[디자인패턴] Singleton 패턴 (0) | 2023.04.20 |
[디자인패턴] Composite 패턴 (0) | 2023.04.11 |