각각의 멤버는 일반 유저인지, 아니면 관리자인지 구분할 수 있는 역할이 있어야 한다. 이를 구분하기 위해서 이와 관련된 코드를 작성해야 한다.
public enum Role {
USER,ADMIN
}
이후 회원 가입 화면으로부터 넘어오는 가입정보를 담은 DTO를 생성해야 한다.
package com.shop.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class MemberFormDto {
private String name;
private String email;
private String password;
private String address;
}
이제 회원 정보를 저장하는 Member 엔티티를 만들어야 한다. 관리할 회원의 정보는 이름, 이메일, 비밀번호, 주소, 역할이다.
package com.shop.entity;
import com.shop.constant.Role;
import com.shop.dto.MemberFormDto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.persistence.*;
@Entity
@Table(name = "member")
@Getter @Setter
@ToString
public class Member {
@Id
@Column
@GeneratedValue(strategy = GenerationType.AUTO)
private Long Id;
private String name;
@Column(unique = true)
private String email;
private String password;
private String address;
@Enumerated(EnumType.STRING)
private Role role;
public static Member createMember(MemberFormDto memberFormDto,
PasswordEncoder passwordEncoder) {
Member member = new Member();
member.setName(member.getName());
member.setEmail(member.getEmail());
member.setAddress(member.getAddress());
String password = passwordEncoder.encode(memberFormDto.getPassword());
member.setPassword(password);
member.setRole(Role.USER);
return member;
}
}
Member 엔티티를 데이터베이스에 저장할 수 있도록 MemberRepository를 만든다.
package com.shop.repository;
import com.shop.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByEmail(String email);
}
service 패키지를 만들고 MemberService 클래스를 작성한다.
package com.shop.service;
import com.shop.entity.Member;
import com.shop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Member saveMember(Member member) {
validateDuplicateMember(member);
return memberRepository.save(member);
}
private void validateDuplicateMember(Member member) {
Member findMember = memberRepository.findByEmail(member.getEmail());
if (findMember != null) {
throw new IllegalStateException("이미 가입된 회원입니다.");
}
}
}
회원가입 기능이 정상적으로 동작하는지 테스트 코드를 작성해 검증해보도록 하겠다.
package com.shop.service;
import com.shop.dto.MemberFormDto;
import com.shop.entity.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
PasswordEncoder passwordEncoder;
public Member createMember() {
MemberFormDto memberFormDto = new MemberFormDto();
memberFormDto.setEmail("test@gmail.com");
memberFormDto.setName("강한솔");
memberFormDto.setAddress("서울시 마포구 연남동");
memberFormDto.setPassword("1234");
return Member.createMember(memberFormDto, passwordEncoder);
}
@Test
@DisplayName("회원가입 테스트")
public void saveMemberTest() {
Member member = createMember();
Member savedMember = memberService.saveMember(member);
assertEquals(member.getEmail(), savedMember.getEmail());
assertEquals(member.getName(), savedMember.getName());
assertEquals(member.getAddress(), savedMember.getAddress());
assertEquals(member.getPassword(), savedMember.getPassword());
assertEquals(member.getRole(), savedMember.getRole());
}
}
다음으로 검증해 볼 내용은 중복된 이메일로 회원 가입을 시도할 경우 “이미 가입된 회원입니다.”라는 에러 메시지를 정상적으로 출력해주는지 테스트 코드를 작성하겠다.
@Test
@DisplayName("중복 회원 가입 테스트")
public void saveDuplicateMember() {
Member member1 = createMember();
Member member2 = createMember();
memberService.saveMember(member1);
Throwable e =assertThrows(IllegalStateException.class, () ->{
memberService.saveMember(member2);
});
assertEquals("이미 가입된 회원입니다.", e.getMessage());
}
중복 회원 가입 테스트를 실행하면 예상한 예외가 발생하고, 테스트를 통과하는 것을 볼 수 있다. 회원 가입 로직이 변경되더라도 작성해둔 테스트를 실행하여 빠르게 테스트 및 검증이 가능하다.
회원 가입 로직을 완성했으므로 이제 회원 가입을 위한 페이지를 만들겠다. Controller 패키지 아래 MemberController 클래스를 만들어보자.
package com.shop.controller;
import com.shop.dto.MemberFormDto;
import com.shop.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping(value = "/new")
public String memberForm(Model model) {
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm";
}
}
회원가입 페이지도 이전 Thymeleaf에서 사용했던 부트스트랩을 사용하겠다. 홈페이지의 예제 Forms에 나와있는 코드를 변형하여 사용한다. 홈페이지를 참고하면 여러가지 예시 코드와 결과 화면을 볼 수 있다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout1}">
<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
<style>
.fieldError {
color: #bd2130;
}
</style>
</th:block>
<!-- 사용자 스크립트 추가 -->
<th:block layout:fragment="script">
<script th:inline="javascript">
$(document).ready(function(){
var errorMessage = [[${errorMessage}]];
if(errorMessage != null){
alert(errorMessage);
}
});
</script>
</th:block>
<div layout:fragment="content">
<form action="/members/new" role="form" method="post" th:object="${memberFormDto}">
<div class="form-group">
<label th:for="name">이름</label>
<input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력해주세요">
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="fieldError">Incorrect data</p>
</div>
<div class="form-group">
<label th:for="email">이메일주소</label>
<input type="email" th:field="*{email}" class="form-control" placeholder="이메일을 입력해주세요">
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="fieldError">Incorrect data</p>
</div>
<div class="form-group">
<label th:for="password">비밀번호</label>
<input type="password" th:field="*{password}" class="form-control" placeholder="비밀번호 입력">
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="fieldError">Incorrect data</p>
</div>
<div class="form-group">
<label th:for="address">주소</label>
<input type="text" th:field="*{address}" class="form-control" placeholder="주소를 입력해주세요">
<p th:if="${#fields.hasErrors('address')}" th:errors="*{address}" class="fieldError">Incorrect data</p>
</div>
<div style="text-align: center">
<button type="submit" class="btn btn-primary" style="">Submit</button>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</div>
</html>
CSRF란 사이트간 위조 요청으로 사용자가 자신의 의지와 상관없이 해커가 의도한대로 수정, 등록, 삭제 등의 행위를 웹사이트 요청하게 하는 공격을 말한다.
package com.shop.controller;
import com.shop.dto.MemberFormDto;
import com.shop.entity.Member;
import com.shop.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@GetMapping(value = "/new")
public String memberForm(Model model) {
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm";
}
@PostMapping(value = "/new")
public String memberForm(MemberFormDto memberFormDto) {
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
return "redirect:/";
}
}
회원가입 후 메인 페이지로 갈 수 있도록 MainController.java 소스를 하나 작성하겠다.
package com.shop.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MainController {
@GetMapping(value = "/")
public String main() {
return "main";
}
}
resources/templates 폴더 아래 main.html 파일을 하나 생성한다. 메인 페이지는 추후 등록된 상품의 목록을 보여주도록 수정하겠다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout1}">
<div layout:fragment="content">
<h1>메인페이지입니다.</h1>
</div>
웹 브라우저에 “localhost/members/new” URL을 입력하면 회원 가입 페이지로 이동하는 것을 볼 수 있다. 회원 가입 등록을 위해 정보를 입력하고 버튼을 누르면 회원가입이 되면서 메인 페이지로 화면이 이동한다. 하지만 현재 상태에서는 이름이나 비밀번호를 입력하지 않아도 정상적으로 저장된다.
회원가입 페이지에서 서버로 넘어오는 값을 검증하기 위해서 pom.xml에 “spring.boot-stater-validation”을 추가하겠다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
유효한 값인지 판단하는 소스가 여러 군데 흩어지면 관리가 힘들다. 자바 빈 밸리데이션을 이용하면 객체의 값을 효율적으로 검증할 수 있다. 빈 검증 어노테이션을 몇가지 살펴보자.
어노테이션 | 설명 |
---|---|
@NotEmpty | NULL 체크 및 문자열의 경우 길이 0인지 검사 |
@NoBlank | NULL 체크 및 문자열의 경우 길이 0 및 빈 문자열(” “) 검사 |
@Length(min=, max=) | 최소, 최대 길이 검사 |
이메일 형식인지 검사 | |
@Max(숫자) | 지정한 값보다 작은지 검사 |
@Min(숫자) | 지정한 값보다 큰지 검사 |
@Null | 값이 NULL인지 검사 |
@NotNull | 값이 NULL이 아닌지 검사 |
유효성을 검증할 클래스의 필드에 어노테이션을 선언한다.
package com.shop.dto;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
@Getter @Setter
public class MemberFormDto {
@NotBlank(message = "이름은 필수 입력 값입니다.")
private String name;
@NotEmpty(message = "이메일은 필수 입력 값입니다.")
@Email(message = "이메일 형식으로 입력해주세요.")
private String email;
@NotEmpty(message = "비밀번호는 필수 입력 값입니다.")
@Length(min=8, max=16, message = "비밀번호는 8자 이상, 16자 이하로 입력해주세요.")
private String password;
@NotEmpty(message = "주소는 필수 입력 값입니다.")
private String address;
}
회원가입이 성공하면 메인 페이지로 리다이렉트 시켜주고, 회원 정보 검증 및 중복회원 가입 조건에 의해 실패한다면 다시 회원 가입 페이지로 돌아가 실패 이유를 화면에 출력해준다.
package com.shop.controller;
import com.shop.dto.MemberFormDto;
import com.shop.entity.Member;
import com.shop.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.validation.Valid;
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@GetMapping(value = "/new")
public String memberForm(Model model) {
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm";
}
@PostMapping(value = "/new")
public String memberForm(MemberFormDto memberFormDto) {
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
return "redirect:/";
}
@PostMapping(value = "new")
public String newMember(@Valid MemberFormDto memberFormDto,
BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
return "member/memberForm";
}
try {
Member member = Member.createMember(memberFormDto, passwordEncoder);
} catch (IllegalStateException e) {
model.addAttribute("errorMessage", e.getMessage());
return "member/memberForm";
}
return "redirect:/";
}
}
유효하지 않은 회원 가입 정보를 입력 후 서버로 전송하면 해당 이유를 화면에서 보여준다.
회원가입이 정상적으로 이루어졌다면 메인페이지로 이동한다.
메인페이지로 이동한 것만 봐서는 실제로 데이터베이스에 데이터가 저장됐는지 궁금할 수 있다. 이전 내용에서 설치했던 MySQL Wrokbench tool을 실행시켜 로컬 데이터베이스에 접속한다. “shop” 데이터베이스를 선택 후 member 테이블을 조회하면 가입할 때 입력한 이메일 주소가 데이터에 추가된 것을 확인할 수 있다. 비밀번호도 입력한 값이 아닌 암호화되어 저장된다.
package com.shop.service;
import com.shop.entity.Member;
import com.shop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
public Member saveMember(Member member) {
validateDuplicateMember(member);
return memberRepository.save(member);
}
private void validateDuplicateMember(Member member) {
Member findMember = memberRepository.findByEmail(member.getEmail());
if (findMember != null) {
throw new IllegalStateException("이미 가입된 회원입니다.");
}
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException{
Member member = memberRepository.findByEmail(email);
if (member == null) {
throw new UsernameNotFoundException(email);
}
return User.builder()
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRole().toString())
.build();
}
}
package com.shop.config;
import com.shop.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
MemberService memberService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/members/login")
.defaultSuccessUrl("/")
.usernameParameter("email")
.failureUrl("/members/login/error")
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))
.logoutSuccessUrl("/")
;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이제 로그인 페이지를 만들도록 하겠다. 로그인 페이지에서는 회원의 아이디와 비밀번호를 입력하는 입력란과 회원가입을 하지 않았을 경우 회원가입 페이지로 이동할 수 있는 버튼을 만들겠다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout1}">
<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
<style>
.error {
color: #bd2130;
}
</style>
</th:block>
<div layout:fragment="content">
<form role="form" method="post" action="/members/login">
<div class="form-group">
<label th:for="email">이메일주소</label>
<input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요">
</div>
<div class="form-group">
<label th:for="password">비밀번호</label>
<input type="password" name="password" id="password" class="form-control" placeholder="비밀번호 입력">
</div>
<p th:if="${loginErrorMsg}" class="error" th:text="${loginErrorMsg}"></p>
<button class="btn btn-primary">로그인</button>
<button type="button" class="btn btn-primary" onClick="location.href='/members/new'">회원가입</button>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</div>
</html>
로그인 페이지를 만들었으니까 이동할 수 있도록 MemberController에 로직을 구현하겠따. 또한 로그인 실패 시 “아이디 또는 비밀번호를 확인해주세요”라는 메세지를 담아서 로그인 페이지로 보내겠다.
package com.shop.controller;
import com.shop.dto.MemberFormDto;
import com.shop.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.shop.entity.Member;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.validation.BindingResult;
import javax.validation.Valid;
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@GetMapping(value = "/new")
public String memberForm(Model model) {
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm";
}
@PostMapping(value = "/new")
public String newMember(@Valid MemberFormDto memberFormDto, BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
return "member/memberForm";
}
try {
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
} catch (IllegalStateException e) {
model.addAttribute("errorMessage", e.getMessage());
return "member/memberForm";
}
return "redirect:/";
}
@GetMapping(value = "/login")
public String loginMember() {
return "/member/memberLoginForm";
}
@GetMapping(value = "/login/error")
public String loginError(Model model) {
model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요");
return "/member/memberLoginForm";
}
}
드디어 로그인/로그아웃 기능이 구현 완료되었다. 회원가입 후 로그인 페이지로 이동하여 아이디와 비밀번호를 입력 후 <로그인> 버튼을 클릭한다. 현재 애플리케이션 재실행 시 데이터베이스의 테이블을 삭제 후 재생성하므로, 회원가입을 진행한 후 로그인을 해야한다. 로그인 페이지 경로는 네비게이션 바에 미리 입력해두었으니 로그인 메뉴를 클릭하면 아이디와 비밀번호를 입력하는 페이지로 이동한다.
해당 게시글은 변구훈, 『스프링 부트 쇼핑몰 프로젝트 with JPA』, 로드북, 2021를 참고하여 작성하였습니다.