본문 바로가기

Web Sever 개발과 CS 기초/스프링

JPA 연관 관계 매핑과 고급 매핑

관련 내용

<JPA 영속성 컨텍스트부터 다양한 매핑까지 완전히 이해하기>

  • JPA 기본 동작과 영속성 원리 이해하기

JPA 기본 동작과 영속성 원리 이해하기

  • JPA 엔티티 매핑 - 테이블, 컬럼, 기본키

JPA 엔티티 매핑 - 테이블, 컬럼, 기본키

  • (현재 글)JPA 연관 관계 매핑과 고급 매핑

JPA 연관 관계 매핑과 고급 매핑

  • JPA지연 로딩, 즉시 로딩 이해와 사용법 영속성전이, 고아 객체 알아보기

JPA지연 로딩, 즉시 로딩 이해와 사용법 영속성전이, 고아 객체 알아보기

  • JPA 값 타입 이해하기

JPA 값 타입 이해하기

  • JPQL, fetch join 중심으로 이해하기

JPA JPQL, fetch join 중심으로 이해하기

개요 목적

이번 글에서는 JPA의 연관 관계 매핑에 대해서 알아본다.

먼저 연관 관계를 설정하기 위한 기본 지식, 단방향과 양방향 연관관계는 무엇인지 알아본다.

그리고 실제 연관 관계(다대일 단방향,양방향, 일대다 단방향, 일대일 단방향,양방향, 다대다)의 구현 코드와 각 코드가 테이블에 어떤 역할을 담당하는 지 알아본다.

실질적으로 실무에서는 다대일 단방향, 양방향과 일대일 단방향, 양방향 매핑만을 사용한다. 그 이유도 아래 글에서 설명한다.

마지막으로 고급 매핑(상속과 @MappedSuperclass)에 대해서도 간단하게 알아본다.

연관관계 매핑을 하기 위한 기본 지식(단방향 -양방향 연관 관계 이해)

연관 관계 매핑이 왜 필요한지 알아보기.

우리가 추구하려고 하는 객체 지향 모델링이 아니라, 테이블 맞춤 모델링을 했을 때 일어나는 문제점을 보고, 왜 연관 관계 매핑이 필요한 지 이해해보자.

아래 그림처럼 객체지향 참조를 통해서 자바 코드를 작성하고 싶다.

그런데, 아래처럼 테이블에 맞춰 객체를 저장하면 참조를 통해 데이터를 저장 조회할 수 없는 불편함이 발생한다.

그래서 우리는 JPA 연관관계 매핑을 배워서, 객체 저장은 외래키로하고 어플리케이션 객체 지향 스타일을 유지하는 코드를 작성해야 한다. 아래 내용을 통해 연관관계 매핑에 대한 기본적인 지식을 알아보자.

단 방향 연관 관계 알아보기

데이터베이스 테이블은 외래 키 하나로 양 쪽 테이블 조인이 가능하다.(단방향이니 양방향이니 나눌 필요가 없다)

그러나 객체는 참조용 필드가 있는 객체만 다른 객체를 참조하는 것이 가능하다.

그렇기 때문에 두 객체 사이에 하나의 객체만 참조용 필드를 갖고 참조하면 단방향 관계, 두 객체 모두가 각각 참조용 필드를 갖고 참조하면 양방향 관계라고 한다.(참조를 해 놓아야 해당 객체에서 참조한 객체를 호출할 수 있다.)

위 테이블을 단방향 연관관계로 설정하면 아래 모양이 나온다.

(Member객체에서만 Team을 조회할 수 있다. member 저장할 땐 Team을 같이 넣어주어야 한다.)

<구현 코드>

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
    private int age;
		
    //이부분이 핵심
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

@ManyToOne(1 대 다)을 설정하고, 해당 참조가 어떤 외래키에 해당되는 지 @JoinColumn을 통해 지정해주면 된다.

양방향 연관 관계- 1. 양방향 연관관계란, 코딩 방법

위에 단방향 연관관계 에서는 member.getTeam() 가능했지만, team.getMemberList()를 통해서 팀이 멤버들의 목록을 참조를 통해 가져올 수 없다.

양방향 연관관계로 설정해 두면 team에서도 멤버들의 목록을 조회할 수 있다.

(두 객체가 서로 참조가 가능해진다.)

(서로 참조를 통해 데이터를 가져올 수 있다고 해서 마냥 좋은 것만은 아니다. 데이터를 추가할 때나 수정할 때 신경 써야 되는 참조 데이터가 늘어난다는 단점도 있다.)

양방향 관계를 만든 그림과 코드를 보면서 양방향 연관관계 방법을 알아보자.

<양방향 매핑 코드>

이런식으로 코드를 작성하면, team 객체에서도, member정보를 가져올 수 있다.

외래키(FK)를 관리할 양방향 매핑관계 주인을 정하는 mappedBy에 대한 설명은 아래에 나온다.

양방향 연관 관계- 2. mappedBy이해, 사용 방법

mappedBy를 사용하기 위해선 객체와 테이블 연관관계 맺는 차이를 알아야한다.

  • 객체의 양방향 관계는, 각 객체가 서로를 지정하는 단방향 2개이다.

  • 테이블은 외래 키 하나로 두 테이블을 서로 참조할 수 있으니 객체 방식과 많이 다르다. (TEAM_ID 외래 키 하나로 양방향 연관 관계 가짐 (양쪽으로 조인할 수 있다.)

→이런 차이에서 딜레마가 발생한다. 외래키(TEAM_ID)(테이블에서 실질적으로 두 테이블을 연결시켜주는)는 하나인데, 객체 쪽에서는 접근할 수 있는 곳이 두개여서 문제가 발생한다.

→멤버 입장에서 팀을 바꾸고 싶은데(Member테이블의 Team_id(FK)를 바꾸고 싶은데) ember 엔티티에서 팀을 바꿔야할지 팀 엔티티에서 팀을 바꿔야할 지 의문을 가지게 된다. 둘 중 하나로 외래키를 관리해야한다.

결론은 FK관리할 주인으로는 외래키가 있는 곳을 주인으로 해야 한다. 즉Member 엔티티의 Team 필드가 주인이다. 반대쪽에 있는 Team의 members에게는 mappedBy team(저의 주인님은요 team입니다)을 작성해줘야한다.

즉 DB의 N(다) 쪽에 주인 역할을 주어야 한다. 반대 1쪽 관련 필드에는 mappedBy를 붙여줘야 한다.

mappedBy가 작성된 주인이 아닌 필드는, 단순 읽기 작업만 가능하고, 이 값이 변경되어도 DB에 전혀 영향을 주지 않는다.

Team.getMembers를 해서 그 안에 값을 바꿔도 DB에 쿼리를 보내지 않는다.

양방향 연관 관계- 3. mappedBy 주의 점 많이 하는 실수

1. 연관 관계 주인에 값을 입력하지 않음

연관 관계 주인인 member.setTeam()을 해야 DB에 외래키가 지정된다.

2. 순수한 객체 관계를 고려하여, 항상 양쪽 다 값을 입력해야 한다

연관관계 주인에게만 입력해도 DB에는 문제 없이 FK가 생성되지만, em.flush를 하지 않은 상태에서(순수한 객체상태)는 참조가 안되는 문제가 발생한다.

Team team = new Team();
team.setName("teamName");
em.persist(team);

Member member = new Member();
member.setName("name");

//주인인 member에만 팀 설정을 해도 DB에는 올바르게 작동한다.
member.setTeam(team);       
em.persist(member);

//그런데 flush를 하지 않은 상태에서 1차캐시에 team을 찾고
//team안에 member를 찾으려고 하면 아직 DB 적용이 안되서
//member 값이 없다는 문제가 발생한다.
Team findTeam = em.find(Team.class, team.getId());
int size = findTeam.getMembers().size();
System.out.println(size);
//팀의 멤버 리스트는 값이 없음을 확인할 수 있다.

해결 방법 한쪽에서 set하게 될 때 다른 쪽에서도 값이 추가될 수 있게 설정 하자

public void setTeam(Team team) {
    this.team = team;
    //이렇게 setTeam을 하면서 team에도 멤버리스트를 추가해주면
    //DB 적용 전 상태에도 team에서 결과가 적용된 멤버리스트를
    //얻을 수 있다. 
    team.getMembers().add(this);
}

3. 양방향 매핑시에 무한 루프를 조심하자

toString(), lombok, JSON 생성 라이브러리 사용 시, 팀에서 멤버를 찾고 멤버에서 팀을 찾는 무한루프가 발생할 수 있음을 주의하자.

4. 단방향 매핑만으로 일단 연관관계를 구성하자

양방향 매핑은 역 방향 조회 기능이 추가된 것일 뿐이다. 연관관계 매핑은 단방향으로 이미 설정이 완료 된 상태이다. 그래서 역 방향 조회가 필요한 곳에만 선택적으로 양방향 매핑을 추가하자.

엔티티 연관 관계 매핑 실전

위에 기본 이해를 바탕으로 사전 설명을 하자면,

양방향 관계에서는 many쪽에(FK가 있는 쪽에) 연관관계 주인 설정을 한다.

일대다 단 방향은 실무적으로 다대일 양방향으로 작업해서 사용한다.

실무에서 쓰이는 연관관계 매핑은 다대일 단방향, 양방향과 일대일 단방향 양방향이라고 생각하면 된다.

다대일 [N:1] @ManyToOne

가장 많이 쓰는 다대일이다.

1. 먼저 다대일 단방향 목표 그림으로 보고, 엔티티 연관관계 매핑을 할 수 있는 지 스스로 테스트를 해보자. 그림 아래에 정답 코드가 나와 있다.

다대일은 다(멤버) 쪽에, 외래키를 관리하는 주인이 있는 것이다. 다쪽인 멤버에서만 일쪽인 팀을 참조할 수 있는 연관관계 매핑을 만들어보자.

@Entity
@Table
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
}

2. 다대일 양방향 목표 그림을 보고, 엔티티 연관관계 매핑 테스트를 해보자. 그림 아래에 정답 코드가 있다.

다대일 양방향 연관관계는 다쪽에 외래키를 관리하는 주인을 가지고 있고, 일쪽인 팀에서도 읽기 전용 멤버 참조 값을 가지고 있는 매핑이다.

@Entity
@Table
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
		//핵심 코드
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    //핵심 코드
    @OneToMany(mappedBy = "team")
    List<Member> members = new ArrayList<>();
}
Hibernate: 
    /* insert hellojpa.Team
        */ insert 
        into
            Team
            (name, id) 
        values
            (?, ?)
Hibernate: 
    /* insert hellojpa.Member
        */ insert 
        into
            Member
            (USERNAME, TEAM_ID, id) 
        values
            (?, ?, ?)

일대다 [1:N] @OneToMany

1 일대다 단방향은 일이 연관관계 주인이다. DB 테이블에서는 다 쪽인 멤버에 반드시 외래키가 있어야 한다. 그런데, 일인 팀쪽에서만 멤버를 알고 싶고, 다쪽인 멤버에는 팀을 알고 싶지 않을 때 사용한다.

아래 목표 그림을 보고 스스로 만들어 보는 테스트를 한 후, 정답 코드를 확인해보자.

@Entity
@Table
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
}
@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
		//핵심코드
    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    List<Member> members = new ArrayList<>();
}

잘 사용하지 않는 이유

Team team = new Team();
team.setName("teamName");
team.getMembers().add(member);
em.persist(team);
----------------------------------------------
Hibernate: 
insert into Team (name, id) values (?, ?)
//팀을 추가했는데, 관계 없는 멤버 테이블에 업데이트 쿼리가 날라간다.
Hibernate: 
update Member set TEAM_ID=?  where id=?

엔티티가 관리하는 외래 키가 다른 테이블에 있어서, 연관 관계 관리를 위해 추가로 UPDATE SQL 실행한다. 그래서 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하자.

일대일 [1:1] @OneToOne

주 테이블이나 대상 테이블 중에 외래 키 선택 가능하다. 외래 키에 데이터베이스 유니크(UNI) 제약조건 추가해서 만들면 된다.

아래 목표 그림을 스스로 연관관계 매핑 만드는 테스트를 진행 한 후, 정답 코드를 확인하자.

@Entity
@Table
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;
		//핵심 코드
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
}
@Entity
public class LockerGo {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
		//mappedBy는 다대일 양방향 방법이랑 같다.
		//일대일 단방향을 만들고 싶으면 해당 필드를 삭제하면된다.
    @OneToOne(mappedBy = "locker")
    private Member member;
}

다대다 [N:M] @ManyToMan

다대다 연관관계 매핑은 실무에서 사용하면 안 된다. 관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없기 때문이다. 해결 방법은 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야 한다.

@JoinColumn(외래키 매핑) 속성

@ManyToOne 속성

@OneToMany 속성

고급 매핑

상속 관계 매핑

객체 지향에는 상속 관계라는 개념이 있지만, 데이터 베이스에는 그런 개념이 없다. 그래서 DB의 슈퍼타입 서브 타입이라는 논리 모델링 기법을 사용하여, 객체 지향 설계를 DB와 매핑할 수 있다. 방법에는 조인 전략, 단일 테이블 전략, 구현 클래스마다 전략이 있다.

객체 지향 모델은 같은데, 전략에 따라서 코드와 DB 테이블 구현이 어떻게 변화하는 지 파악해보자.

1 조인 전략 매핑 방법

// Item 엔티티
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
//name설정으로 컬럼 이름을 지정할 수 있다. 
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item{
	@Id @GeneratedValue
	@Column(name = "ITEM_ID")
	private Long id;
	
	private String name;
	private int price;
	
	...
}
// Album 엔티티
@Entity
//해당 어노테이션 지정하지 않을 시 엔티티 이름을 그냥 DTYPE사용
@DiscriminatorValue("A")
public class Album extends Item{
	private String artist;
}

// Movie 엔티티
@Entity
@DiscriminatorValue("M")
public class Movie extends Item{
	private String director;
	private String actor;
}
// Book 엔티티
@Entity
@DiscriminatorValue("B")
//자식 테이블 ID 컬럼명을 BOOK_ID로 따로 정할 수 있다.
@PrimaryKeyJoinColumn(name = "BOOK_ID")

2 단일 테이블 전략

// Item 엔티티
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DisciminatorColumn(name = "DTYPE")
...

// 자식 엔티티
@Entity
@DiscriminatorValue("A")
...

@Entity
@DiscriminatorValue("M")
...

@MappedSuperclass 상속 기능보다는 공통 정보만 필요할 때

@MappedSuperclass 여러 테이블들이 공통적으로 가지고 있는 컬럼을 대신 소유하는 클래스를 만들 때 사용한다.

@MappedSuperclass를 붙인 엔티티는 테이블이 생성되지 않는다. 그냥 공통의 정보를 가지고 있는 추상 클래스라고 생각하면 된다. 그래서 em.find(BaseEntity) 로조회, 검색 불가하다.

아래 원하는 상황 그림을 보면서, 공통의 정보를 자식 클래스에는 작성하지 않고, 부모 클래스에만 작성하는 상황을 @MappedSuperclass 사용해 만들어 보자.

구현 코드

// BaseEntity
//@Entity 어노테이션을 붙이지 않느다.
@MappedSuperclass
public abstract class BaseEntity{
	@Id @GeneratedValue
	private Long id;
	
	private String name;
	...
}

// Member 엔티티
@Entity
//상속 받는 것으로 DB에 테이블에 id name 정보를 가지고 있는다.
public class Member extends BaseEntity{
	// ID, NAME 상속
	private String email;
	...
}

// Seller 엔티티
@Entity
public class Seller extends BaseEntity{
	// ID, NAME 상속
	private String shopName;
	...
}