스프링 데이터 JPA

스프링 데이터 JPA

생성일
Jan 2, 2023 05:06 AM
최종 편집 일시
Last updated October 16, 2024
태그
JAVA
SPRING

기본 설정

notion image
smwef@sm  ~/Desktop/study/data-jpa  ./gradlew dependencies --configuration compileClasspath Welcome to Gradle 7.5.1! Here are the highlights of this release: - Support for Java 18 - Support for building with Groovy 4 - Much more responsive continuous builds - Improved diagnostics for dependency resolution For more details see https://docs.gradle.org/7.5.1/release-notes.html > Task :dependencies ------------------------------------------------------------ Root project 'data-jpa' ------------------------------------------------------------ compileClasspath - Compile classpath for source set 'main'. +--- org.projectlombok:lombok -> 1.18.24 +--- org.springframework.boot:spring-boot-starter-data-jpa -> 2.7.6 | +--- org.springframework.boot:spring-boot-starter-aop:2.7.6 | | +--- org.springframework.boot:spring-boot-starter:2.7.6 | | | +--- org.springframework.boot:spring-boot:2.7.6 | | | | +--- org.springframework:spring-core:5.3.24 | | | | | \--- org.springframework:spring-jcl:5.3.24 | | | | \--- org.springframework:spring-context:5.3.24 | | | | +--- org.springframework:spring-aop:5.3.24 | | | | | +--- org.springframework:spring-beans:5.3.24 | | | | | | \--- org.springframework:spring-core:5.3.24 (*) | | | | | \--- org.springframework:spring-core:5.3.24 (*) | | | | +--- org.springframework:spring-beans:5.3.24 (*) | | | | +--- org.springframework:spring-core:5.3.24 (*) | | | | \--- org.springframework:spring-expression:5.3.24 | | | | \--- org.springframework:spring-core:5.3.24 (*) | | | +--- org.springframework.boot:spring-boot-autoconfigure:2.7.6 | | | | \--- org.springframework.boot:spring-boot:2.7.6 (*) | | | +--- org.springframework.boot:spring-boot-starter-logging:2.7.6 | | | | +--- ch.qos.logback:logback-classic:1.2.11 | | | | | +--- ch.qos.logback:logback-core:1.2.11 | | | | | \--- org.slf4j:slf4j-api:1.7.32 -> 1.7.36 | | | | +--- org.apache.logging.log4j:log4j-to-slf4j:2.17.2 | | | | | +--- org.slf4j:slf4j-api:1.7.35 -> 1.7.36 | | | | | \--- org.apache.logging.log4j:log4j-api:2.17.2 | | | | \--- org.slf4j:jul-to-slf4j:1.7.36 | | | | \--- org.slf4j:slf4j-api:1.7.36 | | | +--- jakarta.annotation:jakarta.annotation-api:1.3.5 | | | +--- org.springframework:spring-core:5.3.24 (*) | | | \--- org.yaml:snakeyaml:1.30 | | +--- org.springframework:spring-aop:5.3.24 (*) | | \--- org.aspectj:aspectjweaver:1.9.7 | +--- org.springframework.boot:spring-boot-starter-jdbc:2.7.6 | | +--- org.springframework.boot:spring-boot-starter:2.7.6 (*) | | +--- com.zaxxer:HikariCP:4.0.3 | | | \--- org.slf4j:slf4j-api:1.7.30 -> 1.7.36 | | \--- org.springframework:spring-jdbc:5.3.24 | | +--- org.springframework:spring-beans:5.3.24 (*) | | +--- org.springframework:spring-core:5.3.24 (*) | | \--- org.springframework:spring-tx:5.3.24 | | +--- org.springframework:spring-beans:5.3.24 (*) | | \--- org.springframework:spring-core:5.3.24 (*) | +--- jakarta.transaction:jakarta.transaction-api:1.3.3 | +--- jakarta.persistence:jakarta.persistence-api:2.2.3 | +--- org.hibernate:hibernate-core:5.6.14.Final | | +--- org.jboss.logging:jboss-logging:3.4.3.Final | | +--- net.bytebuddy:byte-buddy:1.12.18 -> 1.12.19 | | +--- antlr:antlr:2.7.7 | | +--- org.jboss:jandex:2.4.2.Final | | +--- com.fasterxml:classmate:1.5.1 | | +--- org.hibernate.common:hibernate-commons-annotations:5.1.2.Final | | | \--- org.jboss.logging:jboss-logging:3.3.2.Final -> 3.4.3.Final | | \--- org.glassfish.jaxb:jaxb-runtime:2.3.1 -> 2.3.7 | | +--- jakarta.xml.bind:jakarta.xml.bind-api:2.3.3 | | +--- org.glassfish.jaxb:txw2:2.3.7 | | \--- com.sun.istack:istack-commons-runtime:3.0.12 | +--- org.springframework.data:spring-data-jpa:2.7.6 | | +--- org.springframework.data:spring-data-commons:2.7.6 | | | +--- org.springframework:spring-core:5.3.24 (*) | | | +--- org.springframework:spring-beans:5.3.24 (*) | | | \--- org.slf4j:slf4j-api:1.7.32 -> 1.7.36 | | +--- org.springframework:spring-orm:5.3.24 | | | +--- org.springframework:spring-beans:5.3.24 (*) | | | +--- org.springframework:spring-core:5.3.24 (*) | | | +--- org.springframework:spring-jdbc:5.3.24 (*) | | | \--- org.springframework:spring-tx:5.3.24 (*) | | +--- org.springframework:spring-context:5.3.24 (*) | | +--- org.springframework:spring-aop:5.3.24 (*) | | +--- org.springframework:spring-tx:5.3.24 (*) | | +--- org.springframework:spring-beans:5.3.24 (*) | | +--- org.springframework:spring-core:5.3.24 (*) | | \--- org.slf4j:slf4j-api:1.7.32 -> 1.7.36 | \--- org.springframework:spring-aspects:5.3.24 | \--- org.aspectj:aspectjweaver:1.9.7 \--- org.springframework.boot:spring-boot-starter-web -> 2.7.6 +--- org.springframework.boot:spring-boot-starter:2.7.6 (*) +--- org.springframework.boot:spring-boot-starter-json:2.7.6 | +--- org.springframework.boot:spring-boot-starter:2.7.6 (*) | +--- org.springframework:spring-web:5.3.24 | | +--- org.springframework:spring-beans:5.3.24 (*) | | \--- org.springframework:spring-core:5.3.24 (*) | +--- com.fasterxml.jackson.core:jackson-databind:2.13.4.2 | | +--- com.fasterxml.jackson.core:jackson-annotations:2.13.4 | | | \--- com.fasterxml.jackson:jackson-bom:2.13.4 | | | +--- com.fasterxml.jackson.core:jackson-annotations:2.13.4 (c) | | | +--- com.fasterxml.jackson.core:jackson-core:2.13.4 (c) | | | +--- com.fasterxml.jackson.core:jackson-databind:2.13.4 -> 2.13.4.2 (c) | | | +--- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.4 (c) | | | +--- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.4 (c) | | | \--- com.fasterxml.jackson.module:jackson-module-parameter-names:2.13.4 (c) | | +--- com.fasterxml.jackson.core:jackson-core:2.13.4 | | | \--- com.fasterxml.jackson:jackson-bom:2.13.4 (*) | | \--- com.fasterxml.jackson:jackson-bom:2.13.4 (*) | +--- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.4 | | +--- com.fasterxml.jackson.core:jackson-core:2.13.4 (*) | | +--- com.fasterxml.jackson.core:jackson-databind:2.13.4 -> 2.13.4.2 (*) | | \--- com.fasterxml.jackson:jackson-bom:2.13.4 (*) | +--- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.4 | | +--- com.fasterxml.jackson.core:jackson-annotations:2.13.4 (*) | | +--- com.fasterxml.jackson.core:jackson-core:2.13.4 (*) | | +--- com.fasterxml.jackson.core:jackson-databind:2.13.4 -> 2.13.4.2 (*) | | \--- com.fasterxml.jackson:jackson-bom:2.13.4 (*) | \--- com.fasterxml.jackson.module:jackson-module-parameter-names:2.13.4 | +--- com.fasterxml.jackson.core:jackson-core:2.13.4 (*) | +--- com.fasterxml.jackson.core:jackson-databind:2.13.4 -> 2.13.4.2 (*) | \--- com.fasterxml.jackson:jackson-bom:2.13.4 (*) +--- org.springframework.boot:spring-boot-starter-tomcat:2.7.6 | +--- jakarta.annotation:jakarta.annotation-api:1.3.5 | +--- org.apache.tomcat.embed:tomcat-embed-core:9.0.69 | +--- org.apache.tomcat.embed:tomcat-embed-el:9.0.69 | \--- org.apache.tomcat.embed:tomcat-embed-websocket:9.0.69 | \--- org.apache.tomcat.embed:tomcat-embed-core:9.0.69 +--- org.springframework:spring-web:5.3.24 (*) \--- org.springframework:spring-webmvc:5.3.24 +--- org.springframework:spring-aop:5.3.24 (*) +--- org.springframework:spring-beans:5.3.24 (*) +--- org.springframework:spring-context:5.3.24 (*) +--- org.springframework:spring-core:5.3.24 (*) +--- org.springframework:spring-expression:5.3.24 (*) \--- org.springframework:spring-web:5.3.24 (*)

핵심 라이브러리

  • 스프링 MVC
  • 스프링 ORM
  • JPA, Hibernate
  • 스프링 데이터 JPA

기타 라이브러리

  • H2 데이터베이스 클라이언트
  • 커넥션 풀: HikariCP
  • 로깅: SLF4J & LogBack
  • 테스트 Junit5
  • ** JPA에서 수정은 변경감지(더티체킹) 기능을 사용한다. ***
  • 트랜잭션 안에서 엔티티를 조회한 다음 데이터를 변경하면, 트랜잭션 종료시 변경감지를 해서 변경된 엔티티를 감지하여 UPDATE를 날린다.

공통 인터페이스 설정

  • 스프링 부트 사용시 @SpringBootApplication 위치를 지정 (해당 패키지와 하위 패키지 인식)
  • 위치가 달라질 경우에는 @EnableJpaRepositories가 필요함.
@SpringBootApplication(scanBasePackages = { "com.모자이크.seoul.api", "com.모자이크.seoul.common" }) @EnableCaching @EnableJpaAuditing @EnableAspectJAutoProxy @EntityScan("com.모자이크.seoul.common") @Slf4j //@EnableJpaRepositories("com.모자이크.seoul.common") public class ApiApplication { public static void main(String[] args) { SpringApplication.run(ApiApplication.class, args); } }
  • api 어플리케이션과 Entity 패키지가 분리되어 있기 때문에 scanBasePackages에 common을 추가해 준 모습이다.
  • 위에서 스캔한 BasePackages 속성에 common이 들어가 있기 때문에 @EnableJpaRepositories는 없어도 됨.

공통 인터페이스 분석

  • JpaRepository 인터페이스: 공통 CRUD 제공
  • 제네릭은 <T, ID> (T: 엔티티 타입, ID: 식별자 타입(PK))
    • notion image
  • S: 엔티티와 그 자식 타입

주요 메서드

  • save(S): 새로운 엔티티는 저장하고 이미 있는 엔티티는 병합한다.
  • delete(T): 엔티티 하나를 삭제한다. 내부에서 EntityManager.remove() 호출
  • findById(ID): 엔티티 하나를 조회. 내부에서 EntityManager.find() 호출
  • getOne(ID): 엔티티를 ‘프록시’로 조회한다. 내부에서 EntityManager.getReference() 호출
  • findAll(): 모든 엔티티를 조회한다. 정렬(Sort), 페이징(Pageable) 조건을 파라미터로 제공할 수 있다.

쿼리 메소드 기능

  • 연습용 프로젝트는 현재 LTS인 2.7.6으로 했지만 우리 프로젝트는 2.4.3이다.

순수 JPA NamedQuery

  • 엔티티에 @NamedQuery(name=””, query”jpql”)로 정의
  • repository에서 .createNamedQuery(”name에 정의한 이름”, 리턴타입) 으로 사용.
@Entity @Getter @Setter // 가급적 Setter는 사용하지 않는다. @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString(of = {"id", "username", "age"}) @NamedQuery( name="Member.findByUsername" query="select m from Member m where m.username = :username" ) public class Member { // repository public List<Member> findByUsername(String username) { return em.createNamedQuery("Member.findByUsername", Member.class) .setParameter("username", username) .getResultList(); }

spring-data-jpa를 사용할 경우

@Query(name = "Member.findByUsername") List<Member> findByUsername(@Param("username") String username);
@NamedQuery가 좋은 이유는 애플리케이션 로딩 시점에 에러를 띄워주기 때문이다.
그냥 createQuery로 jpql을 짰을 경우에는 그 기능을 실행했을 때만 에러를 알 수 있다.

@Query 사용법

  • 레포지토리 메소드에 쿼리를 정의하는 방법
@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("select m.username from Member m") List<String> findUsernameList();
  • DTO로 조회하는 경우 jpql의 new operation을 이용해야 한다. (select 안에 DTO 객체의 경로를 넣어서 감싸줘야 함.)
// DTO로 조회하기 @Query("select new study.datajpa.dto.MemberDTO(m.id, m.username, t.name) from Member m join m.team t") List<MemberDto> findMemberDto();

파라미터 바인딩

  • 위치 기반 (?0 ?1 → 유지보수를 위해 가급적 이름 기반을 쓰는 게 낫다.)
  • 이름 기반
@Query("select m from Member m where m.username in :names") List<Member> findByNames(@Param("names") Collection<String> names);
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.username in ('AAA' , 'BBB');
이런식으로 sql이 나가게 된다.

반환 타입

  • 유연한 반환 타입을 지원한다.
List<> / 객체 / Optional 다 반환된다.
  • 주의해야 할 점은 만약 컬렉션을 반환하는 경우에 만약 파라미터를 잘못 넣어서 결과가 없는 경우에 null이 아니고 emptyCollection이 반환된다는 점에 주의해야 함.
  • 결론적으로 Optional을 쓰는 게 마음이 편해진다.
  • optional을 썼는데 두 개 이상의 결과가 반환된다면 NonUniqueResultException이 터진다. 근데 spring-data-jpa에서는 IncorrectResultSizeDataAccessException으로 변환해서 예외처리를 해준다.

순수 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", Member.class) .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(); }
이런 식으로 offset, limit 지정하고 TotalCount 쿼리를 따로 짜서 활용해야 한다.

스프링 데이터 JPA 페이징과 정렬

  • 페이징과 정렬 파라미터
    • org.springframework.data.domin.Sort: 정렬 기능
    • org.springframework.data.domain.Pageable: 페이징 기능 (내부에 Sort 포함)
  • 특별한 반환 타입
    • org.springframework.data.domain.Page: 추가 count 쿼리 결과를 포함하는 페이징
    • org.springframework.data.domain.Slice: 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1 조회)
    • List : 추가 count 쿼리 없이 결과만 반환.
    • //repository Page<Member> findByAge(int age, Pageable pageable); @Test public void paging() { // 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)); memberRepository.save(new Member("member6", 10)); int age = 10; PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username")); //when Page<Member> page = memberRepository.findByAge(age, pageRequest); //then List<Member> content = page.getContent(); long totalElements = page.getTotalElements(); assertThat(content.size()).isEqualTo(3); assertThat(page.getTotalElements()).isEqualTo(6); assertThat(page.getNumber()).isEqualTo(0); assertThat(page.getTotalPages()).isEqualTo(2); assertThat(page.isFirst()).isTrue(); assertThat(page.hasNext()).isTrue(); }
  • PageRequest 객체로 페이징 설정을 만든다.
  • findByAge로 조회한 뒤 Page 객체에 pageRequest와 함께 보낸다.
    • * 주의 ** Page는 인덱스가 0부터 시작이다.
  • List는 page의 getContent로 받아올 수 있다.
  • 전체 데이터 개수는 getTotalElements()로 받아올 수 있다.
    • * 주의 ** 원 쿼리가 조인이 많고 복잡한 경우에는 레포지토리 인터페이스에 성능을 위해서 카운트 쿼리를 분리해 주는 것이 좋다.
    • // Page 인터페이스 구조 public interface Page<T> extends Slice<T> { int getTotalPages(); //전체 페이지 수 long getTotalElements(); //전체 데이터 수 <U> Page<U> map(Function<? super T, ? extends U> converter); //변환기 } // Slice 인터페이스 구조 public interface Slice<T> extends Streamable<T> { int getNumber(); //현재 페이지 int getSize(); //페이지 크기 int getNumberOfElements(); //현재 페이지에 나올 데이터 수 List<T> getContent(); //조회된 데이터 boolean hasContent(); //조회된 데이터 존재 여부 Sort getSort(); //정렬 정보 boolean isFirst(); //현재 페이지가 첫 페이지 인지 여부 boolean isLast(); //현재 페이지가 마지막 페이지 인지 여부 boolean hasNext(); //다음 페이지 여부 boolean hasPrevious(); //이전 페이지 여부 Pageable getPageable(); //페이지 요청 정보 Pageable nextPageable(); //다음 페이지 객체 Pageable previousPageable();//이전 페이지 객체 <U> Slice<U> map(Function<? super T, ? extends U> converter); //변환기 }
      page가 slice를 상속하고 있으므로 slice가 더 상위 개념이다.
      // count 쿼리 분리하는 예시 @Query(value = “select m from Member m”, countQuery = “select count(m.username) from Member m”) Page<Member> findMemberAllCountBy(Pageable pageable);
      이런식으로 분리를 해주어야 복잡한 원쿼리랑 똑같은 쿼리로 카운트를 날려서 성능이 떨어지는 문제를 해결할 수 있다.
    • * 만약 sorting을 pageRequest의 파라미터로 처리하는 것이 힘든 경우에도 쿼리메소드 기능을 이용해서 jpql 식으로 소팅을 해주면 해결할 수 있다. **
    • Page 객체를 DTO로 변환하기

      page.map을 이용하면 된다.
      Page<Member> page = memberRepository.findByAge(age, pageRequest); Page<MemberDTO> toMap = page.map(m -> new MemberDTO(m.getId(), m.getUsername(), null));

      벌크성 수정 쿼리

      순수 JPA의 케이스.
      member 테이블 엔티티들의 age가 20이상인 경우에는 전체적으로 age를 +1 해줄 것임.
      // 레포지토리 public int bulkAgePlus(int age) { return em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age") .setParameter("age", age) .executeUpdate(); } // 테스트 쪽 @Test public void bulkUpdate() { // given memberJpaRepository.save(new Member("member1", 10)); memberJpaRepository.save(new Member("member1", 19)); memberJpaRepository.save(new Member("member1", 20)); memberJpaRepository.save(new Member("member1", 25)); memberJpaRepository.save(new Member("member1", 40)); // when int resultCount = memberJpaRepository.bulkAgePlus(20); //then assertThat(resultCount).isEqualTo(3); }
      Spring-data-jpa를 이용하는 경우
      // 레포지토리 인터페이스 @Modifying @Query("update Member m set m.age = m.age + 1 where m.age >= :age") int bulkAgePlus(@Param("age") int age); // 테스트
      인터페이스에 반환 타입을 int로 해주고 @Modifying 어노테이션을 넣어주면 똑같이 동작한다.
      @Test public void bulkUpdate() { // given memberRepository.save(new Member("member1", 10)); memberRepository.save(new Member("member2", 19)); memberRepository.save(new Member("member3", 20)); memberRepository.save(new Member("member4", 25)); memberRepository.save(new Member("member5", 40)); // when int resultCount = memberRepository.bulkAgePlus(20); //then assertThat(resultCount).isEqualTo(3); }
      modifying을 안넣어주면 익셉션이 터진다.
    • ** JPA는 영속성 컨텍스트를 관리하는데 벌크성 업데이트는 영속성 컨텍스트를 거치지 않고 바로 DB에 넣어버리는 거라 영속성 컨텍스트와 DB 사이의 정합성이 깨질 수 있다는 점에 대해 주의해야 한다.
    • → 만약 위 테스트의 resultCount 다음에 member5를 조회해보면 41이 아닌 40살이 나온다.
      이를 해결하려면… 아래와 같이 해야 한다.
      // 필드쪽 @PersistenceContext EntityManager entityManager; // 영속성 컨텍스트 ... // when int resultCount = memberRepository.bulkAgePlus(20); em.clear();
      em.flush()는 영속성 컨텍스트를 디비에 반영하는 기능.
      em.clear()는 영속성 컨텍스트를 초기화 하는 기능.
      따라서 DB에는 이미 영속성 컨텍스트를 거치지 않고 반영을 때려버렸으니 em.clear()로 영속성 컨텍스트를 지워주면 의도했던 대로 41살을 출력하게 된다.
      하지만 이렇게 직접 호출하는 경우는 거의 없고 인터페이스 쪽에 어노테이션을 추가하여 자동으로 clear()를 하게 해주는 방식을 사용한다.
      @Modifying(clearAutomatically = true) @Query("update Member m set m.age = m.age + 1 where m.age >= :age") int bulkAgePlus(@Param("age") int age);
      modifying 어노테이션의 속성에 clearAutomatically = true를 주면 자동으로 em.clear를 해준다.

      @EntityGraph ??

      JPA의 fetch join을 어노테이션으로 처리하는 것.
      // repository @Query("select m from Member m left join fetch m.team") List<Member> findMemberFetchJoin(); // test //when N + 1 List<Member> members = memberRepository.findMemberFetchJoin();
      이렇게 해서 실행하면
      select member0_.member_id as member_i1_0_0_, team1_.team_id as team_id1_1_1_, member0_.age as age2_0_0_, member0_.team_id as team_id4_0_0_, member0_.username as username3_0_0_, team1_.name as name2_1_1_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id
      select 절에 team.name 까지 한꺼번에 가져오는 쿼리를 내보낸다. (Member 엔티티 안의 team 엔티티가 Proxy 객체가 아닌 진짜 team 엔티티로 다 채워서 가져온다고 보면 된다.)
      따라서 fetch join을 한 경우에 member.getTeam().getClass()를 해보면 $Proxy이런 객체가 찍히는 게 아니고 datajpa.entity.Team이 찍히게 된다.
      이를 엔티티그래프로 변경한다면
      @Override @EntityGraph(attributePaths = {"team"}) List<Member> findAll(); // 또는 jpql에 추가할 수도 있다. @EntityGraph(attributePaths = {"team"}) @Query("select m from Member m") List<Member> findMemberEntityGraph(); // 그냥 메서드에 갖다 붙여도 된다. @EntityGraph(attributePaths = {"team"}) List<Member> findEntityGraphByUsername(@Param("username") String username);
      member의 findAll 로 가져올 때 team까지 땡겨서 가져오고 싶다면 위와 같이 @EntityGraph를 써주면 된다.
      @NamedQuery처럼 @NamedEntityGraph를 엔티티 클래스에 어노테이션으로 달아서 쓸 수도 있다.

      api-server의 UserInfo의 연관관계를 보자.

      @OneToOne(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) private Point point; @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<Log> logList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<Blacklist> blacklists = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<AllowAd> allowAdList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @OrderBy("apply_date desc") private final List<UserGradeHistory> userGradeHistoryList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) private final List<InvestorHistory> investorHistoryList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<InvestorLoanHistory> investorLoanHistoryList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) private final List<Account> accountList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo") private final List<Investment> investmentList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<UserInfoHistory> userInfoHistoryList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "parent", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<ChildHistory> childHistoryList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo") private final List<WefUser> wefUserList = new ArrayList<>(); @OneToMany(fetch = FetchType.EAGER, mappedBy = "referee", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<UserInvitation> invitationList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "invitation", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<UserInvitation> refereeList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<RepaymentSchedule> repaymentScheduleList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<KycHistory> kycHistoryList = new ArrayList<>(); @OneToMany(fetch = FetchType.LAZY, mappedBy = "userInfo", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private final List<VipHistory> vipHistoryList = new ArrayList<>();
      무수한 연관관계 매핑이 되어 있다.
      중간에 invitationList가 EAGER로 매핑되어 있는데… 이러면 안되지 않나… 싶긴 하다.
      public interface UserInfoRepository extends JpaRepository<UserInfo, Long> { Optional<UserInfo> findByRecommendCode(String recommendCode); // @EntityGraph(attributePaths = {"point", "point.pointHistoryList"}) // @Query("select distinct u from UserInfo u where u.name = :name and u.phone = :phone") // Optional<UserInfo> findByNameAndPhoneWithPoint(String name, String phone); // // @EntityGraph(attributePaths = {"point"}) // Optional<UserInfo> findByUserInfoId(Long userInfoId); List<UserInfo> findAllByInvestorTypeNot(InvestorType investorType); List<UserInfo> findAllByInvestorTypeIsIn(List<InvestorType> investorType); @Override @EntityGraph(attributePaths = "point") Optional<UserInfo> findById(Long id); @Override @EntityGraph(attributePaths = "point") List<UserInfo> findAll(); boolean existsByRecommendCode(String recommendCode); @EntityGraph(attributePaths = {"모자이크UserList"}) @Query(value = "select u, wu " + "from UserInfo u" + " join u.wefUserList wu " + "where wu.snsType = '모자이크'" + " and u.name = :name" + " and u.phone = :phone" ) List<UserInfo> findByNameAndPhone모자이크User(String name, String phone); @EntityGraph(attributePaths = "모자이크UserList") @Query("select u " + "from UserInfo u " + " join u.investorHistoryList ih " + " join ih.investorCategory ic " + "where u.name = :name " + " and u.regNumFirst = :regNumFirst " + " and ih.approvalStatus in ('APPROVED','PENDING') " + " and ic.investorType in :investorTypeList " + " and ih.expireDate > current_timestamp ") List<UserInfo> findByInformationInvestmentUser(String name, String regNumFirst, List<InvestorType> investorTypeList); @EntityGraph(attributePaths = {"point", "accountList"}) @Query("select u from UserInfo u where userInfoId = :userInfoId") Optional<UserInfo> findByIdWithPointWithAccount(long userInfoId); @EntityGraph(attributePaths = {"investmentList", "point", "investmentList.product"}) @Query("select u from UserInfo u where userInfoId = :userInfoId") Optional<UserInfo> findByIdWithInvestment(long userInfoId); @EntityGraph(attributePaths = {"investmentList", "point", "investmentList.product", "investmentList.product.contract", "investmentList.product.contract.interestRepaymentMethod"}) @Query("select u from UserInfo u where userInfoId = :userInfoId") Optional<UserInfo> findByIdWithInvestmentSchedule(long userInfoId); @EntityGraph(attributePaths = { "investmentList", "point", "investmentList.product", "investmentList.product.contract", "investmentList.product.contract.marketOverview", "investmentList.product.contract.marketOverview", "investmentList.product.contract.checkList", "investmentList.product.contract.project" }) @Query("select u from UserInfo u where userInfoId = :userInfoId") Optional<UserInfo> findByIdWithInvestmentWithProductWithContract(long userInfoId); }
      UserInfoRepository를 보면 LAZY 로딩 걸려있는 애들에 다 EntityGraph로 설정되어 있다.
      findByIdWithInvestmentWithProductWithContract 메서드를 보면 entitygraph 안에 .product, .product.contract 이런식으로 깊이가 깊어지는 것들도 한번에 fetch join이 가능함을 알 수 있다.

      JPA Hint & Lock

      @QueryHints로 특성 hibernate 속성을 넘길 수 있는데 극한의 성능 최적화를 위해 쓸 때가 있다.
      하지만 정말 특별한 경우가 아니면 굳이 쓸 일이 없다.
      @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<AccountTransaction> findTopByAccountOrderByTransactionDateDescAccountTransactionIdDesc(Account account);
      @Lock은 우리 코드에서 쓰이는 곳이 딱 한 군데 있다.
      이 개념은 매우매우 심화된 개념인데 일단 쓰이는 곳이 있으니 찾아보겠음. (일반적으로 실시간 트래픽이 많은 경우에는 안쓰는 게 훨씬 낫다. 하지만 잔고를 꼭 맞춰야 한다든지 하는 초ㅗㅗㅗ정합성이 필요한 곳에는 이 락을 거는 것이 맞다고 한다.)
      이 메서드는 가장 최신의 잔고를 가져오는 메서드이기 때문에 @Lock을 거는 것이 필요했던 것 같다.
      accountTransaction 같은 경우에는 투자자가 예치금 출금, 입금 등을 할수도 있고 투자한 내역이 실행되서 투자 실행이 될 수도 있고 모집이 취소가 될수도 있고 포인트가 지급될 수도 있는 등 여러 요소에 의해서 거의 동시에 갱신이 이루어질수도 있다. 이 때 충돌에 의한 경합이 벌어지는데 이에 의한 정합성을 보장하기 위해서 PESSIMISTIC_WRITE를 거는 것이라 볼 수 있다.
    • 비관적 잠금 (PESSIMISTIC LOCK)
      • 비관적 잠금(Pessimistic Lock)

        동일한 데이터를 동시에 수정할 가능성이 높다는 비관적인 전제로 잠금을 거는 방식입니다. 예를 들어 상품의 재고는 동시에 같은 상품을 여러명이 주문할 수 있으므로 데이터 수정에 의한 경합이 발생할 가능성이 높다고 비관적으로 보는 것입니다. 이 경우 충돌감지를 통해서 잠금을 발생시키면 충돌발생에 의한 예외가 자주 발생하게 됩니다. 이럴경우 비관적 잠금을 통해서 예외를 발생시키지 않고 정합성을 보장하는 것이 가능합니다. 다만 성능적인 측면은 손실을 감수해야 합니다. 주로 데이터베이스에서 제공하는 배타잠금(Exclusive Lock)을 사용합니다.

        PESSIMISTIC_WRITE

        데이터베이스에서 제공하는 행 배타잠금(Row Exclusive Lock)을 이용하여 잠금을 획득합니다.

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

      만약에 순수 JPA를 직접 사용하고 싶다거나, JDBC를 쓰거나, 부분적으로 MyBatis를 쓰고 싶거나 할 때 사용하는 방법이다. 보통은 querydsl을 사용하고 싶어서 쓴다.
      또한, 기본 레포지토리가 너무 비대해지고 화면에 맞추기 위한 DTO에 담기 위한 복잡복잡한 쿼리들은 아예 클래스 자체를 분리해서 @Repository 걸고 분리해서 쓰는 게 유지보수에 더 낫다고 한다.
      쓰는 곳에서는 커스텀클래스 레포지토리를 final로 받고 @RequiredArgsConstructor로 인젝션 받아서 쓰는 편이 좋다.
      커맨드와 쿼리의 분리, 핵심비즈니스 로직과 아닌 것을 분리, 라이프사이클에 따라서 분리가 필요한 것 등을 고민하여 쪼개주는 것이 좋다.

      Auditing

      엔티티 생성, 변경 추적 기록
    • 등록일, 수정일, 등록자, 수정자
    • 순수 JPA를 이용할 경우

      @PrePersist, @PreUpdate를 이용한다.
      상속시킬 auditing용 Base엔티티에 @MappedSuperclass를 붙여준다.
      @MappedSuperclass @Getter public class JpaBaseEntity { @Column(updatable = false) // 실수로라도 DB update를 쳐도 반영되지 않음. 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(); } }
      타입은 자바 8 이상이므로 LocalDateTime 쓰면 된다.
      // Member 엔티티 public class Member extends JpaBaseEntity {} // Test @Test public void JpaEventBaseEntity() throws Exception { //given Member member = new Member("member1"); memberRepository.save(member); //@PrePersist 발생 Thread.sleep(100); member.setUsername("member2"); em.flush(); //@PreUpdate 발생 em.clear(); //when Member findMember = memberRepository.findById(member.getId()).get(); //then System.out.println("findMember.getCreatedDate() = " + findMember.getCreatedDate()); System.out.println("findMember.getUpdatedDate() = " + findMember.getUpdatedDate()); }

      스프링 데이터 jpa Auditing 기능

    • 스프링 부트 설정 클래스에 @EnableJpaAuditing 추가
    • baseEntity에 @EntityListeners(AuditingEntityListener.class) 추가
    • 사용 어노테이션
      • @CreatedDate
      • @LastModifiedDate
      • @CreatedBy
      • @LastModifiedBy
    • BaseTimeEntity 에 createdDate, modifiedDate를 넣어주고
    • BaseEntity에는 createdBy, modifiedBy만 넣고 baseTimeEntity를 상속받아서 쓰는 게 더 좋다.
    • @CreatedBy, ModifiedBy를 쓰러면 AuditorAware 인터페이스를 스프링 설정에 빈으로 등록해야 한다.
      • 우리 코드는 api > config > auditoraware에 LoginUserAuditorAware 클래스로 존재한다.
      • 시큐리티 컨텍스트에서 권한이 User일 때와 Anonymous일 때를 구분해서 createdBy를 만들어준다.

Web 확장

도메인 클래스 컨버터

@RestController @RequiredArgsConstructor public classMemberController{ private finalMemberRepositorymemberRepository; @GetMapping("/members/{id}") publicStringfindMember(@PathVariable("id")Long id) { Membermember = memberRepository.findById(id).get(); return member.getUsername(); } @GetMapping("/members2/{id}") publicStringfindMember2(@PathVariable("id")Member member) { return member.getUsername(); } @PostConstruct public void init() { memberRepository.save(new Member("userA")); } }
http 요청은 id를 받지만, 파라미터로 엔티티를 받아도 도메인 클래스 컨버터가 동작해서 알아서 Member 엔티티 객체를 반환해 준 모습이다.

* 주의 **

엔티티를 반환해버렸기 때문에 절대절대 단순 조회용으로만 사용해야 한다.
또한 컨트롤러가 트랜잭션 범위 안에 있지 않기 때문에 엔티티를 변경해도 DB에 반영되지 않는다.

페이징과 정렬

@GetMapping("/members") public Page<Member> list(Pageable pageable) { Page<Member> page = memberRepository.findAll(pageable); return page; }
파라미터로 Pageable 인터페이스를 받을 수 있다. 이걸 받으면 PageRequest 객체를 생성한다.
http 요청을 /members?page=0&size=3&sort=id,desc&sort=username,desc 이런식으로
쿼리파라미터로 page, size, sort를 보낼 수 있다.
페이징에 대한 글로벌 설정은 application.yml에 하면 된다.
data: web: pageable: default-page-size: 10 max-page-size: 2000
페이지 사이즈에 대한 전역 설정을 할 수 있다.
혹은 컨트롤러 메서드에 따로 설정을 하고 싶다면 @PageableDefault를 사용할 수 있다.
@GetMapping("/members2") public Page<Member> list2(@PageableDefault(size = 15, sort = "username", direction = Sort.Direction.DESC) Pageable pageable) { Page<Member> page = memberRepository.findAll(pageable); return page; }
만약 페이징 정보가 둘 이상이면 접두사로 구분할 수 있다.
메서드 파라미터 안에 @Qualifier(”member”) Pageable memberPageable, @Qualifier(”order”) Pageable orderPageable
이런식으로 넣어주고 http 요청은 /members?member_page=0&order_page=1 처럼 쓸 수 있다.

하지만 엔티티를 그대로 노출하는 것은 절대 하면 안됨.

Page<Member> 이딴 거 절대 하지 말라는 것임.
DTO를 만들어서 Page<MemberDTO> 이런식으로 변환해서 반환.

Page를 0이 아닌 1부터 시작하게 하고 싶다면?

  • Pageable을 파라미터로 쓰지 말고 아예 커스텀 PageRequest를 생성해서 레포지토리에 넘겨서 쓰는 방법이 있다. + 이렇게 하면 리턴으로 받을 Page의 대체물도 만들어줘야 한다.
  • yml에 spring.data.web.pageable.one-indexed-parameters: true를 하는 방법도 있긴 하지만… 리스트 내용은 페이지가 1부터가 맞는데 Pageable 에 대한 정보를 담은 JSON 부분은 페이지는 그대로 0부터라는 문제점이 있다. (안쓰는 게 나음)

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

  • save() 메서드
    • 새로운 엔티티면 저장
    • 새로운 엔티티가 아니면 병합
  • 새로운 엔티티를 판단하는 기본 전략
    • 식별자가 객체일 때 ‘null’로 판단
    • 식별자가 자바 기본 타입일 때 0으로 판단
    • persistable 인터페이스를 구현해서 판단 로직 변경 가능.
@GeneratedValue를 쓰지 않는 경우에 엔티티에 Persistable 인터페이스를 구현하고 isNew()안에 @CreatedDate를 넣어주면 이게 있는지 없는지 여부로 새로운 객체인지 판단하게 할 수 있다.
@Entity @EntityListeners(AuditingEntityListener.class) // @CreatedDate를 쓸 거기 때문 @NoArgsConstructor(access = Accesslevel.PROTECTED) public class Item implements Persistable<String> { @Id private String id; @CreatedDate private LocalDateTime createdDate; @Override public String getId() { return id; } @Override public boolean isNew() { return createdDate == null; } }

기타 기능들 (실무에서 잘 쓰지는 않는 것들)

명세

DDD 책에서 명세개념을 소개함.
JPA Criteria를 활용해서 명세를 구현함.
하지만, 조금만 어려운 쿼리를 짜면 알아먹을 수가 없는 수준이 되어버림.
고로 생략함.

Query By Example

Example<T> 를 만들고 repository.findAll(example)이런 식으로 써도 동작한다.
값이 null이 아닌애들을 가지고 조건을 걸어서 쿼리를 해주는건데 primitive 타입이라 기본값이 null이 아닌 경우에는 무시를 해주어야 한다.
이 또한 마찬가지로 JOIN이 들어가면 쓰기 힘들어지기 때문에 굳이 쓸 필요는 없다.
하지만! 장점이 있긴 함.
  • 동적 쿼리를 편하게 처리할 수 있음.
  • 도메인 객체를 그대로 사용
  • 디비를 NOSQL로 변경해도 문제가 없음.
  • JpaRepository에 이미 포함되어 있음.
단점
  • INNER JOIN만 가능함.
  • 중첩 제약조건 안됨.
  • 매칭 조건이 매우 단순함. 문자는 starts/contains/ends/regex 까지 지원. 다른 속성은 = 만 가능.
⇒ QueryDSL을 쓰면 그만.

Projections

엔티티 대신 DTO를 조회할 때 사용.
Member 전체 엔티티가 아닌 회원 이름만 조회하고 싶다라고 하면
public interface UsernameOnly { String getUsername(); } public interface MemberRepository ... { List<UsernameOnly> findProjectionsByUsername(String username); }
위의 것은 인터페이스 기반 Closed Projections의 예시이다.
  • 프로퍼티 형식의 인터페이스를 제공하면 구현체는 스프링 데이터 JPA가 제공해줌.
인터페이스 기반 Open Projections
  • 스프링의 SpEl 문법을 사용할 수 있음. (쓰는 거 본적이 없음.)
클래스 기반 Projection도 가능
public class UsernameOnlyDto { private final String username; public UsernameOnlyDto(String username) { this.username = username; } public String getUsername() { return username; } }
제네릭 타입을 줘서 동적 프로젝션도 가능
<T> List<T> findProjectionsByUsername(String username, Class<T> type); List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1", UsernameOnly.class);
중첩구조도 처리가능
public interface NestedClosedProjection { String getUsername(); TeamInfo getTeam(); interface TeamInfo { String getName(); } }
이걸 사용해서 쿼리가 어떻게 나가는지 보면 단점이 있다는 걸 알 수 있음.
정리
  • 프로젝션 대상이 root 엔티티면 유용하게 사용 가능
  • 중첩구조 처럼 프로젝션 대상이 root 엔티티를 넘어가면 JPQL select 최적화가 안됨.
  • 따라서 이 문제도 QueryDSL로 해결 가능.

네이티브 쿼리

  • 정말 어떤 방법으로도 해결이 안되는 경우 네이티브 쿼리를 날려줄 수 있다.
@Query(Value = "select * from member where username = ?", nativeQuery = true) Member findByNativeQuery(String username);
  • 페이징 지원
  • 반환 타입
    • Object[]
    • Tuple
    • DTO(스프링 데이터 인터페이스 Projections 지원)
  • 제약
    • Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있음.
    • JPQL처럼 애플리케이션 로딩 시점에 문법 확인 불가
    • 동적 쿼리 불가
  • JPQL은 위치 기반 파라미터를 1부터 시작하지만 네이티브 SQL은 0부터 시작
  • 네이티브 SQL을 DTO로 변환 하려면
    • DTO 대신 JPA Tuple 조회
    • DTO 대신 MAP 조회
    • @SqlResultSetMapping
    • Hibernate ResultTransformer
    • 다 어렵기 때문에 굳이 써야 한다면 JdbcTemplate or MyBatis 쓰는 게 나음.