본문 바로가기
프로그래밍/JPA, Database

[JPA] nullable=false와 @NotNull 비교, Hibernate Validation

by 카프카뮈 2021. 2. 18.

오늘은 다음의 고민 때문에 글을 작성하게 되었다.

  • JPA에서 DDL을 자동으로 생성할 수 있는데, 이 때 not null 옵션은 어떻게 붙이나?
  • JPA의 엔티티 객체에 @NotNull 검증 어노테이션을 주면 어떻게 되나?
  • @NotNull 대신 @NotEmpty나 @NotBlank를 쓰면 어떻게 되나?
  • 엔티티에 검증 로직을 붙이면 어떻게 작동하나?

JPA의 DDL 생성과 제약조건 매핑

JPA는 데이터베이스 스키마를 자동으로 생성하는 기능을 지원한다.

엔티티로 삼을 객체에 @Entity 어노테이션을 붙이고,

추가적으로 여러 매핑 정보를 엔티티의 필드 위에 추가하여, 자동 생성되는 DDL에 제약조건을 추가할 수 있다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "email", nullable = false)
    private String email;
}

예를 들어 위처럼 어노테이션을 통해 정보를 매핑해주면 어떨까?

create table book (
    id bigint generated by default as identity,
    isbn varchar(255) not null,
    primary key (id)
)

위처럼 자동 생성된 SQL문이 실행될 것이다.


nullable=false vs @NotNull

이 때, 내가 위 코드에 적은 내용 중 nullable = false 라는 내용이 있다.

nullable은 @Column의 속성 중 하나로, 기본값은 true이다.

값을 false로 설정해 주면, 해당 필드는 DDL 생성 시 not null이라는 조건이 붙은 채로 생성된다.

 

그런데, 누군가는 이런 생각을 할 수도 있겠다.

"저렇게 써주면 DB에는 null이 들어갈 수 없지만, 엔티티의 필드에 넣는 건 가능하니까 위험하지 않을까?

엔티티에 Spring Bean Validation을 써서 검증하면 어떨까?"

 

코드를 먼저 보자.

 

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "email", nullable = false)
    @NotNull
    private String email;
}

어노테이션이 줄줄이 들어가니 보기가 힘들어진다!

그런데 여기서 알아낸 재미난 사실이 있다.

@Column에서 nullable=false를 제거해도, @NotNull이 붙어있으면 DDL 생성 시 not null로 생성된다는 것!!

이렇게만 써줬는데
이게 not null로 알아서 바뀌네?

이게 어떻게 가능할까? 다행히 이에 대해 다룬 포스팅을 찾았다.

 

Hibernate @NotNull vs @Column(nullable = false) | Baeldung

A quick and practical overview of @NotNull and @Column(nullable = false) in Java.

www.baeldung.com

여기에서 이에 대한 언급을 하고 있다.

How's that possible?

As it turns out, out of the box, Hibernate translates the bean validation annotations
applied to the entities into the DDL schema metadata.
(...)
However, if, for any reason, we want to disable this Hibernate feature, all we need to do is to sethibernate.validator.apply_to_ddlproperty to false.

Hibernate는 엔티티에 적용된 Bean Validation 어노테이션 역시 DDL로 변환한다는 것!

만약 이 기능을 비활성화하려면, application.properties에 다음 한 줄을 추가하면 된다.

spring.jpa.properties.hibernate.validator.apply_to_ddl=false

인용한 글에서는, 결론적으로 nullable = false 보다 @NotNull을 추천하고 있다.

 

@NotNull 어노테이션을 쓰면, 데이터베이스에 SQL 쿼리를 보내기 전에 예외가 발생한다.

JPA의 Repository 인터페이스가 잘못된 Entity를 저장할 때, ConstraintViolationException을 발생시킨다.

(그 때문에, @Valid나 @Validated 없이도 엔티티를 자연스럽게 검증할 수 있다)

 

물론 값이 검증에 맞지 않아도 객체를 생성하는 것은 가능했다.

또한, Bean Validation의 트리거가 EntityManager가 Flush된 이후이므로,

원하는 타이밍에 맞지 않아 의도치 않은 오류를 맞을 수도 있다.

 

그러나 nullable=false 로 @Column 어노테이션에 속성을 붙이면,

null을 넣은 엔티티를 생성하면 생성이 된 뒤 Repository에 전달되고,

이 값이 DB에 넘어간 뒤에 예외가 발생해 위험한 오류를 맞을 수 있다.

 

이렇게 둘의 장단점을 비교해 보았을 때,

@NotNull을 써서 DB와 서버 모두의 안정성을 챙기는 것이 좋겠다고 생각했다.


다른 Validation은?

그런데, 여기서 하나 궁금한 게 생겼다.

Spring Bean Validation에서는 @NotNull 외에도, 이와 비슷한 어노테이션을 제공한다.

  • @NotNull : null이 들어갈 경우 예외를 발생시킨다.
  • @NotEmpty : 문자열에 null이나 ""가 들어가면 예외를 발생시킨다. NotNull의 문자열 버전 상위호환.
  • @NotBlank : 문자열에 null, "", " "가 들어가면 예외를 발생시킨다. NotEmpty의 상위호환.

위의 @NotEmpty와 @NotBlank는, 문자열에만 적용할 수 있는 검증 어노테이션이다.

내가 단순하게 생각했을 때에는, 이 둘도 null을 금지하니까 not null 옵션을 자동으로 붙여주겠지? 라고 생각했다.

종류별로 하나씩 써봤다.
name과 content는 not null 옵션이 들어가지 않았다!!

 

그런데 당황스러운 상황 발생. 저 둘은 not null을 붙여주지 않는다!!

왜일까 찾아본 결과 여러 가지 정보를 얻을 수 있었다.

아래는 그 사이 했던 삽질의 기록이다.

더보기

1. 원래 Hibernate에 Java Bean Validation을 구현한 Hibernate validator가 존재하고,
여기서 자체적인 @NotNull, @NotBlank, @NotEmpty를 가지고 있었다.

2. 그러나 Hibernate 5가 지나면서, 해당 어노테이션에 @Deprecated 어노테이션이 붙어서 사용할 수 없게 되었다. (링크)

이는 공식 문서에서도 확인할 수 있다.

3. 또한 Spring Boot 2.0.0 M5 버전에서 관련 이슈가 발생했던 것으로 보인다.

이때를 기점으로 위의 어노테이션들은 hibernate 대신 javax.validation으로 옮긴듯하다. 

4. 현재는 hibernate validation은 javax에 없는 특수한 검증 로직 위주로 남아있다.

 

이를 바탕으로, "@NotBlank와 @NotEmpty가 hibernate validator 대신 javax.validation으로 위치를 옮기면서, hibernate.validator.apply_to_ddl에서 지원하지 않게 된 것은 아닐까" 라는 추측을 하게 되었다.

 

다만, 이것은 설득력이 크지 않다. 그래서 이유를 찾기 위해, hibernate validator 페이지를 확인했다.

Hibernate 공식 문서를 읽어보다

Hibernate의 공식 validator 페이지에서는 @NotEmpty를 써야하는 상황에서 다른 방법을 권하는 것을 확인했다.

 

The Bean Validation reference implementation. - Hibernate Validator

Express validation rules in a standardized way using annotation-based constraints and benefit from transparent integration with a wide variety of frameworks.

hibernate.org

public class Car {

   @NotNull
   private String manufacturer;

   @NotNull
   @Size(min = 2, max = 14)
   private String licensePlate;

   @Min(2)
   private int seatCount;

   // ...
}

위의 코드는 공식 페이지의 메인에서 보여주는 예시 코드이다.

@NotBlank나 @NotEmpty를 통해 not null 옵션을 조절하는 대신,

@NotNull을 통해 DB에 not null 옵션을 주고 @Size를 통해 저장할 때 서버에서 추가로 검증을 하도록 한다.

 

왜 @NotBlank와 @NotEmpty를 지원하지 않는지 조금 이해가 되는 느낌이었다.

추측하기로는, 일관성을 위해 @NotNull에 대해서만 not null 옵션으로 번역해 주고,

그 외의 것은 번역하지 않는 것으로 보인다.

@NotEmpty와 @NotBlank는 문자열에만 적용되는 특수한 검증 어노테이션인 만큼, 이해가 가는 부분이다.

 

여러분들도 @Column 대신 검증 어노테이션을 쓰려고 한다면, @NotNull 외의 다른 것을 쓰지 않도록 주의!!

 

* 동일 문서의 ORM 관련 파트에서는 앞서 언급한 자동 변환에 대해 보다 자세히 설명하고 있다. 읽어보시길.

 

* 210221 내용 추가

공식 문서에서는 @NotBlank와 @NotEmpty의 경우 Hibernate metadata impact가 없도록 설정되어 있다.

그에 비해 @NotNull은 not null 옵션이 들어가도록 설정되어 있었다.


그러면, @NotEmpty와 @NotBlank는 쓰지 않는게 좋나?

무조건 그렇다고 생각하지는 않는다.

만약 JPA의 DDL 자동 생성을 사용하지 않지만, 엔티티의 값을 검증하고 싶다면

문자열에 한해서는 @NotEmpty와 @NotBlank가 더 유리하다고 생각한다.

(예 : flyway를 이용해 DB 쿼리를 관리해서 DDL 자동 생성을 쓰지 않는 경우)


테스트를 통한 검증 로직 확인

이제 테스트를 통해 직접 확인해 보려고 한다.

package com.booksdiary.domain;

import lombok.*;
import org.hibernate.validator.constraints.ISBN;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @CreatedDate
    private LocalDateTime createdAt;

    @ISBN
    private String isbn;

    @NotNull
    @Size(min=1)
    private String name;

    @NotBlank
    private String content;
}

먼저, Book이라는 클래스를 사용한다. (실제 프로젝트에서 쓰는 클래스에서 필드만 줄였다)

이제 테스트 코드를 만들어 준다.

package com.booksdiary.domain;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.validation.annotation.Validated;

import javax.validation.ConstraintViolationException;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;

@DataJpaTest
public class BookTest {
    @Autowired
    private BookRepository bookRepository;

    @DisplayName("책을 생성하여 저장하면, 올바르게 수행된다.")
    @Test
    void saveTest() {
        Book book = Book.builder()
                .isbn("978-3-16-148410-0")
                .name("카프카")
                .content("내용")
                .build();

        Book savedBook = bookRepository.save(book);
        assertThat(savedBook.getName()).isEqualTo("카프카");
        assertThat(savedBook.getIsbn()).isEqualTo("978-3-16-148410-0");
    }

    @DisplayName("예외 테스트 : 비어있거나 null인 이름이 들어오면 예외가 발생한다.")
    @NullAndEmptySource
    @ParameterizedTest
    void invalidNameTest(String invalidName) {
        Book book = Book.builder()
                .isbn("978-3-16-148410-0")
                .name(invalidName)
                .content("내용")
                .build();

        assertThatThrownBy(() -> bookRepository.save(book))
                .isInstanceOf(ConstraintViolationException.class);
    }

    @DisplayName("예외 테스트 : ISBN 넘버가 아닌 값이 들어오면 예외가 발생한다.")
    @Test
    void invalidISBNTest() {
        Book book = Book.builder()
                .isbn("12345")
                .name("카프카")
                .content("내용")
                .build();
        assertThatThrownBy(() -> bookRepository.save(book))
                .isInstanceOf(ConstraintViolationException.class);
    }
}

학습 테스트인 만큼 엄격하게 작성하지는 않았다.

javax.validation 뿐 아니라, hibernate에서 제공하는 validation 역시 사용해 보았다.

예를 들어, @ISBN은 isbn 양식에 맞지 않는 값을 Repository를 통해 저장할 때 예외를 발생시킨다!

테스트는 무난히 통과~


DTO에 검증 어노테이션을 달아서 잘못된 값이 오는 걸 막는 건 이해가 된다.

그런데 왜 엔티티에까지 이렇게 검증 로직을 달아야 하나?

 

내가 생각한 이유는 다음과 같다.

  • 의도치 않게 엔티티에 잘못된 값을 넣어도, Repository에 저장할 때 예외를 발생시켜 더 큰 문제를 막을 수 있다.
  • @NotNull 어노테이션을 통해 @Column의 nullable을 대체할 수 있다.
  • 다른 사람이 코드를 봤을 때, 어떤 제약 조건이 있는지 파악하기 쉽다.

물론 단점도 존재한다. 제일 큰 단점은 코드가 더러워 보인다 라는 것.

어노테이션 때문에 스프링 하기 싫다는 사람도 많으니 뭐.


처음엔 @NotNull을 달았는데 왜 DB에 제약조건이 생기지? 로 시작된 글이었다.

쓰다 보니 공부할 게 점점 늘어서 장황한 글이 되어버렸다.

 

참고한 글은 다음과 같다.

Spring Boot에서의 Bean Validation : 링크

Hibernate Validator 가이드 : 링크

Hibernate Validator 공식 문서 : 링크

Hibernate @NotNull vs @Column(nullable = false) : 링크

JavaBean Validation과 Hibernate Validator 그리고 Spring Boot : 링크

Validation 어디까지 해봤니?(Toast MeetUp!) : 링크

nullable = false와 @NotNull의 차이점 : 링크

 

잘못된 내용이 있다면 지적 부탁드립니다.

반응형

댓글