JPA - 연관관계 매핑 (단방향, 양방향)

제훈·2024년 8월 22일

Java

목록 보기
34/34

연관관계 매핑

@ManyToOne, @OneToMany, @ManyToMany 3가지 어노테이션을 활용하면서 단방향 연관관계와 양방향 연관관계에 대해 알아볼 것이다.

기본 세팅

build.gradle

dependencies {
    testImplementation platform('org.junit:junit-bom:5.10.0')
    testImplementation 'org.junit.jupiter:junit-jupiter'

    implementation("org.hibernate.orm:hibernate-core:6.3.1.Final")
    implementation 'com.mysql:mysql-connector-j:8.0.33'
}

resources.META-INF.persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.2">

    <!-- 설명. 엔티티 매니저 팩토리를 식별하기 위한 이름 설정 -->
    <persistence-unit name="jpatest">

        <!-- 설명. 엔티티는 설정에 따로 추가할 것 -->
        <class>com.jehun.section01.manytoone.Category</class>
        <class>com.jehun.section01.manytoone.MenuAndCategory</class>
        <class>com.jehun.section02.onetomany.CategoryAndMenu</class>
        <class>com.jehun.section02.onetomany.Menu</class>
        <class>com.jehun.section03.bidirection.Menu</class>
        <class>com.jehun.section03.bidirection.Category</class>

        <properties>

            <!-- 설명. 데이터베이스 연결 정보 -->
            <property name="jakarta.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
            <property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/menudb"/>
            <property name="jakarta.persistence.jdbc.user" value="swcamp"/>
            <property name="jakarta.persistence.jdbc.password" value="swcamp"/>

            <!-- 설명. hibernate 설정(실행되는 sql 구문을 괜찮은 format 형태로 보여주기) -->
            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            <property name="hibernate.dialect" value="org.hibernate.dialect.MariaDBDialect"/>
<!--            <property name="hibernate.hbm2ddl.auto" value="create"/>-->
        </properties>
    </persistence-unit>
</persistence>

패키지 구조는 총 3가지

  • com.jehun.section01.manytoone
  • com.jehun.section02.onetomany
  • com.jehun.section03.bidirection

1번째껏부터 해볼 것이다.


우선 간단하게 알아보면

Menu와 Category라고 했을 때

4가지 경우가 있다.
1. onetoone
2. onetomany
3. manytoone
4. manytomany

  • OnetoOne
    모든 객체들에 대해 1개 Menu에는 1개 Category만 매핑할 때 -> OnetoOne

  • OneToMany (ManyToOne과 헷갈리지 말라는 의미에서 굵게 표시했다. 크게 중요 X)
    Menu N개가 1개의 Category에 속하는 경우에서 Category가 Menu를 바라볼 때

  • ManyToOne
    Menu N개가 1개의 Category에 속하는 경우에서 Menu가 Category를 바라볼 때

  • ManyToMany
    둘다 많은 것 (즉, 학생과 시험)
    ex) 학생의 종류와 시험의 종류가 여러 개씩 있을 때 '학생이 본 시험'은 ManyToMany를 풀기 위한 중간에 들어간다.

JPA에서는 이 상황에서 '학생이 본 시험'과 같은 엔티티를 만드는 개념 자체가 없다.

JPA에서는 중간 엔티티를 만들지 않아도 자동으로 만들어준다. 물리적으로 불가능하기 때문에 그렇다. -> 중간 엔티티의 존재를 파악 못 하게 되고 따로 Insert 하는 것이 힘들다..

그냥 안 쓰는게 제일 좋다.

ManyToOne부터 정리해보자.


ManyToOne

Menu와 Category는 Menu N개가 Category 1개에 해당이 된다.

즉 Menu -> Category 로 부터의 연관관계를 나타낸다고 생각하면 된다.

Menu는 Category가 없으면 안 되기 때문에 Category가 자식 테이블이다.

Category

@Entity(name="category_section01")
@Table(name="tbl_category")
public class Category {

    @Id
    @Column(name="category_code")
    private int categoryCode;

    @Column(name="category_name")
    private String categoryName;

    @Column(name="ref_category_code")
    private Integer refCategoryCode;
    
    // 기본 생성자, 매개변수가 있는 생성자, getter, toString()
}

Menu 지만 ManyToOne이라는 입장을 나타내기 위해서 나타낸 MenuAndCategory 이다.

무슨 뜻이지? 생각하지 않아도 된다.

MenuAndCategory

@Entity(name="menu_and_category")
@Table(name="tbl_menu")
public class MenuAndCategory {

    @Id
    @Column(name="menu_code")
    private int menuCode;

    @Column(name="menu_name")
    private String menuName;

    @Column(name="menu_price")
    private int menuPrice;

    /* JoinColumn에 쓰이는 컬럼명은 FK 제약조건이 걸린 자식 테이블의 컬럼명을 쓰게 된다. */
    @JoinColumn(name="category_code")
    @ManyToOne                          // 두 엔터디 간의 전체 카디널리티(N:1)를 고려해서 작성한다.
    private Category category;          // 하나의 메뉴는 하나의 카테고리를 가지고 있다

    @Column(name="orderable_status")
    private String orderableStatus;
    
    //기본 생성자, 매개변수가 있는 생성자, getter, toString()
}

이제 ManyToOne 연관관계 테스트를 해보자.

ManyToOneAssociationTests

public class ManyToOneAssociationTests {
    private static EntityManagerFactory emf;
    private static EntityManager em;

    @BeforeAll
    public static void initFactory() {
        emf = Persistence.createEntityManagerFactory("jpatest");
    }

    @BeforeEach
    public void initManager() {
        em = emf.createEntityManager();
    }
    
    @AfterEach
    public void closeManager() {
        em.close();
    }

    @AfterAll
    public static void closeFactory() {
        emf.close();
    }
}

위와 같이 기본 세팅을 해두고 다대일 연관관계 객체 그래프 탐색을 통한 조회를 해보자.

위의 테스트 코드에 추가

    @Test
    public void 다대일_연관관계_객체_그래프_탐색을_이용한_조회_테스트() {
        int menuCode = 15;

        MenuAndCategory foundMenu = em.find(MenuAndCategory.class, menuCode);
        Category menuCategory = foundMenu.getCategory();

        assertNotNull(menuCategory);
        System.out.println("foundMenu = " + foundMenu);
        System.out.println("menuCategory = " + menuCategory);
    }

출력 결과

@ManyToOne을 이용해 한 번에 조인이 돼서 쿼리가 나가는 것을 볼 수 있다.


OneToMany

N+1 문제

Menu와 Category는 Menu N개가 Category 1개에 해당이 된다.

즉, Category -> Menu 연관관계를 나타낸다고 생각하면 된다.

자연스럽게 Category를 Menu 역할로, MenuAndCategory를 CategoryAndMenu 역할로 바꿀 것이다.

Menu

@Entity(name="menu")
@Table(name="tbl_menu")
public class Menu {

    @Id
    @Column(name="menu_code")
    private int menuCode;

    @Column(name="menu_name")
    private String menuName;

    @Column(name="menu_price")
    private int menuPrice;

    @Column(name="category_code")
    private int categoryCode;

    @Column(name="orderable_status")
    private String orderableStatus;
    
    //기본 생성자, 매개변수가 있는 생성자, getter, toString()
}

Menu는 Category에 속하기 때문에 컬럼에도 적어줬다.

CategoryAndMenu

@Entity(name="category_section02")
@Table(name="tbl_category")
public class CategoryAndMenu {

    @Id
    @Column(name="category_code")
    private int categoryCode;

    @Column(name="category_name")
    private String categoryName;

    @Column(name="ref_category_code")
    private Integer refCategoryCode;

    @JoinColumn(name="category_code")
    @OneToMany
    private List<Menu> menuList;
        
    //기본 생성자, 매개변수가 있는 생성자, getter, toString()
}

잘 보면 category_code 의 컬럼명을 가진 Menu와 조인하면서 menuList에 담아준다.

즉, 쿼리로 카테고리를 조회하면?

카테고리 1번 + N개의 메뉴 -> 1 + N개의 쿼리가 나오게 된다..

이것을 N + 1 문제라고 한다.

테스트 코드에서 확인해보자.

OneToManyAssociationTests

기존 세팅에 추가

    @Test
    public void 일대다_연관관계_객체_그래프_탐색을_이용한_조회_테스트() {
        int categoryCode = 4;

        CategoryAndMenu categoryAndMenu = em.find(CategoryAndMenu.class, categoryCode);

        assertNotNull(categoryAndMenu);

        System.out.println("categoryAndMenu = " + categoryAndMenu);
        // 카테고리 1개를 뽑고 싶은데 N개가 딸려온다. (N+1 문제가 발생)
    }

카테고리를 조회하고, 해당 카테고리에 속하는 메뉴도 조회하는 문제가 생기게 되는 것이 @OneToMany 이다.


ManyToMany

아주 좋지 않다.. 양방향은 그냥 하지 않는 것을 추천한다.

일단은 1번째 테스트인 ManyToOne를 한다.

Menu

@Entity(name="menu_section03")
@Table(name="tbl_menu")
public class Menu {

    @Id
    @Column(name="menu_code")
    private int menuCode;

    @Column(name="menu_name")
    private String menuName;

    @Column(name="menu_price")
    private int menuPrice;

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

    @Column(name="orderable_status")
    private String orderableStatus;

    //기본 생성자, 매개변수가 있는 생성자, getter, toString()
}

이제 번거로움이 생기게 된다..

OneToMany도 Category에서 해주는 것이다.

Category

@Entity(name="category_section03")
@Table(name="tbl_category")
public class Category {

    @Id
    @Column(name="category_code")
    private int categoryCode;

    @Column(name="category_name")
    private String categoryName;

    @Column(name="ref_category_code")
    private Integer refCategoryCode;

    @OneToMany(mappedBy = "category") // 양방향을 하기 위해서
    private List<Menu> menuList;

이미 Menu에서 category_code와 조인을 했다는 것을 알고 보자. (Menu는 Category를 바라보는 중)

원래는 OneToMany에서 JoinColumn을 통해 조인을 했었는데 여기서는 OneToManymappedBy를 하여 매핑한다.

양방향 관계에서는 menu에 있는 필드인 category를, 즉. mappedBy에서 연관관계의 주인의 필드값을 적는 것이다.

이제 메뉴를 조회해도 카테고리를, 카테고리를 조회해도 메뉴를 가져온다.

그렇게 됐을 때, 오버라이딩한 toString()으로 그 문제점을 쉽게 알 수 있는데

BidirectionTests

	// 이전부터 하던 기존 세팅 코드에 추가
    
    @Test
    public void 양방향_연관관계_매핑_조회_테스트() {
        int menuCode = 10;
        int categoryCode = 10;

        /* 설명. 연관관계의 주인인 엔티티는 한 번에 join문으로 관계를 맺은 엔티티를 조회해 온다. */
        Menu foundMenu = em.find(Menu.class, menuCode);

        /* 설명. 양방향에서 부모에 해당하는 엔티티는 가짜 연관관계이고, 필요 시 연관관계 엔티티를 조회하는 쿼리를 다시 실행하게 된다. (N + 1 문제 야기) */
         System.out.println("foundMenu = " + foundMenu);
    }

Menu나 Category 중 아무거나 EntityManager에서 find를 해도 상관없다. (어차피 둘다 조회된다.) 그 때 toString()이 오버라이딩된 채로 출력까지 해버리면?

맨처음에 쿼리는

이렇게 잘 되는 것처럼 보이겠지만

이런 식으로 오버플로우 오류가 난다.

바로 메뉴를 불러서 카테고리를 부르는데 카테고리에서 또 메뉴를 부르고? 또 카테고리를 부르고? -> 순환 참조.. 무한으로 참조하기 때문에 생기는 오류인 것이다.


이렇게 간단한 예시만으로 단방향, 양방향 연관관계에 대해 알아보았다.

profile
백엔드 개발자 꿈나무

0개의 댓글