JPA를 "쓸 줄" 아는 것과 "동작을 설명할 수 있는" 것은 달라요. 이번 장은 영속성 컨텍스트가 언제 flush하고, 변경 감지가 어떻게 UPDATE를 만들며, 지연 로딩 프록시가 왜 터지고, N+1이 어디서 새는지를 파고듭니다. 장애 대응과 성능 튜닝, 시니어 면접의 핵심 주제예요.
영속성 컨텍스트(Persistence Context)는 EntityManager가 엔티티를 담아 관리하는 논리적 공간이에요. 여기에 들어온 엔티티는 managed(영속) 상태가 되고, JPA는 이들을 대신 추적하며 1차 캐시·변경 감지·쓰기 지연이라는 세 가지 마법을 부립니다.
find로 읽어오면 JPA는 그 순간의 스냅샷을 떠둬요. flush 시점에 현재 필드값과 스냅샷을 비교해서 달라진 게 있으면 자동으로 UPDATE SQL을 생성합니다. 그래서 em.update() 같은 메서드는 아예 없어요 — 값만 바꾸면 됩니다.
단, 이건 managed 상태에서만 동작해요. detached 엔티티의 필드를 아무리 바꿔도 UPDATE는 나가지 않습니다.
@Transactional
void changeName(Long id) {
Member m = em.find(Member.class, id); // managed + 스냅샷 저장
m.setName("kim"); // 세터만 호출, update() 없음
// 트랜잭션 커밋 시점에 flush → 스냅샷과 비교 → UPDATE member SET name=? 자동 발생
}
em.persist()를 여러 번 호출해도 INSERT가 즉시 나가지 않아요. JPA는 SQL을 쓰기 지연 SQL 저장소에 모아뒀다가 flush 시점에 한꺼번에 보냅니다. 덕분에 hibernate.jdbc.batch_size를 주면 JDBC batch로 묶어 네트워크 왕복을 크게 줄일 수 있어요. (단 IDENTITY 전략은 INSERT를 즉시 실행해야 id를 알 수 있어 쓰기 지연·batch 이점이 제한돼요.)FlushModeType.AUTO).em.flush() 호출 — 개발자가 강제로.
flush ≠ commit. flush는 영속성 컨텍스트의 변경을 DB에 SQL로 반영(동기화)하는 것일 뿐, 트랜잭션을 끝내지 않아요. flush 후에도 롤백하면 다 되돌아갑니다. 그리고 flush해도 1차 캐시는 비워지지 않아요(clear와 다름).
| 메서드 | 하는 일 |
|---|---|
em.persist(e) | transient 엔티티를 managed로. INSERT 예약(쓰기 지연) |
em.find(T, id) | 1차 캐시 확인 후 없으면 DB 조회. 결과를 managed로 등록 |
em.merge(e) | detached 엔티티의 값을 새 managed 엔티티에 복사. 인자가 아니라 반환값이 관리 대상 |
em.detach(e) | 특정 엔티티를 영속성 컨텍스트에서 분리(detached). 이후 변경 감지 안 됨 |
em.remove(e) | managed 엔티티를 removed로. flush 시 DELETE |
merge(e)는 준영속 엔티티 e를 그대로 관리하지 않아요. DB에서 (또는 1차 캐시에서) 엔티티를 찾아 e의 값을 복사한 새 managed 인스턴스를 반환합니다. 그래서 Member managed = em.merge(detached);처럼 반환값을 써야 해요. 또한 merge는 모든 필드를 덮어쓰므로, null인 필드가 있으면 기존 값을 날릴 수 있어 실무에선 변경 감지(조회 후 세터)를 더 선호합니다.엔티티는 네 가지 상태를 오갑니다. 어떤 상태냐가 변경 감지·flush 동작을 결정하기 때문에, 5년차라면 전이를 손끝으로 알아야 해요.
| 상태 | 의미 | 대표 전이 |
|---|---|---|
| transient(비영속) | new로 막 만든 순수 객체. 영속성 컨텍스트가 모름 | new 직후 |
| managed(영속) | 영속성 컨텍스트가 관리 중. 변경 감지 대상 | persist·find·merge 결과 |
| detached(준영속) | 한때 managed였으나 분리됨. 변경 감지 안 됨 | detach·clear·close·트랜잭션 종료 |
| removed(삭제) | 삭제 예약됨. flush 때 DELETE | remove |
Member m = new Member(); // 1) transient — 아직 아무 SQL 없음
em.persist(m); // 2) managed — INSERT 예약(쓰기 지연)
em.detach(m); // 3) detached — 이제 세터 바꿔도 UPDATE 안 나감
Member merged = em.merge(m); // detached → managed (반환값이 관리 대상)
em.remove(merged); // 4) removed — flush 시 DELETE
@Transactional 메서드를 벗어나면 영속성 컨텍스트가 닫히고, 반환된 엔티티는 모두 detached가 돼요. 그래서 컨트롤러/뷰에서 이 엔티티의 지연 로딩 연관을 건드리면 뒤에 나올 LazyInitializationException이 터집니다.@ManyToOne(fetch = FetchType.LAZY)로 매핑하면, 엔티티를 조회할 때 연관 엔티티는 진짜 객체가 아니라 프록시(가짜 대역)가 채워져요. 실제로 프록시의 필드를 처음 사용하는 순간, 영속성 컨텍스트가 DB에 SELECT를 날려 초기화합니다(지연 로딩).
Order order = em.find(Order.class, 1L); // order만 SELECT
// order.getMember() → Member$HibernateProxy (아직 DB 안 감)
String name = order.getMember().getName(); // 이 순간 SELECT member 발생(초기화)
LazyInitializationException: could not initialize proxy - no Session이 터져요. 전형적으로 서비스에서 트랜잭션이 끝난 뒤 컨트롤러·뷰(JSP/타임리프)에서 지연 연관을 접근할 때 발생합니다.spring.jpa.open-in-view=true(기본값)로, 영속성 컨텍스트(=하이버네이트 Session)를 HTTP 응답이 나갈 때까지 열어둬요. 그래서 컨트롤러·뷰에서 지연 로딩을 해도 LazyInitializationException이 안 나요 — 편하죠.Hibernate.isInitialized(proxy)로 초기화됐는지 검사하고, Hibernate.initialize(proxy)로 강제 초기화할 수 있어요. 트랜잭션 안에서 미리 초기화해두면 밖에서 터지지 않습니다. emf.getPersistenceUnitUtil().isLoaded(entity)도 같은 목적으로 쓰여요.N+1 문제는 목록을 한 번(1) 조회한 뒤, 각 엔티티의 연관 엔티티를 루프에서 개별 조회(N)하느라 쿼리가 1 + N번 나가는 현상이에요. 지연 로딩이든 즉시 로딩이든 둘 다 발생할 수 있습니다(EAGER도 개별 조회로 N+1이 됨).
List<Order> orders = em.createQuery("select o from Order o", Order.class)
.getResultList(); // 1) SELECT * FROM orders (1번)
for (Order o : orders) {
o.getMember().getName(); // 2) SELECT member ... 매 건마다 (N번)
}
// 총 1 + N 번의 SQL
| 해결책 | 동작 | 주의 |
|---|---|---|
| fetch join | join fetch로 연관을 한 방(JOIN) 쿼리에 함께 조회 | 컬렉션 fetch join은 페이징 불가·row 뻥튀기(distinct 필요) |
| @EntityGraph | 어떤 연관을 함께 로딩할지 선언적으로 지정(내부적으로 fetch join) | 컬렉션은 fetch join과 동일한 페이징 한계 |
| batch fetch size | hibernate.default_batch_fetch_size로 지연 로딩을 IN 절로 묶어 N번을 N/size번으로 | 페이징과 함께 쓰기 가장 안전. 1+1 형태로 줄어듦 |
// fetch join — Order와 Member를 한 쿼리로
select o from Order o join fetch o.member
// 컬렉션 fetch join — 중복 제거 필요
select distinct o from Order o join fetch o.orderItems
limit으로 자를 수 없어요. 하이버네이트는 이 경우 모든 데이터를 메모리로 읽어와 애플리케이션에서 페이징(HHH000104 경고 로그)해 버립니다 — 데이터가 크면 OOM 위험. 해법: ~ToOne은 fetch join, 컬렉션은 default_batch_fetch_size로 지연 로딩하면 페이징도 되고 IN 절로 N+1도 잡혀요.default_batch_fetch_size = 100을 주면, 지연 로딩으로 프록시 100개를 초기화할 때 SELECT ... WHERE member_id IN (?, ?, ... )로 한 번에 묶어 조회해요. N번 나가던 쿼리가 ceil(N/size)번으로 줄어듭니다.
실무 권장 조합: OSIV off + ~ToOne fetch join + 컬렉션은 batch size. 여기에 필요하면 DTO 직접 조회로 딱 필요한 컬럼만 가져와요.
양방향 연관관계에서는 두 객체가 서로를 참조하지만, DB 테이블에는 외래 키(FK)가 하나뿐이에요. 그래서 JPA는 둘 중 하나를 "연관관계의 주인"으로 정해, 그쪽만 FK를 관리(등록·수정)하게 합니다.
@ManyToOne 쪽)예요. 주인이 아닌 쪽은 mappedBy로 "나는 거울일 뿐, FK는 저쪽이 관리해"라고 선언합니다.
주인이 아닌 쪽(mappedBy)에만 값을 넣으면 DB에 반영되지 않아요. FK를 바꾸려면 반드시 주인 쪽 값을 설정해야 합니다. 실무에선 양쪽을 함께 세팅하는 연관관계 편의 메서드를 만들어 실수를 막아요.
@Entity
class Order {
@ManyToOne(fetch = FetchType.LAZY) // 주인: FK(member_id) 보유
@JoinColumn(name = "member_id")
private Member member;
}
@Entity
class Member {
@OneToMany(mappedBy = "member") // 주인 아님(거울). FK 관리 X
private List<Order> orders = new ArrayList<>();
}
| 옵션 | 의미 |
|---|---|
cascade | 부모 엔티티의 영속 동작(persist/remove 등)을 자식에 전파. 자식의 생명주기가 부모에 완전히 종속될 때만(예: 게시글-첨부) |
orphanRemoval=true | 부모의 컬렉션에서 자식을 빼면 자동 DELETE. "고아 객체 제거" |
CascadeType.ALL + orphanRemoval | 자식을 부모가 완전히 소유. 자식이 다른 엔티티와도 연관되면 위험 |
@ManyToOne·@OneToOne의 기본값이 EAGER인데, 이건 예측 못 한 조인·N+1을 유발하고 어디서 쿼리가 나가는지 추적을 어렵게 해요. 실무 원칙은 모든 연관을 LAZY로 깔고(@ManyToOne(fetch = LAZY)), 필요한 곳에서만 fetch join/EntityGraph로 명시적으로 함께 로딩하는 것입니다. (@OneToMany·@ManyToMany는 기본이 LAZY.)@ManyToOne 두 개로 풀어내는 게 정석입니다.