JAVA 테스트케이스 (2)

승환·2025년 1월 9일

📌 문제

@Valid 를 사용해서 처리하고 있었다. 이제까지는 핸들러로 Exception을 잡아서 해결하고 있었는데,
여러모로 불편한 점이 많아서 그렇게 하지 않고 사용자 정의 어노테이션으로 해결하려고 한다.


방법 1. AOP

AOP(Aspect-Oriented Programming)방법을 쓰면 어노테이션 방법을 쓰면서 throw exception을 유연하게 처리할 수 있다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface MyNotNull {
    String message() default "Field must not be null";
    String apiMessage() default "INVALID_INPUT";
}

이런 식으로 어노테이션을 만들고

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class MyNotNullAspect {

    @Before("@annotation(myNotNull) && args(value,..)")
    public void validateNotNull(MyNotNull myNotNull, String value) {
        if (value == null || value.trim().isEmpty()) {
            String defaultMessage = myNotNull.message();
            ApiCodeEnum apiCode = ApiCodeEnum.valueOf(myNotNull.apiMessage());
            throw new CommonException(apiCode, defaultMessage);
        }
    }
}

이렇게 어노테이션 검사식을 추가할 수 있다.
같은 동작을 하긴 하는데, 이전과 다른 점은 Jakara에서 제공하는 @Valid어노테이션을 사용하면
ConstraintViolationException을 발생시킨다. 하지 않는 방법이 있지만 이건 나중에 다루도록 하고,
AOP방식을 사용하면 내가 원하는 Exception을 터트릴 수 있다는 점에서 관리가 유연하다는 점이 있다.

public void processRequest(@MyNotNull(message = "Name is required", 
apiMessage = "INVALID_NAME") String name) {
    // 메서드 실행 전에 AOP로 유효성 검사 수행
}

이런 식으로 사용한다.


방법 2. 커스텀 Validator클래스 사용

이전 방법에서는 어노테이션만 만들었다면 이번에는 Jakrta처럼 Validator클래스를 만들어서
그에 따른 동작을 하게하는 방법이다.

public interface Validator<T> {
    void validate(T value) throws CommonException;
}

이렇게 범용 인터페이스를 정의하고

public class MyNotNullValidator implements Validator<String> {
    private final String defaultMessage;
    private final ApiCodeEnum apiCode;

    public MyNotNullValidator(String defaultMessage, ApiCodeEnum apiCode) {
        this.defaultMessage = defaultMessage;
        this.apiCode = apiCode;
    }

    @Override
    public void validate(String value) {
        if (value == null || value.trim().isEmpty()) {
            throw new CommonException(apiCode, defaultMessage);
        }
    }
}

이렇게 사용하면, 그에따른 동작을 하게 만들어진다.
이 방법의 장점은 유연성과 재사용성을 모두 챙겼다는 점이다.
그리고 만약에 나중에 다른 어노테이션을 사용하려고 했을 때도 독립정의된 인터페이스로 작동하기 때문에
독립적으로 작동한다는 장점이 있다.

Validator<String> validator = new MyNotNullValidator("Value cannot be null", ApiCodeEnum.INVALID_INPUT);
validator.validate(inputValue);

이렇게 사용한다.


방법 3. Jakarta어노테이션

지금까지 사용했던 방법들이 좋은 방법들이지만 지금 하려는 것에 치명적인 문제점이 있다.
위 방법대로 사용하면 @Vlidate로 검사를 시작할 수 없다.
모두 독립적인 방법으로 검사를 진행하기 때문에 기존에 있던 코드를 모두 변경해줘야 하는 참사가
발생한다.
그래서 호완이 가능한 Jakarta 어노테이션을 사용한다.

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = MyNotNullValidator.class) // 검증기를 연결
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER }) // 적용 대상
@Retention(RetentionPolicy.RUNTIME) // 런타임에 유지
public @interface MyNotNull {

    // 기본 에러 메시지
    String message() default "This field cannot be null or empty";

    // API 메시지 커스텀
    String APImessage() default "INVALID_FIELD";

    // 그룹 지정 (기본값: {})
    Class<?>[] groups() default {};

    // 확장에 사용되는 Payload (기본값: {})
    Class<? extends Payload>[] payload() default {};
}

이렇게 어노테이션을 만들 수 있다.

public class MyNotNullValidator implements ConstraintValidator<MyNotNull, String> {

    private String defaultMessage;
    private ApiCodeEnum apiMessage;

    @Override
    public void initialize(MyNotNull constraintAnnotation) {
        this.defaultMessage = constraintAnnotation.message();  // 기본 메시지
        this.apiMessage = constraintAnnotation.APImessage();   // API 메시지
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.trim().isEmpty()) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(MessageUtils.getMessage("error." + apiMessage,defaultMessage))
                    .addConstraintViolation();
            return false;
        }
        return true;
    }
}

이렇게 컨트롤러를 만들어줘야 하는데 하나하나 살펴보면

@MyNotNull(message="not",apiMessage=ApiCodeEnum.Error_code)

이렇게 어노테이션을 사용하게 될 것이다.
그럼 not이라는 메세지를 담은 API_Error_code가 ConstraintViolationException으로 나오게 되는 것이다.


📌해결

기존 코드를 모두 바꿀 수 없으니 방법 3을 사용하기로 하였다.
근데 문제가 아직 하나 남아있다. 우리는 기존에 터트려야하는 Exception이 CommonException이다.
하지만 3번 방법을 사용하게 되면 원하는 에러를 발생시킬 수 없다.

그래서 방법을 찾아봤는데 총 두가지의 방법이 있었다.

방법 1. isValid안에서 Exception발생

생각해보면 매우 간단한 방법인데

@Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.trim().isEmpty()) {
           	throw new CommonException(apimessage,defualtmessage);
        }
        return true;
    }

이렇게 하면 검사를 함과 동시에 CommonException을 발생시킬 수 있다.
가장 큰 장점은 쉽다는 것이다. 이렇게 하면 별다른 핸들러가 필요없이 하나의 코드에서 처리할 수 있다.

하지만 단점이 있다. 바로 Springboot 프레임워크의 규칙을 위반한다는 것이다.

이게 무슨말이냐, 프레임워크는 정해진 틀이있고 그 안에서 발생할 수 있는 예외가 정해져있다.
하지만 CommonException은 정의한 Exception이고 이것은 프레임워크에서 발생하면 안되는 오류이다.
따라서 프레임워크는 아래와 같은 메세지를 보낸다.

"An unexpected error occurred"

사실 오류 처리만 제대로 하고 있다면 무시해도 별 상관은 없다.(라고 공식문서에 나와있다)
하지만 프레임워크의 특성상 웬만하면 구조는 건들지 않는 것이 좋다.
나중에 어떤 문제가 터질지 모르는 것이니.


방법 2. ExceptionHandler이용

CommonException을 처리할 수 없다면 예외를 catch해서 다른 예외로 바꾸는 핸들러를 사용하면 된다.

@RestControllerAdvice
@Validated
public class GlobalExceptionHandler {

    @ExceptionHandler(ConstraintViolationException.class)
    public void handleConstraintViolationException(ConstraintViolationException ex) {
        // 예외 메시지를 추출
        List<String> errorMessages = ex.getConstraintViolations().stream()
            .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
            .collect(Collectors.toList());

        // CommonException으로 변환하여 던짐
        throw new CommonException(ApiCodeEnum.ERROR_938, errorMessages);
    }
}

천천히 살펴보자

@RestControllerAdvice
@Validated

RestControllerAdvice는 사실 여기서는 필요없는 어노테이션이다.
원래 해주는 역할은 @Valid가 Controller단에 걸려있다면 그걸 잡는 역할을 하는데,
여기서는 service단에 걸어놨기 때문에 필요없는 어노테이션이다.

@Validated는 @Valid를 처리할 것이라는 명시적 어노테이션인것 같다.(확실하지 않다..)

@ExceptionHandler(ConstraintViolationException.class)

아래 나오는 메서드에서 ConstraintViolationException을 처리하겠다는 명시이다.
그냥 메서드로 작성할 수 있지만 다른 오류를 같이 처리하기 위해 묶어서 작성한다.

handleConstraintViolationException(ConstraintViolationException ex) {
        // 예외 메시지를 추출
        List<String> errorMessages = ex.getConstraintViolations().stream()
            .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
            .collect(Collectors.toList());

ConstraintViolationException에서 발생한 예외 메세지를 담아서 리스트화 한다.
리스트로 만드는 이유는 아래 CommonException에서 받는 형식이기 때문이다.

throw new CommonException(ApiCodeEnum.ERROR_938, errorMessages);

CommonException을 터트린다.

이런 방식으로 하니까 확실히 코드가 나눠져서 보기에는 편할 수 있었다.
하지만 이전 포스트에서 설명 했다시피 @Valid는 발생시킬 수 있는 예외가 총 3가지가 있다.
보통 컨트롤러 단에서 잡으니 2개는 잡을 필요가 없지만
그래도 핸들러를 만드려면 다 만들어 줘야 사용하기에 용이하다.
이런 점에서 약간 불편하지만 이런 방식을 채택하는게 유지보수와 사용용이성면에서 좋은 결과를 가질
수 있는것 같다.

profile
왕초보 학부생

0개의 댓글