자바스크립트에서 회원가입 데이터에 대한 유효성 검사를 수행하고, 아이디 중복 확인 및 회원가입 요청을 서버로 전송한다.
$(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 요청에 대하여 처리를 한다.
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로 전달한다.
이전에 구현한 것이지만 Toggle Menu 버튼을 누르면 왼쪽의 sidebar 메뉴가 토글되는 것을 역시 확인할 수 있다. 이 부분은 basic.html의 하단부에 있는 자바스크립트에서 메뉴에 대하여 이벤트를 등록하고 이벤트가 발생하면 sidebar 메뉴를 토글시켜줌으로써 동작시킬 수 있다.
PasswordEncoder 빈을 추가하고, 인증 수행 시 사용자 프로파일 정보를 security principal에 저장하기 위한 CustomUserDetailsService를 주입 받아 http.userDetailsService(userDetailsService)로 추가하여야 한다. 사용자 프로파일을 가져오기 위한 메소드로 MemberRepository에 findByEmail() 메소드를 추가한다. 이는 사용자 정보를 검색하는 과정에서 아이디/비밀번호를 사용한 인증과 SNS 계정을 사용한 인증을 구별하기 위하여 fromSocial 필드를 추가한 것이다. 따라서 별도로 Repository에서 추가하였다.
package com.example.sboot.member.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.example.sboot.member.entity.Member;
public interface MemberRepository extends JpaRepository<Member, String> {
@EntityGraph(attributePaths = {"roleSet"}, type = EntityGraph.EntityGraphType.LOAD)
@Query("select m from Member m where m.fromSocial = :social and m.email =:email")
Optional<Member> findByEmail(@Param("email") String email, @Param("social") boolean social);
}
member_role_set 정보를 같이 가져오기 위하여 EntityGraph를 사용하였다. member 뿐만 아니라 member_role_set의 정보를 가지고 오는 것을 확인할 수 있다.
Entity member에 대응되는 DTO로 MemberDTO를 정의한다. 보통 DTO는 브라우저에서 Controller 및 Service까지 정보를 가지고 다니는 빈이며, Entity는 Repository에서 데이터베이스까지 데이터를 가지고 다니는 빈이다.
AuthMemberDTO는 MemberDTO를 가지고 있도록 정의를 하였으며, User 클래스를 상속하여 필요한 name 속성을 가지도록 정의를 하였다.
security가 동작하는지 확인하기 위하여 http://localhost:8080/login url을 브라우저에서 입력하여 로그인 페이지를 보이도록한다.
앞에서 추가한 시험용 사용자 데이터로 로그인을 시도한다. 예를 들어 Username은 user9@zerock.org를 사용하고, Password는 1111을 입력한 후 Sign in 버튼을 클릭한다.
그러면 로고인이 수행되고 성공적으로 처리가 되어 메인 페이지로 이동한다.
console 창에서 출력되는 로그를 확인하면 /login 페이지가 출력되고
securing POST /login이 수행되면서 로그인 요청이 서버로 전달이 되고
ClubUserDetailsService에서 loadUserNyUsername메소드가 실행이 되고 Usrname user9@zerock.org가 처리됨을 알 수 있다.
또한 사용자의 회원정보를 다음과 같이 가지고 오는 것을 알 수 있다.
Hibernate:
select
member0_.email as email1_0_,
member0_.moddate as moddate2_0_,
member0_.regdate as regdate3_0_,
member0_.address as address4_0_,
member0_.birthday as birthday5_0_,
member0_.from_social as from_soc6_0_,
member0_.name as name7_0_,
member0_.nickname as nickname8_0_,
member0_.password as password9_0_,
member0_.phone as phone10_0_,
roleset1_.member_email as member_e1_1_0__,
roleset1_.role_set as role_set2_1_0__
from
member member0_
left outer join
member_role_set roleset1_
on member0_.email=roleset1_.member_email
where
member0_.from_social=?
and member0_.email=?
사용자 정보를 가져와서 세션에 저장하고 /로 이동함을 알 수 있다. 이로서 메인 페이지가 표시된다.
여기서는 사용자 데이터베이스를 사용하여 인증을 하고 세션 정보를 저장하는 것을 알 수 있다.
이전 게시글에서는 스프링 부트를 이용해서 포트폴리오를 만들기 위하여 기본 프로젝트를 생성하였고, 부트스트랩을 적용하여 뷰에 대한 템플레이트 레이아웃을 적용하였다. 또한 기본 security를 적용하여 로그인까지 적용하였다. 그러나 아직 회원관리 기능이나 다른 기능이 적용되지 않은 상태이다.
이번 게시글에서는 회원관리 기능을 구현하기 위하여 사용자의 프로파일 정보를 구성하고, 데이터베이스 테이블을 생성하고 시험용 사용자를 만들고자 한다. security에서는 비밀번호를 암호화하여 저장하므로 역시 PasswordEncoder도 적용한다.
우선 사용자 프로파일 정보를 정의해 본다.
- email : 30자 이하
- password : 64 바이트
- name : 이름, 30바이트
- nickname : 별명, 30바이트 이하
- phone : 전화번호, 15바이트 이하
- address : 주소, 255바이트 이하
- birthday : 생년월일, 15바이트 이하 - s/l.2000.12.12 (s:양력, l:음력)
- fromSocial : SNS 회원가입 여부 (0: 기본, 1: SNS가입)
- regdate : 가입일
- moddate : 정보 변경일
스프링 부트에서는 JPA를 사용하고, Entity를 사용하여 데이터베이스 테이블을 조작한다. 모든 Entity는 기본적으로 regdate와 moddate가 기본적으로 사용되는 편이므로 BaseEntity라는 것을 정의하여 regdate와 moddate를 저장한다.
com.example.sboot.common.entity 패키지에 다음과 같이 BaseEntity.java를 추가한다.
package com.example.sboot.common.entity;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import lombok.Getter;
@MappedSuperclass // 테이블로 생성되지 않음
@EntityListeners(value={AuditingEntityListener.class}) // Entity 객체가 생성/변경되는 것을 감지
@Getter
public abstract class BaseEntity {
@CreatedDate // JPA에서 엔티티의 생성시간을 자동으로 처리
@Column(name="regdate", updatable=false)
private LocalDateTime regDate;
@LastModifiedDate // JPA에서 엔티티의 수정시간을 자동으로 처리
@Column(name="moddate")
private LocalDateTime modDate;
}
BaseEntity.java는 객체화하지 못하도록 추상 클래스로 정의되며, @MappedSuperclass 어노테이션을 추가하여 JPA가 테이블로 생성하지 않토록한다. 또한 BaseEntity를 상속하여 만든 엔티티의 변경이 있을 경우 JPA에 의하여 자동으로 테이블이 변경되도록 하기 위하여 @EntityListeners(value={AuditingEntityListener.clss})를 추가한다. 또한 main() 메소드가 있는 실행 클래스인 SbootApplication.java에 @EnableJpaAuditing 어노테이션을 추가한다.
package com.example.sboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableJpaAuditing
public class SbootApplication {
public static void main(String[] args) {
SpringApplication.run(SbootApplication.class, args);
}
}
regDate와 modDate는 자바 8에서 추가된 LocalDateTime 클래스를 사용한다. regDate는 열이 추가될 때 자동으로 생성이 될 수 있도록 @CreatedDate 어노테이션을 추가하고, updatable=false로 하여 열 정보의 변경시 값이 변경이 되지 않토록한다. modDate는 열의 생성 및 열의 정보가 변경이 될 때마다 변경이 될 수 있도록 @LastModifiedDate 어노테이션을 추가한다.
회원정보를 저장하기 위한 entity로 com.example.sboot.member.entity 패키지에 Member.java를 다음과 같이 추가한다.
security를 위해 사용자의 권한 정보를 저장하여야 하는데, 이를 위해 MemberRole enum을 다음과 같이 추가한다.
package com.example.sboot.member.entity;
public enum MemberRole {
USER, MANAGER, ADMIN
}
권한 정보인 MemberRole은 USER, MANAGER, ADMIN과 같이 3가지 권한을 가지며, 사용자마다 여러 개의 권한을 부여할 수 있다. 이를 Member entity에서 접근할 수 있도록 Set<MemberRole>을 추가하고, 권한을 추가할 수 있는 메소드 addMemberRole()를 추가한다.
member 테이블과 member_role_set 테이블은 부모와 자식의 관계를 가지며, member 테이블의 아이디인 email을 PK/FK로 관계한다.
앱을 구동하면 다음과 같이 관련 테이블이 생성됨을 알 수 있다.
Hibernate:
create table member (
email varchar(30) not null,
moddate datetime(6),
regdate datetime(6),
address varchar(255),
birthday varchar(15),
from_social bit not null,
name varchar(30) not null,
nickname varchar(30),
password varchar(64) not null,
phone varchar(15),
primary key (email)
) engine=InnoDB
Hibernate:
create table member_role_set (
member_email varchar(30) not null,
role_set integer
) engine=InnoDB
Hibernate:
alter table member_role_set
add constraint FKd46gobgko9rkxodwx1m14qit6
foreign key (member_email)
references member (email)
MySQL workbench를 사용하여 데이터베이스 테이블의 생성을 확인할 수 있다.
security를 적용하기 위하여 security 설정이 필요하다. 다음과 같이 com.example.sboot.config 폴더를 생성하고 SecurityConfig 클래스를 생성한다. 물론 WebSecurityConfigurerAdapter 클래스를 상속을 받아야 한다.
설정파일임을 나타내기 위하여 @Configuration 어노테이션을 추가하고, 어노테이션에 의한 시큐리티 설정을 위해 @EnabledGlobalMethodSecurity 어노테이션을 추가한다.
security에서 제공하는 로그인 페이지와 로그아웃 페이지를 적용하기 위하여 http.formLogin()과 http.logout()을 적용한다.
지금까지는 security에 의해서 default로 정의된 Username user와 발급된 비밀번호를 사용하여 인증이 이루어졌다. 그러나 회원정보를 사용하여 로그인이 이루어져야 하므로 우선 시험적으로 테스트 사용자를 생성하여야 한다. 이를 위해 member 테이블을 조작하기 위한 MemberRepository가 필요하다.
com.example.sboot.member.repository 패키지에 MemberRepository 인터페이스를 생성하며, JpaRepository<Member, String>을 상속한다. 기본적으로 JpaRepository는 모든 JPA 기능을 가지고 있으므로 어느 정도 데이터를 조작할 수 있는 메소드들을 제공하고 있다. 아래 그림의 상속관계를 살펴보면 일반적인 기능만을 가지는 CrudRepository를 가지며, PagingAndSortRepository 기능을 가지며, 그 보다 많은 기능을 가지는 것을 알 수 있다.
이전 게시글에서는 스프링부트를 사용한 포트폴리오를 제작하기 위하여 기본 프로젝트를 생성하고, 프론트앤드에서 사용할 부트스트랩을 적용한 simple sidebar 템플레이트를 적용하였다.(https://talanton.tistory.com/123)
이번 글에서는 포트폴리오에서 사용할 뷰 페이지를 레이아웃을 적용하여 기본 템플레이트를 만든 다음에 페이지의 내용에 따라 뷰 페이지를 만드는 방법을 알아보고자 한다.
src/main/resources 폴더의 templates폴더에 layout 폴더를 생성하고 basic.html 파일을 생성한다.
index.html을 사용하여 basic.html을 다음과 같이 작성한다.
<th:block th:fragment="setContent(content)">태그와 Page content 부분에 <th:block th:replace = "${content}"></th:block>를 사용하여 각 뷰 페이지의 내용을 입력받도록 한다. 종료태그는 맨 마지막 </html> 태그 앞에 있다. 또 Bootstrap core JS와 Core theme JS를 </head> 태그 앞으로 이동한다. sidebarToggle 버튼을 클릭하면 사이드 바의 메뉴가 감춰지거나 보이도록 javascript(jQuery)를 사용하여 제어한다.
home.html을 다음과 같이 수정하여 레이아웃 파일 basic.html을 적용한 메인 페이지를 만든다. <th:block> 태그를 사용하여 home.html 메인 페이지의 내용을 basic.html에 적용하여 메인 페이지를 구성한다. th:fragment="content" 부분에 home.html의 내용을 추가한다.
다시 앱을 구동하고 브라우저를 사용하여 http://localhost:8080 으로 접속을 한다. 다시 로그인 페이지로 이동하고, Username user와 비밀번호를 입력하여 로그인한다. 로그인이 성공하고 다시 메인 페이지로 이동한다. 따라서 레이아웃 파일이 적용됨을 알 수 있다.
동작원리는 아래 그림을 참조한다.
layout1.html의 내용이 exTemplate.html에 적용(대체)이 되고, layout1.html의 content class의 부분이 exTemplate.html의 th:fragment="content"의 내용으로 대체가 되는 것이다.
Spring Boot DevTools, Lombok, Spring Data JPA, Spring Security, Thymeleaf, Spring Web을 설정해 주었다. 예를 들어 Available에서 security를 입력하면 Security->Spring Security 항목을 입력할 수 있다.
아래 그림과 같은 폴더가 Package Explorer에서 생성됨을 알 수 있다.
build.gradle 파일을 열어보면 다음과 같은 정보가 있음을 알 수 있다.
plugins {
id 'java'
id 'war'
id 'org.springframework.boot' version '2.7.6'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
추가로 MySQL connector-java 라이브러리를 build.gradle dependency 부분에 추가한다.
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
implementation("mysql:mysql-connector-java:8.0.29") // 추가된 부분
}
데이터베이스 연동을 위해 MySQL 데이터베이스에서 database myapp을 생성하고, 사용자 'bootuser'@'localhost'와 'bootuser'@'%'를 추가하고, 사용자에게 테입터베이스 접근 권한을 부여한다.
-- 데이터베이스 생성
create database myapp;
-- 사용자 생성
create user 'bootuser'@'localhost' identified by 'bootuser'; -- 서버에서 접속할 수 있는 사용자
create user 'bootuser'@'%' identified by 'bootuser'; -- 다른 컴퓨터에서 접속할 수 있는 사용자
-- 권한 부여
grant all privileges on myapp.* to 'bootuser'@'localhost';
grant all privileges on myapp.* to 'bootuser'@'%';
-- 디스크에 저장
commit;
프로젝트의 설정 파일 application.properties 파일은 다음과 같다. JDBC 연동을 위한 Database 설정과 Spring Data JPA를 위한 설정을 추가하고, security 로그를 위한 설정을 추가한다.
마우스로 프로젝트를 선택하고 우클릭 후 Gradle->Refresh Gradle Project를 선택하여 필요한 라이브러리를 다운로드 받는다. Project and External Dependencies에서 build.gradle에 설정한 라이브러리가 존재하는지 확인해 본다.
주님이 나에게 맡기신 달란트(헬라어로는 Talanton)를 남기고자 합니다. 주님에게 칭찬받는 종이 되기 위하여 주님이 주신 달란트를 땅에 묻어 두지 아니하고, 2배, 5배, 아니 10배를 남기고자 합니다. 나는 부족하지만, 주님께서 함께 하신다면 가능합니다. 하루를 기도로