1. 서론
회원가입v1.0 포스팅을 쓴지 6개월이 지났습니다. 어느덧 개발자로서는 2년차를 향해 달려가고 있습니다.
다양한 프로젝트를 경험하고, 여기저기서 주워들은 지식을 활용해서 더 깊게 정리하고 싶은 마음이 커졌습니다. 그래서 2.0 버전을 만들게 되었습니다.
이번 포스팅 역시 스프링 시큐리티를 이용하여 권한을 부여하고, 회원가입 처리를 하는 간단 예제를 만들어 볼 것입니다.
조금 더 세련된 기술이 적용되었고, v1.0보다 더 쉽게 로직을 따라갈 수 있도록 만들었습니다. 이번 프로젝트에서는 포스팅 겸 프로젝트도 만들어보았습니다. 최신화된 예제 코드는 여기에서 참조가능합니다.
+) 이번 포스팅을 작성하기 위해 저만의 작은 목표를 정해보았습니다. 목표는 아래와 같습니다.
1. @Valid
2. @interface
3. AOP
4. TDD
이 포스팅을 이해한다면?
이 글을 이해하면 간단한 회원가입을 스프링 시큐리티를 이용해서 구현할 수 있습니다.
간단한 MVC 구조를 알고 계신다면 더 편하게 따라오실 수 있습니다.
2. 본론
프로젝트를 생성합니다.
예제의 전체 구조는 다음과 같습니다.
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
대문을 장식하는 인덱스 페이지를 만들어주도록 합니다. 단순하게 회원가입과 로그인만 가능한 마이크로 서비스입니다.
<!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
회원가입 폼도 만들어줍니다. 이메일, 패스워드, 닉네임을 받도록 했습니다.
<!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>
이메일이 중복되지 않는다면, 가입이 성공했기 때문에 다음과 같은 화면을 볼 수 있습니다.
또한, Validator를 구현했기 때문에 똑같은 이메일로 가입하려 했을 때 다음과 같은 창을 볼 수 있습니다.
3. 결론
간단하게 회원가입을 구현해보았습니다. 사실 여기까지는 스프링 시큐리티도 제대로 쓰지 않았고(쓰긴 했지만,,) 일반적인 회원 가입 로직입니다. (한번에 쓰려했는데 너무 양이 많네유,,) 다음 포스팅에서 이어서 로그인을 구현해보겠습니다.
'Dev > SpringBoot' 카테고리의 다른 글
34. [JPA] 02. JPA 시작 (0) | 2021.11.07 |
---|---|
33. [JPA] 01. JPA 소개 (0) | 2021.11.07 |
30. [springboot] WebJar를 이용해 CDN 대체하기 (0) | 2020.11.29 |
29. [springboot] DevTools를 이용해 LiveReload해보자 (0) | 2020.11.29 |
28. [springboot] 프로젝트 초기에 구성하면 좋을 것들 (0) | 2020.11.28 |