포스트

2023-04-24-TIL

2023-04-24-TIL

Today I Learned

Java Record에 빌더 패턴 적용하기

record는 모든 필드를 받는 정규 생성자(canonical constructor)만 자동으로 만들어 주므로, 필드가 많으면 생성자 인자 순서를 맞추기 어렵고 일부만 채우기도 불편하다. 링크한 글은 세 가지 방법을 소개한다. record 안에 정적 중첩 Builder 클래스를 직접 작성하는 방법, Lombok의 @Builder를 record에 붙이는 방법, RecordBuilder 라이브러리로 별도 빌더 클래스와 특정 필드만 바꾼 사본을 만드는 with 메서드를 생성하는 방법이다. Baeldung 글은 record와 Lombok을 비교하면서, 불변 데이터 운반이 목적이면 언어 기능인 record로 충분하고 빌더나 가변 필드처럼 record가 주지 않는 기능이 필요할 때 Lombok을 더한다는 관점을 취한다.

  • https://howtodoinjava.com/java/basics/builder-pattern-for-java-records/
  • https://www.baeldung.com/java-record-vs-lombok

Jackson2ObjectMapperBuilderCustomizer vs MessageConverter

JSON 응답은 전역적으로 Jackson2ObjectMapperBuilderCustomizer를 통해서 LocalDateTime으로 변환되도록 직렬화 설정을 했다.

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class JsonConfig {
    private static final String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> {
            builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(dateTimeFormat)));
        };
    }
}

하지만 JSON 요청에 대해서는 이 설정이 동작하지 않았다.

최신버전의 스프링에서는 application.properties 설정 하나로 이를 관리할 수 있게 지원해준다.

1
2
3
4
spring:
        mvc:
            format:
            date-time: yyyy-MM-dd HH:mm:ss

요청에서 설정이 동작하지 않은 이유는 위 코드가 serializers(...)로 직렬화기만 등록했기 때문이다. 응답(객체를 JSON으로)은 serializer가, 요청 본문(JSON을 객체로)은 deserializer가 담당하므로 요청까지 같은 형식으로 받으려면 builder.deserializers(new LocalDateTimeDeserializer(...))도 함께 등록하거나 필드에 @JsonFormat을 붙여야 한다.

spring.mvc.format.date-time은 성격이 조금 다르다. Spring Boot 프로퍼티 문서는 이 값을 LocalDateTime, OffsetDateTime, ZonedDateTime의 포매팅에 쓰는 형식으로 설명하는데, 이는 Spring MVC의 변환 서비스 설정이라 @RequestParam, @PathVariable, @ModelAttribute처럼 문자열 파라미터를 바인딩할 때 적용된다. @RequestBody의 JSON 본문은 HttpMessageConverter가 Jackson ObjectMapper로 처리하므로 이 프로퍼티의 영향을 받지 않는다. 쿼리 파라미터와 JSON 본문의 날짜 형식은 각각 따로 맞춰야 한다는 점이 이 주제의 요점이다.

위 YAML은 들여쓰기가 어긋나 있어 date-time이 format의 하위 키로 읽히지 않는다. 의도한 설정은 다음과 같다.

1
2
3
4
spring:
  mvc:
    format:
      date-time: yyyy-MM-dd HH:mm:ss

Jackson과 ObjectMapper 설정

Spring Boot는 ObjectMapper를 자동 구성하고, 이를 바꾸는 방법을 몇 단계로 제공한다. 가장 가벼운 것은 spring.jackson.* 프로퍼티이고, 코드로 세밀하게 바꿀 때는 Jackson2ObjectMapperBuilderCustomizer 빈을 등록해 Boot가 만드는 빌더에 설정을 덧붙인다. ObjectMapper 빈을 직접 정의하면 자동 구성이 물러나므로 Boot가 넣어 주던 모듈과 기본 설정까지 직접 챙겨야 한다. 그래서 대부분은 customizer로 덧붙이는 쪽이 안전하다. 링크 중에는 Jackson의 문자 이스케이프 설정으로 JSON 응답의 XSS 문자를 처리하는 사례도 있다.

  • https://circlee7.medium.com/spring-boot-jackson-json-xss-%EC%B2%98%EB%A6%AC-fdc85a18e9f2
  • https://www.baeldung.com/spring-boot-customize-jackson-objectmapper
  • https://interconnection.tistory.com/137
  • https://github.com/eugenp/tutorials/tree/master/spring-boot-modules/spring-boot-data-2
  • https://recordsoflife.tistory.com/599

QueryDSL 페이지네이션

QueryDSL에서 페이지 조회는 offset(pageable.getOffset()), limit(pageable.getPageSize())로 내용을 가져오고, 전체 개수는 별도 count 쿼리로 구한 뒤 PageImpl로 묶는다. 예전에는 fetchResults()로 내용과 개수를 한 번에 얻었지만 QueryDSL 5.0 릴리스 노트는 group by가 여러 개이거나 having이 있는 쿼리에서 fetchResults와 fetchCount를 deprecated로 표시했고, 이후 count 쿼리를 직접 작성하는 방식이 일반화됐다. Spring Data의 PageableExecutionUtils.getPage()에 count 쿼리를 함수로 넘기면 첫 페이지의 결과가 페이지 크기보다 작은 경우처럼 개수를 계산할 수 있는 상황에서는 count 쿼리를 생략한다. Vlad Mihalcea의 글은 깊은 페이지에서 offset이 느려지는 문제와 keyset(seek) 페이지네이션을 함께 다룬다.

  • https://jessyt.tistory.com/55
  • https://data-make.tistory.com/671
  • https://wildeveloperetrain.tistory.com/135
  • https://velog.io/@recordsbeat/QueryDSL%EB%A1%9C-%EA%B2%80%EC%83%89-%ED%8E%98%EC%9D%B4%EC%A7%95-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0
  • https://vladmihalcea.com/pagination-best-practices/
  • https://jddng.tistory.com/345
  • https://samuel-mumo.medium.com/dynamic-queries-and-querydsl-jpa-support-in-spring-a1b4e233084b
  • QueryDSL 5.0.0 release notes

QueryDSL Projection

엔티티 전체가 아니라 필요한 컬럼만 DTO로 받을 때 QueryDSL은 Projections.bean(setter), Projections.fields(필드 직접 주입), Projections.constructor(생성자) 방식을 제공한다. 앞의 둘은 이름이 맞지 않으면 조용히 null이 들어가고, 생성자 방식은 인자 순서와 타입이 틀려도 런타임에야 드러난다. DTO 생성자에 @QueryProjection을 붙이면 Q 타입이 생성되어 컴파일 시점에 검사할 수 있지만, DTO가 QueryDSL에 의존하게 된다는 trade-off가 있다.

  • https://cheese10yun.github.io/querydsl-projections/

QueryDSL 동적 쿼리와 페이지네이션 묶기

rieckpil의 글은 Spring Data JPA의 QuerydslPredicateExecutor를 이용해 요청 파라미터로 만든 Predicate와 Pageable을 리포지토리에 함께 넘기는 방식으로 동적 검색과 페이지네이션을 한 번에 처리한다. 직접 JPAQueryFactory로 조립하는 방식보다 코드가 짧지만, 조인이나 프로젝션이 필요해지면 다시 커스텀 리포지토리로 내려가야 한다.

  • https://rieckpil.de/howto-dynamic-sql-querying-pagination-with-querydsl-and-spring-data-jpa/
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다