[Basic-5] 연관관계 매핑 심화

2026. 1. 6. 12:11·Spring/JPA

1. 연관관계 매핑시 고려사항 3가지

1.1. 다중성 (Multiplicity)

객체 간의 관계를 수적으로 표현하는 개념이다. JPA는 4가지 다중성을 지원한다:

  1. 다대일 (N:1) - @ManyToOne
  2. 일대다 (1:N) - @OneToMany
  3. 일대일 (1:1) - @OneToOne
  4. 다대다 (N:M) - @ManyToMany (실무에서는 사용 제한적)

1.2. 단방향 vs 양방향

테이블의 관점

  • 외래 키 하나로 양쪽 테이블 조인 가능
  • 사실 방향이라는 개념이 존재하지 않음
  • JOIN 쿼리로 어느 방향으로든 조회 가능
-- 양방향으로 모두 조회 가능
SELECT * FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.ID;
SELECT * FROM TEAM T JOIN MEMBER M ON T.ID = M.TEAM_ID;

객체의 관점

  • 참조용 필드가 있는 쪽으로만 참조 가능
  • 한쪽만 참조하면 단방향
  • 양쪽이 서로 참조하면 양방향
  • 객체의 양방향 관계는 사실 두 개의 단방향 관계
// 단방향: Member -> Team
class Member {
    Team team;
}

// 양방향: Member <-> Team
class Member {
    Team team;
}

class Team {
    List<Member> members;
}

1.3. 연관관계의 주인 (Owner)

객체의 양방향 관계에서는 두 참조 중 하나를 연관관계의 주인으로 지정해야 한다:

  • 연관관계의 주인: 외래 키를 관리하는 참조 (등록, 수정, 삭제 가능)
  • 주인의 반대편: 읽기만 가능, 외래 키에 영향을 주지 않음
  • 규칙: 외래 키가 있는 쪽이 연관관계의 주인이 됨

2. 다대일 [N:1]

2.1. 다대일 단방향

가장 흔하고 자연스러운 연관관계로, 데이터베이스의 외래 키 관계와 일치한다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")  // 외래키 매핑
    private Team team;  // 다대일 관계
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;
    // Member에 대한 참조 없음 - 단방향
}

테이블 구조

CREATE TABLE MEMBER (
    ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255),
    TEAM_ID BIGINT,  -- 외래키
    FOREIGN KEY (TEAM_ID) REFERENCES TEAM(ID)
);

CREATE TABLE TEAM (
    ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255)
);

사용 예시

// 저장
Team team = new Team("개발팀");
em.persist(team);

Member member = new Member("김개발");
member.setTeam(team);  // 연관관계 설정
em.persist(member);

// 조회
Member findMember = em.find(Member.class, memberId);
Team findTeam = findMember.getTeam();  // 객체 그래프 탐색

2.2. 다대일 양방향

다대일 단방향에 반대 방향의 참조를 추가한 형태로, 실무에서 가장 많이 사용하는 패턴이다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")  // mappedBy로 주인 지정
    private List<Member> members = new ArrayList<>();
}

연관관계 편의 메서드

@Entity
public class Member {
    // ...
    public void setTeam(Team team) {
        // 기존 팀과 관계 제거
        if (this.team != null) {
            this.team.getMembers().remove(this);
        }

        // 새로운 관계 설정
        this.team = team;
        if (team != null) {
            team.getMembers().add(this);
        }
    }
}

@Entity
public class Team {
    // ...
    public void addMember(Member member) {
        members.add(member);
        member.setTeam(this);
    }
}

다대일 양방향 장점

  1. 객체 그래프 탐색이 양방향으로 가능
  2. JPQL 쿼리 작성이 편리해짐
  3. 객체의 협력 관계를 더 잘 표현 가능

3. 일대다 [1:N]

3.1. 일대다 단방향 

일(1)이 연관관계의 주인이 되는 비직관적인 구조로, 실무에서 권장하지 않는 패턴이다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;
    // Team에 대한 참조 없음
}

문제점

Team team = new Team("개발팀");
em.persist(team);

Member member1 = new Member("김개발");
Member member2 = new Member("이디자인");

// team에서 member 참조 설정
team.getMembers().add(member1);
team.getMembers().add(member2);

em.persist(member1);
em.persist(member2);

// 실행되는 SQL
// 1. MEMBER 테이블에 INSERT (TEAM_ID는 null)
// 2. TEAM_ID를 업데이트하는 UPDATE SQL 추가 실행

일대다 단방향의 단점

  1. 엔티티가 관리하는 외래키가 다른 테이블에 위치
  2. 연관관계 관리를 위해 추가 UPDATE SQL 실행
  3. 성능상의 이슈 발생 가능
  4. 예상치 못한 쿼리 발생 가능

3.2. 일대다 양방향

공식적으로 존재하지 않는 매핑 방식으로, 읽기 전용 필드를 사용해 흉내낸다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
    private Team team;  // 읽기 전용
}

@JoinColumn 속성

  • insertable = false: INSERT 시 이 필드 제외
  • updatable = false: UPDATE 시 이 필드 제외

권장사항: 일대다보다는 다대일 양방향 매핑을 사용할 것


4. 일대일 [1:1]

4.1. 일대일 관계의 특징

  • 일대일 관계는 그 반대도 일대일
  • 주 테이블이나 대상 테이블 중 외래 키 선택 가능
  • 외래 키에 UNIQUE 제약조건 추가 필요

4.2. 주 테이블에 외래 키

단방향

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;  // 일대일 관계
}

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;
    // Member에 대한 참조 없음
}

양방향

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToOne(mappedBy = "locker")
    private Member member;  // 읽기 전용
}

4.3. 대상 테이블에 외래 키

단방향

지원이 안된다. (JPA 표준에서 지원하지 않음)

양방향

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToOne(mappedBy = "member")
    private Locker locker;  // 읽기 전용
}

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

4.4. 일대일 관계 선택 기준

기준 주 테이블에 외래 키 대상 테이블에 외래 키
선호층 객체지향 개발자 데이터베이스 개발자
장점 주 테이블만 조회해도 대상 테이블 데이터 확인 가능 테이블 구조 변경 없이 일대다로 변경 가능
단점 외래키에 null 허용 필요 프록시 기능 한계로 항상 즉시 로딩됨
JPA 매핑 편리함 제약사항 있음

실무 권장사항

  • 주 테이블에 외래 키 방식을 기본으로 사용
  • null 허용이 문제되면 대상 테이블에 외래 키 방식 고려

5. 다대다 [N:M]

5.1. 다대다 관계의 문제점

관계형 데이터베이스에서는 두 테이블로 직접적인 다대다 관계를 표현할 수 없다. 연결 테이블을 통해 일대다, 다대일 관계로 풀어야 한다.

테이블 구조

5.2. @ManyToMany 사용 (비권장)

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @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
    private Long id;

    private String name;

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

@ManyToMany의 한계

  1. 연결 테이블에 추가 컬럼을 넣을 수 없음
  2. 실무에서는 연결 테이블에 주문수량, 주문일시 등 추가 데이터 필요
  3. 엔티티와 테이블이 불일치
  4. 예상치 못한 쿼리 발생 가능

5.3. 다대다 한계 극복: 연결 엔티티 사용 (권장)

연결 테이블을 엔티티로 승격시켜 일대다, 다대일 관계로 매핑한다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

@Entity
public class MemberProduct {
    @Id @GeneratedValue
    private Long id;

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

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

    private int count;       // 추가 필드
    private LocalDateTime orderDate;  // 추가 필드
}

복합키 대신 대리키 사용 (추천)

@Entity
public class Order {
    @Id @GeneratedValue
    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;

    // 복합 유니크 제약조건
    @Table(
        uniqueConstraints = {
            @UniqueConstraint(
                columnNames = {"MEMBER_ID", "PRODUCT_ID"}
            )
        }
    )
}

6. 실전 예제: 다양한 연관관계 매핑

6.1. 도메인 확장

기존 주문 시스템에 배송과 카테고리 기능을 추가한다:

요구사항

  1. 주문과 배송은 1:1 관계 (@OneToOne)
  2. 상품과 카테고리는 N:M 관계 (@ManyToMany → 연결 엔티티로 변경)

6.2. 엔티티 설계

// 주문 엔티티
@Entity
@Table(name = "ORDERS")
public class Order {
    @Id @GeneratedValue
    private Long id;

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

    @OneToOne
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;  // 1:1 관계

    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

// 배송 엔티티
@Entity
public class Delivery {
    @Id @GeneratedValue
    private Long id;

    @OneToOne(mappedBy = "delivery")
    private Order order;

    private String city;
    private String street;
    private String zipcode;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus status;
}

// 상품 엔티티
@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    // N:M 관계를 연결 엔티티로 구현
    @OneToMany(mappedBy = "product")
    private List<CategoryProduct> categoryProducts = new ArrayList<>();
}

// 카테고리 엔티티
@Entity
public class Category {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "category")
    private List<CategoryProduct> categoryProducts = new ArrayList<>();

    // 자기 참조 (계층 구조)
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();
}

// 연결 엔티티: 상품-카테고리
@Entity
public class CategoryProduct {
    @Id @GeneratedValue
    private Long id;

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

    @ManyToOne
    @JoinColumn(name = "CATEGORY_ID")
    private Category category;

    private LocalDateTime addedDate;
}

6.3. @ManyToOne 주요 속성

@ManyToOne(
    optional = false,          // 필수 관계 (기본값: true)
    fetch = FetchType.LAZY     // 지연 로딩 (권장)
    // cascade = CascadeType.ALL  // 영속성 전이 (필요시 사용)
)
@JoinColumn(
    name = "TEAM_ID",
    nullable = false,          // not null 제약조건
    foreignKey = @ForeignKey(name = "FK_MEMBER_TEAM")  // 외래키 이름
)
private Team team;

fetch 속성

  • FetchType.LAZY: 지연 로딩 (성능 최적화)
  • FetchType.EAGER: 즉시 로딩 (N+1 문제 주의)

cascade 속성

  • CascadeType.ALL: 모든 작업 전파
  • CascadeType.PERSIST: 저장만 전파
  • CascadeType.REMOVE: 삭제만 전파

6.4. @OneToMany 주요 속성

@OneToMany(
    mappedBy = "team",
    fetch = FetchType.LAZY,     // 컬렉션은 항상 LAZY 사용
    cascade = CascadeType.ALL,  // 필요에 따라 설정
    orphanRemoval = true        // 고아 객체 제거
)
private List<Member> members = new ArrayList<>();

orphanRemoval

  • true: 컬렉션에서 제거된 엔티티 자동 삭제
  • false: 컬렉션에서 제거만 하고 DB 삭제는 안함

7. 실무 적용 가이드라인

7.1. 연관관계 선택 기준

  1. 다대일 (N:1)
    • 기본으로 사용
    • 가장 자연스러운 관계
    • 성능상 이점 있음
  2. 일대다 (1:N)
    • 가능하면 사용하지 말 것
    • 다대일 양방향으로 대체
    • 불가피한 경우에만 사용
  3. 일대일 (1:1)
    • 주 테이블에 외래키 방식 선호
    • nullable 고려하여 선택
  4. 다대다 (N:M)
    • 실무에서 사용하지 말 것
    • 연결 엔티티로 풀어서 사용

7.2. fetch 전략

// 기본값 (권장)
@ManyToOne(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.LAZY)  // 컬렉션은 항상 LAZY

// 필요할 때만 EAGER 사용
@ManyToOne(fetch = FetchType.EAGER)  // N+1 문제 주의

7.3. cascade 설정

// 자주 함께 저장/삭제되는 관계에서 사용
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems;

// 단독으로 사용되는 경우 cascade 사용하지 않음
@ManyToOne  // cascade 설정 없음
private Member member;

7.4. 연관관계 편의 메서드

// 양방향 관계일 때 항상 제공
@Entity
public class Order {
    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void removeOrderItem(OrderItem orderItem) {
        orderItems.remove(orderItem);
        orderItem.setOrder(null);
    }
}

8. 정리

다양한 연관관계 매핑을 효과적으로 사용하기 위한 핵심 원칙:

  1. 단순함을 유지하라: 다대일 단방향으로 시작, 필요시 양방향으로 확장
  2. 외래키 위치를 따라라: 연관관계의 주인은 외래키가 있는 곳
  3. 지연 로딩을 기본으로: FetchType.LAZY를 기본값으로 사용
  4. 다대다를 피하라: 연결 엔티티를 사용하여 일대다, 다대일 관계로 풀어라
  5. 객체의 일관성을 유지하라: 편의 메서드로 양방향 관계 일관성 보장

연관관계 매핑은 JPA의 가장 강력한 기능이지만, 잘못 사용하면 복잡성과 성능 문제를 초래할 수 있다. 각 관계 유형의 특징과 제약사항을 이해하고, 프로젝트의 요구사항에 맞는 최적의 매핑 전략을 선택하는 것이 중요하다.

'Spring > JPA' 카테고리의 다른 글

[Basic-7] 프록시와 연관관계 관리  (0) 2026.01.06
[Basic-6] 고급 관계 매핑  (0) 2026.01.06
[Basic-4] 연관관계 매핑 기초  (0) 2026.01.06
[Basic-3] 엔터티 매핑 (Entity Mapping)  (0) 2026.01.06
[Basic-2] 영속성 컨텍스트 (Persistence Context)  (0) 2026.01.06
'Spring/JPA' 카테고리의 다른 글
  • [Basic-7] 프록시와 연관관계 관리
  • [Basic-6] 고급 관계 매핑
  • [Basic-4] 연관관계 매핑 기초
  • [Basic-3] 엔터티 매핑 (Entity Mapping)
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30) N
        • Core (18) N
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Basic-5] 연관관계 매핑 심화
상단으로

티스토리툴바