Chapter 08. 다양한 연관관계 매핑

Chapter 08. 다양한 연관관계 매핑

연관관계 매핑 핵심 3요소

┌─────────────────────────────────────────────────────────────┐
│              연관관계 매핑 시 고려할 3가지                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1️⃣ 다중성              2️⃣ 방향              3️⃣ 연관관계 주인 │
│  ┌─────────────┐      ┌──────────┐       ┌──────────────┐  │
│  │ @ManyToOne  │      │  단방향   │       │  외래 키를    │  │
│  │ @OneToMany  │      │  ↓       │       │  관리하는 쪽  │  │
│  │ @OneToOne   │      │  양방향   │       │  = 주인      │  │
│  │ @ManyToMany │      │  ↕       │       │              │  │
│  └─────────────┘      └──────────┘       └──────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

핵심 개념 정리

구분테이블객체
방향외래 키 하나로 양방향 조인 가능참조 필드가 있어야 탐색 가능
연관관계외래 키 1개로 관리양방향 시 참조 2개
주인해당 없음외래 키 관리자 지정 필요

연관관계 주인 규칙

  • 주인: 외래 키를 관리 (INSERT, UPDATE)
  • 주인이 아닌 쪽: mappedBy 속성 사용, 읽기만 가능

1. 다대일 [N:1]

가장 많이 사용하는 연관관계. 외래 키는 항상 N쪽에 존재

┌──────────────────────────────────────────────────────────┐
│                    다대일 관계 구조                        │
│                                                          │
│    [Member] N ─────────────────────> 1 [Team]           │
│       │                                  │               │
│       │ TEAM_ID (FK)                    │               │
│       ▼                                  │               │
│  ┌─────────────┐                  ┌─────────────┐       │
│  │   MEMBER    │                  │    TEAM     │       │
│  ├─────────────┤                  ├─────────────┤       │
│  │ MEMBER_ID PK│                  │ TEAM_ID  PK │       │
│  │ TEAM_ID  FK │ ───────────────> │ NAME       │       │
│  │ USERNAME    │                  └─────────────┘       │
│  └─────────────┘                                        │
└──────────────────────────────────────────────────────────┘

1.1 다대일 단방향

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // getter, setter
}

@Entity
public class Team {
    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    // Team에서는 Member를 참조하지 않음 (단방향)
}

1.2 다대일 양방향

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;  // ⭐ 연관관계 주인

    // 연관관계 편의 메소드
    public void setTeam(Team team) {
        this.team = team;
        if (!team.getMembers().contains(this)) {
            team.getMembers().add(this);
        }
    }
}

@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<>();

    // 연관관계 편의 메소드
    public void addMember(Member member) {
        this.members.add(member);
        if (member.getTeam() != this) {
            member.setTeam(this);
        }
    }
}

양방향 매핑 규칙

┌────────────────────────────────────────────────────────┐
│              양방향 연관관계 핵심 규칙                    │
├────────────────────────────────────────────────────────┤
│ ✅ 외래 키가 있는 쪽이 연관관계의 주인                    │
│ ✅ 양쪽 모두 서로 참조해야 함                            │
│ ✅ 연관관계 편의 메소드를 작성하여 동기화                  │
│ ⚠️  편의 메소드는 무한루프 방지 로직 필수                 │
└────────────────────────────────────────────────────────┘

2. 일대다 [1:N]

일(1)쪽이 연관관계 주인. 권장하지 않는 방식

2.1 일대다 단방향

┌──────────────────────────────────────────────────────────┐
│              일대다 단방향 (비권장)                        │
│                                                          │
│    [Team]                          [Member]              │
│       │                               │                  │
│       │ members ────────────────────> │                  │
│       │           관리                │                  │
│       ▼                               ▼                  │
│  ┌─────────────┐               ┌─────────────┐          │
│  │    TEAM     │               │   MEMBER    │          │
│  ├─────────────┤               ├─────────────┤          │
│  │ TEAM_ID  PK │ <──────────── │ TEAM_ID  FK │ ← 다른   │
│  │ NAME        │               │ MEMBER_ID PK│   테이블! │
│  └─────────────┘               └─────────────┘          │
└──────────────────────────────────────────────────────────┘
@Entity
public class Team {
    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")  // MEMBER 테이블의 FK
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;
    // Team 참조 없음
}

일대다 단방향의 문제점

┌────────────────────────────────────────────────────────┐
│                 일대다 단방향 단점                       │
├────────────────────────────────────────────────────────┤
│ ❌ 외래 키가 다른 테이블에 있음                          │
│ ❌ INSERT 후 UPDATE SQL 추가 실행 필요                  │
│ ❌ 성능 저하 및 관리 복잡성 증가                         │
│                                                        │
│ 💡 해결책: 다대일 양방향 사용 권장!                      │
└────────────────────────────────────────────────────────┘
// 일대다 단방향 저장 시 실행되는 SQL
INSERT INTO MEMBER (MEMBER_ID, USERNAME) VALUES (1, '홍길동');
UPDATE MEMBER SET TEAM_ID = 1 WHERE MEMBER_ID = 1;  // 추가 UPDATE!

2.2 일대다 양방향?

공식적으로 존재하지 않음. 다대일 양방향 사용 권장

@OneToMany는 연관관계 주인이 될 수 없음 (DB 특성상 N쪽에만 FK 존재)


3. 일대일 [1:1]

양쪽 모두 하나의 관계만 가짐. 외래 키 위치 선택 가능

┌──────────────────────────────────────────────────────────┐
│                 일대일 관계 외래 키 위치                   │
│                                                          │
│   ┌──────────────────┐      ┌──────────────────┐        │
│   │     주 테이블     │      │    대상 테이블    │        │
│   │     (MEMBER)     │      │    (LOCKER)      │        │
│   ├──────────────────┤      ├──────────────────┤        │
│   │ MEMBER_ID   PK   │      │ LOCKER_ID   PK   │        │
│   │ LOCKER_ID   FK   │─────>│ NAME             │        │
│   │ USERNAME         │      └──────────────────┘        │
│   └──────────────────┘                                  │
│                                                          │
│   ✅ 주 테이블에 FK: 객체지향적, 주 테이블만 조회해도 확인  │
│   ✅ 대상 테이블에 FK: 1:N으로 변경 시 테이블 구조 유지     │
└──────────────────────────────────────────────────────────┘

3.1 주 테이블에 외래 키 - 단방향

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
}

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

    private String name;
}

3.2 주 테이블에 외래 키 - 양방향

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;  // ⭐ 연관관계 주인
}

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

    private String name;

    @OneToOne(mappedBy = "locker")  // 주인 아님
    private Member member;
}

3.3 대상 테이블에 외래 키

┌────────────────────────────────────────────────────────┐
│            대상 테이블 외래 키 제약사항                   │
├────────────────────────────────────────────────────────┤
│ ❌ 단방향: JPA 지원 안 함                               │
│ ✅ 양방향: 지원됨 (대상 테이블 쪽을 주인으로)             │
│                                                        │
│ ⚠️ 프록시 한계: 외래 키를 직접 관리하지 않으면            │
│    지연 로딩 설정해도 즉시 로딩됨!                       │
└────────────────────────────────────────────────────────┘
// 대상 테이블에 외래 키 - 양방향
@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToOne(mappedBy = "member")  // 주인 아님
    private Locker locker;
}

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

    private String name;

    @OneToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;  // ⭐ 연관관계 주인
}

일대일 전략 비교

전략장점단점
주 테이블 FK주 테이블만 조회해도 연관관계 확인 가능값이 없으면 FK에 NULL
대상 테이블 FK1:N으로 변경 시 테이블 구조 유지프록시 지연 로딩 불가

4. 다대다 [N:N]

관계형 DB는 다대다 표현 불가. 연결 테이블 필요

┌──────────────────────────────────────────────────────────────┐
│                    다대다 관계 구조                           │
│                                                              │
│  객체                         테이블                          │
│  ┌────────┐    ┌────────┐    ┌────────┐ ┌──────┐ ┌────────┐ │
│  │ Member │───>│ Product│    │ MEMBER │─│ M_P  │─│PRODUCT │ │
│  └────────┘<───└────────┘    └────────┘ └──────┘ └────────┘ │
│    N    :    N                         연결 테이블            │
│                                                              │
│  객체: 컬렉션으로 다대다 표현 가능                             │
│  테이블: 연결 테이블로 1:N + N:1로 풀어야 함                   │
└──────────────────────────────────────────────────────────────┘

4.1 다대다 단방향

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long 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<>();
}

@Entity
public class Product {
    @Id @GeneratedValue
    @Column(name = "PRODUCT_ID")
    private Long id;

    private String name;
}

4.2 다대다 양방향

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long 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<>();

    // 연관관계 편의 메소드
    public void addProduct(Product product) {
        products.add(product);
        product.getMembers().add(this);
    }
}

@Entity
public class Product {
    @Id @GeneratedValue
    @Column(name = "PRODUCT_ID")
    private Long id;

    private String name;

    @ManyToMany(mappedBy = "products")  // 역방향
    private List<Member> members = new ArrayList<>();
}

4.3 다대다의 한계

┌────────────────────────────────────────────────────────┐
│               @ManyToMany 실무 사용 불가                 │
├────────────────────────────────────────────────────────┤
│ ❌ 연결 테이블에 추가 컬럼 불가 (수량, 날짜 등)           │
│ ❌ 연결 테이블이 숨겨져 예상치 못한 쿼리 발생             │
│ ❌ 중간 테이블에 비즈니스 로직 확장 불가                  │
│                                                        │
│ 💡 해결책: 연결 엔티티를 만들어 1:N + N:1로 풀기!        │
└────────────────────────────────────────────────────────┘

4.4 다대다 → 일대다 + 다대일 (연결 엔티티)

┌──────────────────────────────────────────────────────────────┐
│              연결 엔티티를 통한 다대다 해소                    │
│                                                              │
│   [Member] 1 ─────< [Order] >───── 1 [Product]              │
│                       │                                      │
│                       │ 주문수량, 주문일자 등                  │
│                       │ 추가 컬럼 가능!                       │
│                       ▼                                      │
│               ┌───────────────┐                              │
│               │    ORDER      │                              │
│               ├───────────────┤                              │
│               │ ORDER_ID   PK │ ← 대리 키 사용 권장!          │
│               │ MEMBER_ID  FK │                              │
│               │ PRODUCT_ID FK │                              │
│               │ ORDER_AMOUNT  │ ← 추가 컬럼                   │
│               │ ORDER_DATE    │                              │
│               └───────────────┘                              │
└──────────────────────────────────────────────────────────────┘

방법 1: 복합 기본 키 사용 (식별 관계)

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

    private String username;

    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

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

    private String name;
}

// 연결 엔티티 - 복합 키
@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;
    private LocalDateTime orderDate;
}

// 복합 키 식별자 클래스
public class MemberProductId implements Serializable {
    private String member;   // MemberProduct.member와 매핑
    private String product;  // MemberProduct.product와 매핑

    // 기본 생성자 필수
    public MemberProductId() {}

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof MemberProductId)) 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);
    }
}

복합 키 식별자 클래스 요구사항

┌────────────────────────────────────────────────────────┐
│          @IdClass 복합 키 요구사항                       │
├────────────────────────────────────────────────────────┤
│ ✅ Serializable 구현 필수                               │
│ ✅ equals(), hashCode() 오버라이드 필수                  │
│ ✅ 기본 생성자 필수                                     │
│ ✅ public 클래스                                       │
│ ✅ 필드명이 엔티티 필드명과 일치해야 함                   │
└────────────────────────────────────────────────────────┘

방법 2: 새로운 기본 키 사용 (비식별 관계) - 권장

@Entity
@Table(name = "ORDERS")
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;
    private LocalDateTime orderDate;
}

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

@Entity
public class Product {
    @Id @GeneratedValue
    @Column(name = "PRODUCT_ID")
    private Long id;

    private String name;
}

사용 예시

// 저장
public void save(EntityManager em) {
    Member member = new Member();
    member.setUsername("홍길동");
    em.persist(member);

    Product product = new Product();
    product.setName("노트북");
    em.persist(product);

    Order order = new Order();
    order.setMember(member);
    order.setProduct(product);
    order.setOrderAmount(2);
    order.setOrderDate(LocalDateTime.now());
    em.persist(order);
}

// 조회
public void find(EntityManager em, Long orderId) {
    Order order = em.find(Order.class, orderId);

    Member member = order.getMember();
    Product product = order.getProduct();

    System.out.println("회원: " + member.getUsername());
    System.out.println("상품: " + product.getName());
    System.out.println("수량: " + order.getOrderAmount());
}

식별 vs 비식별 관계

구분식별 관계비식별 관계
정의부모 PK를 자식 PK + FK로 사용부모 PK를 자식 FK로만 사용
기본 키복합 키 (부모 PK 포함)대리 키 (별도 생성)
장점부모-자식 관계 명확단순, 유연, 확장 용이
단점복잡, 식별자 클래스 필요조인 시 한 번 더 참조
권장⭐ 비식별 관계 + 대리 키 권장!

5. @EmbeddedId 방식 (대안)

@IdClass 대신 @EmbeddedId 사용 가능

// 복합 키를 @Embeddable로 정의
@Embeddable
public class MemberProductId implements Serializable {

    @Column(name = "MEMBER_ID")
    private String memberId;

    @Column(name = "PRODUCT_ID")
    private String productId;

    // 기본 생성자, equals, hashCode
}

@Entity
public class MemberProduct {

    @EmbeddedId
    private MemberProductId id;

    @ManyToOne
    @MapsId("memberId")  // EmbeddedId의 memberId와 매핑
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @ManyToOne
    @MapsId("productId")  // EmbeddedId의 productId와 매핑
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private int orderAmount;
}

@IdClass vs @EmbeddedId

비교@IdClass@EmbeddedId
식별자 접근엔티티 필드로 직접 접근getId().getMemberId()
JPQLSELECT m.member FROMSELECT m.id.memberId FROM
복잡도상대적 단순객체지향적

요약

연관관계 선택 가이드

┌──────────────────────────────────────────────────────────┐
│                연관관계 매핑 의사결정 트리                  │
│                                                          │
│  1. 다중성 결정                                           │
│     ├─ 1:N or N:1 → 다대일 양방향 (가장 일반적)            │
│     ├─ 1:1 → 주 테이블에 FK + 양방향                      │
│     └─ N:N → 연결 엔티티로 1:N + N:1 분해                  │
│                                                          │
│  2. 방향 결정                                             │
│     ├─ 단방향으로 시작                                    │
│     └─ 필요 시 양방향 추가 (JPQL, 객체 그래프)              │
│                                                          │
│  3. 연관관계 주인                                         │
│     └─ 외래 키가 있는 쪽 = 주인 (무조건!)                  │
└──────────────────────────────────────────────────────────┘

실무 권장사항

관계권장 방식비권장
N:1다대일 양방향-
1:N다대일 양방향으로 대체일대다 단방향 ❌
1:1주 테이블 FK + 양방향대상 테이블 FK 단방향
N:N연결 엔티티 + 비식별 관계@ManyToMany ❌

핵심 체크리스트

✅ 연관관계 주인은 외래 키가 있는 쪽
✅ 양방향 시 연관관계 편의 메소드 작성
✅ 일대다 단방향 대신 다대일 양방향 사용
✅ @ManyToMany 대신 연결 엔티티 사용
✅ 복합 키보다 대리 키(Long) + 비식별 관계 선호
✅ mappedBy는 주인이 아닌 쪽에만 설정

주의사항

⚠️ 절대 금지
├─ 일대다 단방향 사용 (UPDATE 쿼리 추가 발생)
├─ @ManyToMany 실무 사용 (추가 컬럼 불가)
├─ 양방향 편의 메소드에서 무한루프
└─ 연관관계 주인이 아닌 쪽에서 FK 수정 시도

💡 권장사항
├─ 단방향으로 설계 시작, 필요 시 양방향 추가
├─ 연결 엔티티는 비식별 관계 + 대리 키 조합
├─ toString(), JSON 직렬화 시 양방향 순환 참조 주의
└─ cascade, orphanRemoval은 신중하게 사용