공부/JPA

연관관계 매핑 기초

Stair 2024. 12. 2. 12:56
반응형

https://surrealcode.tistory.com/103

 

엔티티 매핑

엔티티 매핑엔 다음과 같은 어노테이션이 사용된다.객체와 테이블 매핑 : @Entity, @Table필드와 컬럼 매핑 : @Column기본 키 매핑 : @Id연관관계 매핑 : @ManyToOne, @JoinColumn  객체와 테이블 매핑@Entity- @En

surrealcode.tistory.com

앞서서 왜 테이블 설계와 개발 후 왜 연관관계가 필요한지에 알아봤다.

참고하도록 하자.

 

목표

- 객체와 테이블 연관관계의 차이를 이해

- 객체의 참조와 테이블의 외래 키를 매핑

- 용어 이해

  - 방향(Direction) : 단방향, 양방향

  - 다중성(Multiplicity) : 다대일, 일대다, 일대일, 다대다 이해

  - 연관관계 주인(Owner) : 객체 양방향 연관관계는 관리 주인이 필요

 

 

연관관계가 필요한 이유`

객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다

 

예제 시나리오는 다음과 같다

1. 회원과 팀이 있다.

2. 회원은 하나의 팀에만 소속될 수 있다.

3. 회원과 팀은 다대일 관계이다.

 

팀과 멤버의 관계에선 팀이 1 멤버가 N이 된다.

여러명의 회원이 하나의 팀에 소속될 수 있고, 하나의 팀에 여러명의 회원이 소속될 수 있다.

 

@Entity
@Table(name = "MEMBERS")
public class Members {

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

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

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

    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;
    }

    public Long getTeamId() {
        return teamId;
    }

    public void setTeamId(Long teamId) {
        this.teamId = teamId;
    }
}
@Entity
public class Team {

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

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

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

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

    Members members = new Members();
    members.setUsername("member1");
    members.setTeamId(team.getId()); //외래키를 직접 다루어야함
    em.persist(members);

    //조회
    Members findMember = em.find(Members.class, members.getId());
    Long findTeamId = findMember.getTeamId();
    Team findTeam = em.find(Team.class, findTeamId);

    tx.commit();
} catch (Exception e) {
    tx.rollback();
} finally {
    em.close();
}

SELECT * FROM MEMBER;

MEMBER_ID  TEAM_ID  USERNAME 

1 1 member1


select * from team;

TEAM_ID  NAME  

1 TeamA

 

만약 내가 찾아온 Member가 어느 팀 소속인지 알고 싶다면 코드를 어떻게 짜야할까?

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

 

Member를 찾고, 찾아온 member의 팀 Id를 찾고, 찾아온 팀Id로 팀을 찾는 복잡하고 번거로운 과정을 거쳐야 소속 팀을 찾을 수 있게 된다.

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

 

- 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다.

- 객체는 참조를 사용해서 연관된 객체를 찾는다.

- 테이블과 객체 사이에는 이런 큰 간격이 있다.

 

 

단방향 연관관계

위의 문제를 객체 지향 스럽게 해결하기 위해 단방향 연관관계를 사용해 보았다.

엔티티에선 @(어노테이션)이 중요하다. 데이터베이스에 매핑 되기 때문이다.

Member가 teamId가 아닌 Team의 참조값을 그대로 가져오는 것이다.

@Entity
@Table(name = "MEMBERS")
public class Members {

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

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

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

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

    public Team getTeam() {
        return team;
    }

    public void setTeam(Team team) {
        this.team = team;
    }

    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;
    }
}

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

 

 

@ManyToOne

Member가 하나의 팀에 소속된다.

Member 입장에서는 many, Team 입장에서는 ONE을 알려주는 어노테이션이다.

 

@JoinColumn(name = "TEAM_ID")는 

@ManyToOne관계를 조인할 떄 조인하는 컬럼이 어떤 컬럼인지 알려주는 어노테이션이다.

이렇게 하면 다음과 같은 ORM 매핑이 이루어 지게 된다.

 

아래의 그림을 보자. Member 엔티티의 Team을 @JoinColumn(어노테이션)이 중요하다. 데이터베이스 Member테이블 TEAM_ID에 연관관계를 매핑하였다.

 

try {

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

    Members members = new Members();
    members.setUsername("member1");
    members.setTeam(team); //JPA가 알아서 팀에 PK값을 꺼내서 FK값을 사용한다.
    em.persist(members);

    //조회
    Members members1 = em.find(Members.class, members.getId());
    Team findTeam = members1.getTeam();
    System.out.println("findTeam.getName() = " + findTeam.getName());

    tx.commit();
} catch (Exception e) {
    tx.rollback();
} finally {
    em.close();
}

연관관계를 매핑하였기 때문에

members1에서 바로 Team을 꺼내올 수 있게 되었다.

 

findTeam.getName() = TeamA
Hibernate: 
    /* insert for
        hellojpa.Team */insert 
    into
        Team (name, TEAM_ID) 
    values
        (?, ?)
Hibernate: 
    /* insert for
        hellojpa.Members */insert 
    into
        MEMBERS (TEAM_ID, USERNAME, MEMBER_ID) 
    values
        (?, ?, ?)

 

 

 

양방향 연관관계와 연관관계의 주인

객체랑 테이블 두개는 패러다임 차이가 있다.

객체는 연관관계를 찾을 때 참조라는 것을 사용하고, 테이블은 외래키를 가지고 조인을 활용한다.

우리는 이 둘간의 차이에서 오는 것을 이해해야 한다. 그래야 연관관계 주인이라는 개념이 있다는 걸 이해할 수 있다.

 

앞서 배웠던 코드는 단방향으로밖에 찾을 수 없다. (Member -> Team으로)

하지만 테이블은 사실 JOIN을 활용해서 양쪽으로 왔다갔다 할 수 있다. (멤버에서 팀으로, 팀에서 멤버로 왔다갔다 가능)

테이블의 연관관계는 외래키 하나로 양쪽으로 다 알 수 있는 것이다. 테이블의 연관관계는 외래키 하나로 양방향이 다 있다. 사실 방향이라는 개념 자체가 없다.

이를 양방향 객체 연관관계로 풀어내려면 Team에 Members라는 List형태를 넣어줘야 양쪽으로 참조가 가능하게 된다.

@Entity
public class Team {

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

    @OneToMany(mappedBy = "team") //Member엔티티에 team변수명에 걸려 있다.
    private List<Member> members = new ArrayList<>();

    public List<Member> getMembers() {
        return members;
    }

    public void setMembers(List<Member> members) {
        this.members = members;
    }

members라는 List를 만들어주었다. 

try {

    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());
    List<Member> members = findMember.getTeam().getMembers();

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

    tx.commit();

 

위의 코드를 보면 Member에서 Team으로 Team에서 다시 Member로 왔다갔다 하는 것을 볼 수 있다.

반대 방향으로도 객체 그래프를 탐색할 수 있게 되는 것이다.

 

** 참고 : 객체는 가급적 단방향이 좋다. 나중에 가면 점점 복잡해지기 때문이다. **

 

 

연관관계의 주인과 mappedBy

mappedBy의 정체는 대체 뭘까??

연관관계의 주인과 mappedBy를 알려면 객체와 테이블간 연관관계를 맺는 차이를 이해해야 한다.

 

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

- 객체 연관관계 = 2개(단방향이 2개)

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

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

- 테이블 연관관계 = 1개

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

 

객체의 양방향 관계

- 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개이다. 억지로 두개를 만들어 양방향 연관관계를 맺어버렸다.

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

class A {
 B b;                A -> B (a.getB())
}
class B {
 A a;                B -> A (b.getA())
}

 

테이블의 양방향 연관관계

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

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

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

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

 

 

 

현재 객체는 두 방향으로 만들어 놨다(참조가 두개이다).

어떤 참조값을 사용해서 매핑을 해야 하는지 딜레마가 온다.

만약내가 멤버를 바꾸고 싶거나, 새로운 팀에 들어가고 싶을 때 Member의 Team 값을 바꿔야될지 아니면 Team의 List값을 바꿔야할지 이상해진다.

디비 입장에서는 둘 중 하나 아무나 상관없이 업데이트 치기만 하면 된다

 

 

--> 결국 둘 중 하나로 외래 키를 관리 해야 하기 때문에 연관관계의 주인이 생기게 된다.

양방향 매핑 규칙

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

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

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

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

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

 

--> 누구를 주인으로 해야할까?

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

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

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

 

+ DB입장에서도 보면 외래키가 있는 곳이 무조건 N이다.

 

mappedBy는 수정이 불가능하고 조회만 가능하다.

@OneToMany(mappedBy = "team") //Member엔티티에 team변수명에 걸려 있다.
private List<Member> members = new ArrayList<>();

mappedBy에는 값을 넣어도 DB가 없데이트 되지 않는다.

 

 

 

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

(연관관계의 주인에 값을 입력하지 않음)

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

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

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

 

List는 mappedBy이지만 add를 해주었다. 이러면 DB 업데이트가 불가능하다.

SELECT * FROM MEMBER;

MEMBER_ID  TEAM_ID  USERNAME  

1 null member1


select * from team;

TEAM_ID  NAME  

1 TeamA

 

그래서 DB TEAM_ID가 Null인 것을 확인할 수 있다. 이 문제를 해결하기 위해선 List가 아닌 연관관계 주인에 값을 셋팅해주어야 한다.

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

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

SELECT * FROM MEMBER;

MEMBER_ID  TEAM_ID  USERNAME  

1 1 member1


select * from team;

TEAM_ID  NAME  

1 TeamA

 

운영중에 실수하지 않도록 주의하자.

 

**양방향 매핑시 그냥 양쪽에 값을 다 넣어주도록 하자.

다음과 같은 코드를 확인해 보자.

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

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

//            team.getMembers().add(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.getUsername() = " + m.getUsername());
            }
            System.out.println("==========");

            tx.commit();

 

flush를 하지 않은 상태이다. team 객체와 member 객체는 1차 캐시에 저장만 되어있는 상태다.

이 상태에서는 team의 List에 어떠한 값도 들어있지 않기 때문에 m.getUsername()  for문을 돌려보아도 아무런 결과가 출력되지 않는다.

(위 코드의 결과값)

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

이 문제 뿐만 아니라 테스트할때도 문제가 생기기 때문에 양방향 매핑시에는 꼭 양쪽에 값을 다 넣어주도록 하자.

 

- 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자.

- 연관관계 편의 메소드를 생성하자.

이 문제를 좀 가볍게 하기 위해 "연관관계 편의 메소드"를 사용하여 해결한다.

Member에서 값을 set 할 때, team.getMembers().add(this)를 하여 팀에도 한번에 값을 세팅하는 것이다.

@Entity
public class Member {

    ...
    
    ...

    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }

또는 Team에서 연관관계 편의 메소드를 사용할수도 있다. 둘 중 한곳을 정해서 만들도록 하자.

@Entity
public class Team {

    ...
    
    ...
    
    public void addMember(Member member) {
        member.setTeam(this);
        members.add(member);
    }
}

 

 

 

 

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

  - ex) toString(), lombok, JSON 생성 라이브러리

@Entity
public class Member {

    ...
    
    ...
    
    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", team=" + team + //team의 toString 호출
                '}';
    }
}
@Entity
public class Team {

    ...
    
    ...

    @Override
    public String toString() {
        return "Team{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", members=" + members + //members의 toString호출
                '}';
    }
}
        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

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

            Team findTeam = em.find(Team.class, team.getId()); //1차 캐시
            List<Member> members = findTeam.getMembers();
            System.out.println("==========");
            System.out.println("members = " + findTeam);
            System.out.println("==========");

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

 

양쪽에서 toString을 호출해대며 문제가 생긴다. 

 

깨알 상식 : 컨트롤러에는 엔티티를 절대 반환하면 안된다. 무한루프 문제, 엔티티 변경순간 API 스펙이 변할 수 있다.

 

 

양방향 매핑 정리

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

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

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

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

 

객체 입장에서 볼 때 양방향 맵핑은 이득이 크게 많지 않다. 따라서 단방향으로 설계하고, 어플리케이션 개발 시 양방향이 필요한지 고려해서 만들어도 늦지 않다. 기본적으로 단방향으로 정리하자.

 

연관관계 주인을 정하는 기준

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

- 연관관계의 주인은 외래 키의 위치를 기준으로 정해야 한다.

 

 

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

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

 

참조를 사용하도록 변경하자.

@Entity
public class Item {

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

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

    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }

    public int getStockQuantity() {
        return stockQuantity;
    }

    public void setStockQuantity(int stockQuantity) {
        this.stockQuantity = stockQuantity;
    }
}
@Entity
@Table(name = "test")
public class Member {

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

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

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


    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public String getZipcode() {
        return zipcode;
    }

    public void setZipcode(String zipcode) {
        this.zipcode = zipcode;
    }
}
@Entity
@Table(name = "ORDERS")
public class Order {

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

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

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

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


    public void setMember(Member member) {
        this.member = member;
    }

    public Member getMember() {
        return member;
    }

    public Long getId() {
        return id;
    }

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

    public LocalDateTime getOrderDate() {
        return orderDate;
    }

    public void setOrderDate(LocalDateTime orderDate) {
        this.orderDate = orderDate;
    }

    public OrderStatus getStatus() {
        return status;
    }

    public void setStatus(OrderStatus status) {
        this.status = status;
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }
}
@Entity
public class OrderItem {

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

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

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    private int orderPrice;
    private int count;

    public Order getOrder() {
        return order;
    }

    public void setOrder(Order order) {
        this.order = order;
    }

    public Item getItem() {
        return item;
    }

    public void setItem(Item item) {
        this.item = item;
    }

    public Long getId() {
        return id;
    }

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

    public int getOrderPrice() {
        return orderPrice;
    }

    public void setOrderPrice(int orderPrice) {
        this.orderPrice = orderPrice;
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }
}

 

 

반응형

'공부 > JPA' 카테고리의 다른 글

JPA 고급 매핑  (2) 2024.12.08
다양한 연관관계 매핑  (0) 2024.12.03
엔티티 매핑  (0) 2024.11.30
JPA 영속성 관리  (1) 2024.11.28
JPA 설정하기  (0) 2024.11.26