JPA(Java Persistence API)

[JPA] Part 7. 다대일, 일대다, 일대일, 다대다

cwchoiit 2023. 10. 19. 21:14
728x90
반응형
SMALL
728x90
반응형
SMALL

이 전 포스팅까지는 단방향, 양방향에 대해서 알아보았는데 결론은 테이블 관점은 방향이란 게 없고 자식 테이블에 외래키가 존재하며 그 외래키를 통해 부모 테이블과 조인하여 원하는 데이터를 얻어내거나 반대로 부모 테이블은 본인의 PK를 이용해서 자식 테이블의 외래키와 조인하여 원하는 데이터를 얻을 수 있고 객체 관점은 단방향 또는 양방향 연관관계라는 게 존재하며 양방향 연관관계는 사실 단방향 두 개를 의미하며 설계할 때 양방향이 단 하나도 없어도 애플리케이션에 전혀 문제가 없다는 것. 단방향으로 설계를 끝내고 필요하면 양방향 연관관계를 추가할 것이 결론이었다.

 

이제는 연관관계를 매핑할 때 ManyToOne, OneToMany, OneToOne, ManyToMany 관계에 대해서 알아보려고 한다. 

 

⭐️ ManyToOne

ManyToOne은 다대일로 다음과 같은 관계를 말한다.

유저 테이블과 게시물 테이블 두 개가 있을 때, 하나의 유저는 여러 개의 포스팅을 할 수 있다. 그럼 포스트 입장에서는 다대일이 되는 것이다. 그리고 외래키를 관리하는 쪽은 포스트 쪽이 된다. 이 테이블의 설계를 그대로 객체 관점에서도 적용할 수 있는 게 다대일이다.

 

다음 코드를 보자. 멤버와 주문의 관계는 다대일 연관관계로 적용할 수 있다. 이때 자식 테이블은 주문이 될 것이고 주문 테이블에서 부모 테이블의 기본키를 외래키로 참조한다. 그 설계를 그대로 객체에 적용한다. 

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

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

}
@Entity
public class Order {
    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

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

}

주문 테이블에 멤버 타입의 멤버를 참조하고 컬럼을 조인한다. 이 @JoinColumn 어노테이션은 외래키를 만들어 주는 것이라고 생각하면 된다. 그리고 하나 더 @ManyToOne 어노테이션이 들어있다. 즉, 주문 입장에서는 다대일 관계가 된다. 

그리고 위와 같은 설계를 '다대일 단방향 연관관계'라고 한다.

 

다대일 양방향 연관관계를 만드려면 ?! 다음과 같은 코드로 만들면 된다. 

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

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

}
@Entity
public class Order {
    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

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

}

복습하자면, 다대일 양방향 연관관계는 없어도 그만인데 조회의 편리함을 위해서 작성해도 괜찮다고 했다. 테이블 관점에서는 외래키는 주문 테이블만 가지고 있다. 양방향은 연관관계의 주인을 설정해야 하고 주인은 테이블 관점에서 외래키를 가지고 있는 쪽(자식테이블, 위 예제에서는 주문 테이블)이 연관관계의 주인이 되면 된다. 주인이 아닌 쪽은 mappedBy 옵션으로 '나는 누구에 의해 매핑된 데이터예요'라는 설명이 필요하다.

 

 

결론을 미리 여기서 말하면, 다대일 단방향 또는 다대일 양방향으로 설계하는 것이 가장 좋고 일대다, 다대다는 그냥 사용을 하면 안 된다고 생각해라.

 

 

OneToMany

일대다는 테이블 관점에서는 존재할 수 없는 연관관계다. 즉, 부모 테이블이 외래키를 가지고 있는 경우인데 테이블 관점에서는 존재할 수 없다. 그렇기 때문에 일대다 연관관계는 사용하지 않는 것이 좋다는 결론을 바로 위에 작성했다.

 

우선 그림을 살펴보면 다음과 같다.

객체 관점에서는 이런 경우가 흔하다고 볼 수 있다. 팀에서만 멤버에 관심이 있고 멤버 입장에서는 팀에 관심이 없을 수 있다. 이게 객체 관점에서는 전혀 문제 될 게 없는데 테이블 관점에서는 있을 수 없다. 1:N 관계에서 언제나 N 쪽에 외래키가 포함되기 때문이다. 아래 사진이 테이블 관점에서의 ERD이다.

 

그래서 객체 관점에서는 가능한 부분이기 때문에 다음과 같이 작성할 수 있다.

package org.example.entity.mapping;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Team {

    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}
package org.example.entity.mapping;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;
}

이렇게 작성하면 실제로 동작은 하는데 굉장히 부자연스럽다. 왜냐하면 테이블 관점에서는 팀에서 외래키가 존재하려야 할 수 없는데 지금 객체 관점에서는 존재하게 돼버리니 말이다. 각설하고 위처럼 일대다 단방향으로 설계를 했고 실제로 코드를 돌려보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.mapping.Member;
import org.example.entity.mapping.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

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

        tx.begin();

        try {
            Member member = new Member();
            member.setUsername("member1");
            em.persist(member);

            Team team = new Team();
            team.setName("team1");
            team.getMembers().add(member);
            
            em.persist(team);

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

이렇게 코드를 작성하고 돌려보면 문제 없이 테이블이 만들어지고 데이터가 들어갈 거다. 문제없이 잘 들어가는데 다음 SQL문을 보자.

이상하다. 왜 이상하냐면 멤버와 팀을 새로 만들고 persist() 호출을 했으니 INSERT문이 실행되는 건 맞는데 그다음이다. 난 분명 다음 코드처럼 팀의 멤버를 가져와 멤버를 추가했는데 SQL문에서 멤버의 UPDATE 쿼리가 실행된다. 

team.getMembers().add(member);

왜냐하면 테이블 관점에서는 절대 외래키를 부모가 관리하지 않기 때문에 결국 멤버를 추가해서 팀과 결합시켰으면 멤버가 속한 팀 FK를 업데이트해줘야 하는 것이다. 이게 이제 문제가 발생할 여지가 크다. 어떤 문제냐면 코드를 보기에는 팀을 가져와 작업을 했는데 쿼리문에서 멤버가 업데이트되니까 이해하기가 힘든 것이다. (설령 JPA를 아주 잘 다루더라도 헷갈릴 소지가 있다 코드가 커지고 시간이 지나면 지날수록)

 

그러니까 결론은 그냥 다대일 단방향, 양방향을 사용하자... 

일대다 양방향도 있는데 그냥 넘어가겠다.. 그걸 알 필요가 없다. 다시 한 번 말하지만 다대일 단방향, 양방향을 사용하자.

 

OneToOne

이 일대일 관계는 주 테이블이나 대상 테이블 중에 외래키를 선택해서 넣을 수 있는 관계이다.

다만, 외래키에 데이터베이스 유니크 제약조건을 추가해야 한다. (꼭 추가해야 하는 건 아니지만 추가를 하는 게 관리하기 훨씬 유리하다)

 

일대일은 어떤쪽으로 외래키를 넣어도 상관없다. 위 ERD처럼 Member와 Locker의 일대일 관계에서 멤버에서 외래키를 관리하게 설정하면 된다. 객체 관계에서도 마찬가지로 다음 그림처럼 만들면 된다.

 

나는 주 테이블을 Member 테이블로 선정하고 (주 테이블은 더 자주 사용되는 테이블이라고 생각하면 된다) 대상 테이블을 Locker 테이블이라고 했을 때 주 테이블에 외래키를 가지는 방식으로 설정해 일대일 단방향 관계를 객체로 구성하면 다음과 같다.

package org.example.entity.mapping;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Locker {
    
    @Id @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
}
package org.example.entity.mapping;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

 

일대일 양방향 관계는 다음처럼 현시점은 연관관계의 주인(외래키를 가지고 있는 쪽)이 Member다. 그러니까 Locker에 다음과 같이 코드를 추가한다.

package org.example.entity.mapping;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Locker {
    
    @Id @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member
}

이러면 일대일 양방향 관계는 끝이 난다.

 

 

이제 단방향으로 외래키가 대상 테이블에 존재하는 경우를 살펴보면 다음 그림처럼 생겼다.

이것 또한 가능한데, 한 가지 주의할 점은! 만약 객체 관점에서 주 테이블에 외래키를 가지고 있는데 테이블 관점에서 대상 테이블에 외래키가 존재하는 경우는 JPA는 지원하지 않는다. 즉, 객체 관점에서 Member 객체가 Locker를 참조하고 있는데 테이블 관점에서는 Locker가 MEMBER_ID를 가지고 있는 경우를 말한다. 이런 경우는 JPA에서 지원하지 않으니까 조심해야 한다.

 

양방향은 위 주 테이블이 Member일 때 했던 것처럼 반대로 해주면 된다.

 

일대일 단방향, 양방향은 그냥 주 테이블 또는 대상 테이블 둘 중 하나에 외래키가 들어가면 끝나는 건데 여기서 좀 짚고 넘어갈 부분이 있다. 

 

⭐️ 일대일 양방향 연관관계에서 외래키가 대상 테이블 일 때는 지연 로딩처리가 불가능하다.

 

이를 이해하기 위해 주 테이블에 외래키가 있을 때를 살펴보자.   

주 테이블에 외래키를 보관하는 설계를 했을 때(양방향, 단방향 상관없음) 멤버를 조회하면 LOCKER값은 NULL 또는 특정 값이 존재할 거다. 멤버 테이블에 LOKCER_ID가 있고 멤버가 LOCKER가 할당된 상태라면 NULL값이 아니겠고 할당되지 않은 상태라면 NULL일 테니까. 그럼 할당된 상태일 때 지연 로딩이면 그대로 Lazy Fetch를 이행하면 되고 NULL이면 값이 없으니까 그냥 NULL을 반환하면 된다. 

 

근데 대상 테이블에 외래키가 있는 일대일 양방향 연관관계는 그게 불가능하다. 무슨 소리냐면 만약 아래 그림처럼 대상 테이블에 외래키가 있을 때를 살펴보자.

 

만약 위처럼 대상 테이블에 외래키가 있을 때 멤버를 조회했다고 가정해 보자. 멤버만 조회했을 때 바로 LOCKER와 연결된 상태인지 알 수 있을까? 없다. 멤버 테이블을 봐라. 멤버 테이블엔 LOCKER 관련 외래키가 없다. 그래서 JPA는 결국 LOCKER 테이블을 조회해서 현재 조회한 멤버의 PK와 동일한 멤버 ID가 있는지 확인을 해야 한다. 즉, 어차피 LOCKER를 조회하기 때문에 지연 로딩을 해봤자 아무런 이점을 가질 수 없단 소리다. 그래서 지연 로딩이 의미가 없고 지연 로딩으로 적용을 해도 바로 데이터를 받아온다. 

 

그래서 만약 일대일 관계를 사용하는 경우엔 주 테이블에 외래키를 보관하는 게 데이터베이스 관점 말고 개발자 관점에서 좀 더 유리할 수 있다.

 

ManyToMany

이건 쓰면 안 된다. 그냥 이게 결론이다. 근데 왜 쓰면 안 되는지를 알아보자. 

우선 데이터베이스에서 다대다 관계는 없다. 즉, 다대다 관계를 두 개의 테이블이 하나의 새로운 테이블(그 두 테이블을 연결해 주는)과 1:N 관계로 만들어야 한다. 아래 그림처럼 말이다.

근데 이게 왜 안되냐면, JPA에서 중간 테이블에 필요한 데이터가 들어가질 않는다. 예를 들어 위 상황에서 주문 수량이나 주문 일자 같은 데이터가 필요할 수 있는 가능성이 농후한데 그 데이터를 연결 테이블이 가질 수 없다. 아무것도 넣지 못한다. 각 테이블의 외래키 말고는.

 

그래서 다대다를 해결하는 방법은 연결 테이블을 새로운 엔티티로 승격시켜 사용하는 것. 다음 그림처럼 말이다.

그래서 위처럼 연결 테이블을 엔티티로 승격시켜 다대다를 일대다, 다대일로 풀어라.

 

그럼 예시를 들어보자. 자주 사용되는 Follower, Following 같은 경우는 어떻게 해야 할까? 느낌은 ManyToMany인데 그렇게 사용하지 않기로 했다. 이 역시 마찬가지로 엔티티 하나를 승격시켜서 사용하면 된다. 다만, 여기서는 OneToMany, ManyToOne으로 풀 게 아니다. 왜냐하면 테이블이 두 개가 아니라 같은 멤버 테이블 하나일 테니까. 

그래서 OneToMany, OneToMany로 풀면 된다.

 

이런 형태로 만들 수 있겠다. 코드를 작성해 보자.

package org.example.entity.mapping;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String username;

    @OneToMany(mappedBy = "from")
    private List<Followers> following = new ArrayList<>();

    @OneToMany(mappedBy = "to")
    private List<Followers> followers = new ArrayList<>();
}
package org.example.entity.mapping;

import javax.persistence.*;

@Entity
public class Followers {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "from_user")
    private Member from;

    @ManyToOne
    @JoinColumn(name = "to_user")
    private Member to;
}

팔로워라는 다대다 테이블을 엔티티로 승격시킨 후, from, to 유저를 @ManyToOne으로 선언한다. 이제 이 외래키를 관리하는 쪽이 연관관계의 주인이 된다. 그럼 반대쪽 멤버 테이블에서는 @OneToMany로 following, followers를 읽기 전용(mappedBy)으로 선언하면 된다. 

728x90
반응형
LIST