연관관계 매핑 기초

김준석·2021년 1월 17일
0
post-thumbnail

객체의 참조와 테이블의 외래 키를 매핑하는 것이 이 장의 목표다.

연관관계 매핑을 이해하기 위한 핵심 키워드

  • 방향: 단방향, 양방향이 있다.
    • 회원 → 팀 또는 팀 → 회원 이렇게 둘 중 한쪽만 참조하는 것이 단방향
    • 회원 → 팀 또는 팀 → 회원 서로 참조하고 있는 것이 양방향
  • 다중성: 다대일, 일대다, 일대일, 다대다 다중성이 있다.
    • 한 팀에 여러 회원이 속할 수 있을 때 경우
      • 회원과 팀은 다대일 관계
      • 팀과 회원은 일대다 관계
  • 연관관계 주인: 객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다.

1. 단방향 연관관계

객체 연관관계

  • 회원 객체는 Member.team 필드로 팀 객체와 연관관계를 맺음
  • 회원 객체와 팀 객체는 단방향 관계
    • Member는 필드를 통해 Team을 알 수 있지만, Team은 Member를 알 수 없음

테이블 연관관계

  • 회원 테이블은 TEAM_ID 외래 키로 팀 테이블과 연관관계를 맺음
  • 회원 테이블과 팀 테이블은 양방향 관계
    • 회원 테이블의 TEAM_ID 외래 키를 통해서 회원과 팀(팀과 회원)을 조인할 수 있음
    • MEMBER JOIN TEAM과 TEAM JOIN MEMBER 둘 다 가능

객체 연관관계와 테이블 연관관계

  • 객체 연관관계
    • 객체는 참조(주소)로 연관관계를 맺음
    • 참조를 통한 연관관계는 언제나 단방향
    • 양방향을 만들고 싶으면 반대쪽에 필드를 추가해서 참조해야 한다.
  • 테이블 연관관계
    • 테이블은 외래 키로 연관관계를 맺음
    • 테이블은 외래 키 하나로 양방향

그럼 객체 연관관계와 테이블 연관관계를 어떻게 매핑시킬까?

@Entity
public class Member {
    
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    // 연관관계 매핑
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    // 연관관계 설정
    public void setTeam(Team team) {
        this.team = team;
    }
    
    // ...
}

@Entity
public class Team {
    
    @Id
    @Column(name = "TEAM_ID")
    private String id;
    
    private String name;
    
    // ...
}
  • @ManyToOne

    • 다대일 매핑 정보
    • 연관관계 매핑할 때 다중성을 나타내는 어노테이션은 필수
    • 속성
      • name
        • 기본값: 필드면 + _ + 참조하는 테이블의 기본 키 컬럼명
        • 매핑할 외래 키 이름
      • referencedColumnName
        • 기본값: 참조하는 테이블의 기본 키 컬럼명
        • 외래 키가 참조하는 대상 테이블의 칼럼명
      • foreignKey(DDL)
        • 외래 키 제약조건을 직접 지정할 수 있음
        • 테이블을 생성할 때만 사용
      • unique, nullable, insertable, updatable, columnDefinition, table
        • @Column의 속성과 같음
  • @JoinColumn

    • 외래 키를 매핑할 때 사용

    • 생략 가능

    • 속성

      • optional
        • 기본값: true
        • false로 설정하면 연관된 엔티티가 항상 있어야 함
      • fetch
        • 기본값
          • @ManyToOne=FetchType.EAGER
          • @OneToMany= FetchType.LAZY
        • 글로벌 페치 전략 설정
      • cascade
  • 영속성 전이 기능 사용

  • targetEntity

    • 연관된 엔티티의 타입 정보 설정

      @OneToMany
      private List<Member> members; // 제네릭으로 타입 정보를 알 수 있다.
      
      @OneToMany(targetEntity=Member.class)
      private List members; // 제네릭이 없으면 타입 정보를 알 수 없다.

2. 연관관계 사용

저장

// 팀1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);

// 회원1 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // member1 → team1
em.persist(member1);

// 회원2 저장
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); // member2 → team1
em.persist(member2);

조회

엔티티 조회 방법은 크게 2가지이다.

객체 그래프 탐색

// Member와 연관된 Team 엔티티 조회
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색

객체지향 쿼리 사용(JPQL)

// 팀1에 소속된 회원만 조회
String jpql = "select m from Member m join m.team t where " + "t.name=:teamName";

List<Member> resultList = em.createQuery(jpql, Member.class)
    	.setParameter("teamName", "팀1")
    	.getResultList();

수정

// 새로운 팀2
Team team2 = new Team("team2", "팀2");
em.persist(team2);

// 회원1에 새로운 팀2 설정
Member member = em.find(Member.class, "member1");
member.setTeam(team2);

연관관계 제거

// 회원1을 팀에 소속하지 않도록 변경
Member member1 = em.find(Member.class, "member1");
member1.setTeam(null); // 연관관계 제거

연관된 엔티티 삭제

만약 팀을 삭제하려고 한다면 기존 연관관계를 제거하고 삭제해야 한다(외래 키 제약조건 때문).

// 팀1 삭제
member1.setTeam(null);
member2.setTeam(null);
em.remove(team1);

3. 양방향 연관관계

  • 회원 → 팀
  • 팀 → 회원

테이블 연관관계는 똑같다. 왜냐하면 데이터베이스 테이블은 외래 키로 양방향으로 조회가 가능하기 때문이다.

양방향 연관관계 매핑

Member 엔티티는 똑같다.

@Entity
public class Member {
    
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    // 연관관계 설정
    public void setTeam(Team team) {
        this.team = team;
    }
    
    // ...
}

Team 엔티티를 보자.

@Entity
public class Team {
    
    @Id
    @Column(name = "TEAM_ID")
    private String id;
    
    private String name;
    
    // 추가
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
    
    // ...
}

팀과 회원은 일대다 관계다. 따라서 List<Member> members를 추가했다. 그리고 일대다 관계이기 때문에 @OneToMany 매핑 정보를 사용했다. mappedBy는 반대쪽 매핑이 Member.team이므로 team을 주었다.

일대다 컬렉션 조회

Team team = em.find(Team.class, "team1");
List<Member> members = team.getMembers(); // 팀 → 회원

4. 연관관계 주인

테이블 연관관계

  • 회원 ↔ 팀의 연관관계 1개 (양방향)

하지만 정확히 말하면 객체에는 양방향 연관관계가 없다. 서로 다른 방향 2개가 존재하는 것이다.

객체 연관관계

  • 회원 → 팀 연관관계 1개 (단방향)
  • 팀 → 회원 연관관계 1개 (단방향)

서로 객체를 참조하고 있지만 외래 키는 하나이다. 그러면 어떤 관계를 사용해서 외래 키를 관리해야 할까?

두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계 주인(Owner)이라 한다.

양방향 매핑의 규칙: 연관관계의 주인

연관관계의 주인만이 데이터베이스 연관관계와 매핑되는 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있다.

  • 어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용
  • 주인은 mappedBy 속성을 사용하지 않음
  • 주인이 아니면 mappedBy 속성을 사용해서 연관관계의 주인을 지정

연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것이다.

회원 테이블이 외래 키(TEAM_ID)를 관리하고 있다. 따라서 Member와 Team 엔티티를 볼 때 Member 엔티티가 Team을 참조하고 있기 때문에 Member.team을 주인으로 선택하여 관리하면 된다.

Team 엔티티에 있는 Team.members를 주인으로 선택할 수도 있지만, 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 한다. 왜냐하면 Team.members는 Team 엔티티에 있고, Team 엔티티는 TEAM 테이블에 매핑되어 있는데 외래 키는 MEMBER 테이블이 가지고 있기 때문이다.

연관관계의 주인은 외래 키가 있는 곳

연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.

따라서 외래 키를 가지고 있는 MEMBER 테이블과 매핑되어 있는 Member 엔티티의 Member.team이 주인이 되고, Team.members는 주인이 아님을 설정하기 위해 mappedBy = "team" 속성을 추가해야 한다. 여기서 mappedByteam은 Member 엔티티의 team 필드를 말한다.

참고

데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 다 쪽이 외래 키를 가진다. 다 쪽인 @ManyToOne은 항상 연관관계의 주인이 되므로 mappedBy를 설정할 수 없다. 따라서 @ManyToOne에는 mappedBy 속성이 없다.

5. 양방향 연관관계 저장

// 팀1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);

// 회원1 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1);
em.persist(member1);

// 회원2 저장
Member member2 = new Member("member2", "회원2");
member2.setTeam(team2);
em.persist(member2);

뭔가 이상하다. setTeam() 메서드로 member1, member2는 team1과 연관관계를 맺었지만 Team.members는 어떤 코드도 없다. team1.getMembers().add(member1) 메서드를 통해 Team.members에도 저장을 해야 할 것 같다. 하지만 설명했듯이 Team.members는 연관관계 주인이 아니다. 주인이 아닌 곳에 입력된 값은 외래 키에 영향을 주지 않는다. 따라서 무시해도 된다.

6. 양방향 연관관계의 주의점

만약 연관관계 주인이 아닌 곳에만 값을 입력하면 어떻게 될까?

// 회원1 저장
Member member1 = new Member("member1", "회원1");
em.persist(member1);

// 회원2 저장
Member member2 = new Member("member2", "회원2");
em.persist(member2);

// 팀1 생성
Team team1 = new Team("team1", "팀1");

// 주인이 아닌 곳만 연관관계 설정
team1.getMembers().add(member1);
team1.getMembers().add(member2);

// 팀1 저장
em.persist(team1);

데이터베이스 MEMBER 테이블은 조회하면 이렇게 나올 것이다.

MEMBER_IDUSERNAMETEAM_ID
member1회원1null
member2회원2null

연관관계 주인이 아닌 Team.members에만 값을 저장했기 때문에 적용이 안된 것이다.

연관관계 주인만이 외래 키의 값을 변경할 수 있다.

순수한 객체까지 고려한 양방향 연관관계

그러면 연관관계 주인에만 값을 저장하면 되는 것일까?

객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.

왜 그럴까?

Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원2");

member1.setTeam(team1); // 연관관계 주인에 값 저장
member2.setTeam(team1); // 연관관계 주인에 값 저장

List<Member> members = team1.getMembers();
System.out.println("members.size = " + members.size());

다음 코드가 실행되면 출력 결과는 어떤 결과가 나올까? 결과는 members.size = 0이다. JPA가 없다고 생각하고 객체로 생각하면 당연한 결과이다. 하지만 이것은 기대했던 양방향 연관관계 결과가 아니다.

양방향은 양쪽 다 관계를 설정해야 한다.

Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원2");

member1.setTeam(team1);
team1.getMembers().add(member1); // 연관관계 주인이 아닌 곳도 저장

member2.setTeam(team1);
team1.getMembers().add(team1); // 연관관계 주인이 아닌 곳도 저장

List<Member> members = team1.getMembers();
System.out.println("members.size = " + members.size());

출력 결과는 members,size = 2가 나온다. 이제 JPA를 사용해서 코드를 완성해보자.

Team team1 = new Team("team1", "팀1");
em.persist(team1);

Member member1 = new Member("member1", "회원1");
member1.setTeam(team1);
team1.getMembers().add(member1);
em.persist(member1);

Member member2 = new Member("member2", "회원2");
member2.setTeam(team1);
team1.getMembers().add(team1);
em.persist(member2);

이제 순수한 객체 상태에서도 동작하고, 테이블의 외래 키도 정상적으로 입력된다.

결론: 객체의 양방향 연관관계는 양쪽 모두 관계를 맺어주자.

연관관계 편의 메소드

결국, 양방향 연관관계는 양쪽 다 설정을 해야 한다. 하지만 이전처럼 member.setTeam()team.getMembers().add()를 각각 호출하면 하나만 호출하는 실수를 할 때가 있다. 따라서 양방향 관계에서는 두 코드를 하나처럼 사용하는 것이 안전하다.

public class Member {
    
    private Team team;
    
    public void setTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
    
    // ...
}

그러면 위에서 사용한 코드는 이렇게 바뀐다.

Team team1 = new Team("team1", "팀1");
em.persist(team1);

Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 양방향 설정
em.persist(member1);

Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); // 양방향 설정
em.persist(member2);

이렇게 한 번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라고 한다.

연관관계 편의 메소드 작성 시 주의사항

문제가 있다. 만약 member1team2로 바뀐다면 어떻게 될까?

member1.setTeam(team1); // 처음 연관관계 설정
member1.setTeam(team2); // 변경

member1.setTeam(team1) 연관관계 설정하면 그림과 같을 것이다.

하지만 member1.setTeam(team2) 변경하면 어떻게 될까?

team1 → member1 관계가 제거되지 않았다. 왜일까? setTeam() 메서드를 보면 this.team = teamteam.getMembers().add(this)가 있다. member1.setTeam(team2)를 실행하면 this.team = team으로 member1 → team1 관계가 끊기지만 team1 → member1 관계를 제거하는 코드는 없다. 따라서 연관관계를 변경할 때는 기존 팀이 있으면 기존 팀과 회원의 연관관계를 끊어야 한다.

// 기존 팀과 관계를 제거
if (this.team != null) {
    this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);

team1 → member1 관계를 제거하는 코드가 없어도 데이터베이스에는 문제가 없다. 왜냐하면, 연관관계 주인이 아니기 때문이다. 하지만 왜 이렇게 하는 것일까? 그것은 영속성 컨텍스트 때문이다. 관계를 변경하고 아직 영속성 컨텍스트가 살아있는 상태에서 team1.getMembers() 메서드를 호출하면 member1이 살아있을 것이다. 따라서 관계를 제거하는 것이 안전하다.

7. 정리

양방향의 장점은 반대방향으로 객체 그래프 탐색 기능이 추가된 것뿐이다.

  • 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
  • 단방향을 양방향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
  • 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.
profile
내 몸에는 꼰대의 피가 흐른다.

0개의 댓글