개발 공부/Spring

[플랭고] 일대일에서 일대다로 변경 시 validation 관련 문제 (Custom ConstraintValidator)

gmelon 2023. 8. 23. 01:08

문제 상황

개발 중인 애플리케이션 플랭고의 '기록하기' 기능은 현재 1장의 이미지만 첨부할 수 있다. 이를 20장까지 첨부할 수 있도록 변경하는 작업을 진행했다.

(사진은 2023 인프콘 🥹)

먼저 다음과 같은 작업들을 진행했다.

1. 이미지 url을 저장할 DiaryImage 테이블, 엔티티 생성

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class DiaryImage extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "diary_id")
    private Diary diary;

    private String imageUrl;

    @Builder
    public DiaryImage(Diary diary, String imageUrl) {
        this.diary = diary;
        this.imageUrl = imageUrl;
    }
}

2. Diary(기록) 엔티티와 DiaryImage 엔티티를 일대다로 연관관계 설정

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Diary extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(columnDefinition = "TEXT")
    private String content;

    // 기존 필드
    // private String imageUrl;

    // 변경된 필드
    @OneToMany(mappedBy = "diary", cascade = ALL, orphanRemoval = true)
    private List<DiaryImage> diaryImages = new ArrayList<>();

    /* 생략 */

}

3. 요청 및 응답 DTO의 필드를 List로 변경

@NoArgsConstructor
@Getter
public class DiaryCreateRequestDto {

    private String content;

    // 기존
    // @URL
    // private String imageUrl;

    // 변경
    @URL
    private List<String> imageUrls = new ArrayList<>();

    /* 생략 */

}
@NoArgsConstructor
@Getter
public class DiaryResponseDto {

    private Long id;

    private String content;

    // 기존
    // private String imageUrl;

    // 변경
    private List<String> imageUrls = new ArrayList<>();

    /* 생략 */

}

4. 기존에 하나의 파일만 업로드가 가능했던 S3 관련 api도 여러 파일을 한번에 처리할 수 있도록 변경

@RequiredArgsConstructor
@RequestMapping("/api/s3")
@RestController
public class S3Controller {

    private final S3Service s3Service;

    @PostMapping
    public FileUploadResponseDto uploadAll(@RequestParam List<MultipartFile> files) {
        validateFilesAreNotEmpty(files);
        return s3Service.uploadAll(files);
    }

    /* 생략 */

}

그런데 이렇게 변경하고 테스트를 실행해보니 예상하지 못한 곳에서 다음과 같은 오류가 발생했다.

테스트 결과

디버깅 결과

원인

오류 메시지

No validator could be found for constraint 'javax.validation.constraints.Pattern' validating type 'java.util.List'. Check configuration for 'imageUrls'

 

오류 메시지를 읽어보면, List에 대해 Pattern validation을 수행할 validator가 없다고 한다. 기존 한 개짜리 String 타입의 imageUrl을 List 타입의 imageUrls로 변경하면서 당연히 우리의 스프링 부트 또는 다른 누군가(?)가 리스트가 있으면 해당 리스트의 원소들을 순회하며 validation을 수행해주겠지 라고 생각한게 잘못이었다.

문제가 되는 부분

생각해보니 List에 대해서는 별도로 원소가 없어야 한다거나 몇 개만 있어야 한다거나 등의 검증을 수행할 수도 있기 때문에 자동으로 내부 원소를 순회하며 검증을 해주는 것도 문제가 될 여지가 있어보였다.

해결 방법

이를 해결하기 위해 기존에 사용했던 @URL 대신, List를 타겟으로 하여, 해당 List의 원소를 순회하며 @URL과 동일한 검증 과정을 수행하는 Custom Validator를 만들어 사용하기로 했다.

 

아래 스프링 문서에 보면 ConstraintValidator 를 구현하고 Custon 검증 어노테이션에 해당 클래스를 value로 갖는 @Constraint 메타 어노테이션을 달아주면 동일하게 검증을 할 수 있다고 되어있다.

그래서 아래와 같이 ConstraintValidator를 implements하는 CollectionURLValidator 를 구현해주었다. 이 validator는 Collection의 원소들을 하나하나 순회하며 @URL 이 수행하는 것과 동일한 검증을 수행하고, 하나의 원소라도 검증에 실패할 경우 false를 반환해준다.

public class CollectionURLValidator implements ConstraintValidator<CollectionURL, Collection<? extends CharSequence>> {

    private String protocol;
    private String host;
    private int port;

    @Override
    public void initialize(CollectionURLValidation annotation) {
        this.protocol = annotation.protocol();
        this.host = annotation.host();
        this.port = annotation.port();
    }

    @Override
    public boolean isValid(Collection<? extends CharSequence> values, ConstraintValidatorContext context) {
        if (values == null) {
            return true;
        }

        for (CharSequence value : values) {
            if (!isValid(value)) {
                return false;
            }
        }
        return true;
    }

    private boolean isValid(CharSequence value) {
        if ( value == null || value.length() == 0 ) {
            return true;
        }

        URL url;
        try {
            url = new URL( value.toString() );
        }
        catch (MalformedURLException e) {
            return false;
        }

        if ( protocol != null && protocol.length() > 0 && !url.getProtocol().equals( protocol ) ) {
            return false;
        }

        if ( host != null && host.length() > 0 && !url.getHost().equals( host ) ) {
            return false;
        }

        if ( port != -1 && url.getPort() != port ) {
            return false;
        }

        return true;
    }
}

ConstraintValidator<A, T> 클래스의 docs를 보면 다음과 같이 타입 파라미터 A에 이 Validator에 의해 처리될 어노테이션 타입을 지정하고 타입 파라미터 T에 이 Validator가 지원하는 타겟 타입을 적으라고 되어있다.


따라서 A에는 DTO에 새롭게 달아줄 커스텀 검증 어노테이션 CollectionURL을, T에는 CharSequence과 그 하위로 타입을 제한한 한정적 와일드카드를 사용하는 Collection을 넣어주었다.

 

커스텀 validator인 CollectionURLValidator의 내부 코드는 Collection을 순회한다는 점을 제외하면 기존 @URL의 기본 Validator인 hibernate의 URLValidator의 코드를 참고해서 비슷하게 작성해주었다.

hibernate의 URLValidator

커스텀 검증 어노테이션 CollectionURL 역시 URL에서 사용하지 않는 정보를 제거하고 @Constraint 메타어노테이션의 value를 직접 만든 validator로 변경하는 정도로 비슷하게 구현할 수 있었다.

커스텀 검증 어노테이션 CollectionURL

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Constraint(validatedBy = CollectionURLValidator.class)
public @interface CollectionURL {

    String message() default "올바른 URL이 아닙니다.";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String protocol() default "";

    String host() default "";

    int port() default -1;

}

위와 같이 커스텀 Validator와 어노테이션을 구현하고 다음과 같이 DTO를 수정해주니 이제 테스트가 잘 통과되는 것을 확인할 수 있었다.

 

아쉬운 점

한 가지 아쉬운 점이라면, 한창 인프런에서 토비님의 스프링 부트 강의를 듣고 있던 중이라 어노테이션으로 무언가 동작하게 만드는 일이 너무 신기하고 재밌어 보여서 Collection 검증 어노테이션도 여러 기존 검증 어노테이션과 결합해 범용으로 사용할 수 있도록 구현하고 싶었는데 실패했다는 점이다. 🥲

 

대략 아래 코드처럼 Collection의 검증을 쉽게 할 수 있도록 만들고 싶어서 엄청 길게 validator 코드를 작성하다가 스프링에서 제공하는 클래스들에 대한 이해가 부족해서 계속.. 계속.. 실패하고 우선 나중으로 미루기로 했다.

@CollectionValidation(constraints = {URL.class, ...})
private List<String> imageUrls = new ArrayList<>();

나중에 이런 비슷한 상황이 됐을 때 꼭 다시 시도해보고 싶다.

참고자료