Java

[모던 자바] 디자인 패턴 소개 및 람다를 이용해 리팩터링 하기

Beekei 2022. 3. 11. 18:31
반응형

디자인 패턴(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 함수형 인터페이스로 구현하였다.

반응형