Programing/JPA

JPA 연관관계 매핑 알짜만 빼먹기. 일대일 일대다 다대일 다대다

리커니 2023. 8. 18.
반응형

이번 포스팅에서는 JPA 에서 사용되는 연관관계 매핑에 대해서 알아보도록 하겠습니다.

알아보기에 앞서, 알아두어야 할 용어 몇가지만 확인하고 가도록 하겠습니다.

 

용어

방향( Direction )

한쪽 Entity에서만 참조하는 것을 '단방향' 관계라고 하고

양쪽 Entity에서 서로 참조하는 것을 '양방향' 관계라고 합니다. 

DB 테이블은 항상 양방향 관계이고, 객체를 사용하는 JPA에는 단방향만 존재합니다. 정확히 얘기하자면 Entity간 서로 단방향 관계로 양방향 처럼 보이게 합니다.

 

다중성 ( Multiplicity )

객체간 관계성을 나타내며, 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:N) 이 있습니다.

JPA에서는 @ManyToOne, @OneToMany, @OneToOne, @ManyToMany 로 엔티티간 관계를 표현합니다.

 

연관관계 주인 ( Owner )

객체간 양방향(서로 단방향의 관계)를 설정하기 위해서는 연관관계의 주인을 설정해야 합니다.

JPA에서는 mappedBy 속성을 사용해 연관관계의 주인을 설정하며, 연관관계의 주인은 외래 키를 가지고 있는 Entity로 설정합니다. @ManyToOne 을 제외한 모든 관계 설정에서 활용할 수 있습니다.

 

식별, 비식별 관계

식별자를 기본키 + 외래 키로 사용하는 경우 식별관계라 하고 받아온 식별자는 외래 키로만 사용하고 새로운 식별자를 추가하여 기본키로 사용하는 것을 비식별 관계라고 합니다.

 

단방향 다대일 매핑 (N:1)

위와같은 테이블이 있다고 할 때, CODE가 하나의 GROUP_CODE만 갖는 관계성을 매핑 할 때는 아래와 같이

FK를 설정합니다.

쿼리를 사용하여 조회를 한다면 아래와 같이 작성할 수 있습니다.

select t1.CODE_ID
      , t1.CODE_NM
      , t1.GROUP_CODE_ID
      , t1.GROUP_CODE_NM
  from CODE t1
  join GROUP_CODE t2 on (t2.GROUP_CODE_ID = t1.GROUP_CODE_ID)

 

아래는 관계성이 없는 각각의 Entity를 생성한 코드 입니다.

@Entity
@Table(name = "CODE")
public class Code {
    @Id
    @Column(name = "CODE_ID")
    private String codeId;
    @Column(name = "CODE_NM")
    private String codeNm;
}

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

@Entity
@Table(name = "GROUP_CODE")
public class GroupCode {
    @Id
    @Column(name = "GROUP_CODE_ID")
    private String groupCodeId;
    @Column(name = "GROUP_CODE_NM")
    private String groupCodeNm;
}

Code Entity에 단방향 N:1 관계성을 추가하면 아래와 같습니다.

@Entity
@Table(name = "CODE")
public class Code {
    @Column(name = "CODE_ID")
    private String codeId;
    @Column(name = "CODE_NM")
    private String codeNm;

    @ManyToOne
    @JoinColumn(name = "GROUP_CODE_ID")
    private GroupCode grpCd;
}

이렇게 설정을 할 경우 Code에서는 GroupCode 을 참조할 수 있지만

GroupCode에서는 Code를 참조할 수 없는 N:1 단방향 연관관계가 됩니다.

 

양방향 다대일 일대다 매핑 (N:1, 1:N)

이제 GroupCode 에서도 Code 를 참조할 수 있도록 양방향 매핑을 설정해 보겠습니다.

@Entity
@Table(name = "CODE")
public class Code {
    @Column(name = "CODE_ID")
    private String codeId;
    @Column(name = "CODE_NM")
    private String codeNm;

    @ManyToOne
    @JoinColumn(name = "GROUP_CODE_ID")
    private GroupCode grpCd;
}
@Entity
@Table(name = "GROUP_CODE")
public class GroupCode {
    @Id
    @Column(name = "GROUP_CODE_ID")
    private String groupCodeId;
    @Column(name = "GROUP_CODE_NM")
    private String groupCodeNm;
    
    @OneToMany(mappedBy = "grpCd")
    List<Code> codes = new ArrayList<>();
}

양방향 매핑을 할 때 mappedBy 속성을 통해서 연관관계의 주인(Owner)을 설정합니다. 보통 연관관계의 주인은 DB 테이블 상에 FK를 가지고 있는 Entity로 설정을 합니다. 위의 경우 GROUP_CODE_ID를 FK로 가지고 있는 Code의 groupCode 를 Owner로 설정한 것입니다.

 

참고로 이렇게 양방향 관계를 설정하고 Owner가 아닌 GroupCode 의 toString, equals 메서드 호출 시 

java.lang.StackOverflowError 를 보실 수 있습니다. 이를 해결하기 위해선 toString 메서드에 codes를 제외하고 출력하도록 하고, equals 메서드에서는 @Id 인 변수를 기반으로 비교하도록 Override 해주어야 합니다.

 

양방향 설정한 entity에 접근할 때 failed to lazily initialize a collection of role 오류가 발생한다면 한 트랜젝션 내에서 접근을 했는지 확인 해보세요. 매핑관계에 있는 엔티티 접근 시 한 트랜젝션 내에서 동작해야 하는데 영속성 컨텍스트가 종료되어서 접근 할 수 없다는 오류 입니다. 메서드 내에 @Transactional 을 추가했는지 확인 필요!

 

[toString() Override example]

@Override
public String toString() {
    return "GroupCode{" +
            "groupCodeId=" + groupCodeId +
            ", groupCodeNm=" + groupCodeNm + "}";
}

[lombok 사용 시]

@Entity
@Table(name = "CODE")
@ToString(exclude = "grpCd")
public class Code {
...

 

단방향 일대다 매핑 (1:N)

일대다 관계는 엔티티를 하나 이상 참조할 수 있으므로 자바 컬렉션을 사용해야 합니다. (Collection, List, Set, Map)

@Entity
@Table(name = "CODE")
public class Code {
    @Column(name = "CODE_ID")
    private String codeId;
    @Column(name = "CODE_NM")
    private String codeNm;
}
@Entity
@Table(name = "GROUP_CODE")
public class GroupCode {
    @Id
    @Column(name = "GROUP_CODE_ID")
    private String groupCodeId;
    @Column(name = "GROUP_CODE_NM")
    private String groupCodeNm;
    
    @OneToMany
    @JoinColumn(name = "GROUP_CODE_ID")
    private List<Code> codes = new ArrayList<>();
}

Code Entity에서 연결 부분을 제거하고 GroupCode Entity의 mappedBy 속성제거, @JoinColumn을 추가했습니다.

이렇게 설정을 할 경우 GroupCode에서는 Code를 참조가능, Code에서는 GroupCode를 참조할 수 없는 1:N 단방향 매핑 관계가 됩니다.

 

1:N 단방향 매핑의 경우 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있어서 연관관계 처리를 위한 Update SQL을 추가로 실행해야 합니다. 예를들어 Code에 데이터를 추가하는 경우 

Code insert -> GroupCode insert -> Code에 FK update (양방향의 경우 불필요)

 

이러한 단점 때문에 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용해야 합니다.

 

양방향 일대다 매핑 ( 1:N, N:1 )

일대다 양방향 매핑과 다대일 양방향 매핑은 같은 말 입니다. 그리고 관계형 데이터베이스 특성상 일대다, 다대일 관계는 항상 다 쪽에 FK 가 있습니다. 따라서 @OneToMany, @ManyToOne 둘 중에 Owner는 항상 @ManyToOne을 사용한 곳이 됩니다. 그래서 위에 말씀드린데로 @ManyToOne에는 mappedBy 속성이 존재하지 않습니다.

@Entity
@Table(name = "CODE")
public class Code {
    @Column(name = "CODE_ID")
    private String codeId;
    @Column(name = "CODE_NM")
    private String codeNm;
        
    @ManyToOne
    @JoinColumn(name = "GROUP_CODE_ID")
    GROUP_CODE groupCode;
}
@Entity
@Table(name = "GROUP_CODE")
public class GroupCode {
    @Id
    @Column(name = "GROUP_CODE_ID")
    private String groupCodeId;
    @Column(name = "GROUP_CODE_NM")
    private String groupCodeNm;
    
    @OneToMany
    @JoinColumn(name = "GROUP_CODE_ID")
    private List<Code> codes = new ArrayList<>();
}

다대일( N:1, 1:N ) 매핑과 차이를 보시면 mappedBy 속성을 사용하지 않고 @JoinColumn 을 사용해서 매핑을 하였습니다.

이렇게 되면 문제없이 실행은 되지만, 둘 다 같은 키를 관리하므로 문제가 발생 할 수 있습니다. 따라서 @ManyToOne 쪽에 insertable = false, updatable = false를 추가하여 읽기만 가능하게 해야 합니다. 

 이 방법은 일대다 양방향 매핑이라기보단 일대다 단방향 매핑 반대에 다대일 단방향 매핑을 읽기 전용으로 추가해서 일대다 양방향 처럼 보이도록 하는 방법입니다. 따라서 일대다 단방향 매핑의 단점을 그대로 가지게되므로 다대일 양방향 매핑을 사용하도록 합시다.

 

복합키에서의 관계설정 (공통)

추가적으로 일대다든 다대일이든 (N:1 - 1:N, 1:N - N:1) 이든 한쪽에 복합키가 사용될 경우 (@EmbeddedId 가 사용될 경우) Hibernate 에서 복합 기본 키에서 자동으로 엔티티를 매핑 할 때 자식 엔티티의 변경이 부모 엔티티에 영향을 미칠 수 있기 때문에 항상 복합키가 사용된 곳에서는 insertable = false, updatable = false를 설정해 주어야 합니다.

설정하지 않을 경우

org.hibernate.MappingException (should be mapped with insert="false" update="false")

오류를 보실 수 있습니다.

 

단방향 일대일 매핑 ( 1:1 )

매핑 방식은 다른 연관관계 설정과 동일 합니다. @OneToOne을 쓸 뿐..

 

양방향 일대일 매핑 (1:1, 1:1)

이것 또한 동일 합니다. mappedBy를 사용해서 Owner를 설정해 줍니다.

 

단방향 다대다 매핑 ( N:N )

RDBMS에서는 다대다 매핑이 불가능 합니다. 그래서 매핑 테이블을 생성하여 다대다 관계를 설정하는데, JPA에서는 2개의 객체로 다대다를 풀어낼 수 있습니다. (MAPPING Entity를 생성하지 않아도 됨)

 

위의 변경된 DB 의 테이블을 기준으로 코드를 작성하겠습니다.

@Entity
@Table(name = "CODE")
public class Code {
    @Column(name = "CODE_ID")
    private String codeId;
    @Column(name = "CODE_NM")
    private String codeNm;

    @ManyToMany
    @JoinTable(name = "MAPPING",
        joinColumns = @JoinColumn(name = "CODE_ID"),
        inverseJoinColumns = @JoinColumn(name = "GROUP_CODE_ID")
    )
    private List<GroupCode> groupCodes = new ArrayList<>();
}

@ManyToMany 를 사용했고 @JoinTable을 활용하여 MAPPING Entity 를 생성하지 않고도 매핑을 하였습니다.

joinColumns 속성은 현재 테이블에서 매핑할 조인 컬럼명을, inverseJoinColunms 속성은 반대 Entity와 매핑할 조인 컬럼 명을 지정합니다.

 

양방향 다대다 매핑 ( N:N )

다른 양방향 매핑과 마찮가지로 반대 Entity에 mappedBy 설정을 해주면 됩니다.

@Entity
@Table(name = "CODE_GROUP")
@Data
public class GroupCode {
    @Id
    @Column(name = "GROUP_CODE_ID")
    private String groupCodeId;

    @Column(name = "GROUP_CODE_NM")
    private String groupCodeNm;

    @ManyToMany(mappedBy = "groupCodes")
    List<TEST_CODE> codes = new ArrayList<>();
}

 

다대다 매핑의 문제점 해결

다대다 매핑의 경우 매핑 테이블에 컬럼이 추가되면 @ManyToMany 를 사용할 수 없습니다. 왜냐면 매핑 테이블에 추가한 컬럼들을 매핑 할 수 없기 때문입니다. 결국 매핑 엔티티를 생성해야 하고 이곳에 추가한 컬럼을 매핑해야 합니다.

예를들어 MAPPING 테이블에 REG_DT 라는 등록일자 컬럼이 추가된다면 (다른 하나라도 컬럼이 추가된다면) 해당 테이블에 대한 Entity를 생성해야 합니다.

FK를 가지고 있는 주체가 MAPPING 이기 때문에 Code Entity는 mappedBy를 사용하여 Code의 Owner가 Mapping 이라는 설정을 해줍니다.

@Entity
@Table(name = "CODE")
public class Code {
    @Id
    @Column(name = "CODE_ID")
    private String codeId;

    @Column(name = "CODE_NM")
    private String codeNm;

    @OneToMany(mappedBy = "code")
    private List<Mapping> codeMapping = new ArrayList<>();
}

Mapping Entity를 생성합니다.

@Entity
@Table(name = "MAPPING")
@IdClass(MappingId.class)
public class Mapping {

    @Id
    @ManyToOne
    @JoinColumn(name = "CODE_ID")
    private Code code;

    @Id
    @ManyToOne
    @JoinColumn(name = "GROUP_CODE_ID")
    private GroupCode groupCode;

    @Column(name = "REG_DT")
    private LocalDateTime regDt;
}

복합 기본 키를 사용해야 하기 때문에 @IdClass 를 사용하여 키를 매핑하고 @ManyToOne으로 각각의 매핑 객체를 연결해 줍니다. 그리고 추가된 REG_DT 컬럼을 추가해줍니다.

MappingId 에서는 각각 Mapping의 변수와 연결해줍니다. IdClass의 경우 참조 주소가 아닌 같은 값이 같으면 같다고 판단해야 하기 때문에 @EqualsAndHashCode (lombok annotation) 을 추가해 줍니다. lombok을 사용하지 않는다면 IdClass 변수의 값을 비교하도록 equals와 hashCode 메서드를 Override 해주어야 합니다.

@AllArgsConstructor
@EqualsAndHashCode
public class MappingId implements Serializable {
    private CODE code;
    private CODE_GROUP groupCode;
}

여기서도 ToString이나 equals 메서드 호출 시 양방향 연결에 의한 무한루프(java.lang.StackOverflowError)가 발생할 수 있으니 Mapping Entity 에 출력 제외 코드를 추가해주셔야 합니다.

@ToString(exclude = {"code", "groupCode"})

Entity를 설계할 때는 복합키 보다는 대리키를 사용하여 하나의 @Id로 Entity를 특정할 수 있게 하는 것이 간편합니다(비식별 관계로 구성). 복합 기본 키를 사용하게 되면 여러 복잡한 문제에 부딪힐 수 있으니 설계할 때 유념하도록 합시다.

 

반응형

댓글

💲 추천 글