JPA 다양한 연관관계 공부의 기록

엔티티의 연관관계를 매핑할 때는 다음 3가지를 고려해야 한다.
■ 다중성(일대일, 일대다, 다대일, 다대다)
■ 단방향, 양방향
■ 연관관계의 주인

이 세가지를 고려하여 연관관계를 하나씩 살펴보자

■ 다대일: 단방향, 양방향
■ 일대다: 단방향, 양방향
■ 일대일: 주 테이블 단방향, 양방향
■ 일대일: 대상 테이블 단방향, 양방향
■ 다대다: 단방향, 양방향

다대일 (N:1)

다대일 단방향 매치

@Entity
@Getter @Setter
public class Member {
    @Id
    @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
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;
}

다대일 양방향 매치

연관관계 주인 : 양방향 연관관계의 주인은 항상 다쪽이다. 테이블 상 다쪽에서 FK를 보관하므로 연관관계의 주인을 가져가는 것이다.

양방향 관계일때는 항상 서로가 서로를 참조해야하는데, 연관관계 뿐만 아니라 Java 코드상으로도 오류없이 가능하도록 해야한다.

@Entity
@Getter @Setter
public class Member {
    @Id
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;

    //연관관계 매핑
    @ManyToOne // 다대일
    @JoinColumn(name="TEAM_ID") // join에 사용되는 pk
    private Team team;

    public void setTeam(Team team) {

        if (this.team != null) {
            this.team.getMembers().remove(this);
        }
        this.team = team;
        team.getMembers().add(this);
    }
}

@Entity
@Getter @Setter
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();

    public void addMember(Member member) {
        this.members.add(member);
        if (member.getTeam() != this) {
            member.setTeam(this);
        }
    }
}

일대다 (N:1)

일대다 단방향 매치

위의 예시를 그대로 가져오자면, 하나의 팀은 여러 회원을 참조할 수 있는데 이런 관계를 일대다 관계라 한다. 그리고 팀은 회원들은 참조하지만 반대로 회원은 팀을 참조하지 않으면 둘의 관계는 단방향이다.

일대다로 객체를 매핑할때는 테이블 상으로는 다쪽에 FK가 있지만, 객체상으로는 일쪽에 FK가 있는 이상한 그림이 그려진다.

@Entity
@Getter @Setter
public class Team {
    @Id 
    @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<Member>();
      
@Entity
@Getter @Setter
public class Member {
    @Id
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;
}

일대다 단방향 매핑은 매핑한 객체가 관리하는 외래 키가 다른 테이블 에 있기 때문에 성능상 불이익이 있다. 본인 테이블에 외래 키가 있으면 엔티티의 저장과 연관관계 처리를 INSERT SQL 한 번으로 끝낼 수 있지만, 다른 테이블에 외래 키가 있으면 연관관계 처리를 위한 UPDATE SQL을 추가로 실행해야 한다.

따라서 일대다 단방향 매칭을 사용하느니 다대일 양방향 매칭을 사용하는 것이 바람직하다.

일대다 양방향 매치

일대다 양방향 매핑은 존재하지 않는다. 굳이 만들 수도 있지만 굳이? 대신 다대일 양방향 매핑을 사용해야 한 다. 이는 위에도 언급했든이 연관관계의 주인이 다쪽에 있기 때문이다. 결국 다애일 양방향 매칭과 똑같아진다.

일대일 (1:1)

일대일 관계는 양쪽이 서로 하나의 관계만 가진다. 예를 들어 회원은 하나의 사물 함만 사용하고 사물함도 하나의 회원에 의해서만 사용된다.

연관관계의 주인을 누구로 할지가 관건이다. 회원(주 테이블)이 외래키를 가질 것인기, 사물함(대상 테이블)이 외래키를 가질 것인지는 개발자 성향에 따라 다르게 나타난다.

OOP 개발자들은 주 객체가 대상 객체를 참조하는 식에 익숙하기 때문에 회원이 연관관계의 주인이 되게끔 할 것이고, 전통적인 DB 개발자는 대상 객체에 FK가 있는것이 익숙하기 때문에 반대로 할 것이다.

JPA는 OOP 관점에서 설계된 프레임워크이기 떄문에 주 객체가 대상 객체를 참조하도록 하는, 그러니까 연관관계의 주인이 회원이 되도록 하는 것이 유리하다.

단방향 일대일 매치(주 객체)

@Entity
@Getter @Setter
public class Member {
    @Id
    @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
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
}

양방향 일대일 매치(주 객체)

Locker 객체에는 mappedBy 속성을 사용하여 연관관계의 주인이 아님을 설정하였다.

@Entity
@Getter @Setter
public class Member {
    @Id
    @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
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    @OneToOne(mappedBy = "locker")
    private Member member;
}

단방향 일대일 매치(대상 객체)

일대일 관계 중 대상 테이블에 외래 키가 있는 단방향 관계는 JPA에서 지원하지 않는다. 그리고 이런 모양으로 매핑할 수 있는 방법도 없다. 이 때는 단방향 관계를 Locker에서 Member 방향으로 수정하거나, 양방향 관계로 만들고 Locker를 연관관계의 주인으로 설정해야 한다.

단방향 일대일 매치(대상 객체)

@Entity
@Getter @Setter
public class Member {
    @Id
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    @OneToOne(mappedBy = "member")
    private Locker locker;
}

@Entity
@Getter @Setter
public class Locker {
    @Id
    @Column(name = "LOCKER_ID")
    private Long id;

    private String name;
    
    @OneToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
}

다대다(N:N)

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 그래서 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 연결 테이블을 사용한다.

회원이 상품을 주문하는 시나리오를 생각해보면, 하나의 회원의 여러개의 상품을 주문할 수도 있고, 하나의 상품이 여러명의 회원에 의해 주문될 수도 있는 다대다 상황이다.

DB에서는 중간에 연결 테이블을 만들어서 사용한다.

그런데 객체는 그렇게 할 필요가 없다. Member 객체와 Product 객체 모두 컬렉션을 사용해서 서로를 참조하게 하면 되는 것이다.

다대다 단방향

Member객체에서 Porduct 객체를 참조하는 단방향 매핑을 구현한다.

@Entity
@Getter @Setter
public class Member {
    @Id 
    @Column(name = "MEMBER_ID") 
    private String id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT", 
        joinColumns = @JoinColumn(name = "MEMBER_ID"),
        inverseJoinColumns = @JoinColumn(name =
                                       "PRODUCT_ID"))
    private List<Product> products = 
                             new ArrayList<Product>();
}


@Entity
@Getter @Setter
public class Product {
    @Id 
    @Column(name = "PRODUCT_ID") 
    private String id;
    private String name;
}

Member 엔티티와 Product 엔티티를 @ManyToMany로 매핑했다. 여기서 중요한 점은 @ManyToMany와 @JoinTable을 사용해서 연결 테이블을 바로 매핑한 것이다. 따라서 Member와 Product를 연결하는 회원_상품(Member_Product) 엔티티 없이 매핑을 완료할 수 있다.

다대다 매핑에서 @JoinTable 이 핵심이므로 사용된 속성을 알아보자

■ @JoinTable.name: 연결 테이블을 지정한다. 여기서는 MEMBER_PRODUCT 테이 블을 선택했다.

■ @JoinTable.joinColumns: 현재 방향인 회원과 매핑할 조인 컬럼 정보를 지정한다. MEMBER_ID로 지정했다.

■ @JoinTable.inverseJoinColumns: 반대 방향인 상품과 매핑할 조인 컬럼 정보를 지정한다. PRODUCT_ID로 지정했다.

다대다 양방향

다대다 매핑이므로 역방향도 @ManyToMany를 사용한다.
Member 엔티티(주 객체)에서 필요한 Join 테이블 매칭을 모두 마쳤기 때문에 Product 엔티티에서는 굳이 또 매핑할 필요가 없다.

@Entity
@Getter @Setter
public class Product {
    @Id 
    @Column(name = "PRODUCT_ID") 
    private String id;
    private String name;
    
    @ManyToMany(mappedBy = "products") //역방향 추가 
    private List<Member> members;
}

역방향 매핑까지 끝났으면 두개의 setter로 연관관계를 세팅해야하는 불편함을 막기위해 편의 메서드를 설정하자.

// Member 엔티티에 추가
public void addProduct(Product product) { 
    this.products.add(product);
    product.getMembers().add(this); 
}

다대다 매핑 한계 극복, 연결 엔티티 사용

@ManyToMany를 사용하면 연결 테이블을 자동으로 처리해주므로 도메인 모델이 단순해지고 여러 가지로 편리하다. 하지만 이 매핑을 실무에서 사용하기에는 한계가 있다. 실질적으로 연결 테이블은 연결 정보로서 FK 두개만 가지고 있는게 아니라 다른 정보도 같이 보관하기 마련이다. 회원이 상품을 주문할때 사용하는 연결 테이블은 주문 일자, 주문 수량등의 정보를 같이 보관할 수 있다.

위에서 사용한 방법에서는 연결테이블을 이러한 용도로 활용할 수 없다. 어떻게 이 한계를 극복할 수 있을까?

이렇게 컬럼을 추가하면 더는 @ManyToMany를 사용할 수 없다. 차라리 MemberProduct 엔티티를 만들어서 사용해야한다.

그렇다면 Member와 MemberProduct 엔티티를 일대다로 매핑하고, MemberProduct와 Product 엔티티를 다대일로 매핑하는 과정을 거쳐야한다.

// Member와 MemberProduct 엔티티 일대다 매핑
@Entity
@Getter @Setter
public class Member {
    @Id
    @Column(name = "MEMBER_ID")
    private Long id;
    private String username;

    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProductList;
}

//Product와 MemberProduct 엔티티 일대다 매핑

@Entity
@Getter @Setter
public class Product {
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    private String name;

    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProductList;
}


// MemberProduct 엔티티
@Entity
@Getter @Setter
@IdClass(MemberProductId.class) //식별자 클래스
public class MemberProduct {

    @Id
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member; // Id와 JoinColumn이 모두 사용됨

    @Id
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product; // Id와 JoinColumn이 모두 사용됨

    private int productQuantity;
    @Temporal(TemporalType.TIMESTAMP)
    private Date date;
}

// 회원상품 식별자 클래스
public class MemberProductId implements Serializable {

    private String member; //MemberProduct.member와 연결
    private String product; //MemberProduct.product와 연결
}

복합키

회원상품(MemberProduct) 엔티티는 기본 키가 MEMBER_ID와 PRODUCT_ID로 이루어진 복합 기본 키다. JPA에서 복합 키를 사용하려면 별도의 식별자 클래스를 만들어야 한다.

일반적으로 ManyToOne 매핑 관계에서 One에 있는 쪽이 Many 쪽을 컬렉션으로 저장하는것과는 다르게 MemberProduct 엔티티는 둘을 자신의 기본키이자 외래키로 사용하기 때문에 단일 변수로 받는다.

이러한 식별자 클래스를 만들기 위해서는 아래의 아래의 제약 사항을 지켜야한다.

■ Serializable을 구현해야 한다.
■ equals와 hashCode 메소드를 구현해야 한다.(대부분의 IDE는 알아서 구현한다.)
■ 기본 생성자가 있어야 한다.
■ 식별자 클래스는 public이어야 한다.
■ @IdClass를 사용하는 방법 외에 @EmbeddedId를 사용하는 방법도 있다.

■ 식별관계
회원상품(MemberProduct) 엔티티는 회원과 상품의 기본 키를 받아서 자신의 기본 키로 사용한다. 이렇게 부모 테이블의 기본 키를 받아서 자신의 기본 키 + 외래 키로 사용하는 것을 데이터베이스 용어로 식별 관계(Identifying Relationship)라 한다.

복합키 엔티티 저장

public void save() {
    //회원 저장
    Member member1 = new Member(); 
    member1.setId("member1"); 
    member1.setUsername("회원1"); 
    em.persist(member1);

   //상품 저장
   Product productA = new Product();
   productA.setId("productA"); 
   productA.setName("상품1"); 
   em.persist(productA);

   //회원상품 저장
   MemberProduct memberProduct = new MemberProduct();
   memberProduct.setMember(member1); //주문 회원 - 연관관계 설정 
   memberProduct.setProduct(productA); //주문 상품 - 연관관계 설정 
   memberProduct.setOrderAmount(2); //주문 수량
   em.persist(memberProduct); 
}

복합키 엔티티 조회

복합키로 구성된 엔티티를 조회하기 위해서는 식별자 클래스를 인스턴스화 하고, 해당 인스턴스를 이용해서 조회해야한다.

    public void find() {
//기본키값생성
        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 = " + member.getUsername()); 
        System.out.println("product = " + product.getName()); 
        System.out.println("orderAmount = " +
                memberProduct.getOrderAmount()); 
    }

이렇듯, 복합키를 이용한 방법은 복잡하다.

복합키 없이 다대다 매핑

외래키를 기본 키로 사용하는 대신, 자신만의 고유한 기본 키를 생성하도록 하는 것이다. 이렇게 하면 기본키의 비즈니스 의존도도 낮추고, ORM 매핑시 복합 키를 만들지 않아도 되므로 간단히 매핑을 할 수 있다.

MemberProduct 엔티티를 조금 더 어울리는 이름인 Order로 바꿔보자.


@Entity
@Getter @Setter
public class Order {
    @Id
    @Column(name = "ORDER_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private int OrderAmount;
    @Temporal(TemporalType.TIMESTAMP)
    private Date OrderDate;
}

@Entity
@Getter @Setter
public class Member {
    @Id
    @Column(name = "MEMBER_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;

    @OneToMany(mappedBy = "member")
    private List<Order> orderList;
}

@Entity
@Getter @Setter
public class Product {
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    private String name;

    @OneToMany(mappedBy = "product")
    private List<Order> orderList;
}

복합키를 이용해서 매핑했을 때보다 훨씬 코드가 깔끔해졌고, 조회할때도 식별자 클래스를 인스턴스화 할 필요가 없으므로 편해졌다.

좋은 웹페이지 즐겨찾기