들어가며
JPA를 사용하면 데이터베이스를 객체지향적으로 다룰 수 있을 뿐만 아니라 반복적인 SQL 작성 시간을 줄여 비즈니스 로직 개발에 더욱 몰입할 수 있습니다.
하지만 내부적으로 엔티티가 어떻게 관리되고 동작하는지 정확히 모른 채 사용한다면 복잡한 비즈니스 상황에서 의도치 않은 쿼리가 발생하거나 데이터 정합성이 깨지는 결과를 초래할 수 있스빈다.
결국 JPA를 '잘' 쓰기 위해서는 내부적으로 엔티티가 어덯게 관리되고 동작하는지 잘 알고 있어야 합니다.
이번 글에서는 JPA의 핵심 중의 핵심인 영속성 컨텍스트(Persistence Context)의 개념과 엔티티의 상태 관리에 대해 다루어 보겠습니다.
1. 영속성 컨텍스트란 무엇인가?
영속성 컨텍스트(Persistence Context) 는 한 마디로 "JPA가 엔티티 객체들을 관리하는 내부 저장소" 입니다.
자바 애플리케이션이 데이터베이스와 직접 대화하는 것이 아니라 그 사이에 중간 관리자 역할을 하는 영속성 컨텍스트 레이어가 존재합니다.
[Java Application]
│
│ 엔티티 저장 / 조회 / 삭제 요청
▼
[영속성 컨텍스트] ← JPA의 핵심 공간
│
│ flush() 시점에 SQL을 DB로 전송
▼
[Database]
영속성 컨텍스트는 엔티티 객체의 상태를 추적하고 언제 DB에 SQL을 보낼지를 결정합니다.
이 컨텍스트를 다루는 도구가 바로 EntityManager입니다.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPersistenceUnit");
EntityManager em = emf.createEntityManager(); // 영속성 컨텍스트 생성
중요
EntityManager 하나는 영속성 컨텍스트 하나와 1:1로 대응됩니다.
Spring에서는 @Transactional이 붙은 메서드 범위 안에서 EntityManager (= 영속성 컨텍스트)가 유지됩니다.
영속성 컨텍스트가 제공하는 4가지 기능
영속성 컨텍스트가 단순한 저장소 이상의 역할을 하는 이유는 아래 4가지 기능 때문입니다. 각각은 뒤에서 상태 설명과 함께 자세히 다루겠습니다.
| 기능 | 설명 |
|---|---|
| 1차 캐시 | 한 번 조회한 엔티티는 캐시에 보관, 재조회 시 DB 안 감 |
| 변경 감지 (Dirty Checking) | 엔티티 변경 시 자동으로 UPDATE SQL 생성 |
| 쓰기 지연 (Write-Behind) | SQL을 바로 보내지 않고 모아뒀다가 flush 시 한꺼번에 전송 |
| 동일성 보장 | 같은 트랜잭션 내 같은 PK 조회 → 항상 동일 인스턴스 반환 |
위 4가지 기능에 대해서는 아래에서 자세히 다루겠습니다.
2. 엔티티의 4가지 상태
영속성 컨텍스트에 있느냐 없느냐 그리고 DB와 연결되어 있느냐 없느냐에 따라 엔티티는 4가지 상태 중 하나에 속합니다.
new 키워드
│
▼
[Transient] ←──────────────────────────────┐
│ persist() │
▼ │
[Managed] ──── detach() / clear() / close() ──► [Detached]
│ │
remove() merge()
│ │
▼ │
[Removed] ──── flush() ──► DB 삭제 [Managed]
3. Transient (비영속 상태) — JPA가 모르는 순수 자바 객체
정의
new 키워드로 생성된 직후의 상태입니다. 영속성 컨텍스트에도 없고, DB에도 없습니다. JPA는 이 객체의 존재를 전혀 알지 못합니다.
Member member = new Member();
member.setName("홍길동");
member.setAge(25);
// 이 시점의 member는 Transient 상태
// 아무리 필드를 수정해도 DB에 아무 영향도 없습니다
비유하자면 입사 지원서조차 내지 않은 사람과 같습니다. 회사(JPA)는 그 사람의 존재 자체를 알지 못합니다.
Transient 상태에서는 setter를 아무리 호출해도 DB에 아무런 변화가 없습니다. 당연한 말처럼 들리지만, 나중에 Detached 상태와 혼동하는 경우가 많으므로 명확히 짚고 넘어가야 합니다.
4. Managed (영속 상태) — JPA가 감시하는 상태
정의
persist() 등을 통해 영속성 컨텍스트에 등록된 상태입니다. JPA가 이 객체를 완전히 추적하고 관리합니다. 여기서 JPA의 마법 같은 기능들이 모두 동작합니다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(member); // Transient → Managed
// 이 순간부터 JPA가 member를 관리하기 시작합니다
Managed 상태의 핵심 기능 4가지
1. 1차 캐시
영속성 컨텍스트는 내부적으로 Map<PK, Entity> 형태의 저장소(1차 캐시)를 갖고 있습니다. 엔티티를 조회할 때 DB에 바로 가지 않고 이 캐시를 먼저 확인합니다.
// 첫 번째 조회: 1차 캐시에 없으므로 DB에 SELECT SQL이 나갑니다
Member m1 = em.find(Member.class, 1L);
// 두 번째 조회: 1차 캐시에 있으므로 DB에 SQL이 나가지 않습니다
Member m2 = em.find(Member.class, 1L);
System.out.println(m1 == m2); // true — 완전히 같은 인스턴스!
SQL 로그를 찍어보면 SELECT 문이 한 번만 출력되는 것을 확인할 수 있습니다.
2. 변경 감지 (Dirty Checking)
Managed 상태의 엔티티를 수정하면 em.update() 같은 코드 없이 트랜잭션 커밋 시 자동으로 UPDATE SQL이 발행됩니다.
Member member = em.find(Member.class, 1L); // Managed 상태
member.setName("김철수"); // 그냥 setter만 호출!
em.getTransaction().commit();
// → flush() 발생 → 변경 감지 → UPDATE SQL 자동 실행
이것이 가능한 이유는 JPA가 엔티티를 1차 캐시에 저장할 때 스냅샷(최초 상태의 복사본) 을 함께 보관하기 때문입니다.
flush 시점에 현재 엔티티와 스냅샷을 비교해서 달라진 필드가 있으면 UPDATE SQL을 자동 생성합니다.
[1차 캐시 내부]
┌──────────────────────────────────────────────────┐
│ 엔티티 : Member { id=1, name="김철수", age=25 } │ ← 현재 상태
│ 스냅샷 : Member { id=1, name="홍길동", age=25 } │ ← 최초 조회 시 저장
└──────────────────────────────────────────────────┘
│
flush() 시점에 두 값 비교
│
name이 다르다 → UPDATE SQL 생성
→ UPDATE member SET name='김철수' WHERE id=1
3. 쓰기 지연 (Write-Behind)
persist()를 호출한다고 INSERT SQL이 바로 DB로 나가지 않습니다. 쓰기 지연 저장소에 쌓아두었다가 트랜잭션 커밋 또는 명시적 flush() 시점에 한꺼번에 DB로 전송합니다.
em.getTransaction().begin();
em.persist(memberA); // 쓰기 지연 저장소에 INSERT 보관 (SQL 미전송)
em.persist(memberB); // 쓰기 지연 저장소에 INSERT 보관 (SQL 미전송)
em.persist(memberC); // 쓰기 지연 저장소에 INSERT 보관 (SQL 미전송)
System.out.println("여기까지 SQL 한 줄도 나가지 않았습니다");
em.getTransaction().commit();
// → flush() 발생 → INSERT 3개가 한꺼번에 DB로 전송
이 방식의 장점은 DB 커넥션 점유 시간을 최소화하고, JDBC 배치를 활용해 성능을 높일 수 있다는 점입니다.
4. 동일성 보장
같은 트랜잭션 내에서 같은 PK로 조회하면 항상 완전히 동일한 인스턴스를 반환합니다. 자바 컬렉션(Map)에서 같은 키로 꺼내면 항상 같은 객체인 것과 동일한 원리입니다.
Member a = em.find(Member.class, 1L);
Member b = em.find(Member.class, 1L);
System.out.println(a == b); // true — 주소값까지 같은 동일 인스턴스
이 특성 덕분에 JPA는 반복 가능한 읽기(Repeatable Read) 수준의 일관성을 애플리케이션 레벨에서 보장합니다.
5. Detached (준영속 상태) — 영속성 컨텍스트에서 분리된 상태
정의
한때 Managed였지만 영속성 컨텍스트에서 분리된 상태입니다.
DB에는 해당 데이터가 여전히 존재하지만, JPA는 이 객체를 더 이상 추적하지 않습니다. 변경 감지가 동작하지 않습니다.
퇴사한 직원에 비유 하면 인사 시스템(DB)에는 이름이 남아있지만 회사(JPA)가 그 사람의 행동을 더 이상 관리하지 않습니다.
Detached 상태가 되는 3가지 방법
// ① 특정 엔티티 하나만 분리
em.detach(member);
// ② 영속성 컨텍스트 안의 모든 엔티티를 분리 (초기화)
em.clear();
// ③ EntityManager 종료 → 관리하던 모든 엔티티가 Detached
em.close();
Spring에서 자주 만나는 Detached 상황
Spring에서 @Transactional 메서드가 종료되면 영속성 컨텍스트가 닫힙니다. 그 순간 안에서 조회했던 엔티티가 모두 Detached 상태가 됩니다.
@Service
public class MemberService {
@Transactional
public Member findMember(Long id) {
return memberRepository.findById(id).orElseThrow();
// ← 메서드 종료 = 트랜잭션 종료 = 영속성 컨텍스트 종료
// ← 반환된 member는 Detached 상태!
}
}
// Controller에서
Member member = memberService.findMember(1L); // Detached 상태로 반환
member.setName("변경된 이름");
// JPA가 더 이상 이 객체를 감시하지 않으므로 DB에 전혀 반영되지 않습니다.
// em.update() 같은 것도 없으니 방법이 없어 보이는 상황...
JPA 입문자가 가장 많이 겪는 버그 유형 중 하나라고 합니다. "분명히 setter를 호출했는데 왜 DB가 안 바뀌지?" 하는 혼란이 바로 여기서 옵니다.
Detached → Managed: merge()
분리된 엔티티를 다시 영속성 컨텍스트의 관리 아래에 두려면 merge()를 사용합니다.
member.setName("새로운 이름"); // Detached 상태에서 수정
Member managedMember = em.merge(member); // Detached → Managed
em.getTransaction().commit(); // UPDATE SQL 실행, DB에 반영됨
merge()의 내부 동작을 반드시 이해해야 합니다.
merge(detachedMember) 내부 동작 순서:
1. detachedMember의 PK 값으로 1차 캐시 조회
2. 1차 캐시에 없으면 DB 조회
3. DB에서 가져온 Managed 엔티티에 detachedMember의 필드 값을 덮어씀
4. 값이 채워진 Managed 엔티티를 반환
// 잘못된 사용 — merge() 결과를 무시하면 변경이 반영되지 않습니다
em.merge(detachedMember); // 반환값 버림
detachedMember.setAge(30); // Detached 상태, 감시 안 됨
// 올바른 사용 — 반환된 Managed 엔티티를 사용해야 합니다
Member managed = em.merge(detachedMember);
managed.setAge(30); // Managed 상태, 변경 감지 동작
6. Removed (삭제 상태) — 삭제가 예약된 상태
remove()를 호출하면 엔티티는 Removed 상태가 됩니다.
즉시 DB에서 삭제되는 것이 아니라, flush 시점에 DELETE SQL이 발행됩니다.
em.getTransaction().begin();
Member member = em.find(Member.class, 1L); // Managed 상태
em.remove(member); // Managed → Removed
// 아직 DB에서 삭제되지 않았습니다.
System.out.println("여기까지 DELETE SQL 나가지 않습니다");
em.getTransaction().commit(); // → DELETE SQL 실행 → DB에서 삭제
remove() 호출 전 반드시 확인해야 할 것
remove()는 반드시 Managed 상태의 엔티티에만 호출해야 합니다. Detached 상태의 엔티티에 remove()를 호출하면 예외가 발생합니다.
em.detach(member); // Detached 상태로 만들기
em.remove(member); // IllegalArgumentException 발생!
// 올바른 방법
Member managed = em.merge(member); // 먼저 Managed 상태로 되돌린 후
em.remove(managed); // 삭제
7. flush() — 영속성 컨텍스트와 DB를 동기화하는 시점
flush는 많은 분들이 오해하는 개념입니다.
flush = 영속성 컨텍스트의 변경 내용을 DB에 반영하는 것
flush ≠ 영속성 컨텍스트를 초기화(비우는)하는 것
flush가 발생해도 1차 캐시는 그대로 유지됩니다. 단지 쓰기 지연 저장소에 쌓여있던 SQL들이 DB로 전송될 뿐입니다.
flush가 발생하는 3가지 시점
// ① 트랜잭션 커밋 시 자동 호출 (가장 일반적)
em.getTransaction().commit();
// ② 명시적 호출 (JPQL 실행 전에 데이터 일관성이 필요할 때)
em.flush();
// ③ JPQL 실행 시 자동 호출
// JPQL은 DB에 직접 쿼리를 날리므로, 영속성 컨텍스트의 미반영 변경사항이
// 있을 경우 일관성 문제가 생깁니다. 이를 방지하기 위해 JPQL 실행 전
// 자동으로 flush가 발생합니다.
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList(); // 실행 전 자동 flush
(flush에 대한 설명 추가)
8. 상태 전이 완전 정리
| 상태 | DB 존재 여부 | 영속성 컨텍스트 관리 | 변경 감지 | 주요 전환 방법 |
|---|---|---|---|---|
| Transient | ✗ | ✗ | ✗ | new |
| Managed | ✓ (flush 후) | ✓ | ✓ | persist(), find(), merge() 반환값 |
| Detached | ✓ | ✗ | ✗ | detach(), clear(), close(), 트랜잭션 종료 |
| Removed | 삭제 예정 | ✓ (삭제 대기) | ✗ | remove() |
상황별 선택 가이드
| 상황 | 해야 할 일 |
|---|---|
| 새 엔티티를 DB에 저장하고 싶다 | persist() → Managed → commit |
| DB에서 엔티티를 가져와 수정하고 싶다 | find() → 수정 → commit (변경 감지로 자동 처리) |
| 트랜잭션 밖에서 받은 엔티티를 수정하고 싶다 | merge() → 반환값 사용 → commit |
| 엔티티를 DB에서 삭제하고 싶다 | find() → remove() → commit |
마치며
JPA는 단순히 사용하는 것이 아니라 내부 동작을 이해하고 의도적으로 제어해야 하는 기술이라는 점을 깨닫게 되었습니다.
앞으로는 단순히 동작하는 코드를 넘어 트랜잭션 범위와 엔티티 상태를 의식하며 의도대로 동작하는 코드를 짜는 개발자가 되겠습니다.
'JPA' 카테고리의 다른 글
| JPA 1차 캐시와 2차 캐시 — 캐시 전략으로 DB 부하를 줄이는 방법 (0) | 2026.03.13 |
|---|