[정보 처리] 18. GoF 디자인 패턴 총정리 (Java 예시 코드 수록)
[디자인 패턴 개념]
- GoF 란, 1994 년 출간된 "Design Patterns: Elements of Reusable Object-Oriented Software" (디자인 패턴을 최초로 제안한 책)의 공동 저자인 Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides 의 네 사람을 지칭하는 말입니다.
- 디자인 패턴은, 반복적인 문제들을 해결하기 위한 설계 패턴 중 효율적이고 모범적인 패턴을 일반화 한 것입니다.
디자인 패턴의 GoF 가 처음 제안한 것이며, GoF 가 제안한 디자인 패턴을 GoF 패턴이라 부르지만,
이외에도 다양한 디자인 패턴(MVC, MVVM, BLOC, MVP, Thread Pool, etc...)들이 존재합니다.
본 게시글에서는 GoF 가 제안한 체계적이고 범용적인 GoF 디자인 패턴에 대해서만 정리하겠습니다.
- 디자인 패턴은 구현 단계의 문제에 실제로 적용 가능한 해결 방법입니다.
기능의 향상보다는 문제 해결을 통한 소프트웨어의 구조 변경, 코드의 가독성 등에 집중합니다.
실무에서 디자인 패턴을 활용할 때에는, 상황 파악 후 이에 맞는 디자인 패턴을 바로 선정하여 구현하는 것도 좋지만,
일단 구현한 이후에 리펙토링(코드 기능은 건드리지 않고 구조적으로 개선)을 하는 것도 일반적입니다.
특히 디자인 패턴은 '의사소통'의 용도로 사용하기 좋으며, 저마다의 방법으로 코드를 작성하는 협업 환경에서 본인이 만든 코드가 왜 이렇게 만들어져 있고, 이로인한 장점이 무엇인지를 설득하기 좋으므로 틈틈히 본인이 속한 직무의 디자인 패턴을 공부하고 이해하고 익히시는 것이 좋습니다.
- GoF 디자인 패턴은 5가지의 생성 패턴, 7가지의 구조 패턴, 11가지의 행위 패턴으로 구분됩니다.
1. 생성 패턴 : 클래스 정의, 객체 생성 방식에 적용 가능한 패턴
2. 구조 패턴 : 객체 간 구조와 인터페이스에 적용 가능한 패턴
3. 행위 패턴 : 기능(알고리즘), 반복적인 작업에 적용 가능한 패턴
- 디자인 패턴이 성립될 수 있는 필수 요소는 아래와 같습니다.
1. 패턴명과 구분 : 패턴의 이름과 패턴의 유형(생성, 구조, 행위)
2. 문제 및 배경 : 패턴이 적용되는 분야 또는 배경, 이 패턴으로 해결하고자 하는 문제
3. 솔루션 : 패턴을 구성하는 요소, 관계, 협동 과정(패턴의 내용)
4. 사례 : 간단한 적용 사례(소스 코드와는 별개의 상황적 사례)
5. 결과 : 패턴을 사용했을 경우의 이점과 영향
6. 샘플 코드 : 패턴이 적용된 소스 코드
- 디자인 패턴의 특징은 아래와 같습니다.
1. 디자인 패턴을 공유함으로써 소프트웨어 구조 파악과 원활한 의사소통이 가능해집니다.
2. 소프트웨어 개발의 생산성, 효율성, 재사용성, 확장성이 향상됩니다.(의사소통의 효율 + 프로그래밍 자체적 효율)
3. 초기 비용이 많이 드는 편이며, 객체 지향 개발에 특화된 패턴입니다.
[GoF 디자인 패턴]
(생성(Creational) 패턴)
1. Factory Method
Factory Method 패턴은 객체 생성을 하위 클래스에게 위임하여, 상위 클래스는 생성할 객체의 구체적인 클래스를 알 필요가 없게 만드는 패턴입니다.
예를 들어 설명하겠습니다.
public interface Product {
void use();
}
public class ConcreteProductA implements Product {
public void use() {
System.out.println("Using Product A");
}
}
public class ConcreteProductB implements Product {
public void use() {
System.out.println("Using Product B");
}
}
위와 같은 Product 클래스가 존재한다고 합시다.
만약 이를 사용하는 위치에서 서브 클래스를 그대로 객체화하여 사용한다면, 사용하는 위치와 서브 클래스간 직접적인 결합이 생기는 것이고, 만약 서브 클래스의 생성 방식 및 생성 매개변수 의미가 달라지는 등의 일이 일어난다면,
해당 클래스 사용 부분을 전부 찾아가서 일일이 생성 코드를 변경해줘야 합니다.
하지만,
public abstract class Factory {
public abstract Product createProduct();
}
public class FactoryA extends Factory {
public Product createProduct() {
return new ConcreteProductA();
}
}
public class FactoryB extends Factory {
public Product createProduct() {
return new ConcreteProductB();
}
}
위와 같이 Product 객체를 생성하여 반환하는 것을 전문으로 하는 Factory 클래스와, 객체 생성 메소드가 있다면,
public class Main {
public static void main(String[] args) {
Factory factory = new FactoryA(); // 또는 CreatorB, new Factory 객체만 바꿔주면 됨
Product product = factory.createProduct();
product.use();
}
}
위와 같이 객체 생성 부분의 로직을 모두 Factory 클래스로 몰아넣을 수 있고,
결과적으로 코드 응집성이 좋아지게 되어 유지보수가 쉬워지고, 객체 생성을 서브 클래스에 위임함으로써 Open-Closed Principle 을 충족하기에 코드 결합성이 낮아져서 확장성이 높아진다는 장점이 있습니다.
2. Abstract Factory
Abstract Factory 패턴은 관련 있는 여러 객체를 생성하는 인터페이스를 제공하며,
구체적인 클래스는 서브클래스가 결정하도록 위임하는 생성 패턴입니다.
이 패턴을 사용하면 구체 클래스에 의존하지 않고도 서로 연관되거나 독립적인 객체들을 일관되게 생성할 수 있습니다.
예를 들어 설명하겠습니다.
public interface Button {
void click();
}
public interface Checkbox {
void check();
}
위와 같은 제품 객체에 대한 interface 가 있다고 합시다.
이를 바탕으로 구체적인 제품을 만들 때, MacOS 용 스타일의 버튼와 체크박스, Windows 스타일의 버튼과 체크박스를 만든다고 합시다.
// === 구체적인 제품군: Windows 스타일 ===
public class WindowsButton implements Button {
public void click() {
System.out.println("Windows Button Clicked");
}
}
public class WindowsCheckbox implements Checkbox {
public void check() {
System.out.println("Windows Checkbox Checked");
}
}
// === 구체적인 제품군: Mac 스타일 ===
public class MacButton implements Button {
public void click() {
System.out.println("Mac Button Clicked");
}
}
public class MacCheckbox implements Checkbox {
public void check() {
System.out.println("Mac Checkbox Checked");
}
}
위와 같이 버튼과 체크박스가 그룹별로 구체화되었습니다.
각 객체는 서로 독립되었지만, 결합되어 사용될 때에는 동일 그룹의 객체를 사용해야 합니다.
사용 위치에서 이를 별도로 객체화한다면, 사용자가 어떤 객체가 어떤 그룹에 속하는지를 인지해야만 합니다.
이렇게 되면 자칫 잘못해서 Windows 서비스를 만들 때, Mac 용 객체를 사용하여 문제가 생길 가능성이 있죠.
이러한 추상화된 그룹 분류에 맞는 객체 생성을 사용 위치의 판단을 대신해 주는 것이 Abstract Factory 패턴입니다.
// === 구체적인 팩토리: Windows 스타일 ===
public class WindowsFactory implements GUIFactory {
public Button createButton() {
return new WindowsButton();
}
public Checkbox createCheckbox() {
return new WindowsCheckbox();
}
}
// === 구체적인 팩토리: Mac 스타일 ===
public class MacFactory implements GUIFactory {
public Button createButton() {
return new MacButton();
}
public Checkbox createCheckbox() {
return new MacCheckbox();
}
}
위와 같이 추상적인 그룹 기준으로 나뉘어진 객체를 반환해주는 객체 생성 팩토리 클래스를 두면,
// === 클라이언트 코드 ===
public class Application {
private Button button;
private Checkbox checkbox;
public Application(GUIFactory factory) {
button = factory.createButton();
checkbox = factory.createCheckbox();
}
public void render() {
button.click();
checkbox.check();
}
public static void main(String[] args) {
GUIFactory factory;
// 예: 운영체제에 따라 팩토리를 선택
String os = "Windows"; // 또는 "Mac"
if (os.equals("Windows")) {
factory = new WindowsFactory();
} else {
factory = new MacFactory();
}
Application app = new Application(factory);
app.render();
}
}
사용 위치에서 위와 같이 Factory 종류만 변경해주면 각 그룹에 맞는 객체들을 사용할 수 있으므로,
제품군 일관성을 무너뜨릴 가능성을 코드 단계에서 해소할 수 있고, 팩토리 패턴의 OCP 장점을 그대로 살릴 수도 있습니다.
3. Builder
객체 생성에 많은 인수가 필요한 복잡한 객체를 단계적으로 생성하는 패턴입니다.
복잡한 객체 생성과정을 단계별로 분리(캡슐화)하여 동일한 절차에서도 서로 다른 형태의 객체를 생성할 수 있게 합니다.
Java 실무에서는 Lombok 라이브러리를 이용하면 자주 사용하게 될 패턴입니다.
예를 들어 설명하겠습니다.
User user = new User("John", "Doe", 30, "123-456-7890", "john.doe@example.com");
위와 같은 객체가 있다고 합시다.
보시다시피 생성자의 매개변수 항목이 매우 많고 복잡해보입니다.
파라미터가 많아질수록 어떤 값이 어떤 필드에 대응되는지 알기 어려워지고, 선택적 필드가 있는 경우 생성자의 오버로딩이 난무하게 됩니다. 이런 경우에 Builder 패턴을 사용하면 가독성과 유연성을 확보할 수 있습니다.
이를 해소하기 위해,
// Product
public class User {
private final String firstName;
private final String lastName;
private final int age;
private final String phone;
private final String email;
private User(Builder builder) {
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.age = builder.age;
this.phone = builder.phone;
this.email = builder.email;
}
public static class Builder {
private String firstName;
private String lastName;
private int age;
private String phone;
private String email;
public Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder phone(String phone) {
this.phone = phone;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public User build() {
// 필수 필드 null 체크
if (firstName == null || firstName.isEmpty()) {
throw new IllegalArgumentException("firstName is required.");
}
if (lastName == null || lastName.isEmpty()) {
throw new IllegalArgumentException("lastName is required.");
}
if (email == null || email.isEmpty()) {
throw new IllegalArgumentException("email is required.");
}
return new User(this);
}
}
}
클래스를 정의할 때,
위와 같이 클래스 생성에 필요한 파라미터를 대신 받는 빌더 클래스를 작성하여,
// Client
public class Main {
public static void main(String[] args) {
User user = new User.Builder()
.firstName("John")
.lastName("Doe")
.age(30)
.phone("123-456-7890")
.email("john.doe@example.com")
.build();
System.out.println("User created: " + user);
}
}
위와 같이 Builder 객체의 build 메소드로 생성된 객체를 받는 것이 빌더 패턴의 형태입니다. (참고로, Builder 함수에서 각 요소를 세팅하는 함수가 모두 동일한 Builder 를 반환하기에 함수 실행 바로 뒤에 다른 함수를 실행할 수 있으므로, 이는 메소드 체이닝이라라는 패턴입니다.)
위에서는 build 시점에 null 체크를 하는 처리를 했는데, 이와 같이 입력된 값을 검증하는 로직을 build 에서 처리를 해도 되고,
차라리 Builder 객체 생성 시점에 각 파라미터의 '기본값'을 설정하여 수정할 일부 값만 수정하도록 하는 식으로 처리하여 객체 생성 시점의 수고를 덜 수도 있습니다.
4. Prototype
동일한 타입의 객체를 생성해야 할 때 필요한 비용을 줄이기 위한 패턴입니다.
새로운 객체를 생성하는 것이 아닌 기존의 객체를 복사하여 특정 속성값을 변경합니다.
예를 들어 설명하겠습니다.
public class Document implements Cloneable {
private String header;
private String footer;
private String watermark;
public void setHeader(String header) {
this.header = header;
}
public void setFooter(String footer) {
this.footer = footer;
}
public void setWatermark(String watermark) {
this.watermark = watermark;
}
@Override
public Document clone() {
try {
return (Document) super.clone(); // 얕은 복사
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // 이 클래스는 Cloneable이므로 예외가 발생하면 안 됨
}
}
public void print() {
System.out.println("Header: " + header);
System.out.println("Footer: " + footer);
System.out.println("Watermark: " + watermark);
}
}
위와 같은 클래스가 있습니다.(편의를 위해 파라미터가 3개인데, 사실 매우 많고 복잡하다고 상상해봅시다.)
이 클래스는 보시는 바와 같이 clone 함수를 만들었기에, 객체화 한 이후 멤버변수의 값을 그대로 복제한 새로운 객체를 만들 수 있습니다.
public class Main {
public static void main(String[] args) {
Document doc1 = new Document();
doc1.setHeader("Top Secret");
doc1.setFooter("Company Use Only");
doc1.setWatermark("Draft");
Document doc2 = doc1.clone(); // 복제
doc2.setWatermark("Final");
doc1.print();
doc2.print();
}
}
사용 위치에서는,
위와 같이 doc1 을 수고를 들여 객체화 했는데,
위와 같이 다른 파라미터는 동일한데, 파라미터 하나나 극히 일부만 다른 객체를 생성해낼 때에는 모든 파라미터를 충족시켜 다시 생성하는 것은 낭비이며, 생성한 첫 객체를 복제하여 일부 파라미터만 재할당 하는 것이 더 경제적일 것입니다.
즉, 프로토타입 객체를 하나 만들어서 이를 복제하여 일부를 수정하는 방식으로 객체를 만드는 이러한 방식을 프로토타입 패턴이라고 부릅니다.
5. Singleton
클래스가 오직 하나의 인스턴스만을 가지도록 하는 패턴입니다.
접근제한자와 정적 변수를 활용하며 다수의 인스턴스로 인한 문제(성능저하)를 방지할 수 있습니다.
요약하자면, 한 클래스를 가지고 여러 객체를 만들고 싶어도 못 만들게 제한한 후, 한번 만들어진 객체를 재활용 하는 방식입니다.
실무에서는, 생성 자체가 무겁고, 생성 후 객체 유지시 비용이 많이 요구되거나, 한 프로세스 내에서 단 하나의 객체만으로 충분한 네트워크나 데이터베이스 접속 객체, 로그 객체 등에 적용됩니다.
예를 들어 설명하겠습니다.
public class Singleton {
private static Singleton instance;
private Singleton() {
// private 생성자
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 최초 1회만 생성
}
return instance;
}
public void doSomething() {
System.out.println("싱글톤 인스턴스 동작");
}
}
위와 같이 클래스를 구성할 때,
모든 생성자를 private 로 두면, 외부에서는 생성자로 객체를 생성할 수 없게 됩니다.
대신, 객체를 생성하고 반환하는 getInstance 함수를 외부로 제공함으로써 객체를 가져올 때 무조건 이 함수를 통하게 하고,
getInstance 시에는 처음 함수가 호출된 딱 1번만 생성자를 통해 객체가 생성되게 하며, 다음부터는 이미 생성되어 메모리상 잔류하는 객체를 반환하도록 처리하는 구조입니다.
위에서 보는 구조를 보시면 이해하시듯,
객체가 static 메모리 공간에 잔류하게 되어, 프로세스가 종료되기 전까지 메모리 공간을 차지하게 됩니다.
static 변수는 신중하게 사용하는 것이므로, 싱글톤 패턴 역시 신중하게 도입하셔야 합니다.
(구조(Structural) 패턴)
1. Adaptor
서로 다른 인터페이스를 가진 클래스들을 함께 사용할 수 있도록 하는 패턴입니다.
클래스의 인터페이스를 다른 인터페이스로 변환하여 함께 작동하도록 해줍니다.
예를 들어 설명하겠습니다.
소프트웨어를 개발하다 보면, 기존에 이미 구현된 클래스를 새로운 인터페이스로 맞추어 사용해야 하는 상황이 자주 발생합니다.
기존 시스템에서 제공하는 라이브러리는 print(String message)를 사용하는데,
public class OldPrinter {
public void print(String message) {
System.out.println("OldPrinter: " + message);
}
}
새로 설계된 시스템은 display(String text)를 사용하는 경우를 가정하겠습니다.
public interface NewPrinter {
void display(String text);
}
이런 경우 기존 클래스를 변경하지 않고, 새로운 인터페이스에 맞추어 동작하게 하려면 기존 클래스를 감싸고, 새로운 기능을 추가해주는 역할을 하는 어댑터가 필요합니다.
public class PrinterAdapter implements NewPrinter {
private OldPrinter oldPrinter;
public PrinterAdapter(OldPrinter oldPrinter) {
this.oldPrinter = oldPrinter;
}
@Override
public void display(String text) {
oldPrinter.print(text); // 내부적으로 기존 메서드 호출
}
}
위와 같이 기존의 OldPrinter 를 내부에서 그대로 이용하며,
새로운 인터페이스를 구현하는 방식으로 기존 클래스의 기능을 그대로 내부적으로 가져감과 동시에 새로운 기능을 확장시킬 수 있습니다.
public class Main {
public static void main(String[] args) {
OldPrinter legacyPrinter = new OldPrinter();
NewPrinter printer = new PrinterAdapter(legacyPrinter);
printer.display("Hello, Adapter Pattern!");
}
}
사용시에는 위와 같이 사용할 수 있습니다.
2. Bridge
복잡하게 설계된 클래스를 기능부와 구현부로 분리한 뒤, 두 클래스를 연결하는 패턴입니다.
기능과 구현을 분리하면 결합도는 낮아지고, 각 클래스를 독립적으로 변경, 확장 할 수 있게됩니다.
필요에 따라서 클래스 간의 관계 변경이 필요할 때는 견고한 연결인 상속이 아닌 느슨한 연결인 브릿지를 적용합니다.
예를 들어 설명하겠습니다.
리모컨이 있을 때, 이 리모컨의 기능이 여러 구현체 디바이스에 적용할 수 있다고 가정하겠습니다.
리모컨이 TV 에 사용되었을 때, 리모컨이 라이오에 적용되었을 때, 리모컨의 인터페이스는 같지만, 적용한 디바이스별로 다른 작업을 수행하며, 이렇듯 기능은 기능부인 리모컨에서 설정하였지만, 그에 따른 실질적인 동작의 구현은 구현부에서 작성하도록 할 수 있습니다.
// 구현부 인터페이스
interface Device {
void turnOn();
void turnOff();
void setChannel(int channel);
}
// 기능부
class RemoteControl {
protected Device device;
public RemoteControl(Device device) {
this.device = device;
}
public void turnOnDevice() {
device.turnOn();
}
public void turnOffDevice() {
device.turnOff();
}
public void changeChannel(int channel) {
device.setChannel(channel);
}
}
위와 같이 구현부의 인터페이스와 기능부가 있을 때,
구현부 측에서,
// 구현부 1: TV
class TV implements Device {
private int channel = 1;
public void turnOn() {
System.out.println("TV 켬");
}
public void turnOff() {
System.out.println("TV 끔");
}
public void setChannel(int channel) {
this.channel = channel;
System.out.println("TV 채널: " + channel);
}
}
// 구현부 2: Radio
class Radio implements Device {
private int channel = 1;
public void turnOn() {
System.out.println("Radio 켬");
}
public void turnOff() {
System.out.println("Radio 끔");
}
public void setChannel(int channel) {
this.channel = channel;
System.out.println("Radio 채널: " + channel);
}
}
이렇게 각 인터페이스 메소드에 따른 함수만 구현을 해준다면,
// 테스트
public class BridgePatternSingleFunctionExample {
public static void main(String[] args) {
Device tv = new TV();
Device radio = new Radio();
RemoteControl remoteForTV = new RemoteControl(tv);
RemoteControl remoteForRadio = new RemoteControl(radio);
System.out.println("=== TV 리모컨 ===");
remoteForTV.turnOnDevice();
remoteForTV.changeChannel(3);
remoteForTV.turnOffDevice();
System.out.println("\n=== Radio 리모컨 ===");
remoteForRadio.turnOnDevice();
remoteForRadio.changeChannel(7);
remoteForRadio.turnOffDevice();
}
}
위와 같이 사용시점에 구현부를 다르게 주어 다른 기능을 수행하도록 할 수 있습니다.
이러한 구조를 사용함으로써 코드의 확장성이 보장되며, 개발자는 구현부의 생성과 유지보수에만 신경쓸 수 있게 됩니다.
3. Composite
객체들의 관계를 트리 구조로 구성하여 단일 객체와 복합 객체를 동일하게 다루도록 하는 패턴입니다.
다수의 클래스를 하나의 클래스로 취급할 수 있습니다.
예를 들어 설명하겠습니다.
그래픽 에디터를 만든다고 가정해 보겠습니다.
원(Circle), 사각형(Rectangle) 등은 단일 객체 (Leaf),
이들을 그룹화한 도형(Group)은 복합 객체 (Composite)입니다.
단일 객체와 복합 객체는 별개의 객체가 아니라,
복합 객체는 여러개의 단일 객체와 복합객체들의 모음으로 만들어지는 것입니다.
이를 트리구조로 나타내자면,
Graphic (Component)
/ \
Leaf Composite
(Circle, Rectangle) (Group)
위와 같이 단일 객체와 복합 객체 모두 Graphic 이라는 상위 분류 하에 존재하는 것입니다.
// 공통 인터페이스 (Component)
interface Graphic {
void draw();
}
// Leaf 클래스 1: 원
class Circle implements Graphic {
private String name;
public Circle(String name) {
this.name = name;
}
@Override
public void draw() {
System.out.println("원을 그림: " + name);
}
}
// Leaf 클래스 2: 사각형
class Rectangle implements Graphic {
private String name;
public Rectangle(String name) {
this.name = name;
}
@Override
public void draw() {
System.out.println("사각형을 그림: " + name);
}
}
// Composite 클래스: 그룹
class Group implements Graphic {
private String name;
private List<Graphic> children = new ArrayList<>();
public Group(String name) {
this.name = name;
}
public void add(Graphic graphic) {
children.add(graphic);
}
public void remove(Graphic graphic) {
children.remove(graphic);
}
@Override
public void draw() {
System.out.println("그룹 [" + name + "] 을 그림:");
for (Graphic child : children) {
child.draw(); // 재귀 호출로 트리 구조 처리
}
}
}
위 코드와 같이,
모든 객체는 Graphic 인터페이스를 상속받아 트리구조의 상하구조를 구현하였으며,
복합객체는 이러한 Graphic 계열의 객체들을 내부적으로 가지고 활용함으로써 객체간 구조를 논리적으로 나타낼 수 있게 되었습니다.
4. Decorator
클래스 변경 없이 주어진 상황에 따라 기능을 추가하는 패턴입니다.
기존 클래스의 메소드에 새로운 기능을 추가하거나 확장할 수 있습니다.
예를 들어 설명하겠습니다.
커피 주문 시스템을 생각해봅시다.
기본 커피가 있고, 여기에 우유, 시럽, 휘핑크림 등을 동적으로 조합해서 추가한다고 합시다.
기본 커피에 대한 것은 전혀 변경할 필요가 없이, 이에 추가 요소를 투입하는 개념입니다.
interface Coffee {
String getDescription();
int cost();
}
위와 같은 커피 인터페이스가 있을 때,
class BasicCoffee implements Coffee {
@Override
public String getDescription() {
return "기본 커피";
}
@Override
public int cost() {
return 2000;
}
}
기본 커피가 위와 같이 존재합니다.
이에 우유를 넣어 밀크 커피를 만들려면,
class MilkCoffee extends Coffee {
public MilkCoffee(Coffee coffee) {
super(coffee);
}
@Override
public String getDescription() {
return coffee.getDescription() + ", 우유";
}
@Override
public int cost() {
return coffee.cost() + 500;
}
}
이러한 방식으로, 기본 커피를 받아와 이를 활용하면서 새로운 커피로서 동작하도록 추가 요소를 작성하는 것입니다.
기본 커피라는 요소 외부적으로 꾸며서 밀크 커피라는 기능을 하도록 처리한 것이므로 데코레이터라는 이름에 어울리는 패턴입니다.
5. Facade
복잡한 서브 시스템들을 간편하게 사용할 수 있도록 단순화된 인터페이스를 제공하는 패턴입니다.
다수의 하위 클래스들이 올바른 결합도를 갖도록 하여 의존 관계를 줄이고 복잡성을 낮출 수 있습니다.
예를 들어 설명하겠습니다.
홈시어터 시스템을 생각해보았을 때,
DVD 플레이어, 프로젝터, 앰프, 조명 등 여러 구성요소가 있으며, 그 모든 기능에 대한 조작 방식이 너무 복잡합니다.
사용자의 입장에서는 단순히 "영화 시작" 버튼 하나로 모든 장비가 정상 방식대로 작동되기를 원합니다.
// 서브시스템 1
class DVDPlayer {
public void on() { System.out.println("DVD 플레이어 켬"); }
public void play(String movie) { System.out.println("영화 \"" + movie + "\" 재생 시작"); }
public void off() { System.out.println("DVD 플레이어 끔"); }
}
// 서브시스템 2
class Projector {
public void on() { System.out.println("프로젝터 켬"); }
public void wideScreenMode() { System.out.println("와이드 스크린 모드 설정"); }
public void off() { System.out.println("프로젝터 끔"); }
}
// 서브시스템 3
class Light {
public void dim(int level) { System.out.println("조명을 " + level + "%로 낮춤"); }
public void on() { System.out.println("조명 켬"); }
}
// 서브시스템 4
class Amplifier {
public void on() { System.out.println("앰프 켬"); }
public void setVolume(int level) { System.out.println("볼륨을 " + level + "으로 설정"); }
public void off() { System.out.println("앰프 끔"); }
}
이러한 각각의 서브 시스템들이 존재하고,
이에대한 기능들을 통합하여 하나의 목적을 이루는 것이 너무 복잡할 경우,
class HomeTheaterFacade {
private DVDPlayer dvd;
private Projector projector;
private Light light;
private Amplifier amp;
public HomeTheaterFacade(DVDPlayer dvd, Projector projector, Light light, Amplifier amp) {
this.dvd = dvd;
this.projector = projector;
this.light = light;
this.amp = amp;
}
public void watchMovie(String movie) {
System.out.println("=== 영화 보기 준비 중 ===");
light.dim(10);
projector.on();
projector.wideScreenMode();
amp.on();
amp.setVolume(5);
dvd.on();
dvd.play(movie);
System.out.println("=== 즐거운 감상 되세요! ===");
}
public void endMovie() {
System.out.println("=== 영화 종료 ===");
dvd.off();
amp.off();
projector.off();
light.on();
}
}
위와 같이 특정 목적을 위해 각 요소들을 통합하고 관리하는 Facade 클래스를 만들면,
영화를 본다, 영화를 종료한다라는 간단한 기능을 수행하기 위한 요소들의 복잡한 조합이 단순화 됩니다.
개인적 경험상 Camera 조작 라이브러리를 사용할 때에, 너무 복잡하게 제공되는 기능들의 모음을, 카메라 켜기, 촬영하기, 분석하기 등의 기능으로 단순화 했던 기억이 있습니다. (비동기 처리, 성능 처리 등의 복잡한 처리를 다 해야 했기에 단순한 함수 하나를 구현하는데도 코드량이 엄청났습니다.)
6. Flyweight
메모리 사용량을 최소화하기 위해 객체들 간 데이터 공유를 극대화하는 패턴입니다.
사용빈도가 높을 것으로 예상되는 데이터를 중복 생성하지 않도록 외부 자료 구조에 저장하여 활용할 수 있도록 합니다.
예를 들어 설명하자면,
게임에서 숲에 수천 개의 나무 객체를 배치해야 한다고 가정합시다.
각각의 나무는 종류, 색상, 텍스처 같은 변하지 않는 공통 속성을 가집니다.
결국 바뀌는 정보는 위치(x, y) 정보 뿐이죠.
공통적으로 사용되는 데이터 수천개를 전부 생성하여 메모리에 올려두는 것은 무척이나 비효율적인 일이므로, 공통 속성값은 한번만 메모리에 올려 재활용하고, 위치값만 따로 올리는 방식으로 데이터 공유 및 메모리 사용량을 최소화 할 수 있습니다.
class TreeType {
private final String name;
private final String color;
private final String texture;
public TreeType(String name, String color, String texture) {
this.name = name;
this.color = color;
this.texture = texture;
}
public void draw(int x, int y) {
System.out.println("Drawing tree '" + name + "' of color " + color +
" at (" + x + ", " + y + ")");
}
}
위와 같은 나무 공통 데이터 클래스를 따로 두고,
class Tree {
private final int x;
private final int y;
private final TreeType type;
public Tree(int x, int y, TreeType type) {
this.x = x;
this.y = y;
this.type = type;
}
public void draw() {
type.draw(x, y);
}
}
이렇게 공통 속성을 외부에서 받아서 위치 데이터만 수정하는 방식으로 객체를 생성한다면, 공통 속성을 재활용 할 수 있습니다.
7. Proxy
특정 객체로의 접근을 해당 객체의 대리자를 통해 진행하는 패턴입니다.
대리자를 통해 접근함으로써 원본 객체의 생성 연기, 원격 제어, 접근 제어 등을 결정할 수 있습니다.
예를 들어 설명하자면,
인터넷 접속시 직접 어느 위치든 접근하는 것이 아니라, 인터넷 객체에 접근하기 위해서는 중간 경로를 거쳐서 검증을 받고 접속하도록 구성한다면,
// Subject: 공통 인터페이스
interface Internet {
void connectTo(String site);
}
// RealSubject: 실제 인터넷 사용 클래스
class RealInternet implements Internet {
@Override
public void connectTo(String site) {
System.out.println("Connected to " + site);
}
}
// Proxy: 접근 제한을 걸 수 있는 대리자
class ProxyInternet implements Internet {
private final RealInternet realInternet = new RealInternet();
private static final List<String> bannedSites = List.of("facebook.com", "instagram.com");
@Override
public void connectTo(String site) {
if (bannedSites.contains(site.toLowerCase())) {
System.out.println("Access Denied to " + site);
} else {
realInternet.connectTo(site);
}
}
}
위와 같이 대리자 패턴의 클래스를 이용하여,
RealInternet 을 사용하기 전에 접근 금지 사이트 여부를 파악한 뒤에 접근되도록 처리하여,
사용자와 기능 제공 객체간의 직접 연결 사이에서 제약 등의 처리를 걸 수 있습니다.
(행위(Behavioral) 패턴)
1. Interpreter
언어의 문법을 해석하는 방법을 규정하는 패턴입니다.
다양한 매개변수를 이용하여 여러가지 명령을 처리할 수 있습니다.
예를 들어서 설명하겠습니다.
간단히 문자열 명령어로 로봇을 움직이는 로봇 명령어 해석기를 만든다고 하겠습니다.
"앞으로 왼쪽 앞으로 오른쪽"
위와 같은 명령어가 있을 때,
// 1. 로봇 클래스 (실제 동작 주체)
class Robot {
public void moveForward() {
System.out.println("로봇이 앞으로 이동합니다.");
}
public void turnLeft() {
System.out.println("로봇이 왼쪽으로 돕니다.");
}
public void turnRight() {
System.out.println("로봇이 오른쪽으로 돕니다.");
}
}
위와 같은 로봇이 존재한다고 합시다.
이제 남은 것은, 외부에서 내려주는 명령어를 해석하고 로봇을 움직이는 코드인데,
class SimpleRobotInterpreter {
private final Robot robot;
public SimpleRobotInterpreter(Robot robot) {
this.robot = robot;
}
public void interpret(String commandLine) {
String[] words = commandLine.split(" ");
for (String word : words) {
switch (word) {
case "앞으로":
robot.moveForward();
break;
case "왼쪽":
robot.turnLeft();
break;
case "오른쪽":
robot.turnRight();
break;
default:
System.out.println("알 수 없는 명령어: " + word);
}
}
}
}
이렇게 commandLine 문자열을 입력하면,
이를 해석해서 로봇에게 명령을 내리는 역할을 인터프리터 객체가 수행합니다.
즉, 인터프리터는 명령을 해석해서 적절한 기능을 수행하는 객체를 의미합니다.
2. Template Method
상위 클래스에서는 알고리즘의 뼈대를 정의하고 구체적인 단계는 하위 클래스에서 정의하는 패턴입니다.
알고리즘의 구조를 변경하지 않고 알고리즘의 특정 단계들을 재정의 할 수 있습니다.
예를들어 설명하자면,
커피와 차는 만드는 과정(알고리즘의 흐름)은 비슷하지만, 구체적인 내용(재료나 끓이는 방식 등)은 조금씩 다릅니다.
이런 경우 공통 부분을 상위 클래스로 빼고, 이를 상속받아 하위 클래스에서 구체화 하는 방식으로 알고리즘 구조는 변함 없이 일관성을 유지시킬 수 있습니다.
abstract class Beverage {
// 템플릿 메서드 (순서를 고정)
public final void prepareRecipe() {
boilWater();
brew();
pourInCup();
addCondiments();
}
private void boilWater() {
System.out.println("물을 끓입니다.");
}
private void pourInCup() {
System.out.println("컵에 따릅니다.");
}
// 추상 메서드: 하위 클래스에서 구현
protected abstract void brew();
protected abstract void addCondiments();
}
위와 같이 우려내는 음료에 대한 상위 클래스를 정의하고,
class Coffee extends Beverage {
@Override
protected void brew() {
System.out.println("커피를 내립니다.");
}
@Override
protected void addCondiments() {
System.out.println("설탕과 우유를 추가합니다.");
}
}
class Tea extends Beverage {
@Override
protected void brew() {
System.out.println("차 잎을 우려냅니다.");
}
@Override
protected void addCondiments() {
System.out.println("레몬을 추가합니다.");
}
}
상위 클래스가 정한 틀(템플릿)을 벗어나지 않는 다양한 하위 클래스를 만들어낼 수 있습니다.
3. Chain of Responsibility
문제의 해결을 위한 일련의 처리 객체가 순서대로 문제를 해결하는 패턴입니다.
각각의 처리 객체는 문제의 일정 부분을 처리할 수 있는 연산의 집합이고, 처리 객체에 의해 일부분이 해결된 문제는 다음 처리 객체로 넘겨져 계속 처리됩니다.
간단한 예시를 보겠습니다.
// 추상 핸들러 정의
abstract class Handler {
protected Handler nextHandler;
public void setNextHandler(Handler nextHandler) {
this.nextHandler = nextHandler;
}
public void handleRequest(String request) {
if (nextHandler != null) {
nextHandler.handleRequest(request);
}
}
}
// 구체적 핸들러1: 요청에 "A"가 포함되면 처리
class ConcreteHandlerA extends Handler {
@Override
public void handleRequest(String request) {
if (request.contains("A")) {
System.out.println("ConcreteHandlerA 처리: " + request);
} else {
super.handleRequest(request);
}
}
}
// 구체적 핸들러2: 요청에 "B"가 포함되면 처리
class ConcreteHandlerB extends Handler {
@Override
public void handleRequest(String request) {
if (request.contains("B")) {
System.out.println("ConcreteHandlerB 처리: " + request);
} else {
super.handleRequest(request);
}
}
}
// 구체적 핸들러3: 요청에 "C"가 포함되면 처리
class ConcreteHandlerC extends Handler {
@Override
public void handleRequest(String request) {
if (request.contains("C")) {
System.out.println("ConcreteHandlerC 처리: " + request);
} else {
super.handleRequest(request);
}
}
}
// 클라이언트
public class ChainOfResponsibilityExample {
public static void main(String[] args) {
// 핸들러 체인 생성
Handler handlerA = new ConcreteHandlerA();
Handler handlerB = new ConcreteHandlerB();
Handler handlerC = new ConcreteHandlerC();
handlerA.setNextHandler(handlerB);
handlerB.setNextHandler(handlerC);
// 요청 테스트
handlerA.handleRequest("Request with A"); // ConcreteHandlerA 처리
handlerA.handleRequest("Request with B"); // ConcreteHandlerB 처리
handlerA.handleRequest("Request with C"); // ConcreteHandlerC 처리
handlerA.handleRequest("Request with D"); // 아무도 처리하지 않음
}
}
위와 같이 작업을 처리하는 Handler 객체가 존재할 때,
핸들러에 요청이 들어오면 핸들러의 등록 순서에 따라 작업이 수행되는 것입니다.
중요한 것은, 일련의 처리에서 각 세부 처리를 담당할 객체를 따로 두어 책임 범위를 제한하고 순서대로 작업을 전파하는 것입니다.
4. Command
요청을 객체의 형태로 캡슐화하여 나중에 이용할 수 있도록 요청에 필요한 정보를 저장하는 패턴입니다.
메소드 이름, 매개변수 등의 정보를 저장하여 복구, 취소 등이 가능합니다.
예를 들어서 설명하겠습니다.
그림판에는 이전 명령어를 Undo 하는 기능과, Undo 된 상태에서 다시 실행 상태로 복원하는 Redo 하는 기능이 있죠?
class PaintApp {
public void drawLine(String start, String end) {
System.out.println("선 그리기: " + start + " -> " + end);
}
public void eraseLine(String start, String end) {
System.out.println("선 지우기: " + start + " -> " + end);
}
}
이러한 그림판 객체가 있다고 할 때,
이 객체에 직접 명령을 내리는 것이 아니라,
interface Command {
void execute();
void undo();
}
// ConcreteCommand - 선 그리기 명령
class DrawLineCommand implements Command {
private PaintApp paintApp;
private String startPoint;
private String endPoint;
public DrawLineCommand(PaintApp paintApp, String start, String end) {
this.paintApp = paintApp;
this.startPoint = start;
this.endPoint = end;
}
@Override
public void execute() {
paintApp.drawLine(startPoint, endPoint);
}
@Override
public void undo() {
paintApp.eraseLine(startPoint, endPoint);
}
}
// ConcreteCommand - 지우기 명령 (예: 선 지우기)
class EraseCommand implements Command {
private PaintApp paintApp;
private String startPoint;
private String endPoint;
public EraseCommand(PaintApp paintApp, String start, String end) {
this.paintApp = paintApp;
this.startPoint = start;
this.endPoint = end;
}
@Override
public void execute() {
paintApp.eraseLine(startPoint, endPoint);
}
@Override
public void undo() {
paintApp.drawLine(startPoint, endPoint);
}
}
이렇게 명령어를 캡슐화 하고,
class CommandManager {
private Stack<Command> undoStack = new Stack<>();
private Stack<Command> redoStack = new Stack<>();
public void executeCommand(Command command) {
command.execute();
undoStack.push(command);
redoStack.clear();
}
public void undo() {
if (!undoStack.isEmpty()) {
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
} else {
System.out.println("Undo 할 명령이 없습니다.");
}
}
public void redo() {
if (!redoStack.isEmpty()) {
Command command = redoStack.pop();
command.execute();
undoStack.push(command);
} else {
System.out.println("Redo 할 명령이 없습니다.");
}
}
}
유저에게 명령을 받아서 객체에게 전달하는 중간 객체를 사용하면,
// 사용 예시
public class PaintCommandExample {
public static void main(String[] args) {
PaintApp paintApp = new PaintApp();
CommandManager manager = new CommandManager();
// 선 그리기
Command drawLine = new DrawLineCommand(paintApp, "점A", "점B");
manager.executeCommand(drawLine);
// 선 지우기
Command eraseLine = new EraseCommand(paintApp, "점A", "점B");
manager.executeCommand(eraseLine);
System.out.println("--- Undo 2회 ---");
manager.undo(); // 선 지우기 취소 -> 선 다시 그림
manager.undo(); // 선 그리기 취소 -> 선 지우기
System.out.println("--- Redo 1회 ---");
manager.redo(); // 선 그리기 다시 실행
}
}
위와 같이 깔끔한 방법으로 명령을 취소하거나 다시 실행하게 할 수 있습니다.
이 패턴에서중요한 점은, 객체에서 명령을 직접 받는 것이 아니라 모든 명령들을 동일 인터페이스로 캡슐화하고,
이 명령을 받아들이는 객체를 따로 두어 명령 정보를 관리하고 활용하는 것입니다.
5. Iterator
내부 구현을 노출시키지 않고 집합 객체에 접근하고 싶은 경우에 적용하는 패턴입니다.
집합 객체에 대해 다양한 탐색 경로를 사용할 수 있고 서로 다른 집합 객체 구조에 대해서도 동일한 방법으로 접근할 수 있습니다.
예를 들어 설명하겠습니다.
Iterator 의 핵심은, 내부의 배열, 리스트 등의 순회 가능한 데이터를 순회하는 수단을 간접적으로 제공해주는 것으로,
반복문을 이용한 직접 순회의 경우,
// 배열 기반 컬렉션
class ArrayCollection {
private String[] items = {"A", "B", "C"};
public String[] getItems() {
return items;
}
}
// 링크드 리스트 기반 컬렉션 (단순 구현)
class LinkedListCollection {
private Node head;
private static class Node {
String data;
Node next;
Node(String d) { data = d; }
}
public LinkedListCollection() {
head = new Node("X");
head.next = new Node("Y");
head.next.next = new Node("Z");
}
public Node getHead() {
return head;
}
}
public class Main {
public static void main(String[] args) {
ArrayCollection arrayCol = new ArrayCollection();
LinkedListCollection linkedCol = new LinkedListCollection();
// 배열 기반은 for문
for (int i = 0; i < arrayCol.getItems().length; i++) {
System.out.println(arrayCol.getItems()[i]);
}
// 링크드 리스트는 while문
LinkedListCollection.Node node = linkedCol.getHead();
while (node != null) {
System.out.println(node.data);
node = node.next;
}
}
}
위와 같이 자료구조에 따라 순회하는 방법이 달라서 사용하는 쪽에서 모든 자료구조를 파악해서 그에 특화된 처리 방법을 사용해야 합니다.
하지만 Iterator 패턴을 사용한다면,
interface Iterator<T> {
boolean hasNext();
T next();
}
interface Aggregate<T> {
Iterator<T> iterator();
}
// 배열 기반 컬렉션
class ArrayCollection implements Aggregate<String> {
private String[] items = {"A", "B", "C"};
@Override
public Iterator<String> iterator() {
return new Iterator<>() {
int index = 0;
public boolean hasNext() {
return index < items.length;
}
public String next() {
return items[index++];
}
};
}
}
// 링크드 리스트 기반 컬렉션
class LinkedListCollection implements Aggregate<String> {
private Node head;
private static class Node {
String data;
Node next;
Node(String d) { data = d; }
}
public LinkedListCollection() {
head = new Node("X");
head.next = new Node("Y");
head.next.next = new Node("Z");
}
@Override
public Iterator<String> iterator() {
return new Iterator<>() {
Node current = head;
public boolean hasNext() {
return current != null;
}
public String next() {
String data = current.data;
current = current.next;
return data;
}
};
}
}
public class Main {
public static void printAll(Aggregate<String> collection) {
Iterator<String> it = collection.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
public static void main(String[] args) {
ArrayCollection arrayCol = new ArrayCollection();
LinkedListCollection linkedCol = new LinkedListCollection();
printAll(arrayCol); // 동일한 코드로 출력
printAll(linkedCol); // 동일한 코드로 출력
}
}
위와 같이 서로 다른 자료구조 변수를 순회하더라도, 사용 위치에서는 동일한 방식으로 순회가 가능하여 변수의 종류에 대한 코드 의존성이 사라져서 느슨한 결합을 만들 수 있습니다.
6. Mediator
객체 간의 통신이 직접 이루어지지 않고 중재자를 통해 진행되어 결합도를 감소시키는 패턴입니다.
복잡한 상호작용 관계를 단순화시킬 수 있어 객체 간 통신 복잡성을 줄일 수 있습니다.
예를 들어 채팅 시스템을 만든다고 합시다.
서로 통신하는 두개 이상의 User 객체가 있을 때, 이것들이 서로 직접적으로 연결되어 상대를 조작하면 결합성이 강해져서 확장성이 떨어지고 구조의 복잡도가 높아집니다.
class ChatRoom {
private final List<User> users = new ArrayList<>();
public void sendMessage(String message, User sender) {
for (User user : users) {
if (user != sender) {
user.receive(message);
}
}
}
public void addUser(User user) {
users.add(user);
}
}
위와 같이 채팅방, 즉 객체간 통신을 중재해주는 객체를두고,
class User {
public User(ChatRoom mediator, String name) {
super(mediator, name);
}
public void send(String message) {
System.out.println(name + " sends: " + message);
mediator.sendMessage(message, this);
}
public void receive(String message) {
System.out.println(name + " receives: " + message);
}
}
위와 같이 User 가 다른 객체에 요청을 보낼 때는 mediator 를 통하도록 하면 사용자들끼리 직접적으로 연결되지 않습니다.
위에서는 간단히 객체간 통신을 설명하기 위해 채팅방을 예시로 들었는데,
객체간 상호작용이 일어나는 모든 유형에서 객체간 상호작용을 중재하는 객체를 만들어 사용한다면 이를 Mediator 패턴이라 할 수 있습니다.
7. Memento
롤백을 통해 객체의 상태를 이전 상태로 되돌릴 수 있는 기능을 제공하는 패턴입니다.
객체의 캡슐화가 유지되는 상태에서 객체 내부 상태를 외부에 저장하여 복구가 가능하도록 합니다.
예를 들자면,
Command 패턴에서 한 것처럼 Undo 기능을 텍스트 에디터 예제로 구현해보겠습니다.
// 1. Memento: 저장할 상태를 표현하는 클래스 (캡슐화 유지)
class TextEditorMemento {
private final String content;
public TextEditorMemento(String content) {
this.content = content;
}
public String getContent() {
return content;
}
}
// 2. Originator: 상태를 저장하거나 복구할 수 있는 클래스
class TextEditor {
private String content = "";
public void write(String text) {
content += text;
}
public String getContent() {
return content;
}
public TextEditorMemento save() {
return new TextEditorMemento(content);
}
public void restore(TextEditorMemento memento) {
content = memento.getContent();
}
}
// 3. Caretaker: Memento를 저장하고 관리하는 클래스
class History {
private final List<TextEditorMemento> mementos = new ArrayList<>();
public void save(TextEditorMemento memento) {
mementos.add(memento);
}
public TextEditorMemento undo() {
if (mementos.isEmpty()) return new TextEditorMemento("");
mementos.remove(mementos.size() - 1); // 현재 상태는 제거
return mementos.isEmpty() ? new TextEditorMemento("") : mementos.get(mementos.size() - 1);
}
}
// 4. 사용 예제
public class Main {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
History history = new History();
editor.write("Hello");
history.save(editor.save()); // 상태 저장
editor.write(" World!");
history.save(editor.save()); // 상태 저장
System.out.println("Current: " + editor.getContent()); // Hello World!
editor.restore(history.undo());
System.out.println("Undo 1: " + editor.getContent()); // Hello
editor.restore(history.undo());
System.out.println("Undo 2: " + editor.getContent()); // (빈 문자열)
}
}
위와 같이,
TextEditor 라는 상태값을 지니는 객체가 있을 때, 이 상태값을 객체 내부에만 저장하는 것이 아니라,
외부의 History 객체의 Memento 객체로 저장하는 것입니다.
이로인하여 객체의 이전 상태값을 외부에 저장함으로써 이전 상태를 백업하고 복구할 수 있게 됩니다.
Command 패턴과 비슷하지만, Command 패턴이 객체에 대한 모든 요청을 객체화하여 다루는 것에 초점을 두어, 결과적으로 Undo, Redo 를 할 수 있는 것이라면, Memento 패턴은 '이전 상태'를 외부에 저장하여 복구가 가능하게 하는 것이 중점입니다.
8. Observer
객체의 상태 변화를 관찰하는 옵저버를 등록하여 상태 변화가 있을 때마다 등록된 옵저버에게 통지하는 패턴입니다.
특정 객체에 변화가 생겼을 때, 옵저버는 다른 객체에 의존하지 않고 다른 객체에 통보해 줄 수 있습니다.
예를 들어 설명하겠습니다.
객체의 상태 변화의 관찰하는 주체를
class CurrentConditionsDisplay {
private float temperature;
private float humidity;
public void update(float temperature, float humidity, float pressure) {
this.temperature = temperature;
this.humidity = humidity;
display();
}
public void display() {
System.out.println("현재 상태: 온도 = " + temperature + "도, 습도 = " + humidity + "%");
}
}
이러한 옵저버 클래스로 만들었습니다.
위에서 보듯 이 옵저버는 온도, 습도, 기압의 변화를 관찰하고 그에따른 기능을 수행합니다.
관찰의 대상이 되는 Subject 클래스는,
class WeatherStation {
private List<CurrentConditionsDisplay> observers = new ArrayList<>();
private float temperature;
private float humidity;
private float pressure;
public void setMeasurements(float temperature, float humidity, float pressure) {
this.temperature = temperature;
this.humidity = humidity;
this.pressure = pressure;
notifyObservers(); // 상태 변경 시 옵저버에게 알림
}
public void registerObserver(CurrentConditionsDisplay o) {
observers.add(o);
}
public void removeObserver(CurrentConditionsDisplay o) {
observers.remove(o);
}
public void notifyObservers() {
for (CurrentConditionsDisplay o : observers) {
o.update(temperature, humidity, pressure);
}
}
}
위와 같으며,
보시다시피 자기 자신의 상태를 요구하는 관찰자인 Observer 들을 내부적으로 저장하고,
만약 내부 변수들이 특정 트리거로 인해 변경되면, 이 변경사항을 내부에 저장되어있는 모든 옵저버들에게 전파합니다.
이로인해 관찰 대상과 관찰 주체를 구조적으로 잘 분리하여 관계를 정리할 수 있습니다.
위 구조를 보다 잘 활용하는 방법으론, 옵저버 객체에 콜백을 입력하게 하여,
notifyObservers 로 변경 값이 전파 되는 시점에 해당 콜백이 실행되도록 처리하여 옵저버가 등록되는 시점에 로직이 추가되도록 할 수도 있습니다.
9. State
객체의 내부 상태에 따라 다른 기능을 수행하는 메소드를 구현하는 패턴입니다.
객체의 상태에 따라 동일한 루틴에서도 다른 행동을 할 수 있습니다.
조건문이 많아질 경우 코드가 복잡해지는 문제를 해결합니다.
interface State {
void handle();
}
class ConcreteStateA implements State {
public void handle() {
System.out.println("상태 A의 동작 수행");
}
}
class ConcreteStateB implements State {
public void handle() {
System.out.println("상태 B의 동작 수행");
}
}
class Context {
private State state;
public void setState(State state) {
this.state = state;
}
public void request() {
state.handle();
}
}
public class StatePatternExample {
public static void main(String[] args) {
Context context = new Context();
context.setState(new ConcreteStateA());
context.request(); // 상태 A의 동작 수행
context.setState(new ConcreteStateB());
context.request(); // 상태 B의 동작 수행
}
}
10. Strategy
문제를 해결함에 있어 다양한 알고리즘이 적용될 수 있는 경우, 알고리즘을 별도로 분리(캡슐화)하는 패턴입니다.
특정 객체에 종속되지 않으며 알고리즘에 대한 확장과 변경이 용이합니다.
interface Strategy {
int execute(int a, int b);
}
class AddStrategy implements Strategy {
public int execute(int a, int b) {
return a + b;
}
}
class MultiplyStrategy implements Strategy {
public int execute(int a, int b) {
return a * b;
}
}
class Context {
private Strategy strategy;
public Context(Strategy strategy) {
this.strategy = strategy;
}
public void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
public int executeStrategy(int a, int b) {
return strategy.execute(a, b);
}
}
public class StrategyPatternExample {
public static void main(String[] args) {
Context context = new Context(new AddStrategy());
System.out.println(context.executeStrategy(5, 3)); // 8
context.setStrategy(new MultiplyStrategy());
System.out.println(context.executeStrategy(5, 3)); // 15
}
}
11. Visitor
알고리즘을 자료 구조에서 분리하여 클래스를 수정하지 않고도 새로운 알고리즘을 추가할 수 있도록 하는 패턴입니다.
분리된 알고리즘은 자료구조를 방문하여 문제를 해결하게 됩니다.
interface Visitor {
void visit(ElementA element);
void visit(ElementB element);
}
interface Element {
void accept(Visitor visitor);
}
class ElementA implements Element {
public void accept(Visitor visitor) {
visitor.visit(this);
}
public String operationA() {
return "ElementA의 로직";
}
}
class ElementB implements Element {
public void accept(Visitor visitor) {
visitor.visit(this);
}
public String operationB() {
return "ElementB의 로직";
}
}
class ConcreteVisitor implements Visitor {
public void visit(ElementA element) {
System.out.println("Visitor가 처리: " + element.operationA());
}
public void visit(ElementB element) {
System.out.println("Visitor가 처리: " + element.operationB());
}
}
public class VisitorPatternExample {
public static void main(String[] args) {
Element[] elements = { new ElementA(), new ElementB() };
Visitor visitor = new ConcreteVisitor();
for (Element element : elements) {
element.accept(visitor);
}
}
}