Computer Science/디자인 패턴

디자인 패턴 - 데코레이터 패턴(Decorator pattern) 자바 코드 예시로 보기

sh1mj1 2023. 9. 20. 17:46

데코레이터 패턴이란?

 

데코레이터 패턴은 객체지향 프로그래밍에서 자주 사용되는 디자인 패턴입니다.

 

어떤 객체를 특수한 Wrapper 객체들 내에 감싸서(wrap, decorate) 새로운 행동을 가지도록 하는 것입니다.

 

이렇게만 설명하면 크게 감이 안 올 수 있는데요.

 

먼저 구체적인 프로그램을 만든다고 가정해서 이 프로그램에 데코레이터 패턴을 적용해 나가는 것으로 설명하겠습니다.

 

문제 상황

우리는 사용자들에게 알림을 보내는 `Notifier` 라이브러리를 만들고 있다고 합시다.

 

`Notifier` 는 사용자들에게 중요한 이벤트에 대해 이메일로 알림을 보내줄 수 있는 기능을 가집니다.

 

https://refactoring.guru/ko/design-patterns/decorator 에서 이미지를 가져옴.

 

클라이언트 역할을 하는 타사 이메일 애플리케이션은 `Notifier` 객체를 한 번 생성하고, 설정한 후에 중요한 이벤트가 생길 때마다 이 객체를 사용하게 됩니다.

 

그런데 사용자들은 이메일 알림보다 더 많은 알림을 받고 싶어 한다는 요구사항이 들어왔습니다.

 

어떤 사용자는 SMS 문자로, 다른 사용자들은 페이스북, 혹은 슬랙으로 알림을 받고 싶어한다고 합니다.

 

그렇다면 Notifier 클래스를 확장해서 추가 알림 메서드들을 자식 클래스에 넣어서 타입 계층을 만들 수 있겠네요.

 

 

그런데 또 요구사항이 추가되었습니다. 

 

  • SMS 와 Facebook 으로 알림을 받고 싶어하는 사람
  • SMS 와 Slack 으로 알림을 받고 싶어하는 사람
  • SMS 와 Facebook 과 Slack 으로 알림을 받고 싶어하는 사람
  • ....

 

이런 식으로 많은 알림 메서드를 받고 싶어하는 사람들이 늘어났습니다.

 

 

이렇게 되면 알림 클래스들의 수가 지나치게 많아지게 됩니다.. 이렇게 기존의 상속을 이용하는 방법으로 이것을 구현하는 것은 좋은 방법이 아닌 것 같다고 생각이 드네요

 

해결책

객체의 동작을 수정하고, 또 추가할 때 위에서는 상속을 이용했습니다. 하지만 상속에는 문제가 많습니다.

 

  • 상속은 정적이기 때문에 객체를 다른 자식 클래스의 객체로만 바꿀 수 있으며 런타임 때 기존 객체의 행동을 변경할 수 없다.
  • 자식 클래스는 오직 하나의 부모 클래스만 가질 수 있다.
  • 상속은 취약한 기반 클래스 문제를 가진다.

 

상속 대신에 집합 관계(약한 has-a 관계) 혹은 합성 관계(강한 has-a 관계)를 사용하는 것이 더 나을 것 같습니다.

 

집합이나 합성 관계를 사용하면 연결된 Helper(도우미) 객체를 다른 객체로 쉽게 대체해서 런타임 때 컨테이너의 행동을 변경할 수 있습니다.

 

이렇게 되면 객체는 여러 클래스의 행동을 사용할 수도 있고, 여러 객체들에 대한 참조를 가질 수 있으며, 행동들을 위임할 수 있습니다.

 

래퍼(Wrapper)

먼저 래퍼(Wrapper)라는 말을 알아두고 지나갑시다.

 

'래퍼(Wrapper)' 는 여기서 데코레이터 패턴의 주요 동작과 아이디어를 더 강조하는 측면에서 데코레이터 패턴을 부르는 말입니다.

 

래퍼는 대상 객체와 연결할 수 있는 객체를 말하며, 대상 객체와 같은 메서드들을 가집니다.

 

또 래퍼는 자신이 받는 모든 요청을 대상 객체에게 위임하지만, 요청을 대상에게 전달하기 전/후에 추가적인 동작을 수행해서 결과를 변경할 수 있습니다.

 

래퍼가 래핑된 객체와 같은 인터페이스를 구현하므로 클라이언트는 이런 모든 객체들을 같게 봅니다.

래퍼의 필드를 인터페이스 타입으로 하여 그 인터페이스의 하위 계층인 모든 객체를 받을 수 있도록 하면 여러 래퍼로 객체를 포장했을 때 모든 래퍼들의 행동들이 객체에 추가될 수 있습니다.

 

그림을 보면 더 이해하기 쉬울 텐데요. 위 그림에서 `AaaBbbCccd` 는 `abcd`, `abc`, `ab`, `a` 기능을 모두 가지고 있습니다!!

 

이제 다시 기초 `Notifier` 클래스 내에 있는 `send(message)` 메서드는 그대로 두고, 다른 모든 알림 메서드를 데코레이터로 바꾸어 봅시다.

 

이제 다양한 알림 메서드들이 데코레이터가 되었습니다.

 

클라이언트 코드는 기초 `Notifier` 객체를 사용자들의 요구사항들에 맞는 데코레이터들의 집합으로 래핑해야 합니다.

 

결과적으로 객체들은 스택으로 구성됩니다. 

 

 

데코레이터는 실제로 클라이언트와 작업하는 객체입니다.

 

그리고 모든 데코레이터는 기초 Notifier 와 같은 인터페이스를 구현합니다.

 

즉, 클라이언트 코드는 기초 `Notifier` 객체와 협력하는지, 데코레이터로 감싸진 객체와 협력하는지 상관하지 않습니다. 

 

클라이언트는 단지 메시지를 보낼 뿐이죠.

 

 

 

 

데코레이터 패턴의 일반적인 구조

약간 헷갈릴 수 있는데 여기서 2. `ConcreteComponent` 는 래핑되는 것, 3. `BaseDecorator` 는 래핑하는 것입니다. 

 

둘 모두 `Component` 를 구현한다는 것에 주목하면서 위 구조를 몇 번 자세히 보면 이해할 수 있을 것입니다.

 

데코레이터 패턴의 구현 예시 - 자바

데코레이터는 자바 코드에서, 특히 스트림에서 많이 사용합니다. 

예를 들면

  • java.io.InputStream, OutputStream, Reader, Writer
  • java.util.Collections 과 그 메서드들
  • java.servlet.http.HttpServeltRequestWrapper 와 HttpServeletResponseWrapper 

등이 있습니다.

 

민감한 데이터를 암호화, 복호화하고 압축, 압축풀기하는 데코레이터의 예시를 자바로 구현해봅시다.

먼저 클래스 구조는 아래와 같습니다.

동작은

데이터가 디스크에 기록되기 전에 데코레이터들이 데이터를 암호화, 압축하는 동작과

그리고 데이터는 디스크에서 읽힌 직후에 같은 데코레이터들을 거쳐서 데이터 압축을 풀고, 복호화하는 동작입니다.

 

`DataSource.java` : 읽기, 쓰기 작업을 정의하는 공통 인터페이스

public interface DataSource {
    void writeData(String data);

    String readData();
}

 

`FileDataSource.java`: 간단한 데이터 Reader/Writer

이 클래스에서는 일반 텍스트로만 데이터를 읽고 쓸 수 있음.

public class FileDataSource implements DataSource {
    private String name;

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

    @Override
    public void writeData(String data) {
        File file = new File(name);
        try (OutputStream fos = new FileOutputStream(file)) {
            fos.write(data.getBytes(), 0, data.length());
        } catch (IOException ex) {
            System.out.println(ex.getMessage());
        }
    }

    @Override
    public String readData() {
        char[] buffer = null;
        File file = new File(name);
        try (FileReader reader = new FileReader(file)) {
            buffer = new char[(int) file.length()];
            reader.read(buffer);
        } catch (IOException ex) {
            System.out.println(ex.getMessage());
        }
        return new String(buffer);
    }
}

 

`DataSourceDecorator.java`: 기초 데코레이터. 

public class DataSourceDecorator implements DataSource {
    private DataSource wrappee;

    DataSourceDecorator(DataSource source) {
        this.wrappee = source;
    }

    @Override
    public void writeData(String data) {
        wrappee.writeData(data);
    }

    @Override
    public String readData() {
        return wrappee.readData();
    }
}

 

`EncryptionDecorator.java`: 암호화 데코레이터

public class EncryptionDecorator extends DataSourceDecorator {

    public EncryptionDecorator(DataSource source) {
        super(source);
    }

    @Override
    public void writeData(String data) {
        super.writeData(encode(data));
    }

    @Override
    public String readData() {
        return decode(super.readData());
    }

    private String encode(String data) {
        // 암호화 과정
        byte[] result = data.getBytes();
        for (int i = 0; i < result.length; i++) {
            result[i] += (byte) 1;
        }
        return Base64.getEncoder().encodeToString(result);
    }

    private String decode(String data) {
        // 복호화하는 과정.
        byte[] result = Base64.getDecoder().decode(data);
        for (int i = 0; i < result.length; i++) {
            result[i] -= (byte) 1;
        }
        return new String(result);
    }
}

여기서 `writeData` 와 `readData` 메서드 바디에

`super.writeData(encode(data));` 와 `return decode(super.readData());` 를 주목해서 보면됩니다.

 

데코레이터가 요청을 대상에게 전달하기 전/후에 추가적인 동작을 수행해서 결과를 변경하는 모습을 볼 수 있네요.

 

`CompressionDecorator.java`: 압축 데코레이터

public class CompressionDecorator extends DataSourceDecorator {
    private int compLevel = 6;

    public CompressionDecorator(DataSource source) {
        super(source);
    }

    public int getCompressionLevel() {
        return compLevel;
    }

    public void setCompressionLevel(int value) {
        compLevel = value;
    }

    @Override
    public void writeData(String data) {
        super.writeData(compress(data));
    }

    @Override
    public String readData() {
        return decompress(super.readData());
    }

    private String compress(String stringData) {
        // 압축 과정
        byte[] data = stringData.getBytes();
        try {
            ByteArrayOutputStream bout = new ByteArrayOutputStream(512);
            DeflaterOutputStream dos = new DeflaterOutputStream(bout, new Deflater(compLevel));
            dos.write(data);
            dos.close();
            bout.close();
            return Base64.getEncoder().encodeToString(bout.toByteArray());
        } catch (IOException ex) {
            return null;
        }
    }

    private String decompress(String stringData) {
        // 압축 풀기 과정
        byte[] data = Base64.getDecoder().decode(stringData);
        try {
            InputStream in = new ByteArrayInputStream(data);
            InflaterInputStream iin = new InflaterInputStream(in);
            ByteArrayOutputStream bout = new ByteArrayOutputStream(512);
            int b;
            while ((b = iin.read()) != -1) {
                bout.write(b);
            }
            in.close();
            iin.close();
            bout.close();
            return new String(bout.toByteArray());
        } catch (IOException ex) {
            return null;
        }
    }
}

여기서도 `writeData` 와 `readData` 메서드에서

`super.writeData(compress(data));` 와 `return decompress(super.readData());` 가 수행되고 있습니다.

 

역시 데코레이터가 요청을 대상에게 전달하기 전/후에 추가적인 동작을 수행해서 결과를 변경하는 모습을 볼 수 있습니다.

 

`Demo.java`: 클라이언트 코드

public class Demo {
    public static void main(String[] args) {
        String salaryRecords = "Name,Salary\nJohn Smith,100000\nSteven Jobs,912000";

        // 파일에 대해 쓸 때 암호화를 하고, 압축을 하는 방식. (읽을 때 압축을 풀고 복호화를 하는 방식)
        DataSourceDecorator encoded = new CompressionDecorator(
                                         new EncryptionDecorator(
                                             new FileDataSource("out/OutputDemo.txt")));
        encoded.writeData(salaryRecords);
        DataSource plain = new FileDataSource("out/OutputDemo.txt");

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

        System.out.println("- Encoded --------------");
        System.out.println(plain.readData());
        System.out.println();

        System.out.println("- Decoded --------------");
        System.out.println(encoded.readData());
    }
}

 


실행 결과

Input ----------------
Name,Salary
John Smith,100000
Steven Jobs,912000

- Encoded --------------
Zkt7e1Q5eU8yUm1Qe0ZsdHJ2VXp6dDBKVnhrUHtUe0sxRUYxQkJIdjVLTVZ0dVI5Q2IwOXFISmVUMU5rcENCQmdxRlByaD4+

- Decoded --------------
Name,Salary
John Smith,100000
Steven Jobs,912000

실행 결과를 봅시다.

 

먼저 문자열을 세 가지 데코레이터 객체 encoded 를 사용하여 암호화한 후에 압축하여 `OutputDemo.txt` 라는 파일에 써서 저장했습니다.

 

그 파일을 단순히 파일을 텍스트로만 읽는 `FileDataSource` 객체 plain 으로 읽었을 때는 암호화되고 압축되어있는 정체불명의 텍스트가 읽혔습니다.

 

반면에 그 파일을 다시 압축을 풀고, 복호화하고 읽는 encoded 로 다시 읽었을 때는 기존의 정상적인 텍스트가 읽히는 것을 알 수 있습니다.

 

데코레이터 패턴의 적용

그렇다면 데코레이터 패턴은 언제 사용해야 할까요?

 

데코레이터는 비즈니스 로직을 계층으로 구성하고, 각 계층에 데코레이터를 생성해서 런타임에 이 로직의 다양한 조합으로 객체를 구성할 수 있습니다.

객체들을 사용하는 코드를 훼손하지 않으면서 런타임에 추가 행동들을 객체들에 할당할 수 있어야할 때 사용하면 좋습니다!!

 

이 패턴은 상속을 사용해서 객체의 행동을 확장하는 것이 어색하거나 불가능할 때 사용하세요.

특히 `final` 클래스의 경우 기존 행동들을 재사용할 수 있는 유일한 방법이 데코레이터 패턴을 사용하여 클래스를 래핑하는 것입니다.

 

장단점

👍 장점

  • 새 자식 클래스를 만들지 않고도 객체의 행동을 확장할 수 있음.
  • 런타임에 객체들에서부터 책임을 추가하거나 제거할 수 있음.
  • 객체를 여러 데코레이터로 래핑해서 여러 행동을 합성할 수 있음.
  • 단일 책임 원칙을 준수하기 좋음. 

 

👎 단점

  • 기능 추가, 수정을 위해 많은 수의 데코레이터 클래스를 생성하면 계층 구조가 복잡해질 수 있음.
  • 데코레이터 패턴을 과도하게 사용하면 인터페이스가 지나치게 많은 메서드를 가지게 될 수 있음.
  • 데코레이터 패턴을 계속해서 쌓아올리면 너무 긴 연쇄구조가 형성되어 코드의 가독성을 저하시킬 수 있음.