본문 바로가기
Java/Spring

JPA

by oneny 2024. 3. 26.

JPA

회사에서 Spring Data JPA와 QueryDSL을 사용하고 있다... Spring Data JPA와 QueryDSL을 공부하기 전 JPA에 대해 알아보고자 한다. MyBatis와 달리 JPA이 무엇이고, JPA를 사용함으로써 얻을 수 있는 장점과 사용 시 주의사항들에 대해서 살펴보자.

 

SQL 중심적인 개발의 문제점과 대안

자바를 사용하는 이유 중 하나인 객체 지향 프로그래밍은 추상화, 캡슐화, 정보은닉, 상속, 다형성 등 시스템의 복잡성을 제어할 수 있는 다양한 장치들을 제공하여 객체 모델링으로 저장할 수 있다. 하지만 MyBatis와 같은 SQL 중심의 개발은 SELECT, INSERT, UPDATE, DELETE의 무한 반복과 위 사진처럼 객체의 필드가 테이블에 맞추어 모델링되므로 의존적이게 된다. 따라서 자바 진영의 ORM 기술 표준인 JPA를 사용하면 객체와 데이터베이스 간의 매핑을 지원하여 객체 지향 프로그래밍이 가능하다.

 

JPA

JPA(Java Persistence API)는 자바에서 데이터베이스를 조작하기 위한 API의 표준 명세이며, Hibernate는 JPA 명세를 구현한 구현체 중 하나로, 객체-관계 매핑을 지원한다. 이는 자바 어플리케이션에서 관계형 데이터베이스와의 상호작용을 단순화하고 객체 지향 프로그래밍과 관계형 데이터베이스 간의 간편한 연동을 가능하게 해준다.

 

JPA의 주요 개념

아래는 JPA를 사용할 때 알아야 할 주요 개념이다.

  • 엔티티(Entity): JPA에서 데이터베이스 테이블과 매핑되는 자바 객체를 엔티티라고 한다. 엔티티는 데이터베이스의 특정 레코드에 해당한다.
  • 엔티티 매니저(Entity Manager): 엔티티 매니저는 영속성 컨텍스트를 관리하며, 엔티티의 생명주기를 관리하는 주체이다. 데이터베이스와의 트랜잭션을 시작하고 종료하며 엔티티의 영속성을 보장한다.
  • 영속성 컨텍스트(Persistence Context): 영속성 컨텍스트는 엔티티 매니저가 관리하는 엔티티의 상태를 추적하는 영역이다. 즉, 엔티티 매니저를 통해서 영속성 컨텍스트에 접근이 가능하고, 영속성 컨텍스트 내에서 엔티티는 영속 상태로 유지되며, 이는 데이터베이스와의 일관성을 보장한다.
  • ORM(객체-관계 매핑, Object-Relational Mapping): JPA는 객체와 데이터베이스 간의 매핑을 자동으로 처리해주는 기술로 객체 지향 프로그래밍의 특성을 유지하면서 데이터베이스를 다룰 수 있도록 한다.

 

엔티티 생명주기

춣처: 자바 ORM 표준 JPA 프로그래밍 - 기본편

JPA의 엔티티 생명주기는 엔티티가 존재하는 동안의 상태로 다음과 같은 네 가지 상태로 분리할 수 있다.

  • 비영속(new/transient): 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
  • 영속(managed): 영속성 컨텍스트에 관리되는 상태
  • 준영속(detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제(removed): 삭제된 상태

 

영속

// 객체를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();

// 객체를 저장한 상태(영속)
entityManager.persist(member);

영속 상태는 엔티티가 영속성 컨텍스트에 관리되고 데이터베이스와 동기화되었음을 의미한다. 위에서 볼 수 있듯이 엔티티를 영속 상태로 만들려면 주로 EntityManager의 persist() 메서드를 사용한다. 또한 영속 상태가 되었다고 해서 바로 DB에 요청을 보내는 것이 아니라 트랜잭션을 커밋하는 시점에 요청한다.

 

준영속, 삭제

// 회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태
entityManager.detach(member);

// 객체를 삭제한 상태(삭제)
entityManager.remove(member);

준영속 상태는 영속성 컨텍스트에서 분리(detached)되어 영속성 컨텍스트의 관리를 받지 않기 때문에 변경이 감지되지 않고, 삭제 상태는 엔티티가 영속성 컨텍스트에서 삭제되고, 삭제 또한 쓰기 지연으로 커밋 시점에 요청을 보내게 된다.

 

 

엔티티 매니저와 영속성 컨텍스트

엔티티 매니저(EntityManager)는 JPA에서 영속성 컨텍스트를 관리하고 엔티티의 생명주기를 제어하는 주체로 EntityManagerFactory를 통해 얻어지며, 영속성 컨텍스트를 생성하고 관리하는 역할을 한다. EntityManager는 스레드 간에 공유되지 않고, 각각의 트랜잭션에서 별도의 EntityManager 인스턴스를 사용해야 한다. 또한, 트랜잭션 내에서만 EntityManager가 유효하며, 트랜잭션이 종료되면 EntityManager도 종료된다.

엔티티 매니저는 다음과 같은 주요 기능이 있다.

  • 엔티티의 영속화(Persist): 새로운 엔티티를 데이터베이스에 저장하기 위해 영속성 컨텍스트에 등록한다.
  • 엔티티의 조회(Find): 데이터베이스에서 엔티티를 조회하고, 조회한 엔티티를 영속성 컨텍스트에 등록한다.
  • 엔티티의 수정(Merge): 엔티티의 상태를 변경하고, 변경된 내용을 데이터베이스에 반영한다.
  • 엔티티의 삭제(Remove): 영속성 컨텍스트에서 엔티티를 삭제하고, 데이터베이스에서도 삭제한다.

 

JPA의 성능 최적화 기능

JPA에서는 영속성 컨텍스트를 통해서 엔티티를 관리하고 지속적으로 추적하는 환경을 제공하는데 엔티티 매니저를 통해 생성되며, 트랜잭션 범위 내에서 동작한다. 그리고 영속성 컨텍스트을 사용하여 버퍼링 및 캐싱과 같은 이점들을 얻을 수 있는데 아래와 같은 최적화 기능을 제공한다.

  • 1차 캐시와 동일성(identity) 보장
    JPA의 1차 캐시는 영속선 컨텍스트 내에서 엔티티를 저장하고 관리하는 캐시를 말한다. 이를 통해 같은 트랜잭션 안에서는 같은 엔티티를 반환하기 때문에 약간의 조회 성능이 향상되고, 동일성을 보장해준다. 그리고 DB Isolation Level이 Read Commit이어도 애플리케이션 차원에서는 Repeatable Read를 보장할 수 있다.
  • 트랜잭션을 지원하는 쓰기 지연(Transactional write-behine)
    트랜잭션 내에서 엔티티의 상태가 변경될 때마다 쿼리를 전송하는 것이 아니라 JPA에는  변경 내용은 커밋할 때까지 쓰기 지연 큐에 저장된다. 그리고 커밋하면 JDBC BATCH SQL 기능을 사용해서 hibernate.jdbc.batch_size만큼 쓰기 지연 큐에 있는 변경 내용을 한 번에 DB 서버에 요청하여 여러 엔티티의 변경을 최소한의 데이터베이스 쓰기로 효율적으로 처리할 수 있다.
  • 변경 감지(Dirty Checking)
    영속성 컨텍스트에서 변경 감지는 엔티티의 상태가 변경되어쓴ㄴ지를 감지하고, 변경된 내용을 자동으로 데이터베이스에 UPDATE 요청하는 것을 말한다. 이를 통해 개발자는 명시적인 SQL 쿼리를 작성하지 않아도 엔티티의 변경을 관리할 수 있다.
  • 지연 로딩(Lazy Loading)
    지연 로딩은 연관 관계의 엔티티를 실제로 사용할 때까지 데이터베이스에서 로딩을 미루는 기능을 말한다. 이와 반대로 즉시 로딩은 JOIN SQL로 한 번에 연관된 객체까지 미리 조회하기 때문에 지연 로딩을 통해 필요한 시점에 연관된 엔티티를 로딩함으로써 성능을 최적화할 수 있다.
    지연 로딩은 프록시 객체를 사용하여 실제 엔티티의 로딩을 미루고, 필요한 시점에 프록시를 실제 엔티티로 대체할 수 있다.

 

Flush

플러시(Flush)는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 것을 말한다. 플러시는 트랜잭션이 커밋하거나 JPQL 쿼리를 실행할 때 자동으로 호출되고, EntityManager 인스턴스의 flush() 메서드를 통해서 직접 호출할 수 있다. 플러시가 호출되면 다음과 같은 순서로 이루어진다.

  1. 변경 감지
  2. 수정된 엔티티 쓰기 지연 SQL 저장소에 등록
  3. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송(등록, 수정, 삭제 쿼리)

 

entityManager.persist(memberA);
entityManager.persist(memberB);
entityManager.persist(memberC);

// 중간에 JPQL 실행
query = entityManager.createQuery("select m from Member m", Member.class);)
List<Member> members = query.getResultList();

그리고 JPQL 쿼리를 실행할 때 플러시가 자동으로 호출되는 이유는 entityManager.persist()를 호출하더라도 커밋되는 시점까지 쓰기 지연이 일어난다고 했다. 이 때, member 테이블을 조회하게 되면 memberA, memberB, memberC는 아직 쓰기가 되기 전이므로 조회가 되지 않게 되기 때문에 이를 방지하고자 중간에 JPQL를 실행하게 되면 플러시가 자동으로 호출이 된다.

 

N + 1 문제

N + 1 문제는 조회 시 1개의 쿼리를 생각하고 설계했으나 나오지 않아도 되는 조회의 쿼리가 N번의 추가 쿼리를 수행하여 성능을 저하시키는 문제를 말한다.

 

@Entity
public class Member {

    // ...
    
    @ManyToOne(fetch = FetchType.EAGER) // default가 즉시로딩
    @JoinColumn
    private Team team;
    
    // ...
}

Member와 Team의 일대다 관계에서 즉시 로딩을 적용하면 예쌍하지 못한 SQL이 발생한다.

 

entityManager.createQuery("select m from Member m", Member.class);

위 코드처럼 회원 목록을 조회하게 되면 즉시 로딩에 의해 만약 회원이 10명이라면 10명의 회원에 대한 팀을 조회하기 때문에 N + 1 문제가 발생하게 된다. 따라서 가급적 지연 로딩만 사용하는 것이 좋다. 그리고 @ManyToOne, @OneToOne은 기본이 즉시 로딩으로 따로 fetch 값을 fetch = FetchType.LAZY로 설정해야 한다.

 

public class Member {

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩으로 수정
    @JoinColumn
    private Team team;
    
}

Spring query = "select m From Member m";
List<Member> result = entityManager.createQuery(query, Member.class)
	.getResultList();

for (Member member : result) {
	System.out.println("member = "
	    + member.getUsername()
	    + ", "
	    + "teamName = "
	    + member.getTeam().name()); // N + 1 문제 발생
}

하지만 지연 로딩으로 수정해도 다음과 같은 코드에서 N + 1 문제가 발생할 수 있다. 아직 team에 대해서 조회를 하지 않아 member의 수만큼 루프를 돌게 되면 member.getTeam()을 호출했을 때 member 수만큼 쿼리가 발생하는 N + 1 문제가 발생하게 된다.

 

String query = "select m From Member m join fetch m.team" // 페치 조인으로 해결

따라서 이러한 경우에는 페치 조인을 통해 한 번의 쿼리로 조회할 수 있도록 하면 N + 1 문제를 해결할 수 있다.

 

LazyInitializationException

JPA에서 LazyInitializationException은 주로 지연 로딩(Lazy Loading)과 관련된 문제로 발생한다. 위에서 말했듯이 지연 로딩은 엔티티나 엔티티의 컬렉션을 실제로 필요한 시점까지 로딩을 지연시키는데 이때, 영속성 컨텍스트가 닫혀있는 상태에서 지연 로딩이 시도되면 LazyInitializationException이 발생한다. 즉, 주로 영속성 컨텍스트가 이미 종료되었거나, 트랜잭션이 종료된 상태에서 지연 로딩된 엔티티나 컬렉션에 접근하려고 할 때 발생한다는 것이다.

LazyInitializationException이 발생하는 이유는 서비스 레벨에서 @Transactional이 명시된 메서드가 종료되면 Hibernate의 Session도 함게 종료되는데 FetchType.LAZY가 설정된 필드가 포함된 엔티티 오브젝트에 대해, 컨트롤러 레벨에서 해당 필드를 조회할 때 Getter 메서드를 호출하고 실제 조회 쿼리가 실행된다. 하지만 앞서 이미 Session이 종료된 상태이기 때문에 LazyInitialization 예외가 발생한다.

이를 해결하기 위한 방법에는 먼저 안티 패턴이지만 spring.jpa.properties.hibernate.enable_lazy_load_on_trans = true로 설정하는 방법이 있다. 이는 Session이 종료되었더라도 예외를 발생시키지 말고 다른 Session을 사용하여 데이터를 조회하는 것인데 엔티티에 설정된 관계의 복잡성과 상황에 따라 커넥션 풀을 고갈시키는 장애를 유발할 수 있어 권장되지 않는 방법이다.

가장 권장하는 해결책은 서비스 레벨에서 트랜잭션이 종료되는 시점에 리턴 타입으로 엔티티를 DTO로 변환하는 것다. 지연 로딩을 피하기 위해 미리 로딩하는 방법으로 서비스 레이어에서 영속성 컨텍스트가 아직 열러있는 상태에서 Hibernate.initialize() 메서드를 사용하여 필요한 필드를 미리 로드하고, 각 필드를 DTO로 맵핑하여 리턴함으로써 LazyInitializationException을 피할 수 있다.

 

OSIV(Open Session in View)

OSIV는 JPA에서 사용되는 패턴으로 HTTP 요청이 들어올 때 데이터베이스 세션을 열어두고, 뷰 렌더링이 완료될 때까지 세션을 닫지 않는 방식을 말한다. 일반적으로 JPA의 영속성 컨텍스트는 트랜잭션 범위 내에서만 존재하며, 트랜잭션이 종료되면 영속성 컨텍스트도 종료된다. 그러나 웹 어플리케이션에서 데이터베이스 세션을 뷰가 렌더링될 때까지 유지해야 하는 경우에는 OSIV 패턴이 도입되어 트랜잭션을 뷰 렌더링까지 연장하여 세션을 유지하도록 한다.

하지만 세션을 유지하지 않도록 하는 것이 좋다. 이유는 만약 하나의 컨트롤러에서 두 개의 서비스 레이어를 통해 트랜잭션을 두 번 실행하게 된다면 뷰 렌더링까지 연장했을 때 세션은 어느 것일지 오류를 찾아내기 힘들기 때문이다. 따라서 세션은 트랜잭션이 종료되는 시점에 같이 종료되는 것이 좋다.

 

 

 

출처

자바 ORM 표준 JPA 프로그래밍 - 기본편