연관관계 매핑

엔티티( Entity )들은 대부분 서로 관계를 맺고 있습니다.

예를들어 Category 엔티티와 Book 엔티티가 있을 때, Category에는 많은 Book을 갖는 관계를 갖고 있습니다.

이렇게 엔티티들이 서로 어떤 연관관계를 맺는지 파악하는 것은 매우 매우 중요합니다.


연관관계 매핑이란 객체의 참조와 테이블의 외래키를 매핑하는 것을 의미합니다.

JPA에서는 JDBC( Mybatis )를 사용했을 때와 달리 연관 관계에 있는 상대 테이블의 PK를 멤버변수로 갖지 않고, 엔티티 객체 자체를 통째로 참조합니다.

예를들어 Category 엔티티와 Book 엔티티의 관계에서 Book 엔티티가 Category 엔티티를 참조할 때 Mybatis와 JPA는 아래와 같이 다릅니다.

// Mybatis

private Integer categoryNo;


// JPA

private Category category

Mybatis는 관계에 있는 테이블의 PK를 멤버 변수로 갖지만, JPA는 관계에 있는 엔티티 객체를 참조하고 있습니다.


물론 단순히 참조하는 것만으로는 연관관계를 맺을 수 없고, 매핑하는 방법은 코드로서 알아보겠습니다.

그 전에 연관관계 매핑에서 사용될 용어들을 알아보겠습니다.





용어

1) 방향

단방향 관계 : 두 엔티티가 관계를 맺을 때, 한 쪽의 엔티티만 참조하고 있는 것을 의미합니다.

양방향 관계 : 두 엔티티가 관계를 맺을 때, 양 쪽이 서로 참조하고 있는 것을 의미합니다.


데이터 모델링에서는 관계를 맺어주기만 하면 자동으로 양방향 관계가 되어서 서로 참조하지만, 객체지향 모델링에서는 구현하고자 하는 서비스에 따라 단방향 관계인지, 양방향 관계인지 적절한 선택을 해야 합니다.


어느 정도의 비즈니스에서는 단방향 관계만으로도 해결이 가능하기 때문에 양방향 관계를 꼭 해야 하는 것은 아닙니다.

사실 양방향 관계란 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 할 뿐, 양방향 연관 관계는 존재하지 않는다고 할 수 있습니다.




2) 다중성

관계에 있는 두 엔티티는 다음 중 하나의 관계를 갖습니다.

Many To One   - 다대일( N : 1 )

One To Many   - 일대다( 1 : N )

One To One    - 일대일( 1 : 1 )

Many To Many - 다대다( N : N ) 


예를들어 카테고리는 많은 서적을 갖고 있으므로 카테고리 입장에서는 서적과 일대다 관계이며,

서적의 입장에서는 하나의 카테고리에 속하므로 다대일 관계입니다.

즉, 어떤 엔티티를 중심으로 상대 엔티티를 바라 보느냐에 따라 다중성이 다릅니다.




3) 연관 관계의 주인 ( Owner )

연관 관계에서 주인을 결정합니다.

주인을 찾는 방법은 연관 관계를 갖는 두 테이블에 대해서 외래키를 갖는 테이블이 연관 관계의 주인이 됩니다.

연관관계의 주인만이 외래 키를 관리(등록, 수정, 삭제) 할 수 있고, 반면 주인이 아닌 엔티티는 읽기만 할 수 있습니다.



이제 두 엔티티가 어떻게 연관관계를 맺을 수 있는지 알아보도록 하겠습니다.





1. @ManyToOne - 단방향

@Entity
@Table(name="category")
public class Category {
	@Id
	@Column(name="no")
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Integer no;
	
	@Column( name="name", nullable=false, length=100 )
	private String name;
	
	// getter , setter 생략
	
}


@Entity
@Table( name="book")
public class Book {
	@Id
	@Column(name="no")
	@GeneratedValue( strategy = GenerationType.IDENTITY )
	private Integer no;
	
	@Column(name="title", nullable=false, length=200)
	private String title;
	
	@ManyToOne
	@JoinColumn(name ="category_no")
	private Category category;
	
	// getter , setter 생략
	
}

단방향은 한 쪽의 엔티티가 상대 엔티티를 참조하고 있는 상태입니다.

그래서 Book 엔티티에만 @ManyToOne 어노테이션을 추가했습니다.


@ManyToOne 

@ManyToOne 어노테이션은 이름 그대로 다대일( N : 1 ) 관계 매핑 정보입니다.

책 입장에서는 카테고리와 다대일 관계이므로 @ManyToOne이 됩니다.

연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션(@ManyToMany, @OneToOne 등…)은 필수로 사용해야 하며, 엔티티 자신을 기준으로 다중성을 생각해야 합니다.


@JoinColumn(name="category_no") 

@JoinColumn 어노테이션은 외래 키를 매핑 할 때 사용합니다. 

name 속성에는 매핑 할 외래 키 이름을 지정합니다. 

Book 엔티티의 경우 Category 엔티티의 no 필드를 외래 키로 가지므로, category_no를 작성하면 됩니다.


@JoinColumn 어노테이션을 생략하면 아래와 같은 전략에 따라 외래 키를 매핑합니다.

필드명 +  “_” + 참조하는 테이블의 기본 키(@Id) 컬럼명



위와 같이 엔티티를 정의한 후 hibernate가 테이블을 정의하는 SQL을 보면, book 테이블에 외래키로 category_no를 갖는 것을 알 수 있습니다.


이제 단방향 관계를 맺었으므로 외래키가 생겼기 때문에, Book에서 Category 정보들을 쭉 가져올 수 있게 되었습니다.

그런데 Category에서 Book 정보들을 쭉 가져오고 싶다면 어떻게 해야 할까요?


데이터 모델링에서는 1:N 관계만 설정해주면 자동으로 양방향 관계가 되기 때문에 어느 테이블에서든 join만 해주면 원하는 칼럼들을 가져올 수 있습니다.

Mybatis에서 SQL을 직접 작성하므로 데이터 모델링을 할 때 테이블 간의 외래키만 잘 설정 해줬다면, join으로 원하는 칼럼을 가져올 수 있던 것을 떠올리면 쉽게 이해가 갈 것입니다.


JPA에서는 양방향 관계를 맺음으로써 해결 할 수 있습니다





2. @OnetoMany로 양방향 맺기

@Entity
@Table(name="category")
public class Category {
	@Id
	@Column(name="no")
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Integer no;
	
	@Column( name="name", nullable=false, length=100 )
	private String name;

	@OneToMany(mappedBy="category")
	private List<Book> books = new ArrayList<Book>();
	
	// getter , setter 생략
	
}

Category는 Book을 List로 가지며, @OneToMany 어노테이션에 mappedBy 속성을 추가했습니다.


연관관계의 주인을 정하는 방법은 mappedBy 속성을 사용하는 것입니다.

-  주인은 mappedBy 속성을 사용하지 않습니다.

-  주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 정할 수 있습니다.


주인은 mappedBy 속성을 사용할 수 없으므로 주인이 아닌 Category 엔티티의 books 필드에 mappedBy 속성을 사용하여 주인이 아님을 JPA에게 알려줍니다.

@OneToMany( mappedBy = "category“ )

private List<Book> books = new ArrayList<Book>();


말 그대로 해석해보면, 카테고리 입장에서 많은 책을 가지므로 @OneToMany 어노테이션을 달아주고, books 필드는 category에 의해 매핑되므로, mappedBy = “category”로 작성한 것입니다.

여기서 " category "는 Book 엔티티에서 Category를 참조 할 때 작성한 필드명입니다.



사실 두 엔티티 사이에서 일대다 관계만 잘 따지면 누가 owner인지 따질 필요가 없습니다.

즉 데이터 모델링에서 어떤 엔티티가 외래키를 갖고 있어야 하느지 파악한 후, 적절한 어노테이션만 추가해주면 그만이죠.



Book 클래스의 코드는 똑같습니다.

이미 단방향 관계를 맺은 상태에서 Category가 Book쪽으로 단방향 연결을 해주었기 때문에 양방향 관계가 성립된 것이죠.


또한 양방향 관계든, 단방향 관계든 테이블을 정의하는 SQL도 같습니다.

외래키가 Book에 있습니다.





3. 양방향 관계를 맺었지만 카테고리는 책의 정보를 알지 못한다

양방향 관계를 맺으면 카테고리도 책의 정보를 알 수 있다고 했었습니다.

그런데 실제로는 그렇지 않는데, 다음의 코드를 보면서 살펴보겠습니다.

public static void insertAndFind (EntityManager em) {
	Category category = new Category();
	category.setName("IT");
	em.persist(category);
	
	Book book = new Book();
	book.setTitle("Operation System");
	book.setCategory(category);
	em.persist(book);
	
	List<Book> bookList = category.getBooks();
	for( Book item: bookList) {
		System.out.println(item.getTitle());
	}
}

카테고리와 책을 영속( persist )시킨 후, 카테고리에 속한 모든 책의 제목을 출력하는 코드입니다.

그러나 콘솔 창에는 아무것도 출력이 되지 않습니다. 



INSERT 쿼리가 수행되는 것으로 보아 DB에는 데이터가 들어가는 것을 알 수 있습니다.

그런데 왜 책 제목은 출력이 되지 않을까요?


persistence context에서 카테고리 엔티티가 책 엔티티의 정보들을 가지고 있지 않기 때문입니다.

즉, 아직 DB에 반영되지 않은 상태이므로 책 정보들이 persistence context에 존재하지 않은 것이죠.

따라서 아직 DB에 저장되지 않은, persistence context에 존재하는 책의 정보들을 카테고리 엔티티가 참조할 수 있도록 수정해야 합니다.

public static void insertAndFind (EntityManager em) {
	Category category = new Category();
	category.setName("IT");
	em.persist(category);
	
	Book book = new Book();
	book.setTitle("Operation System");
	book.setCategory(category);
	book.getCategory().getBooks().add(book);
	em.persist(book);
	
	List<Book> bookList = category.getBooks();
	for( Book item : bookList) {
		System.out.println(item.getTitle());
	}
	
}

book.getCategory().getBooks().add(book);

이 한줄의 코드만 추가하면 persistence context에서도 카테고리는 책 엔티티를 참조 할 수 있습니다.



그런데 이 코드도 문제가 있습니다.

만약 " Operation System " 책을 새로운 카테고리로 변경하면 어떻게 될까요?

public static void update(EntityManager em) {
	Category newCategory = new Category();
	newCategory.setName("etc");
	em.persist(newCategory);
	
	Book book = em.find(Book.class, 1);
	book.setCategory(newCategory);
	book.getCategory().getBooks().add(book);
}


카테고리가 변경 되었지만 여전히 IT 카테고리는 operation system 책을 참조하고 있습니다.

따라서 이 문제를 해결해야 합니다.

public void setCategory(Category category) {
	// 이미 카테고리가 있을 경우 관계를 제거한다.
	if( this.category != null ) {
		this.category.getBooks().remove(this);
	}
	
	this.category = category;
	
	if( category != null ) {
		category.getBooks().add(this);
	}
}

Book 클래스에서 setCategory() 메서드를 수정했습니다.


최종적으로 카테고리는 persistence context에서 책 엔티티를 올바르게 참조 할 수 있게 되었습니다.





4. @OneToOne

한 유저는 한 개의 블로그만 만들 수 있는 블로그 서비스를 개발하고 있다고 가정하겠습니다.

즉 유저와 블로그는 1:1 관계라고 할 수 있습니다.


유저 엔티티 입장에서 블로그의 @Id를 외래키로 가져서 블로그를 참조할 수 있고, 그 반대 경우인 블로그가 외래키를 가져서 유저를 참조하는 것도 가능합니다.

여기서 외래키를 갖고 있는 테이블을 주 테이블이라고 합니다.


그런데 어떤 엔티티가 외래키를 갖고 있는 것이 맞을까요?

이것은 관점의 차이인데,

한 유저가 여러 개의 블로그를 가질 수 있도록 확장( OneToMany )될 수 있음을 고려 한다면, 블로그에서 멤버의 키를 외래키로 갖는 것이 좋을 것이고,

유저를 조회 했을 때 자동으로 블로그 엔티티도 조회 되는 것이 좋겠다고 생각하면 유저에서 블로그의 외래키를 갖는 것이 좋을 것입니다.


예제에서는 후자의 방식인, 유저에 외래키를 두는 방식으로 구현해보겠습니다.

[ 단방향 ]

@Entity
@Table(name="user")
public class User {
	@Id
	@GeneratedValue(strategy=GenerationType.AUTO)
	@Column(name="no")
	private Integer no;
	
	@Column(name="id")
	private String id;

	@OneToOne
	@JoinColumn(name="blog_no")
	private Blog blog;
}


@Entity
@Table(name="blog")
public class Blog {
	@Id
	@GeneratedValue(strategy=GenerationType.AUTO)
	@Column(name="no")
	private Integer no;
	
	@Column(name="name")
	private String name;
}



[ 양방향 ]

@Entity
@Table(name="blog")
public class Blog {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	@Column(name="no")
	private Integer no;
	
	@Column(name="name")
	private String name;
	
	@OneToOne(mappedBy="blog")
	private User user;	
}

양방향이기 때문에 연관관계의 주인을 정해야 합니다.

User 클래스 코드는 동일합니다.





5. @ManyToMany

데이터모델링에서 Many To Many의 관계를 해결 할 때, 두 테이블의 외래키를 복합키로 하여 PK를 갖는 테이블을 따로 생성합니다.

그리고 칼럼이 추가적으로 더 필요하면 추가 할 수도 있습니다.



JPA에서는 테이블을 따로 생성할 필요 없이 어노테이션을 달아주면 자동으로 중간 테이블을 생성해줍니다.

[ 단방향 ]

@Entity
@Table(name="user")
public class User {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	@Column(name="no")
	private Integer no;
	
	@Column(name="id")
	private String id;

	@ManyToMany
	@JoinTable(name="user_product",
				joinColumns = @JoinColumn(name = "user_no"),
				inverseJoinColumns = @JoinColumn(name = "product_no"))
	private List<Product> product = new ArrayList<Product>();
}


@Entity
@Table(name="product")
public class Product {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	@Column(name="id")
	private Integer id;
	
	@Column(name="name")
	private String name;
}

@JoinTable 어노테이션은 M:N 관계에서 생성할 테이블 명을 정의하는 어노테이션입니다.

name속성으로 새로 생성할 테이블의 이름을 정의하고,

joinColumns 속성으로 join을 수행할 자신의 칼럼 이름을 작성하고,

inverseJoinColumns 속성으로 join을 수행할 다른 칼럼 이름을 작성합니다.



hibernate가 출력하는 쿼리를 보시면, user_product 테이블이 생성되고, 외래키로 product_no 와 user_no를 가진다는 것을 확인할 수 있습니다.



private static void find(EntityManager em) {
	User user = em.find(User.class, 1);
	List<Product> productList = user.getProducts();
	
	for(Product product : productList ) {
		System.out.println(product.getName());
	}
}

유저가 갖고 있는 모든 상품들을 조회하는 메서드입니다.



hibernate가 출력하는 쿼리를 보니 user_product 테이블에서 조인이 일어나는 것을 알 수 있습니다.



[ 양방향 ]

@Entity
@Table(name="product")
public class Product {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	@Column(name="id")
	private Integer id;
	
	@Column(name="name")
	private String name;

	@ManyToMany(mappedBy="products")
	private List<User> users = new ArrayList<User>();

}

어떤 엔티티가 연관 관계의 주인인지 명시하기 위해 mappedBy 속성만 추가하면 됩니다.




6. User_product 테이블에서 칼럼이 필요하다면?

User_product 테이블은 User와 Product 테이블의 외래키만 칼럼으로 갖고 있습니다.

User_product 테이블은 장바구니( cart )로 대응시킬 수 있는데, cart 테이블에는 수량( count )에 대한 필드가 있을 수 있습니다.

한 유저가 상품을 몇 개 갖고 있는지에 대한 데이터가 필요할 수 있기 때문이죠.


이를 해결하기 위해서는 Cart 엔티티를 정의한 후, User와 Product 엔티티의 PK를 별도의 객체로 정의하여 Cart 엔티티에서 이 객체를 복합키로 갖도록 해야 합니다.

@Entity
@Table(name="cart")
@IdClass(CartId.class)
public class Cart {
	@Id
	@ManyToOne
	@JoinColumn(name="user_no", nullable=false)
	private User user;
	
	@Id
	@ManyToOne
	@JoinColumn(name="product_id", nullable=false)
	private Product item;
	
	@Column(name="count", nullable=false)
	private Integer count;
}


public class CartId implements Serializable{
	private Integer user;
	private Integer product;
	
	// getter setter 생략
}

User와 Product 엔티티의 PK를 복합키로 정의하기 위해 CartId 라는 클래스를 정의했습니다.


그리고 User와 Product 엔티티의 관계 엔티티인 Cart 엔티티를 정의할 때 @IdClass 어노테이션을 작성했습니다.

또한 Cart 엔티티는 User와 Product 엔티티에 대해 ManyToOne 관계이므로 두 엔티티에 대해 @ManyToOne 어노테이션을 작성했고,

수량에 대한 칼럼인 count를 추가했습니다.





이상으로 엔티티간의 관계를 맺는 연관관계 매핑에 대해 알아보았습니다.

엔티티 간의 연관관계를 매핑하는 것은 JPA를 사용하는데 있어 매우 중요합니다.



댓글 펼치기 👇
  1. punkyguy 2020.01.18 16:25

    victolee님 안녕하세요
    JPA 연관관계에 대한 정리를 정말 잘 해셔서 대단히 큰 도움을 얻게 됐습니다
    감사합니다.

    그리고 제가 이 내용을 읽는 과정 중에 이해가 잘 가지 않는 내용이 있었습니다.
    그래서 제가 생각한 부분이 victolee님이 기술하신 내용과 맞는지 확인을 구하고 싶은데요.

    맨 마지막 'User_product'에 연장선상 Cart에 대해 기술하신 내용 중에서
    'Cart' Entity 클래스 복합키 멤버로 등록되는
    user와 product가 모두 ManyToOne으로 연결되고 있는데
    이 경우, [카트 M : 사용자 1], [카트 N : 상품 1]의 연결 관계로 이해하면 될까요?
    제가 생각한 내용에 확신을 할 수 없어서, 빅토리님의 도움을 구하고 싶습니다.
    (p.s. 제가 생각한 상황은 '쇼핑몰'의 사용자 - 상품 - 카트 case입니다.)

    좋은 글을 써주셔서 다시 한 번 감사드리고,
    답변 남겨주시면 정말 감사드리겠습니다.

  2. Favicon of https://victorydntmd.tistory.com victolee 우르르응 2020.01.20 07:55 신고

    안녕하세요~
    넵 말씀하신 내용과 제가 생각하는 부분이 같습니다~
    데이터 모델링에서도 M:N은 M:1과 N:1로 나눠서 생각하거든요.
    객체 지향 모델링에서도 양방향은 이렇게 생각해볼수 있을것 같습니다