티스토리 뷰
✏️ 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가지:
- 트랜잭션 커밋 직전
- JPQL 쿼리 실행 직전 (JPQL은 SQL로 번역되어 DB에 직접 나가는데, 이때 아직 반영 안 된 변경사항이 있으면 조회 결과가 꼬일 수 있어서 JPA가 먼저 flush를 해준다)
- 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 후 영속성 컨텍스트가 닫히면서 → 빌렸던 Connection을 DataSource 풀에 반납한다.
| 계층 | 관리 대상 | 담당 |
| DataSource / HikariCP | 물리적 JDBC Connection 자체 | 커넥션 풀 (몇 개 만들지, 얼마나 유지할지) |
| 영속성 컨텍스트 (JPA/Hibernate) | 엔티티 객체와 그 상태 | 1차 캐시, 변경 감지, 쓰기 지연 SQL |
| 둘의 접점 | "지금 이 트랜잭션이 Connection을 언제 빌리고 언제 돌려주는가" | 트랜잭션 범위 = 영속성 컨텍스트 범위 = 사실상 Connection 점유 시간 |
✏️ 8. 정리
- dirty checking을 모르면 "분명 save()도 안 했는데 왜 DB에 반영됐지?" 하는 상황에서 헤맨다.
- flush 시점을 모르면 JPQL 조회 결과가 예상과 다르게 나오는 이유를 못 찾는다.
- 엔티티 상태(특히 준영속)를 모르면 merge()를 잘못 써서 의도치 않은 엔티티가 생기거나, 트랜잭션 밖에서 값을 바꿨는데 반영이 안 되는 버그를 만든다.
- OSIV와 영속성 컨텍스트 생명주기를 모르면 LazyInitializationException이나 HikariCP 커넥션 풀 고갈 문제를 진단할 수 없다 (이가영 지원자 사례가 정확히 이 지점이었다).
- JDBC/DataSource와 영속성 컨텍스트의 경계를 모르면 "커넥션 풀 문제"와 "JPA 캐시 문제"를 뒤섞어서 엉뚱한 곳을 고치게 된다. 둘은 다른 레이어지만 트랜잭션 범위로 이어져 있다는 걸 알아야 정확히 원인을 짚을 수 있다.
'Back-End > 개인 공부' 카테고리의 다른 글
| [Spring Boot / 스프링 부트] - Spring Boot의 JDBC, JdbcTemplate, JPA (0) | 2026.08.13 |
|---|---|
| [Spring Boot / 스프링 부트] - 세모산 코드에 트랜잭션 적용해서 이해하기 (0) | 2026.08.12 |
| [Spring Boot / 스프링 부트] - Spring @Transactional과 Proxy (0) | 2026.08.11 |
| [K8s / 쿠버네티스] 쿠버네티스(Kubernetes) 수업 정리 (0) | 2026.06.02 |
| [K8s / 쿠버네티스] 쿠버네티스 MinIO 버킷 데이터 Mac으로 다운로드하기 (0) | 2026.05.31 |
- Total
- Today
- Yesterday
- 스프링 부트 crud 게시판 구현
- 이분 매칭
- DP
- 자료구조
- HTML5
- 자바스크립트
- DFS
- js
- c++ string
- C++
- 백준 풀이
- BFS
- 백준
- 알고리즘 공부
- 자바
- 에라토스테네스의 체
- 투 포인터
- 반복문
- Spring Boot
- 유클리드 호제법
- html
- 세그먼트 트리
- java
- 알고리즘
- CSS
- Do it!
- 유니온 파인드
- 스택
- 우선순위 큐
- 카운팅 정렬
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
