@ManyToMany를 지양하는 이유와 연결 엔티티 / 조인 테이블과 @OneToMany

양성준·2025년 4월 15일

스프링

목록 보기
31/49

@ManyToMany 지양

JPA는 DB와 달리, 양쪽 엔티티가 서로 다대다 관계일 때 @ManyToMany를 통해 쉽게 매핑할 수 있게 해준다. (DB와의 불일치 해결)
예를 들어, 메시지와 첨부파일(BinaryContent) 간의 관계는 @ManyToMany와 @JoinTable을 이용해 다음과 같이 선언할 수 있다.
(@JoinTable을 사용하지 않는다면, JPA가 연결 테이블을 생성해줌)

@Entity
public class Message {

    @ManyToMany
    @JoinTable(
        name = "message_attachments",
        joinColumns = @JoinColumn(name = "message_id"),
        inverseJoinColumns = @JoinColumn(name = "attachment_id")
    )
    private List<BinaryContent> attachments;
}

하지만 이 방식은 실무에서 거의 쓰지 않음!

  • 중간 테이블에 컬럼을 추가할 수 없다. 순서, 첨부설명 등 메타정보를 넣을 수 없음
  • 중간 관계를 제어하기 어렵다. 개별 삭제, 조회, 업데이트 등 어려움
  • 객체 그래프 탐색이 불명확하다. 어느 쪽이 연관관계의 주인인지 명확하지 않음
  • 양방향 관계의 복잡성으로 코드 유지보수가 어려워짐

연결 엔티티 사용(1:N + N:1)

대신 실무에서는 @ManyToMany 대신 연결 엔티티를 명시적으로 생성하여 관계를 표현한다.
Message ↔ MessageAttachment ↔ BinaryContent

@Entity
public class MessageAttachment {

    @EmbeddedId
    private MessageAttachmentId id;

    @ManyToOne
    @MapsId("messageId") // 복합키의 messageId와 message.id(PK) 연결
    @JoinColumn(name = "message_id")
    private Message message;

    @ManyToOne
    @MapsId("attachmentId") // 복합키의 attachmentId와 binarycontent.id(PK) 연결
    @JoinColumn(name = "attachment_id")
    private BinaryContent binaryContent;

    public MessageAttachment(Message message, BinaryContent binaryContent) {
        this.message = message;
        this.binaryContent = binaryContent;
        this.id = new MessageAttachmentId(message.getId(), binaryContent.getId());
    }

    protected MessageAttachment() {} 
}

public class Message {
  @OneToMany(mappedBy = "message", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<MessageAttachment> attachments;
}
  • Message -> BinaryContent 단방향 관계를 연결 엔티티로 풀어낸 것이기 때문에, BinaryContent에는 별도의 연관관계 존재 X

복합 키(Composite Key)

  • 연결 엔티티의 경우, 대부분 PK를 별도로 생성해서 사용하지만, FK를 합쳐 복합 키를 PK로 가지는 경우가 있다.
  • 복합 키는 보통 @Embeddable + @EmbeddedId 조합으로 표현한다.
@Embeddable
public class MessageAttachmentId implements Serializable {

    private UUID messageId;
    private UUID attachmentId;

    // equals(), hashCode() 꼭 오버라이드
}
  • @MapsId("messageId")는 이 복합키 안의 messageId 필드와 실제 연관관계 필드를 연결해주는 역할을 한다.

Message → BinaryContent에 접근하려면?

  • Message는 BinaryContent와 직접 연관관계를 맺고 있지 않기 때문에, 접근은 항상 연결 엔티티(MessageAttachment)를 통해서만 가능하다.
// Message에서 BinaryContent ID 추출
List<UUID> binaryContentIds = message.getAttachments().stream()
    .map(att -> att.getBinaryContent().getId())
    .toList();
  • 혹은 아예 Message 엔티티에 헬퍼 메서드를 만들어도 됨!
public List<BinaryContent> getBinaryContents() {
    return attachments.stream()
        .map(MessageAttachment::getBinaryContent)
        .toList();
}

Message -> BinaryContent가 1:N 관계인 경우

  • 참고로 관계는 항상 참조하는 쪽이 좌변
  • N쪽에 FK를 두는 것이 원칙이지만, User, Message 양쪽에서 참조하는 경우, 데이터 무결성 때문에 FK를 두기가 어려움
    • BinaryContent를 User에서도 참조하고, Message에서도 참조하기 때문에 FK를 BinaryContent에 두면, 하나는 null 해줘야함
    • 1:N 단방향 관계를 그럼 N:1 양방향 관계로 풀어내야할까? -> BianryContent에서는 Message를 조회할 일이 없다.
    • 그렇다고 1쪽에 FK를 두고 연관관계의 주인으로 설정하면, JPA 입장에서 1쪽이 N쪽의 FK를 직접 관리하려는 비효율적인 구조가 됨
      => 1:N 단방향을 N:1 양방향(양쪽에서 접근)으로 풀어내기보단, 1:N + JoinTable 또는 연결엔티티로 풀어내는 것이 좋음
  @OneToMany
  @JoinTable(
      name = "message_attachments",
      joinColumns = @JoinColumn(name = "message_id"), // 현재 테이블과 연결되는 JoinTable의 FK
      inverseJoinColumns = @JoinColumn(name = "attachment_id") // 상대 테이블과 연결되는 JoinTable의 FK
  )
  private List<BinaryContent> attachments;
  • 이렇게 하면 연결 테이블(message_attachments)을 JPA가 내부적으로 관리하기 때문에 Message에서 BinaryContnet 직접 접근 가능
    • 실제로는 Join을 통해 중간 테이블을 거치지만 개발자 입장에서는 List< BinaryContent>로 직접 접근하는 것처럼 보인다.
  • Message가 JoinTable을 관리하기 때문에, Message에 BinaryContentList를 넣고 save()해주면 JoinTable인 message_attachments 테이블에도 INSERT 쿼리가 날아간다.
    • 또, messageRepository.delete(message)를 호출하면 JPA가 알아서 message_attachments 테이블에도 DELETE 쿼리를 날림.
  • 이건 N쪽에서 FK를 가지면 안되는 특수한 케이스!
  • 보통의 1:N 관계에서는 N쪽에 FK를 가지기 때문에 1:N 단방향인 경우 N이 연관관계의 주인이므로, 1쪽에서 N을 조회하기가 힘듦
    • Member -> Team의 경우 1:N 관계이므로 Team 쪽에 FK가 존재하지만, Member -> Team 조회가 더 잦다.
      => 이 경우에는 다대일 양방향 관계로 풀어내는 것이 훨씬 유리함. (Member(mappedBy)에서 Team도 조회할 수 있게)

참고 - https://peace-developer.tistory.com/21

profile
백엔드 개발자

0개의 댓글