SpringDataJPA

조수훈·2023년 7월 13일
1

SpringDataJpa

목록 보기
1/1
post-thumbnail

JPA를 사용하여 프로젝트를 진행하면서, 많은 코드들이 중복된다는 것을 깨달았고, 조금더 간편한 방식으로 데이터베이스 액세스 작업을 처리할 수 없을까에 대해서 고민하던 와중에 Spring Data JPA 를 알게되었고, Spring Data JPA 를 배우면서 중요하다고 생각 되는 내용들만 기록 하였다.

섹션 3 - 공통 인터페이스 기능

JavaConfig 설정- 스프링 부트 사용시 생략 가능

스프링 부트 사용시 @SpringBootApplication 위치를 지정(해당 패키지와 하위 패키지 인식)
만약 위치가 달라지면 @EnableJpaRepositories 필요

//@Repository 어노테이션 필요하지 않음 //<엔티티, 엔티티식별자>
public interface MemberRepository extends JpaRepository<Member, Long>{
		List<Member> findByUsernameAndAgeGreaterThan(String username, int age); 
        //구현하지 않아도 된다
}
@SpringBootTest
@Transactional
public class MemberJpaRepositoryTest {
	@Autowired
	MemberRepository memberRepository; //JPA 가 proxy class를 생성해준다
}

섹션 4 - 쿼리 메소드 기능

스프링 데이터 JPA가 제공하는 쿼리 메소드 기능

조회: find…By ,read…By ,query…By get…By,
예:) findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다.
COUNT: count…By 반환타입 long
EXISTS: exists…By 반환타입 boolean
삭제: delete…By, remove…By 반환타입 long
DISTINCT: findDistinct, findMemberDistinctBy
LIMIT: findFirst3, findFirst, findTop, findTop3
비교할수있는 여러가지 쿼리가 더있다. 자세한건 문서를 참고하자
이러한 쿼리 메소드들은 entity 의 필드 값이 변경될때마다 수정해줘한다. 로딩 시점에 오류를 인지할 수 있다는 장점이 있다

JPA 의 NamedQuery

컴파일시점에 오류를 잡아주는 장점이있다.


@NamedQuery(
        name="Member.findByUsername",
        query="select m from Member m where m.username = :username")
public class Member {
 ....
}

//순수 JPA 로 Namedquery 호출
public class MemberRepository {
 	public List<Member> findByUsername(String username){
 ...
 List<Member> resultList =
 em.createNamedQuery("Member.findByUsername", Member.class)
 .setParameter("username", username)
 .getResultList();
 	}
}

//data jpa 로 Namedquery 호출
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
    @Query(name = "Member.findByUsername") //entity의 namedquery를 자동으로 확인하기 때문에 생략가능
    List<Member> findByUsername(@Param("username") String username);
}

참고: 스프링 데이터 JPA를 사용하면 실무에서 Named Query를 직접 등록해서 사용하는 일은 드물다고 한다. 대신 @Query 를 사용해서 리파지토리 메소드에 쿼리를 직접 정의한다

@Query 어노테이션 안에 JPQL

간단하게 작성가능, 컴파일 시점에 오류를 잡아준다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);

    @Query("select m from Member m where m.username = :username and m.age = :age")
    List<Member> findUser(@Param("username") String username, @Param("age") int age);

}

참고: 실무에서는 메소드 이름으로 쿼리 생성 기능은 파라미터가 증가하면 메서드 이름이 매우 지저분해진다 따라서 @Query 기능을 자주 사용하게 된다고한다.

DTO로 직접 조회

DTO로 직접 조회하려먼 JPA 의 new 명령어를 사용해야 한다

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);

    @Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
    List<MemberDto> findMemberDto();;
}

@Data
public class MemberDto {
    private Long id;
    private String username;
    private String teamName;
    public MemberDto(Long id, String username, String teamName) {
        this.id = id;
        this.username = username;
        this.teamName = teamName;
    }
}

유연한 반환타입

public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findListByUsername(String name); //컬렉션
Member findOneByUsername(String name); //단건
Optional<Member> findOptionalByUsername(String name); //단건 Optional
}

결과가 있을수도 있고 없을수도 있다면 Optional 타입을 쓰는게 좋다
반환타입이 컬렉션인데 결과가 없으면 빈 컬렉션을 반환한다
단건조회인데 결과가 없으면 null, 결과가 2건이상이면 javax.persistence.NonUniqueResultException 예외 발생

파라미터 바인딩


select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반


public interface MemberRepository extends JpaRepository<Member, Long> {
 @Query("select m from Member m where m.username = :name")
 Member findMembers(@Param("name") String username);
}
 @Query("select m from Member m where m.username in :names")
 List<Member> findByNames(@Param("names") List<String> names);
}

코드 가독성과 유지보수를 위해 이름 기반을 우선으로 하자

순수 JPA 페이징

public List<Member> findByPage(int age, int offset, int limit) {
        return em.createQuery("select m from Member m where m.age = :age order by m.username desc")
                        .setParameter("age", age)
                        .setFirstResult(offset)
                        .setMaxResults(limit)
                        .getResultList();
    }
    public long totalCount(int age) {
        return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
                .setParameter("age", age)
                .getSingleResult();
    }

Spring data JPA 페이징과 정렬

public interface MemberRepository extends Repository<Member, Long> {
	Page<Member> findByAge(int age, Pageable pageable); 
	//Slice 타입으로 선언한다면, total count 쿼리를 줄일수있다. 
	//limit +1 까지만 count
	Page<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용
	Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
	List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
	List<Member> findByUsername(String name, Sort sort);
}
@Test
public void page() throws Exception {
//given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
//when
PageRequest pageRequest = PageRequest.of(0, 3, [Sort.by](http://sort.by/)(Sort.Direction.DESC,
"username")); //(현재페이지, 조회할 데이터수,정렬정보)
Page<Member> page = memberRepository.findByAge(10, pageRequest);
//then
List<Member> content = page.getContent(); //조회된 데이터
assertThat(content.size()).isEqualTo(3); //조회된 데이터 수
assertThat(page.getTotalElements()).isEqualTo(5); //전체 데이터 수
assertThat(page.getNumber()).isEqualTo(0); //페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); //전체 페이지 번호
assertThat(page.isFirst()).isTrue(); //첫번째 항목인가?
assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
}

두 번째 파라미터로 받은 Pageable 은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 PageRequest 객체를 사용한다
주의: Page는 1부터 시작이 아니라 0부터 시작이다.

countQuery 와 value 를 분리함으로써 , total count 의 쿼리는 join 이 일어나지 않도록 성능을 최적화 할수있다. 실무에서 매우 중요!!

@Query(value = "select m from Member m",
countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);

페이지를 유지하면서 엔티티를 DTO 로 변환하여 결과를 보내고자 할때

Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

벌크성 수정 쿼리

//순수JPA를 사용한 벌크성 수정 쿼리
public int bulkAgePlus(int age) {
int resultCount = em.createQuery("update Member m set m.age = m.age + 1" +"where m.age >= :age")
	.setParameter("age", age)
	.executeUpdate();
return resultCount;
}
//Spring Data JPA 를 사용한 벌크성 수정 쿼리
@Modifying(clearAutomatically = true) //update 쿼리는 요게 있어야함 // claerAutomatically true하면 em.clear() 필요없어짐
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

clear() 없이 findById 로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자

1+N 문제

@Query("select m from Member m left join fetch m.team")
    //1 + N 문제를 해결하기 위해 fetch join 활용
    List<Member> findMemberFetchJoin();

@EntityGraph활용

@Override
@EntityGraph(attributePaths = {"team"})// JPQL 짜기 싫고, fetch join을 넣어야 한다면
List<Member> findAll();


@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m") // jpql 에 fetch join 만 추가하고싶어
List<Member> findMemberEntityGraph();

@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username)

NamedEntityGraph - entity 쪽에서 설정

@NamedEntityGraph(name = "Member.all", attributeNodes =@NamedAttributeNode("team"))
@Entity
public class Member {
}


@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

@QueryHints

JPA 구현체에게 제공되는 힌트이다

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true")) //변경감지를 안함
 Member findReadOnlyByUsername(String username);
 
 @QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly",
 value = "true")},
 forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);
 

forCounting : 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트 적용(기본값 true )

섹션 5 - 확장 기능

사용자 정의 리포지토리 구현

복잡한 Query 가 필요하여 interface안에서 @Query 로만 해결이 불가능할때(entitymanager 등 구현체에서 필요한것들이있을때) ,interface를 구현해야 하는데 , 스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많다. 따라서,새로운 interface를 만들어서 interface를 구현하고 구현체를 작성하여 extends하거나 그냥 새로운 Repository 를 생성 해야한다.(핵심 비지니스 로직을 잘따져서 선택해야함)

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {

}
public interface MemberRepositoryCustom { //사용자 정의 인터페이스
	List<Member> findMemberCustom();
}
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom { //Impl 이름규칙
	private final EntityManager em;
	@Override
	public List<Member> findMemberCustom() {
		return em.createQuery("select m from Member m").getResultList();
	}
}

Auditing

엔티티를 생성, 변경할 때 변경한 사람과 시간을 남긴다

순수 JPA 사용

@MappedSuperclass //상속느낌
@Getter
public class JpaBaseEntity {
	@Column(updatable = false)
	private LocalDateTime createdDate;
	private LocalDateTime updatedDate;
	@PrePersist
	public void prePersist() {
	LocalDateTime now = LocalDateTime.now();
	createdDate = now;
	updatedDate = now;
	}
	@PreUpdate
	public void preUpdate() {
	updatedDate = LocalDateTime.now();
	}
}

public class Member extends JpaBaseEntity {}

스프링 데이터 JPA 사용

@EntityListeners(AuditingEntityListener.class) //이걸 생략하고 orm.xml에 등록하면 전체 적용이가능하다
@MappedSuperclass
public class BaseEntity {
	@CreatedDate
	@Column(updatable = false)
	private LocalDateTime createdDate;
	@LastModifiedDate
	private LocalDateTime lastModifiedDate;
	@CreatedBy
	@Column(updatable = false)
	private String createdBy;
	@LastModifiedBy
	private String lastModifiedBy;
}
@EnableJpaAuditing //(modifyOnCreate =false)를 넣으면 저장시점에 저장데이터만 입력
@SpringBootApplication
public class DataJpaApplication {
	public static void main(String[] args) {
	SpringApplication.run(DataJpaApplication.class, args);
	}
	@Bean //등록자와 수정자를 처리해준다
	public AuditorAware<String> auditorProvider() {
	return () -> Optional.of(UUID.randomUUID().toString());
	}
}
public class BaseTimeEntity {
	@CreatedDate
	@Column(updatable = false)
	private LocalDateTime createdDate;
	@LastModifiedDate
	private LocalDateTime lastModifiedDate;
}

public class BaseEntity extends BaseTimeEntity {
	@CreatedBy
	@Column(updatable = false)
	private String createdBy;
	@LastModifiedBy
	private String lastModifiedBy;
}

도메인 클래스 컨버터

@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Long id) {   
	 Member member = memberRepository.findById(id).get();    
	 return member.getUsername();
}

@GetMapping("/members2/{id}")
public String findMember2(@PathVariable("id") Member member) { //도메인 클래스 컨버터 (조회용으로만 써야함)    
	 return member.getUsername();
}

페이징과 정렬

스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다

@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
	Page<Member> page = memberRepository.findAll(pageable);
	return page;
}

요청 파라미터 예) /members?page=0&size=3&sort=id,desc&sort=username,desc
page: 현재 페이지, 0부터 시작한다.
size: 한 페이지에 노출할 데이터 건수
sort: 정렬 조건을 정의한다. 예) 정렬 속성,정렬 속성...(ASC | DESC)

글로벌 설정법

spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/

개별설정법

@RequestMapping(value = "/members_page", method = RequestMethod.GET)
public String list(@PageableDefault(size = 12, sort = "username",direction = Sort.Direction.DESC) Pageable pageable) {
...
}

페이징 정보가 둘 이상일때

예제: /members?member_page=0&order_page=1
public String list(
@Qualifier("member") Pageable memberPageable,
@Qualifier("order") Pageable orderPageable, ...

page.map()을 사용해서 DTO 를 반환하도록 하자!

@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
	Page<Member> page = memberRepository.findAll(pageable);
	Page<MemberDto> pageDto = page.map(MemberDto::new);
	return pageDto;
}

Page를 1부터 시작하기

스프링 데이터는 Page를 0부터 시작한다.
만약 1부터 시작하려면?

  1. Pageable, Page를 파리미터와 응답 값으로 사용히지 않고, 직접 클래스를 만들어서 처리한다. 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다. 물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.
  2. spring.data.web.pageable.one-indexed-parameters 를 true 로 설정한다. 그런데 이 방법은
    web에서 page 파라미터를 -1 처리 할 뿐이다. 따라서 응답값인 Page 에 모두 0 페이지 인덱스를 사용하는 한계가 있다

섹션 6 - 스프링 데이터 JPA 분석

MemberRepository (interface) → extends -> JpaRepository(interface) → implements -> SimpleJpaRepository(구현체)

스프링 데이터 JPA 구현체 분석

@Repository 를 적용함으로써 JPA 예외를 스프링이 추상화한 예외로 변환

@Transactional(readOnly= true)가 기본으로 설정되어있음 → flush 생략

@Transactional 적용으로 등록, 수정 ,삭제 메서드를 처리

save() 메서드 :새로운 엔티티면 저장(persist), 새로운 엔티티가 아니면 병합(merge)

새로운 엔티티를 구별하는 방법

JPA 식별자 생성 전략이 @GenerateValue 면 save() 호출 시점에 식별자가 없으므로 새로운 엔티티로 인식해서 정상 동작한다. 그런데 JPA 식별자 생성 전략이 @Id 만 사용해서 직접 할당이면 이미 식별자 값이 있는 상태로 save() 를 호출한다. 따라서 이 경우 merge() 가 호출된다. merge() 는 우선 DB를 호출해서 값을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율 적이다. 따라서 Persistable 를 사용해서 새로운 엔티티 확인 여부를 직접 구현하게는 효과적이다.

등록시간( @CreatedDate )을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할수 있다. (@CreatedDate에 값이 없으면 새로운 엔티티로 판단)

@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {

	 @Id
	 private String id;

	 @CreatedDate
	 private LocalDateTime createdDate;

	 public Item(String id) {
		 this.id = id;
	 }
	 @Override
	 public String getId() {
		 return id;
	 }
	 @Override
	 public boolean isNew() {
		 return createdDate == null;
	 }
}

섹션 7 - 나머지 기능들

Specifications(명세) - 구현하면 JPA criteria 를 활용해서 사용가능

실무에서는 JPA criteria 를 거의 안쓴다! 대신에 QueryDSL 을 사용하자.

Query By Example - 도메인 객체를 그대로 사용한다(Example)

실무에서 사용하기에는 매칭조건이 너무 단순하고, LEFT조인이 안됨

실무에서는 QueryDSL 을 사용하자

Projections - 실무의 복잡한 쿼리를 해결하기에는 한계가 있다. QueryDSL!

네이티브 쿼리 - QueryDSL!

profile
잊지 않기 위해 기록하기

0개의 댓글