Загружаем каталог…
Загружаем каталог…
김영한님의 자바 ORM 표준 JPA 프로그래밍 강의를 듣고 정리한 글입니다. 실습 환경: PostgreSQL + Hibernate 6(jakarta) 1. 프록시 문제 제기: Member를 조회할 때 Team도 함께 조회해야 할까? // 1 회원과 팀을 함께 출력 public void printUserAndTeam(Long memberId) { Member member = em.find(Member.class, memberId); Team team = member.getTeam(); System.out.println("회원 이름: " + member.getUsername()); System.out.println("소속팀: " + team.getName()); } // 2 회원만 출력 public void printUser(Long memberId) { Member member = em.find(Member.class, memberId); System.out.println("회원 이름: " + member.getUsername()); } 1은 Team이 필요하니 같이 가져오면 이득 입니다. 2는 Team을 쓰지도 않는데 같이 가져오면 손해 입니다. 비즈니스 로직마다 필요한 데이터가 다릅니다. JPA는 이 문제를 프록시 와 지연 로딩 으로 해결합니다. 지연 로딩을 이해하려면 먼저 프록시를 알아야 합니다. em.find() vs em.getReference() 메서드 동작 em.find() DB를 통해서 실제 엔티티 객체 를 조회 (바로 SELECT) em.getReference() DB 조회를 미루는 가짜(프록시) 엔티티 객체 를 조회 (SELECT 안 함) Member findMember = em.getReference(Member.class, member.getId()); System.out.println("findMember = " + findMember.getClass()); // findMember = class hellojpa.Member$HibernateProxy$a1b2c3 ← 가짜! System.out.println("findMember.id = " + findMember.getId()); // SELECT 안 나감 (id는 이미 알고 있으니까) System.out.println("findMember.username = " + findMember.getUsername()); // 이 시점에 SELECT 쿼리 실행! 💡 강의 자료에는 프록시 클래스명이 javassist 로 나오지만, Hibernate 6은 ByteBuddy 를 써서 Member$HibernateProxy$... 형태로 출력됩니다. 프록시의 구조 Member (실제 클래스) - id, name - getId(), getName() △ │ 상속 MemberProxy (가짜) - Member target = null ← 실제 객체를 가리킬 참조 - getId(), getName() 실제 클래스를 상속받아서 만들어집니다. (Hibernate가 런타임에 자동 생성) 실제 클래스와 겉모양이 같습니다. 사용하는 입장에서는 진짜 객체인지 프록시인지 구분하지 않고 쓰면 됩니다. (이론상) 프록시는 실제 객체의 참조(target)를 보관 합니다. 프록시의 메서드를 호출하면 실제 객체의 메서드를 대신 호출(위임, delegate) 합니다. 프록시 객체의 초기화 Member member = em.getReference(Member.class, 1L); member.getName(); 1. Client → member.getName() 호출 2. 프록시: "target이 null이네?" → 영속성 컨텍스트에 초기화 요청 3. 영속성 컨텍스트 → DB 조회 4. 실제 Member 엔티티 생성 5. 프록시의 target에 실제 엔티티 연결 → target.getName() 호출 💡 비유: 프록시는 택배 송장 과 같습니다. 송장(프록시)만 받았을 때는 상자를 열어 보지 않습니다. 송장에 적힌 운송장 번호(id) 는 바로 알 수 있습니다. 실제 내용물(name)이 궁금해지는 순간 그제야 창고(DB)에 가서 물건을 가져옵니다. 2. 프록시의 특징 (중요! 실무에서 헷갈리는 부분) 1 프록시 객체는 처음 사용할 때 한 번만 초기화된다 member.getName(); // SELECT 실행 member.getName(); // 이미 초기화됨 → SELECT 없음 2 초기화되어도 프록시가 실제 엔티티로 바뀌는 게 아니다 초기화되면 프록시를 통해서 실제 엔티티에 접근 할 수 있게 될 뿐입니다. getClass() 를 찍어 보면 초기화 후에도 여전히 Member$HibernateProxy$... 입니다. 3 타입 체크 시 == 대신 instanceof 를 써야 한다 프록시는 원본 엔티티를 상속 받은 다른 클래스입니다. Member m1 = em.find(Member.class, member1.getId()); // 실제 객체 Member m2 = em.getReference(Member.class, member2.getId()); // 프록시 System.out.println(m1.getClass() == m2.getClass()); // false 😱 System.out.println(m2 instanceof Member); // true ✅ ⚠️ 실무에서는 내가 받은 객체가 프록시인지 아닌지 모르는 경우가 많습니다. 그래서 엔티티 타입 비교는 항상 instanceof 로 해야 합니다. equals() 를 직접 구현할 때도 getClass() 비교 대신 instanceof 를 쓰고, 필드 대신 getter 로 비교해야 프록시에서도 안전합니다. 4 영속성 컨텍스트에 이미 있으면 getReference()를 해도 실제 엔티티를 반환한다 Member m1 = em.find(Member.class, member.getId()); // 1차 캐시에 실제 객체 저장 Member ref = em.getReference(Member.class, member.getId()); // 실제 객체 반환! System.out.println(ref.getClass()); // class hellojpa.Member System.out.println(m1 == ref); // true 이미 1차 캐시에 실제 객체가 있는데 굳이 프록시를 만들 이유가 없습니다. 💡 반대 상황도 있습니다. Member ref = em.getReference(Member.class, member.getId()); // 프록시 Member find = em.find(Member.class, member.getId()); // 프록시 반환! System.out.println(ref == find); // true 먼저 프록시가 만들어졌다면 em.find() 도 같은 프록시를 반환 합니다. 왜일까요? JPA는 같은 트랜잭션 안에서 같은 엔티티의 == 비교가 항상 true임을 보장 해야 하기 때문입니다. (영속성 컨텍스트의 동일성 보장) 5 준영속 상태에서 프록시를 초기화하면 예외가 발생한다 Member ref = em.getReference(Member.class, member.getId()); em.detach(ref); // 또는 em.close(), em.clear() ref.getUsername(); // 💥 LazyInitializationException org.hibernate.LazyInitializationException: could not initialize proxy - no Session 프록시 초기화는 영속성 컨텍스트의 도움 을 받아서 DB를 조회하는 건데, 영속성 컨텍스트에서 분리됐으니 조회할 방법이 없습니다. 💡 실무에서 정말 자주 만나는 에러입니다. 예: 서비스 계층에서 트랜잭션이 끝난 뒤, 컨트롤러나 뷰에서 member.getTeam().getName() 을 호출하는 경우 트랜잭션이 끝나면 영속성 컨텍스트도 닫히기 때문에 이 에러가 납니다. 해결책은 뒤에서 배울 페치 조인 , 또는 트랜잭션 안에서 필요한 데이터를 미리 DTO로 변환하는 것입니다. 3. 프록시 확인 방법 목적 방법 초기화 여부 확인 emf.getPersistenceUnitUtil().isLoaded(entity) 프록시 클래스 확인 entity.getClass().getName() 출력 → $HibernateProxy$ 포함 여부 강제 초기화 Hibernate.initialize(entity); (Hibernate 제공) 강제 초기화 (JPA 표준) 표준에는 없음 → member.getName() 처럼 메서드를 직접 호출 Member ref = em.getReference(Member.class, member.getId()); System.out.println(emf.getPersistenceUnitUtil().isLoaded(ref)); // false Hibernate.initialize(ref); // 강제 초기화 System.out.println(emf.getPersistenceUnitUtil().isLoaded(ref)); // true 💡 em.getReference() 는 실무에서 직접 쓸 일이 거의 없습니다. 하지만 지연 로딩이 내부적으로 이 프록시를 사용 하기 때문에 원리를 꼭 알아야 합니다. 4. 즉시 로딩과 지연 로딩 지연 로딩 (LAZY) - 프록시로 조회 "Member만 쓰는 로직이 대부분이라면?" @Entity public class Member { @Id @GeneratedValue private Long id; @Column(name = "USERNAME") private String name; @ManyToOne(fetch = FetchType.LAZY) // ★ @JoinColumn(name = "TEAM_ID") private Team team; } Member member = em.find(Member.class, 1L); // SELECT * FROM member WHERE id = 1 ← Team은 조회 안 함 Team team = member.getTeam(); System.out.println(team.getClass()); // Team$HibernateProxy$... (프록시) team.getName(); // SELECT * FROM team WHERE id = ? ← 실제 Team을 사용하는 시점에 조회 1. 로딩 → member1 (실제 엔티티) 2. 지연 로딩 LAZY → team1 (프록시 엔티티) Member를 조회할 때 team 필드에는 프록시를 넣어 둡니다. team.getName() 처럼 실제로 Team의 데이터를 사용하는 시점에 초기화(DB 조회)됩니다. ⚠️ member.getTeam() 만으로는 초기화되지 않습니다. 프록시를 꺼낸 것뿐입니다. getTeam().getName() 처럼 프록시의 메서드를 호출해야 쿼리가 나갑니다. 즉시 로딩 (EAGER) - 함께 조회 "Member와 Team을 항상 같이 쓴다면?" @ManyToOne(fetch = FetchType.EAGER) // ★ @JoinColumn(name = "TEAM_ID") private Team team; Member member = em.find(Member.class, 1L); select m.*, t.* from member m left join team t on m.team_id = t.team_id where m.id = ? Member를 조회할 때 항상 Team도 함께 조회합니다. JPA 구현체는 가능하면 조인을 사용해서 SQL 한 번에 함께 조회합니다. team 에는 프록시가 아니라 실제 엔티티 가 들어갑니다. 5. 프록시와 즉시 로딩 주의 ⚠️ ✅ 가급적 지연 로딩만 사용하세요. (특히 실무에서) 이유 1 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다 연관관계가 2~3개일 때는 괜찮지만, 실무에서는 엔티티 하나에 연관관계가 10개 넘게 있기도 합니다. 전부 EAGER면 Member 하나 조회했는데 테이블 10개가 조인 되는 쿼리가 나갑니다. em.find(Member.class, 1L); // → member join team join locker join order join ... 😱 이유 2 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다 (가장 중요!) // Member.team이 EAGER일 때 List<Member> members = em.createQuery("select m from Member m", Member.class) .getResultList(); 실제로 나가는 쿼리: select * from member -- 1번: JPQL 그대로 SQL로 번역 (회원 10명 조회) select * from team where team_id = 1 -- ↓ 그런데 Team이 EAGER네? 채워야 함 select * from team where team_id = 2 select * from team where team_id = 3 ... -- N번: 회원 수만큼 추가 쿼리! 왜 이런 일이 생길까? em.find() 는 JPA가 내부에서 최적화(조인)를 할 수 있습니다. 하지만 JPQL은 작성한 그대로 SQL로 번역 됩니다. select m from Member m 이면 select * from member 만 나갑니다. 결과를 받아 보니 Team이 EAGER 로 설정되어 있습니다. EAGER는 "반환 시점에 반드시 채워져 있어야 한다"는 뜻입니다. 그래서 JPA가 회원마다 Team을 조회하는 쿼리를 추가로 날립니다. 최초 쿼리 1번 + 추가 쿼리 N번 = N+1 문제 💡 회원 1,000명이면 쿼리가 1,001번 나갑니다. 서비스 장애로 이어질 수 있는 대표적인 성능 문제입니다. LAZY로 바꾸면 해결될까? List<Member> members = em.createQuery("select m from Member m", Member.class) .getResultList(); // 쿼리 1번 (Team은 프록시) for (Member m : members) { m.getTeam().getName(); // 루프 돌면서 Team 조회... 결국 N번 } LAZY로 바꾸면 당장은 쿼리 1번 만 나가지만, 나중에 Team을 꺼내 쓰면 결국 N번이 나갑니다. ✅ 진짜 해결책: JPQL 페치 조인 em.createQuery("select m from Member m join fetch m.team", Member.class) 필요한 시점에 딱 필요한 연관관계만 조인해서 한 번에 가져옵니다. 그 밖에 엔티티 그래프 , 배치 사이즈 같은 방법도 있습니다. (객체지향 쿼리 장에서 자세히 다룸) 기본값 정리 → ~ToOne 은 직접 LAZY로 바꿔야 한다 어노테이션 기본값 조치 @ManyToOne EAGER 👉 fetch = FetchType.LAZY 로 변경 @OneToOne EAGER 👉 fetch = FetchType.LAZY 로 변경 @OneToMany LAZY 그대로 @ManyToMany LAZY 그대로 (애초에 안 씀) 💡 외우는 법: ~ToOne 은 하나라서 같이 가져와도 부담이 없을 것 같아 EAGER , ~ToMany 는 많아서 부담되니 LAZY 가 기본값입니다. 하지만 실무에서는 전부 LAZY 로 통일합니다. 6. 지연 로딩 활용 이론적인 활용법 관계 사용 빈도 전략 Member - Team 자주 함께 사용 즉시 로딩 Member - Order 가끔 사용 지연 로딩 Order - Product 자주 함께 사용 즉시 로딩 member1 ──(EAGER) team──→ teamA (실제 엔티티) │ └─(LAZY) orders──→ 주문내역 List (프록시 컬렉션) │ └─(EAGER) product──→ 상품A Member 조회 시 → Team은 같이, 주문 목록은 프록시로 주문 목록을 실제로 사용하는 시점에 → 주문 조회 + 주문의 Product는 즉시 로딩으로 같이 💡 컬렉션도 지연 로딩이 됩니다. Hibernate는 컬렉션을 PersistentBag 같은 래퍼 컬렉션 으로 감싸서 관리합니다. 🚨 실무에서는 이렇게! 모든 연관관계에 지연 로딩을 사용해라! 실무에서 즉시 로딩을 사용하지 마라! 함께 조회가 필요하면 JPQL 페치 조인 이나 엔티티 그래프 기능을 사용해라! 즉시 로딩은 상상하지 못한 쿼리 가 나간다. "Member와 Team을 자주 같이 쓴다"는 판단은 지금 기준 일 뿐입니다. 화면이 늘어나면 Team이 필요 없는 조회도 생깁니다. 기본은 LAZY 로 깔아 두고, 같이 필요한 곳에서만 페치 조인으로 골라서 가져오는 것이 안전합니다. 7. 영속성 전이: CASCADE 언제 쓰나? 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 예: 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장 Parent 1 ──── * Child CASCADE 없이 저장하면 @Entity public class Parent { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "parent") private List<Child> childList = new ArrayList<>(); public void addChild(Child child) { // 연관관계 편의 메서드 childList.add(child); child.setParent(this); } } @Entity public class Child { @Id @GeneratedValue private Long id; private String name; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") private Parent parent; } Child child1 = new Child(); Child child2 = new Child(); Parent parent = new Parent(); parent.addChild(child1); parent.addChild(child2); em.persist(parent); em.persist(child1); // 😩 자식도 하나하나 persist 해야 함 em.persist(child2); CASCADE를 적용하면 @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST) private List<Child> childList = new ArrayList<>(); Parent parent = new Parent(); parent.addChild(child1); parent.addChild(child2); em.persist(parent); // ✅ 부모만 persist하면 자식도 함께 저장 parent ── 영속화 ──→ (Cascade.PERSIST, 영속성 전이) ──→ children [child1, child2] ⚠️ CASCADE 주의 영속성 전이는 연관관계 매핑과 아무 관련이 없습니다. 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐 입니다. cascade 옵션은 "부모에게 한 persist() / remove() 를 자식에게도 똑같이 해 줘"라는 뜻입니다. FK나 연관관계의 주인과는 별개입니다. CASCADE 종류 옵션 설명 ALL 모두 적용 ← 자주 씀 PERSIST 영속 (저장할 때만) ← 자주 씀 REMOVE 삭제 MERGE 병합 REFRESH REFRESH DETACH DETACH 언제 써야 할까? ✅ 자식의 소유자가 하나일 때만 쓰세요. (단일 소유자) 상황 CASCADE 게시글 ↔ 첨부파일 (첨부파일은 그 게시글에서만 관리) ✅ OK 주문 ↔ 주문상품 (주문상품은 주문에서만 관리) ✅ OK 상품 ↔ 주문상품 (주문상품을 주문도, 상품도 참조) ❌ 쓰면 안 됨 회원 ↔ 팀 (팀은 독립적으로 관리) ❌ 쓰면 안 됨 조건 2가지: 부모와 자식의 라이프사이클이 거의 같을 때 (같이 생성, 같이 삭제) 자식을 다른 엔티티가 참조하지 않을 때 ⚠️ 왜 위험할까? Child를 다른 엔티티(예: Other )도 참조하고 있는데 Parent에 CascadeType.REMOVE 를 걸었다면, Parent를 삭제할 때 Child도 같이 삭제되어 Other가 참조하던 데이터가 사라집니다. (FK 에러 또는 데이터
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
프록시와 연관관계 관리 - 지연 로딩, CASCADE, 고아 객체. 김영한님의 자바 ORM 표준 JPA 프로그래밍 강의를 듣고 정리한 글입니다. 실습 환경: PostgreSQL + Hibernate 6(jakarta) 1. 프록시 문제 제기: Member를 조회할 때 Team도 함께 조회해야 할까? // 1 회원과 팀을 함께 출력 public void printUserAndTeam(Long memberId) { Member member = em.find(Member.class, memberId); Team team = member.getTeam(); System.out.println("회원 이름: " + member.getUsername()); System.out.println("소속팀: " +…
Открыть источник