Validation(유효성 검사) 백-프론트 통합 검증 모듈 구현 (Spring-React)
유효성 검사는 어디서 해야 할까, 백엔드? 프론트엔드?
프론트에서만 유효성 검사를 하면, 개발자 도구(Postman과 같은) 로 요청 시 검사 되지 않는다.
백엔드에서만 유효성 검사를 하면, 엔드유저들의 오입력을 미리 쳐내지 못하고 전부 서버로 콜 하기 때문에 부하 문제와 연결된다.
정답은 둘 다 해야 한다.
프론트는 프론트대로 자바스크립트를 사용해서 아래와 같이 유효성 검증을 한다.
function check(email,password) {
var emailCheck = '[a-z0-9]+@[a-z]+\.[a-z]{2,3}';
if(emailCheck.test(email)){
alert('이메일 형식이 올바르지 않습니다.');
}
if(password.length < 8) {
alart('비밀번호는 8자 이상이어야 합니다.');
}
}
백엔드에서도 Spring-Validation 라이브러리를 사용하여 아래와 같이 유효성 검증한다고 하자.
@Data
public class UserDto {
@Email
private String email;
@Size(min=8)
private String password;
}
위와 같은 방식은 관리포인트가 2개이기 때문에 수정하기 번거롭다.
게다가 개발 환경이 체계적이지 않다면, 이 부분을 프론트와 백엔드가 직접 소통하고 맞춰야 하는 상황도 발생한다.
FE-BE 통합 유효성검증 모듈 구현
이메일,비밀번호 등 각 필드에 대한 유효성 검사 규칙을 한 군데에서 관리하도록 모듈을 구현해보려고 한다.
(코드 복붙만 해도 사용 가능합니다. 단,상속해줘야하는 부분이 추가됨)
기술 스택
- Spring & React
- Json Schema
- Annotation Custom
- AOP
라이브러리
implementation 'org.everit.json:org.everit.json.schema:1.5.1'
implementation 'org.springframework.boot:spring-boot-starter-aop'
구현
1. 우선, 공통으로 사용 될 Json Schema 파일을 작성하자
다른 위치에 json schema 파일을 만들어도 되지만, 그렇다면 아래에서 json 파일을 읽어오는 부분에 대한 소스 수정이 필요할 것이다!
아래는 login.json 파일의 예시이다.
{
"title": "login.json",
"description": "로그인 유효성 검증 스키마",
"type": "object",
"properties": {
"userId": {
"type": "string",
"minLength": 2,
"maxLength": 20,
"description": "아이디는 1자 이상, 20자 이하여야 합니다."
},
"email": {
"type": "string",
"pattern" : "[a-z0-9]+@[a-z]+\\.[a-z]{2,3}",
"description": "이메일 형식이 올바르지 않습니다."
}
},
"required": ["userId"]
}
Json Schema 는 minLength,maxLength 등의 기본적인 규칙을 지원한다. 또한 복잡한 유효성 검사는 pattern (정규식 표현) 을 사용하여 검사할 수 있다.
Json Schema 공식 레퍼런스 : https://json-schema.org/understanding-json-schema/reference/index.html
JSON Schema Reference — Understanding JSON Schema 2020-12 documentation
json-schema.org
2. 백엔드 유효성 검증
이제 백엔드 단에서 이 Json Schema 파일을 읽어서 유효성 검증을 하도록 할건데, 여기서 어노테이션과 AOP 를 만드는 작업이 필요하다.
2-1. Controller 에 지정할 어노테이션 생성
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CwValid {
/** 유효성 검증할 json 스키마 네임 입력 */
Valid schema();
}
@Target : 어노테이션이 적용되는 지점을 결정한다. 나는 메서드에 어노테이션을 적용할 것이기 때문에 METHOD 로 지정했다.
@Retention : 어노테이션이 적용되고 유지되는 범위를 정한다. 런타임에도 계속 사용할 것이므로 RUNTIME 으로 지정했다.
@CwValid 어노테이션을 사용하면서 사용될 스키마 네임을 입력받도록 하려고하는데, "스키마명" 이런식의 String 으로 입력받고싶지 않아서, Enum 클래스를 생성했다.
2-2. Valid Enum 클래스 생성
public enum Valid {
LOGIN("login", "로그인 유효성검증"),
SCENARIO("scenario", "시나리오 등록,수정 유효성검증")
;
private final String name; // name을 json 파일 이름과 일치하도록 지정한다
private final String desc;
Valid(String name, String desc) {
this.name = name;
this.desc = desc;
}
public String schemaName() {
return this.name;
}
}
2-3. Dto 의 각 필드에 지정할 어노테이션 생성
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CwValidField {
/** json 스키마에서 어떤 key 와 매핑할건지 입력 */
String jsonKeyName();
}
2-4. Dto 가 상속받을 클래스 생성
@Slf4j
public class CwValidation {
/** AOP 에서 이 클래스를 상속받은 객체를 주입 */
private CwValidation cwValidation;
@Description("CwValidation 을 상속받은 Dto 객체의 필드정보를 읽어서 JsonObject 로 변환")
public boolean validate(Schema schema) {
try {
JSONObject jsonObject = toJsonObject(schema);
schema.validate(jsonObject);
}catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
@Description("CwValidation 을 상속받은 Dto 객체의 @CwValidField 이 명시된 필드정보를 읽어서 JsonObject 로 변환")
private JSONObject toJsonObject(Schema schema) throws Exception {
JSONObject jsonObject = new JSONObject();
/**
* TODO 추후 성능 이슈가 있다면 리플렉터 사용 부분을 시스템 기동 시점에 미리 로드해서 메모리에 올려놓고 (CwValidation 를 상속받은 Dto 정보)
* 메모리에서 필드정보를 매핑하여 사용하도록 수정
*/
Field[] fields = cwValidation.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
/** @CwValidField 어노테이션이 붙은 필드만 변환 */
if (field.isAnnotationPresent(CwValidField.class)) {
CwValidField annotation = field.getAnnotation(CwValidField.class);
String jsonKeyName = annotation.jsonKeyName();
Object value = field.get(cwValidation);
/** @CwValidField 에 명시된 keyName 이 스키마에 존재하지 않으면 로그 남김 */
if (!schema.definesProperty(jsonKeyName)) {
e.printStackTrace();
}
jsonObject.put(jsonKeyName, value);
}
}
return jsonObject;
}
@Hidden
public void setChildInstance(CwValidation cwValidation) {
this.cwValidation = cwValidation;
}
}
setChildInstance() 메서드 위의 @Hidden 어노테이션은, Swagger 에서 cwValidation 필드가 보여지는것을 방지하기 위해서이다. Swagger 를 사용하지 않는다면, 빼도 된다.
위 코드에서 실질적으로 유효성 검사를 하는 부분은 schema.validate(jsonObject); 이다.
2-5. Validator AOP 생성
@Slf4j
@Aspect
@Component
@EnableAspectJAutoProxy
public class Validator {
private static final HashMap<Valid, Schema> validJsonSchema = new HashMap<>();
@Before("@annotation(cwValid)")
public void validate(JoinPoint joinPoint, CwValid cwValid) throws Throwable{
/** 입력된 스키마네임에 해당하는 스키마 가져오기 */
Schema schema = validJsonSchema.get(cwValid.schema());
/** 스키마가 존재하지 않으면 유효성검사 하지않음 */
if (schema == null) return;
Object[] args = joinPoint.getArgs();
for (Object param : args) {
/** 파라미터중에 CwValidation 을 상속받은 Object 를 가져옴 */
if (param instanceof CwValidation) {
CwValidation cwValidation = (CwValidation) param;
cwValidation.setChildInstance(cwValidation);
boolean result = cwValidation.validate(schema);
if(!result) {
throw new Exception("유효성 검증 위반");
}
}
}
}
@Description("스키마 파일을 읽어서 미리 validJsonSchema 필드에 주입해 놓는다.")
public static void loadSchema() {
for(Valid valid : Valid.values()) {
Resource resource = new ClassPathResource("validate/" + valid.name() + ".json");
if (resource.exists()) {
try {
InputStream inputStream = resource.getInputStream();
String schemaString = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
JSONObject jsonObj = new JSONObject(schemaString);
Schema schema = SchemaLoader.load(jsonObj);
validJsonSchema.put(valid,schema);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
@CwValid 어노테이션이 붙었다면, validate() 메서드가 실행되고, 파라미터 중에 CwValidation 클래스를 상속받은 오브젝트 (Dto) 를 가져와서 CwValidation 클래스의 메서드를 실행시켜준다.
자, 이제 백엔드에서의 유효성 검증 모듈 구현이 완료되었다.
실 사용 예시를 위해 Test Controller 와 Test Dto 를 만들어보자.
@Description("유효성 검사 예시 API")
@ResponseBody
@RequestMapping(value = "/validator.do", method = RequestMethod.POST)
@CwValid(schema = Valid.LOGIN)
public void validator(@RequestBody ValidationTestDto validationTestDto) {
System.out.println("유효성 검사");
}
@Data
@Description("유효성검사 테스트 Dto")
public class ValidationTestDto extends CwValidation {
@CwValidField(jsonKeyName = "userId")
String userId;
@CwValidField(jsonKeyName = "email")
String email;
}
이제 이 API 를 호출하면 login.json 스키마에 입력한 대로 유효성 검사를 실행할 것이다!
3. 프론트엔드 유효성 검증
3-1.ajv 라이브러리가 필요하다.
"ajv": "^8.12.0",
package.json > dependencies 에 추가해주었다.
3-2. validator.js 작성
미리 말하자면, 필자는 React 를 사용해서 프론트 서버를 분리했기 때문에, json 파일 위치가 다르다.
한 프로젝트 내에 프론트/백엔드가 공존한다면 resources 경로에서 json 파일을 읽어오도록 소스를 수정해주면 된다.
import Ajv from 'ajv';
import login from './login.json';
import scenario from './scenario.json';
const ajv = new Ajv();
ajv.addSchema(login, 'login');
ajv.addSchema(scenario, 'scenario');
export const validator = (schemaName,data) => {
const validate = ajv.getSchema(schemaName);
const isValid = validate(data);
return {
isValid,
errors: isValid ? null : validate.errors.map((error) => {
const { message, instancePath } = error;
const property = instancePath.substring(1);
const propertySchema = validate.schema.properties[property];
if (!propertySchema) {
return "검증 실패\n관리자에게 문의하세요.";
}
return propertySchema.description;
})
};
};
/** Validation 객체를 FormData 로 변환 */
export const validObjToFormData = (data) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
console.log(key)
console.log(value)
formData.append(key, value);
});
return formData;
}
프론트엔드에서의 유효성 검증 모듈 구현은 이렇게 간단하게 완료된다.
실제 사용은 아래와같이 하면 된다.
import {validator, validObjToFormData} from "./validate/validator";
const checkUserValidation = (userId,email) => {
const loginInfo = {"userId" : userId,"email" : email};
const validatorResult = validator("login",loginInfo);
if (!validatorResult.isValid) {
alert(validatorResult.errors);
return;
}
}
핵심은 validator js 파일을 만들고, 유효성 검사를 원하는 필드를 넘겨서 검증한 후 그에 맞게 alert 을 띄우는 등의 처리를 해주는 것이다.
자, 이제는 새롭게 유효성 검증을 해야하는 화면이 추가된다면
1. 공통으로 사용할 Json Schema 작성
2. 백엔드 : Controller + Dto 에 어노테이션 붙이고 Dto 에 CwValidation 상속
3. 프론트 : validator.js 함수 호출
이 작업만 해주면 된다!