JPA ( 1 + N )

희운·2025년 4월 4일

JPA

목록 보기
2/5

Team Entity



@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 절이 나간다고 보면 되는거야?

네, 질문하신 내용이 정확합니다. 코드와 상황을 분석하면서 단계별로 설명드리겠습니다.

코드 분석

  1. MemberTeam의 관계:

    • Member 엔티티는 Team@ManyToOne 관계를 맺고 있으며, fetch = FetchType.LAZY로 설정되어 있습니다. 즉, Team은 지연 로딩(Lazy Loading) 방식으로 동작합니다.
    • 지연 로딩은 Member를 조회할 때 Team 객체를 즉시 가져오지 않고, 필요할 때(예: member.getTeam() 호출 시) 프록시 객체를 통해 추가 쿼리로 데이터를 가져오는 방식입니다.
  2. 테스트 코드:

    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은 프록시 객체로 남아 있습니다
      여기서 각가의 멤버들의 연관된 team 이 .getTeam().getName
      을 호출할때 쿼리가 발생합니다(다른 팀일경우, 앞전에 이미 쿼리가 나간 팀은 1차 캐시에 존재하기 때문에 추가쿼리가 나가지 않는다.)
    • member.getTeam().getClass()를 호출하면 프록시 객체가 반환되지만, 아직 Team의 실제 데이터는 로드되지 않습니다.
    • member.getTeam().getName()을 호출할 때 프록시가 초기화되며, Team 데이터를 가져오기 위해 추가 쿼리가 실행됩니다.
  3. 1 + N 문제:

    • findAll()Member를 조회하면 처음에 하나의 쿼리(SELECT * FROM Member)가 실행됩니다. (1)
    • 이후 member.getTeam().getName()을 호출할 때마다 각 Member에 연관된 Team을 조회하기 위해 추가 쿼리가 발생합니다. 예를 들어, member1member2가 각각 teamAteamB에 속해 있다면, Team을 조회하는 쿼리가 2번 더 실행됩니다. (N = 2)
    • 결과적으로 쿼리 수는 1 (Member 조회) + N (Team 조회)가 됩니다.

질문에 대한 답변

  1. member.getTeam().getClass()에서 쿼리가 나가는지?

    • 아니요, member.getTeam().getClass()는 프록시 객체의 클래스 타입(예: Team_$$_jvst123_Proxy)을 반환할 뿐, 프록시를 초기화하지 않습니다. 따라서 이 시점에서는 추가 쿼리가 발생하지 않습니다.
  2. member.getTeam().getName()에서 쿼리가 나가는 이유와 WHERE 절:

    • 네, member.getTeam().getName()을 호출하면 프록시가 초기화되면서 Team 데이터를 가져오기 위해 추가 쿼리가 실행됩니다.
    • 이 쿼리는 Memberteam_id 외래 키를 기반으로 해당 Team을 조회합니다. 예를 들어:
      SELECT * FROM Team t WHERE t.team_id = :teamId
      여기서 :teamIdMemberteam_id 값입니다.
    • 즉, Member가 속한 Team을 정확히 가져오기 위해 WHERE 절이 포함된 쿼리가 나가는 것이 맞습니다.
  3. 추가 쿼리가 나가는 이유:

    • Memberteam 필드는 지연 로딩으로 설정되어 있어, 처음 Member를 조회할 때는 Team 데이터가 로드되지 않습니다.
    • getName()처럼 Team의 실제 데이터를 필요로 하는 시점에 JPA가 프록시를 초기화하며 DB에서 데이터를 가져옵니다.
    • 이 과정에서 MemberTeam 간의 관계를 유지하기 위해 team_id를 조건으로 한 쿼리가 실행되는 것입니다.

발생하는 쿼리 예시

  1. findAll() 호출 시:

    SELECT m.member_id, m.username, m.age, m.team_id FROM Member m
    • Member 2개가 조회됨 (member1, member2).
  2. 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

    한 번의 쿼리로 MemberTeam을 모두 가져옵니다.

  • 엔티티 그래프(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 이므로)

profile
기록하는 공간

0개의 댓글