[JPA] 자바 ORM 표준 JPA프로그래밍 읽고 정리 본문

백앤드 개발일지/스프링부트

[JPA] 자바 ORM 표준 JPA프로그래밍 읽고 정리

giron 2021. 8. 24. 21:15
728x90

1차 캐시: @Id와 Entity

스냅샷: 1차캐시에 처음 영속되었을 때의 상태

 

findByName()과 같이 id가 아닌 값으로 조회를 하면 바로 DB에 접근하게 된다.

 

플러시

  • flush(): 영속성 컨텍스트의 내용을 디비에 반영
  • 1차 캐시에 넣어둔게 사라진게 아님! 그냥 반영만 - 동기화

플러시 발생

flush()가 될때 -> 변경 감지-> 수정된 엔티티를 쓰기 지연 SQL저장소 등록 -> 쓰기 지연 SQL저장소의 쿼리 디비에 전송

같은 테이블, JDBC Driver지원, 하이버네이트 같을 때, 디비로 일괄 전송되는 것

영속성 컨텍스트 플러시 할 때

  • em.flush() - 직접호출 ex) test
  • 트랜잭션 커밋 - 자동 호출
  • JPQL 쿼리 실행 - 자동 호출
    • jpql은 객체를 대상으로 하는 sql이므로 플러시가 되야 db에서 값을 가져올 수 있으므로 자동으로 호출
    • jpql대신 mybatis나 jdbcTemplate을 쓰면 em.flush()를 사용해야 한다.
em.persist(userA);
em.persist(userB);
em.persist(userC);

//JPQL 실행

query = em.createQuery("select m from Member m", Member.class);
List<Member> members = query.getResultList();

 

플러시 모드 옵션

em.setFlushMode(FlushModeType.COMMIT)

  • FlushModeType.AUTO: 커밋이나 쿼리를 실행할 때 플러시(default)
  • FlushModeType.COMMIT: 커밋할 때만 플러시
    • 위의 예시에서 Member가 아닌 전혀 객체를 조회할 때, Member는 flush할 필요가 없으니 사용 가능
    • 근데 거의 사용 안한다고 한다.

준영속 

영속성 컨텍스트의 이점을 얻지 못한다.->lazy loading, dirty checking 등

트랜잭션에서 벗어나는 순간 준영속상태 

영속성컨텍스트는 DB PK와 매핑된 식별자로 구별을 한다. 따라서 영속성 컨텍스트의 1차 캐시에는 키가 항상 있다.

따라서 한 번이라도 영속상태가 된 애들은 무조건 식별자(@Id)가 존재한다. : 이게 new 로 새로운 객체와 준영속 객체의 차이이다.

준영속 상태로 만드는 방법

  • em.clear() : 영속성 컨텍스트 초기화 (직접 할 일 거의 없음)
  • em.close(): 영속성 컨텍스트 종료
  • em.detach(entity): 특정 엔티티만 준영속 상태로 전환->영속성 컨텍스트에서만 삭제 + 쓰기 지연 SQL저장소에서 관련 쿼리도 삭제

병합(Merge)

  • 준영속 상태의 엔티티를 다시 영속 상태로 변경
  • 비영속 상태도 영속 상태로 만들 수 있다.
  • save or update 기능을 수행한다.

병합 동작 방식

J2EE, 스프링 컨테이너 환경과 영속성 컨텍스트

  • 기본 전략 - 트랜잭션 범위의 영속성 컨텍스트
  • 과거 OSIV - 요청 당 트랜잭션
  • 스프링 OSIV - 비즈니스 계층 트랜잭션

트랜잭션 범위의 영속성 컨텍스트

  • J2EE, 스프링 컨테이너의 기본 전략
  • 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다.
  • 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

영속성 컨텍스트 생존 범위

트랜잭션 AOP가 시작하고 끝날 때까지 영속성 컨텍스트 생존 범위의 어디서든 EntityManager에 접근하면 모두 같은 영속성 컨텍스트에 접근한다!

@Service
public class JpaService{
    @Autowired
    private SampleRepository1 sampleRepository1;
    @Autowired
    private SampleRepository2 sampleRepository2;

    @Transactional
    public Member method() {
        sampleRepository1.contactPersistence();
        //member는 영속 상태이다.
        Member member = sampleRepository2.getMember();
        return member;
    }
    //트랜잭션 종료
}
@Repository
public class SampleRepository1{

    @PersistenceContext//스프링 프레임워크가 EntitiyManager를 주입을 해준다.
    EntityManager em;

    public void contactPersistence(){
        em.xxx(); // A.영속성 컨텍스트 접근
    }
}

@Repository
public class SampleRepository2{

    @PersistenceContext
    EntityManager em;

    public Member getMember(){
        return em.find(Member.class, "id1"); //B.영속성 컨텍스트 접근
    }
}

위 코드를 보면 SampleRepository1에 엔티티를 저장하고 SampleRepository2에서 조회를 하면 SampleRepository1에 저장했던 객체가 나온다.

 EntityManager : 영속성 컨텍스트가 N : 1이다.

한 트랜잭션에서 다른 em에 접근해도 같은 영속성 컨텍스트 사용

같을 때

트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 같기 때문에 트랜잭션 사용동안 같은 영속성 컨텍스트를 사용한다.

다를 때

스프링은 트랜잭션을 스레드마다 각각 자동으로 할당해준다. 트랜잭션이 다르면 같은 EntiyManger를 접근해도 다른 영속성 컨텍스트에 접근할 수 있다. 따라서 멀티 쓰레드일 때를 고려 안해도 된다. em이 같아도 알아서 다른 영속성 컨텍스트로 가기 때문! - 스프링 덕분!! 스레드마다 각각 자동 할당이므로

트랜잭션 범위의 영속성 컨텍스트 사용의 장점

- 트랜잭션과 복잡한 멀티 쓰레드 상황을 컨테이너가 처리

즉. 개발자는 싱글 쓰레드 애플리케이션처럼 단순하게 개발

트랜잭션 범위의 영속성 컨텍스트 사용의 단점

트랜잭션이 끝나면 영속성 컨텍스트가 종료 -> 엔티티는 준영속 상태가 된다. -> 트랜잭션이 끝난 계층에선 지연로딩 불가

즉, 범위를 벗어나면 지연로딩이 불가능하다.

스프링 OSIV

스프링 OSIV

+ 스프링의 OSIV는 트랜잭션 범위를 벗어나면 수정이 불가능하다. 즉, controller계층에서도 지연로딩 가능하고 수정이 안되므로 안정적이다!

+ 즉, 영속성컨텍스트이지만 트랜잭션이 없다면 -> readOnly로 연관 객체를 꺼내올수까지만 있다.

- 대신 영속성 컨텍스트 생존 범위가 길어서 커넥션을 많이 잡아먹을 수도 있다.

또한 트랜잭션 롤백할 때, OSIV를 사용 안하면 트랜잭션범위와 영속성 컨텍스트 범위가 일치해서 아무 이상이 없지만 OSIV를 사용하면 롤백시 영속성 컨텍스트 close를 스프링 프레임워크가 해준다. -> 엔티티메니저를 flush가 나가지 않고 close

예시

EntityManagerFactory

  • persistence.xml 파일에 정의한 영속 단위 기준으로 생성된다. ex) <persistence-unit name="영속 단위(식별자)">
  • 커넥션 풀 등 디비 연동에 필요한 자원들을 생성한다.
  • 애플리케이션을 시작하면 최초로 한 번 생성된다.
  • 애플리케이션이 종료되면 팩토리를 닫고 사용한 자원들을 반환한다.

EntityManager

  • DB 연동 작업이 필요할 때마다 생성된다. (커넥션 단위)
  • EntityManager로 DB 조작
  • EntityTransaction으로 트랜잭션 관리
  • JPA + Spring을 하면 스프링이 persistence.xml을 읽어 EntityManagerFactory 생성 및 이러한 작업을 해준다.
  • 따라서 개발자는 매핑 설정 중심으로 작업 가능

영속성 컨텍스트

  • EntityManager 단위로 영속성 컨텍스트가 관리한다.
  • 엔티티를 메모리에 보관
  • 기본적으로 영속성 컨텍스트의 생존 범위는 트랜잭션과 같다.
  • J2EE와 스프링 환경에서는 EntityManager : 영속성 컨텍스트가 N : 1이다.
  • J2SE(자바 Swing) 환경에선 EntityManager : 영속성 컨텍스가 1:1 이다.
  • commit 시점에 영속성 컨텍스트의 변경 내역을 DB에 반영한다. -> 쿼리 발생
  • 약간 컬렉션같다...Fake 구현한 느낌

장점

  1. 1차 캐시
    • 우선 1차캐시를 확인 -> 없으면 디비에서 꺼내 1차 캐시에 저장하고 꺼낸다.
  2. 동일성 보장
    • 1차 캐시에 같은 객체가 있기 떄문에 같은 객체를 find 하면 == 으로 동일성이 보장된다.(true)
    • jpa에서는 1차 캐시로 REPEATABLE READ 등급의 트랜잭션 격리 수준을 DB가 아닌 애플리케이션 차원에서 제공(mysql default 격리 수준)
  3. 트랜잭션을 지원하는 쓰기 지연
    • 엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
    • persist할 때마다 1차 캐시에 엔티티를 저장하고 쿼리는 `쓰기 지연 SQL 저장소`(예시)에 넣어둔 후, 
    • transaction.commit() 시점에 쿼리들이 나간다.
  4. 변경 감지(Dirty Checking
    • 1차 캐시와 스냅샷을 비교하여 다른 점을 확인하면 업데이트 쿼리가 나간다.
  5. 지연 로딩

변경 감지

 

commit 시점에 쿼리 나간다.

쿼리를 persist때가 아닌 트랜잭션 commit 시점에 쿼리가 나간다.

마찬가지다.

마찬가지로 트랜잭션 커밋 시점에 쿼리가 나간다.  -> 영속성 컨텍스트로 인해 발생 : 지연 로딩이 가능한 이유!!

 

출처: https://www.youtube.com/watch?v=7ljqL8ThUts&t=356s

회원이 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지... 이렇게 갖는 게 아닌
회원이 이름, 근무 기간, 집 주소.. 이렇게 갖는다 따라서 엔티티를 설계할 때 임베디드 타입을 이용해 객체지향적 설계하자😉

책을 읽으면서 가장 쉽게 와닿았던 멘트이다. 객체에 어디까지 멤버 변수로 설정하고 어디까지 클래스로 묶어야 할지 고민이 많았는데 깨달음을 얻은 것 같다...!😉👍

@Entity
public calss Member{

	@Id @GeneratedValue
    private Long id;
    private String name;
    
    @Embedded Period workPeriod //근무 기간
    @Embedded Address homeAddress //집주소

}
@Embeddable
public class Address{

	//매핑할 컬럼 정의 가능
	@Column(name="city")
    private String city;
	
    protected Address(){} //JPA에서 기본 생성자는 필수이다.
    
    //생성자로 초기값을 설정한다.
    public Address(String city) { this.city = city; }
    
    //Getter는 노출한다.
    public String getCity(){
    	return city;
    }
    //Setter는 만들지 않는다 (공유 참조를 막기 위해)
    
	...
}

위와 같이 Embedded타입을 이용하자. 이때, 불변 객체를 이용해서 객체의 공유 참조를  피해서 side effect를 막아야 한다. 객체를 불변하게 만들면 값을 수정할 수 없으므로 side effect를 원천 차단할 수 있다. 따라서 값 타입은 될 수 있으면 불변 객체로 설계하자. 참고로 Integer, String은 자바가 제공하는 대표적 불변 객체이다.

 

값 타입을 어떻게 비교할지 알아보자.

[자바 객체 비교]

  • 동일성 비교: 인스턴스 참조값을 비교, == 사용
  • 동등성 비교: 인스턴스 값을 비교, .equals()사용

따라서 equals()를 오버라이딩하여 재정의해야 한다. 이때 모든 값이 동일한지 비교해주는 게 좋다. 

* 자바에서 equals()를 재정의하면 hashCode()도 재정의하는 것이 안전하다. 그렇지 않으면 해시를 사용하는 컬렉션(HashMap, HashSet)이 정상 동작하지 않는다고 한다.

 

[값 타입 컬렉션]

값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable어노테이션을 사용하면 된다.

@Entity
public class Member{

	@Id @GeneratedValue
    private Long id;
    
    @ElementCollection
    @CollectionTable(name = "ADDRESS",
    	joinColumns = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<Address>();
    //...
 }
 
 @Embeddable
 public class Address{
 
 	@Column
    private String city;
    private String street;
    private String zipcode;
    //...
    
 }

[정리]

엔티티 타입과 값 타입

  • 엔티티 타입의 특징
    • 식별자가 있다(@Id)
    • 생명 주기가 있다.
    • 공유할 수 있다.
  • 값 타입의 특징
    • 식별자가 없다
    • 생명주기를 엔티티에 의존한다.
    • 엔티티와 다르게 공유하지 않는 게 안전하다.
    • 불변 객체로 만드는 것이 안전하다.

[영속성 전이 + 고아 객체 제거]

@Entity
public class Parent{
	...
	@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList();
    ...
}

위와 같이 코드를 작성하면 부모를 영속화할 때 연관된 자식들도 함께 영속화된다.

영속성 전이는 연관관계 매핑과는 관련이 없고, 단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함만 제공할 뿐이다.

다음은 CASCADE의 종류이다.

public enum CascadeType {
	ALL,	//모두 적용
    PERSIST,	//영속
    MERGE,	//병합
    REMOVE,	//삭제
    REFRESH, //REFRESH
    DETACH,	//DETACH
}

고아 객체 제거는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능이다. 이를 통해

부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.

@Entity
public class Parent{
	@Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<Child>();
    ...
    
}

위와 같이 설정하면 아래처럼 사용할 수 있다.``

Parent parnet1 = em.find(Parent.class, id);
parent1.getChildren().remove(0)	//자식 엔티티를 컬렉션에서 제거
parent1.getChildren().clear() // 모두 자식 엔티티 제거

여기서 주의할 점이 있다.

  • 고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.
  • 따라서 참조하는 곳이 하나일 때만 사용해야 한다. (개인 소유의 엔티티일 때만 가능)
  • 따라서 @OneToOne, @OneToMany에서만 사용할 수 있다.

부모를 제거하면 자식도 같이 제거된다. 즉 CascadeType.REMOVE를 설정한 것과 같다.

 

그렇다면 CascahdeType.ALL + orphanRemoval = true 를 동시에 사용하면, 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.

//자식을 저장하려면 부모에 등록만 하면 된다.(CASCADE)
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);

//자식을 삭제하려면 부모에서 제가하면 된다.(orphanRemoval)
Parent parent = em.find(Parent.class, parentId);
parent.getChildern().remove(removeObject);

[Page 인터페이스]

public interface Page<T> extends Iterable<T>{
	int getNumber();	//현재 페이지
    int getSize();	//페이지 크기
    int getTotalPages();	//전체 페이지 수
    int getNumberOfElements();	//현재 페이지에 나올 데이터 수
    long getTotalElements();	//전체 데이터 수
    boolean hasPreviousPage();	//이전 페이지 여부
    boolean hasNextPage();	//다음 페이지 여부
    boolean isFirstPage();	//현재 페이지가 첫 페이지 인지 여부
    boolean isLastPage();	//현재 페이지가 마지막 페이지 인지 여부
    Pageable nextPageable();	//다음 페이지 객체, 다음 페이지가 없으면 null
    Pageable.previousPageable();	//이전 페이지 객체, 이전 페이지가 없으면 null
    List<T> getContent();	//조회된 데이터
    boolean hasContent();	//조회된 데이터 존재 여부
    Sort getSort();	//정렬 정보
    
}

Pagination 과 Sort방법

```
class PageDto
public Pageable ofWithSortAsc(String sort) {
        return PageRequest.of(this.page - 1, this.size, Sort.by(sort).ascending());
}
```
service layer
rejectPostRepository.findAllByUserId(user.getId(), pageDto.ofWithSortAsc("doDate"));
```
Sort.by(엔티티이름)

[Join과 fetch join]

 n+1문제를 해결할 때, fetch join을 사용하면 된다고 배웠다. 그렇다면 join문은 필요가 없을까? 

결론부터 말하면 유동적으로 써야 하는 것 같다.

  • join과 fetch조인의 가장 큰 차이는 영속화 범위라고 생각한다.
  • join은 SELECT 한 엔티티만 영속화를 한다. 따라서 LAZY 로딩으로 조인한 다른 엔티티를 건드리면 영속 상태가 아니므로 LazyInitializationException이 발생한다. 
    • 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용하면 될 것이다.
  • fetch join은 SELECT 할 때, 관련된 모든 엔티티를 한 번에 끌어오므로 전부 영속화가 된다. 따라서 N+1문제를 해결할 수 있는 것이다.
    • 모든 엔티티 객체 그래프를 끌어오지 않고 일부만 SELECT로 선택하려면 fetch join사용은 불가능하다.

그렇다면 fetch join만 쓰면 고민할 필요가 없지 않나..라고 생각했지만

JPA는 기본적으로 "DB ↔ 객체"의 일관성을 잘 고려해서 사용해야 하기 때문에 로직에 꼭 필요한 Entity만을 영속성 컨텍스트에 담아놓고 사용해야 한다고 한다. 

즉, 사용하지도 않을 엔티티를 영속화시켜놓고 영속성 컨텍스트를 잡아먹는, 1차 캐시를 잡아먹는 무식한 행동은 하지 말자라고 생각했다.

 

[추가] (이전에 fetch join에 관련해서 공부한 자료 여기에 복붙하겠다.
querydsl 정리 여기에 적혀있는 부분 중 fetch join부분만 가져왔다. 
  • fetch join으로 조인하면 영속 상태로 갖고 있게 된다. 그래서 찾은 엔티티에서 다른 엔티티의 값이 필요할 때,fetch join을 사용해야 한다. (한 번에 영속 상태로 끌어오므로)
  • 패치 조인은 특정 칼럼만 뽑아낼 수 없다. 전부 selectFrom 해야 한다.
  • fetch join은 inner join으로 작동한다. (아이템을 사용할 수도 있고 안 할 수도 있는 유저 엔티티에서 페치 조인으로 아이템 엔티티를 뽑아오면 아이템을 사용하는 유저만 조인된다. )
  • 일반 join은 영속 상태로 끌어오지 않으므로 1차 캐시를 이용하지 않을 때 사용하면 좋다. 
  • 위 부분 때문에 N+1문제가 발생하는 것!(영속성 콘텍스트가 관리하지 않으므로 엔티티를 뽑으려면 다시 쿼리를 날려야 하기 때문에)
  • 또는 특정 칼럼만 뽑아낼 때 사용해도 좋다.
  •  일반 조인은 연관된 엔티티를 사용하지 않을 때나, 특정 칼럼만 뽑을 때 사용하면 좋다.
  • 패치 조인은 연관된 엔티티를 사용할 때 사용하면 좋다. (N+1문제 해결)
  •   .leftJoin(user.avatar).fetchJoin()와 같이 leftjoin에 패치조인 적용할 수 있다.

권장순서

  1. 엔티티 조회 방식으로 접근
    1. 패치조인으로 쿼리 수 최적화
    2. 컬렉션 최적화
      1. 페이징 필요시 hibernate.default_batch_size로 최적화
      2. 페이징 필요X -> 페치 조인 사용
      3. 이때, oneToMany가 2개 이상이면 패치 조인 불가능
  2. 엔티티 조회방식이 안되면 DTO 조회 방식 사용
  3. 그래도 안되면 NativaSQL or JdbcTemplate 사용

ps.

엔티티를 조회하게 되면 해당 엔티티의 모든 필드를 조회하게 됩니다. 따라서 데이터 조회 성능에서 필요한 필드만 찍어서 조회하는 것과 비교해서 필드가 많다면 성능에 차이가 발생할 수 있습니다.

*******************************************************************************************

[where절 vs on절]

  • on 절은 전부 조인 후에 일치하지 않은 것은 null값 넣고
  • where 절은 먼저 조건을 필터링한 후에 필터링된 것만 조인한다.

*****************************************************************************************

[OneToMany 일 때 LazyLoading으로 페이징 처리할 때] 

  • Many쪽으로 데이터 뻥튀기기 때문에 1+N문제가 발생한다. (rows의 증가) (2개 이상 있을때만 발생)(중복된 데이터 생성 문제 발생)
  • user : Team = M : 1 일 때,  join(user.team, team).fetchjoin()...
  • ManyToMany일땐, 사용하지 않는다. jpa에서 자동으로 막아준다고는 한다.(너무 많이 땡기면 뻑나기 때문에)
  • ToMany일때는, jpa.properties.hibernate.default_batch_fetch_size: 1000 사용하기

https://www.youtube.com/watch?v=7ljqL8ThUts&t=224s

728x90
Comments