본문 바로가기

프로그래밍/Spring

Custom Annotation을 이용한 객체 검증 작업..(1)

이번 포스팅에서는 Spring에서 모델(Model)을 검증(Validate)하는 부분을 다뤄보고자 한다. 이 포스팅을 Spring을 잘 다루지 못하는 초보분들도 보시겠지만 지금 다루고자 하는 글은 약간 응용편이기 때문에 모델을 검증하는 기본적인 내용은 책이나 인터넷을 보시길 바란다. 책이나 인터넷에 이미 나와있는 뻔한 내용을 포스팅해봤자 보는 사람이나 글을 쓰는 사람이나 별 의미가 없기 때문이다.

 

개인적으로 Spring Controller에 값을 전달할때 Model 개념을 잘 사용하는 편이 아니다. 개인적으로 Model을 이용해서 등록하는 방법보다 Ajax를 통하여 등록하는 방법을 사용하고 등록에 대한 결과를 JSON 문자열로 받아 클라이언트에서 받은 JSON 문자열 값을 분석하여 그에 맞는 행동을 하도록 하는 편이다. 사실 내가 Spring을 접하면서 이 방법을 첨부터 익숙히 다뤘다면 아마 이러지는 않았겠지만 이상하게 이런 방법으로 Controller에 값을 전달하는 방법에 익숙하질 않았다. 내가 값을 보내고 보낸 값을 다시 피드백을 받아 이를 셋팅한다는것이 좀 이해가 어려웠었다(지금은 오늘 올리는 내용에 대한 Sample 작업을 하면서 이 부분을 이해하게 됐다) 그리고 아는 지인에게 Spring 게시판 Sample Project를 만들고 Sample Code를 제작하면서 이 Model이란 개념을 써먹기가 좀 껄끄러운 점도 발견하게 되었다. 지인에게 Sample Code로 만들 당시 게시물 등록작업을 늘 하던 방법으로 Ajax로 등록하는 값을 보내주어 등록 작업 결과를 JSON 문자열로 보내는 작업으로 진행하게 되었는데 @ModelAttribute로 사용자가 입력한 값을 Model 형태로 받을 경우 이 Model이 Spring의 Model 객체에 등록되기 때문에 Controller에서 JSON 문자열을 만들기 위한 객체를 리턴할 경우 사용자가 리턴한 객체와 사용자가 입력한 값이 모두 Model 객체에 들어가게 되어 Model 객체를 이용한 JSON 문자열이 이상하게 만들어지기 때문이었다. JSON 문자열에 내가 Controller에서 return 한 객체의 내용 뿐만 아니라 Controller에서 사용자가 입력한 값들이 들어있는 Model 조차 같이 변환이 되기 때문이었다. 이 문제때문에 @ModelAttribute를 @RequestMapping으로 고쳐서 일일이 각각의 값을 받고록 했다. 만약 이것을 감내하고 @ModelAttribute를 쓰겠다면 웹브라우저에서 JSON 문자열 파싱할때 조금 고민하며 코딩해야 할것이다.

 

@ModelAttribute를 얘기하다가 설명이 약간 삼천포로 빠졌지만..이 포스팅에서 다루는 Validation 방법은 서비스 로직을 이용한 Validation이다. 단순히 값의 성질(예를 들면 문자열의 길이나 값의 범위, Null 여부 등)을 검증하는 것은 여러곳에 나와 있다. 그러나 검증을 할때는 이런 단순한 것 뿐만 아니라 복잡한 것도 검증하는 상황이 올 수 있다. 이번 포스팅에서 설명할 예제로 설명을 하자면 등록하고자 하는 글의 제목이 기존에 등록되어 있는지를 확인하고자 하는 경우(물론 게시판에서 게시물을 등록할때 게시물의 제목에 대한 중복여부를 체크하지는 않는다. 다만 이번 포스팅에서 설정을 그렇게 잡았다) 제목을 Database에서 조회해서 해당 제목이 등록되어 있는지 확인해야한다. 이럴 경우 단순한 코딩으로는 할 수가 없다. Spring에서 DB를 사용하는 서비스 로직을 불러와서 검증을 해야 가능한 부분이다. 이렇게 단순란 값의 성질이 아닌 서비스를 이용해서 어떻게 Model을 검증하는지를 설명하고자 한다. 그리고 이런 검증 로직을 Controller에서 직접 구현하는 것이 아니라 사용자 값을 저장하는 Model 클래스 객체에 @NotNull, @Min 같은 annotation을 붙여서 이를 검증하도록 하고자 한다. 정리하자면 범용적인 중복 여부를 체크하는 custom annotation을 만들어 이를 Model에 적용하면 이 annotation을 이용해서 검증을 한뒤 문제가 발생할 경우 관련 error 메시지를 등록화면에 보내주는 기능을 할 것이다.

 

위에서 범용적인 중복여부를 체크한다고 했다. 우리가 어떤 값에 대해 중복여부를 체크할때 대부분 이런 형태로 Query를 만들것이다.

 

SELECT COUNT(컬럼명) FROM 테이블명 WHERE 컬럼명 = 사용자가 입력한 중복 여부 체크 값

 

즉 외부에서 테이블명과 컬럼명, 그리고 사용자가 중복 여부를 체크하기 위한 값 이렇게 3가지를 입력해주면 중복 체크를 할 수 있다. 그럼 이 값들을 어디서 입력해주면 좋을까? 우리가 Model 클래스를 설계할때 만드는 Model의 속성이 어느 테이블의 컬럼과 연관이 되는지를 알 수 있다. 그래서 Model 클래스를 코딩하는 시점에 중복 여부를 체크하는 속성을 정의할때 annotation으로 어떤 테이블의 어떤 컬럼을 쿼리하면 되는지를 지정해주면 편리할 것이다. 다음은 그러한 생각을 갖고 만든 Model 클래스 소스 코드이다.

 

package com.terry.boardprj.vo;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import com.terry.boardprj.common.ExistCheck;

public class BoardModel {
    
    int idx;
    
    @NotNull
    @Size(min=1)
    @ExistCheck(tableName="BOARD", columnName="TITLE", fieldName="제목")
    String title;
    String content;
    String writer;
    String datetime;
    
    // getter, setter는 생략
}

 

title 멤버변수를 보자. 이 멤버 변수에는 JSR-303의 annotation을 이용한 검증작업으로 @NotNull, @Size를 사용하고있다. 그런데 전혀 생뚱맞은 annotation이 하나 있다. @ExistCheck란 annotation이 있다. Google에서 검색해보면 아마 이 annotation이 검색이 안될것이다. 그도 그럴것이 이 annotation이 지금부터 만들 범용적인 중복 여부를 체크를 하는 annotation이기 때문이다. 이 annotation을 보면 tableName이란 속성에 BOARD를, columnName이란 속성에 TITLE을, fieldName에 제목 이란 값을 넣고 있다. 위에서 설명했던 범용적인 중복 체크 쿼리문을 생각해보자. 이 속성은 Database에서 중복체크를 하기 위해 입력받아야 하는 테이블명과 컬럼명이다(fieldName 속성은 중복여부와는 관련이 없다. 이 속성을 정의한 이유에 대해서는 나중에 설명하겠다) 그래서 사용자가 입력한 title 값이 BOARD 테이블의 TITLE 컬럼에 이미 있는지를 @ExistCheck annotation이 하게 된다.

 

@ExistCheck annotation의 코드를 보자. 여기서는 annotation 자체에 대한 설명을 하지는 않는다. annotation 자체가 궁금하면 검색하길 바란다.

 

package com.terry.boardprj.common;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy={ExistCheckValidator.class})
public @interface ExistCheck {

    String message() default "{ExistCheck}";
    Class[] groups() default {};
    Class[] payload() default {};
    
    String tableName() default "";
    String columnName() default "";
    String fieldName() default "";
    
}

 

message, groups, payload는 검증 작업을 하는 annotation을 만드는 경우 반드시 있어야 하는 속성이다. message의 경우 default로 {ExistCheck}로 설정되어 있다. @ExistCheck로 검증을 하는 과정에서 문제가 있을 경우 Spring에 셋팅되어 있는 MessageSource에 정의되어 있는 에러코드를 통하여 관련 메시지를 보내게 되어 있다. 그러나 MessageSource에 표현하고자 하는 에러코드에 대한 메시지 값을 찾지 못할 경우 ExistCheck 라는 에러코드로 등록되어 있는 메시지 값을 default로 보여준다는 의미이다. tableName, columnName, fieldName 속성은 무슨 값이 들어갈지 이미 눈치챘을것이다. 여기서 눈 여겨봐야 할 코드는 이 부분이다.

 

@Constraint(validatedBy={ExistCheckValidator.class})

 

@Constraint annotation은 만들고자 하는 annotation이 어떤 검증 클래스를 이용해서 검증 작업을 하는지를 지정해준다. JSR-303 같은 annotation을 이용한 검증 작업은 javax.validation.ConstraintValidator 인터페이스를 구현한 클래스를 만들어서 이를 annotation에 등록해주면 된다. ExistCheckValidator 클래스가 바로 ConstraintValidator 인터페이스를 구현한 클래스로 검증 작업을 하게 될 클래스가 된다.

 

ExistCheckValidator 클래스의 코드를 보자. 위에서 언급했듯이 이 클래스가 사용자가 입력한 값을 검증 작업을 하게 된다. 그것을 알고 코드를 보길 바란다

 

package com.terry.boardprj.common;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.beans.factory.annotation.Autowired;

import com.terry.boardprj.service.ValidateService;

public class ExistCheckValidator implements ConstraintValidator<ExistCheck, Object> {

    @Autowired
    ValidateService service;
    
    String tableName;
    String columnName;
    
    
    @Override
    public void initialize(ExistCheck constraintAnnotation) {
        // TODO Auto-generated method stub
        this.tableName = constraintAnnotation.tableName();
        this.columnName = constraintAnnotation.columnName();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        // TODO Auto-generated method stub
	
        if(service.existCheck(tableName, columnName, value.toString()) == true){
            return false;
        }else{
            return true;
        }
    }

}

 

ConstraintValidator 인터페이스를 구현한 클래스의 기본적인 구조는 다음의 형태이다. 

 

public class 클래스명 implements ConstraintValidator<A, T> {

     @Override
     public void initialize(A a) {
    
     }

     @Override
     public boolean isValid(T value, ConstraintValidatorContext context) {

     }
}

 

ConstraintValidator<A, T>에서 A에는 이 클래스를 이용하게 되는 annotation을 설정한다. 그래서 앞에서 만든 annotation인 ExistCheck가 들어가 있다. 그리고 T에는 이 클래스를 이용하는 annotation이 붙을 속성의 클래스 타입이 들어간다. 만약 해당 annotation이 String 타입의 속성에 붙는다면 T에는 String이 들어가면 된다. 그러나 지금 만드는 annotation은 범용적으로 적용할 것이기 때문에 String으로 한정지으면 안된다. 그래서 Object 타입으로 정의했다. initialize 함수는 이 클래스가 객체로 만들어질때의 초기화 작업을 수행하는 함수로 함수의 파라미터에 이 클래스가 이용되는 annotation을 설정한다. 즉 annotation에서 관련 정보를 읽어 초기화 작업을 하는 것이다. 마지막으로 valid 함수는 T 로 정의한 클래스 객체의 값을 읽어 검증작업을 수행하게 되는데 이 함수가 true를 return 하면 검증 작업을 잘 마친것이고, false를 return 하면 검증에 문제가 생겼음을 의미한다. 이런 내용을 알고 다음의 내용을 보기 바란다.

 

이제 이 클래스를 이용해 중복여부를 검증하는 작업을 할려면 무엇이 필요할까? 사용자가 입력한 값은 당연히 있어야 하는거고, 위에서 언급한 쿼리에서 보여주었다시피 중복을 체크할 컬럼과 테이블 명도 있어야 한다. 그리고 이렇게 받은 값을 이용해서 검증할 Spring에 설정해둔 Service가 있어야 한다. 해당 서비스의 함수에 우리가 받은 테이블명, 컬럼명, 검증해야 할 값을 같이 넘겨 기존에 등록되어 있는지를 확인해야 하는 것이다. 그러면 이런 값들을 어떻게 받아야 할까? 실제적인 검증 작업을 수행하는 isValid 함수는 검증 작업을 수행하는 ExistCheck annotation이 붙은 객체를 받을수 있다. 그래서 중복 체크를 하고자 하는 값을 읽어올수는 있다. 그러나 중복 체크를 하기위해 필요한 테이블명과 필드명을 읽어올려면 어떻게 해야 할까? initiallize 함수를 보면 함수의 파라미터로 ExistCheck annotation 객체가 들어간다. 그렇기때문에 initialize 함수에서 ExistCheck annotation에 있는 테이블명과 필드명을 읽으면 된다. 그러나 isValid 함수에서 읽어들인 테이블명과 필드명을 사용해야 하기 때문에 ExistCheckValidator 클래스의 멤버변수인 tableName과 columnName에 테이블명과 필드명을 넣게 된다. 마지막으로 검증작업을 하는 인터페이스인 ValidateService를 구현한 객체가 DI가 되도록 설정한뒤에 isValid 함수에서 ValidateService의 existCheck 함수에 테이블명, 컬럼명, 중복체크하고자 하는 값을 넘겨서 중복인지 아닌지를 판단하여 그에 맞춰 true나 false를 return 하게 되는 것이다.

 

그리고 중요한거..사람들이 아주 기본적인 것을 간혹 망각할때가 있다. Spring에서 사용되는 Bean을 자동으로 DI를 받을려면 해당 Bean을 사용하는 클래스도 Spring의 Bean으로 등록이 되어야 한다는 것이다. 그렇기 땜에 Spring에 등록된 ValidateService Bean의 객체를 DI 받기 위해 ExistCheckValidator도 Spring의 Bean으로 등록해야 한다는 점이다. 클래스를 만드는 시점에 @Component annotation을 붙이거나 다음과 같이 해당 Context의 XML에 선언해줘야 한다.

 

<bean class="com.terry.boardprj.common.ExistCheckValidator" />

 

이 내용을 보면서 의아해 하는 사람도 있을 것이다. 일반적으로 Spring에서 Bean을 사용할 경우 싱글톤이다. 즉 객체 1개를 생성한뒤에 이를 여러 곳에서 사용하는 것이다. 그래서 Bean을 만들때는 상태값을 가지는 멤버변수를 만들면 안된다. 근데 지금 ExistCheckValidator는 상태값을 갖는 멤버변수가 있다. tableName과 columnName이 그것이다. 만약 누군가가 검증 작업을 하던 도중 다른 사람이 검증 작업을 하게 되면 엉뚱한 내용으로 셋팅될 가능성이 있지 않을까? 이 부분은 걱정하지 않아도 된다. 원리는 알 수 없었으나 이렇게 싱글톤으로 XML에 셋팅되어도 검증 하는 작업 시점에서는 단일 객체로 사용되질 않았다.(이를 검증하는 방법은 이클립스에서 WAS를 디버그 모드로 실행한후 initialize 함수 내부의 코드 아무데나 중단점을 설정한뒤 검증 작업을 진행해보면 이 중단점 설정한데서 진행이 멈춰지게 되는데 Debug Perspective에서 Variables 를 보면 this란 항목이 있고 그 항목이 가리키는 것은 ExistCheckValidator이다. 이 this를 마우스로 클릭해보면 하단에 패키지와 클래스명과 주소가 나타나는데 주소 부분이 매번 달랐다.만약 싱글톤이라면 이 주소 부분이 항상 동일해야 한다)

 

XML로 정의하고 @Component annotation을 사용하지 않은데는 이유가 있었다. 검증 작업이 발생하는 위치는 Servlet Context에 등록이 되는 @Controller로 정의된 Bean들에게서 발생하게 된다. 근데 XML을 사용하지 않고 @Component를 사용하여 정의 할 경우 Servlet Context에 @Component annotation이 붙은것도 scan을 해주어야 한다. 하지만 일반적으로 @Component로 정의된 것들은 Root Context에서 관리되게끔 하는 편이다. 물론 ExistCheckValidator에 @Component annotation을 붙이고 이것을 Root Context에 scan이 되게끔 해주어도 돌아가는데는 지장이 없다. 그러나 Servlet Context에서만 사용되는 Bean이라면 Servlet Context에서만 관리되게끔 하는 것이 맞는지라 @Component annotation을 사용하지 않고 Servlet Context XML에 Bean을 등록했다. 만약 이런 검증 작업이 많다면 @Service annotation같이 @Component annotation을 상속받는 별도의 Custom annotation을 만든뒤에 이 Custom annotation을 검증받는 클래스 정의시 같이 지정해주고 이 annotation을 Servlet Context에서 scan이 되게끔 해줘도 된다.(@Service annotation 소스를 보면 @Component annotation을 상속받는 Custom annotation을 만드는 방법을 알수 있다)

 

다음 글에서는 ValidateService를 구현한 클래스와 Dao, 그리고 Controller에서 사용한 코드를 설명하도록 하겠다