모르는게 많은 개발자

[JPA] 3. 엔티티 연관 관계 매핑 정리/예제 본문

스프링

[JPA] 3. 엔티티 연관 관계 매핑 정리/예제

Awdsd 2021. 2. 28. 03:05
반응형

저번 포스팅에서 엔티티 관련 어노테이션에 대해 썼다.

cjw-awdsd.tistory.com/46

 

[JPA] 2. 엔티티 매핑 @어노테이션 정리/예제

이번 글에서는 JPA 엔티티 매핑과 관련된 어노테이션 @Entity, @Table, @Id, @Column에 대해 정리한다. 연관관계 매핑 관련 어노테이션은 다음 글에 포스팅하겠다. 1. @Entity @Entity 어노테이션은 JPA를 사용

cjw-awdsd.tistory.com

이번에는 이어서 엔티티 연관 관계 매핑에 대해 정리하고자 한다.


1. 들어가기전

설명에 앞서 관계형 데이터베이스와 JPA 엔티티에 대해 비교를 해보자.

관계형 데이터베이스는 테이블간 foreign key로 연관관계를 맺고 JOIN을 통해 테이블을 조회한다.

 

예를 들어 Team이라는 테이블과 Member라는 테이블이 있고 Member와 Team은 M:1 관계라 하자.

그렇다면 기본적으로 외래키는 M쪽인 Member 테이블에 존재하고 이 외래키를 통해 Member 와 Team, Team과 Member를 조회할 수 있다. 즉, Member JOIN Team, Team JOIN Member 둘다 가능하다는 뜻이다.

 

하지만 객체에서는 어떨 까? 밑의 간단한 예제를 보자. Team, Member 객체가 있을 떄 Member에 Team멤버 변수를 두어 관계를 맺는다. Member 입장에서는 Team과 연관관계를 맺었다 할 수 있다. 하지만 Team 입장에서는 자기팀의 Member가 누군지 알 수 없다.

즉, Member ->Team은 조회할 수 있지만 Team->Member는 조회할 수 없다. 이러한 관계를 단방향 관계라 한다.

@Entity
public class Team {
    private Long id;
    private String teamName;
    //Team은 자기와 연관된 Member를 알 수 없다.
}

@Entity
public class Member {
    private Long id;
    //Member는 자기와 연관된 Team을 알고있다.
    private Team team;
    private String memberName;
}

이러한 관계를 고치는 법은 무엇일까? 당연히 Team에도 Member 필드를 추가하면 된다.

하지만 중요한 것은 이것을 관계형 데이터베이스처럼 양방향이라 표현할 수 있지만, 엄연히 말하면 서로 다른 단방향 관계가 2개가 되는 것이다.

@Entity
public class Team {
    private Long id;
    private String teamName;
}

@Entity
public class Member {
    private Long id;
    private Team team;
    private String memberName;
}

그럼 이제 이것을 JPA를 통해 연관관계가 어떻게 매핑되는지 살펴보자.


2. 다대일(M:1) 단방향 관계

위에 봤던 Member : Team 관계(M:1)을 단뱡향 관계 매핑을 해보자.

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String MemberName;
    @ManyToOne
    @JoinColumn(name="TEAM_ID")
    private Team team;
}

@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String teamName;
}

먼저 위 결과를 통해 나온 테이블을 보자.

Team 테이블
Member 테이블

테이블을 보면 Member 테이블에 외래킹린 TEAM_ID가 생성된 것을 볼 수 있다. 이것은 @ManyToOne, @JoinColumn 어노테이션으로 생성된 것이다. 단방향이기때문에 Team엔티티에서는 Member를 알 수 없다.

  • @ManyToOne : N:1 관계를 표현하는 어노테이션이다. @ManyToOne이 붙은 엔티티가 M이고 반대 엔티티가 1일 때 붙인다.
  • @JoinColumn(name="") : 외래키를 정의하는 어노테이션이다.
@JoinColumn 속성
name 매핑할 외래키 이름 필드명 + "_" + 참조하는 테이블 기본키 컬럼명
referencedColumnName 외래키가 참조하는 대상 테이블의 컬럼명 참조하는 테이블 기본키 컬럼명
(반대편 테이블의 기본키)
foreignKey(DDL) 외래 키 제약조건 지정 unique, nullable, insertable, updatable, columnDefinition, table
@ManyToOne 속성표
optional false일시 연관된 엔티티가 항상 있어야한다 true
fetch 글로벌 페치 전략 설정 @ManyToOne=FetchType.EAGER @OneToMany=FetchType.LAZY
cascade 영속성 전이 기능 사용  
targetEntity 연관된 엔티티 타입 정보 설정  

이제 위에서 매핑한 Entity로 Member, Team을 저장하는 코드를 보자.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Team team = new Team();
        team.setTeamName("test");
        em.persist(team);

        Member member = new Member();
        member.setMemberName("memberTest");
        //member객체에 team객체를 넣어 관계를 맺는다
        member.setTeam(team);
        em.persist(member);
    }
}

Member 테이블
Team 테이블

위 결과를 보면 member.TEAM_ID에 team.id가 값이 매핑되어 있는것을 확인할 수 있다.

아래는 데이터를 가져오는 코드이다.

public void findTest() {
    Member member = em.find(Member.class, 2L);
    log.info(member.getMemberName());
    log.info(member.getTeam().getTeamName());
}

결과 log

지금은 Member 객체를 찾아 Member가 속한 Team까지 연관관계로 인해 데이터를 가져온다. 하지만 Team을 find했을 때는 단방향 관계로 인해 Member를 찾을 수가 없다.


3. 다대일(M:1) 양방향 관계

이제 양방향 관계는 어떻게 하는지 보자. 양방향 매핑 코드는 다음과 같다.

@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    private String teamName;
    //양방향 매핑을 위해 추가
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String MemberName;
    @ManyToOne
    @JoinColumn(name="TEAM_ID")
    private Team team;
}

위의 코드를 보면 Team에 Member를 저장할 필드가 생성된 것을 볼 수 있고, @OneToMany 어노테이션이 사용됐다. 그리고 속성으로 mappedBy가 사용되었는데 mapppedBy는 양방향 매핑일 때 사용한다.

name 속성에는 반대쪽 매핑의 필드 이름값을 넣는다. 위 코드상에서는 Member의 Team객체의 team을 입력한다.

 

그렇다면 mappedBy는 왜 필요할까? 필드의 클래스로 매핑 관계를 식별할 수 있지않을까? 라는 의문점이 들 수 있다.

 

이것은 위에서 말했던 객체는 서로 다른 단방향 2개로 이루어져있는것과 연관이 있다.

 

단방향 한개로만 이루어졌을 때 보면 연관관계를 TEAM_ID를 통해 관리한다.

그렇다면 서로 다른 단방향 2개면 어떻겠는가?

2개의 연관관계 ID가 필요하게 된다. 즉, 외래키 2개를 관리하게 될텐데 관계형 데이터베이스는 외래키 1개를 가지고 관리한다.

그래서 JPA는 두개의 연관관계중 하나를 고르게 하기 위해 mappedBy를 설정한다. 여기서 관리되는 연관관계를 연관관계의 주인이라 한다. 다시 말해 mappedBy가 없는 엔티티가 연관관계의 주인이다.

 

위의 코드에서도 mappedBy가 없는 Member 엔티티에 외래키가 생성된다.

 

그럼 이제 양방향 관계에 데이터를 넣는 코드를 보자.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Team team = new Team();
        team.setTeamName("test");
        em.persist(team);

        Member member = new Member();
        member.setMemberName("memberTest");
        member.setTeam(team);
        em.persist(member);
    }
}

보고 나서 이상함을 느꼈을 것이다. 위에서 봤던 단방향일때 데이터를 저장하는 코드와 같기 때문이다.

이유는 연관관계주인 방향에서 데이터를 입력하면 연관관계 주인이 아닌(Team)에 Member 데이터를 넣지 않아도 데이터베이스에는 정상적으로 들어간다.

다음과 같이 연관관계 주인이아닌 곳에만 데이터를 넣었을 경우에는 정상적으로 데이터가 삽입되지 않는다.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Team team = new Team();
        team.setTeamName("test");
        em.persist(team);

        Member member = new Member();
        member.setMemberName("memberTest");
        //관계주인인 member에는 team을 설정하지 않음
        //member.setTeam(team);
        
        //관계주인이 아닌 team에 member를 추가
        team.getMembers().add(member);
        em.persist(member);
    }
}

member 테이블

결과를 보면 Member 테이블에 외래키값이 들어가지 않는다.

이러한 오류를 방지하기 위해서는 양쪽 객체 둘다에게 데이터를 저장해야한다.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Team team = new Team();
        team.setTeamName("test");
        em.persist(team);

        Member member = new Member();
        member.setMemberName("memberTest");

        member.setTeam(team);
        team.getMembers().add(member);

        em.persist(member);
    }
}
주의 사항

아래 코드의 오류를 찾아보자.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        //team 생성
        Team team = new Team();
        team.setTeamName("test");
        em.persist(team);

        //member 생성
        Member member = new Member();
        member.setMemberName("memberTest");

        //member와 team 양방향 설정
        member.setTeam(team);
        team.getMembers().add(member);

        //team2 생성
        Team team2 = new Team();
        team2.setTeamName("test2");
        em.persist(team2);
        
        //member의 팀을 team2로 양방향 변경
        member.setTeam(team2);
        team2.getMembers().add(member);
        em.persist(member);
    }
}

위 코드를 보면 member의 팀을 team1에서 team2로 변경하는 코드다. 그리고 결과를 실행해보면 데이터베이스에도 정상적으로 외래키가 team2로 변경될 것이다.

그렇다면 이 코드의 객체 연관관계를 그림으로 보자.

member와 team2는 양방향으로 맺어져있지만 team1은 여전히 member를 가르키고 있다.

이렇게 됐을 때 team1.getMembers()를 하면 어떻게 될까?

member는 team1으로 변경되었지만 team1객체에는 member가 아직도 들어있게 된다.

이러한 버그가 발생될 수 있기 때문에 아래처럼 team1에서도 member를 제거해줘야한다.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        //team 생성
        Team team = new Team();
        team.setTeamName("test");
        em.persist(team);

        //member 생성
        Member member = new Member();
        member.setMemberName("memberTest");

        //member와 team 양방향 설정
        member.setTeam(team);
        team.getMembers().add(member);

        //team2 생성
        Team team2 = new Team();
        team2.setTeamName("test2");
        em.persist(team2);

        //member의 팀을 team2로 양방향 변경
        member.setTeam(team2);
        team2.getMembers().add(member);
        //team에서 member를 제거
        team.getMembers().remove(member);
        em.persist(member);
    }
}

4. 일대다(1:M) 단방향 관계

일대다 관계는 위에서 봤던 @OneToMany다. 하지만 만약 @OneToMany로 단방향 관계를 맺는다면 어떻게 될까?

다시 말해 @OneToMany 어노테이션이 있는 필드에 @JoinColumn을 아래 코드처럼 건다면 어떻게 될까?

@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String teamName;
    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

다대일 관계에서는 @JoinColumn을 둔 엔티티에 외래키가 생성되고 관리한다.

하지만 일대다 단방향 관계에서는 Team 엔티티의 반대편인 Member엔티티에 외래키가 생성되고 관리된다.

일반적으로 일대다 단방향 관계 매핑은 권장되지 않는다.

왜냐하면 연관관계 주인 엔티티에서 외래키를 관리하지않고 반대편 엔티티에서 외래키를 관리하기 때문에(데이터베이스 설계에서 1:M관계에서 외래키는 M에 존재하기 때문에) 관리가 부담스럽다.

 

또한 이로 인해 성능 이슈도 발생한다.

예를 들어 Member 객체를 저장한다 해보자.

Member 엔티티에는 Team엔티티에 대한 정보가 없다. 즉 Member 객체를 저장하면 INSERT 구문에서 외래키는 저장이 되지 않고 데이터베이스에 들어갈 것이다.

그리고 Team객체가 저장될 때 Team객체에 있는 연관 관계 정보를 보고 Member객체에 UPDATE구문으로 외래키가 저장될 것이다. 즉 한번 저장하는데 UPDATE도 항상 같이 호출된다. 그러므로 단방향 관계를 할 때는 일대다 보다는 다대일 단방향으로 하는 것이 좋다.


5. 일대다(1:M) 양방향 관계

일대다 양방향 관계는 존재하지 않는다.

왜냐하면 1:M관계의 주인은 항상 다(M)이기 때문에 일대다 양방향이나 다대일 양방향은 같은 말이다.


6. 일대일(1:1) 단방향 관계

일대일 관계는 양쪽이 서로 하나의 관계만 가지는 관계이다.

일대일 관계에서는 외래키가 어디에 있든 상관이 없다.

 

Member 엔티티와 Locker 엔티티가 있고, 1:1 관계라 하자.

외래키는 Member 또는 Locker 엔티티에 존재할 수 있다.

연관관계 주인을 Member로 한 1:1 단방향 관계 코드를 보자.

@Entity
public class Locker {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

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

Locker 테이블
Member 테이블

일대일 단방향 관계도 다대일 단방향과 비슷하다

@ManyToOne 대신 @OneToOne을 사용하고 관계주인 엔티티에 @JoinColumn을 붙여준다.

Locker를 단방향 관계 주인으로 하고 싶으면 @OneToOne을 Locker에 붙여주면 된다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;
}

@Entity
public class Locker {
    @Id
    @GeneratedValue
    private Long id;
    private String lockerName;

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

Member 테이블
Locker 테이블


7. 일대일(1:1) 양방향 관계

일대일 양방향 관계도 다대일 양방향 관계랑 비슷하다. 바로 코드를 보자.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @OneToOne(mappedBy = "member")
    private Locker locker;
}

@Entity
public class Locker {
    @Id
    @GeneratedValue
    private Long id;
    private String lockerName;

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

위 코드는 Locker를 연관관계 주인으로 한 일대일 관계 매핑이다.

Member를 연관관계 주인으로 하고 싶다면 mappedBy, @joinColumn위치를 서로 바꿔주면 된다.


8. 다대다(M:M) 단방향 관계

관계형 데이터베이스에서 다대다를 2개의 테이블로 표현할 수 없다.

그래서 중간에 관계 테이블을 하나 두어 관계를 맺는다.

M:M 테이블 관계도

하지만 객체는 테이블과 달리 2개의 객체로 다대다 관계를 표현할 수 있다. @ManyTo@Many를 사용하여 표현한다.

코드를 보자.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT",
    joinColumns = @JoinColumn(name = "MEMBER_ID"),
    inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
    private List<Product> product = new ArrayList<>();
}

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String productName;
}

코드를 보면 관계주인만 @ManyToMany를 사용한다. 특이한건 @JoinTable을 사용한다.

@JoinTable은 연결테이블을 지정하는 것이다.

각 속성의 기능은 다음과 같다.

name 연결 테이블 지정
joinColumns 현재 위치의 객체와 매핑할 조인 컬럼 이름
inverseJoinColumns 반대편의 객체와 매핑할 조인 컬럼 이름

결론적으로 이렇게 엔티티를 구성할 경우 3개의 테이블이 생성된다.

Member 테이블
Product 테이블
MEMBER_PRODUCT 테이블

데이터를 집어넣을 때도 MEMBER_PRODUCT 테이블은 신경쓰지 않아도 된다.

아래 코드는 데이터를 넣는 예시코드이다.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Product product = new Product();
        product.setProductName("testProduct");
        em.persist(product);

        Member member = new Member();
        member.setMemberName("testMember");
        member.getProduct().add(product);
        em.persist(member);
    }
}

데이터 넣는 작업은 기존 다대일에서 본 거랑 똑같다 @ManyToMany가 알아서 연관 테이블에 값을 넣어준다.

데이터 결과는 아래와 같다.

Member 테이블
Product 테이블
MEMBER_PRODUCT 테이블


9. 다대다(M:M) 양방향 관계

다대다 양방향은 연관 주인 반대편 객체에도 @ManyToMany를 붙여준다. 그리고 연관주인이 아닌 객체에 mappedBy를 붙여준다.

다음은 양방향 관계 코드이다.

@Entity
public class Product {
    @Id
    @GeneratedValue
    private Long id;
    private String productName;
    @ManyToMany(mappedBy = "product")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT",
    joinColumns = @JoinColumn(name = "MEMBER_ID"),
    inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
    private List<Product> product = new ArrayList<>();
}

다음은 다대다 양방향 관계일 때 데이터 삽입 예제 코드이다.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Product product = new Product();
        product.setProductName("testProduct");
        em.persist(product);

        Member member = new Member();
        member.setMemberName("testMember");

        member.getProduct().add(product);
				//양방향으로 서로 객체 추가
        product.getMembers().add(member);
        em.persist(member);
    }
}
@ManyToMany 한계

@ManyToMany를 사용하면 중간 연결 테이블을 자동으로 관리하고 생성해주므로 편리하다.

하지만 일반적으로 실무에서는 연결 테이블에 외래키가 담지 않고 추가적인 컬럼이 들어간다.

추가적인 컬럼이 들어갈 경우 @ManyToMany는 더이상 사용할 수 없다.

 

그래서 직접 새로운 연결 엔티티를 만들어서 일대다 다대일 관계를 직접 만들어줘야한다.

직접 연결 엔티티를 만든 코드를 보자. 먼저 Product, Member 엔티티를 보자.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String memberName;

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

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String productName;
    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

Member, Product 엔티티를 보면 MemberProduct엔티티를 일대다로 관계를 맺는 것을 볼 수 있다.

이제 MemberProduct 엔티티를 보자.

@Entity
@IdClass(MemberProductId.class)
public class MemberProduct {
    @Id
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @Id
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private String test;
}

코드를 보면 @Id와 @JoinColumn을 동시에 사용해 기본 키 + 외래 키를 한번에 매핑했다.

그리고 @IdClass어노테이션이 있는데 이것은 JPA에서 복합 기본키를 매핑할 수 있게해준다.

@IdClass안에 MemberProductId.class가 들어있는데 이걸 식별자 클래스라 한다.

public class MemberProductId implements Serializable {
    private Long member;
    private Long product;

    @Override
    public int hashCode() {
        return super.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        return super.equals(obj);
    }
}

식별자 클래스를 만들때는 다음 특징을 만족해야한다.

  • Serializable을 구현해야 한다.
  • 식별자 클래스의 속성명과 엔티티에서 사용하는 식별자 속성명은 같아야함(member, product)
  • equals, hashCode 메소드를 구현해야함
  • 기본 생성자가 있어야함
  • 식별자 클래스는 public이어야 함

위처럼 엔티티 연관 관계를 매핑했을 때 데이터를 넣는 예제는 다음과 같다.

public class TestService {
    private final EntityManager em;
    @Transactional
    public void test() {
        Product product = new Product();
        product.setProductName("testProduct");
        em.persist(product);

        Member member = new Member();
        member.setMemberName("testMember");
        em.persist(member);

        MemberProduct memberProduct = new MemberProduct();
        memberProduct.setProduct(product);
        memberProduct.setMember(member);

        product.getMemberProducts().add(memberProduct);
        member.getMemberProduct().add(memberProduct);

        em.persist(memberProduct);
    }
}

MemberProduct 객체에 product와 member를 넣어주고 product와 member에도 양방향을 위해 MemberProduct객체를 넣어주면 된다.

Product 테이블
Member 테이블
MEMBER_PRODUCT 테이블

이렇게 자신의 기본 키 + 외래 키로 사용하는 것을 식별 관계라 한다.

 

하지만 이렇게 사용했을 경우 @IdClass를 사용하는 등 사용 방법이 복잡하다는 단점이있다.

그래서 또다른 방법으로 복합키를 사용하지 않고 연관 테이블에 새로운 기본키를 만들고 외래키는 외래키로서만 사용되는 방법인데 이것을 비식별 관계라 한다.

설명보단 코드를 보면 바로 이해할 수 있을것이다.

@Entity
public class MemberProduct {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private String test;
}

코드를 보면 기존 엔티티 설계 방식과 같이 @Id를 따로 두어 기본키를 만들었다.

나머지 Member, Product는 기존과 같다.

데이터를 넣는 로직도 위의 것과 동일하게 했을경우 아래와 같이 결과가 나온다.

MEMBER_PRODUCT 테이블
Product 테이블
Member 테이블


참고

 

반응형
Comments