Spring

[Spring] 연관관계 매핑

haenni 2024. 6. 30. 13:19

👩‍💻  연관관계 매핑


 

 

📌연관관계 매핑

✏️ 연관관계 매핑이란?

객체간의 관계를 데이터베이스의 관계로 어떻게 매핑할 지 정의하는 것

 

 

✏️ 연관관계가 왜 필요한데?

: 데이터베이스에서는 연관관계를 구사하기 위해서 외래키(FK)를 사용한다. 만약 테이블과 같이 객체에 외래키를 넣어주게 된다면, 외래키를 통해서 또 객체를 찾아야하는 번거로움이 존재한다. 참조 객체를 필드값으로 가지고 있으면 이러한 번거로움이 해결되기에 "객체지향적 코드"가 된다. 이러한 이유로 연관관계 매핑을 사용해주는 것이 좋다.

 

테이블과 같이 객체에 외래키를 넣어준 경우

/* Member 객체 코드 */

public class Member{
	private Long memberId;
    private Long teamId;
}

/* 해당 Member가 포함된 team의 이름을 찾기 위해서 find를 또 해주어야함 */

Long id = member.getTeamId()
Team team = findById(id)

 

참조 객체를 필드값으로 가지고 있는경우

/* Member 객체 */

public Class Member{
	private Long memberId;
    private Team team;
}

/* 멤버의 팀 이름을 알고싶을 때 */

String name = member.getTeam().getTeamName()	//간편함

 

 

 

 

 

📌연관관계 정의 규칙

✏️ 연관관계 정의 규칙 3가지

:연관 관계를 매핑할 때, 생각해야 할 3가지 규칙이 존재한다

규칙 설명
방향 단방향 & 양방향
연관관계 주인 양방향일 때, 연관 관계에서 관리 주체
다중성 일대다(1:N), 다대일(N:1), 일대일(1:1), 다대다(N:M)

 

 

 

📌방향

✏️ 방향

: 단방향 & 양방향

 

데이터베이스 테이블은 외래 키 하나로 양 쪽 테이블 조인이 가능하여 데이터베이스는 단방향이니 양방향이니 나눌 필요가 없다.

그러나 객체는 참조용 필드가 있는 객체만 다른 객체를 참조하는 것이 가능하다.

 

두 객체 사이에 하나의 객체만 참조용 필드를 갖고 참조하면 단방향 관계, 두 객체 모두가 각각 참조용 필드를 갖고 참조하면 양방향 관계라고 한다. 엄밀하게는 양방향 관계↔️는 없고 두 객체가 단방향 참조를 각각 가져서 양방향 관계처럼 사용하고 말하는 것 이다.

 

방향선택은 비즈니스 로직에서 두 객체가 참조가 필요한지 여부를 고민해보면 된다.

  • team.getMember()처럼 참조가 필요하면 Team →Member 단방향참조
    • 만약 참조가 굳이 필요없으면 참조를 안하면 됨
  • member.getTeam()처럼 참조가 필요하면 Member → Team 단방향참조
    • 만약 참조가 굳이 필요없으면 참조를 안하면 됨

 

 

 

✏️ 단방향
단방향 관계는 한 객체가 다른 객체를 참조할 수 있지만, 그 역방향 참조가 존재하지 않는 관계이다. 즉, 한 객체만 다른 객체를 참조하며 관계가 한 방향으로만 설정된다.

 

"단방향 예시" - 객체 A는 객체 B를 참조하지만, 객체 B는 객체A를 참조하지않는다

 

/* Member 객체 */
public class Member {
	private Long memberId;
    private String memberName;
    private List<Team> team;	//Team 객체를 참조하고 있음
}

/* Team 객체 */
public class Team {
	private Long teamId;
    private String teamName;
    //Member 객체를 참조하고 있지 않음. 만약 Member 객체를 참조하고 있다면 아래와 같은 필드가 존재해야한다.
	//private Member member;
}
위의 코드처럼 단방향일 경우, Member객체를 통해서 team을 조회할 수 있지만, Team에서는 Member객체를 통해서 user를 조회할 수 없다.

 

 

 

✏️ 양방향

양방향 관계는 단방향과 다르게 객체가 서로를 참조하는 것을 말한다.

 

"단방향 예시" - 객체 A는 객체 B를 참조하고, 객체 B도 객체 A를 참조한다.

/* Member 객체 */
public class Member {
	private Long memberId;
    private String memberName;
    private List<Team> team;	//Team 객체를 참조하고 있음
}

/* Team 객체 */
public class Team {
	private Long teamId;
    private String teamName;
	private Member member;	//Member 객체를 참조하고 있음
}
위의 코드처럼 양방향일 경우, Member객체를 통해서 team을 조회할 수 있고, Team에서도 Member 객체를 통해서 member를 조회할 수 있다.

 

 

 

 

🤔💭 "근데 그러면 그냥 무조건 양방향 매핑을 하면 되잖아?"

"단방향이니, 양방향이니 나누기엔 머리아프니까 그냥 무조건 양방향으로 매핑하면 되잖아?" 라고 생각할 수 있다.

 

하지만 객체 입장에서는 모든 객체를 양방향 매핑해버린다면 복잡해질 수 있다.

예를 들어서 비즈니스 모델에서 Member는 수많은 테이블과 연관 관계를 맺게되고, 이로 인해서 Member의 클래스가 복잡해진다.

그리고 다른 엔티티들도 불필요한 연관관계 매핑으로 인해 복잡성이 증가할 수 있기때문에, 양방향으로 할지 단방향으로 할지 필히 구분해줘야 한다.

구분하기 좋은 기준은 기본적으로 단방향 매핑으로 하고 나중에 역방향으로 객체 탐색이 꼭 필요하다고 느낄 때 추가하는 것으로 잡으면 된다

 

 

 

 

 

📌연관관계 주인

✏️ 연관관계 주인

: 만약 연관관계가 "양방향"이라면 연관관계 주인을 지정해주어야한다.

두 객체(A, B)가 양방향 관계, 다시 말해 단방향 관계 2개(A→B, B→A)를 맺을 때, 연관 관계의 주인을 지정해야 한다.

 

연관관계 주인을 지정하는 것은 두 객체(A, B)중, "제어의 권한(레코드 저장, 수정 및 삭제 등)"을 어느 객체가 가질 지 정하는 것이다.

연관관계 주인은 연관 관게를 갖는 두 객체를 "저장, 수정, 삭제"할 수 있지만, 연관관계 주인이 아닌 객체는 오로지 "조회"만 가능하다.

 

연관관계 주인이 아닌 객체에 (mappedBy = ) 속성을 사용해 연관관계 주인을 명시해주어야한다

 

⭐️ TIP!
"무조건 외래키(FK)값을 가진 객체를 연관관계 주인으로 설정한다"
즉, 외래키가 없는 객체에 mappedBy 속성을 사용해준다.

 

 

 

✏️ "근데 왜 연관관계 주인을 지정해주어야하는걸까요?"

두 객체(Member, Team)가 서로 양방향 매핑 관계를 가지고 있다고 가정해보자.

만약에 Team의 이름을 저장하고싶을때, Team.setName()을 해야할까, Member.getTeam().setName()을 해야할까?

 

두 객체 입장에서는 두 방법 다 맞는 방법이긴 하다.

그러나 이렇게 객체에서 양방향 연관 관계 관리 포인트가 두 개일 때는 테이블과 매핑을 담당하는 JPA입장에서 혼란을 주게 된다.

즉, Member에서 Team를 수정할 때 FK(Foreign Key)를 수정할 지, Team에서 Member를 수정할 때 FK(Foreign Key)를 수정할 지를 결정하기 어려운 것이다.

그렇기 때문에 두 객체 사이의 연관 관계의 주인을 정해서 명확하게 Member에서 Team을 수정할 때만 FK를 수정하겠다! 라고 정하는 것이다.

 

 

 

✏️ 연관 관계 지정 후 많이하는 실수

 

 

 

📌 다중성

✏️ 다중성

 

데이터베이스를 기준으로 다중성을 결정한다.

(JPA는 JPQL도 그렇고 보통 객체를 기준으로 하는게 일반적인데 다중성을 정하는 기준은 "데이터베이스" 기준이다.)

  • 일대다 ↔ 다대일
  • 일대일 ↔ 일대일
  • 다대다 ↔ 다대다

💡알아두어야할 점

  1. 무조건 "다(N)"쪽이 연관관계 주인을 가져야한다.(사실 무조건은 아니고, 그러는 게 좋다. 데이터베이스는 무조건 "다"쪽이 외래키를 가지고 있기 때문이다)

 

 

✏️ 다대일(N:1)

: 다대일(N:1)에 대해 알아보자

예시)

  • 여러 개의 댓글은 한 개의 게시글에 작성된다.
  • Comment(댓글)이 "다"이고, Board(게시글)은 "일"이다

다대일(N:1) "단방향"예시

"단방향"이기 때문에 Comment만 Post를 참조하게끔 코드를 작성하였다.
Post에는 Comment 참조객체의 필드가 없고, "다"인 Comment에만 Post 참조객체 필드를 넣어주었다.

Comment가 N이고 Post가 1이기 때문에 Comment위에 @ManyToOne 애노테이션을 붙여주고, @JoinColumn을 붙여 어떤 컬럼이 외래 키 역할을 하는지 명시해준다.

여기서 "다"는 Comment고, 데이터베이스에서는 "다"가 무조건 외래키를 가지기 때문에 Comment의 외래키로 post_id를 넣어준다.

 

 

다대일(N:1) "양방향"예시

"양방향"이기 때문에 Comment와 Post 객체가 서로를 참조한다.
양방향은 무조건 연관관계 주인을 설정해주어야한다.(연관관계 주인은 외래키를 가지고 있는 쪽으로 정한다. "다"쪽으로)

Post는 참조객체인 Comment를  List<Comment> comments = new ArrayList<>(); 필드로 작성해주었고, Post가 "일"쪽이기 때문에 @OneToMany 애노테이션과 함께 mappedBy 속성을 지정해주었다.

Comment는 참조객인 Post를 필드로 작성해주었고, Comment가 "다"쪽이기 때문에 @ManyToOne 애노테이션과 함께 Post를 외래키로 지정해주는 @JoinColumn 애노테이션을 사용해주었다.

 

위의 코드처럼 단방향일 경우, Member객체를 통해서 team을 조회할 수 있지만, Team에서는 Member객체를 통해서 user를 조회할 수 없다.

 

 

 

✏️ 일대다(1:N)

어? 일대다는 다대일에서 반대 입장인데 정리할 필요가 있나? 생각할 수 있지만 앞서 다대일의 기준은 연관관계의 주인 다(N)쪽에 둔 것이고, 이번에 언급할 일대다의 기준은 연관관계의 주인을 일(1)쪽에 둔 것이다.

 

※ 참고로 실무에서는 일대다(1:N) 단방향은 거의 쓰지 않도록 한다

 

예시)

  • 여러 개의 댓글은 한 개의 게시글에 작성된다.
  • Comment(댓글)이 "다"이고, Board(게시글)은 "일"이다

일대다(1:N) "단방향" 예시 - "일"쪽 객체에서 "다(N)" 쪽 객체를 조작하는 방법

Board 엔티티는 Board 테이블에 매핑되기 때문에 Board 테이블에 직접 지정할 수 있으나, Post 테이블의 FK(BOARD_ID)를 저장할 방법이 없기 때문에 조인 및 업데이트 쿼리를 날려야 하는 문제가 있다.

치명적인 단점 일만 수정한 것 같은데 다른 수정이 생겨 쿼리가 발생하는 것.
-   Board를 저장했는데 왜 Post가 수정이 되지? 이런 생각을 하게 만듦.
-   업데이트 쿼리 때문에 성능상 이슈는 그렇게 크지는 않음

결과적으로 일대다(1:N) 단방향, 양방향은 쓰지 말고 차라리 다대일(N:1) 양방향으로 쓰는 것이 맞다.

 

 

 

 

✏️ 일대일(1:1)

주 테이블에 외래키를 넣을 수도 있고, 대상 테이블에 외래키를 넣을 수도 있다.

※ 일대일(1:1)이기 때문에 테이블 A, B가 있을 때, A가 주 테이블이면 B가 대상 테이블이고, B가 주 테이블이면 A가 대상 테이블이다.

 

 

예시)

  • 게시글(Board)에 파일을 반드시 1개만 첨부할 수 있다고 가정해보자

일대일(1:1) "단방향"예시

Post 엔티티는 Attachment 엔티티를 참조한다. - @OneToOne 어노테이션을 사용하여 일대일 관계를 설정
@JoinColumn(name = "attachment_id")을 사용하여 Post 테이블에 attachment_id 외래 키 컬럼을 정의한다.
Attachment 엔티티는 Post 엔티티를 참조하지 않는다.

 

 

일대일(1:1) "양방향"예시

양방향 일대일 관계에서는 양쪽 엔티티가 서로를 참조한다.
mappedBy 속성은 참조된 엔티티에서 관계를 정의한 필드를 지정한다.

 

일대일(1:1) 단방향 지원 안함 ❌

Post테이블(주 테이블)이 아닌 Attach테이블(대상 테이블)에 외래 키(FK)를 갖고 있을 때를 생각해보려고 한다.

그러나 이거는 JPA에서는 아예 지원을 하지 않습니다.

 

일대일(1:1) 양방향

이럴 때는 어차피 양 쪽이 일대일이기 때문에 위에서 정의한 대로 처리하면 된다.

그러나 논란의 여지가 있다. 외래 키를 Post에서 관리하는 게 좋을 것인지, Attach에서 관리하는 게 좋을 것인지 생각을 해봐야한다. 

즉 테이블에 어디에 둘 것 인지를 생각해야한다. 테이블은 생성되면 보통 굳어지기에 변경이 어렵지만, 비즈니스는 언제든 바뀔 수 있다

 

게시글이 여러 개의 첨부파일을 첨부할 수 있도록 비즈니스가 변경되면 어떨까요?

그러면 다(N)쪽인 Attach테이블에 외래 키가 있는 것이 변경에 유연하다. 그러면 다(N)가 될 확률이 높은 테이블에 외래 키를 놓는게 무조건 좋을까? 그건 또 아니다

 

객체 입장에서 Post쪽(1)에서 외래 키를 갖게되면 Post를 조회할 때마다 이미 Attach의 참조를 갖고 있기 때문에 성능상 이득이 있다.

 

※ 결론

종합적으로 판단하고 결정해야하는데 단순화해서, 보통 일대일이라고 정할 때도 아주 신중하게 정했다고 가정한다면 주 테이블(Post)에 외래 키를 두는 것이 더 낫다.

 

 

다대다(N:N)

  • 실무 사용 금지 ❌
    • 중간 테이블이 숨겨져 있기 때문에 자기도 모르는 복잡한 조인의 쿼리(Query)가 발생하는 경우가 생길 수 있기 때문이다.
    • 다대다로 자동생성된 중간테이블은 두 객체의 테이블의 외래 키만 저장되기 때문에 문제가 될 확률이 높다.
    • JPA를 해보면 중간 테이블에 외래 키 외에 다른 정보가 들어가는 경우가 많기 때문에 다대다를 일대다, 다대일로 풀어서 만드는 것(중간 테이블을 Entity로 만드는 것)이 추후 변경에도 유연하게 대처할 수 있습니다.

 

 

 

 

📌 복합키

✏️ 복합키란?

: 복합키(Composite Key)는 두 개 이상의 컬럼을 조합하여 기본 키로 사용하는 키이다.

예를 들어, 주문 시스템에서 하나의 주문(Order)에는 여러 상품(Product)이 포함될 수 있다.

이 경우, 주문 ID와 상품 ID를 결합하여 고유한 키를 만들 수 있다.

 

복합키가 필요한 상황 예시     -     주문 시스템 

  • Order (주문)
    • orderId (주문 ID)
    • customerId (고객 ID)
  • Product (상품)
    • productId (상품 ID)
    • name (상품명)
  • OrderItem (주문 항목)
    • orderId (주문 ID)
    • productId (상품 ID)
    • quantity (수량)

OrderItem 엔티티에서 orderId와 productId를 복합키로 사용하여 한 주문에서 하나의 상품이 중복되지 않도록 합니다.

 

 

 

✏️ 복합키 예제

@EmbeddedId를 사용한 복합키 설정

먼저, 내장된 키를 나타내는 클래스를 정의하고, 엔티티 클래스에서 @EmbeddedId를 사용하여 이를 포함합니다.

 

Step 1: 내장된 키 클래스 정의

@Embeddable
public class OrderItemId implements Serializable {
    private Long orderId;
    private Long productId;

    // 기본 생성자
    public OrderItemId() {}

    public OrderItemId(Long orderId, Long productId) {
        this.orderId = orderId;
        this.productId = productId;
    }

    // equals and hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderItemId that = (OrderItemId) o;
        return Objects.equals(orderId, that.orderId) && Objects.equals(productId, that.productId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, productId);
    }

 

 

Step 2: 엔티티 클래스에서 @EmbeddedId 사용

import javax.persistence.*;

@Entity
public class OrderItem {
    @EmbeddedId
    private OrderItemId id;

    private int quantity;

    @ManyToOne
    @MapsId("orderId")
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne
    @MapsId("productId")
    @JoinColumn(name = "product_id")
    private Product product;
    }
}
OrderItemId 클래스는 복합키를 나타내며, Serializable을 구현한다.
OrderItem 엔티티는 OrderItemId를 @EmbeddedId로 포함하고 있다.

 

 

 

 

예시 데이터

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("example-unit");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        // Sample order
        Order order = new Order();
        em.persist(order);

        // Sample product
        Product product = new Product();
        em.persist(product);

        // Order item
        OrderItemId orderItemId = new OrderItemId(order.getId(), product.getId());
        OrderItem orderItem = new OrderItem();
        orderItem.setId(orderItemId);
        orderItem.setOrder(order);
        orderItem.setProduct(product);
        orderItem.setQuantity(10);

        em.persist(orderItem);

        tx.commit();
        em.close();
        emf.close();
    }
}

 


 참고자료

  • 자바 ORM 표준 JPA 프로그래밍(에이콘, 김영한)
  • 자바 ORM 표준 JPA 프로그래밍 - 기본편(인프런 온라인 강의, 김영한)
  • https://jeong-pro.tistory.com/231 [기본기를 쌓는 정아마추어 코딩블로그:티스토리]