디자인 패턴(design pattern)이란?
소프트웨어를 설계할 때 특정 맥락에서 발생하는 문제들을 해결할 수 있는 해결책을 유형별로 정리한 것이 디자인 패턴(design pattern)이다.
디자인 패턴은 공통적인 소프트웨어 문제를 설계할 때 재 사용할 수 있는, 검증된 청사진을 제공해주고, 공통의 언어를 만들어주며 팀원 사이의 의사 소통을 원활하게 해주는 아주 중요한 역할을 한다.
람다를 이용하면 이전에 디자인 패턴으로 해결하던 문제를 더 쉽고 간단하게 해결할 수 있다.
또한 람다 표현식으로 기존의 많은 객체지향 디자인 패턴을 제거하거나 간결하게 재구현할 수 있다.
하지만 모든 코드에서 람다 표현식을 사용하는건 적절하지 않다.
클래스에 상태가 있거나 여러 메서드를 정의하는 등 복잡하다면 기존 패턴을 사용하는 것이 유리할 수 있다.
아래 다섯 가지 패턴을 살펴보자.
- 전략(strategy)
- 템플릿 메서드(template method)
- 옵저버(observer)
- 의무 체인(chain of responsibility)
- 팩토리(factory)
전략(strategy) 패턴
전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다.
다양한 기준을 갖는 입력값을 검증하거나 다양한 파싱 방법을 사용하거나, 입력 형식을 설정하는 등 다양한 시나리오에 전략 패턴을 활용할 수 있다.
예를 들어 결제 로직(알고리즘)을 구현한다고 했을때 여러가지 전략(카드, 현금 결제)으로 나눠서 같은 알고리즘을 구현할 수 있다.
public class BankAccount { // 은행 통장
private int balance;
public BankAccount(int balance) {
this.balance = balance;
}
// 잔액 차감
public void deductBalance(int amount) {
System.out.println(amount + "원 차감!");
this.balance -= amount;
}
}
public interface Payment { // 결제
void pay(BankAccount account, int amount);
}
public class CreditCardPay implements Payment { // 카드 결제
@Override
public void pay(BankAccount account, int amount) {
account.deductBalance(amount);
System.out.println(amount + "원 카드 결재 완료");
}
}
public class AccountTransfer implements Payment { // 계좌 이체
@Override
public void pay(BankAccount account, int amount) {
account.deductBalance(amount);
System.out.println(amount + "원 계좌 이체 완료");
}
}
public class PaymentSystem { // 결제 시스템
private final Payment payment;
public PaymentSystem(Payment payment) {
this.payment = payment;
}
public void pay(BankAccount account, int amount) {
this.payment.pay(account, amount);
}
}
BankAccount myBankAccount = new BankAccount(1_000_000);
PaymentSystem paymentSystem = new PaymentSystem(new CreditCardPay());
paymentSystem.pay(myBankAccount, 100_000);
// 100000원 차감!
// 100000원 카드 결재 완료
이런 경우 람다 표현식을 이용하면 전략 디자인 패턴에서 발생하는 자잘한 코드를 제거할 수 있다.
public interface Payment {
void pay(int amount);
}
public class PaymentSystem {
private final Payment payment;
public PaymentSystem(Payment payment) {
this.payment = payment;
}
public void pay(int amount) {
this.payment.pay(amount);
}
}
BankAccount myBankAccount = new BankAccount(1_000_000);
PaymentSystem paymentSystem = new PaymentSystem((account, amount) -> {
account.deductBalance(amount);
System.out.println(amount + "원 카드 결재 완료");
});
paymentSystem.pay(myBankAccount, 100_000);
// 100000원 차감!
// 100000원 카드 결재 완료
템플릿 메서드(template method) 패턴
알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다.
간단히 말해, 템플릿 메서드는 "이 알고리즘을 사용하고 싶은데 그대로는 안 되고 조금 고쳐야 하는"상황에 적합하다.
먼저 추상화 크래스를 만들어 공통적인 부분을 구현 후 조금 다른 부분만 추상화 메서드를 생성해 상속받은 클래스에서 구현하도록 한다.
public abstract class PaymentSystem {
public void pay(BankAccount account, int amount) {
account.deductBalance(amount);
}
abstract void payProcess(int amount);
}
public class CreditCardPaymentSystem extends PaymentSystem {
@Override
void payProcess(int amount) {
System.out.println(amount + "원 카드 결재 완료");
}
}
BankAccount myBankAccount = new BankAccount(1_000_000);
PaymentSystem paymentSystem = new CreditCardPaymentSystem();
paymentSystem.pay(myBankAccount, 100_000);
// 100000원 차감!
// 100000원 카드 결재 완료
마찬가지로 람다를 이용하면 더욱 간단하게 구현할 수 있다.
위 예제에서 payProcess의 시그니처와 일치하도록 Consumer<Integer> 형식을 갖는 두 번째 인수를 추가한 후
추상화 클래스를 직접 상속받지 않고 직접 람다 표현식을 전달해서 다양한 동작을 실행할 수 있다.
public class PaymentSystem {
public void pay(BankAccount account, int amount, Consumer<Integer> payProcess) {
account.deductBalance(amount);
payProcess.accept(amount);
}
}
BankAccount myBankAccount = new BankAccount(1_000_000);
new PaymentSystem().pay(
myBankAccount,
100_000,
(amount) -> System.out.println(amount + "원 카드 결재 완료")
);
// 100000원 차감!
// 100000원 카드 결재 완료
전략 패턴과 마찬가지로 패턴에서 발생하는 자잘한 코드를 제거할 수 있다.
옵저버(observer) 패턴
어떤 이벤트가 발생했을 때 한 객체(주제)가 다른 객체 리스트(옵저버)에 자동으로 알림을 보내야 하는 상황에서 옵저버 디자인 패턴을 사용한다.
예를 들어 여러 친구들에게 한번에 편지를 보낸다고 가정해보자.
우선 다양한 옵저버를 그룹화할 인터페이스가 필요하다.
interface LetterObserver {
void send(String contents);
}
class Friend_A implements LetterObserver {
@Override
public void send(String contents) {
System.out.println("To. Friend_A : " + contents);
}
}
class Friend_B implements LetterObserver {
@Override
public void send(String contents) {
System.out.println("To. Friend_B : " + contents);
}
}
그리고 주제도 구현해야 한다.
주제는 옵저버를 등록하는 메서드(registerObserver)와 편지를 보내는 메서드(sendObservers)를 구현해야 한다.
interface Subject {
void registerObserver(Observer observer);
void sendObservers(String content);
}
class Sender implements Subject {
private final List<Observer> observerList = new ArrayList<>();
@Override
public void registerObserver(Observer observer) {
observerList.add(observer);
}
@Override
public void sendObservers(String content) {
observerList.forEach(observer -> observer.send(content));
}
}
주제(Sender)는 옵저버를 등록한 후 편지를 보내는 메서드를 통해 한번에 여러 친구에게 편지를 보낼 수 있다.
Sender sender = new Sender();
sender.registerObserver(new Friend_A());
sender.registerObserver(new Friend_B());
sender.sendObservers("이 편지는 영국으로 부터...");
// To. Friend_A : 이 편지는 영국으로 부터...
// To. Friend_B : 이 편지는 영국으로 부터...
자 그럼 이번엔 람다 표현식을 사용해 옵저버 패턴을 구현해보자.
옵저버는 함수형 인테페이스이기 때문에 람다 표현식을 사용할 수 있다.
Sender sender = new Sender();
sender.registerObserver((String content) -> {
System.out.println("To. Friend_A : " + content);
});
sender.registerObserver((String content) -> {
System.out.println("To. Friend_B : " + content);
});
sender.sendObservers("이 편지는 영국으로 부터...");
각각의 옵저버를 구현하는 클래스를 만들 필요가 없어졌다. 즉, 이번에도 역시 자잘한 코드를 제거했다.
의무 체인(chain of responsibility) 패턴
작업 처리 객체의 체인(동작 체인 등)을 만들 떄는 의무 체인 패턴을 사용한다.
한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달하는 식이다.
public abstract class ProcessingObject<T> {
protected ProcessingObject<T> successor;
public void setSuccessor(ProcessingObject<T> successor) {
this.successor = successor;
}
public T handler(T input) {
T output = work(input);
if (successor != null) return successor.handler(output);
return output;
}
abstract protected T work(T input);
}
handler 메서드를 보면 해야할 작업을 처리(work)를 한 후 설정된 successor이 있으면 객체를 넘겨 successor에 handler메서드를 실행시킨다.
그럼 자동차 공장에서 타이어 공장, 문(door) 공장을 거쳐 자동차가 만들어 진다고 가정하고 의무 채인 패턴을 적용해보자.
@Setter
public class Car {
public boolean hasTire; // 타이어 조립 여부
public boolean hasDoor; // 문 조립 여부
public Car() {
this.hasTire = false;
this.hasDoor = false;
}
}
public class TireFactory extends ProcessingObject<Car> { // 타이어 조립 공장
@Override
protected Car work(Car car) {
car.setHasTire(true);
return car;
}
}
public class DoorFactory extends ProcessingObject<Car> { // 문 조립 공장
@Override
protected Car work(Car car) {
car.setHasDoor(true);
return car;
}
}
ProcessingObject<Car> factory1 = new TireFactory();
ProcessingObject<Car> factory2 = new DoorFactory();
factory1.setSuccessor(factory2);
Car car = factory1.handler(new Car());
// Car{hasTire=true, hasDoor=true}
이렇게 factory1과 factory2를 연결해서 작업 체인을 만들 수 있다.
이 방식은 함수 체인(함수 조합) 방식으로 람다 표현식을 조합하면 의무 체인 페턴과 같은 방식으로 사용할 수 있다.
Function<Car, Car> factory1 = (Car car) -> { car.setHasTire(true); return car; };
Function<Car, Car> factory2 = (Car car) -> { car.setHasDoor(true); return car; };
Function<Car, Car> pipeline = factory1.andThen(factory2);
Car car = pipeline.apply(new Car());
// Car{hasTire=true, hasDoor=true}
팩토리(factory) 패턴
인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다.
예를 들어 자동차를 만드는 공장이 있는데 그 공장에서 여러가지 유형 별로 자동차를 만들 수 있다고 생각해보자.
public class Car {
private String name;
private CarType type;
public Car(String name, CarType type) {
this.name = name;
this.type = type;
}
}
public class SmallCar extends Car {
public SmallCar(String name) {
super(name, CarType.SMALL);
}
}
public class SedanCar extends Car {
public SedanCar(String name) {
super(name, CarType.SEDAN);
}
}
public class SuvCar extends Car {
public SuvCar(String name) {
super(name, CarType.SUV);
}
}
경차, 세단, SUV 종류의 클래스가 Car 클래스를 상속받고 있다.
이제 자동자를 만드는 팩토리를 생성해서 사용해보자.
public class CarFactory {
public Car createCar(CarType carType) {
switch (carType) {
case SMALL:
return new SmallCar("모닝");
case SEDAN:
return new SedanCar("아반테");
case SUV:
return new SuvCar("투싼");
default:
return null;
}
}
}
CarFactory carFactory = new CarFactory();
Car car = carFactory.createCar(CarType.SUV);
// Car{name='투싼', type=SUV}
생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 인스턴스를 생산할 수 있다.
그럼 이 코드를 람다 표현식을 사용해서 구현해보자.
public class CarFactory {
private final Map<CarType, Supplier<Car>> map = Map.ofEntries(
Map.entry(CarType.SMALL, () -> new SmallCar("모닝")),
Map.entry(CarType.SEDAN, () -> new SedanCar("아반테")),
Map.entry(CarType.SUV, () -> new SuvCar("투싼"))
);
public Car createCar(CarType carType) {
Supplier<Car> supplier = map.get(carType);
if (supplier != null) return supplier.get();
throw new IllegalArgumentException("No Such Car type");
}
}
CarFactory carFactory = new CarFactory();
Car car = carFactory.createCar(CarType.SUV);
// Car{name='투싼', type=SUV}
Switch문을 사용해 분기를 태웟던 것과 달리 Map에 Supplier 함수형 인터페이스로 구현하였다.
'Java' 카테고리의 다른 글
[모던 자바] DSL(도메인 전용 언어)이란? (2) | 2022.03.15 |
---|---|
[모던 자바] 람다 표현식과 스트림 테스팅과 디버깅 (0) | 2022.03.14 |
[모던 자바] 가동석과 유연성을 개선하는 리팩터링 (0) | 2022.03.11 |
[모던 자바] 컬렉션 팩토리(Collection Factory) 소개 및 사용법 (0) | 2022.03.10 |
[모던 자바] Spliterator 인터페이스란 무엇인가? (0) | 2022.03.10 |