[객체지향] GoF 핵심 디자인 패턴

Hyebin Lee·2022년 2월 25일
0

JAVA

목록 보기
5/6
post-thumbnail

디자인 패턴

GoF 의 디자인 패턴은 객체의 생성. 기능의 확장, 기능의 변경. 구조 등과 관련된 약 20여 개에 이르는 패턴을 정리하고 있다. 이번 장에서는 GoF 패턴 중에서도 영역에 상관없이 자주 사용되는 패턴을 소개할 것이다.

전략(Strategy) 패턴

특정 기능을 하는 클래스에서 해당 기능과 기능을 실행하는 것을 분리하는 패턴을 전략패턴이라고 한다.

💡 한 매장이 상황에 따라 다른 가격 할인 정책을 적용한다고 가정해보자. 매장을 열자마자 들어온 첫 손님을 위한 첫손님 할인 정책과 저녁 시간대에 신선도가 떨어진 과일에 대한 덜 신선한 과일 할인 정책이 있다면 가격을 계산하는 모듈 설계는 어떻게 해야 할까?

가장 원시적으로 쓰는 방법은 if-else문이다. 그러나 이는 계산 모듈에 서로 다른 계산 정책들이 섞여있어 정책이 추가될 수록 코드 분석을 어렵게 하며 수정하는 것이 점점 어려워 진다는 단점이 있다.

따라서 우리는 DiscountStrategy 라는 전략 인터페이스를 하나 생성해주어 기존의 계산 모듈의 할인 가격 계산하기기능과 계산 실행 모듈을 분리한다

이와 같은 전략 패턴을 다음과 같이 구현할 수 있다.

public class Calculator{

	private DiscountStrategy discountStrategy;

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

public int calculate(List<Item> items){
		int sum = 0;
		for (Item item : items){
			sum += discountStrategy.getDiscountPrice(item);
	}
	return sum;
}
}

--------------------------------
public interface DiscountStrategy{

	int getDiscountPrice(Item item);

}

이에 대한 활용은 아래와 같이 할 수 있다.

public class FirstGuestDiscountStrategy implements DiscountStrategy{

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

------------------------------
public DiscountStrategy strategy;

public void onFirstGuestButtonClick(){
strategy = new FirstGuestDiscountStrategy();
}

public void onCalculationButtonClick(){
	Calculator cal = new Calculator(strategy);
	int price = cal.calculate(items);
}

전략 패턴을 적용할 때 얻을 수 있는 이점은 콘텍스트 코드의 변경 없이 새로운 전략을 추가할 수 있다는 점이다. 새로운 할인이 추가되면 그저 DiscountStrategy를 상속받는 객체를 새로 생성해주기만 하면 된다.

일반적으로 if else 로 구성된 코드 블록이 비슷한 기능(비슷한 알고리즘) 을 수행하는 경우 전략패턴을 적용함으로써 코드를 확장 가능하도록 변경할 수 있다.

완전히 동일한 기능을 제공하지만 성능의 장단점에 따라 알고리즘을 선택해야 하는 경우에도 전략 패턴을 사용한다.

템플릿 메서드 패턴

실행 과정과 단계는 동일한데 각 단계 중 일부의 구현이 다른 경우에 사용할 수 있는 태펀이 템플릿 메서드 패턴이다. 템플릿 메서드 패턴은 다음과 같이 두 가지로 구성된다.

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

한 마디로 추상 클래스를 만드는데 동일한 부분은 추상클래스에서 구현하고 동일하지 않은 부분은 추상메서드로 구현했다가 각 하위 클래스에서 구체화(개별화) 하도록 하는 패턴이다.

따라서 상위 클래스는 실행 과정을 구현한 메서드를 제공한다. 이 메서드는 기능을 구현하는데 필요한 각 단계를 정의하며 이 중 일부 단계는 추상 메서드를 호출하는 방식으로 구현된다.

중복된 코드가 반복되면 유지보수가 그만큼 어려워지는데 이 패턴은 중복되는 코드를 방지한다는 점에서 장점이 있다.

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

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

public class SuperCar extends ZetEngine{

	@Override
	public void turnOn(){
		if(notReady) beep();
		else super.turnOn();
}
}

위의 코드와 같이 상위 클래스의 메서드를 오버라이드 해서 상위 클래스의 메서드를 쓸 것인지, 다르게 구현해서 전혀 다른 기능으로 쓸 것인지는 하위 클래스가 흐름 제어를 했었다.

그러나 템플릿 메서드에서는 상위 클래스가 흐름을 제어한다.
템플릿 메서드 (추상 메서드가 아닌 public 상위 클래스 메서드)에서 호출하는 메서드를 추상메서드로 정의하면 그 메서드는 하위 타입에서 반드시 구현되어야 한다.

하지만 protected 메서드 중 추상 클래스에서 빈 구현을 갖는 메서드는 하위 타입에서 구현 시 구현을 해도 되고 안해도 되며 따라서 상위 클래스 입장에서는 제어 대상이 되는 확장 지점이 된다.

이와 같이 상위 클래스에서 실행 시점이 제어되고 기본 구현을 제공하면서 하위 클래스에서 알맞게 확장할 수 있는 메서드를 훅(Hook) 메서드라고 부른다.

참고) protected 메서드는 오로지 상위 클래스에서 하위 타입을 구현할 때 새로 구현될 수 있는 메서드이며 자체적으로 사용이 불가능하고 반드시 public 함수 안에서 호출되는 식으로 쓰이게 된다.

상태(State) 패턴

상태에 따라 동일한 기능 요청의 처리를 다르게 하는 것을 상태 패턴이라고 한다.

예를 들어 자판기에 코인을 넣을 때 자판기의 상태에 따라 다른 처리를 하는 것을 구현하려고 한다면

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

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

위의 코드와 같이 상태에 따라 다른 처리를 하는 상황에서 상태 패턴을 구현할 수 있다.

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

콘텍스트는 필드로 상태 객체를 갖고 있다. 콘텍스트는 클라이언트로 부터 기능 실행 요청을 받으면 상태 객체에 처리를 위임하는 방식으로 구현된다. 예를 들어 자판기 기능을 제공하는 VendingMachine 클래스의 insertCoin() 메서드와 select() 메서드는 state.select(productId, this) 형태로 위임할 수 있다.

그리고 콘크리트 클래스에서 위임받은 콘텍스트 클래스를 사용할 수 있도록 아래와 같이 코드를 구현한다.

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){
		SoundUtil.beep();
}

이와 같이 상태 패턴으로 구현할 때의 장점은 새로운 상태가 추가되더라도 콘텍스트 코드가 받는 영향은 최소화된다는 점이다. 또 상태에 따른 동작을 구현한 코드가 각 상태 별로 구분되기 때문에 수정이 쉽다는 장점이 있다.

상태 변경은 어디서 해야 하는가?

콘텍스트의 상태변경은 상태 객체콘텍스트 두 군데에서 변경할 수 있다.

  • 상태 객체에서 변경: 위의 코드와 같은 경우이다. 콘텍스트에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있게 된다. 하지만 상태 변경 규칙이 여러 클래스에 분산되어 있기 때문에 상태 구현 클래스가 많아질 수록 상태 변경 규칙을 파악하기 어렵다는 단점이 있다. 또한 한 상태 클래스에서 다른 상태 클래스에 대한 의존도 발생한다.
  • 콘텍스트에서 변경: 비교적 상태 개수가 적고 상태 변경 규칙이 거의 바뀌지 않는 경우에 유리하다. 왜냐면 상태 종류가 지속적으로 변경되거나 상태 변경 규칙이 자주 바뀔 경우 콘텍스트의 상태 변경 처리 코드가 복잡해지기 때문이다.
public class VendingMachine{

	private State state;
	
	public void insertCoin(int coin){
		state.increaseCoin(coin,this);
		if( hasCoin())
			changeState(new SelectableState());
}

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

추가적으로 기계에 코인이 들었는지 아닌지 확인하는 등의 기계의 상태에 대한 코드를 추가하고 싶으면 콘텍스트에서 직접 hasCoin() 과 같은 메서드를 작성할 수 있다.

데코레이터(Decorator) 패턴

상속을 하면 기능을 확장할 수 있다는 장점이 있지만 자칫 잘못하면 다양한 조합의 기능 확장이 요구될 때 클래스가 불필요하게 증가할 수 있다. 이러한 경우에 사용할 수 있는 것이 데코레이터 패턴이다. 데코레이터 패턴은 상속이 아닌 위임방식으로 기능을 확장해 나간다.

Untitled

데코레이터 패턴은 다음과 같이

  1. 본래 가장 상위에 있던 클래스를 인터페이스로 빼서 하위 타입마다 비슷하게 작동하는 (혹은 수정해야 할) 메서드를 정의하고 있다.
  2. 실제 상위 인터페이스의 기능을 구현하는 클래스는 따로 뺀다.
  3. 기능 확장을 위해 하위 타입은 FileOutImpl을 상속받지 않고 Decorator라 불리는 별도의 추상 클래스를 상속받는다.
  4. 추상 클래스 Decorator는 필드로 상위 인터페이스 객체를 선언해서 위임 대상으로 삼고, 하위 타입마다 재구성해서 사용할 메서드를 내부에서 상위 인터페이스에서 정의한 메소드를 활용하여 위임대상의 기능을 작동시킨다.
public abstract class Decorator implements FileOut{
	 private FileOut delegate; //위임대상
	 public Decorator(FileOut delegate){
		this.delegate = delegate;
}

protected void doDelegate(byte[] data){
		delegate.write(data); //dekegate에 쓰기 위임 
}
  1. Decorator 클래스를 상속받은 하위 타입 클래스는 자신의 기능을 수행한 뒤에 상위 클래스에서 정의한 메소드를 이용해서 인터페이스의 기능을 위임하도록 한다.
public class EncryptionOut extends Decorator{

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

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

public byte[] encrypt(byte[] data){

}
}
  1. 해당 기능이 필요한 곳에 활용은 아래와 같이 한다.
FileOut delegate = new FileOutImpl();
FileOut fileOut = new EncryptionOut(delegate);
fileOut.write(data);

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

여러 기능을 복합적으로 사용할 경우에는 아래와 같이 상위 인터페이스 객체 구현을 중첩으로 해주면 된다.

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

데코레이터 패턴을 구현할 때 고려할 점은 데코레이터 대상이 되는 타입의 기능 개수에 대한 것이다. 정의되어 있는 메서드가 증가하게 되면 그만큼 데코레이터의 구현도 복잡해진다. 또 마지막코드와 같이 데코레이터 패턴으로 여러 하위 타입이 중첩된 경우 특정 부분에서 비정상적인 동작을 할 때 적절히 log를 찍어 사후처리를 하는 등 예외상황에 대한 처리가 필요하다.

프록시 패턴(Proxy)

프록시 패턴은 실제 객체를 대신하는 프록시 객체를 사용해서 실제 객체의 생성이나 접근 등을 제어하는 패턴이다.

스크롤해서 이미지 리스트에 있는 이미지들을 보여주는 프로젝트를 구현할 때 어차피 화면에 보이지도 않을 이미지를 미리 가져와있는 것은 너무 비효율적이다. 그래서 스크롤 범위 내에 있는 이미지만 보여주는 코드를 구성하고 싶은데 이미지 로딩 방식을 변경할때 마다 List UI 코드를 변경해야 하는 문제가 발생한다. 프록시 패턴은 콘텍스트에서 하나의 클래스만(Image) 사용하는 것처럼 보이지만 실제로는 다른 해당 클래스의 하위 타입의 객체에 접근하도록 하는 패턴이다.

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(); //RealImage 객체에 위임
}
}

예를 들어 상위 4개는 바로 이미지로 로딩하고 나머지는 화면에 보여지는 순간에 로딩하도록 구현해야 할 경우 다음 코드처럼 RealImage 객체와 ProxyImage 객체를 섞어서 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);

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

  1. 가상 프록시에서 실제 생성할 객체의 타입을 사용하는 경우: 위의 예시와 같은 경우로 필요한 순간에 실제 객체를 생성하는 가상프록시의 경우가 많은 경우 가상 프록시에 실제 생성할 객체의 타입을 사용하면 된다.
  2. 접근 제어를 목적으로 사용되는 보호 프록시: 객체 생성할 때 실제 객체를 전달하면 되므로 실제 객체의 타입을 알 필요 없이 그냥 추상 타입을 이용하면 된다. 이때는 위임 방식이 아닌 상속을 사용한다.
public class ProtectedService extends Service{

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

어뎁터(Adpater) 패턴

클라이언트(Handler)가 요구하는 인터페이스와 재사용하려는 모듈의 인터페이스가 일치하지 않을 때 사용할 수 있는 패턴이 어뎁터(Adpater) 패턴이다. 인터페이스가 맞지 않는 문제를 해결하기 위해 어뎁터 패턴을 적용할 수 있다.

이 때 어뎁터 클래스는 재사용하려는 모듈의 인터페이스를 클라이언트가 요구하는 인터페이스로 맞춰 주는 책임을 갖는다. 따라서 어뎁터 클래스의 메서드는 모듈 인터페이스 객체를 실행하고 그 결과를 요구하는 인터페이스에 맞는 리턴 타입으로 변환해 준다.

SL4J 로깅

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

어뎁터 패턴은 개방 폐쇄 원칙을 따를 수 있도록 도와준다.

상속을 이용한 어뎁터 구현

위에서 설명한 방식은 어뎁터 클래스가 모듈인터페이스를 조립하는 방법이였는데 상속을 통해서도 어뎁터를 구현할 수 있다.

이때는 모듈 인터페이스를 어뎁터에 extends 한뒤 모듈 인터페이스의 기능 실행 결과를 super.기능() 형태로 가져온 후 요구하는 인터페이스 형식으로 변화시켜 주면 된다.

0개의 댓글