[디자인 패턴] Proxy, Decorator
디자인 패턴이 너무 어려워서 정리하기 위한 포스팅 / 개인 기록용
프록시 패턴, 데코레이터 패턴은 객체의 어떤 행동에 추가적인 행동을 추가하고 싶을 때 사용한다.
그런데 추가적인 행동을 추가하는데 객체를 수정하지 않는다는 것이 특징이다.
Proxy Pattern
프록시 패턴은 Proxy 개념을 사용하는 패턴이다.
Spring을 사용하는 개발자라면 종종 들을 수 있다. 특히 @Transaction은 AOP로 동작하는데 이 AOP는 Proxy로 동작하기 때문이다.
그래서 프록시가 뭐냐면 대리인, 중재다이다. A가 B에게 어떠한 작업을 요청 해야한다고 가정하자. 여기서 프록시 개념이 사용된다면
A는 B에게 직접 요청을 하지 않고 중간 대리인 C에게 요청을 한다. C는 A의 요청을 받으면 B에게 요청을 전달한다.
그런데 C가 중간에서 C가 B에게 요청을 전달하기 전에 특정한 작업을 추가할 수 있다. 즉 B를 수정하지 않고 특정 행동을 추가할 수 있다.
프록시 패턴은 일반적으로 인터페이스로 행위를 추상화한다.
그리고 프록시 역할을 하는 객체, 실제 행위를 처리하는 객체는 각각 추상화 된 인터페이스를 구현한다.
또한 프록시 객체는 실제 행위를 처리하는 객체를 필드로 가진다.
클라이언트는 프록시 객체를 사용하여 요청한다. 요청을 받은 프록시는 추가적인 과정(checkAccess)를 수행한뒤 실제
행위를 처리하는 객체에게 작업을 위임한다. 글과 그림으로 봐도 이해는 쉽게 되지 않는 것이 디자인 패턴이기에 직접 코드로 확인하자.
interface DocumentReader {
fun read(): String
}
class DocumentReaderProxy(
private val documentReader: DocumentReader,
) : DocumentReader {
private var cachingData: String = ""
override fun read(): String {
if (cachingData.isEmpty()) {
cachingData = documentReader.read()
return cachingData
}
return cachingData
}
}
class BeautifulDocumentReader() : DocumentReader {
override fun read(): String {
println("use BeautifulDocumentReader")
return "Beautiful Document"
}
}
먼저 문서를 읽는 DocumentReader를 인터페이스로 정의한다.
그리고 프록시인 DocumentReaderProxy를 생성하였다. 이 프록시는 내부적으로 캐싱 필드를 가지고 있다.
따라서 만약 캐싱 데이터가 존재한다면 캐싱 데이터를 반환하고, 캐싱 데이터가 존재하지 않으면 실제 DocumentReader 구현체를 통해 데이터를 조회하고 캐싱한다.
그다음으로 DocumentReader를 실질적으로 처리하는 구현체인 BeautifulDocumentReader를 생성하였다.
Reader는 실제 DB로부터 데이터를 조회할 수도 있고 파일로부터 데이터를 조회할 수 있으나 여기서는 그냥 간단히 문자열을 반환한다.
class DesignPattern {
@Test
fun client() {
val documentReader: DocumentReader = DocumentReaderProxy(BeautifulDocumentReader())
documentReader.read()
documentReader.read()
documentReader.read()
}
}
클라이언트는 BeautifulDocumentReader가 아닌 DocumentReaderProxy를 사용하게 되며 실제 처리자인 BeautifulDocumentReader를 전달한다.
첫 read는 캐싱 데이터가 없기에 Proxy가 BeautifulDocumentReader에게 요청을 위임하지만 그 이후로 2번의 추가 요청은
위임하지 않고 자신이 가진 캐싱된 데이터를 반환한다.
이처럼 프록시는 실제 행위를 처리하는 객체에 대한 접근 제어를 하는 데 유용하게 쓰일 수 있다.
프록시 패턴을 사용함으로써BeautifulDocumentReader를 수정하지 않고도 캐싱을 처리할 수 있었다.
이외에도 Spring Data JPA를 사용할 때 연관관계를 사용하는 경우 Lazy Loading을 사용하는 경우가 많을 것이다.
public class Team$HibernateProxy extends Team {
private Team target;
private Long realEntityId;
@Override
public String getName() {
if (target == null) {
this.target = db.getData()
}
return target.getName();
}
}
물론 Entity는 인터페이스가 아니라 클래스여서 내부적으로 상속을 통한 프록시로 처리되긴 하지만 프록시 개념은 동일하다.
지연 로딩이라 함은 데이터에 처음으로 접근할 때 DB에서 조회하는 것이다.
따라서 내부적으로 target이란 필드를 가진다. 이 필드가 실제 데이터를 보관하는 Team 객체이다.
그리고 getName 메서드등을 이용하여 데이터에 접근하려고 하는 경우 target이 null이라면 데이터베이스로부터 조회해온다.
그 이후 name을 반환한다. 이렇게 하면 데이터가 필요해질 시점에 db에 조회를 하기 때문에 나름 효율적일 수 있다.
이외에도 Spring AOP도 프록시 개념이 사용된다.
Decorator Pattern
데코레이터 패턴도 프록시 패턴과 비슷하게 특정 객체의 행위에 특정 작업을 추가할 수 있다.
예를 들어 데이터를 쓰고 읽는 인터페이스 DataSource가 정의되어 있다고 가정하자.
그리고 파일의 데이터를 쓰고 읽는 FileDataSource를 정의하였다.
데이터를 쓰고 읽는 과정에서 특정한 작업을 추가할수 있도록 하는 DataSourceDecorator 추상 클래스를 정의하였다.
Decorator 추상 클래스는 DataSource를 필드로 가지며. 자신이 가진 DataSource에게 단순히 작업을 위임한다.
추상 클래스로 Decorator를 정의하는 이유는 모든 Decorator가 외부로부터 자신이 꾸밀 DataSource를 받아 보관할 필드가 필요하고
중복적인 로직을 효과적으로 관리할 수 있기 때문이다.
위 그림에선 Decorator 추상 클래스를 구현하고 있는 Encryption과 Compression은 자신만의 방식으로 읽기 쓰기 작업을 구현한다.
역시 디자인 패턴이기에 글과 그림만 보고는 이해하기가 쉽지 않다. 코드로 보자.
class Data(
var name: String,
var data: String
)
interface DataSource {
fun writeData(data: Data)
fun readData(): Data
}
class FileDataSource() : DataSource {
override fun writeData(data: Data) {
println("Written ${data.data}")
}
override fun readData(): Data {
return Data("file", "zxcvasdfasfasdf")
}
}
먼저 데이터를 쓰고 읽는 기능을 정의한 DataSource 인터페이스가 있다. 그리고 File에 데이터를 쓰고 읽는 구현체를 정의하였다.
abstract class DataSourceDecorator(
private val wrappee: DataSource,
) : DataSource {
override fun writeData(data: Data) {
wrappee.writeData(data)
}
override fun readData(): Data {
return wrappee.readData()
}
}
그리고 Decorator 추상 클래스이다. 이 추상 클래스는 객체를 전달받고 그 객체에게 작업을 위임하는 역할을 할 뿐이다.
class EncryptionDecorator(
private val wrappee: DataSource,
) : DataSourceDecorator(wrappee) {
override fun writeData(data: Data) {
data.data = "encryption!!"
wrappee.writeData(data)
}
override fun readData(): Data {
val data = wrappee.readData()
data.data = "decryption!!"
return data
}
}
class CompressionDecorator(
private val wrappee: DataSource,
) : DataSourceDecorator(wrappee) {
override fun writeData(data: Data) {
data.data = "compression!!"
wrappee.writeData(data)
}
override fun readData(): Data {
val data = wrappee.readData()
data.data = "decompression!!"
return data
}
}
그리고 위 2개의 클래스는 암호화, 압축 추가 작업을 처리하기 위한 Decorator 구현체이다.
구현체들은 각 작업 전, 후로 추가 작업을 진행하고 전달받은 객체에게 작업을 위임한다.
사실 이 데코레이터 패턴은 코드로 직접 봐도 이게 왜 자꾸 객체를 전달받고 계속 위임하는지 이해하기 쉽지 않다.
일단 위 코드에서 전달받은 객체들은 모두 파일에 데이터를 쓰고 읽는 기능을 제공하는 객체들이다.
그 객체들이 파일에 데이터를 쓰고, 읽기 전,후 과정에 그냥 단순히 어떠한 작업을 추가하는 것이다.
데코레이터 패턴을 사용하는 클라이언트 입장에서 보면 더 이해하기 쉬울 것이다.
class DesignPattern {
@Test
fun client() {
val data = Data("a", "a")
val fileDataSource = FileDataSource()
EncryptionDecorator(CompressionDecorator(fileDataSource)).writeData(data)
}
}
만약 파일에 데이터를 쓰고 싶은데 특정 데이터를 암호화하고 압축하고 싶다고 가정하자.
암호화 하기 -> 압축하기 -> 파일에 쓰기 단계로 진행되어야 한다. 따라서 코드는 위와 같이 된다.
class EncryptionDecorator(
private val wrappee: DataSource,
) : DataSourceDecorator(wrappee) {
override fun writeData(data: Data) {
data.data = "encryption!!"
wrappee.writeData(data)
}
override fun readData(): Data {
val data = wrappee.readData()
data.data = "decryption!!"
return data
}
}
Encryption이 제일 바깥에서 감싸고 있으므로 제일 먼저 실행된다. 따라서 전달받은 data를 암호화 한다.
그리고 전달받은 객체에게 작업을 위임한다. 여기서 전달받은 객체는 CompressionDecorator(fileDataSource)이다.
이것은 압축 -> 파일에 쓰기를 처리하는 객체이다. 그리고 전달받은 객체에 가장 바깥에 있는 것은 압축이니까 압축이 진행된다.
class CompressionDecorator(
private val wrappee: DataSource,
) : DataSourceDecorator(wrappee) {
override fun writeData(data: Data) {
data.data = "compression!!"
wrappee.writeData(data)
}
override fun readData(): Data {
val data = wrappee.readData()
data.data = "decompression!!"
return data
}
}
data는 현재 암호화 상태이고 압축을 진행한다. 압축이 완료되었으면 전달받은 객체에게 작업을 위임한다.
전달받은 객체는 fileDataSource로 원본 객체이다.
class FileDataSource() : DataSource {
override fun writeData(data: Data) {
println("Written ${data.data}")
}
override fun readData(): Data {
return Data("file", "zxcvasdfasfasdf")
}
}
마지막으로 제일 근본적인 목표였던 파일에 데이터 쓰기 작업이 처리된다.
데코레이터 패턴은 말 그대로 장식이다. 위 예시에서는 파일에 데이터를 쓰는 작업을 하기 전에 암호화, 압축이란 장식을 계속해서 추가했다.
장식을 추가하는 것은 단지 원본 객체를 Decorator에 감싸지도록 하는 것이다.
정리
프록시 패턴, 데코레이터 패턴 모두 특정 객체의 작업 전,후로 추가적인 행위를 하고 싶을 때 사용된다.
프록시 패턴은 클라이언트가 대리인 객체에 요청을 보내며 대리인 객체는 실제 객체에게 작업을 위임하는 구조로
대리인 객체가 중간에서 적절하게 요청을 컨트롤 할 수 있는 패턴이다. 대리인 객체는 중간에서 추가 작업을 처리할 수 있고 요청을
제어할 수 있다.
데코레이터 패턴은 데코레이터 구현체가 원본 객체를 계속해서 감싸는 방식으로 추가작업을 처리한다.
데코레이터 패턴이 좀 더 어떠한 작업에 여러가지 작업을 추가하는데 적합하다.
단순히 추가하고 싶은 데코레이터(장식)한테 객체를 넘기면 되기 때문이다.
그래서 보통 프록시 패턴은 특정 객체에 대한 접근 제어를 하는 데 쓰이고 데코레이터 패턴이 특정 작업을 추가하는데 사용하는 것이 주
목적이라고 한다.
보통 스프링도 그렇고 프록시를 사용하는 게 익숙해서, 단순히 하나의 기능을 상황이라면 프록시를 사용해도 괜찮을 것 같고
상황에 따라 여러 추가 작업들을 추가하는 경우에는 데코레이터 패턴이 좋을 것 같다.