JPA를 처음 배우면서 헷갈렸던 개념들을 정리했습니다.
기본 키 생성 전략, Flush, 준영속 상태, JPA Auditing, JpaRepository 동작 원리까지 다룹니다.
엔티티를 만들 때마다 항상 기본 키 설정이 필요하다.
PK를 할당하는 방법은 크게 2가지다.
@GeneratedValue@GeneratedValue에는 4가지 옵션이 존재한다.
기본 키 생성을 데이터베이스에 위임하는 전략이다. (MySQL의 AUTO_INCREMENT)
특징: 쓰기 지연이 동작하지 않는다.
영속성 컨텍스트의 1차 캐시는 key-value 구조로 관리되는데, key를 만들기 위해 ID 값이 필요하다.
AUTO_INCREMENT는 DB에 INSERT 쿼리를 날려야 ID가 생성되므로, em.persist() 호출 즉시 INSERT SQL을 DB에 보낸다.
em.persist(member); // 즉시 INSERT 실행 → ID 반환 → 1차 캐시에 저장
DB의 시퀀스 오브젝트를 사용하는 전략이다. (Oracle, PostgreSQL, H2)
특징: 쓰기 지연이 가능하다.
persist() 시점에 시퀀스 값만 먼저 조회해서 ID를 확보한 뒤, 실제 INSERT는 커밋 시점에 실행된다.
@SequenceGenerator(
name = "MEMBER_SEQ_GENERATOR",
sequenceName = "MEMBER_SEQ",
initialValue = 1,
allocationSize = 50 // 성능 최적화 포인트
)
public class SequenceUser {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
private Long userId;
}
allocationSize로 성능 최적화
allocationSize = 50으로 설정하면, 시퀀스 조회 쿼리(call next value)가 50번 저장에 단 2번만 발생한다.
1 → 현재 시퀀스 값, 첫 번째 ID로 사용51 → 다음 범위의 시작값 (범위 끝 확인용)Hibernate는 이 두 값을 바탕으로 1~50 범위를 메모리에 캐시해서, 2~50 사이의 ID는 DB 호출 없이 바로 꺼내 쓴다.
⚠️ JPA(
allocationSize = 50)와 DB(INCREMENT BY 1)의 증가값이 다르면 PK 중복 에러가 발생할 수 있다. 반드시 통일시키자.
키 생성 전용 테이블을 만들어서 시퀀스를 흉내 내는 전략이다.
모든 DB에서 사용 가능하지만, SELECT + UPDATE + 락으로 인해 성능이 좋지 않다.
DB 마이그레이션 과도기나 여러 DB를 지원해야 하는 경우에 사용한다.
DB 방언에 따라 IDENTITY, SEQUENCE, TABLE 중 하나를 자동으로 선택한다.
운영 환경에서는 가급적 명시적인 전략을 지정하는 것이 좋다.
| 전략 | 쓰기 지연 | 특징 |
|---|---|---|
| 직접 할당 | 가능 | 개발자가 ID 중복 직접 관리 |
IDENTITY | 어려움 | persist() 즉시 INSERT, Batch 불리 |
SEQUENCE | 가능 | allocationSize로 성능 최적화 가능 |
TABLE | 가능 | 모든 DB 호환, 성능 나쁨 |
AUTO | 전략에 따라 다름 | 명시적 지정 권장 |
MySQL이 스타트업·웹 서비스의 사실상 표준 DB이고, MySQL은 SEQUENCE를 지원하지 않는다.
또한 INSERT는 대부분 단건으로 일어나므로 IDENTITY와 SEQUENCE의 성능 차이가 거의 없다.
대용량 삽입이 필요한 경우에만 JdbcTemplate이나 MyBatis를 사용하면 된다.
Flush는 영속성 컨텍스트의 변경 내용을 DB에 반영하는 작업이다.
쓰기 지연 저장소에 쌓아둔 SQL들을 DB로 한꺼번에 보내는 과정이다.
1. em.flush() 직접 호출 (수동)
실무에서는 거의 사용하지 않는다.
2. 트랜잭션 커밋 시 자동 호출
tx.commit()을 호출하면 JPA가 자동으로 flush()를 먼저 실행한 뒤 커밋한다.
3. JPQL 쿼리 실행 시 자동 호출 ⭐
이 메커니즘이 없으면 데이터 불일치 문제가 발생한다.
em.persist(memberA); // 1차 캐시에만 저장 (INSERT 대기 중)
em.persist(memberB);
em.persist(memberC);
// JPQL 실행 직전에 자동 Flush 발동!
// persist한 데이터가 DB에 반영되어야 조회 결과에 포함될 수 있다.
List<Member> result = em.createQuery("select m from Member m", Member.class)
.getResultList();
// → 결과: 3명 조회됨
절대 지워지지 않는다.
Flush 동작 순서는 다음과 같다.
1차 캐시를 비우지 않는 이유는 JPA의 핵심 철학인 영속성과 동일성 때문이다.
a == b가 항상 참이어야 한다.| 메서드 | 역할 |
|---|---|
flush() | 쓰기 지연 저장소 → DB 동기화. 1차 캐시는 유지 |
clear() | 영속성 컨텍스트(1차 캐시) 전체 비우기 |
특수한 상황이 아니라면 AUTO를 사용하자.
1. 비영속
new로 객체를 생성한 상태. 영속성 컨텍스트와 전혀 관계가 없다.
2. 영속
영속성 컨텍스트에 저장되었거나, DB에서 조회된 상태다.
em.persist(member); // 저장
em.find(Member.class, 1L); // 조회
1차 캐시에 들어가고, Dirty Checking의 대상이 된다. 반드시 ID 값을 가지고 있다.
3. 준영속 ⚠️
한때는 영속 상태였지만, 영속성 컨텍스트에서 분리된 상태다.
데이터는 있는데 수정이 안 되는 좀비 같은 상태로, 가장 골치 아픈 상태다.
ID 값은 있지만 JPA가 관리하지 않으므로 값을 바꿔도 DB에 반영되지 않는다.
em.detach(member); // 준영속으로 전환
4. 삭제
삭제 마킹을 해둔 상태. flush() 시점에 DELETE 쿼리가 실행된다.
em.remove(member);
em.contains(member)로 영속 상태인지 확인할 수 있다.
merge는 죽은 객체를 되살리는 게 아니라, 모든 필드를 덮어쓰는 복제 기술이다.
동작 원리
SELECT)merge(member) 호출
→ SELECT 실행
→ 1차 캐시에 저장 {"name": "OriginalName", "grade": "VIP"}
→ 스냅샷에 저장 {"name": "OriginalName", "grade": "VIP"} ← 기준점
→ 1차 캐시를 준영속 객체 값으로 덮어씌움
1차 캐시: {"name": "UpdatedName", "grade": "VIP"}
스냅샷: {"name": "OriginalName", "grade": "VIP"} ← 그대로
커밋 시점
→ Dirty Checking → 다름!
→ UPDATE 쿼리 생성 & DB 전송
⚠️ merge의 치명적 함정: null 덮어쓰기
merge는 모든 필드를 덮어쓰기 때문에, 비워진 값이 있다면 null이 DB에 그대로 반영된다.
이름만 바꾸려고 객체 생성
detachedMember.setId(id);
detachedMember.setName("UserB");
// grade는 null 상태!
→ merge() 수행
→ 1차 캐시: {"name": "UserB", "grade": null}
→ UPDATE name = 'UserB', grade = null
→ ☠️ VIP 등급이 증발!
결론: 특수한 상황이 아니라면 merge는 사용하지 말고, Dirty Checking을 활용하자.
영속 상태의 객체를 직접 수정하면 변경된 필드만 선택적으로 UPDATE되므로 항상 안전하다.
엔티티가 생성/수정될 때 시간을 자동으로 기록해주는 기능이다.
매번 createdAt = LocalDateTime.now()를 직접 쓰지 않아도 자동으로 처리된다.
| 어노테이션 | 동작 시점 | 이후 변경 여부 |
|---|---|---|
@CreatedDate | 최초 INSERT 시 | 변경 안 됨 |
@LastModifiedDate | INSERT + UPDATE 시 | 수정될 때마다 갱신 |
JPA는 엔티티 생명주기마다 이벤트 훅을 제공한다.
| 훅 | 실행 시점 |
|---|---|
@PrePersist | persist() 직전 |
@PostPersist | persist() 직후 |
@PreUpdate | update 직전 |
@PostUpdate | update 직후 |
@PreRemove | remove() 직전 |
@PostRemove | remove() 직후 |
AuditingEntityListener는 Spring Data JPA가 이미 @PrePersist와 @PreUpdate 훅을 구현해둔 클래스다.
우리는 @EntityListeners(AuditingEntityListener.class)로 등록만 하면 된다.
앱 시작
→ JPA가 엔티티 클래스 스캔
→ @EntityListeners 확인 → AuditingEntityListener 등록
→ @PrePersist, @PreUpdate 붙은 메서드 기억해둠
em.persist(member) 호출
→ @PrePersist 시점에 touchForCreate() 실행
→ @CreatedDate 필드에 현재 시간 자동 주입
→ INSERT 쿼리에 시간 포함되어 DB 저장
필수 조건 2가지가 모두 있어야 한다. 하나라도 없으면 시간이 null로 저장된다.
// ① 엔티티에 리스너 등록
@EntityListeners(AuditingEntityListener.class)
// ② 스프링에 Auditing 활성화
@EnableJpaAuditing
@SpringBootApplication
public class Application { ... }
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass // DB 테이블은 안 만들고 필드만 자식 엔티티에 포함
public abstract class BaseEntity {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
@Entity
public class Member extends BaseEntity { // 상속만 하면 자동 적용
...
}
@GeneratedValue 없이 ID를 직접 할당하면 문제가 생긴다.
ID == null → 새 엔티티 → INSERT
ID != null → 기존 엔티티로 오판 → SELECT 후 merge (불필요한 SELECT 발생!)
Persistable 인터페이스를 구현해서 새 엔티티 판단 기준을 직접 정의한다.
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class BaseEntity implements Persistable<String> {
@CreatedDate
private LocalDateTime createdAt;
@Override
public boolean isNew() {
return createdAt == null; // 저장 전엔 null → 새 엔티티로 판단
}
}
@CreatedDate는 persist() 시점에 자동 세팅되므로, 저장 전에는 항상 null이다.
덕분에 isNew() = true → SELECT 없이 바로 INSERT 실행된다.
인터페이스에
JpaRepository만 상속받았을 뿐인데 어떻게 동작하는가?
// 기존: EntityManager 직접 사용
@Repository
public class MemberRepository {
@PersistenceContext
private EntityManager em;
...
}
// Spring Data JPA: 인터페이스 선언만으로 끝
public interface MemberRepository extends JpaRepository<Member, Long> {}
인터페이스(껍데기)만 만들었는데 누가 구현해줄까? → Spring Framework
JpaRepository를 상속한 인터페이스를 작성한다.SimpleJpaRepository)를 자동 생성한다.Spring이 자동으로 만들어주는 구현체 내부를 보면:
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private final EntityManager entityManager; // 내부적으로 EntityManager를 직접 사용
}
JpaRepository는 EntityManager를 편리하게 감싼 껍데기일 뿐이다.
내부적으로 1차 캐시, 변경 감지 등 JPA의 동작 방식은 그대로 유지된다.
→ 이것이 우리가 EntityManager를 먼저 배운 이유다.
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity); // 새로우면 persist (INSERT)
return entity;
} else {
return em.merge(entity); // 아니면 merge (SELECT 후 UPDATE 또는 INSERT)
}
}
isNew()는 ID가 null이면 true, 아니면 false를 반환한다.
ID를 직접 할당하면 ID가 null이 아니므로 merge()가 실행되어 불필요한 SELECT가 발생한다.
이 문제는 앞서 설명한 Persistable 인터페이스 구현으로 해결한다.
JPA를 처음 배울 때 가장 헷갈렸던 부분들을 정리해봤습니다.
핵심을 한 줄씩 요약하면: