본문 바로가기
IT 도서 정리/자바 ORM 표준 JPA 프로그래밍

[JPA] 5. 연관관계 매핑 기초

by yyjun 2024. 7. 3.

엔티티들은 대부분 다른 엔티티와 연관관계가 있다.

 

객체는 참조를 사용해서 관계를 맺고 테이블은 외래 키를 사용해서 관계를 맺는다. 이 둘은 완전 다른 특징을 가진다. ORM에서 가장 어려운 부분이 바로 객체 연관관계와 테이블 연관관계를 매핑하는 일이다.

 

이 장에서는 객체의 참조와 테이블의 외래 키를 매핑하는 방법을 살펴보자.

 

연관관계 매핑을 이해하기 위한 핵심 키워드를 알아보자.

  • 방향(direction)
    • 단방향, 양방향이 있다.
    • 방향은 객체 관계에만 존재하고 테이블 관계는 항상 양방향이다.
  • 다중성(multiplicity)
    • 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 다중성이 있다.
  • 연관관계의 주인(owner)
    • 객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다.

 

단방향 연관관계

객체 연관관계와 테이블 연관관계의 가장 큰 차이

참조를 통한 연관관계는 언제나 단방향이다. 객체 간에 연관관계를 양방향으로 만들고 싶으면 반대쪽에도 필드를 추가해서 참조를 보관해야 한다. 즉, 연관관계를 하나 더 만들어야 한다. 이렇게 양쪽에서 서로 참조하는 것을 양방향 연관관계라 한다. 하지만 정확히 말하면, 이것은 양방향 관계가 아니라 서로 다른 단방향 관계 2개이다.

 

반면 테이블은 외래 키 하나로 양방향으로 조인할 수 있다.

 

참고로 객체가 참조를 사용해서 연관관계를 탐색하는 것을 객체 그래프 탐색이라고 한다. 조인은 데이터베이스에서 외래 키를 사용해서 연관관계를 탐색하는 것을 말한다.

 

객체 관계 매핑

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

 

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;
    
    private String name;
    
    // Getter, Setter
    ...
}

객체 연관관계는 회원 객체의 Member.team 필드를 사용하고, 테이블 연관관계는 회원 테이블의 MEMBER.TEAM_ID 외래 키 칼럼을 사용한다.

 

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

 

  • @ManyToOne
    • 이름 그대로 다대일 관계라는 매핑 정보다.
    • 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
  • @JoinColumn(name="TEAM_ID")
    • 조인 컬럼은 외래 키를 매핑할 때 사용한다.
    • name 속성에는 매핑할 외래 키 이름을 지정한다.
    • 이 어노테이션은 생략할 수 있다.

 

@JoinColumn

@JoinColumn은 외래 키를 매핑할 때 사용한다.

 

@JoinColumn을 생략하면 외래 키를 찾을 때 기본 전략을 사용한다.

@ManyToOne
private Team team;

 

기본 전략: 필드명 + _ + 참조하는 테이블의 컬럼명

ex. 필드명(team) + _ + 참조하는 테이블의 컬럼명(TEAM_ID) = team_TEAM_ID 외래키를 사용한다.

 

@ManyToOne

@ManyToOne 어노테이션은 다대일 관계에서 사용한다.

 

참고

다대일(@ManyToOne)과 비슷한 일대일(@OneToOne) 관계도 있다. 단방향 관계를 매핑할 때 둘 중 어떤 것을 사용해야 할지는 반대편 관계에 달려 있다. 반대편이 일대다 관계면 다대일을 사용하고, 반대편이 일대일 관계면 일대일을 사용하면 된다.

 

연관관계 사용

저장

주의

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.

public void testSave() {
	
    // 팀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);
}

회원 엔티티는 팀 엔티티를 참조하고 저장했다. JPA는 참조한 팀의 식별자(Team.id)를 외래 키로 사용해서 적절한 등록 쿼리를 생성한다.

 

조회

연관관계가 있는 엔티티를 조회하는 방법은 크게 객체 그래프 탐색과 객체지향 쿼리(JPQL) 사용으로 나눌 수 있다.

 

객체 그래프 탐색

member.getTeam()을 사용해서 member와 연관된 team 엔티티를 조회할 수 있다. 이처럼 객체를 통해 연관된 엔티티를 조회하는 것을 객체 그래프 탐색이라고 한다.

 

객체지향 쿼리 사용

객체지향 쿼리인 JPQL에서 연관관계를 어떻게 사용하는지 알아보자. JPQL도 문법은 약간 다르지만 조인을 지원한다.

 

private static void queryLogicJoin(EntityManager em) {
	
    String jpql =  "select m form Member m join m.team t where " + "t.name=:teamName";
    
    List<Member> resultList = em.createQuery(jpql, Member.class)
    	.setParameter("teamName", "팀1");
        .getResultList();
        
    for (Member member : resultList) {
    	System.out.println("[query] member.username=" + member.getUsername());
    }
}

// 결과: [query] member.username=회원1
// 결과: [query] member.username=회원2

JPQL의 from Member m join m.team t 부분을 보면 회원이 팀과 관계를 가지고 있는 필드(m.team)를 통해서 Member와 Team을 조인했다. 그리고 where 절을 보면 조인한 t.name을 검색조건으로 사용해서 팀1에 속한 회원만 검색했다.

 

참고로 :teamName과 같이 :로 시작하는 것은 파라미터를 바인딩 받는 문법이다.

 

수정

private static void updateRelation(EntityManager em) {
	
    // 새로운 팀2
    Team team2 = new Team("team2", "팀2");
    em.persist(team2);
    
    // 회원1에 새로운 팀2 설정
    Member member = em.find(Member.class, "member1");
    member.setTeam(team2);
}

 

단순히 불러온 엔티티의 값만 변경해두면 트랜잭션을 커밋할 때 플러시가 일어나면서 변경 감지 기능이 작동한다. 그리고 변경사항을 데이터베이스에 자동으로 반영한다. 이것은 연관관계를 수정할 때도 마찬가지이다. 참조하는 대상만 변경하면 나머지는 JPA가 자동으로 처리한다.

 

연관관계 제거

private static void deleteRelation(EntityManager em) {

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

 

연관된 엔티티 삭제

연관된 엔티티를 삭제하려면 기존에 있던 연관관계를 먼저 제거하고 삭제해야 한다. 그렇지 않으면 외래 키 제약조건으로 인해 데이터베이스에 오류가 발생한다.

 

양방향 연관관계

객체 연관관계를 살펴보자. 일대다 관계는 여러 건과 연관관계를 맺을 수 있으므로 컬렉션을 사용해야 한다.

 

데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있다. 처음부터 양방향 관계다. 따라서 데이터베이스에 추가할 내용은 없다.

 

양방향 연관관계 매핑

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

 

회원 엔티티에는 변경한 부분이 없다.

@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<Member> members를 추가했다.

 

일대다 관계를 매핑하기 위해 @OneToMany 매핑 정보를 사용했다. mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다.

 

양방향 매핑을 완료했다. 이제부터 팀에서 회원 컬렉션으로 객체 그래프를 탐색할 수 있다.

 

일대다 컬렉션 조회

public void biDirection() {

    Team team = em.find(Team.class, "team1");
    List<Member> members = team.getMembers();
    
    for (Member member : members) {
 	System.out.println("member.username = " + member.getUsername());   
    }
}

/* 결과
member.username = 회원1
member.username = 회원2
*/

 

연관관계의 주인

@OneToMany만 있으면 되지 mappedBy는 왜 필요할까?

 

엄밀하게 이야기하면 객체에는 양방향 연관관계라는 것이 없다. 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 할 뿐이다. 반면에 데이터베이스 테이블은 외래 키 하나로 양쪽이 서로 조인할 수 있다. 따라서 테이블은 외래 키 하나만으로 양방향 연관관계를 맺는다.

 

즉, 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다. 따라서 둘 사이에 차이가 발생한다.

 

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

 

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

양방향 연관관계 매핑 시 두 연관관계 중 하나를 연관관계의 주인으로 정해야만 한다.

 

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

 

어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용하면 된다.

  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

그렇다면 Member.team, Team.members 둘 중 어떤 것을 연관관계의 주인으로 정해야 할까?

 

연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것이다. 여기서는 회원 테이블에 있는 TEAM_ID 외래 키를 관리할 관리자를 선택해야 한다. 만약 회원 엔티티에 있는 Member.team을 주인으로 선택하면 자기 테이블에 있는 외래 키를 관리하면 된다. 하지만 팀 엔티티에 있는 Team.members를 주인으로 선택하면 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 한다.

 

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

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

 

여기서는 회원 테이블이 외래 키를 가지고 있으므로 Member.team이 주인이 된다. 주인이 아닌 Team.members에는 mappedBy="team" 속성을 사용해서 주인이 아님을 설정한다. 그리고 mappedBy 속성의 값으로는 연관관계의 주인인 team을 주면 된다.

 

정리하면 연관관계의 주인만 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있다. 주인이 아닌 반대편(inverse, non-owning side)은 읽기만 가능하고 외래 키를 변경하지는 못한다.

 

참고

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

 

양방향 연관관계 저장

양방향 연관관계를 사용해서 팀1, 회원1, 회원2를 저장해보자.

public void testSave() {
	
    // 팀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);
}

팀1을 저장하고 회원1, 회원2에 연관관계의 주인인 Member.team 필드를 통해서 회원과 팀의 연관관계를 설정하고 저장했다.

 

참고로 이 코드는 단방향 연관관계에서 살펴본 회원과 팀을 저장하는 코드와 완전히 같다.

 

양방향 연관관계는 연관관계의 주인이 외래 키를 관리한다. 따라서 주인이 아닌 방향은 값을 설정하지 않아도 데이터베이스에 외래 키 값이 정상 입력된다.

 

주인이 아닌 곳에 입력된 값은 외래 키에 영향을 주지 않는다. 따라서 주인이 아닌 곳에 입력하는 코드는 데이터베이스에 저장될 때 무시된다.

 

양방향 연관관계의 주의점

양방향 연관관계를 설정하고 가장 흔히 하는 실수는 연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 것이다. 데이터베이스에 외래 키 값이 정상적으로 저장되지 않으면 이것부터 의심해야 한다.

 

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

 

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

연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까?

 

사실은 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다. 양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있다.

 

객체까지 고려해서 주인이 아닌 곳에도 값을 입력해서 객체의 양쪽 모두 관계를 맺어주자.

 

연관관계 편의 메소드

주인인 곳과 아닌 곳 모두 값을 입력해야 한다. 하지만 실수로 둘 중 하나만 입력할 수도 있다.

member.setTeam(team);
team.getMembers().add(member);

 

양방향 관계에서 두 코드는 하나인 것처럼 사용하는 것이 안전하다.

 

Member 클래스의 setTeam() 메소드를 수정해서 코드를 리팩토링하자.

public class Member {

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

 

setTeam() 메소드 하나로 양방향 관계를 모두 설정하도록 변경했다. 이렇게 한 번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라 한다.

 

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

예시 상황에서, 연관관계를 변경할 때는 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 한다. 따라서 기존 관계를 제거하도록 코드를 수정해야 한다.

public void setTeam(Team team) {

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

 

이 코드는 객체에서 서로 다른 단방향 연관관계 2개를 양방향인 것처럼 보이게 하려고 얼마나 많은 고민과 수고가 필요한지 보여준다. 객체에서 양방향 연관관계를 사용하려면 로직을 견고하게 작성해야 한다.

 

연관관계의 주인을 정하는 기준

양방향 연관관계의 주인(Owner)이라는 이름으로 인해 오해가 생길 수 있다.

 

비즈니스 로직상 더 중요하다고 연관관계의 주인으로 선택하면 안 된다. 비즈니스 중요도를 배제하고 단순히 외래 키 관리자 정도의 의미만 부여해야 한다.

 

따라서 연관관계의 주인은 외래 키의 위치와 관련해서 정해야지 비즈니스 중요도로 접근하면 안 된다.

 

참고

일대다를 연관관계의 주인으로 선택하는 것이 불가능한 것만은 아니다. 하지만 성능과 관리 측면에서 권장되지 않는다. 될 수 있으면 외래 키가 있는 곳을 연관관계의 주인으로 선택하자.

 

참고 자료

  • 자바 ORM 표준 JPA 프로그래밍
 

'IT 도서 정리 > 자바 ORM 표준 JPA 프로그래밍' 카테고리의 다른 글

[JPA] 7. 고급 매핑  (4) 2024.07.10
[JPA] 6. 다양한 연관관계 매핑  (4) 2024.07.10
[JPA] 4. 엔티티 매핑  (3) 2024.07.02
[JPA] 3. 영속성 관리  (4) 2024.06.29
[JPA] 2. JPA 시작  (3) 2024.06.27