페이징을 할 때 size
값 요청으로 너무 큰 값이 올 경우를 생각해보자. 지난번에 코린이 페이징 크기를 너무 큰 값으로 요청할 경우 페이징 과정에서 에러가 발생할 수 있다(정확히 어떤 에러인지는 모르겠다. 이 부분은 조금 더 찾아봐야 할 것 같다.)는 이야기를 했다.
마침 우리 프로젝트가 실제 서비스 상황에서 그렇게 큰 크기의 페이징 요청을 받을 일이 없다고 생각했기 때문에, 비즈니스로 약속된 크기에 상관 없이 페이징 크기를 적당히 관리할 수 있는 값으로 제한을 해 주기로 결정했다. 사실 페이징 크기 자체를 제한하는 것은 어려운 일이 아니었다. HandlerMethodArgumentResolver
에 값을 세팅해주면 되기 때문이다. 컨트롤러로 들어오는 페이징 요청을 Pageable
로 매핑하는 리졸버로는 PageableHandlerMethodArgumentResolver
가 있다.
HandlerMethodArgumentResolver
에는 기본적으로 들어온 요청에 대해 바인딩할 클래스를 지정하는 supportsParameter
메서드와 resolveArgument
메서드가 있는데, Pageable
에 대한 바인딩 정보를 변경할 것이므로 supportsParameter
메서드는 건드리지 않고 resolveArgument
메서드만 건드리려고 하였다. 그런데 PageableHandlerMethodArgumentResolver
는 상속하고 있는 PageableHandlerMethodArgumentResolverSupport
라는 클래스에 maxPageSize
라는 필드를 가지고 있었고, 해당 값으로 페이징의 크기를 제한하고 있었다. 이 필드값은 setMaxPageSize
라는 메서드로 설정할 수 있었기 때문에 처음에 우리는 간단히 다음과 같은 코드를 작성했다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
PageableHandlerMethodArgumentResolver pageableHandlerMethodArgumentResolver =
new PageableHandlerMethodArgumentResolver();
pageableHandlerMethodArgumentResolver.setMaxPageSize(150);
resolvers.add(pageableHandlerMethodArgumentResolver);
}
}
처음에는 내부 코드를 잘 확인해보지 않고 저렇게 최대 크기를 생성해 준 뒤 해당 크기보다 큰 요청이 들어오면 자동으로 예외를 발생시켜 줄거라 생각했는데 아니었다. 150보다 큰 값이 와도 150짜리 Pageable
로 바꿔 줄 뿐이었다. 하지만 클라이언트에서 1000짜리 요청을 했는데 데이터가 1000개 이상 있음에도 불구하고 150개만 성공적으로 받아오는 것은 이상하지 않을까? 우리가 원하는 것은 애초에 제한을 넘는 요청이 오면 예외를 터뜨려서 클라이언트에 알려주는 것이었다. 그래서 티키가 아이디어를 냈다.
PageableHandlerMethodArgumentResolver를 상속해서 resolveArgument를 오버라이딩한 뒤 거기서 검증하고 예외를 터뜨리면 되지 않을까?
public class CustomPageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolver {
private static final int MAX_SIZE = 150;
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return super.supportsParameter(parameter);
}
@Override
public Pageable resolveArgument(final MethodParameter methodParameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
final int size = Integer.parseInt(webRequest.getParameter("size"));
if (size > MAX_SIZE) {
throw new InvalidPageSizeException(MAX_SIZE);
}
return super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory);
}
}
request로 들어온 파라미터를 Pageable
로 바인딩하는 과정은 부모 클래스인 PageableHandlerMethodArgumentResolver
에 있으니, 우리가 해 줄 일은 파라미터로 받아온 size가 미리 지정한 MAX_SIZE
보다 큰지 검증해서 크면 예외를 터뜨리도록 하는 일이었다. 이렇게 만든 커스텀 리졸버를 add해 주는 것으로 마무리지었다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
CustomPageableHandlerMethodArgumentResolver customPageableHandlerArgumentResolver =
new CustomPageableHandlerMethodArgumentResolver();
customPageableHandlerMethodArgumentResolver.setMaxPageSize(150);
resolvers.add(customPageableHandlerMethodArgumentResolver);
}
}
COUNT
를 제외한 데이터베이스의 집계 함수는 집계 대상이 없으면 NULL
을 반환한다. 그렇다면 NULL
값을 어떻게 처리해 줄 것인가?(해결)키보드 도메인을 정리하고 보니 다음과 같은 특징이 있었다.
결론적으로 리뷰와 관련된 컬럼이 존재하지는 않지만, 조회 시에 계속해서 리뷰 테이블의 정보를 가져와야 하는 상황이었다. Keyboard
- Review
연관관계를 맺어 주는 방법(여기서는 @OneToMany
가 되는 상황)이 있었지만, 조회 시에 리뷰 수와 평균 평점을 구하려면 즉시 로딩이어야 하는데(어차피 지연 로딩이어도 의미가 없기 때문에) 컬렉션의 즉시 로딩은 그닥 추천되는 방법도 아니고 목록의 조회 시에 그 수많은 리뷰 정보를 다 가져올 필요도 없기 때문에 고려하지 않았다.
그러다 코린이 제시했던 대안이 바로 @Formula
어노테이션을 활용한 가상 컬럼 매핑이었고, @Formula
안에 네이티브 쿼리를 써 넣어 reviewCount
와 rating
두 개의 필드를 만들었다.
@Formula("(SELECT COUNT(1) FROM review r WHERE r.product_id = id)")
private int reviewCount;
@Formula("(SELECT AVG(r.rating) FROM review r WHERE r.product_id = id)")
private double rating;
그런데 문제가 생겼다. 만약 키보드에 대해 작성된 리뷰가 없을 경우 엔티티가 제대로 생성되지 않았다. rating 필드에서 null value was assigned to property ~~
하는 예외가 발생하게 된 것이다. 찾아보니 엔티티의 primitive 필드에 null 값이 들어올 때 발생하는 문제라고 한다.
그렇다. 데이터베이스에서 제공하는 집계 함수들(COUNT, AVG, SUM, …)은 NULL값을 반환할 수 있다는 점을 간과했다. 왜 reviewCount 필드에서는 해당 문제가 발생하지 않았냐고 할 수는 있는데, COUNT만 예외적으로 조건에 맞는 row가 아예 없을 경우 0을 반환한다고 한다. 나머지 함수들은 조건에 맞는 row가 없으면 null을 반환한다.
따라서 조건에 맞는 값이 없으니 DB에서 null을 반환하고, 이걸 double
타입으로 바인딩하려다 보니 예외가 발생하는 것이었다.
때문에 null 처리를 해주려고 처음에는 rating 필드를 wrapper 타입인 Double
로 바꾸려고 했다. 그런데 제품에 작성된 리뷰가 없을 때 클라이언트에 보여주고 싶은 값은 null이 아니라 0.0인 만큼 어딘가에서는 null을 0.0으로 변환시켜줘야 하는데, JPA 엔티티는 생성자를 통해 생성하지 않으므로(정확히는 빈 생성자를 통해 생성하므로) 생성자 코드를 직접 조작해서 값을 컨트롤 할 수가 없었다. 결국 wrapper 타입을 쓰고 클라이언트에 0.0을 보여주려면
의 방법을 사용해야 했다.
다른 대안으로는 데이터베이스의 null 체크 기능을 이용하자는 의견이 있었다. 예를 들어, MySQL에서는 IFNULL
이라는 함수를 제공해서 null 값인 경우에는 원하는 기본값을 반환할 수 있도록 해준다. 이 경우에 @Formula
가 다음과 같이 변하게 된다.
@Formula("(SELECT IFNULL(AVG(r.rating), 0) FROM review r WHERE r.product_id = id)")
private double rating;
하지만 이 방법도 문제가 있었는데, MySQL, Oracle, PostgreSQL 등 데이터베이스 방언마다 null 체크 함수의 이름과 형식이 달라 특정 데이터베이스에 종속적이게 된다는 문제가 있었다.
결국 wrapper 타입을 사용하고 DTO 생성 시 체크하자 vs 특정 데이터베이스 종속 문제를 고려하지 말고 IFNULL을 사용하자
의 두 의견으로 갈렸다.
결과적으로는 IFNULL을 사용하는 쪽으로 결정을 했다. 그 이유는
@Formula
를 쓰면 어노테이션 안에 네이티브 쿼리를 사용하는데, 네이티브 쿼리 자체가 어쩔 수 없이 특정 데이터베이스에 종속적(비록 간단한 select 쿼리는 종속적이지 않지만)이다. 때문에 특정 데이터베이스에 종속적인 문제를 고려할거라면 애초에 @Formula
를 쓰는 의미도 퇴색되지 않을까?Mouse
엔티티라든지) null 체크를 진행하는 메서드가 여러 곳에서 중복되며 계속 만들어야 하지 않을까?였다.
결국 약간의 찝찝함을 남기긴 했지만 @Formula
어노테이션의 네이티브 쿼리에 IFNULL
을 추가하는 것으로 합의를 보았다. 나중에 퇴근 시간에 제이슨에게 여쭤보니 네이티브 쿼리를 써야 하는 상황이라면 특정 데이터베이스에 종속적인 것 신경 쓰지 않고 IFNULL
을 써도 된다고 말씀해주셨다. 데이터베이스 바꿀 일 있어요?
라는 물음은 덤. 사실 그렇다. MySQL에서 안바꿀거다.
현재는 DB 제약조건이 아직 엔티티에 들어 있지 않다. 일단 초기 개발 단계에서는 schema.sql
을 작성하지 않고 JPA의 DDL 생성 기능에 의존하고 있기 때문에, 우리가 계획한대로 제약조건을 걸어줘야 한다. 때문에 내일은 각 필드마다 제약 조건을 걸어주는 작업을 진행할 예정이다.
또한 데모데이를 앞두고 프론트엔드와의 로컬 연결을 위해 CORS 허용 설정 작업도 필요하다.
ㅎㅎ 너무 재밌네요~!! 저도 회의에 참여한 것 같아요~
저도 DB의 변경, 프레임워크의 변경에 대해서까지는 미리 고민하지 않는 편이어서
IFNULL 사용에 한 표를 던졌을 것 같네요~
물론 더 나이스한 처리가 되었다면 좋았겠다는 아쉬움은 있겠지만
그래도 앞으로 나아가야하니 어느 정도 타협할 수밖에 없는 것 같아요 ㅎㅎ
오늘도 좋은 후기 너무 감사합니다~~
진짜로 진짜로 티타임 한 번 하시죠~ 케잌까지 해서 한 잔 사겠씁니다! 😃 ☕️ 🍰