728x90

코드를 보는 것만으로도 어떤 아키텍처인지 파악할 수 있다면 좋지 않을까?

지금부터 코드를 구조화하기 위해 여러 가지 방법을 살펴보자

 

계층으로 구성하기


buckapl
|--- domain
|    |----- Account
|    |----- Activity
|    |----- AccountRepository
|    |----- AccountService
|--- persistence
|    |----- AccountRepositoryImpl
|--- web
|    |----- AccountController

코드를 구조화하는 첫 번째 접근법은 계층을 이용하는 것으로 위처럼 코드를 구성할 수 있다. 간단한 구조의 계층은 가장 적합한 구조가 아닐 수 있으므로 의존성 역전 원칙을 적용해서 의존성이 domain 패키지에 있는 도메인 코드만을 향하도록 했다.

하지만 적어도 세 가지 이유로 이 패키지 구조는 최적의 구조가 아니다.

 

문제점

애플리케이션의 기능 조각(functional slice)이나 특징(feature)을 구분 짓는 패키지 경계가 없다.

  • 이 구조에서 사용자를 관리하는 기능을 추가해야 한다면?
    • web 패키지에 UserController를 추가
    • domain 패키지에 UserService, UserRepository, User를 추가
    • persistence 패키지에 UserRepositoryImpl을 추가
  • 추가적인 구조가 없다면, 아주 빠르게 서로 연관되지 않은 기능들끼리 예상하지 못한 side effect를 일으킬 수 있는 클래스들의 묶음으로 변모할 수 있다.

애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다.

  • 특정 기능을 찾기 위해서는 어떤 서비스가 이를 구현했는지 추측해야 하고, 해당 서비스 내의 어떤 메서드가 그에 대한 책임을 수행하는지 찾아야 한다.

패키지 구조를 통해서는 우리의 목표로 하는 아키텍처를 파악할 수 없다.

  • 어떤 기능이 웹 어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한눈에 알아볼 수 없다.
  • 인커밍 포트와 아웃고잉 포트가 코드 속에 숨겨져 있다.

 

기능으로 구성하기


buckpal
|-- account
    |-- Account
    |-- AccountController
    |-- AccountRepository
    |-- AccountRepositoryImpl
    |-- SendMoneyService

계층 패키지들을 모두 없애고 계좌와 관련된 모든 코드를 최상우의 account 패키지에 넣었다.

 

“계층으로 구성하기” 방법의 몇 가지 문제를 해결해보자

  • 기능을 묶은 그룹은 account와 같은 레벨의 패키지로 들어가고, 패키지 외부에서 접근하면 안 되는 클래스들에 대해 package-private 접근 수준을 이용해 패키지 간의 경계를 강화할 수 있다.
  • 패키지 경계를 package-private 접근 수준과 결합하면 각 기능사이의 불필요한 의존성을 방지할 수 있다.
  • 책임을 좁히기 위해 SendMoneyService와 같이 송금하기 기능을 구현한 클래스를 클래스명으로 바로 찾을 수 있다.
    • 소리치는 아키텍처: 애플리케이션의 기능을 코드를 통해 볼 수 있게 만드는 것
    • 물론 계층형 패키지 구조 방식에서도 가능하다.

 

문제점

  • 계층의 의한 패키징 방식보다 아키텍처의 가시성을 훨씬 더 떨어뜨린다.
    • 어댑터를 나타내는 패키지명도 없다
    • 인커밍 포트, 아웃 고잉 포트도 없다.
  • SendMoneyService가 AccountRepository인터페이스만 알고 구현체를 알 수 없게 했지만 패키지 내부 package-private 접근 수준을 이용해 영속성 코드에 의존하는 것을 막을 수 없다.

 

아키텍처적으로 표현력 있는 패키지 구조


buckpal
|-- account
    |-- adapter
    |   |-- in
    |   |   |-- web
    |   |       |-- AccountController
    |   |-- out
    |   |   |-- persistence
    |   |       |-- AccountPersistenceAdapter
    |   |       |-- SpringDataAccountRepository
    |-- domain
    |   |-- Account
    |   |-- Activity
    |-- application
        |-- SendMoneyService
        |-- port
            |-- in
            |   |-- SendMoneyUseCase
            |-- out
            |   |-- LoadAccountPort
            |   |-- UpdateAccountStatePort

 

헥사고날 아키텍처에서 구조적 핵심요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉 어댑터다.

  • account 패키지: 최상위에 Account 관련 유스케이스를 구현한 모듈임을 나타냄
  • domain 패키지: 유스케이스 다음 레벨로 도메인 모델이 속한 패키지
  • application 패키지: 도메인 모델을 둘러싼 서비스 계층을 포함하는 패키지
    • SendMoneyService
      • 인커밍 포트 케이스 SendMoneyUseCase를 구현
      • 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용
  • adapter 패키지: 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터와 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터 포함하는 패키지

 

패키지 접근 수준

  • 모두 package-private 가능
    • adapter 패키지
    • adapter 패키지 내의 모든 클래스들은 application 패키지 내에 있는 포트 인터페이스를 통하지 않고는 바깥으로 호출되지 않기 때문
  • 일부 public 지정 필요
    • application 패키지의 포트 클래스
      • 어댑터에서 접근 가능해야 하는 포트들은 public 이어야 하기 때문
    • domain 패키지의 도메인 클래스
      • 도메인 클래스들은 서비스, 또는 어댑터에서도 접근 가능하도록 public 이어야 한다.
  • package-private 가능
    • application 패키지의 서비스 클래스
      • 서비스는 인커밍 포트 인터페이스 뒤에 숨겨질 수 있기 때문에 public일 필요가 없다.

 

장점

  • 이러한 패키지 구조는 모델-코드 갭(아키텍처-코드 갭)을 효과적으로 다룰 수 있다.
    • 패키지 구조가 아키텍처를 반영할 수 없다면 결국 관심사의 분리가 불가능해질 것이다.
  • 어댑터 코드를 자체 패키지로 이동시키면 필요할 경우 하나의 어댑터를 다른 구현으로 쉽게 교체 가능하다.
    • SQL 데이터베이스에서 NoSQL 데이터베이스로 교체해야 하는 경우 아웃고잉 포트들만 새로운 어댑터 패키지에 구현하고 기존 패키지를 지우면 된다.
  • DDD 개념을 직접적으로 대응시킬 수 있다.
    • account 같은 상위 레벨 패키지는 다른 바운디드 컨텍스트와 통신할 전용 진입점과 출구(포트)를 포함하는 바운디드 컨텍스트에 해당한다.
모델-코드 갭(model-code gap) 아키텍처 모델에는 항상 코드에 매핑할 수 없는 추상적인 개념, 기술 선택 및 설계 결정이 혼합되어 있다. 최종 결과는 모델이 정한 구성 요소의 배열과 반드시 일치하지 않는 소스 코드가 될 수 있다.

 

의존성 주입의 역할


클린 아키텍처의 가장 본질적인 요건은 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것이다.

 

인커밍 어댑터(ex 웹 어댑터)

  • 제어의 방향이 어댑터와 도메인 코드 간의 의존성 방향과 같아서 쉽다.
  • 어댑터는 단순히 애플리케이션 계층에 위치한 서비스를 호출할 뿐이다.
  • 그럼에도 불구하고 계층 진입점(경계 구분) 구분 짓기 위해 실제 서비스를 포트 인터페이스를 구현하도록 한다.

 

아웃고잉 어댑터(ex 영속성 어댑터)

  • 제어 흐름이 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용
  • 애플리케이션 계층에 포트 인터페이스를 만들고, 어댑터에 해당 포트 인터페이스를 구현한 클래스(아웃 고잉 어댑터)를 둔다.
  • 포트 인터페이스를 구현한 실제 아웃 고잉 어댑터 객체를 누가 애플리케이션 계층에 제공해야 할까?
    • 애플리케이션 계층에서 수동 초기화는 어댑터에 대한 의존성 추가
    • 의존성 주입 활용
    • spring ioc
728x90
728x90

만들면서 배우는 클린 아키텍처를 읽고 공부한 내용을 정리해 보자.

단일 책임 원칙


하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다.

보통 단일 책임 원칙은 위의 내용처럼 해석하기 쉽지만 실제 의도는 아래에 가깝다

컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다.

 

책임은 사실 한 가지 일만 하는 것보다는 변경할 이유로 해석해야 한다. 컴포넌트가 변경할 이유가 오로지 한 가지라면 컴포넌트는 자연스럽게 한 가지 일만 하게 된다. 변경할 이유가 오직 한 가지라는 것은 아키텍처에서 어떤 의미일까?  컴포넌트를 변경할 이유가 한 가지라면 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 소프트웨어가 변경되더라도 기대한 대로 동작할 것이기 때문이다.

 

하지만 변경할 이유라는 것은 컴포넌트 간의 의존성을 통해 쉽게 전파된다. 컴포넌트의 의존성 각각은 해당 컴포넌트를 변경하는 이유 하나씩에 해당한다. 컴포넌트 E를 변경할 유일한 이유는 E의 기능을 바꿔야 할 때뿐이다. 반면 컴포넌트 A는 모든 컴포넌트에 의존하고 있기 때문에 다른 어떤 컴포넌트가 바뀌든지 같이 바뀌어야 한다.

 

 

의존성 역전 원칙


 

계층형 아키텍처에서 계층 간 의존성은 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할 때 상위 계층들이 하위 계층들에 비해 변경할 이유가 더 많다는 것을 알 수 있다. 그러므로 영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다.

 

 

그러나 도메인 코드는 애플리케이션에서 가장 중요한 코드다. 어떻게 이 의존성을 제거할 수 있을까? 바로 의존성 역전 원칙이다.

코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.

 

사실 의존성의 양쪽 코드를 모두 제어할 수 있을 때만 역전시킬 수 있다. 만약 서드파티 라이브러리에 의존성이 있다면 제어할 수 없기 때문에 역전시킬 수 없다. 도메인 코드와 영속성 코드 간의 의존성을 역전시켜서 영속성 코드가 도메인 코드에 의존하고, 도메인 코드를 “변경할 이유”의 개수를 줄여보자.

 

 

엔티티는 도메인 객체를 표현하고 도메인 코드는 이 엔티티들의 상태를 변경하는 일을 중심으로 하기 때문에 먼저 엔티티를 도메인 계층으로 올린다. 그러나 이제는 영속성 계층의 리포지토리가 도메인 계층에 있는 엔티티에 의존하기 때문에 두 계층 사이에 순한 의존성이 생긴다. 따라서 DIP를 적용하여 도메인 계층에 리포지토리 대한 인터페이스를 만들고, 실제 리포지토리는 영속성 계층에서 구현하게 하는 것이다.

 

클린 아키텍처


로버트 마틴은 클린 아키텍처라는 용어를 정립했다. 클린 아키텍처에서는 설계나 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, ui 기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 이야기했다. 이 말은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다.

 

 

대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다. 클린 아키텍처에서 중요한 규칙은 의존성 규칙으로, 계층 간의 모든 의존성이 안쪽으로 향해야 한다는 것이다.  아키텍처의 코어에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있다. 유스케이스는 앞에서 서비스라고 불렀던 것들인데, 단일 책임을 갖기 위해 조금 더 세분화돼 있다. 이를 통해 넓은 서비스 문제를 피할 수 있다.

 

도메인 코드에서는 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있다. 그래서 도메인 코드를 자유롭게 모델링할 수 있다. 예를 들어, DDD를 순수한 형태로 적용해 볼 수도 있다.

 

하지만 클린 아키텍처에는 외부 계층과 철저하게 분리돼야 하므로 애플리케이션 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 가령 영속성 계층에서 ORM 프레임워크를 사용한다고 해보자. 도메인 계층은 영속성 계층을 모르기 때문에 도메인 계층에서 사용한 엔티티 클래스를 영속성 계층에서 함께 사용할 수 없고 두 계층에서 각각 엔티티를 만들어야 한다. 즉, 도메인 계층과 영속성 계층이 데이터를 주고받을 때, 두 엔티티를 서로 변환해야 한다는 뜻이다. 이는 도메인 계층과 다른 계층들 사이에서도 마찬가지다. 하지만 이것은 바람직한 일이다. 이것이 바로 도메인 코드를 프레임워크에 강결합이 제거된 상태이다.

예를 들어 JPA에서 인자가 없는 기본 생성자를 강제하는 것

 

헥사고날 아키텍처


 

헥사고날 아키텍처는 애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트 어댑터 아키텍처라고도 불린다. 육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.

 

육각형 바깥에는 애플리케이션과 상호작용하는 다양한 어댑터들이 있다. 애플리케이션 코어와 어댑터들 간의 통신이 가능해지려면 코어가 각각의 포트를 제공해야 한다.

  • 코어를 주도하는 어댑터(driving adapter)에게는 포트가 코어에 있는 유스케이스 클래스 중 하나에 의해 구현되고 어댑터에 의해 호출되는 인터페이스가 될 것이다.
  • 코어에 의해 주도되는 어댑터(driven adapter)에게는 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.

 

헥사고날 아키텍처도 클린 아키텍처처럼 계층으로 구성할 수 있다. 가장 바깥쪽에 있는 계층을 애플리케이션과 다른 시스템 간의 번역을 담당하는 어댑터로 구성되어 있다. 다음으로 포트와 유스케이스 구현체를 결합해서 애플리케이션 계층을 구성할 수 있다. 마지막 계층에는 도메인 엔티티가 위치한다.

 

결국 핵심은? 의존성


결국 어떤 아키텍처라고 불리든 의존성을 역전시켜 도메인 코드가 다른 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄일 수 있다. 도메인 코드는 비즈니스 문제에 딱 맞도록 자유롭게 모델링 될 수 있고, 영속성 코드와 UI 코드도 영속성 문제와 UI 문제에 맞게 자유롭게 모델링 될 수 있다.

 

728x90
728x90

만들면서 배는 클린 아키텍처를 읽고 공부한 내용을 정리해 보자.

 

계층형 아키텍처


계층으로 구성된 웹 애플리케이션은 누구나 개발해 본 적 있을 것이다.

계층일 이용하는 사고방식은 컴퓨터 과학 수업이나 튜토리얼, 모범사례를 통해 주입되어 왔다.

 

전통적인 웹 애플리케이션 구조

 

사실 계층형 아키텍처는 견고한 아키텍처 패턴이다. 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다. 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다. 잘 만들어진 계층형 아키텍처는 선택의 폭을 넓히고 , 변화하는 요구사항과 외부 요인에 빠르게 적용할 수 있게 해 준다. 로버튼 마틴에 의하면 이것이 바로 아키텍처의 전부다(클린 아키텍처)

 

그렇다면 계층형의 문제점은 무엇일까? 계층형 아키텍처는 코드에 나쁜 습관들이 스며들기 쉽게 만들고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 허점들을 노출한다.

 

데이터베이스 주도 설계를 유도


정의에 따르면 계층형 아키텍처의 토대는 데이터베이스다. 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터베이스에 의존하게 된다. 모든 것이 영속성 계층을 토대로 만들어진다. 이런 방식은 다양한 이유로 문제를 초래한다.

 

우리가 만드는 애플리케이션의 대부분의 목적은 무엇인가, 바로 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 이러한 규칙과 정책을 더욱 편리하게 활용할 수 있게 한다. 이때 우리는 상태가 아니라 행동을 중심으로 모델링한다. 상태가 중요한 요소이긴 하지만 행동이 상태를 바꾸는 주체이기 때문에 행동이 비즈니스를 이끌어간다.

 

그렇다면 왜 도메인 로직이 아닌 데이터베이스를 토대로 아키텍처를 만드는 걸까? 그동안 만들어 본 애플리케이션의 유스케이스를 도메인 로직이 아니라 영속성 계층을 먼저 구현했을 것이다. 데이터베이스의 구조를 먼저 생각하고 이를 토대로 도메인 로직을 구현했을 것이다.

계층형 아키텍처에서는 합리적인 방법이다. 의존성에 방향에 따라 자연스럽게 구현한 것이기 때문이다. 하지만 비즈니스 관점에서는 전혀 맞지 않는 방법이다. 먼저 도메인 로직을 만들어야 한다. 그래야 우리가 로직을 제대로 이해했는지 확인할 수 있으며, 도메인 로직이 맞다는 것을 확인한 후에 이를 기반으로 영속성 계층과 웹 계층을 만들어야 한다.

 

데이터베이스 중심 아키텍처가 만들어지는 가장 큰 원인은 ORM 프레임워크를 사용하기 때문이다. ORM 프레이워크가 나쁘다는 것이 아니라 ORM 프레임워크를 계층형 아키텍처와 결합하면 비즈니스 규칙을 영속성 관점과 섞고 싶은 유혹을 쉽게 받는다.

 

 

도메인 계층에서 이러한 영속성 계층 속 엔티티에 접근할 수 있으며 사용되기 마련이다. 이렇게 되면 영속성 계층과 도메인 계층 사이에 강한 결합이 생긴다. 서비스는 영속성 모델을 비즈니스 모델처럼 사용하게 되고 이로 인해 도메인 로직뿐만 아니라 즉시로딩/지연로딩, 데이터베이스 트랜잭션, 캐시 플러시 등등 영속성 계층과 관련된 작업들을 해야만 한다. 영속성 코드가 사실상 도메인 코드에 녹아들어 가서 둘 중 하나만 바꾸는 것이 어려워진다.

 

지름길을 택하기 쉬워진다.


계층형 아키텍처에서 전체적으로 적용되는 유일한 규칙은, 특정한 계층에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근 가능하다는 것이다. 만약 상위 계층에 컴포넌트에 접근해야 한다면 간단하게 해당 컴포넌트를 계층아래로 내려버리면 된다. 딱 한번 이렇게 하는 것은 괜찮을 수 있지만 보통 영속성 계층은 수년에 걸친 개발과 유지보수로 아래 그림처럼 될 가능성이 높다.

 

 

영속성 계층에서는 모든 것에 접근 가능하기 때문에 시간이 지나면 점점 비대해진다. 영속성 계층은 컴포넌트를 아래 계층으로 내릴수록 비대해진다. 어떤 계층에도 속하지 않는 것처럼 보이는 헬퍼 컴포넌트나 유틸리티 컴포넌트들이 이처럼 아래 계층을 내릴 가능성이 큰 후보다.

 

테스트하기 어려워진다.


계층형 아키텍처를 사용할 때 일반적으로 나타나는 변화의 형태는 계층을 건너뛰는 것이다. 엔티티의 필드를 단 하나만 조작하면 되는 경우에 웹 계층에서 바로 영속성 계층에 접근하면 도메인 계층을 건드릴 필요가 없지 않을까?

 

 

도메인 계층을 건너뛰는 것은 도메인 로직을 코드 여기저기에 흩어지게 만든다. 웹 계층 쪽 유스케이스가 확장되는 경우 아무리 간단한 것에 불과하더라도 도메인 로직을 웹 계층에 구현하게 된다. 따라서 애플리케이션 전반에 걸쳐 책임이 섞이고 핵심 도메인 로직들이 퍼져나갈 확률이 높다.

 

유스케이스를 숨긴다.


 

기능을 추가하거나 변경할 적절한 위치를 찾는 일이 빈번하기 때문에 아키텍처는 코드를 빠르게 탐색하는 데 도움이 돼야 한다.

하지만 계층형 아키텍처는 말했듯이 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽다. 이럴 경우 새로운 기능을 추가할 적당한 위치를 찾는 일이 어려워진 상태이다. 계층형 아키텍처는 도메인 서비스의 ‘너비’에 관한 규칙을 강제하지 않기 때문에 시간이 지나면 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 만들어지기도 한다.

 

 

넓은 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 다시 웹 레이어의 많은 컴포넌트가 이 서비스에 의존하게 된다. 그럼 서비스를 테스트하기도 어려워지고 작업해야 할 유스케이스를 책임지는 서비스를 찾기도 어려워진다. 고도로 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 이런 작업들이 얼마나 수월해질까? UserService에서 사용자 등록 유스케이스를 찾는 대신 RegisterUserService를 바로 열어서 작업을 시작하는 것처럼 말이다.

 

동시 작업이 어려워진다.


계층형 아키텍처는 동시 작업 측면에서는 그다지 도움이 되지 않는다. 계층형 아키텍처에서는 모든 것이 영속성 계층 위에 만들어지기 때문에 특정 기능은 동시에 한 명의 개발자만 작업할 수 있다. 인터페이스를 먼저 같이 정의 후 작업을 할 순 있지만 영속성 로직과 도메인 로직이 뒤섞여서 각 측면을 개별적으로 작업하기 힘들고, 또 코드에 넓은 서비스가 있다면 서로 다른 기능을 동시에 작업하기는 더욱 어렵다.

 

유지보수 가능한 소프트웨어를 만드는데 어떻게?


물론 계층형 아키텍처도 올바르게 구축하고 몇 가지 추가적인 규칙들을 적용하면 유지보수가 쉬워지며 코드를 쉽게 변경하거나 추가할 수 있다. 하지만 앞에서 살펴봤듯이 잘못된 방향으로 흘러가도록 쉽고 용인하는 구조이다.

 

따라서 계층형 아키텍처로 만들든 다른 아키텍처 스타일로 만들든, 계층형 아키텍처의 문제점들을 염두에 두면 지름길을 택하지 않고 유지보수하기에 더 쉬운 솔루션을 만드는 데 도움이 될 것이다.

 

 

728x90
728x90

서론


전통적인 레이어드 아키텍처로 프로젝트를 개발하며 점점 문제점들이 보이기 시작했다. 서비스가 조금씩 커지거나 요구사항의 확장이 진행되며 점점 더 복잡해지면서 유지보수에 점점 더 많은 시간이 쓰이고 있었다. 많은 서비스를 담당하는 엄청난 서비스, 영속성 계층인 데이터베이스에 의존성이 점점 커지는 것 등 이러한 문제점을 해결하기 위한 고민이 있던 와중 평소에 소프트웨어 개발에서 도메인 주도 설계, 클린 아키텍처, 헥사고날 아키텍처라는 용어를 많이 들어왔으나 "클린 아키텍처는 뭐고, 헥사고날 아키텍처는 뭐지?, 둘의 차이는 무엇이고 그래서 어떤 아키텍처를 사용하란 걸까? " 하며 항상 헷갈려했다.

이러한 아키텍처들이 왜 등장하게 되었으며, 전통적인 계층형 아키텍처의 문제점을 해결할 수 있을까 싶어 찾아보게 되었다.

조금 조사하던 와중 DDD는 조금 더 큰 영역의 내용인 것 같아 추후에 다시 작성하도록 하고 먼저 클린아키텍처에 대해서 가볍게 훑은 내용들을 정리하고자 한다.

 

 

왜 소프트웨어 아키텍처가 중요할까?


정말 소프트웨어 아키텍처가 중요한 걸까? 소프트웨어가 제공하는 가치는 두 가지가 있다고 한다. 바로 기능과 구조이다. 기능과 구조에서 조금 더 중요한 것은 무엇일까?? 평소에는 기능이라고 생각해 왔다. 우리가 만드는 애플리케이션의 대부분의 목적은 바로 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 기능들을 편리하게 사용할 수 있게 만드는 것이라고 생각했기 때문이다.

 

하지만 로버틴 C. 마틴은 구조의 중요성은 언급한다. 왜일까? 바로 우리가 원하는 것은 더 정확하게, 더 빠르게, 더 많이 기능을 추가하기 위해 코드를 읽고, 이해하고, 수정해야 하기 때문이다. 즉, 시스템을 만들고 유지보수하는데 투입되는 인력을 최소화하는 것이다. 잘 생각해 보면 레거시 프로젝트이든 최근에 만든 프로젝트이든 새로운 코드를 짜는 것보다 기존 코드를 바꾸는데 훨씬 더 많은 시간을 쓰는 것이 생각났다.

즉 구조가 좋다는 것은 수정의 비용이 적다는 것이다.

 

좋은 아키텍처 - 1. 계층형 아키텍처


그렇다면 좋은 아키텍처에는 무엇이 있을까?  첫 번째로 계층형 아키텍처다. 사실 계층형 아키텍처는 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다고 한다.  따라서 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다.

 

하지만 계층형 아키텍처의 가장 큰 단점은 도메인 계층이 영속성 계층을 의존하는 데이터베이스 주도 설계를 유도한다는 것이다. 도메인 로직을 여러 계층에 흩어지게 만들기 쉬운 아키텍처이다. 기능 기반으로 패키지를 구성하여도 계층적 구조는 동일하다.

 

https://www.youtube.com/watch?v=g6Tg6_qpIVc

 

 

좋은 아키텍처 - 2. 클린 아키텍처


로버트 마틴은 클린 아키텍처라는 용어를 정립했다. 클린 아키텍처에서는 설계나 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, ui 기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 이야기했다.

 

이 말은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다. 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있는 아키텍처이다.

https://www.youtube.com/watch?v=g6Tg6_qpIVc

 

헥사고날 아키텍처?


헥사고날 아키텍처는 애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트 어댑터 아키텍처라고도 불린다. 육각형이란 단어에는 아무런 뜻도 없으며 중요한 것은 육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.

 

https://www.youtube.com/watch?v=g6Tg6_qpIVc

 

클린아키텍처의 애매함

클린 아키텍처는 핵심 규칙 외에는 케이스 바이 케이스라 애매한 지점이 많다. 애매할 때는 아래의 기준점을 참고하면 좋을 것 같다.

1. 필요한 시스템을 만들고 유지보수하는 데 투입되는 인력 최소화에 유리한가?
2. 소스 코드 의존성이 안쪽으로, 고수준의 정책을 향하고 있는가? 
3. 세부 사항이 변경되어도 도메인(핵심 규칙)에 변경이 없을 것인가?
4. 테스트하기 쉬운가?
5. 각각의 아키텍처 원칙들을 잘 지키고 있는가?

 

클린아키텍처는 항상 좋을까?

위에서 말했듯이 전통적인 계층적 아키텍처도 충분히 영속성 계층에 독립적으로 도메인 로직을 유지할 수 있을 것이다. 마찬가지로 클린 아키텍처도 항상 좋을 것은 아니다.

 

외부 계층과 철저하게 분리돼야 하므로 애플리케이션 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 따라서 코드의 절대적인 양이 많아질 것이며, 프로젝트 개발자 모두가 클린 아키텍처를 이해하고 있지 않을 때 혹은 모두가 사용하기로 합의하지 않았을 때에는 사용하는 것이 오히려 좋지 않을 수 있다고 생각한다.

 

참고

728x90
728x90

캐시


자주 사용하는 데이터나 값을 미리 저장해 놓는 임시 저장소 서버나 데이터베이스에 가해지는 부하를 줄이고 성능을 높이기 위해 사용한다. 자주 변경되고 삭제되는 데이터에 적용하면 오히려 성능 저하를 일으킬 수 있다.

 

 

종류


Local Cache

서버 내부에 저장한다.

  • 서버 내부에서 동작하기 때문에 속도가 빠르다.
  • 서버 Resource(Memory, Disk)를 사용한다.

서버 간에 데이터를 공유할 수 없다.

  • 분산 서버인 경우 데이터 정합성이 깨지는 문제가 발생할 수 있다.

종류

  • Ehcache, Caffeine

 

Global Cache

Cache Server를 별도로 사용한다.

서버 간에 데이터를 공유할 수 있다.

  • 네트워크 트래픽이 발생하기 때문에 Local Cache보다 속도가 느리다.

종류

  • Redis, Memcached

 

CDN (Content Delivery Network), Web Caching

프록시 서버(Proxy Server)를 물리적으로 분산하여 사용자 위치를 기준으로 가장 가까운 프록시 서버에서 캐싱되어 있는 웹 콘텐츠를 제공함으로써 웹 페이지 로드 속도를 높이는 서버 네트워크

트래픽이 각 서버로 분산된다.

 

 

 

고려 사항


일반적으로 캐시는 메모리(RAM)에 저장되기 때문에 무분별하게 저장해버리면 용량 부족 현상이 발생하여 시스템이 다운될 수 있다.

캐시 서버에 장애가 발생하면 트래픽이 데이터베이스로 몰리게 되어 과부하로 인해 데이터베이스가 다운될 수 있다. 캐시 서버가 장애로부터 복구되는 동안 데이터베이스가 버틸 수 있도록 대비해야 한다. (캐시 서버를 계층적으로 구축하기도 한다)

캐시를 언제까지 유지시킬 건지 Expire Time 또는 Time-To-Live(TTL)정책과 삭제 알고리즘을 고려해야 한다.

  • Cache Stampede: 부하가 높은 상태에서 캐시가 만료되어 순간적으로 데이터베이스 읽기 작업과 캐시 쓰기 작업이 중복으로 발생하는 현상
  • Hot Keys: 하나의 키에 읽기가 집중될 때도 성능이 떨어질 수 있습니다. 위 글에서는 그 대책으로 키 이름 앞에 Prefix를 붙여 여러 복제본을 만든 뒤, 그 Prefix가 붙은 복제본에 랜덤으로 읽기를 분산시키는 방법을 소개하고 있습니다.
  • Compression: 크기가 큰 데이터를 레디스에 저장할 때도 성능 저하가 발생할 수 있습니다. 이때 적절한 압축을 적용하는 것만으로도 속도와 메모리 사용량에서 큰 이득을 볼 수 있습니다. 적절한 압축 방법과 비율은 상황과 환경에 따라 다를 수 있기 때문에, 이를 적용할 때는 사용 환경을 재현한 벤치마크 테스트가 필수입니다.
  • 만료 주기가 너무 길면 메모리 부족 현상이 발생하거나 데이터 정합성이 깨질 수 있다.
  • 중요한 정보나 민감한 정보는 저장하지 않는다.

 

Local Cache VS Global Cache

  • 데이터 정합성이 깨져도 비즈니스에 영향이 없는 부분은 Local Cache, 데이터 정합성이 중요한 부분에는 Global Cache를 선택할 수 있다.
  • Cloud 환경(Docker, AWS EC2 등)이라면 JVM Memory를 사용하는 Cache는 적합하지 않을 수 있다.
  • Typical caching setupLocal Cache를 첫 번째 수준 Cache로 사용하고 Global Cache를 두 번째 수준 Cache로 사용할 수 있다.
  • Local Cache는 Memory를, Global Cache는 네트워크 트래픽을 많이 사용하는 점을 고려하여 하나 이상을 결합해서 사용하면 효율성이 높아질 수 있다.

 

 

캐시(Cache) 전략


캐싱 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술이다.

일반적으로 캐시(cache)는 메모리(RAM)를 사용하기 때문에 데이터베이스 보다 훨씬 빠르게 데이터를 응답할 수 있어 이용자에게 빠르게 서비스를 제공할 수 있다. 하지만 기본적으로 RAM의 용량은 커봐야 16 ~ 32G 정도라, 데이터를 모두 캐시에 저장해버리면 용량 부족 현상이 일어나 시스템이 다운될 수 있다.

 

따라서 어느 종류의 데이터를 캐시에 저장할지, 얼만큼 데이터를 캐시에 저장할지, 얼마동안 오래된 데이터를 캐시에서 제거하는지에 대한 '지침 전략' 을 숙지할 필요가 있다.

Tip

캐시를 효율적으로 이용하기 위해서는 캐시에 저장할 데이터 특성도 고려해야 한다. 예를 들어 자주 조회되는 데이터, 결괏값이 자주 변동되지 않고 일정한 데이터, 조회하는데 연산이 필요한 데이터를 캐싱해 두면 좋다.

 

 

노드나 스프링으로 서버를 만드는데 있어, 자신의 프로젝트에 캐시 메모리를 적용하려 할 때, 요청(request)이 라우터에 오면 어느 상황에서 어떤 식으로 캐시를 사용할 것인지 보다 효율적으로 서비스가 빠릿빠릿하게 돌아가도록 설계하는 데 있어 참고가 되기를 바란다.

참고로 들어가기 앞서 선수 지식이 필요한데, 바로 cache hit 과 cache miss이다.

  • cache hit : 캐시 스토어(redis)에 데이터가 있을 경우 바로 가져옴 (빠름)
  • cache miss : 캐시 스토어(redis)에 데이터가 없을 경우 어쩔 수 없이 DB에서 가져옴 (느림)

 

 

 

캐싱 전략 패턴 종류


캐시를 이용하게 되면 반드시 닥쳐오는 문제점이 있는데 바로 데이터 정합성 문제이다. 데이터 정합성이란, 어느 한 데이터가 캐시(Cache Store)와 데이터베이스(Data Store) 이 두 곳에서 같은 데이터임에도 불구하고 데이터 정보값이 서로 다른 현상을 말한다.

 

쉽게 말하면, 캐시에는 어떤 게시글의 좋아요 개수가 10개로 저장되어 있는데 데이터베이스에는 7개로 저장되어 있을 경우 정보 불일치가 발생하게 된다. 이전에는 그냥 DB에서 데이터 조회와 작성을 처리하였기 때문에 데이터 정합성 문제가 나타나지 않았지만, 캐시라는 또 다른 데이터 저장소를 이용하기 때문에, 결국 같은 종류의 데이터라도 두 저장소에서 저장된 값이 서로 다를 수 있는 현상이 일어날 수밖에 없는 것이다.

 

따라서 적절한  캐시 읽기 전략(Read Cache Strategy)캐시 쓰기 전략(Write Cache Strategy)을 통해 캐시와 DB 간의 데이터 불일치 문제를 극복하면서도 빠른 성능을 잃지 않게 하기 위해 고심히 연구를 할 필요가 있다.

 

 

캐시 읽기 전략 (Read Cache Strategy)


Look Aside 패턴

  • Cache Aside 패턴이라고도 불림.
  • 데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략. 만일 캐시에 데이터가 없으면 DB에서 조회함.
  • 반복적인 읽기가 많은 호출에 적합.
  • 캐시와 DB가 분리되어 가용되기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장
  • 캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음. 만일 redis가 다운되더라도 DB에서 데이터를 가져올 수 있어 서비스 자체는 문제가 없음.
  • 대신에 캐시에 붙어있던 connection이 많았다면, redis가 다운된 순간순간적으로 DB로 몰려서 부하 발생.

 

Look Asdie Cache 패턴은 애플리케이션에서 캐싱을 이용할 때 일반적으로 사용되는 기본적인 캐시 전략이다.

이 방식은 캐시에 장애가 발생하더라도 DB에 요청을 전달함으로써 캐시 장애로 인한 서비스 문제는 대비할 수 있지만, Cache Store와 Data Store(DB) 간 정합성 유지 문제가 발생할 수 있으며, 초기 조회 시 무조건 Data Store를 호출해야 하므로 단건 호출 빈도가 높은 서비스에 적합하지 않다. 대신 반복적으로 동일 쿼리를 수행하는 서비스에 적합한 아키텍처이다.

이런 경우 DB에서 캐시로 데이터를 미리 넣어주는 작업을 하기도 하는데 이를 Cache Warming이라고 합니다.

Info

[ Cache Warming ] 미리 cache로 db의 데이터를 밀어 넣어두는 작업을 의미한다. 이 작업을 수행하지 않으면 서비스 초기에 트래픽 급증 시 대량의 cache miss 가 발생하여 데이터베이스 부하가 급증할 수 있다. (Thundering Herd) 다만, 캐시 자체는 용량이 적어 무한정으로 데이터를 들고 있을 수는 없어 일정시간이 지나면 expire 되는데, 그러면 다시 Thundering Herd가 발생될 수 있기 때문에 캐시의 TTL을 잘 조정할 필요가 있다. (뒤에서 자세히 설명)

Tip

Thundering Herd는 모든 지점에서 발생되는 것은 아니고, 서비스의 첫 페이지와 같은 대부분의 조회가 몰리는 지점에서 주로 발생된다고 보면 된다.

 

Read Through 패턴

  • 캐시에서만 데이터를 읽어오는 전략 (inline cache)
  • Look Aside와 비슷하지만 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식이라는 차이가 있음.
  • 따라서 데이터를 조회하는 데 있어 전체적으로 속도가 느림.
  • 또한 데이터 조회를 전적으로 캐시에만 의지하므로, redis가 다운될 경우 서비스 이용에 차질이 생길 수 있음.
  • 대신에 캐시와 DB 간의 데이터 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어날 수 있음
  • 역시 읽기가 많은 워크로드에 적합

 

 

Read Through 방식은 Cache Aside 방식과 비슷하지만, Cache Store에 저장하는 주체가 Server이냐 또는 Data Store 자체이냐에서 차이점이 있다.

이 방식은 직접적인 데이터베이스 접근을 최소화하고 Read에 대한 소모되는 자원을 최소화할 수 있다.

하지만 캐시에 문제가 발생하였을 경우 이는 바로 서비스 전체 중단으로 빠질 수 있다. 그렇기 때문에 redis과 같은 구성 요소를 Replication 또는 Cluster로 구성하여 가용성을 높여야 한다.

Tip

이 방식 또한 서비스 운영 초반에 cache warming을 수행하는 것이 좋다.

 

 

캐시 쓰기 전략 (Write Cache Strategy)


Write Back 패턴

  • Write Behind 패턴 이라고도 불림.
  • 캐시와 DB 동기화를 비동 기하기 때문에 동기화 과정이 생략
  • 데이터를 저장할 때 DB에 바로 쿼리하지않고, 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영
  • 캐시에 모아놨다가 DB에 쓰기 때문에 쓰기 쿼리 회수 비용과 부하를 줄일 수 있음
  • Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합
  • 데이터 정합성 확보
  • 자주 사용되지 않는 불필요한 리소스 저장.
  • 캐시에서 오류가 발생하면 데이터를 영구 소실함.

 

 

Write Back 방식은 데이터를 저장할때 DB가 아닌 먼저 캐시에 저장하여 모아놓았다가 특정 시점마다 DB로 쓰는 방식으로 캐시가 일종의 Queue 역할을 겸하게 된다.

캐시에 데이터를 모았다가 한 번에 DB에 저장하기 때문에 DB 쓰기 횟수 비용과 부하를 줄일 수 있지만, 데이터를 옮기기 전에 캐시 장애가 발생하면 데이터 유실이 발생할 수 있다는 단점이 존재한다. 하지만 오히려 반대로 데이터베이스에 장애가 발생하더라도 지속적인 서비스를 제공할 수 있도록 보장하기도 한다.

Tip

이 전략 또한 캐시에 Replication이나 Cluster 구조를 적용함으로써 Cache 서비스의 가용성을 높이는 것이 좋으며, 캐시 읽기 전략인 Read-Through와 결합하면 가장 최근에 업데이트된 데이터를 항상 캐시에서 사용할 수 있는 혼합 워크로드에 적합하다.

 

Write Through 패턴

  • 데이터베이스와 Cache에 동시에 데이터를 저장하는 전략
  • 데이터를 저장할 때 먼저 캐시에 저장한 다음 바로 DB에 저장 (모아놓았다가 나중에 저장이 아닌 바로 저장)
  • Read Through와 마찬가지로 DB 동기화 작업을 캐시에게 위임
  • DB와 캐시가 항상 동기화되어 있어, 캐시의 데이터는 항상 최신 상태로 유지
  • 캐시와 백업 저장소에 업데이트를 같이 하여 데이터 일관성을 유지할 수 있어서 안정적
  • 데이터 유실이 발생하면 안 되는 상황에 적합
  • 자주 사용되지 않는 불필요한 리소스 저장.
  • 매 요청마다 두 번의 Write가 발생하게 됨으로써 빈번한 생성, 수정이 발생하는 서비스에서는 성능 이슈 발생
  • 기억장치 속도가 느릴 경우, 데이터를 기록할 때 CPU가 대기하는 시간이 필요하기 때문에 성능 감소

 

 

Write Through 패턴은 Cache Store에도 반영하고 Data Store에도 동시에 반영하는 방식이다. (Write Back은 일정 시간을 두고 나중에 한꺼번에 저장)

그래서 항상 동기화가 되어 있어 항상 최신정보를 가지고 있다는 장점이 있다.

하지만 결국 저장할 때마다 2단계 과정을 거쳐 치기 때문에 상대적으로 느리며, 무조건 일단 Cache Store에 저장하기 때문에 캐시에 넣은 데이터를 저장만 하고 사용하지 않을 가능성이 있어서 리소스 낭비 가능성이 있다.

Tip

write throuth 패턴과 write back 패턴 둘 다 모두 자주 사용되지 않는 데이터가 저장되어 리소스 낭비가 발생되는 문제점을 안고 있기 때문에, 이를 해결하기 위해 TTL을 꼭 사용하여 사용되지 않는 데이터를 반드시 삭제해야 한다. (expire 명령어)

Tip

Write-Through 패턴과 Read-Through 패턴을 함께 사용하면, 캐시의 최신 데이터 유지와 더불어 정합성 이점을 얻을 수 있다. 대표적인 예로 AWS의 DynamoDB Accelerator(DAX)가 있다. DAX 패턴을 통해 DynamoDB에 대한 읽기 및 쓰기를 효율적으로 수행할 수 있다.

 

Write Around 패턴

  • Write Through 보다 훨씬 빠름
  • 모든 데이터는 DB에 저장 (캐시를 갱신하지 않음)
  • Cache miss가 발생하는 경우에만 DB와 캐시에도 데이터를 저장
  • 따라서 캐시와 DB 내의 데이터가 다를 수 있음 (데이터 불일치)

 

 

 

Write Around 패턴은 속도가 빠르지만, cache miss가 발생하기 전에 데이터베이스에 저장된 데이터가 수정되었을 때, 사용자가 조회하는 cache와 데이터베이스 간의 데이터 불일치가 발생하게 된다.

따라서 데이터베이스에 저장된 데이터가 수정, 삭제될 때마다, Cache 또한 삭제하거나 변경해야 하며, Cache의 expire를 짧게 조정하는 식으로 대처해야 한다.

Tip

Write Around 패턴은 주로 Look aside, Read through와 결합해서 사용된다. 데이터가 한 번 쓰이고, 덜 자주 읽히거나 읽지 않는 상황에서 좋은 성능을 제공한다.

 

 

기타


Refresh Ahead

자주 사용되는 데이터를 캐시 만료 전에 미리 TTL (Expire time)을 갱신합니다.

캐시 미스 발생을 최소화할 수 있지만 Warm Up 작업과 마찬가지로 자주 사용되는 데이터를 잘 예측해야 합니다.

 

 

캐시 읽기 + 쓰기 전략 조합


Look Aside + Write Around 조합

  • 가장 일반적으로 자주 쓰이는 조합

 

 

Read Through + Write Around 조합

  • 항상 DB에 쓰고, 캐시에서 읽을 때 항상 DB에서 먼저 읽어오므로 데이터 정합성 이슈에 대한 완벽한 안전장치를 구성할 수 있음

 

 

Read Through + Write Through 조합

  • 데이터를 쓸 때 항상 캐시에 먼저 쓰므로, 읽어올때 최신 캐시 데이터 보장
  • 데이터를 쓸때 항상 캐시에서 DB로 보내므로, 데이터 정합성 보장

 

 

캐시 저장 방식 지침


캐시 솔루션은 자주 사용되면서 자주 변경되지 않는 데이터의 경우 캐시 서버에 적용하여 반영할 경우 높은 성능 향상을 이뤄낼 수 있다. 이를 Cache Hit Rating이라고 한다. 일반적으로 캐시는 메모리에 저장되는 형태를 선호한다.

 

메모리 저장소로는 대표적으로 Redis와 MemCached가 있으며 이와 같은 솔루션은 메모리를 1차 저장소로 사용하기 때문에 디스크와 달리 제약적인 저장 공간을 사용하게 된다. 많아야 수십 기가 정도의 저장소를 보유하게 되며, 이는 결국 자주 사용되는 데이터를 어떻게 뽑아 캐시에 저장하고 자주 사용하지 않는 데이터를 어떻게 제거해 나갈 것이냐를 지속적으로 고민해야 할 필요성이 있다.

 

따라서 캐시를 저장하는 시점은 자주 사용되며 자주 변경되지 않는 데이터를 기준으로 하는 것이 좋다.

또한 한 가지 고민해야 할 사항은 캐시 솔루션은 언제든지 데이터가 날아갈 수 있는 휘발성을 기본으로 한다는 점이다.

이는 데이터를 주기적으로 디스크에 저장함으로서 어느 정도 해결을 볼 수는 있지만, 실시간으로 모든 데이터를 디스크에 저장할 경우 성능 저하를 일으킬 수 있어 어느 정도 데이터 수집과 저장 주기를 가지도록 설계해야 된다.

 

즉 데이터의 유실 또는 정합성이 일정 부분 깨질 수 있다는 점을 항상 고려해야 한다.

따라서 캐시에 저장되는 데이터는 중요한 정보, 민감 정보 등은 저장하지 않는 것이 좋으며, 캐시 솔루션이 장애가 발생했을 경우 적절한 대응방안을 모색해 두는 것이 바람직하다. (TimeOut, 데이터베이스 조회 병행 등)

 

[ 파레토 법칙 - 8:2 법칙 ] 파레토 법칙이란 전체 결과의 80%가 전체 원인의 20%에서 일어나는 현상을 가리킨다. 서비스에 빗대어 표현하자면 80%의 활동을 20%의 유저가 하기 때문에 20%의 데이터만 캐시 해도 서비스 대부분의 데이터를 커버할 수 있게 된다는 말이다. 즉, 캐시에 모든 데이터를 저장할 필요 없이 "파레토 법칙"에 따라 일부만 저장해도 대부분의 데이터를 커버할 수 있다는 저장 지침이다.

 

캐시 제거 방식 지침


캐시 데이터의 경우 캐시 서버에만 단독으로 저장되는 경우도 있지만, 대부분 영구 저장소에 저장된 데이터의 복사본으로 동작하는 경우가 많다. 이는 2차 저장소(영구 저장소)에 저장되어 있는 데이터와 캐시 솔루션의 데이터를 동기화하는 작업이 반드시 필요함을 의미하며, 개발 과정에 고려사항이 늘어난다는 점을 반드시 기억해야 한다.

 

예를 들어 캐시 서버와 데이터베이스에 저장되는 데이터의 commit 시점에 대한 고려 등이 예가 될 수 있다.

캐시의 만료 정책이 제대로 구현되지 않은 경우 클라이언트는 데이터가 변경되었음에도 오래된 정보가 캐싱되어있어 오래된 정보를 사용할 수 있다는 문제점이 발생한다.

 

따라서 캐시를 구성할 때 기본 만료 정책을 설정해야 한다. 캐시 된 데이터가 기간 만료 되면 캐시에서 데이터가 제거되고, 응용 프로그램은 원래 데이터 저장소에서 데이터를 검색해야 한다. 그래서 캐시 만료 주기가 너무 짧으면 데이터는 너무 빨리 제거되고 캐시를 사용하는 이점은 줄어든다. 반대로 너무 기간이 길면 데이터가 변경될 가능성과 메모리 부족 현상이 발생하거나, 자주 사용되어야 하는 데이터가 제거되는 등의 역효과를 나타낼 수도 있다.

 

 

Cache Stampede 현상


 

 

대규모 트래픽 환경에서 TTL 값이 너무 작게 설정하면 cache stampede 현상이 발생할 가능성이 있다.

캐시 공간은 한정되어 있으므로 저장된 데이터에 만료시간(TTL)을 정하는 것이 보통인데, 해당 데이터에 계속 읽기 요청이 들어오고 있을 때 캐시 만료시간이 닥치면 순간적으로 DB로 가서 찾게 되는 duplicate read 가 발생한다.

또 읽어온 값을 각 각 redis에 쓰는 duplicate write 도 발생되어, 처리량도 다 같이 느려질 뿐 아니라 불필요한 작업이 굉장히 늘어나 요청량 폭주로 장애로 이어질 가능성 도 있다.

 

PER(Probablistic Early Recomputation)

  • 이 현상을 해결하기 위해 PER 알고리즘을 도입할 수 있습니다. 이 알고리즘은 키의 TTL이 실제로 만료되기 전에 일정 확률로 캐시를 갱신하는 방법입니다.
  • 데이터베이스에서 키가 완전히 만료되기 전에 데이터를 먼저 읽어오게 함으로써 Cache Stampede 현상을 막을 수 있습니다.

Debouncing

  • 여러 번 반복되는 이벤트 중 마지막 이벤트만 실행하기

 

 

캐시 공유 방식 지침


캐시는 애플리케이션의 여러 인스턴스에서 공유하도록 설계된다. 그래서 각 애플리케이션 인스턴스가 캐시에서 데이터를 읽고 수정할 수 있다. 따라서 어느 한 애플리케이션이 캐시에 보유하는 데이터를 수정해야 하는 경우, 애플리케이션의 한 인스턴스가 만드는 업데이트가 다른 인스턴스가 만든 변경을 덮어쓰지 않도록 해야 한다.

 

그렇지 않으면 데이터 정합성 문제가 발생하기 때문이다. (각 애플리케이션마다 표시되는 개수가 달라지는 현상)

데이터의 충돌을 방지하기 위해 다음과 같은 애플리케이션 개발 방식을 취해야 한다.

 

먼저, 캐시 데이터를 변경하기 직전에 데이터가 검색된 이후 변경되지 않았는지 일일이 확인하는 방법이다. 변경되지 않았다면 즉시 업데이트하고 변경되었다면 업데이트 여부를 애플리케이션 레벨에서 결정하도록 수정해야 한다. 이와 같은 방식은 업데이트가 드물고 충돌이 발생하지 않는 상황에 적용하기 용이하다.

 

두 번째로, 캐시 데이터를 업데이트하기 전에 Lock을 잡는 방식이다. 이와 같은 경우 조회성 업무를 처리하는 서비스에 Lock으로 인한 대기현상이 발생한다. 이 방식은 데이터의 사이즈가 작아 빠르게 업데이트가 가능한 업무와 빈번한 업데이트가 발생하는 상황에 적용하기 용이하다.

 

캐시 가용성 지침


캐시를 구성하는 목적은 빠른 성능 확보와 데이터 전달에 있으며, 데이터의 영속성을 보장하기 위함이 아니라는 점을 기억하고 설계해야 한다. 데이터의 영속성은 기존 데이터 스토어에 위임하고, 캐시는 데이터 읽기에 집중하는 것이 성능 확보의 지침 사항이 될 수 있다.

 

또한, 캐시 서버가 장애로 인해 다운되었을 경우나 서비스가 불가능할 경우에도 지속적인 서비스가 가능해야 한다. 이는 캐시에 저장되는 데이터는 결국 기존 영구 데이터 스토어에 동일하게 저장되고 유지된다는 점을 뒷받침 하는 설계방식이다. (Write Through)

즉, 캐시 서버가 장애로부터 복구되는 동안 성능상의 지연은 발생할 수 있지만, 서비스가 불가능한 상태가 되지 않도록 고려해야 한다는 말이다.

 

 

참고 출처

https://dico.me/back-end/articles/326/ko

https://inpa.tistory.com/entry/REDIS-📚-캐시Cache-설계-전략-지침-총정리

https://velog.io/@soongjamm/Caffeine-Cache-를-적용해본-경험

https://velog.io/@soongjamm/Eventual-Consistency-란

https://bcp0109.tistory.com/364

https://meetup.nhncloud.com/posts/251

728x90
728x90

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

템플릿 메소드 패턴은 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.

  • 서브클래스에서 언제든 필요할 때마다 알고리즘을 가져다가 쓸 수 있도록 캡슐화 해보자
  • 할리우드 디자인 원칙을 알아보자

개요 - 커피와 홍차 만들기

커피와 홍차는 둘 다 카페인을 가지고 있고, 가장 중요한 점은 매우 비슷한 방법으로 만들어진다는 것이다.

커피 만드는 법

  1. 물을 끓인다.
  2. 끓는 물에 커피를 우려낸다.
  3. 커피를 컵에 따른다.
  4. 설탕과 우유를 추가한다.

홍차 만드는 법

  1. 물을 끓인다.
  2. 끓는 물에 찻잎을 우려낸다.
  3. 홍차를 컵에 따른다.
  4. 레몬을 추가한다.

Coffee 클래스와 Tea 클래스 만들기

이제 커피와 홍차를 만드는 클래스를 준비해 보자.

public class Coffee {

    public void prepareRecipe() {
        boilWater();
        brewCoffeeGrinds();
        pourInCup();
        addSugarAndMilk();
    }

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void brewCoffeeGrinds() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

    private void addSugarAndMilk() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}
public class Tea {

    public void prepareRecipe() {
        boilWater();
        steepTeaBag();
        pourInCup();
        addLemon();
    }

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void steepTeaBag() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

    private void addLemon() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}

조금 전에 Coffee 클래스에서 구현했던 방법과 비슷한 것을 느낄 수 있다.

두 번째와 네 번째 단계가 조금 다르지만 기본적으로 같다고 할 수 있다. 이렇게 공통적으로 코드가 중복된다면 디자인 수정을 고려해보자.

혹시 Coffee와 Tea 클래스는 거의 같으니까 두 클래스의 공통된 부분을 추상화해서 베이스 클래스로 만드는 것을 어떨까??


Coffee 클래스와 Tea 클래스 추상화하기

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

CaffeineBeverage

  • prepareRecipe() 메소드는 서브클래스마다 다르기 때문에 추상 메서드로 선언
  • boilWater()와 putInCup() 메서드는 두 클래스에서 공통으로 사용되므로 슈퍼클래스에 정의

서브 클래스(Coffee, Tea)

  • 서브 클래스는 prepareRecipe() 메소드를 오버라이드해서 음료 제조법을 구현
  • Coffee나 Tea 클래스에만 있던 메소드는 서브 클래스에 그대로 남김

추상화 방법 들여다보기

제조법을 다시 살펴보면 커피와 홍차 제조법의 알고리즘이 똑같다는 사실을 알 수 있다.

  1. 물을 끓인다.
  2. 뜨거운 물을 사용해서 커피 또는 찻잎을 우려낸다.
  3. 만들어진 음료를 컵에 따른다.
  4. 각 음료에 맞는 첨가물을 추가한다.

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • 2, 4 이 둘은 추상화되지 않았지만 똑같다. 서로 다른 음료에 적용될 뿐이다.
  • 이제 prepareRecipe() 까지 추상화하는 방법을 찾아보자. 생각해보면 커피를 필터로 우려내는 일과 티백을 물에 넣어서 홍차를 우려내는 일은 별로 다르지 않다. 사실 거의 같다고 볼 수 있기 때문에 brew() 메서드를 만들어서 커피를 우려내는 홍차를 우려내는 똑같은 메서드를 사용해보자.
  • 이와 마찬가지로 설탕과 우유를 추가하는 일이나 레몬을 추가하는 일도 마찬가지다. 음료에 첨가물을 넣는다는 사실 자체는 똑같기 때문이다. 따라서 addConfiments() 메소드를 양쪽에 사용해보자
public abstract class CaffeineBeverage {

    public void prepareRecipe() {
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    }

    protected abstract void addCondiments();

    protected abstract void brew();

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

}
  • 이제 다시 만든 메소드를 prepareRecipe()에 넣어보자.
public class Coffee extends CaffeineBeverage {

    @Override
    protected void brew() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    @Override
    protected void addCondiments() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}

------------------------------------------------------------------

public class Tea extends CaffeineBeverage{

    @Override
    protected void brew() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    @Override
    protected void addCondiments() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

}
  • 두 클래스에서 음료를 만드는 방법은 CaffeinBeverage에 의해 결정되므로 음료를 우려내는 brew()와 첨가물을 추가하는 addCondiments()를 수정하자.

추상화 과정 다시 살펴보기

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

조금 다른 방식으로 구현해야 하는 부분이 있긴 하지만, 만드는 방법이 사실상 똑같으므로 제조법을 일반화해서 베이스 클래스에 넣었다.

그 후 베이스 클래스에서 전체 처리 과정을 관리하며, 첫 번째와 세 번째 단계는 직접 처리하고 두 번째와 네 번째 단계는 Tea와 coffee 서브클래스에 의존한다.


템플릿 메소드 패턴 알아보기

지금까지 Coffee와 Tea 클래스에 템플릿 메소드 패턴을 적용했다고 할 수 있다. 템플릿 메소드는 CaffeinBeverage 클래스에 들어있다.

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • prepareRecipe()는 템플릿 메소드이다.
  • 템플릿 메소드란 어떤 알고리즘의 템플릿 역할을 하는 메서드이다.
  • 템플릿 내에서 알고리즘의 각 단계는 메소드로 표현한다.
  • 어떤 메소드는 해당 클래스에서 처리되기도 하고, 서브 클래스에서 처리되는 메소드도 있다.
  • 서브 클래스에서 구현해야 하는 메소드는 abstract로 선언해야 한다.

즉 템플릿 메소드는 알고리즘의 각 단계를 정의하며, 서브 클래스에서 일부 단계를 구현할 수 있도록 유도한다.


템플릿 메소드 패턴의 장점

기존 Tea & Coffee 클래스

  • 각 클래스가 각각 작업을 처리한다.
    • 두 클래스에서 각자 알고리즘을 수행
  • Coffee 와 Tea 클래스에 중복된 코드가 존재
  • 알고리즘이 바뀌면 서브클래스를 일일이 열어서 여러 군데를 고쳐야 한다.
  • 클래스 구조상 새로운 음료를 추가하려면 꽤 많은 일을 해야 한다.
  • 알고리즘 지식과 구현 방법이 여러 클래스에 분산되어 있다.

템플릿 메소드를 사용한 CaffeinBeverage

  • CaffeinBeverage 클래스에서 작업을 처리한다.
    • 알고리즘을 독점
  • 서브 클래스에서 코드를 재사용할 수 있다.
  • 알고리즘이 한 군데에 모여 있으므로 한 부분만 고치면 된다.
  • 다른 음료도 쉽게 추가할 수 있는 프레임워크를 제공한다.
    • 음료를 추가할 때 몇 가지 메소드만 더 만들면 된다.
  • CaffeinBeverage 클래스에 알고리즘 지식이 집중되어 있으며 일부 구현만서브클래스에 의존한다.

템플릿 메소드 패턴의 정의

이제 패턴의 정의와 특징을 자세히 알아보자.

간단하게 말하면 템플릿 메소드 패턴은 알고리즘의 템플릿(틀)을 만든다. 템플릿이란 일련의 단계로 알고리즘을 정의한 메소드이다. 여러 단계 가운데 하나 이상의 단계가 추상 메소드로 정의되며, 그 추상 메소드는 서브 클래스에서 구현된다. 이러면 서브 클래스가 일부분의 구현을 처리하게 하면서도 알고리즘의 구조는 바꾸지 않아도 된다.

클래스 다이어그램

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • 템플릿 메소드는 알고리즘을 구현할 때 primitiveOperation을 활용한다.
    • 알고리즘은 이 단계들의 구체적인 구현으로부터 분리되어 있다.
  • 서브 클래스가 알고리즘의 각 단계를 마음대로 건드리지 못하게 final로 선언합니다.
  • 추상 클래스 내에 구상 메소드로 정의된 단계도 있다. 해당 메소드는 fianl로 선언되었으므로 서브 클래스에서 오버라이드할 수 없다.
    • 이 메소드는 템플릿 메소드에서 직접 호출할 수도 있고
    • 서브클래스에서 호출해서 사용할 수도 있다.
  • 기본적으로 아무것도 하지 않는 구상 메소드를 정의할 수도 있다.
    • 이런 메소드를 후크(hook) 라고 부른다.
    • 서브 클래스에서 오버라이드할 수도 있지만, 반드시 그래야 하는건 아니다.

후크 알아보기

후크는 추상 클래스에서 선언되지만 기본적인 내용만 구현되어 있거나 아무 코드도 들어있지 않은 메소드이다.

이러면 서브 클래스는 다양한 위치에서 알고리즘에 끼어들 수 있다. 물론 그냥무시하고 넘어갈 수도 있다. 후크는 다양한 용도로 사용된다. 한 가지 사용법의 예시를 알아보자.

public abstract class CaffeineBeverage {

    public void prepareRecipe() {
        boilWater();
        brew();
        pourInCup();
        if (customerWantsCondiments()) {
            addCondiments();
        }
    }

    protected abstract void addCondiments();

    protected abstract void brew();

    private void boilWater() {
        System.out.println("물 끓이는 중");
    }

    private void pourInCup() {
        System.out.println("컵에 따르는 중");
    }

    private boolean customerWantsCondiments() { // hook
        return true;
    }

}
  • 별 내용이 없는 기본 메소드를 구현해 놓았다.
  • 해당 메소드는 true만 리턴할 뿐 다른 작업은 하지 않는다.
  • 이 메소드는 서브클래스에서 필요할 때 오버라이드할 수 있는 메소드이므로 후크이다.

후크 활용하기

후크를 사용하려면 서브 클래스에서 후크를 오버라이드해야 한다.

위의 예제에서는 알고리즘의 특정 부분을 처리할지 말지를 결정하는 용도로 후크를 사용했다.

즉, 음료에 첨가물을 추가할지 말지를 결정하는 메소드다.

public class CoffeeWithHook extends CaffeineBeverage { // 후크를 오버라이드해서 원하는 기능을 추가

    @Override
    protected void brew() {
        System.out.println("필터로 커피를 우려내는 중");
    }

    @Override
    protected void addCondiments() {
        System.out.println("설탕과 우류를 추가하는 중");
    }

    @Override
    protected boolean customerWantsCondiments() {
        String answer = getUserInput();
        if (answer.toLowerCase().startsWith("y")) {
            return true;
        }
        return false;
    }

    private String getUserInput() {
        String answer = null;
        System.out.println("커피에 우유와 설탕을 넣을까요? (y/n)? ");
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        try {
            answer = br.readLine();
        } catch (IOException exception) {
            System.out.println("io exception");

        }
        if (answer == null) {
            return "no";
        }
        return answer;
    }
}

후크 코드 테스트

public class BeverageTestDrive {

    public static void main(String[] args) {
        CoffeeWithHook coffeeWithHook = new CoffeeWithHook();
        System.out.println("커피 준비 중");
        coffeeWithHook.prepareRecipe();
    }

}

===================================================================

커피 준비 중
물 끓이는 중
필터로 커피를 우려내는 중
컵에 따르는 중
커피에 우유와 설탕을 넣을까요? (y/n)? 
y
설탕과 우류를 추가하는 중

Q) 템플릿을 만들 때 추상 메소드를 써야할 때와 후크를 써야할 때를 어떻게 구분할 수 있을까??

서브 클래스가 알고리즘의 특정 단계를 제공해야만 한다면 추상 메소드를 써야 한다. 알고리즘의 특정 단계가 선택적으로 적용된다면 후크를 쓰면 된다. 후크를 쓰면 서브클래스에서 필요할 때 후크를 구현할 수도 있지만, 꼭 구현해야 하는건 아니기 때문이다.

Q) 후크는 정확하게 어떤 용도로 쓰이는 걸까??

여러 가지 용도로 쓰인다. 알고리즘에서 필수적이지 않은 부분을 서브클래스에서 구현하도록 만들고 싶을 때 후크를 쓸 수 있다. 또한 템플릿 메소드에서 앞으로 일어날 일이나 막 일어난 일에 서브클래스가 반응할 수 있도록 기회를 제공하는 용도로도 쓰일 수 있다.

예를 들면, 내부적으로 특정 목록을 재정렬한 후에 서브 클래스에서 특정 작업을 수행하도록 싶을 때, justReOrderedList() 같은 이름을 가진 후크 메소드를 쓸 수도 있다. 또한 앞에 예제에서 봤듯이 서브클래스가 추상 클래스에서 진행되는 작업을 처리할지 말지 결정하게 하는 기능을 부여하는 용도로 후크를 쓸 수도 있다.

Q) 서브클래스에서 AbstractClass에 있는 모든 추상 메소드를 구현해야 할까??

그렇다. 모든 서브클래스에서 모든 추상 메소드를 정의해야 한다.

즉, 템플릿 메소드에 있는알고리즘의 단계 중에서 정의되지 않은 부분을 모두 채워 줘야 한다.

Q) 추상 메소드가 너무 많아지면 서브 클래스에서 일일이 추상 메소드를 구현해야 하니까 별로 좋지 않을 수 있지 않을까??

맞다. 템플릿 메소드를 만들 때는 그 점을 꼭 생각해 봐야 한다.

알고리즘의 단계를 너무 잘게 쪼개지 않는 것도 한 가지 방법이 될 수 있다. 하지만 알고리즘을 큼직한 몇 가지 단계로만 나누어 놓으면 유연성이 떨어진다는 단점도 있으니 잘 생각해서 결정해야 한다.

그리고 모든 단계가 필수는 아니라는 점도 기억하자. 필수가 아닌 부분을 후크로 구현하면 그 추상 클래스의 서브 클래스를 만들 때 부담이 조금 줄어들 것이다.


할리우드 원칙

디자인 원칙 중 할리우드 원칙이 있다. 이 원칙은 보통 다음과 같이 정의될 수 있다.

할리우드에서 배우들과 연락하는 것과 비슷하게, 슈퍼 클래스에서 모든 것을 관리하고 필요한 서브클래스를 불러서 써야 한다는 원칙이다. 이런 할리우드 원칙을 활용하면 의존성 부패(dependency rot)를 방지할 수 있다.

어떤 고수준 구성 요소가 저수준 구성 요소에 의존하고, 그 저수준 구성 요소는 다시 고수준 구성 요소에 의존하고, 그 고수준 구성 요소는 다시 또 다른 구성 요소에, 그 다른 구성 요소는 또 저수준 구성 요소에 의존하는 것과 같은 식으로 의존성이 복잡하게 꼬여있는 상황을 의존성이 부패했다고 부른다. 이렇게 의존성이 부패하면 시스템 디자인이 어떤 식으로 되어 있는지 아무도 알아볼 수 없다.

할리우드 원칙을사용하면, 저수준 구성 요소가 시스템에 접속할 수는 있지만 언제, 어떻게 그 구성 요소를 사용할지는 고수준 구성 요소가 결정한다.

즉 고수준 구성 요소가 저수준 구성 요소에게 “먼저 연락하지 마세요. 제가 먼저 연락 드리겠습니다.” 라고 이야기 하는 것과 같다.

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • 저수준 구성 요소도 컴퓨테이션에 참여할 수 있다.
  • 하지만 언제, 어떻게 쓰일지는 고수준 구성 요소가 결정한다.
    • 저수준 구성 요스는 절대 고수준 구성 요소를 직접 호출할 수 없다.

할리우드 원칙과 템플릿 메소드 패턴

할리우드 원칙과 템플릿 메소드 패턴의 관계는 쉽게 알 수 있다. 템플릿 메소드 패턴을 써서 디자인 하면 서브클래스에게 “우리가 연락할 테니까 먼저 연락하지마”라고 얘기하는 구조이기 대문이다.

디자인을 다시 한번 살펴보자

https://books.google.co.kr/books?id=Lw8LEAAAQBAJ&printsec=frontcover&hl=ko&source=gbs_ge_summary_r&cad=0#v=onepage&q&f=false

  • CaffeineBeverage 는 고수준 구성 요소이다.
    • 음료를 만드는 방법에 해당하는 알고리즘을 장악하고 있고, 메소드 구현이 필요한 상황에만 서브클래스를 불러낸다.
  • CaffeineBeverage 클래스의 클라이언트는 Tea, Coffee 같은 구상 클래스가 아닌 CaffeineBeverage 에 추상화되어 있는 부분에 의존한다. 이러면 전체 시스템의 의존성을 줄일 수 있다.
  • 서브 클래스는 자질 구레한 메소드 구현을 제공하는 용도로만 쓰인다.
  • Tea와 Coffee는 호출 당하기 전까지는 추상 클래스를 직접 호출하지 않는다.

Q) 할리우드 원칙과 의존성 뒤집기 원칙은 어떤 관계일까??

의존성 뒤집기 원칙은 될 수 있으면 구상 클래스 사용을 줄이고 추상화된 것을 사용해야 한다는 원칙이다. 할리우드 원칙은 저수준 구성 요소가 컴퓨테이션에 참여하면서도 저수준 구성 요소와 고수준 계층 간 의존을 없애도록 프레임워크는 구성 요소를 구축하는 기법이다.

따라서 두 원칙은 객체를 분리한다는 하나의 목표를 공유하지만, 의존성을 피하는 방법에 있어서 의존성 뒤집기 원칙이 훨씬더 강하고 일반적인 내용을 담고 있다.

할리우드 원칙은 저수준 구성요소를 다양하게 사용할수 있으면서도 다른 클래스가 구성 요소에 너무 의존하지 않게 만들어주는 디자인 구현 기법을 제공한다.

Q) 저수준 구성 요소에서는 고수준 구성 요소에 있는 메소드를 호출할 수 없는 것일까??

그렇지 않다. 사실 저수준 구성 요소에서도 상속 계층구조 위에 있는 클래스가 정의한 메소드를, 상속으로 호출하는 경우도 빈번하게 있다. 하지만 저수준 구성 요소와 고수준 구성 요소 사이에 순환 의존성이 생기지 않도록 해야한다.


자바 api 속 템플릿 메서드 패턴 알아보기

바로 Arrays.sort 메소드이다.

private static void legacyMergeSort(Object[] a) {
    Object[] aux = a.clone();
    mergeSort(aux, a, 0, a.length, 0);
}

private static void mergeSort( // 템플릿 메소드
        Object[] src,
    Object[] dest,
    int low,
    int high,
    int off
    ) {
      // Insertion sort on smallest arrays
      if (length < INSERTIONSORT_THRESHOLD) {
          for (int i=low; i<high; i++)
              for (int j=i; j>low &&
                       ((Comparable) dest[j-1]).compareTo(dest[j])>0; j--) // 템플릿 메소드를 완성하려면 comapreTo() 메소드를 구현해야 한다.
                  swap(dest, j, j-1); // Arrays 클래스에 이미 정의되어 있는 구상 ㅇ메솓,
          return;
      }
        // ...

}

만약 배열 속 오리 클래스들을 정렬해야 한다면 Arrays에 있는 정렬용 템플릿 메소드에서 알고리즘을 제공하지만, 오리 비교 방법은 comapreTo() 메소드로 구현해야 한다.

하지만 템플릿 메소드 패턴을 배울 때 서브 클래스에서 일부 단계를 구현한다고 배웠는데, 해당 예제에서는 서브 클래스를 만들지 않고 있다.

sort() 메소드는 정적 메소드이고 정적 메소드 자체는 크게 문제가 되지 않는다. 슈퍼 클래스에 들어있다고 생각하면 되기 때문이다. 하지만 sort() 자체가 특정 슈퍼클래스에 정의되어 있는게 아니므로 sort() 메소드가 우리가 comapreTo() 메소드를 구현했는지 알아낼 수 있는 방법이 필요하다는 점이다.

이러한 문제를 해결하기 위해 Comaprable 인터페이스가 도입되었다. 이제 해당 인터페이스를 구현하기만 하면 문제가 해결 된다.

public class Duck implements Comparable<Duck> {

    private final String name;
    private final int weight;

    public Duck(String name, int weight) {
        this.name = name;
        this.weight = weight;
    }

    public String getName() {
        return name;
    }

    public int getWeight() {
        return weight;
    }

    @Override
    public int compareTo(Duck otherDuck) {
        return Integer.compare(this.weight, otherDuck.getWeight());
    }
}

Q) 오리 정렬 코드가 정말 템플릿 메소드 패턴일까? 억지스러운 것일까?

Arrays.sort() 메소드는 분명 템플릿 메소드 패턴의 정의의 방법을 사용하지 않고 있지만 실전에서 패턴을 적용하는 방법이 책에 나와 있는 방법과 완전히 같을 수는 없다. 주어진 상황과 구현ㄴ상 제약조건에 맞게 고쳐서 적용해야 한다.

일반적으로 자바에서는 배열의 서브클래스를 만들 수 없지만, 어떤 배열에서도 정렬 기능을 사용할 수 있도록 만들어야 했다. 그래서 sort() 메소드를 정적 메소드로 정의한 다음, 대소를 비교하는 부분은 정렬될 객체에서 구현되도록 만든 것이다.

온전한 템플릿 메소드라고 할 순 없겠지만 템플릿 메소드 패턴의 기본 정신을 충실히 따르고 있다. 또한 서브클래스를 만들어야 한다는 제약 조건을 없앰으로써 오히려 더 유연하면서 유용한 정렬 메소드를 만들었다.

Q) 구현해놓은 것을 보니 템플릿 메소드 패턴 보다는 전략 패턴과 가까워 보이는데 템플릿 메소드 패턴이라고 볼 수 있는 근거는 무엇일까?

전략 패턴에서 객체 구성을 사용하니까 어떻게 보면 일리가 있지만 전략 패턴에서는 구성할 때 사용하는 클래스에서 알고리즘을 완전히 구현한다.

Arrays 클래스에서 사용하는 알고리즘은 불완전한다. comapreTo() 를 다른 클래스에서 제공해 줘야 하기 때문이다. 따라서 템플릿 메소드 패턴이 적용되었다고 볼 수 있다.


개념이 비슷해 보이는 패턴

  • 템플릿 메소드 패턴 : 알고리즘의 어떤 단계를 구현하는 방법을 서브클래스에서 결정
  • 전략 패턴 : 바꿔 쓸 수 있는 행동을 캡슐화하고, 어떤 행동을 사용할지는 서브클래스에 맡김
  • 팩토리 메소드 패턴 : 구상 클래스의 인스턴스 생성을 서브클래스에서 결정

템플릿 메소드 vs 전략 패턴

두 가지 모두 같은 요구사항을 구현할 수 있지만 템플릿 메소드 패턴은 알고리즘의 개요를 정의하는 역할을 한다. 진짜 작업 중 일부는 서브클래스에서 처리하며 각 단계마다 다른 구현을 사용하면서도 알고리즘 구조 자체는 그대로 유지할 수 있다. 따라서 알고리즘을 더 강하게 제어할 수 있고, 코드 중복도 거의 없다. 만약 알고리즘이 전부 똑같고 코드 한 줄씩만 다르다면 템플릿 메서드 패턴을 사용한 클래스가 전략 패턴을 사용한 클래스보다 효율적일 수 있다.

하지만 전략 패턴은 상속이 아닌 객체 구성을 사용하기 때문에 상속에서 오는 단점들이 없고 훨씬 더 유연하다는 장점이 있다. 부모 같이 어떤 것에도 의존하지 않고 알고리즘을 전부 알아서 구현할 수 있기 때문이다.


핵심 정리

  • 템플릿 메소드는 알고리즘의 단계를 정의하며 일부 단계를 서브클래스에서 구현하도록 할 수 있다.
  • 템플릿 메소드 패턴은 코드 재사용에 큰 도움이 된다.
  • 템플릿 메소드가 들어있는 추상 클래스는 구상 메소드, 추상 메소드, 후크를 정의할 수 있다.
  • 추상 메소드는 서브클래스에서 구현한다.
  • 후크는 추상 클래스에 들어있는 메소드로 아무 일도 하지 않거나 기본 행동만을 정의한다.
    • 서브 클래스에서 후크를 오버라이드 할 수 있다.
  • 할리우드 원칙에 의하면, 저수준 모듈을 언제 어떻게 호출할지는 고수준 모듈에서 결정하는 것이 좋다.
  • 템플릿 메소드 패턴은실전에서도 꽤 자주 쓰이지만 반드시 교과서적인 방식으로 적용되진 않는다.
  • 전략 패턴과 템플릿 메소드 패턴은 모두 알고리즘을 캡슐화하는 패턴이지만 전략 패턴은 상속을, 템플릿 메소드 패턴은 구성을 사용합니다.
  • 팩토리 메소드 패턴은 특화된 템플릿 메소드 패턴입니다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
    • 진짜 절친에게만 이야기해야 한다.
    • 먼저 연락하지 마세요. 저희가 연락 드리겠습니다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
    • 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
    • 커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
    • 어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
    • 퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
    • 템플릿 메소드 패턴 : 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.
728x90
728x90

어댑터 패턴 & 퍼사드 패턴

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.

퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.


개요 - 어댑터

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

우리 주변에서 볼 수 있는 어댑터의 역할은 전원 소켓의 인터페이스를 플러그에서 필요로 하는 인터페이스로 바꿔 준다고 할 수 있다. 객체지향 어댑터도 똑같이 어떤 인터페이스를 클라이언트에서 요구하는 형태로 적응시키는 역할을 한다.


객체지향 어댑터

어떤 소프트웨어 시스템에서 새로운 업체에서 제공한 클래스 라이브러리를 사용해야 하는데 그 업체에서 사용하는 인터페이스가 기존에 사용하던 인터페이스와 다르다고 가정해 보자.

https://codingsmu.tistory.com/59

그런데 기존 코드를 바꿔서 이 문제를 해결할 수 없는 상황이고, 업체에서 공급받은 클래스도 변경할 수 없다면 어떻게 해야 할까?? 바로 새로운 업체에서 사용하는 인터페이스를 기존에 사용하던 인터페이스에 적응시켜 주는 클래스를 만들면 된다.

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

어댑터는 기존 시스템에서 사용하던 인터페이스를 구현해서 새로운 업체에서 제공한 클래스에 요구 내역을 전달할 수 있다. 어댑터는 클라이언트로부터 요청을 받아서 새로운 업체에서 제공하는 클래스를 클라이언트가 받아들일 수 있는 형태의 요청으로 변환해 주는 중개인 역할을 하는 것이다.


어댑터 사용방법

어댑터를 어떻게 사용하는지 한번 살펴보자

public interface Duck {

    void quack();
    void fly();

}

public class MallardDuck implements Duck {

    @Override
    public void quack() {
        System.out.println("quack");
    }

    @Override
    public void fly() {
        System.out.println("fly");
    }
}
public interface Turkey {

    void gobble();
    void fly();

}

public class WildTurkey implements Turkey {

    @Override
    public void gobble() {
        System.out.println("gobble");
    }

    @Override
    public void fly() {
        System.out.println("short fly");
    }
}

Duck 객체가 모자라서 Turkey 객체를 대신 사용해야 하는 상황이라고 가정해 보자. 물론 인터페이스가 다르기에 Turkey 객체를 바로 사용할 수는 없다. 이 때 필요한 것이 어댑터이다.

// 우선 적응시킬 형식의 인터페이스를 구현해야 한다. 즉 클라이언트에서 원하는 인터페이스를 구현해야 한다.
public class TurkeyAdapter implements Duck { 


    private final Turkey turkey;

    public TurkeyAdapter(Turkey turkey) { // 그리고 기존 형식 객체의 레퍼런스가 필요한다.
        this.turkey = turkey;
    }

    @Override
    public void quack() {
        turkey.gobble();
    }

    /*
     두 인터페이스에 모두 fly가 있지만 turkey의 fly() 메소드를 Duck의 fly() 메소드에 대응시키도록 작성 
     */
    @Override
    public void fly() {
        for (int i = 0; i < 5; i++) {
            turkey.fly();
        }
    }
}
public class DuckTestDrive {

    public static void main(String[] args) {
        Duck duck = new MallardDuck();
        Turkey turkey  = new WildTurkey();
        Duck turkeyAdapter = new TurkeyAdapter(turkey);

        System.out.println(" turkey said that");
        turkey.gobble();
        turkey.fly();

        System.out.println("\n duck said that");
        testDuck(duck);

        System.out.println("\n turkeyAdapter said that");
        testDuck(turkeyAdapter);
    }

    private static void testDuck(Duck duck) {
        duck.quack();
        duck.fly();
    }

}

=======================================================
turkey said that
gobble
short fly

duck said that
quack
fly

turkeyAdapter said that
gobble
short fly
short fly
short fly
short fly
short fly
  • 클라이언트의 testDuck() 메소드는 오리와 칠면조를 전혀 구분하지 못한다.

어댑터 패턴

https://codingsmu.tistory.com/59

이제 어댑터가 어떤 식으로 작동하는지 살펴보자

  1. 클라이언트는 타깃 인터페이스에 맞게 구현되어 있으며, 타깃 인터페이스로 메소드를 호출해서 어댑터에 요청을 보낸다.
  2. 어댑터는 타깃 인터페이스를 구현하며, 어댑티 인스턴스를 가지고 있다. 어댑터는 어댑티 인터페이스로 그 요청을 어댑티에 관한(하나 이상의) 메소드 호출로 변환한다.
  3. 클라이언트는 호출 결과를 받긴 하지만 중간에 어댑터가 있다는 사실을 모르므로, 클라이언트와 어댑티는 서로 분리되어 있다.

Q. 어댑터가 얼마나 적응시켜 줘야 할까? 대형 타깃 인터페이스를 구현해야 한다면 할 일이 정말 많아지지 않을까?

어댑터 구현은 타깃 인터페이스로 지원해야 하는 인터페이스의 크기에 비례해서 복잡해진다. 하지만 다른 대안이 없다. 클라이언트에서 호출하는 부분을 새로운 인터페이스에 맞춰서 고치려면 정말 많은 부분을 고려해야 하고, 코드도 많이 고쳐야 한다. 이런 방법보다는 모든 변경 사항을 캡슐화할 어댑터 클래스 하나만 제공하는 방법이 더 나을 것이다.

Q. 하나의 어댑터는 하나의 클래스만 감싸야 할까?

어댑터 패턴은 하나의 인터페이스를 다른 인터페이스로 변환하는 용도로 쓰인다. 하나의 어댑터에서 타깃 인터페이스를 구현하려고 2개 이상의 어댑티를 감싸야 하는 상황도 생길 수 있다. 사실 이런 내용은 퍼사드 패턴과 관련이 있으므로 퍼사드 패턴 때 다시 보자.

Q. 시스템에 오래된 부분과 새로 만든 부분이 섞여 있으면 어떻게 해야할까?? 어떤 곳에는 어댑터를 사용하고 다른 곳에서 어댑터로 감싸지 않은 인터페이스를 사용하면 헷갈리지 않을까?

이런 상황에서는 두 인터페이스를 모두 지원하는 다중 어댑터(Two Way Adapter)를 만들면 된다. 다중 어댑터로 필요한 인터페이스를 둘 다 구현해서 어댑터가 기존 인터페이스와 새로운 인터페이스 역할을 할 수 있게 하면 된다.


어댑터 패턴의 정의

이제 어댑터 패턴의 정의를 알아보자.

이 패턴을 사용하면 호환되지 않는 인터페이스를 사용하는 클라이언트를 그대로 활용할 수 있다. 인터페이스를 변환해 주는 어댑터를 만들면 되기 때문이다. 이러면 클라이언트와 구현된 인터페이스를 분리할 수 있으며, 변경 내역이 어댑터에 캡슐화되기에 나중에 인터페이스가 바뀌더라도 클라이언트를 바꿀 필요가 없다.

클래스 다이어그램

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

클라이언트

  • 클라이언트는 타킷 인터페이스만 볼 수 있다.

어댑터

  • 어댑터에서 타깃 인터페이스를 구현한다.
  • 어댑터는 어댑티로 구성되어 있다.

어댑티

  • 모든 요청은 어댑티에 위임된다.

어댑터 패턴은 여러 객체지향 원칙을 반영하고 있다. 어댑티를 새로 바뀐 인터페이스로 감쌀 때는 Composition을 사용한다. 이런 접근번은 어댑티의 모든 서브클래스에 어댑터를 쓸 수 있다는 장점이 있다.

그리고 어댑터 패턴은 클라이언트를 특정 구현이 아닌 인터페이스에 연결한다. 서로 다른 백엔드 클래스로 변환시키는 여러 어댑터를 사용할 수도 있다. 이렇게 인터페이스를 기준으로 코딩했기에 타깃 인터페이스만 제대로 유지한다면 나중에 다른 구현을 추가하는 것도 가능하다.


객체 어댑터와 클래스 어댑터

사실 어댑터에는 두 종류가 있다. 하나는 객체 어댑터, 다른 하나는 클래스 어댑터이다.

지금까지 본 예제와 다이어그램 모두 객체 어댑터에 해당하는 내용들이다. 그렇다면 클래스 어댑터란 무엇이고 왜 살펴보지 않았을까? 클래스 어댑터 패턴을 쓰려면 상속이 필요한데 자바에서는 다중 상속이 불가능하므로 자바에서는 불가능하다. 하지만 다중 상속이 가능한 언러를 사용하다 보면 클래스 어댑터를 써야 할 때도 있으니 클래스 다이어그램을 살펴보자

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴

어댑티를 적응시킬때 구성을 사용하는 대신, 어댑터를 어댑티와 타깃 클래스의 서브클래스로 만든다. 상속을 사용하는 클래스 어댑터에 비해 객체 어댑터는 composition을 사용하므로 상속을 통한 코드 분량을 줄이지는 못하지만, 어댑티한테 필요한 일을 시키는 코드만 작성하면 되기 때문에 작성해야할 코드가 적고 유연성을 확보할 수 있다.


어댑터 패턴 실전 적용

https://swk3169.tistory.com/255

Enumeration

Enumeration을 리턴하는 elements() 메소드가 구현되어 있었던, 초기 컬렉션 형식(Vector, Stack, Hashtable 등)은 Enumeration 인터페이스를 이용하면 컬렉션 내에서 각 항목이 관리되는 방식에는 신경 쓸 필요 없이 컬렉션의 모든 항목에 접근이 가능하다.

Iterator

최근에는 Enumeration과 마찬가지로 컬렉션에 있는 일련의 항목들에 접근할 수 있게 해 주면서 항목을 제거할 수 도 있게 해 주는 iterator라는 인터페이스를 이용하기 시작했다.

Enumeration vs Iterator

Enumeration 인터페이스를 사용하는 구형 코드를 다뤄야 할 때도 가끔 있지만 새로운 코드를 만들 때는 Iterator만 사용하는 것이 좋다. 이때 어댑터 패턴을 적용해보자.

https://codingsmu.tistory.com/59

  • Itrerator : 타깃 인터페이스
  • Enumeration : 어댑티 인터페이스
  • 그런데 Iterator의 remove() 메소드는 Enumeration에는 이런 기능을 제공하는 메소드가 없다.
    • 어떻게 해야할까??

어댑터 디자인하기

클래스 다이어그램은 다음과 같다. 먼저 타깃 인터페이스를 구현하고, 어댑티 객체로 구성된 어댑터를 구현해야 한다. hasNext()와 next() 메소드는 타깃에서 업대티로 바로 연결된다.

https://codingsmu.tistory.com/59

remove()는 어떻게 처리할까?

  • 어댑터 차원에서 완벽하게 작동하는 remove() 메소드의 구현 방법은 없다. 따라서 그나마 좋은 방법은 런타임 예외를 던지는 것이다.
  • Iterator 인터페이스는 remove()는 default method로 UnsupportedOperationException을 던지고 있다.

이처럼 메소드가 일대일로 대응되지 않는 상황에서는 어댑터를 완벽하게 적용할 수 없다. 클라이언트는 예외 발생 가능성을 염두에 두고 있어야 하기 때문이다. 하지만 클라이언트에서 주의를 기울이고, 어댑터 문서를 잘 만들어 두면 괜찮을 것이다.

EnumerationIteraotr

public class EnumerationIterator implements Iterator<Object> {

    private final Enumeration<?> enumeration;

    public EnumerationIterator(Enumeration<?> enumeration) {
        this.enumeration = enumeration;
    }

    @Override
    public boolean hasNext() {
        return enumeration.hasMoreElements();
    }

    @Override
    public Object next() {
        return enumeration.nextElement();
    }

    @Override
    public void remove() {
        throw new UnsupportedOperationException();
    }
}

퍼사드 패턴

지금까지 어댑터 패턴을 써서 어떤 클래스의 인터페이스를 클라이언트가 원하는 인터페이스로 변환하는 방법을 어댑터 패턴을 이용하여 구현했다.

이제 조금 다른 이유로 인터페이스를 변경하는 또 다른 패턴을 알아보자. 바로 퍼사드 패턴이다.

퍼사드 패턴은 인터페이스를 단순하게 바꾸려고 인터페이스를 변경하다. 하나 이상의 클래스 인터페이스를 깔끔하면서도 효과적인 퍼사드(겉모양, 외관)으로 덮어주기 때문이다.

데코레이터 vs 어댑터 vs 퍼사드

해당 패턴 모두 객체를 감싸고 있는 공통점을 가지고 있다. 하지만 모두 사용하는 용도가 다르다.

  • 데코레이터는 인터페이스는 바꾸지 않고 감싸고 있는 객체의 행동과 책임을 확장하는 용도로 사용한다.
  • 어댑터는 하나의 인터페이스를 다른 인터페이스로 변환하는 용도로 사용한다.
  • 퍼사드는 인터페이스를 간단하게 변경하는 용도로 사용한다.

개요 - 홈시어터 만들기

패턴을 알아보기 전에 영화나 tv 시리즈 몰아보기가 유행에 따라 각광받고 있는 홈시어터를 구축해보자. 스트리밍 플레이어, 프로젝터, 자동 스크린, 서라운드 음향, 팝콘 기계 등 클래스들이 서로 복잡하게 얽혀 있다.

https://invincibletyphoon.tistory.com/22

영화를 보기위한 일련의 작업

이제 영화를 보려고 하지만 영화를 보려면 몇 가지 일을 더해야 한다.

  1. 팝콘 기계를 켠다
  2. 팝콘을 튀긴다.
  3. 조명을 어둡게 조절한다.
  4. 스크린을 내린다.
  5. 프로젝터를 켠다.
  6. 프로젝터 입력을 스트르밍 플레이어로 설정한다.
  7. 프로젝터를 와이드 스크린 모드로 전환한다.
  8. 앰프를 켠다.
  9. 앰프 입력을 스트리밍 플레이어로 설정한다.
  10. 앰프를 서라운드 음향 모드로 전환한다.
  11. 앰프 볼륭을 중간인 5로 설정한다.
  12. 스트리밍 플레이어를 켠다.
  13. 영화를 재생한다.

이제 이 작업들을 처리하기 위한 어떤 클래스와 메소드가 필요한지 살펴보자.

popper.on();
popper.pop();

lights.dim(10);
screen.down();
projector.on();
projector.setInput(player);
projector.wideScreenMode();

amp.on();
amp.setDvd(player);
amp.setSurroundSound();
amp.setVolume(5);

player.on();
player.play(movie);

클래스가 6개나 필요하고, 만약 영화가 끝나면 어떻게 해야할까?, 방금 했던 일을 전부 역순으로 처리해야 하지 않을까? 다른 라디오나 시스템이 업그레이드하면 이런 복잡한 작동 방법을 또 배워야 하지 않을까?

  • 이렇게 복잡한 일을 퍼사드 패턴으로 간단하게 처리할 수 있는지 알아보자

퍼사드 작동 원리

쓰기 쉬운 인터페이스를 제공하는 퍼사드 클래스를 구현함으로써 복잡한 시스템을 훨씬 편리하게 사용할 수 있다. 물론 기존의 시스템을 직접 건드리고 싶다면 기존 인터페이스를그대로 사용하면 된다.

  1. 홈시어터 시스템용 퍼사드를 만들어보자.
    • watchMovie()와 같이 몇 가지 간단한 메소드만 들어있는 HomeTheaterFacade 클래스를 새로 만들어야 한다.
  2. 퍼사드 클래스는 홈시어터 구성 요소를 하나의 서브시스템으로 간주한다.
    • watchMovie() 메서드는 서브시스템의 메소드를 호출해서 필요한 작업을 처리한다.
  3. 이제 클라이언트 코드는 서비스시템이 아닌 홈시어터 퍼사드에 있는 메서드를 호출한다.
    • watchMovie() 메서드만 호출하면 조명, 스트리밍 플레이어, 앰프 등 알아서 준비된다.
  4. 퍼사드를 쓰더라도 서브시스템에 여전히 직접 접근할 수 있다.
    • 서브시스템 클래스의 고급 기능이 필요하면 언제든지 사용 가능하다.

Q. 퍼사드로 서브시스템 클래스를 캡슐화하면 저수준 기능을 원하는 클라이언트는 어떻게 서브시스템 클래스에 접근할 수 있을까?

퍼사드 클래스는 서브시스템 클래스를 캡슐화하지 않는다. 서브시스템의 기능을 사용할 수 있는 간단한 인터페이스를 제공할 뿐이다. 클라이언트에서 특정 인터페이스가 필요하다면 서브시스템 클래스를 그냥 사용하면 된다. 이점이 퍼사드 클래스의 대표적인 장점이다. 단순화된 인터페이스를 제공하면서도, 클라이언트에서 필요로 한다면 시스템의 모든 기능을 사용할 수 있도록 해줍니다.

Q. 퍼사드에서 기능을 추가하거나 각각의 요청을 서브시스템에 그대로 전달하기도 할까?

퍼사드는 단순화된 서브시스템의 기능을 활용하게 해주는 일 외에도 ‘스마트’한 기능을 알아서 추가한다. 예를 들어, 홈시어터 퍼사드는 새로운 행동을 구현하지는 않지만, 팝콘을 튀기기 전에 팝콘 기계를 켜야 한다는 사실을 알고 있습니다. 그래서 팝콘 기계를 알아서 킨다. 그리고 각 구성 요소를 켜고 적절한 모드를 선택하는 것도 알아서 잘할 정도로 ‘스마트’하다.

Q. 하나의 서브시스템에는 하나의 퍼사도만 만들수 있을까?

그렇지 않다. 특정 서브시스템에 대해 만들 수 있는 퍼사드의 개수에는 제한이 없다.

Q. 더 간단한 인터페이스를 만들 수 있다는 점 말고 퍼사드의 또 다른 장점은 없을까?

퍼사드를 사용하면 클라이언트 구현과 서브시스템을 분리할 수 있다. 예를 들어 홈시어터 시스템을 업그레이드 하기로 가정해보자. 이런 경우 인터페이스가 크게 달라질 수 있을 것이다. 만약 클라이언트를 퍼사드로 만들었다면 클라이언트 코드는 고칠 필요 없이 퍼사드만 바꾸면 된다.

Q. 어댑터는 한 클래스만 감싸고 퍼사드는 여러 클래스를 감쌀 수 있는 것일까?

그렇지 않다. 어댑터 패턴은 하나 이상의 클래스 인터페이스를 클라이언트에서 필요로 하는 인터페이스로 변환한다. 클라이언트가 여러 클래스를 사용할 수도 있기 대문이다. 반대로 퍼사드도 꼭 여러 클래스를 감싸야만 하는 건 아니다. 아주 복잡한 인터페이스를 가지고 있는 단 하나의 클래스에 대한 퍼사드를 만들 수도 있다.

어댑터와 퍼사드의 차이점은 감싸는 클래스의 개수에 있는 것이 아니라 용도에있다. 어댑터 패턴은 인터페이스를 변경해서 클라이언트에서 필요로 하는 인터페이스로 적응시키는 용도로 쓰인다. 반면 퍼사드 패턴은 어떤 서브시스템에 대한 간단한 인터페이스를 제공하는 용도로 쓰인다.


홈시어터 퍼사드

public class HomeTheaterFacade {

    // composition 부분, 사용하고자 하는 서브시스템의 모든 구성 요소가 인스턴스 변수 형태로 저장된다.
    private final Amplifier amp;
    private final Tuner tuner;
    private final StreamingPlayer player;
    private final Projector projector;
    private final TheaterLights lights;
    private final Screen screen;
    private final PopcornPopper popper;

    public HomeTheaterFacade(Amplifier amp, Tuner tuner, StreamingPlayer player,
        Projector projector,
        TheaterLights lights, Screen screen, PopcornPopper popper) {
        this.amp = amp;
        this.tuner = tuner;
        this.player = player;
        this.projector = projector;
        this.lights = lights;
        this.screen = screen;
        this.popper = popper;
    }

    public void watchMovie(String movie) {
        System.out.println("영화 볼 준비 중");
        popper.on();
        popper.pop();

        lights.dim(10);
        screen.down();
        projector.on();
        projector.wideScreenMode();
        projector.setInput(player);

        amp.on();
        amp.setDvd(player);
        amp.setSurroundSound();
        amp.setVolume(5);

        player.on();
        player.play(movie);
    }

    public void endMovie() {
        System.out.println("홈시어터 끄는 중");
        popper.off();
        lights.on();
        screen.up();
        projector.off();
        amp.off();
        player.stop();
        player.off();
    }
}
  • 각 서브시스템에 들어있는 구성요소에게 위임하며 단순화된 인터페이스를 제공한다.
public class HomeTheaterTestDrive {

    public static void main(String[] args) {
        // 구성 요소 초기화
        // 지금은 구성 요소를 직접 생성하지만 보통은 클라이언트에 퍼사드가 주어지므로 직접 구성 요소를 생성하지 않아도 된다.

        Amplifier amp = new Amplifier();
        Tuner tuner = new Tuner();
        StreamingPlayer player = new StreamingPlayer();
        Projector projector = new Projector();
        TheaterLights lights = new TheaterLights();
        Screen screen = new Screen();
        PopcornPopper popper = new PopcornPopper();
        HomeTheaterFacade homeTheater = new HomeTheaterFacade(
            amp,
            tuner,
            player,
            projector,
            lights,
            screen,
            popper
        );
        // 단순화된 인터페이스를 사용
        homeTheater.watchMovie("king kong");
        homeTheater.endMovie();
    }

}

퍼사드 패턴의 정의

퍼사드 패턴을 사용하려면 어떤 서브시스템에 속한 일련의 복잡한 클래스를 단순하게 바꿔서 통합한 클래스를 만들어야 한다. 다른 패턴과 달리 퍼사드 패턴은 상당히 단순한 편이다. 복잡한 추상화 같은 게 필요 없다. 하지만 퍼사드 패턴을 사용하면 클라이언트와 서브시스템이 서로 긴밀하게 연결되지 않아도 되고, 최소 지식 객체지향 원칙을 준수하는데도 도움이 된다.

퍼사드 패턴의 정의는 다음과 같다.

여기서 가장 중요한 점은 패턴의 용도이다. 정의를 보면 퍼사드 패턴은 단순화된 인터페이스로 서브시스템을 더 편리하게 사용하려고 쓰인다는 사실을 알 수 있다. 퍼사드 패턴의 클래스 다이어그램에서도 이 사실을 확인할 수 있다.

클래스 다이어그램

https://secretroute.tistory.com/entry/Head-First-Design-Patterns-제7강-Adapter-패턴과-Facade-패턴


최소 지식 원칙

최소 지식 원칙(Principle of Least Knowledge)에 따르면 객체 사이의 상호자용은 될 수 있으면 아주 가까운 ‘친구’ 사이에서만 허용하는 편이 좋다. 이 원칙은 보통 다음과 같이 정의될 수 있다.

그런데 이게 정확히 무슨 소리일까? 시스템을 디자인할 때 어떤 객체든 그 객체와 상호작용을 하는 클래스의 개수와 상호작용 방식에 주의를 기울여야 한다는 뜻이다.

이 원칙을 잘 따르면 여러 클래스가 복잡하게 얽혀 있어서, 시스템의 한 부분을 변경했을 때 다른 부분까지 줄줄이 고쳐야 하는 상황을 미리 방지할 수 있다. 여러 클래스가 서로 복잡하게 의존하고 있다면 관리하기도 힘들고, 남들이 이해하기 어려운 불안정한 시스템이 만들어진다.

친구를 만들지 않고 다른 객체에 영향력 행사하기

그런데 어떻게 하면 여러 객체와 친구가 되는 것을 피할 수 있을까?

이 원칙은 친구를 만들지 않는 4개의 가이드라인을 제시한다.

  1. 객체 자체
  2. 메소드에 매개변수로 전ㄴ달된 객체
  3. 메소드를 생성하거나 인스턴스를 만든 객체
  4. 객체에 속하는 구성 요소

해당 가이드라인에 따르면 다른 메소드를 호출해서 리턴받은 객체의 메소드를 호출하는 일도 바람직 하지 않다. 따라서 꽤 까다로운 가이드라인이다. 메소드를 호출한 결과로 리턴받은 객체에 들어있는 메소드를 호출하면 다른 객체의 일부분에 요청하게 되고, 직접적으로 알고 지내는 객체의 수가 늘어나는 단점이 있다.

이러한 상황에서 최소 지식 원칙을 따르려면 객체가 대신 요청하도록 만들어야 한다. 그러면 그 객체의 한 구성 요소를 알고 지낼 필요가 없어지고 친구의 수를 줄이는 데도 도움이 된다.

Before

public float getTemp() {
        Thermometer thermometer = station.getThermometer();
        return thermometer.getTemperature();
}
  • station으로 부터 thermometer 객체를 받은 다음, 그 객체의 getTemperature() 메소드를 직접 호출

After

public float getTemp() {
        return station.getTemperature();
}
  • 최소 지식 원칙을 적용해서 thermometer 에게 요청을 전달하는 메소드를 station 클래스에 추가
  • 의존해야 하는 클래스의 개수를 줄인다.

절친에게만 메소드 호출하기

다음은 자동차를 나타내는 Car 클래스이다. 이 클래스르 살펴보면 최소 지식 원칙을 따르면서 메소드를 호출하는 방법을 어느 정도 파악할 수 있다.

public class Car {

    Engine engine; // 해당 클래스의 구성요소, 구성요소의 메소드는 호출해도 된다.

    public Car(Engine engine) {
        this.engine = engine;
    }

    public void start(Key key) {            // 매개변수로 전달된 객체의 메소드는 호출 가능하다.
        Doors doors = new Doors();          // 새로운 객체를 생성, 해당 객체의 메소드 호출 가능
        boolean authorized = key.turns();   // 매개변수로 전달된 객체
        if (authorized) {
            engine.start();                 // 이 객체의 구성 요소를 대상으로 메소드 호출 가능
            updateDashboardDisplay();       // 객체 내에 있는 메소드 호출 가능
            doors.lock();                   // 직접 생성하거나 인스턴스를 만든 객체의 메소드 호출 가능
        }
    }

    private void updateDashboardDisplay() {
        // update display
    }
}

Q. 데메테르의 법칙이라는 것도 있던데, 최소 지식 원칙과 어떤 관계일까?

데메테르의 법칙과 최소 지식 원칙은 완전히 똑같은 말이다. 하지만 좀 더 직관적이고 법칙이라는 단어가 없는 최소 지식 원칙을 선호한다. 모든 원칙은 상황에 따라서 적절하게 따라야 한다.

Q. 최소 지식 원칙도 단점이 있을까?

물론 존재한다. 이 원칙을 잘 따르면 객체 사이의 의존성을 줄일 수 있으며 소프트웨어 관리가 더 편해지지만, 메소드 호출을 처리하는 ‘래퍼’ 클래스를 더 만들어야 할 수도 있다. 그러면 시스템이 복잡해지고, 개발 시간도 늘어나고, 성능도 떨어진다.


핵심 정리

  • 기존 클래스를 사용하려고 하는데 인터페이스가 맞지 않으면 어댑터를 쓰면 된다.
  • 큰 인터페이스와 여러 인터페이스를 단순하게 바꾸거나 통합해야 하면 퍼사드를 쓰면 된다.
  • 어댑터는 인터페이스를 클라이언트에서 원하는 인터페이스로 바꾸는 역할을 한다.
  • 퍼사드는 클라이언트를 복잡한 서브시스템과 분리하는 역할을 한다.
  • 어댑터를 구현할 때는 타깃 인터페이스의 크기와 구조에 따라 코딩해야 할 분량이 결정된다.
  • 퍼사드 패턴에서드는 서브시스템으로 퍼사드를 만들고 진짜 작업은 서브클래스에 맡긴다.
  • 어댑터 패턴에는 객체 어댑터 패턴과 클래스 어댑터 패턴이 있다.
    • 클래스 어댑터를 사용하려면 다중 상속이 가능해야 한다.
  • 한 서브시스템에 퍼사드를 여러 개 만들어도 된다.
  • 어댑터는 객체를 감싸서 인터페이스를 바꾸는 용도로, 데코레이터는 객체를 감싸서 새로운 행동을 추가하는 용도로, 퍼사드는 일련의 객체를 감싸서 단순하게 만드는 용도로 쓰인다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
    • 진짜 절친에게만 이야기해야 한다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
    • 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
    • 커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
    • 어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
    • 퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
728x90
728x90

커맨드 패턴

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

커맨드 패턴을 이용하면 요구 사항을 객체로 캡슐화 할 수 있으며, 매개변수를 써서 여러 가지 다른 요구 사항을 집어넣을 수도 있다. 또한 요청 내역을 큐에 저장하거나 로그로 기록할 수도 있으며, 작업 취소 기능도 지원 가능하다.

  • 호출 캡슐화
  • 한 차원 높은 단계의 캡슐화인 메소드 호출을 캡슐화하는 것을 배워보자
  • 메소드 호출을 캡슐화 하면 계산 과정의 각 부분들을 결정화시킬 수 있끼 때문에, 계산하는 코드를 호출한 객체에서는 어떤 식으로 일을 처리해야 하는지에 대해 전혀 신경쓰지 않아도 된다.
  • 그 외에도 캡슐화된 메소드 호출을 로그 기록용으로 저장을 한다거나 취소 기능을 구현하기 위해 재사용하는 것과 같은 작업을 할 수 도 있다.
  • 요청하는 객체와 요청을 수행하는 객체를 분리하고 싶다면 커맨드 패턴을 사용하자

개요 - 홈 오토메이션 리모콘

리모컨 API 디자인을 해보자. 해당 리모컨에는 일곱 가지 프로그래밍이 가능한 슬롯과 각 슬롯에 대한 온오프 스위치가 있다. 각 슬롯은 서로 다른 가정용 기기에 연결할 수 있다. 리모컨에는 작업 취소 버튼도 장착되어 있다.

조명, 팬, 욕조, 오디오를 비롯한 각종 홈 오토메이션 장비들을 제어하기 위한 용도로 다양한 업체에서 공급 받은 자바 클래스들을 같이 받았다.

각 슬롯을 한 가지 기기 또는 하나로 엮여 있는 일련의 기기들에 할당할 수 있도록 리모컨을 프로그래밍하기 위한 API를 제작해보자.


제공 받은 클래스

https://faun.pub/head-first-design-patterns-using-go-5-encapsulating-invocation-the-command-pattern-2f8c0a79d1c7

제공 받은 클래스들을 살펴보자 리모컨에서 제어해야 하는 객체의 인터페이스에 대한 정보를 얻을 수 있을 것이다.

공통적인 인터페이스가 있는 것 같진 않다. 리모컨에는 on, off 버튼만 있지만, 가전제품 클래스에는 여러 메서드가 존재하고 더 큰 문제는 앞으로 이런 클래스들이 더 추가될 수 있다는 점이다.

따라서 리모컨 버튼을 누르면 자동으로 해야할 일을 처리할 수 있도록 하고 리모컨에서 제품 업체에게 전달받은 클래스에 대해 자세히 알 필요가 없도록 디자인을 진행해야 할 것 같다.

해결책 : 커맨드 패턴

이를 해결하기 위해 어떻게 해야할까??

  • 작업을 요청한 쪽과 작업을 처리하는 쪽을 분리시킨다.
  • 여기서는 리모컨이 작업을 요청하는 쪽, 업체에서 공급한 클래스의 인스턴스는 작업을 처리하는 쪽
  • 이를 위해 커맨드 객체를 추가하여 분리시킬수 있다.
    • 커맨드 객체는 특정 객체에 대한 특정 작업 요청을 캡슐화한다.
    • 따라서 버튼마다 커맨드 객체를 저장해 두면 사용자가 버튼을 눌렀을 때 커맨드 객체를 통해서 작업을 처리하도록 만든다.
    • 리모컨은 작업이 무엇인지 전혀 모르고 작업을 완료하기 위한 객체와 상호작용하는 방법을 알고 있는 커맨드 객체만 존재하므로, 리모컨은 벤더쪽 클래스와 분리된다.
  • 이러한 패턴을 커맨드 패턴 이라고 한다.

커맨드 패턴 다이어그램

https://faun.pub/head-first-design-patterns-using-go-5-encapsulating-invocation-the-command-pattern-2f8c0a79d1c7

클라이언트

  • 클라이언트는 커맨드 객체를 생성해야 한다.
  • 커맨드 객체는 리시버에 전달할 일련의 행동으로 구성된다.

리시버

  • 커맨드 객체에는 행동과 리시버에 대한 정보가 같이 들어 있다.

커맨드

  • 커맨드 객체에서 제공하는 메소드는 excute() 하나 뿐이다.
  • 이 메소드에는 행동을 캡슐화하며, 리시버에 있는 특정 행동을 처리하기 위한 메소드를 호출하기 위한 메서드이다.

인보커

  • 클라이언트에서는 인보커 객체의 setCommand() 메소드를 호출하는데, 이 때 커맨드 객체를 넘겨줍니다.
  • 그 커맨드 객체는 나중에 쓰으기 전까지 인보커 객체에 보관됩니다.
  • 인보커에서 커맨드 객체의 excute() 메소드를 호출하면 리시버에 있는 특정 행동을 하는 메소드가 호출된다.

인보커 로딩

  1. 클라이언트에서 커맨드 객체 생성
  2. setCommand()를 호출하여 인보커에 커맨드 객체를 저장
  3. 나중에 클라이언트에서 인보커한테 그 명령을 실행시켜 달라는 요청을 함

커맨드 패턴과 식당 비유해보기

  • 클라이언트 == 손님
  • 주문서 == 커맨드 객체
  • 주문받기(takeOrder()) == setCommand()
  • 웨이트리스 == 인보커 객체
  • 주문(orderUp()) == execute()
  • 주방장 == 리시버 객체

커맨드 객체

이제 첫 커맨드 객체를 만들어 보자.

Command 인터페이스 만들기

  • 커맨드 객체는 모두 같은 인터페이스를 구현해야 한다.
  • 해당 인터페이스에는 메소드가 하나 밖에 없다.
  • 일반적으로 execute() 이름을 많이 사용한다.
public interface Command {

    void execute();

}

이제 전등을 켜기 위한 커맨드 클래스를 구현해보자. 벤더사에서 제공한 클래스를 보니 Light 클래스에는 on(), off() 두 개의 메소드가 있다.

public class LightOnCommand implements Command {

    Light light;

    public LightOnCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.on();
    }
}
  • 생성자에 이 커맨드 객체로 제어할 특정 전등에 대한 정보가 전달된다.
  • 해당 객체는 light라는 인스턴스 변수에 저장이 되며, execute() 메소드가 호출되면 light 객체가 바로 그 요청에 대한 리시버가 된다.
  • execute() 메소드에서는 리시버 객체에 있는 on() 메서드를 호출한다.

커맨드 객체 사용하기

이제 커맨드 객체를 써서 가정요 기기를 조작하기 위해 버튼이 하나 밖에 없는 리모콘이 있다고 가정하고 코드를 작성해보자.

public class SimpleRemoteControl { // 인보커

    Command slot;

    public SimpleRemoteControl() {
    }

    public void setCommand(Command command) {
        slot = command;
    }

    public void buttonWasPressed() {
        slot.execute();
    }
}
class SimpleRemoteControlTest { // 1

    public static void main(String[] args) {
        SimpleRemoteControl remoteControl = new SimpleRemoteControl(); // 2
        Light light = new Light(); // 3
        LightOnCommand lightOn = new LightOnCommand(light); // 4

        remoteControl.setCommand(lightOn); // 5
        remoteControl.buttonWasPressed();
    }

}
  • 1 : 클라이언트에 해당하는 부분(SimpleRemoteControlTest)
  • 2 : remoteControl 변수가 인보커 역할을 한다.
    • 필요한 작업을 요청할 때 사용할 커맨드 객체를 인자로 전달받는다.
  • 3 : 요청을 받아서 처리할 리시버인 Light 객체를 생성한다.
  • 4 : 커맨드 객체를 생성한다.
    • 이 때 리시버를 전달해 준다.
  • 5 : 커맨드 객체를 인보커한테 전달해 준다.


커맨드 패턴의 정의

이제 커맨드 패턴의 정의를 알아보고 더 자세히 살펴보자.

  • 커맨드 객체는 일련의 행동을 특정 리시버하고 연결시킴으로써 요구 사항을 캡슐화한 것이다.
    • 이렇게 하기 위해서 행동과 리시버를 한 객체에 집어넣고 execute()라는 메소드 하나만 외부에 공개하는 방법을 쓴다.
    • 이 메소드 호출에 의해서 리시버에서 일련의 작업이 처리된다.
    • 외부에서 볼 때는 어떤 객체가 리시버 역할을 하는지, 그 리시버에서 실제로 어떤 일을 하는지 알 수 없다. 그냥 execute() 메소드를 호출하면 요구 사항이 처리된다는 것만 알 수 있을 뿐이다.
    • 즉, 커맨드는 캡슐화된 요구사항이다.
      • 리시버 : action
      • execute() : recevier.action();
  • 명령을 통해서 객체를 매개변수화하는 예도 몇 가지 볼 수 있었다.
    • 리모컨 입장에서는 특정 인터페이수만 구현이 되어 있다면 그 커맨드 객체에서 실제로 어떤일을 하는지 신경 쓸 필요가 없다.
    • 인보커(리모컨에 있는 슬롯 등)에 매개변수를 써서 여러가지 요구사항을 전달할 수도 있다.
  • 커맨드 객체들을 써서 큐나 로그를 구현하거나 작업 취소를 할 수 있으며, 메타 커맨드 패턴이라는 것을 이용하여 명령들로 이루어진 매크로를 만들어서 여러 개의 명령을 한 번에 실행할 수도 있다.

커맨드 패턴 클래스 다이어그램

https://faun.pub/head-first-design-patterns-using-go-5-encapsulating-invocation-the-command-pattern-2f8c0a79d1c7

클라이언트

  • ConcreteCommand를 생성하고 Receiver를 설정한다.

인보커

  • 인보커에는 명령이 들어 있으며, execute() 메소드를 호출함으로써 커맨드 객체에게 특정 작업을 수행해 달라는 요구를 하게 된다.

리시버

  • 리시버는 요구 사항을 수항해기 위해 어떤 일을 처리해야 하는지 알고 있는 객체이다.

커맨드 인터페이스

  • 모든 커맨드 객체에서 구현해야 하는 인터페이스
  • 모든 명령은 execute() 메소드 호출을 통해 수행되며, 이 메소드에서는 리시버에 특정 작업을 처리하라는 지시를 전달한다.
  • 이 인터페이스를 보면 undo() 메소드도 들어있는데, 잠시 후에 알아보자

구상 커맨드

  • 구상 커맨드는 특정 행동과 리시버 사이를 연결해 준다.
  • 인보커에서 execute() 호출을 통해 요청을 하면 구상 커맨드 객체에서 리시버에 있는 메소드를 호출함으로써 그 작업을 처리한다.
  • execute() → receiver.action();

슬롯에 명령 할당하기

이제 리모컨의 각 슬롯에 명령을 할당해보자. 이제 리모컨이 인보커가 되는 것이다.

사용자가 버튼을 누르면 그 버튼에 상응하는 커맨드 객체의 execute() 메소드가 호출되고, 그러면 리시버(vendor class)에서 특정 행동을 하는 메소드가 실행될 것이다.

  • 각 슬롯마다 커맨드 객체가 할당된다.
  • 사용자가 버튼을 누르면 해당 커맨드 객체의 execute() 메소드가 호출된다.
  • execute() 메소드에서는 리시버로 하여금 특정 작업을 처리하도록 지시한다.
public class RemoteControl {

    private static final int SLOT_SIZE = 7;
    Command[] onCommands;
    Command[] offCommands;

    public RemoteControl() {
        offCommands = new Command[SLOT_SIZE];
        onCommands = new Command[SLOT_SIZE];

        Command noCommand = new NoCommand();
        for (int i = 0; i < SLOT_SIZE; i++) {
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;

        }
    }

    public void setCommand(int slot, Command onCommand, Command offCommand) {
        onCommands[slot] = onCommand;
        offCommands[slot] = offCommand;
    }

    public void onButtonWasPushed(int slot) {
        onCommands[slot].execute();
    }

    public void offButtonWasPushed(int slot) {
        offCommands[slot].execute();
    }

    public String toString() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("\n------ Remote Control -------\n");
        for (int i = 0; i < onCommands.length; i++) {
            stringBuilder
                .append("[slot ")
                .append(i)
                .append("] ")
                .append(onCommands[i].getClass().getName())
                .append("    ")
                .append(offCommands[i].getClass().getName())
                .append("\n");
        }
        return stringBuilder.toString();
    }
}
public class RemoteLoader {

    public static void main(String[] args) {
        RemoteControl remoteControl = new RemoteControl();

        Light livingRoomLight = new Light("Living Room");
        Light kitchenLight = new Light("Kitchen");
        CeilingFan ceilingFan = new CeilingFan("Living Room");
        GarageDoor garageDoor = new GarageDoor("Garage");
        Stereo stereo = new Stereo("Living Room");

        LightOnCommand livingRoomLightOn = new LightOnCommand(livingRoomLight);
        LightOffCommand livingRoomLightOff = new LightOffCommand(livingRoomLight);
        LightOnCommand kitchenLightOn = new LightOnCommand(kitchenLight);
        LightOffCommand kitchenLightOff = new LightOffCommand(kitchenLight);

        CeilingFanOnCommand ceilingFanOn = new CeilingFanOnCommand(ceilingFan);
        CeilingFanOffCommand ceilingFanOff = new CeilingFanOffCommand(ceilingFan);

        GarageDoorUpCommand garageDoorUp = new GarageDoorUpCommand(garageDoor);
        GarageDoorDownCommand garageDoorDown = new GarageDoorDownCommand(garageDoor);

        StereoOnWithCDCommand stereoOnWithCD = new StereoOnWithCDCommand(stereo);
        StereoOffCommand stereoOff = new StereoOffCommand(stereo);

        remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
        remoteControl.setCommand(1, kitchenLightOn, kitchenLightOff);
        remoteControl.setCommand(2, ceilingFanOn, ceilingFanOff);
        remoteControl.setCommand(3, stereoOnWithCD, stereoOff);

        System.out.println(remoteControl);

        remoteControl.onButtonWasPushed(0);
        remoteControl.offButtonWasPushed(0);
        remoteControl.onButtonWasPushed(1);
        remoteControl.offButtonWasPushed(1);
        remoteControl.onButtonWasPushed(2);
        remoteControl.offButtonWasPushed(2);
        remoteControl.onButtonWasPushed(3);
        remoteControl.offButtonWasPushed(3);
    }
}

NoCommand 객체

NoCommand 객체는 일종의 널 객체이다. 딱히 리턴할 객체는 없지만 클라이언트 쪽에서 null을 처리하지 않아도 되도록 하고 싶을 때 널 객체를 활용하면 좋다. 특정 슬롯을 쓰려고 할 때 마다 거기에 뭔가가 로딩되어 있는지 확인하려면 좀 귀찮기 때문이다.


완성된 리모컨 api 디자인

https://blog.yevgnenll.me/posts/what-is-command-pattern

  • 리모컨 코드를 최대한 단순하게 만들어서 협력 업체가 새로운 벤더 클래스를 공급하더라도 리모컨 코드를 고치지 않도록 하는 것에 중점을 두었다.
  • 커맨트 패턴을 도입해서 RemoteControl 클래스와 협력 업체로부터 제공되는 클래스를 논리적으로 분리했다.
  • 이러한 디자인은 리모컨의 유지보수 비용을 줄이는데 굉장히 도움이 된다.
  • 커맨드 클래스는 람다 표현식을 사용하여 커맨드 객체를 생성하는 단계를 건너뛰고 인스턴스를 생성하는 대신 그 자리에 함수 객체를 사용할 수 있다.
    • 물론 추상 메서드가 하나일 때만 가능
//        remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
        remoteControl.setCommand(0, () -> livingRoomLight.on(), () -> livingRoomLight.off());
        remoteControl.setCommand(1, kitchenLightOn, kitchenLightOff);
        remoteControl.setCommand(2, ceilingFanOn, ceilingFanOff);
        remoteControl.setCommand(3, stereoOnWithCD, stereoOff);

작업 취소 기능 추가하기

커맨드에서 작업 취소 기능을 지원하려면 execute() 메소드와 비슷한 undo() 메소드가 있어야 한다.

excute() 메소드에서 했던 작업과 정반대의 작업을 처리하면 된다. 커맨드 클래스에 작업 취소 기능을 추가하기 전에 우선 Command 인터페이스에 undo() 메소드를 추가해야 한다.

public interface Command {
        void excete();
    void undo();
}
  • 그리고 RemoteControl 클래스에 사용자가 마지막으로 누른 버튼을 기록하고, UNDO 버튼을 눌렀을 때 필요한 작업을 처리하는 코드를 추가해야 한다.
package command.client;

import command.cmd.Command;
import command.cmd.NoCommand;

public class RemoteControl {

    private static final int SLOT_SIZE = 7;
    Command[] onCommands;
    Command[] offCommands;
    Command undoCommand;

    public RemoteControl() {
        offCommands = new Command[SLOT_SIZE];
        onCommands = new Command[SLOT_SIZE];

        Command noCommand = new NoCommand();
        for (int i = 0; i < SLOT_SIZE; i++) {
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;

        }
        // 다른 슬롯과 마찬가지로 사용자가 다른 버튼을 한 번도 누르지 않은 상태에서 undo 버튼을 누르더라도 별 문제가 없도록 한다.
        undoCommand = noCommand;
    }

    public void setCommand(int slot, Command onCommand, Command offCommand) {
        onCommands[slot] = onCommand;
        offCommands[slot] = offCommand;
    }

    public void onButtonWasPushed(int slot) {
        onCommands[slot].execute();
        // 사용자가 버튼을 누르면 해당 커맨드 객체의 execute() 메서드를 호출한 다음
        // 그 객체의 레퍼런스를 undoCommand 인스턴스 변수에 저장한다.
        // on과 off 버튼을 처리할 때도 같은 방법 사용
        undoCommand = onCommands[slot];
    }

    public void offButtonWasPushed(int slot) {
        offCommands[slot].execute();
        undoCommand = offCommands[slot];
    }

    public void undoButtonWasPushed() {
        undoCommand.undo();
    }

    public String toString() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("\n------ Remote Control -------\n");
        for (int i = 0; i < onCommands.length; i++) {
            stringBuilder
                .append("[slot ")
                .append(i)
                .append("] ")
                .append(onCommands[i].getClass().getSimpleName())
                .append("    ")
                .append(offCommands[i].getClass().getSimpleName())
                .append("\n");
        }
        return stringBuilder
            .append("[undo]")
            .append("    ")
            .append(undoCommand.getClass().getSimpleName())
            .toString();
    }
}
public class RemoteLoader {

    public static void main(String[] args) {
        RemoteControl remoteControl = new RemoteControl();

        Light livingRoomLight = new Light("Living Room");

        LightOnCommand livingRoomLightOn = new LightOnCommand(livingRoomLight);
        LightOffCommand livingRoomLightOff = new LightOffCommand(livingRoomLight);

        remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);

        remoteControl.onButtonWasPushed(0);
        remoteControl.offButtonWasPushed(0);
        System.out.println(remoteControl);
        remoteControl.undoButtonWasPushed();
        remoteControl.offButtonWasPushed(0);
        remoteControl.onButtonWasPushed(0);
        System.out.println(remoteControl);
        remoteControl.undoButtonWasPushed();

    }
}
Living Room light is on
Living Room light is off

------ Remote Control -------
[slot 0] LightOnCommand    LightOffCommand
[slot 1] LightOnCommand    LightOffCommand
[slot 2] CeilingFanOnCommand    CeilingFanOffCommand
[slot 3] StereoOnWithCDCommand    StereoOffCommand
[slot 4] NoCommand    NoCommand
[slot 5] NoCommand    NoCommand
[slot 6] NoCommand    NoCommand
[undo]    LightOffCommand >>>>>> undoCmd에 마지막으로 호출되었던 커맨드 저장
Living Room light is on >>>>>> 사용자가 undo 버튼 클릭
Living Room light is off
Living Room light is on

------ Remote Control -------
[slot 0] LightOnCommand    LightOffCommand
[slot 1] LightOnCommand    LightOffCommand
[slot 2] CeilingFanOnCommand    CeilingFanOffCommand
[slot 3] StereoOnWithCDCommand    StereoOffCommand
[slot 4] NoCommand    NoCommand
[slot 5] NoCommand    NoCommand
[slot 6] NoCommand    NoCommand
[undo]    LightOnCommand >>>>>> undoCmd에 마지막으로 호출되었던 커맨드 저장
Living Room light is off >>>>>> 사용자가 undo 버튼 클릭

Process finished with exit code 0

작업 취소 기능을 구현할 때 상태를 사용하는 방법

작업 취소 기능을 구현하다 보면 간단한 상태를 저장해야 하는 상황도 종종 생긴다.

CeilingFan 클래스로 간단한 속도와 관련된 상태를 저장해보자.

public class CeilingFan {

    String location;
    int speed; // 속도를 나타내는 상태를 저장
    public static final int HIGH = 3;
    public static final int MEDIUM = 2;
    public static final int LOW = 1;
    public static final int OFF = 0;

    public CeilingFan(String location) {
        this.location = location;
        speed = OFF;
    }

    public void high() {
        // turns the ceiling fan on to high
        speed = HIGH;
        System.out.println(location + " ceiling fan is on high");

    }

    public void medium() {
        // turns the ceiling fan on to medium
        speed = MEDIUM;
        System.out.println(location + " ceiling fan is on medium");
    }

    public void low() {
        // turns the ceiling fan on to low
        speed = LOW;
        System.out.println(location + " ceiling fan is on low");
    }

    public void off() {
        // turns the ceiling fan off
        speed = OFF;
        System.out.println(location + " ceiling fan is off");
    }

    public int getSpeed() {
        return speed;
    }
}
package command.cmd;

import command.vendor.CeilingFan;

public class CeilingFanHighCommand implements Command {

    CeilingFan ceilingFan;
    int prevSpeed; // 상태 지역 변수로 선풍기의 속도를 저장

    public CeilingFanHighCommand(CeilingFan ceilingFan) {
        this.ceilingFan = ceilingFan;
    }

    public void execute() {
              // 속도를 변경하기 전에 작업을 취소해야 할 때를 대비해서 이전 속도를 저장
        prevSpeed = ceilingFan.getSpeed();
        ceilingFan.high();
    }

    @Override
    public void undo() {
        if (prevSpeed == CeilingFan.HIGH) {
            ceilingFan.high();
        } else if (prevSpeed == CeilingFan.MEDIUM) {
            ceilingFan.medium();
        } else if (prevSpeed == CeilingFan.LOW) {
            ceilingFan.low();
        } else if (prevSpeed == CeilingFan.OFF) {
            ceilingFan.off();
        }
    }
}

여러 동작을 한 번에 처리하기

package command.cmd;

public class MacroCommand implements Command {

    Command[] commands;

    public MacroCommand(Command[] commands) {
        this.commands = commands;
    }

    @Override
    public void execute() {
        for (int i = 0; i < commands.length; i++) {
            commands[i].execute();
        }
    }

    @Override
    public void undo() { // 역순으로 undo
        for (int i = commands.length - 1; i >= 0; i--) {
            commands[i].undo();
        }
    }
}
public class RemoteLoader {

    public static void main(String[] args) {
        RemoteControl remoteControl = new RemoteControl();
        Light light = new Light("Living Room");
        Stereo stereo = new Stereo("Living Room");

        LightOnCommand lightOnCommand = new LightOnCommand(light);
        LightOffCommand lightOffCommand = new LightOffCommand(light);
        StereoOnCommand stereoOnCommand = new StereoOnCommand(stereo);
        StereoOffCommand stereoOffCommand = new StereoOffCommand(stereo);
        Command[] partyOn = {lightOnCommand, stereoOnCommand};
        Command[] partyOff = {lightOffCommand, stereoOffCommand};
        MacroCommand partyOnMacro = new MacroCommand(partyOn);
        MacroCommand partyOffMacro = new MacroCommand(partyOff);

        remoteControl.setCommand(0, partyOnMacro, partyOffMacro);
        System.out.println(remoteControl);
        System.out.println("---- macro on ------");
        remoteControl.onButtonWasPushed(0);
        System.out.println("---- macro off ------");
        remoteControl.offButtonWasPushed(0);
    }
}
------ Remote Control -------
[slot 0] MacroCommand    MacroCommand
[slot 1] NoCommand    NoCommand
[slot 2] NoCommand    NoCommand
[slot 3] NoCommand    NoCommand
[slot 4] NoCommand    NoCommand
[slot 5] NoCommand    NoCommand
[slot 6] NoCommand    NoCommand
[undo]    NoCommand
---- macro on ------
Living Room light is on
Living Room stereo is on
Living Room stereo is set for CD input
Living Room stereo volume set to 11
---- macro off ------
Living Room light is off
Living Room stereo is off

Process finished with exit code 0

Q) 항상 리시버가 필요할까?? 커맨드 객체에서 execute()를 구현하면 안될까??

A) 일반적으로 리시버에 있는 행동을 호출하는 ‘더미’ 커맨드 객체를 만든다. 하지만 요구 사항의 전부는 아니더라도 대부분을 구현하는 ‘스마트’ 커맨드 객체를 만드는 경우도 자주 볼 수 있다. 물론 커맨드 객체에서 대부분의 행동을 처리해도 됩니다. 하지만 그러면 인보커와 리시버를 분리하기 어렵고, 리시버로 커맨드를 매개변수화할 수 없다는 점을 염두하자.

Q) 작업 취소를 할 때 히스토리 기능은 어떻게 구현할 수 있을까? 즉, undo 버튼을 여러 번 누를 수 있도록 하려면 어떻게 해야 할까??

A) 사실 그리 어려운 일은 아니다. 앞에서는 마지막으로 실행한 커맨드의 레퍼런스만 저장했었는데, 그 대신 전에 실행한 커맨드 자체를 스택에 넣으면 됩니다. 그리고 나서 사용자가 undo 버튼을 누를 때마다 인보커에서 스택 맨 위에 있는 항목을 꺼내서 undo() 메소드를 호출하도록 만들면 된다.

Q) 파티 모드를 구현할 때 PartyCommand의 execute() 메소드에서 다른 커맨드 객체의 excute()를 호출하는 방법을 써도 될까?

A) 그렇게 해도 되지만, 그러면 PartyComman에 파티 모드 코드를 직접 넣어야 하는데, 나중에 문제가 생길 수도 있습니다. MacroCommand를 사용하면 PartyCommand에 넣을 커맨드를 동적으로 결정할 수 있기에 유연성이 훨씬 좋아진다. 일반적으로 MacroCommand 만들어서 쓰는 방법이 더 우아한 방법이며, 추가해야 할 코드를 줄이는데도 도움이 된다.


커맨트 패턴 활용하기

커맨드로 컴퓨테이션의 한 부분(리시버와 일련의 행동)을 패키지로 묶어서 일급 객체 형태로 전달할 수도 있다. 그러면 클라이언트 애플리케이션에서 커맨드 객체를 생성 한 뒤 오랜 시간이 지나도 그 컴퓨테이션을 호출할 수 있다. 심지어 다른 스레드에서 호출할 수도 있다. 이점을 활용해서 커맨드 패턴을 스케줄러나 스레드 풀, 작업 큐와 같은 다양한 작업에 적용할 수 있다.

작업 큐를 떠올려 보자. 큐 한 쪽 끝은 커맨드를 추가할 수 있도록 되어 있고, 다른 쪽 끝에는 커맨드를 처리하는 스레드들이 대기하고 있다. 각 스레드는 우선 execute() 메소드를 호출하고 호출이 완료되면 커맨드 객체를 버리고 새로운 커맨드 객체를 가져옵니다.

작업 큐

  • 커맨드 인터페이스를 구현하는 객체를 큐에 추가한다.

컴퓨테이션(?)

  • 컴퓨테이션을 고정된 개수의 스레드로 제한할 수 있다.

작업 처리 스레드

  • 스레드는 큐에서 커맨드를 하나씩 제겋면서 커맨드의 execute() 메소드를 호출한다.
  • 메소드 실행이 끝나면 다시 큐에서 새로운 커맨드 객체를 가져간다.

작업 큐 클래스는 계산 작업을 하는 객체들과 완전히 분리되어 있다. 한 스레드가 한동안 금융 관련 계산을 하다가 잠시 후에는 네트워크로 뭔가를 내려받을 수도 있다. 작업 큐 객체는 전혀 신경쓸 필요가 없다. 큐에 커맨드 패턴을 구현하는 객체를 넣으면 그 객체를 처리하는 스레드가 생기고 자동으로 execute() 메소드가 호출된다.


커맨드 패턴 더 활용하기

어떤 애플리케이션은 모든 행동을 기록해 두었다가 애플리케이션이 다운되었을 때 그 행동을 다시 호출해서 복구할 수 있어야 한다. 커맨드 패턴을 사용하면 store()load() 메소드를 추가해서 이런 기능을 구현할 수 있다. 자바에서는 이런 메소드를 객체 직렬화로 구현할 수도 있지만, 직렬화와 관련된 제약 조건 때문에 쉽지 않다.

로그 기록은 어떤 명령을 실행하면서 디스크에 실행 히스토리를 기록하고, 애플리케이션이 다운되면 커맨드 객체를 다시 로딩해서 execute() 메소드를 자동으로 순서대로 실행하는 방식으로 작동한다.

지금까지 예로 든 리모컨에는 이런 로그 기록이 무의미하다. 하지만 데이터가 변경될 때마다 매번 저장할 수 없는 방대한 자료구조를 다루는 애플리케이션에 로그를 사용해서 마지막 체크 포인트 이후로 진행한 모든 작업을 저장한 다음 시스템이 다운되었을 때 최근 수행된 작업을 다시 적용하는 방법으로 사용할 수 있다.

스프레드시트 애플리케이션을 예를 들어 볼까요? 매번 데이터가 변경될 때마다 디스크에 저장하지 않고, 특정 체크 포인트 이후의 모든 행동을 로그에 기록하는 방식으로 복구 시스템을 구축할 수 있다. 더 복잡한 애플리케이션에는 이런 테크닉을 확장해서 일련의 작업에 트랜잭션을 활용해서 모든 작업이 완변하게 처리되도록 하거나, 아무것도 처리되지 않게 롤백되도록 할 수 있다.


실전 커맨드 패턴

자바의 스윙 라이브러리에는 사용자 인터페이스 구성 요소에서 발생하는 이벤트에 귀를 기울이는 ActionListener 형태의 옵저버가 어마어마하게 많다는 걸 배웠습니다. 그런데 ActionListener 는 Observer 인터페이스이자 Command 인터페이스이기도 하며, AngelListener와 DevilListenr 클래스는 그냥 Observer가 아니라 구상 Command 클래스이다. 즉, 두 패턴이 한꺼번에 들어가 있는 예제이다.

public class SwingObserverEx { // 클라이언트
        JButton button = new JButton("할까 말까"); // 인보커
    button.addActionListener(new AngelListener());
    button.addActionListener(new DevilListener());
}
    class AngelListener implements ActionListenr { // ActionListenr 커맨드 인터페이스
                public void actionPerformed(ActionEvent event) {
                        System.out.println("하지마!") // System 리시버
                }
    }
    class DevlilListener implements ActionListenr { // Angel, DevlilListener  구상 커맨드
                public void actionPerformed(ActionEvent event) {
                        System.out.println("해!")
                }
    }

핵심 정리

  • 커맨드 패턴을 사용하면 요청하는 객체와 요청을 수행하는 객체를 분리할 수 있다.
  • 이렇게 분리하는 과정의 중심에는 커맨드 객체가 있으며, 이 객체가 행동이 들어있는 리시버를 캡슐화한다.
  • 인보커는 무언가 요청할 때 커맨드 객체의 execute() 메소드를 호출하면 된다.
  • 커맨드는 인보커를 매개변수화할 수 있다. 실행 중에 동적으로 매개변수화를 설정할 수도 있다.
  • execute() 메소드가 마지막으로 호출되기 전의 상태로 되돌리는 작업 취소 메소드를 구현하면 커맨드 패턴으로 작업 취소 기능을 구현할 수도 있다.
  • 매크로 커맨드는 커맨드를 확장해서 여러 개의 커맨드를 한 번에 호출할 수 있게 해주는 가장 간편한 방법이다. 매크로 커맨드로도 어렵지 않게 작업 취소 기능을 구현할 수 있다.
  • 프로그래밍을 하다 보면 요청을 스스로 처리하는 ‘스마트’ 커맨드 객체를 사용하는 경우도 종종 있다.
  • 커맨드 패턴을 활용해서 로그 및 트랜잭션 시스템을 구현할 수 있다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
    • 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
    • 커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
728x90
728x90

싱글턴 패턴

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

싱글턴 패턴은 해당 클래스의 인스턴스가 하나만 만들어지고, 어디서든지 그 인스턴스에 접근할 수 있도록 하기 위한 패턴이다.

고전적인 싱글턴 패턴 구현법

package singleton.before;

public class Singleton {

    private static Singleton uniqueInstance;

    private Singleton() {}

    public static Singleton getInstance() {
        if(uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }

}

초콜릿 공장

초콜릿 공장에서는 초콜릿을 끓이는 장치인 초콜릿 보일러를 컴퓨터로 제어한다.

이 보일러에서는 초콜릿과 우유를 받아서 끓이고 초코바를 만드는 단계로 넘겨준다. 여기에 초콜릿 보일러를 제어하기 위한 클래스가 나와 있다.

public class ChocolateBoiler {

    private boolean empty;
    private boolean boiled;

    public ChocolateBoiler() {
        this.empty = true;
        this.boiled = false;
    }

    public void fill() {
        if (isEmpty()) {
            empty = false;
            boiled = false;
        }
    }

    public void drain() {
        if (!isEmpty() && !isBoiled()) {
            empty = true;
        }
    }

    public void boil() {
        if (!isEmpty() && !isBoiled()) {
            boiled = true;
        }
    }

    public boolean isEmpty() {
        return this.empty;
    }

    public boolean isBoiled() {
        return this.boiled;
    }

}
  • 코드를 보면 실수를 하지 않도록 주의를 기울여져 있다.
  • 하지만 두 개의 ChocolateBoiler 인스턴스가 따로 돌아가게 되면 상당히 안 좋은 상황이 일어날 수 있다는 것을 알 수 있다.
  • 만약 애플리케이션에서 ChocolateBoiler 인스턴스가 두 개 이상 만들어지게 되면 어떤 문제가 생길까??
    • 자원을 불필요하게 잡아먹고, 애플리케이션의 동작이 이상하게 돌아가는 결과에 일관성이 없어지는 심각한 문제가 발생할 것이다.
    • ChocolateBoiler 클래스를 싱글턴으로 업그레드해보자
public class ChocolateBoiler {

    private boolean empty;
    private boolean boiled;

    private static ChocolateBoiler uniqueInstance;

    private ChocolateBoiler() {
        this.empty = true;
        this.boiled = false;
    }

    public static ChocolateBoiler getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new ChocolateBoiler();
        }
        return uniqueInstance;
    }

    public void fill() {
        if (isEmpty()) {
            empty = false;
            boiled = false;
        }
    }

    public void drain() {
        if (!isEmpty() && !isBoiled()) {
            empty = true;
        }
    }

    public void boil() {
        if (!isEmpty() && !isBoiled()) {
            boiled = true;
        }
    }

    public boolean isEmpty() {
        return this.empty;
    }

    public boolean isBoiled() {
        return this.boiled;
    }

}

싱글턴 패턴의 정의

싱글턴의 고전적인 구현법을 배웠다. 그렇다면 싱글턴 패턴의 정의는 무엇이고, 실제로 어떤 식으로 싱글턴 패턴을 적용해야할까??

  • 클래스에서 자신의 단 하나뿐인 인스턴스를 관리하도록 만들면 된다.
    • 그리고 다른 어떤 클래스에서도 자신의 인스턴스를 추가로 만들지 못하도록 해야 한다.
    • 따라서 인스턴스가 필요하면 반드시 클래스 자신을 거치도록 해야 될 것이다.
  • 어디서든 그 인스턴스를 접근할 수 있도록 만들어야 한다.
    • 다른 객체에서 이 인스턴스가 필요하면 언제든지 클래스한테 요청을 할 수 있게 만들고, 요청이 들어오면 그 하나뿐인 인스턴스를 건네주도록 만들어야 한다.
    • 앞에서 보았듯이, 싱글턴이 Lazy하게 생성되도록 구현할 수도 있다. 싱글턴 클래스의 객체가 자원을 많이 잡아먹는 경우에는 유용하다.

클래스 다이어그램

https://www.hanbit.co.kr/channel/category/category_view.html?cms_code=CMS8616098823

골칫덩어리 스레드

고전적인 싱글턴을 이용해서 코드를 고쳤음에도 ChocolateBoiler에 있는 fill() 메소드에서 아직 초콜릿이 끓고 있는데 재료를 집어넣고 말았다. 무슨 일이 일어난 것일까??

  • 조금 전에 다중 스레드를 사용하도록 ChocolateBoiler 컨트롤러를 최적화시킨 것이 문제일까??
  • 스레드가 추가된 것 때문에 이런 문제가 생긴 것일까??

JVM의 입장

두 개의 스레드에서 여기에 있는 코드를 실행시킨다고 가정해보고 두 스레드가 다른 보일러 객체를 사용하게 될 가능성이 있는지 따져보자.

ChocolateBoiler boiler = ChocolateBoiler.getInstacne();
boiler.fill();
boiler.boil();
boiler.drain();
public static ChocolateBoiler getInstance() {
      if (uniqueInstance == null) {
          uniqueInstance = new ChocolateBoiler();
      }
      return uniqueInstance;
}
  • 바로 두 스레드가 동시에 getInstance() 메소드를 수행하게 되면 uniqueInstance null 상태라 각 스레드마다 ChocolateBoiler 인스턴스를 생성하여 리턴하여 결국 서로 다른 두 인스턴스가 만들어진다.

멀티스레딩 문제 해결 방법

문제를 해결하는 방법은 간단한데 바로 getInstance()를 동기화시키기만 하면 된다.

public static synchronized ChocolateBoiler getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new ChocolateBoiler();
        }
        return uniqueInstance;
}
  • synchronized 키워드를 추가하면 한 스레드가 메소드 사용을 끝내기 전까지 다른 스레드는 기다려야 한다.
  • 즉, 두 스레드가 이 메소드를 동시에 실행시키는 일은 일어나지 않는다.
  • 하지만 이렇게 하면 동기화로 인한 속도 문제가 생긴다.
    • 사실 동기화가 꼭 필요한 시점은 이 메소드가 시작되는 때 뿐이다.
    • 즉, 일단 uniqueInstance 변수에 Singleton 인스턴스를 대입하고 나면 굳이 이 메소드를 동기화된 상태로 유지시킬 필요가 없다.
    • 불필요한 오버헤드만 증가시킬뿐인 것

더 효율적인 방법은 없을까?

대부분의 자바 애플리케이션에서 싱글턴이 다중 스레드 환경에서 돌아갈 수도 있도록 만들어야 한다. 하지만 getInstance() 메소드를 동기화시키려면 대가를 치뤄야 한다. 다른 방법은 없을까??

1. getInstance()의 속도가 중요하지 않다면 그냥 둔다.

만약 getInstance() 메소드가 애플리케이션에 큰 부담을 주지 않는다면 그냥 놔둬도 된다. getInstance()를 동기화시키는게 굉장히 쉽고, 효율 면에서도 나쁘지 않을 수있다.

하지만 메소드를 동기화하면 성능이 100배 정도 저하된다는 것을 기억하자. 만약 getInstance()가 애플리케이션에서 병목으로 작용한다면 다른 방법을 생각해야 한다.

2. 인스턴스를 필요할 때 생성하지 말고, 처음부터 만들어 버린다.

애플리케이션에서 반드시 Singleton의 인스턴스를 생성하고, 그 인스턴스를 항상 사용한다면, 또는 인스턴스를 실행중에 수시로 만들고 관리하기가 성가시다면 다음과 같은 식으로 처음부터 Singleton 인스턴스를 만들어버리는 것도 괜찮은 방법이다.

public class Singleton {

    private static Singleton uniqueInstance = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return uniqueInstance;
    }

}
  • 이런 접근법을 사용하면 클래스가 로딩될 때, JVM에서 Singleton의 유일한 인스턴스를 생성해 주고, JVM에서 유일한 인스턴스를 생성하기 전에는 그 어떤 스레드도 uniqueInstance 정적 변수에 접근할 수 없다.

3. DCL(Double Checking Locking)을 써서 getInstance()에서 동기화되는 부분일 줄인다.

DCL을 사용하면 , 일단 인스턴스가 생성되어 있는지 확인한 다음, 생성되어 있지 않았을 때만 동기화를 할 수 있다. 이렇게 하면 처음에만 동기화를 하고 나중에는 동기화를 하지 않도록 동작하여, 바로 원하던 동작이 수행된다.

public class Singleton {

        // 자바 5 이전 버전은 동기화 x
    private volatile static Singleton uniqueInstance = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        if(uniqueInstance == null) { // 인스턴스가 있는지 확인하고, 없으면 동기화된 블럭으러 진입
            synchronized (Singleton.class) {
                if(uniqueInstance == null) { // 블록으로 들어온 후레도 다시 한번 널체크한 후, 인스턴스를 생성한다.
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }

}

싱글턴 관련 Q&A

Q) 모든 메서드와 변수가 static으로 선언된 클래스를 만들어도 결과적으로는 같지 않을까?

A) 맞다. 하지만 필요한 내용이 클래스에 다 들어있고, 복잡한 초기화가 필요없는 경우에만 해당 방법을 사용할 수 있다. 그리고 자바에서 정적 초기화를 처리하는 방법 때문에 복잡해질 수 있다. 특히 여러 클래스가 얽혀 있는 경우에는 지저분하고, 초기화 순서와 관련된 버그는 찾기 어렵기 때문에 해당 방식으로 싱글턴 비슷한 걸 만들어야 한다면 좋지 않을 수 있다.

Q) 클래스 로더와 관련된 문제는 없을까?

A) 클래스 로더마다 서로 다른 네임스페이스를 정의하기 때문에 클래스 로더가 두 개 이상이라면 같은 클래스를 여러 번 로딩할 수도 있다. 만약 싱글턴을 그런 식으로 로딩하면 인스턴스가 여러 개 만들어지는 문제가 발생할 수 있다. 따라서 클래스 로더를 여러 개 사용하면서 싱글턴을 사용한다면 조심해야 하고, 클래스 로더를 직접 지정해서 문제를 회피할 수도 있다.

Q) 전역 변수가 싱글턴보다 나쁜 이유는 무엇일까??

A) 자바의 전역 변수는 기본적으로 객체에 대한 정적 레퍼런스다. 전역 변수를 이런 식으로 사용한다면 게으른 인스턴스를 사용할 수 없는 단점과 싱글턴 패턴을 쓰는 두 가지 이유 중, 클래스의 인스턴스가 하나만 있을 수 있도록 할 수 없다. 전역 변수를 사용한다면 간단한 객체에 대한 전역 레퍼런스를 자꾸 만들게 도면서 네임스페이스를 지저분한게 만드는 경향이 생긴다.

핵심 정리

  • 어떤 클래스를 싱글턴 패턴을 적용하면 애플리케이션에 그 클래스의 인스턴스가 최대 한 개 까지만 있도록 할 수 있다.
  • 싱글턴 패턴을 이용하면 유일한 인스턴스를 어디서든지 접근할 수 있도록 할 수 있다.
  • 자바에서 싱글턴 패턴을 구현할 때는 private 생성자와 정적 메소드, 정적 변수를 사용한다.
  • 다중 스레드를 사용하는 애플리케이션에서는 속도와 자원 문제를 파악해보고 적절한 구현법을 사용해야 한다.
    • 사실상 멀티스레딩을 기본으로 가정해야한다.
  • DCL을 사용하는 방법은 자바 2 버전5보다 전에 나온 버전에서는 쓸 수 없다.
  • 클래스 로더가 여러 개 있으면 싱글턴이 제대로 작동하지 않고, 여러 개의 인스턴스가 생길 수 있다.
  • 1.2 버전보다 전에 나온 JVM을 사용하는 경우에는 가바지 컬렉터 관련 버그 때문에 싱글턴 레지스트리를 사용해야 할 수도 있다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
    • 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
728x90
728x90

헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.

  • 느슨한 결합을 이용하는 객체지향 디자인, 객체의 인스턴스를 만드는 작업이 항상 공개되어 있어야 하는 것은 아니며, 오히려 결합과 관련된 문제가 생길 수 있다. 팩토리 패턴을 이용하여 불필요한 의존성을 없애보자

추상 팩토리 패턴에서는 인터페이스를 이용하여 서로 연관된, 또는 의존하는 객체를 구상 클래스를 지정하지 않고도 생성할 수 있다.

원재료군

뉴옥과 시카고에서 사용하는 재료 종류는 서로 다르다. PizzaStore 분점이 점점 생기면서 해당 분점들은 또 다른 자신들만의 재료들을 사용해야 될 것이다.

이렇게 서로 다른 종유릐 재료들을 제공하기 위해 원재료군을 처리할 방법을 생각해야한다.

원재료 공장 만들기

따라서 이제 원재료를 생산하기 위한 공장을 만들어보자.

원재료 공장에서는 원재료군에 들어있는 반죽, 소스, 치즈 같은 각각의 원재료를 생산한다.

우선 모든 원재료를 생산할 팩토리를 위한 인터페이스를 정의해보자

public interface PizzaIngredientFactory {

    Dough createDough();

    Sauce createSauce();

    Cheese createCheese();

    Veggies[] createVeggies();

    Pepperoni createPepperoni();

    Clams createClam();

}
public class NYPizzaIngredientFactory implements PizzaIngredientFactory {

    @Override
    public Dough createDough() {
        return new ThinCrustDough();
    }

    @Override
    public Sauce createSauce() {
        return new MarinaraSauce();
    }

    @Override
    public Cheese createCheese() {
        return new ReggianoCheese();
    }

    ...
}

피자 클래스 변경

원재료 팩토리가 준비되고 Pizza 클래스에서 팩토리에서 생산한 원재료만 사용하도록 코드를 수정한다.

public abstract class Pizza {

    String name;
    Dough dough;
    Sauce sauce;
    Cheese cheese;
        ...

    public void box() {
        System.out.println("포장");
    }

    public void cut() {
        System.out.println("커팅");
    }

    public void bake() {
        System.out.println("굽기");
    }

    abstract void prepare();
}
  • prepare() 메서드를 제외한 다른 메서드들은 바뀌지 않는다.
public class CheesePizza extends Pizza {

    private final PizzaIngredientFactory pizzaIngredientFactory;

    public CheesePizza(PizzaIngredientFactory pizzaIngredientFactory) { // 생성자를 통해 원재료를 제공하는 팩토리를 주입받는다.
        this.pizzaIngredientFactory = pizzaIngredientFactory;
    }

    @Override
    void prepare() { // 팩토리가 작동하는 부분
        dough = pizzaIngredientFactory.createDough();
        sauce = pizzaIngredientFactory.createSauce();
        cheese = pizzaIngredientFactory.createCheese();
    }
}

앞선 예제에서 NYStyleCheesePizza , ChicagoStyleCheesePizza 클래스를 기억해보자. 그 두 클래스를 살펴보면 지역별로 다른 재료를 사용한다는 것만 빼면 다른 점이 없다.

  • 따라서 피자마다 클래스를 지역별로 따로 만들 필요가 없다. 지역별로 다른 점은 원재료 공장에서 처리하기 때문

이제 피자 코드에서는 팩토리를 이용하여 피자에서 쓰이는 재료를 만든다.

  • 만들어지는 재료는 어떤 팩토리를 쓰는지에 따라 달라지며 피자 클래스에서는 전혀 신경을 쓰지 않는다.
  • 이제 피자 클래스와 지역별 재료가 분리되어 있기 때문에 어떤 지역의 재료 팩토리를 사용하든 피자 클래스는 그대로 재사용할 수 있다.

마찬가지로 피자 가게를 수정해보자

public class NYPizzaStore extends PizzaStore {

    @Override
    protected Pizza createPizza(String type) {
        Pizza pizza = null;
        PizzaIngredientFactory ingredientFactory = new NYPizzaIngredientFactory();

        if (type.equals("cheese")) {
            pizza = new CheesePizza(ingredientFactory);
        } else if (type.equals("greek")) {
            pizza = new GreekPizza(ingredientFactory);
        }
        return pizza;
    }
}
  • 뉴욕 피자 가게에서는 뉴욕 피자 원재료 공장을 주입시켜 준다.

정리

기존 팩토리 패턴에서 추상 팩토리라고 부르는 새로운 형식의 팩토리를 도입해서 서로 다른 피자에서 필요로 하는 원재료군을 생산하기 위한 방법을 구축했다.

  • 추상 팩토리를 통해서 제품군을 생성하기 위한 인터페이스를 제공할 수 있다.
  • 이 인터페이스를 이용하는 코드를 만들면 코드를 제품을 생산하는 실제 팩토리와 분리시킬 수 있다.
  • 이렇게 함으로써 서로 다른 상황별로 적당한 제품을 생산할 수 있는 다양한 팩토리를 구현할 수 있게 된다.

추상 팩토리 패턴 정의

제품군을 만들 때 쓸 수 있는 추상 팩터리 패턴에서는 인터페이스를 이용하여 서로 연관된, 또는 의존하는 객체를 구상 클래스를 지정하지 않고도 생성할 수 있다.

  • 추상 팩토리 패턴을 사용하면 클라이언트에서 추상 인터페이스를 통해서 일련의 제품들을 공급받을 수 있다.
  • 이 때, 실제로 어떤 제이품이 생산되는지 전혀 알 필요가 없다.
  • 따라서 클라이언트와 팩토리에서 생산되는 제품을 분리시킬 수 있다.

클래스 다이어그램

 

추상 팩토리 패턴과 팩토리 메서드 패턴의 차이

추상 팩토리 패턴에 있는 createDough(), createSauce() 같은 메서드는 전부 팩토리 메서드 같이 보인다.

그렇다면 추상 팩토리 패턴 뒤에는 팩토리 메서드 패턴이 숨어져 있는 것일까?

  • 각 메서드는 추상 메서드로 선언되어 있고, 서브 클래스에서 메소드를 오버라이드해서 객체를 만드는 방식이기 때문
  • 추상 팩터리가 일련의 제품들을 생성하는데 쓰일 인터페이스를 정의하기 위해 만들어진 것이므로, 해당 인터페이스에 있는 메서드는 구상 제품을 만드는 일을 맡고 있고, 추상 팩토리의 서브클래스를 만들어서 각 메서드의 구현을 제공한다.
  • 따라서 추상 팩토리 패턴에서 제품을 생성하기 위한 메서드를 구현하는데 있어서 팩토리 메서드를 사용하는것은 자연스러운 일이다.

하지만 팩토리 메서드 패턴은 상속을 통해 객체를 생성하고 추상 팩토리 패턴은 객체 구성을 통해 객체를 생성한다.

또한 추상 팩토리 패턴에서는 제품군에 제품을 추가하는 식으로 관련 제품들을 확대해야 하는 경우에 인터페이스를 수정해야 하지만 팩토리 메서드 패턴에서는 한 가지 제품만 생산하므로 복잡한 인터페이스도 필요하지 않고, 메서드도 하나만 있으면 된다.

  • 추상 팩토리 패턴은 클라이언트에서 서로 연관된 제품군을 만들어야 할 때
  • 팩토레 메소드 패턴은 클라이언트 코드와 인스턴스를 만들어야 할 구상 클래스를 분리시켜야하거나, 어떤 구상 클래스를 필요로 하게 될지 미리 알 수 없는 경우에 매우 유용하다.

핵심 정리

  • 팩토리를 쓰면 객체 생성을 캡슐화할 수 있다.
  • 간단한 팩토리는 엄밀히 디자인 패턴은 아니지만, 클라이언트와 구상 클래스를 분리시키기 위한 간단한 기법으로 활용 가능하다.
  • 팩토리 메소드 패턴에서는 상속을 활용한다. 객체 생성이 서브클래스에게 위임된다. 서브 클래스에서는 팩토리 메소드를 구현하여 객체를 생산한다.
  • 추상 팩토리 패턴에서는 객체 구성을 활용한다. 객체 생성이 팩토리 인터페이스에서 선언한 메소스들에서 구현된다.
  • 모든 팩토리 패턴에서는 애플리케이션의 구상 클래스에 대한 의존성을 줄여줌으로써 느슨한 결합을 도와준다.
  • 추상 팩토리 패턴은 구상 클래스에 직접 의존하지 않고도 서로 관련된 객체들로 이루어진 제품군을 만들기 위한 용도로 쓰인다.
  • DIP에 따르면 구상 형식에 대한 의존을 피하고 추상화를 지향할 수 있다.
  • 팩토리는 구상 클래스가 아닌 추상 클래스, 인터페이스에 맞춰 코딩할 수 있게 해주는 강력한 기법이다.

객체지향 도구 상자

  • 객체지향의 기초(4요소)
    • 캡슐화
    • 상속
    • 추상화
    • 다형성
  • 객체지향 원칙
    • 바뀌는 부분을 캡슐화한다.
    • 상속보다는 구성을 활용한다.
    • 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
    • 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
    • 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
    • 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
  • 객체지향 패턴
    • 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
    • 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
    • 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
    • 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
    • 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
728x90

+ Recent posts