@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)//기본 생성자 접근제어자를 protected 로 설정.
@ToString(of = {"id", "name"})
public class Team {
@Id
@GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team")//fk 가 없는 쪽에 mappedBy 해라.
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
public Member(String username, int age) {
this.username = username;
this.age = age;
}
public Member(String username) {
this.username = username;
}
public Member(String username, int age, Team team) {
this.username = username;
this.age = age;
if (team != null) {
changeTeam(team);
}
}
public void changeUsername(String username) {
this.username = username;
}
/*public void setTeam(Team team) { -> setTeam 이 더 맞는것 같아.
this.team = team;
team.getMembers().add(this);
}*/
//팀변경과 팀세팅을 다 해준다.
public void changeTeam(Team newTeam) {
if(this.team != null) {
this.team.getMembers().remove(this);
}
this.team = newTeam;
newTeam.getMembers().add(this);
}
}
위의 엔티티를 보면 Team : Member = 1 : N 관계인것을 알 수 있고,
서로 양방향관계로 되어있다.
연관관계의 주인은 "테이블" 에서 FK 가 존재하는 곳에 두자!
현재 지연로딩으로 설정을 해놨다.
지연로딩을 했을 때 발생하는 문제점에 대해 알아보자.
@Test
public void findMemberLazy() {
//given
//member1 -> teamA
//member2 -> teamB
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
//when
//select Member
List<Member> members = memberRepository.findAll();
//List<Member> members = memberRepository.findMemberFetchJoin();
//List<Member> members = memberRepository.findEntityGraphByUsername("member1");
// member.getTeam().getName() 에서 지연로딩 발생 : 1 + n (n == 2)
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.teamClass = " + member.getTeam().getClass());//프록시
System.out.println("member.team = " + member.getTeam().getName());//프록시를 초기화
}
}
현재 데이베이스 테이블을 보면

이렇게 되어있다.
아래 내용을 읽으면 충분히 이해될것이다
질문 :
여기서 지연로딩이 발생해서 1 + n 이 발생하는데 그 쿼리가 member.getTeam().getClass()) 을 하면 member가 속한 Team 을 조회하는 쿼리가 나가는거지? 그래서 쿼리에 where 절이 포함되어있는거고 맞지
Member 엔티티의 Team team 필드에 실제 엔티티를 주입하기 위해서 추가 쿼리가 나가는데 member가 속한 team 을 가져오기 위해서 where 절이 나간다고 보면 되는거야?
네, 질문하신 내용이 정확합니다. 코드와 상황을 분석하면서 단계별로 설명드리겠습니다.
Member와 Team의 관계:
Member 엔티티는 Team과 @ManyToOne 관계를 맺고 있으며, fetch = FetchType.LAZY로 설정되어 있습니다. 즉, Team은 지연 로딩(Lazy Loading) 방식으로 동작합니다.Member를 조회할 때 Team 객체를 즉시 가져오지 않고, 필요할 때(예: member.getTeam() 호출 시) 프록시 객체를 통해 추가 쿼리로 데이터를 가져오는 방식입니다.테스트 코드:
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.teamClass = " + member.getTeam().getClass());
System.out.println("member.team = " + member.getTeam().getName());
}
memberRepository.findAll()은 기본적으로 Member 엔티티만 조회합니다. 이 시점에서 Team은 프록시 객체로 남아 있습니다member.getTeam().getClass()를 호출하면 프록시 객체가 반환되지만, 아직 Team의 실제 데이터는 로드되지 않습니다.member.getTeam().getName()을 호출할 때 프록시가 초기화되며, Team 데이터를 가져오기 위해 추가 쿼리가 실행됩니다.1 + N 문제:
findAll()로 Member를 조회하면 처음에 하나의 쿼리(SELECT * FROM Member)가 실행됩니다. (1)member.getTeam().getName()을 호출할 때마다 각 Member에 연관된 Team을 조회하기 위해 추가 쿼리가 발생합니다. 예를 들어, member1과 member2가 각각 teamA와 teamB에 속해 있다면, Team을 조회하는 쿼리가 2번 더 실행됩니다. (N = 2)1 (Member 조회) + N (Team 조회)가 됩니다.member.getTeam().getClass()에서 쿼리가 나가는지?
member.getTeam().getClass()는 프록시 객체의 클래스 타입(예: Team_$$_jvst123_Proxy)을 반환할 뿐, 프록시를 초기화하지 않습니다. 따라서 이 시점에서는 추가 쿼리가 발생하지 않습니다.member.getTeam().getName()에서 쿼리가 나가는 이유와 WHERE 절:
member.getTeam().getName()을 호출하면 프록시가 초기화되면서 Team 데이터를 가져오기 위해 추가 쿼리가 실행됩니다.Member의 team_id 외래 키를 기반으로 해당 Team을 조회합니다. 예를 들어:SELECT * FROM Team t WHERE t.team_id = :teamId여기서 :teamId는 Member의 team_id 값입니다.Member가 속한 Team을 정확히 가져오기 위해 WHERE 절이 포함된 쿼리가 나가는 것이 맞습니다.추가 쿼리가 나가는 이유:
Member의 team 필드는 지연 로딩으로 설정되어 있어, 처음 Member를 조회할 때는 Team 데이터가 로드되지 않습니다.getName()처럼 Team의 실제 데이터를 필요로 하는 시점에 JPA가 프록시를 초기화하며 DB에서 데이터를 가져옵니다.Member와 Team 간의 관계를 유지하기 위해 team_id를 조건으로 한 쿼리가 실행되는 것입니다.findAll() 호출 시:
SELECT m.member_id, m.username, m.age, m.team_id FROM Member m
Member 2개가 조회됨 (member1, member2).member.getTeam().getName() 호출 시 (각 Member마다):
member1에 대해:SELECT t.team_id, t.name FROM Team t WHERE t.team_id = [teamA의 ID]member2에 대해:SELECT t.team_id, t.name FROM Team t WHERE t.team_id = [teamB의 ID]결과적으로 총 3번의 쿼리가 실행됩니다: 1 (Member 조회) + 2 (Team 조회).
1 + N 문제를 해결하려면:
페치 조인(Fetch Join) 사용:
List<Member> members = memberRepository.findMemberFetchJoin();
SELECT m.*, t.* FROM Member m JOIN Team t ON m.team_id = t.team_id
한 번의 쿼리로 Member와 Team을 모두 가져옵니다.
엔티티 그래프(Entity Graph) 사용:
List<Member> members = memberRepository.findEntityGraphByUsername("member1");
@NamedEntityGraph를 통해 Team을 즉시 로딩하도록 설정 가능.
member.getTeam().getName()에서 지연 로딩이 발생하며, Member가 속한 Team을 조회하기 위해 WHERE team_id = ...가 포함된 쿼리가 나갑니다.Team 데이터를 주입하기 위한 JPA의 동작 방식입니다.@Test
public void findMemberLazy2() {
//given
//member1 -> teamA
//member2 -> teamB
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamA);
Member member3 = new Member("member3", 10, teamA);
Member member4 = new Member("member4", 10, teamA);
Member member5 = new Member("member5", 10, teamB);
Member member6 = new Member("member6", 10, teamB);
Member member7 = new Member("member7", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
memberRepository.save(member3);
memberRepository.save(member4);
memberRepository.save(member5);
memberRepository.save(member6);
memberRepository.save(member7);
em.flush();
em.clear();
//when
//select Member
//List<Member> members = memberRepository.findAll();
//List<Member> members = memberRepository.findMemberFetchJoin();
//List<Member> members = memberRepository.findEntityGraphByUsername("member1");
Team team = teamRepository.findById(teamA.getId()).get();
System.out.println("team.getMembers().getClass() = " + team.getMembers().getClass());
System.out.println("-------------------");
for (Member member : team.getMembers()) {
System.out.println("member.name" + member.getUsername());
}
System.out.println("-----------------");
}
여기서 Team 엔티티에 있는 Member 는 Team 엔티티 입장에서는
list 로 존재한다.
1 : n 에서 1쪽 엔티티는 Member 가 언제 지연로딩이 될것인가?
team.getMembers().getClass() = class org.hibernate.collection.spi.PersistentBag
-------------------
2025-04-28T20:30:45.748+09:00 DEBUG 48355 --- [ main] org.hibernate.SQL :
select
m1_0.team_id,
m1_0.member_id,
m1_0.age,
m1_0.created_by,
m1_0.created_date,
m1_0.last_modified_by,
m1_0.last_modified_date,
m1_0.username
from
member m1_0
where
m1_0.team_id=?
2025-04-28T20:30:45.750+09:00 INFO 48355 --- [ main] p6spy : #1745839845750 | took 1ms | statement | connection 5| url jdbc:h2:tcp://localhost/~/datajpa
select m1_0.team_id,m1_0.member_id,m1_0.age,m1_0.created_by,m1_0.created_date,m1_0.last_modified_by,m1_0.last_modified_date,m1_0.username from member m1_0 where m1_0.team_id=?
select m1_0.team_id,m1_0.member_id,m1_0.age,m1_0.created_by,m1_0.created_date,m1_0.last_modified_by,m1_0.last_modified_date,m1_0.username from member m1_0 where m1_0.team_id=1;
member.namemember1
member.namemember2
member.namemember3
member.namemember4
-----------------
결과를 보면 다음과 같이 처음에 team.getMembers().getClass();
를 하면 PersistentBag 라는걸 볼 수 있다.
나도 이건 뭔지 잘 모르지만 프록시같은거라고 생각하자
그리고 필드에 접근했을때 for 문에서 처음 루프를 돌때 Members 와 관련되 쿼리가 나가서 teamId = 1 에 포함된 members 를 전부 가져오는것을 볼 수 잇다.
이처럼 list 라고 list 사이즈만큼 쿼리가 나가는것이 아니라 루프를 통해서 필드에 한번 접근만하면 해당 teamID 에 해당하는 members 를 하나의 쿼리로 가져오는 것을 알 수 있다.
그 다음 루프에서는 쿼리가 발생하지 않음
하지만 만약에 가져온 member 들의 또 다른 연관관계가 있는 엔티티의 필드에 접근하면 그때는 또 추가 쿼리가 발생할것이다. (각각 다른 member 이므로)