JPA란 무엇인가?

JPA(Java Persistence API)는 자바 애플리케이션에서 관계형 데이터베이스와 상호작용할 수 있도록 돕는 표준 API입니다. 이를 통해 객체지향적인 프로그래밍을 유지하면서도 데이터베이스 작업을 수행할 수 있습니다. JPA는 기본적으로 ORM(Object-Relational Mapping) 기술을 기반으로 하며, 엔티티(Entity), 영속성 컨텍스트(Persistence Context), 트랜잭션(Transaction) 등 여러 개념을 이해하고 활용해야 효과적으로 사용할 수 있습니다.

객체(Object)와 관계(Relation)의 차이

  • 객체(Object): 자바와 같은 객체 지향 프로그래밍 언어에서, 객체는 상태(데이터)와 동작(메서드)을 포함하는 독립된 단위입니다. 예를 들어, User라는 클래스가 있을 때, User 객체는 id, name, email 등의 속성을 가질 수 있습니다.

  • 관계(Relation, 테이블): 관계형 데이터베이스에서는 데이터를 테이블의 형태로 저장합니다. 테이블은 행(Row)과 열(Column)로 구성되며, 각 행은 레코드, 각 열은 속성(attribute)을 나타냅니다. 예를 들어, users라는 테이블은 id, name, email이라는 열을 가지며, 각 행은 한 명의 사용자를 나타냅니다.

객체-관계 매핑이 필요한 이유

  • 불일치 문제: 객체와 테이블의 구조가 다릅니다. 객체는 계층적이고 복잡한 구조를 가질 수 있지만, 데이터베이스의 테이블은 단순한 행과 열로 구성됩니다. 이 불일치를 해결하기 위해, 객체를 테이블에 맞게 변환하는 과정이 필요합니다. 이를 “객체-관계 불일치(O/R Impedance Mismatch)”라고도 합니다.

이러한 기술을 바탕으로 데이터베이스의 테이블 기반이 아니라, 자바 객체 기반으로 데이터를 관리하고 쿼리를 생성할 수 있게 됩니다.


JPA의 주요 개념


엔티티(Entity)

엔티티는 데이터베이스 테이블에 매핑되는 클래스입니다. JPA를 사용하면, 자바 객체와 데이터베이스의 테이블 간의 변환 작업을 자동으로 처리할 수 있습니다. 예를 들어, Student라는 클래스가 데이터베이스의 students 테이블과 매핑되며, 각 객체는 테이블의 한 행(row)을 나타냅니다.

@Entity
public class Student {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private int age;

    // 기본 생성자, getter, setter 생략
}

엔티티 매니저(Entity Manager)

EntityManager는 JPA에서 핵심적인 역할을 하는 인터페이스입니다. 영속성 컨텍스트를 관리하고, 엔티티를 데이터베이스와 연동하는 모든 작업을 처리합니다. EntityManager를 통해 엔티티를 조회하고(find()), 저장하고(persist()), 삭제하며(remove()), 트랜잭션을 관리할 수 있습니다.

EntityManager는 다음과 같은 주요 메서드들을 제공합니다:

  • persist(Object entity): 엔티티를 영속성 컨텍스트에 저장하고, 데이터베이스에 추가합니다.
  • merge(Object entity): 이미 존재하는 엔티티의 상태를 갱신합니다. persist()와 달리, merge()는 영속성 컨텍스트에 존재하지 않는 엔티티도 병합할 수 있습니다. merge() 후 반환된 엔티티는 영속성 컨텍스트에서 관리되며, 그 후의 변경 사항은 자동으로 데이터베이스에 반영됩니다.
  • find(Class<T> entityClass, Object primaryKey): 엔티티를 기본 키를 기준으로 조회합니다.
  • remove(Object entity): 영속성 컨텍스트에서 엔티티를 제거하고, 데이터베이스에서도 삭제합니다.
  • getTransaction(): 트랜잭션 객체를 반환합니다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("example-unit");
EntityManager em = emf.createEntityManager();

EntityTransaction tx = em.getTransaction(); // 트랜잭션 객체 생성

try {
    tx.begin(); // 트랜잭션 시작

    // 엔티티 생성 및 저장
    Student student = new Student();
    student.setName("John");
    student.setAge(22);
    em.persist(student); // 엔티티를 영속성 컨텍스트에 저장

    tx.commit(); // 트랜잭션 커밋, 변경사항이 데이터베이스에 반영됨

    tx.begin(); // 새로운 트랜잭션 시작

    // 엔티티 업데이트
    student.setAge(23); // 엔티티의 필드 변경
    em.merge(student); // 변경된 엔티티를 병합하여 영속성 컨텍스트에 반영

    tx.commit(); // 트랜잭션 커밋, 변경사항이 데이터베이스에 반영됨
} catch (Exception e) {
    if (tx.isActive()) {
        tx.rollback(); // 예외 발생 시 트랜잭션 롤백
    }
    e.printStackTrace();
} finally {
    em.close(); // 엔티티 매니저 닫기
    emf.close(); // 엔티티 매니저 팩토리 닫기
}
  • 영속성 컨텍스트는 엔티티 매니저가 생성될 때 초기화되고, 엔티티 매니저가 종료될 때 해제됩니다.
  • 트랜잭션이 커밋되거나 롤백되어도 영속성 컨텍스트 자체는 유지됩니다. 이는 같은 엔티티 매니저 인스턴스를 사용하는 한, 엔티티 상태가 보존된다는 의미입니다.
  • 상태 보존은 엔티티 매니저(em)가 살아 있는 동안, 트랜잭션의 경계와 무관하게 이루어집니다.

영속성 컨텍스트와 1차 캐시

영속성 컨텍스트(Persistence Context)

영속성 컨텍스트는 엔티티의 상태를 관리하는 JPA의 핵심 개념입니다. 엔티티 매니저가 관리하는 “엔티티 저장소”라고 할 수 있으며, 영속성 컨텍스트에 등록된 엔티티는 자동으로 데이터베이스와 동기화됩니다. 영속성 컨텍스트는 특정 트랜잭션 단위로 관리되며, 트랜잭션이 끝나면 해당 컨텍스트도 함께 종료됩니다.

여기서 flush() 등으로 1차 캐시(영속성 컨텍스트)와 데이터베이스를 동기화 시킨다 하더라도 실제 데이터베이스에서 조회를 해보면 변경사항이 반영된 결과를 볼 수 없습니다. 1차 캐시(영속성 컨텍스트)에서 변경사항 등을 반영하여 가지고 있다가 commit()이 실행될 때 실제 데이터베이스에 변경사항 등이 완전히 저장되게 됩니다. 엄밀히 말하면 이 순간이 되어야 완전히 동기화가 이루어졌다고 할 수 있습니다. 즉 commit() 전에는 1차캐시(영속성컨텍스트)만 최신화되어 있다고 생각하면 됩니다.

1차 캐시(First-Level Cache)

1차 캐시는 영속성 컨텍스트 내에서 관리되며, 데이터베이스와의 불필요한 통신을 줄여줍니다. em.persist() 메서드를 사용해 엔티티를 영속성 컨텍스트에 등록하면, 해당 엔티티는 1차 캐시에 저장됩니다. 이후 em.find() 메서드를 통해 동일한 엔티티를 조회할 때, 데이터베이스로 직접 쿼리를 보내지 않고 1차 캐시에서 값을 가져옵니다.

1차 캐시는 영속성 컨텍스트 단위로 관리되므로, 같은 트랜잭션 안에서만 유효합니다. 트랜잭션이 종료되면 1차 캐시도 사라집니다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("example-unit");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

Student student = new Student();
student.setName("John");
student.setAge(22);

em.persist(student); // 1차 캐시와 영속성 컨텍스트에 저장

em.getTransaction().commit();

쓰기 지연과 SQL 저장소

쓰기 지연의 개념

쓰기 지연(Batch Writing)은 JPA가 데이터베이스에 효율적으로 데이터를 반영하기 위해 사용하는 전략입니다. em.persist() 등을 통해 엔티티의 변경사항을 기록할 때, 즉시 데이터베이스에 쿼리를 보내지 않고, SQL 저장소에 쿼리를 쌓아둡니다. 이러한 쿼리들은 em.flush() 또는 em.getTransaction().commit() 시점에 한 번에 실행됩니다.

쓰기 지연의 장점

쓰기 지연은 여러 가지 이점을 제공합니다:

  • 성능 향상: 여러 쿼리를 한 번에 실행하기 때문에 데이터베이스와의 통신 비용을 줄일 수 있습니다.
  • 트랜잭션 관리 용이성: 트랜잭션이 커밋되기 전에 모든 변경사항을 모아두기 때문에, 트랜잭션이 롤백될 경우 쉽게 취소할 수 있습니다.
  • 데이터 일관성: 여러 엔티티 간의 관계가 있는 경우, 트랜잭션 커밋 시점에 모든 변경사항을 일관되게 반영할 수 있습니다.
em.getTransaction().begin();
Student student = new Student();
student.setName("Alice");

em.persist(student); // INSERT 쿼리가 SQL 저장소에 저장

em.flush(); // 저장된 쿼리가 데이터베이스에 전송됨
em.getTransaction().commit(); // 트랜잭션 커밋

플러시(Flush)와 커밋(Commit)

플러시의 역할

플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스와 동기화하는 작업입니다. em.flush() 메서드로 명시적으로 호출하거나, 트랜잭션 커밋 시 자동으로 호출됩니다. 플러시는 SQL 저장소에 있는 쿼리들을 데이터베이스에 반영하지만, 이 시점에 트랜잭션이 완료되지 않았기 때문에 데이터베이스에서 실제로 변경된 내용을 확인할 수는 없습니다.

플러시는 다음과 같은 상황에서 자동으로 실행됩니다:

  • 트랜잭션 커밋 시
  • JPQL 쿼리 실행 시
  • 명시적으로 em.flush()를 호출할 때

플러시의 필요성

플러시는 데이터베이스와 영속성 컨텍스트의 상태를 일치시키기 위해 필요합니다. 이를 통해 JPQL 쿼리 실행 시 최신 상태의 데이터를 기반으로 쿼리를 수행할 수 있으며, 트랜잭션 커밋 전에 모든 변경사항이 반영되었는지 확인할 수 있습니다.

커밋(Commit)

커밋은 트랜잭션의 끝에서 호출되며, 트랜잭션 내의 모든 작업을 확정합니다. 커밋이 이루어지면 플러시된 내용이 데이터베이스에 영구적으로 반영됩니다.

em.getTransaction().begin();

Student student = new Student();
student.setName("Charlie");

em.persist(student); // SQL 저장소에 INSERT 구문이 저장됨

em.flush(); // INSERT 구문이 데이터베이스에 반영됨

em.getTransaction().commit(); // 트랜잭션 커밋, 변경사항이 확정됨

ID 생성 전략

JPA에서는 엔티티의 기본 키(PK)를 생성하는 여러 전략이 있습니다. 각각의 전략은 데이터베이스의 특성과 성능 요구에 따라 적절히 선택해야 합니다.

IDENTITY 전략

IDENTITY 전략은 데이터베이스가 자동으로 증가하는 숫자 값을 생성하는 방식입니다. MySQL, PostgreSQL 등에서 주로 사용됩니다. 엔티티가 처음 persist될 때 ID가 할당됩니다.

  • 장점: 구현이 간단하고, 데이터베이스에서 자동으로 ID를 관리합니다.
  • 단점: 엔티티가 persist될 때 바로 INSERT 쿼리가 실행되어 쓰기 지연을 사용할 수 없습니다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

SEQUENCE 전략

SEQUENCE 전략은 데이터베이스의 시퀀스를 사용하여 ID를 생성하는 방식입니다. Oracle, PostgreSQL 등의 데이터베이스에서 지원합니다. JPA가 시퀀스에서 다음 값을 가져와서 사용합니다.

  • 장점: 배치 처리가 가능하며, 한 번에 여러 ID를 미리 가져올 수 있습니다.
  • 단점: 시퀀스를 지원하지 않는 데이터베이스에서는 사용할 수 없습니다.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "student_seq")
@SequenceGenerator(name = "student_seq", sequenceName = "student_sequence", allocationSize = 50)
private Long id;

이렇게 하면, 데이터베이스에서 시퀀스를 호출할 때마다 50개의 ID가 미리 할당됩니다. 이는 성능 최적화에 도움이 될 수 있습니다.

TABLE 전략

TABLE 전략은 별도의 테이블을 만들어 그 안에서 ID를 관리하는 방식입니다. 모든 데이터베이스에서 사용할 수 있지만, 성능이 떨어질 수 있습니다.

  • 장점: 모든 데이터베이스에서 일관된 방식으로 ID를 관리할 수 있습니다.
  • 단점: 추가적인 테이블을 사용하기 때문에 성능이 저하될 수 있습니다.
@Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "student_table")
@TableGenerator(name = "student_table", table = "id_table", pkColumnName = "entity_name", valueColumnName = "next_id", pkColumnValue = "Student", allocationSize = 50)
private Long id;

ID 생성 전략 요약

각 ID 생성 전략은 데이터베이스의 특성과 요구 사항에 따라 적절히 선택해야 합니다. IDENTITY는 단순하지만 쓰기 지연을 활용할 수 없고, SEQUENCE는 성능이 좋지만 일부 데이터베이스에서만 사용할 수 있습니다. TABLE은 호환성이 높지만 성능이 떨어질 수 있습니다.