도메인 주도 설계 JPA를 이용한 리포지터리 구현

2022. 3. 17. 21:54도메인주도설계

도메인 주도 설계에서 애그리거트를 어떤 저장소에 저장하느냐에 따라 리포지터리를 구현하는 방법이 다릅니다.

이번에는 JPA를 중심으로 리포지터리를 구현하는 방법에 대해 알아보는 시간입니다.

모듈 위치

DIP에 따라서 리포지터리 인터페이스는 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라 영역에 속합니다.

리포지터리 기본 기능 구현

리포지터리의 기본 기능은 다음 두 가지 입니다.

  • 아이디로 애그리거트 조회
  • 애그리거트 저장
1
2
3
4
public interface OrderRepository {
    Order findById(OrderNo id);
    void save(Order order);
}
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Repository
public class JpaOrderRepository implements OrderRepository {
    @PersistenceContext
    private EntityManager entityManager;
 
    @Override
    public Order findById(OrderNo id) {
        return entityManager.find(Order.class, id);
    }
 
    @Override
    public void save(Order order) {
        entityManager.persist(order);
    }
}
cs
애그리거트를 수정한 결과를 저장소에 반영하는 메서드를 추가할 필요는 없습니다. JPA는 애그리거트가 수정되면 변경감지라는 동작을 통해 트랜잭션이 커밋되는 순간 UPDATE 쿼리를 실행합니다.

애그리거트 삭제 기능이 필요하다면 추가할 수 있습니다.

1
2
3
4
5
6
public interface OrderRepository {
    ...
 
    void remove(Order order);
}
 
cs
1
2
3
4
5
6
7
8
9
10
@Repository
public class JpaOrderRepository implements OrderRepository {
    @PersistenceContext
    private EntityManager entityManager;
 
    @Override
    public void remove(Order order) {
        entityManager.remove(order);
    }
}
cs

매핑 구현

애그리거트와 JPA 매핑을 위한 기본 규칙

  • 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.
  • 밸류는 @Embeddable로 매핑 설정한다
  • 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.

아래, 주문 애그리거트를 예시로 들어보겠습니다.

주문 애그리거트 매핑 예시

Order는 애그리거트 루트이기 때문에 @Entity로 매핑합니다.

orderer와 shippingInfo는 Order의 밸류 타입 프로퍼티이므로 @Embedded로 매핑합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
@Table(name = "purchase_order")
public class Order {
    ...
 
    @Embedded
    private Orderer orderer;
 
    @Embedded
    private ShippingInfo shippingInfo;
 
    ...
}
 
cs

Orderer는 밸류이므로 @Embaddable로 매핑합니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Embeddable
public class Orderer {
@Embedded
    @AttributeOverrides(
            @AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
    )
    private MemberId memberId;
 
    @Column(name = "orderer_name")
    private String name;
}
 
cs

사실, @Embedded나 @Embeddable 중 하나만 사용하더라도 JPA는 밸류타입으로 정상 동작하게 됩니다.

하지만 저렇게 명시적으로 적어주는게 가독성에 있어서 더 좋다고 생각할 수 있습니다.

1
2
3
4
5
@Embeddable
public class MemberId implements Serializable {
    @Column(name="member_id")
    private String id;
}
cs

Orderer의 memberId는 Member 애그리거트를 ID로 참조하고 있습니다.

이 처럼, @Embeddable은 중첩을 허용하기 때문에 밸류가 다시 밸류타입 프로퍼티를 설정할 수 있습니다.

PURCHASE_ORDER 테이블을 다시 확인해보면, 주문자는 "orderer_id"라는 컬럼명으로 사용되고 있습니다.

하지만 MemberId의 id는 "member_id"로 매핑되어 있는 상태이기 때문에 @AttributeOverride를 사용하여 "orderer_id"로 매핑할 수 있습니다.

기본 생성자

JPA는 @Entity와 @Embeddable로 클래스를 매핑하려면 기본 생성자를 제공해야합니다.

하이버네이트와 같은 JPA 프로바이더는 DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문입니다.

이런 기술적인 제약으로 기본 생성자가 필요 없는 경우에도 기본 생성자를 추가해줘야합니다.

이런 기본 생성자는 JPA 기술적인 제약때문에 추가된 것이기 때문에 다른 곳에서 호출되어 사용되면 온전하지 못한 객체를 만들 수 있는 여지를 주게 됩니다.
따라서 다른 코드에서 해당 생성자를 호출하지 못하도록 protected로 선언합니다.

하이버네이트는 지연로딩을 구현할 때 프록시를 이용하는데, 해당 프록시는 상위 클래스의 기본 생성자를 호출할 수 있어야 하므로 지연 대상이 되는 @Entity와 @Embeddable은 기본 생성자를 private이 아닌, protected로 선언해야합니다.

필드 접근 방식 사용

데이터베이스에서 데이터를 조회하여 객체에 매핑할 때, JPA는 필드와 메서드의 두 가지 방식으로 매핑을 처리할 수 있습니다.

@Access를 이용하여 다음과 같이 매핑 방식을 설정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
    @Embedded
    private Orderer orderer;
 
    @Embedded
    private ShippingInfo shippingInfo;
 
    protected Order() {
    }
}
 
cs

 

AccessType에는 FIELD와 PROPERTY를 설정할 수 있습니다.

만약 @Access로 매핑 방식 설정을 명시하지 않으면 @Id나 @EmbeddedId가 필드에 위치하면 필드 접근 방식을 선택하고, get 메서드에 위치하면 메서즈 접근방식을 선택하게 됩니다.

필드 접근 방식을 사용해야하는 이유
메서드 접근 방식을 사용하려면 get/set 메서드를 구현해야합니다.
엔티티에 getter, setter가 추가되면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아집니다. 또한 불변 타입의 경우 setter 자체가 필요 없는데 JPA 구현 방식 떄문에 공개 setter를 추가하는 것도 좋지 않습니다.

AttributeConverter를 이용한 밸류 매핑 처리

밸류타입을 한 개의 컬럼에 매핑해야되는 경우, JPA의 AttributeConverter 인터페이스를 이용해 밸류 타입과 컬럼 데이터 간의 변환 처리를 정의할 수 있습니다.

1
2
3
4
5
6
7
package javax.persistence;
 
public interface AttributeConverter<X, Y> {
    Y convertToDatabaseColumn(X var1);
 
    X convertToEntityAttribute(Y var1);
}
cs
  • convertToDatabaseColumn - 밸류 타입 -> DB 컬럼 변환
  • convertToEntityAttribute - DB 컬럼 -> 밸류 타입 변환

해당 기능은, 평소에는 필요없지만 밸류의 여러개의 필드를 하나의 컬럼에 매핑시킬 때 유용할 수 있습니다.

예를 들어, 길이를 나타내는 밸류, Length는 길이 값과 단위 두 필드가 가지면서 하나의 DB 테이블 컬럼 "WIDTH"에 "1000mm"와 같은 형식으로 매핑되야할 수 있습니다.

이런 경우, AttributeConverter의 두 개의 메서드를 재정의하여 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Integer> {
 
    @Override
    public Integer convertToDatabaseColumn(Money money) {
        if (money == null)
            return null;
        else
            return money.getValue();
    }
 
    @Override
    public Money convertToEntityAttribute(Integer value) {
        if (value == nullreturn null;
        else return new Money(value);
    }
}
cs

AttributeConverter를 구현한 클래스는 @Converter 어노테이션을 적용합니다.

authApply 속성이 true인 경우, 모델에 출현하는 모든 Money에 대해서 MoneyConverter가 적용됩니다.

authApply 속성이 false인 경우, 필드 값을 변환할 때 사용할 컨버터를 직접 지정할 수 있습니다.(기본값이 false)

1
2
3
4
5
6
7
@Entity
@Table(name = "purchase_order")
public class Order {
    @Column(name = "total_amounts")
    @Converter(converter = MoneyConverter.class)
    private Money totalAmounts;
}
cs

밸류 컬렉션: 별도 테이블 매핑

밸류 컬렉션 같은 경우, 별도의 테이블에 매핑됩니다.

RDB에서 컬렉션을 하나의 컬럼에 표한하기 보다 별도의 테이블로 빼고 외래키로 연결하는 방법을 사용합니다.

밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
    @EmbeddedId
    private OrderNo number;
 
    @Embedded
    private Orderer orderer;
 
    @ElementCollection
    @CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
    @OrderColumn(name = "line_idx")
    private List<OrderLine> orderLines;
}
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Embeddable
public class OrderLine {
    @Embedded
    private ProductId productId;
 
    @Column(name = "price")
    private Money price;
 
    @Column(name = "quantity")
    private int quantity;
 
    @Column(name = "amounts")
    private Money amounts;
}
cs

@ElementCollection은 해당 필드(orderLines)가 밸류 컬렉션임을 명시해줍니다.

@CollectionTable은 해당 밸류 컬렉션(orderLines)이 어떤 테이블에 어떻게 매핑될지에 대한 정보를 설정합니다.

name 속성으로 테이블명을 설정하고, joinColumns로 조인시 사용될 컬럼을 설정해줍니다.

밸류 컬렉션 같은 경우, 순서 정보가 필요한 경우가 있습니다.
OrderLine같은 경우, 몇 번째 OrderLine인지 알 필요가 있을 수 있습니다. 그런데 OrderLine에는 몇 번째 OrderLine인지 표현해주는 필드가 존재하지 않습니다. 그 이유는 orderLines는 List 타입이고, List 자체가 순서 정보를 가지고 있기 때문입니다. List의 순서 정보를 데이터베이스에 반영하려면 @OrderColumn을 사용해서 List의 인덱스 값을 저장할 수 있습니다.

밸류 컬렉션: 한 개 컬럼 매핑

AttributeConverter을 사용하여 밸류 컬렉션을 ","로 구분하여 하나의 컬럼에 저장할 수도 있습니다.

밸류를 이용한 아이디 매핑

식별자를 기본 타입인 String이나 Long으로 사용하는 것은 나쁘지 않지만, 식별자라는 의미를 부각시키기 위해서 식별자 자체를 별도의 밸류로 만들 수 있습니다.

1
2
3
4
5
6
@Entity
@Table(name = "purchase_order")
public class Order {
    @EmbeddedId
    private OrderNo number;
}
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Embeddable
public class OrderNo implements Serializable {
    @Column(name="order_number")
    private String number;
 
    private OrderNo() {
    }
 
    public OrderNo(String number) {
        this.number = number;
    }
 
    public String getNumber() {
        return number;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderNo orderNo = (OrderNo) o;
        return Objects.equals(number, orderNo.number);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(number);
    }
<div style="padding: 0 6px; white-space: pre; line-
  • 밸류 타입을 식별자로 매핑하면 @Id 대신, @EmbeddedId 애노테이션을 사용합니다.
  • JPA에서 식별자 타입은 Serializable 타입이어야 하므로 식별자로 사용될 밸류 타입은 Serializable 인터페이스를 상속 받아야합니다.
  • 밸류 타입으로 식별자를 구현하면 식별자에 기능을 추가할 수 있다는 장점이 있습니다.
  • 엔티티는 보통 식별자로 비교하기 때문에 식별자로 사용할 밸류 타입은 equals()와 hashcode() 메소드를 알맞게 구현해야합니다.

별도 테이블에 저장하는 밸류 매핑

테이블과 매핑되는 객체라고 해서 무조건 엔티티는 아닙니다.

밸류를 하나의 테이블에 매핑하여 해당 테이블에 식별자가 있다고 해서 그 식별자가 애그리거트 구성요소의 식별자와 동일한 것으로 생각하면 안됩니다.

예를 들어 게시글 데이터를 ARTICLE 테이블과 ARTICLE_CONTENT 테이블에 나눠 저장한다고 합시다.

ARTICLE의 식별자는 외부에서 식별자로 ARTICLE에 추적을 할 수 있어야하지만 ARTICLE_CONTENT의 식별자는 추적이 아닌 단지, ARTICLE 테이블에 연결되기 위함입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
@Table(name = "article")
@SecondaryTable(
        name = "article_content",
        pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    private String title;
 
    @AttributeOverrides({
            @AttributeOverride(name = "content",
                    column = @Column(table = "article_content")),
            @AttributeOverride(name = "contentType",
                    column = @Column(table = "article_content"))
    })
    @Embedded
    private ArticleContent content;
}
cs
  • @SecondaryTable - 밸류를 매핑할 테이블을 설정합니다.
  • @AttributeOverride - 테이블을 지정하고 컬럼을 설정합니다.
  • @SecondaryTable을 이용하면 애그리거트 조회시 두 테이블은 조인되게 됩니다.
  • 하지만 게시글 목록을 조회하고 싶을 때는 ARTICLE_CONTENT가 필요하지 않을 수도 있습니다. 이런 경우 조회 전용 기능을 구현해야합니다.
애그리거트에서 로트 엔티티를 뺀 나머지 구성요소는 대부분 밸류입니다. 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해봐야 합니다.
밸류가 아니라 엔티티가 확실하다면 다른 애그리거트는 아닌지 의심해봐야합니다.

밸류 컬렉션을 @Entity로 매핑하기

개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있습니다.

JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않습니다. 따라서 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용한 상속 매핑으로 처리해야 합니다.

예를 들어 Product에 Image가 있고, Image를 상속받는 InternalImage, ExternalImage가 있다고 합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "image_type")
@Table(name = "image")
public abstract class Image {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "image_id")
    private Long id;
 
    @Column(name = "image_path")
    private String path;
 
    @Temporal(TemporalType.TIMESTAMP)
    @Column(name = "upload_time")
    private Date uploadTime;
 
    protected Image() {}
    public Image(String path) {
        this.path = path;
        this.uploadTime = new Date();
    }
 
    protected String getPath() {
        return path;
    }
 
    public Date getUploadTime() {
        return uploadTime;
    }
 
    public abstract String getUrl();
    public abstract boolean hasThumbnail();
    public abstract String getThumbnailUrl();
 
}
cs
1
2
3
4
5
6
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
    ...
}
 
cs
1
2
3
4
5
@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image {
    ...
}
cs
  • Image에 @Inheritance를 사용해 상속될 수 있는 클래스라고 명시하고 strategy에 SINGLE_TALBE을 설정하여 하나의 테이블에 하위 객체의 데이터를 전부 저장하도록 합니다.
  • 그리고 @DiscriminatorColumn을 사용하여 하위 객체의 데이터를 구분할 컬럼명을 지정합니다.
  • Image는 @Entity로 매핑했지만 개념적으로 밸류이므로 상태를 변경하는 기능은 추가하지 않습니다.
  • 하위 객체인 InternalImage와 ExternalImage는 @DiscriminatorValue를 사용하여 구분될 값을 지정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;
 
    @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
            orphanRemoval = true, fetch = FetchType.EAGER)
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList<>();
 
    protected Product() {
    }
 
    public Product(ProductId id, String name, Money price, String detail, List<Image> images) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.detail = detail;
        this.images.addAll(images);
    }
 
    public void changeImages(List<Image> newImages) {
        images.clear();
        images.addAll(newImages);
    }
}
cs

Image는 개념적으로 밸류이므로 독자적인 라이프 사이클을 갖지 않고 Product의 라이프사이클에 완전히 의존합니다.

따라서 cascade와 orphanRemoval를 사용하여 라이프사이클을 맞춰줍니다.

 

changeImages 메소드를 보면 Image를 변경하기 위해 clear() 메소드를 호출하고 있습니다. 하이버네이트는 @Entity를 위한 컬렉션 객체의 clear() 메서드를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행합니다. Image의 개수가 많고 변경 빈도가 많으면 전체 서비스 성능에 문제가 될 수 있습니다.

 

반면, 하이버네이트는 @Embeddable 타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행합니다.

애그리거트의 특성을 유지하면서 이 문제를 해소하려면 결국 상속을 포기하고 @Embeddable로 매핑된 단일 클래스로 구현해야 합니다. 이 경우, 타입에 따라 다른 기능을 구현하려면 if-else를 써야 하는 단점이 발생합니다.

ID 참조와 조인 테이블을 이용한 단방향 M:N 매핑

애그리거트 간 집합 연관은 성능상의 이유로 피해야하지만, 그럼에도 불구하고 요구사항을 구현하는 데 집합 연관을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;
 
    @ElementCollection
    @CollectionTable(name = "product_category",
            joinColumns = @JoinColumn(name = "product_id"))
    private Set<CategoryId> categoryIds;
}
cs

@ElementCollection을 이용하기 때문에 Product가 삭제되면 중간 테이블의 데이터도 자동으로 삭제된다.

(경험담)
만약, Product와 Category가 다대다 관계에서 Product_Category 라는 중간테이블을 이용하면서 양방향으로 직접 객체 참조를 하고 있다고 합시다.
중간 테이블 입장에서는 Product가 삭제되면 중간 테이블 데이터도 삭제되야하고 Category가 삭제되면 마찬가지로 중간 테이블 데이터도 삭제되야되는 입장이라 라이프 사이클을 둘 다 걸어 놓게되면,
Product가 삭제 될때 Product_Category에 삭제시도를 하는데, Category에 아직 관련 데이터가 삭제되지 않았으므로 Product가 삭제되지 않는 버그가 발생하게 됩니다.
이런 문제도 ID참조와 단방향 관계를 이용해서 연결을 적절하게 끊어 해결할 수 있습니다.

애그리거트 로딩 전략

애그리거트는 개념적으로 하나여야 합니다. 하지만 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야하는 것은 아닙니다. 애그리거트가 완전해야 하는 이유는 두 가지 정도로 생각해 볼 수 있습니다.

  1. 첫 번째 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 합니다.
  2. 두 번째 이유는 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요합니다.

이 중 두 번째는 별도의 조회 전용 기능을 구현하는 방식을 사용하는 것이 유리할 때가 많기 때문에 애그리거트의 완전한 로딩과 관련된 문제는 상태 변경과 더 관련이 있습니다.

상태 변경 기능을 실행하기 위해 조회 시점에 즉시 로딩을 이용해서 애그리거트를 완전한 상태로 로딩할 필요는 없습니다.

JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 실제로 상태를 변경하는 시점에 필요한 구성 요소만 로딩해도 문제가 되지 않습니다.

물론, 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높습니다.

따라서, 무조건 즉시 로딩이나 지연 로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연 로딩을 선택해야 합니다.

JPA에서는 @OneToMany, @ElementCollection에 fetch 속성에 로딩 전략을 설정 할 수 있습니다.

애그리거트 영속성 전파

애그리거트가 완전한 상태여야 한다는 것은 애그리거트 루트를 조회할 때뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 함을 의미합니다.

  • 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 합니다.
  • 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 합니다.

@Embeddable 매핑 타입의 경우 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 됩니다.

반면, 애그리거트에 속한 @Entity 타입의 경우 cascade를 사용하여 라이프사이클을 맞춰야합니다.

식별자 생성기능

식별자는 크게 세 가지 방식 중 하나로 생성됩니다.

  • 사용자가 직접 생성
  • 도메인 로직으로 생성
  • DB를 이용한 일련번호 사용
  1. 식별자를 사용자가 직접 생성하는 경우 식별자 생성 기능을 구현할 필요가 없습니다.
  2. 식별자 생성 도메인 규칙이 있는 경우, 엔티티를 생성할 때 이미 생성한 식별자를 전달하므로 엔티티가 식별자 생성 기능을 제공하는 것보다는 별도 서비스로 식별자 생성 기능을 분리하는 것이 좋습니다. 이 때는 도메인 서비스나 리포지터리에 생성 규칙을 구현하는 것이 좋습니다.
  3. 식별자 생성으로 DB의 자동 증가 컬럼을 사용할 경우 JPA의 식별자 매핑에서 @GeneratedValue를 사용하면 됩니다.
(경험담)
자동 증가 컬럼을 이용하게 되면 도메인 객체를 생성할 때 식별자를 알 수 없고 도메인 객체를 저장한 후에 식별자를 알 수 있습니다.
따라서 JPA의 엔티티 단위 테스트를 할 경우, 식별자 없이 테스트 진행이 어렵기 때문에 데이터베이스에 의존해야하는 단점이 있습니다. @DataJpaTest 를 사용하여 단위테스트를 진행할 수 있습니다.