소스
https://github.com/rkwhr0010/jpa
ORM 에 핵심은 객체의 참조와 테이블의 외래키를 매핑하는 것이다.
객체는 참조로 관계를 맺고, 테이블은 외래키로 관계를 맺으니
객체의 참조와 테이블의 외래키를 매핑만 잘하면, 개발자는 객체만 다루고, JPA가 나머지 부분을 처리해 준다.
핵심 키워드
- 방향
단방향, 양방향
- 다중성
일대다, 다대일, 다대다, 일대일 - 연관관계의 주인
객체의 경우 연관관계로 만들면 주인이 필요하다
단방향 연관관계
관계 정의
- 회원과 팀
- 회원은 하나의 팀만 소속
- 회원과 팀은 다대일
객체 연관관계
테이블 연관관계
객체는 참조로 연관관계를 맺는다.
Member.team
멤버는 팀 참조로 팀을 알 수 있지만, 반대인 팀은 멤버를 알지 못한다.
테이블은 외래 키로 연관관계를 맺는다.
멤버와 팀 테이블은 양방향 관계다.
팀 외래키 TEAM_ID 를 통해 멤버와 팀을 조인, 반대로 팀과 멤버를 조인 할 수 있다.
객체에서 양방향을 만드려고 Team에 Member의 참조를 둘 수 있다.
하지만 이는 양방향 관계가 아닌 단방향 관계를 두 번 사용한 것이다.
테이블은 단 하나의 외래 키로 양방향 조인을 할 수 있다.
순수한 객체 연관관계
public class Member { private String id; private String username; private Team team; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public Team getTeam() { return team; } public void setTeam(Team team) { this.team = team; } } |
public class Team { private String id; private String name; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } } |
Member member = new Member(); member.setId("m1"); member.setUsername("회원1"); Team team = new Team(); team.setId("t1"); team.setName("팀1"); member.setTeam(team); |
객체는 참조를 통해 연관관계를 맺는다.
테이블 연관관계
CREATE TABLE `MEMBER` ( `MEMBER_ID` VARCHAR(255) NOT NULL, `TEAM_ID` VARCHAR(255), `USERNAME` VARCHAR(255), PRIMARY KEY (`MEMBER_ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ; CREATE TABLE `TEAM` ( `TEAM_ID` VARCHAR(255) NOT NULL, `NAME` VARCHAR(255), PRIMARY KEY (`TEAM_ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ; ALTER TABLE `MEMBER` ADD CONSTRAINT `FK_MEMBER_TEAM` FOREIGN KEY (`TEAM_ID`) REFERENCES `TEAM` (`TEAM_ID`); |
SELECT A.*, B.* FROM MEMBER A JOIN TEAM B ON A.TEAM_ID = B.TEAM_ID ; |
데이터 베이스는 외래키를 통해 연관관계를 맺는다.
객체 관계 매핑
참고, 롬복 사용
@Entity @Data public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String username; //연관관계 매핑 @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; } |
@Entity @Data public class Team { @Id @Column(name = "TEAM_ID") private String id; private String name; } |
객체 연관관계, Member.team
테이블 연관관계 MEMBER.TEAM_ID
@ManyToOne
어노테이션은 이 엔티티 객체에 테이블의 관계를 표현해준다.
말 그대로 N:1 관계를 표현해준다.
@JoinColumn(name = "TEAM_ID")
"외래 키"를 매핑할 때 사용한다. 즉, DB 컬럼 이름을 인자로 받는다.
이 어노테이션은 생략이 가능하다.
@JoinColumn
속성 | 기능 | 기본값 |
name | 매핑할 외래 키 이름 | 클래스 필드명 + _ + 테이블 기본 키 컬럼명 |
referencedColumnName | 외래 키가 참조하는 대상 테이블의 컬럼명 | 참조하는 테이블의 기본 키 컬럼명 |
foreignKey | 테이블 생성이 적용될 때 외래 키 제약 조건 생성을 지정하거나 제어하는 데 사용 | |
나머지 속성은 @Column 과 같다. |
@Entity @Data public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String username; //연관관계 매핑 @ManyToOne // @JoinColumn(name = "TEAM_ID") private Team team; } |
Member.team 필드명 + _ + Team테이블 기본키명
@ManyToOne
속성 | 기능 | 기본값 |
optional | false 시 연관된 엔티티가 항상 있어야 한다. | true |
fetch | 글로벌 패치 전략 설정, 지연 로드해야 하는지 아니면 즉시 가져와야 하는지 여부 | FetchType.EAGER 어노테이션 마다 다름 |
CascadeType | 영속성 전이를 사용 타입 결정 | |
targetEntity | 연관된 엔티티 클래스 타입 정보 |
targetEntity은 컬렉션을 Raw 타입으로 다룰 때 필요하다. 하지만 지네릭을 사용하므로 사용할 일이 없다.
연관관계 사용
@Entity @Data @AllArgsConstructor @NoArgsConstructor public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String username; //연관관계 매핑 @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; public Member(String id, String username) { this.id = id; this.username = username; } } |
@Entity @Data @AllArgsConstructor @NoArgsConstructor public class Team { @Id @Column(name = "TEAM_ID") private String id; private String name; } |
public class Main { static EntityManagerFactory emf = Persistence.createEntityManagerFactory("studyjpa"); public static void main(String[] args) { } static void logic(Consumer<EntityManager> logic) { EntityManager em = emf.createEntityManager(); EntityTransaction tx = em.getTransaction(); try { tx.begin(); logic.accept(em); tx.commit(); } catch (Exception e) { e.printStackTrace(); tx.rollback(); } finally { em.close(); } } } |
중복 제거를 위해 위와 같이 따로 메서드를 생성
private static void save() { logic(em -> { Team t1 = new Team("team1", "팀1"); em.persist(t1); Member m1 = new Member("member1", "멤버1"); m1.setTeam(t1); em.persist(m1); Member m2 = new Member("member2", "멤버2"); m2.setTeam(t1); em.persist(m2); }); } |
멤버 엔티티에 팀 참조를 넣으면, 팀 참조의 Id 값을 읽어서 바인딩 해준다.
조회
private static void find() { logic(em -> { Member m1 = em.find(Member.class, "member1"); Team t1 = m1.getTeam(); // 객체 그래프 탐색 System.out.println(m1); System.out.println(t1); }); } |
엔티티로 연관된 엔티티를 조회하는 것은 객체 그래프 탐색이라 한다.
멤버 조회 후 연관된 엔티티 팀을 별도 DB 조회 없이 자바 객체 처럼 꺼내서 사용하는 것을 볼 수 있다.
조회 JPQL
private static void find2() { logic(em -> { String query = "select m from Member m join m.team t " + "where t.name = :teamName"; TypedQuery<Member> q = em.createQuery(query, Member.class) .setParameter("teamName", "팀1"); List<Member> resultList = q.getResultList(); System.out.println(resultList); }); } |
조회 후 한 번 더 조회하는 것을 볼 수 있다.
fetch 를 붙이면 추가 질의 없이 한 번에 끝난다.
연관관계 제거
private static void remove() { logic(em -> { Member m1 = em.find(Member.class, "member1"); m1.setTeam(null); // 연관관계 제거 // em.persist 가 없어도, 더티체킹으로 감지되어 제거된다.(Update) }); } |
연관된 엔티티 삭제
private static void remove2() { logic(em -> { Member m1 = em.find(Member.class, "member1"); Member m2 = em.find(Member.class, "member2"); m1.setTeam(null); m2.setTeam(null); // 팀을 제거하려면 기존 연관관계를 모두 끊어야 가능하다. em.remove(em.find(Team.class, "team1")); }); } |
양방향 연관관계
테이블은 수정할 것이 없다.
외래 키 하나로 원래부터 양방향으로 조회할 수 있다.
객체는 양방향을 흉내내기 위해 단방향을 하나 더 추가한 것이다.
멤버 -> 팀
팀 -> 멤버
@Entity @Data @AllArgsConstructor @NoArgsConstructor public class Team { @Id @Column(name = "TEAM_ID") private String id; private String name; @OneToMany(mappedBy = "team") private List<Member> members = new ArrayList<>(); } |
일대다 표현을 위해 컬렉션을 사용
mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 준다.
private static void find() { logic(em -> { Team t1 = em.find(Team.class, "team1"); for (Member m : t1.getMembers()) { System.out.println(m.getUsername()); } }); } private static void save() { logic(em -> { Team t1 = new Team("team1", "팀1", null); em.persist(t1); Member m1 = new Member("member1", "멤버1"); m1.setTeam(t1); em.persist(m1); Member m2 = new Member("member2", "멤버2"); m2.setTeam(t1); em.persist(m2); }); } |
toString()을 호출하면 안된다. 순환 참조 문제로 StackOverflowError 가 발생한다.
연관관계의 주인
mapperBy 속성의 필요성
테이블은 외래 키 하나로 두 테이블 연관관계 관리
객체는 단 방향 두 개로 양방향을 흉내낸다.
즉, 객체에는 참조가 둘인데 외래 키는 하나다. 이런 차이가 발생한다.
두 객체 중 한 쪽이 테이블의 외래 키는 관리하고, 이를 연관관계의 주인이라 한다.
양방향 매핑의 규칙: 연관관계의 주인
연관관계의 주인만이 DB 연관관계와 매핑되고 외래 키를 CUD할 수 있다.
주인이 아닌 쪽은 읽기만 가능하다.
연관관계 주인을 정하는 속성이 mappedBy 이다.
주인이 아닌 경우 mappedBy 속성을 사용해 연관관계의 주인을 명시한다.
연관관계의 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이다.
위 예시로는 누가 TEAM_ID 외래 키를 관리할지 정해야 한다.
Member.team 의 영우 자신의 매핑된 테이블에 TEAM_ID 를 관리하면 되니 자연스럽다.
Tema.members 의 경우 자신의 테이블에 없는 MEMBER 테이블의 TEAM_ID 를 관리해야 한다. 불가능한 경우이다.
즉, 연관관계의 주인은 테이블에 외래 키가 있는 곳이다.
Member.team 이 연관관계의 주인이다.
연관 관계의 주인만 외래 키에 대한 CUD가 가능하고,
주인이 아닌 반대편은 외래 키 읽기만 가능하다.
일대다, 다대일 연관관계에서는 항상 다 쪽이 외래 키를 가지게 된다.
다대일을 표현하는 @ManyToOne 은 항상 연관관계의 주인이므로 mappedBy 속성이 존재하지 않는다.
양방향 연관관계 저장
연관관계의 주인
private static void save() { logic(em -> { Team t1 = new Team("team1", "팀1"); em.persist(t1); Member m1 = new Member("member1", "멤버1"); m1.setTeam(t1); em.persist(m1); Member m2 = new Member("member2", "멤버2"); m2.setTeam(t1); em.persist(m2); }); } |
양방향 연관관계 주의점
연관관계의 주인이 아님
private static void save2() { logic(em -> { Member m1 = new Member("member1", "멤버1"); em.persist(m1); Member m2 = new Member("member2", "멤버2"); em.persist(m2); //연관관계의 주인이 아닌곳에서 저장 Team t1 = new Team("team1", "팀1"); t1.getMembers().add(m1); t1.getMembers().add(m2); em.persist(t1); }); } |
순수한 객체까지 고려한 양방향 연관관계
객체 관점에서 양쪽 방향에서 모두 값을 입력해주는 것이 안전하다.
JPA는 자바 ORM 표준으로 객체와 RDB 모두 중요하다.
private static void save3() { logic(em -> { Team t1 = new Team("team1", "팀1"); em.persist(t1); Member m1 = new Member("member1", "멤버1"); // 연관관계의 주인, 저장 시 사용된다. m1.setTeam(t1); // 저장 시에는 사용되지 않으나 애플리케이션에선 사용된다. t1.getMembers().add(m1); em.persist(m1); Member m2 = new Member("member2", "멤버2"); // 연관관계의 주인, 저장 시 사용된다. m2.setTeam(t1); // 저장 시에는 사용되지 않으나 애플리케이션에선 사용된다. t1.getMembers().add(m2); em.persist(m2); }); } |
연관관계 편의 메소드
양방향 연관관계는 기존 단방향에 더해 추가 단방향까지 신경써야한다.
전부 호출해야 양방향이 된다.
실수로 하나란 호출하면 양방향이 깨진다.
@Entity @Data @AllArgsConstructor @NoArgsConstructor public class Member { @Id @Column(name = "MEMBER_ID") private String id; private String username; //연관관계 매핑 @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; public Member(String id, String username) { this.id = id; this.username = username; } public void setTeam(Team team) { this.team = team; team.getMembers().add(this); } } |
연관관계의 주인 쪽 엔티티에 한 번만 호출해도 나머지도 처리되도록 편의 기능을 추가했다.
// 연관관계 편의 메서드 리팩터링 저장 private static void save4() { logic(em -> { Team t1 = new Team("team1", "팀1"); em.persist(t1); Member m1 = new Member("member1", "멤버1"); // 연관관계의 주인, 저장 시 사용된다. m1.setTeam(t1); // 저장 시에는 사용되지 않으나 애플리케이션에선 사용된다. // t1.getMembers().add(m1); em.persist(m1); Member m2 = new Member("member2", "멤버2"); // 연관관계의 주인, 저장 시 사용된다. m2.setTeam(t1); // 저장 시에는 사용되지 않으나 애플리케이션에선 사용된다. // t1.getMembers().add(m2); em.persist(m2); }); } |
한 번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라 한다.
연관관계 편의 메소드 작성 시 주의사항
// 연관관계 편의 메서드 주의사항 private static void save5() { logic(em -> { Team t1 = new Team("team1", "팀1"); em.persist(t1); Team t2 = new Team("team2", "팀2"); em.persist(t2); Member m1 = new Member("member1", "멤버1"); m1.setTeam(t1); m1.setTeam(t2); em.persist(m1); // t1 은 여전히 멤버1을 가지고 있다. 물론 // 연관관계의 주인이 아니라 DB에 영향은 없지만, // 문제가 있다. t1.getMembers().forEach(m -> { System.out.println(m.getUsername()); }); }); } |
team1 -> member1 은 연관관계 편의 메서드가 처리한 것
연관관계 편의 메서드에서 신규 관계만 집중한 나머지 남겨질 관계를 신경 쓰지 못한 모습
public void setTeam(Team team) { // 기존 팀에 나의 관계를 지움 if (this.team != null) { this.team.getMembers().remove(this); } this.team = team; //편의 메서드 team.getMembers().add(this); } |
// 연관관계 편의 메서드 주의사항 private static void save5() { logic(em -> { Team t1 = new Team("team1", "팀1"); em.persist(t1); Team t2 = new Team("team2", "팀2"); em.persist(t2); Member m1 = new Member("member1", "멤버1"); m1.setTeam(t1); m1.setTeam(t2); em.persist(m1); // t1 은 여전히 멤버1을 가지고 있다. 물론 // 연관관계의 주인이 아니라 DB에 영향은 없지만, // 문제가 있다. System.out.println("##################"); t1.getMembers().forEach(m -> { System.out.println(m.getUsername()); }); }); } |
지금 발생한 모든 문제는 객체에서 서로 다른 단방향 연관관계 2개를 이용해서 양방향을 흉내내기 위해서 많은 수고가 필요하다는 것을 알려주고 있다.
따라서, 꼭 필요한 경우에만 객체 시점 양방향 관계를 사용하도록 한다.
가급적 단방향만을 사용한다.
정리
단방향 매핑보다 양방향이 훨씬 복잡하다.
연관관계의 주인을 정해야 하고, 양방향 매핑 관련 로직을 더 신경써야 한다.
양방향의 장점은 두 엔티티 사이 양쪽으로 그래프 탐색 기능이 추가된 것에 불과하다.
따라서 양방향 그래프 탐색이 필요하지 않다면, 단방향만 사용하도록 한다.
연관관계의 주인은 항상 외래키가 있는 쪽이다. 비즈니스 데이터가 더 중요한 쪽을 선택하는 것이 아니다.
실전 예제
연관관계 매핑 시작
참고, 롬복 사용
@Entity @Data public class Member { @Id @GeneratedValue @Column(name = "MEMBER_ID") private Long id; private String name; private String city; private String strret; private String zipcode; @OneToMany(mappedBy = "member") @ToString.Exclude private List<Order> orders = new ArrayList<>(); } |
@Entity @Table(name = "ORDERS") @Data public class Order { @Id @GeneratedValue @Column(name = "ORDER_ID") private Long id; @ManyToOne @JoinColumn(name = "MEMBER_ID") private Member member; @OneToMany(mappedBy = "order") private List<OrderItem> orderItems = new ArrayList<>(); private LocalDateTime orderDate; @Enumerated(EnumType.STRING) private OrderStatus status; // 연관관계 편의 메소드 public void setMember(Member member) { if (this.member != null) { this.member.getOrders().remove(this); } this.member = member; member.getOrders().add(this); } public void addOrderItem(OrderItem orderItem) { orderItems.add(orderItem); orderItem.setOrder(this); } } |
public enum OrderStatus { ORDER, CANCEL } |
@Entity @Table(name = "ORDER_ITEM") @Data public class OrderItem { @Id @GeneratedValue @Column(name = "ORDER_ITEM_ID") private Long id; @ManyToOne @JoinColumn(name = "ITEM_ID") private Item item; @ManyToOne @JoinColumn(name = "ORDER_ID") private Order order; private int orderPrice; private int count; public void setOrder(Order order) { if (this.order != null) { this.order.getOrderItems().remove(this); } this.order = order; order.getOrderItems().add(this); } } |
@Entity @Data public class Item { @Id @GeneratedValue @Column(name = "ITEM_ID") private Long id; private String name; private int price; private int stockQuantity; } |