스프링 부트 + JPA를 공부하다 보면 처음에는 마법 같던 기능들이
실제로는 철저한 "영속성 컨텍스트" 기반에서 돌아간다는 걸 알게 된다.
이 글에서는 내가 JPA를 공부하면서 핵심적으로 정리했던 내용을 토대로 순차적으로 정리해보겠다.
1. 엔티티는 무엇인가?
Entity란 JPA에서 가장 기본이 되는 단위로 데이터베이스의 테이블과 직접적으로 매핑되는 자바 객체를 의미한다.
예를 들어 우리가 회원 정보를 저장할 수 있는 테이블 MEMBER가 있다고 하면 이 테이블과 매핑되는 자바 클래스는
보통 Member라는 이름을 가질 거고 이 클래스는 @Entity라는 어노테이션을 통해 JPA가 "이건 DB랑 연결된 객체구나" 라고 인식하게 된다.
이렇게 선언된 엔티티는 단순히 필드를 정의해둔 데이터 저장용 객체를 넘어서서 JPA가 관리하고 추적할 수 있는 영속성 컨텍스트의 주인공이 된다.
DB의 한 줄을 자바 객체로 표현한 것이 엔티티라고 할 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}
예시를 보면 @Id는 엔티티의 기본 키를 지정해주는 어노테이션이고 @GeneratedValue는 자동으로 ID가 생성되도록 설정한 것이다.
2. JPA의 구성 요소들
JPA는 단순히 DB와 객체 사이를 연결해주기만 하는 것이 아니다.
그 사이에서 복잡한 동기화와 최적화를 도와주는 여러 구성요소들로 이루어져 있다.
- EntityManager : 내가 작성한 엔티티를 저장,조회,수정,삭제 등 모든 DB 관련 기능을 사용할 수 있도록 해주는 핵심 도구이다. 이 객체를 통해서 JPA의 모든 동작이 실행된다.
- EntityManagerFactory : EntityManager를 만드는 공장이다. 애플리케이션 실행 시점에 한 번 생성하고 필요할 때마다 EntityManager를 만들어 쓰는 구조이다.
- Persistence Context : 영속성 컨텍스트라고 부르며 EntityManager가 관리하는 1차 캐시 공간이다. 여기에 엔티티가 들어오면 영속 상태가 되고 JPA는 그 객체의 변경 여부 등을 추적할 수 있다.
- Persistence Unit : DB 연결에 필요한 설정이다. 어떤 DB를 쓸 건지 어떤 드라이버를 쓸 건지 계정은 뭔지 같은 설정을 yml 파일 등에 정의하게 된다.
spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
driver-class-name: org.h2.Driver
우리가 흔히 application.yml에 설정하는 내용들이 여기에 속한다.
3. 영속성 컨텍스트란?
영속성 컨텍스트는 엔티티를 영속 상태로 관리하는 메모리 공간이다.
JPA는 DB에 직접 접근하지 않고 일단 모든 엔티티를 영속성 컨텍스트라는 공간에 넣고 관리한다.
안에서 무슨 일이 일어날까?
- 1차 캐시 역할: find() 매서드로 같은 ID의 엔티티를 여러 번 조회하더라도 DB에 여러 번 접근하는 것이 아니다. 처음 조회할 때 DB에서 가져오고 이후부턴 이 컨텍스트에서 바로 꺼내면 된다.
- 변경 감지(더티 체킹) : 이 안에 들어온 객체의 상태가 바뀌었는지를 감시한다. 나중에 flush(동기화) 할 때 원래 값과 바뀐 값을 비교해서 자동으로 UPDATE SQL을 만든다.
- 더티라고 하는 이유를 찾아봤는데 DB 안에서 원본에서 상태 수정된 것을 더티라고 한다고 함.
- 쓰기 지연 저장소 : INSERT/UPDATE/DELETE 쿼리를 즉시 실행하지 않고 flush 시점까지 모아뒀다가 한 번에 처리한다.
즉 이 컨텍스트는 단순한 메모리가 아니라 DB 접근을 줄이고 성능을 올려주는 핵심 구조인 것이다.
4. JPA의 엔티티 생명주기
JPA에서 엔티티는 상태에 따라 비영속 → 영속 → 준영속 → 삭제 순으로 상태가 변화한다.
단순한 개념이 아니라 JPA가 엔티티를 어떻게 다루는지를 결정짓는 중요한 기준이다.
표로 나타내보자.
상태 | 설명 |
비영속 (new) | 자바 객체가 생성되었지만, 아직 JPA가 관리하지 않는 상태. 단순히 new 키워드로 만든 상태이다. |
영속 (managed) | EntityManager에 의해 관리되고 있는 상태이다. JPA가 변경을 감지하고 1차 캐시에 등록되며 쿼리도 이때부터 생성 대상이 되는 것이다. |
준영속 (detached) | 원래는 영속 상태였지만 JPA의 관리 대상에서 벗어난 상태이다. em.detach()를 하거나 em.clear(), em.close()가 호출되면 이 상태가 된다. |
삭제 (removed) | em.remove()를 호출하면 이 상태가 된다. 실제 삭제는 flush 시점에 일어나고 그 전까지는 삭제 예약 상태라고 보면 된다. |
내 이름을 직접 넣었다가 지우고 상태가 어떻게 바뀌는지 코드와 주석으로 간단히 구현하자면
// 1. 비영속
Member member = new Member(); // 아직 JPA와 아무 관계 없음
member.setName("홍성민"); // 단순히 자바 객체의 필드에 값을 넣은 것
System.out.println("비영속 상태: " + member.getName()); // → 홍성민
// 2. 영속
em.persist(member); // 이제부터 JPA가 이 객체를 관리하기 시작함
System.out.println("영속 상태 진입 (persist 완료)");
// 값을 바꿔보자.
member.setName("성민짱"); // JPA는 원래 이름이 "홍성민"이었다는 걸 기억함
System.out.println("이름 수정: " + member.getName()); // → 성민짱
// 3. 준영속
em.detach(member); // 이제 JPA는 더 이상 이 객체를 추적하지 않음
System.out.println("detach 호출로 준영속 상태로 전환");
// 다시 값을 바꾸자.
member.setName("홍성민 복귀!"); // 하지만 JPA는 이미 이 객체에 관심이 없음
System.out.println("준영속 상태에서 이름 변경: " + member.getName());
// 4. 삭제
em.merge(member); // 다시 영속 상태로 병합 (준영속 → 영속)
em.remove(member); // 삭제 예약 상태로 바뀜
System.out.println("remove 호출로 삭제 예약 상태가 됨");
// 이 시점까지도 실제 DELETE 쿼리는 DB에 안 날라감
// flush 시점에 DELETE SQL이 실행됨
// 5. 트랜잭션 커밋 시점에 flush 발생
// INSERT / UPDATE / DELETE 쿼리 한꺼번에 날아감
// 영속성 컨텍스트가 닫히고, 모든 엔티티는 준영속 상태로 전환됨
이렇게 된다. 트랜잭션은 뒤에서 다시 설명하겠다.
친구한테 보여주니 "JPA는 원래 이름이 홍성민이었다는 걸 기억한다" 이 말 뜻이 뭐냐고 물어서 간단히 설명하자면 JPA가 더티 체킹을 하기 위해 영속 상태에 진입한 시점의 값을 따로 저장한다는 의미이다.
이걸 스냅샷이라고 부르는데 왜 기억하냐면 어떤 필드가 바뀌었는지 자동으로 감지해서 UPDATE 쿼리를 만들기 위해서이다.
처음 영속 상태에 들어왔을 때의 상태를 기억해야 나중에 flush 시점에 값이 바뀌었는지를 판단할 수 있고 바뀐 경우에만 필요한 UPDATE 쿼리를 생성해서 성능 최적화가 가능한 것이다. 이 내용은 뒤에서 더 설명하겠다.
5. 쓰기 지연(Write-Behind)
이 부분에서 JPA가 진짜 똑똑하다고 느꼈다.
우리가 em.persist()를 호출하면 당연히 바로 DB에 insert SQL이 날아갈 것 같지만 JPA는 쓰기 지연 저장소라는 공간에 쿼리를 임시 저장해두고 flush 시점이 오기 전까진 절대 DB에 보내지 않는다.
아까 영속성 컨텍스트 설명에서도 말했는데 영속성 컨텍스트는 전체를 감싸는 관리실이라고 생각하고 쓰기 지연은 그 안에 포함된 기능 중 하나이다.
@Transactional
public void save() {
Member m = new Member();
m.setName("성민");
em.persist(m); // 쿼리는 아직 안 나감
System.out.println("중간 로그"); // 이게 먼저 찍힘
// 트랜잭션 커밋 시 flush → INSERT SQL 실행됨
}
이 코드를 실행하면 sout이 먼저 출력되고 INSERT SQL은 그 이후에 실행된다.
persist는 단지 영속성 컨텍스트에 등록만 해둔 거고 실제 insert 쿼리는 flush 시점(보통 트랜잭션 커밋)에 한꺼번에 실행된다.
이렇게 모아서 한꺼번에 보내는 전략을 쓰기 지연 전략이라고 하고 이거 덕분에 쿼리 수가 줄고 성능도 좋아진다.
성능이 좋아지는 이유는? 간단히 예시를 들자면
for (int i = 0; i < 1000; i++) {
em.persist(new Member("user" + i));
}
이걸 호출할 때마다 1000개의 insert 쿼리를 날리는 건 매우 비효율적이다.
쓰기 지연 덕분에 이 쿼리들이 모두 메모리에 쌓여 있다가 flush 시점에 한꺼번에 처리가 가능하기 때문에 쿼리 전송 횟수가 줄어들어서 성능 최적화가 좋다.
DB에 쿼리를 날리고 실패하는 경우를 생각해봐도 이미 데이터가 반영되기 때문에 실패 시 이전 상태로 되돌리려면 직접 복잡한 롤백 처리를 수동으로 해야 하기 때문에 복잡하다.
JPA는 트랜잭션이 커밋되기 전까진 실제로 DB에 아무 쿼리도 보내지 않기 때문에 트랜잭션 단위로 안전하게 모아서 한 번에 처리하고 실패 시 전부 롤백이 가능하다.
6. 변경 감지 (Dirty Checking)
JPA는 update 쿼리를 우리가 직접 작성하지 않아도 객체의 필드 값을 변경했다는 사실만으로 update SQL을 알아서 만들어준다.
이 기능이 바로 변경 감지이다.
@Transactional
public void updateMember() {
Member member = em.find(Member.class, 1L); // 영속 상태
member.setName("바뀐 이름"); // 그냥 setter만 호출
}
이렇게 setName()만 했을 뿐인데 트랜잭션이 끝나서 flush가 호출되는 순간 JPA는 원래 값(스냅샷)과 지금 값이 다르다는 걸 감지하고 자동으로 update SQL을 생성해서 실행한다.
어떻게 작동하는 걸까?
- persist()나 find()를 통해 영속 상태가 되면 JPA는 그 시점의 값을 스냅샷으로 따로 복사해둔다.
- 이후에 setName() 같은 걸로 필드 값을 바꾸면 flush 시점에 이 스냅샷과 현재 값을 비교해서 어 바뀌었네? 하면 그 부분만 반영된 UPDATE 쿼리를 자동으로 생성해주는 것이다.
우리가 SQL을 안 써도 객체만 잘 바꾸면 알아서 DB도 바꿔준다.
완전 객체지향스럽다.
7. 지연 로딩(Lazy Loading)
우리가 JPA로 엔티티를 조회할 때 그 엔티티가 다른 엔티티와 연관된 관계를 갖고 있다면
그 연관된 객체까지 무조건 같이 불러오는 게 좋을까?
예를 들어 Order(주문) 엔티티가 Member(회원) 엔티티를 참조하고 있을 때 나는 주문 정보만 필요한데 굳이 회원 정보까지 매번 같이 들고 올 필요는 없을 것이다.
이럴 때 쓸 수 있는 것이 지연 로딩이다.
지연 로딩이란 엔티티를 조회할 때 연관된 객체를 지금 당장 가져오는 것이 아니라 실제로 그 객체가 필요해졌을 때 DB에서 조회하는 방식이다.
반대로 바로 가져오는 것은 즉시 로딩(Eager Loading)이라고 한다.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY) // ← 여기서 Lazy 설정
private Member member;
}
이 코드처럼 Order 엔티티에서 Member를 참조할 때 지연 로딩을 설정하면 JPA는 Order를 조회할 때 Member는 일단 안 가져온다.
Order order = orderRepository.findOne(1L); // Order만 조회됨
Member member = order.getMember(); // 아직 SELECT 안 날라감
String name = member.getName(); // 이때 SELECT member 쿼리 실행
order.getMember()를 호출하면 그 시점에 JPA는 Member 객체의 껍데기(프록시 객체)만 넘겨준다.
실제로 name, email 같은 정보를 꺼내오려고 하면 그 때 진짜 쿼리가 DB에 날아가고 실제 데이터를 가져오는 것이다.
쉽게 말하면,
getMember()는 문만 열어놓는 거고 getMember().getName() 할 때 진짜 사람이 들어오는 느낌이다.
Order 리스트를 쭉 조회할 때 모든 주문에 해당하는 회원 정보까지 미리 조회하면 너무 무겁기 때문에 이런 경우 지연 로딩을 걸어두면 필요한 순간에만 조회해서 메모리 낭비 없이 처리할 수 있는 것이다.
주의할 점도 있는데 지연 로딩은 조회 타이밍이 늦춰진다는 특징이 있기 때문에 반드시 트랜잭션 안에서 동작해야 안전하다.
- 트랜잭션이 끝나면 영속성 컨텍스트도 닫히고 그 안에서 관리하던 프록시 객체도 동작하지 않기 때문.
- 이 상태에서 지연 로딩을 시도하면 LazyInitializationException 예외가 발생한다.
@Transactional
public OrderDto getOrder(Long id) { // DTO란 계층간 데이터 전달을 위한 객체(Controller ↔ Service ↔ Repository)
Order order = orderRepository.findOne(id);
String memberName = order.getMember().getName(); // OK (트랜잭션 안)
return new OrderDto(order.getId(), memberName);
}
정리하자면
- 지연 로딩은 연관 객체를 지금 바로 가져오는 것이 아닌 실제 사용할 때 쿼리를 날려 가져오는 방식.
- 장점: 필요한 순간에만 데이터를 불러와서 성능이 좋아짐
- 내부 구조: 프록시 객체를 통해 껍데기만 먼저 넘겨주고 필요할 때 진짜 조회
- 주의: 트랜잭션 범위 안에서 사용해야 안전
8. 트랜잭션 종료 시 무슨 일이 일어날까?
앞에서도 계속 설명했던 내용이지만 다시 정리하겠다.
JPA를 사용할 때 흔히 @Transactional이라는 어노테이션을 붙여서 메서드 단위로 트랜잭션을 연다.
그 메서드가 예외 없이 정상적으로 끝나면 트랜잭션이 커밋되는데 이 커밋 시점에 flush가 자동으로 호출된다.
이 때 영속성 컨텍스트 안에 있던 쓰기 지연 SQL들이 한꺼번에 날아가고 모든 SQL이 이 시점에 DB에 전송된다.
flush가 끝나면 영속성 컨텍스트는 닫히게 되고 그 안에 있던 모든 엔티티들은 더 이상 JPA가 추적하지 않는 준영속 상태가 되는 것이다.
쓰레기장에 쓰레기 모아두면, 한 번에 쓰레기차가 와서 싹 치우는 느낌이다.
9. 전체 흐름 요약 - 객체가 DB로 가는 과정
- new 엔티티 생성
→ 아직 비영속 상태. 단순 자바 객체일 뿐, DB도, JPA도 관심 없음.
- EntityManager.persist(엔티티)
→ 객체가 영속 상태가 됨.
→ 영속성 컨텍스트에 등록되고, 1차 캐시, 변경 감지, 쓰기 지연 대상이 됨.
→ 하지만 아직 DB에 INSERT SQL 날아가지 않음
- 필드 값 수정 (setXxx())
→ 아무것도 안 일어남. 단순 자바 객체 필드 변경일 뿐.
→ 하지만 JPA는 몰래 ‘스냅샷’과 비교할 준비를 함 (변경 감지 준비 중).
- 트랜잭션 커밋 시점 (@Transactional 메서드 종료)
→ flush 발생.
→ 쓰기 지연 저장소에 있던 INSERT/UPDATE/DELETE 쿼리 한꺼번에 전송.
→ DB 반영 완료.
- EntityManager 종료 → 영속성 컨텍스트 종료
→ 객체는 더 이상 추적되지 않음.
→ 즉, 준영속 상태로 전환됨. 이제부터는 아무리 setName() 해도 SQL이 안 나감.
결론적으로 생각하자면 JPA는 엔티티의 상태 변화를 중심으로 하는 것 같다.
new 해서 들고 있을 땐 JPA가 모르고 (비영속)
persist해서 등록시키면 JPA가 책임지고 관리하고(영속)
그 안에서 일어나는 모든 변동 사항은 감지되고 필요할 때만 DB로 쿼리를 보내고(쓰기 지연 + 변경 감지)
flush나 commit 시점에 진짜 쿼리가 나가고 DB가 바뀌고
트랜잭션이 끝나면 더 이상 감시를 하지 않는(준영속)
이 과정이 중요하다.
알고 공부하는 것과 모르고 공부하는 것은 천지차이이다.
점점 복잡해질수록 공부하기 싫어지고 포기하고 싶어지지만 그 본질을 깨닫고 재미를 붙이면 순식간에 실력이 느는 것 같다.
Controller(요청 받음) -> Service(트랜잭션 열고 비즈니스 로직 처리) -> Repository(JPA로 DB와 CRUD) -> Entity(진짜 DB처럼 객체 설계)
그리고 이 모든 흐름을 영속성 컨텍스트가 자연스럽게 감싸고 있다는 것을 깨닫고 공부를 해보자.
'Backend > JPA' 카테고리의 다른 글
[JPA] 다대일과 일대다 연관관계 매핑 이해하기 (0) | 2025.04.11 |
---|---|
[JPA] 간단한 문제 해결(회원가입,조회) (0) | 2025.04.10 |