8주차 정리

2025. 5. 3. 16:55·Book/디자인 패턴의 아름다움

행동 디자인 패턴은 주로 클래스나 객체 간의 상호 작용 문제를 해결한다.

옵저버 패턴

정의

발행-구독 패턴이라고도 한다. GOF 디자인 패턴에서는

많은 객체들 사이에서 일대일 의존 관계가 정의되어 있을 때, 어느 한 객체의 상태가 변경되면 이 객체에 의존하고 있는 모든 객체는 자동으로 알림을 받는다.

일반적으로 의존 대상이 되는 객체를 피관찰자, 즉 옵저버블이라고 하고 의존하고 있는 객체를 관찰자, 즉 옵저버라고 한다. 이름이 무엇이든 시나리오가 앞에서 이야기 했던 정의를 충족한다면 옵저버 패턴에 속한다.

의미

옵저버 패턴은 옵저버의 코드와 옵저버블의 코드를 디커플링한다.

적용

분류 방식의 관점에서는 세 가지 관점이 있다.

  • 동기식 차단 옵저버 패턴
  • 비동기식 비차단 옵저버 패턴
  • 교차 프로세스 옵저버 패턴

비동기식 비차단 옵저버 패턴

범용성과 재사용성을 고려하지 않고 단순화한 버전을 구현하는 것은 매우 쉽다. 두 가지 방식이 있는데,

  1. 기존 동기식 차단 옵저버 패턴의 옵저버블 함수에서 새로운 스레드를 생성해 코드를 실행하는 것
  2. 옵저버를 등록하는 함수에서 스레드 풀을 사용하여 모든 옵저버블의 함수를 각각 실행하는 것

첫 번째 방식은 스레드를 빈번하게 생성한 후 소멸시키기 때문에 실행 시간이 오래 걸리며, 스레드가 동시에 실행되는 개수를 제어할 수 없기 때문에, 스레드를 너무 많이 생성하면 스택 오버플로우가 발생한다.

두 번째 방식은 첫 번째 방식의 문제를 해결하기 위해, 스레드 풀을 사용하지만, 옵저버블 함수에 스레드 풀과 비동기 실행 논리가 포함되어 비즈니스 코드의 복잡성과 유지 비용이 증가하는 문제가 있다.

이 밖에도 요구 사항이 지나치게 엄격히 까다로워 동기 차단 방식과 비동기 비차단 방식 간에 유연한 전환이 필요하다면, 옵저버를 등록하는 함수가 쉬지 않고 계속 수정되어야 한다. 게다가 프로젝트에서 두 개 이상의 비즈니스 모듈이 비동기 비차단 옵저버 패턴을 사용해야 한다면 이 코드를 재사용할 수는 없다.

EventBus 프레임워크

EventBus는 옵저버 패턴을 구현하는 백본 코드를 제공하기 때문에, 직접 하나하나 구현하지 않아도 이 프레임워크를 기반으로 옵저버 패턴을 쉽게 구현할 수 있다.

생각해보기

프록시 패턴을 이용해서, 스레드 풀을 생성하거나 옵저버를 등록할 때에 기존 로직을 상속받아서 비즈니스 코드와 관련 없는 것을 지우는 개선은 어떨까?

템플릿 메서드 패턴(1)

주로 재사용 문제와 확장 문제를 해결하기 위해 사용된다.

템플릿 메서드 패턴은 하나의 메서드 안에 정의된 알고리즘 골격으로, 일부 작업 단계를 하위 클래스로 넘길 수 있어 하위 클래스가 알고리즘의 전체 구조를 변경하지 않고 알고리즘의 일부 단계를 재정의할 수 있다.

여기서 알고리즘은 넓은 의미의 비즈니스 논리에 가까우며, 흔히 생각하는 데이터 구조를 다루는 알고리즘이 아님. 여기서 알고리즘 프레임워크가 바로 템플릿이며, 알고리즘 프레임워크를 포함하고 있는 메서드가 템플릿 메서드다.

템플릿 메서드 패턴의 역할: 확장

확장은 코드의 확장성을 의미하는 것이 아니라 프레임워크의 확장성을 의미하며, 제어 반전과 유사한 면이 있다. 이로 인해 템플릿 메서드 패턴은 프레임워크 개발에 자주 사용되며, 프레임워크 사용자는 프레임워크의 소스 코드를 추가로 수정하지 않아도 프레임워크의 기능을 다시 정의하는 것이 가능하다.

생각해보기

계층화를 해야할 것 같은데.. 어떻게 해결하지? 더 작은 단위로 쪼개고, 퍼사드 패턴마냥 불러와야 하나.. ? 싶다.

템플릿 메서드 패턴(2)

재사용과 확장 문제는 콜백을 통해서도 해결할 수 있기 때문에, 프레임워크, 클래스 라이브러리, 구성 요소 등을 설계할 때 종종 콜백이 사용된다.

콜백의 원리와 구현

일반 함수가 단방향 호출인데 반해 콜백은 양방향 호출 관계다. 클래스 A는 클래스 B에 함수 F를 콜백 함수로 미리 등록해두고, 클래스 A가 클래스 B의 P 함수를 호출하면, 클래스 B는 클래스 A가 등록해둔 F 함수를 차례로 호출한다. 클래스 A가 클래스 B를 호출하면, 다시 클래스 B가 클래스 A를 호출하는 메커니즘을 콜백이라고 한다.

public interface ICallback {
	void methodToCallback();
}

public class BClass {
	public void process(ICallback callback) {
		...
		callback.methodToCallback();
		...
	}
}

public class AClass {
	public static void main(String[] args) {
		BClass b = new BClass();
		b.process(new ICallback {
			@Override
			public void methodToCallback() {  // 콜백 함수
				System.out.println("Call back me.");
			}
		}
	}
}

콜백은 동기식 콜백과 비동기식 콜백으로 나뉜다. 동기식 콜백은 함수가 반환되기 전에 콜백 함수를 실행하는 것이고, 비동기식 콜백은 지연 콜백이라고도 하며 함수가 반환된 후 콜백 함수를 실행하는 것이다.

애플리케이션 관점에서 보면 동기식 콜백은 템플릿 메서드 패턴과 매우 유사하고, 비동기식 콜백은 옵저버 패턴과 매우 유사하다.

템플릿 메서드 패턴과 콜백의 차이점

응용 시나리오와 코드 구현의 관점에서 템플릿 메서드 패턴과 콜백의 비교

응용 시나리오의 관점

거의 동일하나, 둘 다 대규모 알고리즘 프레임워크에 속하며, 코드의 재사용과 확장을 위해 특정 단계에 자유롭게 교체할 수 있다. 반면 비동기식 콜백과 템플릿 메서드 패턴은 상당히 다르며 오히려 옵저버 패턴과 비슷하다.

코드 구현의 관점

완전히 다르다. 콜백은 합성 관계를 기반으로 한 객체를 다른 객체로 전달하는 객체 간의 관계이며, 템플릿 메서드 패턴은 상속 관계를 기반으로 구현되며, 하위 클래스가 상위 클래스의 추상 메서드를 재정의하는 클래스 간의 관계다.

코드 구현 측면에서 콜백은 템플릿 메서드 패턴보다 유연하며, 세 가지 측면에서 확인할 수 있다.

  1. Java와 같이 단일 상속만 지원하는 프로그래밍 언어에서는 템플릿 메서드 패턴을 기반으로 작성된 하위 클래스는 이미 상위 클래스를 상속받은 상태이기 때문에, 추가적으로 다른 클래스를 상속받을 수 없다.
  2. 콜백은 클래스를 미리 정의할 필요 없이 익명 클래스를 사용하여 콜백 객체를 생성할 수 있지만, 템플릿 메서드 패턴은 구현에 따라 매번 다른 하위 클래스를 정의해야 한다.
  3. 하나의 클래스에 여러 개의 템플릿 메서드가 정의되어 있는 경우 각 메서드는 해당하는 추상 메서드를 가지며, 템플릿 메서드 중 하나만 사용하더라도 하위 클래스가 정의되어 있는 모든 추상 메서드를 구현해야 한다. 하지만 콜백은 더 유연해서, 콜백 객체를 사용된 템플릿 메서드에 주입하면 된다.

생각해보기

이벤트에 따라 옵저버 패턴처럼 처리할 수 있을 것 같다. 비동기식 콜백은 무언가 요청에 대한 반환값을 받고나서 그 반환에 대한 이벤트를 분기처리 해 옵저버 패턴처럼 이벤트를 콜백 받는 방법도 있을 것이다.

전략 패턴

전략 패턴은 일반적으로 매우 복잡한 형태의 if-else 분기와 switch-case 분기를 피하기 위해 사용된다. 전략 패턴은 이 밖에도 템플릿 메서드 패턴과 마찬가지로 프레임워크를 확장하는 데 사용된다.

알고리즘 클래스 컬렉션을 정의하고, 각 알고리즘을 개별적으로 캡슐화하여, 이를 서로 교환 가능하게 만드는 것이며, 전략 패턴은 이를 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.

팩터리 패턴이 객체의 생성과 사용을 디커플링하고, 옵저버 패턴이 옵저버와 옵저버블을 디커플링하는 것과 마찬가지로, 전략 패턴은 전략의 정의, 생성, 사용을 디커플링할 수 있다.

  1. 전략의 정의

모든 전략 클래스는 동일한 인터페이스를 구현하고 클라이언트 코드는 구현이 아닌 인터페이스 기반으로 프로그래밍되기 때문에 다른 전략으로 대체 가능한 유연성을 갖추고 있다.

public interface Strategy {
	void algorithmInterface();
}

public class ConcreteStrategyA implements Strategy {
	@Override
	public void algorithmInterface() { ... }
}

public class ConcreteStrategyB implements Strategy {
	@Override
	public void algorithmInterface() { ... }
}
  1. 전략의 생성

생성 논리를 캡슐화하고 클라이언트 코드에서 생성과 관련된 세부 정보를 보호하기 위해 유형에 따라 생성 전략의 논리를 추출하여 팩터리 클래스에 넣는다.

public class StrategyFactory {
	private static final Map<String, Strategy> strategies = new HashMap<>();
	static {
		strategies.put("A", new ConcreteStrategyA());
		strategies.put("B", new ConcreteStrategyB());
	}
	
	public static Strategy getStrategy(String type) {
		if (type == null || type.isEmpty()) {
			throw new IllegalArgumentException("type shoud be not empty.");
		}
		return strategies.get(type);
	}
}

일반적으로 전략 클래스가 stateless인 경우, 즉 멤버 변수를 포함하지 않고 순수한 알고리즘 구현만 포함한다면 이 전략 객체를 공유하여 사용할 수 있으며, 매번 getStrategy() 함수를 호출할 때 새로운 전략 객체를 생성할 필요가 없다. 이러한 상황에 대응하기 위해 코드에서 팩터리 클래스의 구현을 차용하여 미리 전략 객체를 전부 생성하여 팩터리 클래스에 캐시한 다음, 사용시에 이 캐시 객체를 직접 반환할 수 있다.

반대로 전략 클래스가 stateful인 경우 비즈니스 시나리오의 필요에 따라 매번 팩터리 메서드에서 새로 생성된 전략 객체를 얻으려면 전략 팩터리 클래스를 구현해야 한다.

public class StrategyFactory {
	public static Strategy getStrategy(String type) {
		if (type == null || type.isEmpty()) {
			throw new IllegalArgumentException("type shoud be not empty.");
		}
		if (type.equals("A")) {
			return new ConcreteStrategyA();
		}
		if (type.equals("B")) {
			return new ConcreteStrategyB();
		}
		return null;
	}
}
  1. 전략의 사용

어떤 전략을 사용할지 의사결정 하는 방법 중 가장 자주 사용되는 방식은 실행 시간 역학을 통해 사용할 전략을 동적으로 결정하는 것이다. 실행 시간 역학이란 어떤 전략을 사용할지 미리 알지 못하지만 프로그램 실행 도중에 설정, 사용자의 입력, 계산 결과와 같은 미확정 요소를 기반으로 사용할 전략을 동적으로 결정한다는 의미다.

// 전략 인터페이스: EvicitionStrategy
// 전략 클래스: LruEvictionStrategy, FifoEvicitionStrategy, LfuEvicitionStrategy ...
// 전략 팩터리: EvicitionStrategyFactory
public class UserCache {
	private Map<String, User> cacheData = new HashMap<>();
	private EvicitionStrategy eviction;
	
	public UserCache(EvicitionStrategy eviction) {
		this.eviction = eviction;
	}
	...
}

// 실행 시간 동적 결정, 즉 설정 파일을 기반으로 사용할 전략
public class Application {
	public static void main(String[] args) throws Exception {
		EvicitionStrategy evicitionStrategy = null;
		Properties props = new Properties();
		props.load(new FileInputStream("./config.properties"));
		String type = props.getProperty("eviction_type");
		evicitionStrategy = EvicitionStrategyFactory.getEvicitionStrategy(type);
		UserCache userCache = new UserCache(evicitionStrategy);
		...
	}
}

public class Application {
	public static void main(String[] args) {
		...
		EvicitionStrategy evicitionStrategy = new LruEvictionStrategy();
		UserCache userCache = new UserCache(evicitionStrategy);
		...
	}
}

이 코드에서 두 번째 Application 클래스에서 사용되는 비실행 시간 동적 결정이 전략 패턴을 활용하지 않는다는 것을 알 수 있다. 이 응용 시나리오에서는 실제로 전략 패턴이 객체지향의 다형성 또는 구현이 아닌 인터페이스 기반으로 퇴화한다.

전략 패턴으로 분기 결정 대체

if-else가 분기가 제거되지 않는데, 전략 패턴을 사용하는 것이 어떤 이점을 가져다주는 것일까? 전략 패턴은 비즈니스 코드에서 전략의 생성을 디커플링할 수 있을 뿐만 아니라 if-else 분기의 복잡한 생성 코드를 팩터리 클래스에 캡슐화하여 코드 작성을 쉽게 할 수 있도록 해준다.

생각해보기

간단한 비즈니스 로직이 복잡해지면서 개방 폐쇄 원칙을 차츰 지켜나가기 어려울 때부터 진짜 분기 판단문을 제거해야 하는 시초라고 생각한다. KISS 원칙을 따르는 것이 중요하다고 생각해, 굳이 분기 판단문을 제거하지 않아도 되는 사례에 사용하는 오버 엔지니어링은 분명 문제가 있다. 하지만 개방 폐쇄 원칙을 지키지 못하고 계속 분기 판단문이 길어져 유지보수가 어려워지면 그때부터는 필요하다고 생각한다.

책임 연쇄 패턴

템플릿 메서드, 전략, 책임 연쇄 패턴은 모두 재사용과 확장이라는 동일한 목적을 가지고 있다. 대다수의 프레임워크에서 이 패턴들을 이용하여 프레임워크의 기능을 확장 시킬수 있도록 하는데, 프레임워크의 소스 코드를 수정하지 않고도 프레임워크의 기능을 원하는 방식으로 변경할 수 있다. 특히, 책임 연쇄 패턴은 프레임워크에서 사용하는 필터, 인터셉터, 플러그인을 개발하기 위해 사용된다.

정의와 구현

여러 개의 수신 객체가 발신된 요청을 처리할 수 있도록, 요청의 발신과 수신을 분리한다. 발신된 요청은 해당 요청을 처리할 수 있는 수신 객체를 만날 때까지 체인을 따라 계속 이동한다.

책임 연쇄 패턴에서는 여러 개의 프로세서 또는 수신 객체가 동일한 요청을 차례로 처리한다. A 프로세서에서 먼저 요청을 받아 처리한 다음, B 프로세서로 요청을 전달하고, B 프로세서에서 이 요청을 처리한 다음 다시 C 프로세서로 전달하는 방식으로 체인을 형성한다. 그리고 체인에 포함된 각각의 프로세서에는 각자 처리할 책임이 있으므로, 이 패턴을 책임 연쇄 패턴이라고 한다.

생각해보기

  1. 전략패턴을 사용한다
  2. 어댑터 패턴을 사용한다

이 두가지 정도 아닐까..

상태 패턴

일반적으로 상태 머신을 구현하는 데 사용되는데, 상태 머신은 게임이나 워크플로 엔진과 같은 시스템 개발에 자주 사용된다. 물론 상태 머신의 구현에는 상태 패턴 외에도 분기 판단 방식과 테이블 조회 방식도 많이 사용된다.

유한 상태 기계란

유한 상태 기계는 상태 머신이라고 불리기도 한다. 상태 머신은 상태, 이벤트, 동작 세 가지의 구성 요소로 이루어져 있다. 이때 이벤트는 전환 조건이라고 부르기도 하며, 상태 전이와 동작 실행을 촉발시키는 역할을 한다. 그러나 동작은 필수가 아니기 때문에, 상태 전이만 발생하고 어떤 동작도 실행되지 않는 경우도 있다.

상태 머신을 구현하는 방법에는 크게 분기 판단 방법, 테이블 조회 방법, 상태 패턴 세 가지가 있다.

분기 판단 방법으로 상태 머신 구현하기

간단한 if-else로 현재 상태 머신의 상태를 보고 상태 전이를 직접 코드로 구현하는 방법

테이블 조회 방법으로 상태 머신 구현하기

2차원 배열 테이블을 만들어 상태 전이에 대한 구현을 하는 방법. 보고 드는 생각은, 코딩 테스트에서 0부터 26까지 a~z를 저장하고, 로직에 맞춰 arr[i]를 그 상태에 맞게 메시지를 파싱하는 작업을 2차원 배열에 맞춰 요구사항에 맞게 바꾼 느낌?

2차원 배열들을 설정 파일에 저장한다면 코드 수정 없이 설정 파일만 수정하면 된다는데, DB에도 저장할 수 있을 것 같다. 근데 이 테이블 조회 방법 자체가 뭔가 이질감이 드는 방법인 듯 하다.

상태 패턴으로 상태 머신 구현하기

수행할 동작이 복잡한 논리 연산이 필요한 경우라면 2차원 배열로는 처리할 수 없다.

상태 패턴은 다른 이벤트에 의해 촉발된 상태 전이와 동작 실행을 다른 상태 클래스로 분할하여 분기 판단 분기를 회피한다.

생각해보기

템플릿 메서드 패턴을 이용해 기반이 되는 상태를 인터페이스가 아닌 추상 클래스로 정의한다.

'Book > 디자인 패턴의 아름다움' 카테고리의 다른 글

7주차 정리  (0) 2025.04.29
6주차 정리  (0) 2025.04.29
5주차 정리  (0) 2025.04.29
4주차 정리  (0) 2025.04.29
3주차 정리  (0) 2025.04.29
'Book/디자인 패턴의 아름다움' 카테고리의 다른 글
  • 7주차 정리
  • 6주차 정리
  • 5주차 정리
  • 4주차 정리
jun96
jun96
프로그래밍 공부
  • jun96
    jun의 공부노트
    jun96
  • 전체
    오늘
    어제
    • 분류 전체보기 (66)
      • Spring (6)
        • 개념 (3)
        • 에러 (1)
      • Java (1)
      • Book (20)
        • 모던 자바 인 액션 (12)
        • 디자인 패턴의 아름다움 (7)
      • Algorithm (1)
      • 코딩테스트 (35)
      • 일상 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 이력서
  • 공지사항

  • 인기 글

  • 태그

    wikidocs
    aws배포
    Algorithm
    디자인패턴의아름다움
    스프링
    aws에 배포하기
    python설치
    알고리즘
    junit5
    Java
    도커컨테이너빌드업
    자바
    DeepDive
    전자정부프레임워크
    프로그래머스
    최프
    모던자바인액션
    datetime
    아직 미완성
    백준
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
jun96
8주차 정리
상단으로

티스토리툴바