JPA 원리
JPA 원리에 대해 설명합니다.
JPA(Java Persistence API)는 자바에서 ORM 기술을 사용하기 위한 표준 명세(Standard Specification)입니다. 이는 JPA 자체가 라이브러리가 아니라 인터페이스와 같은 추상적인 개념이며, 실제 구현체로는 Hibernate, EclipseLink 등이 존재 합니다.
영속성 컨텍스트
JPA를 이해하는 데 가장 중요한 핵심 개념은 바로 영속성 컨텍스트(Persistence Context)입니다. 이는 엔티티(Entity) 객체를 영구적으로 저장하는 환경이라는 뜻을 가진 논리적인 개념으로, EntityManager 를 통해 접근하고 관리됩니다.
영속성 컨텍스트는 데이터베이스와 애플리케이션 사이의 캐시(Cache) 저장소 역할을 하며, 이 저장소는 Map 자료구조를 사용하여 @Id 로 매핑된 기본 키(Primary Key)를 key 로, 해당 엔티티 객체를 value로 저장합니다. 모든 엔티티는 영속성 컨텍스트 내에서 생명주기(비영속, 영속, 준영속, 삭제)를 가집니다. 엔티티 매니저의 persist() 메서드를 호출하면 객체가 영속성 컨텍스트에 저장되며, 이때부터 해당 객체는 영속 상태가 되어 컨텍스트의 관리를 받게 됩니다.
영속성 컨텍스트 핵심 기능
영속성 컨텍스트는 단순한 캐시 이상의 기능을 게공하여 불필요한 DB 접근을 줄이고 데이터 동기화를 자동으로 처리합니다.
1차 캐시(First-Level Cache)
영속성 컨텍스트 내부에 존재하는 캐시 저장소입니다. EntityManager의
find()메서드로 엔티티를 조회할 때, 먼저 1차 캐시를 확인합니다. 만약 찾는 엔티티가 이미 존재하면 DB에 접근하지 않고 메모리에 있는 데이터를 즉시 반환하여 성능을 향상시킵니다. 또한, 같은 트랜잭션 내에서 동일한 키로 여러번 조회하더라도 DB에 한 번만 접근하고, 항상 동일한 객체 인스턴스를 반환함으로써 객체 동일성을 보장합니다.쓰기 지연(Transactional Write-Behind)
엔티티의 상태 변경(추가,수정,삭제)이 발생했을 때, JPA는 SQL을 즉시 DB로 보내지 않습니다. 대신, 쓰기 지연 SQL 저장소(Write-Behind SQL Store)에 해당 SQL 쿼리를 임시로 저장해 둡니다. 이 저장소에 쌓인 쿼리들은 트랜잭션 커밋되는 시점에 한 번에 일괄적으로 DB에 전송되어 실행됩니다. 이를 통해 여러 개의 쿼리를 한 번의 네트워크 통신으로 처리하여 DB 접근 횟수를 최소화하고 성능을 최적화 합니다.
변경 감지(Dirty Checking)
영속성 컨텍스트에 저장된 엔티티는 최초 로딩 시점의 상태를 스냅샷(Snapshot)으로 저장합니다. 트랜잭션이 커밋되어
flush가 호출될 때, JPA는 스냅샷과 현재 엔티티의 상태를 비교하여 변경된 필드를 감지합니다. 만약 변경이 있다면, 별도의update()메서드 호출 없이도 자동으로UPDATE쿼리를 생성하여 쓰기 지연 저장소에 등록합니다. 이는 개발자가 객체의 필드 값을 단순히 변경하는 것만으로도 DB 반영되는 직관적인 경험을 제공합니다.
이러한 메커니즘은 JPA가 DB 작업을 개발자로부터 완벽하게 추상화하여, 마치 자바 컬렉션에 객체를 저장하고 수정하듯이 DB를 다룰 수 있게 합니다. 이로 인해 개발 생산성이 크게 향상되지만, 동시에 이러한 내부 동작 원리에 대한 깊은 이해 없이는 예측 불가능한 성능 문제나 메모리 오버헤드(모든 관리 엔티티의 스냅샷 저장)를 초래 할 수 있습니다.
JPA 기본 동작 원리
EntityManagerFactory
EntityManager 인스턴스를 관리를하며 하나의 DB에 하나의 EntityManagerFactory가 매핑됩니다. DB 접근정보, 옵션 등을 전달하기 위해 Persistence.xml을 사용하는데, Spring boot에서는 application.propertis를 활용해 파일이 자동으로 생성됩니다.
EntityManager
EntityManagerFactory를 통해 사용자 요청 1개당 1개씩 생성되며, DB 커넥션풀을 통해 DB에 CRUD를 요청합니다. 이때, 하나의 트랜잭션에는 한개의 EntityManager만 존재 할 수 있습니다.
예제코드
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
@Entity @Table(name = "course") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String instructor; private double cost; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
class CourseTest { private static EntityManagerFactory emf; private EntityManager em; private EntityTransaction etx; @BeforeAll // 딱 한번 실행됨 static void initFactory() { emf = Persistence.createEntityManagerFactory("course"); } @BeforeEach // 테스트 메서드 직전 마다 실행 void initEntityManager() { em = emf.createEntityManager(); etx = em.getTransaction(); } @AfterEach // 테스트 메서드 직후 마다 실행 void closeEntityManager() { if (em.isOpen()) { em.close(); } } @AfterAll // 테스트 클래스가 끝난뒤 실행 static void closeFactory() { if (emf.isOpen()) { emf.close(); } } @Test @DisplayName("Course 생성") void createCourse() { etx.begin(); try { Course course = Course.builder() .title("JTA") .instructor("Robbie") .cost(22222.1) .build(); em.persist(course); etx.commit(); } catch (Exception e) { e.printStackTrace(); etx.rollback(); } } @Test @DisplayName("Course 조회") void readCourse() { etx.begin(); try { Course course = em.find(Course.class, 1L); System.out.println("course.getId() = " + course.getId()); System.out.println("course.getTitle() = " + course.getTitle()); System.out.println("course.getInstructor() = " + course.getInstructor()); // 같은 내용을 조회할 경우 쿼리가 생성되지 않음. // 우선 1차 캐시 조회 후, 데이터가 없을 경우에만 DB에 쿼리를 날리기 때문. Course course1 = em.find(Course.class, 1L); System.out.println("course1.getId() = " + course1.getId()); System.out.println("course1.getTitle() = " + course1.getTitle()); System.out.println("course1.getInstructor() = " + course1.getInstructor()); System.out.println("(course == course1) = " + (course == course1)); Course course3 = em.find(Course.class, 2L); System.out.println("course3.getId() = " + course3.getId()); System.out.println("course3.getTitle() = " + course3.getTitle()); System.out.println("course3.getInstructor() = " + course3.getInstructor()); etx.commit(); } catch (Exception e) { etx.rollback(); } } @Test @DisplayName("Course 수정") void updateCourse() { etx.begin(); try { Course course = em.find(Course.class, 1L); course.setTitle("Spring"); etx.commit(); } catch (Exception e) { etx.rollback(); } } @Test @DisplayName("Course 삭제") void deleteCourse() { etx.begin(); try { Course course = em.find(Course.class, 1L); assertThat(em.contains(course)).isTrue(); em.remove(course); assertThat(em.contains(course)).isFalse(); etx.commit(); } catch (Exception e) { etx.rollback(); } } }
1. 쓰기 동작
예제코드에 있는 Course 생성시 아래와 같이 동작하고 있습니다.
- 엔티티가 영속화(persist)되어 1차 캐시에 저장
- 쓰기지연 저장소에 INSERT문이 생성되어 1차 캐시에 등록된 데이터를 DB에 추가할 준비
etx.commit()호출하면 INSERT문이 DB로 날아가고(flush) 저장이 끝나면 commit
2. 조회 동작
예제코드에 있는 Coure 조회시 아래와 같이 동작하고 있습니다.
- entityManager가
find()메서드를 이용하여 조회 요청 - 1차 캐시에 원하는 데이터를 찾고 없으면 DB를 조회
- 조회 이후 1차 캐시에 저장하고 객체 반환
- 만약 courseA처럼 1차 캐시에 원하는 데이터가 있으면 객체 반환
1차 캐시는 트랜잭션단위로 캐시하는 거고 만약 다른 트랜잭션이 동일한 쿼리를 호출하였다고 이전 트랜잭션의 1차 캐시를 가져올 수는 없습니다. 즉, 트랜잭션내에서만 1차 캐시가 유효합니다.
3. 수정 동작
예제코드에 있는 Course 수정시 아래와 같이 동작하고 있습니다.
find()메서드로 조회 요청- 스냅샷과 함께 1차 캐시에 저장
- 엔티티 변경하여 커밋
- 1차 캐시에 저장한 스냅샷과 비교하여 변경 필드가 있는지 확인
- 변경점 있으면 DB로 Update문이 실행


