JPA 프로그래밍 정리

JPA 프로그래밍 정리

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

영속성 컨텍스트 관련

영속성 관리

  • 매핑한 엔티티를 엔티티 매니저를 통해 사용하는 방법.
  • 엔티티 매니저 → 엔티티 저장, 수정, 삭제, 조회 등 엔티티와 관련된 모든 일 처리.
  • 엔티티 매니저는 곧 엔티티를 저장하는 가상의 데이터베이스라고 생각할 수 있다.
 

엔티티 매니저 팩토리와 엔티티 매니저

  • 데이터베이스를 하나만 사용하는 경우 보통 EntityManagerFactory를 하나만 생성한다.
  • 이걸 생성하면 properties나 yml에 있는 정보를 바탕으로 팩토리를 생성한다.
  • 이제 이 팩토리를 통해 엔티티 매니저를 생성하여 사용하면 된다.
  • 팩토리는 여러 스레드에서 동시 접근해도 되지만 매니저는 동시성 문제가 발생하므로 스레드 간 공유하면 안된다.
 

영속성 컨텍스트

  • JPA에서 가장 중요한 개념이다.
  • 영속성 컨텍스트란 엔티티를 영구 저장하는 환경이다.
  • 엔티티 매니저를 통해 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관하고 관리한다.
em.persist(member); → 엔티티 매니저를 사용하여 member 엔티티를 영속성 컨텍스트에 저장한다는 뜻.
 

엔티티의 생명주기

  • 4가지 상태가 있다.
    • 비영속(new/transient): 영속성 컨텍스트와 전혀 관계가 없는 상태
    • 영속(managed): 영속성 컨텍스트에 저장된 상태
    • 준영속(detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
    • 삭제(removed): 삭제된 상태
    • notion image
  • 비영속
    • 엔티티 객체를 생성만 딱 했을 때는 아직 영속성 컨텍스트에 저장되지 않았다.
    • 아직 영컨이나 디비와는 전혀 관련이 없다.
    • 이 상태가 비영속 상태이다.
    • em.persist() 호출 전 상태.
  • 영속
    • em.persist()를 해서 엔티티가 영속성 컨텍스트에 저장된 상태.
    • 이는 이제 이 엔티티를 영속성 컨텍스트에서 관리하겠다는 뜻이다.
    • em.find(), JPQL을 통해 엔티티를 조회하면 영속 상태가 된다.
  • 준영속
    • 영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않게 되면 준영속 상태라고 한다.
    • em.detach(), em.close(), em.clear()를 하게 되면 준영속 상태가 된다.
  • 삭제
    • 엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제하는 것이다.
    • em.remove()하면 된다.
 

영속성 컨텍스트의 특징

  • 영속성 컨텍스트와 식별자 값
    • 영컨은 엔티티를 식별자 값으로 구분한다. (@Id를 통해 테이블의 기본 키와 매핑한 값)
    • 따라서 영속 상태는 식별자 값이 반드시 있어야 한다.
  • 영속성 컨텍스트와 데이터베이스 저장
    • 영속성 컨텍스트에 엔티티를 저장한 상태에서 트랜잭션을 커밋하는 순간 영속성 컨텍스트에 새로 저장된 엔티티를 데이터베이스에 반영한다.
    • 이는 em.flush()를 통해 달성된다.
  • 영속성 컨텍스트가 엔티티를 관리했을 때 장정
    • 1차 캐시
    • 동일성 보장
    • 트랜잭션을 지원하는 쓰기 지연
    • 변경 감지
    • 지연 로딩

엔티티 조회

  • 영컨은 내부에 캐시를 가지고 있고 이를 1차 캐시라 한다.
  • 영속 상태의 엔티티는 모두 1차 캐시에 일단 저장된다.
    • notion image
  • 이처럼 1차 캐시 안에 Map 구조로 식별자 값을 key로 엔티티 인스턴스를 value로 가지고 있다고 생각하면 된다.
  • 따라서 영컨에서 데이터를 저장하고 조회하는 모든 기준은 데이터베이스 기본 키값이다.
  • 예를 들어 멤버 엔티티를 조회한다고 하면 Member member = em.find(Member.class, "member1")
  • 이렇게 find()를 통해 조회하고 1차 캐시 안에 엔티티가 있으면 캐시에서 바로 뽑아주고 없으면 데이터베이스에서 조회한다.
    • Member member = new Member(); member.setId("member1"); member.setUsername("회원1"); //1차 캐시에 저장 em.persist(member); //1차 캐시에서 조회 Member findMember = em.find(Member.class, "member1");
  • 만약 1차 캐시에 없다? “member2” 키 값이 없는 상태에서 em.find(Member.class, "member2")
  • 하면 데이터베이스에서 조회해서 1차 캐시에 저장해준 다음 조회한 엔티티를 반환해 준다.

영속 엔티티의 동일성 보장

Member a = em.find(Member.class, "member1"); Member b = em.find(Member.class, "member1"); a == b 하면 true 나온다.
 

엔티티 등록

  • 엔티티 매니저를 사용해서 엔티티를 영속성 컨텍스트에 등록하기.
EntityManager em = emf.createEntityManager(); EntityTransaction trx = em.getTransaction(); // 엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 한다. trx.begin(); em.persist(memberA); em.persist(memberB); //트랜잭션을 커밋하는 순간 데이터베이스에 INSERT를 날린다. trx.commit();
  • 엔티티 매니저는 트랜잭션을 커밋하기 전까지 내부 쿼리 저장소에 INSERT를 차곡차곡 모아둔다.
  • 그러다가 커밋을 만나면 쫙 쏴주는데 이를 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)라고 한다.
  • 하나씩 SQL을 미리 쏘고 커밋을 하든 모아놨다가 한번에 커밋을 하든 결과는 동일하기 때문에 쓰기 지연을 통해 모아서 처리하는 것이 성능상의 이점도 있다.
 

엔티티 수정

  • SQL 수정 쿼리의 문제점
    • 예를 들어 Member 테이블의 이름과 나이를 변경하는 SQL을 짜고 나중에 등급을 변경해야 해서 또 SQL을 짜서 날린다고 치면 사실 한번에 이름, 나이, 등급을 변경하는 쿼리를 날리는 게 이득이다.
    • 그런데 그렇게 한 번에 짜다가 실수로 하나를 입력을 안하거나 하는 실수를 하면 에러가 날 것이고 추가 사항이 생길 때마다 쿼리를 새로 짜야 하는 불편함이 있다.
  • 이런 문제를 JPA는 변경 감지(dirty checking)를 통해 해결해준다.
EntityManager em = emf.createEntityManager(); EntityTransaction trx = em.getTransaction(); trx.begin(); Member memberA = em.find(Member.class, "memberA"); memberA.setUsername("hi"); memberA.setAge(10); trx.commit(); // JDBC 같으면 update 같은 게 있어야 할텐데 이건 없다. // 여기서 커밋하면 memberA 엔티티의 변경사항을 감지해서 데이터베이스에 반영해준다.
  • 이게 왜 되냐면, 엔티티를 영컨에 보관할 때 최초 상태를 복사해서 저장해 두는데 이를 ‘스냅샷’이라고 한다.
  • em.flush()할 때 변경된 엔티티와 스냅샷을 비교해서 변경 사항을 반영해 주는 것이다.
  • 이를 순서대로 보면 이렇다.
      1. 트랜잭션을 커밋하면 엔티티 매니저 내부에서 먼저 플러시가 호출된다.
      1. 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾는다.
      1. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.
      1. 쓰기 지연 저장소의 SQL을 데이터베이스에 보낸다.
      1. 데이터베이스 트랜잭션을 커밋한다.
  • 중요한 점은 이 변경 감지 기능은 영속성 컨텍스트에서 관리하는 영속 상태의 엔티티에만 적용된다는 것이다.
  • 비영속, 준영속 상태의 엔티티는 이것의 영향을 받지 않는다.
  • 이 때 수정 쿼리의 기본전략은 수정한 컬럼에 대해서만 update를 날리는 게 아니고 전체 컬럼에 대해 update를 만들어놓고 보낸다. 따라서 테이블의 컬럼이 대략 30개 이상 되면 성능 저하가 일어나기 때문에 이런 정적 수정 쿼리보다는 @DynamicUpdate를 붙여서 동적 수정 쿼리를 생성해서 날리게 하는 게 더 좋다고 한다. (쓰는 걸 본 적은 없음. 애초에 컬럼이 이렇게 많으면 테이블 설계가 잘못되었다고 보는 게 맞을지도.)
 

엔티티 삭제

  • 엔티티를 삭제하려면 먼저 삭제하려는 엔티티를 조회해야 한다.
  • 조회 후에 조회한 엔티티를 em.remove(”조회한 엔티티”) 해주면 엔티티 삭제 쿼리를 쓰기 지연 SQL 저장소에 보낸다.
  • 트랜잭션을 커밋해서 플러시를 호출하면 데이터베이스에서 실제로 삭제된다.
  • 이 때 주의할 점은 remove하면 그 시점부터 이 엔티티는 영속성 컨텍스트에서 삭제된다는 점이다.
 

플러시

  • em.flush()는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 일이다.
  • 플러시를 실행했을 때의 구체적 동작 내용
      1. 변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교해서 변경된 엔티티를 찾고 이에 대한 수정 쿼리를 만들어 쓰기 지연 SQL 저장소에 등록한다.
      1. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다.
  • 플러시를 일으키는 방법 3가지
    • em.flush()
    • 트랜잭션 커밋
    • JPQL 쿼리 실행시.
 

플러시 모드 옵션

  • 엔티티 매니저에 플러시 옵션을 직접 지정하려면 javax.persistence.FlushModeType을 사용해야 한다.
  • FlushModeType.AUTO: 커밋이나 쿼리를 실행할 때 플러시 (default)
  • FlushModeType.COMMIT: 커밋할 때만 플러시
  • 주의할 점은 flush라는 이름 때문에 flush하고 나면 영속성 컨텍스트에서 엔티티가 지워질 거라고 생각하면 안된다는 것이다.
  • 영컨의 변경 내용을 데이터베이스에 반영하는 것이 flush다.

준영속

  • em.detach(entity): 특정 엔티티만 준영속 상태로 전환한다.
  • em.clear(): 영속성 컨텍스트를 완전히 초기화한다.
  • em.close(): 영속성 컨텍스트를 종료한다.
 

엔티티를 준영속 상태로 전환: detach()

  • em.detach() 메서드는 특정 엔티티를 준영속 상태로 만든다.
  • 이러면 1차 캐시부터 쓰기 지연 SQL 저장소까지 해당 엔티티를 관리하기 위해 영속성 컨텍스트에 있던 내용이 다 제거된다.

영속성 컨텍스트 초기화: clear()

  • 이거는 영속성 컨텍스트 전체를 초기화해서 해당 영속성 컨텍스트의 모든 엔티티를 준영속 상태로 바꿔버리는 작업이다.
  • 따라서 이 영컨에 있었던 모든 엔티티에 대해 변경 감지가 동작하지 않는다.

영속성 컨텍스트 종료: close()

  • 영컨을 종료해버리면 해당 영속성 컨텍스트가 관리하던 영속 상태의 엔티티가 전부 준영속 상태가 된다.

** 근데 영속 상태의 엔티티는 주로 영컨이 종료되면서 준영속 상태가 된다. 개발자가 직접 준영속 상태로 만드는 일은 거의 없다.**

 

준영속 상태의 특징

  • 거의 비영속이나 마찬가지다.
  • 식별자 값은 가지고 있다. 왜나면 이미 한 번은 영속상태였기 때문이다. 영속 상태가 되기 위해선 무조건 식별자 값이 있어야 되기 때문이다.
  • 지연 로딩을 할 수 없다. 지연 로딩은 실제 객체 대신 프록시를 로딩해두고 해당 객체를 실제 사용할 때 영속성 컨텍스트를 통해 데이터를 불러오는 방법인데 준영속 상태는 영컨의 관리하에 있지 않기 때문에 지연로딩이 안된다.
 

병합: merge()

  • 준영속 상태의 엔티티를 다시 영속 상태로 변경하려면 병합을 사용하면 된다.
  • merge()는 준영속 상태의 엔티티를 받아서 새로운 영속 상태의 엔티티를 반환한다.
public class ExamMergeMain { static EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); public static void main(String args[]) { Member member = createMember("memberA", "회원1"); // 마지막에 close()해서 준영속 상태의 member임 member.setUsername("회원명변경"); // 준영속 상태에서의 변경 mergeMember(member); // 준영속 상태를 영속 상태로 변경 } static Member createMember(String id, String username) { // 영속성 컨텍스트1 시작 EntityManager em1 = emf.createEntityManager(); EntityTransaction tx1 = em1.getTransaction(); tx1.begin(); Member member = new Member(); member.setId(id); member.setusername(username); em1.persist(member); tx1.commit(); // 여기서 데이터베이스에는 "회원1"이 반영된다. em1.close(); // 영속성 컨텍스트1 종료, member 엔티티는 준영속 상태가 됨. return member; } static void mergeMember(Member member) { // 영속성 컨텍스트2 시작 EntityManager em2 = emf.createEntityManager(); EntityTransaction tx2 = em2.getTransaction(); tx2.begin(); Member mergeMember = em2.merge(member); // 사실 이 때 mergeMember를 만들어서 넣기보다는 기존에 준영속 상태로 되어있던 member에 merge를 해주는 것이 좋다. tx2.commit(); //준영속 상태 System.out.println("member = " + member.getUsername()); //영속 상태 System.out.println("mergeMember = " + mergeMember.getusername()); System.out.println("em2 contains member = " + em2.contains(member)); System.out.println("em2 contains mergeMember = " + em2.contains(mergeMember)); em2.close(); // 영속성 컨텍스트2 종료 // } } // 출력 결과 member = 회원명변경 mergeMember = 회원명변경 em2 contains member = false em2 contains mergeMember = true

merge()의 동작 방식

  1. merge()를 실행한다.
  1. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.
    1. 2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고 1차 캐시에 저장한다.
  1. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값을 mergeMember에 밀어 넣는다. 이 때, mergeMember의 “회원1”이라는 이름이 “회원명변경”으로 바뀐다.)
  1. mergeMember를 반환한다.
 
** merge()는 비영속 상태인 것도 영속 상태로 만들 수 있다.
 

엔티티 매핑 관련

엔티티 매핑

  • 객체와 테이블 매핑
    • @Entity, @Table
  • 기본 키 매핑
    • @Id
  • 필드와 컬럼 매핑
    • @Column
  • 연관관계 매핑
    • @ManyToOne, @JoinColumn
 

@Entity

  • JPA를 사용해서 테이블과 매핑할 클래스는 @Entity를 붙여야 한다.
  • 속성에는 name이 있는데 기본적으로는 클래스 이름이 그대로 엔티티 이름이 되지만 혹시나 패키지 안에 이름이 같은 엔티티 클래스가 있다면 name을 지정해서 충돌을 막아줘야 한다.
  • 주의사항
    • 기본 생성자는 필수다. (파라미터 없는 public 또는 protected 생성자)
    • final 클래스, enum, interface, inner 클래스에는 사용할 수 없다.
    • 저장할 필드에 final을 사용하면 안된다.
 

@Table

  • 엔티티와 매핑할 테이블을 지정한다. 생략할 경우 매핑한 엔티티 이름을 테이블 이름으로 사용한다.
  • 속성으로는 name, catalog, schema, uniqueConstraints 가 있다.
  • 다만 uniqueConstraints로 유니크 제약조건을 만드는 경우에는 스키마 자동 생성 기능을 사용해서 DDL을 만들 때만 사용된다.
 

데이터베이스 스키마 자동 생성

  • 클래스의 매핑 정보를 통해 어떤 테이블에 어떤 컬럼을 사용하는지 알 수 있기 때문에 이러한 매핑 정보와 데이터베이스 방언을 사용해서 데이터베이스 스키마를 생성한다.
  • 이는 yaml에 ddl.auto: create 로 설정할 수 있다. 하지만 이는 운영단계에서는 절대로 쓰면 안된다.
  • show_sql 설정을 true로 할 경우에는 콘솔에 DDL을 출력할 수 있다.
  • hibernate.hbm2ddl.auto 속성
    • create: 기존 테이블을 삭제하고 새로 생성한다. DROP + CREATE
    • create-drop: create 속성에 추가로 애플리케이션을 종료할 때 생성한 DDL을 제거한다. DROP + CREATE + DROP
    • update: 데이터베이스 테이블과 엔티티 매핑정보를 비교해서 변경 사항만 수정한다.
    • validate: 데이터베이스 테이블과 엔티티 매핑정보를 비교해서 차이가 있으면 ‘경고’를 남기고 애플리케이션을 실행하지 않는다. 이 설정은 DDL을 수정하지 않는다.
    • none: 자동 생성 기능을 하용하지 않으려면 옵션 자체를 삭제하거나 유효하지 않은 값을 주면 된다. none이라는 옵션은 실제로 존재하는 옵션이 아니고 그냥 유효하지 않은 값을 준 것이다. 관례상 none을 많이 사용한다.
 

DDL 생성 기능

  • 스키마 자동 생성기능을 사용하고 있을 경우에 @Column에 속성을 줘서 제약조건을 걸 수 있다.
  • 예를 들어 not null 조건과 길이 조건을 걸고 싶다 하면 @Column(name = "NAME", nullable = false, length = 10)
  • 유니크 제약조건을 걸고 싶다면 @Table(name = “MEMBER”, uniqueConstraints = {@UniqueConstraint(name = “NAME_AGE_UNIQUE”, columnNames = {”NAME”, “AGE”})})
  • 이런식으로 쓸 수 있긴 한데 우리는 DDL 자동 생성 기능을 사용하지 않기 때문에 이런 제약 조건들이 필요가 없다.
  • 하지만 이 기능을 쓴다면 어노테이션만 보고도 테이블 속성들을 확인할 수 있어서 좋은 점도 있다.
 

기본 키 매핑

  • 시퀀스, auto_increment처럼 데이터베이스와 동일한 조건을 주고 싶을 때는 어떻게 할까?
  • JPA의 기본 키 생성 전략
    • 직접 할당
    • 자동 생성 (@GeneratedValue 이용)
      • IDENTITY: 기본 키 생성을 데이터베이스에 위임
      • SEQUENCE: 데이터베이스 시퀀스를 사용해서 기본 키 할당
      • TABLE: 키 생성 테이블 이용
 
  • 직접 할당은 거의 안쓰니 패스

IDENTITY 전략

  • 주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용한다.
  • IDENTITY 전략을 사용하면 JPA가 기본 키 값을 얻어오기 위해 데이터베이스를 추가로 조회한다.
  • 본 전략과 최적화 문제
    • 데이터베이스에 INSERT를 한 후에 기본 키 값을 조회할 수 있는 전략이기 때문에 엔티티에 기본 키 식별자 값을 할당하려면 JPA는 엔티티 생성 후에 추가로 데이터베이스를 조회하게 된다. 하지만, 하이버네이트는 JDBC3에서 추가된 Statement.getGeneratedKeys()를 사용하여 데이터 저장과 동시에 생성된 기본키값도 가져온다.
    • 엔티티가 영속화 되려면 식별자가 무조건 있어야 한다고 앞서 말했다. IDENTITY 전략은 엔티티를 데이터베이스에 저장을 해야 데이터베이스의 전략에 따라 식별자를 가져올 수 있기 때문에 em.persist()를 하는 즉시 INSERT SQL이 날라간다. 이 때문에 트랜잭션을 지원하는 쓰기 지연이 동작하지 않는 전략이다…

SEQUENCE 전략

  • 시퀀스는 유일값을 순서대로 생성하는 데이터베이스 오브젝트다.
@Entity @SequenceGenerator(name = "BOARD_SEQ_GENERATOR", sequenceName = "BOARD_SEQ", // 매핑할 데이터베이스 시퀀스 이름 initailValue = 1, allocationSize = 1) public class Board { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "BOARD_SEQ_GENERATOR") private Long id; }
  • 이 전략의 경우에는 em.persist()로 엔티티를 저장할 때, 데이터베이스의 시퀀스를 통해 식별자를 먼저 조회한 후에 엔티티를 영속성 컨텍스트에 저장하기 때문에 트랜잭션을 커밋해서 em.flush()가 일어날 때 엔티티가 데이터베이스에 저장된다.
  • ** allocationSize의 기본 옵션은 50이기 때문에 데이터베이스의 시퀀스는 1씩 증가한다면 꼭 설정해주어야 하는 부분이다.

Table 전략 생략

AUTO 전략(기본값이다.)

  • 데이터베이스 방언에 따라 IDENTITY, SEQUENCE, TABLE 전략 중 하나를 자동으로 선택해 준다.
  • 이걸 쓰면 자동이기 때문에 데이터 소스가 달라져도 수정할 필요가 없다는 장점이 있다.
 

기본 키 매핑 정리

  • 영속성 컨텍스트는 엔티티를 식별자 값으로 구분하기 때문에 엔티티를 영속 상태로 만들려면 식별자 값이 반드시 있어야 한다고 했다.
  • em.persist() 호출한 직후에 발생하는 일을 식별자 할당 전략별로 정리하면 아래와 같다.
    • 직접 할당: 영속화 호출 전에 직접 할당을 해주지 않으면 예외가 발생한다.
    • SEQUENCE: 데이터베이스 시퀀스에서 식별자 값을 획득한 이후 영속성 컨텍스트에 저장한다.
    • TABLE: 데이터베이스 시퀀스 생성용 테이블에서 식별자 값을 획득한 후 영속성 컨텍스트에 저장한다.
    • IDENTITY: 데이터베이스에 엔티티를 저장해서 식별자 값을 획득한 후 영속성 컨텍스트에 저장한다.
 

필드와 컬럼 매핑

@Column
컬럼을 매핑
@Enumerated
자바의 enum 타입을 매핑
@Temporal
날짜 타입 매핑
@Lob
BLOB, CLOB 매핑
@Transient
특정 필드를 데이터베이스에 매핑하지 않음
@Access
JPA가 엔티티에 접근하는 방식을 지정한다.

@Column

  • 속성 정리
    • name: 필드와 매핑할 테이블 컬럼 이름
    • nallable(DDL 생성 시 적용): false로 설정하면 not null 조건이 붙는다.
    • unique: 딱 한 컬럼에만 unique 제약조건을 걸고 싶을 때 쓸 수 있다. 이것도 DDL 생성 시 적용.
    • length: String 타입에만 걸 수 있고 문자 길이 제약 조건을 줄 수 있다.
    • precision, scale: BigDecimal, BigInteger 타입에 사용가능. precision은 소수점 포함 전체 자리수, scale은 소수 자리수를 지정할 수 있다.
 

@Enumerated

  • EnumType.String
  • EnumType.ORDINAL
 

@Temporal

  • java.util.Date, Calendar를 사용할 때 쓰는데 1.8 이후로 어차피 안쓰고 spring-data-jpa쓰면 @CreatedDate 같은걸로 쓰니까 생략
 

@Lob

  • 매핑하는 필드 타입이 문자면 CLOB이 되고 나머지는 BLOB이 된다.
 

@Transient

  • 데이터베이스와 매핑하지 않는 필드를 만들 때.
  • 객체에 임시로 어떤 값을 보관하고 싶을 때 만들어 쓴다.
 

@Access

  • JPA가 엔티티에 접근하는 방식 지정
  • AccessType.FIELD: 필드가 private이어도 필드로 접근가능.
  • AccessType.PROPERTY: getter를 써서 접근.
  • 이것도 딱히 쓸 일 없음.
 

(초 중요!) 연관관계 매핑 기초

  • 연관관계 매핑이란 객체의 참조와 테이블의 외래 키를 매핑하는 것이다.
  • 방향: 단방향, 양방향 (방향이 있는 것은 객체관계에만 해당하고 테이블 관계는 항상 양방향이다.)
  • 다중성: 다대일, 일대다, 일대일, 다대다
  • 연관관계의 주인: 객체를 양방향 연관관계로 만들 때 연관관계의 주인을 정해주어야 한다.
 

단방향 연관관계

  • 다대일 단방향 관계
    • 회원 테이블과 팀 테이블이 있다.
    • 회원은 하나의 팀에만 소속될 수 있다.
    • 회원과 팀은 다대일 관계다.
      • notion image
    • 객체 연관관계
      • 멤버 객체는 Member.team 필드로 팀 객체와 연관관계를 맺는다.
      • 회원 객체에서는 팀을 알 수 있지만 팀 객테에서는 회원 객체를 알 수 없다. 따라서 단방향 관계이다.
      • 이 때 객체는 참조로 연관관계를 맺는다. team의 주소를 가지고 참조를 하는 것이다.
      • 서로 양방향으로 참조하고 싶으면 양쪽에 필드를 추가해주어야 한다.
    • 테이블 연관관계
      • 멤버 테이블은 TEAM_ID 외래 키로 팀 테이블과 연관관계를 맺는다.
      • 멤버 테이블과 팀 테이블은 양방향 관계이다. 서로 외래키를 통해 참조할 수 있기 때문이다.(JOIN)
 

객체 관계 매핑

@Entity public class Member { ... ... @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; // 연관관계 설정 public void setTeam(Team team) { this.team = team; } } @Entity public class Team { ... }
객체의 연관관계는 Member.team 필드를 사용하는 것이고 테이블의 연관관계는 회원 테이블의 MEMBER.TEAM_ID라는 외래 키 컬럼을 사용한다.
이 둘을 매핑해주는 것이 연관관계 매핑이다.
@ManyToOne을 사용해서 회원과 팀이 다대일 관계라는 것을 알려주고 @JoinColumn을 통해 매핑하려는 외래키가 무엇인지 알려준다.
 

@JoinColumn의 속성

  • name: 매핑할 외래 키 이름 (기본값: 필드명 + _ + 참조하는 테이블의 PK명)
  • referencedColumnName: 외래 키가 참조하는 대상 테이블의 컬럼명

@ManyToOne의 속성

  • optional: false로 설정하면 연관 엔티티가 항상 있어야 함.
  • fetch: 글로벌 페치 전략 설정 (FetchType.EAGER, LAZY)
  • cascade: 영속성 전이 기능 설정
 

연관관계 사용

  • 저장
    • 아까 만들어둔 setTeam을 통해 연관관계를 설정하고 em.persist()해주면 끝.
  • 조회
    • 객체 그래프 탐색과 JPQL의 2가지 방법이 있다.
    • 객체 그래프 탐색: member.getTeam()
    • JPQL
    • private static void queryLogicJoin(EntityManager em) { String jpql = "select m from Member m join m.team t where t.name = :teamName"; List<Member> resultList = em.createQuery(jpql, Member.class) .setParameter("teamName", "팀1"); .getResultList(); }
    • sql 처럼 JPQL을 만들어서 조회할 수 있다.
  • 수정
    • setTeam()을 통해 새로운 team을 설정해주면 플러시가 일어날 때 자동으로 반영된다.
  • 제거
    • private static void deleteRelation(EntityManager em) { Member member1 = em.find(Member.class, "member1"); member1.setTeam(null); }
      setTeam을 null로 설정해주면 연관관계가 삭제된다.
      ** 연관된 엔티티를 삭제하고 싶다면 기존의 연관관계를 먼저 끊어야 한다. (외래 키 제약조건 때문) 연관관계를 모두 제거한 후 em.remove()를 통해 엔티티를 삭제할 수 있다.
       

양방향 연관관계

  • 위의 예제에서 팀에서도 멤버를 참조할 수 있게 해주면 양방향 연관관계가 된다.
    • notion image
  • 이렇게 설정하면 이제 회원과 팀은 다대일, 팀과 회원은 일대다 관계가 된다.
  • 일대다 관계는 team 하나에 여러 member가 관계를 맺고 있을 수 있으므로 컬렉션 타입이 되어야 한다.
    • @Entity public class Member { ... ... @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; // 연관관계 설정 public void setTeam(Team team) { this.team = team; } } @Entity public class Team { ... @OneToMay(mappedBy = "team") private List<Member> members = new ArrayList<Member>(); }
  • Team 객체에 OneToMany를 추가했다.
  • mappedBy에는 연관된 반대편 객체의 필드명을 넣어주면 된다.
 

연관관계의 주인

  • mappedBy를 쓰는 이유이다.
  • 객체에는 사실 양방향 관계가 없고 단방향 두 개를 동시에 썼을 뿐이다.
  • 따라서 이 관계를 관리하는 포인트가 2곳이 되버린다.
  • 객체의 참조는 둘이지만 외래 키는 하나인 문제가 발생하기 때문에 이 외래키를 어느 쪽에서 관리할 지를 정해주는 옵션이 mappedBy이다.
  • 따라서 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있다. 주인이 아닌 쪽은 읽기만 가능하다.
  • 주인은 mappedBy 속성을 사용하지 않는다. 주인이 아닌 쪽에 mappedBy 속성을 사용해서 주인을 지정해주어야 한다.
  • 위의 예제에서는 회원과 팀 관계의 주인은 회원이다. 주인은 테이블 상에 실제로 외래키가 있는 쪽을 주인으로 잡아주는 것이 옳다.
  • MEMBER 테이블에는 실제로 TEAM_ID 외래키가 있다. TEAM을 주인으로 잡아버리면 자기 테이블에 있지도 않은 외래키를 관리해야 한다.
** 결국 이런 이유 때문에 다대일, 일대다 관계에서는 항상 다 쪽이 주인이 된다.
  • member.setTeam()은 동작하지만 team.getMembers().add()는 동작하지 않는다.
  • 하지만 어차피 동작하지 않기 때문에 양쪽에다가 필드값을 다 집어넣는 게 속이 편하다.

연관관계 편의 메소드

  • 근데 양쪽에 관계를 설정하다보면 실수할 수도 있기 때문에 아예 아래 코드처럼 주인 쪽에서 한번에 연관관계를 설정해주는 게 좋다.
    • public class Member { private Team team; public void setTeam(Team team) { this.team = team; team.getMembers().add(this); } } //하지만 위와 같이 코드를 짜면 연관관계가 변경될 경우 기존 연관관계가 계속 조회되는 문제가 있다. //따라서 아래와 같이 바꿔줘야 한다. public void setTeam(Team team) { if (this.team != null) { this.team.getMembers().remove(this); } this.team = team; team.getMembers().add(this); }
       
 

심화 연관관계 매핑

다중성과 방향에 따라 연관관계를 구분하면 아래와 같다. (? : ! 에서 왼쪽이 연관관계의 주인이라 가정)
  • 다대일: 단방향, 양방향
  • 일대다: 단방향, 양방향
  • 일대일: 주 테이블 단방향, 양방향
  • 일대일: 대상 테이블 단방향, 양방향
  • 다대다: 단방향, 양방향
 

다대일

  • 데이터베이스 테이블의 일, 다 관계에서는 외래 키는 항상 다 쪽에 있다. 항상 다 쪽이 연관관계의 주인이다.

다대일 단방향

  • 위에서 했음.

다대일 양방향

  • 위에서 했음.
  • 다만 무한루프에 빠지지 않고 양쪽에 모두 편의 메소드를 추가하는 경우 검증 로직을 추가해주면 된다.
// Member 엔티티 public void setTeam(Team team) { this.team = team; //무한루프에 빠지지 않도록 체크 if(!team.getMembers().contains(this)) { team.getMembers().add(this); } } //Team 엔티티 public void addMember(Member member) { this.members.add(member); if(member.getTeam() != this) { member.setTeam(this); } }
 

일대다

  • 일대다 관계는 일 쪽에서 다를 참조하면 한 개 이상이 나오기 때문에 컬렉션 타입으로 받아야 한다. (Collection, List, Set, Map)
  • 일대다 단방향은 근데 일 쪽에 외래키가 없기 때문에 그냥 안하는 게 맞다.
  • 따라서 일대다, 다대일 관계는 다대일 양방향 매핑으로 하면 된다.
 

일대일

  • 예를 들어 회원은 하나의 사물함만 사용하고 사물함도 하나의 회원에 의해서만 사용될 때 같은 경우이다.
  • 이런 케이스는 다 쪽이 없기 때문에 양쪽 모두 연관관계의 주인이 될 수 있다.
  • 따라서 외래 키를 어느 쪽에서 관리할지를 먼저 정해야 한다.
  • 주 테이블에 외래 키 → 주 테이블만 확인해도 대상 테이블과 연관관계가 있는지 알 수 있다.
  • 대상 테이블에 외래 키 → 전통적인 데이터베이스 개발자들이 선호하는 방식. 일대일에서 일대다로 테이블 관계를 변경할 때도 테이블 구조를 유지할 수 있기 때문.
  • 연관관계 매핑은 쉽다. 그냥 주인 쪽에 JoinColumn 해주고, 반대편에 mappedBy 넣어주면 된다.
 

다대다

  • RDB는 정규화된 테이블 2개로 다대다 관계를 표현할 수 있기 때문에 보통 연결 테이블을 사용해서 일대다, 다대일 관계로 풀어낸다.

다대다: 단방향

@Entity public class Member { ... ... @ManyToMany @JoinTable(name = "MEMBER_PRODUCT", joinColumns = @JoinColumn(name = "MEMBER_ID"), inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID")) private List<Product> products = new ArrayList<Product>(); } @Entity public class Product { .. }
멤버에서 프로덕트로 가는 단방향 관계이다.
@JoinTable 옵션을 통해서 MEMBER_PRODUCT라는 연결 테이블을 지정하고 joinColumns 옵션을 통해 회원과 매핑할 조인 컬럼 정보를 “MEMBER_ID”로 지정하고 반대쪽에서 오는 매핑 정보를 PRODUCT_ID로 지정한다.
 

다대다:양방향

// 위 코드에 Product 엔티티에 mappedBy를 추가해 준다. @ManyToMany(mappedBy = "products") //역방향 추가 private List<Member> members;
역방향도 ManyToMany를 달아주면 되고 mappedBy로 종속되어 있다는 걸 알려주면 된다.
 

다대다 매핑의 한계를 극복하기 위해서는 연결 엔티티를 만들어줘야 한다.

@ManyToMany를 사용하면 연결테이블을 자동으로 생성해주지만 관리하기가 불편하다.
예를 들어서 회원이 상품을 주문하면 단순하게 연결 테이블에 단순히 주문한 회원 아이디와 상품 아이디만 들어가고 끝나지 않고 연결 테이블 안에 주문 수량 컬럼, 주문 날짜, 가격 등의 추가 컬럼이 더 들어가게 된다.
notion image
연결 엔티티인 memberProduct 객체를 만들고 회원과 일대다, 상품과 다대일로 매핑한다. 이 때 MemberProduct가 member_id 외래키를 가지고 있게 된다.
@Entity public class Member { ... @OneToMany(mappedBy = "member") private List<MemberProduct> memberProducts; } @Entity public class Product { ... } @Entity @IdClass(MemberProductId.class) public class MemberProduct { @Id @ManyToOne @JoinColumn(name = "MEMBER_ID") private Member member; //MemberProductId.member와 연결 @Id @ManyToOne( @JoinColumn(name = "PRODUCT_ID") private Product product; //MemberProductId.product와 연결 } // 연결 Id 관리용 클래스 - 복합기본키를 사용하기 때문에 만드는 클래스이다. public class MemberProductId implements Serializable { private String member; private String product; ... // hashCode, equals override 구현 ... }
이렇게 설정하면 MemberProduct 엔티티는 member의 기본키를 받아서 자신의 기본키 + 외래키 (복합키)로 사용한다. 이는 데이터베이스 용어로 식별 관계라고 한다.
Product의 기본 키 또한 자신의 기본키 + 외래키로 사용한다.
저장을 할 때는 다른 것들과 마찬가지로 member, product 만들고 memberProduct에 저 저녁들을 넣고 persist 하면 된다.
조회할 때는 em.find(MemberProduct.class, memberProductId)처럼 식별자 클래스로 조회해야 한다.

새로운 기본키를 사용하는 방법도 있다.

  • MemberProduct 에 아예 새로운 기본키를 할당해서 쓰는 방법이다.
  • 굳이 몰라도 될 것 같다.
  • 지금 pointHistoryDetail이 좀 복잡하게 parent_id를 자기 자신으로 참조해서 하는데 이처럼 다대다로 연결 테이블을 사용하는 방법도 있었을 것 같다.
 

고급 매핑 방법

@MappedSuperclass

  • 부모 클래스는 테이블과 매핑하지 않고 부모 클래스를 상속 받는 자식 클래스에게 매핑 정보만 제공하고 싶을 때 사용하는 것이다.
  • 우리 코드에서 BaseEntity가 이 어노테이션을 가지고 있는데 created_at, updated_at을 필드로 가지고 있고 BaseEntity라는 테이블이 실제로 존재하지 않는다. 대신 이 BaseEntity를 상속받는 모든 테이블이 created_at, updated_at을 컬럼으로 가지게 된다.
  • 이 클래스를 어디서 직접 갖다가 쓰지 않기 때문에 추상클래스로 만들어 놓고 상속받을 때만 쓰는 것이 좋다.
 

프록시와 연관관계 관리

  • 즉시로딩, 지연로딩: 프록시를 사용해서 엔티티 객체를 처음부터 DB에서 조회하지 않고 실제로 사용하는 시점에 조회할 수 있다. 혹은 조인을 활용해서 한 번에 조회할 수도 있다.
  • 영속성 전이와 고아 객체: JPA는 연관 객체를 함께 저장하거나 함께 삭제할 수 있는 영속성 전이, 고아 객체 제거라는 편의 기능을 제공한다.
 

프록시

  • 위에서 예시를 들었던 회원 엔티티와 팀 엔티티의 연관관계에서 회원 엔티티를 조회할 때 항상 팀 엔티티 정보가 필요한 것은 아니다.
@Entity public class Member { private String username; @ManyToOne private Team team; public Team getTeam() { return team; } public String getUsername() { return username; } } @Entity public class Team { private String name; public String getName() { return name; } } public void printUserAndTeam(String memberId) { Member member = em.find(Member.class, memberId); Team team = member.getTeam(); System.out.println("회원이름: " + member.getUsername()); System.out.println('소속팀: " + team.getName()); } public String printUser(String memberId) { Member member = em.find(Member.class, memberId); System.out.println("회원 이름: " + member.getUsername()); }
printUserAndTeam과 printUser 메소드를 비교해보면 전자는 member와 team의 내용이 모두 필요하지만 후자의 경우 member의 필드만 출력하기 때문에 member를 조회하는 시점에 사실 team의 내용까지 불러들일 필요가 없다. 이런 문제를 해결하기 위해 JPA는 DB 조회를 지연하는 방법을 제공하고 이를 지연 로딩이라 한다. printUserAndTeam 안의 team.getName()처럼 실제로 연관된 엔티티의 값을 사용하는 시점에 데이터베이스에서 조회하도록 조회를 미뤄두는 방법이다.
 

프록시 기초

  • em.find()를 쓰면 영속성 컨텍스트에 있을 시 그 엔티티를 반환해주고 없다면 데이터베이스에서 조회한다.
  • 하지만 실제로 엔티티를 사용하는 시점까지 데이터베이스 조회를 미뤄두고 싶다면 em.getReference() 메서드를 사용하면 된다.
  • 이 메서드를 호출하면 실제 엔티티 객체가 아닌 프록시 객체를 반환하게 된다.
  • 프록시 객체는 진짜 객체를 상속받아서 사용하기 때문에 사용 입장에서는 이게 진짠지 가짠지도 모르는 상태다.
    • notion image
  • 위 그림처럼 프록시 객체는 실제 객체에 대한 참조(target)을 가지고 있고 이를 통해 프록시 객체의 메소드를 호출하면 실제 엔티티 객체의 메소드를 호출하게 된다.
  • 프록시 객체는 entity.getName()처럼 실제로 내용을 사용할 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성해 주는데 이것을 프록시 객체의 초기화라고 한다.

프록시의 특징

  • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
    • 초기화 과정
      • 프록시 객체의 메서드를 호출할 때 실제 데이터를 조회한다.
      • 이 때 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청한다.
      • 이 요청이 초기화이다.
  • 프록시 객체를 초기화한다고 해서 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 참조(target)를 실제 엔티티 주소로 주는 것이다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다.
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다. (em.find()와 똑같아진다.)
  • 프록시의 초기화는 영속성 컨텍스트에 엔티티 생성을 요청하는 것이기 때문에 영속성 컨텍스트의 관리를 받지 않는 상태인 준영속 상태의 프록시를 초기화하면 예외가 발생한다.
 

프록시와 식별자

  • em.getReference()로 프록시를 조회할 때 식별자를 인자로 넘겨서 전달하기 때문에 프록시 객체는 이미 이 식별자 값을 가지고 있게 된다.
  • 따라서 member.getName()은 초기화 요청이 필요하지만 member.getId()는 이미 프록시 객체가 가지고 있기 때문에 초기화 요청하지 않는다.
  • 물론 이 요청은 @Access 옵션에 따라서 달라지긴 한다. PROPERTY면 초기화 안하고 FIELD면 초기화한다.
 

프록시 확인

  • PersistenceUnitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 초기화 여부를 확인할 수 있다. 초기화되지 않은 경우에 false를 반환해준다.
  • 조회한 엔티티가 진짜인지 프록시인지 확인하는 방법으로는 클래스명을 출력해보는 것이 있다.
  • sout("memberProxy = " + member.getClass().getName());
  • 해보면 memberProxy = 어쩌구저쩌구.Member_$$_javassist_0 처럼 나온다.
 

즉시 로딩과 지연 로딩

  • 프록시 객체는 연관 엔티티를 지연 로딩할 때 사용한다.
  • 계속해서 예제와 같이 member1 엔티티가 team1에 연관되어 있다고 하자.
Member member = em.find(Member.class, "member1"); Team team = member.getTeam(); // 객체 그래프 탐색 System.out.println(team.getName()); // 팀 엔티티 사용
  • 연관관계 매핑 옵션의 FetchType에 따라 결과가 달라진다.
  • @ManyToOne(FetchType.EAGER)인 경우 회원 클래스를 조회할 때 연관된 팀 엔티티까지 한꺼번에 조회해온다.
    • 위와 같이 하면 아래처럼 join으로 team을 아예 채워서 가져온다. JPA가 기본적으로 OUTER JOIN을 날리는 것은 혹시나 팀에 소속되지 않은 회원이 있을 가능성을 고려해주기 때문이다.
      • SELECT M.MEMBER_ID AS MEMBER_ID, M.TEAM_ID AS TEAM_ID, M.USERNAME AS USERNAME, T.TEAM_ID AS TEAM_ID, T.NAME AS NAME FROM MEMBER M LEFT OUTER JOIN TEAM T ON M.TEAM_ID=T.TEAM_ID, WHERE M.MEMBER_ID='member1'
  • FetchType.LAZY인 경우 연관된 엔티티를 실제로 사용할 때 조회하게 된다. `member.getTeam().getName()처럼 실제로 사용할 때 SQL이 날아간다.
    • 지연 로딩 옵션 하에서는 member.getTeam() 했을 때는 실제 엔티티가 아닌 프록시가 들어가 있는 상태가 된다.
    • 지연로딩인 컬렉션 타입은 어떻게 될까?
    • 회원 테이블과 일대다 관계로 주문 테이블이 매핑되어 있다고 하자.
    • Member member = em.find(Member.class, "member1"); List<Order> orders = member.getOrders(); sout("orders = " + orders.getClass().getName()); //결과: orders = org.hibernate.collection.internal.PersistentBag
    • 컬렉션인 경우에는 하이버네이트에서 컬렉션 추적 관리용으로 컬렉션 래퍼를 사용한다.
    • 지연 로딩할 때 엔티티는 프록시 객체가 할당되는 것처럼 컬렉션은 컬렉션 래퍼가 프록시 역할을 해준다. 따라서 getOrders()를 하는 시점에 컬렉션이 초기화되는 것은 아니고 .get()해서 컬렉션 내부의 내용을 실제로 조회할 때 데이터베이스에 조회 쿼리를 날려서 초기화한다.
    •  

JPA 기본 페치 전략

  • fetch 속성의 기본 설정값
    • ManyToOne, OneToOne → EAGER
    • OneToMany, ManyToMany → LAZY
    • 연관된 엔티티가 하나면 즉시 로딩, 컬렉션이면 지연 로딩을 사용하는 것이 기본 전략이다.
    • 즉시로딩을 사용하면 리소스를 너무 낭비하게 된다.
    • 따라서 모든 연관관계에 지연 로딩을 사용하는 것이 기본이고 꼭 즉시로딩 해야하는 곳만 즉시 로딩을 하는 것이 좋다.
    •  

영속성 전이(Cascade)

  • 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶다면 영속성 전이 기능을 사용하면 된다.
  • 부모 엔티티와 자식 엔티티가 일대다로 연관되어 있다고 하자
@Entity public class parent { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "parent") private List<Child> children = new ArrayList<Child>(); } @Entity public class Child { @Id @GeneratedValue private Long id; @ManyToOne private Parent parent; }
영속성 전이 옵션이 없는 채로 부모 한 명에 자식 두 명을 저장한다고 하면 아래처럼 해야 한다.
private static void saveNoCascade(EntityManager em) { //부모 저장 Parent parent = new Parent(); em.persist(parent); //1번 자식 저장 Child child1 = new Child(); child1.setParent(parent); // 자식 -> 부모 연관관계 설정 parent.getChildren().add(child1); // 부모 -> 자식 연관관계 설정 em.persist(child1) //2번 자식 저장 Child child2 = new Child(); child2.setParent(parent); // 자식 -> 부모 연관관계 설정 parent.getChildren().add(child2); // 부모 -> 자식 연관관계 설정 em.persist(child2) }

Cascade.PERSIST

@Entity public class parent { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST) private List<Child> children = new ArrayList<Child>(); }
PERSIST 옵션은 부모를 영속화할 때 자식들도 같이 영속화하라는 옵션이다.
이 상태에서 저장을 한다고 하면 코드는 이렇게 바뀐다.
private static void saveWithCascade(EntityManager em) { Child child1 = new Child(); Child child2 = new Child(); Parent parent = new Parent(); child1.setParent(parent); // 연관관계 추가 child2.setParent(parent); // 연관관계 추가 parent.getChildren().add(child1); parent.getChildren().add(child2); em.persist(parent); }
부모를 저장할 때 연관 자식들도 같이 저장해준다.
 

CascadeType.REMOVE

  • 옵션이 없는 상태에서 위에서 저장한 부모, 자식 엔티티들을 모두 제거하려면 em.find()로 부모, 자식1,2를 불러온 다음 em.remove를 해줘야 한다.
  • 하지만 remove 옵션이 있는 상태에서는 parent를 조회하고 em.remove(부모) 하면 알아서 연관된 자식 1,2도 지워준다.
 
CascadeType에는 ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH가 있다.
persist, remove 옵션은 em.persist, em.remove할 때 바로 전이가 일어나는 것이 아닌 flush()될 때 전이가 일어난다.
 

고아 객체

  • 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능.
@Entity public class parent { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "parent", orphanRemoval = true) private List<Child> children = new ArrayList<Child>(); } Parent parent1 = em.find(Parent.class, id); parent1.getChildren().remove(0);
orphanRemoval이 켜진 상태에서 위처럼 연관된 자식 엔티티를 컬렉션에서 제거하면 flush()될 때 DELETE가 날라가서 디비에서 삭제를 해준다.
이렇게 할 수 있는 이유는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 상태라고 보고 삭제를 하는 것이다. 따라서 OneToOne, OneToMany에만 사용할 수 있다.
또한 부모를 제거하면 자식은 고아가 되기 때문에 부모 제거시 자식도 같이 제거된다. 이 부분은 CascadeType.REMOVE의 동작 방식과 같다.
 

Cascade와 orphanRemoval 동시에 사용하기

CascadeType.ALL + orphanRemoval = true를 하게 되면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.
Cascade로 인해 자식을 저장하려면 부모에 등록만 하면 된다.
orphanRemoval로 인해 자식을 제거하려면 부모에서 제거하면 된다.
 

객체 지향 쿼리

  • 대표적으로 JPQL, QueryDSL을 사용한다.
    • JPQL

    • Java Persistence Query Language
    • 엔티티 객체를 조회하는 객체지향 쿼리이다.
    • 문법 자체는 SQL과 거의 비슷하고 ANSI 표준이 제공하는 기능은 거의 다 지원한다.
    • 데이터베이스 방언에 따라 자연스럽게 SQL로 변경되어 쏴진다.
    • 유저네임이 ‘kim’인 회원을 조회하는 JPQL을 짜서 날리는 방법.
    • String jpql = "select m from Member as m where m.username = 'kim'; List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
    • 순수 JPA를 사용한다면 모든 쿼리를 JPQL을 사용해 한다고 보면 된다.
    • 기본 문법과 쿼리 API

    • JPQL도 SELECT, UPDATE, DELETE 문을 사용할 수 있다.
    • 엔티티 저장은 em.persist()를 사용하기 때문에 INSERT 문은 없다.
    • SELECT문

    • 엔티티와 속성은 대소문자를 구분한다. 위의 셀렉트 jpql에서 Member, username에 해당한다. SELECT, FROM 등의 키워드는 대소문자를 구분하지 않는다.
    • 엔티티 이름 → Member는 클래스명이 아니고 엔티티명이다. 기본값으로 엔티티명에 클래스명이 할당되서 조회가 가능한 것이다.
    • 별칭은 무조건 달아야 한다. 한 엔티티만 조회한다고 SQL처럼 별칭 없이 SELECT username from Member m 이런식으로 쓰면 에러난다. m.username 처럼 꼭 별칭을 붙여줘야 한다.
    • 작성한 쿼리를 실행하려면 쿼리 객체를 만들어야 한다.
    • 쿼리 객체에는 TypeQuery와 Query 이렇게 두 종류가 있다.

    • 반환 타입을 명확히 지정할 수 있는 경우 TypeQuery를 사용하고 이걸 쓰는 게 편하다.
    • Query를 쓰면 반환타입이 명확하지 않은 탓에 Object[]를 반환하기 때문에 불편하다.
    • 쿼리 결과를 조회하는 방법은 .getResultList(), getSingleResult()를 사용한다.
      • 보통 전자를 많이 쓴다. 후자를 사용하는 경우 결과가 정확히 하나일 때가 아니면 예외를 발생시킨다.

      파라미터 바인딩

    • 이름 기준 파라미터: select m from Member m where m.username = :username" 이처럼 콜론을 앞에 붙여서 파라미터를 넣을 수 있다. 파라미터 바인딩은 .setParameter(”username”, “kim”); 와 같이 할 수 있다.
    • 위치 기준 파라미터: ?1 처럼 물음표 뒤에 위치 값을 주면 된다. (이름 기준을 쓰는 게 더 낫다.)
    • 프로젝션

    • 셀렉트 절에 조회할 대상을 지정하는 것을 프로젝션이라 한다. 프로젝션 대상으로는 엔티티, 임베디드 타입, 스칼라 타입이 있다.
    • 엔티티 프로젝션
      • select m.team from Member m
      • 조회한 엔티티는 영속성 컨텍스트에서 관리된다.
    • 임베디드 타입 프로젝션
      • 임베디드 타입은 엔티티와 거의 비슷하게 사용되나 임베디드 타입을 from에 넣어서 조회의 시작점으로 사용할 순 없다.
    • 스칼라 타입 프로젝션
      • 숫자, 문자, 날짜 같은 기본 데이터 타입들을 스칼라 타입이라고 한다.
      • em.createQuery("select m.username from Member m", String.class)
      • em.createQuery("select avg(o.orderAmount) from Order o", Double.class)
    • 엔티티 전체를 조회하면 편하겠지만 엔티티 안의 특정 값들만 지정해서 조회하면 Object[] 타입으로 반환된다. 이러면 불편하기 때문에 보통은 DTO를 만들어서 new 명령어를 사용해서 조회한다.
    • NEW 명령어

      public class UserDTO { private String username; private int age; public UserDTO(String username, int age) { this.username = username; this.age = age; } }
      위처럼 Member 객체에서 username과 age만 조회하고 싶을 때는 DTO를 만들어서 셀렉트 한다.
      TypedQuery<UserDTO> query = em.createQuery("select new example.UserDTO(m.username, m.age) from Member m", UserDTO.class); List<UserDTO> resultList = query.getResultList();
    • 페이징 API → 스프링 데이터 JPA를 사용할 거라 굳이 불편하게 JPQL로 사용하지 않는 것으로…
    • 복잡 페이징 처리의 경우에는 QueryDSL을 사용하는 편이 좋다.
    • 집합 함수

    • COUNT
    • MAX, MIN
    • AVG
    • SUM
    • NULL 값은 무시하므로 통계에 잡히지 않는다.
    • DISTINCT를 집합 함수 안에 넣으면 중복값을 제거한 뒤 집합계산을 할 수 있다.
    • GROUP BY, HAVING 또한 사용 가능 하다.
    • ORDER BY도 존재 한다.
    • JPQL 조인

    • 내부 조인은 INNER JOIN을 사용하고 INNER는 생략가능하다.
    • 조인할 때는 SQL 조인처럼 엔티티명을 조인하면 안되고 조인할 엔티티의 연관 필드를 사용해야 한다.
    • from Member m join m.team t
    • 외부 조인은 LEFT OUTER JOIN을 사용하고 OUTER는 생략 가능하다.
    • JOIN ON 절도 사용가능 하다.
    • 페치 조인 (중요)

    • JPQL에서 성능 최적화를 위해 제공하는 기능.
    • 엔티티 페치 조인
      • select m from Member m join fetch m.team
      • m.team 다음에 별칭을 안 붙이는 것이 페치 조인의 특징이다.
      • 이렇게 페치 조인을 실행시키면 아래와 같이 SQL이 날라간다.
      • select m.*, t.* from member m inner join team t on m.TEAM_ID=t.ID
      • 반대쪽인 team에서 members 컬렉션 페치 조인을 할 때는 select t from Team t join fetch t.members where t.name = 'A'
      • //SQL select t.*, m.* from team t inner join member m on t.ID = m.TEAM_ID where t.name = 'A'

      페치 조인의 특징과 한계

    • 페치 조인을 사용하면 SQL 쿼리 한번에 연관 엔티티들을 한꺼번에 조회할 수 있어서 성능최적화에 좋다.
    • 글로벌 페치 전략보다 페치 조인이 우선하기 때문에 지연 로딩으로 설정된 연관관계여도 페치 조인을 활용하면 한꺼번에 조회한다.
    • 한계
      • 페치 조인 대상에는 별칭을 줄 수 없음. → select, where, 서브쿼리절에 페치 조인 대상을 사용할 수 없다.
      • 둘 이상의 컬렉션을 페치할 수 없다.
      • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.

      서브 쿼리

    • JPQL에서도 서브 쿼리를 할 수 있으나 WHERE, HAVING 절에서만 사용할 수 있고 SELECT, FROM 절에서는 사용이 안된다. 하이버네이트의 HQL은 SELECT 절 서브쿼리까지는 허용한다.

스프링 데이터 JPA

공통 인터페이스

  • JPARepository를 상속받으면 사용할 수 있는 메서드들 (T는 엔티티, S는 엔티티와 그 자식 타입)
    • save(S): 새로운 엔티티는 저장하고 이미 있는 엔티티는 수정한다.
      • 식별자 값이 null이면 새로운 엔티티로 판단해서 em.persist()를 호출, 식별자 값이 있으면 em.merge()를 호출.
    • delete(T): 엔티티 하나 삭제. em.remove()를 호출.
    • findOne(ID): 엔티티 하나 조회. em.find() 호출
    • getOne(ID): 엔티티를 프록시로 조회. em.getReference() 호출
    • findAll(): 모든 엔티티 조회. 정렬, 페이징 조건을 파라미터로 제공 가능.

쿼리 메소드 기능

  • 메서드 이름만 가지고 쿼리를 유추해서 날려준다.
  • 메서드 이름을 통해서 JPA NamedQuery를 호출해서 알맞은 쿼리를 생성해준다.
  • @Query 어노테이션을 사용하면 리포지토리 인터페이스에 JPQL을 직접 정의해서 쓸 수도 있다.

메서드 이름으로 쿼리 만들어 주는 키워드 레퍼런스

JPA NamedQuery

  • 엔티티 클래스에 @NamedQuery를 이용해서 네임드쿼리를 짜두면 JpaRepository를 상속받은 레포지토리 인터페이스의 메서드에서 가져다 쓸 수 있다.

@Query

  • 레포지토리 메서드에 어노테이션을 붙여서 직접 JPQL을 작성해서 날릴 수 있다.
  • 네이티브 SQL을 날리고 싶다면 nativeQuery = true 옵션을 주면 된다.

파라미터 바인딩

  • 스프링 데이터 JPA도 마찬가지로 이름 기반, 위치 기반 파라미터 바인딩을 지원한다.
  • 이름 기반을 사용하는 것을 추천하며 @Param() 어노테이션을 사용해서 바인딩 해줄 수 있다.

벌크성 수정 쿼리

  • @Modifying 어노테이션을 사용하여 할 수 있다.

반환 타입

  • JPA와 마찬가지로 한 건 이상이면 컬렉션을 사용하고 단건인 경우에는 반환 타입을 특정한다.
  • 순수 JPA에서 단건조회를 할 때 단건이 아닌경우는 예외를 뱉지만 스프링JPA는 예외가 발생하는 경우 null을 반환해준다.

페이징과 정렬

  • Sort, Pageable을 파라미터로 보내서 정렬과 페이징을 할 수 있다.
// Page객체로 받으면 전체 건수 조회용 count 쿼리를 따로 날려준다. Page<Member> findByName(String name, Pageable pageable); // List로 받으면 count 쿼리를 날리지 않는다. List<Member> findByName(String name, Pageable pageable); List<Member> findByName(String name, Sort sort);
  • 페이징 조건은 PageRequest 객체를 생성하여 부여할 수 있다.
  • PageRequest는 Pageable 인터페이스를 구현한 객체다.
PageRequest pageRequest = new PageRequest(0, 10, new Sort(Direction.DESC, "name")); Page<Member> result = memberRepository.findByNameStartingWith("김", pageRequest); List<Member> members = result.getContent(); // 조회 내용 int totalPages = result.getTotalPages(); // 전체 페이지수 boolean hasNextPage = result.hasNextPage(); // 다음 페이지 존재 여부
  • 반환 타입 Page 인터페이스는 다양한 메서드를 지원한다.

Lock

  • 쿼리 메소드에 @Lock 어노테이션을 사용해서 트랜잭션 락을 걸 수 있다.

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

  • 사용자 정의 인터페이스 이름은 마음대로 지어도 되는데 보통 memberRepositoryCustom 처럼 뒤에 Custom을 많이 쓴다.
  • 실제로 이를 구현하는 클래스는 지은 인터페이스명 + Impl로 꼭 해야한다. memberRepositoryCustomImpl 이런식으로 지어야 인식된다.
  • 그리고 커스텀 인터페이스를 원래 레포지토리에서 상속받으면 된다.