03. 영속성관리 - 엔티티 매니저, 트랜잭션, 플러시

이주호·2024년 11월 20일

3.1 엔티티 매니저 팩토리와 엔티티 매니저

엔티티 매니저란?

엔티티를 저장하고 수정하고 삭제하는 등 엔티티와 관련된 일을 모두 관리하는 일종의 엔티티를 저장하는 가상의 데이터베이스로 생각하면 된다.
엔티티 매니저 팩토리란 엔티티 매니저를 생성하는 말 그대로의 공장인데, 엔티티 매니저 공장은 하나를 생성하는데 비용이 많이 들지만 생성한 공장에서 엔티티 매니저를 생성하는 것은 비용이 낮기 때문에 보통 엔티티 매니저 공장을 하나만 생성하고 생성한 공장에서 엔티티 매니저를 여러개 생성하여 사용한다.
또한 엔티티 매니저 팩토리는 여러 스레드에서 동시에 접근하여도 문제가 되지 않지만 엔티티 매니저는 여러 스레드에서 동시에 접근하면 동시성 문제가 발생가 때문에 절대 스레드 간 공유를 하면 안 된다.

// 엔티티 매니저 팩토리 생성하기
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpa");
// 엔티티 매니저 생성하기
EntityManager em = emf.createEntityManager();

Entity(엔티티)란?

엔티티(Entity)
데이터베이스의 테이블에 매핑되는 클래스입니다. 일반적으로 @Entity 어노테이션을 사용하여 매핑합니다.

3.2 영속성 컨텍스트란? Persistence context

영속성 컨텍스트(Persistence context) : Entity를 영구 저장하는 환경 (논리적인 저장소)
Persistence context는 엔터티 매니저를 생성할 때 하나 만들어지고 엔터티 매니저를 통해 영속성 컨텍스트에 접근하고 관리한다.

// 엔터티 저장하기
// persist() 메서드는 엔터티를 엔터티매니저를 통해서 영속성 컨텍스트에 저장한다.
em.persist(member);

3.3 엔티티의 생명주기

  • 비영속(new/transient) : 영속성 컨텍스트와 전혀 관계가 없는 상태
    엔티티 객체를 생성만하고 저장하지 않은 상태. (즉, persist()를 호출하지 않은 상태)

  • 영속(managed) : 영속성 컨텍스트에 저장된 상태
    생성한 엔티티 객체를 영속성 컨텍스트에 저장한 상태. (즉, persist()를 호출한 상태)
    em.find() - 조회를 하거나 JPQL을 상요해서 조회한 엔티티도 영속 상태이다.

  • 준영속(detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태
    영속성 컨텍스트가 관리하던 영속 상태의 엔터티를 영속성 컨텍스트에서 관리하지 않게 되면 준영속 상태가 된다.
    em.detach()를 호출하거나 em.close() / em.clear()를 호출해도 준영속 상태가 된다. (영속성 컨텍스트로 관리할 수 없게 된다는 의미이다.)

  • 삭제(removed) : 삭제된 상태
    엔티티를 영속성 컨텍스트와 DB에서 삭제한다.
    em.remove(member);

3.4 영속성 컨텍스트의 특징

영속성 컨텍스트의 식별자

영속성 컨텍스트는 엔티티를 식별자 값으로 인식한다. 이 식별자는 @Id를 사용해서 테이블의 기본 키와 매핑한 속성을 의미하는데 그렇기 때문에 영속 상태는 반드시 식별자 값이 있어야 한다.

1차 캐시

엔티티를 조회할 때 영속성 컨텍스트에 1차 캐시를 통해서 한다.
영속성 컨텍스트는 영속 상태의 엔티티를 1차 캐시에 저장하는데, 엔티티를 조회할 때 영속성 컨텍스트의 1차 캐시에 해당 엔티티가 있는지 찾는다.(이 때, 앞에서 말한 식별자를 통해서 조회를 한다.) 1차 캐시에 해당 엔티티가 없는 경우 DB에 접근하여 조회하는데 이 경우에 존재할 경우 해당 엔티티를 영속 상태의 엔티티로 새로 반환한다.
이를 조금 더 정리해보자.

  • em.find() 엔티티 조회
  • 영속성 컨텍스트의 1차 캐시에서 조회 -> 있으면 반환
  • 1차 캐시에 없는 경우, DB에서 조회 -> DB에 해당 엔티티가 존재하면 1차 캐시에 저장한 후 새롭게 영속상태의 엔티티로 반환
// EntityManager.find() 메서드 정의
// 첫 번째 파라미터 : 엔티티 클래스 타입, 두 번째 파라미터 : 엔티티의 식별자 값
public <T> T find (Class<T> entityClass, Object primaryKey);

영속 상태가 되어 1차 캐시에 저장된 엔티티는 조회할 때 DB에서 조회하지 않기 때문에 성능을 보장한다.

동일성 보장

또한 1차 캐시에 저장된 엔티티를 가져오기 때문에 여러 번 조회를 해도 동일성을 보장해준다. (==)

	Member m1 = em.find(Member.class, "member1");
    Member m2 = em.find(Member.class, "member2");
    
    m1 == m2 // true (동일성 보장)

트랜잭션을 지원하는 쓰기 지연(transactional write-behind)

엔티티를 등록하는 경우 em.persist()를 사용하는데 em.persist()를 사용한다고 바로 DB에 INSERT QUERY를 보내지 않는다. 엔티티 매니저는 쓰기 지연 SQL 저장소에 INSERT QUERY를 모아 둔 후 트랜잭션을 커밋할 때 모아둔 쿼리를 DB에 보내게 되고, 이것을 트랜잭션을 지원하는 쓰기 지연 이라고 한다.
트랜잭션을 커밋하게 되면 엔티티 매니저는 영속성 컨텍스트를 플러시한다. 플러시는 간단하게 설명하면 영속성 컨텍스트와 DB를 동기화 하는 작업인데 쓰기 지연 SQL 저장소에 저장된 QUERY들을 DB에 보내는 작업을 의미한다. 이 후 DB의 트랜잭션을 커밋하며 작업이 마무리된다.
(반드시 엔티티를 변경하려면 트랜잭션을 시작해야 한다.)

변경 감지(dirty checking)

엔티티를 수정(update)할 때, jpa는 별도의 메서드를 사용하지 않는다. 엔티티의 변경사항을 DB에 자동으로 저장하는데 이것을 변경 감지라고 한다.
jpa는 엔티티를 영속성 컨텍스트에 보관할 때, 최초의 엔티티 상태를 복사해서 저장하는데 이것을 스냅샷이라고 한다. 그리고 플러시 시점에 스냅샷과 엔티티를 비교해서 변경사항이 있다면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.

이 과정을 순서대로 풀어 쓰면 아래와 같다.

  • 트랜잭션을 커밋
  • 엔티티 매니저 내부에서 플러시(flush()) 호출
  • 엔티티와 스냅샷을 비교해서 변경된 엔티티 찾기
  • 변경된 엔티티가 있다면 수정 쿼리를 작성해서 쓰기 지연 SQL 저장소에 전달
  • 쓰기 지연 SQL 저장소의 모인 쿼리를 DB에 전달
  • DB 트랜잭션을 커밋

변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용된다.
또한 기본적으로 변경 감지는 엔티티의 모든 필드를 업데이트하는 전략을 취한다. 이런 기본 전략은 수정 쿼리가 항상 같고, 애플리케이션 로딩 시점에 수정 쿼리를 미리 생성해두고 재사용할 수 있다는 장점이 있다.
다만 필드가 30개 이상인 엔티티에서는 org.hibernate.annotiations.DynamicUpdate 어노테이션을 사용해서 수정된 데이터만을 사용하여 동적으로 UPDATE SQL을 생성할 수 있다고 한다.

엔티티 삭제 em.remove()

엔티티를 삭제하려면 먼저 삭제 대상 엔티티를 조회한 후 em.remove()로 전달하여 삭제한다. em.remove()를 사용하면 전달된 엔티티는 바로 영속성 컨텍스트에서 제거된다.
다만 DB에 바로 반영되는 것이 아니라 등록/수정과 마찬가지로 쓰기 지연 SQL 저장소에 삭제 쿼리를 등록한 후 트랜잭션을 커밋할 때 DB에 삭제 쿼리를 전달한다.

Member m1 = em.find(Member.class, "member1");	// 삭제 대상 엔티티 조회
em.remove(m1);	// 엔티티 삭제

플러시 flush

앞서 말했듯이 flush는 영속성 컨텍스트와 DB를 동기화하는 작업이다.
영속성 컨텍스트를 플러시하는 방법으로는 3가지 방법이 있다.

  • em.flush() 직접 호출
  • 트랜잭션 커밋 시 플러시 자동 호출
    트랜잭션을 커밋만 하면 어떤 데이터도 DB에 반영되지 않는다. JPA에서는 이런 문제점을 예방하기 위해 트랜잭션을 커밋하면 자동적으로 플러시를 먼저 호출한다.
  • JPQL 쿼리 실행 시 플러시 자동 호출
    영속성 컨텍스트에만 있는 엔티티를 JPQL 쿼리를 실행해서 사용하면 DB에는 아직 없어서 문제가 될 수 있다. 이를 예방하기 위해 JPA에서는 JPQL을 실행하면 자동적으로 플러시를 호출해 해결한다.

엔티티 매니저에서 javax.persistence.FlushModeType을 사용하여 플러시 모드를 직접 지정할 수 있다.

  • FlushModeType.AUTO : 커밋이나 쿼리를 실행할 때 플러시 (기본값)
  • FlushModeType.COMMIT : 커밋할 때만 플러시 (성능 최적화를 위해 사용할 수 있음)

플러시를 한다고 영속성 컨텍스트에 보관된 엔티티를 지우는 것은 아니다

준영속 detached

영속성 컨텍스트가 관리를 하던 영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 것을 준영속 detached 상태라고 한다. 따라서 준영속 상태는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.

영속 상태의 엔티티를 준영속 상태로 바꾸는 3가지 방법

1. em.detach(entity) : 특정 엔티티만 준영속 상태로 전환한다
영속 상태의 엔티티를 해당 메서드를 통해 호출하면 준영속 상태가 되고, 이는 영속성 컨텍스트의 1차 캐시쓰기 지연 SQL 저장소에서 해당 관련 정보를 모두 제거함을 의미한다.

2. em.clear() : 영속성 컨텍스트를 완전히 초기화 한다.
해당 메서드는 영속성 컨텍스트를 초기화해서 영속성 컨텍스트에서 관리하던 모든 엔티티를 준영속 상태로 만든다. 이것은 영속성 컨텍스트를 제거하고 새로 만든 것과 같다.

3. em.close() : 영속성 컨텍스트를 종료한다
clear()가 영속성 컨텍스트를 초기화하여 1차 캐시, 쓰기 지연 SQL 저장소를 모두 비운다면 close()는 영속성 컨텍스트 자체를 종료하여 관리하던 영속 상태의 엔티티를 모두 준영속 상태로 만든다. (영속성 컨텍스트가 종료되어 1차 캐시, 쓰기 지연 SQL 저장소 자체도 없는 상태)

준영속 상태의 특징

  • 거의 비영속 상태에 가깝다.
    영속성 컨텍스트에서 관리하지 않기 때문에 영속성 컨텍스트에 어떤 기능도 사용할 수 없다.
  • 식별자를 가지고 있다.
    준영속 상태는 앞서 말했듯이 영속 상태의 엔티티를 엔티티 컨텍스트에서 분리하기 때문에 반드시 식별자 값을 가지고 있다.
  • 지연 로딩(LAZY LOADING)을 할 수 없다. (8장에서 추가 설명)
    지연로딩은 실제 객체 대신 프록시 객체를 로딩해두고 해당 객체를 실제 사용할 때 영속성 컨텍스트를 통해 데이터를 불러오는 방법이다. 준영속 상태는 영속성 컨텍스트가 더 이상 관리하지 않기 때문에 지연로딩 시 문제가 발생한다.

병합 : merge()

merge(entity) : 준영속 상태의 엔티티의 식별자를 통해 새로운 영속 상태의 엔티티를 반환하거나 DB에서도 조회할 수 없는 엔티티의 경우 새로운 엔티티를 영속상태로 반환하는 기법.

준영속 상태의 엔티티를 다시 영속 상태로 변경하는 방법 (비영속 병합도 가능하다)

public <T> T merge(T entity);

병합은 파라미터로 넘어온 엔티티 식별자를 가지고 영속성 컨텍스트를 조회하고, 찾는 엔티티가 없으면 DB를 조회한다. 만약 DB에서도 조회할 수 없으면 새로운 엔티티를 생성해서 병합한다.
이 때, 병합은 새로운 영속 상태의 엔티티를 반환하는데 헷갈리면 안 되는 부분은 준영속 상태의 엔티티 부분이다.
영속 상태였다가 준영속 상태였던 엔티티를 다시 영속 상태로 만드는 것이 아니라 새로운 영속 상태의 엔티티로 반환하는 것을 명심해야 한다.
병합은 준영속, 비영속을 신경 쓰지 않는다. 식별자 갓으로 엔티티를 조회할 수 있으면 병합하고 조회할 수 없으면 새로 생성해서 병합한다. 따라서 병합은 save or update 기능을 수행한다.

///
...
EntityManager em1 = emf.createEntityManager();
EntityTransaction tx1 = em1.getRansaction();
tx1.begin();

Member m1 = new Member();
m1.setId("m1");
m1.setUsername("회원1");

em1.persist(m1);
tx1.commit();
em1.close(); // 영속성 컨텍스트1 종료. m1은 준영속상태

m1.setUsername("회원명 변경");

...
// 새로운 엔티티 매니저 생성, 트랜잭션 생성 /시작
EntityManager em2 = emf.createEntityManager();
EntityTransaction tx2 = em2.getRansaction();
tx2.begin();
Member mergeM2 = em2.merge(m1);	//새로운 영속상태의 mergeM2 엔티티 반환
//m1은 여전히 준영속 상태
tx2.commit();

//준영속 상태
System.out.println("m1 = " + m1.getUsername());	//m1 = 회원명변경
//준영속 상태여도 m1 자체의 username이 바뀐것은 당연하다

//영속상태
System.out.println("mergeM2 = " + mergeM2.getUsername(); //mergeM2 = 회원명변경

System.out.println(em2.contains(m1)); //false
System.out.println(em2.contains(mergeM2)); //true

// -> m1 은 여전히 준영속 상태, mergeM2는 영속 상태
  • "m1" 엔티티를 생성 후 em.persist(m1); 으로 영속성 컨텍스트 1차 캐시에 저장한 후 tx1.commit();
    으로 DB에 엔티티를 저장했다. (현재 "m1"을 키값으로 가지고 username은 ("회원1")
  • em1.close()로 영속성 컨텍스트를 종료하여 영속 상태였던 "m1"은 준영속상태가 되었다.
  • 준영속 상태에서 "m1" 엔티티의 userName을 "회원명 변경"으로 바꿔준다.
  • 새로운 엔티티 매니저를 생성하여 영속성 컨텍스트를 생성한다.
  • em2.merge(m1) : 영속성 컨텍스트 1차 캐시에서 조회 -> 존재하지 않음(m1은 준영속 상태이고, 여기서는 다른 영속성 컨텍스트이기 때문에)
    DB에서 조회 -> (id, username) "m1", "회원1"로 존재하지만 mergeM2에는 바뀐 m1의 username값 "회원명 변경"으로 새로운 영속 상태의 엔티티를 반환한다.
  • tx2.commit() 시 DB에는 해당 엔티티가 "회원1"이지만 mergeM2에는 "회원명변경"으로 존재하기에 변경 감지 기능을 통해 변경 내용을 DB에 반영한다.

부록 (flush, transaction, 복합키 매핑, em.find()시 1차 캐시에도 DB에도 엔티티가 존재하지 않는다면?)


영속성 컨텍스트에 새로 저장된 엔터티는 트랜잭션을 커밋할 때 DB에 저장된다는데 flush를 통해서 된다고 한다.
이 부분을 더 자세히 설명해 주고 여기서 말하는 트랜잭션에 대해서도 더 알아보자


1. 영속성 컨텍스트와 flush

영속성 컨텍스트란?

  • JPA가 관리하는 엔티티 객체의 1차 캐시 역할을 합니다.
  • 엔티티를 영속성 컨텍스트에 저장하면, 데이터베이스와 직접 연결되지 않고 메모리에서 관리됩니다.
  • 즉, 데이터베이스의 작업 대기 상태라고 이해할 수 있습니다.

flush란?

  • flush는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 작업입니다.
  • 트랜잭션이 커밋되기 전, JPA는 영속성 컨텍스트의 변경 내용을 자동으로 데이터베이스에 반영합니다.
flush의 동작 원리:
  1. 변경 감지(Dirty Checking)
    • 영속성 컨텍스트 내에서 엔티티의 상태를 감시합니다.
    • 트랜잭션이 끝나기 전, 엔티티의 변경된 상태를 기록합니다.
  2. SQL 생성
    • 변경된 엔티티에 대한 SQL(insert, update, delete) 문을 생성합니다.
  3. SQL 실행
    • 생성된 SQL 문이 데이터베이스로 전달되어 실행됩니다.

flush가 호출되는 시점

  • 트랜잭션이 커밋될 때 자동 호출
  • JPQL 또는 Native Query 실행 시, 쿼리 결과의 일관성을 보장하기 위해 호출
  • entityManager.flush()를 통해 수동 호출 가능

2. 트랜잭션이란?

트랜잭션의 정의

  • 데이터베이스에서 작업의 논리적 단위를 의미합니다.
  • 트랜잭션은 반드시 모두 성공(Commit)하거나 모두 실패(Rollback)해야 합니다.
  • 예를 들어, 은행 계좌 이체를 생각해 보면, 송금자 계좌에서 돈을 빼고 수신자 계좌에 돈을 넣는 두 작업이 하나의 트랜잭션으로 묶여야 합니다. 하나만 성공하면 데이터가 불일치 상태에 빠지기 때문입니다.

JPA와 트랜잭션

  • JPA는 트랜잭션 내에서만 데이터베이스와 상호작용합니다. 트랜잭션이 없으면 JPA는 동작하지 않습니다.
트랜잭션의 주요 단계:
  1. 트랜잭션 시작

    • EntityManager.getTransaction().begin() 호출
    • Spring에서는 @Transactional 어노테이션을 사용하여 트랜잭션을 시작합니다.
  2. 작업 수행

    • persist, merge, remove 같은 엔티티 작업 수행
    • 이 작업은 즉시 데이터베이스에 반영되지 않고 영속성 컨텍스트에 기록됩니다.
  3. flush 호출

    • 트랜잭션이 커밋될 때 JPA가 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영합니다.
  4. 트랜잭션 커밋 또는 롤백

    • Commit: 데이터베이스에 변경 사항이 영구적으로 저장됩니다.
    • Rollback: 변경 사항이 모두 취소되고 데이터베이스 상태가 이전으로 복구됩니다.

예제 코드로 살펴보기

@Transactional
public void saveEntity() {
    // 트랜잭션 시작 (Spring이 자동으로 처리)
    
    // 1. 엔티티 생성
    MyEntity entity = new MyEntity();
    entity.setName("Test");

    // 2. 영속성 컨텍스트에 저장 (아직 DB에는 반영되지 않음)
    entityManager.persist(entity);

    // 3. 트랜잭션 커밋 시 flush 호출
    // 영속성 컨텍스트의 변경 내용을 DB에 반영
    // INSERT INTO MyEntity (name) VALUES ('Test');
}

3. flush와 commit의 차이점

flushcommit
영속성 컨텍스트의 변경 내용을 DB에 반영트랜잭션을 종료하고 변경 내용을 확정
SQL이 생성되고 실행되지만, 트랜잭션은 열려 있음DB에 변경 사항을 영구적으로 저장
영속성 컨텍스트를 비우지 않음트랜잭션을 종료하고 영속성 컨텍스트 정리

핵심 요약

  1. flush는 데이터베이스에 SQL을 실행하는 단계로, 영속성 컨텍스트의 변경 내용을 반영합니다.
  2. 트랜잭션이 commit될 때 flush가 자동으로 호출되며, 변경 사항이 데이터베이스에 영구적으로 저장됩니다.
  3. 트랜잭션은 데이터 작업의 논리적 단위이며, JPA는 트랜잭션 내에서만 데이터베이스 작업을 수행합니다.

-> 내가 한 정리
flush 는 영속성 컨텍스트와 DB를 동기화하는 작업이다. 더 자세히 말하면 flush를 호출하는 순간 영속성 컨텍스트 내부에 스냅샵을 비교 후 다른 점을 dirty checking 하여 쓰기 지연 SQL 저장소에 있는 쿼리를 DB에 전송한다. 그 후 DB 트랜잭션을 커밋한다.

트랜잭션은 DB의 논리적작업 단위를 의미한다. trasaction에서 실패하면 다 롤백되고 성공하면 commit된다.
트랜잭션은 반드시 모두 성공(Commit)하거나 모두 실패(Rollback)해야 합니다.
JPA는 트랜잭션 내에서만 데이터베이스와 상호작용합니다. 트랜잭션이 없으면 JPA는 동작하지 않습니다.

그래서 반드시 트랜잭션을 시작하고 영속성 컨텍스트를 관리한다.

EntityManagerFactory emf = Persistence.createEntityManagerFActory("jpa");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

tx.begin();		// transaction 시작
...
tx.commit();	// transaction 커밋

복합키 매핑 (7장에서 설명)

JPA에서 PK(Primary Key)가 단일 속성이 아닌 복합 키(Composite Key)인 경우, @Id를 여러 개 붙이는 것이 아니라 다른 방식으로 처리해야 합니다. JPA는 복합 키를 지원하기 위해 두 가지 주요 접근 방식을 제공합니다.


1. @IdClass 사용

  • @IdClass는 별도의 클래스를 만들어 복합 키를 정의하고, 엔티티에 이 클래스를 사용하여 복합 키를 매핑하는 방법입니다.

사용법:

  1. 복합 키 클래스 생성

    • 복합 키를 정의하는 별도의 클래스는 반드시 Serializable을 구현해야 합니다.
    • equals()와 hashCode() 메서드를 재정의해야 합니다.
  2. 엔티티에 @IdClass 적용

    • 엔티티 클래스에서 @Id를 각 필드에 지정하고, @IdClass로 복합 키 클래스를 연결합니다.

예제 코드:

import java.io.Serializable;
import java.util.Objects;

// 복합 키 클래스
public class OrderId implements Serializable {
    private Long orderId;
    private Long productId;

    // 기본 생성자
    public OrderId() {}

    // 생성자
    public OrderId(Long orderId, Long productId) {
        this.orderId = orderId;
        this.productId = productId;
    }

    // equals와 hashCode 재정의
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderId orderId1 = (OrderId) o;
        return Objects.equals(orderId, orderId1.orderId) &&
               Objects.equals(productId, orderId1.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, productId);
    }
}
import jakarta.persistence.*;

@Entity
@IdClass(OrderId.class) // 복합 키 클래스 설정
public class Order {
    @Id
    private Long orderId;

    @Id
    private Long productId;

    private int quantity;

    // Getter, Setter 생략
}

2. @EmbeddedId 사용

  • @EmbeddedId는 복합 키를 포함하는 내장(Embeddable) 클래스에 매핑하는 방식입니다.
  • 복합 키 클래스를 엔티티 내부에서 임베드(Embed)하여 사용하는 방식입니다.

사용법:

  1. 복합 키 클래스 생성

    • 복합 키 클래스를 생성할 때 @Embeddable 어노테이션을 붙입니다.
    • 클래스 내부 필드에 복합 키가 될 속성을 정의합니다.
  2. 엔티티에 @EmbeddedId 적용

    • 엔티티에 @EmbeddedId를 사용하여 복합 키 클래스를 참조합니다.

예제 코드:

import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class OrderId implements Serializable {
    private Long orderId;
    private Long productId;

    // 기본 생성자
    public OrderId() {}

    public OrderId(Long orderId, Long productId) {
        this.orderId = orderId;
        this.productId = productId;
    }

    // equals와 hashCode 재정의
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderId orderId1 = (OrderId) o;
        return Objects.equals(orderId, orderId1.orderId) &&
               Objects.equals(productId, orderId1.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, productId);
    }
}
import jakarta.persistence.*;

@Entity
public class Order {
    @EmbeddedId
    private OrderId id; // 복합 키

    private int quantity;

    // Getter, Setter 생략
}

@IdClass vs @EmbeddedId

비교 항목@IdClass@EmbeddedId
키 정의 위치별도의 외부 클래스엔티티 내부에 내장된 키 클래스
가독성더 많은 코드 작성이 필요간결하고 직관적
호환성기존의 데이터베이스 스키마와 잘 맞음새로운 설계에 적합
사용 용도키 클래스가 엔티티와 명확히 분리되어야 할 때엔티티와 키가 강하게 결합될 때

선택 기준

  • 기존 데이터베이스 설계가 복잡한 경우: @IdClass가 더 적합.
  • 새로운 프로젝트에서 간결한 코드와 설계를 선호: @EmbeddedId를 권장.

요약

  • 단일 키: @Id만 사용.
  • 복합 키: @IdClass 또는 @EmbeddedId 중 하나를 사용.
  • 일반적으로 설계가 단순하다면 @EmbeddedId를 사용하는 것이 더 직관적이고 관리하기 쉽습니다.

em.find() : 1차 캐시에도 DB에도 없다면?

1차 캐시에도 DB에도 찾는 엔티티가 없다면 null로 초기화됩니다.

그러면 엔티티를 조회하고 엔티티의 속성을 가져와 쓴다고 하면 항상 엔티티가 null인지 아닌지 확인하는 로직을 추가해야 하네?

네, 정확합니다! JPA에서 엔티티 조회 후 그 속성을 사용하려면 항상 null 여부를 확인하는 것이 안전한 코드 작성의 기본입니다. 데이터베이스나 애플리케이션 로직에서 존재하지 않는 데이터를 조회하는 경우, JPA는 null을 반환하므로 이를 처리하지 않으면 NullPointerException(NPE) 같은 런타임 오류가 발생할 수 있습니다.


1. null 확인이 필요한 이유

  • em.find()는 조회한 엔티티가 1차 캐시나 데이터베이스에 없을 경우 null을 반환합니다.
  • null 상태를 확인하지 않고 속성에 접근하면 NPE가 발생할 가능성이 있습니다.

예제:

Member member = entityManager.find(Member.class, 1L);
System.out.println(member.getName()); // member가 null이면 NPE 발생!

2. 안전한 코드 작성 방법

(1) null 체크를 추가하는 방법

  • null 여부를 확인한 뒤 속성에 접근하도록 작성합니다.
Member member = entityManager.find(Member.class, 1L);
if (member != null) {
    System.out.println(member.getName());
} else {
    System.out.println("Member not found!");
}

(2) Optional로 래핑하여 처리

  • Java 8 이상에서는 Optional을 사용하여 더 간결하게 처리할 수 있습니다.
Optional<Member> optionalMember = Optional.ofNullable(entityManager.find(Member.class, 1L));
optionalMember.ifPresentOrElse(
    m -> System.out.println(m.getName()),
    () -> System.out.println("Member not found!")
);

3. null 체크를 피할 수 있는 상황

(1) 비즈니스 로직상 엔티티가 항상 존재하는 경우

  • 예를 들어, 식별자로 조회했을 때 엔티티가 반드시 존재해야 하는 상황이라면, find() 대신 getReference()를 사용하여 LazyInitializationException으로 처리하거나, 별도의 예외를 던지도록 코드를 작성합니다.

예제: 예외 처리

Member member = entityManager.find(Member.class, 1L);
if (member == null) {
    throw new EntityNotFoundException("Member with ID 1L not found");
}
System.out.println(member.getName());

(2) QueryDSL 또는 JPQL로 존재 여부를 먼저 확인

  • 조회 전에 count 쿼리 등을 사용하여 해당 엔티티가 존재하는지 미리 확인합니다.
boolean exists = entityManager
    .createQuery("SELECT COUNT(m) > 0 FROM Member m WHERE m.id = :id", Boolean.class)
    .setParameter("id", 1L)
    .getSingleResult();

if (exists) {
    Member member = entityManager.find(Member.class, 1L);
    System.out.println(member.getName());
} else {
    System.out.println("Member not found!");
}

4. 언제 null 체크가 필요하지 않은가?

  • 연관 관계를 통해 접근할 때 (지연 로딩):
    • 연관된 엔티티가 존재하지 않을 경우, 프록시 객체는 동작하지만 속성을 호출하면 LazyInitializationException이 발생할 수 있으므로 주의가 필요합니다.
  • 애플리케이션 로직에서 특정 엔티티가 항상 존재한다고 보장되는 경우에는 별도의 null 확인 로직을 생략할 수 있습니다.

5. 코드 작성 팁

단순한 엔티티 조회라면:

Member member = entityManager.find(Member.class, 1L);
if (member == null) {
    System.out.println("Member not found!");
} else {
    System.out.println(member.getName());
}

Null-safe 접근이 필요하다면:

Optional.ofNullable(entityManager.find(Member.class, 1L))
    .ifPresentOrElse(
        m -> System.out.println(m.getName()),
        () -> System.out.println("Member not found!")
    );

엔티티가 반드시 있어야 한다면:

Member member = Optional.ofNullable(entityManager.find(Member.class, 1L))
    .orElseThrow(() -> new EntityNotFoundException("Member not found!"));
System.out.println(member.getName());

결론:

  • 조회한 엔티티를 사용할 때는 null 체크가 기본입니다.
  • null 처리를 간소화하려면 Optional을 활용하거나, 예외를 던지는 로직을 추가하여 안전한 코드 작성을 지향하세요.

항상 모든 로직에 엔티티가 null인지 확인하는 로직을 구현하지 않고 JPA에서 방법을 제공할 것 같은데?

맞아요! JPA에서는 이런 조회 실패 시 처리를 편하게 하기 위해 여러 기능과 패턴을 제공해요. 개발자가 매번 null을 확인하지 않도록 도와주는 방법들이 있습니다. 아래에서 하나씩 살펴볼게요.


1. getReference() (지연 로딩을 통한 프록시 활용)

JPA에서 em.getReference()를 사용하면, 실제 엔티티를 즉시 조회하지 않고 프록시 객체를 반환합니다.
이 방식은 엔티티가 반드시 존재해야 하는 경우에 유용합니다.

특징:

  • 존재하지 않는 엔티티에 접근하면 예외 발생:
    • 데이터베이스에 엔티티가 없을 경우, 해당 프록시 객체에 접근하면 EntityNotFoundException을 던집니다.
  • 필요할 때만 데이터베이스와 통신하므로 성능 최적화가 가능.

예제:

Member member = entityManager.getReference(Member.class, 1L); // 프록시 객체 반환
System.out.println(member.getName()); // 실제 엔티티에 접근 시 SELECT 실행

// 만약 엔티티가 존재하지 않으면 EntityNotFoundException 발생

장점:
예외를 던져 바로 문제를 인식할 수 있음.
단점:
엔티티가 없을 때 예외를 발생시키는 것이 필요하지 않은 경우에는 사용이 부적합.


2. JPA 쿼리 방식으로 조회

JPA에서 JPQL이나 QueryDSL을 활용해 조회 로직을 커스터마이징할 수 있습니다.
이를 통해 엔티티가 없을 때 예외 처리 또는 대체 데이터를 반환하도록 처리 가능합니다.

(1) JPQL 활용

Member member = entityManager
    .createQuery("SELECT m FROM Member m WHERE m.id = :id", Member.class)
    .setParameter("id", 1L)
    .getResultStream()
    .findFirst() // Optional로 감쌈
    .orElseThrow(() -> new EntityNotFoundException("Member not found!"));

(2) QueryDSL 활용

Optional<Member> optionalMember = queryFactory
    .selectFrom(QMember.member)
    .where(QMember.member.id.eq(1L))
    .fetchOneOptional(); // Optional로 반환
optionalMember.ifPresentOrElse(
    m -> System.out.println(m.getName()),
    () -> System.out.println("Member not found!")
);

3. Spring Data JPA 활용

Spring Data JPA를 사용하면 기본적으로 제공되는 findById 메서드가 Optional을 반환하므로 null 처리를 간소화할 수 있습니다.

예제:

Optional<Member> optionalMember = memberRepository.findById(1L);
optionalMember.ifPresentOrElse(
    member -> System.out.println(member.getName()),
    () -> System.out.println("Member not found!")
);
  • 엔티티가 없으면 Optional.empty()를 반환하므로, null 여부를 일일이 확인할 필요가 없습니다.

4. @NotFound로 연관 엔티티 처리

JPA에서 연관 관계에 대해 @NotFound(action = NotFoundAction.IGNORE)를 사용하면, 특정 연관 엔티티가 없을 경우 자동으로 null을 설정합니다.

예제:

@Entity
public class Order {
    @ManyToOne
    @NotFound(action = NotFoundAction.IGNORE) // 연관된 Member가 없으면 null 처리
    private Member member;
}
  • 이 설정은 연관 엔티티가 없는 경우에만 적용됩니다.
  • 기본 엔티티 조회에는 영향을 주지 않습니다.

5. 예외를 기본 처리로 사용하는 ExceptionMapper

Spring이나 다른 프레임워크에서 커스텀 예외 처리를 통해 조회 실패를 통합적으로 관리할 수도 있습니다.

방법:

  1. 엔티티 조회 실패 시 예외 처리 추가:
    • JPA에서 반환된 값이 null인 경우 예외를 던지도록 처리.
  2. 글로벌 예외 처리:
    • @ControllerAdvice를 활용하여 특정 예외 발생 시 사용자에게 적절한 메시지를 반환.

예제:

Member member = entityManager.find(Member.class, 1L);
if (member == null) {
    throw new EntityNotFoundException("Member with ID 1L not found");
}

6. 조회 실패를 방지하는 리포지토리 전략

Spring Data JPA의 도메인 이벤트 리스너를 활용해 기본적으로 엔티티가 존재하지 않을 때 예외를 처리하는 로직을 추가할 수 있습니다.


정리: JPA의 조회 실패 처리를 위한 주요 기능

  1. getReference(): 존재하지 않는 엔티티에 접근 시 예외를 던짐.
  2. JPQL/QueryDSL: Optional을 활용해 유연한 처리.
  3. Spring Data JPA: findById()Optional 반환.
  4. @NotFound: 연관 엔티티에 대한 처리.
  5. 글로벌 예외 처리: 통합된 방식으로 조회 실패 관리.

결론:
JPA는 다양한 방식으로 조회 실패 상황을 처리하도록 도와줍니다.
일일이 null을 확인하지 않으려면 Optional 활용, 예외 처리, 또는 프록시를 통한 동작 방식을 적절히 조합해 사용하세요.

profile
코드 위에서 춤추고 싶어요

0개의 댓글