티스토리 뷰

 

해당 글은 이어서 GoF의 구조 패턴(Structural Pattern)을 다루겠습니다.

 

어댑터 (Adapter)

어댑터, 말 그대로 무언가를 다른 것에 맞게 변환해주는 존재입니다. 마치 콘센트 모양이 맞지 않을 때 사용하는 어댑터처럼 말이죠. 이는 프로그래밍 세계에서도 마찬가지입니다.

 

특히, 기존에 만들어진 코드, 특히 수정하기 어렵거나 아예 손댈 수 없는 코드들을 다룰 때 어댑터 패턴이 유용합니다. 오래되고 복잡한 레거시 코드라던가 외부 라이브러리 사용 그리고 프레임워크 또는 SDK를 예시로 들 수 있습니다. 이런 코드들은 분석하고 수정하는 것 자체가 굉장히 힘든 경우가 많습니다. 하지만 어댑터 패턴을 이용하면 이런 골칫거리 코드들을 새로운 시스템에 맞게끔 연결해주는 다리 역할을 할 수 있습니다.


즉, 어댑터는 서로 다른 인터페이스를 가진 코드들을 연결해주는 역할을 하면서 시스템 전체의 확장성을 높여주는 역할을 수행합니다.

따라서 개발자 입장에서는 복잡한 기존 코드를 직접 수정하는 수고를 덜 수 있으니, 어댑터는 개발자의 스트레스를 줄여주는 고마운 존재라고 할 수 있습니다.

 

 

그렇다면 해당 패턴을 구현하는 간단한 예시를 설명하겠습니다.

 

결제 모듈 교체

온라인 쇼핑몰에서 오래된 결제 모듈(LegacyPayment)을 최신 결제 모듈(ModernPayment)로 교체해야 합니다. 하지만 기존 쇼핑몰 시스템은 LegacyPayment에 강하게 결합되어 있어, 빠른 시간 내에 직접적인 교체가 어려운 상황입니다.

 

문제점:

  • LegacyPayment 모듈은 오래되어 보안에 취약하고 유지보수가 어렵습니다.
  • ModernPayment 모듈은 최신 보안 기술을 적용하고 있으며, 다양한 기능을 제공합니다.
  • 기존 시스템을 직접 수정하면 많은 시간과 비용이 소요되며, 오류 발생 가능성이 높습니다.

 

// 기존 시스템이 사용하는 결제 인터페이스, 어댑터 패턴을 적용할 대상.
abstract class PaymentGateway {
    abstract processPayment(cardNumber: string, amount: number): Promise<bool>;
}
// 새로운 결제 시스템
class ModernPayment {
    perform(cardDetails: string, amount: number): Promise<bool> {
    	// 결제 성공 여부를 반환.
        return true;
    }
}
class ModernPaymentAdapter extends PaymentGateway {
    constructor(public payment: ModernPayment) {
    	super();
    }
    
    processPayment(cardNumber: string, amount: number): Promise<bool> {
        // ModernPayment 모듈의 메서드 호출을 위해 데이터 변환
        String cardDetails = "card_number=" + cardNumber;
        double paymentAmount = amount / 100.0; // LegacyPayment는 센트 단위, ModernPayment는 원 단위

        // ModernPayment 모듈의 makePayment 메서드 호출
        return this.payment.perform(cardDetails, paymentAmount);
    }
}
// 결제를 수행하는 쇼핑몰 시스템.
class ShoppingCart {
    constructor(public payment: PaymentGateway) {}
    
    checkout(cartNumber: number, amount: number) async {
    	if (await this.playment.processPayment(cartNumber, amount)) {
            console.log("결제가 완료되었습니다.");
        } else {
            console.log("결제에 실패했습니다.");
        }
    }
}
// 기존 결제 모듈 사용
const legacyPayment: LegacyPayment = new LegacyPayment();
const cart: ShoppingCart = new ShoppingCart(legacyPayment);
cart.checkout("1234-5678-9012-3456", 10000);

// 어댑터를 사용하여 새로운 결제 모듈 사용
ModernPayment modernPayment = new ModernPayment();
ModernPaymentAdapter adapter = new ModernPaymentAdapter(modernPayment);
cart = new ShoppingCart(adapter);
cart.checkout("1234-5678-9012-3456", 10000);

 

조금만이라도 생각해본다면 어댑터 패턴이 모든 상황에서 최선의 선택은 아니라는 것을 알 수 있습니다, 때로는 "땜빵"처럼 느껴질 수 있습니다. 이러한 패턴들은 근본적인 문제를 해결하기보다는 문제를 우회하는 방식에 가깝습니다. 특히 장기적인 관점에서 시스템 재설계 없이 어댑터에 의존하는 것은 마치 기술 부채를 쌓는 것과 같다고 느껴집니다.

 

기술 부채의 간단한 시각적 예시

 

하지만 그렇다고 어댑터 패턴 자체를 안좋게 바라보기에는 아까운 패턴입니다. 현실적인 제약 때문에 이상적인 해결책을 적용하기 어려운 경우, 어댑터 패턴은 유용한 도구가 될 수 있습니다.

 

따라서 해당 패턴을 사용할 때는 근본적인 문제의 해결 방안을 먼저 생각해보고 빠른 시간 내에 해결할 수 없다고 생각되면 해당 패턴을 도입할 지 생각해보세요, 또한 어댑터를 과도하게 사용하면 코드의 복잡성이 증가하고 유지보수가 쉬어지기는 커녕 오히려 어려워질 수 있습니다.(뭐든 과하면 안좋습니다)

 

브릿지 (Bridge)

해당 패턴은 생각보다 간단한 개념입니다, 과도한 상속 남용으로 복잡해지고 깊어질 수 있는 클래스 계층 구조를 최대한 평평하게 유지하기 위해서 주로 사용되는 디자인 패턴 중 하나입니다. (또한 추상화와 구현을 분리하여 서로 독립적으로 확장 가능하게 만드는 것을 목표로 합니다.)

생각해보면 Android의 ConstraintLayout 요소와 매우 유사한 목적성을 지닌 디자인 패턴입니다, 둘 다 계층 구조의 복잡성을 해결하고 유연성을 높이기 위해 "분리"와 "관계 설정"이라는 개념을 활용한다는 공통점이 있습니다.

예시를 들어보겠습니다.
Shape 라는 추상 클래스가 존재한다고 가정합시다, 이는 말 그대로 모양을 추상화한 것입니다, 개발자는 다양한 모양을 정의하기 위해서 Shape를 확장하는 Circle, Cube 라는 클래스들을 선언할 수 있습니다.

만약 여기서 색상과 재질이라는 특성을 정의하고 싶다면 클래스를 확장하거나 별도의 Color, Material 이라는 추상 클래스들을 선언하여 분리하는 경우가 있습니다. 당연하게도 후자가 브릿지 패턴이라고 볼 수 있습니다.

일단 클래스를 계속 확장하는 경우의 예시를 보도록 하겠습니다.

 

abstract class Shape {
    abstract get debugLabel(): string;
}

class Circle extends Shape {
    get debugLabel(): string { return "circle";	}
}

class RedCircle extends Circle {
    get debugLabel(): string { return "red-circle"; }
}

class WoodRedCircle extends RedCircle {
    get debugLabel(): string { return "wood-red-circle"; }
}

class BlueCircle extends Circle {
    get debugLabel(): string { return "blue-circle"; }
}

class WoodBlueCircle extens BlueCircle {
    get debugLabel(): string { return "wood-blue-circle"; }
}

// ... 자세한 내용 생략

 

해당 코드에서는 색상이라는 개념을 도입하기 위해 RedCircle 같은 클래스들을 선언하였습니다. 하지만 새로운 색상을 추가할 때마다 새로운 클래스를 선언해야 하며(심하면 제곱에 제곱에 제곱에...), 이러한 과정이 반복되면 클래스 계층 구조가 과도하게 깊어지고 복잡해질 수 있습니다. 또한, 모양과 색상을 독립적으로 확장하기 어렵습니다.

 

이유는 해당 예시에서는 색상을 모양의 특성으로써 바라보기보다는, 모양 자체를 확장하는 방식으로 접근했기 때문입니다.

최상위 추상 클래스인 Shape는 모양의 추상적인 개념을 정의하는데, 여기에 색상이라는 또 다른 추상적인 개념을 추가하려는 시도는 확장성에 문제를 야기합니다. 색상 자체도 독립적인 추상적인 개념이며, 이렇게 계속해서 추상적인 개념을 확장해 나가면 개발자는 관리해야 할 변수가 급증하여 복잡성이 커지고 유지보수가 매우 어려워집니다. 이는 잦은 확장이 필요한 코드에서 매우 비효율적이며, 개발자의 피로도를 높이는 코드 구조를 만듭니다.

 

abstract class Color {
    abstract get debugLabel(): string;
}

class Red extends Color {
    get debugLabel(): string { return "red"; }
}

abstract class Material {
    abstract get debugLabel(): string;
}

class Wood extends Material {
    get debugLabel(): string { return "wood"; }
}

// -------------------------------------

abstract class Shape {
    abstract get debugLabel(): string;
}

class Circle extends Shape {
    constructor(public color: Color, public material: Material) {
    	super();
    }

    get debugLabel(): string {
    	return `${this.material.debugLabel}-${this.color.debugLabel}-circle`;
    }
}

 

해당 예시에서는 추상적 개념을 서로 분리하면서 Circle은 색상과 재질이라는 "특성"들을 가지게 하고 있습니다. 이는 존재를 확장하는 것이 아닌 "특징"을 추가하는 방식으로, 모양과 색상 그리고 재질들을 독립적으로 확장할 수 있도록 합니다. 즉, 새로운 모양이나 색상을 추가할 때, 기존 클래스를 수정하지 않고 새로운 클래스를 선언하면 됩니다.

 

색상을 추가하고 싶다면 BlackColor 하나만 선언하면 된다는 의미입니다, 만약 해당 디자인 패턴을 사용하지 않는다면 BlackCircle, WoodBlackCircle, BlackCube, WoodBlackCube ... 흠... 너무 끔찍할 것 같네요, 그만 알아봅시다.

 

컴포지트 (Composite)

해당 패턴은 복합 객체(자식을 가지는)와 단일 객체(자식을 가질 수 없는)를 하나의 인터페이스로 묶어서 마치 동일한 객체처럼 다룰 수 있도록 하는 디자인 패턴입니다. 이 패턴을 사용하면 트리 구조를 가진 데이터를 효율적으로 관리할 수 있습니다.


예를 들어, 파일 시스템을 생각해 보세요. 파일 시스템은 폴더와 파일로 구성된 계층적인 트리 구조를 가지고 있습니다. 폴더는 파일뿐만 아니라 다른 폴더도 포함할 수 있습니다. 반면 파일은 다른 파일이나 폴더를 포함할 수 없죠. 즉, 폴더는 자식 노드를 가질 수 있지만 파일은 그렇지 않습니다.


만약 이러한 차이 때문에 파일과 폴더를 다루는 인터페이스를 따로 구현한다면 어떻게 될까요? 예를 들어, 파일 시스템의 모든 요소를 순회하며 출력하는 기능을 구현한다고 가정해봅시다. 폴더와 파일을 별도의 인터페이스로 다룬다면 각각의 특성에 맞는 서로 다른 로직을 작성해야 할 것입니다. 이는 코드를 복잡하게 만들고 유지보수를 어렵게 만듭니다. (단, 이는 다른 관점을 가지는 개발자에게는 객체들이 확실하게 또는 논리적으로 구분되지 않으므로 복잡하다고 느낄 수도 있습니다)


하지만 컴포지트 패턴을 이용하면 이러한 문제를 해결할 수 있습니다. 컴포지트 패턴을 통해 파일과 폴더 모두 동일한 인터페이스를 가지도록 하면, 마치 동일한 유형의 객체처럼 다룰 수 있습니다.

 

따라서 파일 시스템 순회와 같은 작업을 수행할 때, 폴더와 파일을 구분하지 않고 동일한 로직으로 처리할 수 있어 코드가 훨씬 간결해지고 유지보수도 용이해집니다.

 

// 파일과 폴더의 공통 인터페이스를 정의합니다.
abstract class FileComponent {
    abstract get name(): string;
    abstract get size(): number;
    abstract get children(): FileComponent[];
    
    abstract attach(child: FileComponent): boolean;
    abstract detach(child: FileComponent): boolean;
}

class File extends FileComponent {
    private _name: string;
    private _size: number;

    constructor(name: string, size: number) {
        super();
        this._name = name;
        this._size = size;
    }

    get name(): string { return this._name; }
    get size(): number { return this._size; }
    get children(): FileComponent[] { return null; }
    
    attach(_: FileComponent): boolean { return false; }
    detach(_: FileComponent): boolean { return false; }
}

class Folder extends FileComponent {
    private _name: string;
    private _children: FileComponent[] = [];

    constructor(name: string) {
        super();
        this._name = name;
    }

    get name(): string { return this._name; }
    get size(): number {
        return this.children.reduce((a, b) => a + b.size, 0);
    }

    get children(): FileComponent[] { return this._children }

    attach(child: FileComponent): boolean {
        if (!this.children.includes(child)) {
            return this.children.push(child), true;
        }

        return false;
    }

    detach(child: FileComponent): boolean {
        if (this._children.includes(child)) {
            return this._children = this.children.filter(e => e != child), true;
        }

        return false;
    }
}
const f1 = new File("file 1", 100);
const f2 = new File("file 2", 50);
const f3 = new File("file 3", 300);
const projectFile = new Folder("Project Files");
const cpp = new Folder("cpp");
const cmake = new Folder("cmake");

cpp.attach(f1);
cpp.attach(f2);
cmake.attach(f3);

projectFile.attach(cpp);
projectFile.attach(cmake);

// 프로젝트 파일의 총 크기를 계산
console.log(projectFile.size); // 450

// 프로젝트 파일의 모든 파일 목록 출력
function printFiles(component: FileComponent) {
  if (component.children) {
    component.children.forEach(child => printFiles(child));
  } else {
    console.log(component.name);
  }
}

printFiles(projectFile); // "file 1", "file 2", "file 3" 출력

 

참고로 해당 코드의 파일 이름들을 출력하는 로직에서 재귀 함수(Recursive Function)를 이용하는 것을 볼 수 있습니다. 이는 트리 구조에서 자식들을 순회할 때 매우 흔히 사용하는 방식들 중에서 하나입니다.

 

추가적으로 파일 시스템 뿐만 아니라 트리 구조를 가질 수 있는 여러 다양한 상황에서 사용할 수 있습니다, 예를 들어 명령어가 있겠죠.

 

컴포지트 패턴과 같은 유익하고 다양한 상황에서 사용될 수 있는 여러 디자인 패턴을 사용하여 여러분들이 객체지향 코딩에 관심과 재미를 느끼셨으면 좋겠습니다. 해당 글을 읽어주져서 감사합니다.

 

Happy coding in the another world!

 

https://mttankkeo.tistory.com/19