본문 바로가기
Programming/Java, Spring

자바 함수형 프로그래밍과 디자인 패턴

by Renechoi 2023. 6. 21.

디자인 패턴 

 

- 반복해서 등장하는 프로그래밍 문제들에 대한 해법들을 패턴화 해놓은 것

- 패턴들을 숙지해놓으면 비슷한 문제가 생겼을 때 패턴들이 이정표가 되어준다. 

 

- 생성 패턴 : 오브젝트의 생성에 관한 패턴

- 구조 패턴: 상속을 이용해 클래스/오브젝트를 조합하여 더 발전된 구조로 만드는 패턴

- 행동 패턴: 필요한 작업을 여러 객체에 분배하여 객체간 결합도를 낮추는 패턴 

 

 

 

Builder Pattern 

 

- 대표적인 생성 패턴

- 객체의 생성에 대한 로직과 표현에 대한 로직을 분리해준다

- 객체의 생성 과정을 유연하게 해준다

- 객체의 생성 과정을 정의하고 싶거나 필드가 많아 constructor가 복잡해질 때 유용 

 

 

다음과 같은 field를 가진 User를 만든다고 해보자. 

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

 

 

User class 내에 빌더를 정의한다. 

 

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 User build() {
      return new User(this);
   }


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

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

 

 

유저 오브젝트 내에는 생성자를 빌더를 통해 구현한다. 

 

 

 

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;
}

 

 

여기서 필수로 필요한 인자는 id와 name으로 설정한다면, 

 

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

 

이와 같은 메서드를 만들어 준다. 

 

이와 같이 정의한 User를 생성해보자. 

 

User user = User.builder(1, "name").build();

 

builder 메서드에서 id와 name을 다음과 같이 받고 

 

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

 

생성된 Builder 클래스는 build() 메서드를 통해 User 생성자를 호출하고

 

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

 

생성자를 통해 객체가 생성된다. 

 

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;
}

 

이렇게 만든 Builder 클래스에서 정의한 with 메서드는 다음과 같이 사용하면서 생성자에 인자값을 넣어줄 수 있다. 

 

User.builder(2,"name2").withVerified(false).withEmailAddress("abc@emial.com");

원하는 것은 builder가 아니니까 build()를 호출해준다. 

User.builder(2,"name2").withVerified(false).withEmailAddress("abc@emial.com").build();

 

with메서드는 이렇게 되어 있다.

 

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

 

 

만약 default 값을 필드에 정의해주고 싶다면 Builder 내에서 필드를 초기화해주면 된다. 

 

그런데 with~ 필드가 하나하나 만들어준다면, 필드가 많아질수록 계속 늘어나게 되는 문제가 생긴다. 

 

이를 함수형 프로그래밍을 이용해 개선해보자. 

 

public Builder with(Consumer<Builder> consumer) {
   consumer.accept(this);
   return this;
}

즉 컨슈머를 현재 builder에 적용을 시켜줌으로써 

컨슈머를 통해서 필드를 set 해주고 

return this 한다. 

 

 

그렇다면 이를 어떻게 사용할까? 

 

User.builder(3, "name3").with(builder -> {
   builder.emailAddress = "123@email.com";
   builder.isVerified = true;
   builder.createdAt = LocalDateTime.now();
}).build();

 

이와 같이 하나의 setter 기능을 하는 consumer로서 통일시킬 수 있다. 

 

 

Decorator Pattern 

- 구조 패턴 

- 용도에 따라 객체에 기능을 계속 추가(decorate)할 수 있게 해준다. 

 

이 패턴을 통해 Price Processor를 만들어보자. 

 

하나의 기능을 가진 processor로부터 여러 기능을 계속 덧붙여서 조합된 processor를 만드는 것이다. 

 

 

먼저 다음과 같은 인터페이스를 구현한다. 

public interface PriceProcessor {
   Price process(Price price);

   // 자신 다음에 실행될 프로세서를 받아온다.
   // 호출시 새로운 프라이스 프로세서를 호출해주는데, Functional interface이므로 람다를 이용해 자신 먼저 작업을 하고
   // 그 다음에 next로 들어온 process를 호출해주는 새로운 priceprocess를 호출해서 리턴해준다.
   default PriceProcessor andThen(PriceProcessor next){
      return price -> next.process(process(price));
   }
}

 

위의 인터페이스를 구현하는 3가지 각기 다른 프로세서를 구현해보자. 

 

public class TaxPriceProcessor implements PriceProcessor {

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

}

 

 

public class DiscountPriceProcessor implements PriceProcessor {

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

}

 

public class BasicPriceProcessor implements PriceProcessor {

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

}

 

 

이와 같은 프로세서를 호출하고 인터페이스 프로세서에 default로 구현해놓은 메서드를 통해 연결한다. 

 

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

PriceProcessor decoratedPriceProcessor = basicPriceProcessor.andThen(discountPriceProcessor);

 

이 프로세서는 본인의 작업을 수행하고 이후 discount 작업을 수행한다. 

 

이렇게 생성된 데코레이터 프로세서를 통해 price를 처리해보자. 

Price processedPrice = decoratedPriceProcessor.process(originalPrice);

 

 

즉, basic processor가 discount 기능을 갖게 된 것이다. 

 

여기에 tax를 처리하는 기능을 추가하려면 또 한번 데코레이팅을 해줄 수 있다. 

 

PriceProcessor decoratedPriceProcessor2 = basicPriceProcessor.andThen(discountPriceProcessor).andThen(taxPriceProcessor);

 

이렇게 기능을 추가해줄 수 있다. 

 

그런데 기능이 계속 늘어난다면 클래스가 너무 많아지는 문제가 생긴다. 이를 람다를 이용해 간편하게 처리해보자. 

 

PriceProcessor decoratedPriceProcessor3 = basicPriceProcessor
   .andThen(price -> new Price(price.getPrice() + " apply another process"));

 

 

 

Strategy Pattern

- 대표적인 행동 패턴

- 런타임에 어떤 전략(알고리즘)을 사용할지 선택할 수 있게 해준다.

- 전략들을 캡슐화하여 간단하게 교체할 수 있게 해준다. 

 

예를 들어 다음과 같은 이메일 보내는 서비스를 개선해보자. 

 

public class EmailService {
   public void sendPlayWithFriendsEmail(User user) {
      user.getEmailAddress().ifPresent(email -> 
         System.out.println("Sending 'Play With Friends' email to " + email));
   }
   
   public void sendMakeMoreFriendsEmail(User user) {
      user.getEmailAddress().ifPresent(email -> 
         System.out.println("Sending 'Make More Friends' email to " + email));
   }
   
   public void sendVerifyYourEmailEmail(User user) {
      user.getEmailAddress().ifPresent(email -> 
         System.out.println("Sending 'Verify Your Email' email to " + email));
   }
}

 

 

먼저 EmailProvider라는 인터페이스를 만든다. 

 

public interface EmailProvider {
   String getEmail(User user);
}

email Provider가 인터페이스이기 때문에 구현체를 다음과 같이 주입하여 유연하게 필드를 초기화할 수 있다. 

 

public class EmailSender {
   private EmailProvider emailProvider;
   
   public EmailSender setEmailProvider(EmailProvider emailProvider) {
      this.emailProvider = emailProvider;
      return this;
   }
   
   public void sendEmail(User user) {
      String email = emailProvider.getEmail(user);
      System.out.println("Sending " + email);
   }
}

 

Email 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();
   }
}

 

이제 이 EmailProvider를 사용하는 예시를 살펴보자. 

 

EmailSender emailSender = new EmailSender();
EmailProvider verifyYourEmailAddressEmailProvider = new VerifyYourEmailAddressEmailProvider();
EmailProvider makeMoreFriendsEmailProvider = new MakeMoreFriendsEmailProvider();

 

생성자를 통해 생성한 후 

 

emailSender.setEmailProvider(verifyYourEmailAddressEmailProvider);
users.stream()
   .filter(user -> !user.isVerified())
   .forEach(emailSender::sendEmail);

emailSender에 해당하는 emailProvider를 주입해준다. 

 

유저에게 이메일을 보내도록 한다. 

 

users.stream()
   .filter(user -> !user.isVerified())
   .forEach(emailSender::sendEmail);

 

이때 이와 같은 메서드를 똑같이 매번 작성하더라도 emailSender에 어떤 provider를 넣어주느냐에 따라 다른 로직을 수행하게 된다. 

 

 

 

 

 

 

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("error");
      }
   }
}

 

 

여기서 어떤 식으로 유저를 검증하고, 어떤 식으로 DB에 쓸 것인지 그 내용은 하위 클래스에서 정의한다. 

 

이 서비스를 상속하는 유저 서비스를 만들어보자. 

 

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("write user");
   }
}

 

이제 디테일을 이렇게 정의 했을 때 로직 플로우는 추상 클래스를 따르게 된다. 

 

다음과 같이 사용한다. 

 

User alice = User.builder(1, "Alice")
   .with(builder -> {
      builder.emailAddress = "alice@fastcampus.co.kr";
      builder.isVerified = false;
      builder.friendUserIds = Arrays.asList(201, 202, 203, 204, 211, 212, 213, 214);
   }).build();

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

// 큰 틀은 같지만 디테일은 다른 두 createUser()
userService.createUser(alice);
internalUserService.createUser(alice);

 

똑같은 템플릿을 통해 진행이 되지만 디테일하게 구현한 내부 메서드가 다르기 때문에 다른 로직을 수행한다. 

 

 

함수형 프로그래밍을 이용해 좀 더 유연하게 구현해보자. 

 

함수형 인터페이스를 필드로 갖는다. 

 

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;
}

 

정의한 predicate와 consumer를 생성자를 통해 공급 받는다.

 

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");
   });

 

기존 abstract를 구현한 구현체들의 디테일들을 이렇게 바로 구현한다. 

 

이제 기존의 호출과 같은 방식으로 다음과 같이 호출한다. 

 

userServiceInFunctionalWay.createUser(alice);

 

 

 

Chain of Responsibility Pattern

책임 연쇄 패턴 

 

- 행동 패턴의 하나

- 명령과 명령을 각각의 방법으로 처리할 수 있는 처리 객체들이 있을 때

 -> 처리 객체들을 체인으로 엮는다

 -> 명령을 처리 객체들이 체인의 앞에서부터 하나씩 처리해보도록 한다

 -> 각 처리 객체는 자신이 처리할 수 없을 때 체인의 다음 처리 객체로 명령을 넘긴다.

 -> 체인의 끝에 다다르면 처리가 끝난다. 

- 새로운 처리 객체를 추가하는 것으로 매우 간단히 처리 방법을 더할 수 있다. 

 

 

Order가 주어졌을 때 step들의 체인을 통과하면서 각 단계를 거쳐가면서 프로세스 되도록 만들어보자. 

 

 

먼저 OrderProcessStep을 이렇게 정의한다. 

 

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));
   }
   
}

현재 next가 set이 되어 있지 않다면 next는 새로 들어온 next가 된다. 

 

만약 set이 되어 있다면 다음 orderProcess step에서 set next를 콜해준다. 계속해서 다음이 있다면 다음을 호출하고, 맨 마지막에 가서는 next가 없기 때문에 그때 거기서 next를 set하게 된다. 

 

linkedList와 유사하다. 

 

마지막에 process 메서드를 통해 order를 실행해준다. 받아온 컨슈머를 통해 실행하고, 다음 스텝이 있다면 next를 실행한다. 

 

이 OrderProcessStep을 통해 워크플로우를 구현해보자. 

 

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));
   }
});

위와 같이 OrderStatus에 따라 다른 로직을 수행하는 스텝을 구현한다. 

 

initializeStep은 초기화를 담당한다. 

 

이후 setOrderAmountStep은 OrderStatus가 progress 상태라면 로직을 수행한다. 

 

다른 스텝도 만들어보자. 

 

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);
   }
});

Order의 Amount가 0보다 작을 경우 verify에 실패하는 verify 스텝과 만약 verify를 통과했다면 payment를 수행하는 스텝이다. 

 

 

 

이렇게 만든 스텝들은 앞에서 구현한 setNext를 통해 체인으로 연결된다. 

 

OrderProcessStep chainedOrderProcessSteps = initializeStep
   .setNext(setOrderAmountStep)
   .setNext(verifyOrderStep)
   .setNext(processPaymentStep)
   .setNext(handleErrorStep)
   .setNext(completeProcessingOrderStep);
public OrderProcessStep setNext(OrderProcessStep next) {
   if (this.next == null) {
      this.next = next;
   } else {
      this.next.setNext(next);
   }
   return this;
}

 

 

이렇게 하나의 체인으로 만들어진 스텝은 각 스텝이 자신에게 할당된 역할만을 담당한다. 

 


reference. https://fastcampus.co.kr/dev_red_lsh

 

 

반응형