5장 연관관계 매핑 기초

이주호·2024년 11월 27일
1

객체는 참조를 통해 관계를 맺고 테이블은 외래키를 통해 관계를 맺는다. 이런 객체의 연관관계와 테이블의 연관관계를 맺는 것이 이 장의 목표이다.

단방향 연관관계

다대일(N:1) 단방향 관계를 살펴보자.

객체 연관관계

  • 회원 객체는 Member.team 참조를 통해 연관관계를 맺는다. 이 때, Memer -> Team 조회는 Member.getTeam()으로 조회가 가능하지만 Team -> Member는 불가능하다.

테이블 연관관계

  • 테이블은 항상 양방향 연관관계를 가진다.
  • 회원 테이블은 TEAM_ID 외래키로 팀 테이블과 연관관계를 맺는다.
  • 이 때, TEAM_ID 외래키를 가지고 회원 테이블에서 팀 테이블을 조인할 수 있고, 팀 테이블에서 회원 테이블을 조인할 수 있다.
// MEMBER 테이블에서 TEAM 테이블 조인
SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

// TEAM 테이블에서 MEMBER 테이블 조인
SELECT *
FROM TEAM T
JOIN MEMBER T ON T.TEAM_ID = M.TEAM_ID

정리하면 객체의 참조는 항상 단방향 연관관계를 맺고 테이블은 항상 단방향 연관관계를 가진다.
따라서 객체를 양방향 연관관계를 가지기 위해서는 단방향 연관관계를 2번 맺어야 한다.
다음과 같이 객체의 연관관계를 맺으려는 객체를 속성으로 각각 가져야 함을 의미한다.
A -> B (a.b)
B -> A (b.a)


// 객체의 양방향 연관관계
class A {
	B b
 }
 
 class B {
 	A a
 }

객체 그래프 탐색

Team team = member1.getTeam();

위와 같이 참조를 통해 연관관계를 탐색하는 것을 객체 그래프 탐색이라고 한다.

위에 참조를 통한 연관관계 탐색을 테이블에서도 똑같이 구현해 보면 아래와 같다.

SELECT T.*
FROM MEMBER M
	JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID = 'member1'

이렇게 외래키를 통해 연관관계 탐색하는 것을 테이블에서는 조인이라고 한다.

객체 관계 매핑

이제 객체와 테이블의 연관관계를 매핑해보자.

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


@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;

    private String name;
    
    //setter, getter
}
  • 객체 연관관계 : 회원 객체의 Member.team 필드 사용
  • 테이블 연관관계 : 회원 테이블의 MEMBER.TEAM_ID 외래 키 컬럼을 사용

Member.team과 MEMBER.TEAM_ID를 매핑하는 것이 연관관계 매핑이다.

@ManyToOne

다대일 연관관계를 맺기 위해 사용하는 어노테이션이다. 이름 그대로 다대일(N:1) 관계라는 매핑 정보이다.
다대일을 매핑하기 위해서 필수적으로 어노테이션을 사용해야한다.

@JoinColumn

외래키를 매핑할 때 사용하는 어노테이션
name 속성에는 매핑할 외래키의 이름을 지정한다. 이 어노테이션은 생략할 수 있다.
(어노테이션을 생략하면 선언한 필드 이름과 조인할 객체의 id가 외래키로 설정된다고 한다)

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


@Entity
public class Team {

    @Id
    private String id;

    private String name;
    
    //setter, getter
}

이런 경우 Member에 선언한 team 이름과 외래 키로 연관관계를 맺을 Team에 id를 언더바로 조합하여 외래키로 설정한다고 한다. (외래 키 : teamid)
**자동 생성 외래 키 이름: 참조 대상 필드
참조 대상 클래스 기본 키 필드**


연관관계 사용

저장

        // 팀 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);

- JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.
memer1에 set을 통해서 team1을 참조했고 em.persist로 저장하였다. 이렇게 하면 JPA에서는 참조한 팀의 식별자를 통해서 적절한 등록 쿼리를 생성한다.

INSERT INTO TEMA (TEAM_ID, NAME) VALUES ('team1', '팀1')
INSERT INTO MEMBER (MEMBER_ID, NAME, TEAM_ID) VALUES ('member1', '회원1', 'team1')

조회

  • 객체 그래프 탐색 (객체 연관관계를 사용한 조회)
    member.getTeam()을 사용해서 member와 연관된 team 엔티티를 조회할 수 있다.
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();	// 객체 그래프 탐색
  • 객체지향 쿼리 사용(JPQL)
		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();

위에 jpql을 보면 조인을 할 때 Member가 참조하고 있는 m.team(필드)을 이용해서 조인을 하고 있다.
:teamName 부분은 : 뒤에 붙어있는 이름으로 파라미터를 바인딩하는 문법이다. 이를 실행하면 다음과 같은 SQL이 실행된다.

SELECT M.*
FROM MEMBER MEMBER
INNER JOIN TEAM TEAM ON MEMBER.TEAM_ID = TEAM.TEAM_ID
WHERE TEAM.NAME = '팀1'

수정

em.update 와 같은 기능이 없기 때문에 앞선 장에서 설명했듯이 엔티의 값만 변경하면 트랜잭션을 커밋할 때 더티체킹을 통해서 변경사항이 자동으로 반영된다.
이것은 연관관계를 수정할 때도 같은데, 참조하는 대상만 변경하면 나머지는 JPA가 자동으로 처리한다.

Team team2 = new Team("team2", "팀2");
em.persist(team2);

Member member = em.find(Member.class, "member1");
member.setTeam(team2);	//참조하는 team1을 team2로 변경

연관관계 제거

연관관계를 null로 설정하여 연관관계를 제거한다.

Member member1 = em.find(Member.class, "member1");
member1.setTeam(null);	// 연관관계 제거

다음과 같은 SQL이 실행된다.

UPDATE MEMBER
SET
	TEAM_ID=NULL, ...
WHERE
	ID='member1'

연관된 엔티티 삭제

연관된 엔티티를 삭제하려면 기존의 연관관계를 먼저 제거하고 삭제해야 한다. 그렇지 않으면 외래 키 제약조건으로 인해, 데이터베이스에서 오류가 발생한다.
만약, 팀1에 회원1과 회원2가 소속되어 있다면 팀1을 삭제하려면 연관관계를 먼저 제거해야 한다.

member1.setTeam(null);	// 회원1 연관관계 제거
member2.setTeam(null);	// 회원2 연관관계 제거
em.remove(team);		// 팀 삭제

양방향 연관관계

이제 양방향 연관관계를 살펴보자.
Member와 Team은 다대일 관계이다. 반대로 팀과 멤버는 일대다 관계이다. 일대다 관계는 여러 건과 연관관계를 맺을 수 있으므로 컬렉션을 사용해야 한다. 그러기 위해 Team.members를 List 컬렉션으로 추가한다.

  • 회원 -> 팀 (Member.team)
  • 팀 -> 회원(Team.members)

테이블은 앞서 말했듯이 외래키로 양방향 연관관계를 맺기 때문에 별다른 조치가 필요하지 않다.

양방향 연관관계를 매핑하기 위해 팀 엔티티를 다음과 같이 변경한다.

@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>();
	
    //Getter, Setter...

일대다 관계를 매핑하기 위해 List로 컬렉션을 추가했고, @OneToMany 매핑정보를 사용했다.
해당 어노테이션에서 사용한 mappedBy 속성은 양방향 매핑일 때 사용하는데, 반대쪽 매핑의 필드 이름을 값으로 주면 된다. 반대쪽 매핑이 Member.team이므로 team을 값으로 넣어주었다.
이제부터 팀에서 회원 컬렉션으로 객체 그래프를 탐색할 수 있다.


연관관계의 주인

mappedBy를 설명하기 위해서는 연관관계의 주인이 있다는 것을 알아야 한다. 앞서 말했듯이 테이블은 외래키를 통해 양방향 연관관계를 가지지만 객체는 그렇지 않다. 위에서 양방향 연관관계를 매핑했다고 했지만 사실은 단방향 연관관계를 2개 매핑해 양방향처럼 보이게 한 것이다.

  • 팀 -> 회원 (단방향)
  • 회원 -> 팀 (단방향)

이처럼 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래키는 하나여서 둘 사이의 차이가 발생한다.
이런 차이로 인해 JPA에서는 두 객체 연관관계 . 중하나를 정해서 테이블의 외래키를 관리해야 하는데 이것을 연관관계의 주인(Owner)이라 한다.

그래서 양방향 연관관계를 매핑할 때는 다음과 같은 규칙이 존재한다.

  • 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다.
  • 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록,수정,삭제)할 수 있다.
  • 주인이 아닌 쪽은 읽기만 할 수 있다.
  • mappedBy를 이용해서 연관관계의 주인을 정한다.
  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니라면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

연관관계의 주인을 정한다는 것은 외래키의 관리자를 선택하는 것이다.
그래서 외래키를 가지고 있는 테이블이 연관관계의 주인이 된다. 여기서는 Member가 Team을 외래키로 가지고 있기 때문에 Member.team이 주인이 된다.
데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 "다"쪽이 외래 키를 가진다. 그래서 항상 @ManyToOne은 연관관계의 주인이 되므로 mappedBy 속성이 없다.


양방향 연관관계 저장

양방향 연관관계에서 엔티티를 저장하는 것은 앞에 단방향 연관관계에서 저장하는 것과 완전히 코드가 동일하다.
양방향 연관관계는 연관관계의 주인이 외래 키를 관리한다. 따라서 주인이 아닌 방향은 값을 설정하지 않아도 데이터베이스에서 외래 키값이 정상 입력된다. 이 말은 다음과 같이 team1에 member1,member2를 추가할 필요가 없다는 말이다.

team1.getMembers().add(member1);	//무시(연관관계의 주인이 아님)
team1.getMembers().add(member2);	//무시(연관관계의 주인이 아님)

연관관계의 주인인 Member.team이 다음과 같이 외래 키를 관리한다.

  member1.setTeam(team1);	//연관관계 설정(연관관계의 주인)
  member2.setTeam(team1);	//연관관계 설정(연관관계의 주인)

양방향 연관관계의 주의점

양방향 연관관계를 설정하고 가장 많이 하는 실수가 바로 위에서 언급한 연관관계의 주인이 아닌 것에만 값을 입력하는 경우라고 한다.

  team1.getMembers().add(member1);	//무시(연관관계의 주인이 아님)
  team1.getMembers().add(member2);	//무시(연관관계의 주인이 아님)
  
  //member1.setTeam(team1);	//연관관계 설정(연관관계의 주인)
  //member2.setTeam(team1);	//연관관계 설정(연관관계의 주인)

이렇게 설정하면 외래 키 TEAM_ID에 team1이 아닌 null 값이 입력된다. 연관관계의 주인이 아닌 Team.members에만 값을 저장했기 때문이다. 외래 키는 연관관계의 주인(여기서는 Member.team)이 관리하는 것을 꼭 기억하자.

하지만 이는 데이터베이스에 저장될 때의 이야기이고 연관관계의 주인이 아닌 엔티티에 값을 안 넣는 것은 객체 입장에서 심각한 문제를 야기할 수 있다. team1.getMembers().size()를 구한다고 하면 값을 안 넣었을 경우 0이 나오기 때문이다. 그래서 객체까지 고려해서 주인이 아닌 곳에도 값을 입력해야 한다.

연관관계 편의 메서드

이처럼 양방향 연관관계는 양쪽 다 신경을 써야 하는데 각각 코드를 호출하면 실수로 한 곳에만 값을 넣어줄 수도 있다. 이를 방지하기 위해 다음과 같이 set을 수정하면 좋다.

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

이렇게 수정하면 다음과 같이 사용할 수 있다.

public void test() {
  
  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);
}

연관관계 편의 메서드 주의사항

위에 코드는 사실 문제가 있다. member1의 Team을 수정했을 때 기존 팀에서 여전히 member1이 조회된다는 것이다.

  member1.setTeam(teamA)	//teamA
  member1.setTeam(teamB)	//teamA -> teamB 수정
  Member findMember = teamA.getMember();	//member1이 여전히 조회된다.

이 문제는 연관관계를 수정할 때, 기존 연관관계를 제거하지 않아서 일어나는 문제이다. 다음과 같이 수정하면 된다.


public void setTeam(Team team) {
  if (this.team != null) {
  	this.team.getMembers().remove(this);
  }
  this.team = team;
  team.getMembers().add(this);

정리

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

참조 : [자바 ORM 표준 JPA 프로그래밍]

profile
코드 위에서 춤추고 싶어요

0개의 댓글