본문 바로가기

Spring/JPA

JPA Entity Validation @Column(nullable=false)가 아닌 @NotNull을 써야한다고?

반응형

JPA Entity Validation

JPA Entity Validation. 여러분은 어떻게 유효성 검사를 하고 계신가요?

이번에 인턴사원분들과 코드리뷰를 진행하다가 새롭게 알게 된 사실을 정리하려고 합니다.

저는 보통 유효성 검사를 할 때, @Column(nullable = false)@NotNull 같은거 쓰면 되지 않나?하고 지냈습니다.

조금 찾아보니까 여러 블로그에서 @Column(nullable = false) 를 사용하지말고 @NotNull을 써야한다고 나와있습니다.

그 이유는 대부분 @Column(nullable = false) 의 경우, JPA를 통해 ddl을 자동 생성할 때에만 create 쿼리(Query)에만 들어가고, Entity의 @Column(nullable = false)가 적용된 property에 null을 집어넣고 DB에 persist할 때는 null 체크를 안 하기 때문이라고 합니다.

이 내용은 반은 맞고 반은 틀렸습니다.

아래 테스트에서 어디가 어떻게 맞고 틀렸는지 해보겠습니다.

테스트 환경

대충 쓱 보고 넘어가도 좋습니다.

//spring boot version : 2.4.3
//build.gradle파일 내용 중 dependencies 부분...
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    compile 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
//...

쓸 데 없는게 좀 많이 들어가 있는데요.

spring-boot-starter-data-jpa랑 h2대신 로컬에 있는 MySQL을 사용한 것만 확인하면 될 것 같습니다.

application.yml 파일은 아래와 같습니다.

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/book?useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
  jpa:
    show-sql: true
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    hibernate:
      ddl-auto: create

간단하게 테스트 DB에 연결했고, show-sql로 쿼리 확인하고, ddl-auto로 테스트 조건을 변경할 예정입니다.

Entity 설계는 아래가 기본 베이스고 테스트에서 바뀌는 것은 BigDecimal로 선언된 price에 적용되는 애노테이션만 바뀝니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String author;
    @Column
    private BigDecimal price;

    @Builder
    public Book(String title, String author, BigDecimal price){
        this.title = title;
        this.author = author;
        this.price = price;
    }
}

기본적인 JpaRepository를 만들어줬습니다.

public interface BookRepository extends JpaRepository<Book, Long> {}

@Test로 진행해도 좋지만 그냥 실제 느낌나게(?) @Service로 진행했습니다.

@Service
@Slf4j
@RequiredArgsConstructor
public class BookServiceImpl {
    private final BookRepository bookRepository;
    @PostConstruct
    public void init(){
        Book book = Book.builder()
                .price(BigDecimal.TEN)
                .title("Spring Boot")
                .author("jdk")
                .build();
        log.error("create book!");
        Book savedBook = bookRepository.save(book);
        log.error("savedBook = " + savedBook);

        Book nullBook = Book.builder()
                .price(null)
                .title("Spring Boot 2")
                .author("jdk")
                .build();
        log.error("create nullBook");
        bookRepository.save(nullBook);
        log.error("nullBook = " + nullBook);
    }

}

테스트 조건

ddl-auto=create, @Column(nullable=true)

위에 조건 그대로 테스트를 진행했습니다. nullable의 경우 default가 true입니다.

너무나도 당연하게 진행됩니다.

create table 쿼리에서는 price에 not null이 적용되지 않은 것을 확인할 수 있고,

nullable=true이기 때문에 두 번째 nullBook의 price가 null임에도 불구하고 insert 쿼리가 잘 나간 것을 볼 수 있습니다.

ddl-auto=create, @Column(nullable=false)

ddl-auto를 create로 해놓았고, nullable=false로 해놓았기 때문에 create table 쿼리에서 price에 not null이 들어간 것을 확인할 수 있습니다.

그로인해 두 번째 nullBook을 persist하려할 때 깔끔하게 터져주는 예외를 볼 수 있습니다.

~~org.hibernate.PropertyValueException: not-null property references a null or transient value : jpabook.jpashop.domain.Book.price

근데 문제는 nullable=true가 validation을 해준건지 table scheme에서 price에 not null을 적용해주었기 때문에 DB에서 거부한 건지 확인이 어렵습니다.

그래서 다음과 같이 MySQL에 직접 테이블을 생성하고 ddl-auto를 none으로 해봤습니다.

ddl-auto=none, @Column(nullable=false)

이제 이러면 validation 못하겠지? 당연히 테이블 스키마때문일꺼야!하고 예상했는데요... 어? 처리했습니다. null을 거르더군요?

분명 다른 블로그에서 @Column(nullable=false) 는 JPA ddl 자동 생성에만 관여해야하는데 벨리데이션에 관여했습니다.

그래서 뭔가 이상하다 싶어서 Hibernate 문서를 찾아봤습니다.

hibernate.check_nullability 라는 속성이 @Colum(nullable=false) 를 통해서 validation까지 해주는 것인데요.

이 속성 값이 true여야 유효성 검사를 해줍니다.

default 부분을 잘 읽어보면 웃깁니다.🤔

Bean Validation이 클래스패스에 있고, Hibernate Annotation이 사용되면 기본 값은 false 즉, 검사를 안 하고

반대면 true 입니다. (Bean Validation이 클래스패스에 없고 Hibernate Annotation을 사용 안하면 검사를 한다는 얘기죠.)

위에 상황은 그래서 검사를 한 겁니다. Bean Validation이 클래스 패스에 없으니까요!

ddl-auto=none, @Column(nullable=false), spring-boot-starter-validation

그러면 Bean Validation을 클래스패스에 넣어보면 알 수 있습니다.

build.gradle에 dependencies 부분에 spring-boot-starter-validation을 넣습니다.

dependencies {
    //...
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    //...
}

 

오케이 계획대로 되고 있습니다. 검사를 안 합니다!!

hibernate.check_nullability 속성 활성화

사실 속성의 default 값에 의존할 게 아니라 그냥 속성을 켜서 검사를 시키면 됩니다.

아까 위에서 spring-boot-validation을 넣어서 검사를 안 하는 상황에서 속성을 켜주면 다음과 같이 다시 검사를 진행합니다.

아래 application.yml 파일에서 spring.jpa.properties.hibernate.check_nullability=true를 보시면 됩니다.

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/book?useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
  jpa:
    properties:
      hibernate:
        check_nullability: true
    show-sql: true
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    hibernate:
      ddl-auto: none


ddl-auto=create, @NotNull

참고로 @NotNull 을 사용하려면 spring-boot-starter-validation을 추가해야합니다.

기존 Entity의 price에 @Column을 빼고 @NotNull만 넣고 테스트합니다.

@Column(nullable = false) 가 없어도 create table 쿼리를 보면 price에 not null이 적용된 것을 확인할 수 있고, 마찬가지로 nullBook에 대해서 null 체크를 해줍니다.

ddl-auto=none, @NotNull

ddl-auto를 끄고 table을 직접 쿼리로 not null 없이 생성해줍니다.

그 다음에 테스트해보면 null 체크를 잘 해주는 것을 확인할 수 있습니다.

혹시나 하는 마음에 javax.validation.constraints 패키지에 있는 @NotEmpty, @Size 도 title, author에 주고 테스트를 해봤는데 잘 적용되는 것을 확인했습니다.

애초에 @NotNull 같은 애들은 꼭 Entity가 아니더라도 빈(Bean)을 대상으로 validation을 해주기 때문에 활용성도 높아보였습니다.

결론

개인적인 생각으로 대부분의 경우에 spring-boot-starter-validation을 넣지 않을까 합니다. (JPA를 쓰면서)

따라서 헷갈리지 않게 javax.validation.constraints 패키지에 있는 어노테이션으로 validation을 처리해주는 것이 안전성을 높이지 않을까 싶었습니다.

그러나 조금 더 찾아보면, 이것도 베스트 프랙티스가 아니라며 @PrePersist, @PreUpdate 이런 것을 쓰라는데 더 이상은 귀찮아서 나중으로 미뤘습니다. 😛


참고 사이트

https://www.baeldung.com/hibernate-notnull-vs-nullable

반응형