본문 바로가기
📖 책책책 책을 읽읍시다. 📖/자바 객체 지향의 원리와 이해

Chapter 6. 스프링이 사랑한 디자인 패턴

by 컴쏘 2024. 12. 3.

 

이번에는 디자인 패턴에 대해서 알아보자. 

 

디자인 패턴은 설계 패턴이다. 

  • 실제 개발 현장에서 비즈니스 요구 사항을 프로그래밍으로 처리하면서 만들어진 다양한 해결책 중에서 많은 사람들이 인정한 베스트 프랙티스를 정리한 것이다. 
  • 디자인 패턴은 당연히 객체 지향 특성과 설계 원칙을 기반으로 구현돼 있다. 

 

스프링 프레임워크는 많은 개발자들이 사랑한 개발 프레임워크이다. 

  • 스프링 프레임워크 : 자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크 
  • 책의 필자는 스프링 프레임워크를 OOP 프레임워크라고 표현했다. (객체 지향의 특성과 설계 원칙을 극한까지 적용한 프레임워크이다.)

 

디자인 패턴은 객체 지향의 특성 중 상속(extends), 인터페이스(interface/implements), 합성(객체를 속성으로 사용) 3가지를 이용한다. 

  • 이 3가지 외에는 다른 방식은 없다. 
  • 그러다보니 여러 디자인 패턴이 비슷해 보일 수 있으니 집중해서 살펴보자. 

 

| 어댑터 패턴 - Adapter Pattern

어댑터를 변환하면 변환기라고 할 수 있다. 변환기의 역할서로 다른 두 인터페이스 사이에 통신이 가능하게 하는 것이다. 

 

데이터베이스 관련 프로그램을 작성해봤더라면, 다양한 데이터베이스 시스템을 공통의 인터페이스인 ODBC 또는 JDBC를 이용해 조작할 수 있다는 것을 알 수 있다. 

  • ODBC/JDBC가 어댑터 패턴을 이용다양한 데이터베이스 시스템단일한 인터페이스로 조작할 수 있게 해주기 때문이다. 

 

자바의 플랫폼별 JRE도 어댑터 패턴이다. 

 

 

SOLID에서 개방 폐쇄 원칙을 설명할 때 JDBC, JRE를 예시로 들었다. 

이를 통해서 알 수 있는 것은 어댑터 패턴은 개방 폐쇄 원칙을 활용한 설계 패턴이라는 것이다. 

 

예시를 보자. 책에 나온 예시가 부족한 것 같아서 예시를 만들었다. (by. Chat GPT)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// HDMI 인터페이스
interface HDMI {
    void connectHDMI();
}
 
// HDMI 디스플레이 클래스
class HDMIDisplay implements HDMI {
    @Override
    public void connectHDMI() {
        System.out.println("HDMI 디스플레이에 연결되었습니다.");
    }
}
 
// USB-C 인터페이스
interface USBC {
    void connectUSBC();
}
 
// USB-C 디바이스 클래스
class USBCDevice implements USBC {
    @Override
    public void connectUSBC() {
        System.out.println("USB-C 디바이스에 연결되었습니다.");
    }
}
 
// 어댑터 클래스: USBC -> HDMI
class USBCtoHDMIAdapter implements HDMI {
    private USBC usbCDevice;
 
    // 어댑터 생성자에서 USB-C 디바이스를 받음
    public USBCtoHDMIAdapter(USBC usbCDevice) {
        this.usbCDevice = usbCDevice;
    }
 
    @Override
    public void connectHDMI() {
        // USB-C 디바이스의 메서드를 호출하여 HDMI 연결처럼 동작
        System.out.println("어댑터를 사용하여 USB-C 디바이스를 HDMI 디스플레이에 연결합니다.");
        usbCDevice.connectUSBC();
    }
}
 
// 사용해보기
public class AdapterPatternExample {
    public static void main(String[] args) {
        // HDMI 디스플레이 생성
        HDMI display = new HDMIDisplay();
        display.connectHDMI();
 
        // USB-C 디바이스 생성
        USBC usbCDevice = new USBCDevice();
 
        // 어댑터를 사용하여 USB-C 디바이스를 HDMI 디스플레이에 연결
        HDMI adapter = new USBCtoHDMIAdapter(usbCDevice);
        adapter.connectHDMI();
    }
}
cs
  • USBCtoHDMIAdapter는 USB-C 인터페이스를 HDMI 인터페이스로 변환하여 두 장치를 호환되도록 만든다.
  • 어댑터 패턴(Adapter Pattern)은 호환되지 않는 두 인터페이스를 연결하기 위해 사용한다. 
    • 기존 코드를 수정하지 않고도 새로운 시스템 또는 객체를 기존 시스템과 호환할 수 있다. 

 

어댑터 패턴은 합성을 사용한, 객체를 속성으로 만들어서 참조하는 디자인 패턴이다. 

 

호출 당하는 쪽의 메서드를 호출하는 쪽의 코드에 대응하도록 중간에 변환기를 통해 호출하는 패턴 

 

 

| 프록시 패턴 - Proxy Pattern

프록시는 대리자, 대변인을 뜻한다. 대리자/대변인이라고 하면 다른 누군가를 대신해 그 역할을 수행하는 존재이다. 

 

프록시 패턴의 경우는 실제 서비스가 가진 메서드와 같은 이름의 메서드를 사용하는데, 이를 위해 인터페이스를 사용한다. 

  • 인터페이스를 사용하면 서비스 객체가 들어갈 자리대리자 객체를 대신 투입클라이언트 쪽에서는 실제 서비스 객체를 통해 메서드를 호출하고 반환값을 받는지, 대리자 객체를 통해 메서드를 호출하고 반환 값을 받는지 전혀 모르게 처리할 수도 있다. 

 

이번에도 예시를 만들었다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Subject 인터페이스
interface Image {
    void display();
}
 
// 실제 객체: 고해상도 이미지
class RealImage implements Image {
    private String filename;
 
    // 생성자에서 이미지 로드
    public RealImage(String filename) {
        this.filename = filename;
        loadImage();
    }
 
    // 이미지 로드 메서드
    private void loadImage() {
        System.out.println("고해상도 이미지 [" + filename + "]를 로드 중...");
    }
 
    @Override
    public void display() {
        System.out.println("고해상도 이미지 [" + filename + "]를 화면에 표시합니다.");
    }
}
 
// 프록시 객체: 이미지를 로드하지 않고 대리로 처리
class ProxyImage implements Image {
    private String filename;
    private RealImage realImage;
 
    public ProxyImage(String filename) {
        this.filename = filename;
    }
 
    @Override
    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");
        Image image2 = new ProxyImage("photo2.jpg");
 
        // 처음 호출: 이미지를 로드 후 표시
        image1.display();
        System.out.println();
 
        // 두 번째 호출: 캐싱된 이미지를 바로 표시
        image1.display();
        System.out.println();
 
        // 다른 이미지 호출: 새로운 이미지 로드
        image2.display();
    }
}
 
cs
  • ProxyImage가 RealImage에 대한 접근을 대리한다. 
  • ProxyImage는 실제로 이미지를 로드하지 않으면서 display() 호출만 처리한다.
  • 실제 이미지(RealImage)는 필요한 경우에만 생성한다. 

 

프록시 패턴의 중요 포인트는 다음과 같다. 

  • 대리자는 실제 서비스와 같은 이름의 메서드를 구현한다. 이때 인터페이스를 사용한다. 
  • 대리자는 실제 서비스에 대한 참조 변수를 갖는다. (합성
  • 대리자는 실제 서비스의 같은 이름을 가진 메서드를 호출하고 그 값을 클라이언트에게 돌려준다. 
  • 대리자는 실제 서비스의 메서드 호출 전후에 별도의 로직을 수행할 수도 있다. 

 

대리자는 자신의 입장을 가감하지 않는다. 프록시 패턴도 동일하다. 실제 서비스 메서드의 반환 값에 가감하지 않는다. 

프록시 패턴은 실제 서비스 메서드의 반환 값에 가감하는 것을 목적으로 하지 않고 제어의 흐름을 변경하거나 다른 로직을 수행하기 위해 사용한다. 

 

 

프록시 패턴은 개방 폐쇄 원칙과 의존 역전 법칙과 관련있다. 

 

제어 흐름을 조정하기 위한 목적으로 중간에 대리자를 두는 패턴 

 

 

| 데코레이터 패턴 - Decorator Pattern

데코레이터는 도장/도배업자를 의미한다. 여기서는 장식자라는 뜻을 가지고 봐보자. 

 

데코레이터 패턴원본에 장식을 더하는 패턴이라는 것이 이름에 잘 드러나 있다. 

  • 데코레이터 패턴은 프록시 패턴과 구현 방법이 같다. 
  • 다만, 프록시 패턴은 클라이언트가 최종적으로 돌려 받는 반환값조작하지 않고 그대로 전달하는 반면, 데코레이터는 클라이언트가 받는 반환 값에 장식을 덧입힌다. 
    • 프록시 패턴 : 제어의 흐름을 변경하거나 별도의 로직 처리를 목적으로 한다. 클라이언트가 받는 반환 값을 특별한 경우가 아니라면 변경하지 않는다. 
    • 데코레이터 패턴 : 클라이언트가 받는 반환값에 장식을 더한다. 

 

이번에도 예시를 만들었다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// 공통 인터페이스
interface Coffee {
    String getDescription();
    double getCost();
}
 
// 기본 커피 클래스
class SimpleCoffee implements Coffee {
    @Override
    public String getDescription() {
        return "기본 커피";
    }
 
    @Override
    public double getCost() {
        return 2000.0// 기본 커피 가격
    }
}
 
// 데코레이터 클래스 (추상 클래스)
abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee; // 데코레이터가 감쌀 커피 객체
 
    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }
 
    @Override
    public String getDescription() {
        return coffee.getDescription();
    }
 
    @Override
    public double getCost() {
        return coffee.getCost();
    }
}
 
// 우유 추가 데코레이터
class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }
 
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", 우유";
    }
 
    @Override
    public double getCost() {
        return coffee.getCost() + 500.0// 우유 추가 비용
    }
}
 
// 시럽 추가 데코레이터
class SyrupDecorator extends CoffeeDecorator {
    public SyrupDecorator(Coffee coffee) {
        super(coffee);
    }
 
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", 시럽";
    }
 
    @Override
    public double getCost() {
        return coffee.getCost() + 300.0// 시럽 추가 비용
    }
}
 
// 초콜릿 추가 데코레이터
class ChocolateDecorator extends CoffeeDecorator {
    public ChocolateDecorator(Coffee coffee) {
        super(coffee);
    }
 
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", 초콜릿";
    }
 
    @Override
    public double getCost() {
        return coffee.getCost() + 700.0// 초콜릿 추가 비용
    }
}
 
// 테스트 코드
public class DecoratorPatternExample {
    public static void main(String[] args) {
        // 기본 커피
        Coffee coffee = new SimpleCoffee();
        System.out.println(coffee.getDescription() + " | 가격: " + coffee.getCost());
 
        // 우유 추가
        coffee = new MilkDecorator(coffee);
        System.out.println(coffee.getDescription() + " | 가격: " + coffee.getCost());
 
        // 시럽 추가
        coffee = new SyrupDecorator(coffee);
        System.out.println(coffee.getDescription() + " | 가격: " + coffee.getCost());
 
        // 초콜릿 추가
        coffee = new ChocolateDecorator(coffee);
        System.out.println(coffee.getDescription() + " | 가격: " + coffee.getCost());
    }
}
 
cs
  • Coffee 인터페이스는 기본 객체(SimpleCoffee)와 데코레이터(CoffeeDecorator)가 동일한 메서드를 구현하게 하여 클라이언트가 이 둘을 구분하지 않고 사용할 수 있게 한다. 
  • CoffeeDecorator는 추상 클래스이며, Coffee 인터페이스를 구현한다.
    • CoffeeDecorator는 Coffee 객체를 감쌉니다. (합성을 활용)
    • 모든 메서드는 기본적으로 내부의 Coffee 객체에게 위임됩니다.
  • 이를 통해 다양한 데코레이터를 체인처럼 연결하여 동적으로 기능을 추가할 수 있습니다.
  • CoffeeDecorator를 상속받아 추가 기능(우유, 시럽, 초콜릿 추가)을 구현한다. 
  • 내부적으로 감싸고 있는 coffee 객체의 기존 기능을 호출한 후, 새로운 기능(추가 설명과 가격)을 더한다.

 

데코레이터 패턴의 중요 포인트를 짚어보자. 반환값에 장식을 더한다는 것을 빼면 프록시 패턴과 동일하다. 

  • 장식자는 실제 서비스와 같은 이름의 메서드를 구현한다. 이때 인터페이스를 사용한다. 
  • 장식자는 실제 서비스에 대한 참조 변수를 갖는다. (합성) 
  • 장식자는 실제 서비스의 같은 이름을 가진 메서드를 호출하고, 그 반환 값에 장식을 더해 클라이언트에게 돌려준다. 
  • 장식자는 실제 서비스의 메서드 호출 전후에 별도의 로직을 수행할 수도 있다. 

 

데코레이터 패턴이 프록시 패턴과 동일한 구조를 갖기에 데코레이터 패턴도 개방 폐쇄 원칙과 의존 역전 원칙이 적용된 설계 패턴이다. 

 

메서드 호출의 반환값에 변화를 주기 위해 중간에 장식자를 두는 패턴