Syncope.T-*
Published 2022. 9. 29. 09:36
QueryDSL에 관하여 上篇 BackEnd/Spring
728x90
이번 글은 미괄식입니다. 천천히 커피 한잔 하시면서 읽어주세요

솔직히 우리는 질렸을지도 모른다. 혹은

매번 검색 필터의 결과에 대한 로직을 구성할 때마다 굉장한 절차적이고 Waterfall 같은 코드를 짜야 함에 있어서 지쳤을지도 모른다.

 

옛날부터 했던 코드들을 살펴보면 아래와 같다.

사실상 백엔드 개발자로서 양심을 잊었다던가, 개발을 잘 못하거나, 잘 진행하지 않거나 실패했다고 보면 된다.

대부분의 블로그 검색 코드들이나 학원에서 배운것 마냥 쓰는 코드들이나 마찬가지다 모두 실패작이다.

Legacy #1

Controller

Spring Framework MVC, iBatis 를 사용하던 시절에는 Controller부터 어지럽다.

JSP에서 Form, 혹은 Javascript에서 FormData 형식으로 ModelAttribute를 받는다. 검색을 진행하는 searchForm에서는 JSP에서 선택한 옵션이나 검색한 검색어 등 필터를 유지하려 그대로 JSP에 Model Data로 던져주어야 한다.

@RequestMapping("/management/customer/searchCustomer")
public String search_customer(ModelMap model,
                              @ModelAttribute CustomerSearchForm searchForm) throws Exception {
    List<CustomerSearchDTO> customerSearch = managementService.customerSearch(searchForm);
    searchForm.customPagination(customerSearch);
    searchForm.filter();

    model.addAttribute("bbsFlag", "search_customer");
    model.addAttribute("bbsTitle", "고객 검색");
    model.addAttribute("searchForm", searchForm);

    return "managementPlatform/customer/search_customer";
}

DAO

초기 전자정부 프레임워크나 MVC 인터페이스 내에서는 Service와 Repository 에서는 특별한 일 없으면 Data-Forward만 진행하고 (사실상 이것도 의미 없는 짓이다) 백엔드 개발자가 왜 DBA가 하는 일에 시간을 80% 이상 쏟아부어야 하는지 모를 쿼리를 작성해야 한다...

차마 나도 못 보겠어서 방문자들을 위해 Blur를 처리해드렸습니다..

Paginator

그러면 받은 결과를 Paginator에 넣어두어야 하는데 iBatis나 MyBatis에서는 Pageable 관련 프레임워크가 존재하니 그걸 써도 상관은 없긴 하다. 그러나 완전 Legacy는 직접 구현을 하는 것이다.

@Getter
@Setter
@NoArgsConstructor
public class Pagination {
    private Integer pagesPerBlock = 5; // 페이지버튼이 몇 개씩 보여질 건지
    private Integer postsPerPage = 10; // 페이지당 게시글 수
    private Long totalPostCount = 0L; // 총 게시글 수
    private Integer totalLastPageNum = 1; // 총 페이지 수

    // 아래 이하 페이지네이션 정보
    private Boolean isPrevExist;
    private Boolean isNextExist;
    private Integer blockLastPageNum;
    private Integer blockFirstPageNum;
    private Integer currentPageNum = 1;
    private Integer pageList;

    public Pagination(Integer pagesPerBlock, Integer postsPerPage, Long totalPostCount) {
        if(pagesPerBlock > 0){
            this.pagesPerBlock = pagesPerBlock;
        }

        this.postsPerPage = postsPerPage;
        this.totalPostCount = totalPostCount;

        this.setTotalLastPageNum();
    }

    public void customPagination(Long totalPostCount){
        this.totalPostCount = totalPostCount;
        this.setTotalLastPageNum();
    }

    private void setTotalLastPageNum() {
        // 총 게시글 수를 기준으로 한 마지막 페이지 번호 계산
        // totalPostCount 가 0인 경우 1페이지로 끝냄
        if(totalPostCount == 0) {
            this.totalLastPageNum = 1;
        } else {
            this.totalLastPageNum = (int) (Math.ceil((double)totalPostCount / postsPerPage));
        }
    }

    public void paginate(Integer currentPageNum, Boolean isFixed) {

        if(pagesPerBlock % 2 == 0 && !isFixed) {
            throw new IllegalStateException("getElasticBlock: pagesPerBlock은 홀수만 가능합니다.");
        }
        if(currentPageNum > totalLastPageNum) {
            currentPageNum = totalLastPageNum;
        }

        // 블럭의 첫번째, 마지막 페이지 번호 계산
        int blockLastPageNum = totalLastPageNum;
        int blockFirstPageNum = 1;

        // 글이 없는 경우, 1페이지 반환.
        if(isFixed) {

            Integer mod = totalLastPageNum % pagesPerBlock;
            if(totalLastPageNum - mod >= currentPageNum) {
                blockLastPageNum = (int) (Math.ceil((float)currentPageNum / pagesPerBlock) * pagesPerBlock);
                blockFirstPageNum = blockLastPageNum - (pagesPerBlock - 1);
            } else {
                blockFirstPageNum = (int) (Math.ceil((float)currentPageNum / pagesPerBlock) * pagesPerBlock)
                        - (pagesPerBlock - 1);
            }
        } else {
            Integer mid = pagesPerBlock / 2;

            if(currentPageNum <= pagesPerBlock) {
                blockLastPageNum = pagesPerBlock;
            } else if(currentPageNum < totalLastPageNum - mid) {
                blockLastPageNum = currentPageNum + mid;
            }

            blockFirstPageNum = blockLastPageNum - (pagesPerBlock - 1);

            if(totalLastPageNum < pagesPerBlock) {
                blockLastPageNum = totalLastPageNum;
                blockFirstPageNum = 1;
            }
        }

        // 페이지 번호 할당
        List<Integer> pageList = new ArrayList<>();
        for(int i = 0, val = blockFirstPageNum; val <= blockLastPageNum; i++, val++) {
            pageList.add(i, val);
        }

        this.isPrevExist = (int)currentPageNum > (int)pagesPerBlock;
        this.isNextExist = blockLastPageNum != 1 && (int) blockLastPageNum != (int) totalLastPageNum;
        this.blockLastPageNum = blockLastPageNum;
        this.blockFirstPageNum = blockFirstPageNum;
        this.currentPageNum = currentPageNum;
    }

    public <T> List<T> getPage(List<T> sourceList){
        if(this.postsPerPage <= 0 || this.currentPageNum <= 0) {
            this.postsPerPage = 10;
            this.currentPageNum = 1;
        }

        int fromIndex = (this.currentPageNum - 1) * this.postsPerPage;
        if(sourceList == null || sourceList.size() <= fromIndex){
            return Collections.emptyList();
        }
        return sourceList.subList(fromIndex, Math.min(fromIndex + this.postsPerPage, sourceList.size()));
    }
}

위 부분을 페이지 네이션에 쓰일 Form에다 상속하여 쓰면 된다.

@Getter
@Setter
@Slf4j
public class CustomerSearchForm extends Pagination {
    private String pharmacyIdx;
    private String searchArea = "1", searchKeyword;
    private String orderColumn, orderDirection;
.
..
...
}

View

그다음 JSP에서 필터에 관련된 Element들 마다 Form에 관한 Data를 주입하면 되는데 한 곳만 발췌하면 이러하다

<input type="text" name="searchKeyword" id="searchKeyword" autocomplete="off"
   value="${searchForm.searchKeyword}"
   <c:choose>
       <c:when test="${searchForm.searchArea eq '1'}">
           placeholder="이름 검색"
       </c:when>
       <c:when test="${searchForm.searchArea eq '2'}">
           placeholder="가입처 검색"
       </c:when>
   </c:choose>/>

검색하기까지 과정이 굉장히 복잡하다...
혹여나 Javascript에서 더 처리해주어야 할 정보가 있다고 생각해 보아라. 더욱 어지럽다.
다음으로 실패한 Legacy 2를 알아보자

 

Legacy #2

실패작 2번의 후보는 Spring boot로 진행했었는데, 그래도 1번 후보보다 보는 게 좀 그나마 편안할 것 같다고 사료된다.

Spring boot를 시작할 때는 Boot 4 버전이었던 것 같다.

이때의 붐은 XML에 붙어있던 설정이나 빈에 관한 내용들이 Annotation 혹은 이미 존재하던 Bean을 CRUD 하는 방식으로 하는 것이 유행이었어서 최대한 MVC 패턴 내에서 코드 분리 화가 이루어져 결과적으로는 더러워 보이면 안 됐었던 걸로 기억한다. (필자의 주관적인 생각입니다)

Controller

예시로 공지사항 페이지를 들고 왔는데 그래도 Restful 하게 짜여 있는 애긴 하다.

컨트롤러만 봐도 슬림해진 것 같다. 내 뱃살도 이랬으면 좋겠다.

@RestController
@RequestMapping("/api/notice")
@AllArgsConstructor
public class NoticeController {

    private NoticeService noticeService;

    @GetMapping
    public ResponseEntity list(@PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable,
                               @RequestParam(value = "isEnabled", required = false) String isEnabled,
                             @RequestParam(value = "startDate", required = false) LocalDateTime startDate,
                             @RequestParam(value = "endDate", required = false) LocalDateTime endDate,
                             @RequestParam(value = "search", required = false) String search) {
        return ResponseEntity.ok(noticeService.findAll(pageable, isEnabled, startDate, endDate, search));
    }

    @GetMapping("/{id}")
    public ResponseEntity get(@PathVariable("id") Long id){
        return ResponseEntity.ok(noticeService.get(id));
    }


    @DeleteMapping
    @ManagerPermission
    public ResponseEntity delete(@RequestBody IdRequest idRequest) {
        return ResponseEntity.ok(noticeService.delete(idRequest));
    }

    @PostMapping
    @ManagerPermission
    public ResponseEntity create(@ModelAttribute PostNotice dto) {
        return ResponseEntity.ok(noticeService.create(dto));
    }

    @PatchMapping
    @ManagerPermission
    public ResponseEntity patch(@Validated @ModelAttribute PatchNotice dto){
        return ResponseEntity.ok(noticeService.patch(dto));
    }

    @GetMapping("/version")
    public ResponseEntity version() {
        return ResponseEntity.ok(noticeService.version());
    }
}

Service

서비스도 기존에 서비스해야 할 코드들을 엄청 많이 작성하다 보니깐 (공통 Component로 코드를 빼도 엄청난 건 사실이다) 서비스도 다이어트하는 게 좋아 보였다. 그래서 람다를 엄청 썼었고, Repository 쪽으로 분리시키는 건 여전했다.

 

근데 이제 보이는 건데 rollbackFor를 Exception으로 해놨네? 과거의 본인은 엄청 귀찮은 녀석이었던 것 같다. 여전히 열심 히지 않은 걸까

@Slf4j
@Service
@Transactional(rollbackFor = Exception.class)
@AllArgsConstructor
public class NoticeService {

    private final NoticeRepository noticeRepository;
    private final NoticePostMapper noticePostMapper = Mappers.getMapper(NoticePostMapper.class);
    private final NoticeResponseMapper noticeResponseMapper = Mappers.getMapper(NoticeResponseMapper.class);
    private final VersionService versionService;

    private final Uploader uploader;


    public Object findAll(Pageable pageable, String isEnabled, LocalDateTime startDate, LocalDateTime endDate, String search) {
        if(startDate != null && endDate != null && search != null){
            Long totalSize = 0L;
            List<FindNotice> list = noticeRepository
                    .findNotices(pageable.getOffset(), pageable.getPageSize(), isEnabled, search, startDate, endDate, totalSize);
            if(list.size() > 0){
                totalSize = list.get(0).getTotalCount();
            }
            return new PageImpl<>(list.stream()
                    .map(NoticeResponse::new)
                    .collect(Collectors.toList()), pageable, totalSize);
        }else{
            return noticeRepository.findNotices(pageable);
        }
    }

    public Notice get(Long id) {
        return noticeRepository.findById(id).orElse(null);
    }

    public Notice find(Long id, Boolean filter) {
        if(filter){
            return noticeRepository.filteredById(id).orElse(null);
        }
        return noticeRepository.findById(id).orElse(null);
    }

    public NoticeResponse create(PostNotice dto) {
        Notice notice = noticePostMapper.toEntity(dto);
        if(dto.getImages() != null){
            List<Storage> images = new ArrayList<>();
            List<MultipartFile> dtoImages = dto.getImages();
            for (int i = 0; i < dtoImages.size(); i++) {
                MultipartFile file = dtoImages.get(i);
                Storage storage = uploader.upload(file, UploadDirectory.NOTICE);
                storage.setOrder(i+1);
                storage.setDownloadUrl(Constants.getImageUrl(UploadDirectory.NOTICE + "/" + storage.getSavedFileName()));
                images.add(storage);
            }
            notice.setImages(images);
        }
        noticeRepository.saveAndFlush(notice);

        NoticeResponse response = noticeResponseMapper.toDto(notice);
        response.setImages(notice.getImages());

        // 공지사항 버전 업데이트
        versionService.patch(VersionType.NOTICE);
        return response;
    }

    public NoticeResponse patch(PatchNotice dto) {
        Notice notice = noticeRepository.findById(dto.getId()).orElse(null);
        if(notice == null)
            throw new RestRuntimeException(ErrorCode.DATA_NOT_FOUND, "공지사항을 찾을 수 없습니다");

        //log.info("Patch JSON : {}", Constants.getGson().toJson(dto));

        notice.setTitle(Constants.selectNotNull(dto.getTitle(), notice.getTitle()));
        notice.setContent(Constants.selectNotNull(dto.getContent(), notice.getContent()));
        notice.setEnabled(Constants.selectNotNull(dto.getEnabled(), notice.getEnabled()));
        notice.setStartDate(Constants.selectNotNull(dto.getStartDate(), notice.getStartDate()));
        notice.setEndDate(Constants.selectNotNull(dto.getEndDate(), notice.getEndDate()));

        // 삭제할건 삭제하고
        if(dto.getDeleteFileName() != null){
            Iterator<Storage> iter = notice.getImages().iterator();
            while(iter.hasNext()){
                Storage image = iter.next();
                //log.info("image.getSavedFileName() : {}", image.getSavedFileName());
                //log.info("dto.getDeleteFileName().contains(image.getSavedFileName()) : {} ", dto.getDeleteFileName().contains(image.getSavedFileName()));
                if(dto.getDeleteFileName().contains(image.getSavedFileName())){
                    uploader.remove(UploadDirectory.NOTICE, image);
                    iter.remove();
                }
            }
        }

        if(dto.getImages() != null){
            List<MultipartFile> dtoImages = dto.getImages();
            for (MultipartFile file : dtoImages) {
                Storage storage = uploader.upload(file, UploadDirectory.NOTICE);
                storage.setDownloadUrl(Constants.getImageUrl(UploadDirectory.NOTICE + "/" + storage.getSavedFileName()));
                notice.getImages().add(storage);
            }
        }

        if(notice.getImages().size() > 0){
            AtomicInteger count = new AtomicInteger(1);
            notice.getImages().stream().sorted(Comparator.comparing(Storage::getSavedDateTime)).forEach(x->x.setOrder(count.getAndIncrement()));
        }

        noticeRepository.saveAndFlush(notice);

        NoticeResponse response = noticeResponseMapper.toDto(notice);
        response.setImages(notice.getImages());

        // 공지사항 버전 업데이트
        versionService.patch(VersionType.NOTICE);
        return response;
    }

    public VersionResponse version() {
        return versionService.getVersion(VersionType.NOTICE);
    }

    public Set<Long> delete(IdRequest idRequest){
        Set<Long> deletes = new HashSet<>();
        if(idRequest.getListId() != null){
            deletes.addAll(idRequest.getListId());
        }
        if(idRequest.getId() != null){
            deletes.add(idRequest.getId());
        }

        noticeRepository.deleteAllByIdInQuery(deletes);

        return deletes;
    }
}

여전히 Service가 긴 건 길구나라고 느낀다. 다른 말로 표현하면 더러운 건 더러운 거다.

 

Repository

이번 레포지토리에서는 Procedure를 호출하는 쪽으로 작업을 했나 보다. 그래서 후다닥 DB에 Procedure를 가져왔다.

@Repository
public interface NoticeRepository extends JpaRepository<Notice, Long> {

    @Procedure(procedureName = "`lx_gokseong`.`FIND_NOTICE_ALL`")
    List<FindNotice> findNotices(@Param("PAGE_OFFSET") Long offset,
                                 @Param("PAGE_SIZE") Integer size,
                                 @Param("IS_ENABLED") String isEnabled,
                                 @Param("SEARCH_TEXT") String search,
                                 @Param("START_DATE") LocalDateTime startDate,
                                 @Param("END_DATE") LocalDateTime endDate,
                                 @Param("RECORD_COUNT") Long totalSize);

    @Query(value = "SELECT * FROM notice WHERE (NOW() BETWEEN startDate AND endDate) AND (enabled IS TRUE) ORDER BY createdDate DESC",
            countQuery = "SELECT COUNT(*) FROM notice WHERE (NOW() BETWEEN startDate AND endDate) AND (enabled IS TRUE)",
            nativeQuery = true)
    Page<Notice> findNotices(Pageable pageable);

    @Query(value = "SELECT * FROM notice WHERE (NOW() BETWEEN startDate AND endDate) AND (enabled IS TRUE) AND id :id", nativeQuery = true)
    Optional<Notice> filteredById(@Param("id") Long id);

    @Modifying
    @Query("DELETE FROM Notice n WHERE n.id IN :deletes")
    void deleteAllByIdInQuery(@Param("deletes") Set<Long> deletes);
}
DROP PROCEDURE IF EXISTS `FIND_NOTICE_ALL`;
DELIMITER $$
CREATE PROCEDURE `FIND_NOTICE_ALL`(
    IN PAGE_OFFSET BIGINT,
    IN PAGE_SIZE INTEGER,
    IN IS_ENABLED VARCHAR(24),
    IN SEARCH_TEXT VARCHAR(100),
    IN START_DATE VARCHAR(24),
    IN END_DATE VARCHAR(24),
    OUT RECORD_COUNT BIGINT
)
BEGIN
    SELECT COUNT(1) INTO RECORD_COUNT
    FROM notice n
    WHERE ((n.startDate BETWEEN START_DATE AND END_DATE) OR (n.endDate BETWEEN START_DATE AND END_DATE))
      AND (IF(IS_ENABLED = 'ALL', n.enabled IS NOT NULL, IF(IS_ENABLED = 'TRUE', n.enabled IS TRUE, n.enabled IS FALSE)))
      AND CONCAT(n.title, '\n', n.content) LIKE CONCAT('%', SEARCH_TEXT, '%');


    SELECT n.id,
           n.title,
           n.content,
           n.images,
           n.enabled,
           n.startDate,
           n.endDate,
           n.createdDate,
           n.lastModifiedDate,
           RECORD_COUNT AS totalCount
    FROM notice n
    WHERE ((n.startDate BETWEEN START_DATE AND END_DATE) OR (n.endDate BETWEEN START_DATE AND END_DATE))
      AND (IF(IS_ENABLED = 'ALL', n.enabled IS NOT NULL, IF(IS_ENABLED = 'TRUE', n.enabled IS TRUE, n.enabled IS FALSE)))
      AND CONCAT(n.title, '\n', n.content) LIKE CONCAT('%', SEARCH_TEXT, '%')
		ORDER BY n.createdDate DESC
    LIMIT PAGE_OFFSET, PAGE_SIZE;
END $$
DELIMITER ;

기억나는 것으로는, TotalCount를 쓰기 위해서 저 RECORD_COUNT SELECT 하는 걸 어쩔 수 없이 진행해야 하는데 저거 때문에 RECORD_COUNT로 쓰일 Procedure의 Paramter를 손해 보면서 까지 넘겨야 하는 것이 마음에 들지 않았던 걸로 기억한다.

 

페이지 네이션은 Page<T>로 받으면 Response가 페이지네이션 정보로 나간다.

언뜻 보면 2번의 실패작도 나쁘지 않다.

라고 생각하는 당신은 QueryDSL을 만나지 못했기 때문이다.

 

下編에서 계속...

profile

Syncope.T-*

@Syncope

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

profile on loading

Loading...