본문 바로가기
BackEnd/JPA

[JPA] 연관관계 매핑 기초

by 개발 Blog 2024. 7. 14.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

단방향 연관관계

객체와 테이블 연관관계의 차이를 이해해 보자.

 

객체를 테이블에 맞추어 모델링하는 예

(참조 대신에 외래 키를 그대로 사용한다.)

@Entity
public class Team {

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

}

- TEAM_ID가 PK다.

@Entity
public class Member {

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

    @Column(name = "USERNAME")
    private String username;

    @Column(name = "TEAM_ID")
    private Long teamId;

}

- Team의 PK를 참조한다. 

- Member 테이블에 TEAM_ID값을 그대로 생성한다. (문제점)

 

Member객체를 만들고 team을 저장해 보자

 try {

    Team team = new Team();
    team.setName("TeamA");
    em.persist(team);

    Member member = new Member();
    member.setUsername("member1");
    member.setTeamId(team.getId());
    em.persist(member);

    tx.commit();
}

- Team의 PK값인 TEAM_ID가 1이고 member는 FK로 TEAM_ID 1을 가진다.

- TEAM_ID로 조인을 할 수도 있다.

 

외래키 식별자를 직접 다루면 조회할 때도 문제점이 있다.

Member findMember = em.find(Member.class, member.getTeamId());
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);

- 멤버가 어느 팀 소속인지 알고 싶다고 가정했을 때, 우선 em.find로 멤버 클래스를 가져온다.

- 그럼 Team을 바로 못 가져오기 때문에 findTeamId를 먼저 가져온다.

- 그리고 findTeamId로 Team을 가져온다. 

 

이런 식으로 정보가 필요할 때마다 연관 관계가 없기 때문에 매번 멤버에서 조회를 먼저 해야 한다. 

 

객체를 테이블에 맞춰 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.

 

테이블은 외래 키로 조인을 해서 연관된 테이블을 찾고, 객체는 참조를 사용해서 연관된 객체를 찾는다. 이것이 가장 큰 간격이다.

 

단방향 연관관계

//    @Column(name = "TEAM_ID")
//    private Long teamId;

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

- 이렇게 맵핑하면 Team의 Id값이 아닌 참조값을 가져온다.  

- 하나의 팀에 여러 멤버가 소속되니까 맴버가 N 팀이 1이다. 따라서 @ManyToOne를 적어준다.

- 그리고 조인되는 컬럼을 @JoinColumn으로 명시해 준다.

 

그러면 아래와 같은 관계가 된다.

객체 지향 모델링(ORM 매핑)

 

이 상태에서 테스트를 해보자.

//팀
Team team = new Team();
team.setName("TeamA");
em.persist(team);

//맴버
Member member = new Member();
member.setUsername("member1");
member.setTeam(team);

em.persist(member);

Member findMember = em.find(Member.class, member.getId());

Team findTeam = findMember.getTeam();
System.out.println("findTeam = " + findTeam.getName());

- member.setTeam(team)으로 바로 넣으면 JPA에서 알아서 PK를 꺼내고 값을 인서트 할 때 FK을 사용한다.

- 조회할 때도 findMember.getTeamId() 대신에 바로 findMember.getTeam()으로 조회하면 된다.

여기서 select 쿼리가 없는 이유는 영속성 컨텍스트에 1차 캐시에서 값을 가져오기 때문이다.

 

DB에 select 하는 쿼리를 보고 싶을 때는 em.flush(); em.clear(); 를 적어주면 된다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setTeam(team);

em.persist(member);

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

Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();
System.out.println("findTeam = " + findTeam.getName());

tx.commit();

- flush()로 영속성 컨텍스트에 있는 값을 DB로 보내서 싱크를 맞춘 후

- clear()로 영속성 컨텍스트를 초기화시킨다.

 

위 테스트에서 알 수 있듯이 JPA에서는 객체의 참조와 테이블의 외래키를 맵핑해서 연관관계를 만들 수 있다.

 

양방향 연관관계와 연관관계의 주인1 - 기본

JPA의 포인터라고 불리는 양방향 연관관계를 알아보자.

 

 

테이블은 단방향 매핑에서 봤던 연관관계와 똑같다. 왜냐하면 DB에서는 FK만 있으면 조인해서 양쪽 모두 접근 가능하기 때문이다.

ex) select m.member_id, t.team_id

         from member m join team t

             on m.team_id = t.team_id;

 

하지만 객체는 Team에서 Member에 접근하려면 List를 넣어줘야 양방향 접근이 가능하다. 

@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<>();
	//생략
 }

- Team에 @OneToMany로 연관관계 매핑을 한다.

Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();

for (Member m : members) {
    System.out.println("m = " + m.getUsername());
}

여기서 이런 의문이 생길 수 있다. 

member에서는 @JoinColumn(name = "TEAM_ID")으로 컬럼만 적었는데

team에서는 @OneToMany(mappedBy = "team")으로 team 전체를 적네? 왜 다른 걸까?

 

mappedBy(객체와 테이블 간에 연관관계를 맺는 차이)를 이해해야 한다.

 

객체와 테이블이 관계를 맺는 차이

- 객체 연관관계 = 2개

  ㄴ 회원 -> 팀 연관관계 1개 (단방향)

  ㄴ 팀 -> 회원 연관관계 1개 (단방향)

- 테이블 연관관계 = 1개

  ㄴ 회원 <--> 팀의 연관관계 1개 (양방향)

 

객체의 양방향 관계

- 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개다.

- 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.

- A -> B (a.get(B))

- B -> A (b.get(A))

 

테이블의 양방향 관계

- 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.

- MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계를 가진다. (양쪽으로 조인할 수 있다.)

SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

SELECT *
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

 

둘 중 하나로 외래키를 관리해야 한다.

- MEMBER 테이블의 TEAM_ID(FK) 값은 Member 객체도 참조하고, Team 객체도 참조한다.

- 이때, FK를 Member의 team값을 변경했을 때 update 해야 할지 Team의 members값을 변경했을 때 update 해야 할지 딜레마가 생긴다.

- 테이블 입장에서는 TEAM_ID 하나만 관리하지만 객체는 그렇지 않다. 주인을 정해야 한다.

  

연관관계의 주인(Owner)

<양방향 매핑 규칙>

- 객체의 두 관계 중 하나를 연관관계 주인으로 지정한다.

- 연관관계의 주인만 외래 키를 관리한다.(등록, 수정)

- 주인이 아닌 쪽은 읽기만 가능하다.

- 주인은 mappedBy 속상 사용할 수 없다.

- 주인이 아니면 mappedBy 속성으로 주인을 지정한다.

 

누구를 주인으로?

- 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안 된다.

- 외래키가 있는 곳을 주인으로 정한다.

- 여기서는 Member.team이 연관관계 주인이다.

 

양방향 연관관계와 연관관계의 주인2 - 주의점, 정리

양방향 매핑 시 가장 많이 하는 실수

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

Team team = new Team();
team.setName("TeamA");

team.getMembers().add(member); //역방향(주인이 아닌 방향)만 연관관계 설정
em.persist(team);

- INSERT 쿼리가 2번 나갔다.

- DB에서 MEMBER의 TEAM_ID가 NULL이다. 

- Team은 주인이 아니기 때문에 읽기만 가능한데 team 객체를 이용해서 값을 변경하니까 MEMBER 테이블에 변화가 없는 것이다.

- 주인인 Member에서 데이터를 변경해야 한다.

Team team = new Team();
team.setName("TeamA");
//team.getMembers().add(member); //역방향(주인이 아닌 방향)만 연관관계 설정
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);

- 이제 MEMBER의 TEAM_ID가 1로 세팅된 것을 확인할 수 있다.

 

그럼 Team은 주인이 아니기 때문에 위의 주석된 코드를 적지 않아도 될까? 

 

그건 아니다. 양방향 연관관계일 때 순수한 객체 관계를 고려하면 항상 양쪽 다 값을 입력해야 한다. 

객체지향적으로 코드를 짜야한다.

 

만약 생략하면 2가지 문제점이 있다.

1. flush(), clear()를 생략한 경우

//저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);

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

Team findTeam = em.find(Team.class, team.getId()); // 1차 캐시에 존재
List<Member> members = findTeam.getMembers();

System.out.println("=============");
for (Member m : members) {
	System.out.println("m = " + m.getUsername());
}
System.out.println("=============");

tx.commit();

- 위 코드에서 findTeam은 1차 캐시에서 값을 찾아온다.

- 그런데 아직 team은 name("TeamA")에 대한 정보 말고는 업데이트된 정보가 없다.

- 그래서 team.getId를 해도 아무 값도 안 나오는 것이다.

 

따라서 양쪽 다 업데이트해주어야 한다.

 

2. 테스트 케이스를 작성하는 경우

- 테스트 케이스는 JPA 없이도 동작하도록 순수한 자바 코드로 작성하는데 양방향으로 업데이트하지 않으면 NULL값이 나올 수 있다.

 

양방향 연관관계 주의 - 실습

1. 연관관계 편의 메서드를 생성한다.

public class JpaMain {

    public static void main(String[] args) {
        // 생략
        
        try {
            //저장
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setUsername("member1");
            member.changeTeam(team); // 여기서 member의 team에 데이터를 저장할 때 양방향 세팅
            em.persist(member);
@Entity
public class Member {
	//생략
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this); //Team의 List<Member>에 데이터 저장
    }
}

- this는 Member 자기 자신

@Entity
public class Team {
    // 생략
    
    public List<Member> getMembers() {
        return members;
    }
}

- 양방향 연관관계를 모두 적어주는 것은 귀찮기도 하고 실수할 가능성이 있다.

- 그래서 team.getMembers(). add(this)로 team의 값이 들어올 때 양방향으로 값을 설정할 수 있다.

- 관례적으로 쓰이는 Getter, Setter 말고 로직이 들어가는 경우에는 메서드에 이름을 정해주는 것이 좋다.

 

연관관계 편의 메서드는 Member나 Team 중 어디에 만들어도 상관없다.

 

2. 양방향 매핑 시에 무한 루프를 조심하자.

@Entity
public class Member {

    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", team=" + team +
                '}';
    }
}

================================================


@Entity
public class Team {

    @Override
    public String toString() {
        return "Team{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", members=" + members +
                '}';
    }
}

- 위 코드에서 System.out.println("members = " + findTeam); 이렇게 작성을 하면 findTeam 객체를 문자열로 변환한다.

- 그때 해당 객체의 클래스인 Team 클래스의 toString 메서드는 members 리스트를 출력하는데 members 리스트에는 Member 객체가 포함되어 있다.

- Member 클래스의 toString 메서드는 team 필드를 포함하여 출력한다. team필드는 다시 Team 객체를 가리키고 있고 다시 Team의 toString() 메서드를 호출한다. 

- 이런 식으로 메서드의 무한 재귀호출 때문에 스택오버플로우가 발생한다.

- lombok을 사용할 때도 toString()은 웬만하면 쓰지 말자.

- JSON 생성 라이브러리도 컨트롤러에서 엔티티를 바로 반영하면 제이슨으로 바꿀 때 무한루프에 빠진다.

  ㄴ DTO로 변환해서 반환하는 것을 추천한다.

 

정리

- 단방향 매핑만으로도 이미 연관관계 매핑은 완료된다.

- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가되는 것뿐이다.

- 실무에서는 JPQL에서 역방향으로 탐색할 일이 많다.

- 단방향 매핑을 잘하고 양방향은 필요할 때 추가해도 된다. (테이블에 영향을 주지 않는다.) 

 

단방향 매핑으로 설계를 끝내려고 해 보자.

 

실전 예제 2 - 연관관계 매핑 시작

- 테이블 구조는 이전과 같다. 

 

- 참조를 사용하도록 변경한다.

- Member와 Order에서 연관관계 주인은 외래키가 있는 Order가 된다.

@Entity
@Table(name = "ORDERS")
public class Order {
	//생략

//    @Column(name = "MEMBER_ID")
//    private Long memberId;

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

- 기존 코드는 주석처리하고 연관관계 설정을 한다.

 

@Entity
public class OrderItem {

//생략

//    @Column(name = "ORDER_ID")
//    private Long orderId;
//    @Column(name = "ITEM_ID")
//    private Long itemId;

    @ManyToOne
    @JoinColumn("ORDER_ID")
    private Order order;

    @ManyToOne
    @JoinColumn("ITEM_ID")
    private Item item;
    
}

- ORDER_ITEM 입장에서는 ORDERS와 ITEM의 외래키를 모두 가지기 때문에 둘 다 설정해 준다.

- 이렇게 외래키 값을 그대로 가지는 것이 아니라 객체를 가지도록 설계한다.

- 나중에 필요하면 OrderItem 객체를 조회해서 getOrder, getId로 값을 가져올 수 있다.

 

@Entity
public class Member {
	//생략

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

- Member에서 양방향 매핑을 한다.

 

@Entity
@Table(name = "ORDERS")
public class Order {
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();
    
}

- OrderItem과 양방향 매핑을 한다.

 

주문이 들어왔을 때를 가정해 보자.

(Order와 OrderItem은 현재 양방향 연관관계이기 때문에 연관관계 편의 메서드를 만든다.)

@Entity
@Table(name = "ORDERS")
public class Order {
    //생략
    
    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }
}

- Order에 연관관계편의 메서드를 만든다.

public class JpaMain {
    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {

            Order order = new Order();
            order.addOrderItem(new OrderItem());
            
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }
}

- Main 클래스에서 연관관계편의 메서드를 호출한다.

 

그런데 Order에서 orderItems List가 꼭 필요한 것만은 아니다.

public class JpaMain {
    public static void main(String[] args) {
        
        try {

            Order order = new Order();
            em.persist(order);

            OrderItem orderItem = new OrderItem();
            orderItem.setOrder(order);

            em.persist(orderItem);
            }
     }
}

- 이렇게 양방향 연관관계가 아니어도 애플리케이션 개발하는데 아무 문제가 없다.

 

단방향 연관관계만으로도 대부분의 개발을 수행할 수 있다. 

그러나 양방향 연관관계를 사용하는 주된 이유는 개발의 편의성과 JPQL 작성 시 복잡성을 줄이기 위해서이다. 

 

보통은 단방향 연관관계를 주로 사용하고 개발의 편리성을 위해 양방향 연관관계를 사용할 때도 있다고 알아두자.

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

[JPA] 고급 매핑  (0) 2024.07.15
[JPA] 다양한 연관관계 매핑  (0) 2024.07.14
[JPA] 엔티티 매핑  (0) 2024.07.12
[JPA] 영속성 관리 - 내부 동작 방식  (1) 2024.07.12
[JPA] JPA 시작하기  (0) 2024.07.11