본문 바로가기
[ JAVA ]/JAVA Spring Security

[ Spring Boot ] Spring Security - 회원가입 Validation Check

by 환이s 2023. 10. 4.


이전 포스팅에서 Spring Security를 활용해서 회원가입 및 로그인 기능을 코드 구현으로 알아보았습니다.

 

 

[ Spring Boot ] Spring Security - 기본 개념 및 예제

취준 활동을 끝내고 백엔드 개발자로 경력을 쌓기 위한 회사를 찾기 위해 이곳저곳 면접을 다니고, 회사 소스 코드 파악 및 프로젝트 투입 준비를 하다 보니 블로그를 소홀하게 관리했네요.. 오

drg2524.tistory.com

 

 

[ Spring Boot ] Spring Security - 회원가입 및 로그인

이전 포스팅에서 security 기본 개념에 대해 알아봤습니다. Spring Security에 대해 궁금하다면 아래 포스팅을 참고하면 될 것 같습니다. [ Spring Boot ] Spring Security - 기본 개념 및 예제 취준 활동을 끝내

drg2524.tistory.com

 

오늘은 사용자가 회원가입을 진행했을 때 입력한 데이터 값이 서버로 전송되기 전에 특정 규칙에 맞게 입력되었는지, 존재하는 유저 체크 등을 확인하는 검증 단계에 필요한 Validation 유효성 검증 단계를 구현해 보겠습니다.

 


gradle

 

유효성 검사를 위해 추가할 어노테이션 @Valid를 이용해서 유효성 검사와 아이디 중복 검사를 확인하는 검증 단계를 구현하려고 하는데, 그전에 gradle 파일에   Validation  Dependencies 해주셔야 합니다.

 

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

MemberDTO

 

유효성 검사를 위해 validation 어노테이션을 사용합니다.

 

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

import lombok.*;


@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder 
public class MemberDTO {

	private Long idx;  // 번호
	private Long num; // rownum 
	@NotBlank(message = "아이디는 필수 입력 값입니다.")
	@Pattern(regexp = "^[a-z]{1}[a-z0-9]{5,10}+$", message = "아이디는 영문과 숫자 조합하여 7~16자이내로 입력해주세요.")
	private String userid;
	
	
	@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
	@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
	private String pwd;
	
	@NotBlank(message = "이름은 필수 입력 값입니다.")
	@Pattern(regexp = "^[가-힣]*$" , message = "이름은 한글만 사용 가능합니다.")
	private String username; // 이름 
	
	@NotBlank(message = "전화번호를 입력해주세요.")
	@Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$" ,message = "전화번호는 앞자리는 01이며, 중간 3~4자리, 세번째는 4자리인 전화번호를 입력해주세요.")
	private String userphone; // 전화번호
	
	@NotBlank(message = "주소는 필수 입력 값입니다.")
	@Pattern(regexp = "^[가-힣0-9\\p{Punct}\\s]+$" ,message = "주소는 도로명 주소로 형식에 맞춰 진행해주세요. ")
	private String useraddr; //주소 
	
	private String zipcode;
	private String basicaddr;
	

	private String isEnabled; // 사용 가능 여부
	private Integer failCnt;   // 실패 횟수
	private String usergrade; // 사용자 등급  권한 > user - manager - admin
	
	
	private String delete_yn; // 삭제 여부
	
	private String create_user; // 생성자
	private Object create_date; // 생성일자
	
	private String update_user; //수정자
	private Object update_date; //수정일자

 

혹시나 JS , JAVA  클래스 파일에 정규표현식을 사용하는 경우, 구글링 하셔서 복붙하시는 분들은 JS에 사용되는 정규표현식은 JAVA 클래스 파일에 적용이 안됩니다. 

정규표현식을 대부분 구글링 해서 사용하시는 분들이 간혹 이런 실수를 하시기 때문에 적어보았습니다..^^

위 패턴에 적용된 정규표현식은 JAVA 클래스에 맞춰서 적용해 보았습니다. 

Controller

 

@PostMapping("/auth/join")
	public String join(@Valid MemberDTO dto, Errors errors, Model model, HttpServletResponse response)
			throws Exception {

		logger.info("dto = {}", dto);

		if (errors.hasErrors()) {
			// 회원가입 실패 시 데이터 유지
			model.addAttribute("memberDto", dto);

			// 유효성 통과 못한 필드, 메시지 핸들링
			Map<String, String> validatorResult = memberservice.validateHandling(errors);

			for (String key : validatorResult.keySet()) {
				model.addAttribute(key, validatorResult.get(key));

			}
			return "join";
		}

		memberservice.save(dto);

		ScriptUtils.alertAndMovePage(response, "회원가입 성공! 로그인 페이지로 이동합니다.", "/auth/loginForm");

		return "";
	}

 

Controller에서는 매개변수로 사용될 DTO 객체 앞에 @Vaild 어노테이션을 사용하고, Errors를 통해 유효성 검사 적합 여부를 확인합니다.

 

여기서 Errors는 반드시 DTO(Request) 객체 바로 뒤에 위치해야 합니다.
두 개의 객체에 validation 검사를 진행한다면, 각각 객체 뒤에 Errors를 받도록 합니다.

 

errors.hasErrors() 메서드로 유효성 검사에 실패한 필드가 있는지 확인하고, 

 

memberDto를 Model에 담아줘서 회원가입 실패 시, 회원가입 페이지에서 입력했던 정보들을 그대로 유지하기 위해 입력받았던 데이터를 그대로 할당해 줍니다.

 

유효성 검사에 실패한 필드가 있다면, memberservice.validateHandling(errors) 즉, service 계층으로 Errors 객체를 전달하여 비즈니스 로직을 구현하고 Model에 담아줍니다.

 


Service

 

@Transactional(readOnly = true)
	public Map<String, String> validateHandling(Errors errors) {
		Map<String, String> validatorResult = new HashMap<>();

		// 유효성 검사에 실패한 필드 목록 받기
		for (FieldError error : errors.getFieldErrors()) {
			String validKeyName = String.format("valid_%s", error.getField());
			validatorResult.put(validKeyName, error.getDefaultMessage());
		}

		return validatorResult;
	}

 

유효성 검사에 실패한 필드들은 Map을 통해 응답할 수 있게 넣어줍니다.

 

여기까지 정규표현식 검증을 할 수 있게 코드 구현을 해보았습니다.

 

다음으로는 중복 체크를 해야 하는데, Validation 어노테이션으로는 단일 필드에 대한 유효성 검증만 처리가 가능하기 때문에, validation 어노테이션으로는 불가능했습니다.

 

그럼 어떤 방식으로 해결했는지 말씀드리자면 Validation을 커스터마이징 하는 방법으로 해결할 수 있었습니다.


MemberRepository

 

@Mapper
public interface MemberRepository {

	 MemberDTO findByUserName(Map<String, Object> param);
	
	 void save(MemberDTO dto);
	
	 boolean existsByUserId(String userid); // 추가

 

existsByUserId(userid)의 역할은 해당 데이터가 DB에 존재하는지 확인하기 위해 추가로 작성했습니다. 

 

해당 데이터가 존재할 경우 true, 존재하지 않을 경우 false가 리턴됩니다.

 


Mapper

 

<select id="existsByUserId" resultType="boolean">
		SELECT 
			COUNT(*)
		FROM
			userinfo_tbl
		WHERE 
			USERID = #{userid}
</select>

Validator를 구현한 AbstractValidator <T> 생성

 

import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public abstract class AbstractValidator<T> implements Validator {
	
	
	@Override
	public boolean supports(Class<?> clazz) {
		return true;
	}
	
	
	@SuppressWarnings("unchecked") // 컴파일러에서 경고하지 않도록 하기 위해 설정
	@Override
	public void validate(Object target, Errors errors) {
		try { // 검증 로직 
			doValidate((T) target, errors);
		} catch (RuntimeException e) {
			log.error("중복 검증 에러 발생 !! " , e);
			throw e;
		}
		
		
		
	}


	protected abstract void doValidate(T target, Errors errors);
	
	

}

 

validate를 구현했고, 검증 로직이 들어갈 부분을 doValidate로 별도로 빼주었습니다.

 


CheckUserIdValidator Class Create

 

package com.example.task.boradTable.config.auth;

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;

import com.example.task.boradTable.domain.MemberRepository;
import com.example.task.boradTable.web.dto.MemberDTO;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Component // bean 등록을 위한 컴포넌트 등록
public class CheckUserIdValidator extends AbstractValidator<MemberDTO>{
	
	private final MemberRepository memberRepository;

	@Override
	protected void doValidate(MemberDTO dto, Errors errors) {
		if (memberRepository.existsByUserId(dto.toEntity().getUserid())) {
			errors.rejectValue("userid", "아이디 중복 오류", "이미 사용중인 아이디 입니다.");
		}
		
	}

}

 

doValidate를 구현해 검증 로직을 작성하고 Bean으로 등록될 수 있도록 @Component 등록을 해줍니다.


Controller

 

private static final Logger logger = LoggerFactory.getLogger(MemberController.class);

	private final MemberService memberservice;
	private final BoardService boardservice;

	private final CheckUserIdValidator checkUserIdValidator;

	@InitBinder // 바인딩, 검증
	public void validatorBinder(WebDataBinder binder) {
		binder.addValidators(checkUserIdValidator);
	}

 

Validator를 사용하기 위해 @InitBinder 어노테이션을 사용합니다.

 

WebDataBinder를 인자로 받는 메서드를 작성해 검증 Validator를 추가했습니다.

 

@InitBinder 어노테이션은 특정 Controller에서 바인딩, 검증 설정을 변경하고 싶을 때 사용하고 WebDataBinder는 HTTP 요청 정보를 Controller 메서드의 파라미터나 Model 바인딩할 대 사용되는 바인딩 객체입니다.

 


Mustache(View)

 

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>JOIN</title>
</head>
<body>
{{>layout/header}}
<div id="posts_list">
    <div class="container col-md-8">
        <form method="post" modelAttribute="memberDto" name="userJoin" id="userJoin">
        	<!-- mustache는 csrf 토큰을 제공해주지 않는다. yml 파일에 설정 함. -->
            <input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
            <div class="form-group">
                {{#idx}}
                <label>번호</label>
                <input type="text" name="idx" id="idx" value="{{idx}}" class="form-control" readonly/>
                {{/idx}}
            </div>
            <div class="form-group">
                <label>아이디</label>
                <input type="text" name="userid" id="userid" value="{{#memberDto}}{{memberDto.userid}}{{/memberDto}}" onkeydown="noSpaceForm(this);" maxlength="16" class="form-control" placeholder="아이디를 입력해주세요"/>
                {{#valid_userid}} <span id="valid">{{valid_userid}}</span> {{/valid_userid}}
            </div>
 
            <div class="form-group">
                <label>비밀번호</label>
                <input type="password" name="pwd" id="pwd" value="{{#memberDto}}{{memberDto.pwd}}{{/memberDto}}" onkeydown="noSpaceForm(this);" maxlength="16" class="form-control" placeholder="비밀번호를 입력해주세요"/>
                {{#valid_pwd}} <span id="valid">{{valid_pwd}}</span> {{/valid_pwd}}
            </div>
            <div class="form-group">
                <label>이름</label>
                <input type="text" name="username" id="username" value="{{#memberDto}}{{memberDto.username}}{{/memberDto}}" onkeydown="noSpaceForm(this);" maxlength="6"  class="form-control" placeholder="이름을 입력해주세요"/>
                {{#valid_username}} <span id="valid">{{valid_username}}</span> {{/valid_username}}
            </div> 
 
             <div class="form-group">
                <label>전화번호</label>
                <input type="text" name="userphone" id="userphone" value="{{#memberDto}}{{memberDto.userphone}}{{/memberDto}}" onkeydown="noSpaceForm(this);" maxlength="13"  class="form-control" placeholder="전화번호는 하이푼(-) 포함하여 입력해주세요"/>
                {{#valid_userphone}} <span id="valid">{{valid_userphone}}</span> {{/valid_userphone}}
            </div> 
 
 			 <div class="form-group">
 			 	<label>우편번호</label><br>
 			 	<input type="text" name="zipcode" id="zipcode" value="{{#memberDto}}{{memberDto.zipcode}}{{/memberDto}}"  placeholder="우편번호 찾기" readonly>
 			 	<button type="button" class=" btn-outline-success" onclick="daumZipcode()">ZipCode</button>
 			 	
            </div> 
            <div class="form-group">
 			 	<label>일반주소</label><br>
 			 	<input type="text" name="basicaddr" id="basicaddr" value="{{#memberDto}}{{memberDto.basicaddr}}{{/memberDto}}" readonly>
 			 	
            </div> 
            <div class="form-group">
            	<label>상세주소</label>
                <input type="text" name="useraddr" id="useraddr" value="{{#memberDto}}{{memberDto.useraddr}}{{/memberDto}}"  maxlength="50"   class="form-control" placeholder="주소를 입력해주세요"/>
                {{#valid_useraddr}} <span id="valid">{{valid_useraddr}}</span> {{/valid_useraddr}}
            </div>
            <br>
            <br>
            <div style=" margin-left: 40%;">
            <button type="button" class="btn btn-primary bi bi-person" onclick="joinJs()"> 가입</button>
            <button type="button" class="btn btn-info bi bi-arrow-return-left" onclick="loginForm()">뒤로가기</button>
 			</div>
        </form>
    </div>
</div>
<script src="//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
{{>layout/footer}}
</body>
</html>

JS

 

mustache 파일 입력란 박스에서 공백이 들어오지 않게 자바 스크립트로 먼저 유효성 체크를 진행합니다.

 

function joinJs() {
	
	const userid = $('#userid').val();

	if (userid == "") {
		alert("아이디는 필수 입력해주세요.");
		$('#userid').focus();
		return;
	}

	const pwd = $('#pwd').val();

	if (pwd == "") {
		alert("비밀번호를 입력해주세요.");
		$('#pwd').focus();
		return;
	}

	const username = $('#username').val();

	if (username == "") {
		alert("이름을 입력해주세요.");
		$('#username').focus();
		return;
	}

	const userphone = $('#userphone').val();

	if (userphone == "") {
		alert("전화번호를 입력해주세요.");
		$('#userphone').focus();
		return;
	}

	const useraddr = $('#useraddr').val();

	if (useraddr == "") {
		alert("주소를 입력해주세요.");
		$('#useraddr').focus();
		return;

	}
	
	const zipcode = $('#zipcode').val();
	
	if (zipcode == "") {
		alert("ZipCode 버튼을 눌러서 주소 조회를 해주세요.");
		$('#zipcode').focus();
		return;

	}




	console.log("userid = " + userid);
	console.log("pwd = " + pwd);
	console.log("custname = " + username);
	console.log("phone = " + userphone);
	console.log("address = " + useraddr);
	
	

	document.userJoin.action = "/auth/join";
	document.userJoin.submit();

}
function daumZipcode() {
	
	new daum.Postcode({
	        oncomplete: function(data) {
	            var addr = '';
	            var extraAddr = '';
	            if (data.userSelectedType === 'R') {
	                addr = data.roadAddress;
	            } else {
	                addr = data.jibunAddress;
	            }
	            if(data.userSelectedType === 'R'){
	                if(data.bname !== '' && /[동|로|가]$/g.test(data.bname)){
	                    extraAddr += data.bname;
	                }
	                if(data.buildingName !== '' && data.apartment === 'Y'){
	                    extraAddr += (extraAddr !== '' ? ', ' + data.buildingName : data.buildingName);
	                }
	                if(extraAddr !== ''){
	                    extraAddr = ' (' + extraAddr + ')';
	                }
	                document.getElementById("basicaddr").value = extraAddr;
	            } else {
	                document.getElementById("useraddr").value = '';
	            }
	            document.getElementById('zipcode').value = data.zonecode;
	            document.getElementById("basicaddr").value = addr;
	            document.getElementById("useraddr").focus();
	        }
	    }).open();

}

function noSpaceForm(obj) {
	
	const str_space=/\s/;
	if(str_space.exec(obj.value)){
		obj.focus();
		obj.value = obj.value.replace(/\s| /gi,'');
		return false;
	}
	
}

 

여기까지 Validation을 커스텀하기 위해 Validator를 구현하고 검증 로직을 작성하면 중복체크 또한 유효성 검사에 포함시킬 수 있습니다.

 

해당 Validator를 사용하기 위해서는 Controller에 @InitBinder가 붙은 WebDataBinder를 파라미터로 받는 메서드를 반드시 추가해주셔야 합니다.


마치며

 

오늘은 회원가입 Validation 체크를 위한 코드 구현을 해보았습니다.

다음 포스팅에서 뵙겠습니다.

728x90