Developer.

[멋사 백엔드 19기] TIL 16일차 GoF 디자인 패턴(생성, 구조)

📂 목차


📚 본문

GoF 디자인 패턴

정처기에서 정리를 다 했지만, 다시 본다. 글 보다는 이번에는 코드 위주로 설명한다.

생성 패턴

객체를 생성할 때 마주할 수 있는 프로그래밍 문제를 해결할 솔루션이며
객체 생성 과정을 캡슐화해서 유연하고 재사용 가능한 객체 생성을 돕는 패턴이다.

Singleton
 
public class Example {
    private static Example instance;

    private Example() { /* 생성은 감추고 */ }

    // synchronized 를 쓰는건 때에 따라서
    public synchronized static Example getInstance() {
        instance = Objects.requiredNonNullElseGet(instance, () ->
            new Example()
        );
    }
}
Factory
interface Phone {
    void call();
}

class IPhone implements Phone {
    void call() {
        System.out.println("아이폰 짱");
    }
}

class Galaxy implements Phone {
    void call() {
        System.out.println("갤럭시 짱");
    }
}

public interface PhoneFactory {
    // 어떤 제품이 나올지 모르니 공통적인 제품에 대해
    // 추상화를 반환하도록
    Phone createPhone();
}

// 서브 클래스가 이를 구현하여 다양한 Factory 구현
public GalaxyFactory implements PhoneFactory {
    public Phone createPhone() {
        return new Galaxy();
    }
}

public IPhoneFactory implements IPhoneFactory{
    public Phone createPhone() {
        return new IPhone();
    }
}
Builder
public class Person {
    // 전부 private final 하게
    private final long id;
    private final String name;
    private final int height;
    private final int weight;
    private final String phoneNumber;
    
    // 생성자를 encapsulation
    private Person(long id, String name, int height, int weight, String phoneNumber) {
        this.id = id;
        this.name = name;
        this.height = height;
        this.weight = weight;
        this.phoneNumber = phoneNumber;
    }

    // Getter 만 구현

    public static class Builder {
        private long id;
        private String name;
        private int height;
        private int weight;
        private String phoneNumber;

        // 기본 생성자로

        // Lombok 에서는 set 없이 id() 로 가져가기는 한다.
        public Builder id(long id) {
            this.id = id;
        }

        // ... 이하 setter 생략

        public Person build() {
            return new Person (id, name, height, weight, phoneNumber);
        }
    }
}
Prototype
public class Rectangle implements Cloneable {
    private int height;
    private int weight;

    public Rectangle(int height, int weight) {
        this.height = height;
        this.weight = weight;
    }

    // 생성자로 복제하기
    public Rectangle(Rectangle rectangle) {
        this.height = rectangle.height;
        this.weight = rectangle.weight;
    }

    // 메서드로 복제하기 (Clonable 구현)
    @Override
    public Rectangle clone() {
        // 깊은 복사 로직도 필요 시 추가
        // 상위 구현체에 cloneable 하다면 해당 메서드를 써도 됨
        // 단, 캐스팅을 할지 말지는 선택
        return new Rectangle(this);
    }
}
Abstract Factory

다수의 부품들을 유연하게 제작하고 싶을때 가져가는 패턴이다.

// 1️⃣ Product 인터페이스
interface Chair {
    void sitOn();
}

interface Sofa {
    void lieOn();
}

// 2️⃣ ConcreteProduct 클래스
class ModernChair implements Chair {
    @Override
    public void sitOn() {
        System.out.println("앉는 중: 모던 체어");
    }
}

class ModernSofa implements Sofa {
    @Override
    public void lieOn() {
        System.out.println("누워보는 중: 모던 소파");
    }
}

class VictorianChair implements Chair {
    @Override
    public void sitOn() {
        System.out.println("앉는 중: 빅토리안 체어");
    }
}

class VictorianSofa implements Sofa {
    @Override
    public void lieOn() {
        System.out.println("누워보는 중: 빅토리안 소파");
    }
}

// 3️⃣ AbstractFactory
interface FurnitureFactory {
    Chair createChair();
    Sofa createSofa();
}

위처럼 쓰면 FurnitureFactory 의 하위로 다수의 다양한 가구들을 만들어내는
팩토리들을 생성시킬 수 있을 것이다.

구조 패턴

클래스와 객체를 조합하여 더 큰 구조를 만들 때 사용하는 패턴이다.

Adapter

호환되지 않는 인터페이스를 맞춰주고 싶을 때 사용하는 패턴이다.

// 기존 클래스 1
class KakaoRemote {
    public void turnOn() {
        System.out.println("카카오 TV ON");
    }

    public void turnOff() {
        System.out.println("카카오 TV OFF");
    }
}

// 기존 클래스 2
class NaverRemote {
    public void on() {
        System.out.println("네이버 TV ON");
    }

    public void off() {
        System.out.println("네이버 TV OFF");
    }
}

위 두 인터페이스는 기능은 동일하되 인터페이스는 맞지 않으므로 인터페이스를 하나 더 추가하여 위 API를 통일시켜준다.

interface Remote {
    void on();
    void off();
}

// Adapter: KakaoRemote를 Remote 인터페이스로 변환
// has-a 로 느슨하게 연결
class KakaoRemoteAdapter implements Remote {
    private final KakaoRemote kakaoRemote;

    public KakaoRemoteAdapter(KakaoRemote kakaoRemote) {
        this.kakaoRemote = kakaoRemote;
    }

    @Override
    public void on() {
        kakaoRemote.turnOn();
    }

    @Override
    public void off() {
        kakaoRemote.turnOff();
    }
}

// Adapter: NaverRemote를 Remote 인터페이스로 변환
// has-a 로 느슨하게 연결
class NaverRemoteAdapter implements Remote {
    private final NaverRemote naverRemote;

    public NaverRemoteAdapter(NaverRemote naverRemote) {
        this.naverRemote = naverRemote;
    }

    @Override
    public void on() {
        naverRemote.on();
    }

    @Override
    public void off() {
        naverRemote.off();
    }
}

// Client
public class Main {
    public static void main(String[] args) {
        Remote kakao = new KakaoRemoteAdapter(new KakaoRemote());
        Remote naver = new NaverRemoteAdapter(new NaverRemote());

        kakao.on();   // "카카오 TV ON"
        kakao.off();  // "카카오 TV OFF"

        naver.on();   // "네이버 TV ON"
        naver.off();  // "네이버 TV OFF"
    }
}
Bridge

실체과 실행을 분리시켜 독립적으로 확장시키도록 하는 패턴이다.

여기서 실행은 어떤 것이든 될 수 있다. 어쨋든 분리시키는게 중요한 것이다.

interface Renderer {
    void renderCircle(float x, float y, float radius);
}

class VectorRenderer implements Renderer {
    @Override
    public void renderCircle(float x, float y, float radius) {
        System.out.println("Vector 방식으로 원 그리기 at (" + x + "," + y + ") radius " + radius);
    }
}

class RasterRenderer implements Renderer {
    @Override
    public void renderCircle(float x, float y, float radius) {
        System.out.println("Raster 방식으로 원 그리기 at (" + x + "," + y + ") radius " + radius);
    }
}

Renderer 는 실행하는 인터페이스 이다. 즉 특정 행위를 하는 인터페이스를 정의하고,
특정 행위를 하는 구현체를 VectorRenderer, RasterRenderer 로 가져가고 있다.

여기서 이 행위의 중심에 있는, 행위를 하는 주체가 없다. 즉, 실체가 없다는 뜻이다.

해당 실체를 Renderer 을 has-a 로 가지게 하여서 구현하면 되겠다.

abstract class Shape {
    protected Renderer renderer;

    protected Shape(Renderer renderer) {
        this.renderer = renderer;
    }

    abstract void draw();
    abstract void resize(float factor);
}

class Circle extends Shape {
    private float x, y, radius;

    public Circle(Renderer renderer, float x, float y, float radius) {
        super(renderer);
        this.x = x;
        this.y = y;
        this.radius = radius;
    }

    @Override
    void draw() {
        renderer.renderCircle(x, y, radius);
    }

    @Override
    void resize(float factor) {
        radius *= factor;
    }
}

public class Main {
    public static void main(String[] args) {
        Renderer vector = new VectorRenderer();
        Renderer raster = new RasterRenderer();

        Circle circle1 = new Circle(vector, 5, 5, 10);
        Circle circle2 = new Circle(raster, 10, 10, 20);

        circle1.draw(); // Vector 방식
        circle2.draw(); // Raster 방식

        circle1.resize(2);
        circle1.draw(); // Vector 방식, radius 20
    }
}
Composite

말 그대로 그냥 객체를 조립하여서 부분-전체 관계로 표현하는 것이다.

Application에 DI 를 할 때 해당 instance 들을 다 들고 있는 클래스를 생각해보자.

이는 쉬우니 그냥 넘어간다.

Decorator

이는 Java IO 로 많이 보았을 것이다.

객체에 새로운 책임을 추가할 수 있는 패턴이며, 상속 대신 구성(has-a 관계) 를 사용하여 기능을 확장한다.

  • Interface Component
  • Concrete Component
  • Decorator Class
  • Decorator Concrete Class

Interface Component

// Component
interface Coffee {
    String getDescription();
    int cost();
}

기본 뼈대를 정의하고, Concrete Component 실체를 구현한다.

Concrete Component

class BasicCoffee implements Coffee {
    @Override
    public String getDescription() {
        return "기본 커피";
    }

    @Override
    public int cost() {
        return 2000;
    }
}

여기까지는 일반적인 클래스 설계 및 구현과 다를 바가 없다. 하지만 그 이후 Decorator(추상 클래스) 를 통해

  1. Component 를 상속하여 내부에 Concrete Component 를 두고,
  2. 해당 기능들을 전부 유지시키며 Overriding 을 한다.

Decorator

abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee;

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

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

    @Override
    public int cost() {
        return coffee.cost();
    }
}

여기서 이 추상 클래스의 하위 클래스도 base object(Concrete component) 를 사용할 수 있도록 protected 로 구성해야 한다.

그 이후는 추가하고 싶은 기능들을 Decorator 를 통해 다양한 기능을 가진 클래스들로 확장해나가면 된다.

class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }

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

    @Override
    public int cost() {
        return coffee.cost() + 500;
    }
}

class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }

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

    @Override
    public int cost() {
        return coffee.cost() + 200;
    }
}
Facade

복잡한 서브시스템을 단순화된 인터페이스로 제공하기 위해(여러 라이브러리를 하나의 API로 통합하기 위해) 사용된다.

Adapter 랑 다른 점은 Adapter 는 유사한 기능을 가진 클래스 끼리 메서드 시그니처가 다를 때 사용하고,
Facade 는 다수의 다양한 기능들과 복합적이고 복잡한 인터페이스들을 제공하는 시스템에 대해 여러 객체를 단일 접근점을 통해
단순하게 접근할 수 있도록 사용하는 것이다. 즉 Facade 는 종합 리모컨 느낌이면 Adapter 는 연결만 시켜주는 애다.

class TV {
    public void on() { System.out.println("TV 켜기"); }
    public void off() { System.out.println("TV 끄기"); }
}

class SoundSystem {
    public void on() { System.out.println("사운드 시스템 켜기"); }
    public void off() { System.out.println("사운드 시스템 끄기"); }
}

class DVDPlayer {
    public void on() { System.out.println("DVD 플레이어 켜기"); }
    public void off() { System.out.println("DVD 플레이어 끄기"); }
    public void play(String movie) { System.out.println(movie + " 재생"); }
}

다수의 기기들이 다양한 기능들을 제공함을 볼 수 있지만 각각 메서드 시그니처가 다를 수도, 같을 수도 있다.

Facade

class HomeTheaterFacade {
    private final TV tv;
    private final SoundSystem sound;
    private final DVDPlayer dvd;

    public HomeTheaterFacade(TV tv, SoundSystem sound, DVDPlayer dvd) {
        this.tv = tv;
        this.sound = sound;
        this.dvd = dvd;
    }

    public void watchMovie(String movie) {
        tv.on();
        sound.on();
        dvd.on();
        dvd.play(movie);
        System.out.println("영화 시작 준비 완료!");
    }

    public void endMovie() {
        dvd.off();
        sound.off();
        tv.off();
        System.out.println("영화 종료!");
    }
}

Facade 를 두어 모든 기능들을 조합하여 다양한 기능들을 생성해낼 수 있다.

Proxy

실체 객체에 접근하기 전에 대리 객체를 두어 제어한다. 목표하는 기능으로는 다음과 같다:

  • 접근 제어
  • 지연 로딩
  • 로깅, 캐싱

구조는 Subject(inteface), RealSubject, Proxy(RealSubject 를 감싸고 접근 제어) 로 가져간다.

Lazy Proxy

필요할 때만 초기화를 수행하도록 하여 메모리를 최적화 할 수 있다.

// Subject
interface Image {
    void display();
}

// RealSubject
class RealImage implements Image {
    private String filename;

    public RealImage(String filename) {
        this.filename = filename;
        loadFromDisk();
    }

    private void loadFromDisk() {
        System.out.println("이미지 로딩: " + filename);
    }

    @Override
    public void display() {
        System.out.println("이미지 표시: " + filename);
    }
}
// 아직 생성되지 않은 상태

// Proxy 를 통해 has-a 관계로 RealImage 를 부르기 전까지는 초기화 X
class ProxyImage implements Image {
    private RealImage realImage;
    private String filename;

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

    @Override
    public void display() {
        if (realImage == null) { // Lazy initialization
            realImage = new RealImage(filename);
        }
        System.out.println("프록시를 통해 접근 중...");
        realImage.display();
    }
}

Protection Proxy

사용자의 권한에 따라 접근을 제어한다.

class ProtectedImageProxy implements Image {
    private RealImage realImage;
    private String filename;
    private String userRole;

    public ProtectedImageProxy(String filename, String userRole) {
        this.filename = filename;
        this.userRole = userRole;
    }

    @Override
    public void display() {
        if ("ADMIN".equals(userRole)) {
            if (realImage == null) realImage = new RealImage(filename);
            realImage.display();
        } else {
            System.out.println("권한 없음: 이미지 접근 불가");
        }
    }
}

Virtual Proxy

필요한 기능만을 가져오고 싶을 때 사용한다. 이때는 Subject 가 분리가 잘 되어 있어야 한다.

// Subject
interface Image {
    void display();
}

// RealSubject
class RealImage implements Image {
    private String filename;

    public RealImage(String filename) {
        this.filename = filename;
        loadFromDisk();
    }

    private void loadFromDisk() {
        System.out.println("원본 이미지 로딩: " + filename);
    }

    @Override
    public void display() {
        System.out.println("이미지 표시: " + filename);
    }
}

// Virtual Proxy
class VirtualImageProxy implements Image {
    private RealImage realImage;
    private String filename;
    private boolean showThumbnailOnly;

    public VirtualImageProxy(String filename, boolean showThumbnailOnly) {
        this.filename = filename;
        this.showThumbnailOnly = showThumbnailOnly;
    }

    @Override
    public void display() {
        if (showThumbnailOnly) {
            System.out.println("썸네일 표시: " + filename);
        } else {
            if (realImage == null) {
                realImage = new RealImage(filename);
            }
            realImage.display();
        }
    }
}

Smart Proxy

실 객체에 접근할 때 추가 기능을 제공하는 프록시이며, 캐싱 기능, 로깅 기능 등등을 예로 들 수 있겠다.

interface Image {
    void display();
}

// 실제 객체
class RealImage implements Image {
    private final String filename;

    public RealImage(String filename) {
        this.filename = filename;
        loadFromDisk();
    }

    private void loadFromDisk() {
        System.out.println("이미지 로딩: " + filename);
    }

    @Override
    public void display() {
        System.out.println("이미지 표시: " + filename);
    }
}

// Smart Proxy
class SmartImageProxy implements Image {
    private RealImage realImage;
    private final String filename;
    private boolean cached = false;

    public SmartImageProxy(String filename) {
        this.filename = filename;
    }

    @Override
    public void display() {
        System.out.println("[Smart Proxy] display 호출 기록");
        if (!cached) {
            realImage = new RealImage(filename);
            cached = true;
        } else {
            System.out.println("[Smart Proxy] 캐시된 이미지 사용");
        }
        realImage.display();
    }
}

행위 패턴은 다음 장에서..