개별 웹툰 페이지를 조회하는 API 를 구현해보겠다.
위와 같이 웹툰 개별 페이지로 들어가면 웹툰의 정보와 전체 회차 목록을 볼 수 있다. 이 중 빨간 영역의 전체 회차 목록을 불러오는 API 를 개발하자.
- 최신순, 오래된 순 정렬
- PageSize = 30
- 썸네일, 회차 제목, 별점, 업로드 일자가 필요
구현에 앞서 필요한 기본 개념을 짚고 간다.
Querydsl 을 사용하는 이유
QueryDSL은 하이버네이트 쿼리 언어(HQL: Hibernate Query Language)의 쿼리를 타입에 안전하게 생성 및 관리해주는 프레임워크이다. QueryDSL은 정적 타입을 이용하여 SQL과 같은 쿼리를 생성할 수 있게 해 준다.
QueryDSL 을 이용하면 Spring Data JPA 만으로는 구현하기 힘들었던 복잡한 쿼리나 동적 쿼리를 간편하게 구현할 수 있다.
QueryDSL이 등장하기 이전에는 Mybatis, JPQL, Criteria 등 문자열 형태로 쿼리문을 작성하여 컴파일 시에 오류를 발견하는 것이 불가능했다. 하지만, QueryDSL은 자바 코드로 SQL 문을 작성할 수 있어 컴파일 시에 오류를 발생하여 잘못된 쿼리가 실행되는 것을 방지할 수 있다.
QueryDSL 장점
- 문자가 아닌 코드로 쿼리를 작성할 수 있어 컴파일 시점에 문법 오류를 확인할 수 있다.
- 인텔리제이와 같은 IDE의 자동 완성 기능의 도움을 받을 수 있다.
- 복잡한 쿼리나 동적 쿼리 작성이 편리하다.
- 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.
- JPQL 문법과 유사한 형태로 작성할 수 있어 쉽게 적응할 수 있다.
페이징(Paging) 이란?
페이징은 많은 정보, 이를테면 게시판에 존재하는 수백 수천개의 게시글과 같은 정보들을 페이지로 나눠 효과적으로 정보를 제공하게 하는 역할을 한다.
이러한 페이징을 개발하기 위해서는 page 관련 쿼리를 파라미터로 받아서 직접 처리하는 방법이 있었지만 JPA에서 또 Spring Data 프로젝트에서는 효과적으로 페이징을 처리할 수 있게 방법을 제공한다.
Spring Data 의 페이징과 정렬
JpaRepository 의 부모 인터페이스인 PagingAndSortingRepository 에서 페이징과 소팅이라는 기능을 제공한다.
- JpaRepository의 패키지는 "org.springframework.data.jpa.repository"으로 되어있다. JpaRepository는 JPA 패키지에 있기 때문에 JPA에서만 활용할 수 있다는 것을 알 수 있다.
- PagingAndSortingRepository, CrudRepository 등등은 "org.springframework.data"에 있는 것을 알 수 있다. 이것은 JPA 뿐만 아니라 다른 형태에서도 사용할 수 있다는 것을 알 수 있다. 예를 들면 MyBatis 등등이 될 수 있다.
내부를 살펴보면 위와 같다. 우리는 Pageable 을 이용해 페이징 기능을 사용할 수 있고, Page로 페이징 처리의 결과를 반환받는다.
즉, 반환값이 Page 고 Pageable 을 파라미터로 받는 메서드를 레포지토리 클래스 내에 직접 작성함으로써 페이징 처리 쿼리를 커스텀할 수 있다. (반환 타입이 Slice<T>, List<T> 일수도 있으나 게시판 형식이기 때문에 여기서는 Page<T> 를 사용한다)
Pageable 을 구현하는 구현체는 PageRequest 이고 구조는 위와 같다.
구현체는 필드로 page, size, sort 를 갖는다.
Controller의 파라미터에 Pageable 객체를 받는 것으로 설정을 해주면, Request 요청이 오게 되면 내부적으로 Argument Resolver등이 동작해서 Pageable 객체를 만들어준다.
- page : 현재 페이지, 0부터 시작
- size : 한 페이지에 노출할 데이터 건수
- sort : 정렬 조건을 정의. (정렬 속성, 정렬 파라미터 등이 있음)
@PageableDefault 어노테이션을 이용하면 컨트롤러 단위로 Pageable의 default Value를 설정할 수 있다.
- page : 몇 번째 페이지를 불러올지 설정
- size : 한 페이지에 가져올 데이터 갯수
- sort : 어떤 것을 기준으로 정렬할 것인지
- direction : 어떤 방향으로 정렬할 것인지
다음과 같은 기본값을 사용자가 설정을 해줄 수 있다. 쿼리 파라미터로 어떠한 값도 넘어오지 않는 경우에 다음 값이 컨트롤러 단위로 들어가게 된다. (cf. 페이징 정보가 둘 이상인 경우 @Qualifer 를 이용할 수도 있다)
이제 본격적으로 위의 웹툰 페이지를 참고하여 컨트롤러를 만들어보자
https://comic.naver.com/webtoon/list?titleId=796152 //첫 화면 주소
https://comic.naver.com/webtoon/list?titleId=796152&page=3&sort=DESC //기본 최신순
https://comic.naver.com/webtoon/list?titleId=796152&page=1&sort=ASC //오래된 순
주소창을 확인해보면 웹툰의 id와 Pageable 정보가 파라미터로 넘어오는 것을 확인할 수 있다.
Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/webtoon")
public class WebtoonController {
private final WebtoonService webtoonService;
@GetMapping("/list/posts")
@Operation(summary = "개별 웹툰 페이지 조회")
public Page<PostRsDto> findPage(@RequestParam Long webtoonId,
@PageableDefault(size = 30,
sort = "createdDate",
direction = Sort.Direction.DESC) Pageable pageable) {
return webtoonService.findPage(webtoonId, pageable);
}
}
Service
@Service
@RequiredArgsConstructor
public class WebtoonService {
private final WebtoonRepository repository;
public Page<PostRsDto> findPage(Long webtoonId, Pageable pageable) {
Page<PostRsDto> page = repository.findPostPage(webtoonId, pageable);
return page;
}
}
Dto
@NoArgsConstructor
@Data
public class PostRsDto {
private Long postId;
private String imageUrl; //썸네일
private String content; //소제목
private float starPost;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime createdDate; //업로드 일자
@QueryProjection
public PostRsDto(Long postId, String imageUrl, String content, float starPost, LocalDateTime createdDate) {
this.postId = postId;
this.imageUrl = imageUrl;
this.content = content;
this.starPost = starPost;
this.createdDate = createdDate;
}
}
Repository
public interface WebtoonRepositoryCustom {
//개별 웹툰 페이지 조회
Page<PostRsDto> findPostPage(Long webtoonId, Pageable pageable);
}
이제 쿼리문을 작성하는 것만이 남았다 (가장 중요)
내 프로젝트의 엔티티 구조상 웹툰 회차 테이블과 썸네일을 저장하는 이미지 테이블이 일대다 관계를 가지므로 이 점을 유의해서 적절히 조인해야한다. Post(1) -- Image(N)
현재 Image 테이블엔 더미 데이터가 넣어져 있지 않은 상태다. 하지만 회차의 썸네일과 본문 만화가 존재하지 않더라도 Post 자체는 존재할 수 있고 조회가 되어야되기 때문에 (엑박이 날지라도..) 외부조인을 사용하고자 했다.
초기코드(실패)
많이 돌아다니는 자료가 Member(N) 과 Team(1) 의 예제일 것이다. 대개 외래키를 가지고 있는 것은 N쪽이고 주 테이블을 N으로 잡는다. Querydsl 에서의 조인 기본 문법은 join(조인 대상, 별칭으로 사용할 Q타입) 이다. 아래와 같은 예제가 많다.
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
하지만 이 경우 select 로 조회하는 칼럼이 member 테이블의 값이다. 나의 경우 Image 테이블에서 썸네일 컬럼 하나만 가져올뿐, 대부분 Post 테이블의 정보를 조회한다. 처음에는 Post 를 주 테이블로 하고자 했지만 이 경우 외부조인(leftjoin)을 어떻게 써야할지 감이 오지 않았다. 문법 오류만 왕창 보고서 찝찝한 마음과 함께 Image 를 주테이블로 잡고 rightjoin 을 아래와 같이 시도했다.
List<PostRsDto> content = queryFactory
.select(new QPostRsDto(
post.id,
image.imageUrl,
post.content,
post.starPost,
post.createdDate
))
.from(image)
.rightJoin(image.post, post)
.where(post.webtoon.id.eq(webtoonId))
.orderBy(postSort(pageable))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Image 테이블에 테스트용으로 3행의 데이터를 추가했다. 하지만 결과는 아래와 같았다.
대체 왜 postId 는 null 이 나오는거야??
여기서 삽질을 한참 했다.
결과적으로 내가 내린 결론은 외부조인일지라도 postId는 조인칼럼이기 때문에 Image 에 있는 postId 만 조회가 된다는 추측이다. Post 를 주테이블로한 leftjoin 을 하면되지 않나 하고 단순하게 생각해서 아래와 같은 코드를 짜면..
.from(post)
.leftJoin(post.images, image)
.where(post.webtoon.id.eq(webtoonId))
에러가 난다. (돌아가지도 않음)
rightJoin 코드를 돌렸을 때 sql 을 살펴보면 아래와 같다
select 된 postId 가 image.post_id 인게 굉장히 신경쓰인다.
해결
on절을 활용해 연관관계 없는 엔티티 외부 조인을 이용한다 (JPA 2.1부터 지원)
하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 외부 조인하는 기능이 추가되었다. 물론 내부 조인도 가능하다.
주의! 문법을 잘 봐야 한다. leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어간다.
- 일반조인: leftJoin(member.team, team)
- on조인: from(member).leftJoin(team).on(xxx)
public class WebtoonRepositoryImpl implements WebtoonRepositoryCustom {
private final JPAQueryFactory queryFactory;
public WebtoonRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public Page<PostRsDto> findPostPage(Long webtoonId, Pageable pageable) {
List<PostRsDto> content = queryFactory
.select(new QPostRsDto(
post.id,
image.imageUrl,
post.content,
post.starPost,
post.createdDate
))
.from(post)
.leftJoin(image).on(image.post.eq(post), image.isThumbnail.isTrue())
.where(post.webtoon.id.eq(webtoonId))
.orderBy(postSort(pageable))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(post)
.from(post)
.where(post.webtoon.id.eq(webtoonId))
.fetchCount();
return new PageImpl<>(content, pageable, total);
//TODO 카운트 쿼리 최적화
}
private OrderSpecifier<?> postSort(Pageable page) {
if (!page.getSort().isEmpty()) {
for (Sort.Order order : page.getSort()) {
Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
return new OrderSpecifier(direction, post.createdDate);
}
}
return new OrderSpecifier(Order.DESC, post.createdDate);
}
}
관련 내용이 향로님 블로그에 정리되어 있더라 훗날의 나를 위해 링크를 남겨놓는다..
https://jojoldu.tistory.com/342
결과
개선점
- sort=Direction.asc 가 아닌 sort=ASC 여도 정렬이 되게끔 해야하는데.. Direction 내 함수를 찾아보면 될 것 같기도
- createdDate 외 정렬 조건이 늘어나는 것을 고려해서 OrderSpecifier 를 수정해보는게 좋을 것 같음
- https://ojt90902.tistory.com/717
https://ittrue.tistory.com/292
https://velog.io/@hoyun7443/JPA%EC%97%90%EC%84%9C-Pageable%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%8E%98%EC%9D%B4%EC%A7%95%EA%B3%BC-%EC%A0%95%EB%A0%AC
https://ojt90902.tistory.com/715
https://dingdingmin-back-end-developer.tistory.com/entry/Springboot-JPA-Querydsl-%EB%8F%99%EC%A0%81-%EC%A0%95%EB%A0%AC-OrderSpecifier
https://itmoon.tistory.com/73
'기타' 카테고리의 다른 글
프로젝트 배포하기 : Route 53에 로드밸런서 연결 (ALB, VPC, 서브넷) (1) | 2024.07.29 |
---|---|
ERD Cloud 를 이용한 데이터 모델링, DB 설계 (0) | 2023.09.17 |
우아한 테크코스 프리코스 회고 (0) | 2023.03.23 |
오브젝트 (역할, 책임, 협력/캡슐화와 응집도에 대하여) (0) | 2023.03.23 |
AWS SNS vs. SQS (feat. Lambda) (1) | 2023.03.07 |
댓글