문제 상황
개발 중인 애플리케이션 플랭고의 '기록하기' 기능은 현재 1장의 이미지만 첨부할 수 있다. 이를 20장까지 첨부할 수 있도록 변경하는 작업을 진행했다.
![](https://blog.kakaocdn.net/dn/ceXHiZ/btsBKk1z1N4/yBHXAtuw4jrDQjJ0Y3gj01/img.png)
(사진은 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);
}
/* 생략 */
}
그런데 이렇게 변경하고 테스트를 실행해보니 예상하지 못한 곳에서 다음과 같은 오류가 발생했다.
테스트 결과
![](https://blog.kakaocdn.net/dn/bpyv0W/btsBCY7i7Fg/v05OK1Te9FyZjKR8U5M51k/img.png)
디버깅 결과
![](https://blog.kakaocdn.net/dn/veiWH/btsBC6dgO9z/XAxeQ0aegyD4gKxHxQVLs0/img.png)
원인
오류 메시지
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을 수행해주겠지 라고 생각한게 잘못이었다.
문제가 되는 부분
![](https://blog.kakaocdn.net/dn/kXxkh/btsBDL7OrId/zakKs9cCK406xmekZyoopk/img.png)
생각해보니 List에 대해서는 별도로 원소가 없어야 한다거나 몇 개만 있어야 한다거나 등의 검증을 수행할 수도 있기 때문에 자동으로 내부 원소를 순회하며 검증을 해주는 것도 문제가 될 여지가 있어보였다.
해결 방법
이를 해결하기 위해 기존에 사용했던 @URL 대신, List를 타겟으로 하여, 해당 List의 원소를 순회하며 @URL과 동일한 검증 과정을 수행하는 Custom Validator를 만들어 사용하기로 했다.
아래 스프링 문서에 보면 ConstraintValidator
를 구현하고 Custon 검증 어노테이션에 해당 클래스를 value로 갖는 @Constraint 메타 어노테이션을 달아주면 동일하게 검증을 할 수 있다고 되어있다.
![](https://blog.kakaocdn.net/dn/UuNSV/btsBC2vfT4t/4GheaILaok33oJhNl0KWf1/img.png)
그래서 아래와 같이 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가 지원하는 타겟 타입을 적으라고 되어있다.
![](https://blog.kakaocdn.net/dn/bdmLM3/btsBC5rS3x6/o36NqFwgf6TVYLcbypUty1/img.png)
따라서 A에는 DTO에 새롭게 달아줄 커스텀 검증 어노테이션 CollectionURL을, T에는 CharSequence과 그 하위로 타입을 제한한 한정적 와일드카드를 사용하는 Collection을 넣어주었다.
커스텀 validator인 CollectionURLValidator의 내부 코드는 Collection을 순회한다는 점을 제외하면 기존 @URL의 기본 Validator인 hibernate의 URLValidator의 코드를 참고해서 비슷하게 작성해주었다.
hibernate의 URLValidator
![](https://blog.kakaocdn.net/dn/bujfyF/btsBHQTYHyl/yGfi5eoHOHdyvZHej9U2r1/img.png)
커스텀 검증 어노테이션 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를 수정해주니 이제 테스트가 잘 통과되는 것을 확인할 수 있었다.
![](https://blog.kakaocdn.net/dn/czWwWx/btsBKk8kFuT/IUm3hiJ3T3LmMvdNdMOX60/img.png)
![](https://blog.kakaocdn.net/dn/pcJWm/btsBFHqkz2G/8kXnKXjHk2JtFaszUZktpK/img.png)
아쉬운 점
한 가지 아쉬운 점이라면, 한창 인프런에서 토비님의 스프링 부트 강의를 듣고 있던 중이라 어노테이션으로 무언가 동작하게 만드는 일이 너무 신기하고 재밌어 보여서 Collection 검증 어노테이션도 여러 기존 검증 어노테이션과 결합해 범용으로 사용할 수 있도록 구현하고 싶었는데 실패했다는 점이다. 🥲
대략 아래 코드처럼 Collection의 검증을 쉽게 할 수 있도록 만들고 싶어서 엄청 길게 validator 코드를 작성하다가 스프링에서 제공하는 클래스들에 대한 이해가 부족해서 계속.. 계속.. 실패하고 우선 나중으로 미루기로 했다.
@CollectionValidation(constraints = {URL.class, ...})
private List<String> imageUrls = new ArrayList<>();
나중에 이런 비슷한 상황이 됐을 때 꼭 다시 시도해보고 싶다.