책임 연쇄 패턴
책임 연쇄 패턴(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