Spring/코드로 배우는 스프링부트 웹 프로젝트

코드로 배우는 스프링부트 웹 프로젝트 Day 5

한 면만 쓴 종이 2022. 8. 17. 11:51

/* Annotation들은 스프링부트 프로젝트의 Annotation 정리 페이지에 따로 정리해두었습니다. */

 

public interface GuestbookRepository extends JpaRepository<Guestbook, Long>, QuerydslPredicateExecutor<Guestbook> {
}

Querydsl을 이용하므로 GuestRepository인터페이스가 QuerydslPredicateExecutor 추가 상속하도록 함

 

[엔티티의 테스트]

 

test 폴더에 repository/GuestbookRepositoryTests 생성

package org.zerock.guestbook.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.guestbook.entity.Guestbook;

import java.util.stream.IntStream;

@SpringBootTest
public class GuestbookRepositoryTests {
    
    @Autowired
    private GuestbookRepository guestbookRepository;
    
    @Test
    public void insertDummies(){

        IntStream.rangeClosed(1, 300).forEach(i -> {
            Guestbook guestbook = Guestbook.builder()
                    .title("Title...." + i)
                    .content("Content..." + i)
                    .writer("user" + (i % 10))
                    .build();
            System.out.println(guestbookRepository.save(guestbook));
        });
    }
}

300개의 테스트 데이터 생성

 

자동으로 moddate, regdate 채워짐

 

[수정 시간 테스트]

(엔티티 클래스는 setter 관련 기능을 만들지 않는 것을 권장 - 필요에 따라서 최소한으로 수정 기능을 만들기도 함)

Guestbook클래스에 changeTitle(), changeContent() 메서드를 추가

public void changeTitle(String title){
    this.title = title;
}

public void changeContent(String content){
    this.content = content;
}

modDate는 엔티티를 수정하고 save() 했을 경우에 동작함

 

modDate 갱신 정상 동작 확인

@Test
public void updateTest() {

    Optional<Guestbook> result = guestbookRepository.findById(300L);
    
    if (result.isPresent()){
        Guestbook guestbook = result.get();
        
        guestbook.changeTitle("Change Title....");
        guestbook.changeContent("Change Content...");
        
        guestbookRepository.save(guestbook);
    }
}

300을 id 로 갖는 엔티티를 result로 받음

result 값이 있으면 title과 content를 수정한 후, save()

정상적으로 처리됨

 

[Querydsl 테스트]

 

검색 조건의 경우의 수

  1. '제목/내용/작성자'
  2. '제목 + 내용' / '내용 + 작성자' / '제목 + 작성자'
  3. '제목 + 내용 + 작성자'

멤버 변수들이 많을수록 조합의 수는 많아지게 됨

이를 대비하여 상황에 맞게 쿼리를 처리할 수 있는 Querydsl이 필요함

 

📌Querydsl 사용법

    • BooleanBuilder를 생성

    • 조건에 맞는 구문은 Querydsl에서 사용하는 Predicate 타입의 함수를 생성

    • BooleanBuilder에 작성된 Predicate를 추가하고 실행

 

[단일 항목 검색 테스트]

제목(title)에 '1'이 있는 엔티티들을 검색하는 경우 예시

package org.zerock.guestbook.repository;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.zerock.guestbook.entity.Guestbook;
import org.zerock.guestbook.entity.QGuestbook;


import java.util.Optional;
import java.util.stream.IntStream;

@SpringBootTest
public class GuestbookRepositoryTests {
    ```
    @Test
    public void testQuery1() {

        Pageable pageable = PageRequest.of(0, 10, Sort.by("gno").descending());

        QGuestbook qGuestbook = QGuestbook.guestbook;

        String keyword = "1";

        BooleanBuilder builder = new BooleanBuilder();

        BooleanExpression expression = qGuestbook.title.contains(keyword);

        builder.and(expression);

        Page<Guestbook> result = guestbookRepository.findAll(builder, pageable);

        result.stream().forEach(guestbook -> {
            System.out.println(guestbook);
        });
    }
}

• 동적 처리를 위해 Q도메인 클래스(QGuestbook)를 얻어옴 (엔티티 클래스에 선언된 title, content 같은 필드들을 변수로 활용 가능해짐)

• 만든 조건은 where문에 and나 or같은 키워드와 결합시킴

 

Page<T>: 페이지 정보를 담는 인터페이스

Pageable: 페이지 처리에 필요한 정보를 담게 되는 인터페이스

 

과정

PageRequest에 의해 Pageable에 페이징 정보가 담겨 객체화됨

Pageable이 JpaRepository가 상속된 인터페이스의 메서드에 파라미터로 전달됨

앞의 메서드의 return으로 Page<T>가 전달됨

전달된 Page<T>에 담겨진 Page정보를 바탕으로 로직을 처리

 

PageRequest의 메서드

of(int page, int size): 0부터 시작하는 페이지 번호와 개수. 정렬이 지정되지 않음

of(int page, int size, Sort sort): 페이지 번호와 개수, 정렬 관련 정보

 

BooleanBuilder

쿼리를 조건별로 쌓아서 동적 쿼리로 쓸 수 있음

where문에 들어가는 조건들을 넣어주는 컨테이너

GuestbookRepository에 추가된 QuerydslPredicateExecutor 인터페이스의 findAll() 사용 가능

and나 or 사용 가능

 

BooleanExpression

메서드가 늘어나지만 메서드 명을 통해 어떤 기능인지 확인이 가능다는 것 등 가독성이 증가하며, 메서드를 이용하므로 재사용성이 증가

Predicate의 구현체

null일 때 무시될 수 있고, and 또는 or절을 통해 조합 가능

 

BooleanExpression ⇨ qGuestbook.title.contains(keyword) 같이 표현식의 결과로 반환되는 값

BooleanBuilder  이러한 표현식을 모아서 사용할 수 있도록 도와주는 도구

 

참고: Querydsl - 동적쿼리(BooleanBuilder, BooleanExpression) : 네이버 블로그 (naver.com)

 

Hibernate: 
    select
        guestbook0_.gno as gno1_0_,
        guestbook0_.moddate as moddate2_0_,
        guestbook0_.regdate as regdate3_0_,
        guestbook0_.content as content4_0_,
        guestbook0_.title as title5_0_,
        guestbook0_.writer as writer6_0_ 
    from
        guestbook guestbook0_ 
    where
        guestbook0_.title like ? escape '!' 
    order by
        guestbook0_.gno desc limit ?
Hibernate: 
    select
        count(guestbook0_.gno) as col_0_0_ 
    from
        guestbook guestbook0_ 
    where
        guestbook0_.title like ? escape '!'

 where조건절에서 title에 대한 처리가 온전히 처리되는 것을 볼 수 있음

 

[다중항목 검색 테스트]

 

앞서 정리한 것 처럼 BoolenaBuilder는 and() 혹은 or()의 파라미터로 BooleanBuilder를 전달 가능하기 때문에 복합적인 쿼리 생성 가능

@Test
public void testQuery2() {

    Pageable pageable = PageRequest.of(0, 10, Sort.by("gno").descending());

    QGuestbook qGuestbook = QGuestbook.guestbook;

    String keyword = "1";

    BooleanBuilder builder = new BooleanBuilder();

    BooleanExpression exTitle = qGuestbook.title.contains(keyword);

    BooleanExpression exContent = qGuestbook.content.contains(keyword);

    BooleanExpression exAll = exTitle.or(exContent); // exTitle과 exContent 조건 결합

    builder.and(exAll); // BooleanBuilder에 exAll 추가

    builder.and(qGuestbook.gno.gt(0L)); // 'gno가 0보다 크다' BooleanBuilder에 추가

    Page<Guestbook> result = guestbookRepository.findAll(builder, pageable);
    
    result.stream().forEach(System.out::println);
}

 

쿼리문 일부

Hibernate: 
    select
        count(guestbook0_.gno) as col_0_0_ 
    from
        guestbook guestbook0_ 
    where
        (
            guestbook0_.title like ? escape '!' 
            or guestbook0_.content like ? escape '!'
        ) 
        and guestbook0_.gno>?

where절 내부에 or로 생성된 쿼리 확인 가능

 

[서비스 계층과 DTO]

실제 프로젝트를 작성할 경우, 엔티티 객체를 영속 계층 바깥쪽에서 사용하는 방식 보다는 DTO(Data Transfer Object)를 이용하는 방식을 권장함

 

DTO

• 엔티티 객체와 달리 각 계층끼리 주고받는 우편물이나 상자의 개념

순수하게 데이터를 담고 있다는 점에서 엔티티 객체와 유사

목적이 데이터의 전달이므로, 읽고 쓰는 것이 허용되는 것이 가능하고, 일회성으로 사용하는 성격이 강하다는 점이 엔티티 객체와 다름

 

DTO는 일회성으로 데이터를 주고받는 용도인 반면, 엔티티 객체는 실제 데이터 베이스와 관련있고, 엔티티 매니저가 관리하는 객체이므로, 생명주기가 전혀 다르기 때문에 분리해서 처리하는 것을 권장함

 

*웹 애플리케이션을 제작할 때는 HttpServletRequest나 HttpServletResponse를 서비스 계층으로 전달하지 않는 것이 원칙

*엔티티 객체가 JPA에서 사용하는 객체이므로 JPA외에서 사용하지 않는 것을 권장

 


 

서비스 계층에서 DTO로 파라미터 리턴 타입을 처리하도록 구성

DTO 사용시 이점: 엔티티 객체의 범위를 한정할 수 있기 때문에 좀 더 안전한 코드 작성 가능 + 화면과 데이터 분리 취지에도 더 부합함

DTO 사용시 단점: Entity와 유사한 코드를 중복으로 개발함 + 엔티티 객체 → DTO객체 또는 DTO객체  엔티티 객체의 변환 과정 필요

 

dto/GuestbookDTO 생성

package org.zerock.guestbook.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class GuestbookDTO {
    
    private Long gno;
    private String title;
    private String content;
    private String writer;
    private LocalDateTime regDate, modDate;
}

Guestbook 필드와 거의 비슷한 필드를 가짐

getter/setter를 통해 자유롭게 값을 변경 가능하게 구성함 (@Data)

 

서비스 계층: GuestbookDTO를 이용해서 필요한 내용을 전달받고, 반환하도록 처리

GuestbookService 인터페이스

package org.zerock.guestbook.service;

import org.zerock.guestbook.dto.GuestbookDTO;

public interface GuestbookService {
    Long register(GuestbookDTO dto);
}

GuestbookServiceImpl 클래스

package org.zerock.guestbook.service;

import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
import org.zerock.guestbook.dto.GuestbookDTO;

@Service
@Log4j2
public class GuestbookServiceImpl implements GuestbookService {
    
    @Override
    public Long register(GuestbookDTO dto) {
        return null;
    }
}

스프링에서 빈으로 처리되도록 @Service 추가

 

[등록과 DTO를 엔티티로 변환하기]

서비스 계층에서는 파라미터를 DTO로 타입으로 받음 → JPA로 처리하려면 엔티티 타입의 객체로 변환하는 작업이 필수!

 

해당 기능 구현 방법

    • DTO 클래스에 해당 기능 적용

    • ModelMapper라이브러리 이용

    • MapStruct라이브러리 이용

    • 직접 처리 ✓

 

기존에 만든 DTO와 엔티티 클래스를 변경하지 않기 위해 예제에서는 GuestbookServie인터페이스에 default 메서드를 이용해서 이를 처리하는 형태로 작업

GuestbookService 인터페이스 수정

public interface GuestbookService {
    Long register(GuestbookDTO dto);
    
    default Guestbook dtoToEntity(GuestbookDTO dto) {
        Guestbook entity = Guestbook.builder()
                .gno(dto.getGno())
                .title(dto.getTitle())
                .content(dto.getContent())
                .writer(dto.getWriter())
                .build();
        return entity;
    }
}

default를 이용해서 추상 클래스를 생략하고 구현 클래스에서 동작 가능한 dtoToEntity() 생성

 

GuestbookServiceImpl 클래스

@Override
public Long register(GuestbookDTO dto) {
    
    log.info("DTO----------------------");
    log.info(dto);

    Guestbook entity = dtoToEntity(dto);
    
    log.info(entity);
    
    return null;
}

GuestbookDto 타입의 dto를 Guestbook타입의 entity로 변환 (dto entity)