객체는 그래프로 연관된 객체들을 탐색한다. 그런데 객체가 DB에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다. JPA 구현체들은 이 문제를 해결하려고 프록시라는 기술을 사용한다.
- 프록시를 사용하면 연관된 객체를 처음부터 DB에서 조회하는 것이 아니라 실제 사용하는 시점에 DB에서 조회할 수 있다. 하지만 자주 함꼐 사용하는 객체들은 조인을 사용해서 함꼐 조회하는 것이 효과적
JPA는 즉시 로딩과 지연로딩이라는 방법으로 둘을 모두 지원한다.
프록시
엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것이 아니다.
예를 들어 회원 엔티티를 조회할 때 연관된 팀 엔티티는 비즈니스 로직에 따라 사용될 때도 있지만 그렇지 않을 때도 있다.
codyssey/springAndJPA/ProxyJPA/ProxyJPA/src/main/java/org/example at main · whitecy01/codyssey
나만의 백엔드 세계를 탐험하는 여정 기초부터 실전까지 한 걸음씩 쌓아가는 개발자의 기록. Contribute to whitecy01/codyssey development by creating an account on GitHub.
github.com
@Entity
public class Member {
private String username;
//연관관계 매핑
@ManyToOne
private Team team;
public Team getTeam(){
return team;
}
public String getUsername(){
return username;
}
}
@Entity
public class Team {
private String name;
public String getName(){
return name;
}
}
// 회원과 팀 정보를 출력하는 비즈니스 로직
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());
}
다음과 같은 코드에서
- printUserAndTeam → 회원 엔티티를 찾아서 회원과 연관된 팀의 이름도 출력함
- printUser → 회원 엔티티만 출력하는 데 사용 연관된 팀은 엔티티는 사용하지 않음
- 이때, 팀 엔티티까지 DB에서 함꼐 조회하는 것은 효율적이지 않음
JPA → 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 방법을 제공 하는데 이것을 지연 로딩이라고함.
하지만 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라고함.
프록시 기초
JPA에서 식별자로 엔티티 하나를 조회할 때는 EntitiyManager.find()를 사용함.
다음 메소드는 영속성 컨텍스트에 엔티티가 없으면 DB를 조회
Member member = em.find(Member.class, "member1");
이렇게 엔티티를 직접 조회하면 조회한 엔티티를 실제 사용하든 사용하지 않든 DB를 조회하게 된다.
→ 엔티티를 실제 사용하는 시점까지 DB 조회를 미루고 싶으면 EntityManager.getReference() 메소드를 사용하면 됨
Member member = em.getReference(Member.class, "member1");
위 메소드를 호출할 때 JPA는 DB를 조회하지 않고 실제 엔티티 객체도 생성하지 않음. 대신 DB 접근을 위임한 프록시 객체를 반환

자 이제 프록시를 좀 더 파헤쳐보자
프록시 특징
- 프록시 클래스는 실제 클래스를 상속 받아서 만들어짐 → 실제 클래스와 겉 모양이 같음
- 사용자 입장에서는 이것이 진짜 객체인지 프록시 객체인지 구분하지 않고 사용

프록시 객체는 실제 객체에 대한 참조(target)을 보관함. 그리고 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

프록시 객체의 초기화
- 프록시 객체는 member.getName()처럼 실제 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성함 → 이것을 프록시 객체의 초기화라고함.
//MemberProxy 반환
Member member = em.getReference(Member.class, "id1");
member.getName(); //1. 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();
}
}

위 분석
- 프록시 객체에 member.getName()을 호출해서 실제 data를 조회
- 프록시 객체는 실제 엔티티가 생성되어있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청 이것을 초기화라함.
- 영속성 컨텍스트는 DB를 조회해서 실제 엔티티 객체를 생성
- 프록시 객체는 실제 엔티티 객체의 참조 Member target 멤버변수에 보관
- 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환
프록시의 특징
- 처음 사용할 때 한 번만 초기화됨
- 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아님 → 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티팅 ㅔ접근할 수 있는 것.
- 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 DB에서 조회할 필요가 없어서 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환
- 초기화는 영속성 컨텍스트의 도움을 받아야 가능 → 영속성 컨텍스트의 도움을 받을 수 없으면 준영속 상태의 프록시를 초기화하면 예외가 발생.
프록시와 식별자
엔티티를 프록시로 조회할 때 → 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관함
Team team = em.getReference(Team.class, "team1")//식별자 보관
team.getId();//초기화되지 않음
위는 이미 프록시가 team1 값을 알고 있으니 굳이 DB에 물어볼 필요가 없음 id = “team1”이니 get해도 프록시를 초기화하지 않는다.
뭐 엔티티 접근 방식을 프로퍼티 (@Access(AccessType.PROPERTY))로 설정한 경우에만 초기화하지 않는다.
PROPETY 자리를 FIELD로 설정하면 어떤 일을 하든 프록시 객체를 초기화함.
Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1")//SQL 실행 안함
member.setTeam(team);
→ 연관관계를 설정할 때 식별자 값만 사용하므로 프록시를 사용하면 DB 접근 횟수를 줄일 수 있음.
프록시 확인
JPA가 제공하는 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 알 수 있다.
boolean isLoad = em.getEntityManagerFactory()
.getPersistenceUnitUtil().isLoaded(entity);
//또는 boolean isLoad = emf.getPersistenceUnitUtil().isLoaded(entity);
System.out.println("isLoad = " + isLoad); // 초기화 여부 확인
조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지 확인하려면 클래스명을 직접 출력해보면 됨
- 출력될 때는 클래스 명 뒤에 ..javassit..라 되어 있는데 이것으로 프록시 인 것을 확인 가능
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("pure-jpa");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Member member = em.getReference(Member.class, 1L);
// 1. 클래스 이름 확인
System.out.println(member.getClass());
// 2. instanceof 로 프록시 여부 확인
System.out.println(member instanceof HibernateProxy);
// 3. Hibernate 유틸로 초기화 여부 확인
System.out.println(Hibernate.isInitialized(member));
em.getTransaction().commit();
em.close();
결과
- javassit가 안나온 걸 볼 수 있는데 Hibernate 5.x 초반때만 프록시 생성 라이브러리 Javassit을 사용했음 그 이후는 프록시 생성 라이브러리 ByteBuddy 즉 Hibernate 공식 변경 사항이라 이렇게 보이는 거
class org.example.Member$HibernateProxy$p3nm9ciH
org.example.Member$HibernateProxy$p3nm9ciH
true
false
즉시 로딩과 지연 로딩
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();//객체 그래프 탐색
System.out.println(team.getname());//팀 엔티티 사용
프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용함. member1이 team1에 소속해 있다고 가정해보자.
- 조회할 때 2가지 방법이 나뉨
- 즉시 로딩 → 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
- 지연 로딩 → 연관된 엔티티를 실제 사용할 때 조회한다.
즉시 로딩
즉시 로딩(EAGER LOADING)을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정
@Entity
public class Member {
@Id
private String username;
//연관관계 매핑
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
//즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
- member1을 조회하는 순간 팀도 함꼐 조회됨.
- 회원과 팀 두 테이블을 조회해야 하므로 쿼리를 2번 실행 할 것 같지만… JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용
한 번 실습을 위해 다음과 같이 코드
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("pure-jpa");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// 1. 저장
Team team = new Team("team1");
em.persist(team);
Member member = new Member("member1", team);
em.persist(member);
em.flush();
em.clear(); // ★ 중요: 1차 캐시 제거
System.out.println("=== Member 조회 ===");
Member findMember = em.find(Member.class, "member1");
System.out.println("=== getTeam() 호출 ===");
Team findTeam = findMember.getTeam();
System.out.println("Team name = " + findTeam.getName());
em.getTransaction().commit();
em.close();
emf.close();
@Entity
public class Member {
@Id
private String username;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
protected Member() {} // JPA 기본 생성자
public Member(String username, Team team) {
this.username = username;
this.team = team;
}
public Team getTeam() {
return team;
}
public String getUsername() {
return username;
}
}
@Entity
public class Team {
@Id
private String name;
protected Team() {} // JPA 기본 생성자
public Team(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
결과
Hibernate:
/* insert for
org.example.Member */insert
into
Member (TEAM_ID, username)
values
(?, ?)
=== Member 조회 ===
Hibernate:
select
m1_0.username,
t1_0.name
from
Member m1_0
left join
Team t1_0
on t1_0.name=m1_0.TEAM_ID
where
m1_0.username=?
left join == left outer join을 했음 == Member는 무조건 가져오고 Team은 있으면 붙이고 없으면 null
left outer join을 유심히 봐야함 → 회원 테이블에 TEAM_ID 외래 키는 NULL 값을 허용하고 있음 따라서 팀에 소속되지 않는 팀이 있을 수 있음. 그래서 내부 조인을 하면 팀과 회원 데이터 조회가 불가능 그래서 outer join을 사용 즉, 외부 조인을 사용
그런데.. 외부 조인 보다 내부 조인이 성능과 최적화가 더 유리함. 내부 조인을 하려면 외래 키에 NOT NULL 제약 조건을 설정하면 값이 있는 것을 보장함 이때 내부 조인만 사용
지연 로딩
지연 로딩(LAZY LOADING)을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정
@Entity
public class Member {
@Id
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
//지연 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색 -> 프록시 객체
team.getName(); // 팀 객체 실제 사용
- 회원만 조회하고 팀은 조회하지 않음 대신 같이 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둠
- 이 프록시 객체는 실제 사용될 때까지 Data 로딩을 미룸. 그래서 지연 로딩
team.getName();
- 위 코드에서 실제 데이터가 필요한 순간이 왔다 이때 되어서야 DB를 조회해서 프록시 객체를 조회한다.
- team.getName()호출로 프록시 객체가 초기화되면서 실행되는 SQL은 다음과 같다.
SELECT * FROM TEAM
WHERE TEAM_ID = 'team1'
즉시 로딩, 지연 로딩 정리
- 필요할 때마다 SQL을 실행해서 연관된 엔티티를 지연 로딩하는 것도 최적화 관점에서 꼭 좋은 것은 아님.. 즉시 로딩 및 지연 로딩은 상황에 따라서 뭐가 좋은지는 다르다.
정리해보자
- 지연 로딩 → 연관된 엔티티를 프록시로 조회한다. 프록시를 실제 사용할 때 초기화하면서 DB를 조회
- 즉시 로딩 → 연관된 엔티티를 즉시 조회함. 하이버네이트는 가능하면 SQL 조인을 사용해서 한 번에 조회
JPA의 기본 페치(fetch) 전략은 연관된 엔티티가 하나면 즉시 로딩을 컬렉션이면 지연 로딩을 사용한다.
- 컬렉션을 로딩하는 것은 비용이 많이 들고 너무 많은 데이터를 로딩할 수 있기 때문
- 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것 → 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화
기본 설정값은 다음과 같음
- ManyToOne, OneToOne : 즉시 로딩
- OneToMany, ManyToMany : 지연 로딩
컬렉션에 FetchType.EAGER 사용 시 주의점
- 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
- 컬렉션을 조인한다는 것은 DB 테이블로 보면 일대다 조인이다. 일대다 조인은 결과 데이터가 다 쪽에 있는 수 만큼 증가하게 됨 → 문제는 서로 다른 컬렉션을 2개 이상 조인할 때 발생함 예를 들어 A테이블을 N, M 두 테이블과 일대다 조인을 하면 SQL 실행 결과가 N 곱하기 M이 되면서 너무 많은 data를 반환활 수 있음.
- 컬렉션 즉시 로딩은 항상 외부 조인을 사용
- 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 not null 제약조건을 걸어두면 모든 회원은 팀에 소속되므로 항상 내부 조인을 사용해도 된다.
- 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생 DB제약조건으로 이런 상황을 막을 수 없다. 따라서 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용함.
즉시 로딩 조인 전략 정리
- ManyToOne, OneToOne
- optional = false → 내부 조인
- optional = true → 외부 조인
- OneToMany, ManyToMany
- optional = false → 외부 조인
- optional = true → 외부 조인
영속성 전이 : CASCADE
- 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이(transitive persistence) 기능을 사용
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent")
private List<Child> children = new ArrayList<Child>();
@Entity
public class Child {
@Id @GeneratedValue
private Long id;
@ManyToOne
private Parent parent;
만약 부모 1명에게 자식 2명을 저장한다면
private static void saveNoCascade(EntityManager em){
//부모 저장
Parent parent = new Parent();
em.persist(parent);
//1번 자식 저장
Child child1 = new Child();
child1.setParent(parent) // 자식 -> 부모 연관관계 설정
prent.getChildren().add(child1); // 부모 -> 자식
em.persist(child1);
//2번 자식 저장
Child child2 = new Child();
child2.setParent(parent) // 자식 -> 부모 연관관계 설정
prent.getChildren().add(child2); // 부모 -> 자식
em.persist(child2);
}
→ JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야함
그래서 위 코드에서 부모 엔티티 영속 상태 만들고 → 자식 엔티티도 각각 영속 상태로 만든다.
- 이럴 때 CASCADE를 사용해서 부모만 영속하면 연관된 자식까지 한 번에 영속 상태로 하게 할 수 있다.
- 다음 코드로 바꾸면 됨
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERIST)
private List<Child> children = new ArrayList<Child>();
@Entity
public class Child {
@Id @GeneratedValue
private Long id;
@ManyToOne
private Parent parent;
추가적으로 삭제할 때는 CascadeType.REMOVE로 설정하면 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 다 삭제할 수 있다.
고아 객체
- JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체라고 함.
- 이 기능을 사용해서 부모 엔티티에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<Child>();
→ 이 기능은 영속성 컨텍스트를 플러시할 때 적용되어 플러시 시점에 DELETE SQL이 실행됨.
모든 자식 엔티티 제거 하려면 밑 코드 사용
parent1.getChildren().clear()
주의해야할점 → 만약 삭제한 엔티티가 다른 곳에서도 참조가 발생하면 문제가 발생할 수 있으니 orphanRemovel은 @OneToOne, @OneToMany에만 사용할 수 있다.
그리고 개념적으로 볼때 부모를 제거하면 자식은 고아가됨 → 부모를 제거하면 자식도 같이 제거됨 == 위에서 한 CasCadeType.REMOVE를 설정한 것과 같다.
CascadeType.ALL + orphanRemoval = true를 동시에 하면 우째 될까?
- 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다 예를 들면 다음과 같다.
자식을 저장하려면 부모에 등록만 하면 된다(CASCADE)
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);
자식을 삭제하려면 부모에서 제거 하면 된다(orphanRemoval)
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(removeObject);
'개발 지식 > Spring boot' 카테고리의 다른 글
| [Spring boot] JPA - 상속 관계 매핑 (0) | 2026.01.30 |
|---|---|
| [Spring boot] JPA - JPA의 연관관계 (0) | 2026.01.30 |
| [Spring boot] JPA - 기본 키 매핑 전략 (0) | 2026.01.30 |
| [Spring boot] JPA - 영속성 컨텍스트(엔티티 매니저 팩토리와 엔티티 매니저, 커넥션풀, 영속성 컨텍스트, 준영속) (0) | 2026.01.30 |
| [Spring boot] JPA - 순수 JPA 애플리케이션 개발(JPQL) (0) | 2026.01.30 |
