[자바 ORM 표준 JPA 프로그래밍] 7주차 스터디

박서영·2026년 5월 9일

08장.프록시

  • 프록시와 지연로딩, 즉시로딩: 객체가 데이터베이스에 저장되어 연관된 객체를 마음껏 탐색하기 어려운 상황에서 JPA 구현체들은 이 문제를 해결하기 위해 프록시를 사용함. 프록시를 사용하면 연관된 객체를 처음부터 데이터베이스에서 조회하는 것이 아니라 실제 사용하는 시점에 데이터베이스에서 조회할 수 있음. 다만, 자주 함께 사용하는 객체들은 조인을 사용해 함께 조회하는 것이 효과적임.
  • 영속성 전이와 고아 객체: JPA는 연관된 객체를 함께 저장하거나 함께 삭제할 수 있는 영속성 전이와 고아 객체 제거라는 편리한 기능을 제공함.

8.1 프록시

엔티티 조회 시 연관된 엔티티들이 항상 사용되지는 않는다.

예) 회원 엔티티 조회 시 연관된 팀 엔티티의 사용 여부

  • 회원과 팀 정보를 출력하는 비즈니스 로직
public void printUserAndTeam(String memberId) {
	Member member = em.find(Member.class, memberId);
    Team team = member.getTeam();
    
    System.out.println("회원이름: "+member.getUsername());
    System.out.println("소속팀: "+team.getName());
}
  • 회원 정보만 출력하는 비즈니스 로직
public String printUser(String memberId) {
	Member member = em.find(Member.class, memberId);
    System.out.println("회원 이름: "+member.getUsername());
}

printUsername()의 경우 memberId를 사용해 회원 엔티티와 팀을 모두 출력하는 반면, printUser()의 경우 회원 엔티티만을 출력한다.

이때 printUser() 메소드는 회원 엔티티만 사용하기에 em.find()로 회원 엔티티를 조회할 때 회원과 연관된 팀 엔티티까지 데이터베이스에서 함께 조회해 두는 것은 비효율적이다.

따라서 JPA는 이런 문제 해결을 위해 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 한다.

즉, team.getName()처럼 엔티티 값을 실제로 사용할 때 데이터베이스에서 필요한 데이터를 조회하는 것이다.

이런 지연 로딩 기능에서 실제 엔티티 객체 대신, 데이터베이스 조회를 지연할 수 있는 가짜 객체프록시 객체라고 한다.

(1) 프록시 기초

JPA에서 식별자로 엔티티 하나를 조회할 때에는 EntityManager.find()를 사용한다. 해당 메소드는 영속성 컨텍스트에 엔티티가 없으면 데이터베이스를 조회한다.

Member member = em.find(Member.class, "member1");

즉, 위처럼 엔티티를 직접 조회하면 조회한 엔티티의 사용여부와 관계없이 데이터베이스를 조회하게된다.

만일 데이터베이스 조회를 실제 사용 시점까지 미룰 때는 EntityManager.getReference() 메소드를 사용한다. 이때 JPA는 데이터베이스를 조회하지 않고, 실제 엔티티 객체도 생성하지 않는 대신 데이터베이스 접근을 위임한 프록시 객체를 위임한다.

프록시 객체의 초기화

프록시 객체의 초기화: 실제 엔티티가 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는 것

//MemberProxy 변환
Member member = em.getReference(Member.class, "id1");
member.getName();
class MemberProxy extends Member {
	Member target = null;
    
    public String getName() {
    	if (target == null) {
        	//2. 초기화 요청
            //3. DB 조회
            //4. 실제 엔티티 생성 및 참조 보관
            this.target = ...;
        }
        
        //5. target.getName();
        return target.getName();
    }
}

  1. 프록시 객체에 member.getName()을 호출해서 실제 데이터를 조회한다.
  2. 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데, 이를 초기화라한다.
  3. 영속성 컨텍스트는 데이터베이스를 조회해 실제 엔티티 객체를 생성한다.
  4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 변수에 보관한다.
  5. 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환

프록시의 특징

  • 실제 클래스를 상속받아 만들어지기에 실제 클래스와 겉모양이 동일하다.
  • 즉, 사용할 때는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용해도 된다.
  • 실제 객체에 대한 참조를 보관한다.
  • 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
  • 프록시 객체는 처음 사용 시 한 번만 초기화됨.
  • 프록시 객체를 초기화했다고, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 프록시 객체가 초기화되며, 프록시 객체를 통해 실제 엔티티에 접근할 수 있다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해 사용해야한다.
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없기에 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.
  • 초기화는 영속성 컨텍스트의 도움을 받아야한다. 따라서 준영속 상태의 프록시를 초기화하면 문제가 발생한다.

준영속 상태와 초기화

//MemberProxy 반환
Member member = em.getReference(Member.class, "id1');
transaction.commit();
em.close(); //영속성 컨텍스트 종료

member.getName(); //준영속 상태 초기화 시도 -> 예외발생

em.close()를 통해 영속성 컨텍스트를 종료하게되면, member는 준영속 상태가 된다. 이때 member.getName()을 호출하면 프록시를 초기화해야하는데 영속성 컨텍스트가 없기에 실제 엔티티 조회가 불가해 예외가 발생한다.

(2) 프록시와 식별자

엔티티를 프록시로 조회할 때 식별자(PK)값을 파라미터로 전달하는데 프록시 객체는 이 식별자값을 보관한다.

Team team = em.getReference(Team.class, "team1"); //식별자 보관
team.getId(); //초기화X

프록시 객체는 식별자를 이미 가지고 있기에, 식별자 값을 조회해도 프록시 초기화가 발생하지는 않는다. 단, 엔티티 접근방식을 프로퍼티(@Access(AccessType.PROPERTY)로 설정한 경우에만 초기화를 하지 않는다.

만약 엔티티 접근방식을 필드(@Access(AccessType.FIELD))로 설정하면 JPA는 getId() 메소드가 id만 조회하는 메소드인지 다른 필드까지 활용해서 어떤 일을 하는 메소드인지 알지 못하기에 프록시 객체를 초기화한다.

프록시는 다음처럼 연관관계 설정 시 유용하게 사용할 수 있다.

Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1"); //SQL 실행X
member.setTeam(team);

연관관계 설정 시에는 식별자값만 사용하기에 프록시를 사용하게되면 데이터베이스 접근 횟수를 줄일 수 있음. 연관관계 설정 시에는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않는다.

(3) 프록시 확인

JPA에서 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인할 수 있음. 아직 초기화되지 않은 프록시 인스턴스는 false를 반환한다. 이미 초기화되었거나 프록시 인스턴스가 아닐 때에는 true를 반환한다.

boolean isLoaded = em.getEntityManagerFactory()
					.getPersistenceUnitUtil().isLoaded(entity);
//또는 boolean isLoad = emf.getPersistenceUnitUtil().isLoaded(entity);

조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지를 확인하기 위해서는 클래스명을 직접 출력하면 된다. ...javassist...라고 적혀있으면 프록시인 것을 알 수 있다. 출력 결과는 프록시 생성 라이브러리에 따라 달라질 수 있다.

System.out.println("memberProxy = 		qw"+member.getClass().getName());

8.2 즉시 로딩과 지연로딩

프록시 객체는 주로 연관된 엔티티를 지연로딩하기 위해 사용한다.

Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); //객체 그래프 탐색
System.out.println(team.getName()); //팀 엔티티 사용

JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 두 가지 방법을 제공한다.

  • 즉시로딩: 엔티티를 조회할 때 연관도니 엔티티도 함께 조회한다
    • 설정방법: @ManyToOne(fetch = FetchType.EAGER)
  • 지연로딩: 연관된 엔티티를 실제 사용할 때 조회
    • 설정방법: @ManyToOne(fetch = FetchType.LAZY)

(1) 즉시 로딩

즉시 로딩 사용 시, @ManyToOnefetch 속성을 FetchType.EAGER로 설정한다.

@Entity
public class Member {
	@ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
   	private Team team;
}

Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); //객체 그래프 탐색

회원과 팀을 즉시 로딩으로 설정하였기에, em.find(Member.class, "member1");로 회원을 조회하는 순간 팀도 함께 조회한다.

이때 쿼리를 2번 실행하지 않고, JPA 구현체는 즉시 로딩 최적화를 위해 가능하면 조인 쿼리를 사용한다. 즉, 회원과 팀을 조인해 쿼리 하나로 두 엔티티를 모두 조회한다.

SELECT
	M.MEMBER_ID AS MEMBER_ID,
    M.TEAM_ID AS TEAM_ID,
    M.USERNAME AS USERNAME,
    T.TEAM_ID AS TEAM_ID,
    T.NAME AS NAME
FROM MEMBER M LEFT OUTER JOIN 
	 TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID='member1';

(2) 지연 로딩

지연 로딩 사용을 위해서는, @ManyToOnefetch 속성을 FetchType.LAZY로 지정한다.

@Entity
public class Member {
	@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    //...
}

회원과 팀을 지연 로딩으로 설정했기에 em.find(Member.class, "member1"); 호출 시, 회원만 조회하고 팀은 조회하지 않는다. 대신 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둔다.

Team team = member.getTeam(); //프록시 객체

반환되는 팀 객체는 프록시 객체로, 이 프록시 객체는 실제 사용까지 데이터 로딩을 미루게된다.

(3) 즉시 로딩, 지연 로딩 정리

연관된 엔티티를 처음부터 모두 영속성 컨텍스트에 올려두는 것은 현실적이지 않다. 다만, 필요할 때마다 SQL을 실행해 연관된 엔티티를 지연 로딩하는 것도 최적화 관점에서 보면 좋지 않다고 한다.

예를 들어 애플리케이션 로직에서 회원과 팀 엔티티를 같이 사용하는 경우가 많을 때면, SQL 조인을 사용해 둘을 한 번에 조회하는 것이 더 효율적이다.


8.3 지연 로딩 활용

사내 주문 관리 시스템

  • 회원은 팀 하나에만 소속할 수 있다. (N:1)
  • 회원은 여러 주문 내역을 가진다. (1:N)
  • 주문내역은 상품정보를 가진다. (N:1)


    애플리케이션 로직
  • 회원과 연관된 팀은 자주 함께 사용되어, 즉시로딩으로 설정.
  • 회원과 연관된 주문은 가끔 사용되어, 지연로딩으로 설정.
  • 주문과 연관된 상품은 자주 함께 사용되어, 즉시로딩으로 설정.
@Entity
public class Member {
	@Id
    private String id;
    private String username;
    private Integer age;
    
    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;
    
    @OneToMany(mappedBy = "member", fetch=FetchType.LAZY)
    private List<Order> orders;
}

회원과 팀의 연관관계를 FetchType.EAGER로 설정하여 즉시 로딩되도록 설정한다. 외에 회원과 주문 내역의 경우에는 FetchType.LAZY로 설정해 실제 사용될 때까지 로딩을 지연한다.

회원 조회 시의 SQL은 아래와 같다.

SELECT 
	member.id AS MEMBERID,
    member.age AS AGE,
    member.team_id AS TEAM_ID,
    member.username AS USERNAME,
   	team.id AS TEAMID,
    team.name AS NAME
FROM member member LEFT OUTER JOIN
	 team team on member.team_id = team1_.ID
WHERE member0_.ID = 'member1';

회원과 팀이 즉시 로딩으로 설정되었기 때문에, 하이버네이트는 조인 쿼리를 만들어 회원과 팀을 한 번에 조회한다. 반면, 회원과 주문 내역은 지연 로딩으로 설정했기에 결과를 프록시로 조회하게된다. 따라서 위의 SQL에는 전혀 나타나지 않는다.

회원 조회 후 member.getTeam()을 호출하게되면, 이미 로딩된 팀 엔티티를 반환하게 된다.

(1) 프록시와 컬렉션 래퍼

컬렉션 래퍼: 하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는 것.

엔티티 지연 로딩 시, 프록시 객체를 사용해 지연로딩을 처리하게되지만, 컬렉션의 경우에는 컬렉션 래퍼가 지연 로딩을 처리해주게됨.

컬렉션의 경우에는 member.getOrders()를 호출해도 컬렉션 초기화가 발생하지 않고 컬렉션에서 member.getOrders().get(0); 처럼 실제 데이터를 조회할 때에 데이터베이스를 조회해서 초기화하게된다.

(2) JPA 기본 패치 정략

fetch 속성의 기본 설정값은 아래와 같다.

  • @ManyToOne, @OneToOne: 즉시로딩
  • @OneToMany, @ManyToMany: 지연로딩

JPA 기본 fetch 전략은 연관된 엔티티가 하나면 즉시로딩, 컬렉션이면 지연로딩을 사용하게된다. 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못할 때, 너무 많은 데이터를 로딩하게 될 수 있기 때문이다.

추천되는 방법은 모든 연관관계에서 지연로딩을 사용하는 것이었다. 그리고 추후 애플리케이션 개발이 어느정도 완료되었을 때, 실제 사용하는 상황을 보고 꼭 필요한 상황에 즉시 로딩을 사용하도록 최적화하면 된다.

(3) 컬렉션에서 FetchType.EAGER 사용시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장되지 않는다.
    • 컬렉션과 조인하는 것은 데이터베이스 테이블로 보면 일대다 조인으로 일대다 조인은 결과 데이터가 다쪽에 있는 수만큼 증가된다.
    • 서로 다른 컬렉션 2개 이상을 조인할 때는 너무 많은 데이터를 반환할 수 있어 애플리케이션 성능 저하가 발생할 수 있다.
  • 컬렉션 즉시 로딩 때는 항상 외부 조인을 사용한다.

FetchType.EAGER 설정과 조인 전략 정리

  • @ManyToOne, @OneToOne:

    • optional = false: 내부 조인
    • optional = true: 외부 조인
  • @OneToMany, @ManyToMany

    • optional = false: 외부조인
    • optional = true: 외부조인

8.4 영속성 전이: CASCADE

영속성 전이: 특정 엔티티를 영속 상태로 만들 때, 연관된 엔티티도 함께 영속 상태로 만들 때 사용. JPA의 경우 CASCADE 옵션을 통해 영속성 전이를 제공한다.

@Entity
public class Parent {
	@Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<>();
}

@Entity
public class Child {
	@Id @GeneratdValue
    private Long id;
    
    @ManyToOne
    private Parent parent;
}

부모 하나에 자식 둘을 저장하는 경우

Parent parent = new Parent();
em.persist(parent);

Child child1 = new Child();
child1.setParent(parent);
parent.getChildren().add(child1);
em.persist(child1);

Child child2 = new Child();
child2.setParent(parent);
parent.getChildren().add(child2);
em.persist(child2);

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야함. 그렇기에 부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만든다.

이때 영속성 전이를 사용하면 부모만 영속 상태로 만들면, 연관된 자식까지 한 번에 영속 상태로 만들어야함.

(1) 영속성 전이: 저장

@Entity
public class Parent {
	@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList<>();
}

cascade = CascadeType.PERSIST 옵션을 통해 부모를 영속화할 때 연관된 자식들도 함께 영속화하는 설정. 해당 옵션을 적용할 때, 부모와 자식 엔티티를 한 번에 영속화할 수 있음.

CASCADE 저장

private static void saveWithCascade(EntityManager em) {
	Child child1 = new Child();
   Child child2 = new Child();
   
   Parent parent = new Parent();
   child1.setParent(parent);
   child2.setParent(parent);
   parent.getChildren().add(child1);
   parent.getChildren().add(chlid2);
   
   //부모 저장, 연관된 자식들 저장
   em.persist(parent);
}

부모만 영속화하면, CascadeType.PERSIST로 설정한 자식 엔티티까지 함게 영속화해서 저장.

영속성 전이의 경우에는 연관관계 매핑과는 관련이 없다. 단지 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공한다.

(2) 영속성 전이: 삭제

부모와 자식 엔티티를 모두 제거할 때는 각각의 엔티티를 하나씩 제거해야한다.

Parent findParent = em.find(Parent.class, 1L);
Child findChild1 = em.find(Child.class, 1L);
Child findChild2 = em.find(Child.class, 2L);

em.remove(findChild1);
em.remove(findChild2);
em.remove(findParent);

영속성 전이는 엔티티를 삭제할 때도 사용할 수 있음. CascadeType.REMOVE로 설정하게되면 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제.

이 경우 DELETE SQL을 3번 실행해 부모와 연관된 자식을 모두 삭제한다. 삭제 순서는 외래키 제약조건을 고려해 자식을 먼저 삭제한 후에 부모를 삭제하게된다.

만약 CascadeType.REMOVE를 설정하지 않고 코드를 실행하면 부모 엔티티만 삭제된다. 하지만, 데이터베이스의 부모 열을 삭제하면 외래키 제약조건으로 인해 외래키 무결성 예외가 발생한다.

(3) CASCADE의 종류

CascadeType은 다양한 옵션이 존재한다.

public enum CascadeType {
	ALL,
    PERSIST,
    MERGE,
    REMOVE,
    REFRESH,
    DETACH
}

위의 속성 중 여러 개를 같이 사용할 수 있다.


8.5 고아 객체

고아 객체 제거: JPA가 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는 것을 말함.

해당 기능을 통해 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동삭제되도록할 수 있다.

@Entity
public class Parent {
	@Id @GeneratedValue
    private Long id;
    
    @OneToMany (mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<>();
}

고아 객체 기능 활성화를 위해서는 컬렉션에 orphanRemoval = true를 설정한다. 이후에는 컬렉션에서 제거한 엔티티는 자동으로 삭제된다. 이 기능은 영속성 컨텍스트 플러시할 때 적용되기에 플러시 시점에 DELETE SQL이 실행된다.

모든 자식 엔티티를 제거하기 위해서는 컬렉션을 비우면 된다.

  • 고아 객체 제거 기능: 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능.
    • 해당 기능은 참조하는 곳이 하나일 때에만 사용해야한다.
    • 삭제한 엔티티를 다른 곳에서도 참조하면 문제가 발생한다.

8.6 영속성 전이 + 고아 객체, 생명 주기

CascadeType.ALL + orphanRemoval = true를 동시에 사용하게되면?

  • 일반적으로 엔티티는 EntityManger.persist()를 통해 영속화됨.
  • 이후 EntityManager.remove()를 통해 제거됨
    => 즉, 엔티티 스스로 생명주기를 관리하게된다.

만약 위의 두 옵션을 모두 활성화하게되면, 부모 엔티티를 통해 자식의 생명 주기를 관리할 수 있게된다.

profile
이불 밖은 위험해.

0개의 댓글