기본 키 전략부터 Spring Data JPA까지

김민호·2026년 6월 1일

JPA

목록 보기
2/2

JPA를 처음 배우면서 헷갈렸던 개념들을 정리했습니다.
기본 키 생성 전략, Flush, 준영속 상태, JPA Auditing, JpaRepository 동작 원리까지 다룹니다.


목차

  1. @Id와 기본 키 생성 전략
  2. Flush: 영속성 컨텍스트의 동기화
  3. 준영속 상태와 merge()
  4. JPA Auditing
  5. Spring Data JPA - JpaRepository

1. @Id와 기본 키 생성 전략

엔티티를 만들 때마다 항상 기본 키 설정이 필요하다.
PK를 할당하는 방법은 크게 2가지다.

  1. 직접 넣기
  2. DB에게 맡기기: @GeneratedValue

@GeneratedValue에는 4가지 옵션이 존재한다.

IDENTITY

기본 키 생성을 데이터베이스에 위임하는 전략이다. (MySQL의 AUTO_INCREMENT)

특징: 쓰기 지연이 동작하지 않는다.

영속성 컨텍스트의 1차 캐시는 key-value 구조로 관리되는데, key를 만들기 위해 ID 값이 필요하다.
AUTO_INCREMENT는 DB에 INSERT 쿼리를 날려야 ID가 생성되므로, em.persist() 호출 즉시 INSERT SQL을 DB에 보낸다.

em.persist(member); // 즉시 INSERT 실행 → ID 반환 → 1차 캐시에 저장

SEQUENCE

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번째 호출: 결과 1 → 현재 시퀀스 값, 첫 번째 ID로 사용
  • 2번째 호출: 결과 51 → 다음 범위의 시작값 (범위 끝 확인용)

Hibernate는 이 두 값을 바탕으로 1~50 범위를 메모리에 캐시해서, 2~50 사이의 ID는 DB 호출 없이 바로 꺼내 쓴다.

⚠️ JPA(allocationSize = 50)와 DB(INCREMENT BY 1)의 증가값이 다르면 PK 중복 에러가 발생할 수 있다. 반드시 통일시키자.

TABLE

키 생성 전용 테이블을 만들어서 시퀀스를 흉내 내는 전략이다.
모든 DB에서 사용 가능하지만, SELECT + UPDATE + 락으로 인해 성능이 좋지 않다.
DB 마이그레이션 과도기나 여러 DB를 지원해야 하는 경우에 사용한다.

AUTO

DB 방언에 따라 IDENTITY, SEQUENCE, TABLE 중 하나를 자동으로 선택한다.
운영 환경에서는 가급적 명시적인 전략을 지정하는 것이 좋다.

전략 비교

전략쓰기 지연특징
직접 할당가능개발자가 ID 중복 직접 관리
IDENTITY어려움persist() 즉시 INSERT, Batch 불리
SEQUENCE가능allocationSize로 성능 최적화 가능
TABLE가능모든 DB 호환, 성능 나쁨
AUTO전략에 따라 다름명시적 지정 권장

실무 표준: IDENTITY

MySQL이 스타트업·웹 서비스의 사실상 표준 DB이고, MySQL은 SEQUENCE를 지원하지 않는다.
또한 INSERT는 대부분 단건으로 일어나므로 IDENTITY와 SEQUENCE의 성능 차이가 거의 없다.
대용량 삽입이 필요한 경우에만 JdbcTemplate이나 MyBatis를 사용하면 된다.


2. Flush: 영속성 컨텍스트의 동기화

Flush는 영속성 컨텍스트의 변경 내용을 DB에 반영하는 작업이다.
쓰기 지연 저장소에 쌓아둔 SQL들을 DB로 한꺼번에 보내는 과정이다.

Flush가 발생하는 시점

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차 캐시는 비워질까?

절대 지워지지 않는다.

Flush 동작 순서는 다음과 같다.

  1. Dirty Checking: 1차 캐시 객체와 스냅샷 비교 → 변경 사항 있으면 UPDATE SQL 생성
  2. SQL 전송: 쓰기 지연 SQL 저장소의 쿼리들을 DB에 전송
  3. DB 응답: DB가 SQL을 받아서 실행 (아직 커밋은 안 된 상태)
  4. 1차 캐시: 아무 일도 일어나지 않는다. 객체는 그대로 남아있다.

1차 캐시를 비우지 않는 이유는 JPA의 핵심 철학인 영속성과 동일성 때문이다.

  • 재사용성: 방금 저장하고 바로 다시 조회하는 경우가 많으므로 캐시를 비우면 비효율적이다.
  • 동일성 보장: 한 트랜잭션 안에서는 a == b가 항상 참이어야 한다.

flush() vs clear()

메서드역할
flush()쓰기 지연 저장소 → DB 동기화. 1차 캐시는 유지
clear()영속성 컨텍스트(1차 캐시) 전체 비우기

FlushMode 설정

  • FlushModeType.AUTO (기본값, 권장): 커밋 직전 + JPQL 실행 직전에 자동 flush
  • FlushModeType.COMMIT: 오직 커밋할 때만 flush → 쿼리가 꼬일 수 있으므로 주의

특수한 상황이 아니라면 AUTO를 사용하자.


3. 준영속 상태와 merge()

엔티티 생명주기: 4가지 상태

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(): 복제 기술, 덮어쓰기

merge는 죽은 객체를 되살리는 게 아니라, 모든 필드를 덮어쓰는 복제 기술이다.

동작 원리

  1. JPA가 준영속 객체를 받는다.
  2. 객체의 ID로 1차 캐시 확인 → 없으면 DB 조회 (SELECT)
  3. 조회한 영속 객체에 준영속 객체의 값을 덮어씌운다.
  4. 수정된 영속 객체를 반환한다.
  5. 처음 준영속 객체는 여전히 준영속 상태로 버려진다.
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되므로 항상 안전하다.


4. JPA Auditing

엔티티가 생성/수정될 때 시간을 자동으로 기록해주는 기능이다.
매번 createdAt = LocalDateTime.now()를 직접 쓰지 않아도 자동으로 처리된다.

@CreatedDate / @LastModifiedDate

어노테이션동작 시점이후 변경 여부
@CreatedDate최초 INSERT 시변경 안 됨
@LastModifiedDateINSERT + UPDATE 시수정될 때마다 갱신

동작 원리

JPA는 엔티티 생명주기마다 이벤트 훅을 제공한다.

훅실행 시점
@PrePersistpersist() 직전
@PostPersistpersist() 직후
@PreUpdateupdate 직전
@PostUpdateupdate 직후
@PreRemoveremove() 직전
@PostRemoveremove() 직후

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 { ... }

실무 패턴: BaseEntity 상속

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass // DB 테이블은 안 만들고 필드만 자식 엔티티에 포함
public abstract class BaseEntity {
    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

@Entity
public class Member extends BaseEntity { // 상속만 하면 자동 적용
    ...
}

Persistable 인터페이스

@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 실행된다.


5. Spring Data JPA - JpaRepository

핵심 의문

인터페이스에 JpaRepository만 상속받았을 뿐인데 어떻게 동작하는가?

기존 방식 vs Spring Data JPA

// 기존: EntityManager 직접 사용
@Repository
public class MemberRepository {
    @PersistenceContext
    private EntityManager em;
    ...
}

// Spring Data JPA: 인터페이스 선언만으로 끝
public interface MemberRepository extends JpaRepository<Member, Long> {}

작동 원리

인터페이스(껍데기)만 만들었는데 누가 구현해줄까? → Spring Framework

  1. JpaRepository를 상속한 인터페이스를 작성한다.
  2. Spring이 애플리케이션 시작 시 스캔해서 해당 인터페이스를 찾는다.
  3. 런타임에 구현 객체(SimpleJpaRepository)를 자동 생성한다.
  4. 생성된 프록시 객체를 스프링 빈으로 등록한다.

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를 먼저 배운 이유다.

save() 메서드의 한계

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를 처음 배울 때 가장 헷갈렸던 부분들을 정리해봤습니다.

핵심을 한 줄씩 요약하면:

  • 기본 키 전략: 실무는 IDENTITY, 성능이 필요하면 SEQUENCE + allocationSize
  • Flush: DB 동기화일 뿐, 1차 캐시는 절대 비워지지 않는다
  • 준영속: 좀비 상태, merge 대신 Dirty Checking을 쓰자
  • JPA Auditing: 훅을 직접 안 써도 되는 이유는 Spring이 이미 구현해뒀기 때문
  • JpaRepository: EntityManager의 편리한 래퍼, 내부 동작은 동일하다
profile
개발자를 꿈꾸고 있어요

0개의 댓글