Project/토이 프로젝트

[Cinemagram] 회원가입 유효성 검사(Validation)와 예외처리(ExceptionHandler, @ControllerAdvice) 적용 - (3)

Lea Hwang 2022. 11. 15. 21:39

이번 포스팅은 저번에 말씀드린 거와 같이 password 암호화부터 진행하도록 하겠습니다.

password 암호화

AuthService

@Transactional는 클래스나 인터페이스 또는 메서드 위에 붙여 함수가 실행되고 종료될 때까지 트랜잭션 관리를 해주며 insert, update, delete 할 때 사용합니다.

 

import 주의

import org.springframework.transaction.annotation.Transactional;

 

회원 가입할 때 암호화 

1. 빈으로 등록

@EnableWebSecurity 
@Configuration 
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder encode() {
        return new BCryptPasswordEncoder();
    }

    ...
}

 

2. 이제 DI 해서 쓰기만 하면 됩니다.

@RequiredArgsConstructor
@Service 
public class AuthService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    /* 회원가입 */
    @Transactional
    public User signup(User user) {
        String rawPassword = user.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword);
        user.setPassword(encPassword);
        user.setRole("ROLE_USER"); 
        User userEntity = userRepository.save(user);
        return userEntity;
    }
}

 

암호화가 잘 되어 DB에 들어간 것을 확인할 수 있습니다.

 

암호화는 잘 되었지만, 문제점이 있습니다. ID역할을 하는 username이 동일해도 가입이 되는 문제인데요.

 

이 경우는 간단하게 User 모델의 해당 칼럼에 제약조건을 걸어주기만 하면 해결가능합니다.

@Builder
@AllArgsConstructor 
@NoArgsConstructor  
@Data
@Entity             
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    private int id;

    @Column(unique = true)
    private String username;
    
}

 

제약조건이 잘 걸렸는지 확인을 해보니 제약조건 위배 에러가 발생함을 확인할 수 있었습니다.

java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'apple' for key 'user.UK_jreodf78a7pl5qidfh43axdfb'

 

TIP
스키마 변경(테이블 변경) 시 코드 변경한 후 application.yml의 ddl-auto: create로 초기화하고 구동해야 합니다.
(+ 그 후 update로 바꿔주어야 데이터가 날아가지 않습니다.)

 

 

핵심기능과 공통기능

여기까지 해서 회원가입(핵심기능) 구현이 끝이 났습니다. 

 

지금 까지 했던 것을 요약해보자면

클라이언트에서 회원가입을 하기 위해 username, password, email, name을 넣고 서버로 보내면

서버는 받은 데이터를 JPA를 통해서 DB에 INSERT를 하였습니다.

 

이 정도만 해도 괜찮지만 유지보수성을 높이고 코드도 깔끔하게 하기 위한 [[ 처리 ]]를 추가적으로 해줄 예정입니다.

처리란 AOP를 의미하는 것으로 Aspect Oriented Programming관점 지향 프로그램입니다. (공통기능)

회원가입, 로그인과 같은 중요 로직은 "핵심기능"
전처리, 후처리와 같이 없어도 문제가 되지 않지만 있으면 유지보수성과 가독성이 높아지는 기능을 "공통기능 AOP"라 합니다.

공통기능은 핵심기능에 넣지 않고 따로 빼서 처리합니다.

 

처리에는 두 종류가 있습니다. 여기에서의 기준은 DB인데요, DB까지 가서 물어봐야 응답해줄 수 있는 게 있고 그 전 서버단에서 검증 로직을 통해 처리해 줄 수 있는 부분이 있습니다.

 

예를 들어 apple과 같은 username이 이미 있는지의 확인은 DB까지 가서 물어봐야 하지만

가입 시 username의 길이를 제한하고자 한다면 DB까지 가서 처리할 필요 없이 필드 값 위에 제약조건 어노테이션을 붙임으로써 간단히 해결할 수 있습니다.

 

DB까지 갔다가 오류창을 보내는 처리: 후처리 - ExceptionHandler 예외처리

서버단에서 판단 가능 : 전처리 - Validation 유효성 검사

 

 

전처리부터 구현해보도록 하겠습니다.

Validation

(1) build.gradle에 validation라이브러리를 추가합니다. (의존성 추가)

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

(2) 의존성 추가 후 유효성 검사가 필요한 Request 객체 앞에 @Valid를 붙임으로써 유효성 검사를 적용할 수 있습니다. 

AuthController

@PostMapping("/auth/signup")
public String signup(@Valid SignupReqDto signupReqDto) {
    User user = signupReqDto.toEntity();
    authService.signup(user);
    return "/auth/signin";
}

 

Validation 어노테이션 종류

어노테이션 설명
@Null null만 허용한다.
@NotNull 빈 문자열(""), 공백(" ")은 허용하되, Null은 허용하지 않음
@NotEmpty 공백(" ")은 허용하되, Null과 빈 문자열("")은 허용하지 않음
@NotBlank null, 빈 문자열(""), 공백(" ") 모두 허용하지 않는다.
@Email 이메일 형식을 검사한다. 단, 빈 문자열("")의 경우엔 통과 시킨다.
(@Pattern을 통한 정규식 검사를 더 많이 사용)
@Pattern(regexp = ) 정규식 검사할 때 사용한다.
@Size(min=, max=) 길이를 제한할 때 사용한다.
@Max(value = ) value 이하의 값만 허용한다.
@Min(value = ) value 이상의 값만 허용한다.
@Positive 값을 양수로 제한한다.
@PositiveOrZero 값을 양수와 0만 가능하도록 제한한다.
@Negative 값을 음수로 제한한다.
@NegativeOrZero 값을 음수와 0만 가능하도록 제한한다.
@Future Now 보다 미래의 날짜, 시간이어야 한다.
@FutureOrPresent Now 거나 미래의 날짜, 시간이어야 한다.
@Past Now 보다 과거의 날짜, 시간이어야 한다.
@PastFutureOrPresent Now 거나 과거의 날짜, 시간이어야 한다.

출처: https://dev-coco.tistory.com/123

 

(3)  유효성 검사를 진행할 SignupReqDto에는 validation 어노테이션을 적용하고

       User오브젝트에는 객체 필드와 DB 테이블 칼럼을 매핑하는 @Column을 적용합니다.

SignupReqDto

@Data
public class SignupReqDto {

    @Size(min = 2, max = 20)
    @NotBlank
    private String username;
    @NotBlank
    private String password;
    @NotBlank
    private String email;
    @NotBlank
    private String name;

    ...
}

 

User

@Builder
@AllArgsConstructor 
@NoArgsConstructor  
@Data
@Entity             
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    private int id;

    @Column(unique = true, length = 20)
    private String username;

    @Column(nullable = false)
    private String password;
    @Column(nullable = false)
    private String name;
    private String website;
    private String bio;
    @Column(nullable = false)
    private String email;
    private String phone;
    private String gender;

    private String profileImageUrl; 
    private String role;            

    private LocalDateTime createDate;

    @PrePersist 
    public void createDate() {
        this.createDate = LocalDateTime.now();
    }
}

 

(4) BindingResult bindingResult

validation 검사를 진행하면서 필드 오류 발생 시 bindingResult의 getFieldErrors 컬렉션에 담아둡니다.

BindingResult의 위치가 중요한데요, 검증할 대상 바로 다음에 와야합니다. 

 

AuthController

@PostMapping("/auth/signup")
public String signup(@Valid SignupReqDto signupReqDto, BindingResult bindingResult) {
    if(bindingResult.hasErrors()) {
        Map<String,String> errors = new HashMap<>();

        for(FieldError error : bindingResult.getFieldErrors()) {
            errors.put(error.getField(),error.getDefaultMessage()); // 키 밸류
        }
    }
    User user = signupReqDto.toEntity();
    authService.signup(user);
    return "/auth/signin";
}

 

(5) 만약 유효성 검사에서 실패했다면 회원가입을 진행하여 DB에 INSERT 할 필요가 없습니다. 

     이 경우 회원가입 로직이 아닌 오류 페이지(문자열 출력)를 리턴하도록 코드를 수정하겠습니다.

 

@ResponseBody

@Controller에서 메서드 리턴 타입 앞에 @ResponseBody을 붙이면 데이터를 리턴합니다.

@Controller와 @ResponseBody 합치면 @RestContoller입니다. ← REST API 스타일로 만들 때 사용
▷@ResponseBody : Json 형태로 데이터를 반환
▷@RestController :  주용도는 Json 형태로 객체 데이터를 반환하는 것입니다.

전통적인 Spring MVC의 컨트롤러인 @Controller는 주로 View를 반환하기 위해 사용했습니다. 
만약 컨트롤러에서는 데이터를 반환하고자 한다면 @ResponseBody 어노테이션을 활용해주어야 합니다.
이를 통해 Controller도 Json 형태로 데이터를 반환할 수 있습니다.

 

AuthController

@PostMapping("/auth/signup")
public @ResponseBody String signup(@Valid SignupReqDto signupReqDto, BindingResult bindingResult) {
    if(bindingResult.hasErrors()) {
        Map<String,String> errors = new HashMap<>();

        for(FieldError error : bindingResult.getFieldErrors()) {
            errors.put(error.getField(),error.getDefaultMessage());
        }
        return "회원가입 validation 검사를 통과하지 못 하였습니다.";
    }else {
        User user = signupReqDto.toEntity();
        authService.signup(user);
        return "/auth/signin";
    }
}

 

회원가입 시 조건을 충족하지 못하면  "회원가입 validation 검사를 통과하지 못하였습니다."라는 문자열을 잘 출력합니다.

 

하지만 예상치 못 한 문제가 발생하였는데요, 조건에 맞게 잘 기입하더라도 우리가 원한 auth/signin 페이지가 아닌 문자열이 리턴됨을 확인할 수 있습니다.

해당 부분은 전역적으로 예외 처리가 가능한 ExceptionHandler로 해결하고자 합니다.

 

ExceptionHandler, @ControllerAdvice

회원가입을 성공적으로 할 시 auth/signin 파일을 리턴 받기 위해 우선 @ResponseBody을 지우겠습니다.

그리고 유효성 검사를 실패하면 return으로 처리하는 게 아닌 Exception을 발동시켜 오류 페이지로 넘어가게 코드를 작성했습니다. 

 

AuthController

@PostMapping("/auth/signup")
public String signup(@Valid SignupReqDto signupReqDto, BindingResult bindingResult) {
    if(bindingResult.hasErrors()) {
        Map<String,String> errors = new HashMap<>();

        for(FieldError error : bindingResult.getFieldErrors()) {
            errors.put(error.getField(),error.getDefaultMessage());
        }
        throw new RuntimeException("회원가입 유효성 검사 실패함");
    }else {
        User user = signupReqDto.toEntity();
        authService.signup(user);
        return "/auth/signin";
    }
}

 

하지만 이렇게 했을 땐 UX가 좋지 않은 화면이 나옵니다. 사용자가 이 화면을 본다면 엄청 큰일이 난 줄 알 수도 있기에 정확이 어떤 상황인지 알 수 있는 화면을 만들어서 보여주는 게 좋을 것입니다.

 

 

예쁘 게 화면을 만드는 것은 차치하고 이런 Exception이 날 때마다 핵심기능 코드와 함께 적는 것은 가독성뿐만 아니라 유지보수에도 좋지 않습니다. 그래서 Exception이 터지면 한 클래스에서 전부 가로채서 처리하도록(ExceptionHandler) 코드를 작성해보겠습니다.

 

 

handler 폴더를 만들고 ControllerExceptionHandler 클래스를 생성한 후 @ControllerAdvice를 붙였습니다.

해당 어노테이션을 붙임으로써 모든 Exception을 다 가로채서 해당 클래스에서 처리할 수 있게 되었습니다.

 

ControllerExceptionHandler

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(RuntimeException.class) 
    public String validationException(RuntimeException e){
        return e.getMessage();
    }
}

 

@ExceptionHandler(RuntimeException.class)는 RuntimeException이 발동하는 모든 Exception은 해당 함수가 가로채겠다는 의미입니다.

 

여기까지 하고 회원가입 시 우리가 기입했던 Exception메시지가 출력됨을 알 수 있습니다. 

 

 

여기까지는 잘 되었습니다. 여기서 약간 더 욕심을 내어서 해쉬 맵인 errors에 담긴 에러 메시지를 리턴 하고 싶을 땐 어떻게 해야 할까요? 

우선 throw new RuntimeException()으로는 하지 못하는데 그 이유는 String만 받기 때문입니다.

public RuntimeException(String message) {
    super(message);
}

 

handler에 exception패키지를 만들어서 validation관련 exception class를  생성하겠습니다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CustomValidationException extends RuntimeException{

    private static final long serialVersionUID = 1L;

    private String message;
    private Map<String, String> errors;

}

 

이제 RuntimeException 부분을 우리가 만든 CustomValidationException으로 바꿔줍니다.

 

ControllerExceptionHandler

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(CustomValidationException.class)
    public Map<String, String> validationException(CustomValidationException e){
        return e.getErrors();
    }
}

 

AuthController

@PostMapping("/auth/signup")
public String signup(@Valid SignupReqDto signupReqDto, BindingResult bindingResult) {
    if(bindingResult.hasErrors()) {
        Map<String,String> errors = new HashMap<>();

        for(FieldError error : bindingResult.getFieldErrors()) {
            errors.put(error.getField(),error.getDefaultMessage());
        }
        throw new CustomValidationException("회원가입 유효성 검사에 실패하였습니다.", errors);
    }else {
        User user = signupReqDto.toEntity();
        authService.signup(user);
        return "/auth/signin";
    }
}

 

화면에서 회원가입 시 errors에 담긴 메시지를 잘 출력하는 것을 확인할 수 있습니다. 

 

 

[ 정리 ]

폴더 구조를 보면서 설명해 드리자면, exception패키지 안에는 앞으로 Custom 한 Exception을 하나씩 만들 예정이며

여기서 만든 Exception이 Controller에서 터지면 ControllerExceptionHandler가 낚아채서 어떻게 처리할지에 대한 코드를 하나씩 구현할 예정입니다.

 

 

지금까지 한 유효성 검사와 예외 처리한 것을 정리해보자면

  • SignupReqDto에 validation 어노테이션을 통한 유효성 검사들 중 하나라도 실패하면 bindingResult에 담기고
  • bindingResult에 하나라도 있다면(bindingResult.hasErrors()) 해시 맵 errors에 저장됩니다.
  • 그 후 errors를 CustomValidationException에 넣어 날려버립니다.
    • 그럼 else 부분인 회원가입이 로직을 타지 않게 되겠죠.
  • 어라! Controller에서 xxxException이 터졌네! 내(ControllerExceptionHandler)가 가져와서 처리해야지
    • CustomValidationException의 경우니까, 에러 메시지를 리턴해야겠다.  
  • 만약 정상적으로 회원가입을 완료했다면  로그인 창으로 넘어가게 됩니다.
TIP
만약에 생소한 메서드를 구현할 때 매개변수로 어떤 것을 넣어야 할지 모르겠다면?
Ctrl + P를 기억하세요!

 

 

그런데 말입니다.

bindingResult에 담긴 errors 뿐만 아니라 내가 직접 적은 메시지도 같이 출력하고 싶다면 어떻게 해야 할까요?

우선 지금은 리턴 타입이 Map<String, String>이므로 여기서 수정할 수는 없습니다. 

@ExceptionHandler(CustomValidationException.class)
public Map<String, String> validationException(CustomValidationException e){
    return e.getErrors();
}

 

 

리턴할 때 공통적으로 쓰일 DTO를 만들어보겠습니다. 

공통 응답 DTO

ResDto

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResDto {

    private String message;
    private Map<String, String> errors;
}

 

ControllerExceptionHandler로 가서 리턴 타입을 Map<String, String>에서 ExResDto로 수정해줍니다.

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(CustomValidationException.class)
    public ResDto validationException(CustomValidationException e){
        return new ResDto(e.getMessage(), e.getErrors());
    }
}

 

 

코드에 대한 확인은 Postman으로 진행하겠습니다.

(공백으로 남기고 가입 클릭했을 시 프런트에서 막으므로 메시지 출력이 안됨)

 

우리가 원하는 정보들이 잘 출력됨을 확인했습니다. 

 

여기서 하나 더 수정하고 가면 좋을 것이 있습니다.  추가의 추가의 추가..

위에서 만든 공통 응답에 쓰일 DTO의 현재 모습은 출력하는 데이터의 형태가 Map<String, String> 일 경우에만 출력이 가능하도록 구성해두었습니다.

 

하지만 우리는 상황에 따라 오브젝트를 리턴할 수도 있고 String을 리턴 하고 싶을 수도 있습니다.

즉 사용 상황에 따라 타입이 달라질 수 있으므로 제네릭으로 수정해보겠습니다.

 

ResDto

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResDto<T> {
    private int code;      // 1(성공), -1(실패)
    private String message;
    private T data;
}

 

ControllerExceptionHandle

이 경우 리턴 타입이 Map이므로  ResDto<Map<String,String>>으로 수정했습니다.

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(CustomValidationException.class)
    public ResDto<Map<String,String>> validationException(CustomValidationException e){
        return new ResDto(-1,e.getMessage(), e.getErrors());
    }
}

 

만약 리턴 타입을 잘 모르겠다면 ResDto<?>를 적어도 똑같이 작동합니다.

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(CustomValidationException.class)
    public ResDto<?> validationException(CustomValidationException e){
        return new ResDto<Map<String,String>>(-1,e.getMessage(), e.getErrors());
    }
}

 

code를 추가했더니 실패했는지 성공했는지 바로 알 수 있는 장점이 있습니다.

 

우선 여기까지 회원가입 유효성 검사와 예외처리 적용으로 push 했습니다.

 

예외처리를 모아 처리한 것처럼 추후 validation들을 모아 처리하는 AOP클래스를 만들어서 연결해보겠습니다.

 

 

 

 

위에서 잠깐 @ResponseBody에 대해 언급했었습니다. 그럼 항상 비교하는 @RequestBody란 무엇일까요?

좀 더 정확하고 자세하게 기술된 문서들도 많겠지만, 한 줄로 쏙 정리하는 것도 중요하다고 생각해서 간단히만 짚고 넘어가겠습니다.

❗❗ @RequestBody vs. @ResponseBody
@RequestBody
클라이언트에서 서버로 필요한 데이터를 요청하기 위해 JSON 데이터를 요청 본문에 담아서 서버에 보냅니다.
서버에서는 해당 어노테이션을 사용하여 HTTP요청 본문에 담긴 값들을 자바객체로 변환시켜 객체에 저장합니다.

@ResponseBody
해당 어노테이션을 사용하여 서버에서 클라이언트로 응답 데이터를 전송할 때 자바객체를 HTTP응답 본문의 객체로 변환하여 클라이언트로 전송합니다.

참고 : 
https://cheershennah.tistory.com/179

 

 

다음 포스팅은 유효성 검사에 실패할 시 화면에 Json 코드를 보여주는 게 아닌 간단하게 팝업 처리를 한 뒤 로그인 구현으로 넘어가겠습니다.

 정석으로는 사용자가 입력한 데이터를 그대로 보여주고 어느 필드를 수정해야 할지 친절하게 알려줘야 하지만
(FieldError, ObjectError 이용) 원래 계획했던 기능들을 구현하는 게 우선이기에 핵심 기능을 다 구현한 후에 작업해서 올리도록 하겠습니다.