회원가입 기능 구현 1 - 스프링, jsp, 오라클, mybatis
회원가입 기능을 구현하는 방법에 대해 정리한 글입니다.
1단계 : 아래 첨부한 사진의 오른쪽 맨 끝에처럼 <a> 태그를 만들어 줍니다.
<a href="/login-form" id="sign-in">SIGN IN</a>
<a href="/terms-of-use" id="sign-up">SIGN UP</a>
navbar.jsp
2단계 : SIGN UP 이라는 <a>태그 를 클릭했을 때, 전송될 HTTP 요청메세지를 받을 컨트롤러를 아래와 같이 만들어 줍니다.
@GetMapping("/terms-of-use")
public String getTermsOfUseControllerMethod(){
return "/login/termsOfUse";
}
3단계 : termsOfUse.jsp 라는 페이지가 렌더링될 수 있도록 합니다.
약관에 쓰여져 있는 글은 지금 별로 중요하지 않기 때문에, 첫문장만 남기고 모두 제거해서 아래에 코드를 남겨놨습니다.
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<html>
<head>
<link rel="stylesheet" href="../../css/login/termsOfUse.css"
</head>
<body>
<div class="full-container">
<div class="terms-of-use-container">
<form action="/terms-of-use" method="post">
<!-- 첫번째 체크박스 -->
<input type="checkbox" id="myCheckbox1" name="myCheckbox1" class="custom-checkbox">
<label for="myCheckbox1">
<span class="essential-option">
[필수]
</span>
단어충전소 이용약관
</label>
<div class="terms-container">
여러분을 환영합니다.
</div>
<br/>
<!-- 두번째 체크박스 -->
<input type="checkbox" id="myCheckbox2" name="myCheckbox2" class="custom-checkbox">
<label for="myCheckbox2">
<span class="essential-option">
[필수]
</span>
개인정보 수집 및 이용
</label>
<div class="terms-container">
개인정보보호법에 따라 단어충전소에 회원가입 신청하시는 분께 수집하는 개인정보의 항목, 개인정보의 수집 및 이용목적, 개인정보의 보유 및 이용기간, 동의 거부권 및 동의 거부 시 불이익에 관한 사항을 안내 드리오니 자세히 읽은 후 동의하여 주시기 바랍니다.
</div>
<br/>
<!-- 세번째 체크박스 -->
<input type="checkbox" id="myCheckbox3" name="myCheckbox3" class="custom-checkbox">
<label for="myCheckbox3">
<span class="selectable-option">
[선택]
</span>
개인정보 수집 및 이용
</label>
<div class="terms-container">
단어충전소 및 제휴 서비스의 이벤트・혜택 등의 정보 발송을 위해 단어충전소 아이디(아이디 식별값 포함),
휴대전화번호(단어충전소 앱 알림 또는 문자), 이메일주소를 수집합니다.
</div>
<br/><br/>
<button type="submit" id="submit-btn"> 다음 </button>
</form>
</div>
</div>
<script src="../../js/login/termsOfUse.js"> </script>
</body>
</html>
termsOfUse.jsp
document.addEventListener('DOMContentLoaded', function(){
const checkbox1 = document.getElementById('myCheckbox1');
const checkbox2 = document.getElementById('myCheckbox2');
const submitButton = document.querySelector('#submit-btn');
// 버튼 초기 상태 설정
submitButton.disabled = true;
submitButton.style.color = "#636363";
submitButton.style.backgroundColor = "#a8a8a8";
// 체크박스 상태 변경 시 버튼 상태와 스타일 업데이트
function updateButtonState() {
const isEnabled = checkbox1.checked && checkbox2.checked;
//둘 다 체크되었다면,
if(isEnabled) {
//submitButton 의 disabled(클릭할 수 없음) 이 false 로 되어라.
submitButton.disabled = false;
submitButton.style.backgroundColor = "#478eff";
submitButton.style.color = "#fff";
}else{
submitButton.disabled = true;
submitButton.style.backgroundColor = "#a8a8a8";
submitButton.style.color = "#636363";
}
}
// 각 체크박스에 이벤트 리스너 추가
checkbox1.addEventListener('change', updateButtonState);
checkbox2.addEventListener('change', updateButtonState);
});
위 코드는 termsOfUse.jsp 와 연결된 termsOfUse.js(자바스크립트 파일) 입니다.
위 코드 중
submitButton.disabled = true;
은 다음 버튼은 [필수] 항목을 모두 체크했을 때에만 누를 수 있도록 하기 위해서, 기본값을 disabled = true 로 해두었습니다.
그리고,
checkbox1.addEventListener('change', updateButtonState);
checkbox2.addEventListener('change', updateButtonState);
이렇게 [필수] 인 체크박스의 상태가 바뀔때마다 이벤트리스너로 updateButtonState 라는 함수가 실행되도록 하였습니다.
그리고, updateButtonState 라는 함수에서는 만약 필수인 항목들이 모두 체크되어 있다면, 다음 버튼을 활성화하도록 하였습니다.
4단계 : termsOfUse.jsp 파일의 form이 제출됬을 경우, 이 요청을 받을 컨트롤러를 만듭니다.
@PostMapping("/terms-of-use")
public String postTermsOfUseControllerMethod(@RequestParam(required = false) String myCheckbox1,
@RequestParam(required = false) String myCheckbox2,
@RequestParam(required = false) String myCheckbox3){
return "redirect:/Join-form?" + "myCheckbox1=" + myCheckbox1+ "&" + "myCheckbox2=" + myCheckbox2+ "&" + "myCheckbox3=" + myCheckbox3;
}
myCheckbox1 과 myCheckbox2 는 [필수] 이기 때문에, 체크가 되어있었을 것이므로, on 이 올 것이다.
그리고, myCheckbox3 는 체크가 되어있었다면, on 이 넘어와 있을 것이고, 체크되어 있지 않았다면 null 이 넘어와 있게 된다.
리다이렉트를 하면서 쿼리스트링으로 myCheckbox1, myCheckbox2, myCheckbox3 의 값을 넘겨주었다.
5단계 : 리다이렉트되서 다시 요청된 /Join-form url을 매핑할 컨트롤러를 만듭니다.
@GetMapping("/Join-form")
public String getJoinFormControllerMethod(@RequestParam String myCheckbox1,
@RequestParam String myCheckbox2,
@RequestParam String myCheckbox3,
Model model
){
MemberJoinDTO memberJoinDTO = new MemberJoinDTO();
memberJoinDTO.setMyCheckbox1(myCheckbox1);
memberJoinDTO.setMyCheckbox2(myCheckbox2);
memberJoinDTO.setMyCheckbox3(myCheckbox3);
model.addAttribute("memberJoinDTO", memberJoinDTO);
return "/login/joinForm";
}
코드의 흐름대로 설명을 이어가자면, 우선 MemberJoinDTO 타입의 객체를 만듭니다.
@Data
public class MemberJoinDTO {
private Integer id;
private String userId;
private String password;
private String userName;
private String delete_member_fl;
private String zipCode;
private String phoneNumberStart;
private String phoneNumberMiddle;
private String phoneNumberEnd;
private String streetAddress;
private String address;
private String detailAddress;
private String referenceItem;
private String myCheckbox1;
private String myCheckbox2;
private String myCheckbox3;
private String email;
private String emailDomain;
private String customEmailDomain;
}
그리고 리다이렉트한 컨트롤러가 쿼리스트링으로 넘겨준 myCheckbox1, myCheckbox2, myCheckbox3 값을, 생성한 MemberJoinDTO 객체의 필드 값으로 바인딩 합니다.
그리고 나서, model 에 그 객체를 담아서 joinForm.jsp 파일로 제어권을 이동시킵니다.
들어가기 전에, validation 하는 방법을 최대한 많이 정리해두고 싶었기 때문에, 이 포스팅에서는 <form:form> 태그와 BindingResult 를 이용해서 validation 을 진행하는 방법, javascript 를 통한 방법을 섞어서 진행했습니다. 결론적으로 아이디 중복검사를 제외하고서는 굳이 검증을 위해서 서버까지 오지 않아도 되게끔, javascript 를 통해서 validation 을 진행하는 것이 맞지 않았나 생각합니다.
6단계 : validation
일단, joinForm.jsp 파일 중 <form:form>태그는 아래와 같습니다.
<form:form modelAttribute="memberJoinDTO" method="post">
<div class="id-password-container">
<form:input type="text" path="userId" id="user-id" class="input-tag" placeholder="아이디" style="display: inline-block;"/>
<span id="user-id-status" style="font-size:11px; display:inline-block;">
</span>
<div class="tag-for-verification-message" >
<form:errors path="userId" class="error-message"/>
</div>
<form:input type="password" path="password" id="user-password" class="input-tag" placeholder="비밀번호" style=" display:inline-block;"/>
<span id="user-password-status" style="font-size:11px; display:inline-block;">
</span>
<div style="font-size:14px;">
비밀번호: 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해 주세요.
</div>
<div class="tag-for-verification-message">
<form:errors path="password" class="error-message"/>
</div>
</div>
<div class="name-phone-container">
<form:input type="text" path="userName" id="name-input" class="input-tag" placeholder="이름" />
<div class="tag-for-verification-message" >
<form:errors path="userName" class="error-message"/>
</div>
<div>
<form:input path="phoneNumberStart" type="text" style="width:25%;" placeholder="000" class="input-tag"/>
<form:input path="phoneNumberMiddle" type="text" style="width:25%;" placeholder="0000" class="input-tag"/>
<form:input path="phoneNumberEnd" type="text" style="width:25%;" placeholder="0000" class="input-tag"/>
<br/>
<div class="tag-for-verification-message" >
<form:errors path="phoneNumberStart" style="width:25%;" class="error-message"/>
</div>
</div>
</div>
<div class="email-div" style="font-size: 12px; ">
<form:input path="email" style="width: 10vw; height: 60%;" />     @    
<form:input type="text" id="customEmailDomain" path="customEmailDomain" style="width: 7vw; display: none;" placeholder="도메인 직접 입력" />
<form:select path="emailDomain" style="width: 7vw; height: 80%;">
<form:option value="naver.com" label="naver.com" />
<form:option value="daum.net" label="daum.net" />
<form:option value="gmail.com" label="gmail.com" />
<form:option value="custom" label="직접 입력" />
</form:select>
</div>
<!--다음 주소 api 사용 시작-->
<div class="address-container">
<form:input type="text" path="zipCode" id="sample4_postcode" class="input-tag" placeholder="우편번호" readonly="true"/>
<form:errors path="zipCode" class="error-message"/>
<input class="address-btn" type="button" onclick="sample4_execDaumPostcode()" value="우편번호 찾기"><br>
<div>
<form:input type="text" path="streetAddress" id="sample4_roadAddress" class="input-tag" placeholder="도로명주소" style="margin-top:1.5px;" readonly="true" />
</div>
<div>
<form:input type="text" path="address" id="sample4_jibunAddress" class="input-tag" placeholder="지번주소" style="margin-top:1.5px;" readonly="true"/>
</div>
<div>
<span id="guide" style="color:#999;display:none"></span>
</div>
<div>
<form:input type="text" path="detailAddress" id="sample4_detailAddress" class="input-tag" placeholder="상세주소" style="margin-top:1.5px;" />
</div>
<div>
<form:input type="text" path="referenceItem" id="sample4_extraAddress" class="input-tag" placeholder="참고항목" style="margin-top:1.5px;" readonly="true" />
</div>
</div>
<div class="join-btn-div">
<input class="join-btn" type="submit" value="회원가입"/>
</div>
</form:form>
6단계 -1 : fetch 를 통한 아이디 중복검사
joinForm.jsp 파일 중 아래 부분을 봅시다.
<form:input type="text" path="userId" id="user-id" class="input-tag" placeholder="아이디" style="display: inline-block;"/>
<span id="user-id-status" style="font-size:11px; display:inline-block;">
그리고, 아래는 이와 관련된 fetch 를 통해 ajax 통신을 하는 js 코드입니다.
document.getElementById('user-id').addEventListener('input', function(){
var userId = this.value;
if(userId==''){
document.getElementById('user-id-status').innerText = '';
return; // 만약, 아무것도 입력하지 않은 상태라면, 이 함수를 빠져나가서 아무 문구도 안나오게 함.
}
//Fetch API를 사용하여 서버에 비동기 요청을 보냄
fetch('check-user-id', {
method:'POST',
body: JSON.stringify({userId: userId}),
headers: {
'Content-Type' : 'application/json'
}
})
.then(response => response.json())
.then(data => {
if(data.isAvailable){
document.getElementById('user-id-status').innerText='사용 가능한 아이디입니다';
document.getElementById('user-id-status').style.color='#0066ff';
}else{
document.getElementById('user-id-status').innerText='이미 사용 중인 아이디입니다.';
document.getElementById('user-id-status').style.color='red';
}
});
});
user-id 라는 id 를 가진 <form:input> 태그에 내용을 입력하는 이벤트가 발생할 때마다 이벤트리스너가 발생되도록 하였습니다.
우선, fetch 부분을 보면, 이 input 에 입력된 내용을 가지고, JSON 형식으로 서버와 통신하고 있습니다.
check-user-id 라는 url 에 매핑되는 컨트롤러와 컨트롤러 안에 있는 서비스계층 코드는 아래와 같습니다.
@PostMapping("check-user-id")
@ResponseBody
public Map<String,Boolean> duplicateIdValidationControllerMethod(@RequestBody Map<String,String> request){
Map<String, Boolean> returnMap = idDuplicateCheckService.idCheck(request);
return returnMap;
}
public Map<String, Boolean> idCheck(Map<String, String> request) {
String userId = request.get("userId");
MemberJoinDTO memberById = memberMapper.findMemberById(userId);
Map<String, Boolean> map = new ConcurrentHashMap<>();
if (memberById == null) {
map.put("isAvailable", true);
}else{
map.put("isAvailable", false);
}
return map;
}
json 형식의 데이터를 DTO객체를 따로 만들지 않고 그냥 @RequestBody 애노테이션과 Map자료구조로 받았다.
그리고, 그대로 idCheck라는 서비스계층으로 넘겨주었다. idCheck 라는 메서드에서는 request 라는 Map자료구조에 담긴 데이터(사용자가 input태그에 입력한 값)를 꺼낸 다음, 데이터베이스에 이와 동일한 아이디를 찾아오도록 했다.
마이바티스를 사용했는데, findMemberById 라는 메서드가 실행되면 실행되게 되는 SQL 문은 다음과 같다.
SELECT * FROM member
WHERE USER_ID = #{userId}
AND DELETE_MEMBER_FL = 'N'
</select>
그리고,
Map<String, Boolean> map = new ConcurrentHashMap<>();
if (memberById == null) {
map.put("isAvailable", true);
}else{
map.put("isAvailable", false);
}
return map;
이 부분으로 인해, 만약, 데이터베이스에 사용자가 입력한 아이디와 동일한 아이디가 없어서 null 이라면 isAvailable 이라는 key에 true가 담기도록 하였고, 만약 사용자가 입력한 아이디와 동일한 아이디가 있다면, false가 담기도록 하였다.
그래서, 이렇게 만들어진 Map 자료구조가 다시 fetch 구문으로 돌아가게 되면, isAvailable 이라는 key에 담긴 것이 true인지 false인지에 따라 user-id-status 라는 id를 가진 span태그의 내용과 색깔이 달라지도록 한 것이다.
마지막으로, fetch구문 있는 js 코드 중
if(userId==''){
document.getElementById('user-id-status').innerText = '';
return; // 만약, 아무것도 입력하지 않은 상태라면, 이 함수를 빠져나가서 아무 문구도 안나오게 함.
}
이 부분은 뭐냐면, 사용자가 user-id 라는 id를 가진 input 태그에 내용을 입력하다가 동일한 아이디가 이미 있어서 그 input태그 안의 내용을 다 지웠을 때에도
"사용 가능한 아이디입니다." 또는
"이미 사용 중인 아이디입니다." 라는 문구가 user-id-status 라는 id를 가진 span 태그에 잔존하는 문제를 없애기 위해서 작성해둔 것이다.
6단계-2 : javascript 를 통한 validation
비밀번호 (8~16글자 사이 영어대소문자 / 숫자 / 특수문자)
joinForm.jsp 파일 중 아래를 보자.
<form:input type="password" path="password" id="user-password" class="input-tag" placeholder="비밀번호" style=" display:inline-block;"/>
<span id="user-password-status" style="font-size:11px; display:inline-block;">
</span>
아래는 위 코드와 관련된 validation 관련 js 코드이다.
let passwordInput = document.getElementById('user-password');
let statusExpressSpan = document.getElementById('user-password-status');
passwordInput.addEventListener('input', function(){
statusExpressSpan.style.color = 'red';
let userPassword = this.value; //passwordInput 의 값
if(userPassword == ''){
statusExpressSpan.innerText = '';
return;
}
const lengthPattern = /^.{8,16}$/;
const letterPattern = /.*[A-Za-z].*/;
const numberPattern = /.*[0-9].*/;
const specialCharPattern = /.*[!@#&()–[{}\]:;',?/*~$^+=<>].*/;
// 길이 검사
if (!lengthPattern.test(userPassword)) {
statusExpressSpan.innerText = '비밀번호는 8자 이상 16자 이하이어야 합니다.';
return;
}
// 알파벳 문자 검사
if (!letterPattern.test(userPassword)) {
statusExpressSpan.innerText = '비밀번호에는 최소 한 개의 알파벳 문자가 포함되어야 합니다.';
return;
}
// 숫자 검사
if (!numberPattern.test(userPassword)) {
statusExpressSpan.innerText = '비밀번호에는 최소 한 개의 숫자가 포함되어야 합니다.';
return;
}
// 특수 문자 검사
if (!specialCharPattern.test(userPassword)) {
statusExpressSpan.innerText = '비밀번호에는 최소 한 개의 특수 문자가 포함되어야 합니다.';
return;
}
statusExpressSpan.style.color = 'blue';
// 모든 조건을 통과한 경우
statusExpressSpan.innerText = '적합한 비밀번호 입니다!'; // 아무 메시지도 표시하지 않음
});
간단히 이 코드를 설명하자면, 비밀번호 쓰는 input태그에 값을 입력할 때마다 작동하는 이벤트리스너를 만들어서, 정규표현식에 일치하지 않으면, 그에 맞는 문구가 span태그안에 쓰여지도록 작성하였다.
6단계 - 3 : <form:form> 태그와 BindingResult 와 @Valid
1)
<form:form> 태그와 BindingResult 에 대해서 정리를 시작하려면, 다시 아래 컨트롤러부터 시작해야한다.
@GetMapping("/Join-form")
public String getJoinFormControllerMethod(@RequestParam String myCheckbox1,
@RequestParam String myCheckbox2,
@RequestParam String myCheckbox3,
Model model
){
MemberJoinDTO memberJoinDTO = new MemberJoinDTO();
memberJoinDTO.setMyCheckbox1(myCheckbox1);
memberJoinDTO.setMyCheckbox2(myCheckbox2);
memberJoinDTO.setMyCheckbox3(myCheckbox3);
model.addAttribute("memberJoinDTO", memberJoinDTO);
return "/login/joinForm";
}
여기서, myCheckbox1, myCheckbox2, myCheckbox3 만 바인딩된 MemberJoinDTO 타입 클래스를 model에 담아줬었다.
MemberJoinDTO 타입 객체의 현재 값은 아래와 같다.
id | null |
userId | null |
password | null |
userName | null |
delete_member_fl | null |
zipCode | null |
phoneNumberStart | null |
phoneNumberMiddle | null |
phoneNumberEnd | null |
streetAddress | null |
address | null |
detailAddress | null |
referenceItem | null |
myCheckbox1 | on |
myCheckbox2 | on |
myCheckbox3 | null |
null | |
emailDomain | null |
customEmailDomain | null |
이렇게 생긴 객체를 joinForm.jsp 파일로 넘겨주면?
joinForm.jsp 파일 내에 있던 <form:input> <form:select> 태그를 보면, 아래 빨강색 부분처럼 path 속성이 있다.
<form:input type="text" path="userId" placeholder="아이디" />
<form:select path="emailDomain" style="width: 7vw; height: 80%;"></form:select>
joinForm.jsp 파일에 넘겨준 객체의 각 필드명과 동일한 값을 path속성의 값으로 가진 <form:input> 태그 또는 <form:select> 태그와 자동으로 매핑이 된다.
이렇게 자동으로 매핑이 되기 위해서는?
객체를 넘겨준 컨트롤러(getJoinFormControllerMethod 메서드)에서 model에 객체를 담을 때에 키로 줬던 값 ==
<form:form> 태그의 속성 중 modelAttribute 속성의 값
이어야 한다.
즉, model.addAttribute("memberJoinDTO", memberJoinDTO); 여기서 보라색 부분과
<form:form modelAttribute="memberJoinDTO" method="post"> 여기 보라색 부분이 일치해야 한다는 것이다.
2)
이제 form:form 태그가 제출되면, 이 HTTP요청을 받을 컨트롤러를 보자.
@PostMapping("/Join-form")
public String postJoinFormControllerMethod (@Valid @ModelAttribute MemberJoinDTO memberJoinDTO, BindingResult bindingResult){
//유효성 검사
if (memberJoinDTO.getUserId().equals("") ) {
bindingResult.rejectValue("userId", null, "아이디를 입력 해주세요");
}
if (memberJoinDTO.getPassword().equals("")) {
bindingResult.rejectValue("password", null, "비밀번호를 입력해주세요");
}
if (memberJoinDTO.getUserName().equals("")) {
bindingResult.rejectValue("userName", null, "이름을 입력해주세요");
}
if (memberJoinDTO.getZipCode().equals("")|| memberJoinDTO.getStreetAddress().equals("")|| memberJoinDTO.getAddress().equals("")) {
bindingResult.rejectValue("zipCode", null, "우편번호를 찾기를 통해 주소를 찾아주세요.");
}
if (memberJoinDTO.getPhoneNumberStart().equals("") || memberJoinDTO.getPhoneNumberMiddle().equals("") || memberJoinDTO.getPhoneNumberEnd().equals("")) {
bindingResult.rejectValue("phoneNumberStart", null, "전화번호를 입력해주세요");
}
if (bindingResult.hasErrors()) {
return "/login/joinForm";
}
//checkbox value : on => 1 , null => 0 변환
MemberJoinDTO changedMemberJoinDTO = joinService.onAndNullChange(memberJoinDTO);
// 비밀번호 해싱
String rawPassword = changedMemberJoinDTO.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
changedMemberJoinDTO.setPassword(encodedPassword);
//insert 진행 서비스
insertMemberService.insertMember(changedMemberJoinDTO);
return "/home/home";
}
본격적인 설명에 앞서 전체적인 흐름을 봐야한다.
public String postJoinFormControllerMethod (@Valid @ModelAttribute MemberJoinDTO memberJoinDTO, BindingResult bindingResult){...}
@Valid 애노테이션은 맨 마지막 부분에 설명되어 있으니 일단 넘어가자.
@ModelAttribute 를 통해 폼데이터를 담은 MemberJoinDTO 타입 객체가 생성되었다. 그리고 그 옆에 그 바로 다음에 보면 BindingResult bindingResult 를 써주면 된다.
다음은
if (memberJoinDTO.getUserId().equals("") ) {
bindingResult.rejectValue("userId", null, "아이디를 입력 해주세요");
}
이런 코드들이 있는데, 폼데이터를 담은 MemberJoinDTO 타입 객체의 필드를 하나씩 꺼내서 validation 을 진행하는 것이다. "아이디가 공백인지", "비밀번호가 공백인지" 등을 검사하고 있다.
그리고, 만약 검증에 실패했다면(예를 들어, 아이디가 공백이었다면) bindingResult.rejectValue() 메서드를 호출하고 있다.
if (bindingResult.hasErrors()) {
return "/login/joinForm";
}
그리고 validation 하는 코드들이 끝나고 나면, 위 코드가 나온다.
bindingResult.hasErrors() 즉, BindingResult 타입 객체에 대고 rejectValue() 같은 메서드를 호출한 경우 ( == 검증에 걸린 경우) true가 반환되어 이 컨트롤러로 폼을 보냈던 jsp파일인 joinForm.jsp 파일로 돌아가도록 하고 있다.
(그리고, 그 밑에는 비밀번호를 해싱해서 DB 테이블에 insert 하는 코드들인데, 포스팅이 난잡해지지 않기 위해서 다음 글에서 설명해두도록 한다.)
근데, 검증에 실패했을 경우 왜 joinForm.jsp 파일로 되돌아가도록 했을까?
이런식으로 동일한 페이지에서 어떤 부분때문에 회원가입이 진행되지 않았는지 사용자에게 알려주기 위해서이다.
그런데, 어떻게 model 이나 request 스코프에 "아이디를 입력해주세요" 라는 데이터를 담아주지 않았는데도 이게 가능한걸까?
bindingResult.rejectValue(); 를 이용하면, BindingResult 타입 클래스 내부적으로 rejectValue()를 호출하면서 매개변수로 넣어준 값을 model 에 담아준다.
이를 좀 더 이해하기 위해서는 bindingResult.rejectValue() 의 매개변수로 어떤 걸 넣어야 하는지에 대해 알 필요가 있다.
---------------------------------------------------------------------------------------------------------------------
3)
void rejectValue(
@Nullable String field,
String errorCode,
@Nullable Object[] errorArgs,
@Nullable String defaultMessage);
rejectValue() 메서드를 호출하면서 이렇게 총 4개의 매개변수를 넣을 수 있다.
매개변수 각각의 의미에 대해 알아보자.
ㄱ) field, defaultMessage
bindingResult.rejectValue("userId", null, "아이디를 입력 해주세요");
위 코드를 보면, 매개변수가 field, errorCode, defaultMessage 이렇게 3개밖에 없는데, rejectValue() 라는 메서드가 오버로딩(Over Loading)되어 있어서 errorArgs 매개변수와 defaultMessage 매개변수는 생략이 가능하기 때문에 그런 것이다.
어쨋든, 처음 매개변수로 온 "userId" 가 지금 다루고자 하는 field 자리에 온 매개변수값이다.
이렇게 해두면, 검증에 걸려서 다시 되돌아간 joinForm.jsp 파일에 보면, 아래와 같은 <form:errors> 태그가 있는데, path 속성 값이 userId 인 이 태그에 "아이디를 입력 해주세요" 라는 데이터가 바인딩되게 된다.
<form:errors path="userId" />
여기서 알 수 있는게 세번째 파라미터값으로 준 "아이디를 입력 해주세요"(defaultMessage 파라미터) 가 매핑된 <form:erorrs> 태그에 바인딩 되는 것이다.
ㄴ) errorCode, errorArgs
이 부분은 일단 따라해보면, 이해가 더 쉽기 때문에 일단 따라해보는게 더 좋다.
src/main/resources 경로에 messages.properties 파일을 생성하고 이 파일에 key=value 형태로 데이터를 저장해둔다.
예를 들어, 아래와 같이 말이다.
userId={0}은 {1}!
bindingResult.rejectValue("userId", null, "아이디를 입력 해주세요"); 이건 errorArgs 매개변수가 생략된 예제기 때문에, 아래 코드로 진행한다.
bindingResult.rejectValue("userId", "userId",new Object[]{"오늘", "비가 오네요"} ,"아이디를 입력 해주세요");
이렇게 해놓은 다음에, 이제 사용자가 아이디를 입력하지 않으면 어떻게 되냐면,
이렇게 된다.
참고) 인코딩 문제가 발생한다면, messages.properties 파일의 인코딩 형식을 UTF-8 로 바꿔주면 된다.
방법은 인텔리제이 기준 : settings -> File Encodings -> Default Encoding for properties files 에서 UTF-8 로.
즉, 우선순위가 defaultMessage 매개변수보다 messages.properties 파일에 적어둔 게 우선되어서 <form:errors> 태그에 바인딩 되게 된다.
그리고, errorCode 는 messages.properties 파일에 적어둔 key=value 형태의 데이터 중 key 부분을 적어주면 되고,
errorArgs 는 message.properties 파일에 적어둔 key=value 형태의 데이터 중 value에서 {0} {1} 이 자리에 순차적으로 들어오게 될 값을 적어주면 된다. 당연히 {2} 도 만들 수 있고, {3} 도 만들 수 있다.
이때, messages.properties 의 key 값에 대해 좀 더 깊이 알아보자.
bindingResult.rejectValue("userId", "tiger","아이디를 입력 해주세요"); // field, errorCode, defaultMessage
이렇게 해두고, messages.properties 에 아래와 같이 써둔다면 어떻게 될까?
tiger=4순위
tiger.userId=2순위
tiger.memberJoinDTO.userId=1순위
tiger.java.lang.String=3순위
1순위인 tiger.memberJoinDTO.userId 가 <form:errors> 태그에 바인딩 되게 된다.
즉, 우선순위가 존재한다는 것이다.
1순위 : errorCode.objectName.field
2순위 : errorCode.field
3순위 : errorCode.field타입
4순위 : errorCode
여기서 주의할 건, 1순위에서 objectName 은, 폼데이터를 @ModelAttribute 를 통해서 MemberJoinDTO 타입 객체에 담았다고 했을 때, MemberJoinDTO 라는 타입이 아니라, 그 객체가 담긴 변수인 memberJoinDTO 를 써줘야 한다는 것이다.
또한, 3순위의 field 타입은 그냥 String 만 써주는 게 아니라 java.lang.String 이라고 써줘야 한다는 것이다.
그렇다면, 이 우선순위를 외워야 할까?
아니다. 그냥 log를 찍어보면, 아래와 같이 나오는데, 이 중 빨강 부분이 우선순위를 말해주는 것이다. 로그를 통해 보면 됨.
bindingResult =org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'memberJoinDTO' on field 'userId': rejected value [];
codes
[
tiger.memberJoinDTO.userId,
tiger.userId,
tiger.java.lang.String,
tiger
];
arguments []; default message [아이디를 입력 해주세요]
4) 스프링은 @ModelAttribute 로 폼데이터를 객체에 바인딩할 때 타입오류가 나면, new FieldError()를 직접 만들기도 한다.
예를 들어, 폼데이터로 어떤 문자열이 들어왔는데, @ModelAttribute 로 바인딩할 객체의 타입은 int 인 경우, 스프링은 new FieldError()를 만들게 된다. 그럼, new FieldErorr() 란 무엇인가?
지금까지 bindingResult.rejectValue(); 메서드를 호출함으로써 <form:errors> 태그에 메세지를 바인딩했다. 근데, bindingResult.rejectValue(); 라는 메서드 내부적으로 bindingResult.addError(new FieldError()); 이런식으로 new FieldError() 라는 인스턴스를 생성하게 된다.
즉, new FieldError() 인스턴스가 사실 BindingResult 를 통한 검증의 핵심 본체 역할을 하는데, 인스턴스를 만들 때에 어떤 값을 파라미터로 넘겨줘야 하는지에 관해서는 아래에 짧게나마 정리해 두겠다.
어쨋든, 스프링은 new FieldError() 객체를 만들게 되는데, 스프링이 만든 new FieldError() 인스턴스를 로그 찍어보면 아래와 같다.
Field error in object 'item' on field 'price':
rejected value [qqq];
codes [
typeMismatch.item.price,
typeMismatch.price,
typeMismatch.java.lang.Integer,
typeMismatch];
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price]];
default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; nested exception is java.lang.NumberFormatException: For input string: "qqq"]
그래서, 개발자가 아무런 조치를 취하지 않은 경우, 위 코드 중 빨강색 부분인 default message 가 그대로 <form:errors> 에 바인딩 되게 된다. 따라서, @ModelAttribute 를 이용해서 폼데이터를 객체에 바인딩할 때 타입오류가 나는 걸 방지하고자 한다면, 위 코드 중 초록색 부분을 보고 우선순위를 파악해서 messages.properties 파일에 <form:errors> 태그에 바인딩되었으면 하는 데이터를 정의해두면 된다.
new FieldError() 인스턴스를 만들 때 넘겨줄 파라미터는 아래와 같다.
public FieldError(
String objectName,
String field,
@Nullable Object rejectedValue,
boolean bindingFailure,
@Nullable String[] codes,
@Nullable object[] arguments,
@Nullable String defaultMessage
)
일단, 아래 rejectValue 메서드의 파라미터와 동일한 색끼리는 동일한 역할을 한다고 보면 된다.
objectName 는 폼데이터를 @ModelAttribute 를 통해 어떤 객체에 담았다고 했을 때, 그 객체를 담은 변수의 이름을 써주면 된다.
rejectedValue 는 검증에서 걸려서 폼데이터를 보낸 jsp파일로 돌아갔을 때, 사용자가 입력한 값을 그대로 두기 위해서 사용자가 입력한 값을 입력하는 자리이다. 폼데이터가 바인딩된 객체로부터 얻어와서 집어넣는다.
bindingFailure 은 타입오류인지 쓰는 곳이다. 보통 타입오류인 경우 스프링이 스스로 new FieldError() 를 만들어주기 때문에, 직접 new FieldError() 를 만드는 경우에는 false 로 둔다.
void rejectValue(
@Nullable String field,
String errorCode,
@Nullable Object[] errorArgs,
@Nullable String defaultMessage);
그런데, 너무 복잡하지 않나? 어차피 거의 모든 프로젝트에서 공통적으로 검증을 할 때 "공백이 올 수 없다거나", "최대 9999까지만 입력할 수 있다거나" 등 정형화된 틀이 있을텐데? 서버단에서 validation 을 하려면 이렇게 복잡하게 해야한다고?
그래서, 스프링은 간단한 애노테이션으로 validation 을 할 수 있는 방법을 제공한다. 그게 바로 밑에 있는 @Valid 애노테이션이다.
5) @Valid 애노테이션
일단 이 애노테이션을 사용하기 위해서는 아래 라이브러리를 추가해줘야 한다. (gradle 기준)
implementation 'org.springframework.boot:spring-boot-starter-validation'
그리고 @Valid 애노테이션은 @ModelAttribute 앞에 붙여야 한다.
그래서, @Valid @ModelAttribute BindingResult 순이 되게 된다.
이 애노테이션을 붙인 다음에, @ModelAttribute 로 폼데이터를 담을 객체의 타입인 MemberJoinDTO 타입 클래스에다가 아래처럼 @NotNull 을 필드에 붙여주면 된다. (참고로, 이와 관련한 애노테이션에 대해 알고 싶다면 : https://www.sourcecodeexamples.net/2021/03/java-bean-validation-annotation-list.html)
@Data
public class MemberJoinDTO {
private Integer id;
@NotBlank
private String userId;
private String password;
private String userName;
private String delete_member_fl;
private String zipCode;
private String phoneNumberStart;
private String phoneNumberMiddle;
private String phoneNumberEnd;
private String streetAddress;
private String address;
private String detailAddress;
private String referenceItem;
private String myCheckbox1;
private String myCheckbox2;
private String myCheckbox3;
private String email;
private String emailDomain;
private String customEmailDomain;
}
이렇게 하면, @ModelAttribute 로 폼데이터를 객체에 바인딩할 때, 바인딩할 객체의 타입인 MemberJoinDTO 로 간다.
-> 갔더니 @NotNull 애노테이션이 있다 -> 근데 만약 바인딩할 폼데이터 중 userId 값이 비어있다면, 스프링은 자동으로 new FieldError() 인스턴스를 만들게 된다. 아래 코드처럼, userId 가 공백인지 검증하는 부분을 주석처리하고 다시 테스트를 진행해보았다.
@PostMapping("/Join-form")
public String postJoinFormControllerMethod (@Valid @ModelAttribute MemberJoinDTO memberJoinDTO, BindingResult bindingResult){
//유효성 검사
// if (memberJoinDTO.getUserId().equals("") ) {
// bindingResult.rejectValue("userId", "tiger","아이디를 입력 해주세요");
// }
if (memberJoinDTO.getPassword().equals("")) {
bindingResult.rejectValue("password", null, "비밀번호를 입력해주세요");
}
if (memberJoinDTO.getUserName().equals("")) {
bindingResult.rejectValue("userName", null, "이름을 입력해주세요");
}
if (memberJoinDTO.getZipCode().equals("")|| memberJoinDTO.getStreetAddress().equals("")|| memberJoinDTO.getAddress().equals("")) {
bindingResult.rejectValue("zipCode", null, "우편번호를 찾기를 통해 주소를 찾아주세요.");
}
if (memberJoinDTO.getPhoneNumberStart().equals("") || memberJoinDTO.getPhoneNumberMiddle().equals("") || memberJoinDTO.getPhoneNumberEnd().equals("")) {
bindingResult.rejectValue("phoneNumberStart", null, "전화번호를 입력해주세요");
}
if (bindingResult.hasErrors()) {
log.info("bindingResult = {}", bindingResult);
return "/login/joinForm";
}
//checkbox value : on => 1 , null => 0 변환
MemberJoinDTO changedMemberJoinDTO = joinService.onAndNullChange(memberJoinDTO);
// 비밀번호 해싱
String rawPassword = changedMemberJoinDTO.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
changedMemberJoinDTO.setPassword(encodedPassword);
//insert 진행 서비스
insertMemberService.insertMember(changedMemberJoinDTO);
return "/home/home";
}
테스트 결과 아래와 같이 나왔다.
스프링이 만들어준 new FieldError() 의 defaultMessage 가 "공백일 수 없습니다" 가 아닐까? 하는 마음에 로그를 찍어보았더니 역시나 그러했다.
bindingResult = org.springframework.validation.BeanPropertyBindingResult: 1 errors Field error in object 'memberJoinDTO' on field 'userId': rejected value [];
codes
[
NotBlank.memberJoinDTO.userId,
NotBlank.userId,
NotBlank.java.lang.String,
NotBlank
]
; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [memberJoinDTO.userId,userId]; arguments []; default message [userId]]; default message [공백일 수 없습니다]
그렇다면, 애노테이션 기반 validation 을 한다면, 스프링이 지정해준 메세지("공백일 수 없습니다" 와 같은) 밖에 못쓰는 걸까?
아니다. 앞서 말했듯이, 위 코드 중 초록색 부분을 보고 우선순위를 파악한 다음 messages.properties 파일에다가 <form:errors> 태그에 바인딩 되었으면 하는 메세지를 적어주면 된다.
이렇게 해서 6단계 - 3 : <form:form> 태그와 BindingResult 와 @Valid
까지 모두 완료했다.
회원가입 페이지를 구현하는데 남아 있는 부분은
1. 다음 주소 api 도입하는 방법
2. DB 에 insert 쿼리 하는 과정들.
이렇게 두 가지이다.
글이 너무 길어져서 두 가지는 다음 포스팅으로..