티스토리 뷰

반응형

✏️  1. 영속성 컨텍스트란?

영속성 컨텍스트는 엔티티를 영구 저장하는 환경이라는 뜻으로, JPA엔티티 객체를 관리하기 위해 내부적으로 유지하는 "1차 캐시" 공간이다. EntityManager를 통해 엔티티를 저장하거나 조회하면, 실제로는 DB에 바로 반영되는 게 아니라 이 영속성 컨텍스트라는 중간 계층에 먼저 올라간다.

"DB에 접근하는 것"과 "엔티티를 다루는 것" 사이에 영속성 컨텍스트라는 완충지대가 있다. 이 완충지대 덕분에 캐싱, 변경 감지, 쓰기 지연 같은 최적화가 가능해진다.

EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

tx.begin();

Member member = new Member("member1", "회원1");
em.persist(member); // 영속성 컨텍스트에 저장 (아직 DB에 INSERT 안 나감)

tx.commit(); // 커밋 시점에 INSERT 쿼리가 실제로 나감

 

✏️ 2. 영속성 컨텍스트의 특징

2-1. 1차 캐시

영속성 컨텍스트 내부에는 Map<식별자, 엔티티> 형태의 1차 캐시가 있다.
find()로 조회하면 먼저 1차 캐시를 확인하고, 없을 때만 DB에 쿼리를 날린다.

Member member1 = em.find(Member.class, "member1"); // DB 조회 (SELECT 쿼리 발생)
Member member2 = em.find(Member.class, "member1"); // 1차 캐시에서 조회 (쿼리 없음)

2-2. 동일성(identity) 보장

같은 트랜잭션 안에서 같은 식별자로 조회한 엔티티는 항상 같은 객체(참조)다. == 비교가 true가 된다. 이건 1차 캐시가 있기 때문에 가능한 것으로, DB 트랜잭션 격리 수준이 READ COMMITTED여도 애플리케이션 레벨에서 반복 가능한 읽기(REPEATABLE READ)를 제공하는 효과가 있다.

Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");
System.out.println(a == b); // true

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

persist()를 호출해도 즉시 INSERT 쿼리가 나가지 않는다. 영속성 컨텍스트 내부의 쓰기 지연 SQL 저장소에 쿼리를 모아뒀다가, 트랜잭션이 커밋되는 시점에 한꺼번에 DB로 보낸다(flush). 이 덕분에 JDBC 배치 기능을 활용해 여러 INSERT를 묶어 보낼 수 있다.

tx.begin();
em.persist(memberA); // SQL 저장소에 INSERT 쿼리 저장, 아직 미전송
em.persist(memberB); // SQL 저장소에 INSERT 쿼리 저장, 아직 미전송
// 커밋 직전에 flush가 호출되며 INSERT 쿼리 2개가 DB로 전송됨
tx.commit();

2-4. 변경 감지 (dirty checking)

영속 상태의 엔티티를 수정하면, 별도의 update 메서드를 호출하지 않아도 트랜잭션 커밋 시점(정확히는 flush 시점)에 자동으로 UPDATE 쿼리가 나간다. 이게 JPA에서 가장 헷갈려하는 부분이자 가장 중요한 특징이다.

 

동작 원리: 영속성 컨텍스트는 엔티티를 1차 캐시에 저장할 때 최초 상태를 복사한 스냅샷도 함께 저장한다. flush 시점에 스냅샷과 현재 엔티티 값을 비교해서 다른 필드가 있으면 UPDATE 쿼리를 만들어낸다.

tx.begin();

Member member = em.find(Member.class, "member1");
member.setName("이름변경"); // setter로 값만 바꿈. em.update() 같은 메서드 없음!

tx.commit(); // 이 시점에 변경 감지가 동작해서 UPDATE 쿼리 자동 발생

2-5. 지연 로딩 (lazy loading)

연관관계 필드를 실제로 사용하는 시점에만 SELECT 쿼리를 날리는 전략. FetchType.LAZY로 설정하면 프록시 객체를 반환해두고, 필드에 접근할 때 그제서야 DB 조회가 일어난다.

@Entity
class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}

Order order = em.find(Order.class, 1L); // Order만 조회, Member는 프록시
order.getMember().getName(); // 이 시점에 Member SELECT 쿼리 발생

✏️ 3. 엔티티의 생명주기 (영속성 상태)

JPA에서 엔티티는 4가지 상태 중 하나를 갖는다.

상태 설명 만드는 방법
비영속 (New/Transient) 영속성 컨텍스트와 전혀 관계없는 순수한 객체 new Member()
영속 (Managed) 영속성 컨텍스트가 관리하는 상태 em.persist(), em.find(), em.merge() 반환값
준영속 (Detached) 영속성 컨텍스트가 관리하다가 분리된 상태 em.detach(), em.clear(), em.close()
삭제 (Removed) 삭제하기로 표시된 상태 em.remove()
// 1. 비영속
Member member = new Member();
member.setName("member1");

// 2. 영속 - 영속성 컨텍스트에 저장, 1차 캐시에 들어감
em.persist(member);

// 3. 준영속 - 1차 캐시에서 분리, 이후로는 변경 감지 대상 아님
em.detach(member);

// 4. 삭제 - DB 삭제 요청 (flush 시점에 DELETE 실행)
em.remove(member);
new Member()  ── persist()──▶  영속(Managed)  ── remove()──▶  삭제(Removed)
                                    │
                              detach()/clear()/close()
                                    ▼
                              준영속(Detached)
                                    │
                                merge()
                                    ▼
                              영속(Managed) 상태의 새로운 인스턴스 반환

준영속 상태에서 조심할 점 =>  detached 엔티티는 더 이상 영속성 컨텍스트가 관리하지 않으므로, setter로 값을 바꿔도 DB에 반영되지 않는다. 반영하려면 merge()로 다시 영속 상태로 만들어야 한다.

Member detached = em.find(Member.class, "member1");
em.detach(detached);
detached.setName("바뀐이름"); // 변경 감지 대상 아님, DB 반영 안 됨

Member merged = em.merge(detached); // 새 영속 엔티티를 반환 (detached 자체가 영속화되는 게 아님)
merged.setName("이제_이건_반영됨");

✏️ 4. EntityManager와 트랜잭션의 관계

4-1. EntityManager의 생명주기

  • EntityManagerFactory애플리케이션 전체에서 딱 하나만 생성 (비용이 크고 스레드 세이프함)
  • EntityManager요청마다(혹은 트랜잭션마다) 새로 생성 - 스레드 간 공유 금지 (스레드 세이프하지 않음)
  • 영속성 컨텍스트는 기본적으로 EntityManager 하나당 하나씩 만들어짐 (1:1 매핑)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPersistenceUnit");
EntityManager em = emf.createEntityManager(); // 요청마다 생성

4-2. 트랜잭션 범위의 영속성 컨텍스트 (Transaction-scoped Persistence Context)

Spring Data JPA를 쓰면 이 개념이 훨씬 간단해진다. Spring@Transactional이 붙은 메서드 범위에 맞춰 영속성 컨텍스트의 생명주기를 자동으로 관리해주기 때문이다. 

  • 트랜잭션이 시작될 때 영속성 컨텍스트가 생성되고
  • 트랜잭션이 커밋/롤백될 때 flush 후 영속성 컨텍스트가 종료된다

@Transactional이 없는 상태에서 조회한 엔티티를 컨트롤러까지 들고 가서 지연 로딩 필드에 접근하면 LazyInitializationException이 터지는 이유가 바로 이거다. 영속성 컨텍스트가 서비스 메서드 종료와 함께 이미 닫혀버렸기 때문이다.

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public void updateMemberName(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow();
        member.setName(newName); // dirty checking으로 자동 UPDATE
        // 메서드 종료 시 트랜잭션 커밋 → flush → 영속성 컨텍스트 종료
    }
}

4-3. OSIV (Open Session In View)

Spring Boot는 기본적으로 spring.jpa.open-in-view=true로 설정되어 있어서, 영속성 컨텍스트를 트랜잭션이 끝난 뒤에도 HTTP 응답이 끝날 때까지 유지해준다. 이러면 컨트롤러/뷰 레이어에서도 지연 로딩이 동작하지만, 그만큼 DB 커넥션을 오래 붙잡고 있게 되어 커넥션 풀 고갈 위험이 커진다(HikariCP에서 얘기했던 것과 바로 연결되는 지점).

 

✏️ 5. 주요 메서드 정리

5-1. persist(entity) - 저장

비영속 엔티티를 영속 상태로 만든다. 즉시 INSERT가 나가는 게 아니라 쓰기 지연 저장소에 쌓인다.

Member member = new Member("member1", "회원1");
em.persist(member);

5-2. find(entityClass, id) - 조회

1차 캐시 우선 조회, 없으면 DB SELECT 후 1차 캐시에 저장.

Member member = em.find(Member.class, "member1");

5-3. 변경 (update가 따로 없다)

JPA에는 em.update() 같은 메서드가 존재하지 않는다. 영속 상태의 엔티티는 필드값을 바꾸는 것만으로 dirty checking 대상이 되어 flush 시점에 자동으로 UPDATE 쿼리가 생성된다.
참고: Spring Data JPA의 save()는 새 엔티티면 내부적으로 persist(), 식별자가 있는 기존 엔티티면 merge()를 호출한다. merge()는 준영속 엔티티를 영속 상태로 되돌릴 때 쓰는 것이지, "update 쿼리를 직접 날리는" 메서드가 아니라는 점을 헷갈리면 안 된다.

Member member = em.find(Member.class, "member1");
member.setName("변경된이름"); // 이게 사실상 "update"다

5-4. remove(entity) - 삭제

엔티티를 삭제 상태로 만든다. flush 시점에 DELETE 쿼리가 나간다.

Member member = em.find(Member.class, "member1");
em.remove(member);

5-5. flush() -  영속성 컨텍스트의 변경 내용을 DB에 반영

flush는 커밋이 아니다. 쓰기 지연 저장소에 쌓인 SQL을 DB로 전송할 뿐, 트랜잭션 자체를 끝내지는 않는다. flush 이후에도 롤백은 가능하다.

flush가 자동으로 발생하는 시점 3가지:

  1. 트랜잭션 커밋 직전
  2. JPQL 쿼리 실행 직전 (JPQL은 SQL로 번역되어 DB에 직접 나가는데, 이때 아직 반영 안 된 변경사항이 있으면 조회 결과가 꼬일 수 있어서 JPA가 먼저 flush를 해준다)
  3. em.flush() 명시적 호출
Member member = em.find(Member.class, "member1");
member.setName("변경됨");

em.flush(); // UPDATE 쿼리 즉시 전송, 하지만 트랜잭션은 아직 안 끝남

tx.rollback(); // flush로 DB에 반영됐어도 롤백하면 취소됨
// JPQL 실행 전 자동 flush 예시
Member member = new Member("member1", "회원1");
em.persist(member); // 아직 쓰기 지연 저장소에만 있음

// 만약 flush가 자동으로 안 됐다면 아래 JPQL은 방금 저장한 member1을 못 찾음
List<Member> members = em.createQuery("select m from Member m", Member.class)
                          .getResultList(); // 실행 직전 자동 flush → member1도 조회됨

5-6. clear() / detach()  - 영속성 컨텍스트 비우기

  • clear(): 영속성 컨텍스트 전체를 초기화 (1차 캐시, 쓰기 지연 저장소 모두 비움)
  • detach(entity): 특정 엔티티 하나만 준영속 상태로 분리

대량 데이터를 벌크로 처리할 때 1차 캐시가 계속 쌓여서 메모리를 잡아먹고 flush 비용도 커지는 걸 막기 위해 주기적으로 clear()를 호출하는 패턴을 자주 쓴다 (박윤지 지원자가 100만 건 배치 처리에서 JPA를 걷어낸 이유와 같은 맥락이다: 영속성 컨텍스트에 엔티티가 계속 쌓이면 뒤로 갈수록 dirty checking 비용이 커진다).

for (int i = 0; i < 100000; i++) {
    Member member = new Member("member" + i, "회원" + i);
    em.persist(member);

    if (i % 100 == 0) { // 100건마다
        em.flush();  // DB에 반영
        em.clear();  // 영속성 컨텍스트 비우기 (메모리 확보)
    }
}

✏️ 6. EntityManager vs 트랜잭션 vs 영속성 컨텍스트 

트랜잭션이 시작되면 영속성 컨텍스트가 열리고, 그 안에서 EntityManager를 통해 엔티티를 관리하다가, 트랜잭션이 커밋되는 순간 변경 감지로 쌓인 변경사항이 flush되어 DB에 반영되고, 컨텍스트는 닫힌다.

개념 역할 생명주기
EntityManagerFactory EntityManager를 만드는 공장 애플리케이션 전체에서 1개
EntityManager 엔티티를 저장/조회/삭제하는 실질적인 창구, 영속성 컨텍스트를 내부에 가짐 요청/트랜잭션 단위로 생성-소멸
영속성 컨텍스트 엔티티를 실제로 캐싱·추적·관리하는 논리적 공간 기본적으로 EntityManager와 생명주기 같이함
(Spring에서는 트랜잭션 범위와 일치)
트랜잭션 DB 작업의 원자성 단위, 커밋 시점에 flush를 트리거함 @Transactional 메서드 범위

 

✏️ 7. JDBC와 DataSource - 영속성 컨텍스트는 이 계층에서 어디에 있나

영속성 컨텍스트는 JDBC/DataSource 레벨의 개념이 아니다. JDBC는 "DB에 연결해서 SQL을 실행하고 결과를 받아오는" 표준 API일 뿐이고, 1차 캐시·dirty checking·쓰기 지연 같은 개념은 그 위에 있는 JPA(Hibernate) 구현체가 만들어낸 것이다. 다만 둘은 "커넥션을 언제 빌리고 언제 돌려주는가"라는 지점에서 직접 맞물린다. 

7-1. 전체 계층 구조

  • JDBC는 자바 표준 스펙(java.sql.Connection, Statement, ResultSet)으로, "어떻게 DB에 SQL을 보내고 결과를 받을지"를 정의한다. ORM이 없어도 JDBC만으로 DB 접근이 가능하다.
  • DataSource(javax.sql.DataSource)는 Connection을 얻는 표준 인터페이스다. 매번 새 Connection을 맺는 대신, HikariCP 같은 구현체가 이 인터페이스를 구현해서 미리 만들어둔 커넥션을 빌려주는 "풀"로 동작한다.
  • 영속성 컨텍스트(JPA/Hibernate)는 이 DataSource가 빌려준 JDBC Connection을 내부적으로 하나 물고 있으면서, 그 위에서 엔티티 관리·캐싱·쿼리 생성을 담당한다.
애플리케이션 코드 (Service, Repository)
        │
        ▼
EntityManager / 영속성 컨텍스트   ← JPA(Hibernate)가 관리하는 논리적 계층
   (1차 캐시, 변경 감지, 쓰기 지연)     엔티티 상태를 추적, "언제 SQL을 만들지" 결정
        │
        ▼
Hibernate SessionFactory → JDBC Connection, Statement 생성  ← 여기서부터 진짜 SQL 실행
        │
        ▼
DataSource (HikariCP 등 커넥션 풀 구현체)   ← Connection을 "빌려주고 반납받는" 풀
        │
        ▼
실제 DB (물리적 TCP 커넥션)

7-2. DataSourceConfig의 역할

Spring Boot는 application.yml의 spring.datasource.* 설정만으로 DataSource 빈을 자동으로 만들어준다(내부적으로 HikariCP를 기본 구현체로 사용). 하지만 데이터소스가 여러 개거나 세밀한 제어가 필요할 때는 직접 @Configuration 클래스로 DataSourceConfig를 작성한다.

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public DataSource dataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }
}
# application.yml
spring:
  datasource:
    hikari:
      jdbc-url: jdbc:mysql://localhost:3306/mydb
      username: root
      password: 1234
      maximum-pool-size: 10

여기까지는 순수 JDBC/커넥션 풀 설정이고, 영속성 컨텍스트는 아직 등장하지 않는다. 영속성 컨텍스트가 등장하는 지점은 이 DataSource를 가지고 EntityManagerFactory(정확히는 Hibernate의 SessionFactory)를 만드는 단계다. 즉 구조상 순서는 DataSourceConfig(JDBC 커넥션 풀 설정) → EntityManagerFactory(그 DataSource 위에 JPA/Hibernate를 얹음) → EntityManager(영속성 컨텍스트를 실제로 갖는 객체) 다. DataSourceConfig 자체는 "누가 커넥션을 관리할지"를 정의할 뿐이고, 영속성 컨텍스트는 그 위 레이어가 만들어내는 개념이라는 게 정확한 관계다

@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
    LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
    em.setDataSource(dataSource); // DataSourceConfig에서 만든 DataSource를 주입
    em.setPackagesToScan("com.example.demo.entity");
    em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
    return em;
}

7-3. 그래도 왜 "DataSourceConfig에서 영속성 컨텍스트 얘기가 나온다"고 느껴질까

실무에서 자주 헷갈리는 이유는, 영속성 컨텍스트(트랜잭션)의 생명주기가 DataSource 풀에서 Connection을 빌리고 반납하는 시점을 그대로 결정하기 때문이다.

  • 트랜잭션이 시작되면 → 영속성 컨텍스트가 열리고 → 그 안에서 실제 SQL이 필요한 첫 시점DataSource(HikariCP 풀)에서 Connection을 하나 빌려온다.
  • 트랜잭션이 끝나면(커밋/롤백) → flush 후 영속성 컨텍스트가 닫히면서 → 빌렸던 ConnectionDataSource 풀에 반납한다.
계층 관리 대상  담당
DataSource / HikariCP 물리적 JDBC Connection 자체 커넥션 풀 (몇 개 만들지, 얼마나 유지할지)
영속성 컨텍스트 (JPA/Hibernate) 엔티티 객체와 그 상태 1차 캐시, 변경 감지, 쓰기 지연 SQL
둘의 접점 "지금 이 트랜잭션이 Connection을 언제 빌리고 언제 돌려주는가" 트랜잭션 범위 = 영속성 컨텍스트 범위 = 사실상 Connection 점유 시간

✏️ 8. 정리

  • dirty checking을 모르면 "분명 save()도 안 했는데 왜 DB에 반영됐지?" 하는 상황에서 헤맨다.
  • flush 시점을 모르면 JPQL 조회 결과가 예상과 다르게 나오는 이유를 못 찾는다.
  • 엔티티 상태(특히 준영속)를 모르면 merge()를 잘못 써서 의도치 않은 엔티티가 생기거나, 트랜잭션 밖에서 값을 바꿨는데 반영이 안 되는 버그를 만든다.
  • OSIV와 영속성 컨텍스트 생명주기를 모르면 LazyInitializationException이나 HikariCP 커넥션 풀 고갈 문제를 진단할 수 없다 (이가영 지원자 사례가 정확히 이 지점이었다).
  • JDBC/DataSource와 영속성 컨텍스트의 경계를 모르면 "커넥션 풀 문제"와 "JPA 캐시 문제"를 뒤섞어서 엉뚱한 곳을 고치게 된다. 둘은 다른 레이어지만 트랜잭션 범위로 이어져 있다는 걸 알아야 정확히 원인을 짚을 수 있다.
반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함