Java/Spring & Spring Boot

JPA(Java Persistence API)

마손리 2023. 4. 23. 22:40

JPA

JPA(Java Persistence API)는 Java 진영에서 사용하는 ORM(Object-Relational Mapping) 기술의 표준 사양(또는 명세, Specification)이다.

 

 Java의 인터페이스로 사양이 정의되어 있기 때문에 JPA라는 표준 사양을 구현한 구현체는 따로 있다.

 

Hibernate ORM

Hibernate ORM는 JPA 표준 사양을 구현한 구현체중 하나이다. 

 

쉽게말하면 JPA라는 표준사양을 구현하는 API이며 JPA에서 지원하는 기능 이외에 Hibernate 자체적으로 사용할 수 있는 API 역시 지원하고 있다.

JPA는 Java Persistence API의 약자이지만 현재는 Jakarta Persistence라고도 불린다.

 

데이터 액세스 계층에서의 JPA 위치

데이터 액세스 계층에서 JPA는 데이터 액세스 계층의 상단에 위치한다.

 

데이터 저장, 조회 등의 작업은 JPA를 거쳐 JPA의 구현체인 Hibernate ORM을 통해서 이루어지며 Hibernate ORM은 내부적으로 JDBC API를 이용해서 데이터베이스에 접근하게 된다.

 

 

 

영속성 컨텍스트(Persistence Context)

ORM은 객체(Object)와 데이터베이스 테이블의 매핑을 통해 엔티티 클래스 객체 안에 포함된 정보를 테이블에 저장하는 기술이다.

 

JPA에서는 테이블과 매핑되는 엔티티 객체 정보를 영속성 컨텍스트(Persistence Context)라는 곳에 보관해서 애플리케이션 내에서 오래 지속되도록 한다.

그리고 이렇게 보관된 엔티티 정보는 데이터베이스 테이블에 데이터를 저장, 수정, 조회, 삭제하는 데 사용된다.

 

영속성 컨텍스트에는 1차 캐시라는 영역과 쓰기 지연 SQL 저장소라는 영역이 있다.

 

JPA API 중에서 엔티티 정보를 영속성 컨텍스트에 저장(persist)하는 API를 사용하면 영속성 컨텍스트의 1차 캐시에 엔티티 정보가 저장된다.

 

JPA API로 영속성 컨텍스트 작동 흐름 알아보기

build.gradle 설정

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // Spring data JPA
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'	// H2, In-memory DB
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

 

JPA 설정(src/resources/application.yml)

spring:
  h2:
    console:
      enabled: true
      path: /h2     
  datasource:
    url: jdbc:h2:mem:test
  jpa:
    hibernate:
      ddl-auto: create  # (1) 스키마 자동 생성
    show-sql: true      # (2) SQL 쿼리 출력

(1)과 같이 설정을 추가해 주면 JPA에서 사용하는 엔티티 클래스를 정의하고 애플리케이션 실행 시, 이 엔티티와 매핑되는 테이블을 데이터베이스에 자동으로 생성해 준다.(In-memory DB를 사용할시 애플리케이션을 재부팅하면 모든 데이터(테이블 포함)가 지워지기때문에 편의를 위해 테스트시에만 사용)

 

Spring Data JDBC에서는 schema.sql 파일을 이용해 테이블 생성을 위한 스키마를 직접 지정해 주어야 했지만 JPA에서는 (1)의 설정을 추가하면 JPA가 자동으로 데이터베이스에 테이블을 생성해 준다.

 

(2)와 같이 설정을 추가해 주면 JPA의 SQL 쿼리를 로그로 출력해 준다. 

 

 

샘플 코드 실행을 위한 Configuration 클래스 생성

package com.codestates.basic;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;

// (1)
@Configuration
public class JpaBasicConfig {
    private EntityManager em;
    private EntityTransaction tx;

		// (2)
    @Bean
    public CommandLineRunner testJpaBasicRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction();

        return args -> {
            // (3) 
        };
    }
}

 

(1)과 같이 특정 클래스에 @Configuration 애너테이션을 추가하면 Spring에서 Bean 검색 대상인 Configuration 클래스로 간주하여 (2)와 같이 @Bean 애너테이션이 추가된 메서드를 검색한 후, 해당 메서드에서 리턴하는 객체를 Spring Bean으로 추가해 준다.

 

(3)과 같이 CommandLineRunner 객체를 람다 표현식으로 정의해 주면 애플리케이션 부트스트랩 과정이 완료된 후에 이 람다 표현식에 정의한 코드를 실행해 준다.

 

샘플 코드를 위한 Entity클래스

@Getter
@Builder
@Setter
@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long memberId;
    private String email;
}

@Entity 애너테이션과 @Id 애너테이션을 추가해 JPA에서 엔티티로 등록

 

 

예제1 - 영속성 컨텍스트에 엔티티 저장

@Configuration
public class JpaBasicConfig {
    private EntityManager em;

    @Bean
    public CommandLineRunner testJpaBasicRunner(EntityManagerFactory emFactory) { // (1)
        this.em = emFactory.createEntityManager();  // (2)

        return args -> {
            example01();
        };
    }

    private void example01() {
        Member member = Member.builder().email("hgd@gmail.com").build();
        em.persist(member); // (3)

        Member resultMember = em.find(Member.class, 1L); // (4)
        System.out.println("Id: " + resultMember.getMemberId() + ", email: " + 
                resultMember.getEmail());
    }
}
  • JPA의 영속성 컨텍스트는 EntityManager 클래스에 의해서 관리되는데 이 EntityManager 클래스의 객체는 (1)과 같이 EntityManagerFactory 객체를 Spring으로부터 DI 받을 수 있다.
  • (2)와 같이 EntityManagerFactory의 createEntityManager() 메서드를 이용해서 EntityManager 클래스의 객체를 얻을 수 있다. 이제 이 EntityManager 클래스의 객체를 통해서 JPA의 API 메서드를 사용할 수 있다.
  • (3)과 같이 persist(member) 메서드를 호출하면 영속성 컨텍스트에 member 객체의 정보들이 저장된다.
  • (4)에서는 영속성 컨텍스트에 member 객체가 잘 저장되었는지 find(Member.class, 1L) 메서드로 조회하고 있다.
    • find() 메서드의 파라미터 설명
      • 첫 번째 파라미터는 조회할 엔티티 클래스의 타입
      • 두 번째 파라미터는 조회할 엔티티 클래스의 식별자 값
예제1을 실행했을 때의 영속성 컨텍스트의 상태이다.

em.persist(member)를 호출하면 그림과 같이 1차 캐시에 member 객체가 저장되고, 이 member 객체는 쓰기 지연 SQL 저장소에 INSERT 쿼리 형태로 등록이 됩니다. 즉 persist()메서드가 SQL문으로 변환되어 쓰기 지연 SQL 저장소로 변환된다.

 

Hibernate: drop table if exists member CASCADE 
Hibernate: drop table if exists orders CASCADE 
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table member (member_id bigint not null, 
			email varchar(255), primary key (member_id))
Hibernate: call next value for hibernate_sequence

**Id: 1, email: hgd@gmail.com**

출력 결과를 보면 ID가 1인 Member의 email 주소를 영속성 컨텍스트에서 조회하고 있는 것을 확인할 수 있다.

member 객체 정보를 출력하는 라인 위쪽 로그에서 JPA가 내부적으로 테이블을 자동 생성하고, 테이블의 기본키를 할당해 주는 것을 확인할 수 있다.

 

그런데, em.persist(member)를 호출할 경우, 영속성 컨텍스트에 member 객체를 저장하지만 실제 DB의 테이블에 회원 정보를 저장하지는 않는다. 

 

실제 로그에도 insert 쿼리가 보이지 않는다.

 

예제2 - 영속성 컨텍스트와 테이블에 엔티티 저장

@Configuration
public class JpaBasicConfig {
    private EntityManager em;
    private EntityTransaction tx;

    @Bean
    public CommandLineRunner testJpaBasicRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction(); // (1)

        return args -> {
            example02();
        };
    }

    private void example02() {
        tx.begin(); // (2)
        
        Member member = Member.builder().email("hgd@gmail.com").build();
        em.persist(member); // (3)
        
        tx.commit(); // (4)

        Member resultMember1 = em.find(Member.class, 1L); // (5)
        System.out.println("Id: " + resultMember1.getMemberId() + ", email: " + resultMember1.getEmail());
        
        Member resultMember2 = em.find(Member.class, 2L);// (6)
        System.out.println(resultMember2 == null);// (7)

    }
}
  • (1)에서는 EntityManager를 통해서 Transaction 객체를 얻는다. JPA에서는 이 Transaction 객체를 기준으로 데이터베이스의 테이블에 데이터를 저장한다.
  • JPA에서는 (2)와 같이 Transaction을 시작하기 위해서 tx.begin() 메서드를 먼저 호출해 주어야 한다.
  • (3)에서 member 객체를 영속성 컨텍스트에 저장한다.
  • (4)와 같이 tx.commit()을 호출하는 시점에 영속성 컨텍스트에 저장되어 있는 member 객체를 데이터베이스의 테이블에 저장한다. (쓰기 지연 SQL 저장소의 SQL문들을 순서대로 실행)
  • (5)에서 em.find(Member.class, 1L)을 호출하면 (3)에서 영속성 컨텍스트에 저장한 member 객체를 1차 캐시에서 조회한다. 1차 캐시에 member 객체 정보가 있기 때문에 별도로 테이블에 SELECT 쿼리를 전송하지 않는다.
  • (6)에서 em.find(Member.class, 2L)를 호출해서 식별자 값이 2L인 member 객체를 조회한다. 하지만 영속성 컨텍스트에는 식별자 값이 2L인 member 객체는 존재하지 않기 때문에 (7)의 결과는 true가 된다. (6)에서는 영속성 컨텍스트에서 식별자 값이 2L인 member 객체가 존재하지 않기 때문에 테이블에 직접 SELECT 쿼리를 전송한다. (실행 결과 로그에서 확인할 수 있음.)

 

예제2 코드를 실행했을 때의 영속성 컨텍스트의 상태입니다.

tx.commit()을 했기 때문에 member에 대한 INSERT 쿼리는 실행되어 쓰기 지연 SQL 저장소에서 사라지지만 1차 캐시에는 실제 데이터의 객체(Entity)가 계속 남아있고 실제 데이터는 DB에 저장되게 된다.

 

Hibernate: drop table if exists member CASCADE 
Hibernate: drop table if exists orders CASCADE 
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table member (member_id bigint not null, email varchar(255), 
                                                       primary key (member_id))
Hibernate: call next value for hibernate_sequence
Hibernate: insert into member (email, member_id) values (?, ?)
Id: 1, email: hgd@gmail.com

// (1)
**Hibernate: select member0_.member_id as member_i1_0_0_, 
		member0_.email as email2_0_0_ from member member0_ where member0_.member_id=?**
true

 

실행 결과를 보면 (1)에서 SELECT 쿼리가 실행된 것을 볼 수 있다.

이 SELECT 쿼리를 통해 코드 예제2 코드의 (6)에서 em.find(Member.class, 2L)로 조회를 했는데 식별자 값이 2L에 해당하는 member2 객체가 영속성 컨텍스트의 1차 캐시에 없기 때문에 자동적으로 테이블에서 한번 더 조회한다는 것을 알 수 있다.

 

위 두 개의 샘플 코드에서 알수 있는 것으로,

  • em.persist()를 호출하면 영속성 컨텍스트의 1차 캐시에 엔티티 클래스의 객체가 저장되고, 쓰기 지연 SQL 저장소에 INSERT 쿼리가 등록된다.
  • tx.commit()을 하는 순간 쓰기 지연 SQL 저장소에 등록된 INSERT 쿼리가 실행되고, 실행된 INSERT 쿼리는 쓰기 지연 SQL 저장소에서 제거된다.
  • em.find()를 호출하면 먼저 1차 캐시에서 해당 객체가 있는지 조회하고, 없으면 테이블에 SELECT 쿼리를 전송해서 조회한다.


예제3 - 쓰기 지연을 통한 영속성 컨텍스트와 테이블에 엔티티 일괄 저장

@Configuration
public class JpaBasicConfig {
    private EntityManager em;
    private EntityTransaction tx;

    @Bean
    public CommandLineRunner testJpaBasicRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction();

        return args -> {
                example03();
        };
    }

    private void example03() {
        tx.begin();
            
        Member member1 = Member.builder().email("member1@gmail.com").build();
        Member member2 = Member.builder().email("member2@gmail.com").build();

        em.persist(member1);  // (1)
        em.persist(member2);  // (2)
		
        tx.commit(); // (3)
     }
}

 

예제 3에서는 (1), (2)에서 각각 member1과 member2 객체를 영속성 컨텍스트에 저장하고 있다.

 

코드에서 tx.commit()이 실행되기 직전의 영속성 컨텍스트 상태를 표현한 것이다.

보다시피 tx.commit()을 하기 전까지는 em.persist()를 통해 쓰기 지연 SQL 저장소에 등록된 INSERT 쿼리가 실행이 되지 않는다.

따라서 tx.commit()이 실행되기 전에는 테이블에 데이터가 저장이 되지 않는다.

 

tx.commit()이 실행된 직후의 영속성 컨텍스트 상태를 표현한 것이다.

보다시피 tx.commit()이 실행된 이후에는 쓰기 지연 SQL 저장소에 등록된 INSERT 쿼리가 모두 실행되고 실행된 쿼리는 제거된다.

따라서 tx.commit()이 실행된 이후에는 테이블에 데이터가 저장된다.

 

Hibernate: drop table if exists member CASCADE 
Hibernate: drop table if exists orders CASCADE 
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table member (member_id bigint not null, email varchar(255), primary key (member_id))
Hibernate: create table orders (order_id bigint not null, created_at timestamp, primary key (order_id))
Hibernate: call next value for hibernate_sequence
Hibernate: call next value for hibernate_sequence

// (1)
Hibernate: insert into member (email, member_id) values (?, ?)
Hibernate: insert into member (email, member_id) values (?, ?)

실행 결과를 보면 (1)과 같이 쓰기 지연 SQL 저장소에 저장된 INSERT 쿼리가 실행이 된 것을 확인할 수 있다.



예제4 - 영속성 컨텍스트와 테이블에 엔티티 업데이트

@Configuration
public class JpaBasicConfig {
    private EntityManager em;
    private EntityTransaction tx;

    @Bean
    public CommandLineRunner testJpaBasicRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction();

        return args -> {
             example04();
        };
    }

    private void example04() {
       tx.begin();
       em.persist(Member.builder().email("hgd1@gmail.com").build());    // (1)
       tx.commit();    // (2)

       tx.begin();
       Member member1 = em.find(Member.class, 1L);  // (3)
       member1.setEmail("hgd1@yahoo.co.kr");       // (4)
       //em.persist(memberUpdate); persist context에 또 등록할 필요없음, (5)
       tx.commit();   // (6)
    }
}

예제4에서는 이미 테이블에 저장된 데이터의 정보를 업데이트하는 방법을 보여주고 있다.

  • 먼저 (1)에서 member 객체를 영속성 컨텍스트의 1차 캐시에 저장한다.
  • (2)에서 tx.commit()을 호출해서 영속성 컨텍스트의 쓰기 지연 SQL 저장소에 등록된 INSERT 쿼리를 실행한다.
  • (3)과 같이 (2)에서 테이블에 저장된 member 객체를 영속성 컨텍스트의 1차 캐시에서 조회한다. 테이블에서 조회하는 것이 아님, 영속성 컨텍스트의 1차 캐시에 이미 저장된 객체가 있기 때문에 영속성 컨텍스트에서 조회.
  • (4)에서 setter 메서드로 이메일 정보를 변경한다. 여기서 중요한 사실은 em.update() 같은 JPA API가 있을 것 같지만 (4)와 같이 setter 메서드로 값을 변경하기만 하면 업데이트 로직은 완성이 된다.
  • (5)변경된 데이터를 persist()메서드로 또 등록해줄 필요없다. (스냅샷 관련 밑에 서술)
  • (6)에서 tx.commit()을 실행하면 쓰기 지연 SQL 저장소에 등록된 UPDATE 쿼리가 실행이 된다.

 

Hibernate: drop table if exists member CASCADE 
Hibernate: drop table if exists orders CASCADE 
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table member (member_id bigint not null, email varchar(255), primary key (member_id))
Hibernate: call next value for hibernate_sequence
Hibernate: insert into member (email, member_id) values (?, ?)

// (1)
**Hibernate: update member set email=? where member_id=?**

실행 결과를 보면 UPDATE 쿼리가 실행이 된 것을 확인할 수 있다.

 

UPDATE 쿼리가 실행이 되는 과정 (스냅샷)

영속성 컨텍스트에 엔티티가 저장될 경우에는 저장되는 시점의 상태를 그대로 가지고 있는 스냅샷을 생성한다.

그 후 해당 엔티티의 값을 setter 메서드로 변경한 후, tx.commit()을 하면 변경된 엔티티와 이 전에 이미 떠 놓은 스냅샷을 비교한 후, 변경된 값이 있으면 쓰기 지연 SQL 저장소에 UPDATE 쿼리를 등록하고 UPDATE 쿼리를 실행한다.

 

예제5 - 영속성 컨텍스트와 테이블의 엔티티 삭제

@Configuration
public class JpaBasicConfig {
    private EntityManager em;
    private EntityTransaction tx;

    @Bean
    public CommandLineRunner testJpaBasicRunner(EntityManagerFactory emFactory) {
        this.em = emFactory.createEntityManager();
        this.tx = em.getTransaction();

        return args -> {
            example05();
        };
    }

    private void example05() {
        tx.begin();
        em.persist(Member.builder().email("hgd1@gmail.com").build()));  // (1)
        tx.commit();    //(2)

        tx.begin();
        Member member = em.find(Member.class, 1L);   // (3)
        em.remove(member);     // (4)
        System.out.println(em.find(Member.class, 1L));	// (5)
        tx.commit();     // (6)
        
        tx.begin();
        System.out.println(em.find(Member.class, 1L));// (7)
        tx.commit();
    }
}

 

예제5에서는 이미 테이블에 저장된 데이터를 삭제하는 방법을 보여주고 있다.

  • 먼저 (1)에서 Member 클래스의 객체를 영속성 컨텍스트의 1차 캐시에 저장한다.
  • (2)에서 tx.commit()을 호출해서 영속성 컨텍스트의 쓰기 지연 SQL 저장소에 등록된 INSERT 쿼리를 실행한다.
  • (2)에서 테이블에 저장된 Member 클래스의 객체를 (3)과 같이 영속성 컨텍스트의 1차 캐시에서 조회한다.
  • (4)에서 em.remove(member)을 통해 영속성 컨텍스트의 1차 캐시에 있는 엔티티를 제거를 요청한다.
  • (6)에서 tx.commit()을 실행하면 영속성 컨텍스트의 1차 캐시에 있는 엔티티를 제거하고, 쓰기 지연 SQL 저장소에 등록된 DELETE 쿼리가 실행이 된다.

 

예제5 - 부록, 삭제된 데이터를 찾을 경우

Hibernate: drop table if exists member CASCADE 
Hibernate: drop table if exists orders CASCADE 
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table member (member_id bigint not null, email varchar(255), primary key (member_id))
Hibernate: call next value for hibernate_sequence
Hibernate: insert into member (email, member_id) values (?, ?)

null // (5)번 결과
**Hibernate: delete from member where member_id=?** // (4)번의 remove()메서드를 (6)번에서 실행

// (7)번의 Entity가 Persistence context에 존재하지 않으므로 SELECT문 실행
**Hibernate: select member0_.member_id as member_i1_1_0_, 
			member0_.email as email2_1_0_ from member member0_ where member0_.member_id=?** 
null // (7)번 결과

(5)번과 (7)번으로 각각 다른 트랜잭션에서 삭제된 데이터를 찾아보았다.

 

출력 결과의 순서를 보면 예제 코드 (5)번의 데이터 출력이 먼저 되고 (4)번의 DELETE 쿼리문이 (6)번 commit()메서드에 실행되었다.

하지만, (7)번의 경우 앞선 예제2와 마찬가지로 commit()메서드가 실행되기 전 SELECT 쿼리문이 실행된뒤 해당 데이터를 출력한다.

또한, (5)번의 경우에는 분명 Persistence Context와 DB에서 데이터가 삭제되기 이전인데도 null값을 출력한다.

 

이런 현상이 일어나는 이유는 remove()메서드 실행시 Persistence Context의 캐시 내의 해당 entity객체의 라이프사이클 상태가 removed로 변경된다.
이후 removed상태인 객체는 다른 쿼리문이 실행되지 않으며 flush() 메서드를 통해 쓰기 지연 SQL 저장소에 DELETE 쿼리문이 등록된다. 이후 commit()메서드에서 해당 DELETE 쿼리문이 실행 된다.

(출처 :https://thorben-janssen.com/entity-lifecycle-model/)

 

EntityManager의 flush() API

예제코드에서는 flush() 메서드를 사용 하지 않았지만 tx.commit() 메서드가 호출되면 JPA 내부적으로 em.flush() 메서드가 호출되어 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다.