⚙️ 학습 환경
- Intellij IDE
- h2 DB : 2.2.222
- hibernate : 5.6.15.Final
👉 회원과 팀의 관계가 있을 경우를 예시로 들었다.
- 방향(Direction)
- 단방향 : [회원 → 팀] or [팀 → 회원], 둘 중 한 쪽만 참조하는 것을 단방향 관계라 한다
- 양방향 : [회원 → 팀, 팀 → 회원], 양쪽 모두 서로 참조하는 것을 양방향 관계라 한다
- 다중성(Multiplicity)
- 다대일(N : 1) =
ManyToOne
- 일대다(1 : N) =
OneToMany
- 일대일(1 : 1) =
OneToOne
- 다대다(N : N) =
ManyToMany
- 다대일(N : 1) =
- 연관관계의 주인
- 객체를 양방향 연관관계로 만들 때 연관관계의 주인을 정해야 한다
📌Point
- DB 양방향과 객체 양방향의 차이
DB 테이블은 외래키(Foreign key) 하나로 조인(Join)을 사용해 양방향으로 쿼리가 가능하다. 따라서 DB는 항상 양방향이다. 객체의 경우, 참조용 필드를 가지고 있는 객체만 연관된 객체를 조회할 수 있으므로 항상 단방향이다. 이때 서로 참조할 경우 두 개의 단방향이 생기며 이를 양방향이라고 한다. 2개의 참조를 통해 생기는 2개의 단방향을 양방향이라고 하는 것이므로 DB의 양방향과는 형태가 다르다.
이런 이유 때문에 연관관계의 주인이라는 개념이 생긴다. DB는 외래 키(FK) 하나로 두 테이블이 연관관계를 맺기 때문에 연관관계를 관리하는 포인트는 FK 하나이다. 하지만 객체는 양방향 관계로 매핑할 경우 A → B, B → A, 두 곳에서 서로 참조하기 때문에 연관관계를 관리하는 포인트가 두 개가 된다. 이때 JPA는 두 객체 중 하나를 정해서 외래 키를 관리하게 만들어야 하는데, 외래 키를 관리하는 객체를 연관관계의 주인(Owner)이라고 한다. 보통 외래키를 가지는 테이블과 매핑되는 Entity가 관리하는 것이 효율적이므로, 이 Entity가 연관관계의 주인이 된다.
- ❓왜 일반적으로 Many 쪽에 FK를 두는 것이 권장될까?
관계형 데이터베이스에서 외래 키는 특정 테이블의 열(column)에 다른 테이블의 기본 키(primary key)를 저장하여 두 테이블 간의 관계를 정의하는 데 사용된다. 외래 키를 사용하는 주요 이유는 두 테이블 간의 참조 무결성(referential integrity)을 보장하기 위함이다.
- 참조 무결성: 외래 키는 참조하는 테이블의 기본 키 값을 가지기 때문에, Many 쪽에 위치할 경우 한 엔터티(행)가 One 쪽의 특정 엔터티를 참조한다는 것을 명확하게 나타낸다.
- 데이터 중복 최소화: Many 쪽에 외래 키가 있으면, 각 Many 측의 엔터티(행)는 One 측의 특정 엔터티를 참조하는 정보만을 저장한다. 반면, 외래 키가 One 쪽에 있으면, 모든 참조 정보를 리스트나 배열 형태로 저장해야 하므로 데이터 중복이 발생할 수 있다.
- 스토리지 효율성: One 측에 외래 키가 위치하면, 해당 키를 위한 추가적인 스토리지 공간이 필요하게 된다. 특히, Many 쪽의 엔티티 수가 많을 경우, 이러한 스토리지 요구 사항은 크게 증가한다.
- 쿼리 성능: 외래 키가 Many 쪽에 있으면, 일대다 관계를 쿼리할 때 조인 연산을 더 효율적으로 수행할 수 있다. One 쪽에 외래 키가 있다면, 해당 정보를 조회하기 위해 추가적인 테이블 스캔이 필요하게 되고, 쿼리 성능의 관점에서 일대다 관계에서는 외래 키를 Many 쪽에 두는 것이 일반적으로 권장된다.
✅ 다대일 -@ManyToOne
DB Query
-- member 테이블 생성 CREATE TABLE member ( member_id VARCHAR(255) NOT NULL, team_id VARCHAR(255), username VARCHAR(255), PRIMARY KEY (member_id) ); -- team 테이블 생성 CREATE TABLE team ( team_id VARCHAR(255) NOT NULL, name VARCHAR(255), PRIMARY KEY (team_id) ); -- member 테이블에 외래 키 추가 (ALTER TABLE 사용) ALTER TABLE member ADD CONSTRAINT fk_member_team FOREIGN KEY (team_id) REFERENCES team(team_id); 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'; SELECT M.* FROM TEAM T JOIN MEMBER M ON M.TEAM_ID = T.TEAM_ID;
🔹단방향 관계(N:1)
- Member Class
@Entity public class Member { @Id @GeneratedValue @Column(name = "MEMBER_ID") private Long id; private String name; @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; //... getter, setter...// }
- Team Class
@Entity public class Team { @Id @GeneratedValue @Column(name = "TEAM_ID") private Long id; private String name; //... getter, setter...// }
- Main
import jpa.model.entity.Member; import jpa.model.entity.Team; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.persistence.EntityTransaction; import javax.persistence.Persistence; import java.util.List; public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 testSave(em); tx.commit();//트랜잭션 커밋 //조회 mtoFindTest(em); } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void testSave(EntityManager em) { Member member1 = new Member("member1"); Member member2 = new Member("member2"); em.persist(member1); em.persist(member2); Team team = new Team("team1"); em.persist(team); member1.setTeam(team); // 연관관계 설정 member2.setTeam(team); // 연관관계 설정 } public static void mtoFindTest(EntityManager em) { System.out.println("====================================================="); TypedQuery<Member> query = em.createQuery( "SELECT m FROM Member m WHERE m.name = :name", Member.class ); query.setParameter("name", "member1"); Member findMember = query.getSingleResult(); Team findTeam = findMember.getTeam(); System.out.println("Member Name: " + findMember.getName()); System.out.println("Team Id: " + findTeam.getId()); System.out.println("Team Name: " + findTeam.getName()); } }
Member 엔티티는
getTeam()
메소드를 통해 Team team; 필드를 참조해 Team 엔티티를 참조할 수 있지만, Team 엔티티는 참조할 필드가 존재하지 않는다.mtoFindTest()
메소드는 Member 엔티티를 통해 Team 엔티티를 조회하는 로직을 수행한다. 동작시키면 Member Entity를 통해서 Team Entity를 조회할 수 있는 것을 알 수 있다.(Member → Team)따라서 회원과 팀은 다대일(N : 1) 단방향 연관관계이다.
@ManyToOne @JoinColumn(name = "TEAM_ID") private Team team;
다대일 연관관계이므로 @ManyToOne을 사용해 연관관계를 설정한다.
@JoinColumn을 사용하여 Member.team 필드를 TEAM_ID 외래 키와 매핑한다
🔹양방향 관계(N:1, 1:N)
객체의 참조가 양방향이 되었다고 해서, 테이블에 영향을 주는 것은 아니다. 테이블은 항상 양방향이기 때문이다. 객체의 참조 방향만 하나 더 생겼으며, 참조하기 위한 필드가 생긴 것을 볼 수 있다. Team class의 코드도 아래와 같이 수정된다. Member Class는 변경 사항이 없다.
- Team Class
@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<>(); //... getter, setter...// }
@OneToMany(mappedBy = "team")
앞에서 살펴보았듯이 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 따라서 항상 양방향이다. 엔티티는 항상 단방향으로 매핑하는데 첫 번째는 Member → Team으로 설정했고, 두 번째는 Team → Member로 2개의 단방향 관계(참조)를 설정함으로 양방향 관계를 설정했다. 외래 키는 하나인데 외래 키를 관리하는 객체는 두 곳이 되었다.
- 객체 연관관계 단방향 2개
- 회원(Member) → 팀(Team)
[ Member.team ]
- 팀(Team) → 회원(Member)
[ Team.members ]
- 회원(Member) → 팀(Team)
- 테이블 연관관계 양방향 1개
- 회원(MEMBER) ↔ 팀(TEAM)
- 객체 연관관계 단방향 2개
따라서 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 @OneToMany()
의 mappedBy 속성을 통해서 지정했다. 값으로 준 “team”은 Member 클래스의 필드인 Team team
의 변수명 ‘team’이다. 해당 필드를 통해서 외래 키를 관리한다는 의미이다. 여기까지 읽었다면 현재 연관관계의 주인은 누구인가? Member인가? 아니면 Team인가?
“mappedBy 속성을 가졌다면 연관관계의 주인이 아니다.” 라고 외우자. 주인은 mappedBy 속성을 사용하지 않는다. 그렇다면 현재 외래 키는 Member 클래스의 Team team;
필드를 사용해 관리하고 있는 것이다. 연관관계의 주인만 DB의 연관관계와 매핑되고 외래 키를 관리할 수 있다.
- Member : 연관관계의 주인⭕, 외래 키를 관리(등록, 수정, 삭제) 할 수 있다
- Team : 연관관계의 주인❌, 외래 키를 읽을 수만 있다
❓왜 mappedBy를 사용해서 연관관계의 주인을 표시해야 할까?
테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 아래 쿼리를 보자. DB는 항상 양방향 매핑이며, 외래 키 하나로 두 테이블을 Join해서 참조할 수 있다.
👉 DB는 항상 양방향 매핑이다. 따라서 외래 키를 통해서 참조할 수 있다.
-- 양방향 가능
SELECT T.*
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID = 'member1';
SELECT M.*
FROM TEAM T
JOIN MEMBER M ON M.TEAM_ID = T.TEAM_ID;
그렇다면 객체는 어떠한가? 객체는 참조하기 위한 필드가 필요하다. 객체의 양방향 관계라는 것은 논리적인 의미이고, 실제적으로는 서로 다른 단방향이 2개이다. 양방향 관계일 때 Member.team과 Team.members을 통해서 서로 참조하는 값을 각각 업데이트한다면 두 값이 모두 DB 테이블에 반영되어야 한다. 그렇다면 이때 어떤 값을 믿어야할까? 어떤 값을 마지막으로 저장하고 유지해야할까?
이러한 관계는 결국 두 데이터 모두 신뢰할 수 없게 된다. 그래서 만들어진 것이 연관관계의 주인(Owner)이라는 개념이다. 연관관계의 주인만 외래 키를 관리(등록, 수정, 삭제)할 수 있으며, 주인이 아닌 쪽은 읽기만 가능하도록 한 것이다. 두 객체 중의 하나만 연관관계 매핑을 하여 테이블과 매핑하고 FK를 관리하는 것이다. 따라서 연관관계의 주인이 아닌(현재 주인은 Member이다) Team에서 값을 업데이트하더라도 값이 반영되지 않는다. 주인이 아닌 쪽은 오직 Read만 가능하다.
- Main
public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 testSave(em); tx.commit();//트랜잭션 커밋 //조회 mtoFindTest(em); } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void testSave(EntityManager em) { Member member1 = new Member("member1"); Member member2 = new Member("member2"); em.persist(member1); em.persist(member2); Team team = new Team("team1"); em.persist(team); member1.setTeam(team); // 연관관계 설정 member2.setTeam(team); // 연관관계 설정 } public static void mtoFindTest(EntityManager em) { System.out.println("====================================================="); TypedQuery<Team> query = em.createQuery( "SELECT t FROM Team t WHERE t.name = :name", Team.class ); query.setParameter("name", "team1"); Team findTeam = query.getSingleResult(); for (Member member : findTeam.getMembers()) { System.out.println("Member ID: " + member.getId()); System.out.println("Member Name: " + member.getName()); System.out.println("Team Id: " + findTeam.getId()); System.out.println("Team Name: " + findTeam.getName()); } } }
양방향 연관관계가 설정되어있을 경우, Team을 조회하면 Member까지 모두 조회할 수 있다. 이는 Team Entity가 Member Entity의 Team team; 필드로 외래 키를 읽어 그 관계를 불러올 수 있기 때문이다. Team → Member 방향으로 참조가 되는 것이다. 양방향 관계를 설정함으로써
Team.getMembers()
를 호출해서 Member을 참조할 수 있다.
❗양방향 연관관계 주의점
🔻연관관계의 주인이 아닌 곳에서 값을 입력하는 경우
// 양방향 연관관계는 연관관계의 주인만 외래키를 관리한다, 즉 Member가 외래키를 관리한다
public static void testSaveNotOwner(EntityManager em) {
Team team2 = new Team("team2", "팀2");
em.persist(team2);
Member member1 = em.find(Member.class, "member1");
//member1.setTeam(team2);
em.persist(member1);
Member member2 = em.find(Member.class, "member2");
//member2.setTeam(team2);
em.persist(member2);
// owner가 아니기 때문에 연관관계 설정이 되지 않는다
team2.getMembers().add(member1); //무시
team2.getMembers().add(member2); //무시
}
현재 연관관계의 주인은 Member이다. 따라서 Member을 통해서 연관관계를 설정하지 않으면 DB에 연관관계 설정이 되지 않는다. 위 코드에서는 Team2로 연관관계를 변경하려고 했지만 변경되지 않았다. 그 이유는 Team Entity는 owner가 아니기 때문에 연관관계를 관리할 수 없다. member을 통해서 연관관계를 매핑하는 코드를 주석 해제하면 올바르게 매핑되는 것까지 확인할 수 있다.
🔻순수한 객체까지 연관관계를 고려하지 않은 경우
//== 순수한 객체 연관관계 ==//
public static void testPOJO(EntityManager em) {
Team team1 = new Team("team1", "팀1");
Member member1 = new Member("member1", "회원1");
Member member2 = new Member("member2", "회원2");
member1.setTeam(team1);
member2.setTeam(team1);
List<Member> members = team1.getMembers();
System.out.println("members.size: " + members.size());
}
JPA를 사용하지 않는 순수한 객체이다. 코드를 보면 Member.team에만 관계를 설정하고 반대 반향으로 설정하지 않았기 때문에 출력 결과가 0이 된다. JPA를 통해서 DB에 값이 올바르게 저장될지는 몰라도 JPA를 사용하지 않는 다른 로직에서는 값이 없는 오류가 발생할 수 있다. 그래서 JPA를 사용하든, 하지 않든 어느 쪽에서도 동일한 값을 가져올 수 있도록 객체까지 고려해서 양쪽 다 관계를 맺어야 한다.
//== JPA 객체 양방향 ==//
public static void testSaveORM(EntityManager em) {
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); //연관관계 설정 member1 -> team1
team1.getMembers().add(member1); //연관관계 설정 team1 -> member1
em.persist(member1);
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); //연관관계 설정 member2 -> team1
team1.getMembers().add(member2); //연관관계 설정 team1 -> member2
em.persist(member2);
Team findTeam = em.find(Team.class, "team1");
if (team1.getMembers().size() == findTeam.getMembers().size()){
System.out.println("연관관계가 동일합니다.");
}
}
JPA는 물론 순수한 객체까지 연관관계를 설정한 코드이다.
🔻기존의 관계가 제거되지 않는 경우
member1.setTeam(teamA);
member1.setTema(teamB);
Member findMember = teamA.getMember(); // member1이 여전히 조회된다
teamA에서 teamB로 관계를 변경할 때 기존의 teamA와의 관계를 제거하지 않았다. 이 경우 영속성 컨텍스트가 유지되는 동안은 여전히 teamA와의 관계도 1차 캐시에 갖고 있게 된다. 그래서 커밋 후 영속성 컨텍스트가 초기화되기 전에 다른 로직에서 연관관계를 호출할 때 teamA와의 관계를 호출하는 것도 가능하다. 연관관계 편의 메소드를 작성할 때는 아래와 같이 기존의 관계를 끊어주는 로직이 필요하다.
public void setTeam(Team team) {
//기존의 관계가 있다면 제거
if (this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);
}
✅ 일대다 -@OneToMany
🔹단방향(1 : N)
일대다 관계는 다대일 관계의 반대 방향이다. 일대다 관계는 엔티티를 하나 이상 참조할 수 있으므로 자바 컬렉션을 사용해야 한다. 하나의 팀은 여러 회원을 참조할 수 있는데 이런 관계를 일대다 관계라 한다. 팀은 회원들을 참조하지만 반대로 회원은 팀을 참조하지 않을 때 단방향 관계이다.
Team.members로 회원 테이블(MEMBER)의 TEAM_ID 외래 키를 관리한다. 보통 자신이 매핑한 테이블의 외래 키를 관리하는데, 반대쪽 테이블에 있는 외래 키를 관리하고 있다. 일대다 관계에서 테이블의 외래 키는 항상 다(N)쪽에 있지만, Member 엔티티에는 외래 키를 매핑할 수 있는 참조 필드가 없다.
- Member Class
@Entity public class Member { @Id @GeneratedValue @Column(name = "MEMBER_ID") private Long id; private String name;
- Team Class
@Entity public class Team { @Id @GeneratedValue @Column(name = "TEAM_ID") private Long id; private String name; @OneToMany @JoinColumn(name = "TEAM_ID") // MEMBER 테이블의 TEAM_ID (FK) private List<Member> members = new ArrayList<>();
일대다 단방향 관계를 매핑할 때는 @JoinColumn을 명시해야 한다. 그렇지 않으면 JPA는 연결 테이블을 중간에 두고 연관관계를 관리하는 조인 테이블 전략을 기본으로 사용해서 매핑한다.
@JoinColumn을 사용하지 않을 경우, 위와 같이 JoinTable이 생성된다.
- Main
public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 testSave(em); tx.commit();//트랜잭션 커밋 } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void testSave(EntityManager em) { Member member1 = new Member("member1"); Member member2 = new Member("member2"); Team team = new Team("team1"); team.getMembers().add(member1); // OneToMany 연관관계 설정 team.getMembers().add(member2); // OneToMany 연관관계 설정 em.persist(member1); //INSERT-member1 em.persist(member2); //INSERT-member2 em.persist(team); //INSERT-team, UPDATE-member1.fk, UPDATE-member2.fk } }
Entity 클래스가 매핑된 테이블에 외래 키가 있으면 INSERT SQL 한 번으로 연관관계 설정까지 끝낼 수 있지만, 일대다 관계에서 다른 테이블에 외래 키가 있기 때문에 UPDATE SQL을 추가로 실행하는 것을 볼 수 있다.
Member 엔티티는 Team 엔티티의 존재를 모르고(참조하는 필드가 없다), 연관관계 정보도 Team.members에서 관리한다. Member 엔티티를 저장할 때 MEMBER 테이블의 TEAD_ID 컬럼에는 아무것도 저장되지 않고, Team 엔티티를 저장할 때 Team.members 참조 값을 확인해서 MEMBER 테이블의 TEAM_ID 값을 저장한다. 그렇기 때문에 위와 같이 UPDATE SQL이 추가로 발생하게 된다.
🔹양방향(1:N, N:1)
일대다 양방향 매핑은 존재하지 않는다(일대다 양방향과 다대일 양방향은 사실 똑같은 말이다). @OneToMany는 연관관계의 주인이 될 수 없는데 관계형 데이터베이스 특성상 N 쪽에 외래 키가 있기 때문이다. 따라서 연관관계의 주인은 항상 @ManyToOne을 사용하는 쪽이다.
매핑이 완전히 불가능한 것은 아닌데, N쪽에 읽기 전용으로 단방향 매핑을 하나 추가해주면 된다. 이렇게 하면 단방향이 하나 더 생기지만, 외래 키를 관리할 수는 없기 때문에 양방향 매핑으로 된다.
- Member Class
@Entity public class Member { @Id @GeneratedValue @Column(name = "MEMBER_ID") private Long id; private String name; @ManyToOne @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false) //읽기 전용 private Team team;
일대다 단점을 고스란히 가진다. 되도록이면 다대일 양방향 매핑을 사용하자!
✅ 일대일(1:1) -@OneToOne
일대일 관계는 양쪽이 서로 하나의 관계만 가진다. 예를 들어 회원은 하나의 사물함만 사용하고 사물함도 하나의 회원에 의해서만 사용된다. 일대일 관계는 두 테이블 중 어느 곳에서든 외래 키를 가질 수 있다. 따라서 주 테이블과 대상 테이블 중 누가 외래 키를 가질 지 선택해야 한다. 주 테이블은 비즈니스 로직에 의해 결정된다. 여기서는 회원이 사물함을 사용하기 때문에 회원이 주 테이블, 사물함이 대상 테이블이다.
🔹주 테이블에 외래 키
주 객체가 대상 객체를 참조하는 것처럼, 주 테이블에 외래 키를 두고 대상 테이블을 참조하는 방법이다. 주 테이블만 확인해도 대상 테이블과 연관관계가 있는지 알 수 있다는 장점이 있다. 객체 참조와 외래 키를 비슷하게 사용할 수 있으며, 이때는 외래 키에 유니크 제약조건을 추가해줘야 한다.
🔻단방향
- Member Class
@Entity public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String username; @OneToOne @JoinColumn(name = "LOCKER_ID", unique = true) private Locker locker; }
- Locker Class
@Entity public class Locker { @Id @Column(name = "LOCKER_ID") private String id; private String name; }
일대일 관계이므로 객체 매핑에 @OneToOne을 사용했고, 유니크 제약 조건을 추가했다. 이 관계는 다대일 단방향(@ManyToOne)과 거의 비슷하다.
🔻양방향
- Locker Class
@Entity public class Locker { @Id @Column(name = "LOCKER_ID") private String id; private String name; @OneToOne(mappedBy = "locker") private Member memeber; }
양방향 관계에서는 항상 Owner을 설정해야 한다. MEMBER 테이블이 외래 키를 가지고 있으므로 Member 엔티티에 있는 Member.locker가 연관관계의 주인이다.
🔹대상 테이블에 외래 키
🔻단방향
지원하지 않는다. 매핑할 수 있는 방법이 없다.
🔻양방향
- Locker Class
@Entity public class Locker { @Id @Column(name = "LOCKER_ID") private String id; private String name; @OneToOne @JoinColumn(name = "MEMBER_ID") private Member memeber; }
- Member Class
@Entity public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String username; @OneToOne(mappedBy = "member") private Locker locker; }
위와 같이 대상 테이블에 외래 키를 두고 싶다면, 양방향 관계로 설정하면 된다.
❗일대일 관계 주의사항
프록시를 사용할 때 외래 키를 직접 관리하지 않는 일대일 관계는 지연 로딩으로 설정하더라도 즉시 로딩된다. 위 예제에서 Locker.member은 지연 로딩할 수 있지만, Member.locker은 지연 로딩으로 설정하더라도 즉시 로딩되는데 프록시의 한계 때문에 발생하는 문제이다.
✅ 다대다(N:N)
정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 조인 테이블을 사용한다. 하지만 객체는 다대다 관계를 표현할 수 있는데 컬렉션을 사용해 서로 참조하면 된다. @ManyToMany 어노테이션을 사용하면 다대다 관계를 편리하게 매핑할 수 있다.
🔹단방향
- Member Class
@Entity public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String name; @ManyToMany @JoinTable(name = "MEMBER_PRODUCT", joinColumns = @JoinColumn(name = "MEMBER_ID"), inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID")) private List<Product> products = new ArrayList<>(); public Member(){} public Member(String id, String name) { this.id = id; this.name = name; }
Member 엔티티와 Product 엔티티를 @ManyToMany로 매핑했다. JPA는 해당 어노테이션을 통해서 다대다 관계를 일대다, 다대일 관계로 자동으로 풀어준다.
- @JoinTable : 연결 테이블을 지정한다, MEMBER_PRODUCT 테이블로 지정했다
- joinColumns : 정방향과 매핑할 조인 컬럼 정보를 지정한다(회원)
- inverseJoinColumns : 역방향과 매핑할 조인 컬럼 정보를 지정한다(상품)
- @JoinTable : 연결 테이블을 지정한다, MEMBER_PRODUCT 테이블로 지정했다
- Product Class
@Entity public class Product { @Id @Column(name = "PRODUCT_ID") private String id; private String name; public Product(){} public Product(String id, String name) { this.id = id; this.name = name; }
- Main Class
public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 testSave(em); tx.commit(); //트랜잭션 커밋 em.clear(); //조회 find(em); } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void testSave(EntityManager em) { Product productA = new Product("productA", "상품A"); em.persist(productA); Member member1 = new Member("member1", "회원1"); member1.getProducts().add(productA); em.persist(member1); } //== 조회 ==// public static void find(EntityManager em) { Member member = em.find(Member.class, "member1"); List<Product> products = member.getProducts(); for(Product product : products) { System.out.println("Product Id: " + product.getId()); System.out.println("Product Name: " + product.getName()); } } }
쿼리를 살펴보면 Member 객체를 불러올 때 연결 테이블인 MEMBER_PRODUCT 테이블을 조인하여서 연관된 상품에 대한 정보를 가져온다.
🔹양방향
- Member Class, 연관관계 편의 메소드
//== 연관관계 편의 메소드 ==// public void addProduct(Product product) { products.add(product); product.getMembers().add(this); }
- Product Class
@Entity public class Product { @Id @Column(name = "PRODUCT_ID") private String id; private String name; @ManyToMany(mappedBy = "products") private List<Member> members = new ArrayList<>();
- Main
public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 testSave(em); tx.commit();//트랜잭션 커밋 em.clear(); //조회 findTInverse(em); } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void testSave(EntityManager em) { Product productA = new Product("productA", "상품A"); em.persist(productA); Member member1 = new Member("member1", "회원1"); member1.addProduct(productA); em.persist(member1); } //== 조회 ==// public static void find(EntityManager em) { Member member = em.find(Member.class, "member1"); List<Product> products = member.getProducts(); for(Product product : products) { System.out.println("Product Id: " + product.getId()); System.out.println("Product Name: " + product.getName()); } } //== 역방향 탐색 ==// public static void findTInverse(EntityManager em) { Product product = em.find(Product.class, "productA"); List<Member> members = product.getMembers(); for (Member member : members) { System.out.println("Member Id: " + member.getId()); System.out.println("Member Name: " + member.getName()); } } }
🔹다대다 한계 극복
@ManyToMany를 사용하면 연결 테이블을 자동으로 처리해주지만 컬럼을 추가할 수 없는 제약이 생긴다. 예를 들어 회원이 상품을 주문하면 연결 테이블에 단순히 주문한 회원 아이디와 상품 아이디만 담는 것이 아니라, 수량, 날짜 등을 함께 넣기 때문에 다대다 매핑의 한계를 극복할 방법이 필요하다.
🔻IdClass 복합 식별자 사용
앞에서 테이블은 다대다 관계를 지원하지 않는다고 했다. 그래서 @ManyToMany 어노테이션을 사용해서 연관관계가 있는 조인 테이블을 자동으로 매핑되게 하여 관리할 수 있었다. 지금은 조인 테이블에 새로운 컬럼들을 추가하기 위해서 다대다 관계를 일대다, 다대일 관계로 풀어내고, 조인 테이블을 복합 식별자로 관리한다.
- Member Class
@Entity public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String name; @OneToMany(mappedBy = "member") private List<MemberProduct> memberProducts; //... getter, setter ...//
- Product Class
@Entity public class Product { @Id @Column(name = "PRODUCT_ID") private String id; private String name; // @OneToMany(mappedBy = "product") // private List<MemberProduct> memberProducts; //... getter, setter ...//
주석 부분을 해제하면 Product도 MemberProduct와 일대다 관계로 객체 그래프 탐색이 가능하다. 여기서는 사용하지 않아서 주석처리했다.
- MemberProduct
@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; //... getter, setter ...//
@Id, @JoinColumn 어노테이션을 통시에 사용해서 기본키 + 외래키 ⇒ 복합키로 설정되도록 만들었다. 이 복합키를 기본키로 매핑하기 위해선 @IdClass 어노테이션과 함께 복합키를 정의하는 클래스가 필요하다.
➡️ 복합 기본 키
MemberProduct는 MEMBER_ID와 PRODUCT_ID로 이루어진 복합 기본 키를 가지게 된다. JPA에서는 복합 기본 키를 사용하기 위해 별도의 식별자 클래스를 정의해야하고 다음과 같은 규칙이 있다.
- 복합 키는 별도의 식별자 클래스로 만들어야 한다
- Serializable을 구현해야 한다
- equals와 hashCode를 구현해야 한다
- 기본 생성자가 있어야 한다
- 식별자 클래스는 public 이어야 한다
👉 위 규칙에 맞게 정의한 식별자 클래스는 아래와 같다.
- MemberProductId
public class MemberProductId implements Serializable { private String member; private String product; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MemberProductId that = (MemberProductId) o; return Objects.equals(member, that.member) && Objects.equals(product, that.product); } @Override public int hashCode() { return Objects.hash(member, product); } //... getter, setter ...// }
MemberProduct는 회원과 상품의 기본 키를 받아서 복합 키로 만들고 자신의 기본 키로 사용하고, 동시에 회원과 상품의 관계를 위한 외래 키로도 사용한다.
- Main
public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 testSave(em); tx.commit(); //트랜잭션 커밋 em.clear(); //조회 find(em); } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void testSave(EntityManager em) { Member member1 = new Member("member1", "회원1"); em.persist(member1); Product productA = new Product("productA", "상품A"); em.persist(productA); MemberProduct memberProduct = new MemberProduct(); memberProduct.setMember(member1); memberProduct.setProduct(productA); memberProduct.setOrderAmount(2); em.persist(memberProduct); } //== 조회 ==// public static void find(EntityManager em) { MemberProductId memberProductId = new MemberProductId(); memberProductId.setMember("member1"); memberProductId.setProduct("productA"); MemberProduct memberProduct = em.find(MemberProduct.class, memberProductId); Member member = memberProduct.getMember(); Product product = memberProduct.getProduct(); System.out.println("Member Id: " + member.getId()); System.out.println("Member Name: " + member.getName()); System.out.println("Product Id: " + product.getId()); System.out.println("Product Name: " + product.getName()); } }
🔻새로운 기본 키 사용
복합키를 사용하지 않고 데이터베이스에서 생성해주는 식별자 값을 사용하는 방법이다. Order이라는 새로운 테이블을 통해서 주문 회원과 주문 상품의 연관관계를 설정하고 관리할 수 있다. 연관관계의 주인은 Order 테이블이다. 이 테이블을 통해 연관관계와 외래 키를 관리한다. 양방향 관계로 설정했으며 Member과 Product는 Order 테이블을 통해서 각각 참조할 수 있다.
- Member Class
@Entity public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String name; @OneToMany(mappedBy = "member") private List<Order> member = new ArrayList<>(); public Member(){} public Member(String id, String name) { this.id = id; this.name = name; }
- Product Class
@Entity public class Product { @Id @Column(name = "PRODUCT_ID") private String id; private String name; @OneToMany(mappedBy = "product") private List<Order> products = new ArrayList<>();
- Order Class
@Entity @Table(name = "product_order") // h2 DB에서 order은 예약어이다 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;
- Main
public class Main { public static void main(String[] args) { //엔티티 매니저 팩토리 생성 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득 try { tx.begin(); //트랜잭션 시작 //저장 save(em); tx.commit(); //트랜잭션 커밋 em.clear(); //조회 findTwoWay(em); } catch (Exception e) { e.printStackTrace(); tx.rollback(); //트랜잭션 롤백 } finally { em.close(); //엔티티 매니저 종료 } emf.close(); //엔티티 매니저 팩토리 종료 } public static void save(EntityManager em) { Member member1 = new Member("member1", "회원1"); em.persist(member1); Product productA = new Product("productA", "상품A"); em.persist(productA); Order order = new Order(); order.setMember(member1); //연관관계 설정 order.setProduct(productA); //연관관계 설정 order.setOrderAmount(2); em.persist(order); } //== 조회 ==// public static void find(EntityManager em) { System.out.println("======================================================="); Long orderId = 1L; Order order = em.find(Order.class, orderId); Member member = order.getMember(); Product product = order.getProduct(); System.out.println("Member Id: " + member.getId()); System.out.println("Member Name: " + member.getName()); System.out.println("Product Id: " + product.getId()); System.out.println("Product Name: " + product.getName()); } //== 양방향 조회 ==// public static void findTwoWay(EntityManager em) { System.out.println("======================================================"); // Member -> Product System.out.println("### Member ###"); Member findMember = em.find(Member.class, "member1"); for (Order findMemberOrder : findMember.getListOrder()) { System.out.println("Member Id: " + findMemberOrder.getMember().getId()); System.out.println("Member Name: " + findMemberOrder.getMember().getName()); } // Product -> Member System.out.println("### Product ###"); Product findProduct = em.find(Product.class, "productA"); for (Order findProductOrder : findProduct.getListOrder()) { System.out.println("Product Id: " + findProductOrder.getProduct().getId()); System.out.println("Product Name: " + findProductOrder.getProduct().getName()); } } }
🔎 번외 : github에서는 왜 FK를 사용하지 않을까?
Uploaded by N2T