Spring Boot (Chapter 4)[JPA]


4. JPA 스타트

4.1 스프링과 JPA

데이터베이스 연동에 사용되는 기술은 전통적인 JDBC에서부터, 스프링 DAO, 마이바티스, 하이버네이트 같은 ORM에 이르기까지 매우 다양하다. 이 중에서 하이버네이트 같은 ORM은 어플리케이션에서 사용하는 SQL까지도 프레임워크에서 제공하기 때문에 개발자가 처리해야 할 일들을 엄청나게 줄여준다. 이런 이유로 하이버네이트 이후에 수많은 ORM 프레임워크가 등장했으며, 이런 ORM들을 보다 쉽게 사용할 수 있또록 표준화시킨 것이 JPA(Java Persistence API)다.

스프링 데이터 JPA는 스프링 부트에서 이런 JPA를 쉽게 사용할 수 있도록 지원하는 모듈이다. 따라서 스프링 데이터 JPA를 사용하면 JPA를 사용하는 데 필요한 라이브러리나 XML 설정은 신경 쓸 필요가 없다. 복잡한 JPA의 개념이나 동작 원리를 모르고도 쉽게 JPA를 사용할 수 있다. 하지만 실제로 JPA를 이용하여 어플리케이션을 개발하다 보면 순수하게 JPA에서 제공하는 기능이나 동작원리를 이해해야만 해결할 수 있는 문제들이 발생한다. 따라서 스프링 부트를 사용하지 않고 순수하게 JPA만을 이용해서 간단한 CRUD를 처리해봄으로써 JPA의 기본 기능과 동작 원리를 이해한다.

4.1.1 JPA 개념 이해하기

어플리케이션은 사용자가 입력한 데이터나 운용 과정에서 생성한 데이터를 재사용 하기 위해서 데이터베이스 같은 저장공간에 저장해야 한다. 이것이 어플리케이션의 가장 중요한 기능이며, 어플리케이션 개발과 운영 과정에서 많은 시간이 필요한 부분이다.

데이터베이스 연동 기술은 어플리케이션에서 SQL을 다루는 방식에 따라 구분된다. 마이바티스 같은 프레임워크는 SQL을 개발자가 직접 XML파일에 등록해서 사용한다. 반면 하이버네이트같은 ORM은 프레임워크에서 SQL을 생성하기 때문에 개발자가 SQL을 직접 작성하거나 신경 쓸 필요가 없다.

JPA란
  • JPA 개념

하이버네이트는 기존의 EJB 기술인 엔티티 빈이 가지는 여러 문제들을 대체하기 위한 오픈소스 프레임워크로 등장했다. 하이버네이트는 비록 자바 표준은 아니지만 가장 많은 개발자들이 사용하는 ORM이고, 하이버네이트 개발자들이 중심이 되어 만든 ORM 표준이 바로 JPA인 것이다.

JPA는 마치 JDBC 프로그램에서 JDBC API와 같은 개념으로 이해하면 쉽다. JDBC API는 java.sql 패키지로서, JDBC 인터페이스를 이용하여 데이트베이스 연동을 처리하면 실제로 실행될 때는 인터페이스를 구현한 드라이버 객체가 동작한다. 이렇게 구현해야 나중에 데이터베이스를 변경할 때, 데이터베이스에 종송적인 드라이버만 교체하면 어플리케이션 소스는 수정하지 않아도 되는 것이다.

JPA도 마찬가지다. JPA가 제공하는 인터페이스를 이용하여 데이터베이스를 처리하면 실제로는 JPA를 구현한 구현체가 동작하는 것이다.
JPA를 구현한 구현체는 하이버네이트, EclipseLink, DataNucleus 등 여러가지가 있는데 스프링 부트에서는 기본적으로 하이버네이트를 JPA 구현체로 이용한다.

springboot_3_5

JPA를 이용하면 어플리케이션을 개발하는 시점에는 하이버네이트를 사용하다가 실제 서비스가 시작될 때, 다른 ORM 기술인 EclipseLink로 변경할 수 있는 것이다.


  • JPA 동작 원리

JPA는 자바 객체를 컬렉션에 저장하고 관리하는 것과 비슷한 개념이라고 했다. 하지만 결국 컬렉션에 저장된 객체를 테이블의 로우(Row)와 매핑하기 위해서는 누군가가 JDBC API를 이용해서 실질적인 연동 작업을 처리해야 한다. JPA는 자바 어플리케이션과 JDBC 사이에 존재하면서 JDBC의 복잡한 절차를 대신 처리해준다. 따라서 개발자는 JDBC의 복잡한 API를 모르고도 데이터베이스를 사용할 수 있는 것이다.

springboot_3_6

여기까지만 놓고 보면 JPA는 기존의 마이바티스와 별 차이가 없다. 중요한 것은 JPA가 데이터베이스 연동에 사용되는 코드뿐만 아니라 SQL까지 제공한다는 것이다. 따라서 JPA를 이용해서 데이터베이스 연동을 처리하면 개발 및 유지보수의 편의성이 극대화되는 것이다.

JPA는 어떤 메커니즘으로 SQL을 생성하는 것일까? 테이블과 VO 클래스 이름을 똑같이 매핑하고, 테이블의 칼럼을 VO 클래스의 멤버 변수와 매핑하면 얼마든지 VO 클래스를 기준으로 어느 정도 획일화된 SQL을 생성할 수 있는 것이다.


4.1.2 JPA 퀵 스타트

JPA는 테이블이 없으면 자바 클래스를 기준으로 매핑할 테이블을 자동으로 생성하는데, 테이블과 매핑되는 자바 클래스를 엔티티라고 한다. JPA를 사용하는데 있어서 가장 먼저 할 일은 바로 엔티티를 만드는 것이다.

일반적인 ValueObject 클래스 처럼 테이블과 동일한 클래스 이름을 사용하고 컬럼과 매핑될 멤버 변수를 선언하면 된다. Board

package com.bys.sample.domain;

@Getter
@Setter
@ToString
@Entity
@Table(name = "BOARD")
public class Board {

    @Id
    @GeneratedValue
    private Long seq;
    private String title;
    private String writer;
    private String content;
    private Date createDate;
    private Long cnt;
}

엔티티가 작성되면 JPA어노테이션을 이용하여 테이블과 엔티티 클래스를 매핑해주면 된다.

엔티티 매핑에 사용되는 어노테이션

어노테이션 의미
@Entity @Entity가 설정된 클래스를 엔티티라 하며, 기본적으로 클래스 이름과 동일한 테이블과 매핑된다.
@Table 엔티티 이름과 매핑될 테이블 이름이 다른 경우, name 속성을 사용하여 매핑한다. 엔티티 이름과 테이블 이름이 동일하면 생략해도 된다.
@Id 테이블의 기본 키를 매핑한다. 예제에서는 seq변수가 테이블의 SEQ 컬럼과 매핑되도록 설정했다. 엔티티의 필수 어노테이션으로서 @Id가 없는 엔티티는 사용하지 못한다.
@GeneratedValue @Id가 선언된 필드에 기본 키 값을 자동으로 할당한다. 다양한 옵션이 있지만 @GeneratedValue만 사용하면 설정된 데이터베이스에 따라서 JPA가 자동으로 결정해준다.

4.2 JPA 설정

4.2.2 엔티티 매핑 설정하기

JPA는 엔티티 클래스에 매핑과 관련된 어노테이션만 적절히 설정하면 엔티티를 이ㅛㅇ해서 데이터를 쉽게 관리할 수 있다.

  1. @Entity와 @Id @Entity는 자바 클래스를 JPA가 관리하는 엔티티로 인식하게하는 어노테이션이다. 클래스에 @Entity를 붙이면 JPA는 이 클래스로부터 생성된 객체를 엔티티로 인식한다. 그리고 엔티티로부터 생성된 객체는 반드시 다른 객체와 식별할 수 있어야 하는데 이를 위해서 반드시 사용해야 하는 어노테이션이 @Id다. 테이블에 저장된 각 로우는 PK컬럼을 통해 유일한 데이터로 식별할 수 있다. 그리고 이런 테이블과 매핑되는 엔티티 역시 PK컬럼과 매핑될 식별자를 가지고 있어야 하는데 이를 식별자 필드라고 한다. JPA는 @Id를 이용해서 식별자 필드를 매핑한다.

  2. @Table 엔티티 이름과 테이블 이름이 다른 경우에 @Table을 이용하여 매핑할 테이블 이름을 정확히 지정해야 한다. @Table은 다양한 속성을 가질 수 있으며 중요한 속성을 정리하면 다음과 같다.

    속성 설명
    name 매핑될 테이블 이름을 지정한다.
    catalog 데이터베이스 카탈로그를 지정한다.
    schema 데이터베이스 스키마를 지정한다.
    uniqueConstraints 결합 unique 제약조건을 지정하며, 여러 개의 컬럼이 결합되어 유일성을 보장해야 하는 경우 사용한다.
     @Entity
     @Table(name = "T_BOARD", uniqueConstraints = {@UniqueConstraint(columnNames = {"SEQ", "WRITER"})})
     public class BoardEntity {
    
         @Id
         @GeneratedValue
         private Long seq;
    
         private String title;
         private String writer;
         private String content;
         private Date createDate;
         private Long cnt;
     }
    

    위 설정은 BoardEntity라는 클래스를 T_BOARD 테이블과 매핑하라는 설정이다. 그리고 SEQ, WRITER 두 개의 컬럼을 결합했을 때 유일한 값만 유지하도록 유일케 제약조건을 설정했다.

  3. @Column @Column 어노테이션은 엔티티의 변수와 테이블의 컬럼을 매핑할 때 사용한다. 일반적으로 엔티티의 변수 이름과 컬럼이름이 다를 때 사용하며, 생략하면 기본으로 변수 이름과 동일한 이름의 컬럼이 매핑된다.

  4. @Temporal @Temporal은 java.util.Date 타입의 날짜 데이터를 매핑할 때 사용한다. 이때 TemporalType을 이용하여 날짜의 형식을 지정할 수 있는데, TemporalType.DATE는 날짜만 출력하고, TemporalType.TIME은 시간만 출력한다. TemporalType.TIMESTAMP는 날짜와 시간을 모두 출력한다.
     @Entity
     @Table(name = "T_BOARD", uniqueConstraints = {@UniqueConstraint(columnNames = {"SEQ", "WRITER"})})
     public class BoardEntity {
    
         @Temporal(TemporalType.DATE)
         private Date createDate;
     }
    
  5. @Transient 엔티티 클래스의 변수들은 대부분 테이블의 컬럼과 매핑된다. 그러나 몇몇 변수는 매핑되는 컬럼이 없거나 검색 관련 변수같이 임시로 사용되는 변수들은 아예 매핑을 제외해야 하는 경우도 있다. @Transient는 엔티티 클래스 내의 특정 변수를 영속 필드에서 제외할 때 사용한다.


4.2.3 식별자 값 자동 증가시키기

테이블과 매핑되는 엔티티는 반드시 PK컬럼과 매핑될 식별자 필드를 가져야 하며, @Id 어노테이션을 이용하여 매핑한다. 식별자 필드에 자동으로 증가된 값을 할당할 때는 식별자로 사용할 변수 위에 @GeneratedValue라는 어노테이션을 사용하면 된다.

@GeneratedValue 어노테이션의 속성과 의미

속성 설명
strategy 자동 생성 전략을 선택한다.
generator 이미 생성된 키 생성기를 참조한다.

이 중에서 strategy는 PK 값 생성 전략을 지정하는 속성으로 매우 중요하다. PK값 생성 전략은 TABLE, SEQUENCE, IDENTITY, AUTO 네 가지가 있는데 각각 다음과 같은 의미다.

PK 생성 전략 종류와 의미

PK전략 해설
GenerationType.TABLE 하이버네이트가 테이블을 사용하여 PK 값을 생성한다. 따라서 PK 값 생성만을 위한 별도의 테이블이 필요하다.
GenerationType.SEQUENCE 시퀀스를 이용하여 PK 값을 생성한다. 당연히 이 전략은 시퀀스를 지원하는 데이터베이스에서만 사용할 수 있다.
GenerationType.IDENTITY auto_increment나 IDENTITY를 이용하여 PK 값을 생성한다. 일반적으로 MySQL 같은 데이터베이스를 이용할 때 사용한다.
GenerationType.AUTO 하이버네이트가 데이터베이스에 맞는 PK 값 생성 전략을 선택한다.
@TableGenerator(name = "BOARD_SEQ_GENERATOR",
        table = "ALL_SEQUENCES",
        pkColumnValue = "BOARD_SEQ",
        initialValue = 0,
        allocationSize = 1)
public class BoardEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "BOARD_SEQ_GENERATOR")
    private Long seq;
}

@TableGenerator를 보면 ALL_SEQUENCES라는 키 생성 테이블을 만들고 “BOARD_SEQ” 이름으로 증가되는 값을 저장하라는 의미다. 그리고 initialValue를 0으로 지정했기 때문에 “BOARD_SEQ” 이름으로 처음 저장되는 번호는 0이 된다. 그리고 allocationSize를 1로 지정했기 때문에 한 번 꺼내 쓸 때마다 자동으로 1씩 증가할 것이다. 이렇게 만들어진 테이블 생성기를 참조하기 위해서 이름을 “BOARD_SEQ_GENERATOR”로 설정했다.


4.3 JPA API 이해

4.3.1 EntityMangerFactory와 EntityManager 이해하기

어플리케이션에서 JPA를 이용하여 CRUD 기능을 처리하려면 EntityManager 객체를 사용해야 한다. 결국 JPA를 이용하는 어플리케이션의 시작은 EntityManager의 생성이라고 할 수 있다. EntityManager는 EntityManagerFactory로부터 얻을 수 있다.

springboot_4_1

  1. Persistence 클래스를 이용하여 영속성 유닛 (persistence-unit) 정보가 저장된 JPA 메인 환경설정 파일(persistence.xml)을 로딩한다.
  2. 영속성 유닛(persistence-unit) 설정 정보를 바탕으로 EntityManagerFactory 객체를 생성한다.
  3. EntityManagerFactory로부터 EntityManager를 얻어서 데이터베이스 연동을 처리한다.

그림에서는 마치 Persistence가 persistence.xml 파일을 직접 읽어 들이는 것으로 표현했지만, 실제로는 JPA가 자동으로 META-INF 폴더에 있는 persistence.xml 파일을 로딩하는 과정으로 이해하면 된다.

4.3.2 영속성 컨텍스트와 엔티티 상태

영속성 컨텍스트(Persistence Context)는 논리적인 개념으로서 EntityManager를 생성 할 때 자동으로 만들어진다. 영속성 컨텍스트는 엔티티 객체들을 관리하는 일종의 컨테이너라할 수 있으며, EntityManager를 통해서 접근할 수 있다. 따라서 영속성 컨텍스트를 EntityManager와 동일한 개념으로 이해하면 된다.

영속성 컨텍스트에 등록된 엔티티는 EntityManager가 제공하는 메서드를 통해 관리되며, New, Managed, Detached, Removed 상태로 존재한다.

  1. New New 상태는 엔티티 객체를 생성만 했을 뿐 아직 엔티티를 영속성 컨텍스트에 저장하지 않은 상태다.

  2. Managed Managed 상태는 EntityManager를 통해 엔티티가 영속성 컨텍스트에 저장된 상태를 의미한다. 엔티티를 영속 상태로 만들기 위해서는 EntityManager의 persist() 메서드를 사용한다. find() 메서드를 통해서도 엔티티를 영속 상태로 만들 수 있다. find() 메서드는 상세 조회 기능의 메서드지만 EntityManager의 find() 메서드를 호출 했을 때, 조회하고자 하는 엔티티가 영속성 컨텍스트에 있으면 해당 엔티티가 반환되고, 만약 없다면 데이터베이스에서 데이터를 조회하여 새로운 엔티티 객체를 생성하여 영속성 컨텍스트에 저장하기 때문이다.

  3. Detached 준영속 상태는 한 번 영속성 컨텍스트에 들어간 엔티티가 어떤 이유에서 영속성 컨텍스트에서 벗어난 상태를 의미한다. 엔티티가 영속성 컨텍스트에서 벗어났기 때문에 준영속 상태의 엔티티는 값을 수정해도 데이터베이스에 아무런 영향을 미치지 못한다. Managed 상태에서 Detached 상태로 전환되는 경우는 EntityManager의 detach(), clear(), close() 메서드를 호출했을 때이다.

    • detach(entity): 특정 엔티티만 준영속 상태로 전환한다.
    • clear(): 영속성 컨텍스트를 초기화한다. 영속성 컨텍스트가 관리하던 엔티티들을 모두 삭제한다.
    • close(): 영속성 컨텍스트를 종료한다. 영속성 컨텍스트는 종료되기 직전에 자신이 관리하던 엔티티들을 모두 삭제한다.

준영속 상태의 엔티티는 메모리에서 완전히 사라진 것이 아니기 때문에 merge() 메서드를 통해 다시 영속 상태로 전환될 수 있다.

  1. Removed 삭제 상태는 엔티티가 영속성 컨텍스트에서도 제거되고 테이블의 데이터도 삭제된 상태다. 영속 상태의 엔티티는 remove() 메서드를 이용해서 삭제할 수 있다. 그리고 일반적으로 삭제된 엔티티는 재사용하지 않고 가비지 컬렉션이 되도록 내버려둔다.
영속성 컨텍스트와 1차 캐치

EntityManager의 persist() 메서드를 통해 특정 엔티티를 영속성 컨텍스트에 등록하면, 저장된 엔티티에 해당하는 INSERT가 실행된다. 그런데 persist() 메서드를 호출했다고 해서 JPA가 곧바로 테이블에 INSERT를 실행하지 않는다. 그렇다면 JPA는 왜 영속성 컨텍스트에 등록된 엔ㅌ티티에 대해서 바로 SQL 처리를 해주지 않는 것일까? 그 이유는 영속성 컨텍스트 내부에 1차 캐시라는 것을 사용하기 때문이다.

springboot_4_2

EntityManager의 persist() 메서드를 통해 Board 엔티티를 영속화시키면, 엔티티는 영속성 컨텍스트가 가지고 있는 1차 캐시에 등록된다. 1차 캐시는 일종의 Map 같은 컬렉션으로서 Key(@Id로 매핑한 식별자 값), Value(엔티티 객체)로 엔티티를 관리한다. 중요한 것은 1차 캐시에 저장된 엔티티는 바로 실제 데이터베이스에 반영되지 않는다.

1차 캐시에 저장된 엔티티는 EntityTransaction으로 트랜잭션을 종료할 때 실제 데이터베이스에 반영된다. 다음은 commit()을 통해 트랜잭션을 종료할 때의 상황이다.

springboot_4_3

트랜잭션 객체의 commit() 메서드를 호출하면 1차 캐시에 저장된 엔티티에 해당하는 INSERT 구문이 생성되고 데이터베이스로 전송된다. 이렇게 영속성 컨텍스트에 저장된 엔티티를 데이터베이스에 반영하는 과정을 플러시(Flush)라고 한다.


4.3.3 영속성 컨텍스트와 SQL저장소 이해하기

영속성 컨텍스트에 엔티티를 등록하고 트랜잭션을 커밋하면 테이블에 INSERT 구문이 실행된다. 영속성 컨텍스트트는 1차 캐시뿐만 아니라 SQL 저장소라는 것도 가지고 있다. Board 엔티티를 persist() 메서드로 영속성 컨텍스트에 저장하면 영속성 컨텍스트는 두 가지 작업을 순차적으로 처리한다.

첫 번째는 엔티티를 1차 캐시에 등록하는 것이고 두 번째는 1차 캐시에 등록된 엔티티에 해당하는 INSERT 구문을 생성하여 SQL저장소에 등록하는 것이다.

springboot_4_4

만약 계속해서 새로운 엔티티가 등록되면 등록 순서대로 1차 캐시와 SQL 저장소에 각각 엔티티와 SQL 구문들이 누적되어 저장된다. 그리고 나서 commit() 메서드로 트랜잭션을 종료하면 SQL저장소에 저장되었던 모든 SQL이 한꺼번에 데이터베이스로 전송된다.

springboot_4_5

이렇게 함으로써 한 번의 데이터베이스 통신으로 SQL 구문을 한꺼번에 처리할 수 있고 결과적으로 성능을 최적화 할 수 있다.


엔티티 수정과 스냅샷
public class JPAClient {
    public static void main(String[] args) {
        // EntityManager 생성
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("Chapter04"); // Chater04 -> persistence.xml persistence-unit-name

        EntityManager em = emf.createEntityManager();

        // Transaction 생성
        EntityTransaction tx = em.getTransaction();

        try {
            // Transaction 시작
            tx.begin();

            // 수정할 게시글 조회
            Board board = em.find(Board.class, 1L);
            board.setTitle("검색한 게시글의 제목 수정");

            // Transaction commit
            tx.commit();

        } catch (Exception e) {
            e.printStackTrace();
            // Transaction rollback
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

엔티티를 수정하기 전에 find() 메서드로 수정할 엔티티를 검색했다. 엔티티 수정을 위해서는 수정할 엔티티가 반드시 영속성 컨텍스트에 있어야 한다. 만약 그렇지 않다면 예외가 발생한다. 이렇게 검색된 엔티티는 영속성 컨텍스트 내의 1차 캐시에 저장된다. 그리고 나서 엔티티의 변수 값을 수정하면 JPA는 수정된 변수들을 찾아 UPDATE 구문을 작성하여 SQL 저장소에 저장할 것이다. 그리고 트랜잭션이 종료될 때 실질적인 UPDATE 처리가 될 것이다.

JPA는 검색된 엔티티를 영속성 컨텍스트에 저장할 때, 엔티티의 복사본을 만들어서 별도의 컬렉션에 저장하는데 이 저장 공간을 스냅샷(Snapshot)이라고 한다. 그리고 트랜잭션이 종료될 때 스냅샷에 저장된 원래의 엔티티와 1차 캐시에 수정된 엔티티를 비교해서 변경된 값을 이용하여 UPDATE를 만드는 것이다.

엔티티 수정에서 JPA의 기본 전략은 모든 필드 수정이다.

엔티티 삭제하기

remove() 메서드로 엔티티를 삭제하면 일차적으로 영속성 컨텍스트에서 해당 엔티티가 빠지고 DELETE 구문이 SQL 저장소에 등록된다. 그리고 나서 트랜잭션이 종료될 때 SQL 저장소에 저장된 DELETE가 데이터베이스에 전송된다.

삭제에서 주의할 것은 수정과 마찬가지로 영속성 컨텍스트에 삭제할 엔티티가 없다면 예외가 발생한다는 것이다.

목록 검색과 JPQL

테이블에 저장된 특정 데이터를 상세 조회하려면 EntityManager의 find() 메서드를 사용하면 된다. 하지만 목록을 조회하기 위해서는 JPQL(Java Persistence Query Language)라는 JPA에서 제공하는 별도의 쿼리 명령어를 사용해야 한다.





Reference

  • 스프링 부트 (채규태)

Tag: [ book  programming  spring  framework  springboot  jpa  @entity  @id  @table  @column  @generatedvalue  @temporal  @transient  entitymanager  ]