티스토리 뷰

Backend

Middleware 패턴과 코드 수준에서의 활용

지마켓 선현상 2021. 4. 30. 14:32

변경에 강한 패턴

생뚱맞은 도입이지만 좋은 코드란 무엇일까요? 여러 설명들을 들 수 있지만 그중 꽤 많이 거론되는 덕목은 변경하기 용이한 코드입니다. 코드의 작성 시점에서 변경은 미래에 일어날 일입니다. 지금 그 요구를 다 안다면 기다릴 것 없이 코드에 반영하면 되지만 그렇지 않기 때문에 변경이 용이할 수 있다는 점은 쉽지는 않습니다. 한편으로는 그렇기에 코드를 변경하는 케이스들을 일반화하여 코드를 작성하는 시점에 어떤 변경 패턴을 대비하곤 합니다. 그리고 그런 방법 중에 Middleware 패턴을 코드 수준에서 이용해 보려고 합니다.

코드 흐름과 변경

먼저, 코드를 변경하는 패턴 중에는 코드가 진행하는 흐름에 대한 패턴을 짧게 살펴볼까 합니다. 대표적으로 then 의 의미를 사용하는 방식들을 찾아볼 수 있습니다.

// Function#andThen(Function)
first.andThen(second);

// CompletableFuture#thenCombine(CompletionStage,BiFunction)
before.thenCombine(after, (b, a) -> b + a);

이 패턴들은 이 흐름을 이어가거나 흐름 중간에 끼어넣는 변경을 하기 용이합니다.

// Function#andThen(Function)
first.andThen(second)
  .andThen(third); // 이어지는 흐름을 추가

// CompletableFuture#thenCombine(CompletionStage,BiFunction)
before
  .thenCombine(between, (b, intercepted) -> intercepted + b) // 추가 로직을 끼어넣음
  .thenCombine(after, (b, a) -> b + a)

하나의 패턴으로 흐름에 대한 다양한 변경 상황을 대응할 수 있으니 코드 흐름을 쉽게 수정하여 미래에 있을 변경을 쉽게 반영할 수 있도록 대비할 수 있습니다.

Middleware 패턴

웹 애플리케이션의 요청을 처리하는 패턴 중에는 요청과 응답의 과정에 필요한 일련의 처리를 위한 개입을 하곤 합니다. 일반적으로 이런 처리 패턴을 보입니다.

비즈니스 규칙과 관계없는 처리들을 이런 패턴으로 처리 흐름에 포함할 수 있습니다. 예를 들면 메시지를 압축하거나 직렬화/역직렬화를 하는 등의 작업을 포함할 때 자주 사용합니다. middleware 패턴이 모두 request 흐름과 response 흐름을 가로지르는 context를 유지하는 것은 아니지만 하나의 interceptor 가 맡은 작업에 context를 유지하면 좀 더 다양한 작업을 편하게 할 수 있습니다.

 

이런 패턴을 코드 내에서도 사용하려고 한다면 어떤 형태로 사용하면 좋을까요? Monad 패턴이 도움이 될까요?

Middleware Monad

먼저, 이런 일을 하는 클래스 이름을 MiddlewareTask 로 하도록 하겠습니다. 이 클래스를 통해서 어떤 사용 방법이 될지 먼저 살펴보겠습니다. 위의 then 을 사용하는 패턴과 비슷하게 사용하면 좋을 것 같습니다. 그렇다면 중간중간 새로운 middleware를 넣기 편할 것 같습니다. 그리고 middleware 패턴의 주요한 부분은 middleware 중간에 별도의 next를 호출하여 제어를 다음 middleware로 넘겼다가 수행이 끝난 후 제어를 next 다음으로 돌려받는 도구를 제공하는 것입니다.

MiddlewareTask.of((value, next) -> { /* ... next.apply() ... */ })
    .andThen((value, next) -> { /* ... next.apply() ... */ })
    .andThen((value, next) -> { /* ... next.apply() ... */ })
    .run(initialData);

이런 사용법 정도면 좋을 것 같습니다. andThen 을 통해 이어질 middleware를 추가하고 각 middleware는 제공된 next 를 통해 제어를 다루는 방식입니다.

 

먼저 middleware 를 정의해보겠습니다.

public class MiddlewareTask<T, R> {

  private final BiFunction<T, Function<T, R>, R> middleware;
  // ...

middleware 는 T 타입의 파라미터와 next 제어 함수를 받아 R 타입의 결과를 반환하는 형태의 함수입니다. next 를 제공받는 건 나름 주요한 특징인데 이는 이 middleware의 다음에 올 middleware를 특정하지 않고 처리 흐름을 제공하는 컨테이너에 맡기는 방법이기 때문입니다. 즉, middleware 입장에서는 이 next 가 어떤 함수인지 몰라도 (다시 말하면, 특정한 함수에 의존하지 않아도) 처리 흐름에 결합할 수 있습니다. MiddlewareTask 는 이 middleware에 대한 컨테이너로 시작합니다.

 

다음은 of 메서드와 생성자를 만들 차례입니다. of 메서드는 하나의 middleware를 받아 시작합니다.

public class MiddlewareTask<T, R> {

  private final BiFunction<T, Function<T, R>, R> middleware;

  private MiddlewareTask(BiFunction<T, Function<T, R>, R> middleware) {
    this.middleware = middleware;
  }

  public static <T, R> MiddlewareTask<T, R> of(BiFunction<T, Function<T, R>, R> middleware) {
    return new MiddlewareTask<>(middleware);
  }
  // ...

다음은 이 task 를 수행할 run() 메서드입니다.

// ...
public R run(T param, Function<T, R> task) {
  return middleware.apply(param, task);
}

간단하게 middleware의 수행을 시작하면 될 것 같습니다. 실제로 수행하고 싶은 task 와 이에 필요한 parameter로 작업을 시작합니다.

 

이제 가장 중요한 부분입니다. 바로 andThen() 을 구현할 차례입니다. Monad 를 이해하는 방법 에서 소개한 대로 monad 패턴이 효과가 좋은 순간은 합성이 필요할 때입니다. andThen() 에는 지금 middleware와 그 뒤에 올 middleware를 합성하는 방법을 작성해야 합니다. (이것은 monad의 명세 중 join에 해당합니다.)

public class MiddlewareTask<T, R> {

  private final BiFunction<T, Function<T, R>, R> middleware;
  // ...

  public MiddlewareTask<T, R> andThen(MiddlewareTask<T, R> after) {
    return new MiddlewareTask<>((value, next) ->
        middleware.apply(value, value2 -> after.middleware.apply(value2, next))
    );
  }
  // ...

지금 middleware의 nextafter 를 수행하는 코드가 됩니다. 원래의 nextafter 다음에 수행하도록 after 의 middleware 수행에 주입하면 합성하는 방법이 완성됩니다.

 

MiddlewareTask 의 전체 코드는 다음과 같습니다.

public class MiddlewareTask<T, R> {

  private final BiFunction<T, Function<T, R>, R> middleware;

  private MiddlewareTask(BiFunction<T, Function<T, R>, R> middleware) {
    this.middleware = middleware;
  }

  public static <T, R> MiddlewareTask<T, R> of(BiFunction<T, Function<T, R>, R> middleware) {
    return new MiddlewareTask<>(middleware);
  }

  public static <T, R> MiddlewareTask<T, R> empty() {
    return new MiddlewareTask<>((value, next) -> next.apply(value));
  }

  public MiddlewareTask<T, R> andThen(MiddlewareTask<T, R> after) {
    return new MiddlewareTask<>((value, next) ->
        middleware.apply(value, value2 -> after.middleware.apply(value2, next))
    );
  }

  public MiddlewareTask<T, R> andThen(BiFunction<T, Function<T, R>, R> middleware) {
    return andThen(MiddlewareTask.of(middleware));
  }

  public R run(T param, Function<T, R> task) {
    return middleware.apply(param, task);
  }
}

직관적인 사용을 위해 andThen(BiFunction<T, Function<T, R>, R>) 을 추가하였습니다. static 메서드 empty() 도 편의를 위해 추가 하였습니다.

 

이제 다음과 같은 코드가 작성 가능합니다.

private MiddlewareTask<String> greeter = MiddlewareTask.of(this::extract);

public String process(String record) {
  return greeter.run(record, name -> "hello " + name);
}

private String extract(String value, Function<String, String> next) {
  String[] token = value.split("=");
  String param = token[0];
  String result = next.apply(token[1]); // '=' 을 기준으로 value 부분만 다음으로 넘김
  return param + "=" + result; // 떼었던 파라미터 명을 다시 붙여서 반환
}

record 를 파싱 해서 파라미터의 이름을 떼고 실제 필요한 부분만을 다음 작업에 제공하는 middleware를 앞쪽에 포함하였습니다. 그리고 반환 시에 다시 해당 파라미터 이름을 붙인 형태로 돌려주기도 합니다. 파라미터를 다루는 middleware 내에서 context 를 공유하기 때문에 쉽게 가능합니다. 만약, value 를 암호화한 채로 다루는 것이 요구사항으로 추가되었다면 이 변경을 어떻게 반영하게 될까요?

private MiddlewareTask<String> greeter = 
    MiddlewareTask.of(this::extract)
        .andThen(this::crypto);

public String process(String record) {
  return greeter.run(record, name -> "hello " + name);
}

private String extract(String value, Function<String, String> next) { /* ... */ }

private String crypto(String value, Function<String, String> next) {
  String key = "_KeY_"; // decrypt/encrypt 에 동일한 키 사용
  String decrypted = decrypt(value, key); // 복호화하여 다음을 진행
  String result = next.apply(decrypted);
  return encrypt(result, key); // 다시 암호화하여 반환
}

이처럼 crypto middleware 를 만들어 MiddlewareTask 체인 적당한 위치에 andThen 으로 추가하면 됩니다. crypto middleware에서도 decrypt/encrypt 에서 사용하는 key를 동일하게 사용하는 것 역시 context 가 middleware 내에서 공유되기 때문입니다.

정리

사실 middleware monad는 업무에 필요한 순간이 생겨서 고려하게 되었습니다. Try Catch 지옥 벗어나기 에서 실 사용 사례를 소개해보도록 하겠습니다.

 

완성된 MiddlewareTask 의 전체 코드를 보면 그리 길지 않습니다. 사용성을 위한 메서드를 제외한다면 정말 몇 줄 되지 않습니다. 그리고 이 클래스를 monad 처럼 사용할 수 있는 요인은 이 하나의 메서드에 작성한 짧은 합성 방법입니다.

// this 에 after 를 합성
public MiddlewareTask<T, R> andThen(MiddlewareTask<T, R> after) {
  return new MiddlewareTask<>((value, next) ->
      middleware.apply(value, value2 -> after.middleware.apply(value2, next))
  );
}

Monad 를 이해하는 방법 에서 합성 규칙을 통해 Monad를 설명해 보았습니다. 그리고 합성 규칙을 찾아서 Monad 를 만들어 보았습니다. 이번 예시와 Try Catch 지옥 벗어나기, Multi Data Source 에서 Data 모으기를 통해 제가 Monad 를 설명해 본 방식이 효과적이길 바라봅니다.

 

긴 글 읽어주셔서 감사합니다.

'Backend' 카테고리의 다른 글

try-catch 지옥 벗어나기  (0) 2021.05.21
Rust Memory Management  (2) 2021.05.07
Monad 를 이해하는 방법  (2) 2021.04.16
Component 와 Transclude  (0) 2021.04.12
자바의 HashMap을 효과적으로 사용하는 법  (1) 2021.02.26
댓글