JPA Programming Basic

 · 36 mins read

JPA Programming Base

영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의 노트

Project

JPA

  • Java Persistence API
  • 자바 진영의 ORM 기술 표준 (인터페이스의 모음)
    • 구현체로는 Hibernate, EclipseLink, DataNucleus..
  • Application과 JDBC 사이에서 동작

ROM

  • Object-Relational Mapping
  • Object는 Object대로, RDBMS는 RDBMS대로 설계
  • ORM 프레임워크가 중간에서 매핑

EntityManagerFactory

  • persistence.xml 설정 정보 확인 후 persistence-unit name 에 맞는 EntityManagerFactory 생성
  • Web Server 가 생성되는 시점에 하나만 생성해서 애플리케이션 전체에서 공유
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
    

EntityManager

  • 요청 건마다 생성
  • 쓰레드간 공유하면 안 되고, 사용 후 종료
    EntityManager em = emf.createEntityManager();
    

EntityTransaction

  • JPA의 모든 데이터 변경은 트랜젝션 안에서 실행
    EntityTransaction tx = em.getTransaction();
    tx.begin();
    

영속성 관리

영속성 컨텍스트

PersistenceContext (엔티티를 영구 저장하는 환경)

  • EntityManager 를 통해 PersistenceContext 에 접근
    • EntityManager, PersistenceContext 는 1:1, N:1 관계 존재
  • 엔티티 생명주기
    • 비영속 (new / transient)
      • 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
        Member member = new Member();
        member.setId(1L);
        member.setName("Aaron");
        
    • 영속 (managed)
      • 영속성 컨텍스트에 관리되는 상태
        entityManager.persist(member);
        
    • 준영속 (detached)
      • 영속성 컨텍스트에 저장되었다가 분리된 상태
        entityManager.detach(member);
        entityManager.clear()
        entityManager.close()
        
    • 삭제 (removed)
      • 삭제된 상태
        entityManager.remove(member);
        

.

영속성 컨텍스트의 이점

  • 1차 캐시에서의 조회
    • 사용자의 하나의 요청-응답(하나의 트랜젝션) 내에서만 효과가 있으므로 성능 이점을 기대하지는 않음.
    • 엔티티 조회 시 먼저 1차 캐시에서 조회 후, 없을 경우 DB에서 조회
  • 영속 엔티티의 동일성(identity) 보장
    • 1차 캐시로 반복 가능한 읽기(REPEATABLE READ) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공
  • 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
    • Query를 쌓아 두다가 transaction.commit() 을 하는 순간 데이터베이스에 Query 전송
  • 변경 감지(Dirty Checking)
    • transaction.commit() 시점에 엔티티와 스냅샷(처음 읽어 온 엔티티 상태) 비교 후 변경이 감지되면 Update Query 를 쓰기 지연 SQL 저장소에 저장
    • 이후 DB에 Query 전송 및 Commit
  • 지연 로딩(Lazy Loading)

.

Flush

영속성 컨텍스트의 변경내용을 데이터베이스에 반영하는 역할

발생 시점

  • 변경 감지
  • 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송 (CUD Query)

호출 방법

  • 직접 호출 : em.flush()
  • 자동 호출 : 트랜잭션 커밋, JPQL 쿼리 실행

엔티티 매핑

객체와 테이블

객체와 테이블 매핑

  • @Entity: JPA가 관리하는 클래스 (기본 생성자 필수)
  • @Table: 엔티티와 매핑할 테이블 지정

필드와 컬럼 매핑

  • @Column

기본 키 매핑

  • @Id

연관관계 매핑

  • @ManyToOne
  • @JoinColumn

.

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

@Entity가 있는 클래스 DDL을 애플리케이션 실행 시점에 자동 생성 (개발 환경에서만 사용)

<property name="hibernate.hbm2ddl.auto" value="create" />
  • create: 기존테이블 삭제 후 다시 생성 (DROP + CREATE)
  • create-drop: create와 같으나 종료시점에 테이블 DROP
  • update: 변경분만 반영(운영DB에는 사용하면 안됨)
  • validate 엔티티와 테이블이 정상 매핑되었는지만 확인
  • none: 사용하지 않음

주의

  • 운영 장비에는 절대 create, create-drop, update 사용하면 안됨.

    • 개발 초기 단계는 create 또는 update

    • 테스트 서버는 update 또는 validate

    • 스테이징과 운영 서버는 validate 또는 none

.

필드와 컬럼

@Entity
public class Member {

    @Id
    private Long id;

    /**
     * @Column : 컬럼 매핑
     * - name : 필드와 매핑할 테이블 컬럼명 (default. 객체 필드명)
     * - insertable : 등록 가능 여부 (default. TRUE)
     * - updatable : 수정 가능 여부 (default. TRUE)
     *
     * 아래는 DDL 조건
     * - nullable : null 허용 여부 (default. TRUE)
     * - unique : 유니크 제약 조건, 제약조건명이 랜덤키로 생성되어 주로 사용하지는 않음 (default. FALSE)
     * - columnDefinition : 데이터베이스 컬럼 정보 직접 설정 (ex. varchar(100) default ‘EMPTY')
     * - length : 문자(String) 길이 제약조건 (default. 255)
     *
     * BigDecimal, BigInteger 타입에 사용
     * - precision : 소수점을 포함한 전체 자릿수 (default. 19)
     * - scale : 소수 자릿수 (default. 2)
     */
    @Column(name = "name")
    private String username;

    private Integer age;

    /**
     * @Enumerated : enum 타입 매핑
     * 주의. ORDINAL 사용 X! (enum 순서를 저장)
     */
    @Enumerated(EnumType.STRING)
    private RoleType roleType;

    /**
     * @Temporal : 날짜 타입 매핑
     * DATE, TIME, TIMESTAMP
     * (LocalDate, LocalDateTime 사용할 시 생략)
     */
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdDate;
    @Temporal(TemporalType.TIMESTAMP)
    private Date lastModifiedDate;

    /**
     * @Lob : BLOB, CLOB 매핑
     * 매핑 필드 타입이 문자면 CLOB, 나머지는 BLOB으로 매핑
     * - CLOB: String, char[], java.sql.CLOB
     * • BLOB: byte[], java.sql. BLOB
     */
    @Lob
    private String description;

    /**
     * @Transient : 해당 필드를 컬럼에 매핑하지 않음
     * 메모리상에서만 임시로 데이터를 보관할 경우 사용
     */
    @Transient
    private String temp;
}

.

기본 키

  • @Id : 직접 할당할 경우
  • @GeneratedValue : 자동 생성할 경우
    • AUTO : 방언에 따라 자동 지정 (default)
    • IDENTITY : 데이터베이스에 위임 (MYSQL)
      • 주로 MySQL, PostgreSQL, SQL Server, DB2 에서 사용
      • 참고) DB INSERT Query 실행 후에 ID 값을 알 수 있으므로, em.persist() 시점에 즉시 INSERT Query 실행 및 DB 식별자 조회
    • SEQUENCE : 데이터베이스 시퀀스 오브젝트 사용 (ORACLE, @SequenceGenerator)
      • 주로 오라클, PostgreSQL, DB2, H2 에서 사용
    • TABLE : 키 생성용 테이블 사용, (모든 DB, @TableGenerator)

Long Type + 대체키 + 키 생성전략 사용 권장

AUTO & IDENTITY

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

SEQUENCE

allocationSize

  • 시퀀스를 한 번 호출할 때 증가하는 수 (성능 최적화에 사용, default. 50)
    • 웹 서버를 내리는 시점에 메모리에 저장되어있던 시퀀스들이 날라가서 구멍이 생기므로, 50~100이 적절
    • DB 시퀀스 값이 하나씩 증가하도록 설정되어 있다면, 이 값을 반드시 1로 설정
  • ex) 초기 1 ~ 51 까지 조회, 이후 시퀀스는 DB에서 조회하지 않고 메모리상에서 조회
    • 메모리에서 시퀀스 51을 만나는 순간 다시 DB에서 조회(next call)
  • 미리 시퀀스 값을 올려두므로 동시성 문제가 되지 않음
  • 이 부분은 Table 전략도 유사
@Entity
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR", //생성 이름
        sequenceName = "MEMBER_SEQ", //매핑할 데이터베이스 시퀀스 이름
        initialValue = 1, allocationSize = 1)
public class Member {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE,
          generator = "MEMBER_SEQ_GENERATOR")
  private Long id;
}

연관관계 매핑

JPA는 객체의 참조와 테이블의 외래 키를 매핑

방향(Direction)

단방향

  @Entity
  public class Member {
      @Id @GeneratedValue
      private Long id;

      @Column(name = "USERNAME")
      private String name;

      @ManyToOne
      @JoinColumn(name = "TEAM_ID")
      private Team team;
  }

  //...
  //단방향 연관관계 설정 (참조 저장)
  Team team = new Team();
  team.setName("TeamA");
  em.persist(team);

  Member member = new Member();
  member.setName("member1");
  member.setTeam(team);

  em.persist(member);

양방향

  • 객체의 양방향 관계는 사실 서로 다른 단뱡향 관계 2개라는 사실.
    • 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 함
  • 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리
  @Entity
  public class Member {
      @Id @GeneratedValue
      private Long id;

      @ManyToOne
      @JoinColumn(name = "TEAM_ID")
      private Team team;
  }

  @Entity
  public class Team {
      @Id @GeneratedValue
      private Long id;
       
      @OneToMany(mappedBy = "team")
      List<Member> members = new ArrayList<Member>();
  }

연관관계의 주인(Owner)

외래키를 관리하는 참조

양방향 매핑 규칙

  • 관계를 갖는 두 객체 중 하나의 객체를 연관관계의 주인으로 지정
    • 연관관계의 주인만이 외래 키를 관리(등록, 수정)하고, 주인이 아닌 쪽은 조회만 가능
    • 주인이 아닌 객체의 필드에 mappedBy 속성으로 주인 필드를 지정
  • 연관관계의 주인은 다(N:1)에 해당하는 객체쪽이 갖도록(외래키를 갖는 테이블 기준)
    • 연관관계의 주인에 값 설정하기
      Team team = new Team();
      team.setName("TeamA");
      em.persist(team);
      
      Member member = new Member();
      member.setName("member1");
      //연관관계의 주인에 값 설정
      member.setTeam(team);
      //순수 객체 상태를 고려해서 항상 양쪽에 값 설정하기
      team.getMembers().add(member);
      em.persist(member);
      
    • 연관관계 편의 메소드를 생성하는 것이 편리
      @Entity
      public class Member {
        //..
      
        public void changeTeam(Team team) {
          this.tema = team;
          team.getMembers().add(this);
        }
      }
      
      // OR (두 객체 중 한 객체를 선택)
      
      @Entity
      public class Team {
        //...
      
        public void addMember(Member member) {
          member.setTeam(this);
          members.add(member);
        }
      }
      
  • 양방향 매핑시에 무한 루프로 인한 StackOverflow 조심하기
    • toString(), lombok, JSON 생성 라이브러리(=> Controller DTO 반환으로 해결)

단방향 매핑만으로도 연관관계 매핑은 완료된 상태.

추후 역방향 탐색이 필요할 경우에 추가하기!(테이블에 영향 X)

다중성(Multiplicity)

다대일(N:1) - @ManyToOne

  • 테이블 외래키 기준으로 연관된 참조를 설정(N이 연관관계의 주인)
  • 양방향 연결을 할 경우, 반대 객체에도 OneToMany 방향 설정 추가(조회만 가능)

일대다(1:N) - @OneToMany

  • 일대다 단방향
    • 위와 반대 케이스로 일(1)이 연관관계의 주인이 될 경우, A(1) 테이블 업데이트를 시도했지만 B(N) 테이블도 함께 업데이트를 해야하는 상황으로 여러 이슈 발생 요소가 생김
      • 엔티티가 관리하는 외래키가 다른 테이블에 있으므로 연관관계 관리를 위해 추가 업데이트 쿼리 발생
        @OneToMany
        @JoinColumn(name = "team_id")
        private List<Member> members = new ArrayList<>();
        
  • 일대다 양방향 연결은 공식적으로 존재하지 않는 매핑

일대다 단뱡향 매핑보다 다대일 양방향 매핑을 사용하자

일대일(1:1) - @OneToOne

ex) 회원과 개인 락커의 관계

  • 외래키에 DB 유니크(UNI) 제약조건 필요
  • 설정은 다대일 매핑과 유사
  • 주/대상 테이블 중에 외래키 선택 가능
    • 주 테이블 선택
      • JPA 매핑이 편리하여 객체지향 개발자 선호
      • 장점. 주 테이블만 조회해서 대상 테이블 데이터 확인 가능
      • 단점. 값이 없으면 외래키에 null 허용
    • 대상 테이블 선택
      • 전통 DB 개발자 선호
      • 장점. 주/대상 테이블을 1:1 관계에서 1:N 관계로 변경 시 테이블 구조 유지 가능
      • 단점. 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩

단방향

@Entity
  public class Member {
    //..
    @OneToOne
    @JoinColumn(name = "locker_id")
    private Locker locker;
  }

양방향

@Entity
public class Locker {
    //..
    @OneToOne(mappedBy = "Locker")
    private Member member;
}

다대다(N:M) - @ManyToMany

  • RDB는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없으므로 중간 테이블이 필요
  • 객체는 @ManyToMany, @JoinTable로 다대다 관계를 표현할 수 있지만, 기타 데이터를 포함시킬 수 없는 한계로 실무에서는 사용하기 어려움 (관계형 데이터베이스 테이블은 N:N 관계 설계가 불가능하므로 엔티티와 테이블 불일치 문제도 발생)
    • 연결 테이블용 엔티티를 추가하는 방법 사용
  • @ManyToMany -> @OneToMany, @ManyToOne 로 풀어서 사용하자.

Member.java

@Entity
public class Member {
  //...
  @OneToMany(mappedBy = "member")
  private List<MemberProduct> memberProducts = new ArrayList()<>;
}

MemberProduct.java

id 대신 (member, product)를 묶어서 PK, FK로 사용할 수도 있지만,

향후 비즈니스적인 조건이 추가될 경우를 고려하면, GeneratedValue ID를 사용하는 것이 더 유연하고 개발이 쉬워지는 장점이 있음

@Entity
public class MemberProduct {

    @Id @GeneratedValue
    @Column(name = "member_product_id ")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;

    private int orderAmount;
    private int price;
    private LocalDataTime orderDateTime;
}

Product.java

@Entity
public class Product {
    //...
    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList()<>;
}

고급 매핑

상속관계 매핑

  • 객체의 상속 구조DB의 슈퍼/서브타입 관계를 매핑

  • DB 슈퍼/서브타입 논리 모델을 물리 모델로 구현하는 방법

    • @DiscriminatorColumn(name=“DTYPE”) / 자식 타입 필드 사용 (default. DTYPE)
    • @DiscriminatorValue(“XXX”) / 자식 타입명 수정 시 (default. entity name)
    • @Inheritance(strategy=InheritanceType.XXX) / 상속 타입

조인 전략 JOINED

  • 기본 정석으로 사용
  • 장점
    • 테이블 정규화 (저장공간 효율화)
    • 외래키 참조 무결성 제약조건 활용
  • 단점
    • 조회 쿼리가 복잡해지고, 조인을 많이 사용하게 되어 성능 저하
    • 데이터 저장 시 INSERT Query 두 번 호출
//Item.java
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name=DTYPE)
public class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private Integer price;
}
//Album.java
@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    private String artist;
}
//Movie.java
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;
    private String actor;
}
//Book.java
@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;
}

단일 테이블 전략 SINGLE_TABLE

  • 단순하고 확장 가능성이 없을 경우 사용
  • 장점
    • 조회 시 조인이 필요 없으므로 일반적으로 조회 성능이 빠르고 단순
  • 단점
    • 자식 엔티티가 매핑한 컬럼은 모두 null 허용
    • 단일 테이블에 많은 필드를 저장하므로 테이블이 커질 수 있고, 상황에 따라 조회 성능이 저하될 수 있음
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn
public class Item {
}

구현 클래스마다 테이블 전략 TABLE_PER_CLASS

  • 부모 클래스는 추상(abstract) 클래스로 생성
  • 유지 보수 및 관리 최악으로 비추하는 전략..
  • 장점
    • 서브 타입을 명확하게 구분해서 처리하기 효과적
    • not null 제약조건 사용 가능
  • 단점
    • 자식 테이블을 함께 조회할 때 성능 저하(UNION Query)
    • 자식 테이블을 통합해서 쿼리를 작성하기 어려움
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {
}

.

@MappedSuperclass

  • 공통 매핑 정보가 필요할 경우 사용
  • 추상 클래스 권장(직접 생성해서 사용할 일이 없음)
  • ex. 등록일, 수정일, 등록자, 수정자 등..
  • 헷갈리지 않기!
    • 상속관계 매핑X - 자식 클래스에 매핑 정보만 제공)
    • 엔티티/테이블 매핑X - 조회, 검색(em.find(BaseEntity)) 불가

프록시 & 연관관계 관리

프록시

DB 조회를 미루는(Lazy) 가짜(Proxy) 엔티티 객체 조회

em.getReference()

  • 실제 클래스를 상속 받아 만들어지고, 실제와 겉 모양만 같은 빈 깡통
  • 실제 엔티티의 참조(target)를 보관하고, 프록시 메서드를 호출하면 실제 엔티티 메서드를 호출

프록시 객체 특징

  • 프록시 객체는 처음 사용할 때 한 번만 초기화
    • 초기화 시 프록시 객체를 통해 실제 엔티티에 접근 가능
  • 프록시 객체는 원본 엔티티를 상속받으므로, 타입 체크 시 instance of 사용
  • 한 영속성 컨텍스트 안에서 동일한 ID 조회 시, JPA는 항상 같은 타입의 엔티티를 반환
    • 영속성 컨텍스트에 찾는 엔티티가 이미 있다면, em.getReference()를 호출해도 실제 엔티티를 반환
    • 반대로 프록시 조회 후, 엔티티 조회를 해도 실제 엔티티가 아닌 porxy 반환
  • 준영속 상태일 때(em.clear() / em.close() / em.detach()), 프록시를 초기화하면 LazyInitializationException 발생
//프록시 인스턴스의 초기화 여부 확인
emf.getPersistenceUnitUtil().isLoaded(entity);
//프록시 클래스 확인
entity.getClass();
//프록시 강제 초기화
org.hibernate.Hibernate.initialize(entity);
entity.getName() //JPA는 호출 시 초기화

즉시 로딩과 지연 로딩

즉시 로딩

@ManyToOne(fetch = FetchType.EAGER)
  • 연관 객체를 조인으로 함께 조회

지연 로딩

@ManyToOne(fetch = FetchType.LAZY)
  • 연관 객체를 프록시로 조회
  • 프록시 메서드를 호출하는 시점에 초기화(조히)

즉시/지연 로딩 주의 사항

  • 실무에서는 지연 로딩만 사용하자
    • 즉시 로딩 적용 시, 연관 관계가 많아지게 되면 예상하지 못한 SQL 발생
    • 또한, JPQL에서 N+1 문제 발생
  • N+1 문제 해결은 JPQL fetch join 혹은 Entity Graph 기능을 사용하자.
  • @ManyToOne, @OneToOne의 default는 즉시 로딩이므로 LAZY 설정 필요
    • @OneToMany, @ManyToMany default : 지연 로딩

영속성 전이

CASCADE

@OneToMany(mappedBy="parent", cascade=CascadeType.PERSIST)
  • 특정 엔티티를 영속 상태로 만들 때, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 경우 사용
    • 연관관계를 매핑하는 것과는 아무 관련 없음.
  • 엔티티의 소유자가 하나일 때(단일 엔티티에 종속적, 라이프 사이클이 유사)만 사용하기.
    • 여러 엔티티에서 관리되는 경우 사용 X! (관리가 힘들어진다..)
  • 종류 (보통 ALL, PERSIST, REMOVE 안에서 사용하며 라이프 사이클을 동일하게 유지)
    • ALL: 모두 적용
    • PERSIST: 영속
    • REMOVE: 삭제
    • MERGE: 병합
    • REFRESH: REFRESH
    • DETACH: DETACH

고아 객체

@OneToMany(mappedBy="parent", cascade=CascadeType.PERSIST, orphanRemoval = true)
  • 부모 엔티티와 연관관계가 끊어진 자식 엔티티
  • 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제
    • 고아 객체 제거 설정 : orphanRemoval = true
    • 영속성 전이와 동일하게 특정 엔티티가 개인 소유할 때만 사용하기
    • @OneToOne, @OneToMany만 사용 가능
    • 부모 엔티티를 제거할 때 CascadeType.REMOVE와 동일하게 자식도 함께 제거
  • 영속성 전이와 함께 사용할 경우 (CascadeType.ALL + orphanRemovel=true)
    • 부모 엔티티를 통해 자식의 생명 주기 관리 가능
    • DDD Aggregate Root 개념을 구현할 때 유용

JPA Data Type

엔티티 타입

  • @Entity로 정의하는 객체
  • 데이터가 변해도 식별자로 추적 가능

값 타입

  • 식별자가 없으므로 값 변경 시 추적 불가
  • 생명 주기를 엔티티에 의존
  • 데이터 공유 X!
  • 기본값 타입
    • Java Basic Type : int, double..
    • Wrapper Class : Integer, Long..
    • String
  • 임베디드 타입
  • 컬렉션 값 타입
  • 안전하게 불변 객체로 만들기

Embedded Type

  • 새로운 값 타입 정의 (기본 값 타입을 모아서 만든 복합 값 타입)
    • @Embeddable: 값 타입 정의
    • @Embedded: 값 타입 사용

장점

  • 값 타입을 객체지향적으로 사용 (재사용, 높은 응집도 ..)
  • 임베디드 타입 클래스만이 사용하는 유용한 메서드 생성
  • 임베디드 타입을 소유한 엔티티의 생명주기를 의존

특징

  • 잘 설계된 ORM Application은 매핑한 테이블 수보다 클래스 수가 더 많음
  • 한 엔티티에서 같은 임베디드 타입을 사용하여 컬럼명이 중복될 경우
    • @AttributeOverrides, @AttributeOverride 를 사용해서 컬러명 속성 재정의
  • 임베디드 타입의 값이 null이면, 매핑 컬럼 값 모두 null
@Embeddable
public class Address {

    @Column(length = 10)
    private String city;
    @Column(length = 20)
    private String street;
    @Column(length = 5)
    private String zipcode;

    public Address() {}

    private String fullAddress() {
        return getCity() + " " + getStreet() + " " + getZipcode();
    }

    //..
}


@Entity
public class Member extends BaseEntity{
  
  //..
  @Embedded
  private Address homeAddress;

  @AttributeOverrides({
      @AttributeOverride(name = "city",
              column = @Column(name = "work_city")),
      @AttributeOverride(name = "street",
              column = @Column(name = "work_street")),
      @AttributeOverride(name = "zipcode",
              column = @Column(name = "work_zipcode")),
  })
  private Address workAddress;
}

값 타입

불변 객체

  • 값 타입을 여러 엔티티에서 공유하면 Side Effect(부작용) 발생
    • 인스턴스 값을 공유하는 것은 위험하므로 값을 복사해서 사용하기
  • 값 타입을 불변 객체로 설계하여 객체 타입을 수정할 수 없게 만들기
    • 불변 객체: 생성 시점를 제외하고 값을 변경할 수 없는 객체
      • 생성자로만 값을 설정하고, Setter는 생성하지 않기
      • Integer, String은 자바가 제공하는 대표적인 불변 객체
    Address address = new Address("city", "street", "10000");
    
    Member member = new Member();
    member.setUsername("member1");
    member.setHomeAddress(address);
    em.persist(member);
    // 값을 공유하지 않고 새로 생성
    Address newAddress = new Address("NewCity", address.getStreet(), address.getZipcode());
    member.setHomeAddress(newAddress);
    

값 타입 비교

  • 값 타입 비교는 equals를 사용한 동등성 비교를 사용
    • 동일성(identity) 비교: 인스턴스 참조 값 비교 ==
    • 동등성(equivalence) 비교: 인스턴스 값 비교 equals()
  • 값 타입의 equals() 메소드를 적절하게 재정의
    • 프록시 사용을 고려하여 getter() 사용 추천
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return Objects.equals(getCity(), address.getCity()) 
        && Objects.equals(getStreet(), address.getStreet()) 
        && Objects.equals(getZipcode(), address.getZipcode());
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(getCity(), getStreet(), getZipcode());
    }
    

값 타입 컬렉션

  • 값 타입을 하나 이상 저장할 경우 사용
    • 셀렉트 박스와 같이 값 변경이 필요 없는 단순한 경우 사용
    • @ElementCollection, @CollectionTable
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없으므로, 별도의 테이블이 필요
    • 값 타입 컬렉션은 엔티티와 생명주기가 같음 (Casecade.ALL + orphanRemoval=true)
    @Entity
    public class Member extends BaseEntity{
      //...
      @ElementCollection
      @CollectionTable(name = "FAVORITE_FOOD", joinColumns =
          @JoinColumn(name = "member_id")
      )
      @Column(name = "food_name")
      private Set<String> favoriteFoods = new HashSet<>();
    
      @ElementCollection
      @CollectionTable(name = "ADDRESS", joinColumns =
          @JoinColumn(name = "member_id")
      )
      private List<Address> addressHistory = new ArrayList<>();
    }
    
  • 저장
    //..
    member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
    member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));
    
  • 조회
    • default. FetchType.LAZY 전략 사용
  • 수정
    findMember.getAddressHistory.remove(new Address("oldCity", "street", "12345"));
    findMember.getAddressHistory.add(new Address("newCity", "street", "12345"));
    
    fineMember.getFavoriteFoods().remove("치킨");
    fineMember.getFavoriteFoods().add("햄버거");
    

값 타입 컬렉션의 제약

  • 값 타입 컬렉션은 엔티티와 다르게 식별자 개념이 없으므로 변경 시 추적이 어려운 큰 단점 존재
    • 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 모든 값을 다시 저장하는 비효율적인 동작(식별자가 없으므로..)
    • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키로 구성해야 함
  • 결론적으로, 셀렉트 박스와 같이 변경이 필요 없는 단순한 경우가 아니라면, 값 타입 컬렉션 대신 일대다 단방향 관계를 추천 (식별자, 지속적인 값 추적, 변경이 필요한 경우)
    @Embeddable
    public class Address {
    
        private String city;
        private String street;
        private String zipcode;
        //...
    }
    
    @Entity
    @Table(name = "ADDRESS")
    public class AddressEntity {
    
      @Id @GeneratedValue
      private Long id;
    
      private Address address;
    
      public AddressEntity(String city, String street, String zipcode) {
        this.address = new Address(city, street, zipcode);
      }
        //..
    }
    
    @Entity
    public class Member extends BaseEntity{
      //...
      @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
      @JoinColumn(name = "member_id")
      private List<AddressEntity> addressHistory = new ArrayList<>();
    }
    

    객체지향 쿼리 언어

JPQL (Java Persistence Query Language)

  • SQL을 추상화한 객체 지향 쿼리 언어(특정 데이터베이스에 의존 X)
  • 테이블이 아닌 엔티티 객체를 대상으로 쿼리
  • 문자로 JPQL이 작성되다보니 동적 쿼리 작성이 어려운 단점

    List<Member> result = em.createQuery(
      "select m From Member m where m.name like ‘%park%'", Member.class
    ).getResultList();
    

QueryDSL

  • 문자가 아닌 자바코드로 JPQL 작성
    • 컴파일 시점에 문법 오류 체크s
    • 편리한 동적쿼리 작성
  • JPQL 빌더 역할

    JPAFactoryQuery query = new JPAQueryFactory(em);
    QMember m = QMember.member;
    
    List<Member> list = 
        query.selectFrom(m)
              .where(m.age.gt(18))
              .orderBy(m.name.desc())
              .fetch();
    

Reference documentation

네이티브 SQL

  • JPQL로 해결할 수 없는 특정 데이터베이스에 의존적인 기능 사용 시 SQL을 직접 작성

    String sql =
      "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = ‘kim’";
    
    List<Member> resultList =
      em.createNativeQuery(sql, Member.class).getResultList(); 
    

기타

  • JPA를 사용하면서 JDBC API, SpringJdbcTemplate, MyBatis 등을 함께 사용 가능
  • 단, 영속성 컨텍스트를 적절한 시점(SQL을 실행하기 직전)에 강제 플러시 필요 (em.flush())

기본 문법

반환 타입

  • TypeQuery: 반환 타입이 명확할 때 사용
  • Query: 반환 타입이 명확하지 않을 때 사용

조회

  • query.getResultList(): 결과가 하나 이상일 경우 (리스트 반환)
    • 결과가 없으면 빈 리스트 반환
  • query.getSingleResult(): 결과가 정확히 하나일 경우 (단일 객체 반환)
    • 결과가 없으면: javax.persistence.NoResultException
    • 둘 이상이면: javax.persistence.NonUniqueResultException

파라미터 바인딩

Member result = em.createQuery("select m from Member m where m.username = :username", Member.class)
                    .setParameter("username", "member1")
                    .getSingleResult();

프로젝션

  • SELECT 절에 조회할 대상을 지정하는 방식
    • 엔티티 프로젝션, 임베디드 타입 프로젝션, 스칼라 타입 프로젝션
    • 스칼라 타입 프로젝션의 경우 여러 값 조회 시 DTO 조회 추천
    List<MemberDto> result = 
          em.createQuery("select new jpql.MemberDto(m.username, m.age) from Member m", MemberDto.class)
          .getResultList();
    

페이징

  • setFirstResult(int startPosition) : 조회 시작 위치
  • setMaxResults(int maxResult) : 조회할 데이터 수
String jpql = "select m from Member m order by m.name desc";
List<Member> resultList = em.createQuery(jpql, Member.class)
    .setFirstResult(0)
    .setMaxResults(10)
    .getResultList();

조인

  • 내부 조인:
    • SELECT m FROM Member m [INNER] JOIN m.team t
  • 외부 조인
    • SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
  • 세타 조인
    • select count(m) from Member m, Team t where m.username = t.name

.

  • Join On (JPA 2.1, Hibernate 5.1 이상)
    • 조인 대상 필터링
      • SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'A'
    • 연관관계가 없는 엔티티 외부 조인
      • SELECT m, t FROM Member m LEFT JOIN Team t on m.username = t.name

서브 쿼리

select m from Member m
where m.age > (select avg(m2.age) from Member m2)
  • 지원 함수
    • [NOT] EXISTS (Subquery): 서브쿼리에 결과가 존재하면 참
    • {ALLANYSOME} (Subquery)
      • ALL: 모두 만족하면 참
      • ANY, SOME: 조건을 하나라도 만족하면 참
    • [NOT] IN (Subquery): 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참

.

  • 한계
    • JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능
      • Hibernate를 사용할 경우 SELECT 절도 가능
    • FROM 절의 서브 쿼리는 현재 JPQL에서 불가능 (조인으로 풀어보기)

타입 표현

  • 문자: ‘She’’s’
  • 숫자: 10L(Long), 10D(Double), 10F(Float)
  • Boolean: true, false
  • ENUM: jpql.MemberType.Admin (패키지명 포함) or query.setParameter()
      Member result = em.createQuery("select m from Member m where m.type = :userType", Member.class)
                      .setParameter("userType", MemberType.ADMIN)
                      .getResultList();
    
  • 엔티티 타입: 상속 관계에서 사용
    • 조회 대상을 특정 자식으로 한정
      --JPQL
      select i from Item i where type(i) IN (Book, Movie) 
      
      --SQL
      select i from i where i.DTYPE in ('Book', 'Movie')
      
    • 타입 캐스팅
      --JPQL
      select i from Item i where treat(i as Book).auther = 'kim'
      
      --SQL
      select i.* from Item i where i.DTYPE = 'B' and i.auther = 'kim'
      
  • COALESCE : 특정 컬럼이 Null일 경우 대체 값 반환
    select coalesce(m.username,'이름 없는 회원') from Member m
    
  • NULLIF : 지정된 두 식이 같으면 Null 반환
    select NULLIF(m.username, '관리자') from Member m
    

중급 문법

경로 표현식

  • 상태 필드 (m.username): 경로 탐색의 종점 (탐색 불가)
  • 단일 값 연관 경로 (m.team): 묵시적 내부 조인(inner join) 발생 (탐색 가능)
  • 컬렉션 값 연관 경로 (m.orders): 묵시적 내부 조인 발생 (탐색 불가)
    • 명시적 조인을 통해 별칭으로 탐색 가능

.

  • 명시적 조인: JOIN 키워드를 직접 사용
    • 조인이 발생하는 상황을 한 눈에 파악할 수 있어서 쿼리 튜닝이 편리
    • 조인은 SQL 튜닝에 중요한 포인트이므로, 가급적 명시적 조인을 사용하자 !!
      select m, t from Member m join m.team t
      
  • 묵시적 조인: 경로 표현식에 의해 묵시적으로 조인 발생
    • 내부 조인만 가능
    • 조인 쿼리를 파악하기 어려움
      select m.team from Member m
      

엔티티 직접 사용

  • JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용

Named Query

  • 미리 정의해두고 사용하는 JPQL 정적 쿼리
  • 애플리케이션 로딩 시점에 쿼리 검증 및 캐싱 후 재사용

벌크 연산

  • 한 번의 쿼리로 여러 엔티티 변경 (UPDATE, DELETE)
    • executeUpdate()로 영향을 받은 엔티티 수 확인 가능
  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 전달하므로
    • 벌크 연산을 먼저 실행하거나
    • 벌크 연산 수행 후 영속성 컨텍스트 초기화 (em.clear())
@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query="select m from Member m where m.username = :username")
public class Member {
 ...
}
//
List<Member> resultList =
 em.createNamedQuery("Member.findByUsername", Member.class)
    .setParameter("username", "회원1")
    .getResultList();

페치 조인

Fetch Join

  • JPQL 성능 최적화를 위해 제공
  • 쿼리 한 번에 연관된 엔티티나 컬렉션을 함께 조회 (즉시 로딩 우선 적용)
    • 일반 조인에서는 연관된 엔티티를 함께 조회하지 않음
  • N + 1 이슈의 해결 방법
  • [ LEFT [OUTER] / INNER ] JOIN FETCH
    SELECT m FROM Member m JOIN FETCH m.team;
    

Collection Fetch Join

  • 일대다 관계에서의 페치 조인

    -- JPQL
    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
    
  • 일대다 관계에서의 N+1 문제는 batchSize 설정으로 해결 가능

    • LAZY 동작 시, IN 쿼리로 size 만큼 한 번에 조회
      • 개별 성정
        @BatchSize(size = 100)
        @OneToMany(mappedBy = "team")
        List<Member> members = new ArrayList<Member>();
        
      • Global 설정
        hibernate.default_batch_fetch_size: 100
        
    • SQL
      select *
      from member
      where member.team_id in (?, ?, ?..)
      

DISTINCT

  • JPQL에서 DISTINCT가 제공하는 기능
    • 쿼리에 DISTINCT 추가
    • 애플리케이션에서 중복 엔티티 제거

한계

  • 페치 조인 대상에는 별칭 불가
    • 객체 그래프 사상(N에 해당하는 모든 데이터 조회를 기대)과 맞지 않음
    • t.members에서 조건을 걸고 싶다면, member를 select절에서 사용하자.
  • 컬렉션은 한 개만 페치 조인 가능
  • 컬렉션 페치 조인을 하면 페이징 API 사용 불가
    • 단일 값 연관 필드(1:1/N:1)는 페치 조인을 해도 페이징 가능
    • 컬렉션 페치 조인으로 컬렉션 데이터가 잘리는 현상 발생

스프링 부트/JPA 로드맵