영속성 컨텍스트 생명주기 지연 로딩 프록시 N+1 문제 연관관계 📝 문제풀이
◀ 심화 CH 04 📋 목차 심화 CH 06 ▶
🗃️ 심화 CHAPTER 05 · 5년차 트랙

JPA는 언제 SQL을 날리고, 왜 N+1에 걸릴까?

JPA를 "쓸 줄" 아는 것과 "동작을 설명할 수 있는" 것은 달라요. 이번 장은 영속성 컨텍스트가 언제 flush하고, 변경 감지가 어떻게 UPDATE를 만들며, 지연 로딩 프록시가 왜 터지고, N+1이 어디서 새는지를 파고듭니다. 장애 대응과 성능 튜닝, 시니어 면접의 핵심 주제예요.

🎯 이 장을 끝내면
🧠
영속성 컨텍스트
엔티티를 "관리"한다는 것의 의미
1차 캐시 · 변경 감지 · 쓰기 지연 · flush 시점

영속성 컨텍스트(Persistence Context)EntityManager가 엔티티를 담아 관리하는 논리적 공간이에요. 여기에 들어온 엔티티는 managed(영속) 상태가 되고, JPA는 이들을 대신 추적하며 1차 캐시·변경 감지·쓰기 지연이라는 세 가지 마법을 부립니다.

🗄️
1차 캐시
같은 트랜잭션 내 동일 id 재조회는 DB 안 감
🔍
변경 감지
스냅샷과 비교해 바뀌면 자동 UPDATE
📮
쓰기 지연
SQL을 모아뒀다 flush 때 한 번에
🔍 변경 감지(Dirty Checking) — UPDATE를 내가 안 불렀는데? 영속 상태 엔티티를 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=? 자동 발생
}
💡
쓰기 지연(Write-Behind). em.persist()를 여러 번 호출해도 INSERT가 즉시 나가지 않아요. JPA는 SQL을 쓰기 지연 SQL 저장소에 모아뒀다가 flush 시점에 한꺼번에 보냅니다. 덕분에 hibernate.jdbc.batch_size를 주면 JDBC batch로 묶어 네트워크 왕복을 크게 줄일 수 있어요. (단 IDENTITY 전략은 INSERT를 즉시 실행해야 id를 알 수 있어 쓰기 지연·batch 이점이 제한돼요.)
⏱️ flush는 언제 일어나는가 (3가지) ① 트랜잭션 커밋 직전 — 가장 흔한 경우. 커밋 전에 자동 flush.
② JPQL/HQL 쿼리 실행 직전 — 아직 DB에 안 나간 변경을 쿼리가 못 보면 안 되니, 쿼리 전에 flush(기본 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의 함정. merge(e)는 준영속 엔티티 e를 그대로 관리하지 않아요. DB에서 (또는 1차 캐시에서) 엔티티를 찾아 e의 값을 복사한 새 managed 인스턴스를 반환합니다. 그래서 Member managed = em.merge(detached);처럼 반환값을 써야 해요. 또한 merge는 모든 필드를 덮어쓰므로, null인 필드가 있으면 기존 값을 날릴 수 있어 실무에선 변경 감지(조회 후 세터)를 더 선호합니다.
🔄
엔티티 생명주기
transient · managed · detached · removed
이 상태가 무엇이냐에 따라 SQL이 나가고 안 나가고가 갈려요.

엔티티는 네 가지 상태를 오갑니다. 어떤 상태냐가 변경 감지·flush 동작을 결정하기 때문에, 5년차라면 전이를 손끝으로 알아야 해요.

상태의미대표 전이
transient(비영속)new로 막 만든 순수 객체. 영속성 컨텍스트가 모름new 직후
managed(영속)영속성 컨텍스트가 관리 중. 변경 감지 대상persist·find·merge 결과
detached(준영속)한때 managed였으나 분리됨. 변경 감지 안 됨detach·clear·close·트랜잭션 종료
removed(삭제)삭제 예약됨. flush 때 DELETEremove
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
🖼️ 그림으로 보기 — 엔티티 생명주기 상태 전이
new Member() transient 비영속 · 관리 안 됨 managed 영속 · 변경 감지 대상 detached 준영속 · 감지 안 됨 removed 삭제 예약 · flush 시 DELETE persist detach · close · 종료 merge remove find · merge 결과도 managed
🧭
트랜잭션이 끝나면 전부 detached. 스프링에서 @Transactional 메서드를 벗어나면 영속성 컨텍스트가 닫히고, 반환된 엔티티는 모두 detached가 돼요. 그래서 컨트롤러/뷰에서 이 엔티티의 지연 로딩 연관을 건드리면 뒤에 나올 LazyInitializationException이 터집니다.
🕵️
지연 로딩 프록시
진짜가 아니라 대역(프록시)이 온다
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 발생(초기화)
🖼️ 그림으로 보기 — 프록시는 접근하는 순간 초기화돼요
📄
① find(Order)
order만 SELECT. member 자리엔 진짜 객체가 아직 없어요.
🕵️
② 프록시(대역)
getMember()는 HibernateProxy. 아직 DB에 안 가요.
③ getName() 접근
이 순간 SELECT member 실행 → 초기화. 컨텍스트가 닫혔으면 여기서 예외!
⚠️
LazyInitializationException. 프록시를 아직 초기화하지 않은 채로 영속성 컨텍스트가 닫히면(트랜잭션 종료 후), 나중에 그 프록시를 건드리는 순간 초기화할 방법이 없어 LazyInitializationException: could not initialize proxy - no Session이 터져요. 전형적으로 서비스에서 트랜잭션이 끝난 뒤 컨트롤러·뷰(JSP/타임리프)에서 지연 연관을 접근할 때 발생합니다.
🪟 OSIV(Open Session In View)의 트레이드오프 스프링 부트는 spring.jpa.open-in-view=true(기본값)로, 영속성 컨텍스트(=하이버네이트 Session)를 HTTP 응답이 나갈 때까지 열어둬요. 그래서 컨트롤러·뷰에서 지연 로딩을 해도 LazyInitializationException이 안 나요 — 편하죠.
대가: DB 커넥션을 응답 렌더링이 끝날 때까지 붙잡고 있어요. 트래픽이 몰리면 커넥션 풀 고갈로 이어질 수 있습니다. 그래서 대규모/고성능 서비스에서는 OSIV를 끄고(false), 서비스(트랜잭션) 계층 안에서 fetch join·DTO 조회로 필요한 데이터를 미리 로딩해 내보내는 방식을 씁니다.
💡
프록시 초기화 여부 확인. Hibernate.isInitialized(proxy)로 초기화됐는지 검사하고, Hibernate.initialize(proxy)로 강제 초기화할 수 있어요. 트랜잭션 안에서 미리 초기화해두면 밖에서 터지지 않습니다. emf.getPersistenceUnitUtil().isLoaded(entity)도 같은 목적으로 쓰여요.
💥
N+1 문제
쿼리 1번이 어느새 N+1번이 되는 순간
JPA 성능 이슈의 8할이 여기예요.

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
🖼️ 그림으로 보기 — N+1 vs fetch join
지연 로딩 — 루프에서 개별 조회 ① SELECT * FROM orders (1번) SELECT member #1 SELECT member #2 SELECT member #3 … 각 건마다 (N번) 쿼리 = 1 + N 번 fetch join — 한 방(JOIN)에 SELECT o FROM Order o join fetch o.member Order + Member를 조인으로 함께 쿼리 = 1 번
해결책동작주의
fetch joinjoin fetch로 연관을 한 방(JOIN) 쿼리에 함께 조회컬렉션 fetch join은 페이징 불가·row 뻥튀기(distinct 필요)
@EntityGraph어떤 연관을 함께 로딩할지 선언적으로 지정(내부적으로 fetch join)컬렉션은 fetch join과 동일한 페이징 한계
batch fetch sizehibernate.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
⚠️
컬렉션 fetch join + 페이징 = 위험. 일대다(컬렉션) fetch join은 조인 결과로 row 수가 뻥튀기되기 때문에, DB에서 limit으로 자를 수 없어요. 하이버네이트는 이 경우 모든 데이터를 메모리로 읽어와 애플리케이션에서 페이징(HHH000104 경고 로그)해 버립니다 — 데이터가 크면 OOM 위험. 해법: ~ToOne은 fetch join, 컬렉션은 default_batch_fetch_size 지연 로딩하면 페이징도 되고 IN 절로 N+1도 잡혀요.
🧩 batch fetch size가 하는 일 default_batch_fetch_size = 100을 주면, 지연 로딩으로 프록시 100개를 초기화할 때 SELECT ... WHERE member_id IN (?, ?, ... )로 한 번에 묶어 조회해요. N번 나가던 쿼리가 ceil(N/size)으로 줄어듭니다. 실무 권장 조합: OSIV off + ~ToOne fetch join + 컬렉션은 batch size. 여기에 필요하면 DTO 직접 조회로 딱 필요한 컬럼만 가져와요.
🔗
연관관계 매핑
연관관계의 주인은 FK를 가진 쪽
mappedBy·cascade·orphanRemoval을 정확히.

양방향 연관관계에서는 두 객체가 서로를 참조하지만, DB 테이블에는 외래 키(FK)가 하나뿐이에요. 그래서 JPA는 둘 중 하나를 "연관관계의 주인"으로 정해, 그쪽만 FK를 관리(등록·수정)하게 합니다.

👑 연관관계의 주인 = 외래 키를 가진 쪽 주인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자식을 부모가 완전히 소유. 자식이 다른 엔티티와도 연관되면 위험
⚠️
즉시 로딩(EAGER)은 실무에서 지양. @ManyToOne·@OneToOne의 기본값이 EAGER인데, 이건 예측 못 한 조인·N+1을 유발하고 어디서 쿼리가 나가는지 추적을 어렵게 해요. 실무 원칙은 모든 연관을 LAZY로 깔고(@ManyToOne(fetch = LAZY)), 필요한 곳에서만 fetch join/EntityGraph로 명시적으로 함께 로딩하는 것입니다. (@OneToMany·@ManyToMany는 기본이 LAZY.)
🧭
@ManyToMany는 실무에서 피하세요. 중간 테이블을 JPA가 숨겨 관리해 컬럼 추가·세밀한 제어가 불가하고 쿼리가 예측 불가해요. 연결 엔티티(중간 엔티티)를 명시@ManyToOne 두 개로 풀어내는 게 정석입니다.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·장애대응에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 06 📐 시스템 설계와 성능 테스트 — 부하 테스트, 병목 진단, 확장성 (제작 예정)