모르지 않다는 것은 아는것과 다르다.

객체지향

디자인 패턴에 함수형 프로그래밍 적용하기

채마스 2022. 3. 6. 23:18

빌더 패턴

  • 대표적인 생성 패턴이다.
  • 객체의 생성에 대한 로직과 표현에 대한 로직을 분리해준다.
  • 객체의 생성 과정을 유연하게 해준다.
  • 객체의 생성 과정을 정의하고 싶거나 필드가 많아 constructor 가 복잡해질 때 유용하다.
  • 아래와 같이 빌더 패턴을 구현할 수 있다.
public class User {
    private int id;
    private String name;
    private String emailAddress;
    private boolean isVerified;
    private LocalDateTime createdAt;
    private List<Integer> friendUserIds;

    public User(Builder builder) {
        this.id = builder.id;
        this.name = builder.name;
        this.emailAddress = builder.emailAddress;
        this.isVerified = builder.isVerified;
        this.createdAt = builder.createdAt;
        this.friendUserIds = builder.friendUserIds;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Optional<String> getEmailAddress() {
        return Optional.ofNullable(emailAddress);
    }

    public boolean isVerified() {
        return isVerified;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public List<Integer> getFriendUserIds() {
        return friendUserIds;
    }

    public static Builder builder(int id, String name) {
        return new Builder(id, name);
    }

    public static class Builder {
        private int id;
        private String name;
        public String emailAddress;
        public boolean isVerified;
        public LocalDateTime createdAt;
        public List<Integer> friendUserIds = new ArrayList<>();

        private Builder(int id, String name) {
            this.id = id;
            this.name = name;
        }

        public Builder withEmailAddress(String emailAddress){
            this.emailAddress = emailAddress;
            return this;
        }

        public Builder withVerified(boolean isVerified){
            this.emailAddress = emailAddress;
            return this;
        }

        public Builder withCreatedAt(LocalDateTime createdAt){
            this.emailAddress = emailAddress;
            return this;
        }

        public Builder withFriendUserIds(List<Integer> friendUserIds){
            this.friendUserIds = friendUserIds;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }

}
  • User 의 내부 static 클래스로 Builder 클래스를 만들고 User 의 static 메소드로 builder 메소드를 구현한다.
  • 마지막으로 Builder 클래스 내부에 User 를 반환하는 build() 메소드를 구현함으로써 User 와 Builder 를 연결해준다.
  • 이렇게 되면 아래와 같이 빌더 패턴을 이용해서 객체를 생성 할 수 있다.
User user = User.builder(1, "hyunwook")
        .withEmailAddress("hyunwook@gmail.com")
        .withVerified(true)
        .build();

빌더 패턴에 함수형 프로그래밍 적용

public Builder withEmailAddress(String emailAddress){
    this.emailAddress = emailAddress;
    return this;
}

public Builder withVerified(boolean isVerified){
    this.emailAddress = emailAddress;
    return this;
}

public Builder withCreatedAt(LocalDateTime createdAt){
    this.emailAddress = emailAddress;
    return this;
}

public Builder withFriendUserIds(List<Integer> friendUserIds){
    this.friendUserIds = friendUserIds;
    return this;
}
  • User 클래스 내부에 위와 같은 메소드를 아래와 같이 바꿀 수 있다.
public Builder with(Consumer<Builder> consumer) {
    consumer.accept(this);
    return this;
}
  • 기존의 4개의 메소드(setter)를 1개의 메소드로 줄일 수 있다. -> 기존 방식보다 훨씬 간단하게 빌더 패턴을 구현할 수 있다. (메소드가 많아질 수록 더욱 효과적이다.)
  • 그렇게 되면 아래와 같이 빌더 패턴을 적용할 수 있다.
User user = User.builder(1, "Alice")
        .with(builder -> {
            builder.emailAddress = "alice@fastcampus.co.kr";
            builder.isVerified = true;
        }).build();

 

Decorator Pattern

  • 구조 패턴의 하나이다.
  • 용도에 따라 객체에 기능을 계속 추가(decorate)할 수 있게 해준다.
  • 먼저 Decorator Pattern 을 적용하기 위해서 아래와 같이 Functional Interface 를 하나 구현해 준다.
@FunctionalInterface
public interface PriceProcessor {
    Price process(Price price);

    default PriceProcessor andThen(PriceProcessor next) {
        return price -> next.process(process(price));
    }
}
  • Functional Interface 이기 때문에 1개의 Abstract 메소드를 갖는다.
  • andThen 메소드는 process(price) 에서 먼저 자신먼저 process 를 해주고 인자로 받은 다음 PriceProcessor 를 process 해준다.
  • Functional Interface 이기 때문에 람다식을 적용할 수 있다.
  • 다음으로는 PriceProcessor 를 구현한 여러 Processor 들을 아래와 같이 구현한다.
public class BasicPriceProcessor implements PriceProcessor {

    @Override
    public Price process(Price price) {
        return price;
    }

}
public class DiscountPriceProcessor implements PriceProcessor {

    @Override
    public Price process(Price price) {
        return new Price(price.getPrice() + ", then applied discount");
    }

}
public class TaxPriceProcessor implements PriceProcessor {

    @Override
    public Price process(Price price) {
        return new Price(price.getPrice() + ", then applied tax");
    }

}
  • 위와 같이 3개의 Processor 를 구현했으니 아래와 같이 Decorator Fattern 을 적용할 수 있다.

Decorator Pattern 에 함수형 프로그래밍 적용

Price unprocessedPrice = new Price("Original Price");

PriceProcessor basicPriceProcessor = new BasicPriceProcessor();
PriceProcessor discountPriceProcessor = new DiscountPriceProcessor();
PriceProcessor taxPriceProcessor = new TaxPriceProcessor();

PriceProcessor decoratedPriceProcessor = basicPriceProcessor
    .andThen(discountPriceProcessor)
    .andThen(taxPriceProcessor);
    // .andThen(price -> new Price(price.getPrice() + ", then applied tax"));
Price processedPrice = decoratedPriceProcessor.process(unprocessedPrice);
  • 위와 같이 추가적인 기능을 끼워넣어서 새로운 기능을 만들 수 있다.
  • 만약 클래스를 만들기 싫다면 주석처리 한것처럼 람다식을 이용해서 처리할 수 있다. -> 하지만 재사용할 필요가 있다면 클래스로 구현하는것이 좋다.

 

Strategy Pattern

  • 대표적인 행동 패턴이다.
  • 런타임에 어떤 전략(알고리즘)을 사용할 지 선택할 수 있게 해준다.
  • 전략들을 캡슐화하여 간단하게 교체할 수 있게 해준다.
  • 먼저 Strategy Pattern 을 적용하기 위해서 아래와 같이 Interface 를 하나 구현해 준다.
public interface EmailProvider {
    String getEmail(User user);
}
  • 위의 interface 는 Abstract 메소드 1개만을 가지고 있기 때문에 Functional Interface 이다.
  • 다음으로는 위의 인터페이스를 구현한 2개의 Provider 를 구현한다.
public class VerifyYourEmailAddressEmailProvider implements EmailProvider {

    @Override
    public String getEmail(User user) {
        return "'Verify Your Email Address' email for " + user.getName();
    }

}
public class MakeMoreFriendsEmailProvider implements EmailProvider {
    @Override
    public String getEmail(User user) {
        return "'Make More Friends' email for " + user.getName();
    }
}
  • 이제 아래와 같이 Strategy Pattern 을 적용할 수 있다. (User 와 관련된 코드는 생략한다.)

Strategy Pattern 에 함수형 프로그래밍 적용

emailSender.setEmailProvider(verifyYourEmailAddressEmailProvider); // 전략 주입
users.stream()
    .filter(user -> !user.isVerified())
    .forEach(emailSender::sendEmail);

emailSender.setEmailProvider(makeMoreFriendsEmailProvider); // 전략 변경
users.stream()
    .filter(User::isVerified)
    .filter(user -> user.getFriendUserIds().size() <= 5)
    .forEach(emailSender::sendEmail);
  • 전략이 변경됨에 따라 sendEmail 메소드의 결과 값이 달라진다.
  • 하지만 이렇게 전략을 만들때마다 클래스를 만들어 줘야할까? -> EmailProvider 가 Functional Interface 이기 때문에 그렇지 클래스를 따로 구현하지 않고 아래와 같이 람다식으로 대체할 수 있다.
emailSender.setEmailProvider(user -> "'Verify Your Email Address' email for " + user.getName());
        users.stream()
            .filter(User::isVerified)
            .filter(user -> user.getFriendUserIds().size() > 5)
            .forEach(emailSender::sendEmail);

emailSender.setEmailProvider(user -> "'Make More Friends' email for " + user.getName());
        users.stream()
            .filter(User::isVerified)
            .filter(user -> user.getFriendUserIds().size() > 5)
            .forEach(emailSender::sendEmail);
  • 이렇게 되면 굳이 VerifyYourEmailAddressEmailProvider 클래스와 MakeMoreFriendsEmailProvider 클래스를 구현하지 않고도 Strategy Pattern 을 적용시킬 수 있다.

 

Template Method Pattern

  • 대표적인 행동패턴중 하나이다.
  • 상위 클래스는 알고리즘의 뼈대만을 정의하고 알고리즘의 각 단계는 하위 클래스에게 정의를 위임하는 패턴이다.
  • 알고리즘의 구조를 변경하지 않고 세부 단계들을 유연하게 변경할 수 있게 해준다.
public abstract class AbstractUserService {
    protected abstract boolean validateUser(User user);

    protected abstract void writeToDB(User user);

    // 뼈대 구성
    public void createUser(User user) {
        if (validateUser(user)) {
            writeToDB(user);
        } else {
            System.out.println("Cannot create user");
        }
    }
}
  • createUser 메소드를 통해서 뼈대만 만들어두고 validateUser, writeToDB 메소드의 디테일한 구현은 하위 클래스에게 맡긴다.
  • 다음으로는 하위 클래스를 아래와 같이 구현한다.
public class UserService extends AbstractUserService {

    @Override
    protected boolean validateUser(User user) {
        System.out.println("Validating user " + user.getName());
        return user.getName() != null && user.getEmailAddress().isPresent();
    }

    @Override
    protected void writeToDB(User user) {
        System.out.println("Writing user " + user.getName() + " to DB");
    }


}
public class InternalUserService extends AbstractUserService {
    @Override
    protected boolean validateUser(User user) {
        System.out.println("validating internal user " + user.getName());
        return true;
    }

    @Override
    protected void writeToDB(User user) {
        System.out.println("Writing user " + user.getName() + " to internal DB");
    }
}
  • 이제 아래와 같이 Template Method Pattern 을 적용시킬 수 있다.

Template Method Pattern 에 함수형 프로그래밍 적용

UserService userService = new UserService();
InternalUserService internalUserService = new InternalUserService();

userService.createUser(alice);
internalUserService.createUser(alice);
  • 위에 코드에서 userService, internalUserService 둘다 createUser 메소드를 실행시켰지만 내부 구현 로직이 다르기 때문에 다른 결과를 반환한다.
  • 하지만 하위 클래스가 많이지면 하나하나 클래스로 구현해주기는 번거롭다 -> 아래와 같이 함수형 인터페이스를 적용시켜서 해결할 수 있다.
public class UserServiceInFunctionalWay {
    private final Predicate<User> validateUser;
    private final Consumer<User> writeToDB;

    public UserServiceInFunctionalWay(Predicate<User> validateUser, Consumer<User> writeToDB) {
        this.validateUser = validateUser;
        this.writeToDB = writeToDB;
    }

    public void createUser(User user) {
        if (validateUser.test(user)) {
            writeToDB.accept(user);
        } else {
            System.out.println("Cannot create user");
        }
    }
}
  • 먼저 추상 메소드로 구현했던 클래스를 위와 같이 함수형 인터페이스를 인자로 가지는 클래스로 대체한다.
  • 그렇게 되면 아래와 같이 따로 클래스를 구현하지 않고 Template Method Pattern 을 적용시킬 수 있다.
UserServiceInFunctionalWay userServiceInFunctionalWay = new UserServiceInFunctionalWay(
        user -> {
            System.out.println("Validating user " + user.getName());
            return user.getName() != null && user.getEmailAddress().isPresent();
        },
        user -> {
            System.out.println("Writing user " + user.getName() + " to DB");
        });
userServiceInFunctionalWay.createUser(alice);
  • Predicate, Consumer 타입을 인자를 람다식으로 전달해서 UserServiceInFunctionalWay 객체를 만들었다.
  • 이렇게 하면 따로 하위 클래스를 구현하지 않아도 된다.

 

Chain of Responsibility Pattern

public class OrderProcessStep {
    private final Consumer<Order> processOrder;
    private OrderProcessStep next;

    public OrderProcessStep(Consumer<Order> processOrder) {
        this.processOrder = processOrder;
    }

    public OrderProcessStep setNext(OrderProcessStep next) {
        if (this.next == null) {
            this.next = next;
        } else {
            this.next.setNext(next);
        }
        return this;
    }

    public void process(Order order) {
        processOrder.accept(order);
        Optional.ofNullable(next)
            .ifPresent(nextStep -> nextStep.process(order));
    }
}
  • 생성자에서 Consumer 타입의 processOrder 을 세팅해 준다.
  • setNext 메소드를 보면, 재귀 방식으로 맨 뒤까지 이동한뒤에 set 해준다. 이렇게 되면 뒤에 계속해서 OrderProcessStep 을 붙일 수 있다.
  • process 메소드는 Order 를 받아서 processOrder 를 실행시켜준다. 또한 다음 step 이 있다면 다음 step 도 실행시킨다.
public class Chapter10Section6 {

    public static void main(String[] args) {
        OrderProcessStep initializeStep = new OrderProcessStep(order -> {
            if (order.getStatus() == OrderStatus.CREATED) {
                System.out.println("Start processing order " + order.getId());
                order.setStatus(OrderStatus.IN_PROGRESS);
            }
        });

        OrderProcessStep setOrderAmountStep = new OrderProcessStep(order -> {
            if (order.getStatus() == OrderStatus.IN_PROGRESS) {
                System.out.println("Setting amount of order " + order.getId());
                order.setAmount(order.getOrderLines().stream()
                        .map(OrderLine::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add));
            }
        });

        OrderProcessStep verifyOrderStep = new OrderProcessStep(order -> {
            if (order.getStatus() == OrderStatus.IN_PROGRESS) {
                System.out.println("Verifying order " + order.getId());
                if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
                    order.setStatus(OrderStatus.ERROR);
                }
            }
        });

        OrderProcessStep processPaymentStep = new OrderProcessStep(order -> {
            if (order.getStatus() == OrderStatus.IN_PROGRESS) {
                System.out.println("Processing payment of order " + order.getId());
                order.setStatus(OrderStatus.PROCESSED);
            }
        });

        OrderProcessStep handleErrorStep = new OrderProcessStep(order -> {
            if (order.getStatus() == OrderStatus.ERROR) {
                System.out.println("Sending out 'Failed to process order' alert for order " + order.getId());
            }
        });

        OrderProcessStep completeProcessingOrderStep = new OrderProcessStep(order -> {
            if(order.getStatus() == OrderStatus.PROCESSED) {
                System.out.println("Finished processing order " + order.getId());
            }
        });

        OrderProcessStep chainedOrderProcessSteps = initializeStep
                .setNext(setOrderAmountStep)
                .setNext(verifyOrderStep)
                .setNext(processPaymentStep)
                .setNext(handleErrorStep)
                .setNext(completeProcessingOrderStep);

        Order order = new Order()
                .setId(1001L)
                .setStatus(OrderStatus.CREATED)
                .setOrderLines(Arrays.asList(
                        new OrderLine().setAmount(BigDecimal.valueOf(1000)),
                        new OrderLine().setAmount(BigDecimal.valueOf(2000))));
        chainedOrderProcessSteps.process(order);

        Order failingOrder = new Order()
                .setId(1002L)
                .setStatus(OrderStatus.CREATED)
                .setOrderLines(Arrays.asList(
                        new OrderLine().setAmount(BigDecimal.valueOf(1000)),
                        new OrderLine().setAmount(BigDecimal.valueOf(-2000))));
        chainedOrderProcessSteps.process(failingOrder);
    }

}
  • 먼저 OrderProcessStep 타입의 step 들을 만들어 준다.
  • 그 다음 setNext 메소드를 통해서 step 을 연결한 chainedOrderProcessSteps 를 만든다.
  • 그 다음 Order 객체를 만들어주고, chainedOrderProcessSteps 에 process 를 하게 되면 연결 된 step 들을 순차적으로 검사한다.

 

 

REFERENCES

  • 이승환님의 JAVA Stream

'객체지향' 카테고리의 다른 글

상속 보다는 합성을 사용하라!  (0) 2022.03.10
역할과 책임분리  (0) 2022.03.10
다형성  (0) 2022.02.28
디자인 패턴  (0) 2022.02.28
SOLID  (0) 2022.02.28