Post

[SPRING] JPA 에서 @ColumnDefault 적용을 위한 @DynamicInsert VS @PrePersist

작성계기

개발 중간에 단위테스트를 진행하고 있었다. 그런데 Store 라는 엔티티에는 수 많은 필드가 작성되어있고, 내가 테스트할려는 로직에는 그 수 많은 필드의 값을 다 채울 필요가 없었다. 그런데 int 자료형인 필드의 경우 DB 에 들어갈시 null 로 저장되면 Java 에서 가져올 때 int 타입에는 null 값이 없어 에러가 발생한다. 그래서 기본값을 설정해주는 @ColumnDefault 를 필드에 붙여주면 해결이 될 줄 알았으나.. 실제로는 그렇지 않았다. 이를 해결하기 위해서 @DynamicInsert@PrePersist 를 사용하는데 이에 대해 적어보려고 한다.

@ColumnDefault 에 대해

먼저 @ColumnDefault 에 대해 이야기하자면, DDL 처음 생성시 기본값을 채워주는 롬복이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StoreTopItem {

    @Id @GeneratedValue
    @Column(name = "store_id")
    private Long id;
    
    private String firstGradeItemName;
    private String secondGradeItemName;
    private String thirdGradeItemName;

    @ColumnDefault("0")
    private int firstGradeItemNumber;
    @ColumnDefault("0")
    private int secondGradeItemNumber;
    @ColumnDefault("0")
    private int thirdGradeItemNumber;
    
    public StoreTopItem(String firstGradeItemName){
        this.firstGradeItemName = firstGradeItemName;
    }
}

해당 클래스는 가게에 많이 판매된 상품의 수를 저장하는 엔티티다. 상품의 수를 저장하는 필드 firstGradeItemNumber 의 경우 자료형이 int 이다. 기본값 없이 생성하면 DB 에서는 intnull 로 저장한다. 여기까지는 문제가 없으나 해당 엔티티를 다시 가져올 떄 문제가 발생한다. DB 에서 null 로 저장된 값을 JAVA 에서 int 자료형에 맵핑할 수 없다. 왜냐하면 자바에는 intnull 값을 저장할 수 없기때문이다.(물론 Integer 를 사용하면 null 자료형도 저장이 가능하다.)

그래서 이 문제를 해결하기위해 @ColumnDefault("0") 롬복을 이용해 기본값은 0 으로 저장하도록 했다.

StoreTopItem 엔티티를 실제로 DB 에서 생성할때는 다음과 같은 쿼리가 날라간다.

1
2
3
4
5
6
7
8
9
10
CREATE TABLE store_top_item (
                                store_id BIGINT NOT NULL AUTO_INCREMENT,
                                first_grade_item_name VARCHAR(255),
                                second_grade_item_name VARCHAR(255),
                                third_grade_item_name VARCHAR(255),
                                first_grade_item_number INT DEFAULT 0,
                                second_grade_item_number INT DEFAULT 0,
                                third_grade_item_number INT DEFAULT 0,
                                PRIMARY KEY (store_id)
);

sql 쿼리에서 first_grade_item_number INT DEFAULT 0 로 기본값은 0으로 세팅되도록 된다. 그래서 기본값이 0 으로 알아서 세팅되니까 문제가 없을 줄 알았는데.. 그게 아니었다.

JPA 에서 Insert 와 Update

JPA 에서는 Insert 와 Update 할 때 기본전략으로 모든 엔티티의 필드를 업데이트 한다. 예를 들어 StoreTopItem 에서 생성자로 firstGradeItemName 값만 넣어주고 객체를 만든 뒤 DB에 저장을 한다고 해보자. 저장을 할 때에는 DB에 Insert 쿼리가 날라가는데 다음과 같은 쿼리가 날라간다.

1
2
3
4
5
6
INSERT 
INTO
    store_top_item
    (id, first_grade_item_name, second_grade_item_name, third_grade_item_name, first_grade_item_number, second_grade_item_number, third_grade_item_number) 
VALUES
    (DEFAULT, "ThisIsfirstGradeItemNameValue", null, null, null, null, null) // 모든 칼럼에 대한 쿼리 생성

실제로 날리는 쿼리를 보면 실제로 값을 저장한 firstGradeItemName 필드에 대한 Insert 가 아닌 모든 필드에 대해 Insert 를 해준다. 당연히 firstGradeItemName 외에는 데이터를 넣어두지 않았기때문에, 다른 필드는 null 값으로 Insert 를 한다! 어쨋든 null 값으로 Insert 를 해버렸으니 first_grade_item_numberDEFAULT 0 이 작동하지 않고 null 값으로 저장된다. Update 는 구체적인 예제를 들지않았지만 마찬가지로 모든 필드에 대해 Update 를 한다.

@ColumnDefault(“0”) 이 안됬던 이유

나는 @ColumnDefault("0") 를 사용해 first_grade_item_number 에는 0 값이 들어가있는줄 알았다. 하지만 Insert 할 때 null 로 넣어져서 실제로 DB 에서는 null 값이 저장되어 있었다. DB에 null 값이 저장된것 까지는 괜찮다. 하지만 테스트를 하기위해서 StoreTopItem 엔티티를 다시 가져오면 DB 에 있는 first_grade_item_numbernull 값이 JAVA 에서 first_grade_item_numberint 자료형에 맵핑이 되지 않아서 오류가 생긴다. JAVA 의 intnull 저장이 불가능 하기 때문이다.

JPA 의 기본전략

어쨋든 문제는 JPA 의 하이버네이트가 모든 필드를 업데이트 하기때문에 생긴 문제다. 그래서 왜 모든 필드를 업데이트 하는데? 라는 의문이 들 수 있다. 모든 필드를 업데이트하면 위와같은 오해? 가 생길 수도 있고 무엇보다 모든 필드를 보낸다는것은 그만큼 더 많은 데이터를 주고받는 비효율적인 상황이다.

그렇지만 나보다 뛰어난 분들이 만든 JPA 시스템에 그런것도 고려하지 않았을리가 없으니 찾아보니 이유가 있었다.

  1. 모든 필드를 사용하면 바인딩 되는 데이터만 다를 뿐 등록, 수정 쿼리가 항상 같다. 따라서 애플리케이션 로딩 시점에 쿼리를 미리 생성해두고 재사용할 수 있다.
  2. 데이터베이스에 동일한 쿼리를 보내면 데이터베이스는 이전에 한 번 파싱된 쿼리를 재사용할 수 있다.

정리하자면 Insert 와 Update 에서 동일한 구조의 쿼리를 보낸다. 따라서 JPA hibernate 와 DB 에서 동일한 구조를 재사용함으로써 최적화가 가능하다.

해결법 첫번째 : @DynamicInsert, @DynamicUpdate

첫번째 해결방법으로느 JPA 의 기본전략을 적용시키지 않는것이다. @DynamicInsert, @DynamicUpdate 를 사용하면 모든 필드로 업데이트하지 않고 필요한 부분만 업데이트 한다. 예제와 함께 설명을 하겠다.

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
@Entity
@Getter
@DynamicInsert
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StoreTopItem {

    @Id @GeneratedValue
    @Column(name = "store_id")
    private Long id;
    
    private String firstGradeItemName;
    private String secondGradeItemName;
    private String thirdGradeItemName;

    @ColumnDefault("0")
    private int firstGradeItemNumber;
    @ColumnDefault("0")
    private int secondGradeItemNumber;
    @ColumnDefault("0")
    private int thirdGradeItemNumber;
    
    public StoreTopItem(String firstGradeItemName){
        this.firstGradeItemName = firstGradeItemName;
    }
}

적용방법은 간다하다. 그냥 엔티티에 @DynamicInsert 롬복을 달아주면 된다. 업데이트에 적용하고 싶으면 @DynamicUpdate 를 추가하면 된다.

이전에 예시를 들었던것처럼 StoreTopItem 에서 생성자로 firstGradeItemName 값만 넣어주고 객체를 만든 뒤 DB에 저장을한다. 엔티티에 @DynamicInsert 가 있으면 저장을 할 때 DB에 Insert 쿼리가 다음과 같이 날라가게된다.

1
2
3
4
5
6
INSERT 
INTO
    store_top_item
    (id, first_grade_item_name) 
VALUES
    (DEFAULT, "ThisIsfirstGradeItemNameValue")

필요한 값만 Insert 쿼리를 날려준다. 그러면 first_grade_item_number 에 대한 값은 Insert 하지 않았으므로 기본값을 0 으로 설정해둔것이 정상적으로 적용이 된다. 그래서 DB 에는 0 으로 저장이 된다. 그러면 @ColumnDefault("0") 가 의도한대로 적용이 되었다.

해결법 두번째 : @PrePersist

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
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StoreTopItem {

    @Id @GeneratedValue
    @Column(name = "store_id")
    private Long id;
    
    private String firstGradeItemName;
    private String secondGradeItemName;
    private String thirdGradeItemName;


    private Intger firstGradeItemNumber;

    private Intger secondGradeItemNumber;

    private Intger thirdGradeItemNumber;
    
    @PrePersist
    public StoreTopItem(String firstGradeItemName){
        this.firstGradeItemNumber = this.firstGradeItemNumber == null ? 0 : this.firstGradeItemNumber;
        this.secondGradeItemNumber = this.secondGradeItemNumber == null ? 0 : this.secondGradeItemNumber;
        this.thirdGradeItemNumber = this.thirdGradeItemNumber == null ? 0 : this.thirdGradeItemNumber;
    }
}

@DynamicInsert 를 사용하지 않고 @PrePersist 를 사용하면 StoreTopItem 엔티티가 persist 되기 전에 실행된다. 이렇게하면 Insert 되기전에 firstGradeItemNumber 값이 null 이면 0 으로 저장한다. 그리고 firstGradeItemNumber 의 자료형이 int 에서 Integer 로 바뀌었다. intnull 값을 저장하지 못하기 때문이다.

@DynamicInsert VS @PrePersist

@DynamicInsert@PrePersist 어떤것이 좋을까?

둘 다 장단점이 있다. 전자의 경우 필요한 데이터한 삽입, 수정을 하기때문에 전송량이 적다. 그래서 엔티티의 필드가 엄청많은데 한 두개만 삽입, 수정을 한다면 성능측면에서 이점을 볼 수 있다.

후자의 경우 JPA 의 기본전략을 그대로 가져갈 수 있다. 쿼리 구조가 같다는 이점인데 일반적인 상황에서는 성능이 더 좋다고 생각된다.

나같은 경우에는 @DynamicInsert 를 채택했는데 그 이유는 다음과 같다.

  • 예제로는 StoreTopItem 을 들었지만, 실제로 적용할 엔티티는 필드가 많기에 일부 필드만 업데이트할시 @DynamicInsert 가 성능상 좋다고 판단했다.
  • 실제로 적용할 엔티티에서, @ColumnDefault 를 적용할 필드는 엔티티에 있는 필드말고도 값타입(@Embeddable) 에 있는 필드도 적용해야되는데 값타입에 @PrePersist 를 적용 할려면 매우 번거롭다.
  • int 자료형에서 @PrePersist 를 적용할려면 null 값을 넣을 수 없으므로 Integer 자료형을 사용해야되는데 Integer 는 성능 저하를 유발한다.
  • @DynamicInsert 를 사용하는편이 매우 간편하다. 그리고 간편함이라는것은 가독성 또한 좋다는것을 뜻한다.

후기

JPA 의 기본전략으로 전체필드를 업데이트한다는건 일반적으로 예측하기 어려웠다. 그리고 JPA 의 기본전략이 항상 성능이 좋은것도 아니었다. JPA 를 다루면서 좋은 성능을 내고싶다면 이런 기본전략부터 시작해서 여러가지를 고려해봐야 겠다고 느겼다. 결론은 JPA 공부를 하자.. 아는만큼 보인다.

Reference

https://velog.io/@choidongkuen/JPA-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-DynamicInsert-DynamicUpdate-%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B4%85%EC%8B%9C%EB%8B%A4
https://dotoridev.tistory.com/6
https://gaemi606.tistory.com/entry/JPA-DynamicInsert-DynamicUpdate-%EC%97%94%ED%8B%B0%ED%8B%B0-%EB%A6%AC%EC%8A%A4%EB%84%88
https://mangchhe.github.io/jpa/2021/09/06/EntityDynamicQuery/

This post is licensed under CC BY 4.0 by the author.