
카카오 알림톡 프로젝트를 진행하면서 템플릿 관리 API를 만들때 JPA Speicification을 활용하여 기간 검색 조건과 키워드 조건을 묶어서 개발하게 되었다.
검색조건을 만들고, 기간과 키워드 검색을 묶어서 만드는 로직을 만들면서 배운것들이 많기에 TIL 포스팅을 하면서 정리하고자 한다.
기간별로 등록된 템플릿을 조회할 수 있도록 하기
기간별로 키워드 검색하여 등록된 템플릿을 조회할 수 있도록 하기
기간을 둘다 넣지 않을 경우에는 전체 기간을 디폴트로 조회할 수 있게 하기
페이지네이션해주기
페이지네이션시 최신 등록된 템플릿이 가장 위에서 조회될 수 있게 하기
NotificationController.java@GetMapping("/templates") public ResponseDto<?> searchTemplate( NotificationSearchCriteria criteria, @PageableDefault(sort = "id", direction = DESC) Pageable pageable) { return new ResponseDto<>(service.searchEntityTemplate(criteria, pageable)); }
GetMapping으로 지정해준다.
파라미터는 두개를 전달한다.
NotificationSearchCriteria 라는 DTO클래스로 Request를 받는다.
Pageable 인스턴스로 페이지네이션을 해준다.
@PageableDefault 어노테이션으로 간단하게 sorting을 해줄 수 있다.
여기서는 id를 기준으로 DESC정렬을 해주었다.
NotificationSearchCriteria.java@Getter @Setter @NoArgsConstructor public class NotificationSearchCriteria { private LocalDateTime startDate; private LocalDateTime endDate; private String keyword; public void setStartDate(String startDate) { this.startDate = startDateTime(toLocalDate(normalizer(startDate))); } public void setEndDate(String endDate) { this.endDate = endDateTime(toLocalDate(normalizer(endDate))); } }
컨트롤러를 통해 Client의 request을 받아내는 DTO 클래스다.
컨트롤러를 통해 들어오는 인자는 모두 String값이므로, setter를 override하여 LocalDateTime으로 형변환 시켜주었다.
참고로 normalizer, toLocalDate, startDateTime은 회사에서 사용하는 util클래스에 있는 static 메소드다.
이 클래스들은 매핑과 정규화를 시켜준다.
NotificationServiceImpl.java
@Override public TemplatePage searchEntityTemplate(NotificationSearchCriteria criteria, Pageable pageable) { return new TemplatePage(manager.searchTemplates(criteria, pageable)); }
service 계층은 manager 계층에서 만들어진 로직을 전달할 뿐이다.
여기서 중요한건 TemplatePage객체로 반환한다는 것인데, 이 구조가 매우 중요하다.
manager.searchTemplates(criteria, pageable)은 Page타입을 리턴한다.
TemplatePage는 Page타입을 인자로 받아 한번 더 감싸주는 역할을 한다.
이는, RestDoc을 만들 때 Pagination부분을 간결화하여 문서작성을 용이하게하고, Client에게 필요한 데이터만 전달해주기 위함이다. ⭐️
TemplatePage.javapublic class TemplatePage extends ResponsePage<TemplateDto> { public TemplatePage(Page<TemplateDto> page) { super(page); } }
ResponsePage.java@Data @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class ResponsePage<T> { private List<T> content; private int page; private int size; private long total; public ResponsePage(Page<T> page) { this.content = page.getContent(); this.page = page.getNumber() + 1; this.size = page.getSize(); this.total = page.getTotalElements(); } }
TemplatePage가 상속한 추상클래스다.
Pageable 인터페이스를 간결화하기 위함이다.
가공된 데이터는 content에 담기고, pageable 필드들은 여기서 가공되어 리턴된다.
TemplateSpec.javastatic Specification<NotificationTemplate> search(NotificationSearchCriteria criteria) { Specification<NotificationTemplate> spec = (root, query, builder) -> { List<Predicate> predicates = new ArrayList<>(); //startDate만 있을 경우 if (!isEmpty(criteria.getStartDate()) && isEmpty(criteria.getEndDate())) { predicates.add(builder.greaterThanOrEqualTo(root.get("createdAt"), criteria.getStartDate())); //endDate만 있을 경우 } else if (isEmpty(criteria.getStartDate()) && !isEmpty(criteria.getEndDate())) { predicates.add(builder.lessThanOrEqualTo(root.get("createdAt"), criteria.getEndDate())); //둘다 있을 경우 } else if (!isEmpty(criteria.getStartDate()) && !isEmpty(criteria.getEndDate())) { predicates.add(builder.between(root.get("createdAt"), criteria.getStartDate(), criteria.getEndDate() )); //둘다 없을 경우 } else if (isEmpty(criteria.getStartDate()) && isEmpty(criteria.getEndDate())) { predicates.add(builder.lessThanOrEqualTo(root.get("createdAt"), LocalDateTime.now())); } if (!isEmpty(criteria.getKeyword())) { predicates.add(builder.or( builder.like(root.get("subject"), "%" + criteria.getKeyword() + "%"), builder.like(root.get("content"), "%" + criteria.getKeyword() + "%") )); } return builder.and(predicates.toArray(new Predicate[0])); }; return spec; }
핵심 비지니스 로직이다. 두둥 ⚙️
해당 인터페이스의 위치는 관례상 Repository에 위치한다.
왜냐하면 JpaSpecificationExecutor를 implements 받은 Repository 와 같은 위치에 있는것이 자연스럽고, 의미상 맞기 때문이다.
greaterThanOrEqualTo와 lessThanOrEqualTo를 사용하면 이상, 이하가 연산이 되는데, 이는 날짜연산에도 동일하게 작동한다. specification만세
분기는 4가지로 처리했다.
startDate만 있을 경우 -> start date ~ 오늘까지 조회endDate만 있을 경우 -> ~ end date 조회between을 사용하여 처리했다.keyword조회를 더하였다.keyword조회는 요구조건이 제목과 내용에서 조건을 조회해달라고 하였으므로, like를 사용하였고,or로 묶었다. TasonTemplateRepository.java
public interface TasonTemplateRepository extends JpaRepository<NotificationTemplate, Long>, JpaSpecificationExecutor<NotificationTemplate> { Optional<NotificationTemplate> findByTemplateCode(String templateCode); @Query("select count(*) from NotificationTemplate n where n.templateCode = :code") int checkDuplicateCode(@Param("code") String code); @Override Page<NotificationTemplate> findAll(Specification<NotificationTemplate> spec, Pageable pageable); int deleteByTemplateCode(String templateCode); }
참고로 Repository는 interface로 구현하는것이 좋다.
인터페이스로 구현 후, 인터페이스끼리는 다중상속이 되므로, JpaRepository와 JpaSpecificationExecutor를 상속받았다.
checkDuplicationCode메소드는 jpql을 사용하여 중복체크를 하기위해 사용하였다.
이번 프로젝트를 통해서 배우는 것이 참 많다.
Specification과 Pagination을 JPA를 통해 하면서 느끼는 것은 JPA가 정말 혁신적이다라는 것이다. 검색조건과 페이징을 하기위해 sql 노가다를 하며 끙끙거리던 예전의 내모습이 스쳐간다.
이 포스팅을 읽는 분들이 해당 예제를 통해 JPA의 specification이 조금이라도 더 잘 이해가 되셨길 바래본다.
그럼 이만 ! ✋