StatCounter - Free Web Tracker and Counter

JPA, 다양한 연관관계 맵핑

반응형

이번 포스팅에서는 다양한 연관관계 맵핑에 대해 공부한 내용을 복습/정리해보고자 한다.

 

연관관계 맵핑 시 고려해야 할 사항


  • 다중성
  • 단방향, 양방향 고려
  • 연관관계의 주인

연관관계를 맵핑할 때 고려해야 할 사항은 기본적으로 위 세 가지라고 한다. 위 세 가지에 대해서 하나하나 풀어서 살펴보려고 한다.

다중성


다중성은 연관관계를 맺고자 하는 테이블이 어떤 관계를 가지고 있는지를 나타내는 것으로 이해하면 된다. 테이블과 연결시켜 주기 위해서는 필드에 어노테이션을 달아줘야하는데 @ManyToOne, @OneToMany, @ManyToMany, @OneToOne이 존재한다. 여기서 @ManyToMany 연관관계는 표현은 할 수 있으나 실무에서는 절대로 사용하지 않을 것이 권장된다고 한다. 

단방향, 양방향 고려


단방향, 양방향 고려는 객체 관점에서 고민해야할 부분이다. 테이블은 외래 키 하나로 양쪽 JOIN 및 탐색이 가능하다. 그렇기 때문에 DB 테이블에게는 '방향성'이라는게 없다고 한다. 

반면에 객체는 참조를 가지고 움직인다. 따라서 참조 필드가 있는 쪽으로만 탐색이 가능하다. 한쪽만 참조하면 단방향 연관관계를 가지고, 양쪽으로 참조하면 양방향 연관관계를 가진다. 그렇지만 실제로는 단방향 연관관계 2개를 가진 것으로 이해를 하면 된다. 

연관관계의 주인


테이블은 FK 하나로 양쪽이 연관관계를 가진다. 그렇지만 객체 입장에서 바라봤을 때, 양방향 연관관계를 가진다면 FK가 2개가 되는 셈이다. 따라서, 테이블과 유사하게 동작할 수 있도록 FK를 한쪽에서만 관리하도록 지정을 해야한다. 이 때, FK를 관리하는 쪽을 연관관계의 주인이라고 한다. 

연관관계의 주인을 통해서 값을 수정, 변경할 경우에는 실제로 쿼리가 발생한다. 그렇지만 가짜 맵핑(연관관계의 주인이 아닌 쪽)은 값을 수정, 변경해도 적용되지 않고 단순히 조회만 가능하다.

 

 

다대일 연관관계 (다가 FK의 기준이 된다)

다대일 단방향


  • 그래프 탐색은 Member → Team으로만 가능하다.
  • Member 엔티티의 Team은 @JoinColumn으로 TEAM_ID(FK)와 연결된다.
  • Team 필드에 @ManyToOne으로 연관관계 맵핑을 해준다. Member는 Team과 다대일 관계이기 때문이다.

다대일 관계 단방향 관계는 아래와 같이 작성할 수 있다. 

@Entity
public class Member {

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

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
   }
@Entity
public class Team {

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

}

 

다대일 양방향


  • 다대일 양방향은 다대일 단방향에서 다른 방향에도 그래프 탐색이 가능하도록 해준다.
  • 객체 관점에서 참조 객체가 하나 추가된다.
  • 연관관계의 주인은 항상 다대일의 다쪽이 가진다.
  • DB 테이블 상에서의 변경점은 없다.

다대일 양방향 연관관계를 구현한 코드는 아래와 같다. 다대일 단방향 연관관계에서 코드 두 줄만 더 추가하면 된다.

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

다대일 양방향은 단뱡항 관계에서 다른 쪽으로 그래프 탐색이 가능하도록 추가를 한 것이다. '일'의 역할을 하는 클래스에 '다'를 가질 수 있도록 Java collection을 추가하고, 이 필드가 DB 테이블 관점에서는 '일대다' 관계라는 것을 알려준다.

이렇게 선언되면 객체 입장에서는 FK가 두 개 있는 것이기 때문에 한 개로 만들어줘야한다. 따라서 mappedBy 옵션을 사용해서 FK를 하나만 사용할 수 있도록 설정해준다.

 

일대다 연관관계 : '일'이 FK 관리

일대다 단방향


  • 객체는 '일'이 FK를 관리하고, 테이블은 '다'가 FK를 관리한다.
  • 객체 입장에서는 이런 설계가 있을 수 있으나, DB 입장에서는 무조건 '다'가 FK를 관리해야한다.
  • @JoinColumn을 꼭 사용해야한다. 사용하지 않으면 Join Table(중간 테이블이 하나 추가되는 방식)을 사용한다.
  • 다대일 단방향 사용을 권장한다.

위의 연관 관계를 코드로 구현하면 다음과 같다.

@Entity
public class Member {

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

}

@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<>();

}

코드로 볼 수 있듯이, Team의 members가 @OneToMany 어노테이션을 가지고, @JoinColumn으로 Member Table의 'TEAM_ID' FK와 맵핑되는 것을 명시해주었다. 연관관계를 맺어준 후 코드를 실행해보면 아래 쿼리가 나가는 것을 볼 수 있다. 

MEMBER Table의 FK 키인 TEAM_ID로 Join Column이 정상적으로 설정되었다는 쿼리가 확인되었다. 그리고 TEST 코드를 하나 짜서 쿼리가 어떻게 나가는지 살펴보면 다음과 같다.

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

Team team = new Team();
team.setName("teamA");
ArrayList<Member> memberList = new ArrayList<>();
memberList.add(member);
team.setMembers(memberList);
em.persist(team);

TEST 코드는 다음과 같이 작성했다. 객체에서는 TEAM이 외래 키를 관리하기 때문에 TEAM에 MEMBER를 넣어주는 형식으로 코드를 짜야한다. 따라서 MEMBER를 먼저 만들고, MEMBER가 저장된 MEMBERLIST를 하나 만들어서, 그것을 TEAM에 저장하는 형태로 코드를 작성했다. 

위 코드를 실행하면 먼저 insert 쿼리가 두 번 나간다. 여기까진 당연하다. 그런데 update 쿼리가 한번 더 나가는 것을 확인할 수 있다. update 쿼리는 Member Table의 TEAM_ID에 대한 업데이트 쿼리가 나간다. 

Team을 em.persist()하는 것은 그냥 하면 된다. 그렇지만 이 때 실제 DB Table의 외래 키는 수정이 되지 않는다. em.persist()를 하게 되면 이 때 MEMBER Table의 TEAM_ID를 변경할 방법이 없다. 이런 이유 때문에 Update 쿼리가 한번 더 나가서 MEMBER TABLE의 FK값을 업데이트를 한번 더 해주는 것이다. 

이런 방식은 일대다 단방향의 아주 심각한 단점이 될 수 있다. 쿼리를 사용하는 사람의 입장에서는 em.persist()를 2번만 해서 Insert 쿼리가 2번만 나가야 하는데, Update 쿼리까지 나가는 것을 보고 혼란스러울 수 있다. 특히, 테이블이 수십 개 단위로 돌아가게 되면 사실상 로그를 추적하지 못한다고 보는 게 맞을 것 같다. 

 

일대다 단방향, JoinColumn을 사용하지 않았을 경우.


일대다 단방향에서 JoinColumn으로 FK 값을 지정해주지 않으면, Join Table로 동작한다. 중간에 테이블을 하나 추가해서 값을 저장하는 방식으로 동작한다. 예를 들어 TEAM과 MEMBER에서는 TEAM_MEMBER 혹은 MEMBER_TEAM이라는 중간 테이블이 하나 더 만들어진다.

코드를 실행해보면 TEAM_MEMBER 테이블이 만들어지는 것을 볼 수 있다. 이 테이블은 각 테이블의 PK값을 가지는 테이블이다. 이 PK값끼리 연결해서 값을 저장한다고 보면 된다. 굳이 테이블을 하나 더 만들어서, DB 용량을 더 차지할 필요가 있을까? 

 

일대다 단방향 정리


  • 일대다 단방향은 객체 관점에서는 일이 연관관계의 주인
  • DB 테이블은 다에 외래키가 있음.
  • 객체와 테이블의 차이 때문에 객체는 반대편 테이블의 FK를 관리하는 특이한 구조다.
  • @JoinColumn을 사용해서 반드시 외래키를 맵핑 해주어야 한다. 그렇지 않으면 JoinTable 모드로 동작한다. 

 

일대다 단방향 매핑의 단점


  • 엔티티가 관리하는 외래 다른 테이블에 있음.
  • 연관관계 관리를 위해 추가로 UPDATE 쿼리가 나간다. 이 때문에 로그 추적이 어렵다.

따라서, 일대다 단방향 매핑 사용은 지양하는 것이 좋다고 한다. 대신 다대일 양방향 매핑을 사용하는 것이 권장된다고 한다.

 

일대다 양방향 


  • 일대다 양방향 맵핑은 공식적으로는 존재하지 않는다.
  • 꼼수를 활용해 구현은 가능하다. 그러나 공식적으로 존재하지 않으니 쓰지 않는 것이 추천된다.
  • @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)를 사용해서, 읽기 전용 필드로 만들어서 구현할 수 있다. 
  • 다대일 양방향을 사용하는 것을 권장한다.

일대다 양방향은 일대다 단방향에서 다대일로 그래프 탐색이 가능하도록 필드 객체를 하나 넣어주고, 특정 어노테이션을 넣어주면 구현이 가능하다. 그림에서 볼 수 있듯이 굉장히 괴랄하다. 

@ManyToOne
@JoinColumn(name = "TEAM_ID", updatable = false, insertable = false)
private Team team;

 

일대일 관계

일대일 단방향 : 주 테이블에 외래키, 단방향


  • 일대일 관계는 아무 테이블에서나 외래 키를 선택 가능하다.
  • 외래 키를 선택하면 DB에 Unique(Uni) 제약 조건을 반드시 추가해야한다. 

Member 기준으로 Locker와 일대일 관계를 가진다고 가정하면, 코드는 아래와 같이 작성할 수 있다. @JoinColumn에 반드시 Unique를 적어주어 버그가 발생하지 않게 해야한다. 

@Entity
public class Member {

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

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

}


@Entity
public class Locker {

    @Id
    @GeneratedValue
    private Long id;
    private String name;

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

위 코드는 연관관계 매핑을 할 때 사용한 것이고, 아래 코드로 정상적으로 돌아가는 것을 확인해보고자 한다. Unique를 넣었을 때와 넣지 않았을 때를 구분해서 볼 수 있다.

Locker locker = new Locker();
locker.setName("lockerA");
em.persist(locker);


Member member = new Member();
member.setUsername("memberA");
member.setLocker(locker);
em.persist(member);

Member member1 = new Member();
member1.setUsername("memberB");
member1.setLocker(locker);
em.persist(member1);

위 코드는 일대일 관계에서 일부러 버그를 유발하는 코드이다. member와 locker는 각각을 1개씩 밖에 가지지 못한다. 그런데 memberA와 memberB가 같은 Locker를 가진다고 코드를 작성했다.

좌 : Unique X / 우 : Unique O

왼쪽은 Unique를 설정하지 않았을 때, DB Table을 확인했다. DB Table을 보면 각 Member들은 1번 락커를 가지고 있다고 주장한다. 이 결과를 보면 일대일 관계가 아니라 다대일 관계가 된다. 즉, 버그 상황이다. 이걸 해결하기 위해 @JoinColumn 안에 Unique 옵션을 넣을 수 있다. Unique 옵션을 넣으면 오른쪽처럼 컴파일 시에 에러가 발생하는 것을 볼 수 있다. 

 

일대일 양방향 : 주 테이블에 외래키, 양방향.


일대일 양방향은 한쪽에 필드 객체를 하나 더 만들어 그래프 탐색이 가능하도록 만들어주면 된다. 위의 예시에서 아래 코드만 더 추가하면 된다. 추가한 후 연관관계의 주인을 알려주기 위해 mappedBy 옵션을 켜서 알려주기만 하면 된다. 

@Entity
public class Locker {

    @Id
    @GeneratedValue
    private Long id;
    private String name;

    @OneToOne(mappedBy = "locker")
    private Member member;

}

@ManyToOne / @OneToMany를 사용한 것과 거의 차이가 없다. 마찬가지로 단방향에서 양방향을 추가한 것이기 때문에 테이블 상에서의 변화는 없다. 

 

일대일 단방향, 대상 테이블 외래키


일대일 단방향, 대상 테이블이 외래키를 가지는 형태는 JPA에서 제공하지 않는다고 한다. 실제로 코드를 짜서 테이블이 정상적으로 맵핑되는지 확인해봤다.

실행 결과를 보면 알 수 있듯이, FK에 대한 설정이 이루어지지 않는 것을 볼 수 있다. 즉, 지원되지 않아 맵핑이 이루어지지 않는다. 

 

일대일 양방향, 대상 테이블 외래 키


위 테이블은 대상 테이블인 Locker에서 외래키를 가지는 것이다. 일대일 연관관계는 외래 키를 자기 스스로 관리를 해야하기 때문에 사실상 앞의 연관관계와 다른 것이 거의 없다. 일대일 양방향, 주 테이블 외래키와 거의 동일하게 코드를 작성하면 된다.

 

 

일대일 정리 → 주 테이블, 외래 키 단방향 추천


주 테이블에 외래 키 정리 

  • 주 객체가 대상 객체의 참조를 가지는 것처럼 주 테이블에 외래 키를 두고 대상 테이블을 찾음
  • 객체지향 개발자가 선호함(위와 일맥상통)
  • JPA 매핑 편리함
  • 장점 : 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능함. 주 테이블 조회 시, FK 값을 가져오기 때문에 데이터 유무 확인 가능.
  • 단점 : 대상 테이블에 값이 없으면, 주 테이블에 Null을 허용해야함. 

대상 테이블에 외래 키 정리

  • 대상 테이블에 외래 키가 존재한다. 따라서 양방향이 강제된다(대상 테이블 단방향 지원 X)
  • 전통적인 DB 개발자가 선호(Locker가 일대다 관계가 될 경우, 변경점 작다)
  • 장점 : 주 테이블과 대상 테이블의 관계가 일대일에서 일대다로 변경 시, 테이블 구조가 유지됨.
    → 하나의 락커를 여러명이 쓴다고 하면, 어노테이션만 바꿔주면 된다.
  • 단점 : 프록시 기능의 한계로 지연 로딩을 설정해도 항상 즉시 로딩이 된다.

 

대상 테이블 외래키 관리하는 양방향 모드는 지연 로딩이 설정이 되지 않는다. 내가 이해한 바로는 이렇다.

실제 Member 찾아봄.

  1. JPA에서 Member 객체를 요청한다.
  2. 외래 키는 Locker Table의 MEMBER_ID가 관리중이기 때문에 외래 키를 타고 들어간다.
  3. 외래 키로 Join을 해서 Member_ID가 같은 Member를 찾아서 반환해준다.

1 → 3번으로 가는 과정에서 이미 쿼리란 쿼리는 다 치고 뒤적거려서 Member를 찾아주었다. 따라서 프록시 모드를 활용한 지연기능은 사용하지 못한다. 성능 최적화 관점에서는 치명적이라고 할 수 있다. 

 

 

 

다대다 연관관계


좌 : 다대다 표현 X , 우 : 일대다, 다대일로 풀자.

  • RDBMS는 정규화 된 테이블 2개로 다대다 관계를 표현할 수 없다. 따라서 연결 테이블을 하나 추가해서 일대다, 다대다 관계로 풀어내야한다.
  • 객체는 Collection을 2개 사용해서 다대다 관계를 만들어낼 수 있다.
    → Member는 ProductList를 가질 수 있고, 동시에서 Product는 MemberList를 가질 수 있다. 
  • @ManyToMany로 표현할 수 있다. 기본적으로 JoinTable 전략을 사용해서 중간 테이블을 만든다.
  • @JoinTable로 Table명을 지정해줄 수 있다. 

다대다를 아래 코드로 구현을 해보았다. 간단하게 다대다 단방향을 구현했다.

public class Member{
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT_TABLE")
    private List<Product> ProductList = new ArrayList<>();
}

구현한 결과를 살펴보면 아래처럼 Join Table이 만들어진 것을 볼 수 있다. 여기서 JoinTable을 지정하지 않으면 각 엔티티의 대문자가 '_'로 합쳐진 것이 만들어진다. Join Table로 Table명을 지정해줄 수 있다. 

만들어진 Table 결과는 위와 같다. Member와 Product 각각에 있는 변수들은 하나도 생성이 되지 않았다. 오로지 PK만 FK로 넘어와서 서로를 연결하는 역할을 하고 있을 뿐이다. 구현이 되긴 되는데, 이게 정말 효과적일까? 

다대다 매핑의 안 좋은 점

  • 편리해보이지만 실무에서는 사용할 수 없다.
  • Join Table은 단순히 Join만 하고 끝나는 용도가 아니다. 예를 들어, 주문시간, 수량 같은 데이터가 들어오면 Join Table에 이것을 추가할 수 있는 방법은 없다. 
  • 예를 들어 Member와 Product 사이에 주문 시간과 수량같은 데이터가 들어왔다고 가정해보자. JoinTable에는 이런 정보를 넣을 수 없다. 넣을 수 없기 때문에 주문 시간과 수량 데이터는 처리할 수가 없어진다.

 

다대다 매핑의 한계 극복


다대다 매핑은 기본적으로 JPA에서 중간 테이블을 하나 만들어준다. 이 중간 테이블의 단점은 PK끼리만 연결되는 단순한 형태이고 필요한 정보를 추가할 수 없는 구조다. 그렇다면 중간 테이블을 직접 하나 만들어서 일대다, 다대다 관계로 풀어주고 여기에 필요한 변수들을 추가하면서 다대다 매핑의 한계를 극복할 수 있다. 즉, 연결 테이블용 엔티티를 하나 더 만들어서 맵핑을 해주면 된다.

@Entity
@Table(name = "ORDER")
public class MemberProduct {

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

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

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

    private int orderAmount;
    private LocalDate orderDate;

}

다대일, 일대다로 풀어내기 위한 연결 엔티티는 다음과 같이 작성했다. 양쪽의 FK를 들고 있어서 맵핑이 가능하도록 작성을 했다. 위 코드를 작성하고 Create Table 쿼리가 정상적으로 나가지, 실제 H2 DB에 정상적으로 만들어지는지를 확인했다. 

위는 빌드 실행 결과다. 빌드 실행 시, ORDERS Table이 만들어지는 것을 확인했고 Alter Table에서 FK로 MEMBER_ID, PRODUCT_ID가 정상적으로 등록되는 것을 확인했다. 테스트 코드도 하나 만들어 동작시켜보았다. 

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

Product product = new Product();
product.setName("productA");
em.persist(product);

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

Member, Product 엔티티를 하나씩 만들어서 MemberProduct 중간 테이블 엔티티에 저장하는 코드를 작성했다. 이 때, 단방향 맵핑으로만 되어있기 때문에 MemberProduct에 바로 엔티티를 저장하는 방식을 선택했다. 양방향을 설정한다고 하더라도 연관관계의 주인은 '다'쪽인 MemberProduct이기 때문에 코드상 바뀔 내용은 없다.

쿼리는 Insert만 3번을 나가고, em.persist가 3번 나간 것을 감안하면 합당하다. 그리고 실행 결과 DB에 정상적으로 값이 저장된 것을 볼 수 있다. 

 

다양한 연관관계 간략 정리


다대일 연관관계

  • 자주 사용하는 연관관계다.
  • 엔티티의 FK와 테이블의 FK를 동일한 곳에서 관리하기 때문에 코드 추적에도 용이하다.

일대다 연관관계

  • 이해하기 어려워 자주 사용하지는 않는다. 
  • 엔티티가 관리하는 FK가 DB에서는 다른 테이블에 있다. 
  • 엔티티가 연관관계의 주인이라 값을 수정하면, Update 쿼리가 한번 더 나간다.
  • @JoinColumn을 사용하지 않으면 다대대 관계와 마찬가지로 엔티티 테이블과 FK 테이블의 중간 테이블(Join Table)이 만들어진다.

일대일 연관관계

  • 어떤 테이블이든지 FK를 관리해도 괜찮다.
  • 대상 테이블에서 FK를 관리하는 경우, 단방향 연관관계는 지원되지 않는다.
  • 양방향 연관관계를 사용할 때, 주 테이블에서 FK를 관리하는지, 대상 테이블에서 FK를 관리하는지에 따른 장단점이 있다.
  • 외래키 선택 시, Unique 옵션을 반드시 달아줘야한다.

다대다 연관관계

  • Join Table 전략으로 양쪽 테이블의 PK를 FK로 가지는 중간 테이블을 만든다. 이 Table에는 다른 필드를 추가할 수 없다.
  • 실무에서는 사용하지 않는다.
  • Join Table을 관리할 엔티티를 하나 만들어서, 일대다 + 다대일 관계로 풀어내야한다.

연관관계 추천 전략

  1. 다대일 관계를 위주로 사용한다.
  2. 다대다 관계는 다대일, 일대다 관계로 풀어서 정리한다. 
  3. 단방향 관계로 먼저 설계하고, 필요하면 양방향 관계로 만들어준다. 

 

댓글

Designed by JB FACTORY