항해99 - 실전 프로젝트 07(API 제작 - QueryDsl)

2021. 12. 17. 01:03항해99/실전프로젝트

728x90

저번 포스팅에서는 JPA의 연관관계를 통해서 DTO를 만들어 API를 제작하였다.

 

하지만 이 방식은 한 API에 수많은 Query문이 필요하게되고

궁극적으로는 엄청난 시간이 소모된다.

 

그렇기 때문에 결론적으로는 복잡한 DTO를 만들기 위해서는 Join을 통해서

한번에 데이터를 가져오는 SQL 문이 필요하게 되고

 

이 SQL문을 쉽고 간편하게 쓰기 위해서 이번 프로젝트에서는

QueryDSL 라이브러리를 추가하였다.

 

 


예시 API


요청 메시지

  • sort : 정렬 키워드
    • like 좋아요순
    • star 별관측지수순
    • latest 최신순
  • cityName : 지역 검색
  • offset : 페이지 offset

 

커뮤니티 게시판의 글 목록을 불러오는 api이다.

 

정렬은 글의 좋아요순, 별관측지수순, 최신순으로 정렬이 가능하고

 

기존에는 전국의 모든 지역의 추천 장소 글을 가져오지만

지역 명을 넣게 되면 그 지역의 글들만 가져오게 된다.

 

페이징기능이 있기에 다음 페이지를 이동하고 싶으면

offset에 다음 페이지를 넣으면 된다.

응답 메시지

  • code : 응답 메시지 상태 코드
    •  성공 : 200
    •  실패 : 500
  • msg : 응답 메시지의 상태 메시지
  • data : 응답 데이터
    • currentPage : 현재 페이지의 층 번호
    • maxPage : 최대 페이지의 층 번호
    • dataSize : 현재 데이터 사이즈
    • dataList : 커뮤니티 리스트 목록

 

기존의 http 통신에서 백엔드에 에러가 난다면 http header에

statusCode 500번을 실어서 보낸다.

 

하지만 이 프로젝트에서는 좀 더 명확히 상태코드를 볼 수 있도록

body에 이 코드를 같이 넣어주었다.

 

data 에서도 바로 리스트를 반환하는 것 이 아닌

이 리스트의 메타 정보를 추가하였다.

 

페이징 기능이 있기에 데이터의 페이지 번호, 최대 페이지 번호,

글 목록의 개수를 추가 해주었다.

 


DTO


@Getter
@Setter
@NoArgsConstructor
@ToString
public class CommunityDtoCustom {
    private Long id;
    private String writer;
    private String title;
    private String cityName;
    private String address;
    private String img;
    private String contents;
    private String modifiedAt;
    private Long likeCount;
    private Boolean likeCheck;
    private Boolean bookmarkCheck;

    @QueryProjection
    public CommunityDtoCustom(Long id, String writer, String title, String cityName, String address, String img, String contents, LocalDateTime modifiedAt, Long likeCount, Boolean likeCheck, Boolean bookmarkCheck) {
        String[] sliceAddress = address.split(" ");
        String customAddress = "";

        for (int i = 0; i < sliceAddress.length; i++) {
            if(i>1)
                break;
            customAddress += sliceAddress[i] + " ";
        }

        String contentResult = contents.replaceAll("<(/)?([a-zA-Z]*)(\\s[a-zA-Z]*=[^>]*)?(\\s)*(/)?>", "");

        this.id = id;
        this.writer = writer;
        this.title = title;
        this.cityName = cityName;
        this.address = customAddress;
        this.img = img;
        this.contents = contentResult;
        this.modifiedAt = Timestamped.TimeToString(modifiedAt, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
        this.likeCount = likeCount;
        this.likeCheck = likeCheck;
        this.bookmarkCheck = bookmarkCheck;
    }
}

일단 API 설계에 맞추어서 DTO를 만들어 준다.

 

생성자의 @QueryProjection은 생성자를 기준으로 QClass를 만들어준다.

이 QClass를 통해서 QueryDsl를 좀 더 잘 사용할 수 있다.

String[] sliceAddress = address.split(" ");
String customAddress = "";

for (int i = 0; i < sliceAddress.length; i++) {
  if(i>1)
  	break;
  customAddress += sliceAddress[i] + " ";
}

기존 board 테이블에서의 address 필드 정보는 "경기도 연천군 전곡읍 선사로 76"

요런식으로 자세히 써있는 경우가 있다.

 

하지만 데이터를 보낼 때는 

 

 

이렇게 잘라서 보내기 위해서 위의 코드를 사용하였다.

 

String contentResult = contents.replaceAll("<(/)?([a-zA-Z]*)(\\s[a-zA-Z]*=[^>]*)?(\\s)*(/)?>", "");

 

기존 board 테이블에서의 content 필드 정보에서 이미지는 html tag 형식으로 저장 되어있다.

상세페이지에서는 이 이미지 태그를 이용하여 이미지를 보여준다.

 

하지만 카드 본문에서는 위 이미지태그를 보내면 안되기 때문에 정규식을 통해서 img 태그를 모두 삭제했다.

 

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PageResponseDto<T> {
    private int currentPage;
    private int maxPage;
    private int dataSize;
    private T dataList;
}

페이징 기능 위한 DTO 이다.

현재 페이지, 최대 페이지, 데이터 크기, 실 데이터는 제네릭을 통해서 어떤 DTO든 넣을 수 있다.

 

※ 왜 DTO에서 텍스트 전처리를 하였는가?

 

서비스 계층에서는 업무를 담당하는 부분에 대한 로직"을 해야한다고 생각한다.

 

예를 들어 "통장에 돈을 입금" 하라는 API가 요구된다면 서비스 계층에서는 

입출금 전표와 실제 금액 액수를 보고 예외처리 등을 하거나 자산 DB에 돈을 넣는 작업을 한다.

 

하지만 위의 경우에는 단순한 텍스트 VIEW에 관한 수정사항이라 보다 간편하게 DTO 생성자를 통해서

전처리 하였다.

 


Controller


    @GetMapping("/community/list")
    public ResponseDto getBoard(@RequestParam(defaultValue = "star") String sort,
                                @RequestParam(defaultValue = "all") String cityName,
                                @AuthenticationPrincipal UserDetailsImpl userDetails,
                                @RequestParam(defaultValue = "1", required = false)int offset){
        Page<CommunityDtoCustom> communityDtoList;

        communityDtoList = boardService.getBoardList(userDetails, sort, cityName, offset-1);

        PageResponseDto pageResponseDto = new PageResponseDto(communityDtoList.getNumber()+1,
                communityDtoList.getTotalPages(), communityDtoList.getContent().size(), communityDtoList.getContent());

        return new ResponseDto(200L, "성공", pageResponseDto);
    }

기본 정렬은 별관측지수순, 기본 지역은 전국, 기본 페이지는 1 페이지를 기준으로 하였다.

 

가져온 요청메시지와 함께 서비스 계층으로 보내어 데이터를 가져오도록 한다.

 


Service


    public Page<CommunityDtoCustom> getBoardList(
                     UserDetailsImpl userDetails, String sort, String cityName, int offset) {
        PageRequest page = PageRequest.of(offset, 12);

        return boardRepository.findCommunityList(userDetails, page, cityName, sort);
    }

서비스 계층에서는 페이징을 위해서 PageRequest 객체를 만들어서 리포지토리계층으로 보낸다.

 


Repository


 

 

    @Override
    public Page<CommunityDtoCustom> findCommunityList(UserDetailsImpl userDetails, PageRequest pageRequest, String cityName, String sort) {
        List<CommunityDtoCustom> result;
        result = queryFactory
                .select(
                        new QCommunityDtoCustom(
                                board.id,
                                QUser.user.nickname,
                                board.title,
                                location.cityName,
                                board.address,
                                board.img,
                                board.content,
                                board.modifiedAt,
                                boardLikeCount(),
                                (userDetails == null) ? setFalse() : distinguishLikeExistUser(userDetails.getUser()),
                                (userDetails == null) ? setFalse() : distinguishBookmarkExistUser(userDetails.getUser())
                        )
                )
                .from(board)
                .join(board.user, QUser.user)
                .join(board.location, location)
                .join(location.star, star)
                .orderBy(getOrderBy(sort))
                .where(likeAddressOrTitle(cityName))
                .offset(pageRequest.getOffset())
                .limit(pageRequest.getPageSize())
                .fetch();

        JPAQuery<Board> countQuery = countCommunityDtoCustomContainingCityListQuery(cityName);


        return PageableExecutionUtils.getPage(result, pageRequest, countQuery::fetchCount);
    }​

대망의 querydsl를 사용하는 repository 계층이다.

querydsl에서는 DTO에 맞추어서 데이터를 조회할 수 있는데 QClass를 이용하면 가능하다.

 

앞서 DTO에 QueryProjection을 삽입한 이유가 여기에 있다.

select 함수 내에 QClass 객체를 생성하면서 조회가 가능하다.

 

 

로그인 상태에서의 게시글 리스트, 자기가 좋아요를 누른 카드에는 이처럼 빨강색으로 하트가 채워져있다.

 

 

비로그인 상태의 게시글 리스트 모든 하트와 북마크의 표시가 되어있지 않다.

 

(userDetails == null) ? setFalse() : distinguishLikeExistUser(userDetails.getUser()),
(userDetails == null) ? setFalse() : distinguishBookmarkExistUser(userDetails.getUser())

사진에서 처럼 로그인 했을 때랑 비로그인 상태를 확인해서 값을 반환해주어야한다. 

만약 로그인 상태가 아닐때는

    private BooleanExpression setFalse() {
        return Expressions.asBoolean(false);
    }

만약 로그인 상태라면

    private BooleanExpression distinguishLikeExistUser(User user) {
        return JPAExpressions
                .select(like)
                .from(like)
                .where(userIdEqLikeUserId(user).and(boardIdEqLikeBoardId()))
                .exists();
    }
    private BooleanExpression distinguishBookmarkExistUser(User user) {
        return JPAExpressions
                .select(bookmark)
                .from(bookmark)
                .where(userIdEqBookmarkUserId(user).and(boardIdEqBookmarkBoardId()))
                .exists();
    }
    private BooleanExpression userIdEqLikeUserId(User user) {
        return like.user.id.eq(user.getId());
    }

    private BooleanExpression boardIdEqLikeBoardId() {
        return like.board.id.eq(board.id);
    }

위 처럼 서브쿼리를 함수로 잘라서 삼항연산자로 서브쿼리를 넣어주었다.

 

.orderBy(getOrderBy(sort))
.where(likeAddressOrTitle(cityName))

추가적으로 정렬방식과 검색 where 조건문을 살펴보자

 

private OrderSpecifier<? extends Serializable> getOrderBy(String sort) {
    if(sort.equals("star"))
        return star.starGazing.desc();
    else if(sort.equals("like"))
        return board.likeCount.desc();
    else if(sort.equals("latest"))
        return board.modifiedAt.desc();
    else
        return null;
}

정렬에서는 if 문을 통해서 적절한 desc() 함수를 가져오게 하고

 

private BooleanExpression likeAddressOrTitle(String cityName) {
    BooleanExpression result = 
    	cityName.equals("all") ? 
        	null : board.address.contains(cityName).or(board.title.contains(cityName));
    
    return result;
}

조건문 또한 삼항연산자를 통해서 "all" 이라면 조건을 없애고 citiName이 있다면 contains 함수를 통해서

데이터를 찾아온다.

 

.offset(pageRequest.getOffset())
.limit(pageRequest.getPageSize())

페이징 또한 pageRequest 객체에서 가져온 데이터를 토대로 데이터를 자르고

 

JPAQuery<Board> countQuery = countCommunityDtoCustomContainingCityListQuery(cityName);
private JPAQuery<Board> countCommunityDtoCustomContainingCityListQuery(String cityName) {
    return queryFactory
            .selectFrom(board)
            .join(board.user, user)
            .join(board.location, location)
            .join(location.star, star)
            .orderBy(star.starGazing.desc())
            .where(board.address.contains(cityName));
}

카운터 쿼리를 통해 총 게시글의 수를 가져온다.

@Override
public int getTotalPages() {
   return getSize() == 0 ? 1 : (int) Math.ceil((double) total / (double) getSize());
}

이 게시글 수는 Page 객체의 구현체에서 total 페이지를 사이즈를 계산하는데 사용한다.