본문 바로가기
Spring/- [시리즈] Spring 프로젝트 되살리기

Spring 프로젝트 되살리기 4 - 코드 리팩토링 2 (@Valid 적용)

by 정구정구 2026. 7. 25.

이전 이야기

https://record47584.tistory.com/88

 

Spring 프로젝트 되살리기 3 - 코드 리펙토링 1 (중첩 if문 제거 등)

이전 이야기https://record47584.tistory.com/87 Spring 프로젝트 되살리기 2 - ArgumentResolver 이슈이전 이야기https://record47584.tistory.com/86 Spring 프로젝트 되살리기 1 - 빌드 에러 해결시나리오 1누군가가 git에 올

record47584.tistory.com

 

시나리오 4

간단하게 코드만 변경하는 리팩토링은 끝났다. 이제 좀 더 깊게 리팩토링 해보자!

 

 

리팩토링 1 - @valid를 이용한 유효성 검사

@Transactional
public void changePassword(long userId, UserChangePasswordRequest userChangePasswordRequest) {
    if (userChangePasswordRequest.getNewPassword().length() < 8 ||
            !userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
            !userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
        throw new InvalidRequestException("새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.");
    }

    User user = userRepository.findById(userId)
            .orElseThrow(() -> new InvalidRequestException("User not found"));

    if (passwordEncoder.matches(userChangePasswordRequest.getNewPassword(), user.getPassword())) {
        throw new InvalidRequestException("새 비밀번호는 기존 비밀번호와 같을 수 없습니다.");
    }

    if (!passwordEncoder.matches(userChangePasswordRequest.getOldPassword(), user.getPassword())) {
        throw new InvalidRequestException("잘못된 비밀번호입니다.");
    }

    user.changePassword(passwordEncoder.encode(userChangePasswordRequest.getNewPassword()));
}

 

유저 비밀번호를 바꾸는 service 코드 이다. 비밀번호 유효성을 체크하는 코드가 어지럽게 위치하고 있다. 이는 객체지향에도 맞지 않고 가독성도 나쁘다. @Valid를 통해 유효성 검사를 DTO에게 맡기면서 간단한 코드로 변경할 수 있다.

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

 

@Valid를 쓰기 위해서 위 종속성을 추가하고, 

@PutMapping("/users")
public void changePassword(@Auth AuthUser authUser, @Valid @RequestBody UserChangePasswordRequest userChangePasswordRequest) {
    userService.changePassword(authUser.getId(), userChangePasswordRequest);
}

 

위와 같이 Controller Request DTO  @RequestBody 앞에 @Valid를 추가해준다. 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserChangePasswordRequest {

    @NotBlank
    private String oldPassword;

    @NotBlank
    // @Pattern을 통한 유효성 검사
    @Pattern(regexp="^(?=.*[0-9])(?=.*[A-Z]).{8,}$",
            message = "비밀번호는 영문자와 숫자, 특수기호가 적어도 1개 이상 포함된 6자~12자의 비밀번호여야 합니다.")
    private String newPassword;
}

 

위와 같이 @Pattern 안에 알맞는 정규식을 추가하면 DTO에서 비밀번호 유효성을 체크하게 된다. 

@Transactional
public void changePassword(long userId, UserChangePasswordRequest userChangePasswordRequest) {

    /* 비밀번호 유효성 검사 코드 제거 */    
    
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new InvalidRequestException("User not found"));

    if (passwordEncoder.matches(userChangePasswordRequest.getNewPassword(), user.getPassword())) {
        throw new InvalidRequestException("새 비밀번호는 기존 비밀번호와 같을 수 없습니다.");
    }

    if (!passwordEncoder.matches(userChangePasswordRequest.getOldPassword(), user.getPassword())) {
        throw new InvalidRequestException("잘못된 비밀번호입니다.");
    }

    user.changePassword(passwordEncoder.encode(userChangePasswordRequest.getNewPassword()));
}

 

위와 같이 service의 changePassword 메서드에서 비밀번호 유효성 검사 코드는 필요 없으니 제거해준다.

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getMessage());
    }

    public ResponseEntity<Map<String, Object>> getErrorResponse(HttpStatus status, String message) {
        Map<String, Object> errorResponse = new HashMap<>();
        errorResponse.put("status", status.name());
        errorResponse.put("code", status.value());
        errorResponse.put("message", message);

        return new ResponseEntity<>(errorResponse, status);
    }
}

 

@Valid에서 에러가 발생한 경우,  MethodArgumentNotValidException가 발생한다. 이 예외를 체크하는 핸들러를  GlobalExceptionHandler에 추가해줘야 한다.

 

(@RestControllerAdvice GlobalExceptionHandler에 대해서는 아래를 참고)

 

Spring에서의 예외 처리 (+ 커스텀 에러 구현)

Spring에서의 예외 처리자바의 try-catch문을 대신해서, 스프링에서의 핸드링 방식을 정리해 보았다. 1. Spring 기본 예외 처리2. @ExceptionHandler3. @RestControllerAdvice 이렇게 3가지가 존재하고, 동시에 존재

record47584.tistory.com

 

 

리팩토링 2 - 아예 커스텀 어노테이션으로 빼기 

 위처럼 비밀번호 유효성 검사를 @Pattern으로 정의하면, 비밀번호 유효 검사 기준이나 메시지가 바뀌면 비밀번호를 받는 모든 DTO를 수정해야 한다. 아예 비밀번호 유효성 검사를 커스텀 어노테이션으로 만들면, 이 어노테이션만 관리하면 된다. 

// 이 어노테이션이 어디에 붙일 수 있는가 (클래스 필드, 파라미터)
@Target({ElementType.FIELD, ElementType.PARAMETER})

// 언제까지 유지 될 것인지 (런타임 내내 유지)
@Retention(RetentionPolicy.RUNTIME)

// 검증 기준 Constraint 선언
@Constraint(validatedBy = PasswordValidator.class)

public @interface ValidPassword {

    // 에러 메시지
    String message() default "새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.";

    // 검증 그룹화를 위한 필드 (잘 쓰지 않음)
    Class<?>[] groups() default {};

    // 검증 실패 시 부가 메타데이터를 전달하는 필드 (잘 쓰지 않음)
    Class<? extends Payload>[] payload() default {};
}
public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (password == null) {
            return false;
        }
        return password.length() >= 8
                && password.matches(".*\\d.*")
                && password.matches(".*[A-Z].*");
    }
}

 

위 코드들을 추가한 뒤,

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserChangePasswordRequest {

    @NotBlank
    private String oldPassword;

    @NotBlank
    @ValidPassword // 커스텀 어노테이션
    private String newPassword;
}

 

DTO의 필드에 다시 어노테이션을 달아준다.

 

결과

 

유효성 검사에 실패해서 에러를 클라이언트에 전달한다.