책임은 사실 한 가지 일만 하는 것보다는 변경할 이유로 해석해야 한다. 컴포넌트가 변경할 이유가 오로지 한 가지라면 컴포넌트는 자연스럽게 한 가지 일만 하게 된다. 변경할 이유가 오직 한 가지라는 것은 아키텍처에서 어떤 의미일까? 컴포넌트를 변경할 이유가 한 가지라면 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 소프트웨어가 변경되더라도 기대한 대로 동작할 것이기 때문이다.
하지만 변경할 이유라는 것은 컴포넌트 간의 의존성을 통해 쉽게 전파된다. 컴포넌트의 의존성 각각은 해당 컴포넌트를 변경하는 이유 하나씩에 해당한다. 컴포넌트 E를 변경할 유일한 이유는 E의 기능을 바꿔야 할 때뿐이다. 반면 컴포넌트 A는 모든 컴포넌트에 의존하고 있기 때문에 다른 어떤 컴포넌트가 바뀌든지 같이 바뀌어야 한다.
의존성 역전 원칙
계층형 아키텍처에서 계층 간 의존성은 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할 때 상위 계층들이 하위 계층들에 비해 변경할 이유가 더 많다는 것을 알 수 있다. 그러므로 영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다.
그러나 도메인 코드는 애플리케이션에서 가장 중요한 코드다. 어떻게 이 의존성을 제거할 수 있을까? 바로 의존성 역전 원칙이다.
코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.
사실 의존성의 양쪽 코드를 모두 제어할 수 있을 때만 역전시킬 수 있다. 만약 서드파티 라이브러리에 의존성이 있다면 제어할 수 없기 때문에 역전시킬 수 없다. 도메인 코드와 영속성 코드 간의 의존성을 역전시켜서 영속성 코드가 도메인 코드에 의존하고, 도메인 코드를 “변경할 이유”의 개수를 줄여보자.
엔티티는 도메인 객체를 표현하고 도메인 코드는 이 엔티티들의 상태를 변경하는 일을 중심으로 하기 때문에 먼저 엔티티를 도메인 계층으로 올린다. 그러나 이제는 영속성 계층의 리포지토리가 도메인 계층에 있는 엔티티에 의존하기 때문에 두 계층 사이에 순한 의존성이 생긴다. 따라서 DIP를 적용하여 도메인 계층에 리포지토리 대한 인터페이스를 만들고, 실제 리포지토리는 영속성 계층에서 구현하게 하는 것이다.
클린 아키텍처
로버트 마틴은 클린 아키텍처라는 용어를 정립했다. 클린 아키텍처에서는 설계나 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, ui 기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 이야기했다. 이 말은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다.
대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다. 클린 아키텍처에서 중요한 규칙은 의존성 규칙으로, 계층 간의 모든 의존성이 안쪽으로 향해야 한다는 것이다. 아키텍처의 코어에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있다. 유스케이스는 앞에서 서비스라고 불렀던 것들인데, 단일 책임을 갖기 위해 조금 더 세분화돼 있다. 이를 통해 넓은 서비스 문제를 피할 수 있다.
도메인 코드에서는 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있다. 그래서 도메인 코드를 자유롭게 모델링할 수 있다. 예를 들어, DDD를 순수한 형태로 적용해 볼 수도 있다.
하지만 클린 아키텍처에는 외부 계층과 철저하게 분리돼야 하므로 애플리케이션 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 가령 영속성 계층에서 ORM 프레임워크를 사용한다고 해보자. 도메인 계층은 영속성 계층을 모르기 때문에 도메인 계층에서 사용한 엔티티 클래스를 영속성 계층에서 함께 사용할 수 없고 두 계층에서 각각 엔티티를 만들어야 한다. 즉, 도메인 계층과 영속성 계층이 데이터를 주고받을 때, 두 엔티티를 서로 변환해야 한다는 뜻이다. 이는 도메인 계층과 다른 계층들 사이에서도 마찬가지다. 하지만 이것은 바람직한 일이다. 이것이 바로 도메인 코드를 프레임워크에 강결합이 제거된 상태이다.
예를 들어 JPA에서 인자가 없는 기본 생성자를 강제하는 것
헥사고날 아키텍처
헥사고날 아키텍처는 애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트 어댑터 아키텍처라고도 불린다. 육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.
육각형 바깥에는 애플리케이션과 상호작용하는 다양한 어댑터들이 있다. 애플리케이션 코어와 어댑터들 간의 통신이 가능해지려면 코어가 각각의 포트를 제공해야 한다.
코어를 주도하는 어댑터(driving adapter)에게는 포트가 코어에 있는 유스케이스 클래스 중 하나에 의해 구현되고 어댑터에 의해 호출되는 인터페이스가 될 것이다.
코어에 의해 주도되는 어댑터(driven adapter)에게는 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.
헥사고날 아키텍처도 클린 아키텍처처럼 계층으로 구성할 수 있다. 가장 바깥쪽에 있는 계층을 애플리케이션과 다른 시스템 간의 번역을 담당하는 어댑터로 구성되어 있다. 다음으로 포트와 유스케이스 구현체를 결합해서 애플리케이션 계층을 구성할 수 있다. 마지막 계층에는 도메인 엔티티가 위치한다.
결국 핵심은? 의존성
결국 어떤 아키텍처라고 불리든 의존성을 역전시켜 도메인 코드가 다른 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄일 수 있다. 도메인 코드는 비즈니스 문제에 딱 맞도록 자유롭게 모델링 될 수 있고, 영속성 코드와 UI 코드도 영속성 문제와 UI 문제에 맞게 자유롭게 모델링 될 수 있다.
계층일 이용하는 사고방식은 컴퓨터 과학 수업이나 튜토리얼, 모범사례를 통해 주입되어 왔다.
사실 계층형 아키텍처는 견고한 아키텍처 패턴이다. 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다. 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다. 잘 만들어진 계층형 아키텍처는 선택의 폭을 넓히고 , 변화하는 요구사항과 외부 요인에 빠르게 적용할 수 있게 해 준다. 로버튼 마틴에 의하면 이것이 바로 아키텍처의 전부다(클린 아키텍처)
그렇다면 계층형의 문제점은 무엇일까? 계층형 아키텍처는 코드에 나쁜 습관들이 스며들기 쉽게 만들고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 허점들을 노출한다.
데이터베이스 주도 설계를 유도
정의에 따르면 계층형 아키텍처의 토대는 데이터베이스다. 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터베이스에 의존하게 된다. 모든 것이 영속성 계층을 토대로 만들어진다. 이런 방식은 다양한 이유로 문제를 초래한다.
우리가 만드는 애플리케이션의 대부분의 목적은 무엇인가, 바로 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 이러한 규칙과 정책을 더욱 편리하게 활용할 수 있게 한다. 이때 우리는 상태가 아니라 행동을 중심으로 모델링한다. 상태가 중요한 요소이긴 하지만 행동이 상태를 바꾸는 주체이기 때문에 행동이 비즈니스를 이끌어간다.
그렇다면 왜 도메인 로직이 아닌 데이터베이스를 토대로 아키텍처를 만드는 걸까? 그동안 만들어 본 애플리케이션의 유스케이스를 도메인 로직이 아니라 영속성 계층을 먼저 구현했을 것이다. 데이터베이스의 구조를 먼저 생각하고 이를 토대로 도메인 로직을 구현했을 것이다.
계층형 아키텍처에서는 합리적인 방법이다. 의존성에 방향에 따라 자연스럽게 구현한 것이기 때문이다. 하지만 비즈니스 관점에서는 전혀 맞지 않는 방법이다. 먼저 도메인 로직을 만들어야 한다. 그래야 우리가 로직을 제대로 이해했는지 확인할 수 있으며, 도메인 로직이 맞다는 것을 확인한 후에 이를 기반으로 영속성 계층과 웹 계층을 만들어야 한다.
데이터베이스 중심 아키텍처가 만들어지는 가장 큰 원인은 ORM 프레임워크를 사용하기 때문이다. ORM 프레이워크가 나쁘다는 것이 아니라 ORM 프레임워크를 계층형 아키텍처와 결합하면 비즈니스 규칙을 영속성 관점과 섞고 싶은 유혹을 쉽게 받는다.
도메인 계층에서 이러한 영속성 계층 속 엔티티에 접근할 수 있으며 사용되기 마련이다. 이렇게 되면 영속성 계층과 도메인 계층 사이에 강한 결합이 생긴다. 서비스는 영속성 모델을 비즈니스 모델처럼 사용하게 되고 이로 인해 도메인 로직뿐만 아니라 즉시로딩/지연로딩, 데이터베이스 트랜잭션, 캐시 플러시 등등 영속성 계층과 관련된 작업들을 해야만 한다. 영속성 코드가 사실상 도메인 코드에 녹아들어 가서 둘 중 하나만 바꾸는 것이 어려워진다.
지름길을 택하기 쉬워진다.
계층형 아키텍처에서 전체적으로 적용되는 유일한 규칙은, 특정한 계층에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근 가능하다는 것이다. 만약 상위 계층에 컴포넌트에 접근해야 한다면 간단하게 해당 컴포넌트를 계층아래로 내려버리면 된다. 딱 한번 이렇게 하는 것은 괜찮을 수 있지만 보통 영속성 계층은 수년에 걸친 개발과 유지보수로 아래 그림처럼 될 가능성이 높다.
영속성 계층에서는 모든 것에 접근 가능하기 때문에 시간이 지나면 점점 비대해진다. 영속성 계층은 컴포넌트를 아래 계층으로 내릴수록 비대해진다. 어떤 계층에도 속하지 않는 것처럼 보이는 헬퍼 컴포넌트나 유틸리티 컴포넌트들이 이처럼 아래 계층을 내릴 가능성이 큰 후보다.
테스트하기 어려워진다.
계층형 아키텍처를 사용할 때 일반적으로 나타나는 변화의 형태는 계층을 건너뛰는 것이다. 엔티티의 필드를 단 하나만 조작하면 되는 경우에 웹 계층에서 바로 영속성 계층에 접근하면 도메인 계층을 건드릴 필요가 없지 않을까?
도메인 계층을 건너뛰는 것은 도메인 로직을 코드 여기저기에 흩어지게 만든다. 웹 계층 쪽 유스케이스가 확장되는 경우 아무리 간단한 것에 불과하더라도 도메인 로직을 웹 계층에 구현하게 된다. 따라서 애플리케이션 전반에 걸쳐 책임이 섞이고 핵심 도메인 로직들이 퍼져나갈 확률이 높다.
유스케이스를 숨긴다.
기능을 추가하거나 변경할 적절한 위치를 찾는 일이 빈번하기 때문에 아키텍처는 코드를 빠르게 탐색하는 데 도움이 돼야 한다.
하지만 계층형 아키텍처는 말했듯이 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽다. 이럴 경우 새로운 기능을 추가할 적당한 위치를 찾는 일이 어려워진 상태이다. 계층형 아키텍처는 도메인 서비스의 ‘너비’에 관한 규칙을 강제하지 않기 때문에 시간이 지나면 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 만들어지기도 한다.
넓은 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 다시 웹 레이어의 많은 컴포넌트가 이 서비스에 의존하게 된다. 그럼 서비스를 테스트하기도 어려워지고 작업해야 할 유스케이스를 책임지는 서비스를 찾기도 어려워진다. 고도로 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 이런 작업들이 얼마나 수월해질까? UserService에서 사용자 등록 유스케이스를 찾는 대신 RegisterUserService를 바로 열어서 작업을 시작하는 것처럼 말이다.
동시 작업이 어려워진다.
계층형 아키텍처는 동시 작업 측면에서는 그다지 도움이 되지 않는다. 계층형 아키텍처에서는 모든 것이 영속성 계층 위에 만들어지기 때문에 특정 기능은 동시에 한 명의 개발자만 작업할 수 있다. 인터페이스를 먼저 같이 정의 후 작업을 할 순 있지만 영속성 로직과 도메인 로직이 뒤섞여서 각 측면을 개별적으로 작업하기 힘들고, 또 코드에 넓은 서비스가 있다면 서로 다른 기능을 동시에 작업하기는 더욱 어렵다.
유지보수 가능한 소프트웨어를 만드는데 어떻게?
물론 계층형 아키텍처도 올바르게 구축하고 몇 가지 추가적인 규칙들을 적용하면 유지보수가 쉬워지며 코드를 쉽게 변경하거나 추가할 수 있다. 하지만 앞에서 살펴봤듯이 잘못된 방향으로 흘러가도록 쉽고 용인하는 구조이다.
따라서 계층형 아키텍처로 만들든 다른 아키텍처 스타일로 만들든, 계층형 아키텍처의 문제점들을 염두에 두면 지름길을 택하지 않고 유지보수하기에 더 쉬운 솔루션을 만드는 데 도움이 될 것이다.
전통적인 레이어드 아키텍처로 프로젝트를 개발하며 점점 문제점들이 보이기 시작했다. 서비스가 조금씩 커지거나 요구사항의 확장이 진행되며 점점 더 복잡해지면서 유지보수에 점점 더 많은 시간이 쓰이고 있었다. 많은 서비스를 담당하는 엄청난 서비스, 영속성 계층인 데이터베이스에 의존성이 점점 커지는 것 등 이러한 문제점을 해결하기 위한 고민이 있던 와중 평소에 소프트웨어 개발에서 도메인 주도 설계, 클린 아키텍처, 헥사고날 아키텍처라는 용어를 많이 들어왔으나 "클린 아키텍처는 뭐고, 헥사고날 아키텍처는 뭐지?, 둘의 차이는 무엇이고 그래서 어떤 아키텍처를 사용하란 걸까? " 하며 항상 헷갈려했다.
이러한 아키텍처들이 왜 등장하게 되었으며, 전통적인 계층형 아키텍처의 문제점을 해결할 수 있을까 싶어 찾아보게 되었다.
조금 조사하던 와중 DDD는 조금 더 큰 영역의 내용인 것 같아 추후에 다시 작성하도록 하고 먼저 클린아키텍처에 대해서 가볍게 훑은 내용들을 정리하고자 한다.
왜 소프트웨어 아키텍처가 중요할까?
정말 소프트웨어 아키텍처가 중요한 걸까? 소프트웨어가 제공하는 가치는 두 가지가 있다고 한다. 바로 기능과 구조이다. 기능과 구조에서 조금 더 중요한 것은 무엇일까?? 평소에는 기능이라고 생각해 왔다. 우리가 만드는 애플리케이션의 대부분의 목적은 바로 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 기능들을 편리하게 사용할 수 있게 만드는 것이라고 생각했기 때문이다.
하지만 로버틴 C. 마틴은 구조의 중요성은 언급한다. 왜일까? 바로 우리가 원하는 것은 더 정확하게, 더 빠르게, 더 많이 기능을 추가하기 위해 코드를 읽고, 이해하고, 수정해야 하기 때문이다. 즉, 시스템을 만들고 유지보수하는데 투입되는 인력을 최소화하는 것이다. 잘 생각해 보면 레거시 프로젝트이든 최근에 만든 프로젝트이든 새로운 코드를 짜는 것보다 기존 코드를 바꾸는데 훨씬 더 많은 시간을 쓰는 것이 생각났다.
즉 구조가 좋다는 것은 수정의 비용이 적다는 것이다.
좋은 아키텍처 - 1. 계층형 아키텍처
그렇다면 좋은 아키텍처에는 무엇이 있을까? 첫 번째로 계층형 아키텍처다. 사실 계층형 아키텍처는 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다고 한다. 따라서 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다.
하지만 계층형 아키텍처의 가장 큰 단점은 도메인 계층이 영속성 계층을 의존하는 데이터베이스 주도 설계를 유도한다는 것이다. 도메인 로직을 여러 계층에 흩어지게 만들기 쉬운 아키텍처이다. 기능 기반으로 패키지를 구성하여도 계층적 구조는 동일하다.
좋은 아키텍처 - 2. 클린 아키텍처
로버트 마틴은 클린 아키텍처라는 용어를 정립했다. 클린 아키텍처에서는 설계나 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, ui 기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 이야기했다.
이 말은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다. 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있는 아키텍처이다.
헥사고날 아키텍처?
헥사고날 아키텍처는 애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트 어댑터 아키텍처라고도 불린다. 육각형이란 단어에는 아무런 뜻도 없으며 중요한 것은 육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.
클린아키텍처의 애매함
클린 아키텍처는 핵심 규칙 외에는 케이스 바이 케이스라 애매한 지점이 많다. 애매할 때는 아래의 기준점을 참고하면 좋을 것 같다.
1. 필요한 시스템을 만들고 유지보수하는 데 투입되는 인력 최소화에 유리한가?
2. 소스 코드 의존성이 안쪽으로, 고수준의 정책을 향하고 있는가?
3. 세부 사항이 변경되어도 도메인(핵심 규칙)에 변경이 없을 것인가?
4. 테스트하기 쉬운가?
5. 각각의 아키텍처 원칙들을 잘 지키고 있는가?
클린아키텍처는 항상 좋을까?
위에서 말했듯이 전통적인 계층적 아키텍처도 충분히 영속성 계층에 독립적으로도메인 로직을 유지할 수 있을 것이다. 마찬가지로 클린 아키텍처도 항상 좋을 것은 아니다.
외부 계층과 철저하게 분리돼야 하므로 애플리케이션 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 따라서 코드의 절대적인 양이 많아질 것이며, 프로젝트 개발자 모두가 클린 아키텍처를 이해하고 있지 않을 때 혹은 모두가 사용하기로 합의하지 않았을 때에는 사용하는 것이 오히려 좋지 않을 수 있다고 생각한다.
자주 사용하는 데이터나 값을 미리 저장해 놓는 임시 저장소 서버나 데이터베이스에 가해지는 부하를 줄이고 성능을 높이기 위해 사용한다. 자주 변경되고 삭제되는 데이터에 적용하면 오히려 성능 저하를 일으킬 수 있다.
종류
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)
즉, 캐시 서버가 장애로부터 복구되는 동안 성능상의 지연은 발생할 수 있지만, 서비스가 불가능한 상태가 되지 않도록 고려해야 한다는 말이다.
이제 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("컵에 따르는 중");
}
}
즉 템플릿 메소드는 알고리즘의 각 단계를 정의하며, 서브 클래스에서 일부 단계를 구현할 수 있도록 유도한다.
템플릿 메소드 패턴의 장점
기존 Tea & Coffee 클래스
각 클래스가 각각 작업을 처리한다.
두 클래스에서 각자 알고리즘을 수행
Coffee 와 Tea 클래스에 중복된 코드가 존재
알고리즘이 바뀌면 서브클래스를 일일이 열어서 여러 군데를 고쳐야 한다.
클래스 구조상 새로운 음료를 추가하려면 꽤 많은 일을 해야 한다.
알고리즘 지식과 구현 방법이 여러 클래스에 분산되어 있다.
템플릿 메소드를 사용한 CaffeinBeverage
CaffeinBeverage 클래스에서 작업을 처리한다.
알고리즘을 독점
서브 클래스에서 코드를 재사용할 수 있다.
알고리즘이 한 군데에 모여 있으므로 한 부분만 고치면 된다.
다른 음료도 쉽게 추가할 수 있는 프레임워크를 제공한다.
음료를 추가할 때 몇 가지 메소드만 더 만들면 된다.
CaffeinBeverage 클래스에 알고리즘 지식이 집중되어 있으며 일부 구현만서브클래스에 의존한다.
템플릿 메소드 패턴의 정의
이제 패턴의 정의와 특징을 자세히 알아보자.
간단하게 말하면 템플릿 메소드 패턴은 알고리즘의 템플릿(틀)을 만든다. 템플릿이란 일련의 단계로 알고리즘을 정의한 메소드이다. 여러 단계 가운데 하나 이상의 단계가 추상 메소드로 정의되며, 그 추상 메소드는 서브 클래스에서 구현된다. 이러면 서브 클래스가 일부분의 구현을 처리하게 하면서도 알고리즘의 구조는 바꾸지 않아도 된다.
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)를 방지할 수 있다.
어떤 고수준 구성 요소가 저수준 구성 요소에 의존하고, 그 저수준 구성 요소는 다시 고수준 구성 요소에 의존하고, 그 고수준 구성 요소는 다시 또 다른 구성 요소에, 그 다른 구성 요소는 또 저수준 구성 요소에 의존하는 것과 같은 식으로 의존성이 복잡하게 꼬여있는 상황을 의존성이 부패했다고 부른다. 이렇게 의존성이 부패하면 시스템 디자인이 어떤 식으로 되어 있는지 아무도 알아볼 수 없다.
할리우드 원칙을사용하면, 저수준 구성 요소가 시스템에 접속할 수는 있지만 언제, 어떻게 그 구성 요소를 사용할지는 고수준 구성 요소가 결정한다.
즉 고수준 구성 요소가 저수준 구성 요소에게 “먼저 연락하지 마세요. 제가 먼저 연락 드리겠습니다.” 라고 이야기 하는 것과 같다.
음료를 만드는 방법에 해당하는 알고리즘을 장악하고 있고, 메소드 구현이 필요한 상황에만 서브클래스를 불러낸다.
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)
추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
진짜 절친에게만 이야기해야 한다.
먼저 연락하지 마세요. 저희가 연락 드리겠습니다.
객체지향 패턴
스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
템플릿 메소드 패턴 : 알고리즘의 골격을 정의한다. 템플릿 메소드를 사용하면 알고리즘의 일부 단계를 서브클래스에서 구현할 수 있으며, 알고리즘의 구조는 그대로 유지하면서 알고리즘의 특정 단계를 서브클래스에서 재정의할 수도 있다.
어댑터는 기존 시스템에서 사용하던 인터페이스를 구현해서 새로운 업체에서 제공한 클래스에 요구 내역을 전달할 수 있다. 어댑터는 클라이언트로부터 요청을 받아서 새로운 업체에서 제공하는 클래스를 클라이언트가 받아들일 수 있는 형태의 요청으로 변환해 주는 중개인 역할을 하는 것이다.
어댑터 사용방법
어댑터를 어떻게 사용하는지 한번 살펴보자
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
클라이언트는 타깃 인터페이스에 맞게 구현되어 있으며, 타깃 인터페이스로 메소드를 호출해서 어댑터에 요청을 보낸다.
어댑터는 타깃 인터페이스를 구현하며, 어댑티 인스턴스를 가지고 있다. 어댑터는 어댑티 인터페이스로 그 요청을 어댑티에 관한(하나 이상의) 메소드 호출로 변환한다.
클라이언트는 호출 결과를 받긴 하지만 중간에 어댑터가 있다는 사실을 모르므로, 클라이언트와 어댑티는 서로 분리되어 있다.
Q. 어댑터가 얼마나 적응시켜 줘야 할까? 대형 타깃 인터페이스를 구현해야 한다면 할 일이 정말 많아지지 않을까?
어댑터 구현은 타깃 인터페이스로 지원해야 하는 인터페이스의 크기에 비례해서 복잡해진다. 하지만 다른 대안이 없다. 클라이언트에서 호출하는 부분을 새로운 인터페이스에 맞춰서 고치려면 정말 많은 부분을 고려해야 하고, 코드도 많이 고쳐야 한다. 이런 방법보다는 모든 변경 사항을 캡슐화할 어댑터 클래스 하나만 제공하는 방법이 더 나을 것이다.
Q. 하나의 어댑터는 하나의 클래스만 감싸야 할까?
어댑터 패턴은 하나의 인터페이스를 다른 인터페이스로 변환하는 용도로 쓰인다. 하나의 어댑터에서 타깃 인터페이스를 구현하려고 2개 이상의 어댑티를 감싸야 하는 상황도 생길 수 있다. 사실 이런 내용은 퍼사드 패턴과 관련이 있으므로 퍼사드 패턴 때 다시 보자.
Q. 시스템에 오래된 부분과 새로 만든 부분이 섞여 있으면 어떻게 해야할까?? 어떤 곳에는 어댑터를 사용하고 다른 곳에서 어댑터로 감싸지 않은 인터페이스를 사용하면 헷갈리지 않을까?
이런 상황에서는 두 인터페이스를 모두 지원하는 다중 어댑터(Two Way Adapter)를 만들면 된다. 다중 어댑터로 필요한 인터페이스를 둘 다 구현해서 어댑터가 기존 인터페이스와 새로운 인터페이스 역할을 할 수 있게 하면 된다.
어댑터 패턴의 정의
이제 어댑터 패턴의 정의를 알아보자.
이 패턴을 사용하면 호환되지 않는 인터페이스를 사용하는 클라이언트를 그대로 활용할 수 있다. 인터페이스를 변환해 주는 어댑터를 만들면 되기 때문이다. 이러면 클라이언트와 구현된 인터페이스를 분리할 수 있으며, 변경 내역이 어댑터에 캡슐화되기에 나중에 인터페이스가 바뀌더라도 클라이언트를 바꿀 필요가 없다.
어댑터 패턴은 여러 객체지향 원칙을 반영하고 있다. 어댑티를 새로 바뀐 인터페이스로 감쌀 때는 Composition을 사용한다. 이런 접근번은 어댑티의 모든 서브클래스에 어댑터를 쓸 수 있다는 장점이 있다.
그리고 어댑터 패턴은 클라이언트를 특정 구현이 아닌 인터페이스에 연결한다. 서로 다른 백엔드 클래스로 변환시키는 여러 어댑터를 사용할 수도 있다. 이렇게 인터페이스를 기준으로 코딩했기에 타깃 인터페이스만 제대로 유지한다면 나중에 다른 구현을 추가하는 것도 가능하다.
객체 어댑터와 클래스 어댑터
사실 어댑터에는 두 종류가 있다. 하나는 객체 어댑터, 다른 하나는 클래스 어댑터이다.
지금까지 본 예제와 다이어그램 모두 객체 어댑터에 해당하는 내용들이다. 그렇다면 클래스 어댑터란 무엇이고 왜 살펴보지 않았을까? 클래스 어댑터 패턴을 쓰려면 상속이 필요한데 자바에서는 다중 상속이 불가능하므로 자바에서는 불가능하다. 하지만 다중 상속이 가능한 언러를 사용하다 보면 클래스 어댑터를 써야 할 때도 있으니 클래스 다이어그램을 살펴보자
어댑티를 적응시킬때 구성을 사용하는 대신, 어댑터를 어댑티와 타깃 클래스의 서브클래스로 만든다. 상속을 사용하는 클래스 어댑터에 비해 객체 어댑터는 composition을 사용하므로 상속을 통한 코드 분량을 줄이지는 못하지만, 어댑티한테 필요한 일을 시키는 코드만 작성하면 되기 때문에 작성해야할 코드가 적고 유연성을 확보할 수 있다.
Enumeration을 리턴하는 elements() 메소드가 구현되어 있었던, 초기 컬렉션 형식(Vector, Stack, Hashtable 등)은 Enumeration 인터페이스를 이용하면 컬렉션 내에서 각 항목이 관리되는 방식에는 신경 쓸 필요 없이 컬렉션의 모든 항목에 접근이 가능하다.
Iterator
최근에는 Enumeration과 마찬가지로 컬렉션에 있는 일련의 항목들에 접근할 수 있게 해 주면서 항목을 제거할 수 도 있게 해 주는 iterator라는 인터페이스를 이용하기 시작했다.
Enumeration vs Iterator
Enumeration 인터페이스를 사용하는 구형 코드를 다뤄야 할 때도 가끔 있지만 새로운 코드를 만들 때는 Iterator만 사용하는 것이 좋다. 이때 어댑터 패턴을 적용해보자.
클래스가 6개나 필요하고, 만약 영화가 끝나면 어떻게 해야할까?, 방금 했던 일을 전부 역순으로 처리해야 하지 않을까? 다른 라디오나 시스템이 업그레이드하면 이런 복잡한 작동 방법을 또 배워야 하지 않을까?
이렇게 복잡한 일을 퍼사드 패턴으로 간단하게 처리할 수 있는지 알아보자
퍼사드 작동 원리
쓰기 쉬운 인터페이스를 제공하는 퍼사드 클래스를 구현함으로써 복잡한 시스템을 훨씬 편리하게 사용할 수 있다. 물론 기존의 시스템을 직접 건드리고 싶다면 기존 인터페이스를그대로 사용하면 된다.
홈시어터 시스템용 퍼사드를 만들어보자.
watchMovie()와 같이 몇 가지 간단한 메소드만 들어있는 HomeTheaterFacade 클래스를 새로 만들어야 한다.
퍼사드 클래스는 홈시어터 구성 요소를 하나의 서브시스템으로 간주한다.
watchMovie() 메서드는 서브시스템의 메소드를 호출해서 필요한 작업을 처리한다.
이제 클라이언트 코드는 서비스시템이 아닌 홈시어터 퍼사드에 있는 메서드를 호출한다.
watchMovie() 메서드만 호출하면 조명, 스트리밍 플레이어, 앰프 등 알아서 준비된다.
퍼사드를 쓰더라도 서브시스템에 여전히 직접 접근할 수 있다.
서브시스템 클래스의 고급 기능이 필요하면 언제든지 사용 가능하다.
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();
}
}
퍼사드 패턴의 정의
퍼사드 패턴을 사용하려면 어떤 서브시스템에 속한 일련의 복잡한 클래스를 단순하게 바꿔서 통합한 클래스를 만들어야 한다. 다른 패턴과 달리 퍼사드 패턴은 상당히 단순한 편이다. 복잡한 추상화 같은 게 필요 없다. 하지만 퍼사드 패턴을 사용하면 클라이언트와 서브시스템이 서로 긴밀하게 연결되지 않아도 되고, 최소 지식 객체지향 원칙을 준수하는데도 도움이 된다.
퍼사드 패턴의 정의는 다음과 같다.
여기서 가장 중요한 점은 패턴의 용도이다. 정의를 보면 퍼사드 패턴은 단순화된 인터페이스로 서브시스템을 더 편리하게 사용하려고 쓰인다는 사실을 알 수 있다. 퍼사드 패턴의 클래스 다이어그램에서도 이 사실을 확인할 수 있다.
최소 지식 원칙(Principle of Least Knowledge)에 따르면 객체 사이의 상호자용은 될 수 있으면 아주 가까운 ‘친구’ 사이에서만 허용하는 편이 좋다. 이 원칙은 보통 다음과 같이 정의될 수 있다.
그런데 이게 정확히 무슨 소리일까? 시스템을 디자인할 때 어떤 객체든 그 객체와 상호작용을 하는 클래스의 개수와 상호작용 방식에 주의를 기울여야 한다는 뜻이다.
이 원칙을 잘 따르면 여러 클래스가 복잡하게 얽혀 있어서, 시스템의 한 부분을 변경했을 때 다른 부분까지 줄줄이 고쳐야 하는 상황을 미리 방지할 수 있다. 여러 클래스가 서로 복잡하게 의존하고 있다면 관리하기도 힘들고, 남들이 이해하기 어려운 불안정한 시스템이 만들어진다.
친구를 만들지 않고 다른 객체에 영향력 행사하기
그런데 어떻게 하면 여러 객체와 친구가 되는 것을 피할 수 있을까?
이 원칙은 친구를 만들지 않는 4개의 가이드라인을 제시한다.
객체 자체
메소드에 매개변수로 전ㄴ달된 객체
메소드를 생성하거나 인스턴스를 만든 객체
객체에 속하는 구성 요소
해당 가이드라인에 따르면 다른 메소드를 호출해서 리턴받은 객체의 메소드를 호출하는 일도 바람직 하지 않다. 따라서 꽤 까다로운 가이드라인이다. 메소드를 호출한 결과로 리턴받은 객체에 들어있는 메소드를 호출하면 다른 객체의 일부분에 요청하게 되고, 직접적으로 알고 지내는 객체의 수가 늘어나는 단점이 있다.
이러한 상황에서 최소 지식 원칙을 따르려면 객체가 대신 요청하도록 만들어야 한다. 그러면 그 객체의 한 구성 요소를 알고 지낼 필요가 없어지고 친구의 수를 줄이는 데도 도움이 된다.
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)
추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
진짜 절친에게만 이야기해야 한다.
객체지향 패턴
스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
어댑터 패턴 : 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와준다.
퍼사드 패턴 : 서브시스템에 있는 일련의 인터페이스를 통합 인터페이스로 묶어 준다. 또한 고수준 인터페이스도 정의하므로 서브시스템을 더 편리하게 사용할 수 있다.
커맨드에서 작업 취소 기능을 지원하려면 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)
추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
객체지향 패턴
스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
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;
}
}
A) 맞다. 하지만 필요한 내용이 클래스에 다 들어있고, 복잡한 초기화가 필요없는 경우에만 해당 방법을 사용할 수 있다. 그리고 자바에서 정적 초기화를 처리하는 방법 때문에 복잡해질 수 있다. 특히 여러 클래스가 얽혀 있는 경우에는 지저분하고, 초기화 순서와 관련된 버그는 찾기 어렵기 때문에 해당 방식으로 싱글턴 비슷한 걸 만들어야 한다면 좋지 않을 수 있다.
Q) 클래스 로더와 관련된 문제는 없을까?
A) 클래스 로더마다 서로 다른 네임스페이스를 정의하기 때문에 클래스 로더가 두 개 이상이라면 같은 클래스를 여러 번 로딩할 수도 있다. 만약 싱글턴을 그런 식으로 로딩하면 인스턴스가 여러 개 만들어지는 문제가 발생할 수 있다. 따라서 클래스 로더를 여러 개 사용하면서 싱글턴을 사용한다면 조심해야 하고, 클래스 로더를 직접 지정해서 문제를 회피할 수도 있다.
Q) 전역 변수가 싱글턴보다 나쁜 이유는 무엇일까??
A) 자바의 전역 변수는 기본적으로 객체에 대한 정적 레퍼런스다. 전역 변수를 이런 식으로 사용한다면 게으른 인스턴스를 사용할 수 없는 단점과 싱글턴 패턴을 쓰는 두 가지 이유 중, 클래스의 인스턴스가 하나만 있을 수 있도록 할 수 없다. 전역 변수를 사용한다면 간단한 객체에 대한 전역 레퍼런스를 자꾸 만들게 도면서 네임스페이스를 지저분한게 만드는 경향이 생긴다.
핵심 정리
어떤 클래스를 싱글턴 패턴을 적용하면 애플리케이션에 그 클래스의 인스턴스가 최대 한 개 까지만 있도록 할 수 있다.
싱글턴 패턴을 이용하면 유일한 인스턴스를 어디서든지 접근할 수 있도록 할 수 있다.
자바에서 싱글턴 패턴을 구현할 때는 private 생성자와 정적 메소드, 정적 변수를 사용한다.
다중 스레드를 사용하는 애플리케이션에서는 속도와 자원 문제를 파악해보고 적절한 구현법을 사용해야 한다.
사실상 멀티스레딩을 기본으로 가정해야한다.
DCL을 사용하는 방법은 자바 2 버전5보다 전에 나온 버전에서는 쓸 수 없다.
클래스 로더가 여러 개 있으면 싱글턴이 제대로 작동하지 않고, 여러 개의 인스턴스가 생길 수 있다.
1.2 버전보다 전에 나온 JVM을 사용하는 경우에는 가바지 컬렉터 관련 버그 때문에 싱글턴 레지스트리를 사용해야 할 수도 있다.
객체지향 도구 상자
객체지향의 기초(4요소)
캡슐화
상속
추상화
다형성
객체지향 원칙
바뀌는 부분을 캡슐화한다.
상속보다는 구성을 활용한다.
구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
객체지향 패턴
스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
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)
추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
객체지향 패턴
스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.