이건 강의 없이 번외 편으로 만들어지는 파트입니다.
Role 클래스 작성(어드민,유저 권한)-> DB 단 작성(Dto, Entity 순서는 상관없음) ->레포지토리 작성-> 서비스 클래스 작성->컨트롤러 클래스 작성->view 단 작성 -> 밸리데이션 의존성 추가 -> Dto 추가 수정 -> 컨트롤러 클래스에서 회원 가입 시 리턴으로 어디로 돌아갈지 작성
각각의 멤버는 일반 유저인지,아니면 관리자인지 구분할 수 있는 역할 이 있어야 합니다. 이를 구분하기 위해서 constant 패키지에 아래의 Role.java를 추가합니다.
Role
package cho.boardplus.constant;
public enum Role {
USER,ADMIN
}
회원 가입 화면으로 부터 넘어오는 가입 정보를 담을 DTO를 생성하고 Entity 도 마저 생성합시다.
MemberFormDtO
package cho.boardplus.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class MemberFormDto {
private String name;
private String email;
private String password;
private String address;
}
Entity는 이름,이메일,비밀번호,주소 이렇게 설정할 생각입니다.
Member (Entity)
package cho.boardplus.entity;
import cho.boardplus.constant.Role;
import cho.boardplus.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 extends BaseEntity {
@Id
@Column(name="member_id")
@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(memberFormDto.getName());
member.setEmail(memberFormDto.getEmail());
member.setAddress(memberFormDto.getAddress());
String password = passwordEncoder.encode(memberFormDto.getPassword());
member.setPassword(password);
member.setRole(Role.ADMIN);
return member;
}
}
- 회원은 이메일을 통해 유일하게 구분해야 하기 때문에, 동일한 값이 데이터베이스에 들어올 수 없도록 unique 속성을 지정합니다
- 자바의 enum 타입을 엔티티의 속성으로 지정할 수 있습니다. Enum을 사용할 때 기본적으로 순서가 저장되는데, enum의 순서가 바뀔 경우 문제가 발생할 수 있으므로 "EnumType.STRING" 옵션을 사용해서 String으로 지정하기를 권장합니다.
- public static MemberEntity createMember(MemberFormDTO memberFormDTO, PasswordEncoder passwordEncoder) Member 엔티티를 생성하는 메소드 입니다. Member 엔티티에 회원 생성하는 메소드를 만들어서 관리를 한다면 코드가 변경되더라도 한 군데만 수정하면 되는 이점이 있습니다.
- 스프링 시큐리티 설정 클래스에 등록한 BCryptPasswordEncoder Bean을 파라미터로 넘겨서 비밀번호를 암호화 합니다.
- memberEntity.setRole(Role.ADMIN); 회원가입할 때 어드민 권한으로 가입하는 의미 입니다. 이걸 USER 로 변경 하면 회원가입 할때 권한이 일반 유저로 적용됩니다.
MemberEntity를 데이터베이스에 저장할 수 있도록 MemberRepository를 만듭니다.
MemberRepository
package cho.boardplus.repository;
import cho.boardplus.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member,Long> {
Member findByEmail(String email);
}
- 회원가입 시 중복된 회원이 있는지 검사하기 위해서 이메일로 검사할 수 있도록 쿼리 메소드를 작성합니다.
service 패키지에서 MemberService 클래스를 추가해서 작성합니다.
MemberService
package cho.boardplus.service;
import cho.boardplus.entity.Member;
import cho.boardplus.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("이미 가입된 회원입니다.");
}
}
}
- 비즈니스 로직을 담당하는 서비스 계층 클래스에 @Transactional 어노테이션을 선언합니다. 로직을 처리하다가 에러가 발생하였다면, 변경된 데이터를 로직을 수행하기 이전 상태로 콜백 시켜줍니다.
- 빈을 주입하는 방법으로는 @Autowired 어노테이션을 이용하거나, 필드 주입(Setter 주입), 생성자 주입을 이용하는 방법이 있습니다.@RequiredArgsConstructor 어노테이션은 final이나 @NonNull이 붙은 필드에 생성자를 생성해 줍니다. 빈에 생성자가 1개이고 생성자의 파라미터 타입이 빈으로 등록이 가능하다면 @Autowired 어노테이션 없이 의존성 주입이 가능합니다.
- 이미 가입된 회원의 경우 IllegalStateException 예외를 발생시킵니다.
회원가입은 db에서 아주 민감하게 다뤄야 하기 때문에 테스트 코드를 작성하면서 해야 안전합니다.
일단 테스트 코드를 작성하기 위해 application-test.properties를 추가해야 합니다.
application-test.properties
# Datasource
spring.datasource.driver-class-name= org.h2.Driver
spring.datasource.url= jdbc:h2:mem:test
spring.datasource.username= sa
spring.datasource.password=
# H2
spring.jpa.database-platform= org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto= update
MemberServiceTest
package cho.boardplus.service;
import cho.boardplus.dto.MemberFormDto;
import cho.boardplus.entity.Member;
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;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
public class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
PasswordEncoder passwordEncoder;
public Member createMember() {
MemberFormDto memberFormDto = new MemberFormDto();
memberFormDto.setEmail("test@email.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());
}
}
- 테스트 클래스에서 @Transactional 어노테이션을 선언할 경우, 테스트 실행 후 록백 처리가 됩니다. 이를 통해 같은 메서드를 반복적으로 테스트 할 수 있습니다.
- 회원 정보를 입력한 MemberEntity 만드는 메소드를 작성합니다.
- junit Assertions 클래스의 asserEquals 메소드를 이용하여 저장하려고 요청했던 값과 실제 저장된 데이터를 비교합니다. 첫 번째 파라미터에는 기대 값, 두 번째 파라미터에는 실제로 저장된 값을 넣어줍니다.
테스트 실행 결과 회원가입이 정상적으로 이루어진 것을 확인할 수 있습니다.
다음으로 검증해 볼 내용은 중복된 이메일로 회원가입을 시도할 경우 "이미 가입된 회원입니다."라는 에러 메시지를 정상적으로 출력해 주는지 테스트 코드를 작성하고 실행해 볼 겁니다.
MemberServiceTest
package cho.boardplus.service;
import cho.boardplus.dto.MemberFormDto;
import cho.boardplus.entity.Member;
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;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
public class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
PasswordEncoder passwordEncoder;
public Member createMember() {
MemberFormDto memberFormDto = new MemberFormDto();
memberFormDto.setEmail("test@email.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 saveDuplicateMemberTest(){
Member member1 = createMember();
Member member2 = createMember();
memberService.saveMember(member1);
Throwable e = assertThrows(IllegalStateException.class, () ->{
memberService.saveMember(member2);});
assertEquals("이미 가입된 회원입니다.",e.getMessage());
}
}
- junit의 Assertions 클래스의 assertThrows 메소드를 이용하면 예외 처리 테스트가 가능합니다. 첫 번째 파라미터에는 발생할 예외 타입을 넣어줍니다.
- 발생한 예외 메시지가 예상 결과와 맞는지 검증합니다.
중복 회원 가입 테스트를 실행하면 예상한 예외가 발생하고, 테스트를 통과하는 것을 볼 수 있습니다. 회원 가입 로직이 변경되더라도 작성해 둔 테스트를 실행하여 빠르게 테스트 및 검증이 가능합니다.
회원 가입 로직을 완성했으므로 이제 회원 가입을 위한 페이지를 만들겠습니다. Controller 패키지 아래에 MemberController 클래스를 만들어 봅니다.
MemberController
package cho.boardplus.controller;
import cho.boardplus.dto.MemberFormDto;
import cho.boardplus.entity.Member;
import cho.boardplus.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"; //뷰 단으로 리턴
}
}
- 회원 가입 페이지로 이동할 수 있도록 MemberController 클래스에 메소드를 작성합니다.
회원 가입 페이지를 작성해야 합니다. 이건 다른 곳에서 긁어 왔습니다.
MemberForm
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
</head>
<body>
<h1>회원가입</h1>
<form action="#" th:action="@{/members/new}" th:object="${memberFormDto}" method="post">
<label for="name">이름:</label>
<input type="text" id="name" name="name" th:field="*{name}" required>
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
<label for="email">이메일주소:</label>
<input type="email" id="email" name="email" th:field="*{email}" required>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
<label for="password">비밀번호:</label>
<input type="password" id="password" name="password" th:field="*{password}" required>
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
<label for="address">주소:</label>
<input type="text" id="address" name="address" th:field="*{address}" required>
<p th:if="${#fields.hasErrors('address')}" th:errors="*{address}"></p>
<input type="submit" value="가입하기">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</body>
</html>
- 회원 가입 시 실패했다면 에러 메시지를 경고창을 이용해서 보여줍니다.
- 스프링 시큐리티를 사용할 경우 기본적으로 CSRF(cross Site Request Forgery)를 방어하기 위해 모든 POST 방식의 데이터 전송에는 CSRF 토큰 값이 있어야 합니다 CSRF 토큰은 실제 서버에서 허용한 요청이 맞는지 확인하기 위한 토큰입니다. 사용자의 세션에 임의의 값을 저장하여 요청마다 그 값을 포함하여 전송하면 서버에서 세션에 저장된 값과 용청이 온 값이 일치하는지 확인하여 CSRF를 방어합니다.
CSRF
CSRF(Cross Site Request Forgery)란 사이트 간 위조 요청으로 사용자가 자신의 의지와 상관없이 해커가 의도한 대로 수정, 등록, 삭제 등의 행위를 웹사이트 요청하게 하는 공격을 말합니다.
MemberController
package cho.boardplus.controller;
import cho.boardplus.dto.MemberFormDto;
import cho.boardplus.entity.Member;
import cho.boardplus.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 newMember(@Valid MemberFormDto memberFormDto){
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
return "redirect:/"; //메인페이지로 리턴
}
}
회원가입 후 메인 페이지로 갈 수 있도록 리턴값을 줬습니다.
localhost/members/new URL을 입력하면 위 사진과 같이 페이지로 이동하는 걸 볼 수 있습니다. 회원 가입 등록 을 위해 정보를 입력하고 Submit 버튼을 누르면 회원 가입이 되면서 메인페이지로 화면이 이동합니다. 하지만 지금 이 상태로에서는 이름이나 비밀번호를 입력하지 않아도 정상적으로 저장됩니다. 회원 가입 페이지에서 서버로 넘어오는 값을 검증하기 위해 새로운 기능을 추가해야 합니다. build.gradle 에 의존성을 추가해줍니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
유효한 값인지 판단하는 소스가 여러 군데 흩어지면 관리하기가 힘듭니다. 자바 빈 밸리데이션을 이용하면 객체의 값을 효율적으로 검증할 수 있습니다. 빈 검증 어노테이션을 몇 가지 살펴보겠습니다.
java.validation 어노테이션 예시
어노테이션 | 설명 |
@NotEmpty | NULL 체크 및 문자열의 경우 길이 0인지 검사 |
@NotBlank | NULL 체크 및 문자열의 경우 길이 0 및 빈 문자열(" ") 검사 |
@Length(min= , max=) | 최소, 최대 길이 검사 |
이메일 형식인지 검사 | |
@Max(숫자) | 지정한 값보다 작은지 검사 |
@Min | 지정한 값보다 큰지 검사 |
@Null | 값이 NULL인지 검사 |
@NotNull | 값이 NULL이 아닌지 검사 |
유효성을 검증할 클래스의 필드에 어노테이션을 선언합니다.
MemberFormDto
package cho.boardplus.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;
}
회원 가입이 성공하면 메인페이지로 리다이렉트 시켜주고, 회원 정보 검증 및 중복회원 가입 조건에 의해 실패한다면 다시 회원 가입 페이지로 돌아가 실패 이유를 화면에 출력해 주겠습니다.
MemberController
package cho.boardplus.controller;
import cho.boardplus.dto.MemberFormDTO;
import cho.boardplus.entity.MemberEntity;
import cho.boardplus.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 newMember(@Valid MemberFormDTO memberFormDTO, BindingResult bindingResult , Model model){
if (bindingResult.hasErrors()){
return "member/memberForm";
}
try{
MemberEntity memberEntity = MemberEntity.createMember(memberFormDTO,passwordEncoder);
memberService.saveMember(memberEntity);
}catch (IllegalStateException e){
model.addAttribute("errorMessage", e.getMessage());
return "member/memberForm";
}
return "redirect:/";
}
}
- 검증하려는 객체의 앞에 @Valid 어노테이션을 선언하고, 파라미터로 bindingResult 객체를 추가합니다. 검사 후 결과는 bindingResult에 담아줍니다. bindingResult.hasErrors()를 호출하여 에러가 있다면 회원 가입 페이지로 이동합니다.
- 회원 가입 시 중복 회원 가입 예외가 발생하면 에러 메시지를 뷰로 전달합니다.
유효하지 않은 회원 가입 정보를 입력 후 서버로 전송하면 해당 이유를 화면에서 보여줍니다.
다르게 내용으로 작성을 해봤습니다. db에 정상적으로 오면 성공입니다.
중요한 건 view 단이랑 getter 내용 작성한 내용이랑 일치하게 조심히 작성하세요 정 어려우면 gpt를 쓰시면 조금 편할 겁니다.