이전 게시글에서는 로그인 여부에 따른 메뉴 표시와 로그아웃 기능을 구현하였다.(https://talanton.tistory.com/128)

이 글에서는 회원가입 기능을 구현하고자 한다.

- 회원가입 폼을 작성

- 아이디 중복 확인

- 회원가입 절차

초기 동작은 오른쪽 위의 Dropdown 메뉴에서 Join 메뉴를 클릭하면 /member/join이 서버로 요청이 되며, 회원가입 폼이 보여져야 한다.

서버에서는 /member/join url 요청을 처리하기 위하여 MemberController가 동작한다.

	@GetMapping("/join")
	public void join() {
		log.info("join...");
	}

뷰 페이지는 templates/member/join.html이 동작한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content} )}">
    <th:block th:fragment="content">
		<script src="/js/join.js"></script>
		
        <h1 class="mt-4">Join Page</h1>
        <form class="form-join mt-4" method="post" action="/member/join">
        	<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
        	<input type="hidden" name="fromSocial" value="false">
			<div class="form-row">
				<div class="col-lg-10">
					<label>아이디(이메일 형식) *</label>
					<input class="form-control" placeholder="kdhong@naver.com" name="email" type="email" required autofocus>
				</div>
				<div class="col-lg-2">
					<label>* : 필수입력</label>
					<button type="button" class="form-control btn btn-secondary">중복확인</button>
				</div>
			</div>
			<div class="form-row mt-3">
				<div class="col-lg-6">
					<label>암 &nbsp; 호 *</label>
					<input class="form-control"	placeholder="암호 입력 (특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하)" name="password" type="password" required>
				</div>
				<div class="col-lg-6">
					<label>암호 확인 *</label>
					<input class="form-control" placeholder="암호 입력 (특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하)" name="password2" type="password" required>
				</div>
			</div>
			<div class="form-row mt-3">
				<div class="col-lg-6">
					<label>이 &nbsp; 름 *</label>
					<input class="form-control"	placeholder="홍길동" name="name" type="text" maxlength="30" required>
				</div>
				<div class="col-lg-6">
					<label>별 &nbsp; 명</label>
					<input class="form-control" placeholder="바람의 아들" name="nickname" maxlength="30" type="text">
				</div>
			</div>
			<div class="form-row mt-3">
				<label>휴대전화</label>
				<input class="form-control" placeholder="010-3333-4444(- 포함)" name="phone" type="tel">
			</div>
			<div class="form-group row mt-3">
				<label for="colFormLabel" class="col-lg-1 col-form-label">생년월일 : </label>
				<div class="col-lg-1">
					<select name="calendar" class="form-control">
						<option value="s" selected>양력</option>
						<option value="l">음력</option>
					</select>
				</div>
				<div class="col-lg-1">
					<input class="form-control"	placeholder="2000" name="year" type="number">
				</div>
				<label class="mt-2">년</label>
				<div class="col-lg-1">
					<input class="form-control"	placeholder="12" name="month" type="number">
				</div>
				<label class="mt-2">월</label>
				<div class="col-lg-1">
					<input class="form-control"	placeholder="12" name="date" type="number">
				</div>
				<label class="mt-2">일</label>
			</div>
			<div class="form-group mt-3">
				<label>주 &nbsp; 소</label> <input class="form-control"
					placeholder="16245 경기도 성남시 수정구 수정로 319번지 753동 4701호" name="address" type="text">
			</div>
			<button type="submit" class="btn btn-success">Join</button>
			<button type="reset" class="btn btn-warning">Reset</button>
        </form>
    </th:block>
</th:block>

부트스트랩을 이전 글에서 사용한 것은 잘 동작하지 않으므로, 기존에 다운로드 받아 사용했던 것을 사용하여 작업을 한다.

startbootstrap-simple-sidebar-gh-pages_old.zip
1.05MB

압축을 해제한 후 src/main/resource/static 폴더에 붙여넣기 한다.

assets 폴더의 아이콘은 재사용 하였다.

css 파일은 simple-sidebar.css로 변경하였다. 기존에 사용하던 styles.css를 삭제를 하였다.

js 폴더는 기존 것은 삭제하고 login.js와 join.js만 유지하였다. 다른 것은 vendor 폴더에 있는 것을 사용한다.

기존의 부트스트랩을 적용하면 basic.html도 변경을 해주어야 한다. 기존에 있던 css와 js 부분을 아래와 같이 변경해 준다.

<title>Talanton 스프링 부트 홈 페이지</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" th:href="@{/assets/favicon.ico}" />
<!-- Bootstrap core CSS -->
<link th:href="@{/vendor/bootstrap/css/bootstrap.min.css}" rel="stylesheet">
<!-- Custom styles for this template -->
<link th:href="@{/css/simple-sidebar.css}" rel="stylesheet">
<!-- Bootstrap core JS-->
<script th:src="@{/vendor/jquery/jquery.min.js}"></script>
<script th:src="@{/vendor/bootstrap/js/bootstrap.bundle.min.js}"></script>
</head>

전체 basic.html은 다음과 같이 부트스트랩 template가 변경이 되었으므로 역시 수정해 준다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <meta name="_csrf" th:content="${_csrf.token}"/>
	<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
    <title>Simple Sidebar - Start Bootstrap Template</title>
    <!-- Bootstrap core CSS -->
    <link th:href="@{/vendor/bootstrap/css/bootstrap.min.css}" rel="stylesheet">
    <!-- Custom styles for this template -->
    <link th:href="@{/css/simple-sidebar.css}" rel="stylesheet">
    <!-- Bootstrap core JavaScript -->
    <script th:src="@{/vendor/jquery/jquery.min.js}"></script>
    <script th:src="@{/vendor/bootstrap/js/bootstrap.bundle.min.js}"></script>
</head>
<body>
<div class="d-flex" id="wrapper">
    <!-- Sidebar -->
    <div class="bg-light border-right" id="sidebar-wrapper">
        <div class="sidebar-heading">Start Bootstrap </div>
        <div class="list-group list-group-flush">
            <a href="#" class="list-group-item list-group-item-action bg-light">Dashboard</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Shortcuts</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Overview</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Events</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Profile</a>
            <a th:href="@{/sise/list}" class="list-group-item list-group-item-action bg-light">주식 시세 보기</a>
        </div>
    </div>
    <!-- /#sidebar-wrapper -->
    <!-- Page Content -->
    <div id="page-content-wrapper">
        <nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
            <button class="btn btn-primary" id="menu-toggle">Toggle Menu</button>
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav ml-auto mt-2 mt-lg-0">
                    <li class="nav-item active">
                        <a class="nav-link" th:href="@{/}">Home <span class="sr-only">(current)</span></a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="#">Link</a>
                    </li>
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                            Dropdown
                        </a>
                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
                            <a sec:authorize="isAuthenticated()" class="dropdown-item logoutBtn" href="#">Logout</a>
                            <a sec:authorize="isAuthenticated()" class="dropdown-item" th:href="@{/member/profile(email=${#authentication.principal.member.email})}">MyPage</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="/member/login">Login</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="/member/join">Join</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="#">아이디 찾기</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="#">비밀번호 찾기</a>
                        </div>
                    </li>
                </ul>
            </div>
        </nav>
        <div class="container-fluid">
            <th:block th:replace = "${content}"></th:block>
        </div>
    </div>
    <!-- /#page-content-wrapper -->
</div>
<!-- /#wrapper -->
<!-- Menu Toggle Script -->
<script>
	var token = $("meta[name='_csrf']").attr("content");
	var header = $("meta[name='_csrf_header']").attr("content");
	
    $("#menu-toggle").click(function(e) {
        e.preventDefault();
        $("#wrapper").toggleClass("toggled");
    });
    $(".logoutBtn").click(function(e) {
    	e.preventDefault();
    	$.ajax({
    		url: '/member/logout',
    		type: 'post',
    		// CSRF 토큰 값을 헤더로 전송
			beforeSend: function(xhr) {
			    xhr.setRequestHeader(header, token);
			},
			success: function(data) {
				alert("로그아웃 되었습니다.");
				location.href = "/member/login";
			}
    	});
    });
</script>
</body>
</th:block>
</html>

그러면 회원가입 폼은 아래와 같이 표시된다.

- 아이디는 이메일을 사용한다.

- 아이디를 입력하고 중복확인 버튼을 눌러 아이디 중복 확인을 한다.

- 암호와 암호확인을 자바스크립트에서 정규표현식을 사용하여 유효성 검사를 수행한다.

- 이름 : 필수 파라미터 (*로 표시)

- 나머지는 선택 사항으로 입력하지 않아도 회원가입이 수행된다.

자바스크립트에서 회원가입 데이터에 대한 유효성 검사를 수행하고, 아이디 중복 확인 및 회원가입 요청을 서버로 전송한다.

$(document).ready(function() {
	var idCheck = false;	// 아이디 중복 검사 통과 여부
	var passwordValid = false;	// 비밀번호 유효성 검사 결과
	var password2Valid = false;	// 비밀번호 확인 유효성 검사 결과
	var emailValid = false;	// 이메일 유효성 검사 결과
	
	var token = $("meta[name='_csrf']").attr("content");
	var header = $("meta[name='_csrf_header']").attr("content");
	
	var formObj = $(".form-join");
	
	// 이메일 확인 유효성 검사
	$("input[name='email']").on("keyup", function(e) {
		var regExp = /^[-A-Za-z0-9_]+[-A-Za-z0-9_.]*[@]{1}[-A-Za-z0-9_]+[-A-Za-z0-9_.]*[.]{1}[A-Za-z]{2,5}$/;
		if( !regExp.test($("input[name='email']").val()) ) {
			$("input[name='email']").css("color", "#EE5656");
			emailValid = false;
		}
		else {
			$("input[name='email']").css("color", "blue");
			emailValid = true;
		}
	});
	
	// 비밀번호 유효성 검사
	$("input[name='password']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password']").val()) ) {
			$("input[name='password']").css("color", "#EE5656");
			passwordValid = false;
		}
		else {
			$("input[name='password']").css("color", "blue");
			passwordValid = true;
		}
	});
	
	// 비밀번호 확인 유효성 검사
	$("input[name='password2']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password2']").val()) ) {
			$("input[name='password2']").css("color", "#EE5656");
			password2Valid = false;
		}
		else {
			$("input[name='password2']").css("color", "blue");
			password2Valid = true;
		}
	});

	// 아이디 중복 확인
	$(".btn-secondary").on("click", function(e) {
		var email = $("input[name='email']").val();
		if(email == null || email.length == 0) {
			alert("아이디(이메일 형식)를 입력하세요");
			return;
		}
		if(emailValid == false) {
			alert("고유한 이메일 형식의 아이디를 사용하세요.");
			return;
		}
		// 서버로 중복 확인 요청
		$.ajax({
		  url: '/member/idCheck',
		  data: {email: email},
		  type: 'POST',
		  beforeSend: function(xhr) {
	          xhr.setRequestHeader(header, token);
	      },
		  success: function(result){
			if(result == "ok") {
				idCheck = true;
				alert("사용할 수 있는 아이디 입니다.");
			} else {
				idCheck = false;
				alert("사용할 수 없는 아이디 입니다.");
			}
		  }
		}); //$.ajax
	});
	
	$(".btn-success").on("click", function(e) {
		e.preventDefault();
		var email = $("input[name='email']").val();
		if(email == null || email.length == 0) {
			alert("아이디를 입력하세요");
			email.focus();
			return;
		}
		
		if(idCheck == false) {
			alert("아이디 중복확인을 하세요");
			return;
		}
		
		if(emailValid == false) {
			alert("고유한 이메일 형식의 아이디를 사용하세요.");
			return;
		}
		
		var password = $("input[name='password']").val();
		if(password == null || password.length == 0) {
			alert("암호를 입력하세요");
			password.focus();
			return;
		}
		
		if(passwordValid == false) {
			alert("특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하를 사용하세요.");
			return;
		}
		
		var password2 = $("input[name='password2']").val();
		if(password2 == null || password2.length == 0) {
			alert("암호 확인을 입력하세요");
			password2.focus();
			return;
		}
		
		if(password2Valid == false) {
			alert("특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하를 사용하세요.");
			return;
		}
		
		if(password != password2) {
			alert("암호 확인을 입력하세요");
			password2.focus();
			return;
		}
		
		var name = $("input[name='name']").val();
		if(name == null || name.length == 0) {
			alert("이름을 입력하세요");
			name.focus();
			return;
		}

		console.log("submit clicked");
		formObj.submit();
	});
});

이메일 형식의 아이디를 입력하고 중복 확인 버튼을 클릭하면 자바스크립트 join.js에서 아이디를 입력 유무에 대한 유효성 검사를 수행한 후 Ajax를 사용하여 아이디 중복 확인(/member/idCheck)을 서버로 요청한다. 이때 아이디인 email을 파라미터로 전송한다. 또한 CSRF를 header에 전송하여야 한다. 아래 그림을 보면 X-CSRF-TOKEN 헤더에 CSRF 토큰이 전송됨을 확인할 수 있다.

 

MemberController에서는 /member/idCheck 요청에 대하여 처리를 한다.

	@PostMapping("idCheck")
	@ResponseBody
	public String idCheck(String email) {
		log.info("idCheck..." + email);
		if(memberService.idCheck(email)) {
			return "ok";
		}
		else {
			return "nok";
		}
	}

email을 파라미터로 얻고 MemberService의 idCheck() 메소드를 호출하여 아이디 중복 여부를 검사한다. 아이디가 중복되지 않을 경우 true가 반환되며, 브라우저로 "ok" 문자열을 반환한다.

아이디가 중복된 경우 idCheck()에서 false가 반환되며, 브라우저로 "nok" 문자열을 반환한다.

자바스크립트에서는 중복 확인 결과를 받아 사용자에게 알리고, 아이디 중복확인 결과를 변수에 저장한다.

이미 있는 사용자 일 경우는 "사용할 수 없는 아이디 입니다" 경고창이 보여진다.

MemberService.java 인터페이스는 다음과 같다. idCheck()와 join() 메소드를 추가하고 MemberDTO를 엔티티 Member로 변환하는 dtoToEntity() 메소드를 정의한다.

package com.example.sboot.member.service;

import java.util.HashSet;

import com.example.sboot.member.dto.MemberDTO;
import com.example.sboot.member.entity.Member;
import com.example.sboot.member.entity.MemberRole;

public interface MemberService {
	boolean idCheck(String userid);
	void join(MemberDTO member);
	
	default Member dtoToEntity(MemberDTO dto) {
		Member member = Member.builder()
				.email(dto.getEmail())
				.password(dto.getPassword())
				.name(dto.getName())
				.nickname(dto.getNickname())
				.phone(dto.getPhone())
				.address(dto.getAddress())
				.birthday(dto.getCalendar()+"."+dto.getYear()+"."+dto.getMonth()+"."+dto.getDate())
				.fromSocial(false)
				.roleSet(new HashSet<MemberRole>())
				.build();
		member.addMemberRole(MemberRole.USER);
		return member;
	}
}

MemberDTO는 브라우저로부터 입력되는 파라미터 데이터를 수신하기 위하여 정의되며, MemberService에서 Entity로 변환된다. 이때 파라미터의 변환이 수행된다. 어떻게 변환을 할지는 입력 데이터와 데이터베이스에 저장되는 형태에 따라 다를 수 있다.

package com.example.sboot.member.dto;

import java.time.LocalDateTime;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class MemberDTO {
	private String email;
	private String password;
	private String name;
	private String nickname;
	private String phone;
	private String calendar;	// 양력(sonar), 음역(lunar)
	private String year;
	private String month;
	private String date;
	private String address;
	private boolean fromSocial;
	private LocalDateTime regDate;
	private LocalDateTime modDate;
}

MemberServiceImpl.java는 구현 객체로 다음과 같다. 데이터베이스 연동을 위해 MemberRepository를 주입받는다. MemberController로부터 idCheck() 요청을 받으면 MemberRepository로 email을 가진 사용자가 있는지 질의하여 아이디를 가진 사용자가 있는지 알 수 있다.

package com.example.sboot.member.service;

import java.util.Optional;

import javax.transaction.Transactional;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.example.sboot.member.dto.MemberDTO;
import com.example.sboot.member.entity.Member;
import com.example.sboot.member.repository.MemberRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Service
@RequiredArgsConstructor
@Log4j2
public class MemberServiceImpl implements MemberService {
	private final MemberRepository memberRepository;
	private final PasswordEncoder pwencoder;

	@Override
	public boolean idCheck(String email) {
		log.info("idcheck..." + email);
		
		Optional<Member> member = memberRepository.findById(email);
		return member.isEmpty();
	}

	@Transactional
	@Override
	public void join(MemberDTO memberDTO) {
		String encPw = pwencoder.encode(memberDTO.getPassword());
		memberDTO.setPassword(encPw);
		Member member = dtoToEntity(memberDTO);
		memberRepository.save(member);
	}
}

회원정보를 입력하고 Join 버튼을 클릭하면, 자바스크립트 join.js에서 회원정보에 대한 유효성 검사를 수행한 후 이상이 없으면 /member/join POST 요청을 서버로 보낸다.

브라우저에서 개발자 도구에서 제공하는 network 탭을 이용하면 서버로 전송되는 데이터를 확인할 수 있다.

MemberController에서는 /member/join POST 요청을 받아 처리한다. MemberService로 회원정보의 저장을 요청하고, 로그인 창으로 리다이렉트를 수행하며, 로그인 절차에서 회원의 아이디를 한번만 활용할 수 있도록 RedirectAttributes로 전달한다.

	@PostMapping("/join")
	public String join(MemberDTO member, RedirectAttributes rttr) {
		log.info("================================");
		log.info("register: " + member);
		memberService.join(member);
		rttr.addFlashAttribute("result", member.getEmail());
		return "redirect:/member/login";
	}

로그인 창으로 이동하며, 회원의 아이디인 이메일이 표시됨을 알 수 있다.

MySQL workbench를 사용하여 회원정보가 member 테이블 및 member_role_set 테이블에 저장이 되었는지를 확인한다.

이로서 회원가입 기능이 잘 동작하는 것을 확인할 수 있다.

password에 대하여 정책을 특수문자, 영문자, 숫자의 조합으로 6자 이상으로 사용하도록 요구하고 있으므로, 이전에 login.html(login.js)에서 유효성 검사를 위해 임시로 막아두었던 코드를 다시 수정하여 유효성 검사가 제대로 수행이 되도록한다.

	// 비밀번호 유효성 검사
	$("input[name='password']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password']").val()) ) {
			$("input[name='password']").css("color", "#EE5656");
			passwordValid = false;
		}
		else {
			$("input[name='password']").css("color", "blue");
			passwordValid = true;
		}
		passwordValid = true;
	});

로그인 절차가 잘 수행이 된다. 비밀번호에 대하여 특수문자, 영문자, 숫자의 조합으로 6자 이상을 입력하여야 로그인이 진행된다.

전체 소스코드는 다음과 같다.

sboot.zip
2.22MB

Posted by 세상을 살아가는 사람
,