본 글은 인프런 김영한님의 JPA 로드맵을 기반으로 정리했습니다.
연관관계 매핑시 고려할 사항은 크게 3가지다.
방향(Direction): 단방향, 양방향
다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
연관관계의 주인(Owner): 엔티티 양방향 연관관계는 관리 주인이 필요
이전 글에서 테이블은 외래키 하나만으로 양방향 연관관계가 완성되고, 엔티티는 단방향과 양방향을 선택할 수 있다고 했다. 그리고 양방향 연관관계에서는 외래키를 관리하는 연관관계의 주인을 정해줘야한다. 잎으로 양방향 연관관계에서 주인이 아닌쪽을 양방향 연관관계의 거울이라고 표현하겠다.
이번 글에서는 연관관계의 다중성에 집중해서 살펴보자.
테이블에서는 N:1 에서 항상 N쪽이 외래키를 가진다. 외래키를 가진 테이블에 매핑된 엔티티를 연관관계의 주인으로 설정하는 것이 다대일 연관관계다.
다대일 연관관계는 단방향, 양방향 모두 가능하다. N:1 관계를 가지는 Member, Team 예제를 통해 살펴보자.
특징을 살펴보자.
Member 테이블에 Team 테이블 FK가 있다.
Member 엔티티가 외래키를 관리한다.
Team 엔티티는 Member 엔티티로의 참조가 없다.
다대일 단방향 연관관계는 JPA에서 가장 흔하게 사용된다.
코드를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
}
N쪽인 Member 엔티티가 연관관계의 주인이 된다. Member에서 @JoinColumn
애노테이션을 통해 연관된 엔티티와 매핑할 수 있는 테이블의 외래키 컬럼을 지정한다.
특징을 살펴보자.
Member 테이블에 Team 테이블 FK가 있다.
Member 엔티티가 외래키를 관리한다.
Team 엔티티는 Member 엔티티를 참조한다. 외래키를 관리할 수 없는 읽기 전용 참조가 된다.
Team 엔티티에서 Member 엔티티를 읽기 전용으로 참조할 수 있다. 양방향 연관관계는 양쪽 엔티티의 값을 모두 관리해줘야되므로 단방향 연관관계보다 관리하기 복잡하다.
코드를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
N쪽인 Member 엔티티의 코드는 그대로다. 1쪽인 Team 엔티티는 연관관계의 거울이 된다. @OneToMany
애노테이션을 통해 다중성을 명시해주고 mappedBy
속성을 통해 연관관계의 거울임을 표현할 수 있다. members 필드를 통해 Member 엔티티를 읽기 전용으로 참조할 수 있다.
일대다 연관관계는 다대일 연관관계와 테이블은 똑같다. 그러나, 외래키가 존재하지 않는 엔티티로 반대편 테이블의 외래키를 관리하게 된다.
일대다 연관관계도 다대일 연관관계처럼 단방향, 양방향 모두 가능하다. 예제를 살펴보자.
데이터베이스는 1:N에서 항상 N쪽인 테이블이 외래키를 가진다. 그러나 일대다 연관관를 사용하면 특이하게도 1쪽인 엔티티로 외래키를 관리할 수 있다. 그러나 이 매핑은 다음의 단점들로 인해 거의 사용되지 않는다.
엔티티가 관리하는 외래키가 다른 테이블에 있기 때문에 직관적이지 않고 관리의 어려움이 생긴다.
하나의 엔티티로 두 개의 테이블을 관리하기 때문에 추가로 UPDATE SQL이 실행된다.
1쪽에서 N쪽을 참조할 필요가 있으면 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하자.
코드를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
}
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
}
테이블에서 외래키를 가지고 있는 Member 엔티티는 아무런 매핑정보가 없다. 그 대신 1쪽인 Team 엔티티가 Member 테이블의 외래키를 관리하게된다. @OneToMany
애노테이션을 통해 다중성을 명시하고 @JoinColumn
을 통해 외래키와 매핑한다.
@JoinColumn
을 꼭 사용해야한다. 생략하면 조인 테이블 방식을 사용한다. 연관관계를 매핑하기 위해 중간에 테이블이 하나 추가된다. 테이블이 하나 추가되기 때문에 관리와 성능 측면에서 손해다. 그러나 아예 일대다 매핑을 사용하지 않는것이 좋다. 일대다 매핑보다는 다대일 양방향 매핑을 사용하자.
먼저 말하자면 일대다 양방향 매핑은 JPA 스펙에서 공식적으로 존재하지않는다. 그러나, 애노테이션을 통해 필드를 읽기 전용으로 만들면 구현할 수 있다.
일대다 양방향 매핑은 엔티티끼리 서로 참조를 가진다. 그러나, 1:N 관계에서 외래키가 매핑되는 N쪽의 Member 엔티티가 아니라 1쪽의 Team 엔티티가 외래키를 관리하게 된다.
코드를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(insertable=false, updatable=false)
private Team team;
}
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
}
Member 엔티티에서 @JoinColumn(insertable=false, updatable=false)
애노테이션을 통해 연관된 Team 엔티티를 읽기 전용 필드로 참조했다. Team 엔티티의 코드는 일대다 단방향 매핑과 똑같다. 그러나, 굳이 JPA 스펙에서 공식적으로 존재하지도 않는 매핑을 복잡한 방식으로 구현할 필요는 없다. 일대다 단방향 매핑에서 언급한 것처럼 일대다 매핑대신 다대일 양방향 매핑을 사용하자.
일대일 연관관계는 반대쪽도 일대일이다. 데이터베이스에서 테이블끼리 일대일 관계를 가지면 외래키를 어느 테이블에 두어도 무방하다. 데이터베이스에서 외래키에 데이터베이스 유니크(UNIQUE) 제약조건을 걸어주면 일대일 테이블 매핑이 완성된다. 다대일 매핑과 다른점은 외래키에 걸어준 유니크 제약조이다.
비즈니스적으로 더 자주 사용되는 테이블을 주 테이블 그렇지 않은 테이블을 대상 테이블로 나눠서 설명하겠다. 예제에서 Member가 주 테이블이고 Locker가 대상 테이블이다.
엔티티 연관관계는 다대일 단방향과 똑같다. 테이블에서는 외래키에 유니크 제약조건을 걸어줘야한다. 유니크 제약조건을 걸지 않고 애플리케이션에서 로직을 통해서 유니크 제약조건을 달성할 수 있긴 하지만 제약조건을 완벽히 지키기면서 코드를 짜는 것은 매우 힘드니 유니크 제약조건을 걸어주는 것이 낫다. 향후 Member가 Locker를 여러개 사용할 수 있다는 요구사항이 추가되면 데이터베이스의 유니크 제약조건을 풀면 그만이다.
코드를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}
@Entity
@Getter @Setter
public class Locker {
@Id
@GeneratedValue
private Long id;
private String name;
}
@ManyToOne
대신 @OneToOne
을 사용하는 작은 차이가 있다. 다대일 단방향 매핑과 매우 유사하다. Member가 Locker를 여러개 사용할 수 있다는 요구사항이 생기면 데이터베이스의 유니크 제약조건을 풀고 @OneToOne
에서 @ManyToOne
으로 변경하면 그만이다.
다대일 양방향 매핑처럼 외래 키가 있는 곳이 연관관계의 주인이 된다. 반대쪽 엔티티는 읽기 전용필드를 통해 연관된 엔티티를 참조한다.
코드를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}
@Entity
@Getter @Setter
public class Locker {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToOne(mappedBy = "locker")
private Member member;
}
Member 엔티티의 코드는 그대로다. Locker 엔티티는 Member 타입의 참조를 가진다. @OneToOne
으로 다중성을 정하고 mappedBy
속성으로 연관관계의 주인이 아님을 명시해줬다.
지금까지 주 테이블에 외래키가 있는경우를 살펴봤다. 데이터베이스에서 주 테이블이 아닌 대상 테이블에 외래키가 존재할 수 있다.
대상 테이블인 Locker에 외래키가 존재한다. 이 때 Member 엔티티만 Locker 엔티티를 참조하는 단방향 연관관계를 맺고 싶을 수 있다. 그러나 JPA를 통해 이 관계를 매핑할 수 있는 방법은 존재하지 않는다. 양방향 관계는 가능하니 살펴보자.
대상 테이블인 Locker 테이블에 외래키가 존재한다. 엔티티는 양방향 연관관계를 맺어 서로를 참조한다. 그러나 연관관계의 주인은 Member 엔티티가 된다. 즉 Member 엔티티가 Locker 테이블에 존재하는 외래키를 관리하고 Locker 엔티티는 연관관계의 거울이된다.
매핑하는 코드는 4.2절의 "주 테이블에 외래키, 양방향"이랑 똑같다.
일대일 연관관계는 주 테이블에 외래키가 있는 경우와 대상 테이블에 외래키가 있는 경우가 있다. 각각의 특징, 장단점을 살펴보자. 다시 말하자면 주 테이블은 비즈니스상 더 자주 사용되는 Member와 같은 테이블이고 대상 테이블은 상대적으로 덜 사용되는 Locker와 같은 테이블이다.
참고로 단뱡향, 양방향 연관관계의 경우 다대일과 마찬가지로 단방향으로 매핑을 끝낸 후 필요할 때 양방향 연관관계로 바꾸면된다.
주 테이블에 외래키
주 객체가 대상 객체의 참조를 가지듯이 주 테이블에 외래키를 두고 대상 테이블을 찾는다.
객체지향 개발자가 선호하는 방식이다.
JPA 매핑이 편리하다.
장점: 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능하다.
단점: 대상 테이블에 값이 없는 경우 주 테이블의 외래키에 null을 허용해야한다.
대상 테이블에 외래키
대상 테이블에 외래키를 둔다.
관계형 데이터베이스 개발자가 선호하는 방식이다.
장점: 주 테이블과 대상 테이블이 일대다 관계로 변경되는 경우 테이블 구조를 유지할 수 있다. 유니크 제약조건을 풀고 매핑하는 애노테이션만 변경하면 된다.
단점: 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩된다.
결론적으로 굵게 강조한 대상 테이블에 외래키를 두는 방식의 단점 때문에 주 테이블에 외래키를 두는 방식을 추천한다.
지연 로딩과 프록시, 즉시 로딩의 개념은 이후의 글에서 살펴보겠다. 간단히 말하자면 주 테이블에 외래키가 없기 때문에 대상 테이블에 데이터가 있는지 알려면 실제로 대상 테이블을 조회 해야한다. 대상 테이블에 이미 조회 쿼리가 나가야되기 때문에 지연 로딩 전략을 통해 참조 객체를 프록시로 채워넣는 방식이 의미가 없어진다. 이해가 되지 않는다면 이후의 글에서 살펴볼테니 일단 넘어가면 된다.
데이터베이스의 컬럼은 원자값이어야 하므로 다대다 연관관계를 정규화된 테이블 2개로 표현할 방법이 없다. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야한다. 그러나 엔티티는 컬렉션을 사용할 수 있기 때문에 엔티티 2개만으로 다대다 연관관계를 표현할 수 있다. 아래 그림처럼 말이다.
그러나 다대다 매핑은 실무에서 사용할 수 없다. 엔티티를 다대다 연관관계로 매핑해도 데이터베이스는 자동으로 연결 테이블을 생성해 연관관계를 관리한다. 문제는 단순히 두 테이블을 (PK, FK) 조합으로 연결만 하고 끝난다는 것이다. 실무에서는 단순히 연결만 하고 끝나는 경우가 거의 없다. 위의 경우 주문시간, 주문 수량같은 데이터를 추가해야할 수도 있고 하다 못해 등록일, 수정일, 등록자, 수정자와 같은 데이터라도 추가해야할 수 있다. 아래 그림처럼 말이다.
그러나 엔티티를 다대다 매핑로 매핑하면 연결 테이블에 매핑 정보외에 추가 데이터를 넣을 방법이 없다. 이 한계를 극복하려면 연결 테이블용 엔티티를 추가해야한다. 다른말로 자동생성되는 연결 테이블을 사용하는 대신 연결 테이블을 엔티티로 승격해야한다. 그러려면 다대다 연관관계를 일대다+다대일로 풀어내야한다.
JPA에서 다대다 연관관계를 사용하면 MemberProduct 처럼 단순히 두 엔티티의 이름을 합친 연결테이블이 생성된다. 더 최악은 연결테이블에 매핑 정보외에 아무런 추가 데이터도 넣을 수 없다는 것이다. 위처럼 연결 테이블을 엔티티로 승격하면 의미있는 이름을 지어줄 수 있고 필요에따라 추가 데이터를 넣을수도 있다.
코드를 살펴볼텐데 @ManyToMany
는 실전 예제에서 살펴보고 이번엔 바로 Member와 Product의 다대다 관계를 일대다+다대일로 풀어낸 예제를 살펴보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
@OneToMany(mappedBy = "member")
private List<MemberProduct> memberProducts = new ArrayList<>();
}
Member가 Product를 참조하는 대신 중간 엔티티인 MemberProduct를 참조한다.
@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;
}
중간 엔티티 MemberProduct를 추가해서 다대다 관계를 일대다+다대일로 풀어냈다. 자동 생성되는 연결테이블과 비교해 가지는 장점들을 살펴보자.
중간 엔티티는 count, price 등 추가 데이터를 가질 수 있다.
필요하다면 MemberProduct 대신 Order라는 더 명확하고 의미있는 이름으로 바꿀수 있다.
FK의 조합을 PK로 사용하는 대신 비즈니스적으로 의미 없는 대리키를 사용할 수 있다.
@Entity
@Getter @Setter
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "product")
private List<MemberProduct> memberProducts = new ArrayList<>();
}
Product 역시 Member를 직접 참조하는 대신 중간 엔티티인 MemberProduct를 참조한다.
다시 한번 강조한다. 다대다 연관관계를 사용하지말고 중간 엔티티를 추가해서 일대다+다대일 연관관계로 풀어주자.
조금 더 복잡한 예제를 통해 연관관계 매핑에 익숙해져보자. 이번엔 다대다 연관관계도 포함했다. 요구사항은 다음과 같다.
회원과 주문은 1:N
주문과 배송은 1:1
주문과 상품, 카테고리와 상품은 N:M
카테고리는 자기자신과 1:N
엔티티 연관관계는 다음과 같다.
테이블 ERD는 다음과 같다.
이제 엔티티 연관관계를 코드로 표현해보자.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
private String city;
private String street;
private String zipcode;
}
@Entity
@Getter @Setter
@Table(name = "ORDERS")
public class Order {
@Id @GeneratedValue
@Column(name = "ORDER_ID")
private Long id;
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems;
@OneToOne
@JoinColumn(name = "DELIVERY_ID")
private Delivery delivery;
}
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
private Long id;
private String city;
private String street;
private String zipcode;
@Enumerated(EnumType.STRING)
private DeliveryStatus status;
@OneToOne(mappedBy = "delivery")
private Order order;
}
@Entity
@Getter @Setter
public class OrderItem {
@Id @GeneratedValue
@Column(name = "ORDER_ITEM_ID")
private Long id;
@ManyToOne
@JoinColumn(name = "ORDER_ID")
private Order order;
@ManyToOne
@JoinColumn(name = "ITEM_ID")
private Item item;
private int orderPrice;
private int count;
}
@Entity
@Getter @Setter
public class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
private int stockQuantity;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
}
@Entity
public class Category {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "PARENT_ID")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> children = new ArrayList<>();
@ManyToMany
@JoinTable(name = "CATEGORY_ITEM",
joinColumns = @JoinColumn(name = "CATEGORY_ID"),
inverseJoinColumns = @JoinColumn(name = "ITEM_ID"))
private List<Item> items = new ArrayList<>();
}
다음에 특히 주목하자.
일대일 연관관계를 가지는 주문(Order)과 배송(Delivery)은 주 테이블인 주문이 외래키를 관리하는 양방향 매핑이다.
주문(Order)과 상품(Item)의 다대다 관계를 중간 엔티티인 주문상품(OrderItem)으로 풀어낸 덕분에 주문상품은 추가 데이터를 가지고 대리키를 사용할 수 있다.
카테고리(Category)와 상품(Item)은 다대다 관계다. @ManyToMany
를 사용하면서 @JoinTable
을 통해 연결 테이블의 이름을 설정하고 매핑할 외래키를 지정해줬다.
카테고리(Category)는 자기 자신과 다대일 양방향 매핑을 가진다.