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
이런 것을 쓰라는데 더 이상은 귀찮아서 나중으로 미뤘습니다. 😛
참고 사이트