궤도

[Spring] [면적면적[12]] 기록 페이지 (QueryDSL을 이용해서 통계를 내보자) 본문

💻 현생/📕 면적면적(스프링 실습)

[Spring] [면적면적[12]] 기록 페이지 (QueryDSL을 이용해서 통계를 내보자)

영이오 2021. 8. 18. 19:06

북적북적의 백엔드를 클론하고 있다.

 

Github

 

build.gradle

plugins {
	id 'org.springframework.boot' version '2.5.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
	id 'java'
}

group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	all {
		exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-log4j2'
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.6.RELEASE'
	implementation 'org.mapstruct:mapstruct:1.4.2.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
	implementation 'com.querydsl:querydsl-jpa'
	implementation 'org.projectlombok:lombok:1.18.18'
	runtimeOnly 'mysql:mysql-connector-java'
	implementation 'com.googlecode.json-simple:json-simple:1.1.1'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

def querydslDir = "$buildDir/generated/querydsl"

querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}

sourceSets {
	main.java.srcDir querydslDir
}

configurations {
	querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

test {
	useJUnitPlatform()
}

 

 

src/main/resources/application.yml

aladin:
  url: http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?ttbkey=비밀키&itemIdType=ISBN13&output=js&Version=20131101&ItemId={isbn}
naver:
  id: 아이디
  secret: 비번
secret:
  key: 비번
cloud:
  aws:
    credentials:
      access-key: 엑세스 키
      secret-key: 시크릿 키
    s3:
      bucket: 버킷 이름
    region:
      static: 지역
    stack:
      auto: false
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3305/데베?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password: 비번
  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        format_sql: true
        show-sql: true
#logging:
#  level:
#    org:
#      hibernate:
#        type:
#          descriptor:
#            sql: trace
server:
  servlet:
    encoding:
      force-response: true
logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

요구사항 정리

 

나의 메모

[Request] {GET}

[Response] {List<Item>, HttpStatus.OK} / {HttpStatus.NO_CONTENT}

 

차트 보기

[Request] {year, GET}

[Response] {List<Item>, HttpStatus.OK} / {HttpStatus.NO_CONTENT}


나의 메모

 

내가 보유한 책 중에서 메모가 있는 책들만 나온다.

그리고 메모가 여러개 있다면 가장 최근에 나온(이거 때문에 삽질 오래함) 메모가 대표 메모격으로 나온다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemoDto {

    private Long bookId;
    private String title;
    private String thumbnail;
    private String content;
    private LocalDateTime saved;
}

dto다. memoId는 없는데 bookId는 있는 이유는...저 상태에서 책 누르면 책 상세정보 페이지로 넘어가기 때문이다.

 

    /**
     * 각 책마다 제일 최근에 저장한 메모 불러오기
     * localhost:8080/history/my-memos
     *
     * @param user
     * @return ResponseEntity
     */
    @GetMapping("/my-memos")
    public ResponseEntity<?> myMemos(@AuthenticationPrincipal User user) {
        log.info("[Request] Retrieve all Memos " + user.getEmail());
        List<MemoDto> result = historyService.myMemos(user);
        if (result.isEmpty())
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        return new ResponseEntity<>(result, HttpStatus.OK);
    }

컨트롤러 만들고...

서비스 만들기 전에 생각을 해봤다.

 

일단 특정 사용자가 보유한 책들은 양방향 매핑으로 저장했다...그 책들을 기반으로 메모 레포지토리에서 in으로 찾으면?

해당 책을 참조하고 있는 메모가 다 나올 것이다. 그니까 예를 들어서

 

책1 - 메모1, 2, 3

책2 - 메모4, 5

이런식으로 있다면 저대로 쿼리를 날리면

메모1, 2, 3, 4, 5가 나올 것이다.

 

근데 난 여기서 책1의 가장 최근 메모인 메모3과 책2의 가장 최근 메모인 메모 5만 필요하다.

 

가장 무식하게 생각해볼 수 있는 방법은 in으로 메모를 찾지말고 책 하나하나에 대해 쿼리를 날리는 것이다.

그니까 findBy책1, findBy책2 이런식으로 하고 그 결과 마다 가장 최근 메모를 빼오면 된다.

딱봐도 비효율적이고 해서는 안될 짓 같다.

 

그래서 애시당초 이 방법은 고려도 안했었고...group by를 쓰기로 했다. in으로 찾은다음에 책을 기준으로 group by하고 거기서 가장 최근 메모만 빼오면 되겠다 싶었다.

 

Spring data jpa는 group by를 제공하지 않는 듯하다. 그래서 QueryDSL을 사용하기로 했다.

 

plugins {
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" //추가
}

dependencies {
	implementation 'com.querydsl:querydsl-jpa' //추가
}

build.gradle에서 플러그인과 디펜던시에 저렇게 추가하고

 

def querydslDir = "$buildDir/generated/querydsl"

querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}

sourceSets {
	main.java.srcDir querydslDir
}

configurations {
	querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

이런것도 추가한다.

 

성공했다면 이렇게 compileQuerydsl이란게 생겼을 것이다. 더블클릭하고 기다리면

 

build/generated에 이렇게 Q어쩌구들이 생긴다. mapStruct로 생긴 애들도 같이 옮겨왔다.

 

이걸 이제 어떻게 쓰냐면...

package jpa.myunjuk.module.repository.memo;

import java.util.List;

public interface CustomizedMemoRepository {
    List<Long> findLatestMemoByBookIds(List<Long> bookIds);
}

Spring data jpa를 사용할 때처럼 인터페이스를 만들고 구현할 메소드의 이름을 적는다.

이건 우리가 직접 구현해야 한다

 

package jpa.myunjuk.module.repository.memo;

import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.Memo;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemoRepository extends JpaRepository<Memo, Long>, CustomizedMemoRepository {
    List<Memo> findAllByBookOrderBySavedDesc(Book book);
    List<Memo> findByIdIn(List<Long> id);
}

기존 레포지토리에 이렇게 상속받으면 서비스단에서 따로 레포지토리를 추가할 필요가 없다.

 

package jpa.myunjuk.module.repository.memo;

import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;

import java.util.List;

import static jpa.myunjuk.module.model.domain.QMemo.memo;

@RequiredArgsConstructor
public class CustomizedMemoRepositoryImpl implements CustomizedMemoRepository {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Long> findLatestMemoByBookIds(List<Long> bookIds) {
        return jpaQueryFactory.select(memo.id.max())
                .from(memo)
                .where(memo.book.id.in(bookIds))
                .groupBy(memo.book)
                .fetch();
    }
}

이건 구현체다. 음...QueryDSL 공식문서를 한 번 읽어보는걸 추천한다.

그냥 sql문 날리는거랑 비슷했다.

 

처음부터 책별로 가장 최근에 나온 메모를 빼왔으면 좋았겠지만...group by 내부에서 정렬하는게 불가능했다...

그래서 좋은 방법 없을까 생각하며 온갖 삽질을 하다가 꼼수아닌 꼼수를 떠올렸다.

 

난 모든 엔티티의 pk를 AI로 설정했다. 그니까 최근에 저장된 메모일수록 pk값이 커진다는 뜻이다.

 

그래서 그냥 groupBy 한 뒤에 memo.id.max()만 select해서 각 그룹별(책)로 pk가 가장 큰 메모만 빼오면 그게 가장 최근에 저장된 메모가 된다.

 

    /**
     * myMemos
     *
     * @param user
     * @return List<MemoDto>
     */
    public List<MemoDto> myMemos(User user) {
        List<Long> bookIds = user.getBooks().stream()
                .map(Book::getId)
                .collect(Collectors.toList());

        return memoRepository.findByIdIn(memoRepository.findLatestMemoByBookIds(bookIds)).stream()
                .map(o -> MemoDto.builder()
                        .bookId(o.getBook().getId())
                        .title(o.getBook().getTitle())
                        .thumbnail(o.getBook().getThumbnail())
                        .content(o.getContent())
                        .saved(o.getSaved())
                        .build())
                .collect(Collectors.toList());
    }

그래서 이게 서비스 코드이다.

 

        List<Long> bookIds = user.getBooks().stream()
                .map(Book::getId)
                .collect(Collectors.toList());

먼저 사용자의 책에서 id만 빼온다.

 

memoRepository.findLatestMemoByBookIds(bookIds)

여기까지 하면 각 책별 가장 최근에 저장된 메모의 id가 나온다.

 

memoRepository.findByIdIn(memoRepository.findLatestMemoByBookIds(bookIds))

그럼 이렇게 하면 해당 id의 메모들이 나올 것이고

 

return memoRepository.findByIdIn(memoRepository.findLatestMemoByBookIds(bookIds)).stream()
                .map(o -> MemoDto.builder()
                        .bookId(o.getBook().getId())
                        .title(o.getBook().getTitle())
                        .thumbnail(o.getBook().getThumbnail())
                        .content(o.getContent())
                        .saved(o.getSaved())
                        .build())
                .collect(Collectors.toList());

어떻게 뭐 잘해서 dto에 담으면 된다.

 

이부분도 QueryDSL로 처리할 수 있는데 굳이 그렇게 하지않은 이유는 어디선가 QueryDSL이 지연로딩을 하지 않아서 성능이 떨어질 수 있다는...뭐 그런 말을 본 것 같다. 아닐 수도 있다.

 

    @Test
    @DisplayName("Retrieve my memos | Success")
    void memosSuccess() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        List<MemoDto> memoDtos = historyService.myMemos(user);
        assertEquals(2, memoDtos.size());
    }

테스트 코드이다. 사실 계속 그래왔지만 테스트 코드들이 테스트 유저에 의존하는 정도가 큰 편이라...고민 중이다.

 


차트 보기

 

원래 처음엔 권수별, 페이지별로 api를 분리했는데, 블로그에 글을 쓰려고 보니 그냥 권수별 dto에 쪽수만 넣으면 페이지별 dto라서 합치기로 결정했다. 둘이 따로 조작되는 것도 아니고 검색 옵션 지정하면 같이 변하니까 이게 맞을 것 같다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChartDto {

    private Long totalCount;
    private List<Item> itemList = new ArrayList<>();

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @ToString
    public static class Item {

        private Integer month;
        private Long count;
        private Integer page;
    }
}

dto를 만들고

 

    /**
     * 읽은 책 차트 보기
     * localhost:8080/history/chart
     *
     * @param user
     * @param year
     * @return ResponseEntity
     */
    @GetMapping("/chart")
    public ResponseEntity<?> chart(@AuthenticationPrincipal User user,
                                   @RequestParam int year) {
        log.info("[Request] Chart " + user.getEmail());
        ChartDto result = historyService.chart(user, year);
        if (result.getTotalCount() == 0)
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        return new ResponseEntity<>(result, HttpStatus.OK);
    }

컨트롤러 만들고

 

public interface CustomizedBookRepository {
    List<Item> findByYearGroupByMonth(Long userId, int year);
}
public interface BookRepository extends JpaRepository<Book, Long>, CustomizedBookRepository {
}

이번에도 QueryDSL을 사용할 것이다. 달별로 groupBy해야하기 때문이다.

 

@RequiredArgsConstructor
public class CustomizedBookRepositoryImpl implements CustomizedBookRepository {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Item> findByYearGroupByMonth(Long userId, int year) {
        return jpaQueryFactory.select(
                Projections.constructor(
                        Item.class,
                        book.endDate.month(),
                        book.count(),
                        book.totPage.sum()))
                .from(book)
                .where(book.user.id.eq(userId)
                        .and(book.bookStatus.eq(BookStatus.DONE))
                        .and(book.endDate.year().eq(year)))
                .groupBy(book.endDate.month())
                .fetch();
    }
}

결과를 dto에 한번에 담고 싶다면 이렇게 Projections.constructor를 사용하면 된다.

아무튼 일단 사용자가 보유한 책이어야하고, '읽은 책'에 속해야 하고, 다 읽은 년도가 입력값과 동일해야 한다.

그 결과를 다 읽은 달별로 group 해주면 count()로 해당 달의 권 수를 저장하고, totPage.sum()으로 해당 달의 책 페이지 총합을 저장한다.

 

    /**
     * chart
     *
     * @param user
     * @param year
     * @return ChartDto
     */
    public ChartDto chart(User user, int year) {
        validateYear(year);
        List<Item> statistics = bookRepository.findByYearGroupByMonth(user.getId(), year);

        return ChartDto.builder()
                .totalCount(statistics.stream().mapToLong(Item::getCount).sum())
                .itemList(statistics)
                .build();
    }

서비스 코드이다.

위에 이미지를 보면 그래서 해당 년도에 해당하는 책이 총 몇권인지도 알려줘야한다. 그러니 stream()으로 합을 구하자.

 

    @Test
    @DisplayName("Chart | Success")
    void chartAmount() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        ChartDto chartDto = historyService.chart(user, 2019);
        assertEquals(7, chartDto.getTotalCount());
    }

    @Test
    @DisplayName("Chart | Fail : Invalid year")
    void chartFail() throws Exception {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        InvalidReqParamException e = assertThrows(InvalidReqParamException.class, () ->
                historyService.chart(user, 13));
        assertEquals("year = 13", e.getMessage());
    }

테스트 코드

 

아까 메모랑 같이 테스트

 

4월달에 읽은 책은 총 3권이고 그 3권의 총 페이지 수는 598이다...뭐 이런거다.


참고 블로그

Querydsl Gradle 설정

Comments