본문 바로가기
[ ORM ]/JPA

[ JPA ] 다양한 연관관계 매핑

by 환이s 2024. 3. 13.


연관관계 매핑 시 고려사항 3가지

 

엔티티의 연관관계를 매핑할 때는 다음 3가지 고려사항이 있다.

 

[ 다중성 ]

  • 다대일(@ManyToOne)
  • 일대다(@OneToMany)
  • 일대일(@OneToOne)
  • 다대다(@ManyToMany)
    • 다대다는 실무에서 사용하면 안 된다.

 

[ 단방향, 양방향 ]

  • 테이블의 외래 키(FK) 하나로 조인을 사용해서 양방향으로 쿼리가 가능하므로 사실상 방향이라는 개념이 없다.
  • 객체는 참조용 필드를 가지고 있는 객체만 연관된 객체를 조회할 수 있다.
  • 객체 관계에서 한쪽만 참조하는 것을 단방향, 양쪽에서 서로 참조하는 것을 양방향이라고 한다.

 

[ 연관관계의 주인 ]

  • JPA는 두 객체 연관관계 중 하나를 정해서 데이터베이스 외래 키(FK)를 관리하는데 이것을 연관관계의 주인이라 한다.
  • 외래 키를 가진 테이블과 매핑한 엔티티(일반적으로 N쪽에 해당하는)가 외래 키(FK)를 관리하는 게 효율적이므로 보통 이곳을 연관관계의 주인으로 선택한다.
  • 주인이 아닌 방향은 외래 키를 변경할 수 없고 읽기만 가능하다.
    • 주인이 아닌 엔티티는 mappedBy 옵션에 반대쪽 매핑의 필드 이름을 넣는다.

 

단방향, 양방향 및 연관관계의 주인에 대한 개념은 아래 포스팅을 참고하면 도움이 된다.

 

[ JPA ] 연관관계 매핑 기초

연관관계 매핑? 연관관계 매핑이란 객체의 참조와 테이블의 외래 키를 매핑하는 것을 의미한다. JPA에서는 연관 관계에 있는 상대 테이블의 PK를 멤버 변수로 갖지 않고, 엔티티 객체 자체를 통째

drg2524.tistory.com


다대일 [N:1] - @ManyToOne

 

가장 많이 사용하는 연관관계이며, 다대일의 반대는 일대다이다.

 

@Entity
public class Member {
    @Id
    private Long id;

    @Column(name = "name")
    private String username;

    private Integer age;  

    @ManyToOne  //N:1 단방향 연관관계 
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

// Team Entity에는 참조가 필요하지 않음
//Team

@Entity
public class Team {
    @Id
    private Long id;

    @Column(name = "name")
    private String name;

}

 

Team 객체와 @ManyToOne의 관계를 맺고 있는 것을 확인할 수 있는데, 코드를 보면

일(Team) 대 다(Mamber) 관계이며, 다쪽인 Member에 @JoinColumn(FK)가 설정되어 있다.

 

단방향이기 때문에 Member에서 Team 쪽의 참조만 가지고 있고 Team 쪽에서는 다른 참조관계를 갖고 있지 않다.

 

[ 다대일 주요 속성 - @ManyToOne ]

속성 설명 기본값
optional false로 설정하면 연관된 엔티티가 항상 있어야 한다. True
fetch 글로벌 페치 전략을 설정한다. @ManyToOne=FetchType.EAGER

@OneToMany=FetchType.LAZY
cascade 영속성 전이 기능을 사용한다.  
targetEntity 연관된 엔티티의 타입 정보를 설정한다.

이 기능은 거의 사용하지 않는다.
컬렉션을 사용해도 제네릭으로 타입 정보를 알 수 있다.
 

 

 

[ N:1 양방향 ]

 

외래 키(FK)가 있는 쪽이 연관관계의 주인이다. 양쪽에서 서로 참조하도록 개발하면 된다.

연관관계가 주인이 아닌 쪽은 단순 조회만 가능하기에 필드만 추가해 주면 된다.

 

//Member

@Entity
public class Member {
    @Id
    private Long id;

    @Column(name = "name")
    private String username;

    private Integer age;  

    @ManyToOne  //다대일 단방향 연관관계 
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
//Team
// 양방향 매핑 - Team 엔티티는 컬렉션 추가
@Entity
public class Team {
    @Id
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
}

 

기존에 아무 참조관계도 없던 Team 클래스에 @OneTomany(1:N) 관계가 설정되었다.

양방향 관계에서는 연관관계 주인이 아닌 곳에서 mappedBy 속성을 통해 반대편의 연관관계 주인을 나타내줘야 한다.


일대다 [1:N] - @OneToMany

 

일대다는 1이 연관관계의 주인이 되는 방식으로, 권장하지 않는 방식이다.

실무에서도 거의 사용되지 않는다.

 

테이블 1:N 관계는 항상 다(N) 쪽에 외래 키가 있다.

물론 FK가 N 쪽에 있는 것은 생각해 보면 당연하다.

 

Team의 Members를 수정해 주면 TEAM_ID라는 다른 테이블에 있는 FK를  UPDATE 해줘야 한다.

 

일대다 단방향은 1 : N에서 1의 입장에서 연관관계를 관리하겠다는 의미이기 때문에

어찌 되든 다른 테이블에 있는 FK를 관리해야 한다.

 

객체와 테이블의 차이 때문에 반대편의 외래키를 관리하는 특이한 구조를 나타낸다.

 

예제 코드로 알아보자.

// 일대다 단방향 매핑 - Team 엔티티 컬렉션 추가
@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<Member>();
}

 

Team 엔티티를 확인해 보면

Team의 members 필드에 joinColumn(name = "TEAM_ID")로 지정되어 있다.

 

Team이 FK를 관리하게 되는 것이다.

 

코드를 실행해 보자.

Member member = new Member();
member.setName("member1");
entityManager.persist(member);

Team team = new Team();
team.setName("teamA");
team.getMembers().add(member); // 문제가 되는 부분이다.

entityManager.persist(team);

tr.commit();

 

실행해 보면 다음과 같다.

//Log

Hibernate :
	call next value for hibernate_sequence
Hibernate :
	call next value for hibernate_sequence
Hibernate :
	/* insert hellojpa.Member
    	/* insert
        into
        	Member
            (USERNAME, MEMBER_ID)
        value
        	(?, ?)
Hibernate :
	/* insert hellojpa.Team
    	/* insert
        into
        	Team
            (USERNAME, TEAM_ID)
        value
        	(?, ?)
Hibernate :
	/* create one-to-many row hellojpa.Team.members */ update // 여기를 주목하자.
    	Member
    set
    	TEAM_ID=?
   	where
    	MEMBER_ID=?

 

연관관계 관리를 위해 2개의 INSERT 쿼리가 나간 후,

추가로 UPDATE SQL 실행하는 모습을 볼 수 있다.

 

이는 1:N에서 save 될 때,

양 족 객체를 저장한 뒤 UPDATE 쿼리를 통해 FK 설정하기 때문이다.

 

개발자 입장에서는 헷갈릴 수가 있다.

Team 객체를 UPDATE 했는데, Member에 대한 UPDATE 쿼리문이 나가고 있기 때문이다.

 

또한 1:N 매핑을 권장하지 않는 이유는 다음과 같다.

 

1.@JoinColumn을 꼭 사용해야 한다.

   만약 그렇지 않으면 중간에 JOIN 테이블이 따로 생성된다.

 

2.DB 테이블에서는 항상 N 쪽에 FK가 있기 때문에 패러다임에 충돌이 있다.

   그러면 Entity가 관리하는 FK가 다른 테이블에 있게 되고, 위 로그처럼 UPDATE 쿼리가 발생한다.

 

3. 실무에서는 테이블이 1~2개가 아닌 수십 가지가 공존한다.

   그렇기에 관리가 어려워진다.

 

따라서 1:N 단방향보다는 N:1 양방향 매핑을 사용하자.


일대일 [1:1] - @OneToOne

 

일대일 관계는 양쪽이 서로 하나의 관계만 갖고 반대도 일대일 관계이다.

 

[ 1:1 단방향 ]

// 1:1 단방향 매핑
@Entity
public class Member {  
    @Id
    private Long id;

    @Column(name = "name")
    private String username;

    private Integer age;  

    @OneToOne 
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
}

 

일대일 관계는 주 테이블이나 대상 테이블 중에 FK 선택이 가능하고,

FK 데이터베이스 unique 제약 조건을 추가해줘야 한다.

 

N:1 연관관계와 동일하게 FK가 있는 곳이 연관관계의 주인이며, 주인이 아닌 곳에 mappedBy를 넣어준다.

 

[ 1:1 양방향 ]

// 1:1 양방향 매핑
// Member
@Entity
public class Member {  
    @Id
    private Long id;

    @Column(name = "name")
    private String username;

    private Integer age;  

		@OneToOne 
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
}

// Locker

@Entity
public class Locker {
    @Id
    private Long id;

    @Column
    private String name;

    @OneToOne(mappedBy = "locker")  
    private Member member;  //읽기전용
}

 

N:1 양방향 매핑과 같은 방식으로 FK가 있는 곳이 연관관계의 주인이다.

주인이 아닌 곳에는 mappedBy를 적용해야 한다.

 

[ 1:1  단방향 (대상 테이블에 FK가 있는 단방향) ]

1:1 대상 테이블 FK 단방향 관계는 JPA  지원을 안 한다. 즉, 불가능한 모델이다.

대신 대상 테이블에 FK가 있도록 하고 싶다면, 다음과 같이 양방향으로 만들어야 한다.

 

[ 1:1  단방향 (대상 테이블에 FK가 있는 양방향) ]

근데 사실 이 방법은 맨 처음 1:1 방식을 대칭으로 뒤집은 것일 뿐이다.

 

일대일 맵핑을 정리하자면

 

  • 주 테이블에 FK
    • 주 객체가 대상 객체의 참조를 가지는 것처럼 주 테이블에 FK를 두고 대상 테이블을 찾음
    • 객체지향 개발자 선호
    • JPA 매핑 편리
  • 장점 : 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능
  • 단점 : 값이 없으면 FK에 null 허용

 

  • 대상 테이블의 FK
    • 대상 테이블에 FK가 존대
    • 전통적인 데이터베이스 개발자 선호
  • 장점 : 주 테이블과 대상 테이블을 1:1에서 1:N 관계로 변경할 때 테이블 구조 유지
    • unique 제약 조건만 없애도록 UPDATE 해주면 끝난다.
  • 단점 : 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩됨(프록시는 뒤에서 설명)
1. PK가 주 객체에게 있는 경우

프록시 Locker 객체를 만들려면 JPA는 Member를 로딩할 때,
Member에 Locker 객체 값이 있는지 없는지 알아야 한다.

Member TABLE을 확인해서 FK가 있으면 값이 있다고 가정하고 Proxy를 생성하고, 없다면 null을 삽입해 준다.

2. PK가 대상 테이블에 있는 경우

대상 테이블인 Locker TABLE을 열어서 값이 있는지를 확인해야 한다.
이때 값이 있어야 Member의 Locker가 값이 있다고 인정된다.

하지만 Locker를 찾는 쿼리가 나가기 때문에 Locker를 Proxy로 만드는 의미가 없어진다.

다대다 [N:M] - @ManyToMany

 

관계형 데이터베이스는 정규화된 테이블 2개로 N:M 관계를 표현할 수 없다.

따라서 연결 테이블을 추가해서 1:N , N:1 관계로 풀어내야 한다.

 

하지만 객체 입장에서는 다르다.

객체는 Member에 products라는 참조 변수를, Product에 members라는 참조 변수를 만들면 N:M 관계가 가능하다.

 

N:M 관계가 가능한 객체와 N:M 관계가 불가능한 테이블을 매핑하기 위해

@ManyToMany 애노테이션과 @JoinTable로 연결 테이블을 지정해야 한다.

 

예제 코드로 알아보자.

 

// Member

@Entity
public class Member {
    @Id
    @Column(name="MEMBER_ID")
    private Long id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name="MEMBER_PRODUCT",
               joinColumns = @JoinColumn(name="MEMBER_ID"),
               inverseJoinColumns = @JoinColumn(name="PRODUCT_ID"))
    private List<Product> products = new ArrayList<>();
}

.

Member 엔티티와 Product 엔티티를 @ManyToMany, N:M로 연관관계를 설정했다.

 

N:M 관계를 풀기 위해서는 중간에 매핑 테이블이 필요하기 때문에

@JoinTable 설정을 통해 별도로 엔티티를 만들지 않고 풀어낼 수 있다.

 

@JoinTable
name : 연결 테이블 지정
joinColumns : 현재 방향에서 매핑한 Join 컬럼 정보 지정
inverseJoinColumns : 반대 방향에서 매핑한 Join 컬럼 정보 지정

 

따라서 Member 엔티티에 Product 연관관계를 추가 후 persist를 호출하면 아래와 같은 쿼리가 발생한다.

INSERT INTO PRODUCT ...
INSERT INTO MEMBER ...
INSERT INTO MEMBER_PRODUCT ...

 

[ N:M 매핑의 한계 ]

  • 편리해 보이지만 실무에서는 사용 안 한다.
  • 연결 테이블(Join 테이블)이 단순히 연결만 하고 끝나지 않는다.
  • 중간 테이블에 추가적인 데이터를 넣을 수 없다는 한계점이 존재한다.
    • 주문시간, 수량 같은 데이터가 들어올 수 있는데, 이를 반영하기가 어렵다.
  • 중간 테이블이 숨겨져 있기 때문에 의도치 않은 쿼리가 생성될 수 있다.

 

[ N:M 한계 극복 ]

  • 연결 테이블용 엔티티를 따로 추가(연결 테이블을 엔티티로 승격)해준다.
    • 이전에는 @JoinTable을 사용하여 연결 테이블이 생성되었지만, 이번에는 연결 테이블을 Entity로 만들어 사용한다.
  • @ManyToMany => @OneToMany, @ManyToOne

//연결 테이블 Entity

@Entity
public class MemberProduct { 
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
	@JoinColumn(name = "MEMBER_ID")
    private Member member;

    @ManyToOne
	@JoinColumn(name = "PRODUCT_ID")
    private Product product;

	private int count;
	private int price;

    private LocalDateTime orderDateTime;
}
//Member

@Entity
public class Member {
    @Id
    private Long id;

    @Column(name = "name")
    private String username;

    private Integer age;  

    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}
//Product

@Entity
public class Product {
    @Id
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

마치며

 

오늘은 연관관계 매핑 기초에 이어서 다양한 연관관계 매핑 개념에 대해 알아봤습니다.

 

다음 포스팅에서 뵙겠습니다.

위 포스팅 글은 김영한님의 자바 ORM 표준 JPA 프로그래밍-기본편을 참고했습니다.

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 강의 - 인프런

현업에서 실제로 JPA로 개발을 하고 있습니다. 그런 입장에서보면 지금 작성하고 있는 코드들이 어떻게 작동하는지 이해하는데 큰 도움을 주는 강의입니다. 다음은 제가 느낀 이 강의의 장점들

www.inflearn.com

 

728x90

'[ ORM ] > JPA' 카테고리의 다른 글

[ JPA ] 프록시(Proxy)  (62) 2024.03.21
[ JPA ] 상속 관계 매핑  (59) 2024.03.18
[ JPA ] 연관관계 매핑 기초  (74) 2024.03.07
[ JPA ] Entity Mapping  (77) 2024.02.26
[ JPA ] 영속성 컨텍스트(Persistence Context) 개념 정리 및 사용법  (75) 2024.02.21