본문 바로가기

Programing/Framework

[JPA] H2 테스트 코드에서 createdAt 필드가 없던 이유

코드리뷰에서 save 를 반복하며 직접 iteration을 도는 것보다 saveAll 을 호출하는 것이 성능상 더 좋을 것 같다는 comment가 달렸다.

정말로 그럴지 테스트 해보기 위해 테스트코드를 짰다.

 

사실 테스트라기 보다는 리스트로 데이터를 saveAll을 수행시 어떻게 동작하는지 디버깅을 해보려는 것이었다.

@DataJpaTest
@ActiveProfiles("test")
class RequestRepositoryFunctionalSpec extends Specification {

    @Autowired
    RequestRepository sut

    def "saveAll 로 처리하면 성능상 이점이 있을것이다 by yh"() {
        given:
        List<RequestEntity> entities = [
                    getDummyEntityWithNotnullFields('1'),
                    getDummyEntityWithNotnullFields('2'),
                ]

        when:
        def result = sut.saveAll(entities)

        then:
        result.size() == 2
    }

    def getDummyEntityWithNotnullFields(String orderIdParam) {
        def req = new RequestEntity()
        req.with {
            orderId = orderIdParam
            createdAt = LocalDateTime.now()
        }
        return req
    }
}

 

그런데 이상하게 자꾸 createdAt이 null 이라면서 예외가 발생했다.

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: NULL not allowed for column "CREATED_AT"; SQL statement:

위의 테스트 코드에서 분명히 현재 시간으로 createdAt을 넣고 있는데 null이라는 것이다.

디버깅

결국은 디버깅을 해서 hibernate가 생성하는 쿼리를 확인해보았다.

놀랍게도 INSERT 문에 CREATED_AT 컬럼이 없었다. 따라서 파라미터에서도 제외되었다.

꽤 여러 군데를 찾아보다가 원인을 찾았다.

범인은?

범인은 바로 엔티티에 있었다.

@Entity
@Table(name = "REQUEST")
public class RequestEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "REQUEST_ID", nullable = false, unique = true)
    private Long requestId;
    
    @Column(name = "ORDER_ID", length = 30, nullable = false)
    private String orderId;

    @Column(name = "CREATED_AT", nullable = false, insertable = false, updatable = false)
    private LocalDateTime createdAt;
    // ..

바로 createdAt의 @Column 속성중 insertable이 false로 되어 있던 것이다.

그래서 SQL로 만들어질 때 엔티티에 설정한 값이 만들어지지 않았다.

그렇다면 DB에는 어떻게 값이 들어갔을까?

분명 stage 서버에서 테스트를 수행했기에 분명 DB에는 데이터들이 들어갔다.

불일치는 어디에서 벌어졌을까?

이유는 DEFAULT 때문이었다.

CREATE TABLE REQUEST
(
	REQUEST_ID BIGINT(20) AUTO_INCREMENT COMMENT '트랜잭션 아이디',
	ORDER_ID VARCHAR(30) NOT NULL COMMENT '주문번호',
	CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP  NOT NULL COMMENT '생성일시',
	-- ..

 

DB에서 수행할 때는 기본값이 적용되고 있었으므로 값이 애초에 전달될 필요가 없었던 것이다.

 

하지만 엔티티에 현재 시간을 넣어 수행하는 코드는 이미 사용되고 있었다.

또한 분산 시스템에서는 DB 시스템의 시간을 입력받는 것보다는 애플리케이션의 시간을 넣는 것이 바람직하다.

saveAll ?

또 다른 문제. saveAll은 성능이 더좋았을까?

Repository 는 CrudRepository 를 상속받아 구현을 했다.

CrudRepository<T, ID> 는 인터페이스일 뿐이다. 실제 어떤 클래스가 인스턴스가 되는지는 디버깅을 해봐야 알 수 있었다.

 

SimpleJpaRepository<T, ID> 가 구현체로 동작하고 있었다.

SimpleJpaRepository ~ CrudRepository

따라서 saveAll 구현을 보면 도움이 될 것이다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
	// ..
	@Transactional
	@Override
	public <S extends T> List<S> saveAll(Iterable<S> entities) {

		Assert.notNull(entities, "Entities must not be null!");

		List<S> result = new ArrayList<S>();

		for (S entity : entities) {
			result.add(save(entity));
		}

		return result;
	}

결국 조삼모사이다. Jpa가 iteration을 도느냐 개발자의 코드에서 도느냐의 차이일 뿐이다.

break point 지정

하위에 가면 JDBC가 수행된다.

JdbcPreparedStatement 의 생성자 부근에 break point 를 지정하면 JdkDynamicAopProxy(java.lang.reflect.Method#invoke 등)의 늪을 쉽게 통과할 수 있다.

invoke -> proceed -> invoke -> proceed -> invoke -> proceed...