본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 4: Spring Boot에서 Entity 연관관계

들어가기

엔티티는 여러 개가 존재하고 엔티티 간에 관계가 존재하다. 이런 관계는 대부분 데이터베이스 기준으로 ER 다이어그램에서 카디널리티(cardinality)에 해당한다. 결국 데이터베이스에 테이블 간에 관계를 엔티티로 매핑하는 방식을 기술한다. 이런 엔티티 간에 관계를 연관관계라고 한다.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

샘플

아래는 샘플로 사용할 엔티티 클래스 다이그램이다. 사용자가 서비스에 가입하고 게시물을 관리하기위한 구조이다.

Fig 01. 게시글 글래스 다이어그램

앞으로 다룰 엔티티 연관관계를 다루기 위해 만든 샘플이므로 참고만 하시기 바란다.

연관관계

관계는 다중성과 방향을 알아야 한다. 다중성은 두 대상이 서로 1:1, 1:N, N:M 관계인지를 의미한다. 방향은 한쪽 방향인지 양 방향인지를 의미한다.

연관관계에 대한 매핑을 위한 어노테이션으로 4가지가 있다.

  • @OneToOne
  • @OneToMany
  • @ManyToOne
  • @ManyToMany

그리고 이런 연관관계에는 방향성이 있다. 방향은 단방향 또는 양방향이 된다. 또한 이런 연관관계와 같이 @JoinColumn 어노테이션이 사용된다. 이 어노테이션을 사용해서 다른 테이블과 조인하는 상세한 설정을 할 수 있게 한다.

1:1 연관관계

1:1 연관관계는 @OneToOne 어노테이션을 사용한다. 예를 들어 구독을 위한 서비스로 구독 엔티티와 멤버 간에 관계를 보자. 구독은 서비스에 가입하면 생성 되고 이를 가입한 멤버가 존재한다. 멤버는 한번만 구독할 수 있다. 이를 클래스 다이어그램으로 표현하면 다음과 같다.

Fig 02. Member과 Subscription 엔티티

Subscription 테이블과 Member 테이블 간에 1:1 연관관계를 가진다. 그래서 Subscription은 Member에 대한 참조를 하게 된다. 이를 데이터베이스 ERD로 표현하면 아래와 같다.

Fig 03. member와 subscription 테이블 ERD

1:1 연관관계에 의해 subscription 테이블에는 구독한 멤버를 가리키는 외래키 member_id을 가진다.

단방향 관계

먼저 단방향 관계를 살펴보자. 이를 엔티티로 정의해보자.

@Entity
open class Subscription (
  @Id
  @GeneratedValue
  private var id: Long?,
  @OneToOne
  private var member: Member,
  // ...
){
  // ...
}

@OneToOne 어노테이션으로 관계를 표현하고 있다. Subscription를 조회하게 되면 자동으로 Member도 같이 조회된다. 주의할 개념이 있는데 이렇게 연관관계를 선언한 곳이 연관관계 소유 엔티티라고 한다. 해당 엔티티의 매핑 테이블에서 연관 테이블의 외래키가 생성된다. 대부분 단방향 엔티티에서 연관관계를 설정할 경우 연관관계 소유 엔티티가 된다.

데이터베이스의 subscription 테이블에는 member와 연결을 위해 member_id가 자동으로 생성된다. 이 외래키는 객체 필드 이름을 기반으로 해서 아래와 같은 생성 규칙에 의해서 생성된다.

  • 생성규칙: 필드 이름 + 밑줄(_) + 참조 엔티티 ID 이름

@OneToOne 어노테이션에서 설정 가능한 속성은 아래와 같다.

  • targetEntity: 연관된 엔티티 타입 정보, 기본은 연관된 필드 타입을 사용
  • cascade: 연관 엔티티에 대한 영속성 처리 방법 설정, 열거형 CascadeType으로 지정
  • fetch: 연관 엔티티를 늦게 또는 바로 가져올지 여부, 열거형 FetchType으로 지정
  • optional: 연관 관계가 선택인지 필수 인지 여부 (false이면 필수, 기본: true)
  • mappedBy: 관계를 가지는 필드 이름 설정, 양방향 관계에서 사용
  • orphanRemoval: 삭제시 연관 엔티티 삭제 여부 (true이면 같이 삭제, 기본: false)

targetEntity은 굳이 설정할 필요는 없다. 연관 엔티티 타입과 전혀 다른 타임을 사용할 일은 없기 때문에 알아서 타입 정보를 가져온다. cascade에 대한 자세한 내용은 아래 CascadeType 항목을 참조하기 바란다. fetch는 연관 엔티티의 로딩 시점을 설정한다. 자세한 내용은 아래 지연 로딩 항목을 참조하기 바란다. optional은 옵션 여부인데 false인 경우 반드시 엔티티가 있어야한다. 데이터베이스 관점에서는 not null이되어야 한다. mappedBy은 차후애 양방향 관계에서 다룰려고 한다. 그리고 orphanRemoval은 고아 객체가 생기지 않게 자신이 삭제될 때에 연관 엔티티도 같이 삭제되는지 여부를 설정한다. 고아객체는 연관관계가 끝어진 자식 엔티티를 말한다. 즉, 고아 객체는 해당 객체로 접근할 수 있는 엔티티가 없게 된다는 의미이다.

양방향 관계

양방향 관계는 단방향 관계가 쌍으로 구성되었다고 보면 된다. 클래스 다이어그램으로 보면 양쪽으로 연관 관계가 구성된다. 데이터베이스 관점에서는 외래키에 의해 연결되어 있기 때문에 변경사항이 그대로 적용된다. 양방향 관계는 기존 단방향 관계에서 반대 방향 관계를 추가하면 된다. 이를 엔티티로 정의해보자.

@Emtity
open class Member (
  @Id
  @GeneratedValue
  private val id: Long?,
  @OneToOne(mappedBy="member")
  private subscription: Subscription?,
  // ...
){
  // ...
}

이전에 먼저 연관 관계를 소유한 엔티티 방향이다. 이번에는 소유권이 없는 반대 방향 관계에서 mappedBy 속성을 사용해 연관 관계가 있는 필드를 지정한다. 이런 엔티티를 연관 관계 소유권이 없는 엔티티라고 하며, 외래키 생성은 하지 않고 mappedBy 의해서 연관관계 매핑을 처리한다.

Subscription 엔티티에서 Member형인 member 필드에 의해서 Member 엔티티와 연관 관계를 만들었다. Member 엔티티에서 해당 연관관계를 가진 필드인 member을 지정하면 된다. 주의할 부분은 여기서 두 엔티티간에 서로 참조가 순환관계에 있기 때문에 주의가 필요하다.

추가로 엔티티 간에 연관관계를 끈기 위해서는 어떻게 할까? 서로 간에 연관 관계갖는 두 엔티티는 독립적인 식별자를 가지고 있다. 이들 간에 연관 관계 끝기 위해서는 참조하는 필드에 null 값을 할당하면 된다. 데이터베이스 관점에 보면 외래키가 null이 된다.

1:N 연관관계

@ManyToOne과 @OneToMany 어노테이션으로 여러 엔티티가 한 엔티티를 참조하는 관계을 설정한다. 예를 들어 멤버와 게시글의 관계를 보자. 멤버는 여러 게시글을 작성할 수 있지만, 게시글 입장에서 소유자는 1명이 된다. 클래스 다이어그램으로 표현하면 다음과 같다.

Fig 04. Member와 Post 엔티티

게시글인 Post에 소유자인 owner 참조로 Member와 연관되어 있다. 이를 이를 데이터베이스 ERD로 표현하면 다음과 같다.

Fig 05. member와 post 테이블 ERD

post 테이블의 외래키 owner_id을 통해서 member 테이블과 1:N 관계를 갖는다.

단방향

먼저 단방향 엔티티를 정의해보면 아래와 같다. @ManyToOne 어노테이션을 사용해서 정의할 수 있다.

@Entity
open class Post (
  @Id
  @GenratedValue
  private var id: Long?,
  @ManyToOne
  var owner: Member,
  // ...
) {
  //...
}

어떻게 보면 앞의 1:1 관계와 비슷하다. 그러나 이는 Post 입장에서 그렇게 보일 뿐 Member 입장에서는 여러 Post을 갖게 된다. @ManyToOne 어노테이션을 사용하는 이유는 정확한 표현도 있지만 양뱡향 사용도 고려해야 하기 때문이다. 즉 여러 개 Post가 한개 Member로 연결된다고 보면 된다. @ManyToOne에서도 마찬가지로 외래키 식별자 생성 규칙과 자세한 속성은 @OneToOne을 참고하시기 바란다. 참고로 @ManyToOne 에서는 mappedBy 속성은 없다.

양방향

이번에는 양방향 엔티티을 정의해보겠다. 앞애 단방향의 반대방향 관계를 정의하는데 @OneToMany 어노테이션을 사용한다. 즉 N:1을 반대로 1:N으로 이해하면 된다.

@Entity
open class Member(
  @Id
  @GeneratedValue
  private var id: Long?,
  @OneToMany(mappedBy="owner")
  private var posts: MutableList<Post> = mutableListOf(),
  // ...
) {
  // ...
}

mappedBy 속성에 의해서 Post 엔티티에서 관계를 가지는 필드 이름을 지정하면 된다.

M:N 연관관계

이번에는 M:N 연관 관계를 살펴보자. M:N 관계는 사로 관계가 1개가 아닌 여러 개로 서로 연관되어 있는 형태이다. 이번 예는 게시글과 해시태그 관계이다. 게시글은 여러 해시태그를 가질 수 있고 한 해시태그도 여러 게시글에 사용될 수 있다.

Fig 06. Post와 HashTag 엔티티

ERD로 표현하면 다음과 같다.

Fig 07. post와 hash_tag 테이블 ERD

M:N 관계는 두 테이블 만으로는 표현할 수 없고 중간에 매핑 테이블이 필요하다. 중간에 매핑 테이블을 이용해서 양쪽 테이블과 1:N관계를 만들어 결국 양쪽 테이블과에 M:N 관계를 만들 수 있다.

단방향

이제 단방향 엔티티로 정의해보자.

@Entity
open class HashTag (
  @Id
  @GeneratedValue
  private var id: Long?,
  @ManyToMany
  private var posts: MutableList<Post>,
  // ...
) {
  // ...
}

M:N 관계는 @ManyToMany 어노테이션을 사용해서 매핑할 수 있다. 별거 없이 그대로 어노테이션을 사용하면 된다. 이 중간 매핑 테이블은 아래 생성 규칙에 의해서 생성된다.

  • 테이블 이름: 엔티티 이름 + 밑줄(_) +연관 필드 이름
  • 컬럼 이름: 필드 이름 또는 연관 엔티티 이름 + 밑줄(_) + 연관 엔티티 ID 이름

컬럼 이름이 조금 헤갈릴 수 있다. 필드 이름이 있으면 필드 이름을 사용하고 없으면 엔티티 이름을 사용한다.

Fig 08. hash_tag_posts 테이블

단방향인 경우 posts 필드 명이 있으므로 “posts”를 사용하고 Post에 식별자로 “id”를 사용해 “posts_id”가 컬럼 이름으로 Post 외례키로 사용한다. 그리고 HashTag에 대한 엔티티는 필드가 없으므로 필드 이름과 HashTag의 식별자를 사용해서 “hash_tag_id”를 사용한다. 다음에 양방향에서 HashTag에 대한 필드가 있으므로 필드 이름이 사용된다.

양방향

이번에는 양방향 관계을 가지는 엔티티를 정의해보자. 기존에 단뱡향 관계에서 반대 방향에 대한 관계를 설정한다.

@Entity
open class Board (
  @Id
  @GeneratedValue
  private var id: Long?,
  @ManyToMany(mappedBy="posts")
  private var hashTags: MutableList<HashTag>,
  // ...
) {
  // ...
}

mappedBy 속성을 사용해서 반대방향에 대해 연결할 필드를 설정해주면 된다.

Fig 09. 양방향 hash_tag_posts 테이블

HashTag 엔티티에 대한 연결 필드가 생겼으므로 HashTag에 대한 외래키로 “hash_tags_id”가 사용된다.

@JoinColumn 어노테이션

@JoinColumn 어노테이션은 엔티티 연관 관계을 위한 조인 설정을 한다. @JoinColumn 어노테이션은 연관관계 어노테이션과 같이 사용되며 만약 사용되지 않으면 기본값이 적용된다. 지원되는 속성은 다음과 같다.

  • name: 외래키 컬럼 이름
  • referencedColumnName: 외래키 컬럼에 의해 참조되는 테이블의 컬럼 이름
  • foreginKey: 조인되는 컬럼에 지정하는 외례키 제약 조건, ForeignKey 어노테이션으로 설정
  • table: 컬럼이 포함되는 테이블 이름
  • unique: 고유키 여부(기본: false)
  • nullable: 외래키 null 여부(기본: true)
  • insertable: 추가 쿼리에 포함 여부(기본: true)
  • updatable: 업데이트 쿼리에 포함 여부(기본: true)
  • columnDefinition: 컬럼에 대한 DDL 생성시 사용되는 SQL 구분

name은 외래키 컬럼 이름으로 DDL에 영향을 준다. 이 속성이 더 우선한다. referencedColumnName은 외래키에 의해 참조하는 테이블의 컬럼 이름이다. 기본적으로 기본키을 사용하는데 다른 컬럼을 사용할 경우 설정할 수 있다. 단, 조건이 유일성을 가지고 있으면 된다. 만약, 이런 외래키에 대한 제약 조건을 변경하고 싶다면 foreginKey를 제약조건을 변경할 수 있다. 그외 table, unique, nullable, instable, updatable, collumnDefinition은 이전에 @Column 속성과 동일하다.

@ForeiginKey 어노테이션에서 다음과 같은 속성을 지원한다.

  • foreginKeyDefinition: 외래키 제약 조건 정의
  • name: 외래키 제약 조건 이름
  • value: 스키마 생성시 외래키 제약 조건 지정 여부, 열거형 ConstraintMode로 설정
    • CONSTRAINT: 제약조건 적용
    • NO_CONSTRAINT: 제약 조건 미적용
    • PROVIDER_DEFAULT: 공급자 정의 값 사용 (기본)

@JoinTable 어노테이션

연관 관계 매핑에서 사용된다. 연관 관계의 소유자 측에서 설정된다. 보통 M:N 관계와 단방향 1:N 관계에서 사용된다. 물론 연관 관계에서도 사용할 수 있다. @JoinTable에서 제공되는 속성은 다음과 같다.

  • name: 조인 테이블 이름
  • foreignKey: 테이블 생성시 joinColumns에 해당하는 컬럼에 대한 외래키 제약조건 생성 설정, ForeignKey 어노테이션으로 설정(아래 참조)
  • joinColumns: 조인 테이블의 외래키 컬럼으로 소유 엔티티의 기본 테이블 참조, JoinColumn 어노테이션으로 설정(자세한 내용은 아래 참조)
  • inverseForeignKey: 태이블 생성시 inverseJoinColumns에 해당하는 컬럼에 대한 외래키 제약조건 생성 설정, ForeignKey 어노테이션으로 설정(아래 참조)
  • inverseJoinColumns: 조인 테이블의 외래키 컬럼으로 소유하지 않은 엔티티의 기본 테이블 참조(joinColumns의 반대방향), JoinColumn 어노테이션으로 설정(자세한 내용은 아래 참조)
  • indexes: 테이블을 위한 인덱스, Index 어노테이션을 설정(아래 참조)
  • catalog: 데이터베이스 catalog 이름
  • schema: 데이터베이스 schema 이름
  • uniqueConstraints(DDL): 유니크 제약 조건

사용하는 예를 보자. @JoinTable은 연관관계 소유 엔티티에서 정의해야 한다. 그렇지 않으면 제대로 매핑 테이블이 생성되지 않는다.

@Entity
open class Subscription(
    @Id
    @GeneratedValue
    private var id: Long?,
    @OneToOne
    @JoinTable(name="memberSubscription",
        joinColumns = [JoinColumn(name="subscription_id")],
        inverseJoinColumns = [JoinColumn(name="member_id")]
    )
    private var member: Member?,
    //...
) {
    //...
}

member_subscription 매핑 테이블이 생성되고 각 엔티티에 연결되 외래키가 각각 “subscription_id”와 “member_id”가 생성된다. joinColumns가 소유 엔티티에서 있는 기본키에 해당하고 inverseJoinColumns가 반대 방향에 연관관계 소유하지 않는 엔티티의 기본키에 해당한다. 만약 기본값으로면 @JoinTable 어노테이션을 사용하면 연관관계 소유하지 않은 엔티티에 대한 외래키를 외래키 생성 규칙에 의해서 생성되고, 소유하는 엔티티에서는 자신의 기본키가 사용된다.

영속성 전이(Cascade)

영속성에 대한 작업이 수행되면 다른 엔티티에 영속성이 전파되는 작업을 선택할 수 있다. 영속성 전이는 CacadeType에 의해서 선택할 수 있고 아래와 같은 종류를 지원한다.

  • ALL: 아래 모든 타입을 적용
  • PERSIST: persist()할 때 서로 영속화, 저장할 때 같이 저장
  • MERGE: merge()할 대 서로 영속화, 변경적용할 때 같이 갱신
  • REMOVE: remvoe()할 때 서로 영속화, 삭제되면 같이 삭제
  • REFRESH: refresh()할 때 서로 영속화, 새로고침할때 같이 로딩
  • DETACH: detach()할 때 영속성 분리

CacadeType은 1:1 관계는 문제가 없을 가능성이 높지만, 1:N, N:M 관계인 경우는 무결성 문제가 발생할 가능성이 높다. 사용하기에는 편리하지만, 복잡한 구조에서는 예상치 못한 상황이 발생할 수 있기 때문에 신중하게 사용해야한다.

@Emtity
open class Member (
  @Id
  @GeneratedValue
  private val id: Long?,
  @OneToOne(mappedBy="member", cascade=CascadeType.ALL)
  private subscription: Subscription?,
  // ...
){
  // ...
}

Member 엔티티의 모든 영속성 변경 내용이 Subscription 엔티티에 적용된다. 다른 영속성 전이로 orphanRemoval 속성을 사용하는 방법이 있다. 이는 엔티티 삭제할 때에 연관 엔티티까지 같이 삭제하는 옵션이다.

@Emtity
open class Member (
  @Id
  @GeneratedValue
  private val id: Long?,
  @OneToOne(mappedBy="member", orphanRemoval=true)
  private subscription: Subscription?,
  // ...
){
  // ...
}

Member 엔티티가 삭제될 경우 연관 엔티티인 Subscription도 같이 삭제된다.

지연로딩

연관관계에 있는 엔티티는 로딩시 연관된 엔티티까지 모두 로딩한다. 만약 연관관계가 1:N, M:N 관계인 엔티티가 여러 단계로 구성되어 있다면 많은 데이터 로딩으로 인한 성능 문제가 발생할 수 있다. 이런 연관 관계에서 지연 로딩 방식을 지원한다. 지원 로딩 바익은 엔티티 로딩시 연관 엔티티를 즉시 로딩하는게 아니라, 해당 연관엔티티를 사용할 때에 로딩한다. 이는 fetch 속성에 FetchType.EAGER와 FetchType.LAZY를 사용할 수 있다. EAGER는 로딩할 때에 연관된 엔티티도 같이 로딩하는 방석이고 LAZY은 사용할 때에 로딩하는 방식이다. 기본은 EAGER이다.

@Entity
open class Member(
  @Id
  @GeneratedValue
  private var id: Long?,
  @OneToMany(mappedBy="owner", fetch=FetchType.LAZY)
  private var posts: MutableList<Post>,
  // ...
) {
  // ...
}

Member 엔티티 로딩되는데 Post가 같인 조인되서 로딩되지 않는다. 해당 posts 조회 시점에 별도 조회 쿼리로 데이터를 로딩한다.

마무리

엔티티의 연관관계에서 살펴보았다. 또한 연관된 엔티티 간에 생명주기도 살펴보았다. 데이터베이스의 제약 조건으로 인해 실제 데이터 업데이트, 삭제에 의한 문제가 발생할 경우가 많다. 해당 데이터 제약 조건에 맞는 엔티티에서 데이터 처리 또는 그에 맞는 제약 조건을 정의해야할 것이다. 또한 복잡한 엔티티 연관 관계에서 한꺼번에 많은 데이터 로딩으로 성능 문제를 지연 로딩으로도 어느정도 해결할 수 있다. 물론 연관관계가 아닌 엔티티 식별자만으로 데이터를 조회하는 방식으로 접근 가능하다.

아직 확인할 부분도 많고 설명이 부족한게 너무 많네요. 부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩 생활되세요. ospace.

참고

[1] 최범균, JPA 프로그래밍 입문, 가메출판사, 2017

[2] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[3] 5기_테오, JPA Cascade는 무엇이고 언제 사용해야 할까?, https://tecoble.techcourse.co.kr/post/2023-08-14-JPA-Cascade/

[4] Annotation Type ManyToOne, https://docs.jboss.org/hibernate/jpa/2.1/api/javax/persistence/ManyToOne.html

[5] Annotation Type OneToMany, https://docs.jboss.org/hibernate/jpa/2.1/api/javax/persistence/OneToMany.html

[6] Annotation Type ManyToMany, https://docs.jboss.org/hibernate/jpa/2.1/api/javax/persistence/ManyToMany.html

반응형