Developer.

[멋사 백엔드 19기] TIL 41일차 JPA 기본

📂 목차


📚 본문

JPA

이전에 배웠던 JDBC 는 테이블을 기준으로 했다면, JPA 는 객체를 기준으로 DB 의 상태를 업데이트를 한다. 자바에석 객체와 관계형 데이터베이스를 매핑(ORM) 하기 위한 표준 인터페이스를 JPA 라고 한다. 따라서 SQL 을 따로 직접 쓰지 않고 객체만을 수정하는 것으로 DB를 다룰 수 있다.

plugins {
    id 'java'
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.hibernate:hibernate-core:6.4.4.Final'
    implementation 'com.mysql:mysql-connector-j:8.3.0'
    implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
    implementation 'org.slf4j:slf4j-simple:2.0.13'
    compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.32'
}

test {
    useJUnitPlatform()
}

이런 구현체의 기술은 hibernate 사가 만들었고 이를 바탕으로 인터페이스 표준화 되었다.

ORM

여기서 ORM은 객체 지향 프로그래밍 언어와 관계형 데이터베이스 간의 데이터를 변환하는 프로그래밍 기술이며, 매개변수를 일일히 binding 시킬 필요 없이 객체만 저장하면 hibernate 가 알아서 ORM 기술을 통해 SQL 문을 생성해준다.

JPA 장점

  • SQL 작성량 감소
  • 유지보수 용이
  • DB 에 독립적인 코드
  • 객체 지향적인 코드 작성 가능

JDBC 와 JPA 차이

구분 JDBC JPA
코드 작성 SQL 직접 작성 객체 중심, SQL 자동 생성
데이터 변환 ResultSet 수동 매핑 자동 매핑
생산성 낮음 (반복 코드 많음) 높음 (보일러플레이트 제거)
유지보수 SQL 수정 시 여러 곳 변경 엔티티 변경으로 자동 반영
DB 독립성 낮음 (DB별 SQL 차이) 높음 (Dialect로 자동 처리)
학습 곡선 낮음 높음

JPA 클래스 다이어그램

간단히 보자.

jpa_architecture.png

Hibernate Core 는 실제 구현체이다. 우리는 Interface 만 보고 사용하는데, 인터페이스들 내부를 보자.

EntityManagerFactory 인터페이스

JPA 의 핵심 구성요소 중 하나이다. DB 와의 연결을 관리하고 EntityManager 를 생성하는 역할을 한다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("unitName");
EntityManager em = emf.createEntityManager();

따라서 EntityManager 는 factory 에서 최초로 생성되게 된다. EntityManagerFActory 는 당므 역할을 가진다.

  • 생성 책임: EntityManager 객체를 생성
  • DB 설정 관리: persistence.xml 또는 yml 의 설정 정보를 읽어 DB 연결 구성
  • 비용이 큼: 생성 시 많은 초기화 작업이 일어나므로, 어플리케이션 당 하나만 생성하는 것이 일반적
EntityManager

EntityManagerFactory 에서 만들어지는 EntityManager 라는 것은 DB 와의 모든 상호작용을 처리하는 인터페이스이며, Persistence Context 라는 저장소에 엔티티를 저장하고 관리하는 것을 수행한다.

  • 엔티티 저장: persist()
  • 조회: find()
  • 수정: 영속 상태의 엔티티를 변경하면, 트랜잭션 커밋을 통해 자동 반영(Dirty Checking)
  • 삭제: remove()

우리가 자주 다루는 객체이며, DB 와 가장 교류가 많이 일어나는 객체이다. 여기서 관리되는 객체를 IoC 컨테이너에서 관리하는 Bean 것처럼 별칭을 붙이는데 Entity 라고 한다.

EntityManager 는 스레드 안전하지 않다. 따라서 각 트랜잭션마다 따로 EntityManagerFactory 를 통해 EntityManager 를 생성하는 것이 좋다.

@Entity

JPA 를 쓰기 전에 다음 의존성을 추가해준다.

// JPA API (표준 인터페이스)
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'

// Hibernate (JPA 구현체)
implementation 'org.hibernate:hibernate-core:6.4.4.Final'

모르면 위는 검색하면 된다. 엔티티를 정의하기 위해 @Entity 애너테이션을 비즈니스 도메인 쪽에 쓸 수 있다.

@Entity  // JPA에게 이 클래스가 테이블과 매핑된다고 알림
@Table(name = "users")  // (선택) 테이블 이름 지정
public class User {

    @Id  // 기본 키(PK) 지정
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // 자동 증가
    private Long id;

    @Column(nullable = false, length = 50)  // 컬럼 속성 정의
    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    // 기본 생성자 (필수)
    protected User() {}

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // Getter/Setter
}

여기서 @Entity 가 해당 클래스가 Entity 로써, JPA 에게 알리는 역할을 한다. 기본적으로 이런 Table 과의 ORM 은 1:1 로 매핑이 되어야 하며, 반드시 식별자가 필요하여서 @Id 로 기본키를 지정하여야 한다.

규칙

  • 1:1 로 테이블과 매핑
  • @Id 식별자 필요
  • 기본 생성자 필요
  • final 금지(필드던 클래스던)

또 위와 같이 설정하면 끝이 아니라, persistence.xml 을 통해 EntityManagerFactory 의 설정을 통해 관리할 엔티티를 추가해줘야 한다.

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
                                 http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">

    <persistence-unit name="UserPU" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <class>com.example.jpa.User</class>

        <properties>
            <!-- 데이터베이스 연결 설정 -->
            <property name="jakarta.persistence.jdbc.driver"
                      value="com.mysql.cj.jdbc.Driver"/>
            <property name="jakarta.persistence.jdbc.url"
                      value="jdbc:mysql://localhost:3306/exampledb"/>
            <property name="jakarta.persistence.jdbc.user"
                      value="user"/>
            <property name="jakarta.persistence.jdbc.password"
                      value="user1234"/>

            <!-- Hibernate 설정 -->
            <property name="hibernate.dialect"
                      value="org.hibernate.dialect.MySQLDialect"/>
            <property name="hibernate.hbm2ddl.auto"
                      value="update"/>
            <property name="hibernate.show_sql"
                      value="true"/>
            <property name="hibernate.format_sql"
                      value="true"/>
        </properties>
    </persistence-unit>
</persistence>

여기서 persistence-unit 태그가 보일텐데, 이 태그가 하나의 영속성 컨텍스트의 단위를 정의하는 태그이다. name 이 persistence-unit 을 식별하는 이름이며, 나머지 옵션들은 다음과 같다.

  • transaction-type="RESOURCE_LOCAL": 트랜잭션 타입
  • provider: JPA 구현체를 지정하는 부분이며 hibernate 사에서 만든 구현체를 넣어주면 된다.
  • class: 이 persistence-unit 에서 관리할 엔티티 클래스를 명시한다(나머지는 그냥 복붙을 하면 되지만, 이는 개발자가 넣어줘야 한다).
  • properties: 이는 자바의 프로퍼티 설정하는 것과 똑같다. 생략한다.
    • hibernate.dialect: SQL 문법 에서 파생된 문법들에 맞춰 자동으로 지정하는 프로퍼티
    • hibernate.hbm2ddl.auto: 엔티티 기반으로 DDL 스크립트를 자동 처리하는 동작을 지정하는 프로퍼티 시작, create, update, create-drop, validate, none 이 있음
    • hibernate.show_sql: hibernate 가 실행하는 SQL 을 콘솔에 출력할지 여부
    • hibernate.format_sql: 로그로 출력되는 SQL 을 보기 좋게 포매팅

create: 기존 테이블 삭제 후 재생성
create-drop: 종료 시 테이블 삭제
update: 변경사항만 반영
validate: 스키마 검증만 수행
none: 아무 작업도 하지 않음

이제 Entity 를 관리하기 위한 준비를 마쳤으면 엔티티가 어떻게 생성되고 끝나는지도 보자.

Entity Lifecycle

엔티티는 기본적으로 4가지 상태를 가진다.

상태(State) 설명 주요 특징 전이 메서드 및 조건 DB 반영 시점 비고
New (비영속, Transient) 엔티티가 생성되었지만 영속성 컨텍스트에 등록되지 않은 상태 - JPA가 관리하지 않음
- 식별자(ID) 미할당
- DB와 무관
em.persist(entity) → Persistent(영속) 없음 new 키워드로 생성된 엔티티
Managed (영속, Persistent) 영속성 컨텍스트에 의해 관리되는 상태 - 1차 캐시에 저장됨
- 변경 감지(Dirty Checking) 가능
- 트랜잭션 커밋 시 DB 반영
- persist()로 등록
- find() 또는 JPQL 조회 시
- merge() 결과로 반환될 때
flush() 또는 트랜잭션 commit 시점 em.detach(), em.clear(), em.close()로 분리 가능
Detached (준영속, Detached) 한때 영속이었으나 현재 컨텍스트와 분리된 상태 - 변경 감지 안 됨
- DB 반영 불가
- 동일 ID 새 조회 가능
- em.detach(entity)
- em.clear() (전체 제거)
- em.close() (종료)
- 트랜잭션 종료 후
없음 다시 영속화하려면 em.merge(entity) 필요
Removed (삭제 예약, Removed) 엔티티가 삭제 예약된 상태 - DB에서 삭제될 예정
- 아직 컨텍스트에는 존재 (삭제 플래그)
em.remove(entity) flush() 또는 commit() 시점에 DELETE 실행 em.persist()로 다시 영속화 가능 (삭제 취소 효과)
Deleted (DB 삭제됨) 실제로 DB에서 삭제 완료된 상태 - 트랜잭션 커밋 후 DELETE 반영 완료 트랜잭션 커밋 완료 시 이미 DB에서 제거됨 이후 재사용 불가
Entity 동등성

JPA 에서 엔티티 동일성을 이해하기 위해 크게 두 가지를 구분해야 한다.

  1. 객체 동일성(Object Identity): 자바의 두 객체가 메모리 상 같은 인스턴스인지 여부
  2. 논리적 동일성(Entity Identity): 엔티티 PK 를 통해 같은지 판별

이를 통해 다음을 보자.

Entity 구현
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "jpa_user")
@NoArgsConstructor
@Getter
@Setter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

@GenearatedValue를 볼 수 있는데, 해당 필드가 DB 테이블의 자동 생성 기능을 이용하고 있고, 그 기능이 어떤 타입인지를 써주면 id 가 null 이더라도 Persist Context 에 들어갔을때 아이디를 자동 생성해준다.

  • GeneratedType.IDENTITY: MySQL 의 AUTO_INCREMENT 와 매핑
  • GeneratedType.SEQUENCE: Oracle 이나 PostgreSQL 의 SEQUENCE 자료구조와 매핑
  • GeneratedType.TABLE
  • GeneratedType.AUTO: JPA 구현체가 자동으로 생성

Persistence Context

영속성 컨텍스트라고 하며, 영속성 컨텍스트는 1차 캐시라 봐도 무방하다. 내부적으로 엔티티를 관리할 때 중요한 것은 Entity 의 구별법인데, 여기서 영속성 컨텍스트는 논리적 동일성을 따르고, 따라서 같음을 구현하고 싶을때는 equals()hashCode() 를 PK 기반으로 구현

엔티티 동일성

JPA 에서 엔티티 동일성을 이해할 시 크게 두 가지를 구분

  1. 객체 동일성(Object Identity): 자바 == 연산자를 이용해 두 객체가 메모리 상 같은 인스턴스인지 여부
  2. 논리적 동일성(Entity Identity): 엔티티 PK 를 통해 같은지 판별

Detached 엔티티와의 동등성을 검증할 때는 PK 가 같아도 서로 다른 자바 객체일 수 있다.

User detachedUser = new User();
detachedUser.setId(1L);

User managedUser = em.find(User.class, 1L);
System.out.println(detachedUser == managedUser); // false
System.out.println(detachedUser.equals(managedUser)); // true, equals를 PK 기반으로 구현했다면
  • 영속성 컨텍스트 내: 같은 PK → 같은 객체
  • 영속성 컨텍스트 밖: PK 가 같아도 다른 객체 (equals() 는 PK 기반이면 true)
  • equals / hashCode: PK 기반으로 구현하는 것이 일반적(Lombok 을 씀)

동등성 구현 원칙

  1. 식별자(id) 기반 비교: 데이터베이스 행을 대표하는 id 로 비교
  2. 일관성 유지: equals()truehashCode() 도 같은 값 반환
  3. null 처리: 비영속 엔티티는 idnull 일 수 있으므로 null 처리 필수
  4. 복합 키: id 가 여러 필드로 구성되면 모든 필드로 비교
// equals 메서드: id 기반 비교

@Override 
public boolean equals(Object o) {
	if (this == o) return true; // 1. 같은 참조면 true 
	if (o == null || getClass() != o.getClass()) return false; // 2. null 또는 다른 클래스면 false
	User user = (User) o; 
	return id != null && id.equals(user.id); // 3. id가 있고 같으면 true 
} 

// hashCode 메서드: id 기반
@Override 
public int hashCode() { 
	return id != null ? id.hashCode() : 0; // id가 없으면 0 반환 
}
비영속 엔티티 처리
User user1 = new User("Alice", "alice@example.com");  // id = null  
User user2 = new User("Alice", "alice@example.com");  // id = null  
  
System.out.println(user1.equals(user2));  // false (id가 둘 다 null)

Set<User> users = new HashSet<>();
users.add(user1);

em.persist(user1);  // id 할당됨 (예: id = 1)  
// 문제: HashSet에서 hashCode 변경으로 user1을 찾을 수 없음

이를 해결하기 위해 엔티티를 컬렉션에 추가하기 전에 영속화하거나, 비즈니스 키 사용 비즈니스 키는 우선 유일해야 하며 not null 이어야 함. 따라서 다음과 같이 구현:

@Column(unique = true, nullable = false)
private String email; // 비즈니스 키

이는 DB 레벨에서의 검증이지 실제 비영속 엔티티를 선언할때 이게 된다는 아니다. 이러고 다음을 구현한다:

@Override 
public boolean equals(Object o) {
	if (this == o) return true; 
	if (o == null || getClass() != o.getClass()) return false; 
	User user = (User) o; 
	return email != null && email.equals(user.email); // email로 비교 
}

@Override
public int hashCode() { 
	return email != null ? email.hashCode() : 0; 
}

이렇게 하더라도 단점이 존재(비즈니스 키 변경시 문제 발생)

따라서 실무에서는

  • 논리적 동등성 사용
  • 컬렉션 사용 전 영속화
  • 불변 비즈니스 키 존재 시: email, username 등 불변 필드로 구현 가능
@Column(unique = true, nullable = false, updatable = false)
// 불변 필드, Setter 은 없애도록, JPA 가 UPDATE 문에서 컬럼 제외, 비즈니스 키로써 적합
private String email;
  • Lombok 사용@EqualsAndHashCode(onlyExplicitlyIncluded = true) + @EqualsAndHashCode.Include 사용, 롬복은 자동으로 equals(), hashCode() 를 제공함, 하지만 가변 필드를 사용하면 equals / hashCode 변경 가능성이 존재하여 위 코드 예시의 @Column 과 합쳐서 사용하여서 equals()hashCode() 에서 해당 @EqualsAndHashCode.Include가 있는 필드만을 동등성 검증에 포함시켜줌
import lombok.EqualsAndHashCode;
import jakarta.persistence.*;

@Entity
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User {

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

    @EqualsAndHashCode.Include
    @Column(unique = true, nullable = false, updatable = false)
    private String email;

    private String name;

    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }
}
1차 캐시

영속성 컨텍스트는 1차 캐시가 있음으로써 DB 와의 트래픽을 줄일 수 있다. 처음 엔티티 매니저에게 요청을 하면 엔티티 매니저는 요청을 토대로 Persistence Context 의 1차 캐시에 해당 객체가 있는지를 본다. 만약 찾고자 하는게 없다면 다음 과정을 거친다:

read 과정

  1. 1차 캐시에 있으면 해당 객체를 반환하고 없다면 다음 과정을 거친다.
  2. Entity Manager 는 빈 객체를 통해서 DB 에게 다시 요청한다(즉 SELECT 쿼리문을 던질 것이다)
  3. Entity Manager 는 해당 객체를 받고 데이터를 Persistence Context 에게 알리며
  4. Persistence Context 는 이를 다시 1차 캐시에 저장한다(없었기 때문)
  5. 그러고 result 를 반환한다.

따라서 영속성 컨텍스트는 알아서 PK 를 기준으로 하여 객체의 메모리 관리를 진행하게 된다. DB 접근 횟수를 줄이기 때문에 성능 최적화 기능도 한다.

Transaction

마지막으로 트랜잭션 클래스를 보자. 트랜잭션은 DB 작업의 논리적 단위라고 하며, 여러 작업을 하나로 묶어서 모두 성공하거나 실패하거나 두 상태 중 하나로만 되도록 하는 단위이며 특징으로는 ACID 를 가진다.

JPA 에서의 트랜잭션은 DB 의 트랜잭션이랑 의미 차이는 많이 없지만 JPA 에서는 트랜잭션 단위로 영속성 컨텍스트를 관리하게 되고 1차 캐시에 있는 변경 사항을 DB에 반영하고 싶을 때 commit() 을 하며 rollback() 으로 persistence context 에서 진행중인 작업을 이전의 commit 상태로 바꿀 수 있다.

EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

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

    User user = new User("Alice", "alice@example.com");
    em.persist(user); // 1차 캐시에 저장, DB에는 아직 반영 X

    user.setName("Alice Updated"); // Dirty Checking으로 변경 추적

    tx.commit(); // DB에 INSERT + UPDATE 실행
} catch (Exception e) {
    tx.rollback(); // 실패 시 DB 반영 취소
} finally {
    em.close();
}

위 코드를 사용하여 EntityTransaction 을 가져올 수 있고, 실패시 롤백 까지의 구현을 try-catch 로 할 수 있다.

JPA Entity Manager Factory 관리

이렇게 보면 엔티티 매니저는 트랜잭션의 시작과 끝에서만 생존하여 메모리에 상주되어야 하므로 함수 내부에서 선언되고 삭제되어야 한다. 이때 이를 만드는 EntityManagerFactory 를 통해서 만들게 되는데, 이를 Singleton 으로 두며, 어플리케이션이 종료될때 factory 자원을 회수할 수 있도록 만드는 코드가 다음과 같다.

public class JPAUtil {
	private static final EntityManagerFactory emfInstance =
			Persistence.createEntityManagerFactory("lionPU");

	// Java 어플리케이션이 종료될 때 자동으로 close()메소드가 호출되도록 합니다.
	static {
		Runtime.getRuntime().addShutdownHook(new Thread(() -> {
			if (emfInstance != null) {
				System.out.println("---- emf close ---");
				emfInstance.close();
			}
		}));
	}

	private JPAUtil() {}

	public static EntityManagerFactory getEntityManagerFactory() {
		return emfInstance;
	}
}

EntityManagerFactory 는 스레드 안전하기 때문에 저렇게 선언한다고 한들 만들어지는 EntityManager 끼리의 충돌은 없다. static 블록은 클래스가 JVM 에 로드될 때 딱 한 번 실행되는 코드이다. 여기서 어플리케이션 하나에 하나가 생성되는 Runtime 객체를 들고와 addShutdownHook 를 걸고 있는 것을 볼 수 있다.

hook 라는 것은 특정 트리거가 실행될 때 실행되는 함수를 말하며 이벤트라고 봐도 무방하다. 따라서 shutdown 되면 해당 Thread 를 실행하는 것을 정의하고 있다(emf의 자원 회수). 중요한 것은 이렇게 static 으로 자원을 회수하도록 코드화 하는 것이다. 다른 클래스에서 해당 자원을 회수하기 위해 회수하는 코드를 추가한다면 SRP 가 훼손될 수 있다.

위 방식 말고도 또 다른 방식으로는 Spring 의 이벤트 리스너를 사용하는 것이다(스프링을 가용할 환경이 된다면 쓰도록 한다).

@Component
public class EMFCleanup {

    private final EntityManagerFactory emf;

    public EMFCleanup(EntityManagerFactory emf) {
        this.emf = emf;
    }

    @EventListener
    public void onApplicationEvent(ContextClosedEvent event) {
        if (emf.isOpen()) {
            System.out.println("---- emf close (Spring) ----");
            emf.close();
        }
    }
}

활용은 이 정도만 하면 된다. 나머지 고급 개념들은 다음 포스트에서 다룬다.