인프런에서 querydsl에 관한 강의를 듣고 토이 프로젝트에 적용해보고 싶은 생각이 들었다. 그래서 한 번 해보기로 함~~!!

 

 

먼저 gradle 설정

plugins {
    ...
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
    ...
}
...
dependencies {
    ...
    implementation 'com.querydsl:querydsl-jpa'
    ...
}
...
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

 

 

 

 

queryDSL을 기존의 spring data JPA repository를 사용하던 곳에 적용해 보려고 했다.

사용자 정의 Repository

복잡한 기능을 추가하거나, 사용자 정의 함수를 추가할 때 사용한다.

Spring data JPA는 인터페이스로 동작하기 때문에, 내가 원하는 코드를 넣으려면 사용자 정의 repository라는 것을 구현해야 한다.

출처: https://www.inflearn.com/course/Querydsl-%EC%8B%A4%EC%A0%84

 

사용자 정의 repository의 사용법은 아래 단계들과 같다.

1. 사용자 정의 인터페이스 작성

2. 사용자 정의 인터페이스 구현

3. 스프링 데이터 레포지토리에 사용자 정의 인터페이스 상속

 

여기서 주의할 점은 기존에 있던 JpaRepository를 상속하는 repository 인터페이스와 같은 이름의 Impl을 구현해야 한다는 점이다.

 

이 방법 외에도 특정한 기능인 경우에는 따로 repository를 만들 수도 있다.

현재 구현하는 애플리케이션의 경우, 게시판에만 search & filter 기능을 사용하기 때문에 후자의 방법을 이용하여 구현해 보기로 했다.

 

 

 

기존 코드: stream filter, map, collect 등의 기능을 이용했었음

1. 모든 게시판 가져오면서 숨김처리한 게시물 필터링

2. 조건에 맞추어 필터링

3. 조건에 맞추어 정렬

(정렬 조건 중 까다로운 점은 현재까지 모임 신청이 approved된 인원을 뺀 remain 에 대한 정렬을 구현하는 것이었다)

여기서 나는 같은 stream을 여러번 읽어서 필터링 하고, 이 필터링 된 코드를 또 stream으로 바꾸어 sorting하는 등이 문제라고 생각했다.

private List<Board> sortBoardList(SortingType sorting, List<Address> addresses, List<Category> categories, User user) {
	List<Board> boards = filterBoardList(addresses, categories, user);

	if (sorting == SortingType.REMAIN) {
		return boards.stream()
				.sorted(Comparator.comparing(Board::getRemainRecruitNumber, Comparator.reverseOrder()))
				.collect(Collectors.toList());
	} else if (sorting == SortingType.DEADLINE) {
		return boards.stream()
				.sorted(Comparator.comparing(Board::getStartsAt, Comparator.naturalOrder()))
				.collect(Collectors.toList());
	}
	return boards.stream()
			.sorted(Comparator.comparing(BaseEntity::getCreatedAt, Comparator.reverseOrder()))
			.collect(Collectors.toList());
}

private List<Board> filterBoardList(List<Address> addresses, List<Category> categories, User user) {
	return findAllBoards(user).stream()
			.filter(board -> addresses.contains(board.getAddress()))
			.filter(board -> categories.contains(board.getCategory()))
			.collect(Collectors.toList());
}

private List<Board> findAllBoards(User user) {
	Set<Board> hiddenBoards = user.getUserHiddenBoard().stream()
			.map(HiddenBoard::getBoard).collect(Collectors.toSet());

	return boardRepository.findAll().stream()
			.filter(board -> board.getStatus().getCode() != BoardStatus.CANCELED.getCode())
			.filter(board -> !hiddenBoards.contains(board))
			.collect(Collectors.toList());
}

이러고 나서 마지막에 페이징 처리를 했음...

 

 

바뀐 코드: 

queryDSL로 바꾸기 위해서 일단 필터링 조건 들을 나누었다.

BooleanBuilder를 사용하면 훨씬 쉽게 할 수 있었지만,

where절의 조건으로 쓰면 가독성이 좋은 BooleanExpression으로 구현하고 싶었다.

BooleanExpression을 사용할 때 주의할 점은 null 에 대한 부분을 생각해 주어야 한다는 점이다.

private BooleanExpression isFilteredCategories(List<Long> categories) {
	return categories != null ? Expressions.anyOf(categories.stream().map(this::isFilteredCategory).toArray(BooleanExpression[]::new)) : null;
}

private BooleanExpression isFilteredCategory(Long categoryId) {
	return board.category.id.eq(categoryId);
}

private BooleanExpression isFilteredCities(List<Long> cities) {
	return cities != null ? Expressions.anyOf(cities.stream().map(this::isFilteredCity).toArray(BooleanExpression[]::new)) : null;
}

private BooleanExpression isFilteredCity(Long cityId) {
	return board.address.id.eq(cityId);
}

private BooleanExpression isSearchedKeywords(List<String> keywords) {
	return Expressions.allOf(keywords.stream().map(this::isSearchedKeyword).toArray(BooleanExpression[]::new));
}

private BooleanExpression isSearchedKeyword(String keyword) {
	return board.content.containsIgnoreCase(keyword);
}

private BooleanExpression isDeletedBoard() {
	return board.status.ne(BoardStatus.CANCELED);
}

BooleanExpression을 쓰면서 어려웠던 점은, 단일 파라미터에 대한 예제는 많이 나와있었지만,

여러가지 필터링을 하기 위해 List 값에 대한 BooleanExpression을 만드는 것들은 잘 나오지 않아 시간을 좀 많이 썼다...

 

 

그 다음은 이 조건들을 적용하기 전에 먼저 querydsl로 짤 쿼리문을 직접 짜 보았다.

SELECT HIDDEN_FILTERED.*, COALESCE(APPROVED_USER.approved_number, 0) as approved_number, (HIDDEN_FILTERED.recruit_count - COALESCE(APPROVED_USER.approved_number, 0)) as remain_number
FROM (SELECT b.* FROM board as b left join hidden_board as hb on (b.id = hb.board_id and hb.user_id=1) where hb.user_id is null) as HIDDEN_FILTERED
left join (select board_id, count(user_id) as approved_number from applied_user where status='APPROVED' group by board_id) as APPROVED_USER
on HIDDEN_FILTERED.id = APPROVED_USER.board_id
order by approved_number DESC;

첫번째 시도는 위와 같이 mysql에서 직접 쿼리문을 작성할 경우 결과값이 아주아주~~ 잘 나왔다.

 

 

하지만, querydsl에서는 서브쿼리를 from문에서 사용하지 못한다는것을 몰랐다... 그래서 아래와 같이 left join을 3번 해보기로 했다.

SELECT b.*, (b.recruit_count - COALESCE(count(au.user_id), 0)) as remain_count
FROM (board as b left join hidden_board as hb on b.id = hb.board_id and hb.user_id=1) left join applied_user as au on b.id = au.board_id and au.status = 'APPROVED'
where hb.user_id is null
group by b.id
order by remain_count DESC;

 

 

 

쿼리 테스트를 해 본 후, querydsl에 해당 쿼리문을 적용해 보았다.

여기서 어려웠던 점은, 정렬 조건 중 remain 인원에 대한 것이었다.

모집 인원에 대한 인원 수는 db 필드로 있었으나, remain 인원에 대한 것은 applied user의 상태를 보고 세어야 했다.

이것은 자바 함수를 쓰면 매우 간단해서 이전 방법을 사용할 때는 깨닫지 못했지만, querydsl로 바꾼 후 시간이 많이 걸리게 된 원인이다ㅠ

 

인프런에 관련 함수를 쓸 수 없을지 질문을 올렸는데, 아래는 그 답변이었다.

---

querydsl의 결과는 결국 JPQL이 만들어지고, JPQL은 또 SQL로 번역되기 때문에 SQL로 할 수 있는 로직만 실행할 수 있습니다.

해당 코드는 자바 함수이기 때문에 JPQL이나 SQL에서 사용하는 것이 불가능합니다.

대신에 JPQL이나 SQL에서 다음과 같은 방식은 사용할 수 있습니다.

order by item.count2 - item.count1 desc

querydsl에서 사용하려면 다음 코드를 참고해주세요.

---

 

그래서 결국 applied user를 직접 구해서 뺀 값에 대한 정렬을 하도록 바꾸었다.

public List<Board> filter(BoardFilterCondition boardFilterCondition, Pageable pageable) {
	return jpaQueryFactory
			.select(board)
			.from(board)
			.leftJoin(hiddenBoard).on(board.id.eq(hiddenBoard.board.id).and(hiddenBoard.user.id.eq(boardFilterCondition.getUserId())))
			.leftJoin(appliedUser).on(board.id.eq(appliedUser.board.id).and(appliedUser.status.eq(AppliedStatus.APPROVED)))
			.where(
					hiddenBoard.user.id.isNull(),
					isFilteredCategories(boardFilterCondition.getCategory()),
					isFilteredCities(boardFilterCondition.getCity()),
					isDeletedBoard()
			)
			.groupBy(board.id)
			.orderBy(orderType(boardFilterCondition.getSorting()))
			.offset(pageable.getOffset())
			.limit(pageable.getPageSize())
			.fetch();
}

private OrderSpecifier<?> orderType(SortingType sortingType) {
	if(sortingType == SortingType.REMAIN){
		return board.recruitCount.subtract(appliedUser.user.id.count().coalesce(0L)).desc();
	}
	if (sortingType == SortingType.DEADLINE) {
		return board.startsAt.asc();
	}
	return board.createdAt.desc();
}

여러번 stream으로 필터링을 해야 했던 코드가 비교적 가독성 있어졌다...!! 그리고 페이징 처리도 repository 내에서 다 할 수 있었다.

 

 

 

마찬가지로 검색 쿼리도 구현해 보았다. 검색 쿼리는 필터링 보다 정렬도 까다롭지 않아 더 쉬운 편이었다.

public List<Board> search(BoardSearchCondition boardSearchCondition, Pageable pageable) {
	return jpaQueryFactory.select(board)
			.from(board).leftJoin(hiddenBoard)
			.on(board.id.eq(hiddenBoard.board.id).and(hiddenBoard.user.id.eq(boardSearchCondition.getUserId())))
			.where(
					hiddenBoard.user.id.isNull(),
					isSearchedKeywords(boardSearchCondition.getKeywords()),
					isDeletedBoard()
			)
			.orderBy(board.createdAt.desc()) // 최신순 정렬
			.offset(pageable.getOffset())
			.limit(pageable.getPageSize())
			.fetch();
}

 

 

 

느낀점

인강만 듣고 쉬운 예제들을 봤을 땐 금방 적용하겠지~ 하고 생각했는데,

실제 구현할 부분에 적용하려고 하니 생각보다 어려웠다. 그리고 가장 중요한 것은 sql 자체를 짜는 능력이라고 생각했다.

sql 자체를 제대로 짜면 그 것을 querydsl로 옮기는 것은 쉬운 일이라고 느꼈다. 성능은 또 다른 문제지만...ㅜㅠ

 

구현한 repository 전체 코드는 아래와 같다.

github.com/Yapp-17th/Android_2_Backend/blob/develop/common-module/src/main/java/com/yapp/crew/domain/repository/BoardSearchAndFilterRepository.java

 

Yapp-17th/Android_2_Backend

Backend Repository for Android Team 2. Contribute to Yapp-17th/Android_2_Backend development by creating an account on GitHub.

github.com

 

인턴을 했을 때 JPA 를 쓰긴 썼었는데 안 쓴거나 마찬가지였던 것 같다ㅠㅠㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

이전에는 아래와 같이 객체를 테이블에 맞춰서 모델링 했었음...

class Job{
    @Id
    private String jobId
    ...
}
class Step{
    @Id
    private String stepId;
    private String jobId;
    ...
}

이렇게 했을 때는 객체를 테이블에 저장하거나 조회할 때는 쉽고 편하다.

하지만 job에서 step을 조회할때, 또는 step에서 job을 조회하는 경우 참조된 객체를 가져올 수 없다.

물론 이 때는 데이터베이스에 저장이 주 기능이어서 딱히 상관없었을 수도 있다,,,

 

이번 토이 프로젝트에서는 JPA를 이용하여 객체지향 모델링을 해 볼건데

이번에는 객체 참조를 통해 관계를 맺도록 해볼것이다.

 

  • build.gradle dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

server:
  address: localhost
  port: 8080

spring:
  jpa:
    show-sql: true # API 호출시, SQL 문을 콘솔에 출력
    generate-ddl: true # DDL 정의시 데이터베이스의 고유 기능을 사용 ex) 테이블 생성, 삭제 등
    database: mysql
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
  datasource:
    url: jdbc:mysql://localhost:3306/ewhaianDB?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: 
    driver-class-name: com.mysql.cj.jdbc.Driver

 

  • Entity class 생성,,,을 하기 전에 저번에 정의했던 연관관계를 다시 보면 (객체는 참조가 있는 방향으로만 조회가 가능하다.)

    • 회원-글/시간표/강평/댓글: 일대 다 관계

    • 글-댓글: 일대 다 관계

    • 글/댓글-사진: 일대 다 관계

    • 강의-강의평가: 일대 다 관계

    • 시간표-강의:다대 다 관계

      • 시간표에 다른 강의 여러개 넣을 수 있고

      • 같은 강의가 여러 시간표에 들어갈 수 있다

      • --> 다대 다 관계는 관계형 데이터베이스 뿐만 아니라 엔티티에서도 거의 사용하지 않는다고 함

      • --> 따라서 연결 엔티티 강의시간표를 추가해서 일대다 관계+다대일 관계로 만들도록 한다.

      • 강의 시간표에는 담은 강의의 학점 합도 포함하도록 한다.

    • 추가) 즐겨찾기의 경우도 다대다 관계이므로 관계를 쪼개도록 한다.

 

 

 

 

 

 


  • JPA를 사용할 때 양방향 관계에서 주의할 점

@Entity
@Getter @Setter
public class BookStore{
    @Id @GeneratedValue
    private Integer id;
    
    private String name;
    
    @OneToMany(mappedBy="bookStore")
    private Set<Book> books=new HashSet<>();
    
    void add(Book book){
        //book.setBookStore(this); 이 부분을 꼭 설정해주도록
        this.books.add(book);
    }
}
@Entity
@Getter @Setter
public class Book{
    @Id @GeneratedValue
    private Integer id;
    private Sring isbn;
    private String title;
    
    @ManyToOne
    private BookStore bookStore;
}

위와 같이 객체를 정의해두고 테스트 코드를 아래와 같이 돌렸을 때 결과를 확인하면

@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoJpaTestApplicationTests{
    @Autowired
    BookStoreRepository bookStoreRepository
    
    @Autowired
    BookRepository bookRepository
    
    @Test
    public void contextLoads(){
        BookStore bookStore=new BookStore();
        bookStore.setName("시애틀 책방");
        bookStoreRepository.save(bookStore);
        
        Book book=new Book();
        book.setTitle("JPA 공부 좀 하면서 쓰세요.");
        bookStore.add(book);
        bookRepository.save(book);
    }
}

--> book DB를 확인하면 bookStore id가 없다. (연관 관계 맵핑이 안되어 있다.)

 

mappedBy가 없을 때에는 Book과 BookStore은 양방향 관계가 아니고 서로 다른 단방향 관계 2개이다.

BookStore에서 Book을 참조하는 OneToMany 관계는 기본적으로 join 테이블(book_store_id - book_id)이 생성된다.

 

여기서 mappedBy="BookStore"를 써주면 양방향 관계가 된다. (OneToMany와 ManyToOne을 하나로 묶어준다.)

--> 이 때 관계의 주인을 Book(Many에 해당하는 쪽)으로 설정한다.

이 경우에는 join테이블이 생기는 것이 아니라 Book과 BookStore 테이블 2개만 생기고,

foreign key를 Book쪽에서 가지고 있게 된다. (Book 테이블에 book_store_id가 있음)

 

즉, mappedBy는 관계의 주인을 알려준다.

= 관계의 주인이 Book이고, Book에서 자기 자신을 BookStore라고 참조하고 있다는 뜻

= 이 경우 관계의 주인인 쪽에 관계가 설정 되어야 데이터베이스에 반영된다.

= add를 할 때 관계의 주인인 Book에 관계를 설정해야 sync가 된다.

 

출처

https://www.youtube.com/watch?v=brE0tYOV9jQ

https://victorydntmd.tistory.com/208

https://velog.io/@conatuseus/%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-%EA%B8%B0%EC%B4%88-1-i3k0xuve9i

 

'Java (+ Spring)' 카테고리의 다른 글

Java Unit Test: Junit5  (0) 2021.02.10
Spring Boot: Spring Security & 카카오 로그인 API(...ing)  (0) 2020.10.06
Spring: Spring MVC 설정 하기  (0) 2020.07.26
Android: tutorial page  (0) 2020.07.18
Android: Launch screen  (0) 2020.07.16

+ Recent posts