Web

Validation(유효성 검사) 백-프론트 통합 검증 모듈 구현 (Spring-React)

hyeindev 2023. 7. 19. 20:44

유효성 검사는 어디서 해야 할까, 백엔드? 프론트엔드?

프론트에서만 유효성 검사를 하면, 개발자 도구(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 파일을 작성하자

프로젝트의 resources 하위에 validate 폴더 만들고, 그 안에 json 파일을 생성

다른 위치에 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 함수 호출

이 작업만 해주면 된다!