Java프로그래밍

[VUE3 + JAVA] 비밀번호 벨리데이션 체크 예제 :PasswordValidator 클래스를 만들자!

FRONTEND

userPwd: yup
          .string()
          .required('비밀번호를 입력해 주세요.')
          .min(8, '8자리 이상으로 입력해 주세요.')
          .matches(regExpForPw, '영문, 숫자 및 특수문자 포함, 8자 이상으로 입력해 주세요.')
          .test(
            'no-sequential-numbers',
            '연속된 숫자(예: 123, 321)는 사용할 수 없습니다.',
            (value) => !value || !hasSequentialNumbers(value)
          )
          .test(
            'no-repeated-characters',
            '동일한 문자를 3자리 이상 반복할 수 없습니다.',
            (value) => !value || !hasRepeatedCharacters(value)
          ),
// * 비밀번호 정규식 패턴 (영문, 숫자, 특수문자 포함 8자리 이상)
export const regExpForPw =
  /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+~`\-={}[\]:;"'<>,.?/|\\])[A-Za-z\d!@#$%^&*()_+~`\-={}[\]:;"'<>,.?/|\\]{8,}$/;

  

/**
 * 연속된 숫자(정방향 또는 역방향)가 포함되어 있는지 검사
 *
 * @param value 검사할 문자열
 * @param limit 연속으로 허용하지 않는 숫자의 개수 (기본값: 3)
 * @returns 연속된 숫자가 있는 경우 true, 그렇지 않으면 false
 */

export function hasSequentialNumbers(value: string, limit: number = 3): boolean {

  if (!value || limit < 2) return false;

  for (let i = 0; i <= value.length - limit; i++) {

    const chunk = value.slice(i, i + limit);

    if (/^\d+$/.test(chunk)) {

      const digits = chunk.split('').map(Number);

      let isAscending = true;

      let isDescending = true;

      for (let j = 1; j < digits.length; j++) {

        if (digits[j] !== digits[j - 1] + 1) isAscending = false;

        if (digits[j] !== digits[j - 1] - 1) isDescending = false;

      }

      if (isAscending || isDescending) return true;

    }

  }

  return false;

}

/**
 * 문자열에 지정된 개수 이상의 반복된 문자가 있는지 검사.
 * ex> 반복 문자 검사 (예: aaa, $$$, 1111)
 *
 * @param value 검사할 문자열
 * @param limit 반복을 허용하는 문자 개수 (기본값: 3)
 * @returns 반복된 문자가 있는 경우 true, 그렇지 않으면 false
 */

export function hasRepeatedCharacters(value: string, limit = 3): boolean {
  const pattern = new RegExp(`(.)\\1{${limit - 1},}`);
  return pattern.test(value);

}

frontend 규칙 그대로 Backend(Java)에서 1:1로 동일하게 적용하는 형태로 구현!

Frontend 규칙 요약

  1. 최소 8자
  2. 영문 포함
  3. 숫자 포함
  4. 특수문자 포함 (frontend에서 정의한 전체 특수문자 집합)
  5. 연속 숫자 3자리 이상 금지 (123, 321 등)
  6. 동일 문자 3회 이상 반복 금지 (aaa, $$$, 111)


Java 정규식 (Frontend와 동일하게 맞춤)

private static final String REGEX_PW =
"^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+~`\\-={}\\[\\]:;\"'<>,.?/|\\\\])" +
"[A-Za-z\\d!@#$%^&*()_+~`\\-={}\\[\\]:;\"'<>,.?/|\\\\]{8,}$";

✔ frontend 정규식 그대로 이스케이프만 Java 맞춤


✅ 연속 숫자 검사 (Frontend hasSequentialNumbers 동일 로직)

public static boolean hasSequentialNumbers(String value, int limit) {
    if (value == null || value.length() < limit || limit < 2) return false;

    for (int i = 0; i <= value.length() - limit; i++) {
        String chunk = value.substring(i, i + limit);

        if (chunk.matches("\\d+")) {
            boolean isAscending = true;
            boolean isDescending = true;

            for (int j = 1; j < chunk.length(); j++) {
                int prev = Character.getNumericValue(chunk.charAt(j - 1));
                int curr = Character.getNumericValue(chunk.charAt(j));

                if (curr != prev + 1) isAscending = false;
                if (curr != prev - 1) isDescending = false;
            }

            if (isAscending || isDescending) return true;
        }
    }

    return false;
}

✅ 반복 문자 검사 (Frontend hasRepeatedCharacters 동일)

public static boolean hasRepeatedCharacters(String value, int limit) {
    if (value == null || limit < 2) return false;

    String regex = "(.)\\1{" + (limit - 1) + ",}";
    return value.matches(".*" + regex + ".*");
}

✅ 최종 비밀번호 검증 메서드

public static String validatePassword(String password) {

    if (password == null || password.isEmpty()) {
        return "비밀번호를 입력해 주세요.";
    }

    if (password.length() < 8) {
        return "8자리 이상으로 입력해 주세요.";
    }

    if (!password.matches(REGEX_PW)) {
        return "영문, 숫자 및 특수문자 포함, 8자 이상으로 입력해 주세요.";
    }

    if (hasSequentialNumbers(password, 3)) {
        return "연속된 숫자(예: 123, 321)는 사용할 수 없습니다.";
    }

    if (hasRepeatedCharacters(password, 3)) {
        return "동일한 문자를 3자리 이상 반복할 수 없습니다.";
    }

    return null; // 정상
}

✅ Controller 적용 예시

String pwd = StringUtil.nvl(params.getUserPwd(), "");

String errorMsg = validatePassword(pwd);
if (errorMsg != null) {
    result.put("msg", errorMsg);
    modelMap.addAttribute(CmnConst.RECORD, result);
    return modelMap;
}



PasswordValidator 클래스 생성


import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
public class PasswordValidator implements Validator {

    private static final String REGEX_PW =
        "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+~`\\-={}\\[\\]:;\"'<>,.?/|\\\\])" +
        "[A-Za-z\\d!@#$%^&*()_+~`\\-={}\\[\\]:;\"'<>,.?/|\\\\]{8,}$";

    @Override
    public boolean supports(Class<?> clazz) {
        return String.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        String password = (String) target;

        if (password == null || password.isEmpty()) {
            errors.reject("password.required", "비밀번호를 입력해 주세요.");
            return;
        }

        if (password.length() < 8) {
            errors.reject("password.min", "8자리 이상으로 입력해 주세요.");
            return;
        }

        if (!password.matches(REGEX_PW)) {
            errors.reject("password.pattern", "영문, 숫자 및 특수문자 포함, 8자 이상으로 입력해 주세요.");
            return;
        }

        if (hasSequentialNumbers(password, 3)) {
            errors.reject("password.sequential", "연속된 숫자(예: 123, 321)는 사용할 수 없습니다.");
            return;
        }

        if (hasRepeatedCharacters(password, 3)) {
            errors.reject("password.repeat", "동일한 문자를 3자리 이상 반복할 수 없습니다.");
        }
    }

    private boolean hasSequentialNumbers(String value, int limit) {
        if (value == null || value.length() < limit || limit < 2) return false;

        for (int i = 0; i <= value.length() - limit; i++) {
            String chunk = value.substring(i, i + limit);

            if (chunk.matches("\\d+")) {
                boolean isAscending = true;
                boolean isDescending = true;

                for (int j = 1; j < chunk.length(); j++) {
                    int prev = Character.getNumericValue(chunk.charAt(j - 1));
                    int curr = Character.getNumericValue(chunk.charAt(j));

                    if (curr != prev + 1) isAscending = false;
                    if (curr != prev - 1) isDescending = false;
                }

                if (isAscending || isDescending) return true;
            }
        }
        return false;
    }

    private boolean hasRepeatedCharacters(String value, int limit) {
        String regex = "(.)\\1{" + (limit - 1) + ",}";
        return value.matches(".*" + regex + ".*");
    }
}

Controller 적용

@PostMapping("/user/save")
public ResponseEntity<?> saveUser(
        @RequestBody UserRequest req,
        Errors errors) {

    passwordValidator.validate(req.getUserPwd(), errors);

    if (errors.hasErrors()) {
        return ResponseEntity.badRequest().body(errors.getAllErrors().get(0).getDefaultMessage());
    }

    return ResponseEntity.ok("SUCCESS");
}

Bean 자동 주입

@Autowired
private PasswordValidator passwordValidator;

@Component 없이 사용하는 방법

PasswordValidator 클래스에서 @Component만 제거 후 사용

Controller에서 직접 사용

@PostMapping("/user/save")
public ResponseEntity<?> saveUser(@RequestBody UserRequest req, Errors errors) {

    PasswordValidator validator = new PasswordValidator();
    validator.validate(req.getUserPwd(), errors);

    if (errors.hasErrors()) {
        return ResponseEntity.badRequest()
                .body(errors.getAllErrors().get(0).getDefaultMessage());
    }

    return ResponseEntity.ok("SUCCESS");
}

여러곳에서 공통 사용 시 (권장)

public class PasswordValidators {
    private static final PasswordValidator INSTANCE = new PasswordValidator();

    public static PasswordValidator get() {
        return INSTANCE;
    }
}
PasswordValidators.get().validate(req.getUserPwd(), errors);

@InitBinder 방식 (Bean 등록 없이 자동연결)

@InitBinder
protected void initBinder(WebDataBinder binder) {
    binder.addValidators(new PasswordValidator());
}
@PostMapping("/user/save")
public ResponseEntity<?> saveUser(@RequestBody String userPwd, Errors errors) {
    if (errors.hasErrors()) {
        return ResponseEntity.badRequest()
                .body(errors.getAllErrors().get(0).getDefaultMessage());
    }
    return ResponseEntity.ok("SUCCESS");
}

Errors error은 초기화를 어떻게 해?

Spring Validator에서 Errors는 개발자가 new로 생성하는 객체가 아니다.
👉 Spring이 자동으로 만들어서 주입해 주거나,
👉 직접 만들려면 BindingResult 구현체를 사용해야 한다.

상황별로 정확히 정리해줄게.


✅ 1. Controller에서 자동 주입 방식 (가장 권장)

@PostMapping("/user/save")
public ResponseEntity<?> saveUser(
        @RequestBody UserRequest req,
        Errors errors) {

    PasswordValidator validator = new PasswordValidator();
    validator.validate(req.getUserPwd(), errors);

    if (errors.hasErrors()) {
        return ResponseEntity.badRequest()
                .body(errors.getAllErrors().get(0).getDefaultMessage());
    }

    return ResponseEntity.ok("SUCCESS");
}

👉 Spring MVC가 Errors(BindingResult) 를 자동 생성해서 주입해줌
👉 개발자는 절대 new 안함


✅ 2. 수동 생성해야 하는 경우 (Service, 테스트 등)

Controller 밖에서 Validator 쓰면 이렇게 만든다.

import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.Errors;

Errors errors = new BeanPropertyBindingResult(password, "password");

PasswordValidator validator = new PasswordValidator();
validator.validate(password, errors);

if (errors.hasErrors()) {
    System.out.println(errors.getAllErrors().get(0).getDefaultMessage());
}

✅ 3. 단순 문자열 검증 전용 util 방식 (Errors 없이)

PasswordValidator validator = new PasswordValidator();
Errors errors = new BeanPropertyBindingResult(pwd, "userPwd");
validator.validate(pwd, errors);

❌ 하면 안 되는 방식

Errors errors = new Errors();   // ❌ 인터페이스라 불가
Errors errors = null;         // ❌ NPE

Hi, I’m 관리자