티스토리 뷰
이전 글: https://gojs.tistory.com/45
JPA 엔티티 매핑
@Entity엔티티 클래스에 필수로 붙어야하는 어노테이션이다.속성설명기본값name엔티티 이름을 설정한다. 보통 클래스 이름을 그대로 사용한다.클래스 이름엔티티 클래스는 아래와 같은 제약조건
gojs.tistory.com
관계형 데이터베이스의 테이블간의 관계를 객체간의 연관관계로 표현할 수 있다.
크게 단방향 연관관계과 양방향 연관관계로 구분할 수 있으며 이에 대해 알아보자.
단방향 연관관계
회원과 팀이라는 엔티티를 예시로 들어서 이해해보자. 아래와 같은 기본 요구사항이 있다고 가정한다.
- 회원은 하나의 팀에 속한다.
- 하나의 팀에는 여러 회원이 속해있다.
객체 연관관계에서는 Member 타입의 객체는 team 필드를 포함하여 단방향 연관관계를 가지게 된다.
따라서 Member는 Team을 알 수 있지만, Team은 Member를 알 수 없다.
테이블 연관관계에서는 member 테이블이 team_id 컬럼을 가지며 연관관계를 가지게 된다.
member와 team은 조인하여 서로를 알 수 있기 때문에 양방향 관계를 가지게 된다.
SELECT *
FROM MEMBER M
JOIN TEAM T
ON M.TEAM_ID = T.ID;
SELECT *
FROM TEAM T
JOIN MEMBER M
ON T.ID = M.TEAM_ID;
객체를 테이블 처럼 관리하기 위해서는 객체도 양방향 관계를 만들어 주어야 한다.
그러나 Team에 List<Member> 타입 필드를 만들어 준다고 하더라도 이것은 두 개의 단방향 관계일 뿐이다.
객체 연관관계
public class Member {
private String id;
private String username;
private Team team;
public void setTeam(Team team) {
this.team = team;
}
...
}
public class Team {
private String id;
private String name;
...
}
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원2");
Team team1 = new Team("team1", "팀1");
member1.setTeam(team1);
member2.setTeam(team1);
Team findTeam = member1.getTeam();
위와 같이 member1 객체를 참조해서 team1 객체를 탐색할 수 있는데 이를 객체 그래프 탐색이라고 한다.
테이블 연관관계
CREATE TABLE MEMBER (
MEMBER_ID VARCHAR(255) NOT NULL,
TEAM_ID VARCHAR(255),
USERNAME VARCHAR(255),
PRIMARY KEY (MEMBER_ID)
)
CREATE TABLE TEAM (
TEAM_ID VARCHAR(255) NOT NULL,
NAME VARCHAR(255),
PRIMARY KEY (TEAM_ID)
)
ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
FOREIGN KEY (TEAM_ID) REFERENCES TEAM;
INSERT INTO TEAM(TEAM_ID, NAME) VALUES('team1', '팀1');
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES ('member1', 'team1', '회원1');
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES ('member2', 'team1', '회원2');
SELECT T.*
FROM MEMBER M
JOIN TEAM T
ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID = 'member1';
위와 같이 외래 키를 사용하여 연관관계를 탐색할 수 있는데 이를 조인이라고 한다.
이와 같이 객체와 데이터베이스 관점에서의 연관관계를 엔티티 클래스에서 매핑해보자.
@Getter
@Setter
@Entity
public class Member {
@Id
@Column(name = "MEMBER_ID")
private String id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Getter
@Setter
@Entity
public class Team {
@Id
@Column(name = "TEAM_ID")
private String id;
private String name;
}
Member 엔티티와 Team 엔티티를 매핑했으며, Member 엔티티에서 연관관계를 매핑하기 위해 사용한 코드를 살펴보자.
- @ManyToOne: 다대일 관계라는 매핑 정보
- optional : false로 설정하면 연관 엔티티 필수
- fetch : 로딩 전략을 설정
- cascade : 영속성 전이 기능 설정
- targetEntity : 연관된 엔티티 타입 정보 설정
- @JoinColumn: 외래 키를 매핑할 때 사용
- name : 매핑할 외래 키 이름 (”필드명_참조 기본 키 컬럼명”이 기본값)
- referencedColumnName : 참조하는 대상 테이블 컬럼명
- foreignKey : 외래 키 제약조건 직접 지정
@Test
@DisplayName("단방향 연관관계를 이용해서 정상적으로 데이터를 저장하고 조회한다.")
void saveTest() {
// given
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1);
em.persist(member1);
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1);
em.persist(member2);
// when
Member findMember = em.find(Member.class, "member1");
// then
Assertions.assertEquals("member1", findMember.getId());
Assertions.assertEquals("team1", findMember.getTeam().getId());
}
테스트 코드를 작성해서 단방향 연관관계를 이용한 데이터 저장 및 조회 테스트 성공했다.
양방향 연관관계
@Getter
@Setter
@NoArgsConstructor
@Entity
public class Team {
@Id
@Column(name = "TEAM_ID")
private String id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Team(String id, String name) {
this.id = id;
this.name = name;
}
}
양방향 연관관계 매핑을 위해 Team 엔티티에 @OneToMany를 사용한 members 필드를 추가했다.
mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 엔티티의 연관관계 필드 이름을 설정한다.
이렇게 양방향 연관관계 매핑 후 team.members로 객체 그래프를 탐색할 수 있다.
@Test
@DisplayName("양방향 연관관계를 이용해서 정상적으로 데이터를 저장하고 조회한다.")
void retrieveBiDirectionTest() {
// given
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1);
em.persist(member1);
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1);
em.persist(member2);
// when
Team team = em.find(Team.class, "team1");
List<Member> members = team.getMembers();
// then
Assertions.assertInstanceOf(List.class, members);
Assertions.assertEquals(1, members.size());
Assertions.assertEquals(member1, members.get(0));
}
테스트 코드를 작성하여 양방향 연관관계의 객체 그래프 탐색에 대해서 테스트했다.
Assertions.assertEquals(1, members.size()); 에서 fail이 발생한다.
team 내부의 members는 객체 인스턴스로 존재하기 때문에 member1.setTeam(team1)을 하더라도 team.members에는 아무런 값이 세팅되지 않는다.
member1.setTeam(team1);
team1.getMembers().add(member1);
따라서 위와 같이 코드를 작성해야한다.
이러한 실수를 줄여주기 위해서 Entity 클래스의 setter 메서드를 아래와 같이 리팩토링 할 수 있다.
public class Member {
...
private Team team;
...
public void setTeam(Team team) {
this.team = team;
team.getMembers.add(this);
}
}
이처럼 한 번에 양방향 관계를 설정하는 메서드를 연관관계 편의 메서드라고 한다.
member1.setTeam(team1);
member1.setTeam(team2);
// member1의 팀은 team2로 바뀌었지만
// team1의 members에는 member1이 여전히 포함되어 있다.
Member findMember = team1.getMembers().get(0);
간단하게 작성한 연관관계 편의 메서드는 위와 같은 버그를 내포하고 있다.
이러한 버그를 없애기 위해 추가적으로 아래와 같이 리팩토링 할 수 있다.
public void setTeam(Team team) {
if (this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);
}
연관관계의 주인
양방향 연관관계를 엄밀하게 따지면 두 개의 단방향 연관관계 매핑처럼 보인다.
두 개의 연관관계 중 하나의 연관관계는 외래키와 매핑되어 데이터베이스에 쿼리를 수행할 수 있다.
이처럼 양방향 연관관계에서 외래키를 관리하는 하나의 연관관계를 연관관계의 주인이라고 한다.
(주인이 아닌 경우 mappedBy 속성으로 주인을 지정한다)
다대일 단방향 관계
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
...
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
...
}
회원은 Member.team으로 팀 엔티티를 참조할 수 있지만, 팀에서는 회원을 참조할 수 없다 (단방향)
다대일 양방향 관계
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
public void setTeam(Team team) {
this.team = team;
if(!team.getMembers().contains(this)) {
team.getMembers().add(this);
}
}
...
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public void addMember(Member member) {
this.members.add(member);
if (member.getTeam() != this) {
member.setTeam(this);
}
}
...
}
위처럼 양방향 연관관계의 경우에는 외래 키가 있는 쪽이 연관관계의 주인이다. (항상 서로를 참조한다)
일대다 단방향 방향
일대다 관계는 다대일의 반대 방향이다. (연관관계 주인이 외래 키를 포함하지 않는 테이블에 있음)
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
...
}
@Entity
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<>();
...
}
일대다 단방향 관계를 매핑할 때에는 @JoinColumn을 명시해야한다. 그렇지 않으면 자동으로 조인 테이블 전략을 사용하게 된다.
그러나 일대다 단방향 매핑은 외래 키를 다른 엔티티에서 관리한다는 점에서 단점이 발생한다.
Member member1 = new Member("member1");
Member member2 = new Member("member2");
Team team1 = new Team("team1");
team1.getMembers().add(member1);
team1.getMembers().add(member2);
em.persist(member1);
em.persist(member2);
em.persist(team1);
transaction.commit();
위와 같은 코드에서는 쿼리가 총 4개가 발생한다.
insert … member1 … MEMBER ..
insert … member2 … MEMBER ..
insert … team1 … TEAM ..
update MEMBER .. member1, member2 ..
일대다 매핑은 성능 문제, 관리 문제 등 단점이 크게 드러나는 방식이므로 왠만하면 다대일 매핑을 사용하자.
일대일 관계
일대일 관계는 그 반대도 일대일 관계이다.
따라서 어느쪽에서나 외래 키를 가질 수 있기 때문에 개발자가 직접 정해줘야한다.
@Entity
public class Member {
@Id
@Column(name = "MEMBER_ID")
private String id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
...
}
@Entity
public class Locker {
@Id @GeneratedValue
@Column(name = "LOCKER_ID")
private Long id;
private String name;
@OneToOne(mappedBy = "locker")
private Member member;
}
다대다 연관관계
관계형 데이터베이스에서는 두 개의 테이블 다대다 관계를 표현할 수 없다.
따라서 일반적으로는 관계 테이블을 연결 테이블로 사용하게 된다.
하지만 JPA에서는 관계 테이블없이 다대다 관계를 표현할 수 있다.
@Entity
public class Member {
@Id @Column(name = "MEMBER_ID")
private String id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
@ManyToMany
@JoinTable(name = "MEMBER_PRODUCT",
joinColumns = @JoinColumn(name = "MEMBER_ID"),
inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
private List<Product> products = new ArrayList<>();
...
}
@Entity
public class Product {
@Id @Column(name = "PRODUCT_ID")
private String id;
private String name;
@ManyToMany(mappedBy = "products")
private List<Member> members;
}
다대다 관계를 매핑하기 위해 @JoinTable 어노테이션을 선언하여 연결 테이블과 매핑하였다.
@JoinTable
- name : 연결 테이블을 지정한다.
- joinColumns : 현재 방향에서 연결 테이블과 조인할 컬럼을 지정한다.
- inverseJoinColumns : 반대 방향에서 연결 테이블과 조인할 컬럼을 지정한다.
또한 다대다 관계도 마찬가지로 연관관계의 주인을 설정하게 된다. (mappedBy가 없는 쪽이 주인)
@DisplayName("다대다 양방향 연관관계에서 역방향으로 객체 그래프 탐색된다.")
@Test
void findInverseManyToMany() {
// given
EntityTransaction tx = em.getTransaction();
Member member1 = new Member("member1", "jaeseok");
Member member2 = new Member("member2", "jeeyu");
Member member3 = new Member("member3", "sejong");
Product product1 = new Product("product1", "apple");
member1.addProduct(product1);
member2.addProduct(product1);
member3.addProduct(product1);
tx.begin();
em.persist(member1);
em.persist(member2);
em.persist(member3);
em.persist(product1);
// when
Product findProduct = em.find(Product.class, "product1");
List<Member> findMembers = findProduct.getMembers();
tx.commit();
// then
Assertions.assertInstanceOf(List.class, findMembers);
Assertions.assertEquals(3, findMembers.size());
Assertions.assertTrue(findMembers.contains(member1)
&& findMembers.contains(member2)
&& findMembers.contains(member3));
}
member에 insert 쿼리 3개, product에 insert 쿼리 1개, member_product에 insert 쿼리 3개가 발생했다.
product를 찾아올 때는 영속성 컨텍스트에 있는 인스턴스를 그대로 반환해서 select 쿼리는 발생하지 않았다.
영속성 컨텍스트에 없는 엔티티를 조회해서 객체 그래프 탐색을 한다면 연결 테이블을 조인해서 회원 정보를 받아 올 것이다.
다대다 매핑의 한계
@ManyToMany는 도메인 모델이 단순해지는 장점이 있기는 하지만 실무에서 사용하기 힘든 한계가 있다.
예를 들어 연결 테이블에 조인컬럼을 제외하고 필요한 데이터가 있는 경우가 있다.
이와 같은 경우에는 연결 엔티티를 따로 만들어서 일대다, 다대일 관계로 풀어내야한다.
@Entity
public class Member {
@Id @Column(name = "MEMBER_ID")
private String id;
@OneToMany (mappedBy = "member")
private List<MemberProduct> memberProducts;
...
}
@Entity
public class Product {
@Id @Column(name = "PRODUCT_ID")
private String id;
private String name;
...
}
@Entity
@IdClass(MemberProductId.class)
public class MemberProduct {
@Id
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
@Id
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product;
private int orderAmount;
...
}
public class MemberProductId implements Serializable {
private String member;
private String product;
@Override
public boolean equals(Object o) {...}
@Override
public int hashCode() {...}
}
연결 엔티티인 MemberProduct 엔티티를 보면 @Id, @JoinColumn 어노테이션을 같이 선언하여 기본키와 외래키를 한번에 매핑하였다.
그리고 @IdClass를 선언하여 복합 기본 키를 매핑하였다.
복합 기본 키는 별도 식별자 클래스가 필요하며 @IdClass나 @EmbeddedId를 사용하여 매핑할 수 있다.
또한 Serializable 구현과 public 기본 생성자가 필요하다.
추천 방법
구조 개선을 위해 크가지 두 가지 방법을 적용할 수 있다.
우선 MemberProduct라는 관계를 표현하는 도메인을 Order로 정의하는 것이다.
그리고 기본 키 생성 전략을 DB에서 생성해주는 대리 키를 이용하는 방법이다.
대리 키를 사용하는 경우 아래와 같은 장점들이 있다.
- 간편하다.
- 영구하게 사용할 수 있다.
- 비즈니스에 의존하지 않는다.
- 복합 키를 만들지 않아도 된다.
@Entity
public class Order{
@Id @GeneratedValue
@Column(name = "ORDER_ID")
private Long id;
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product;
private int orderAmount;
...
}
개선 결과 코드는 위와 같다.
다음 글: https://gojs.tistory.com/47
JPA 연관관계 매핑 (2)
JPA에서의 기본적인 연관관계 매핑하는 방법에 대해서 알아보았다.https://gojs.tistory.com/46 연관관계 매핑관계형 데이터베이스의 테이블간의 관계를 객체간의 연관관계로 표현할 수 있다.크게 단
gojs.tistory.com
'공부 > JPA' 카테고리의 다른 글
JPA 프록시와 로딩 전략 (0) | 2024.07.27 |
---|---|
JPA Entity 간 연관관계 매핑 (2) (0) | 2024.07.27 |
JPA Entity 구성하기 (0) | 2024.07.24 |
JPA 영속성 관리 기법 (0) | 2024.07.23 |
JPA 기본 구성 및 알아보기 (0) | 2024.07.23 |