JPA(Java Persistence API)

[JPA] Part 6. 객체 지향형 모델링, 단방향 양방향 연관관계 주인

cwchoiit 2023. 10. 18. 10:25
728x90
반응형
SMALL
728x90
반응형
SMALL

데이터베이스와 자바의 객체를 매핑하는 것을 이전 시간에 공부했는데 데이터베이스와 자바의 객체는 한 가지 이질적인 부분이 있다.

데이터베이스는 한 테이블에서 다른 테이블을 참조할 때 외래키를 이용하여 어떤 레코드를 참조하는지만 표현하는 반면, 객체 지향적이란 건 객체가 다른 객체를 참조할 수 있어야 객체 지향적이라고 할 수 있다.

 

객체 지향적인 설계가 복잡하고 어려운 내용인것 같다. 공부하고 공부해도 모자란 부분이 이 부분인 것 같지만 한 가지 확실한 건 다음과 같은 설계가 객체 지향적이라고 볼 순 없을 것 같다.

 

Member Entity

package org.example.domain;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;
    private String city;
    private String street;
    private String zipcode;

}

Order Entity

package org.example.domain;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    @Column(name = "MEMBER_ID")
    private Long memberId;
    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

}

이렇게 두 개의 엔티티가 있고 쇼핑몰에 대한 데이터베이스를 설계할 때 멤버(유저)는 주문을 할 수 있을 것이고 주문 정보에는 어떤 유저가 주문을 했는지에 대한 정보가 있을 것이다. 그래서 위와 같이 Order Entity에 유저의 PK인 유저 ID를 필드로 설정했다고 하면 이는 데이터 중심 설계를 했다고 봐야 한다. 데이터베이스가 추구하는 다른 테이블을 참조하는 방식인 외래키를 사용하는 방법.

 

그러나, 이 코드를 객체 관점으로 보게되면 주문 객체에서 유저 객체를 참조하지 않고 유저 객체를 참조할 수 있는 ID만을 가지고 있는 상태이기 때문에 객체 지향적 설계와 거리가 있다고 보는 게 더 합리적이다.

 

실제로 이런 설계를 가진 애플리케이션이라면 애플리케이션에서 주문 정보로부터 유저 정보를 알아내려면 주문 정보를 가져온 후 주문 정보에 담겨있는 유저의 PK를 찾아내서 다시 한 번 유저를 가져와야 한다. 다음 코드를 보자.

package org.example;

import org.example.domain.Member;
import org.example.domain.Order;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

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 {
            Order findOrder = em.find(Order.class, 1L);
            
            Long findOrderMemberId = findOrder.getMemberId();
            
            Member findMember = em.find(Member.class, findOrderMemberId);
            System.out.println("findMember = " + findMember.getName());

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

위 코드에서 엔티티 매니저로부터 주문 정보를 가져온 후 주문 정보로부터 다음과 같은 코드는 사용할 수가 없다.

Member orderMember = findOrder.getMember();

왜냐하면 주문 정보는 유저를 참조하지 않기 때문이다. 이는 객체 지향적인 설계가 아닌 데이터 지향적 설계가 됐기 때문이다.

 

객체 지향적 설계를 하기 위해서는 이를 변경해야 한다. 그리고 그 시점에서 우린 단방향 연관관계를 배울 수 있다.

 

단방향 연관관계

단방향 연관관계란, 한쪽에서만 다른 쪽을 참조한 상태를 말한다. 예제에서는 주문 정보에서만 유저를 참조하면 된다고 가정해보고 단방향 연관관계를 설정해 보면 다음과 같이 주문 엔티티를 변경할 수 있다.

변경된 Order Entity

package org.example.domain;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    
    /*@Column(name = "MEMBER_ID")
    private Long memberId;*/
    
    // 변경한 부분
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    private LocalDateTime orderDate;

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

멤버와 주문 정보의 관계는 '일대다'이다. 즉, 멤버 한명이 많은 주문을 할 수 있다는 얘기다. 그러면 주문 엔티티에서는 멤버를 설정할 때 @ManyToOne 어노테이션을 붙여준다. 그리고 주문 정보에서 칼럼을 조인하는데 그 칼럼은 데이터베이스에서 저장하는 방식처럼 멤버의 PK를 지정해 주면 된다. 그때 사용하는 어노테이션이 @JoinColumn이다.

 

이렇게 변경한다면 주문 정보를 찾아내면 그 주문을 한 유저도 객체 입장에선 바로 참조가 가능하다.

package org.example;

import org.example.domain.Member;
import org.example.domain.Order;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

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 {
            Order findOrder = em.find(Order.class, 1L);
            
            // 개선된 코드
            Member findMember = findOrder.getMember();
            System.out.println("findMember = " + findMember.getName());

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

저 위에서 주문을 찾고 주문에 담긴 유저의 아이디를 가져와 다시 한 번 유저를 찾는 번거로움 없이 주문을 찾아내면 그 주문을 행한 유저도 바로 가져올 수 있게 코드가 개선되었다. 이렇게 설계가 되어야 객체 지향적 설계에 더 가깝다고 볼 수 있을 것 같다.

 

 

 

⭐️ 양방향 연관관계와 연관관계 주인

이 부분이 너무너무 중요하다. 객체 지향적으로도 중요하고 애플리케이션 관점에서도, 데이터베이스 관리 차원에서도 너무 중요하다.

우선, 위에서 Member, Order 엔티티를 살펴보면 Order에서만 Member를 참조할 수 있고 Member에서는 Order를 참조할 수 없게 되어 있는 '단방향 연관관계'이다. 

 

Member와 Order 대신 좀 더 직관적으로 이해하기 좋게 Member와 Team으로 변경해 보자. 

위 그림은 단방향 연관관계를 표현한 그림이다. Team과 Member가 객체로 보이는 부분이 위에 부분인데 Team은 Member에 대한 참조가 없고 Member는 Team에 대한 참조가 있다. Member와 Order를 생각해 보면 동일하다.

 

테이블 관점으로 보면 테이블은 사실 방향이란 게 없는 것이다. 무슨 소리냐면 이 상태에서도 멤버는 TEAM_ID라는 외래키를 통해 본인의 팀을 조인으로 알아낼 수 있고 TEAM 입장에서도 본인의 PK와 MEMBER의 외래키 TEAM_ID를 조인해서 MEMBER를 가져올 수 있다. 즉, 한쪽만 참조 또는 양쪽 둘 다 참조라는 개념이 사실 없는 거다.

 

그러나 객체는 Member라는 객체가 Team을 참조하지만 Team에서는 그 어떤 방법으로도 자기한테 속한 Member를 가져올 방법이 없다. 이런 경우를 단방향 연관관계라고 했고 이제 양방향은 두 객체 모두 서로를 참조하는 경우를 말한다.

 

양방향 연관관계는 이런 상태를 의미한다.

테이블은 그 전과 전혀 다른 게 없다. 정말 동일한 상태이고 객체 간 관계가 이렇게 변한다. 멤버에서도 팀을 참조하고 팀에서도 본인한테 속한 멤버들을 참조한다. 

 

그럼 기존 멤버 엔티티는 이렇게 생겼었다.

package org.example.entity;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" })
})
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR",
        sequenceName = "MEMBER_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Member {
    @Id
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "MEMBER_SEQ_GENERATOR")
    private Long id;

    @Column(nullable = false)
    private String name;

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

멤버 입장에서는 팀과 다대일 관계다. 즉, 하나의 팀에 여러 멤버가 속할 수 있다는 뜻이고 그것을 객체로 표현하는 방법이 위와 같다.

@ManyToOne, @JoinColumn(name = "TEAM_ID") 어노테이션으로 Team을 참조한다.

 

그럼, Team 엔티티에서는 어떻게 멤버들을 참조할 수 있을까?

package org.example.entity;

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

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

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

Team에 속한 멤버들을 저장할 ArrayList를 필드로 선언하고 그 필드에 @OneToMany 어노테이션을 추가했다. 팀과 멤버는 팀 입장에서 일대다 관계이기 때문에 OneToMany는 이해하기 어려운 부분은 아니다. 그러나 여기 mappedBy가 문제다.
우선 mappedBy = "team"이라는 건 Member 클래스에 선언한 team이라는 필드와 매핑한다는 뜻이다. 근데 왜 여기에만 mappedBy 옵션이 있는지 이해하기 힘들다. 이때 '연관관계의 주인' 개념을 알아야 한다.

 

우선 주인은 누구인지를 따져보기 전에 양방향 연관관계에서 '객체 연관관계'와 '테이블 연관관계'를 좀 더 깊게 이해해 보자.

양방향 연관관계에서 객체 연관관계는 2개다.

  • 멤버 > 팀 연관관계 1개 (단방향)
  • 멤버 < 팀 연관관계 1개 (단방향)

즉, 양방향 연관관계에서 객체 연관관계는 그저 단방향 두 개가 존재하는 것이다.

 

그러나 테이블 연관관계는 위에서도 얘기했지만 방향이란 게 사실 없다. 그냥 서로가 서로를 참조할 수 있다. 실제로 팀에서는 테이블에서 멤버 관련 정보는 하나도 없지만 PK와 멤버의 외래키를 가지고 조인해서 속한 멤버를 구할 수 있지 않나. 

  • 멤버 <> 팀 연관관계 1개 (양방향)

그래서 그림으로 살펴보면 다음과 같다. 

그럼 이런 상태에서 만약 멤버가 속한 팀을 변경하고 싶다면? 객체 관점에서는 어떤 객체를 변경해야 할까?

멤버의 팀을 변경해야 하나? 팀의 멤버를 변경해야하나?

뭔가 이상하다. 테이블의 관점에서는 부모 테이블은 아예 자식 테이블에 관련된 정보 자체가 없기 때문에 멤버가 속한 팀을 변경하려면 당연히 그냥 멤버의 특정 레코드의 팀 외래키를 변경하면 된다. 그럼 객체 관점에서는 어떤 걸 변경해야 할까? 테이블의 관점을 따라가면 된다. 그 그림이 가장 아름다운 그림이 될 확률이 높다.

 

즉, 객체 관점에서 두 객체가 양방향(단방향 두 개)으로 연관관계를 가질 때 연관관계의 주인을 지정하면 되는데 그 주인은 테이블의 관점에서 외래키가 있는 곳을 주인으로 정하면 된다. 그럴 때 이 예제에서 연관관계의 주인은 누가 되면 될까? 그렇다. 멤버가 되면 된다. 왜냐? 테이블의 관점에서 멤버가 외래키를 가지고 있기 때문.

 

그리고 테이블 관점에서 멤버가 속한 팀을 변경하려면 멤버의 외래키를 변경하면 된 것처럼 연관관계의 주인만이 값의 변경을 할 수 있다. 그리고 주인이 아닌 쪽은 읽기만 가능하다. 그래서 mappedBy"나는 지금 누구에 의해 매핑된 데이터예요"라는 의미이며 주인은 mappedBy 속성을 사용하지 않는다. 반대로 주인이 아니면 mappedBy 속성으로 주인을 지정해줘야 한다. 

 

이제 다시 엔티티 클래스를 보면 이해가 된다.

package org.example.entity;

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

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

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

테이블 관점에서 팀은 멤버 관련 필드는 아예 없다. 그 말은 외래키를 멤버가 가지고 있단 의미이고 외래키를 가지고 있는 쪽이 객체 관점에서는 연관 관계의 주인이 되면 되니까 주인이 아닌 이 Team 엔티티에서 mappedBy 속성으로 주인은 멤버 클래스의 'team' 필드라고 알려준 것.

 

그래서, 객체 관점에서 데이터를 수정 및 변경 작업을 할 때 연관관계의 주인 쪽에서만 변경이 가능하기 때문에 멤버가 속한 팀을 변경하려면 멤버 엔티티에서 team 필드값을 변경하면 되는 것이다. 아무리 팀 엔티티에서 멤버 리스트에 멤버를 추가해 봐야 DB에 추가되지 않는다. 

 

이 점을 이해해야만 왜 팀 엔티티의 멤버 리스트 필드에 멤버를 추가해도 DB에는 추가된 멤버가 반영되지 않는지를 알 수 있고 그렇게 알게 되면 멤버를 만들어서 멤버의 팀 필드에 팀을 추가해야만 DB에 추가된 멤버가 반영된다는 것을 알 수 있다.

 

그래서 아래 코드는 잘못된 코드인데 어디가 잘못된 코드인지 한번 확인해 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.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 {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            em.persist(member);
            
            team.getMembers().add(member);

            em.flush();
            em.clear();

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

보면, Team 엔티티의 객체 team을 만들었고 team.getMembers().add(member); 라는 코드가 한 줄 보인다.

이 부분이 잘못된 코드이다. 왜냐하면 위에서 말한 이 team의 members라는 리스트는 mappedBy로 읽기 전용 필드이다. 즉, JPA가 데이터베이스에 반영할 때 전혀 관심사항이 없는 데이터라는 의미이다. 실제로 위 코드로 실행해보면 데이터베이스에 해당 멤버의 팀 값은 없다. 아래는 그 결과물이다. 

 

 

따라서, DB에 새로운 멤버를 팀에 추가하고 싶다면 다음과 같은 코드로 변경되어야 한다.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.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 {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            member.setTeam(team);
            em.persist(member);

            em.flush();
            em.clear();

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

연관관계의 주인인 member의 team에 적용할 팀을 세팅해줘야 한다. 그래야 JPA는 데이터베이스에 변경사항을 반영할 때 적용해 준다.

 

그럼 여기서 한 가지 의문이 생긴다. 

연관관계의 주인쪽에만 데이터를 넣으면 JPA가 데이터베이스에 쓰기 지연 SQL문을 날려서 실제 데이터베이스에 적용되기 전까진 team.getMembers()를 가져오면 데이터는 없는 것 아닌가?

맞다! 정확하다! 코드로 그것을 증명해보면,

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;
import org.example.entity.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 {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            member.setTeam(team);
            em.persist(member);

            Team findTeam = em.find(Team.class, team.getId());
            System.out.println("findTeam.getMembers(): START");
            for (Member teamMember : findTeam.getMembers()) {
                System.out.println("teamMember = " + teamMember.getName());
            }
            System.out.println("findTeam.getMembers(): END");

            em.flush();
            em.clear();

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

위 코드는 주인인 member의 team에만 team값을 세팅한 후, flush()가 발생하기 전 또는 commit()이 이루어지기 전에 엔티티매니저로부터 team을 가져와서 (이 때 가져오는 건 1차 캐시로부터 가져온다. 왜냐하면 persist()후 commit()이 일어나지 않았기 때문에) 팀이 가지고 있는 모든 멤버를 출력하는 코드이다.

 

결과는 다음과 같다. 아무것도 출력되지 않는다.

 

그러니까 결론은, 양방향 연관관계일 때는 객체의 관점에서 필요하므로 양쪽에 다 데이터를 수정해주는게 더 좋다. 어차피 주인이 아닌쪽에 데이터를 수정해봐야 데이터베이스에는 아무런 영향도 끼치지 않기 때문에 데이터베이스에 문제가 생길 걱정 자체를 할 필요가 없다. 아예 JPA는 그 데이터를 쳐다도 보지 않을테니까.

 

 

마무리

진짜 결론은 양방향 연관관계는 없어도 무방하다는 것. 가장 중요한 건 단방향 연관관계만으로 애플리케이션을 충분히 만들 수 있고 단방향 연관관계만 있을 때 가장 깔끔하다. 양방향은 사실 조회를 위해 편의상 만드는 거지 그 외 더 이상 기능이 없다.

그래서, 가장 중요한 건 처음엔 단방향 연관관계로만 잘 설계를 하고 필요하면 (조회를 편하게 하고싶다면) 그 때 양방향을 걸어도 전혀 문제가 없다.

 

728x90
반응형
LIST