본문 바로가기
Dev/SpringBoot

31. [springboot] Spring boot 기초 회원가입 예제 v2.0

by VIPeveloper 2020. 12. 31.
반응형

1. 서론

회원가입v1.0 포스팅을 쓴지 6개월이 지났습니다. 어느덧 개발자로서는 2년차를 향해 달려가고 있습니다.

다양한 프로젝트를 경험하고, 여기저기서 주워들은 지식을 활용해서 더 깊게 정리하고 싶은 마음이 커졌습니다. 그래서 2.0 버전을 만들게 되었습니다.

이번 포스팅 역시 스프링 시큐리티를 이용하여 권한을 부여하고, 회원가입 처리를 하는 간단 예제를 만들어 볼 것입니다.

조금 더 세련된 기술이 적용되었고, v1.0보다 더 쉽게 로직을 따라갈 수 있도록 만들었습니다. 이번 프로젝트에서는 포스팅 겸 프로젝트도 만들어보았습니다. 최신화된 예제 코드는 여기에서 참조가능합니다.

 

+) 이번 포스팅을 작성하기 위해 저만의 작은 목표를 정해보았습니다. 목표는 아래와 같습니다.

1. @Valid

2. @interface

3. AOP

4. TDD

이 포스팅을 이해한다면?

이 글을 이해하면 간단한 회원가입을 스프링 시큐리티를 이용해서 구현할 수 있습니다.

간단한 MVC 구조를 알고 계신다면 더 편하게 따라오실 수 있습니다.

 


2. 본론

프로젝트를 생성합니다.

예제의 전체 구조는 다음과 같습니다.

[그림01] 전체 조감도입니다.

Spring Boot 2.4.0 을 사용하였고, Java15를 이용하였습니다.

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>15</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>javax.validation</groupId>
            <artifactId>validation-api</artifactId>
            <version>2.0.1.Final</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

도메인 모델링

먼저, 원활한 회원가입을 위해 도메인을 모델링해줍니다.

여기서 주의할 점은 username 부분인데, 시큐리티에서 템플릿과 연동 시 기본으로 제공하는 name 값이 username 이므로 이에 맞추어 줍니다. (스프링 시큐리티를 이용해 커스터마이징 가능합니다.)

 

+) @EqualsAndHashCode(of="id")를 사용했습니다.

연관 관계가 복잡해 질 때, @EqualsAndHashCode에서 서로 다른 연관 관계를 순환 참조하느라 무한 루프가 발생하고, 결국 stack overflow가 발생할 수 있기 때문에 id 값만 주로 사용해줍니다.(출처)

 

++) @Setter를 사용하지 않는 이유는 값을 함부로 변경하는 것을 방지하기 위해서입니다.

Account.java

package com.example.demo.account;

import lombok.*;

import javax.persistence.*;
import java.util.UUID;

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")
public class Account {

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

    private String username;
    private String password;
    private String nickname;
    private boolean remember;
}

 

레파지토리 모델링

엔티티를 바탕으로 repository 클래스를 만들어줍니다.

JpaRepository는 다양한 interface를 제공해주는데, 제가 요즘 가장 많이 사용하고 있는 두 함수를 만들어줍니다.

이름만 봐도 무엇을 Param으로 삼아 처리해줄 것인지 유추해볼 수 있습니다.

AccountRepository.java

package com.example.demo.account;

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

public interface AccountRepository extends JpaRepository<Account,Long> {
    boolean existsByUsername(String username);
    Account findByUsername(String username);
}

서비스 모델링

서비스 클래스를 만들어 Transaction 처리할 수 있는 환경을 만들어줍니다. @Transactional 어노테이션은 함수가 불의의 사고로 구동 실패 시 Rollback 할 수 있도록 안전장치하는 어노테이션이므로 로직 처리는 여기에서 진행해줍니다.

 

+) 이 계층은 흔히 '비즈니스 로직'이라고 불리웁니다. 엥간한 로직처리는 여기에서 수행해줍니다.

AccountService.java

package com.example.demo.account;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
@RequiredArgsConstructor
public class AccountService implements UserDetailsService {

    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;

    public void signUp(SignUpForm signUpForm) {
        Account account = Account.builder().username(signUpForm.getUsername())
                .password(passwordEncoder.encode(signUpForm.getPassword()))
                .build();
        accountRepository.save(account);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByUsername(username);
        if(account == null){
            throw new UsernameNotFoundException(username);
        }

        return new UserAccount(account);
    }
}

+) 여기엔 SignUpForm 이라는 DTO 객체가 있습니다. Entity의 변경은 자주 이루어지는 것을 지양합니다. 자주 변경하면, 협업에 혼돈이 올 수 있기 때문입니다. 이를 방지하기 위해 DTO를 만들어 프런트엔드 개발자분이 수행한 name attribute와 이름을 맞춰주고, 백엔드단의 Entity와 매핑하는 작업을 진행하기 위해 DTO를 사용합니다.

SignUpForm.java

package com.example.demo.account;

import lombok.Data;

import javax.validation.constraints.NotBlank;

@Data
public class SignUpForm {
    
    @NotBlank
    private String username;
    private String password;
    private String nickname;
    private boolean remember;
}

웹 계층 모델링

- 프론트엔드와 직접적으로 관련된 컨트롤러 계층입니다. 직관적이고 쉽게 코드를 짜는 것이 좋습니다.

package com.example.demo.account;

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.PostMapping;

import javax.validation.Valid;
import java.util.Collections;
import java.util.List;

@Controller
@RequiredArgsConstructor
public class AccountController {

    private final AccountService accountService;
    private final AccountRepository accountRepository;
    private final SignUpFormValidator signUpFormValidator;

    @InitBinder("signUpForm")
    public void initBinder(WebDataBinder webDataBinder){
        webDataBinder.addValidators(signUpFormValidator);
    }

    @GetMapping("/sign-up")
    public String signUpForm(Model model){
        model.addAttribute("signUpForm",new SignUpForm());
        return "sign-up";
    }

    @PostMapping("/sign-up")
    public String signUpSubmit(@Valid SignUpForm signUpForm, Errors errors, RedirectAttributes attributes){
        if(errors.hasErrors()){
            return "sign-up";
        }
        attributes.addFlashAttribute("message","회원가입 성공!");
        accountService.signUp(signUpForm);
        return "redirect:/";
    }

    @GetMapping("/")
    public String home(@CurrentUser Account account, Model model){
        if(account != null){
            model.addAttribute(account);
        }

        return "index";
    }
}

@GetMapping일 때는 간단한 DTO객체를 만들어 sign-up 뷰로 넘기고, @PostMapping 요청으로 받았을 때에는 에러유무를 확인한 후, 회원가입 로직을 수행합니다. 그 후, Flash Message를 담아 초기화면으로 리다이렉트 시켜줍니다.

 

+) 여기서 추가 구현 할 부분이 두 가지 있습니다. @Valid와 @CurrentUser 입니다.

@Valid

@Valid는 Errors와 함께 다니며, Validator를 상속받아 구현체를 만들어주면 백엔드에서 로직검사를 수행할 수 있습니다. 이를 WebDataBinder로 가져오게 되고, Validator를 추가하기만 하면 끝입니다.

    @InitBinder("signUpForm")
    public void initBinder(WebDataBinder webDataBinder){
        webDataBinder.addValidators(signUpFormValidator);
    }

SignUpFormValidator.java

 

package com.example.demo.account;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
@RequiredArgsConstructor
public class SignUpFormValidator implements Validator {

    private final AccountRepository accountRepository;

    @Override
    public boolean supports(Class<?> aClass) {
        return aClass.isAssignableFrom(SignUpForm.class);   // 어떤 타입의 인스턴스를 검증을 할 것인가?
    }

    // 뭘 검사할 것인가?
    @Override
    public void validate(Object o, Errors errors) {
        SignUpForm signUpForm = (SignUpForm)o;
        if(accountRepository.existsByUsername(signUpForm.getUsername())){
            errors.rejectValue("username","invalid username","이미 사용중인 아이디입니다.");
        }
    }
}

해당 DTO를 활용하여 validate 로직을 구현해보았습니다. 저는 단순히 아이디가 존재하면, 에러를 반환하도록 하였습니다.

@CurrentUser

@CurrentUser 라는 어노테이션으로 Account 타입을 받고 싶을 때 사용합니다.

package com.example.demo.account;

import org.springframework.security.core.annotation.AuthenticationPrincipal;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME) // 런타임까지 유지할 수 있도록 합니다.
@Target(ElementType.PARAMETER)      // 파라메타에만 붙을 수 있도록 합니다.
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {
}

@AuthenticationPrincipal 어노테이션은 스프링 시큐리티에서 사용하는 어노테이션입니다. 로그인 이전에는 anonymousUser라는 String 타입이고, 인증되었다면 account를 받을 수 있도록 합니다. 하지만 시큐리티에서는 account라는 property는 존재하지 않습니다. 기본적을 제공해주는 것은 username, password, grant 정도인데요. 이를 커스텀하기 위한 어뎁터를 구현해줍니다.

UserAccount.java

package com.example.demo.account;

import lombok.Getter;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.List;

// @CurrentUser와 연관된 객체
@Getter
public class UserAccount extends User {

    private Account account;

    public UserAccount(Account account) {
        super(account.getUsername(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
        this.account = account;
    }
}
스프링 시큐리티가 지원하는 유저정보(extends User)와, 도메인에서 다루는 유저정보(Account)의 차이를 매꾸어줍니다.

뷰 꾸며주기

뷰 페이지를 꾸며줍니다. 프론트엔드는 부트스트랩을 이용합니다. 디자인적 감각이 없어도 가성비넘치는(?) 디자인적 감각을 이끌어주기 때문입니다.

index.html

대문을 장식하는 인덱스 페이지를 만들어주도록 합니다. 단순하게 회원가입과 로그인만 가능한 마이크로 서비스입니다.

[그림02] 인덱스페이지

 

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">

    <style>
        body {font-family: Arial, Helvetica, sans-serif; margin: 0;}

        .container {
        width: 100vw;
        height: 100vh;
        padding: 0;
        }
        .container > .box{
        text-align:center;
        position: relative;
        top: 50%;
        transform: translateY(-50%);
        }
    </style>
</head>
<body class="bg-light">

<div class="container">
    <p class="alert alert-info" th:if="${message}" th:text="${message}"></p>
    <div class="box" th:if="${account != null}">
        <h2 class="text-muted mb-4" th:text="${account?.nickname}+'님, 안녕하세요!'"></h2>
        <form th:action="@{/log-out}" method="post">
            <button type="submit" class="btn btn-info" style="width:auto;">log out</button>
        </form>
    </div>
    <div class="box" th:if="${account == null}">
        <h2 class="text-muted ">간단하게 구현해본 회원가입, 로그인</h2>
        <p><small class="text-muted mb-4">Covered by, dkyou</small></p>
        <a th:href="@{/sign-up}" class="btn btn-lg btn-outline-primary">sign up</a>
        <a th:href="@{/log-in}" class="btn btn-lg btn-outline-primary">log in</a>
    </div>
</div>

<!-- Option 1: jQuery and Bootstrap Bundle (includes Popper) -->
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>

</body>
</html>

멋지진 않지만 나름 느낌있는 첫 화면이 완성되었습니다.

 

sign-up.html

회원가입 폼도 만들어줍니다. 이메일, 패스워드, 닉네임을 받도록 했습니다.

[그림03] 회원가입 페이지

 

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
</head>
<body>

<div class="container mt-5">
    <h1>Sign Up</h1>
    <p>Please fill in this form to create an account.</p>
    <hr>
    <form th:action="@{/sign-up}" th:object="${signUpForm}" method="post">
        <div class="mb-3">
            <label th:for="username" class="form-label"><b>Email address</b></label>
            <input type="email" class="form-control" th:field="*{username}" placeholder="이메일을 입력하세요" aria-describedby="emailHelp">
            <div id="emailHelp" class="form-text">이메일은 중복될 수 없습니다.</div>
            <p th:if="${#fields.hasErrors('username')}" th:errors="*{username}">email Error</p>
        </div>
        <div class="mb-3">
            <label th:for="password"><b>Password</b></label>
            <input id="password" type="password" class="form-control" placeholder="비밀번호를 입력하세요" th:field="*{password}" required>
        </div>
        <div class="mb-3">
            <label th:for="nickname"><b>Nickname</b></label>
            <input id="nickname" type="text" class="form-control" placeholder="닉네임을 입력하세요" th:field="*{nickname}" required>
        </div>

        <div class="mb-3 form-check">
            <input type="checkbox" th:field="*{remember}" class="form-check-input" id="exampleCheck1">
            <label class="form-check-label"  for="exampleCheck1">아이디 기억하기</label>
        </div>
        <button type="submit" th:href="@{/sign-up}" class="btn btn-primary">Sign Up</button>
    </form>
</div>

    <!-- Option 1: jQuery and Bootstrap Bundle (includes Popper) -->
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>

</body>
</html>

이메일이 중복되지 않는다면, 가입이 성공했기 때문에 다음과 같은 화면을 볼 수 있습니다.

[그림04] 회원가입 성공 메세지 띄우기

 

또한, Validator를 구현했기 때문에 똑같은 이메일로 가입하려 했을 때 다음과 같은 창을 볼 수 있습니다.

[그림05] 회원가입 실패 메세지 띄우기

 

3. 결론

간단하게 회원가입을 구현해보았습니다. 사실 여기까지는 스프링 시큐리티도 제대로 쓰지 않았고(쓰긴 했지만,,) 일반적인 회원 가입 로직입니다. (한번에 쓰려했는데 너무 양이 많네유,,) 다음 포스팅에서 이어서 로그인을 구현해보겠습니다.

반응형