[Daily Flow 1.] Validator 리팩토링
사이드 포스팅 1탄
![[Daily Flow 1.] Validator 리팩토링](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1741161717995%2Fa62693a8-0f24-4dc9-8d80-cefe541ed663.png&w=3840&q=75)
이 포스팅은 사이드 프로젝트 Daily Flow를 개발하며 진행한 리팩토링 및 이슈에 대한 해결 사항을 기록하기 위해 작성되었습니다.
Daily Flow라는 태스크/지출 관리 및 분석 서비스를 사이드 프로젝트로 개발 중에 있다. 익숙하지 않은 Nest.js를 활용해 API를 직접 만들고 빨리 서비스의 기본 기능 구현 사항을 만들어내자 라는 생각 때문인 지 코드가 난장판이다.
배포도 지난 연말 구매했던 NAS를 통해서 할 예정이라 일단 정신 사나운 코드들 부터 수정하고 뒷일을 진행하기로 마음 먹었다.
그 중 가장 첫 타겟은 Validator.
이유는 리팩토링을 진행하기 전 모든 유효성 검사가 각각의 유틸함수로 작성되어있어 사용도 수정도 불편했다. 하지만 결국 기능적으로는 Validator라는 큰 골자를 공유하기 때문에 충분히 하나의 단일 함수로 처리가 가능할 것 같았다.
암묵적 입출력을 걷어내고 명시적 입출력으로 작성하기
기존코드
// utils/input.ts
// 이메일 유효성 검사
export const isEmailValid = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// 비밀번호 유효성 검사 (특수문자 포함)
export const isPasswordValid = (password: string) => {
const specialCharRegex = /[!@#$%^&*(),.?":{}|<>]/;
return specialCharRegex.test(password);
};
// SigninForm.tsx 컴포넌트 내부
// 비밀번호 확인 일치 검사
const doPasswordsMatch = () => {
return signupData.password === signupData.confirmed_password;
};
위 코드의 문제점은 한 눈에 봐도 몇가지 드러난다.
동일한 기능을 수행하는 코드가 반복됨
동일한 역할을 하지만 여러곳에 흩어져 작성되어있음
암묵적 입출력으로 작성되어있어 유연하게 사용이 불가능함
그 말인 즉 큰 틀에서 바라보면 명시적으로 test 로직과 필드를 주입하고 그 결과를 반환하는 함수를 만들어야한다는 결과가 도출된다.
수정된 코드
// 필드 유효성 검사를 공통 로직으로 추출
const isValid = (field: string, regex: RegExp) => {
return regex.test(field);
};
isValid('email', /^[^\s@]+@[^\s@]+\.[^\s@]+$/);
isValid('password', /[!@#$%^&*(),.?":{}|<>]/);
// 매치 유효성 검사를 공통 로직으로 추출
const doMatch = (data: Record<string, string>, field1: string, field2: string) => {
return data[field1] === data[field2]
};
doMatch(signupData, 'password', 'confirmed_password');
암묵적으로 작성되어있던 data나 필드명 그리고 정규식을 명시적으로 작성할 수 있도록 변경해주었다. 이것만으로도 충분히 상태의 코드가 작성이 된다. 하지만 form에서의 유효성 검사는 필드 단일 유효성 검사는 물론 form 전체에 대한 유효성 검사도 진행된다. 때문에 현재와 같은 단일 필드 유효성 검사를 실행하게 된다면 유효성 함수를 여러번 작성 및 실행하게 된다.
isValid('email', /^[^\s@]+@[^\s@]+\.[^\s@]+$/)
&& isValid('password', /[!@#$%^&*(),.?":{}|<>]/)
&& doMatch(signupData, 'password', 'confirmed_password')
&& // 위 유효성 검사 통과 후 작성될 로직
이러한 유효성 검사 역시 초반의 유효성 검사에 비해선 상당히 개선된 모양새이지만 여전히 휴먼 에러를 일으키기 좋은 형태의 코드이다. 최종적으로 만들어내야할 유효성 검사는 단일 필드 대상이 아닌 단일 필드 검사를 모두 통과한 경우에 true를 반환해주는 함수이다.
every 메소드를 활용한 리팩토링
자바스크립트에서 기본 제공되는 배열 메소드 중 every 라는 메소드가 있다. 아래는 mdn에 작성되어있는 소개이다.
Array인스턴스의every()메서드는 배열의 모든 요소가 제공된 함수로 구현된 테스트를 통과하는지 테스트합니다. 이 메서드는 불리언 값을 반환합니다.
위 설명에 따르면 특정 조건을 통과하면 true, 특정 조건을 통과하지 않는다면 false를 반환하며 순회를 멈추는 메소드로 유효성 검사를 위해 활용하면 딱 알맞은 메소드로 보인다.
Rules 객체 만들기
기본적으로 배열을 순회하며 테스트를 진행하기 때문에 규칙이 담긴 배열을 전달해줘야하는데 배열을 전달해주기 전 규칙을 정의한 Rules 객체를 만들어준다.
const rules = {
email : (field) => {
test: (data: DataType) => Boolean(data[field]),
},
password : (field) => {
test: (data: DataType) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data[field] || ""),
}
}
위 rules 객체에는 필요에 따라 다양한 규칙들을 작성해줄 수 있어 유지보수가 한결 자유롭다. every 메소드는 순회하며 test 함수를 실행시켜 나오는 결과물에 따라 결과값이 정해지므로 test 함수를 작성해준다.
createValidator 함수 만들기
만들어진 규칙을 바탕으로 validator 함수를 만들어주도록 한다. 필요한 인자는 규칙 배열과 필드 데이터이며 every 메소드가 검증 후 최종적으로 값을 반환하기 때문에 작성 또한 쉽다.
// validator 함수
const createValidator = (rules: ResultType[], data) => {
return rules.every((rule) => rule.test(data));
};
// 사용
createValidator([
rules.email('email'),
rules.password('password')
], data) // 조건에 통과했다면 true, 실패했다면 false를 반환한다.
하지만 위 방식의 문제점이 존재한다. 검증 시점을 개발자가 제어하고 싶어도 실행 즉시 그 값을 반환하는 형태이기 때문에 제어가 불가능하다. 그리고 동일한 검증로직을 한번 생성한 후 재사용하는 형태가 아닌 필요한 곳 모두에 코드를 다시 작성해줘야하는 등의 불편함도 존재한다.
때문에 이 역시도 한번 더 리팩토링을 진행한다.
고차함수로 createValidator 함수 만들기
위 코드는 결과를 반환했다면 이번에는 ‘결과를 반환하는 함수’를 반환하는 함수를 만들어보자.
리팩토링 역시 단순하다. 반환되는 값만 함수로 변경해주면 된다. 물론 form data를 createValidator가 직접 받는 게 아닌 반환되는 함수의 매개변수로 전달해야한다는 점은 유의해줘야 한다.
즉 createValidator([…rules]) 는 (data) => rules.every((rule) => rule.test(data)) 라는 함수 덩어리가 된다.
이 때 반환되는 함수의 rules는 클로저로 인해 createValidator가 실행 후 초기화가 되더라도 prototype에 저장되어있게 된다.
이렇게 만들어진 createValidator는 사용자가 필요할 때 어디서든 재사용할 수 있게 된다. 더불어 rules와 data의 분리도 이루어지게 된다.
// 고차함수로 작성된 createValidator
const createValidator = (rules) => {
return (data) => rules.every((rule) => rule.test(data));
};
// 사용
const formValidator = createValidator([
rules.email('email'),
rules.password('password')
]);
formValidator(data);
에러 메시지 추가 하기
이제 마지막 단계만 남았다. 유효성 검사를 하게 되면 필연적으로 각 field의 입력값에 따라 오류 메시지를 띄워주게 되는데 기존에 작성된 에러메시지들 또한 파편적으로 흩어져있었다. 하지만 에러 메시지 또한 유효성 검사와 직접적으로 관계를 가지고 있기에 유효성 검사 시 함께 처리하는 것이 가능하다는 판단이다.
기존 코드
// 이메일 에러 메시지 반환 함수
const getEmailErrorMessage = (data: Record<string, any>) =>
!data.email || isEmailValid(data.email)
? ""
: "올바른 형식의 이메일이 아닙니다";
// 비밀번호 에러 메시지 반환 함수
const getPasswordErrorMessage = (data: Record<string, any>) =>
!data.password || isPasswordValid(data.password)
? ""
// 비밀번호 일치 함수
const confirmedPasswordErrorMessage =
!signupData.confirmed_password || doPasswordsMatch(signupData)
? ""
: "비밀번호가 일치하지 않습니다";
유효성 검사코드와 마찬가지로 에러메시지 별로 함수가 작성되어 있다. 심지어 외부 함수 혹은 값에 의존하여 순수하지 못한 함수로 작성되어있다보니 버그가 발생할 가능성도 높았다. 하지만 지금 유효성 검사를 위한 함수와 규칙 객체가 존재하고 있기 때문에 이 역시도 손쉽게 통합가능하다.
규칙에 에러메시지 통합
const rules = {
email : (field) => {
test: (data: DataType) => Boolean(data[field]),
// message 추가
message: "올바른 형식의 이메일이 아닙니다",
// 에러메시지 객체를 생성할 때 필드명이 필요해 필드도 추가한다.
field,
},
password : (field) => {
test: (data: DataType) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data[field] || ""),
// message 추가
message: "비밀번호가 일치하지 않습니다",
field,
}
}
validator 로직 수정
이 때 약간의 문제가 발생하는데 every() 메소드는 규칙을 테스트하고 불리언값을 반환해주는 역할이기에 지금처럼 에러메시지도 핸들링해야하는 상황에서는 적합하지 않다. 이 때문에 every() 메소드가 아닌 forEach() 메소드를 통해 직접적으로 로직을 작성해주도록 한다.
export const createValidatorWithError = (rules: ExtendedResultType[]) => {
return (data: DataType): ValidationResult => {
const errors: Record<string, string> = {};
let isValid = true;
rules.forEach((rule) => {
const valid = rule.test(data);
if (!valid) {
const field = rule.field;
if (!errors[field]) errors[field] = "";
errors[field] = rule.message;
isValid = false;
}
});
return { isValid, errors };
};
};
코드의 길이가 늘어나 다분히 복잡해보이지만 하나 하나 뜯어보면 단순하다.
// form 전체를 대상으로 하는 유효성 검사이기 때문에 객체를 활용해 에러메시지를 저장해준다.
const errors: Record<string, string> = {};
// 기본 vaild 값을 정해준다. 여기서는 각 규칙을 검사하며 false일 때
// isValid도 false로 바꾸기 때문에 true를 기본값으로 한다
let isValid = true;
rules.forEach((rule) => {
// 규칙을 확인한다.
const valid = rule.test(data);
// 규칙이 false 일 때
if (!valid) {
// 현재 필드를 확인 후 저장한다.
const field = rule.field;
// errors 객체에 field 키에 값을 초기화 해준다
if (!errors[field]) errors[field] = "";
// 에러메시지 객체에 규칙에 작성되어있는 에러 메시지를 등록하고
errors[field] = rule.message;
// isValid를 false로 변경한다.
isValid = false;
}
});
// isValid와 errors 객체를 반환
return { isValid, errors };
마치며
유효성 검사는 React hook form 과 같은 유명한 라이브러리들이 존재하지만 특별하게 필요한 경우가 아니라면 이런 형태로 만들어 쓰는 것이 자유도 면에서 긍정적이라고 생각한다. 또한 대부분이 라이브러리가 필요없는 수준의 단순한 폼에 대해 유효성 검사를 하고 있기 때문에 이런 기능들은 직접 구현하는 것이 더 편하지 않을까? 하는 생각이 든다.
최근 쏙쏙 들어오는 함수형 코딩 책을 읽고 있는데 이 책에서 언급된 내용들이 코드를 설명하고 작성하는 데에 있어서 도움이 된다는 생각이 든다. 한번 씩 일독을 해보면 어떨까 하는 생각.


![[트러블슈팅] 서버를 터뜨린 쟈그마한 함수](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1748357643167%2Ffc68d237-0bfe-4f79-8e28-15fd98c5710d.png&w=3840&q=75)
![[기술 서적 후기 및 요약] 쏙쏙 들어오는 함수형 코딩](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1743129459556%2F14a7d741-d22e-4622-98e4-1857e449821e.png&w=3840&q=75)