JPA 이해하기 (feat. ORM)

JPA 이해하기 (feat. ORM)

JPA(Java Persistence API) 은 자바의 표준 ORM API 이다.

그렇다면 ORM 이란 무엇일까? JPA 를 제대로 이해하기 위해 우선 ORM 에 대한 이해가 필요하다.

ORM 이란?

ORM 이란 Object-Relational Mapping 의 약자로, 이름 그대로 객체(Object)와 관계형 데이터(Relational data) 를 매핑하기 위한 기술이다.

이러한 매핑이 필요한 이유는 객체 지향 언어(Object Oriented Language)과 관계형 데이터베이스(Relational Database)에서 데이터를 표현 하는 방식이 다르기 때문이다.

이 둘 간의 차이 때문에 개발자는 더 많은 코드를 작성해야 하며, 이는 반복적이고 실수하기 쉬운 작업이 된다.

그렇기 때문에 개발자는 Object Oriented 한 Design 에 집중할 수 없게 된다.

ORM 은 이러한 문제를 해결해 준다.

패러다임의 불일치

객체 지향 프로그래밍(이하 OOP)과 관계형 데이터베이스(이하 RDB)의 데이터 표현 방식이 다른 문제를 패러다임의 불일치라고 부르기도 한다.

OOP 와 RDB 에서 데이터를 표현하는 방식이 다른 이유는 애초에 이들의 목표와 동작 방식이 다르기 때문이다.

예를 들어, RDB 에서는 데이터의 중복을 줄이고 및 일관성을 높이기 위해 하나의 데이터를 여러개의 테이블로 쪼개어 저장하고 필요할 때 조인하여 사용하게 된다.

반면, OOP 에서는 하나의 객체가 다른 객체에 대한 참조를 포함하며, 연관된 두 객체는 모두 메모리 상에 존재하기 때문에 하나의 객체로 이와 연관된 객체들의 데이터를 손쉽게 얻을 수 있다.

그럼 4가지 관점에서 OOP 와 RDB 가 데이터를 다루는 방식이 어떻게 다른지, 또 JPA 는 이를 어떻게 해결하는지 좀 더 살펴보자.

상속

객체는 상속이라는 개념이 있는 반면 테이블은 상속의 개념이 없다.

예를 들어 여러 운송 수단의 리스트를 표현하기 위해 운송 수단에 대한 클래스를 다음과 같이 설계했다고 가정하자.

OOP 에서의 데이터 모델링

그렇다면 RDB 에서는 다음과 같이 테이블을 설계해야 할 것이다.

RDB 에서의 데이터 모델링

즉, Car 객체를 DB 에 저장하기 위해서는 Vehicle 클래스에 해당하는 데이터와 Car 에서 정의한 데이터로 나누어 2번의 INSERT 쿼리가 필요하며,

DB 에서 데이터를 가져올 때는 조인을 통해 가져온 데이터로 Car 객체를 생성해야 한다.

반면, OOP 에서는 단순히 list.add() 와 list.get() 명령어 한 번으로 원하는 데이터를 저장하고 읽을 수 있다.

JPA 는 개발자가 OOP 스타일로 Car 객체를 저장하더라도 내부적으로 2개의 쿼리를 만들어 상속 관계를 RDB 에 맞는 데이터로 변환하여 저장해준다.

연관 관계

학교에서 한 명의 학생이 하나의 반에 속해있는 상황을 가정해보자.

class Student {
int studentId;
String name;
Class clazz;
}

class Class {
int classId;
String className;
Teacher teacher;
}

OOP 에서는 학생에 대한 객체가 반에 대한 객체를 포함한다. 정확히는 참조를 가지고 있다.

반면, RDB 에서는 참조를 가지는 것이 아니라 반에 대한 참조를 FK 로 대체하고, 반에 대한 데이터를 분리하여 따로 저장해야한다.

즉, 데이터 저장 시에 데이터를 분리하여 2개의 쿼리를 사용해야 하며, 다시 읽을 때는 조인을 통한 재조립이 필요하다.

JPA 는 이를 내부적으로 처리해주기 때문에 개발자는 OOP 스타일로 데이터를 저장하고 읽을 수 있게 된다.

객체 그래프 탐색

객체 연관관계가 다음과 같은 그래프 형태를 이루고 있다고 하자.
연관관계 그래프

이때 어떤 사람이 거주하는 국가를 얻기 위해 다음과 같은 코드를 작성했다고 하자.

person.getHouse().getCity().getCountry();

이렇게 연관된 객체를 얻기 위한 행위를 객체 그래프 탐색이라고 한다.

DB 에서 person 데이터를 가져오려면 앞서 말한것 처럼 연관된 테이블과의 조인을 통해 가져와야 한다.

그리고 이때 정해진 쿼리문에 따라 탐색이 가능한 경계가 정해지게 된다. 만약 이 경계를 넘는 객체를 얻어 사용하려 하면 NPE(Null Pointer Exception)가 발생할 것이다.

문제는, 처음 프로그램을 개발할 때는 person 에서 부터 어디까지 그래프를 탐색해야 하는지 알 수 없다는 것이다.

물론 넉넉하게 모든 연관된 테이블을 조인하여 데이터를 가져올 수도 있을 것이다. 하지만 불필요하게 많은 테이블을 조인하여 모든 데이터를 가져오는 것은 좋은 방법이 아닐 것이다.

JPA 는 지연 로딩을 사용하여 이 문제를 해결한다. 그때 그때 연관된 데이터가 필요할 때 데이터를 로딩하는 것이다.

비교

RDB 에서는 데이터를 PK 값으로 식별하기 때문에 같은 id 를 가진 데이터는 당연히 같은 데이터로 취급된다.

반면, OOP 에서 두 객체 간의 동일성 비교는 '==' 연산자를 사용한다. 즉, 두 객체가 같은 참조를 가지고 있는지를 보는것이다.

그런데 다음과 같이 DB 에서 가져온 객체의 동등성을 비교하는 경우를 보자.

Student student1 = studentDAO.getStudent(studentId);
Student student2 = studentDAO.getStudent(studentId);
assert student1 == student2;

일반적으로 DAO 는 매번 새로운 인스턴스를 만들어 반환하도록 구현되기 때문에 두 객체의 동일성을 비교하면 물론 다르다는 결과가 나올 것이다.

JPA 에서는 동일한 키 값으로 데이터를 요청하면 같은 인스턴스를 반환하기 때문에 이런 문제가 발생하지 않는다.

그럼 이제 JPA 에 대해 자세히 알아보자.

JPA 란?

JPAJava Persistence API 의 약자로 자바 진영에서 만든 표준 ORM API 이다.
(참고로 2019년에 Jakarta Persistence 로 이름이 바뀌었다)

JPA 는 캐싱, 지연 로딩, 쓰기 지연 등을 통한 성능을 향상시켜 주고, 변경 감지, 동일성 보장 등을 통해 개발 편의성을 향상시켜 준다.

다음은 JPA 의 동작 방식에 대해 알아보자.

JPA 의 동작 방식

JPA 에서는 저장하고자 하는 데이터를 엔티티라고 부르는데, 우선 JPA 에서 엔티티를 DB 에 저장하는 샘플 코드를 보자.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("persistence-unit");
EntityManager em = emf.createEntityManager();

EntityTransaction tx = em.getTransaction();

try {
tx.begin();

Student student = new Student();
student.setName("홍길동");
em.persist(student);

tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
}

emf.close();

Persistence 클래스의 createEntityManagerFactory() 메소드로 엔티티 매니저 팩토리(Entity Manager Factory) 를 생성하는데, 이때 DB 의 커넥션 풀도 함께 생성된다.

그렇기 때문에 팩토리를 생성하는 일은 비용이 많이 들며, 보통 하나만 만들어 애플리케이션 전체에서 공유한다.

EntityManagerFactory 의 createEntityManager() 메소드로 엔티티 매니저(Entity Manager) 를 생성할 수 있는데, 이 엔티티 매니저는 가상의 DB 와 같은 역할을 한다고 보면된다.

우리는 엔티티와 관련된 일을 할 때 이 엔티티 매니저와 상호작용하게 된다. 엔티티를 생성하는 비용은 거의 없으며 각 엔티티 매니저는 필요할 때 커넥션 풀에서 커넥션을 얻어 사용한다.

Entity Manager 를 생성할 때는 영속성 컨텍스트(Persistence Context) 가 같이 생성되는데, 이 영속성 컨텍스트를 이해하는 것이 중요하다.

기본적으로 하나의 엔티티 매니저에는 하나의 영속성 컨텍스트가 할당되지만 서로 다른 엔티티 매니저가 하나의 영속성 컨텍스를 사용할 수도 있다.

엔티티와 관련된 동작을 수행할 땐 트랜잭션 사이에 수행되어야 한다.

그럼 엔티티를 저장, 조회, 수정, 삭제할 때 내부 동작 원리를 알아보자.

엔티티의 저장

엔티티는 생명주기를 갖는다. 엔티티에는 영속(Managed), 준영속(Detached), 비영속(Transient), 삭제(Removed) 이렇게 4가지 상태가 있으며,

엔티티 매니저의 특정 메소드가 호출되거나 JPQL 이 실행되면 상태가 변한다.

엔티티의 생명주기

entityManager.persist(entity) 의 형태로 엔티티를 저장하면 엔티티는 비영속(Transient) 상태에서 영속(Managed) 상태가 되며 영속성 컨텍스트 내에서는 다음과 같은 일이 일어난다.

persist() 수행시 내부 동작 과정

  1. 엔티티 매니저의 persist() 메소드를 통해 엔티티가 영속성 컨텍스트에 들어온다.
  2. 엔티티가 영속성 컨텍스트의 1치 캐시에 저장되면서 초기 상태의 스냅샷이 따로 보관된다.
  3. 엔티티를 DB 에 저장하기 위한 쿼리가 자동 생성되어 쓰기 지연 SQL 저장소에 저장된다.
  4. 트랜잭션을 커밋하면 내부적으로 먼저 flush() 를 수행하는데, 이는 쓰기 지연 SQL 저장소에 저장된 쿼리들을 DB 에 보내 수행하게 함으로써 영속성 컨텍스트와 DB 의 상태의 동기화를 위한 것이다.
  5. 트랜잭션을 커밋한다.

엔티티의 조회

엔티티 매니저의 find() 메소드로 엔티티를 DB 에서 읽을 수 있다. 하지만 곧바로 DB 에서 데이터를 찾는 것이 아니라 먼저 1차 캐시를 살펴본다.

1차 캐시에 해당 엔티티가 있으면 곧바로 이를 반환하고, 만약 없다면 DB 에서 데이터를 다져와 1차 캐시에 저장한 뒤 반환한다.

엔티티를 조회하는 다른 방법으로는 JPQL(Java Persistence Query Language) 이 있다.

JPQL 은 SQL 과는 달리 자바 객체에 대한 쿼리를 정의한다. 그래서 JPQL 은 SQL 에 대해서는 전혀 모른다.

JPQL 을 실행하기 전에 자동으로 flush() 를 수행하고 JPQL 을 실행하는데, 그 이유는

쓰기 지연 SQL 저장소에 있는 쿼리를 DB 와 동기화해야 정상적인 결과가 나올 것이기 때문이다.

엔티티의 수정

저장과 삭제와는 달리 수정을 위한 메소드가 따로 존재하지 않는다.

수정을 위해서는 엔티티 매니저의 메소드를 호출할 필요 없이 영속 상태의 엔티티 객체를 수정하기만 하면된다.

어떻게 엔티티 객체를 수정했을 뿐인데 DB 에 이 사실이 반영되는 것일까?

그것은 바로 영속성 컨텍스트 내부에서 변경 감지(dirty checking) 라는 것을 하기 때문이다.

엔티티 매니저의 flush() 메소드가 실행되면 엔티티가 처음 persist 될 때 저장된 스냅샷과 현재 상태를 비교하여 상태가 달라졌으면 쓰기 지연 SQL 저장소에 업데이트 쿼리를 저장한다.

그렇기 때문에 변경 사항이 DB 에 반영되는 것이다.

만약 엔티티를 영속성 컨텍스트에 의해 관리되지 않는 준영속 상태로 만들고 싶다면 엔티티 매니저의 detach() 메소드 인자로 엔티티를 넘겨주거나, clear() 메소드를 통해 영속성 컨텍스트의 내용을 모두 지우거나, 또는 close() 메소드를 통해 엔티티 매니저를 종료시키면 된다. 엔티티 매니저를 종료시키면 영속성 컨텍스트는 소멸한다.

준영속 상태의 엔티티는 1차 캐시에 존재하지 않으므로 수정해도 DB 에는 이것이 반영되지 않는다.

다시 영속 상태로 만들고 싶다면 merge() 메소드 인자로 엔티티를 넘겨주면 된다.

merge() 메소드가 실행되면 엔티티를 1차 캐시에서 찾고, 만약 있다면 메소드 인자로 넘어온 값을 복사 한뒤 1차 캐시에 있는 엔티티를 반환한다.

만약 1차 캐시에 없다면 DB 에서 값을 읽어 1차 캐시에 저장하고 동일한 동작을 수행한다.

근데 여기서 만약 DB 에 해당 데이터가 없는 경우가 있다. 이런 경우 merge() 메소드는 사실상 persist() 메소드와 동일하게 동작한다. 즉, merge() 메소드는 엔티티의 상태가 준영속이건 비영속이건 상관없이 사용할 수 있어, 엔티티의 생성과 수정 모두에 사용될 수 있다.

엔티티의 삭제

엔티티 매니저의 remove() 메소드 인자로 삭제하고자 하는 엔티티를 넘겨주어 삭제할 수 있다.

엔티티의 저장하거나 수정할 때와 마찬가지로, 삭제 쿼리를 쓰기 지연 SQL 저장소에 저장했다가 flush 시에 실제로 DB 에서 삭제된다.

참고1: https://en.wikipedia.org/wiki/Object-relational_mapping
참고2: 자바 ORM 표준 JPA 프로그래밍

Comments