Spring

커스텀 어노테이션으로 중복 코드 개선하기

oyatplum 2025. 1. 3. 15:15

들어가기 전 문제 상황

https://github.com/UMC-TripPiece/TripPiece-backend/pull/82

기존의 커스텀 어노테이션은

@Documented
@Constraint(validatedBy = UserExistValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExistUser {
    String message() default "해당하는 유저가 존재하지 않습니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
@Documented
@Constraint(validatedBy = MapExistValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExistMap {
    String message() default "해당하는 맵이 존재하지 않습니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
...
@Component
@RequiredArgsConstructor
public class UserExistValidator implements ConstraintValidator<ExistUser, Long> {

    private final UserRepository userRepository;

    @Override
    public void initialize(ExistUser constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(Long value, ConstraintValidatorContext context) {
        boolean isValid = userRepository.existsById(value);

        if (!isValid) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(ErrorStatus.NOT_FOUND_USER.toString()).addConstraintViolation();
        }

        return isValid;
    }
}
@Component
@RequiredArgsConstructor
public class MapExistValidator implements ConstraintValidator<ExistMap, Long> {

    private final MapRepository mapRepository;

    @Override
    public void initialize(ExistMap constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(Long value, ConstraintValidatorContext context) {
        boolean isValid = mapRepository.existsById(value);

        if (!isValid) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(ErrorStatus.NOT_FOUND_MAP.toString()).addConstraintViolation();
        }

        return isValid;
    }
}

위와 같이 엔티티별로 어노테이션과 validator가 각각 존재했다.

@GetMapping("/stats/{userId}")
    @Operation(summary = "유저별 맵 통계 API", description = "유저별 방문한 나라와 도시 수 반환")
    public ApiResponse<MapStatsResponseDto> getMapStatsByUserId(@PathVariable(name = "userId") @ExistUser Long userId) {
        MapStatsResponseDto stats = mapService.getMapStatsByUserId(userId);
        return ApiResponse.onSuccess(stats);
    }

이는 위의 controller에서 파라미터로 userId, mapId, travelId 등 다른 엔티티의 id를 받아올 때, 유효성을 검사하기 위해 각각 작성되었다.

 

하지만.. 엔티티별 id가 다르다고 해서 저렇게 중복되는 코드를 엔티티별로 모두 작성하는 것은 너무 비효율적이라고 생각했다.

 

 

(참고로 커스텀 어노테이션 생성은 아래 포스팅을 참고!)

https://velog.io/@potato_song/Java-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0

 


 

커스텀 어노테이션 생성과 제네릭 타입 사용하여 동적으로 엔티티 존재 여부 확인하기

 

https://github.com/UMC-TripPiece/TripPiece-backend/pull/97

 

1. 커스텀 애노테이션 정의 (@ExistEntity)

@Documented
@Constraint(validatedBy = EntityExistValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExistEntity {
    String message() default "";
    Class<?> entityType();
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

새롭게 ExistEntity 어노테이션을 생성했다. 기존과의 차이점은 엔티티를 구별하기 위해 제네릭 타입으로 entityType()을 명시했고, 엔티티별로 다른 에러 메세지를 출력하기 위해 message() default는 빈 문자열로 두었다.

 

2. 유효성 검사 로직 (EntityExistValidator)

@Component
@RequiredArgsConstructor
public class EntityExistValidator implements ConstraintValidator<ExistEntity, Long> {
    private final Map<Class<?>, JpaRepository<?, Long>> repositoryMap;
    private final Map<Class<?>, ErrorStatus> errorStatusMap;
    private Class<?> entityType;

    @Override
    public void initialize(ExistEntity constraintAnnotation){
        this.entityType = constraintAnnotation.entityType();
    }

    @Override
    public boolean isValid(Long value, ConstraintValidatorContext context) {
        if (value == null) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("ID는 필수 값입니다.").addConstraintViolation();
            return false;
        }

        JpaRepository<?, Long> repository = repositoryMap.get(entityType);
        if (repository == null) {
            throw new IllegalArgumentException("Repository not found for entity type: " + entityType.getName());
        }

        boolean exists = repository.existsById(value);

        if (!exists) {
            context.disableDefaultConstraintViolation();

            // 동적 메시지 설정
            String msg = errorStatusMap.get(entityType).getMessage();
            context.buildConstraintViolationWithTemplate(msg).addConstraintViolation();
        }

        return exists;
    }
}

initalize 메소드로 ExistEntity 어노테이션에서 전달된 entityType을 저장했다.

isValid 메소드를 통해 입력값(엔티티의 id)의 유효성을 검사하고, 동적으로(entityType을 이용하여) 매핑된 JpaRepository를(아래 3에서 설명) 가져왔다.

가져온 레포지토리의 existsById(value)메소드를 사용하여 해당 레포지토리에서 입력값의 id를 가져오도록 하였고,

이때 id가 없다면 에러 핸들링을 해줬다. 매핑된 errorStatusMap과 entityType을 이용하여 해당 엔티티에 맞는 에러 메세지를 동적으로 설정했다.

 

3. 동적 설정 (ValidatorConfig)

@Configuration
public class ValidatorConfig {
    @Bean
    public Map<Class<?>, JpaRepository<?, Long>> repositoryMap(
            UserRepository userRepository,
            CityRepository cityRepository,
            MapRepository mapRepository,
            TravelRepository travelRepository
    ) {
        Map<Class<?>, JpaRepository<?, Long>> map = new HashMap<>();
        map.put(umc.TripPiece.domain.User.class, userRepository);
        map.put(umc.TripPiece.domain.City.class, cityRepository);
        map.put(umc.TripPiece.domain.Map.class, mapRepository);
        map.put(umc.TripPiece.domain.Travel.class, travelRepository);
        return map;
    }

    @Bean
    public Map<Class<?>, ErrorStatus> errorStatusMap() {
        Map<Class<?>, ErrorStatus> map = new HashMap<>();
        map.put(umc.TripPiece.domain.User.class, ErrorStatus.NOT_FOUND_USER);
        map.put(umc.TripPiece.domain.City.class, ErrorStatus.NOT_FOUND_CITY);
        map.put(umc.TripPiece.domain.Map.class, ErrorStatus.NOT_FOUND_MAP);
        map.put(umc.TripPiece.domain.Travel.class, ErrorStatus.NOT_FOUND_TRAVEL);
        return map;
    }
}

repositoryMap 빈과 errorStatusMap 빈은 Map 객체를 통해 각각 엔티티와 해당 레포지토리 혹은 에러 상태를 맵핑한다.

 

4. Controller에서 사용

@PostMapping("/mytravels/end/{travelId}")
    @Operation(summary = "여행 종료 API", description = "여행을 종료하고 요약 정보 반환")
    public ApiResponse<TravelResponseDto.TripSummaryDto> endTravel(@PathVariable("travelId") @ExistEntity(entityType = umc.TripPiece.domain.Travel.class) Long travelId) {
        TravelResponseDto.TripSummaryDto response = travelService.endTravel(travelId);
        return ApiResponse.onSuccess(response);
    }
@GetMapping("/{userId}")
    @Operation(summary = "유저별 맵 불러오기 API", description = "유저별 맵 리스트 반환")
    public ApiResponse<List<MapResponseDto>> getMapsByUserId(@PathVariable(name = "userId") @ExistEntity(entityType = umc.TripPiece.domain.User.class) Long userId) {
        List<MapResponseDto> maps = mapService.getMapsByUserId(userId);
        return ApiResponse.onSuccess(maps);
    }

그러면 컨트롤러에서 위와 같이 @ExistEntity에서 해당 엔티티만 넣어주면 끝이다!

 

 

 

위의 과정을 통해 

1. 재사용성

: 다양한 엔티티에 대해 동일한 유효성 검사 가능

2. 유효성 검사와 비즈니스 로직 분리

: 유효성 검사 로직이 어노테이션과 Validator 클래스 내부에 캡슐화되어 비즈니스 로직과 분리됨

3. 동적 맵핑을 통한 확장성

: 새로운 엔티티가 추가될 때, 기존 로직을 변경하지 않고 ValidatorConfig에 맵핑 추가로 해결

4. 유지보수 용이

위의 4가지 장점이 가능해졌다!

'Spring' 카테고리의 다른 글

Interceptor로 토큰 전역 처리하기  (0) 2025.01.07
AOP로 jwt token 검증하기 - 커스텀 어노테이션, ThreadLocal 사용  (2) 2025.01.03
스프링 기초4  (0) 2024.02.01
스프링 기초3  (3) 2024.01.28
스프링 기초2  (2) 2024.01.27