[JAVA] 디자인 패턴(2) | 구조 패턴 (Structural Patterns)

chaeyeong·2025년 3월 7일

자바 이론

목록 보기
9/9

객체 구조 디자인 패턴이란?
클래스와 객체 간의 관계를 조직화하여 확장성과 유지보수성을 높이는 패턴이다.


1. 어댑터 패턴 (Adapter Pattern)

서로 다른 인터페이스를 가진 클래스를 연결하여 기존 클래스를 수정하지 않고도 새로운 기능을 추가할 수 있도록 하는 패턴이다.

// 클라이언트가 사용하는 공통 인터페이스
interface MediaPlayer {
    void play(String audioType, String fileName);
}

// 기존 MP3 플레이어 클래스
class MP3Player implements MediaPlayer {
    public void play(String audioType, String fileName) {
        System.out.println("MP3 파일 재생 중: " + fileName);
    }
}

// 기존 MP4 플레이어 (MediaPlayer 인터페이스와 호환되지 않음)
class MP4Player {
    public void playMP4(String fileName) {
        System.out.println("MP4 파일 재생 중: " + fileName);
    }
}

// 어댑터 클래스 (MediaPlayer 인터페이스를 구현하여 MP4Player와 연결)
class MediaAdapter implements MediaPlayer {
    private MP4Player mp4Player = new MP4Player();

    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("mp4")) {
            mp4Player.playMP4(fileName); // MP4 플레이어의 메서드를 호출하여 변환
        } else {
            System.out.println("지원되지 않는 형식: " + audioType);
        }
    }
}

// 클라이언트 코드 (어댑터 패턴을 활용하여 MP4 파일도 재생 가능)
public class AdapterPatternExample {
    public static void main(String[] args) throws FileNotFoundException {
        MediaPlayer mp3Player = new MP3Player();
        mp3Player.play("mp3", "music.mp3");

        MediaPlayer adapter = new MediaAdapter();
        adapter.play("mp4", "video.mp4");
    }
}

⭕️ 장점

  • 기존 클래스를 수정하지 않고 새로운 기능을 추가할 수 있다.
  • 다양한 인터페이스 간의 변환을 지원한다.
    • 예) MediaAdapter가 여러 개의 다른 포맷(AVI, MKV 등)을 지원하도록 확장
  • 리스크가 큰 레거시 시스템 코드의 직접 변경 없이 오래된 시스템을 새로운 시스템과 연결할 수 있다.

❌ 단점

  • 변환해야 하는 대상이 많아질수록 관리해야 할 코드가 늘어나기 때문에 코드가 복잡해질 수 있다.
  • 어댑터는 중간 계층의 역할을 하므로, 직접 객체를 호출하는 것보다는 성능이 저하될 수 있다.


2. 데코레이터 패턴 (Decorator Pattern)

기존 객체에 새로운 기능을 동적으로 추가할 수 있도록 하는 패턴이다.

// 기본 커피 인터페이스
interface Coffee {
    String getDescription(); 
    double cost();
}

// 가장 기본적인 커피 클래스
class SimpleCoffee implements Coffee {
    public String getDescription() {
        return "기본 커피";
    }

    public double cost() {
        return 2000.0; 
    }
}

// 데코레이터 (추상 클래스)
abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee; // 기존 커피 객체를 감싸는 역할

    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    public String getDescription() {
        return coffee.getDescription();
    }

    public double cost() {
        return coffee.cost();
    }
}

// 우유 추가 데코레이터
class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }

    public String getDescription() {
        return super.getDescription() + ", 우유 추가";
    }

    public double cost() {
        return super.cost() + 500.0; 
    }
}

// 설탕 추가 데코레이터
class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }

    public String getDescription() {
        return super.getDescription() + ", 설탕 추가";
    }

    public double cost() {
        return super.cost() + 300.0; 
    }
}

// 휘핑크림 추가 데코레이터
class WhipDecorator extends CoffeeDecorator {
    public WhipDecorator(Coffee coffee) {
        super(coffee);
    }

    public String getDescription() {
        return super.getDescription() + ", 휘핑크림 추가";
    }

    public double cost() {
        return super.cost() + 700.0; 
    }
}

// 클라이언트 코드
public class DecoratorPatternExample {
    public static void main(String[] args) {
        // 기본 커피
        Coffee coffee = new SimpleCoffee();
        System.out.println(coffee.getDescription() + " 가격: " + coffee.cost() + "원");

        // 우유 추가
        coffee = new MilkDecorator(coffee);
        System.out.println(coffee.getDescription() + " 가격: " + coffee.cost() + "원");

        // 설탕 추가
        coffee = new SugarDecorator(coffee);
        System.out.println(coffee.getDescription() + " 가격: " + coffee.cost() + "원");

        // 휘핑크림 추가
        coffee = new WhipDecorator(coffee);
        System.out.println(coffee.getDescription() + " 가격: " + coffee.cost() + "원");
    }
}

⭕️ 장점

  • 기존 클래스를 수정하지 않고 기능의 확장이 가능하다.
  • 여러 개의 데코레이터의 조합하여 기능을 유연하게 추가할 수 있다.

❌ 단점

  • 데코레이터가 많아질수록 객체가 여러 개 겹쳐지는 구조가 되어 복잡해질 수 있다.
  • 코드가 다소 복잡해질 수 있다. (단순한 기능 추가에는 상속이 더 이로울 수 있다.)


3. 프록시 패턴 (Proxy Pattern)

실제 객체에 대한 접근을 제어하기 위해 대리 객체를 사용하는 패턴이다.

// 이미지 인터페이스 (공통 인터페이스)
interface Image {
    void display();
}

// 실제 이미지 객체 (비싼 리소스를 사용하는 객체)
class RealImage implements Image {
    private String fileName;

    public RealImage(String fileName) {
        this.fileName = fileName;
        loadFromDisk(); // 파일 로딩 (비용이 큰 작업)
    }

    private void loadFromDisk() {
        System.out.println("디스크에서 " + fileName + " 로딩 중...");
    }

    public void display() {
        System.out.println(fileName + " 화면에 표시됨");
    }
}

// 프록시 객체 (실제 객체에 대한 접근을 지연시키거나 제어)
class ProxyImage implements Image {
    private RealImage realImage; // 실제 이미지 객체
    private String fileName;

    public ProxyImage(String fileName) {
        this.fileName = fileName;
    }

    public void display() {
        if (realImage == null) { // 실제 객체가 필요할 때만 생성 (지연 로딩)
            realImage = new RealImage(fileName);
        }
        realImage.display();
    }
}

// 클라이언트 코드
public class ProxyPatternExample {
    public static void main(String[] args) {
        Image image1 = new ProxyImage("photo1.jpg");

        // 첫 번째 호출 (이미지 로딩 발생)
        image1.display();

        System.out.println("-------------");

        // 두 번째 호출 (이미 로딩된 이미지 사용)
        image1.display();

        System.out.println("-------------");

        // 새로운 이미지 객체 생성
        Image image2 = new ProxyImage("photo2.jpg");
        image2.display();
    }
}

⭕️ 장점

  • 객체 생성을 지연 시킬 수 있다.
    • RealImage는 파일을 디스크에서 로드하는 비용이 큰 작업을 수행하는데, ProxyImage를 사용하면 실제 이미지가 필요할 때만 객체를 생성하여 성능을 최적화 할 수 있다.

❌ 단점

  • 객체 호출이 많은 경우에는 오버헤드가 발생할 수 있다.
    • ProxyImagedisplay()를 호출할 때마다 if (realImage == null)을 검사하는데 이러한 추가적인 연산이 많아질 경우에는 성능에 영향을 줄 수 있다.
  • 다수의 객체를 프록시로 감싸면 코드의 복잡도가 증가할 수 있다. 단순한 객체라면 직접 접근이 더 효율적이다.


4. 퍼사드 패턴 (Facade Pattern)

복잡한 시스템에 대한 간단한 인터페이스를 제공하는 패턴이다.

// 서브 시스템 1: CPU
class CPU {
    void start() {
        System.out.println("CPU가 시작됩니다...");
    }
}

// 서브 시스템 2: 메모리
class Memory {
    void load() {
        System.out.println("메모리가 로드됩니다...");
    }
}

// 서브 시스템 3: 하드 드라이브
class HardDrive {
    void read() {
        System.out.println("하드 드라이브에서 데이터를 읽습니다...");
    }
}

// 퍼사드 클래스 (복잡한 서브 시스템을 단순화)
class ComputerFacade {
    private CPU cpu;
    private Memory memory;
    private HardDrive hardDrive;

    public ComputerFacade() {
        this.cpu = new CPU();
        this.memory = new Memory();
        this.hardDrive = new HardDrive();
    }

    // 클라이언트가 쉽게 사용할 수 있는 단순한 인터페이스 제공
    public void startComputer() {
        System.out.println("컴퓨터 부팅을 시작합니다...");
        cpu.start();
        memory.load();
        hardDrive.read();
        System.out.println("컴퓨터 부팅 완료!");
    }
}

// 클라이언트 코드
public class FacadePatternExample {
    public static void main(String[] args) {
        // 퍼사드 객체를 사용하여 복잡한 과정 없이 컴퓨터를 쉽게 시작
        ComputerFacade computer = new ComputerFacade();
        computer.startComputer();
    }
}

⭕️ 장점

  • 복잡한 서브시스템을 단순화 할 수 있다. 클라이언트는 여러 개의 서브 시스템을 개별적으로 호출할 필요 없이 퍼사드를 통해 간단한 인터페이스로 접근 가능하다.
  • 클라이언트 코드에서 복잡한 내부 로직이 사라지므로 가독성이 좋아진다.
  • 클라이언트가 직접 서브 시스템을 호출하면 서브 시스템이 변경될 때마다 클라이언트 코드의 수정이 필요하지만, 퍼사드를 사용하면 퍼사드 코드만 수정하면 돼서 의존성이 줄고 유지보수성이 높아진다.

❌ 단점

  • 퍼사드 패턴을 과하게 적용하면 모든 서브 시스템마다 퍼사드 클래스를 사용해야 하는 문제가 발생할 수 있다. 따라서, 퍼사드는 특정 기능을 단순화 하는 역할만 수행하여 너무 많은 책임을 갖지 않도록 해야 한다.


5. 브릿지 패턴 (Bridge Pattern)

추상화와 구현을 분리하여 독립적으로 확장할 수 있도록 하는 패턴이다.

// 장치(Device) 인터페이스 (구현 부분)
interface Device {
    void turnOn();
    void turnOff();
}

// TV 클래스 (Device 인터페이스 구현)
class TV implements Device {
    public void turnOn() {
        System.out.println("TV 전원이 켜졌습니다.");
    }

    public void turnOff() {
        System.out.println("TV 전원이 꺼졌습니다.");
    }
}

// 라디오 클래스 (Device 인터페이스 구현, 추가 가능)
class Radio implements Device {
    public void turnOn() {
        System.out.println("라디오 전원이 켜졌습니다.");
    }

    public void turnOff() {
        System.out.println("라디오 전원이 꺼졌습니다.");
    }
}

// 리모컨 추상 클래스 (추상화 부분)
abstract class RemoteControl {
    protected Device device; // 구현체와 연결

    public RemoteControl(Device device) {
        this.device = device;
    }

    abstract void togglePower();
}

// 기본 리모컨 (추상화 확장)
class BasicRemote extends RemoteControl {
    public BasicRemote(Device device) {
        super(device);
    }

    public void togglePower() {
        System.out.println("전원 버튼을 누릅니다.");
        device.turnOn();
    }
}

// 고급 리모컨 (추상화 확장, 추가 가능)
class AdvancedRemote extends RemoteControl {
    public AdvancedRemote(Device device) {
        super(device);
    }

    public void togglePower() {
        System.out.println("전원 버튼을 누릅니다.");
        device.turnOn();
        System.out.println("밝기 조정을 시작합니다.");
    }
}

// 클라이언트 코드
public class BridgePatternExample {
    public static void main(String[] args) {
        // TV와 기본 리모컨 연결
        Device tv = new TV();
        RemoteControl basicRemote = new BasicRemote(tv);
        basicRemote.togglePower();

        System.out.println("----------------");

        // 라디오와 고급 리모컨 연결
        Device radio = new Radio();
        RemoteControl advancedRemote = new AdvancedRemote(radio);
        advancedRemote.togglePower();
    }
}

⭕️ 장점

  • 추상화와 구현을 분리하여 각각의 독립적인 확장이 가능하다.
  • 코드의 유지보수성이 증가한다.
    • 새로운 장치를 추가해도 리모컨(RemoteControl) 부분은 수정할 필요가 없다.
    • 새로운 리모컨을 추가해도 장치(Device) 부분은 수정할 필요가 없다.

❌ 단점

  • 구조가 다소 복잡해질 수 있다. 특히 간단한 문제에는 오버-엔지니어링(Over-engineering)이 될 수도 있다.


6. 컴포지트 패턴 (Composite Pattern)

객체들을 트리 구조로 구성하여 부분-전체 계층을 표현하는 패턴이다.

import java.util.ArrayList;
import java.util.List;

// 공통 인터페이스 (단일 객체와 복합 객체를 동일하게 다룸)
interface Component {
    void show();
}

// Leaf(단일 객체)
class Leaf implements Component {
    private String name;

    public Leaf(String name) {
        this.name = name;
    }

    public void show() {
        System.out.println("Leaf: " + name);
    }
}

// Composite(복합 객체, 다른 Component들을 포함할 수 있음)
class Composite implements Component {
    private String name;
    private List<Component> children = new ArrayList<>();

    public Composite(String name) {
        this.name = name;
    }

    public void add(Component component) {
        children.add(component);
    }

    public void remove(Component component) {
        children.remove(component);
    }

    public void show() {
        System.out.println("Composite: " + name);
        for (Component component : children) {
            component.show();
        }
    }
}

// 클라이언트 코드
public class CompositePatternExample {
    public static void main(String[] args) {
        // 단일 요소(Leaf) 생성
        Leaf leaf1 = new Leaf("Leaf 1");
        Leaf leaf2 = new Leaf("Leaf 2");
        Leaf leaf3 = new Leaf("Leaf 3");

        // 루트 노드 (Composite)
        Composite root = new Composite("Root");
        root.add(leaf1);
        root.add(leaf2);

        // 서브 트리 (Composite)
        Composite subTree = new Composite("SubTree");
        subTree.add(leaf3);

        // 루트에 서브 트리 추가
        root.add(subTree);

        // 트리 구조 출력
        root.show();
    }
}

⭕️ 장점

  • 객체를 트리 구조로 쉽게 표현할 수 있다. 예를 들어 파일 시스템(폴더-파일 관계), 조직 구조(부서-직원 관계) 등을 효과적으로 모델링 할 수 있다.
  • 클라이언트가 단일 객체와 복합 객체를 동일하게 다룰 수 있다.

❌ 단점

  • 트리 구조를 관리하는 코드가 복잡해질 수 있다.
  • 계층 구조가 깊어질수록 관리해야 할 객체 수가 많아져서 메모리 사용량이 증가할 수 있다.


7. 플라이웨이트 패턴 (Flyweight Pattern)

동일한 객체를 공유하여 메모리 사용을 줄이는 패턴이다.

import java.util.HashMap;
import java.util.Map;

// 플라이웨이트 객체 (공유되는 객체)
class Character {
    private final char symbol; // 변경되지 않는 내부 상태 (Intrinsic State)

    public Character(char symbol) {
        this.symbol = symbol;
    }

    public void display(String font) { // 외부 상태 (Extrinsic State)
        System.out.println("Character: " + symbol + ", Font: " + font);
    }
}

// 플라이웨이트 팩토리 (객체를 캐싱하여 재사용)
class CharacterFactory {
    private static final Map<java.lang.Character, Character> characters = new HashMap<>();

    public static Character getCharacter(char symbol) {
        characters.putIfAbsent(symbol, new Character(symbol)); // 이미 존재하는 경우 기존 객체 반환
        return characters.get(symbol);
    }
}

// 클라이언트 코드
public class FlyweightPatternExample {
    public static void main(String[] args) {
        Character a1 = CharacterFactory.getCharacter('A');
        Character a2 = CharacterFactory.getCharacter('A');
        Character b = CharacterFactory.getCharacter('B');

        // 동일한 객체를 공유하므로 a1과 a2는 같은 객체
        a1.display("Arial");
        a2.display("Times New Roman");
        b.display("Verdana");

        System.out.println("a1과 a2는 같은 객체인가? " + (a1 == a2)); // true (공유된 객체)
    }
}

⭕️ 장점

  • 반복적으로 생성되는 객체를 공유하여 메모리 소비를 최소화 할 수 있다.
  • 객체 생성을 최적화 할 수 있어 생성 비용이 큰 객체에서도 효과적이다. (대규모 그래픽 요소, 데이터 베이스 연결 등)

❌ 단점

  • 공유 객체는 내부 상태를 유지하지만, 외부 상태는 클라이언트가 직접 관리하므로 잘못된 외부 상태 공유 시 동시성 문제가 발생할 수 있다.
    • 공유 객체 내부에 외부 상태를 저장할 경우 여러 스레드에서 접근하면 데이터 충돌 가능성이 있다.
  • 일반적인 객체 생성 방식보다 코드가 다소 복잡해질 수 있다.

profile
그래도 해야지 어떡해

0개의 댓글