본문 바로가기
Java/Spring

JPA 씹어먹기

by java개발자 2021. 4. 19.

기준: springboot 2.1.9

 

"자바 ORM 표준 JPA 프로그래밍", 김영한님의 책은 놀랍다.

1번 읽고 느낀점: JPA에 무작정 뛰어들면 안되는구나. 개념없이 맨땅에 헤딩하면, 많은 시간을 낭비할 것 같다.(한번 시도했다가 수많은 에러만 만났다.)

JPA는 정석대로 배우는게 좋겠다.

 

한번 읽고, 다 정리가 안되어서, 두번 읽고, 실습하고, 나름대로 정리를 해본다.

 

초판은 2015년. 현재는 2021년.

꽤 많은 세월이 흘렀음에도 대적할만한 책이 없다.

 

책을 읽으면서 주의할 점은

Spring Data JPA가 지원하는 기능

JPA가 지원하는 기능

하이버네이트가 지원하는 기능이 조금씩 다르다.

이 부분을 비교해가면서 읽어야 나중에 혼란이 없다.

 

* 영속성 컨텍스트(1차 캐시)는 1회성이다. 트랜잭션이 시작하고 끝나면 사라진다.!!

* 전체적으로 가능한 기능을 소개해주지만 결국 사용하는 기능이 정해져있다. 그것에 집중하면서!!!

* 상태의 일관성을 위해 예외적인 경우가 있다. 그런데, 오히려 방법의 일관성이 저해되어서 잘 납득이 안되는 기능은 주의해야 한다.(flush, 비영속 vs 영속)

 

주요 개념

엔티티

영속성 컨텍스트

플러시, 준영속, 영속

연관관계

프록시, 즉시로딩, 지연로딩, 영속성 전이, 고아객체

객체그래프탐색

JPQL, Criteria, QueryDSL, Native SQL

OSIV

트랜잭션, 낙관적 락, 비관적 락

1차 캐시, 2차 캐시

 

JPA가 하는 모든 기능을 한줄로 요약하자.

 

Map이나 List의 함수를 이용해서 데이터를 저장/조회하듯이 Database도 그렇게 쉽게 다룰 수 있으면 얼마나 좋을까?

개발자 -> JPA -> DB

JPA는 개발자와 DB를 연결해준다.

Java 코드만으로 DB schema를 작성할 수 있다. 그것이 엔티티 클래스이다. (엔티티=테이블)

매번 반복되는 CRUD 기능들을 JPA에 미리 준비된 함수로 간단하게 실행할 수 있다. 개발자가 할 일은 엔티티를 잘 만들어주면 된다.

더 나아가서, 테이블은 보통 단독으로 사용하지 않고, 외래키를 이용해서 두 테이블간에 관계를 설정해서, 조회/추가/수정/삭제를 한다. 엔티티도 마찬가지다. 엔티티간의 연관관계가 있다.

 

영속성 컨텍스트(1차 캐시)가 엔티티를 관리하면 좋은점.

  • 1차 캐시: 엔티티를 1차 캐시에서 먼저 조회한다. 없으면 DB조회해서 1차 캐시에 저장하고, 영속상태 엔티티를 반환.
  • 동일성 보장: @Id(기본키)로 구분
  • 트랜잭션을 지원하는 쓰기 지연: 트랜잭션 마지막 커밋 할 때, flush를 날려서 모아놓은 update/insert/delete sql을 한번에 실행한다.
  • 변경 감지: 영속된 엔티티의 경우만 변경 감지가 된다. (비영속X, 준영속X, 삭제된엔티티X)
  • 지연 로딩: 엔티티간의 관계가 정의되어 있을때, LAZY로딩으로 지정된 참조 테이블은 프록시 객체로 반환
    • 객체를 실제 사용(getter 또는 PersistentBag인 경우 get(i))할 때, DB 조회를 통해 데이터 반환
    • => 영속성 컨텍스트에 이미 데이터가 있으면 지연 로딩(프록시 객체)이라 해도, 프록시 객체가 아닌 실제 객체가 반환된다. (메인 엔티티를 조회하는 시점에 한해서, 조회 다 하고, 나중에 LAZY를 이용할려면 먼저 DB부터 다녀온다. DB 갔다가 영속성에 이미 있으면 영속성에 있는 객체로...(그럼 뭐하러 DB에 가는거지??))
  • 영속성 컨텍스트를 사용하지 않고, 매번 DB에서 조회해도 된다.
    • 엔티티 단위가 아니라, 값 단위로 JPQL을 작성해서 조회하면 된다. select a.XXX, a.YYYY ............
  • 1번 조회만 하는 @Service라면, 1차 캐시의 이점이 없다.
  • @Service 내에 여러 @Service가 중첩되어 있고, 트랜잭션은 전파 되어서 1개로 묶여 있다면, 의미가 있을듯...
    • 같은 함수 scope내에서 조회가 필요하면 그냥 엔티티 공유를 하면 되지만, 같은 트랜잭션이지만, 다른 함수 scope라면 다시 DB 조회를 시도할 텐데...(그렇다고 함수에 엔티티를 파라메터로 넘기는 건 좀 그렇고...) DB조회 없이 1차캐시에서 가져올 수 있으면 좋은거다....

플러시하는 방법

  • flush는... 1차캐시와 DB를 동기화하는 작업.
  • em.flush() 직접 호출
  • 트랜잭션 커밋 시 자동 호출
  • JPQL 쿼리 실행시 자동 호출(메소드 쿼리, queryDsl 등등)
  • 식별자를 기준으로 조회하는 findById 메소드는 자동 호출X, DB조회만 한다.
  • (왜???????? JPQL은 해주면서...) - 108p
    • JPQL쿼리는 DB에 직접 실행한다.
    • findById 메소드는 1차캐시에서 먼저 찾아보고, 없으면 DB 조회하고, 1차 캐시에 저장하고, 1차 캐시의 영속상태 엔티티를 반환한다.
  • @GeneratedValue의 IDENTITY 식별자 생성전략(auto_increase)을 사용하면, 엔티티를 DB에 저장해야 식별자를 구할 수 있으므로, 즉시 insert SQL이 DB에 전달된다. 쓰기지연X (바로 flush)
// 트랜잭션 시작
em.persist(member1);
em.persist(member2);
em.persist(member3);

1. findById()

2. JPQL...
// 트랜잭션 끝

------------------
persist를 해서 이미 1차캐시에 들어 있다.
findById를 할 때, 따로 flush가 필요없다. 일단 1차캐시에서 찾고 나서 없으면 DB에서 조회하므로.

그런데
JPQL은 1차캐시에서 안 찾고, 직접 DB에서 조회를 하므로, 
flush를 해줘야 위에 실행했던 member1~3을 DB에서 찾을 수 있다.

그럼 왜?
JPQL은 1차캐시에서 안 찾고, 바로 DB에서 찾나?
findById는 엔티티 단위이기 때문에 1차캐시에서 찾기 쉽다.
그런데, JPQL은 쿼리의 상황에 따라 복잡하다. select 단위가 엔티티 단위가 아닐 수도 있고,,,
그래서 1차캐시에서 찾는 것을 포기한 듯 하다.(설명이 책 어딘가에 있었는데...) - 464p

비영속 vs 영속 (왜 서로 다르게 동작하지???)

@GeneratedValue없이 id를 직접 지정하는 경우(member1은 비영속)

Member member1 = Member.builder().id(444L).name("이름1").build();
Member member1Managed = memberRepository.save(member1);
System.out.println("member1=member1Managed" + (member1 == member1Managed)); // false

@GeneratedValue로 id가 자동생성되는 경우(member1은 영속)

Member member1 = Member.builder().name("이름1").build();
Member member1Managed = memberRepository.save(member1);
System.out.println("member1=member1Managed" + (member1 == member1Managed)); // true

 

엔티티 매핑

(위치) 기본값 기타
@Entity (클래스) 클래스 이름을 엔티티 이름으로 기본 생성자 필수
@Table (클래스) 엔티티 이름을 테이블 이름으로  
@Column (필드) nullable=true
length=255
primitive(기본타입)에서 @Column을 사용하는 경우 nullable=false로 지정하는 것이 안전.
@GeneratedValue (필드) AUTO - IDENTITY로 하면 바로 플러시
- SEQUENCE로 하면 시퀀스만 돌려서 식별자를 가지고 있는다.

SEQUENCE는 쓰기지연 기능을 사용할 수 있으므로 IDENTITY보다 더 좋은듯?
@Enumerated (필드) EnumType.ORDINAL EnumType.STRING 권장
@Temporal (필드) @Temporal을 생략하면, TemporalType.TIMESTAMP와 같음  
@Transient (필드)   테이블과 맵핑하지 않음. 임시....
@Access (클래스 또는 필드)   @Id를 필드에 놓으면, @Access(AccessType.FIELD)와 동일

getter에 @Access(AccessType.PROPERTY)를 사용하면, 특별히 조합된 데이터를 저장할 수 있다.

연관관계 매핑(방향, 다중성, 연관관계 주인)

 

N:1 양방향

N (연관관계의 주인) 1
@ManyToOne 기본값
optional=true
FetchType.EAGER
@OneToMany 기본값
FetchType.LAZY

연관관계의 주인 지정
mappedBy="${N의 필드명}"
@JoinColumn      

N을 영속화하고, 1에 N을 연결하고나서, 1을 저장.

N:1 (Member: Team)

Member을 영속화하고, Team에 Member을 연결하고나서, Team을 저장.

==> 좀 이상한데... N의 optional=false로 하면 1이 먼저 존재해야 한다.

N과 1이 동시에 저장되는 구조라면 다 세팅(서로 연결)해놓고, 마지막에 CASCADE로 한번에 처리하자.

 

권장하는 관계

다대일, 일대다 단방향, 양방향

일대일(주 테이블) 단방향, 양방향

 

상속관계 맵핑

  • 각각의 테이블로 변환해서, 조인 -- 조회쿼리가 복잡, insert sql 2회씩
  • 통합 테이블 1개 -- 테이블이 커지면 속도X (관리하기는 편하겠군...)
  • @MappedSuperclass : 순수하게 엔티티 컬럼만 상속하기 위해 사용
    • createdAt 컬럼 @EntityListeners(AuditingEntityListener.class)와 함께 사용

연관관계(LAZY, EAGER)

@ManyToOne(fetch = FetchType.LAZY)

    select
        member0_.id as id1_2_0_,
        member0_.reg_date as reg_date2_2_0_,
        member0_.update_date as update_d3_2_0_,
        member0_.city as city4_2_0_,
        member0_.name as name5_2_0_,
        member0_.street as street6_2_0_,
        member0_.team_id as team_id8_2_0_,
        member0_.zipcode as zipcode7_2_0_ 
    from
        member member0_ 
    where
        member0_.id=?
        
// 실제 getter() 하는 순간
    select
        team0_.id as id1_6_0_,
        team0_.name as name2_6_0_ 
    from
        team team0_ 
    where
        team0_.id=?
@ManyToOne(fetch = FetchType.EAGER, optional = false)

    select
        member0_.id as id1_2_0_,
        member0_.reg_date as reg_date2_2_0_,
        member0_.update_date as update_d3_2_0_,
        member0_.city as city4_2_0_,
        member0_.name as name5_2_0_,
        member0_.street as street6_2_0_,
        member0_.team_id as team_id8_2_0_,
        member0_.zipcode as zipcode7_2_0_,
        team1_.id as id1_6_1_,
        team1_.name as name2_6_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        member0_.id=?
@ManyToOne(fetch = FetchType.EAGER)

    select
        member0_.id as id1_2_0_,
        member0_.reg_date as reg_date2_2_0_,
        member0_.update_date as update_d3_2_0_,
        member0_.city as city4_2_0_,
        member0_.name as name5_2_0_,
        member0_.street as street6_2_0_,
        member0_.team_id as team_id8_2_0_,
        member0_.zipcode as zipcode7_2_0_,
        team1_.id as id1_6_1_,
        team1_.name as name2_6_1_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        member0_.id=?

fetch=FetchType.EAGER인 경우

@ManyToOne, @OneToOne

  • optional=false: 내부조인
  • optional=true: 외부조인

@OneToMany, @ManyToMany

  • optinal=false, true: 외부조인

영속성 전이

  • CASCADE 종류: all, persist, merge, remove, refresh, detach
  • 고아 객체(orphanRemoval = true): 부모 엔티티와 연관관계가 끊어진 경우, 자동으로 삭제

쿼리

  • JPQL
    • 동적쿼리: @Query
    • 정적쿼리: Named 쿼리 --> xml로 관리
  • Criteria: 비추
  • 네이티브 SQL : 엔티티 조회 가능, 1차 캐시 가능
    • 동적: @Query(value="", nativeQuery = true)
    • 정적: XML
  • QueryDSL
  • JDBC 직접, Mybatis: 1차캐시와 동기화하는 작업은 직접해야 함!!

 

fetch join 주의

  • 컬렉션(일대다) 페치 조인에서는 결과가 중복될 수 있다. distinct 필요
  • 컬렉션(일대다) 페치 조인에서는 paging 안됨. 메모리 이슈!!  ------> BatchSize 추천
  • 기본 엔티티로 조회해야 한다. Dto로는 조회X

묵시적 join vs 명시적 join

  • 묵시적 join은 항상 내부조인 (값이 없으면 안나와!!)

서브쿼리

  • where, having에서 사용 가능
  • select, from에서는 안됨. 그럼 어떻게 해?????

 

 

최종 전략!

  • 기본키는 IDENTITY 보다는 SEQUENCE가 좋은듯. (쓰기 지연이 보장되므로)
  • 중복되는 컬럼(생성일, 수정일)은 @MappedSuperclass 으로
  • 가능하면 JPQL보다 JPA 표준 함수를 사용하자. (1차 캐시 장점)
    • JPA 표준 함수 -> JPQL -> 하이버네이트 구현 기능 -> 네이티브 SQL -> Mybatis, Jdbc
  • 비즈니스 로직을 참고해서, 일단 단방향으로 작성. (기획은 언제나 변하니까, 객체 그래프가 필요하면, 양방향으로 변경). 양방향으로 변경시, 연관관계 메소드 주의!!!
  • 조인에서 비식별 관계를 사용하고, 기본키는 Long 타입의 대리키 사용.
  • 조인에서 비식별 관계에서 필수적 관계(NOT NULL)는 내부조인으로 사용됨. vs 선택적관계(nullable)는 외부조인을 사용해야 한다.
    • optional = false
  • primitive type은 nullable = false로
  • 기본으로 LAZY 로딩. (글로벌 로딩 전략)
    • EAGER는.. 정말... 매번.... 항상 필요한 경우에만...  (그런데, EAGER가 N+1의 문제해결은 안됨.)
    • LAZY로 했다가
      • 여러 엔티티가 함께 필요하면, fetch join으로 해서 엔티티로 가져오기(dto로는 못가져옴)
      • 여러 엔티티의 일부 값들이 함께 필요하면 JPQL에서 join해서 값을 각각 dto로 가져오기

 

쿼리 전략 (다시 정리)

  설명 장점 단점
1. JPA 표준 함수 일반적인 기능들 DTO 리턴(O)
page (O)
 
2. JPQL   DTO 리턴(O)
page (O)
단순 join해도 LAZY는 한번에 못 가져옴
3. JPQL (fetch join) - 1:N에서 결과중복가능(distinct필요) 엔티티 리턴 가능(LAZY도 join으로 한번에 가져올 수 있음) - DTO 리턴(X)
- 1:N에서 page 기능(X)
4. 네이티브 SQL (XML)   multiline string
DTO 리턴(O)
page (O - 수동)
native query인데, 엔티티 리턴(O) - 좀더확인필요
엔티티이외의 컬럼은 따로 받아야 한다.

 

확인 사항

1. 엔티티를 만들때, validation이 가능한가? (nullable, length

2. enum type인데, DB에 값이 잘못 들어가 있는 경우, 조회할 때 에러? (column check 가능?)

 

 

생각해보기

1. 누군가가 DB 컬럼값을 직접 수정하면, JPA는 알아차리지 못할까? 영속성 컨텍스트가 진행중인 경우에는(트랜잭션 중에는) 그럴 수 있다. 하지만, 배치작업이 아닌 이상, 트랜잭션 시간이 얼마나 길까?

트랜잭션이 끝나고, 새로운 트랜잭션이 시작하면, 영속성 컨텍스트에는 아무것도 없으므로 강제로 수정된 DB의 값을 가져올 수 있다.

2. 꽤 진행된 프로젝트에서, 기본키전략을 바꾸거나 LAZY를 EAGER로 변경하면, 사이드이펙트가 있을까?

3. (동일한 트랜잭션 내에서) JPQL 1건을 실행하고, 엔티티를 JSON으로 변환하면서 연관관계된 엔티티의 LAZY 로딩을 모두 실행시킨다. 다량의 SQL 발생...

동일한 로직을 다시 실행하면, 1건의 JPQL 쿼리는 실행되지만,

JSON변환시 발생하는 LAZY 로딩 SQL은 실행되지 않는다. 이미 1차 캐시에 있으므로.

(과연 그럴까? 이 상황은 동일한 로직을 다시 실행할 때, 연관엔티티를 프록시로 전환할때, 이미 실객체를 넣어버리는 경우다. 그래서 이 경우 LAZY로딩은 SQL을 실행하지 않음.)

결론: JPQL은 무조건 SQL을 실행한다.(물론 동일한 엔티티가 1차캐시에 있으면, DB 조회 결과를 버리고 1차캐시에서 반환한다. 그리고 연관된 엔티티들은 1차 캐시부터 조회한다. 생성될 때 프록시라면 DB조회하고나서 1차캐시에 있으면 DB 결과를 버린다.)

 

 

개발 중반에 엔티티의 일부를 바꾸면 생기는 사이드이펙트

1. CASCADE 수정

cascade.persist덕분에 연관엔티티 조합후에 최종 엔티티의 save 메소드 한번으로 할 수 있었는데, CASCADE를 빼버리면, 연관된 엔티티가 영속성 객체가 아니므로 아마 최종 엔티티의 save 메소드시에 에러가 발생할 것이다.

2. LAZY -> EAGER

원하는 연관된 엔티티를 바로 가져오겠다고, LAZY -> EAGER로 바꿔버리면,

그동안 LAZY를 위해 처리했던 최적화 작업들(프록시 제거, fetch join 등이 허사가 된다. - fetch join을 위해 희생했던 부분들...)

 

 

DB SQL 결과를 버리고, 영속성 컨텍스트에 있는 것을 반환한다는 증거

(select 컬럼이 많은데, 로그로 다 표현하지 않는 경우)

Hibernate: 
    select
        members0_.team_id as team_id13_2_0_,
        members0_.id as id1_2_0_,
        members0_.id as id1_2_1_,
        members0_.reg_date as reg_date2_2_1_,
        members0_.update_date as update_d3_2_1_,
        members0_.city as city4_2_1_,
        members0_.state as state5_2_1_,
        members0_.street as street6_2_1_,
        members0_.plus_four as plus_fou7_2_1_,
        members0_.zip as zip8_2_1_,
        members0_.name as name9_2_1_,
        members0_.area_code as area_co10_2_1_,
        members0_.local_number as local_n11_2_1_,
        members0_.provider_id as provide12_2_1_,
        members0_.team_id as team_id13_2_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
2021-04-25 00:59:55.877 TRACE 26220 --- [nio-8080-exec-1] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1117]
2021-04-25 00:59:55.877 TRACE 26220 --- [nio-8080-exec-1] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_1_] : [BIGINT]) - [119]
2021-04-25 00:59:55.877 TRACE 26220 --- [nio-8080-exec-1] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([team_id13_2_0_] : [BIGINT]) - [1117]
2021-04-25 00:59:55.877 TRACE 26220 --- [nio-8080-exec-1] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_0_] : [BIGINT]) - [119]

 

에러모음

1. query specified join fetching, but the owner of the fetched association was not present in the select list

fetch join 했는데 엔티티로 리턴하지 않는 경우. DTO는 안됨!!

2. api response로 만들어질때 JSON 생성시 에러

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: com.kakaocommerce.order.api.dto.ApiResult["data"]->java.util.ArrayList[0]->com.kakaocommerce.order.api.dto.BookmarkedListDto2["store"]->com.kakaocommerce.order.models.Store$HibernateProxy$WtV4fN4L["$$_hibernate_interceptor"])

3.N:1 fetch join + Page 기능을 추가하면 서버 자체가 실행하지 않는다.

Caused by: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list 
[FromElement{explicit,not a collection join,fetch join,fetch non-lazy properties,classAlias=t,role=org.example.models.Member.team,tableName=team,tableAlias=team1_,origin=member member0_,columns={member0_.team_id,className=org.example.models.Team}}]