그동안 아무런 예외 처리를 하지 않고 컨트롤러에서 받아 서비스로 넘겨서 Entity에 지정한 옵션들과 일치하지 않으면
수많은 하얀 페이지를 만났다.
다른 템플릿 엔진은 예외처리를 할 클래스를 만들어서 View에서 처리해줘야하는 것 같은데 타임리프는 BindingResult를 모델로 넘겨 사용할 수 있고 시큐리티와 연동되는 부분도 많아서 꽤 간단한 편이였던것 같다.
이번 포스트는 회원가입, 로그인부터 살펴보고 다음 포스트는 서비스에서 터지는 예외처리로 넘어가겠다.
회원가입
User Entity
@Entity
@Getter
@NoArgsConstructor
public class User extends TimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private String email;
@Column(nullable = false, length = 100)
private String password;
@Enumerated(EnumType.STRING)
private Roles role;
@Enumerated(EnumType.STRING)
private Emotion emotion;
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
@OneToMany(mappedBy = "user")
private List<Comment> comments = new ArrayList<>();
@Builder
public User(String username, String nickname, String email, String password, Roles role, Emotion emotion) {
this.username = username;
this.nickname = nickname;
this.email = email;
this.password = password;
this.role = role;
this.emotion = emotion;
}
public void edit(String nickname, String password){
this.nickname = nickname;
this.password = password;
}
public void edit(Emotion emotion){
this.emotion = emotion;
}
public void addComment(Comment comment) {
comment.setUser(this);
comments.add(comment);
}
}
UserRequestDTO
@Data
public class UserCreate {
@NotBlank(message = "아이디는 필수 입력값입니다.")
private String username;
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
private String email;
@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$", message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.")
private String nickname;
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
private String password;
private Roles role;
private Emotion emotion;
@Builder
public UserCreate(String username, String email, String nickname, String password) {
this.username = username;
this.email = email;
this.nickname = nickname;
this.password = password;
}
public User toEntity() {
return User.builder()
.username(this.username)
.email(this.email)
.nickname(this.nickname)
.password(this.password)
.role(this.role).
emotion(this.emotion).build();
}
}
@Valid 와 함께 유효성 검사를 위해 @NotBlank, @Pattern 추가
AccountController
@RequiredArgsConstructor
@Controller
@RequestMapping("/account")
public class AccountController {
private final UserService userService;
@GetMapping("/login")
public String login() {
return "account/login";
}
@GetMapping("/signup")
public String signupForm(Model model) {
model.addAttribute("userCreate", new UserCreate());
return "account/signup";
}
@PostMapping("/signup")
public String signup(@Valid UserCreate user, BindingResult result, Model model) {
userService.checkDuplicateSignup(new UserCheck(user), result);
if(result.hasErrors()){
model.addAttribute("userCreate", user); // 넘어온 입력값 다시 넘김
return "account/signup";
}
userService.save(user);
return "redirect:/account/login";
}
}
회원가입 폼에서 회원가입을 실행하면
@Valid가 DTO의 필드에 걸려있는 조건들에 따라서 error가 있다면 BindingResult에 FiledError가 추가된다.
@Valid와 필드에 걸린 어노테이션으로 잡힌 예외 말고 아이디, 이메일, 닉네임은 회원 가입할 때 중복 검사가 필요해 UserService 까지 넘겨서 예외처리를 했다.
만약 에러가 있다면 넘어온 입력값을 모델에 추가하고 BindingResult는 자동으로 모델에 추가되서 뷰로 넘겨준다.
UserCheck
@Data
public class UserCheck {
private String username;
private String nickname;
private String email;
private String objectName;
public UserCheck(UserCreate userCreate) {
this.username = userCreate.getUsername();
this.nickname = userCreate.getNickname();
this.email = userCreate.getEmail();
this.objectName = "userCreate";
}
}
컨트롤러에서 서비스로 넘길 때 사용하는 DTO.
추후에 사용자 정보 변경 할때도 서비스의 중복 검사를 사용하기 위해 만들었다.
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByNickname(String nickname);
boolean existsByEmail(String email);
}
중복검사를 위해 JPARepository를 상속받은 Repository에 메소드를 추가해줬다.
Entity의 존재를 찾고 싶은 필드와 existsByField 문법으로 파생쿼리를 사용할 수 있다.
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public void save(UserCreate userCreate) {
String encodedPassword = passwordEncoder.encode(userCreate.getPassword());
userCreate.setPassword(encodedPassword);
userCreate.setRole(Roles.USER);
userCreate.setEmotion(Emotion.NEUTRAL);
userRepository.save(userCreate.toEntity());
}
private boolean checkDuplicateUsername(UserCheck user){
return userRepository.existsByUsername(user.getUsername());
}
private boolean checkDuplicateEmail(UserCheck user){
return userRepository.existsByEmail(user.getEmail());
}
private boolean checkDuplicateNickname(UserCheck user){
return userRepository.existsByNickname(user.getNickname());
}
public void checkDuplicateSignup(UserCheck user, BindingResult result){
if(checkDuplicateUsername(user)){ // Add BindingResult FieldError
result.addError(new FieldError(user.getObjectName(), "username", "이미 가입된 아이디입니다."));
}
if(checkDuplicateNickname(user)){
result.addError(new FieldError(user.getObjectName(), "nickname", "이미 존재하는 별명입니다"));
}
if(checkDuplicateEmail(user)){
result.addError(new FieldError(user.getObjectName(), "email", "이미 가입된 이메일입니다."));
}
}
}
Repository에 적용한 파생쿼리를 Service에서 사용해 이미 DB에 있는 값이면 필드에 대한 중복 예외를 추가해준다.
여기서 적용된 BingdingResult를 Thymeleaf에 던져준다.
Thymeleaf View
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:justify-content="http://www.w3.org/1999/xhtml">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link th:href="@{/css/bootstrap.min.css}"
href="../../static/css/bootstrap.min.css"
rel="stylesheet">
<title>Emotion Community</title>
<link th:href="@{/css/signin.css}"
href="signin.css" rel="stylesheet">
</head>
<style>
p{
font-size: 2px;
font-weight: bold;
color: #b02a37;
}
.fieldError{
border-color: #bd2130;
}
</style>
<body class="text-center">
<div th:replace="fragments/common :: menu">
</div>
<form class="form-signin" th:action="@{|/account/signup|}" method="post" th:object="${userCreate}">
<img class="mb-4" src="https://cdn-icons-png.flaticon.com/128/817/817787.png" alt="" width="72" height="72">
<h1 class="h3 mb-3 font-weight-normal">Please sign up</h1>
<label th:for="username" class="sr-only">아이디</label>
<input type="text" th:field="*{username}" class="form-control fieldError" placeholder="username"
th:class="${#fields.hasErrors('username')} ? 'form-control fieldError' : 'form-control'" >
<p th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></p>
<label th:for="email" class="sr-only">이메일</label>
<input type="email" th:field="*{email}" class="form-control fieldError" placeholder="email"
th:class="${#fields.hasErrors('email')} ? 'form-control fieldError' : 'form-control'" >
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
<label th:for="nickname" class="sr-only">닉네임</label>
<input type="text" th:field="*{nickname}" class="form-control" placeholder="nickname"
th:class="${#fields.hasErrors('nickname')} ? 'form-control fieldError' : 'form-control'" >
<p th:if="${#fields.hasErrors('nickname')}" th:errors="*{nickname}" class="fieldError"></p>
<label th:for="password" class="sr-only">비밀번호</label>
<input type="password" th:field="*{password}" class="form-control" placeholder="Password"
th:class="${#fields.hasErrors('password')} ? 'form-control fieldError' : 'form-control'" required>
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
<button class="btn btn-lg btn-primary btn-block" type="submit">회원가입</button>
<p class="mt-5 mb-3 text-muted">© 2017-2022</p>
</form>
</body>
</html>
BindingResult에 추가된 필드에러를 쓰기 위해 (form +th:object) + (input+ th:field)를 사용했다.
th:field를 사용하면 id, name을 해당 필드 값으로 채워준다.
필드에러가 존재하면 th:class로 style 값을 불러와 input 바깥 색상을 바꾸고 , p 태그로 필드 에러를 출력한다.
결과
로그인
스프링 시큐리티와 타임리프를 사용하면 로그인 오류를 쉽게 구현할 수 있다.
Security Config Class
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
@Bean
public SecurityFilterChain
securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests((request)->request
.antMatchers("/posts","/posts/read/**","/posts/search/**","/account/**","/css/**" ).permitAll()
.anyRequest().authenticated()).
formLogin((form)->form
.loginPage("/account/login").permitAll()
.defaultSuccessUrl("/posts")
.failureUrl("/account/login?error")))
.logout().permitAll()
.logoutSuccessUrl("/posts");
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
해당 클래스에서 loginPage, 로그인 성공시 렌더링할 url 등을 설정하고 failureUrl을 따로 설정안하면 로그인 페이지에 error를 파라미터로 넘겨주는데 이걸 타임리프에서 사용할 것 이기 때문에 따로 명시해뒀다.
Thymeleaf View
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:justify-content="http://www.w3.org/1999/xhtml">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link th:href="@{/css/bootstrap.min.css}"
href="../../static/css/bootstrap.min.css"
rel="stylesheet">
<title>Emotion Community</title>
<link th:href="@{/css/signin.css}"
href="signin.css" rel="stylesheet">
</head>
<body class="text-center">
<div th:replace="fragments/common :: menu">
</div>
<form class="form-signin" th:action="@{/account/login}" method="post">
<img class="mb-4" src="https://cdn-icons-png.flaticon.com/128/3129/3129291.png" alt="" width="72" height="72">
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<div th:if="${param.error}" class="alert alert-danger" role="alert"> // 로그인 오류 시 파라미터 넘어옴
아이디나 비밀번호가 올바르지 않습니다.
</div>
<div th:if="${param.logout}" class="alert alert-warning" role="alert">
You have been logged out.
</div>
<label for="username" class="sr-only"></label>
<input type="text" id="username" name="username" class="form-control" placeholder="username" required autofocus>
<label for="inputPassword" class="sr-only"></label>
<input type="password" id="inputPassword" name="password" class="form-control" placeholder="Password" required>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-muted">© 2017-2022</p>
</form>
</body>
</html>
Thymeleaf에서 파라미터를 ${}를 사용해 가져올 수 있는데 로그인 오류 시에 error 파라미터가 넘어올때 로그인 오류 메시지를 보여준다.
결과
출처
https://www.baeldung.com/spring-data-exists-query
https://www.baeldung.com/spring-security-login