Post

연관 관계 매핑 (1)

🎬 Intro

단방향, 양방향 연관 관계에 대해 알아봅시다.

✅ 단방향 연관관계

단방향 연관관계는 한쪽 엔티티만 다른 엔티티를 참조하는 관계 입니다. Member는 Team을 참조하고 Team은 Member를 참조하지 않는다고 했을때 이 경우를 단방향 관계라고 하는것이죠 따라서 DB상 Member 테이블에는 Team의 PK의 값을 외래키로 들고 있습니다.

✅ 양방향 연관관계

양방향 연관관계는 두 엔티티가 서로를 참조하는 관계 입니다. Member와 Team이 서로 참조하고 있는것 입니다. 여기서 주의할 점은 객체 상에서 연관관계의 주인을 정하는 것 입니다. DB상에는 Member, Team 테이블 중 한곳에만 참조하는 상대의 외래키를 갖고 있게 됩니다. 따라서 DB관점에서는 문제가 되지 않지만, 객체 관점에서는 문제가 발생하게 됩니다. 바로 이 외래키를 누가 들고 있고 관리할것인가에 대해서 말이죠.

답을 먼저 말씀드리면 N:1 중 N쪽이 연관관계의 주인이 되어야 합니다. 즉, Team아닌 Member가 연관관계의 주인이 되어야하는 것이죠 그 이유는 DB상 테이블의 상태와 JPA 동작방식 때문입니다. DB상에는 N:1관계 중 N이 외래키를 관리하게 됩니다. 이것만 보더라도 외래키를 갖고 있는 N쪽이 연관관계의 주인이 되는것이 자연스럽습니다.

만약 외래키를 갖고 있지 않는 1쪽이 연관관계 주인이 된다면 JPA는 조인 테이블을 생성하게 됩니다. 또한 복잡한 쿼리를 사용하며 이는 성능 저하를 초래할 수 있습니다. 이러한 이유 때문에 N:1 중 N쪽이 객체 관점에서 연관관계 주인이 되는게 맞는것이죠.

또한 JPA는 연관관계 주인에 관계가 업데이트될 때 DB에 관계를 반영하게 됩니다. 즉, Member에다가만 Team을 세팅해도 둘 사이의 연관관계가 DB에 반영되지만, Team에다가만 Member를 반영하게 되면 둘 사이의 연관관계가 DB에 반영되지 않습니다. 즉, 연관관계의 주인이 아닌 쪽은 주인쪽을 읽기만 가능합니다.

📝 예제

단방향 연관관계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
@Table
@NoArgsConstructor
@Data
public class Member {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @Column(name = "member_id")
  private Long id;

  private String name;

  // Join Column을 기준으로 Team의 객체를 찾아오게 된다.
  // 만약 Member 테이블에 team_id가 null 이면 Team 객체도 null이다.
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "team_id")
  private Team team;

  @Builder
  public Member(final Long id, final String name, final Team team) {
    this.id = id;
    this.name = name;
    this.team = team;
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity
@Table
@NoArgsConstructor
@Data
public class Team {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @Column(name = "team_id")
  private Long id;

  private String name;

  @Builder
  public Team(final Long id, final String name) {

    this.id = id;
    this.name = name;
  }

}

  • Member와 Team은 N:1 관계 이고, Member만 Team을 참조하는 단방향 관계 입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class JpaTest1 {

  @Autowired
  private MemberRepository memberRepository;

  @Autowired
  private TeamRepository teamRepository;

  @Autowired
  private EntityManager em;

  @DisplayName("단방향 연관 관계 테스트")
  @Test
  void jpaTest1() throws Exception {
    //given

    Team team = Team.builder()
      .name("테스트팀1")
      .build();

    // 영속성 컨텍스트에 등록
    Team savedTeam = teamRepository.save(team);

    Member member = Member.builder()
      .name("테스트유저1")
      .team(savedTeam)
      .build();

    // 영속성 컨텍스트에 등록
    Member savedMember = memberRepository.save(member);

    //when

    // Team은 영속성 컨텍스트에 등록되어 있으므로, 1차 캐시에서 가져옴 SELECT 쿼리 X
    Team getTeam = savedMember.getTeam();
    getTeam.setName("테스트팀2");

    System.out.println("======= 쓰기 지연 =========");
    em.flush();

    //then
    assertThat(savedTeam.getName()).isEqualTo("테스트팀2");
  }

}

  • @DataJpaTest@Transactional을 감싸고 있습니다.
  • 테스트 환경에서 @Transactional은 테스트가 종료 후 롤백이 되기 때문에 DB에 반영되지 않습니다.
  • Team 생성하고 save를 이용하여 영속성 컨텍스트에 등록합니다.
  • Member를 생성하고 save를 이용하여 영속성 컨텍스트에 등록합니다. 이때 Member에 등록되는 Team은 savedTeam이므로 영속성 컨텍스트에 등록된 객체 입니다. 따라서 추가적인 SELECT 쿼리가 실행되지 않습니다.
  • ManyToOne 관계로 Member에 Team이 존재하므로 해당 객체를 가져와서 변경합니다. 이때 영속성 컨텍스트에 저장된 savedTeam과 getTeam은 동일한 객체입니다. 따라서 영속성 컨텍스트 관리하는 객체에 변경이 일어났으므로 더티체킹에 의해서 UPDATE 쿼리가 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Hibernate: 
    create table member (
        member_id bigint not null,
        team_id bigint,
        name varchar(255),
        primary key (member_id)
    )
    
Hibernate: 
    create table team (
        team_id bigint not null,
        name varchar(255),
        primary key (team_id)
    )
Hibernate: 
    alter table if exists member 
       add constraint FKcjte2jn9pvo9ud2hyfgwcja0k 
       foreign key (team_id) 
       references team

img.png

  • ManyToOne 연관관계 매핑에 의해 Member쪽 테이블에 Team의 PK가 외래키로 설정되었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Hibernate: 
    select
        next value for team_seq
Hibernate: 
    select
        next value for member_seq
======= 쓰기 지연 =========
Hibernate: 
    insert 
    into
        team
        (name, team_id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        member
        (name, team_id, member_id) 
    values
        (?, ?, ?)
Hibernate: 
    update
        team 
    set
        name=? 
    where
        team_id=?

  • 실행된 쿼리를 em.flush()를 호출하여 확인해봅시다.
  • save 2번 => INSERT 쿼리 2번, 더티체킹 1번 => UPDATE 쿼리 1번이 잘 실행되었습니다.
  • 여기서 주의 할 점은 테스트 환경이 아니라면 em.flush()를 이용해서 수동으로 DB에 반영하는일은 거의 없습니다.
  • 또한 flush()에 의해 영속성 컨텍스트가 쌓아둔 쿼리를 모두 출력했기때문에 UPDATE 쿼리가 존재하지만, 실제로는 커밋 시점에 최종적으로 설정된 값으로 INSERT문이 한번만 실행되게 됩니다. 즉, “테스트팀2”로 INSERT문이 한번만 실행되게 되는것이죠

양방향 연관관계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
@Table
@NoArgsConstructor
@Data
public class Member {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @Column(name = "member_id")
  private Long id;

  private String name;

  // Join Column을 기준으로 Team의 객체를 찾아오게 된다.
  // 만약 Member 테이블에 team_id가 null 이면 Team 객체도 null이다.
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "team_id")
  private Team team;

  @Builder
  public Member(final Long id, final String name, final Team team) {
    this.id = id;
    this.name = name;
    this.team = team;
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
@Table
@NoArgsConstructor
@Data
public class Team {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @Column(name = "team_id")
  private Long id;

  private String name;

  @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<Member> members = new ArrayList<>();

  @Builder
  public Team(final Long id, final String name) {

    this.id = id;
    this.name = name;
  }

}

연관관계 주인에 관계를 업데이트해야하는 이유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class JpaTest2 {

  @Autowired
  private MemberRepository memberRepository;

  @Autowired
  private TeamRepository teamRepository;

  @Autowired
  private EntityManager em;

  @DisplayName("양방향 연관관계 테스트")
  @Test
  void jpaTest2() throws Exception {
    //given

    Team team = Team.builder()
      .name("테스트팀1")
      .build();

    // 영속성 컨텍스트에 등록, savedTeam 과 team은 동일한 객체
    Team savedTeam = teamRepository.save(team);

    Member member = Member.builder()
      .name("테스트유저1")
      .team(savedTeam)
      .build();

    // 영속성 컨텍스트에 등록
    Member savedMember = memberRepository.save(member);

    System.out.println("========= 쓰기 지연 ============");
    em.flush();
    em.clear();
    System.out.println("==============================");

    // when
    System.out.println("========= SELECT =============");
    Team getTeam = teamRepository.findById(savedTeam.getId()).orElse(null);
    System.out.println("==============================");
    System.out.println("========== 지연 로딩 ===========");
    for (Member getMember : getTeam.getMembers()) {
      System.out.println(getMember.getName());
    }
    System.out.println("========== 지연 로딩 ===========");

    //then
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Hibernate: 
    select
        next value for team_seq
Hibernate: 
    select
        next value for member_seq
========= 쓰기 지연 ============
Hibernate: 
    insert 
    into
        team
        (name, team_id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        member
        (name, team_id, member_id) 
    values
        (?, ?, ?)
==============================
========= SELECT =============
Hibernate: 
    select
        t1_0.team_id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?
==============================
========== 지연 로딩 ===========
Hibernate: 
    select
        m1_0.team_id,
        m1_0.member_id,
        m1_0.name 
    from
        member m1_0 
    where
        m1_0.team_id=?
테스트유저1
==============================
  • em.flush()는 영속성 컨텍스트에 쌓인 내용들을 DB에 수동으로 반영합니다.
  • em.clear()는 영속성 컨텍스트를 비웁니다.
  • Team을 생성하고 Member에 Team을 설정한뒤 둘을 save합니다. INSERT 쿼리가 2개 생성 됩니다.
  • 연관관계의 주인인 Member에 Team을 설정했기 때문에 둘 사이의 관계는 DB에 잘 반영됩니다. 영속성 컨텍스트를 flush()와 clear() 후 가져온 Team의 members를 조회해보면 잘 반영됐다는것을 확인할 수 있죠
  • 그렇다면 Team에 Member를 세팅하고 Member에 Team을 세팅하지 않는다면 어떻게 될까요??
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class JpaTest2 {

  @Autowired
  private MemberRepository memberRepository;

  @Autowired
  private TeamRepository teamRepository;

  @Autowired
  private EntityManager em;

  @DisplayName("양방향 연관관계 테스트")
  @Test
  void jpaTest2() throws Exception {
    //given

    Team team = Team.builder()
      .name("테스트팀1")
      .build();

    // 영속성 컨텍스트에 등록, savedTeam 과 team은 동일한 객체
    Team savedTeam = teamRepository.save(team);

    Member member = Member.builder()
      .name("테스트유저1")
      .build();

    // 영속성 컨텍스트에 등록
    Member savedMember = memberRepository.save(member);

    // Team에 Member를 추가
    savedTeam.getMembers().add(savedMember);

    System.out.println("========= 쓰기 지연 ============");
    em.flush();
    em.clear();
    System.out.println("==============================");

    // when
    System.out.println("========= SELECT =============");
    Team getTeam = teamRepository.findById(savedTeam.getId()).orElse(null);
    System.out.println("==============================");
    System.out.println("========== 지연 로딩 ===========");
    for (Member getMember : getTeam.getMembers()) {
      System.out.println(getMember.getName());
    }
    System.out.println("==============================");

    //then
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Hibernate: 
    select
        next value for team_seq
Hibernate: 
    select
        next value for member_seq
========= 쓰기 지연 ============
Hibernate: 
    insert 
    into
        team
        (name, team_id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        member
        (name, team_id, member_id) 
    values
        (?, ?, ?)
==============================
========= SELECT =============
Hibernate: 
    select
        t1_0.team_id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?
==============================
========== 지연 로딩 ===========
Hibernate: 
    select
        m1_0.team_id,
        m1_0.member_id,
        m1_0.name 
    from
        member m1_0 
    where
        m1_0.team_id=?
==============================

  • Team에 Member를 추가했는데 테스트유저1이 출려되지 않았습니다.
  • 그 이유는 JPA는 연관관계 주인에 관계가 업데이트될 때 DB에 관계를 반영하기 때문입니다.
연관관계 편의 메서드를 사용해야하는 이유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class JpaTest2 {

  @Autowired
  private MemberRepository memberRepository;

  @Autowired
  private TeamRepository teamRepository;

  @Autowired
  private EntityManager em;

  @DisplayName("양방향 연관관계 테스트")
  @Test
  void jpaTest2() throws Exception {
    //given

    Team team = Team.builder()
      .name("테스트팀1")
      .build();

    // 영속성 컨텍스트에 등록, savedTeam 과 team은 동일한 객체
    Team savedTeam = teamRepository.save(team);

    Member member = Member.builder()
      .team(team)
      .name("테스트유저1")
      .build();

    // 영속성 컨텍스트에 등록
    Member savedMember = memberRepository.save(member);

    System.out.println("========= 쓰기 지연 ============");
    // em.flush();
    // em.clear();

    // when
    System.out.println("========= SELECT =============");
    Team getTeam = teamRepository.findById(savedTeam.getId()).orElse(null);
    System.out.println("==============================");
    System.out.println("========== 지연 로딩 ===========");
    for (Member getMember : getTeam.getMembers()) {
      System.out.println(getMember.getName());
    }
    System.out.println("==============================");

    //then
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
Hibernate: 
    select
        next value for team_seq
Hibernate: 
    select
        next value for member_seq
========= 쓰기 지연 ============
========= SELECT =============
==============================
========== 지연 로딩 ===========
==============================

  • flush(), clear()를 주석처리하여 DB 반영과 영속성 컨텍스트를 비우는 작업을 취소하였습니다.
  • findById로 가져오는 Team은 1차 캐시에 저장된 놈이므로 SELECT 쿼리가 나가지 않습니다.
  • Member에 Team을 설정했지만 Team에는 Member가 없습니다. 왜 그럴까요??
  • 현재 Member와 Team은 1차 캐시에 있는 순수 객체 상태입니다. 따라서 Team의 members 컬렉션에 member를 추가해주지 않았기 때문에 member가 존재하지 않는것입니다.
  • 만약 flush()를 통해 DB에 둘의 관계를 반영되면, Team의 members가 사용되는 시점에 외래키를 이용해 member를 잘 가져오게 됩니다.
  • JPA와 같은 ORM은 DB의 데이터를 객체로 다루는것이 목표이기 때문에 객체 상태와 DB 데이터는 무조건 일치해야합니다.
  • 따라서 연관관계 메서드를 통해 순수 객체 상태인 Member, Team에게 둘의 관계를 설정해줘야합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class JpaTest2 {

  @Autowired
  private MemberRepository memberRepository;

  @Autowired
  private TeamRepository teamRepository;

  @Autowired
  private EntityManager em;

  @DisplayName("양방향 연관관계 테스트")
  @Test
  void jpaTest2() throws Exception {
    //given

    Team team = Team.builder()
      .name("테스트팀1")
      .build();

    // 영속성 컨텍스트에 등록, savedTeam 과 team은 동일한 객체
    Team savedTeam = teamRepository.save(team);

    Member member = Member.builder()
      .name("테스트유저1")
      .build();

    System.out.println("========= 쓰기 지연 ============");
    em.flush();
    em.clear();
    System.out.println("==============================");

    // when
    System.out.println("========= SELECT =============");
    Team getTeam = teamRepository.findById(savedTeam.getId()).orElse(null);
    System.out.println("==============================");
    System.out.println("========== 지연 로딩 ===========");
    for (Member getMember : getTeam.getMembers()) {
      System.out.println(getMember.getName());
    }
    System.out.println("==============================");

    //then
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
Hibernate: 
    select
        next value for team_seq
Hibernate: 
    select
        next value for member_seq
========= 쓰기 지연 ============
========= SELECT =============
==============================
========== 지연 로딩 ===========
테스트유저1
==============================
  • 연관관계 편의 메서드로 둘 사이의 관계를 설정했으므로 team에 member가 잘추가된것을 확인할 수 있습니다.

✨ Summary

연관관계 주인

  • 외래키를 가지고 있는쪽을 연관관계 주인으로 설정합니다.

단방향 연관관계

  • 한쪽 엔티티만 다른 엔티티를 참조하는 관계 입니다.
  • DB에서는 N쪽이 외래키를 갖게 됩니다.

양방향 연관관계

  • 두 엔티티가 서로를 참조하는 관계 입니다.
  • N:1 관계에서 N쪽이 연관관계의 주인이 되어야합니다.
  • JPA는 연관관계의 주인에 관계가 업데이트 됐을때만 두 사이의 관계를 DB에 반영합니다. 즉, N쪽에 1의 정보를 설정해야합니다.

연관관계 편의 메서드의 필요성

  • 양방향 연관관계에서 연관관계 주인에만 관계를 설정하면, 순수 객체에서는 주인이 아닌쪽에 관계가 반영되지 않습니다.
  • 따라서 연관관계 편의 메서드를 사용해 양쪽 엔티티에 관계를 설정하여, 객체 상태와 DB 상태를 일치시켜야합니다.

ETC

  • @DataJpaTest 애노테이션은 @Transactional을 감싸고 있기때문에 테스트 완료후 자동으로 롤백됩니다.
This post is licensed under CC BY 4.0 by the author.