Dev/JPA

JPA 에서 연관관계의 정의

린네의 2023. 12. 27. 01:16

 
12월동안 JPA 강의를 수강하고 개인 프로젝트에 적용해보면서 가장 힘들었던 내용이다.
까먹기전에 수업들었던 내용을 간략하게 정리하려고 한다. 사실 강의를 한번들어서 연관관계를 파악하기에는 쉽지 않았다. 역시 직접해보는게 최고다. 개인 프로젝트 적용할 때 속도는 느렸지만 도움이 많이 되었기 때문에  혹시 이 개념이 헷갈린다면
시간이 좀 걸리더라도 본인이 직접 처음부터 강사님의 예제와 유사한 구성을 새로 기획하여 만들어 보는 것을 추천한다.

 

1.  연관관계란?

 
말 그대로 두 객체가 연관되어 서로의 객체를 참조 가능한 상태를 의미한다.
 
 

2. 방향(Direction) 의 종류와 의미 , 단방향과 양방향이란?

 
 두 객체(Entity) 가 있을 때,  서로 연관관계가 있다면 양방향 연관 관계, 한쪽에 대해서만 관계가 있다면 단방향 연관관계록 표현할 수 있다.
 
 
 1) 단방향 연관 관계
 
  예를 들어 소속팀과, 팀에 속한 멤버가 있다고 하자. 이 때 소속팀은 Team 이라는 객체로, 멤버는 Member 라는 객체로 정의할 수 있다.
 
Member 객체가 어떤 팀에 속해있는지에 대해 알기 위해서는 Team 의 정보가 필요하고, 결과적으로는 Team 이 가진 기본키를 참조해야 한다. 이렇게 Member 가 Team 에 대한 정보를 참조하지만, Team 에서는 따로 Member 에 대해 참조하지 않을 때 단방향이라고 표현한다.
 
 Member → Team 
 
 
코드로 표현하면 다음과 같다.

// Member 객체
@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private  String username;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
 }

 
 

// Team 객체 
@Entity
public class Team {
    @Id @GeneratedValue
    @Column(name = "team_id")
    private Long id; 
    private String name;
  }

 
 
 
 2) 양방향 연관관계
 
  말 그대로 단방향이 양쪽으로 있는 것이다.
소속팀과 멤버의 관계에서,  Team 객체에 대해서도 Member 의 정보를 조회할 수 있다. 단 한 소속팀엔 여러명의 멤버객체들이 연관 되므로 List<Member> 를 통해 연관관계를 표현해야 한다. 
 
 
 Member → Team 
 Member ← Team 
 
 
 
코드로 표현하면 다음과 같다.
 

// Member 객체
@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private  String username;
    
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
 }
// Team 객체 
@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<>();
  }

 
 
 양방향 연관관계를 사용할 경우 Member → Team 을 통해 값을 관리할 것인지,  Member← Team 을 통해 값을 관리할 것인지 정해야 한다. 이것을  연관관계의 주인을 지정한다고 표현한다.  여기서  값을 관리한다는 말은 등록과 수정이 발생했을 때 어떤 방향의 관계가  값이 변경되는 기준이 되는 것인지를 의미한다.
 
   예시를 들어보자, memberA 는 원래 소속팀이 teamT 였다가 teamR 로 변경 되었다.  Member → Team 를 기준으로  'memberA 의 팀이 teamR 로 변경되었다' 는 내용을 갱신하려면  Member 객체에 있는 Team 정보를 변경 해주면 된다.  역으로 Member← Team 를 기준으로  동일한 내용을 갱신하려면 teamT 에 있는 members 에서 memberA 를 찾아 삭제하고,  teamR 에 대해 memberA 를 다시 추가해줄 수 있다. 
 양쪽에서 모두 데이터를 변경할 수 있기 때문에 양방향 관계에서는 연관관계의 주인 이라는 매핑 규칙을 통해 데이터의 관리 기준을 정의 해야한다.
 
연관관계의 주인을 지정하는 규칙은 다음과 같다.
 

  •  외래키가 있는 곳을 주인으로 정하자
  •  주인이 아닌 곳에서는 mappedBy 를 추가
  •  외래키가 있는 곳이 연관관계의 주인이 된다.  일반적으로 DB 에서 1:N 관계의 경우 N 에 해당하는 테이블에 외래키가 있으므로, N 에 해당하는곳을 연관관계의 주인으로 정해야 한다.

 
일반적으로 소속 팀에는 여러명의 멤버가 들어갈 수 있고, 한 멤버는 여러가지 소속팀에 속하는 것이아니라 한팀에 속할 수 있다. 
따라서  Member 와 Team 의 관계는 Member 가 N, Team 이 1 에 해당한다. 이 내용을 위 규칙에 따라 그대로 대입해보면 
연관관계의 주인은 Member 가 됨을 알 수 있다.
 
위 코드에서 Team 은 연관관계의 주인이 아니기 때문에, team.members 에 해당하는 필드에 mappedBy 키워드가 붙은 것이다.
 
mappedBy = ' 연관관계에서 1 에 해당하는 객체' 로 표현할 수 있다.
 
실제 코드에 반영할 때, 연관관계의 주인을 기준으로 값을 먼저 세팅하고 그 다음 mappedBy 에 해당하는 곳에 값을 세팅해야한다.
양방향이므로, 양쪽에 값을 세팅해야 함을 잊으면 안된다.
 

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

    Member member = new Member();
           member.setUsername("member1");
            
           team.getMembers().add(member); // 없어도 member 에 team 정보는 들어감
           member.setTeam(team); //연관관계의 주인에 값 설정
            
    em.persist(member);

 
 
위 코드에서 가장 중요한 것은 member.setTeam(team) 이다.  member 객체에서 관리하는 team 의 데이터가 기준이기 때문에 해당 코드가 빠지면 실제 db 에는 team_id 값은 null 로 매핑 된다.
 
반면, team.getMembers().add(member) 은 없어도 데이터가 반영됨에는 큰 문제가 없다. 
그러나 순수한 객체 관계를 고려하여 양쪽 다 값을 설정하는것이 권장 된다.
 
이렇게 양방향 관계를 설정할 때, 무한루프현상을 주의 해야한다.
 

  • 주의 ! toString(), lombok, JSON 

toString() 으로 예를 들어보자.  양방향 관계가 설정 되어 있는 객체 Member, Team 에 모두 toString 이 선언 되어 있을 경우 코드는 다음과 같다.
 

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

 
 
이 때 실제 호출 되는 순서는
 
Member.toString 호출  → Member.team 호출 -> Member.team.members() 호출 -> members 에서 각각 다시 Member.toString() 호출 .. 
 
이므로 무한루프에 빠지게 된다.
 
같은 맥락에서 Controller 에서 Return 시 엔티티 자체로 리턴해서도 안된다. 
 
실무에서는 단방향을 쓰는 것을 권장한다.
 

 

3. 다중성(Multiplicity) 란 ? 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)

 
 
 데이터베이스에서 테이블간의 데이터 관계를 의미할때, 1:1, 1:N, N:N 등으로 관계를 정의할 수 있는 것 처럼
객체도 유사하게 연관관계를 가질 수 있다.
 
1)  단방향 다대일 ( N : 1 ) 연관관계 
 

  • 가장 많이쓰는 연관관계
  •  N 쪽에 외래키가 있다
  •  뒤집으면 일대다 ( 1 : N ) 연관관계가 된다

 
@ManyToOne 을 통해 연관관계를 정의할 수 있다.  Many → One 방향의 관계를 의미하므로 Member 과 Team 관계를 생각하면 
Member 가 Many , Team 이  One 에 해당한다. 즉 Member 객체에서 Team 객체를 조회하는 것이 가능하다.
 
코드로 표현하면 다음과 같다.
 

// Member 객체
@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private  String username;
    
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
 }

 
 
위 코드는 Member → Team 인 단방향 연관관계이면서 Many → One 인 다대일 관계이다.  
 
 
2)  양방향 다대일 ( N : 1 ) 연관관계 
 

  • 외래키가 있는 쪽에 연관관계의 주인

 양방향이라는 개념 자체가 단방향이 두개 있는 개념이기 때문에,  Many → One 와 One → Many 가 동시에 있다고 생각하면 쉽다.
Member 와 Team 을 예시로 들면,  Member → Team 은 Many → One 에 해당하고 Team -> Member 은 One → Many 에 해당하는 관계다.  위에서 열심히 설명한 양방향 관계에서, 연관관계의 주인은 외래키가 있는 곳이 되어야 함을 정의 했었다. 
 
따라서 양방향일경우,  @ManyToOne 이 연관관계의 주인에 해당 된다.
 
 
3)  단방향 일대다 ( 1 : N ) 연관관계 
 

  • 단방향일 경우 1 이 연관관계의 주인이다 

 @OneToMany 를 통해 연관관계를 정의할 수 있다. 주의해야할 점은 단방향이라는 것이다. 
공식적으로 일대다가 양방향인 경우는 존재하지 않는다.  일대다를 뒤집으면 다대일이기 때문에, 양방향을 사용하고 싶다면 다대일 양방향 형식으로 구현하면 된다.
 
 반대편 방향의 객체의 외래키를  관리하는 특이한 구조를 가지기 때문에( 1 에 해당하는 객체가 데이터를 관리하는 연관관계의 주인이 되므로 ) 사실 일반적으로 생각하는 연관관계 설정에 해당하진 않는다. 객체가 관리하는 외래키가 다른 테이블에 있다는 것 자체가 단점이기 때문이다.  실무에서는 일대다 매핑보다는 차라리 다대일 양방향 매핑을  사용하는 것이 훨씬 좋다.
 
구현자체는 @JoinColumn 을 사용해서 아래와 같이 Team 객체 내에 선언한다 ( Team   Member 가 One   Many 에 대응하기 때문이다 ) 
 

 @OneToMany
 @JoinColumn(name = "team_id")
 private List<Member> memberlist = new ArrayList<>();

 
 
 
4)  양방향 일대다 ( 1 : N ) 연관관계 
 
이런 연관관계는 존재하지 않는다. 양방향 다대일 관계를 사용하도록 하자.
사실 방법이 있긴 하지만, 권장되지 않기 때문에 나중에 내가 실무에서 쓸 내용을 정리하는 게시글의 목적에 맞지 않아 정리하지 않도록 하겠다.
 
 
 
5)  단방향 일대일 ( 1 : 1 ) 연관관계 
 

  • 일대일 관계는 뒤집어도 일대일 
  •  다대일과 유사
  •  주테이블에 외래키

@OneToOne 을 통해 일대일 연관관계를 정의할 수 있다. 
 
실제 세계에서 일대일 관계를 생각해보자. 회원과 사물함이 있다. 일반적으로 하나의 사물함은 한명의 회원만이 사용할 수 있다. 한명의 회원은, 하나의 사물함만 이용할 수 있다. 
 
회원을 Member, 사물함을 Locker 라는 객체로 정의하면   Member →  Locker 는 One → One 에 해당하는 단방향 연관관계가 성립하게 된다. 반대의 경우도 동일하다.
 
일대일 관계는, 어떤 객체를 주 객체로 삼냐에 따라서 외래키의 포함 여부가 달라진다.  Member 를 주테이블로 삼는다면 Member 에서 Locker 로 접근하여 정보를 관리할 수 있다.  코드로 작성하면 아래와 같다.
 

// Member 객체 
@Entity
public class Member () {

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

@Column(name = "member_name")
private String name;

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

}


// Locker 객체 
@Entity
public class Locker () {

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

@Column(name = "locker_name")
private String name;

}

 
 
 
아래는 Locker 을 주테이블로 삼았을 경우에 대한 예시이다.
 

// Member 객체 
@Entity
public class Member () {

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

@Column(name = "member_name")
private String name;



}


// Locker 객체 
@Entity
public class Locker () {

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

@Column(name = "locker_name")
private String name;

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

}

 
 
이때 주의할 점이 있는데,  데이터베이스에 FK 에 해당하는 값에 유니크 제약 조건을 추가해야 일대일이 성립한다.
 
 
6)  양방향 일대일 ( 1 : 1 ) 연관관계 

  •  외래키가 있는 곳이 연관관계의 주인

일대일의 양방향 관계는, 다대일의 양방향과 동일하다.  주테이블에 외래키가 지정되고, 외래키를 가지는 쪽을 연관관계의 주인으로 설정한다.
 
만약 Member 가 주테이블이라면, 외래키를 가지게 되고 양방향 관계는 아래 코드 처럼 작성되게 된다.
 

// Member 객체 
@Entity
public class Member () {

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

@Column(name = "member_name")
private String name;

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

}


// Locker 객체 
@Entity
public class Locker () {

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

@Column(name = "locker_name")
private String name;

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

}

 
 
 
 
 추가적으로, 연관관계의 주인을 지정하고 싶은 테이블에 외래키가 없다면 어떻게 될까? 정답은 JPA 로는 지원하지 않기 때문에 불가능하다 이다.  
 
 
 
 
7) 단방향, 양방향 다대다 연관관계 
 
@ManyToMany 를 통해 연관관계를 정의할 수 있다.
해당 연관관계는 실무에서 사용하면 안되는 연관관계다 따라서 구현 방법 보다는 ManyToMany 대신 무엇을 사용하면 되는지에 대해 정리하려고 한다.
 
보통 객체의 연관관계가 데이터베이스의 테이블 관계와 유사하게 가는데,  테이블에서 다대다는 불가능하다. 실제로 데이터베이스에서는 N:M 를 표현하기 위해서는 중간에 관계 테이블을  별도로 만들어 N : M 을 구현한다.  
 
실제 주문과 상품의 관계를 생각해보면, 여러개의 주문에 여러개의 상품이 매핑될  수 있다.  A 주문에 대해 ㄱ,ㄴ,ㄷ 상품이 들어 갈 수 있고 ㄱ 상품이 A, B, C 주문에 각각 포함될 수도 있기 때문이다. 
 
이런 관계를 테이블로 표현하기에는 한계가 있기 때문에 주로 중간에 '주문 정보 테이블' 을 새로 생성하여  연관관계를 맺는다.
주문과 주문정보 테이블은 1 : N  를,   주문정보테이블과 상품은 N : 1  관계를 맺는다 . 주문 정보를 Order,  주문 정보 테이블을 OrderItem, 상품을 Item 객체로 표현하면  Order → OrderItem 은 One →  Many ,  OrerItem →  Item 은 Many →  One 에 대응 된다. ( A 주문에 대해 ㄱ, ㄴ, ㄷ 상품이 포함되었으면 OrderItem 에 A 주문 정보는  'A,ㄱ',   'A,ㄴ',   'A,ㄷ' 형식으로 들어가기 때문에 
 Order → OrderItem 은 One →  Many 이고,  상품 또한 A 주문외에  B, C 주문에 각각 포함 될 경우 'B,ㄱ',  'C, ㄱ' 가 성립하므로  OrerItem →  Item 은 Many →  One 가 성립 된다. )
 
객체 구현시에도 데이터베이스에서 처럼 '주문 정보 테이블' 에 해당하는 엔티티를 생성하고,  @ManyToMany 를 @OneToMany , @ManyToOne 으로 분리해서 사용하면 된다.
 
@ManyToMany 가 공식적으로 사용될 수 있는 이유는 객체에서는 Collection 가 사용가능하여 List 형식등으로 연관객체를 참조할 수 있기 떄문이다. 하지만 실전에서는 예시처럼 중간테이블이 단순하지 않기 때문에, 테이블의 관계가 아주 복잡해질 수 있다. 따라서  데이터베이스처럼 풀어서 사용하도록 하는 것이 좋다.
 
 
이상으로 연관관계 매핑에 대한 정리가 끝났다.
아직 지연 로딩이 개념이 남았지만.. 삽질을 통해 확실하게 이해한 것 같아서 뿌듯하다 :> 
 
나중에 개인적으로 보기 위한 용도가 커서 최대한 자세하게 글로 풀어 설명 했는데 누군가에게 도움이 되면 기쁠 것 같다.
 
( 사실 김영한 강사님의 강의가 워낙 유명하기도 해서 동일한  교재 피피티 테이블 구성도가 구글링을 조금만해도 돌아다니기 때문에 그 내용은 웬만하면 빼고 정리 했다.)