스프링 시큐리티를 이용한 회원가입 구현하기

진크·2022년 2월 22일
5
post-thumbnail

1. 스프링 시큐리티 설정 추가하기

security dependency 추가하기

해당 의존성을 추가하고 라이브러리를 받아옵니다.

//build.gradle

dependencies {
	....
	implementation 'org.springframework.boot:spring-boot-starter-security'
	testImplementation 'org.springframework.security:spring-security-test'
	...
}

애플리케이션을 실행하고 기존에 작성했던 localhost/thymeleaf/ex URL에 접근했을 때, 스프링 시큐리티에서 제공하는 로그인 페이지로 이동됩니다.

스프링 시큐리티에서 기본적으로 제공하는 아이디는 user이고, 비밀번호는 애플리케이션을 실행할 때마다 콘솔창에 출력해서 보여줍니다.

스프링 시큐리티를 추가하는 것만으로도 모든 요청이 인증을 필요로 하게 됩니다. 하지만 매번 스프링 시큐리티 콘솔창에서 비밀번호를 찾아 입력할 수 없기 때문에 회원 가입 기능과 각 페이지마다 필요한 권한처리를 하도록 하겠습니다.

  • 인증이 필요없는 경우: 상품 상세 페이지 조회
  • 인증이 필요한 경우: 상품 주문
  • 관리자 권한이 필요한 경우: 상품 등록

스프링 시큐리티 설정하기

설정을 관리할 config라는 패키지를 만들고 스프링시큐리티를 설정을 처리할 SecurityConfig 클래스를 만들겠습니다.

🐢
Ctrl + O 단축키를 입력하시면 Override 가능한 메서드 목록을 확인하여 손쉽게 구현할 수 있습니다.

package me.jincrates.gobook.config;

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;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • @EnableWebSecurity: WebSecurityConfigurerAdapter를 상속받는 클래스에 해당 어노테이션을 선언하면 SpringSecurityFilterChain이 자동으로 포함됩니다.
  • void configure(HttpSecurity http): http 요청에 대한 보안을 설정합니다.
  • PasswordEncoder passwordEncoder(): 비밀번호를 그대로 저장하지 않고 BCryptPasswordEncoder의 해시 함수를 이용하여 암호화처리합니다.

2. 회원가입 기능 구현하기

회원 도메인을 관리하기 위해 domain 패키지 하위에 members 패키지를 생성하고 각각의 멤버가 일반 유저인지 관리자인지를 구분할 MemberRole enum을 생성하겠습니다.

//me/jincrates/gobook/domain/members/MemberRole.java

package me.jincrates.gobook.domain.members;

public enum MemberRole {
    USER, ADMIN
}

Member DTO, Member 엔티티 작성

다음은 회원가입 화면으로부터 넘어오는 가입정보를 담을 dto를 생성하고 회원정보를 저장하는 Member 엔티티를 만들겠습니다. 관리할 회원 정보는 이름, 이메일, 비밀번호, 주소, 역할입니다. dto로 전달받은 값이 유효한지도 검증하겠습니다.

//build.gradle

dependencies {
	....
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	...
}
package me.jincrates.gobook.web.dto;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;

@NoArgsConstructor
@Getter
public class MemberFormDto {

    @NotBlank(message = "이름은 필수 입력 값입니다.")
    private String name;

    @NotEmpty(message = "이메일은 필수 입력 값입니다.")
    @Email(message = "이메일 형식으로 입력해주세요.")
    private String email;

    @NotEmpty(message = "비밀번호는 필수 입력 값입니다.")
    @Length(min = 4, max = 16, message = "비밀번호는 4자 이상, 16자 이하로 입력해주세요.")
    private String password;

    @NotEmpty(message = "주소는 필수 입력 값입니다.")
    private String address;

    @Builder
    public MemberFormDto(String name, String email, String password, String address) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.address = address;
    }
}
package me.jincrates.gobook.domain.members;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.jincrates.gobook.web.dto.MemberFormDto;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.persistence.*;

@NoArgsConstructor
@Getter
@Table(name = "member")
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Column(unique = true)
    private String email;

    private String password;

    private String address;

    @Enumerated(EnumType.STRING)
    private MemberRole role;

    @Builder
    public Member(String name, String email, String password, String address, MemberRole role) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.address = address;
        this.role = role;
    }

    public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder) {
        Member member = Member.builder()
                .name(memberFormDto.getName())
                .email(memberFormDto.getEmail())
                .address(memberFormDto.getAddress())
                .password(passwordEncoder.encode(memberFormDto.getPassword()))  //암호화처리
                .role(MemberRole.USER)
                .build();
        return member;
    }
}

MemberRepository 생성

Member 엔티티를 만들었다면 데이터베이스에 저장할 수 있도록 MemberRepository를 만들겠습니다.

package me.jincrates.gobook.domain.members;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Member findByEmail(String email);
}
  • Member findByEmail(String email): 회원 가입시 중복된 회원이 있는지 검사하기 위해 이메일로 회원을 검사하는 메소드를 작성합니다.

MemberService 생성

gobook 패키지 아래 service 패키지를 만들고 MemberService 클래스를 작성합니다.

package me.jincrates.gobook.service;

import lombok.RequiredArgsConstructor;
import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.domain.members.MemberRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Transactional
@Service
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: 로직을 처리하다가 에러가 발생하면, 변경된 데이터를 로직을 수행하기 이전 상태로 콜백 시켜줍니다.
  • @RequiredArgsConstructor: final이나 @NonNull이 붙은 필드에 생성자를 생성해줍니다. 빈에 생성자가 1개이고 생성자의 파라미터 타입이 빈으로 등록이 가능하다면 @Autowired 어노테이션이 없이 의존성 주입이 가능합니다.

회원가입 테스트 코드 작성

//src/test/java/me/jincrates/gobook/service/MemberServiceTest.java

package me.jincrates.gobook.service;

import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.domain.members.MemberRole;
import me.jincrates.gobook.web.dto.MemberFormDto;
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.*;

@SpringBootTest
@Transactional
@TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"})
public class MemberServiceTest {

    @Autowired
    MemberService memberService;

    @Autowired
    PasswordEncoder passwordEncoder;

    public Member createMember() {
        MemberFormDto memberFormDto = MemberFormDto.builder()
                .email("test@email.com")
                .name("테스트")
                .address("서울시 강서구")
                .password("1111")
                .build();
        return Member.createMember(memberFormDto, passwordEncoder);
    }

    @Test
    @DisplayName("회원가입 테스트")
    public void saveMemberTest() {
        Member member = createMember();
        Member savedMember = memberService.saveMember(member);

        assertEquals(member.getEmail(), savedMember.getEmail());
    }
}

MemberController 작성

web 패키지 안에 MemberController를 작성합니다. 회원가입이 성공하면 메인 페이지로 리다이렉드 시켜주고, 회원 정보 검증 및 중복회원 가입 조건에 의해 실패한다면 다시 회원 가입 페이지로 돌아가 실패 이유를 화면에 출력하도록 하겠습니다.

package me.jincrates.gobook.web;

import lombok.RequiredArgsConstructor;
import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.service.MemberService;
import me.jincrates.gobook.web.dto.MemberFormDto;
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;

@RequiredArgsConstructor
@RequestMapping("/members")
@Controller
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(@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:/";
    }
}
  • @Valid 어노테이션 : 객체의 유효성을 검증합니다.

MemberForm.html 작성

Controller에서 작성한 메소드 리턴값에 맞게 경로를 지정합니다. 기본적으로 resources하위인 templatese를 바라보기 때문에 하위에 member 폴더를 만들고 그 하위에 MemberForm.html을 작성했습니다. 부트스트랩 Checkout 예제를 통해 더 우아하게 꾸밀 수 있습니다.(https://getbootstrap.kr/docs/5.1/examples/checkout/)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/default}">

<!-- 사용자 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 py-2">
            <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 py-2">
            <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 py-2">
            <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 py-2">
            <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" class="py-3">
            <button type="submit" class="btn btn-outline-dark">Submit</button>
            <button type="button" class="btn btn-outline-dark" onclick="history.back();">Cancel</button>
        </div>
        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
    </form>

</div>

</html>
  • ${_csrf.token} : 스프링 시큐리티를 사용할 경우 기본적으로 CSRF(Cross Site Request Forgery)를 방어하기 위해 모든 POST 방식의 데이터 전송에는 CSRF 토큰 값이 있어야 합니다. CSRF 토큰은 실제 서버에서 요청이 맞는지 확인하기 위한 토큰입니다. 사용자의 세션에 임의의 값을 저장하여 요청마다 그 값을 포함하여 전송하면 서버에서 세션에 저장된 값과 요청이 온 값이 일치하는지 확인하여 CSRF를 방어합니다.

MainController, main.html 작성

메인 페이지로 이동할 수 있도록 MainController를 생성하고 main.html 페이지를 작성합니다.

package me.jincrates.gobook.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MainController {

    @GetMapping(value = "/")
    public String main() {
        return "main";
    }
}
<!DOCTYPE html>
<html xmlns:th="http//www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/default}">

<div layout:fragment="content">

    <h1>메인페이지입니다.</h1>

</div>

</html>

쓰다보니 너무 길어져서 로그인 및 로그아웃은 다음으로 넘기겠습니다.

profile
철학있는 개발자 - 내가 무지하다는 것을 인정할 때 비로소 배움이 시작된다.

3개의 댓글

comment-user-thumbnail
2022년 8월 27일

좋은 글 감사합니다. 저도 따라 구현해봤는데 자꾸 @PostMapping 하는 부분에서 member 정보가 안넘어오는 NullPointerError가 나더라구요. 어떻게 하면 해결할 수 있을까요..?

1개의 답글
comment-user-thumbnail
2022년 10월 18일

코드 정말 깔끔하게 잘 작성하셨네요!!
시큐리티로 회원가입을 구현하는데 참고가 많이 됐습니다!
혹시 Member 클래스에 있는 createMember가 정적 팩토리 메서드 맞나요??

답글 달기