엔티티를 저장하고 수정하고 삭제하는 등 엔티티와 관련된 일을 모두 관리하는 일종의 엔티티를 저장하는 가상의 데이터베이스로 생각하면 된다.
엔티티 매니저 팩토리란 엔티티 매니저를 생성하는 말 그대로의 공장인데, 엔티티 매니저 공장은 하나를 생성하는데 비용이 많이 들지만 생성한 공장에서 엔티티 매니저를 생성하는 것은 비용이 낮기 때문에 보통 엔티티 매니저 공장을 하나만 생성하고 생성한 공장에서 엔티티 매니저를 여러개 생성하여 사용한다.
또한 엔티티 매니저 팩토리는 여러 스레드에서 동시에 접근하여도 문제가 되지 않지만 엔티티 매니저는 여러 스레드에서 동시에 접근하면 동시성 문제가 발생가 때문에 절대 스레드 간 공유를 하면 안 된다.
// 엔티티 매니저 팩토리 생성하기
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpa");
// 엔티티 매니저 생성하기
EntityManager em = emf.createEntityManager();
Entity(엔티티)란?
엔티티(Entity)
데이터베이스의 테이블에 매핑되는 클래스입니다. 일반적으로 @Entity 어노테이션을 사용하여 매핑합니다.
영속성 컨텍스트(Persistence context) : Entity를 영구 저장하는 환경 (논리적인 저장소)
Persistence context는 엔터티 매니저를 생성할 때 하나 만들어지고 엔터티 매니저를 통해 영속성 컨텍스트에 접근하고 관리한다.
// 엔터티 저장하기
// persist() 메서드는 엔터티를 엔터티매니저를 통해서 영속성 컨텍스트에 저장한다.
em.persist(member);

비영속(new/transient) : 영속성 컨텍스트와 전혀 관계가 없는 상태
엔티티 객체를 생성만하고 저장하지 않은 상태. (즉, persist()를 호출하지 않은 상태)
영속(managed) : 영속성 컨텍스트에 저장된 상태
생성한 엔티티 객체를 영속성 컨텍스트에 저장한 상태. (즉, persist()를 호출한 상태)
em.find() - 조회를 하거나 JPQL을 상요해서 조회한 엔티티도 영속 상태이다.
준영속(detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태
영속성 컨텍스트가 관리하던 영속 상태의 엔터티를 영속성 컨텍스트에서 관리하지 않게 되면 준영속 상태가 된다.
em.detach()를 호출하거나 em.close() / em.clear()를 호출해도 준영속 상태가 된다. (영속성 컨텍스트로 관리할 수 없게 된다는 의미이다.)
삭제(removed) : 삭제된 상태
엔티티를 영속성 컨텍스트와 DB에서 삭제한다.
em.remove(member);
영속성 컨텍스트는 엔티티를 식별자 값으로 인식한다. 이 식별자는 @Id를 사용해서 테이블의 기본 키와 매핑한 속성을 의미하는데 그렇기 때문에 영속 상태는 반드시 식별자 값이 있어야 한다.
엔티티를 조회할 때 영속성 컨텍스트에 1차 캐시를 통해서 한다.
영속성 컨텍스트는 영속 상태의 엔티티를 1차 캐시에 저장하는데, 엔티티를 조회할 때 영속성 컨텍스트의 1차 캐시에 해당 엔티티가 있는지 찾는다.(이 때, 앞에서 말한 식별자를 통해서 조회를 한다.) 1차 캐시에 해당 엔티티가 없는 경우 DB에 접근하여 조회하는데 이 경우에 존재할 경우 해당 엔티티를 영속 상태의 엔티티로 새로 반환한다.
이를 조금 더 정리해보자.
// 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 (동일성 보장)
엔티티를 등록하는 경우 em.persist()를 사용하는데 em.persist()를 사용한다고 바로 DB에 INSERT QUERY를 보내지 않는다. 엔티티 매니저는 쓰기 지연 SQL 저장소에 INSERT QUERY를 모아 둔 후 트랜잭션을 커밋할 때 모아둔 쿼리를 DB에 보내게 되고, 이것을 트랜잭션을 지원하는 쓰기 지연 이라고 한다.
트랜잭션을 커밋하게 되면 엔티티 매니저는 영속성 컨텍스트를 플러시한다. 플러시는 간단하게 설명하면 영속성 컨텍스트와 DB를 동기화 하는 작업인데 쓰기 지연 SQL 저장소에 저장된 QUERY들을 DB에 보내는 작업을 의미한다. 이 후 DB의 트랜잭션을 커밋하며 작업이 마무리된다.
(반드시 엔티티를 변경하려면 트랜잭션을 시작해야 한다.)
엔티티를 수정(update)할 때, jpa는 별도의 메서드를 사용하지 않는다. 엔티티의 변경사항을 DB에 자동으로 저장하는데 이것을 변경 감지라고 한다.
jpa는 엔티티를 영속성 컨텍스트에 보관할 때, 최초의 엔티티 상태를 복사해서 저장하는데 이것을 스냅샷이라고 한다. 그리고 플러시 시점에 스냅샷과 엔티티를 비교해서 변경사항이 있다면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.
이 과정을 순서대로 풀어 쓰면 아래와 같다.
변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용된다.
또한 기본적으로 변경 감지는 엔티티의 모든 필드를 업데이트하는 전략을 취한다. 이런 기본 전략은 수정 쿼리가 항상 같고, 애플리케이션 로딩 시점에 수정 쿼리를 미리 생성해두고 재사용할 수 있다는 장점이 있다.
다만 필드가 30개 이상인 엔티티에서는 org.hibernate.annotiations.DynamicUpdate 어노테이션을 사용해서 수정된 데이터만을 사용하여 동적으로 UPDATE SQL을 생성할 수 있다고 한다.
엔티티를 삭제하려면 먼저 삭제 대상 엔티티를 조회한 후 em.remove()로 전달하여 삭제한다. em.remove()를 사용하면 전달된 엔티티는 바로 영속성 컨텍스트에서 제거된다.
다만 DB에 바로 반영되는 것이 아니라 등록/수정과 마찬가지로 쓰기 지연 SQL 저장소에 삭제 쿼리를 등록한 후 트랜잭션을 커밋할 때 DB에 삭제 쿼리를 전달한다.
Member m1 = em.find(Member.class, "member1"); // 삭제 대상 엔티티 조회
em.remove(m1); // 엔티티 삭제
앞서 말했듯이 flush는 영속성 컨텍스트와 DB를 동기화하는 작업이다.
영속성 컨텍스트를 플러시하는 방법으로는 3가지 방법이 있다.
엔티티 매니저에서 javax.persistence.FlushModeType을 사용하여 플러시 모드를 직접 지정할 수 있다.
플러시를 한다고 영속성 컨텍스트에 보관된 엔티티를 지우는 것은 아니다
영속성 컨텍스트가 관리를 하던 영속 상태의 엔티티가 영속성 컨텍스트에서 분리된 것을 준영속 detached 상태라고 한다. 따라서 준영속 상태는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.
1. em.detach(entity) : 특정 엔티티만 준영속 상태로 전환한다
영속 상태의 엔티티를 해당 메서드를 통해 호출하면 준영속 상태가 되고, 이는 영속성 컨텍스트의 1차 캐시와 쓰기 지연 SQL 저장소에서 해당 관련 정보를 모두 제거함을 의미한다.
2. em.clear() : 영속성 컨텍스트를 완전히 초기화 한다.
해당 메서드는 영속성 컨텍스트를 초기화해서 영속성 컨텍스트에서 관리하던 모든 엔티티를 준영속 상태로 만든다. 이것은 영속성 컨텍스트를 제거하고 새로 만든 것과 같다.
3. em.close() : 영속성 컨텍스트를 종료한다
clear()가 영속성 컨텍스트를 초기화하여 1차 캐시, 쓰기 지연 SQL 저장소를 모두 비운다면 close()는 영속성 컨텍스트 자체를 종료하여 관리하던 영속 상태의 엔티티를 모두 준영속 상태로 만든다. (영속성 컨텍스트가 종료되어 1차 캐시, 쓰기 지연 SQL 저장소 자체도 없는 상태)
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는 영속 상태
영속성 컨텍스트에 새로 저장된 엔터티는 트랜잭션을 커밋할 때 DB에 저장된다는데 flush를 통해서 된다고 한다.
이 부분을 더 자세히 설명해 주고 여기서 말하는 트랜잭션에 대해서도 더 알아보자
entityManager.flush()를 통해 수동 호출 가능트랜잭션 시작
EntityManager.getTransaction().begin() 호출@Transactional 어노테이션을 사용하여 트랜잭션을 시작합니다.작업 수행
persist, merge, remove 같은 엔티티 작업 수행flush 호출
트랜잭션 커밋 또는 롤백
@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');
}
| flush | commit |
|---|---|
| 영속성 컨텍스트의 변경 내용을 DB에 반영 | 트랜잭션을 종료하고 변경 내용을 확정 |
| SQL이 생성되고 실행되지만, 트랜잭션은 열려 있음 | DB에 변경 사항을 영구적으로 저장 |
| 영속성 컨텍스트를 비우지 않음 | 트랜잭션을 종료하고 영속성 컨텍스트 정리 |
-> 내가 한 정리
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 커밋
JPA에서 PK(Primary Key)가 단일 속성이 아닌 복합 키(Composite Key)인 경우, @Id를 여러 개 붙이는 것이 아니라 다른 방식으로 처리해야 합니다. JPA는 복합 키를 지원하기 위해 두 가지 주요 접근 방식을 제공합니다.
@IdClass는 별도의 클래스를 만들어 복합 키를 정의하고, 엔티티에 이 클래스를 사용하여 복합 키를 매핑하는 방법입니다.복합 키 클래스 생성
Serializable을 구현해야 합니다.엔티티에 @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 생략
}
@EmbeddedId는 복합 키를 포함하는 내장(Embeddable) 클래스에 매핑하는 방식입니다.복합 키 클래스 생성
@Embeddable 어노테이션을 붙입니다.엔티티에 @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 | @EmbeddedId |
|---|---|---|
| 키 정의 위치 | 별도의 외부 클래스 | 엔티티 내부에 내장된 키 클래스 |
| 가독성 | 더 많은 코드 작성이 필요 | 간결하고 직관적 |
| 호환성 | 기존의 데이터베이스 스키마와 잘 맞음 | 새로운 설계에 적합 |
| 사용 용도 | 키 클래스가 엔티티와 명확히 분리되어야 할 때 | 엔티티와 키가 강하게 결합될 때 |
@IdClass가 더 적합.@EmbeddedId를 권장.@Id만 사용.@IdClass 또는 @EmbeddedId 중 하나를 사용.@EmbeddedId를 사용하는 것이 더 직관적이고 관리하기 쉽습니다.1차 캐시에도 DB에도 찾는 엔티티가 없다면 null로 초기화됩니다.
그러면 엔티티를 조회하고 엔티티의 속성을 가져와 쓴다고 하면 항상 엔티티가 null인지 아닌지 확인하는 로직을 추가해야 하네?
네, 정확합니다! JPA에서 엔티티 조회 후 그 속성을 사용하려면 항상 null 여부를 확인하는 것이 안전한 코드 작성의 기본입니다. 데이터베이스나 애플리케이션 로직에서 존재하지 않는 데이터를 조회하는 경우, JPA는 null을 반환하므로 이를 처리하지 않으면 NullPointerException(NPE) 같은 런타임 오류가 발생할 수 있습니다.
null 확인이 필요한 이유em.find()는 조회한 엔티티가 1차 캐시나 데이터베이스에 없을 경우 null을 반환합니다.null 상태를 확인하지 않고 속성에 접근하면 NPE가 발생할 가능성이 있습니다.Member member = entityManager.find(Member.class, 1L);
System.out.println(member.getName()); // member가 null이면 NPE 발생!
null 체크를 추가하는 방법null 여부를 확인한 뒤 속성에 접근하도록 작성합니다.Member member = entityManager.find(Member.class, 1L);
if (member != null) {
System.out.println(member.getName());
} else {
System.out.println("Member not found!");
}
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!")
);
null 체크를 피할 수 있는 상황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());
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!");
}
null 확인 로직을 생략할 수 있습니다.Member member = entityManager.find(Member.class, 1L);
if (member == null) {
System.out.println("Member not found!");
} else {
System.out.println(member.getName());
}
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을 확인하지 않도록 도와주는 방법들이 있습니다. 아래에서 하나씩 살펴볼게요.
getReference() (지연 로딩을 통한 프록시 활용)JPA에서 em.getReference()를 사용하면, 실제 엔티티를 즉시 조회하지 않고 프록시 객체를 반환합니다.
이 방식은 엔티티가 반드시 존재해야 하는 경우에 유용합니다.
EntityNotFoundException을 던집니다.Member member = entityManager.getReference(Member.class, 1L); // 프록시 객체 반환
System.out.println(member.getName()); // 실제 엔티티에 접근 시 SELECT 실행
// 만약 엔티티가 존재하지 않으면 EntityNotFoundException 발생
장점:
예외를 던져 바로 문제를 인식할 수 있음.
단점:
엔티티가 없을 때 예외를 발생시키는 것이 필요하지 않은 경우에는 사용이 부적합.
JPA에서 JPQL이나 QueryDSL을 활용해 조회 로직을 커스터마이징할 수 있습니다.
이를 통해 엔티티가 없을 때 예외 처리 또는 대체 데이터를 반환하도록 처리 가능합니다.
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!"));
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!")
);
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 여부를 일일이 확인할 필요가 없습니다.@NotFound로 연관 엔티티 처리JPA에서 연관 관계에 대해 @NotFound(action = NotFoundAction.IGNORE)를 사용하면, 특정 연관 엔티티가 없을 경우 자동으로 null을 설정합니다.
@Entity
public class Order {
@ManyToOne
@NotFound(action = NotFoundAction.IGNORE) // 연관된 Member가 없으면 null 처리
private Member member;
}
ExceptionMapperSpring이나 다른 프레임워크에서 커스텀 예외 처리를 통해 조회 실패를 통합적으로 관리할 수도 있습니다.
null인 경우 예외를 던지도록 처리.@ControllerAdvice를 활용하여 특정 예외 발생 시 사용자에게 적절한 메시지를 반환.Member member = entityManager.find(Member.class, 1L);
if (member == null) {
throw new EntityNotFoundException("Member with ID 1L not found");
}
Spring Data JPA의 도메인 이벤트 리스너를 활용해 기본적으로 엔티티가 존재하지 않을 때 예외를 처리하는 로직을 추가할 수 있습니다.
getReference(): 존재하지 않는 엔티티에 접근 시 예외를 던짐.Optional을 활용해 유연한 처리.findById()로 Optional 반환.결론:
JPA는 다양한 방식으로 조회 실패 상황을 처리하도록 도와줍니다.
일일이 null을 확인하지 않으려면 Optional 활용, 예외 처리, 또는 프록시를 통한 동작 방식을 적절히 조합해 사용하세요.