Ayden's journal

책임 연쇄 패턴

책임 연쇄 패턴(Chain of Responsibility Pattern)은 여러 개의 처리 객체들이 연쇄적으로 연결되어 있다. 이 체인은 각 객체가 자신에게 주어진 요청을 처리하거나, 처리할 수 없으면 그 요청을 다음 객체로 넘기면서 요청이 처리될 때까지 순차적으로 전달된다. 이를 통해 시스템에서 요청이 처리될 수 있는 경로를 동적으로 정의할 수 있다. 이 패턴을 활용하면 사용자 입력에 대한 처리 과정에서 여러 단계의 검증을 거쳐야 할 때, 또는 로그 기록, 보안 검사와 같은 처리 과정을 단계적으로 수행해야 할 때 효과적이다.

이 포스트에서는 책임 연쇄 패턴을 활용하여 내가 만들고 관리하는 라이브러리 sicilian에서 input value를 검증할 때 쓰는 함수 execValidate를 리팩토링 해보려 한다. 아래는 실제 execValidate의 코드 전체이다. switch문과 if문이 복잡하게 혼잡되어있어 코드 가독성이 심각한 수준이다. 변수도 많다보니 뭐가 어디에 쓰이는지, 어떻게 돌아가는지조차 알기 어렵다.

export const execValidate: ExecValidate =
  ({ getStore, setError, getErrorObjStore }) =>
  (e) => {
    type T = typeof getStore extends () => infer U ? U : never;
    const { name, value } = e.target;
    const store = getStore()
    const ErrorObj = getErrorObjStore()[name] as RegisterErrorObj<T>;

    let message = ""

    if (ErrorObj) {
      let flag = 0;

      for (const propertyKey in ErrorObj) {
        switch (propertyKey) {
          case "required":
            if (!value.length) {
              message = typeof ErrorObj[propertyKey] === "object"
                ? ErrorObj[propertyKey]!.message
                : `${name}는 비어있을 수 없습니다.`
              flag++;
            }
            break;

          case "minLength":
            if (isNumber(ErrorObj[propertyKey]!)) {
              if (value.length < ErrorObj[propertyKey]!) {
                message = `${name}는 ${ErrorObj[propertyKey]!}자 이상이어야 합니다.`,
                flag++;
              }
            } else {
              if (value.length < ErrorObj[propertyKey]!.number) {
                message = ErrorObj[propertyKey]!.message ?? `${name}는 ${ErrorObj[propertyKey]!.number}자 이상이어야 합니다.`,
                flag++;
              }
            }
            break;

          case "maxLength":
            if (isNumber(ErrorObj[propertyKey]!)) {
              if (value.length > ErrorObj[propertyKey]!) {
                message = `${name}는 ${ErrorObj[propertyKey]!}자 이하여야 합니다.`,
                flag++;
              }
            } else {
              if (value.length > ErrorObj[propertyKey]!.number) {
                message = ErrorObj[propertyKey]!.message ?? `${name}는 ${ErrorObj[propertyKey]!.number}자 이하여야 합니다.`,
                flag++;
              }
            }
            break;

          case "RegExp":
            if (isArray(ErrorObj.RegExp!)) {
              for (const RegExp of ErrorObj.RegExp!) {
                if (!RegExp.RegExp.test(value)) {
                  message = RegExp.message ?? `${name}의 값이 정규표현식을 만족하지 않습니다.`,
                  flag++;
                  break;
                }
              }
            } else {
              if (!ErrorObj.RegExp!.RegExp.test(value)) {
                message = ErrorObj.RegExp!.message ?? `${name}의 값이 정규표현식을 만족하지 않습니다.`,
                flag++;
              }
            }
            break;

          case "custom":
            if (isArray(ErrorObj.custom!)) {
              for (const customChecker of ErrorObj.custom!) {
                if (!customChecker.checkFn(value, store)) {
                  message = customChecker.message ?? `${name}의 값이 검증 함수를 만족하지 않습니다.`,
                  flag++;
                  break;
                }
              }
            } else {
              if (!ErrorObj.custom!.checkFn(value, store)) {
                message = ErrorObj.custom!.message ?? `${name}의 값이 검증 함수를 만족하지 않습니다.`,
                flag++;
              }
            }
            break;

          default:
            break;
        }

        if (flag === 1) {
          setError({[name]: message} as Partial<T>);
          break;
        }
      }
    }
  };

 

책임 연쇄 패턴은 크게 두 부분으로 나눌 수 있다. 하나는 핸들러(Handler) 역할을 하는 객체들이다. 이 객체들은 요청을 처리하거나, 처리할 수 없을 경우 다음 핸들러에게 요청을 넘긴다. 다른 하나는 핸들러체인(HandlerChain) 역할을 하는 객체로 핸들러들이 순차적으로 연결된 구조를 관리한다. 이 객체는 각 핸들러가 요청을 처리할 수 있도록 연결하고, 요청이 전달될 경로를 정의한다. 핸들러체인은 요청이 전달되는 순서와 흐름을 제어하며, 요청이 최종적으로 처리될 때까지 각 핸들러를 적절히 호출한다.

나는 우선 switch문으로 제어되는 각 항목을 핸들러 객체로 분리했다. 덕분에 각 객체는 자신이 처리해야 할 요청에 대한 책임만 맡게 되었고, 이로 인해 코드의 가독성 및 유지보수성이 크게 향상되었다. 각 핸들러는 독립적으로 동작할 수 있으므로, 새로운 요청 처리 방식이나 조건이 추가될 때마다 기존 코드를 수정하는 대신, 새로운 핸들러 객체를 추가하는 방식으로 시스템을 확장할 수 있다. 또한, 핸들러 객체들이 switch문 대신 체인 구조로 연결되면서, 요청의 흐름이 더욱 유연하고 직관적으로 관리될 수 있게 되었다.

type HandleMethodProps<T extends InitState> = {value: string, ErrorObj: RegisterErrorObj<T>, name: string, store: T};

interface IHandler<T extends InitState> {
  handle(props: HandleMethodProps<T>): string | null;
}

class RequiredHandler<T extends InitState> implements IHandler<T> {
  public handle({ErrorObj, value, name}: HandleMethodProps<T>) {
    // required가 false이거나 value가 비어있지 않으면 null을 반환
    if (!ErrorObj.required || value.length !== 0) return null;

    const message = typeof ErrorObj.required === "object"
    ? ErrorObj.required!.message
    : `${name}는 비어있을 수 없습니다.`

    return message;
  }
}

class MinLengthHandler<T extends InitState> implements IHandler<T> {
  public handle({value, ErrorObj, name}: HandleMethodProps<T>) {
    if (isNumber(ErrorObj.minLength!)) {
      if (value.length < ErrorObj.minLength!) {
        return `${name}는 ${ErrorObj.minLength!}자 이상이어야 합니다.`;
      }
    } else {
      if (value.length < ErrorObj.minLength!.number) {
        return ErrorObj.minLength!.message ?? `${name}는 ${ErrorObj.minLength!.number}자 이상이어야 합니다.`
      }
    }

    return null
  }
}

class MaxLengthHandler<T extends InitState> implements IHandler<T> {
  public handle({value, ErrorObj, name}: HandleMethodProps<T>) {
    if (isNumber(ErrorObj.maxLength!)) {
      if (value.length < ErrorObj.maxLength!) {
        return `${name}는 ${ErrorObj.minLength!}자 이상이어야 합니다.`;
      }
    } else {
      if (value.length < ErrorObj.maxLength!.number) {
        return ErrorObj.maxLength!.message ?? `${name}는 ${ErrorObj.maxLength!.number}자 이상이어야 합니다.`
      }
    }

    return null
  }
}

class RegExpHandler<T extends InitState> implements IHandler<T> {
  public handle({value, ErrorObj, name}: HandleMethodProps<T>) {
    if (isArray(ErrorObj.RegExp!)) {
      for (const RegExp of ErrorObj.RegExp!) {
        if (!RegExp.RegExp.test(value)) {
          return RegExp.message ?? `${name}의 값이 정규표현식을 만족하지 않습니다.`
        }
      }
    } else {
      if (!ErrorObj.RegExp!.RegExp.test(value)) {
        return ErrorObj.RegExp!.message ?? `${name}의 값이 정규표현식을 만족하지 않습니다.`
      }
    }

    return null
  }
}

class CustomHandler<T extends InitState> implements IHandler<T> {
  public handle({value, ErrorObj, name, store}: HandleMethodProps<T>) {
    if (!store) return null;

    if (isArray(ErrorObj.custom!)) {
      for (const customChecker of ErrorObj.custom!) {
        if (!customChecker.checkFn(value, store)) {
          return customChecker.message ?? `${name}의 값이 검증 함수를 만족하지 않습니다.`
        }
      }
    } else {
      if (!ErrorObj.custom!.checkFn(value, store)) {
        return ErrorObj.custom!.message ?? `${name}의 값이 검증 함수를 만족하지 않습니다.`
      }
    }

    return null
  }
}

 

 

 

그리고 핸들러 체인 객체를 만들었다. HandlerChain 클래스는 요청을 처리할 수 있는 핸들러들을 동적으로 관리하며, 각 핸들러가 요청을 처리할 수 있도록 돕는다. 클래스 내부에서 handlerMap을 사용하여 각 검증 조건에 해당하는 핸들러들을 미리 맵핑해두었고, addHandler 메서드를 통해 핸들러 체인에 원하는 핸들러를 추가할 수 있다. handle 메서드는 각 핸들러가 요청을 처리하도록 순차적으로 호출하며, 첫 번째로 처리된 오류가 발생하면 즉시 후속 핸들러들의 처리를 중단하고 오류를 설정한다.
이 구조를 통해 요청 처리의 흐름을 유연하게 제어할 수 있으며, 새로운 검증 조건을 추가할 때마다 새로운 핸들러만 추가하면 되어 코드 변경 없이 확장할 수 있는 장점이 있다. 또한, 각 핸들러가 독립적으로 동작하므로 유지보수와 테스트가 용이해진다. 이와 같은 방식으로 책임 연쇄 패턴을 활용하면 코드가 깔끔해지고, 각 검증 로직이 잘 분리되어 변경에 강한 시스템을 구축할 수 있다.

class HandlerChain<T extends InitState> {
  private handlers: Array<IHandler<T>> = []
  private handlerMap = new Map([
    ["required", RequiredHandler],
    ["minLength", MinLengthHandler],
    ["maxLength", MaxLengthHandler],
    ["RegExp", RegExpHandler],
    ["custom", CustomHandler],
  ])

  constructor(private setError: (action: Partial<T>) => void) {}

  public addHandler(handlerKey: ValidateKey): void {
    const Handler = this.handlerMap.get(handlerKey)!

    this.handlers.push(new Handler<T>());
  }

  public handle(props: {value: string, ErrorObj: RegisterErrorObj<T>, name: string, store: T}): void {
    for (const handler of this.handlers) {
      const handled = handler.handle(props);
      if (handled) {
        console.log(handled)
        this.setError({[props.name]: handled} as Partial<T>);
        break;
      }
    }
  }
}

 

이렇게 핸들러 객체와 핸들러 체인 객체를 통해 코드를 분리한 덕분에 execValidate는 유효성 검사 로직을 실행하는 책임만을 가지게 되었다. execValidate 함수는 이벤트 객체에서 값을 추출하고, 해당 값에 대해 처리해야 할 오류 객체(ErrorObj)와 스토어 값을 가져오는 역할만 수행한다. 실제 검증 작업은 HandlerChain에 의해 관리되며, 각 핸들러 객체가 자신에게 맞는 검증을 수행하도록 위임된다. 이를 통해 execValidate 함수는 비즈니스 로직을 간결하게 유지할 수 있고, 추가적인 검증 조건이나 처리 로직이 필요할 때마다 핸들러 객체만 수정하면 되므로 유지보수가 훨씬 용이하다.

export const execValidate: ExecValidate =
  ({ getStore, setError, getErrorObjStore }) =>
  (e) => {
    type T = typeof getStore extends () => infer U ? U : never;
    const { name, value } = e.target;
    const store = getStore()
    const ErrorObj = getErrorObjStore()[name] as RegisterErrorObj<T>;

    if (!ErrorObj) return;

    const handlerChain = new HandlerChain<T>(setError)

    for (const key in ErrorObj) {
      handlerChain.addHandler(key as ValidateKey);
    }

    handlerChain.handle({value, ErrorObj, name, store})
  };

 

핸들러 체인을 사용하는 방식은 코드의 책임을 명확히 분리하고, 각 부분의 역할을 독립적으로 관리할 수 있게 도와주며, 새로운 검증 로직이나 조건을 추가하는 데에도 유연하게 대응할 수 있다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기