객체지향 설계를 위한 디자인 패턴

임준영·2021년 4월 18일
2

디자인 패턴

객체 지향 설계는 소프트웨어로 해결하고자 하는 문제를 다루면서, 동시에 재설계 없이 또는 재설계를 최소화하면서 요구 사항의 변화를 수용할 수 있도록 만들어 줍니다. 객체 지향 설계를 하다 보면, 이전과 비슷한 상황에서 사용했던 설계를 재사용하는 경우가 종종 발생합니다.

이런 설계는 특정 상황에 맞는 해결책을 빠르게 찾을 수 있도록 도와주는데, 이렇게 반복적으로 사용되는 설계는 클래스, 객체의 구성, 객체 간 메시지 흐름에서 일정 패턴을 갖습니다.
이런 패턴을 잘 습득하면 다음과 같은 이득을 얻을 수 있게 됩니다.

  • 상황에 맞는 올바른 설계를 더 빠르게 적용할 수 있습니다.
  • 각 패턴의 장단점을 통해서 설계를 선택하는데 도움을 얻을 수 있습니다.
  • 설계 패턴에 이름을 붙임으로써 시스템의 문서화, 이해, 유지 보수에 도움을 얻을 수 있습니다.

이 분야에 자주 사용되는 패턴들을 모아서 집대성한 다양한 책이 존재하는데, 그 중에서도 가장 유명한 GoF의 디자인 패턴은 많은 프로그래머들에게 도움을 주었습니다. 이 책은 객체지향 프로그래밍에서 발견된 여러 패턴들을 모아놓은 책으로서, 소프트웨어 개발에서 자주 만나게 되는 일반적인 문제를 해결해 주는 설계를 제시하고 있습니다. GoF의 디자인 패턴은 객체의 생성, 기능의 확장, 기능의 변경, 구조 등과 관련된 약 20여개에 이르는 패턴을 정리하고 있습니다.

이번 포스팅에서는 GoF의 패턴 중에서도 영역에 상관없이 자주 사용되는 패턴에 대해서 소개하겠습니다.

  • 전략 패턴 / 템플릿 메서드 패턴 / 상태 패턴
  • 데코레이터 패턴 / 프록시 패턴 / 어댑터 패턴
  • 옵저버 패턴 / 미디에이터 패턴 / 파사드 패턴
  • 추상 팩토리 패턴 / 컴포지트 패턴

그리고 GoF에는 속해 있지 않는 널(Null) 객체 패턴을 추가하였습니다. 이들 패턴은 개발과정에서 자주 사용하는 패터닝ㄴ 만큼, 익혀 두면 많은 도움을 얻을 것 입니다.

1. 전략 패턴

한 과일 매장은 상황에 따라 다른 가격 할인 정책을 적용하고 있습니다. 매장을 열자마자 들어온 첫 손님을 위한 첫 손님 할인정책과 저녁 시간대에 신선도가 떨어진 과일에 대한 덜 신선한 과일 할인정책이 있다면, 아래 코드처럼 계산하는 모듈에 이런 가격 할인 정책을 적용하기 위한 if-else 블록이 포함될 것입니다.

public class Calculator {

    public int calculate(boolean firstGuest, List<Item> items) {
        int sum = 0;
        for(Item item: items) {
            if(firstGuest) 
                sum += (int) (item.getPrice() * 0.9); // 첫 손님 10% 할인
            else if(!item.isFresh())
                sum += (int) (item.getPrice() * 0.8); // 덜 신선한 것 20% 할인
            else
                sum += item.getPrice();
        }
        return sum;
    }
}

위 코드는 비교적 간단하지만 다음의 문제를 포함하고 있습니다.

  • 서로 다른 계산 정책들이 한 코드에 섞여 있어, 정책이 추가될수록 코드 분석을 어렵게 만듭니다.
  • 가격 정책이 추가될 때마다 calculate 메서드를 수정하는 것이 점점 어려워집니다. 예를 들어 마지막 손님 50% 할인과 같은 새로운 가격 정책이 추가될 경우, calculate 메서드에 마지막 손님을 구분하기 위한 lastGuest 파라미터가 추가되고, if 블록이 하나 더 추가되어야 합니다.

이런 문제를 해결하기 위한 방법 중의 하나는 아래 그림처럼 가격 할인 정책을 별도 객체로 분리하는 것입니다.

Untitled Diagram

위 그림에서 DiscountStrategy 인터페이스는 상품의 할인 금액 계산을 추상화하였고, 각 콘크리트 클래스는 상황에 맞는 할인 계산 알고리즘을 제공합니다. Calculator 클래스는 가격 합산 게산의 책임을 집니다. 여기서 가격 할인 알고리즘을 추상화하고 있는 DiscountStrategy을 전략이라고 부르고 가격 계산 기능 자체의 책임을 갖고 있는 Caculator를 콘텍스트라고 부르는데, 이렇게 특정 콘텍스트에서 알고리즘을 별도로 분리하는 설계 방법이 전략 패턴입니다.

전략 패턴에서 콘텍스트는 사용할 전략을 직접 선택하지 않습니다. 대신 콘텍스트의 클라이언트가 콘텍스트에 사용햘 전략을 전달 해줍니다. 즉, DI(의존성 주입)를 이용해서 콘텍스트에 전략을 전달해 줍니다. 그리고 전략이 어떤 메서드를 제공할 지의 여부는 콘텍스트가 전략을 어떤 식으로 사용하느냐에 따라 달라집니다.

앞서 위에 그림에서 보여주는 금액 할인 정책을 DiscountStrategy로 분리한 경우, 아래 코드처럼 Calculator를 구현할 수 있을 것입니다.

public class Caluclator {

    private DiscountStrategy discountStrategy;

    public Calculator(DiscountStrategy discountStrategy) {
        this.discountStrategy = discountStrategy;
    }

    public int calculator(List<Item> items) {
        int sum = 0;
        for(Item item: items) {
            sum += discoutStrategy.discountPrice(item);
        }
    }
    return sum;
}

코드를 살펴보면 Calculator 클래스는 생성자를 통해서 사용할 전략 객체를 전달받고, calculate() 메서드에 각 Item의 가격을 계산할 때 전략 객체를 사용하고 있습니다. 위 코드에서 Calculator는 각 Item 별로 할인 정책을 적용하고 있으므로 DiscountStrategy 인터페이스는 아래 코드처럼 정의될 것입니다.

public interface DiscountStrategy {
    public int getDiscountPrice();
}

만약 각 아이템 별로 할인 정책이 있고, 전체 금액에 대한 할인 정책이 별도로 필요하다면, DiscountStrategy의 인터페이스에 전체 금액 할인을 위한 메서드가 추가될 것입니다.

public interface DiscountStrategy {
    public int getDiscountPrice(Item item);
    public int getDiscountPrice(int totalPrice);
}

또는, 전체 금액 할인 정책을 위한 전략을 별도 인터페이스로 분리할 수도 있을 것입니다.

public interface ItemDiscountStrategy {
    int getDiscountPrice(Item item);
}

public interface TotalPriceDiscountStrategy {
    int getDiscountPrice(int totalPrice);
}

전략 객체는 콘텍스트를 사용하는 클라이언트에서 직접 생성합니다. 예를 들어, 첫 번째 손님에 대해 할인을 해주는 FirstGuestDiscountStrategy 구현 클래스를 아래 코드와 같이 구현했다고 합시다.

public class FirstGuestDiscountStrategy implements DiscountStrategy {

    @Override
    public int getDiscountPrice(Item item) {
        return (int) (item.getPrice() * 0.9);
    }
}

첫 번째 손님이 들어와서 계산을 하면, 계산기에서 첫 번째 손님 할인 적용 버튼을 누른 뒤에 계산 버튼을 누를 것입니다. 이를 처리하는 코드는 다음과 같은 방식으로 작성될 것입니다.

private DiscoutStrategy strategy;

public void onFirstGuestButtonClick() {
    // 첫 손님 할인 버튼을 누를 때 생성 됩니다.
    strategy = new FirstGuestDiscountStrategy();
}

public void onCalculationButtonClick() {
    // 계산 버튼 누를 때 실행됨
    Calculator cal = new Calculator(strategy);
    int price = cal.calculate(items);
    ...
}

위 코드를 보면 Calculator를 사용하는 코드에서 FirstGuestDiscountStrategy 클래스의 객체를 생성하는 것을 알 수 있습니다. 이는 콘텍스트를 사용하는 클라이언트가 아래 그림처럼 전략의 상세 구현에 대한 의존이 발생한다는 것을 뜻합니다.

Untitled Diagram (1)

콘텍스트의 클라이언트가 전략 인터페이스가 아닌 상세 구현을 안다는 것이 문제처럼 보일 수 있으나, 이 경우에는 전략의 콘크리트 클래스와 클라이언트의 코드가 쌍을 이루기 때문에 유지 보수 문제가 발생할 가능성이 줄어듭니다.

예를 들어, 덜 신선한 과일 할인 정책을 추가하려면 클라이언트에 덜 신선한 과일 할인정책 적용 버튼을 처리하는 코드가 생기고 이 코드에서 NonFreshDiscountStrategy 객체를 생성해 주게 됩니다. 또한 기능이 제거될 때에도 함께 제거됩니다. 따라서 클라이언트의 버튼 처리 코드에서 전략 객체를 직접 생성하는 것은 오히려 코드 이해를 높이고 코드 응집을 높여주는 효과를 갖습니다.

전략 패턴을 적용할 때 얻을 수 있는 이점은 콘텍스트 코드의 변경 없이 새로운 전략을 추가할 수 있다는 점입니다. 앞에서 본 예제에서 마지막 손님 대폭 할인 정책을 추가하는 경우, 계산을 제공하는 Calculator 클래스의 코드는 변경되지 않습니다. 단지 새로운 할인 정책을 구현한 LastGuestDiscountStrategy 클래스를 추가하고, 마지막 손님 대폭 할인 버튼을 클릭을 처리하는 코드에서 LastGuestDiscountStrategy의 객체를 생성해 주기만 하면 됩니다.

private DiscountStrategy strategy;

public void onLastGuestButtonClick() {
    // 마지막 손님 대폭 할인 버튼 누를 때 생성됨
    strategy = new LastGuestDiscountStrategy();

}

public void onCalculationButtonClick() {
    // 계산 버튼 누를 때 실행 됩니다.
    Calculator cal = new Calculator(strategy);
    int price = cal.calculate(items);
    ...
}

전략 패턴을 적용함으로써 Calculator 클래스는 할인 정책 확장에는 열려 있고 변경에는 닫혀 있게 됩니다. 즉, 개방 폐쇄 원칙을 따르는 구조를 갖게 됩니다.

일반적으로 if-else로 구성된 코드 블록이 비슷한 기능을 수행하는 경우에 전략 패턴을 적용함으로써 코드를 확장 가능하도록 변경할 수 있습니다. 위에서 살펴본 if-else 블록에 따라 전략 패턴을 적용함으로써 새로운 할인 정책을 보다 쉽게 추가할수 있도록 만들어보았습니다.

2. 템플릿 메서드 패턴

프로그램을 구현하다 보면, 완전한 동일한 절차를 가진 코드를 작성하게 될 때가 있습니다. 심지어 이 코드들은 절차 중 일부 과정의 구현만 다를 뿐 나머지 구현은 똑같을 때도 있습니다. 예를 들어, DB 데이터와 LDAP를 이용해서 인증을 처리하는 클래스는 사용자 정보를 가져오는 부분의 구현만 다를 뿐 인증을 처리하는 과정은 완전히 동일할 수 있습니다.

DB나 LDAP가 아닌 중간에 인증 서버를 두는 경우라도 두 클래스와 거의 유사한 코드를 갖게 될 것입니다. 이렇게 실행 과정/단계는 동일한데 각 단계 중 일부의 구현이 다른 경우에 사용할 수 있는 패턴이 템플릿 메서드 패턴 입니다. 템플릿 메서드 패턴은 다음과 같이 두 가지로 구성됩니다.

  • 실행 과정을 구현한 상위 클래스
  • 실행 과정의 일부 단계를 구현한 하위 클래스

상위 클래스는 실행 과정을 구현한 메서드를 제공합니다. 이 메서드는 기능을 구현하는데 필요한 각 단계를 정의하며 이 중 일부 단계는 추상 메서드를 호출하는 방식으로 구현됩니다. 이때 추상 메서드는 구현이 다른 단계에 해당합니다. 예를 들어, 템플릿 메서드 패턴을 적용하면 아래 코드와 같이 상위 클래스를 작성할 수 있습니다.

public abstract class Authenticator {

    // 템플릿 메서드
    public Auth authenticate(String id, String pw) {
        if(!doAuthenticate(id, pw)) 
            throw createException();
        
        return createAuth(id);
    }

    protected abstract boolean doAuthenticate(String id, String pw);

    private RuntimeException createException() {
        throw new AuthException();
    }
    protected abstract Auth createAuth(String id);
}

authenticate() 메서드는 DbAuthenticator와 LdapAuthenticaotr에서 동일했던 실행 과정을 구현하고 있고, 두 클래스에서 차이가 나는 부분은 별도의 추상 메서드로 분리하였습니다.

예를 들어, id/pw를 이용해서 인증 여부를 확인하는 단계는 doAuthenticate() 추상 메서드로 분리하였고, Auth 객체를 생성하는 단계는 createAuth() 추상 메서드로 분리하였습니다. Authenticate() 메서드는 모든 하위 타입에 동일하게 적용되는 실행 과정을 제공하기 때문에, 이 메서드를 템플릿 메서드라고 부릅니다.

Authenticator 클래스를 상속받은 하위 클래스는 authenticate() 메서드에서 호출하는 다른 메서드만 알맞게 재정의 해주면 됩니다. 예를 들어 LdapAuthenticator 클래스는 아래 코드처럼 구현할 수 있습니다.

public class LdapAuthenticator extends Authenticator {

    @Override
    protected boolean doAuthenticate(String id, String pw) {
        return ldapClient.authenticate(id, pw);
    }

    @Override
    protected Auth createAuth(String id) {
        LdapContext ctx = ldapClient.find(id);
        return new Auth(id, ctx.getAttribute("name"));
    }
}

LdapAuthenticator 클래스는 이제 전체 실행 과정 구현을 제공하지 않고 일부 과정의 구현만을 제공합니다. 전체 실행 과정은 상위 타입인 Authenticator의 authenticate() 메서드에서 제공하게 됩니다.

템플릿 메서드 패턴을 사용하게 되면, 동일한 실행과정의 구현을 제공하면서 동시에 하위 타입에서 일부 단계를 구현할 수 있도록 할 수 있습니다. 이는 각 타입에서 코드가 중복되는 것을 방지합니다.

템플릿 메서드 패턴을 적용하기 전에 DbAuthenticator와 LdapAuthenticator는 완전히 동일한 구조를 갖고 있었습니다. 차이점이라면 DB를 사용하느냐 LDAP를 사용하느냐 일뿐, 실행 과정 자체는 완전히 동일했습니다. 새로운 인증 방식이 추가되더라도 이 과정은 완전히 동일하기 때문에 거의 비슷한 코드가 중복될 것입니다. 중복된 코드가 출현한다는 것은 그만큼 유지 보수를 어렵게 만드는데, 템플릿 메서드 패턴을 사용함으로써 코드 중복 문제를 제거하면서 동시에 코드를 재사용 할 수 있게 합니다.

상위 클래스가 흐름 제어 주체

템플릿 메서드 패턴의 특징은 하위 클래스가 아닌 상위 클래스에서 흐름 제어를 한다는 것입니다. 일반적인 경우 하위 타입이 상위 타입의 기능을 재사용할지 여부를 결정하기 때문에, 흐름 제어를 하위 타입이 하게 됩니다. 예를 들어, 아래 코드에서 SuperCar 클래스의 turnOn() 메서드는 상위 클래스의 turnOn() 메서드를 재사용할지 여부를 자신이 결정합니다.

public class SuperCar extends ZetEngine {

    @Override
    public void turnOn() {
        // 하위 클래스에서 흐름 제어
        if(notReady) 
            beep();
        else 
            super.turnOn();
    }
}

반면에 템플릿 메서드 패턴에서는 상위 타입의 템플릿 메서드가 모든 실행 흐름을 제어하고, 하위 타입의 메서드는 템플릿 메서드에 호출되는 구조를 갖게 됩니다.

템플릿 메서드인 authenticate() 메서드와 템플릿 메서드에서 호출하는 메서드인 doAuthenticate() / createAuth() 메서드의 접근 범위는 각가 public과 protected로 설정되어 있습니다. 템플릿 메서드의 경우 외부에서 제공하는 기능에 해당되기 때문에 public 범위를 가져야 하는 반면에 doAuthenticate() 메서드나 createAuth() 메서드는 템플릿 메서드에서만 호출되는 메서드로서 public일 필요가 없습니다. 이 두 메서드는 하위 타입에서 재정의할 수 있어야 하기 때문에 private이 아닌 protected 범위를 가져야 합니다.

이 예제에서는 템플릿 메서드에서 호출하는 메서드를 추상 메서드로 정의했는데, 기본 구현을 제공하고 하위 클래스에서 알맞게 재정의하도록 구현할 수도 있습니다. 이 경우 해당 메서드는 기능의 확장 지점으로 사용될 수 있습니다. 예를 들어, 안드로이드에서 비동기 처리를 위한 기능을 제공하는 AsyncTask 클래스의 코드를 보면, 아래처럼 doBackground() 추상 메서드와 빈 구현을 갖는 onPreExecute() 메서드를 제공하고 있습니다.

public abstract class AsyncTask<Params, Progress, Result> {

    public AsyncTask() {
        mWorker = new WorkerRunnable<Params, Result>() {
            public Result call() throws Exception {
                mTaskInvoked.set(true);
                Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
                return postResult(doInBackground(mParams));
            }
        };
        ...
    }
    
    public final AsyncTask<Params, Progress, Result> executeOnExecutor(Executor exec, Params... params) {
        ...
        mStatus = Status.RUNNIN;
        onPreExecute();
        mWorker.mParams = params;
        exec.execute(mFuture);
        return this;
    }
    protected abstract Result doInBackground(Params... params);

    protected void onPreExecute() { // 하위 클래스의 확장 지점

    }
    ...// 기타 다른 코드
}

AsyncTask를 상속받아 구현하는 클래스는 doInBackground() 메서드는 반드시 구현해 주어야 하지만, onPreExecute() 메서드의 경우는 필요한 경우에만 구현해 주면 됩니다. 즉, onPreExecute() 메서드는 상위 클래스 입장에서는 제어 대상이 되는 확장 지점이 되며, 하위 클래스에 맞는 확장 기능을 구현할 위치가 됩니다.

훅 메서드란? 이렇게 상위 클래스에서 실행 시점이 제어되고, 기본 구현을 제공하면서, 하위 클래스에서 알맞게 확장할 수 있는 메서드를 훅(hook) 메서드라고 부릅니다.

3. 템플릿 메서드와 전략 패턴의 조합

템플릿 메서드와 전략 패턴을 함께 사용하면 상속이 아닌 조립의 방식으로 템플릿 메서드 패턴을 활용 할 수 있는데, 대표적인 예가 스프링 프레임워크의 Template으로 끝나는 클래스 들입니다. 이 클래스들은 템플릿 메서드를 실행할 때, 변경되는 부분을 실행할 객체를 파라미터를 통해서 전달받는 방식으로 구현되어 있습니다.

예를 들어, 트랜잭션 기능을 제공하는 TransactionTemplate 클래스의 execute() 메서드는 다음과 같이 구현되어 있습니다.

public <T> T execute(TransactionCallback<T> action) throw TransactionException {
    // 일부 코드 생략
    TransactionStatus status = this.transactionManager.getTransaction(this);
    T result; 
    try {
        result = action.doInTransaction(status);
    } catch (RuntimeException ex) {
        rollbackOnException(status, ex);
        throw ex;
    }
    // 기타 다른 익셉션 처리 코드
    this.transactionManager.commit(status);
    return result;
}

execute() 메서드는 트랜잭션의 시작/커밋/롤백 등의 실행 흐름을 제공하는 템플릿 메서드인데, execute() 메서드는 앞서 살펴본 템플릿 메서드와 다음의 차이점이 있습니다.

  • 앞서 템플릿 메서드가 하위 타입에서 재정의할 메서드를 호출하고 있다면
  • TransactionTemplate의 execute() 메서드는 파라미터로 전달받은 action의 메서드를 호출하고 있습니다.

따라서 TransactionTemplate의 execute() 메서드를 사용하는 코드는 다음과 같이 execute() 메서드를 호출할 때 원하는 기능을 구현한 TransactionCallback 객체를 전달합니다.

transactionTemplate.execute(new TransactionCallback<String>() {
    public String doInTransaction(TransactionStatus status) {
        // 트랜잭션 범위 안에서 실행될 코드 
    }
}); 

템플릿 메서드 패턴과 전략 패턴을 조합하게 되면, 상속에 기반을 둔 템플릿 메서드 구현과 비교해서 유연함을 갖습니다. 상속을 통한 재사용의 경우 앞에 포스팅에서 살펴봤듯이 클래스가 불필요하게 증가할 수 있고 런타임에 교체할 수 없는 단점이 있는 반면에 조립/위임을 사용하는 경우에 런타임에 템플릿 메소드에서 사용할 객체를 교체할 수 있는 장점을 갖게 됩니다.
하지만, 상속 방식의 경우 훅 메서드를 재정의 하는 방법으로 하위 클래스에서 쉽게 확장 기능을 제공할 수 있는 장점이 있는 반면에, 조립/위임 방식에서는 확장 기능을 제공하려면 구현이 다소 복잡해지는 단점이 있습니다.

4. 상태(State) 패턴

단일 상품을 판매하는 자판기에 들어갈 소프트웨어를 개발해 달라는 요구가 들어왔습니다. 이 자판기의 동작 방식은 아래와 같은 조건으로 동작합니다.

  • 동전이 없을 때 동전을 넣으면 자판기 금액 표시란에 금액이 증가하고 제품 선택이 가능합니다.
  • 제품 선택이 가능하면 동전을 넣으면 금액이 증가하고 제품 선택이 가능합니다.
  • 동전이 없을때 제품을 선택하면 아무런 동작을 하지 않고 동전 없음이라는 표시가 유지됩니다.
  • 제품 선택 가능이면 제품을 선택하고 자판기에서 제품을 주고 잔액이 감소합니다. 결과적으로 잔액이 있으면 제품 선택이 가능하고, 없으면 동전이 없다고 표시됩니다.

아래 코드는 자판기 프로그램의 담당자는 조건에 따라 다른 코드를 실행해야 한다는 판단을 하고, 아래와 같이 코드를 작성하였습니다.

public class VendingMachine {

    public static enum State { NOCOIN, SELECTABLE }
    
    private State state = State.NOCOIN;

    public void insertCoin(int coin) {
        
        switch(state) {
            case NOCOIN:
                increaseCoin(coin);
                state = State.SELECTABLE;
                break;
            case SELECTABLE:
                increaseCoin(coin);
                break;
        } 
    }

    public void select(int productId) {
        switch(state) {
            case NOCOIN:
                // 아무것도 하지 않음
                break;
            case SELECTABLE:
                decreaseCoin();
                if(hasNoCoin()) {
                    state = State.NOCOIN;
                }
        }
    }

    ..// increaseCoin, provideProduct, decreaseCoin 구현
}

자판기 프로그램을 구현하는 도중에 다음과 같은 새로운 요구사항이 들어왔습니다.

  • 자판기에 제품이 없는 경우에는 동전을 넣으면 바로 동전을 되돌려 줍니다.

이 기능을 추가하기 위해 자판기 프로그램은 아래 코드처럼 바뀝니다.

public class VendingMachine {

    public static enum State { NOCOIN, SELECTABLE, SOLDOUT }
    
    private State state = State.NOCOIN;

    public void insertCoin(int coin) {
        
        switch(state) {
            case NOCOIN:
                increaseCoin(coin);
                state = State.SELECTABLE;
                break;
            case SELECTABLE:
                increaseCoin(coin);
                break;
            case SOLDOUT:
                resultCoin();
                break;
        } 
    }

    public void select(int productId) {
        switch(state) {
            case NOCOIN:
                // 아무것도 하지 않음
                break;
            case SELECTABLE:
                decreaseCoin();
                if(hasNoCoin()) {
                    state = State.NOCOIN;
                }
            case SOLDOUT:
                // 아무 것도 하지 않음
        }
    }

    ..// increaseCoin, provideProduct, decreaseCoin 구현
}

또 다시, 자동세척 증일 때에도 동전을 넣으면 바로 돌려줘야 한다는 요구 사항이 추가되었습니다. 이 요구사항을 충족하기 위해 insertCoin() 메서드와 select() 메서드에 또 다른 조건문이 추가될 것입니다.

위 코드에서는 insertCoin() 메서드와 select() 메서드는 동일한 구조의 조건문을 갖고 있습니다. 이는 상태가 많아질수록 복잡해지는 조건문이 여러 코드에서 중복해서 출현하고, 그만큼 코드 변경을 어렵게 만든다는 것을 의미합니다.(예를 들어, 새로운 상태를 추가하거나 기존 상태를 빼려면 모든 조건문을 찾아서 수정해줘야 합니다.)

VendingMachine 클래스의 코드를 다시 한번 살펴보면, 조건문은 다음과 같은 의미를 내포하고 있습니다.

  • 상태에 따라 동일한 기능 요청의 처리를 다르게 합니다.

예를 들어, insertCoin() 메서드는 아래 코드와 같이 NOCOIN이냐, SELECTABLE이냐, 또는 SOLDOUT이냐에 따라서 다르게 동작하고 있습니다.

 switch(state) {
    case NOCOIN:
        increaseCoin(coin);
        state = State.SELECTABLE;
        break;
    case SELECTABLE:
        increaseCoin(coin);
        break;
    case SOLDOUT:
         resultCoin();
         break;
} 

select() 메서드 역시 insertCoin() 메서드와 동일하게 상태에 따라 다르게 동작합니다. 이렇게 기능이 상태에 따라 다르게 동작해야할 때 사용할 수 있는 패턴이 상태 패턴입니다. 상태 패턴에서는 상태를 아래 그림과 같이 별도 타입으로 분리하고, 각 상태 별로 알맞은 하위타입을 구현합니다.

Untitled Diagram (1)

상태 패턴에서 중요한 점은 상태 객체가 기능을 제공한다는 점입니다. State 인터페이스는 동전 증가 처리와 제품 선택 처리를 할 수 있는 두 개의 메서드를 정의하고 있습니다. 이 두 메서드는 모든 상태에서 동일하게 적용되는 기능입니다.

콘텍스트 필드는 상태 객체를 갖고 있습니다. 콘텍스트는 클라이언트로부터 기능 실행 요청을 받으면, 상태 객체에 처리를 위임하는 방식으로 구현합니다. 예를 들어, 자판기 기능을 제공하는 VendingMachine 클래스의 insertCoin() 메서드와 select() 메서드는 아래 코드와 같이 State 객체에 처리를 위임하는 방식으로 동작합니다.

// 상태 패턴을 적용한 VendingMachine 구현
public class VendingMachine {

    private State state;

    public VendingMachine() {
        this.state = new NoCoinState();
    }

    public void insertCoin(int coin) {
        state.increaseCoin(coin, this); // 상태 객체에 위임
    }

    public void select(int productId) {
        state.select(productId, this); // 상태 객체에 위임
    }

    public void changeState(State newState) {
        this.state = newState;
    }

    ...// 기타 다른 기능
}

state 필드를 NoCooinState 객체로 초기화했는데, NoCoinState 클래스는 아래와 같이 구현했습니다.

public class NoCoinState implements State {

    @Override
    public void increaseCoin(int coin, VendingMachine vm) {
        vm.increaseCoin(coin);
        vm.changeState(new SelectableState());
    }

    @Override
    public void select(int productId, VendingMachine vm) {
        SoundUnit.beep();
    }
}

NoCoinState 클래스의 increaseCoin() 메서드는 VendingMachine의 동전 수를 증가시키고, 상태를 SeletableState로 변경합니다. 즉, 동전 없는 상태에서 동전을 넣으면 동전 수를 증가시키고 선택 가능 상태로 변경하는 기능을 제공하는 것입니다. NoCoinState 클래스의 select() 메서드는 에러 음을 발생시킵니다. 이는 동전 없는 상태에서 음료를 선택하면 에러 음을 발생시킨다는 것을 뜻합니다.

NoCoinState 클래스와 유사하게 SelectableState 클래스는 음료 선택이 가능한 상태에서 동전을 넣을 때와 음료를 선택할 때의 자판기 동작 방식을 구현합니다.

public class SelectableState implements State {

    @Override
    public void increaseCoin(int coin, VendingMachine vm) {
        vm.increaseCoin(coin);
    }

    @Override
    public void select(int productId, VendingMachine vm) {
        vm.provideProduct(productId);
        vm.decreaseCoin();

        if(vm.hasNoCoin()) 
            vm.changeState(new NoCoinState());
    }
}

NoCoinState 클래스와 SelectableState 클래스를 보면, 상태 패턴을 적용함으로써 VendingMachine 클래스에 구현되어 있는 상태 별 동작 구현 코드가 각 상태의 클래스로 이동함을 알 수 있습니다. 또한, 이 과정에서 VendingMachine 클래스의 코드 구현은 상태 객체에 위임하는 방식으로 단순해집니다.

상태 패턴의 장점은 새로운 상태가 추가되더라도 콘텍스트 코드가 받는 영향은 최소화 된다는 점입니다.

예를 들어, 자판기 예제에서 자판기의 청소 상태 구현을 위해 CleaningState 클래스를 추가하더라도 insertCoin() 메서드와 select() 메서드의 코드는 그대로 유지됩니다.
상태가 많아질수록 조건문을 이용한 방식은 코드가 복잡해져서 유지 보수를 어렵게 만들지만, 상태 패턴의 경우 상태가 많아지더라도 코드의 복잡도는 증가하지 않기 때문에 유지보수에 유리합니다.

상태 패턴의 두 번째 장점은 상태에 따른 동작을 구현한 코드가 각 상태 별로 구분되기 때문에 상태 별 동작을 수정하기가 쉽다는 점입니다. 조건문을 이용한 방식을 사용할 경우 동전 없음 상태의 동작을 수정하려면 각 메서드를 찾아다니면서 수정해줘야 하는 반면에, 상태 패턴을 적용한 경우 동전 없음 상태를 표현하는 NoCoinState 클래스를 수정해 주면 됩니다. 관련된 코드가 한 곳에 모여있기 때문에 안전하고 더 빠르게 구현을 변경할 수 있게 됩니다.

상태 변경은 누가?

상태 패턴을 적용할 때 고려할 문제는 콘텍스트의 상태 변경을 누가 하느냐에 대한 것입니다. 상태 변경을 하는 주체는 콘텍스트나 상태 객체 둘 중 하나가 됩니다. 앞서 예제에서는 각 상태객체에서 콘텍스트의 상태를 변경해 주었습니다.

예를 들어, NoCoinState 클래스의 increaseCoin() 메서드는 VendingMachine의 changeState() 메서드를 호출해서 VendingMachine의 상태를 SelectableState로 변경하였습니다.

public class NoCoinState implements State {

    @Override
    public void increaseCoin(int coin, VendingMachine vm) {
        vm.increaseCoin(coin);
        // 상태 객체에서 콘텍스트의 상태 변경
        vm.changeState(new SelectableState());
    }

상태 객체에서 콘텍스트의 상태를 변경하려면 콘텍스트의 다른 값에 접근해야 할 때도 있습니다.
예를 들어, SelectableState 클래스의 select() 메서드는 VendingMachine의 상태를 NoCoinState로 변경해야 하는지 여부를 확인하기 위해 VendingMachine의 hasNoCoin() 메서드를 사용하고 있습니다. 이는 상태 객체에서 콘텍스트의 상태를 변경할 수 있는 조건을 확인할 수 있도록 콘텍스트 인터페이스에 메서드를 추가해야한 다는 것을 의미합니다.

public class SelectableState implements State {

    @Override
    public void select(int productId, VendingMachine vm) {
        vm.provideProduct(productId);
        vm.decreaseCoin();
        // 상태 변경을 위해, vm 객체가 동전이 없는지 확인
        if(vm.hasNoCoin()) 
            vm.changeState(new NoCoinState());
    }

콘텍스트 상태를 변경할 경우 콘텍스트의 코드가 다소 복잡해질 수 있습니다. 앞서 VendingMachine 클래스 예제에서 콘텍스트가 직접 상태를 변경하도록 VendingMachine 클래스를 수정하면 아래와 같은 코드가 됩니다.

public class VendingMachine {
    private State state;

    public VendingMachine() {
        this.state = new NoCoinState();
    }

      public void insertCoin(int coin) {
        state.increaseCoin(coin, this); 
        if(hasCoin) 
            changeState(new SelectableState()); // 콘텍스트 상태 변경
    }

    public void select(int productId) {
        state.select(productId, this); 
        if(state.isSelectable() && hasNoCoin())
            changeState(new NoCoinState()); // 콘텍스트 상태 변경
    }

    private void changeState(State newState) {
        this.state = newState;
    }

    private boolean hasCoin() {
        ...
    }

    private boolean hasNoCoin() {
        ...
    }
    ...// 기타 다른 기능

}

VendingMachine 클래스의 changeState() 메서드, hasNoCoin() 메서드 그리고 hasCoin() 메서드의 접근 범위를 private으로 지정했는데, 그 이유는 상태 객체에서 콘텍스트의 상태를 변경하기 위한 목적으로 이들 메서드에 접근할 필요가 없어졌기 때문입니다. 이제 상태 객체는 자신이 수행해야 하는 작업만 처리하도록 바뀝니다.

public class SelectableState implements State {
    // 콘텍스트가 상태를 변경하므로, 상태 객체는 자신이 할 작업만 처리합니다.
    @Override
    public void select(int productId, VendingMachine vm) {
        vm.provideProduct(productId);
        vm.decreaseCoin();
    }
}

콘텍스트의 상태 변경을 누가 할지는 주어진 상황에 맞게 정해주어야 합니다. 먼저 콘텍스트에서 상태를 변경하는 방식은 비교적 상태 개수가 적고 상태 변경 규칙이 거의 바뀌지 않는 경우에 유리합니다. 왜냐하면 상태 종류가 지속적으로 변경되거나 상태 변경 규칙이 자주 바뀔 경우 콘텍스트의 상태 변경 처리가 복잡해질 가능성이 높기 때문입니다. 상태 변경 처리 코드가 복잡해질수록 상태 변경의 유연함이 떨어집니다.

반면에 상태 객체에서 콘텍스트의 상태를 변경할 경우, 콘텍스트에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있게 됩니다. 하지만 상태 변경 규칙이 여러 클래스에서 분산되어 있기 때문에, 상태 구현 클래스가 많아질수록 상태 변경 규칙을 파악하기가 어려워지는 단점이 있습니다. 또한, 한 상태 클레스에서 다른 상태 클래스에 대한 의존도 발생합니다.

두 방식은 명확하게 서로 상반되는 장단점을 갖고 있기 때문에, 상태 패턴을 적용할 때에는 주어진 상황에 알맞은 방식을 선택해야 합니다.

5. 데코레이터(Decorator) 패턴

상속은 기능을 확장하는 방법을 제공합니다. 예를 들어, 데이터를 파일에 출력하는 기능을 제공하는 FileOut 클래스가 있을 때, FileOut 클래스에 버퍼 기능을 추가하거나 압축 기능을 추가하려면 상속을 받아서 기능을 확장해서 구현할 수 있을 것입니다.

상속을 이용한 기능 확장 방법이 쉽긴 하지만, 다양한 조합의 기능 확장이 요구될 때 클래스가 불필요하게 증가하는 문제가 발생합니다. 버퍼 기능과 압축 기능을 함께 제공해야 한다거나, 압축한 뒤 암호화 기능을 제공해야 한다거나, 또는 버퍼 기능과 암호화 기능을 함께 제공해야 한다면, 클래스가 증가하고 계층 구조가 복잡해집니다.

이런 경우 사용할 수 있는 패턴이 데코레이터 패턴입니다. 데코레이터 패턴은 상속이 아닌 위임을 하는 방식으로 기능을 확장해 나갑니다. 위 예제에 데코레이터 패턴을 적용하면 아래 그림과 같이 바뀝니다.

Untitled Diagram

그림에서 FileOut 인터페이스는 파일 출력 기능을 정의하고 있고, 실제 파일 출력 기능은 FileOutImpl 클래스가 구현하고 있습니다. 여기서 중요한 점은 기능 확장을 위해 FileOutImpl 클래스를 상속받지 않고, Decorator라고 불리는 별도의 추상 클래스를 만들었다는 점입니다.

Decorator 클래스는 모든 데코레이트를 위한 기반 기능을 제공하는 추상 클래스로서 코드는 아래와 같습니다. 이 클래스의 doDelegate() 메서드는 생성자를 통해서 전달받은 FileOut 객체에 쓰기 기능을 위임합니다.

public abstract class Decorator implements FileOut {

    private FileOut delegate; // 위임 대상

    public Decorator(FileOut delegate) {
        this.delegate = delegate;
    }

    protected void doDelegate(byte[] data) {
        delegate.write(data); // delegate 쓰기 위임
    }
}

BufferdOut 클래스, EncryptionOut 클래스, ZipOut 클래스는 모두 데코레이터 클래스로서 Decorator 클래스를 상속받고 있습니다. 이들 클래스는 자신의 기능을 수행한 뒤에 상위 클래스의 doDelegate() 메서드를 이용해서 파일 쓰기를 위임하도록 구현합니다. 예를 들어, EncryptionOut 클래스는 아래와 같이 구현할 수 있습니다.

// EncrpytionOut 클래스
public class EncryptionOut extends Decorator {

    public EncryptionOut(FileOut delegate) {
        super(delegate);
    }

    public void write(Byte[] data) {
        byte[] encryptedData = encrypt(data);
        super.doDelegate(encryptedData);
    }

    private byte[] encrpyt(byte[] data) {
        ...
    }

}

EncryptionOut 클래스의 write() 메서드는 파일에 쓸 데이터를 암호화 한 뒤에, doDelegate() 메서드를 이용해서 암호화된 데이터를 delegate 객체에 전달합니다. BufferedOut 클래스와 ZipOut 클래스도 비슷한 방식으로 구현합니다.

이제 파일에 데이터를 암호화해서 쓰는 기능이 필요한 곳의 코드를 만들어 봅시다. 이 코드는 다음과 같이 FileOut 객체를 이용해서 EncryptionOut 객체를 생성한 뒤에, EncryptionOut 객체의 writ() 메서드를 실행합니다.

FileOut delegate = new FileOutImpl();
FileOut fileOut = new EncryptionOut(delegate);
fileOut.write(data);

EncryptionOut의 write() 메서드를 실행하면 EncryptionOut의 write() 메서드에서 데이터를 암호화하고, FileOutImpl 객체의 write() 메서드에 암호화 된 데이터를 전달하게 됩니다. 여기서 EncryptionOut 객체는 FileOutImpl 객체가 제공하는 파일 쓰기 기능에 암호화 기능을 추가해 주는 역할을 수행하게 되며, 기존 기능에 새로운 기능을 추가해 준다는 의미에서 EncrpytionOut 객체를 데코레이터라고 부릅니다.

데코레이터 패턴의 장점은 데코레이터를 조합하는 방식으로 기능을 확장할 수 있다는 데에 있습니다.

FileOut delegate = new FileOutImpl();
FileOut fileOut = new EncryptionOut(new ZipOut(delegate));
fileOut.wirte(data);

기능 적용 순서의 변경도 쉽습니다. 아래 코드처럼 데코레이터 생성 순서를 변경해 주기만 하면 됩니다.

// 버퍼 -> 암호화 -> 압축 -> 파일 쓰기
FileOut fileOut = new BufferedOut(new EncryptionOut(new ZipOut(delegate)));

// 암호화 -> 압축 -> 버퍼 -> 파일 쓰기
FileOut fileOut = new EncryptionOut(new ZipOut(new BufferedOut(delegate)));

데코레이터 패턴을 사용하면 각 확장 기능들의 구현이 별도의 클래스로 분리되기 때문에, 각 확장 기능 및 원래 기능을 서로 영향 없이 변경할 수 있도록 만들어 줍니다. FileOutImpl 클래스의 구현을 변경하더라도 EncryptionOut 클래스가 내부 암호화 알고리즘을 변경하더라도 다른 데코레이터나 FileOutImpl 클래스는 영향을 받지 않습니다. 즉, 데코레이터 패턴은 단일 책임 원칙을 지킬수 있도록 만들어 줍니다.

데코레이터 패턴은 전략 패턴/템플릿 메서드 패턴/상태 패턴과 함께 매우 흔하게 사용되는 패턴입니다. 스프링 프레임워크의 경우 트랜잭션 처리를 위해 데코레이터 패턴을 사용합니다. 스프링 프레임워크에서 트랜잭션 관련 설정을 추가하면, 아래 그림과 같이 트랜잭션 기능이 추가된 데코레이터 객체를 런타임에 생성합니다.

Untitled Diagram (1)

스프링 프레임워크는 데코레이터를 이용해서 트랜잭션을 처리합니다.

데코레이터 패턴을 적용할 때 고려할 점

데코레이터 패턴을 구현할 때 고려할 점은 데코레이터 대상이 되는 타입의 기능 개수에 대한 것입니다. 앞서 예제에서 데코레이터 대상이 되는 FileOut 타입은 write() 메서드가 한 개만 정의되어 있어 데코레이터 구현이 비교적 간단하지만, 정의되어 있는 메서드가 증가하게 되면 그만큼 데코레이터의 구현도 복잡해집니다.

데코레이터 구현에서 고려해야 할 또 다른 사항은 데코레이터 객체가 비정상적으로 동작할 때 어떻게 처리할 것이냐에 대한 것입니다.

예를 들어, 게시글 작성 이후 생성된 게시글 데이터를 외부 메시지 서버에 전송해 주는 기능을 별도의 데코레이터로 구현했다고 합시다. 이 경우 런타임의 객체 간 메시지 흐름은 아래와 같습니다.

Untitled Diagram (2)

외부의 메시지 서버에 장애가 발생하면, 위 그림의 실행 흐름에서 6번 과정에 문제가 발생하게 됩니다. 하지만, 1~5번까지의 과정은 정상적으로 실행이되어 트랜잭션이 커밋되었기 때문에 DB에는 새로운 데이터가 정상적으로 추가됩니다. 이 경우 6번 과정의 문제가 발생했다고 해서 클라이언트에 익셉션을 발생시키는 것은 올바른지 고민해 봐야합니다. 왜냐하면 클라이언트가 요구하는 기능인 게시글 등록 자체는 정상적으로 실행되었기 때문입니다.

이런 경우 메시지 전송 데코레이터는 외부 메시지 서버에 데이터 전송에 실패하더라도 익셉션을 발생시키지는 대신 실패 로그를 남기는 방법을 선택할 수 있습니다. 이렇게 함으로써 외부 메시지 서버 연동에 실패하더라도 클라이언트는 에러 결과가 아닌 정상 결과를 볼 수 있으며, 향후에 실패 로그를 이용해서 데이터 재전송 같은 사후 처리를 할 수 있게 됩니다.

데코레이터의 단점은 사용자 입장에서 데코레이터 객체와 실제 구현 객체의 구분이 되지 않기 때문에 코드만으로는 기능이 어떻게 동작하는지 이해하기가 어렵습니다.

예를 들어, 아래 코드에서 writeTo() 메서드는 파라미터로 전달 받은 FileOut 객체를 사용하는데, writeTo() 메서드는 이 FileOut 객체가 단순히 파일에 쓰기만 하는지 아니면 압추을 하는지 등의 여부를 알 수 없습니다. 실제로 FileOut 객체가 어떻게 동작하는지 알려면 런타임에 생성된 객체의 구조를 이해해야 합니다.

public class ImageSource {

    public void writeTo(FileOut out) {
        out.write(imageData);
    }
}

6. 프록시(proxy) 패턴

제품 목록을 보여주는 GUI 프로그램은 아래 그림처럼 목록 중 일부를 화면에 보여주고, 스크롤을 할 때 나머지 목록을 화면에 표시할 수 있습니다.

Untitled Diagram (3)

제품 목록을 구성할 때 관련된 모든 이미지를 로딩하도록 구현할 수 있는데, 이 경우 블필요하게 메모리를 사용하는 문제가 발생할 수 있습니다.

예를 들어, 목록 하단에 위치한 이미지는 실제로 스크롤을 하기 전까지는 화면에 보이지 않음에도 불구하고 목록을 구성할 때 메모리에 이미지 정보를 로딩하게 됩니다. 특히 이미지를 로컬 파일 시스템이 아닌 웹에서 읽어 온다면 이미지 로딩으로 인해 제품 목록을 보여주기 까지 대기 시간이 길어지게 됩니다.

불필요한 이미지 로딩에 따른 메모리 낭비와 이미지 로딩에 따른 화면 출력 대기 시간이 길어지는 문제를 해결하는 방법은 이미지가 실제로 화면에 보여질 때 이미지 데이터를 로딩하는 것입니다. 이미지가 필요할 때 이미지 데이터를 로딩하는 기능을 추가하는 가장 쉬운 방법은 필요 시점에 Image 클래스를 이용해서 이미지를 로딩하는 DynamicLoadingImage 클래스를 추가하고, 아래 그림과 같이 목록을 보여주는 클래스에서 Image 클래스 대신 DynamicLoadingImage를 사용하게 만드는 것입니다.

Untitled Diagram

하지만, 위 그림과 같이 구현하게 되면, 이미지 로딩 방식을 변경해야 할 때 ListUI 코드를 변경해야 하는 문제가 발생합니다. 예를 들어, 화면에 보여줄 목록의 개수가 5개 미만이면 바로 로딩하고 5개 이상이면 동적으로 로딩하도록 구현해야 할 때 ListUI 코드는 Image 클래스와 DynamicLoadingImage 클래스를 구분하는 조건문을 갖게 될 것입니다.

이런 상황에서 ListUI 변경 없이 이미지 로딩 방식을 교체할 수 있도록 해주는 패턴이 프록시 패턴입니다. 프록시 패턴은 실제 객체를 대신하는 프록시 객체를 사용해서 실제 객체의 생성이나 접근 등을 제어할 수 있도록 해 주는 패턴으로서, 구조는 아래 그림과 같습니다.

Untitled Diagram (1)

위 그림에서 Image 인터페이스는 이미지를 표현하며 ListUI는 Image 타입을 이용해서 화면에 이미지를 표시합니다. RealImage 클래스는 실제로 이미지 데이터를 로딩해서 메모리에 보관하는 콘크리트 클래스입니다. 여기서 중요한 건 ProxyImage 클래스입니다.

PrxoyImage 클래스가 프록시 패턴에서 프록시 역할을 하는데, ProxyImage 클래스는 아래 코드처럼 구현됩니다.

public class ProxyImage implements Image {

    private String path;
    private RealImage image;

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

    public void draw() {
        if(image == null) {
            image =new RealImage(path); // 최초 접근 시 객체 생성
        }
        image.draw();
    }
}

ProxyImage 클래스는 draw() 메서드가 호출되기 전까지 RealImage 객체를 생성하지 않습니다. ProxyImage 클래스의 draw() 메서드는 최초로 draw() 메소드를 실행할 때 RealImage 객체를 생성하고, 그 뒤로 생성된 RealImage 객체의 draw() 메서드를 호출합니다.

ListUI 클래스는 Image 타입을 사용하기 때문에 실제 타입이 RealImage 인지 PrxoyImage인지 여부는 모릅니다. 단지, Image 타입의 draw() 메서드를 이용해서 이미지를 그려 달라고 할 뿐입니다. ListUI 클래스가 다음과 같이 Image 타입의 목록을 가지고 있고, 스크롤이 되는 시점에 해당 Image 객체의 draw() 메소드를 호출한다고 합시다.

public class ListUI {
   
    private List<Image> images;
   
    public ListUI(List<Image> images) {
        this.images = images;
    }

    // 스크롤 시, 화면에 표시되는 이미지를 표시
    public void onScroll(int start, int end) {
        for(int i = start; i <= end; i++) {
            Image image = images.get(i);
            image.draw();
        }
    }
}

ListUI 클래스의 images 필드에 보관된 Image 객체의 실제 타입이 ProxyImage인 경우, 위 코드의 onScroll() 메서드에서 이미지를 그리는 과정은 다음과 같이 동작하게 됩니다.

PrxoyImage 객체는 최초에 draw() 메서드가 실행될 때 RealImage 객체를 생성하기 때문에, ProxyImage 객체의 draw() 메서드가 호출되기 전에는 RealImage 객체를 생성되지 않으므로 메모리에 이미지 데이터를 로딩하지 않습니다. 따라서 화면에 표시되지 않는 이미지를 로딩하기 위해 불필요하게 메모리를 낭비하는 상황을 방지할 수 있게 됩니다.

또한, ListUI 클래스는 이미지가 언제 로딩되는지 알 필요가 없기 때문에, 이미지 로딩 정책을 변경하더라도 ListUI 클래스의 코드는 영향을 받지 않습니다.

예를 들어, 상위 4개는 바로 이미지를 로딩하고 나머지는 화면에 보여지는 순간에 로딩하도록 구현해야 할 경우 다음 코드처럼 RealImage 객체와 PrxoyImage 객체를 섞어서 ListUI에 전달해 주면 됩니다.

List<String> paths = ...
List<Image> images = new ArrayList<Image> (paths.size());
for(int i = 0; i < paths.size(); i++){
    if (i < 4) {
        images.add(new RealImage(paths.get(i)));
    } else {
        images.add(new ProxyImage(paths.get(i)));
    }
}
// 이미지 로딩 정책의 변경이 ListUI에 영향을 주지 않습니다.
ListUI listUI = new ListUI(images);

ProxyImage처럼 필요한 순간에 실제 객체를 생성해 주는 프록시를 가상 프록시라고 부르는데, 프록시에는 가상 프록시 외에 보호 프록시나 원격 프록시 등이 존재합니다. 보호 프록시는 실제 객체에 대한 접근을 제어하는 프록시로서, 접근 권한이 있는 경우에만 실제 객체의 메서드를 실행하는 방식으로 구현합니다. 원격 프록시는 RMI(Remote Method Invocation)처럼 다른 프로세스에 존재하는 객체에 접근할 때 사용되는 프록시입니다. 원격 프록시는 내부적으로 IPC(Inter process communication)이나 TCP 통신을 이용해서 프로세스의 객체를 실행하게 됩니다.

프록시 패턴을 적용할 때 고려할 점

프록시를 구현할 때 고려할 점은 실제 객체를 누가 생성할 것이냐에 대한 것입니다.
PrxoyImage와 같은 가상 프록시는 필요한 순간에 실제 객체를 생성하는 경우가 많기 때문에, ProxyImage 클래스에서 직접 RealImage 타입을 사용한 것처럼 가상 프록시에서 실제 생성할 객체의 타입을 사용하게 됩니다. 반면에 접근 제어를 위한 목적으로 사용되는 보호 프록시는 보호 프록시 객체를 생성할 때 실제 객체를 전달하면 되므로, 실제 객체의 타입을 알 필요 없이 추상화 타입을 사용하면 됩니다.

위임 방식이 아닌 상속을 사용해서 프록시를 구현할 수도 있습니다. 예를 들어, 특정 기능은 관리자만 실행할 수 있어야 한다고 할 경우 프록시를 사용할 수 있을 것입니다.

이대 보호 프록시는 다음과 같이 상위 클래스의 메서드를 제정의하는 방법으로 구현할 수 있습니다.

public class ProtectedService extends Service {

    @Override
    public void someMethod() {
        if(! CurrentContext.getAuth().isAdmin()) 
            throw new AccessDeniedException();

        super.someMethod();        
    }
}

상속 방식을 사용하면 위임 방식에 비해 구조가 단순해서 구현이 비교적 쉽습니다. 하지만, 상속 방식의 프록시는 객체를 생성하는 순간 실제 객체가 생성되기 때문에 가상 프록시를 구현하기에는 적합하지 않습니다.

위임 기반의 프록시 패턴 구현은 앞서 살펴본 데코레이터 패턴의 구현과 매우 유사한데, 이 두 패턴은 의도에서 분명한 차이를 보입니다. 프록시 패턴의 경우 실제 객체에 대한 접근을 제어하는데 초점이 맞춰져 있는 반면에 데코레이터 패턴은 기존의 객체의 기능을 확장하는데 초점을 맞추고 있습니다. 따라서 클래스의 이름을 부여할 때에는 의도에 맞는 단어를 선택해야 합니다.

7. 어댑터 패턴

웹 게시판에 통합 검색 기능을 추가해 달라는 요구가 들어와서 DB를 이용해서 검색 기능을 구현하기로 결정한 상황을 생각해 봅시다. 통합 검색 기능을 정의하기 위해 아래 그림과 같이 SearchService 인터페이스를 작성하고, DB를 이용한 DBSearchService 클래스를 구현하였습니다.

Untitled Diagram (2)

게시글의 개수가 빠르게 증가하면서 SQL의 like를 이용한 검색 속도 성능에 문제가 발생하기 시작했습니다. 검색 속도의 문제를 해결하기 위해 Tolr라는 오픈 소스 검색 서버를 도입하기로 결정했습니다. Tolr 자료를 확인한 프로그래머는 별도로 제공하는 TolrClient 모듈을 사용하면 Tolr와 쉽게 연동할 수 있다는 것을 알게 되었습니다.

문제는 아래 그림에서 보듯이 TolrClient가 제공하는 인터페이스와 SearchService 인터페이스가 맞지 않다는 점입니다. WebSearchRequestHandler 클래스를 비롯해서 여러 클래스가 SearchService를 사용하도록 만들어졌기 때문에, SearchService 대신 TolrClient를 사용하도록 변경하는 작업은 많은 변경을 요구합니다.

Untitled Diagram (1)

이렇게 클라이언트(WebSearchRequestHandler)가 요구하는 인터페이스와 재사용하려는 모듈의 인터페이스가 일치하지 않을 때 사용할 수 있는 패턴이 어댑터(Adapter) 패턴입니다. 인터페이스가 맞지 않는 문제를 해결하기 위해 어댑터 패턴을 적용하면 아래 그림과 같은 구조를 갖게 됩니다.

Untitled Diagram

위 글에서 어댑터에 해당하는 SearchServiceTolrAdapter 클래스는 TolrClient를 SearchService 인터페이스에 맞춰 주는 책임을 갖습니다. SearchServiceTolrAdapter 클래스의 search() 메서드는 아래 코드에서 보듯이 TolrClient 객체를 실행하고 그 결과를 SearchService 인터페이스에 맞는 리턴타입으로 변환해 줍니다.

// TolrClient를 SearchService에 맞춰주는 객체 위임 방식 어댑터 구현
public class SearchServiceAdapter implements SearchService {

    private TolrClient tolrClient = new TolrClient();

    public SearchResult search(String keyword) {
        // keyword를 tolrClient가 요구하는 형식으로 변환.
        TolrQuery tolrQuery = new TolrQuery(keyword);
        // TolrClient 기능 실행
        QueryResponse response = tolrClient.query(tolrQuery);
        SearchResult result = convertToResul(response);
        return result;
    }

    private SearchResult convertToResult(QueryResponse response) {
        List<TolrDocument> tolrDocs = response.getDocumentList().getDocuments();
        List<SearchDocument> docs = new ArrayList<SearchDocument> ();
        for(TolrDocument tolrDoc: tolrDocs) {
            docs.add(new SearchDocument(tolrDoc.getId(), ...);
        }
        return new SearchResult(docs);
    }
}

SearchServiceTolrAdapter 클래스는 SearchService 인터페이스를 구현하고 있으므로, WebSearchRequestHandler 클래스의 코드 수정 없이 DB 기반 통합 검색에서 TolrClient를 이용한 통합 검색으로 구현을 변경할 수 있게 됩니다.

어댑터 패턴이 적용된 예는 SLF4J라는 로깅 API 입니다. SLF4J는 단일 로깅 API를 사용하면서 자바 로깅, log4j, LogBack 등의 로깅 프레임워크를 선택적으로 사용할 수 있도록 해주는데, 이 때 SLF4J가 제공하는 인터페이스와 각 로깅 프레임워크를 맞춰 주기 위해 어댑터를 사용하고 있습니다.

Untitled Diagram (2)

어댑터 패턴은 개방 페쇄 원칙을 따를 수 있도록 도와줍니다. 위 그림에서 로깅 프레임 워크를 LogBack으로 교체하고 싶다면 LogBack을 slf4j-api 패키지의 Logger로 맞춰주는 새로운 어댑터만 구현해 주면 됩니다.

이 과정에서 slf4j-api 패키지의 Logger를 사용하는 코드는 전혀 영향을 받지 않습니다.

앞서 SearchServiceTolrAdapter 클래스는 TolrCliet를 조립하는 방법으로 구현했는데, TolrClient를 상속받는 방법으로 어댑터를 구현할 수도 있습니다.

Untitled Diagram

상속을 이용해서 어댑터를 구현하는 경우, SearchServicTolrAdapter 클래스의 search() 메서드는 상위 클래스인 TolrClient에 정의된 메서드를 호출하는 방식으로 코드를 작성하게 됩니다. 아래 코드는 상속을 이용한 어댑터 패턴의 구현 예입니다.

public class SearchTolrAdapter extends TolrClient implements SearchService {

    public SearchResult search(String keyword) {
        // keyword를 tolrClient가 요구하는 형식으로 변환
        TolrClient tolQuery = new TolrQuery(keyword);
        //TolrClient 기능 실행
        QueryResponse response = super.query(tolrQuery);
        //TolrClient의 결과를 SearchResult로 변환
        SearchResult result = convertResult(response);
        return result;
    }

    private SearchResult convertResult(QueryResponse response) {
        List<TolrDocument> tolDocs = response.getDocumentList().getDocuments();

        List<SearchDocument> docs = new ArrayList<SearchDocument> ();

        for(TolrDocument tolrDocument : tolDocs) {
            docs.add(new SearchDocument(tolrDocument.getId(), ...));
        }
        return new SearchResult(docs);
    }
}

클라이언트가 사용하는 SearchService가 인터페이스가 아닌 일부 구현이 포함된 추상 클래스라면, 자바와 같이 클래스 단일 상속만을 지원하는 언어에서는 클래스의 상속을 이용한 어댑터 구현에 제약을 받게 됩니다.

0개의 댓글