본문 바로가기

Spring

DDD를 위한 일대다 연결관계 매핑

요구사항 정의

  • 시험은 N개의 문제를 가진다.
  • 문제 등록은 시험이 있어야 가능하다.
  • 문제의 생성, 수정, 삭제, 조회는 시험을 통해서만 가능하다.
  • 시험이 삭제되면, 문제들도 삭제된다.
  • 시험과 문제는 각각 유니크한 식별자를 가진다.

하위 엔티티들을 관리하기 위한 전략

  • 시험에 있어서 문제는 관리해야 할 하위 엔티티이다. 시험을 통해서만 문제가 등록되도록 영속성 전이 옵션 (cascade)을 사용한다.
  • 생성, 수정, 삭제의 관점에서 보자. 생성은 상위 엔티티의 컬렉션에 하위 엔티티를 넣어 영속 상태로 만들면 JPA에서 insert 해준다.
  • 수정은 조회 후 컬렉션의 원소를 수정하여 영속 상태로 만들면 JPA에서 update 해준다.
  • 삭제는 조회 후 컬렉션의 원소를 삭제하여 영속 상태로 만들면 JPA에서 delete 해준다.
    • 이 때, 도메인 모델을 구현하기 위해, 상위 엔티티와 연결이 끊겼다면 삭제해주는 옵션인 orphanremoval 옵션을 사용한다.

일대다 단방향

  • 객체 관점 : 시험은 문제를 알고 있지만, 문제는 시험을 모른다.
  • 테이블 관점 : 시험의 PK를 문제의 FK로 가진다.

코드

package com.onewayjpatest.demo.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor
@Getter
public class Exam {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL,orphanRemoval = true)
    private List<Question> questions = new ArrayList<>();

    public Exam(String content) {
        this.content = content;
    }
    public void addQuestion(Question question){
        this.questions.add(question);
    }
}

package com.onewayjpatest.demo.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@NoArgsConstructor
@Getter
public class Question {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "content")
    private String content;
    public Question(String content){
        this.content = content;
    }
}

위와 같이 작성해놓고 Exam의 리스트에 Question을 넣어 영속화하는 테스트를 해보자. exam을 insert하고, question을 insert하는 쿼리가 함께 날라가게 된다.

그런데, 이상한 점이 있다. exam의 PK를 알고 있기 때문에 question을 insert할 때 FK를 바로 할당할 수 있을 것 같은데, FK를 초기화하기 위한 쿼리가 두 번 날라간다.

insert문에서 FK의 값이 NULL로 할당되는걸 확인할 수 있다.

왜 이런 현상이 발생하는걸까? JPA의 관점에서 보자. exam에선 question을 알지만, question은 exam을 모른다. 따라서 question의 FK 값을 넣기 위해서 exam에게 값을 물어봐야 하는 것이다.

  • 단점 : 불필요한 쿼리가 한 번 더 날라간다.
  • 장점 : 객체 관점에서 question이 exam의 존재를 몰라야 하므로 설계상 이점이 있다.

다대일 양방향

  • 객체 관점 : 시험과 문제는 서로를 알고 있다.
  • 테이블 관점 : 시험의 PK를 문제의 FK로 가진다.
package com.onewayjpatest.demo.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor
@Getter
public class Exam {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;
    @OneToMany(mappedBy = "exam", fetch = FetchType.LAZY, cascade = CascadeType.ALL,orphanRemoval = true)
    private List<Question> questions = new ArrayList<>();

    public Exam(String content) {
        this.content = content;
    }
	public void addQuestion(Question question){ //연관관계 편의 메서드
		question.changeExam(this);
		this.questions.add(question);
	}
}

package com.onewayjpatest.demo.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@NoArgsConstructor
@Getter
public class Question {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "content")
    private String content;
    public Question(String content){
        this.content = content;
    }
	public void changeExam(Exam exam){
		this.exam = exam;
	}
}

mappedBy로 연관관계의 주인이 question인걸 표시하고, question이 exam의 존재를 알게 됐다.

insert 쿼리 한 방에 FK가 같이 채워지는걸 볼 수 있다.

  • 단점 : question이 exam의 존재를 알게 되었기 때문에, 설계상 손해를 보게 된다.
  • 장점 : 불필요한 쿼리가 날라가지 않는다.

고찰

  • 성능을 고려하는 상황이 아니라면, 일대다 단방향을 선택하고 반대라면 양방향을 선택하는 것이 좋겠다.
  • 개인적인 생각으로는 성능 문제는 언젠간 만나게 될 이슈이고, 애플리케이션을 운영하면서도 문제 파악을 어렵게 하는 주범이기 때문에 미리 양방향으로 연결하는게 좋을 것 같다.
  • 양방향으로 연결은 하되, 애초에 연관관계를 설정할 때 상위 엔티티에서만 설정하는 것도 좋은 방법인 것 같다.