click-tour 리펙토링 - (3)

지종권(JiJongKwon)·2023년 6월 13일

🚀 validator

회원 가입 시 아이디, 닉네임, 이메일 중복을 확인한 후 없으면 회원가입을 할 수 있게 만들었었다.

수정 전 UserService

@Service
@RequiredArgsConstructor
public class UsersService {

    @Transactional
    public UserJoinRequestDto register(UserJoinRequestDto userJoinRequestDto){
        Optional<Users> usersId = usersRepository.findByLoginId(userJoinRequestDto.getLoginId());
        Optional<Users> usersNickname = usersRepository.findByNickname(userJoinRequestDto.getNickname());
        Optional<Users> usersEmail = usersRepository.findByEmail(userJoinRequestDto.getEmail());

        // id, nickname, email 중복
        if(usersId.isPresent() || usersNickname.isPresent() || usersEmail.isPresent()){
            return null;
        }

        // 회원가입
        userJoinRequestDto.setLoginPassword(passwordEncoder.encode(userJoinRequestDto.getLoginPassword()));
        usersRepository.save(userJoinRequestDto.toEntity());
        return userJoinRequestDto;

    }

이 코드를 보면 메모리 낭비가 있고, 재사용성은 볼 수도 없다.

그래서 RegisterValidator를 만들려고 한다.

RegisterValidator

이메일 중복 확인 테스트

	@Test
    public void 이메일_중복_확인_테스트() {
        // Given
        UserJoinRequestDto userJoinRequestDto = UserJoinRequestDto.builder()
                .email("test@example.com")
                .build();


        Mockito.when(usersRepository.existsByEmail("test@example.com")).thenReturn(true);

        // When
        Errors errors = new BeanPropertyBindingResult(userJoinRequestDto, "userJoinRequestDto");
        registerValidator.validate(userJoinRequestDto, errors);

        // Then
        assertTrue(errors.hasErrors());
        assertEquals("이메일 중복", errors.getFieldError("email").getDefaultMessage());
    }

이메일 설정후 vaildator를 통해 유효한지 확인하는 테스트 코드이다.

테스트 코드에서 필요한 메서드는 validate(Object target, Errors erros)

RegisterValidator

@Component
@RequiredArgsConstructor
public class RegisterValidator implements Validator {

    private final UsersRepository usersRepository;

    /**
     * supports() 메서드는 Validator 인터페이스의 메서드로서, 주어진 클래스가 Validator가 유효성 검사를 수행할 수 있는 대상인지 여부를 결정
     *
     * @param clazz the {@link Class} that this {@link Validator} is
     *              being asked if it can {@link #validate(Object, Errors) validate}
     * @return boolean
     */
    @Override
    public boolean supports(Class<?> clazz) {
        return UserJoinRequestDto.class.equals(clazz);
    }

    /**
     * 중복 체크
     *
     * @param target the object that is to be validated
     * @param errors contextual state about the validation process
     */
    @Override
    public void validate(Object target, Errors errors) {
        UserJoinRequestDto userJoinRequestDto = (UserJoinRequestDto) target;

        // 이메일 중복 체크
        if (usersRepository.existsByEmail(userJoinRequestDto.getEmail())) {
            errors.rejectValue("email", "400", "이메일 중복");
        }

        // 아이디 중복 체크
        if (usersRepository.existsByLoginId(userJoinRequestDto.getLoginId())) {
            errors.rejectValue("loginId", "400", "아이디 중복");
        }

        // 닉네임 중복 체크
        if (usersRepository.existsByNickname(userJoinRequestDto.getNickname())) {
            errors.rejectValue("nickname", "400", "닉네임 중복");
        }
    }
}

Validator interface를 이용하여 RegisterValidator클래스를 만들었다.

validate 메서드는 repository에서 email, loginId, nickname이 존재하는지 확인한 후 존재한다면 error를 binding한다.

errors에서 rejectValue와 reject 메서드 둘 중 rejectValue를 사용하였는데 가시성과 유지보수 측면에서 rejectValue가 우수하다.

org.springframework.validation.Errors
reject : 전체 객체에 대한 유효성 검사 오류를 처리
rejectValue : 특정 필드에 대한 유효성 검사 오류를 처리

테스트 코드 성공

Response

validator를 만들었으니 이제 활용할 차례이다.

수정 전 UserApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("api/v1/users")
public class UsersApiController {
    private final UsersService usersService;


    // 회원가입
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody UserJoinRequestDto userJoinRequestDto) {

        // 중복된 아이디 또는 닉네임 또는 이메일이 있을 때
        if(usersService.register(userJoinRequestDto) == null){
            return new ResponseEntity<>(new ExceptionDto(ErrorMessage.DUPLICATE_ID_OR_NICKNAME_OR_EMAIL),
                    HttpStatus.BAD_REQUEST);
        }
        return new ResponseEntity<>(new ResponseDto(SuccessMessage.SUCCESS_REGISTER), HttpStatus.OK);
    }

validator가 적용되기 전 코드이다. 3가지를 한 번에 검사하여 응답메시지를 보낸다. 이렇게 될 시 어떤 곳에서 중복이 일어났는지 알 수 없다.

우선 중복확인은 service Layer에서 확인하려고 한다. 이유는 비즈니스 로직을 처리하는 주요 layer이기 때문이다.

수정 후 UserService

@Service
@RequiredArgsConstructor
public class UsersService {

    private final UsersRepository usersRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;
    private final RegisterValidator registerValidator;

    @Transactional
    public void register(UserJoinRequestDto userJoinRequestDto, BindingResult bindingResult) {
        checkUserValidate(userJoinRequestDto, bindingResult);
        userJoinRequestDto.setLoginPassword(passwordEncoder.encode(userJoinRequestDto.getLoginPassword()));
        usersRepository.save(userJoinRequestDto.toEntity());
    }

    ...

    /**
     * 회원가입 유효성 검사
     *
     * @param bindingResult
     */
    public void checkUserValidate(UserJoinRequestDto userJoinRequestDto, BindingResult bindingResult) {
        registerValidator.validate(userJoinRequestDto,bindingResult);
        if (bindingResult.hasErrors()) {
            List<String> errorList =
                    bindingResult.getFieldErrors()
                            .stream()
                            .map(DefaultMessageSourceResolvable::getDefaultMessage)
                            .collect(Collectors.toList());

            throw new NotValidException(errorList);
        }
    }
}

여기에서 Errors 대신 BindingResult를 사용한다.

Spring MVC에서 폼 데이터를 검증할 때 사용되는 BindingResult와 Errors는 둘 다 같은 역할을 한다. 그러나 BindingResult는 Errors의 하위 클래스이기 때문에 BindingResult를 사용하는 것이 더 좋다.

BindingResult는 폼 데이터의 유효성 검사 결과를 개별 필드별로 확인할 수 있다. Errors는 폼 데이터의 유효성 검사 결과를 전체 객체로만 확인할 수 있다. 따라서 BindingResult를 사용하는 것이 더 효율적이다.

수정 후 UserApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("api/v1/users")
public class UsersApiController {
    private final UsersService usersService;

    // 회원가입
    @PostMapping("/register")
    public ResponseEntity<?> register(@Valid @RequestBody UserJoinRequestDto userJoinRequestDto, BindingResult bindingResult) {
        usersService.register(userJoinRequestDto, bindingResult);
        return new ResponseEntity<>(new ResponseDto(SuccessMessage.SUCCESS_REGISTER), HttpStatus.OK);
    }
}

비즈니스 로직을 서비스 레이어에 옮겨 보기 좋아진 거 같다.

예외처리 클래스

이제 Validator를 만들었으니 조건에 일치하지 않는 값이 들어오면 예외처리를 만들어야 한다.

ExceptionDto

@Data
@NoArgsConstructor
@SuperBuilder
public class ExceptionDto {

    private int stateCode;
    private String message;

    public ExceptionDto(ErrorMessage errorMessage) {
        this.stateCode = errorMessage.getCode();
        this.message = errorMessage.getMessage();
    }
}

상태 코드와 메시지 dto

NotValidExceptionResponseDto

@Getter
@SuperBuilder
public class NotValidExceptionResponseDto extends ExceptionDto {
    private final List<String> errors;
}

ExceptionDto를 상속받은 클래스
validator에서 Exception 발생시 어떤 에러인지 알려주는 dto

NotValidException

@ResponseStatus(HttpStatus.BAD_REQUEST)
@Getter
public class NotValidException extends RuntimeException {
    private final List<String> errorList;

    public NotValidException(List<String> errList) {
        this.errorList = errList;
    }
}

예외가 발생하면 errors를 담는 클래스

CustomizedGlobalExceptionHandler

@RestController
@ControllerAdvice // 사전 컨트롤러
public class CustomizedGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(NotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<NotValidExceptionResponseDto> handleCustomizedNotValidException(NotValidException ex) {
        return new ResponseEntity<>(
                NotValidExceptionResponseDto.builder()
                        .stateCode(HttpStatus.BAD_REQUEST.value())
                        .message("아이디 또는 이메일 또는 닉네임 중복")
                        .errors(ex.getErrorList())
                        .build(), HttpStatus.BAD_REQUEST
        );
    }
}

NotValidException.class에서 발생하는 예외를 처리하는 핸들러
지금 보니깐 하드코딩이 있는데 조만간 다 정리해주지..ㅎㅎ

결과

이메일 중복 시

아이디 중복 시

닉네임 중복 시

3개 다 중복

이로써 중복 검사 리펙토링이 끝이 났다고 생각을 했는데 아직 좀 더 있다...

그래도 어느정도 진행완료

글을 쓰면서 느끼는건데 설명을 잘 못하는 거 같다. 다음 포스팅에는 기록이 아닌 공유라는 느낌으로 작성해 보겠다.

0개의 댓글