spring jpa (Hibernate) 요청당 쿼리 count

회사 프로젝트가 거의 막바지로 이르면서 성능과 관련된 코드들을 리팩토링 하는중이다. 거의 대부분 jpa와 관련 있을 듯해서 jpa 튜닝(?) 이라고 해야 되나? 아무튼 그러고 있다. orm이 아닌 mapper 같은 경우에는 개발하면서 대충 몇번의 쿼리를 날리는지 감으로 알 수 있지만 jpa같은 경우에는 언제 어디서 쿼리들이 n+1이 될지 모르니 계속 모니터링을 하기 귀찮아서 로컬에서 테스트하거나 테스트 서버에 올릴때 요청당 쿼리를 count하는 것을 만들었다.
설명보다는 코드를 보자.

@Slf4j
@RequiredArgsConstructor
public class RequestCountInterceptor extends HandlerInterceptorAdapter {

  private final HibernateInterceptor hibernateInterceptor;

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    hibernateInterceptor.start();
    return true;
  }

  @Override
  public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    Counter counter = hibernateInterceptor.getCount();
    long duration = System.currentTimeMillis() - counter.getTime();
    Long count = counter.getCount().get();
    log.info("time : {}, count : {} , url : {}", duration, count,  request.getRequestURI());
    if(count >= 10){
      log.error("한 request 에 쿼리가 10번 이상 날라갔습니다.  날라간 횟수 : {} ", count);
    }
    hibernateInterceptor.clear();
  }
}

spring 의 기본적으로 제공해주는 Interceptor를 상속받아 구현하였다. 여기서는 preHandle에 시작을 알리고 afterCompletion메서드에서는 시간과 count를 출력하고 마무리 짓는 클래스이다. 흔한 Interceptor이다.
다음으로는 하이버네이트 인터셉터이다.

public class HibernateInterceptor implements StatementInspector {

  private ThreadLocal<Counter> queryCount = new ThreadLocal<>();

  void start() {
    queryCount.set(new Counter(new AtomicLong(0), System.currentTimeMillis()));
  }

  Counter getCount() {
    return queryCount.get();
  }

  void clear() {
    queryCount.remove();
  }

  @Override
  public String inspect(String sql) {
    Counter counter = queryCount.get();
    if(counter != null){
      AtomicLong count = counter.getCount();
      count.addAndGet(1);
    }
    return sql;
  }
}

@Data
public class Counter {
  private final AtomicLong count;
  private final Long time;
}

여기서는 실제 쿼리를 날릴때 캐치해서 count를 올려주는 그런 클래스이다. 시작을 알리는 메서드와 끝을 알리는 메서드 그리고 데이터를 가져오는 메서드가 존재한다. 최초에는 count에 0과 현재시간을 넣어 주기만 하면된다. 그리고 하이버네이트가 쿼리를 날릴 때 count를 올려주기만 하면 끝이다. 필요하다면 여기서 sql을 조작 할 수 도 있다.

@Configuration
public class HibernateConfig extends HibernateJpaAutoConfiguration {

  public HibernateConfig(DataSource dataSource, JpaProperties jpaProperties, ObjectProvider<JtaTransactionManager> jtaTransactionManagerProvider) {
    super(dataSource, jpaProperties, jtaTransactionManagerProvider);
  }

  @Override
  protected void customizeVendorProperties(Map<String, Object> vendorProperties) {
    vendorProperties.put("hibernate.session_factory.statement_inspector", hibernateInterceptor());
    super.customizeVendorProperties(vendorProperties);
  }

  @Bean
  public HibernateInterceptor hibernateInterceptor() {
    return new HibernateInterceptor();
  }

  @Bean
  public RequestCountInterceptor requestCountInterceptor() {
    return new RequestCountInterceptor(hibernateInterceptor());
  }
}

필자는 거의 대부분 Spring boot 로 개발을 해서 설정도 Spring boot 기준으로 한다. 또한 jpa구현체도 하이버네이트다. 구현체를 다른거를 쓴다면 아마도 다른거를 상속 받아야 겠지만 필자는 거의 하이버네이트만 쓰기에 다른거는 모르겠다.
RequestCountInterceptor 클래스랑 HibernateInterceptor클래스를 빈으로 등록해주고 HibernateJpaAutoConfiguration를 상속받아 customizeVendorProperties를 오버라이딩 받으면 된다. 그리고 속성중 hibernate.session_factory.statement_inspector에 우리가 만든 하이버네이트 인터셉터를 넣으면된다. Spring boot를 쓰지 않는다면 xml이나 java config에 hibernate.session_factory.statement_inspector을 추가 하면 될 거 같다.
마지막으로 spring 인터셉터를 등록하자.

@Configuration
@RequiredArgsConstructor
public class WebConfig extends WebMvcConfigurerAdapter {

  private final RequestCountInterceptor requestCountInterceptor;

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(requestCountInterceptor).addPathPatterns("/**");

  }
}

RequestCountInterceptor를 빈으로 등록하는 거는 WebConfig에 있는게 더 어울린다. 뭐 그게 중요한게 아니니..
중요한건 실서버에는 올리지 말자…. 혹시나 이코드로 인해 실서버가 죽으면 안되니..
정말로 테스트를 위한 코드이니 테스트로만 사용하고 운영서버에서는 빼자!

테스트를 해보면 아래와 같이 로그가 남겨진다.

2016-09-03 00:00:42.482  INFO 885 --- [nio-8080-exec-4] m.w.testing.RequestCountInterceptor      : time : 51, count : 4 , url : /

근데 뭔데 4번이나 날라갔지? 흠
아 참고로 Spring boot 1.4 와 hibernate 5.0.9 기준이다.

Spring Boot 1.4 Test

이번 시간에는 Spring Boot 1.4부터 추가된 Test를 알아보자

Spring Boot 1.3 에서는 이런 어노테이션을 붙어서 테스트를 진행했다.

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class SimpleTestCase {
}

하지만 1.4.부터는 좀더 심플하게 바뀌었다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class SimpleTestCase{
}

좀더 간판해졌다는걸 알수 있다.
1.4 부터 추가된 @MockBean 이라는 어노테이션이 있다. 가짜 객체를 만들어 테스트할 수 있게 만들어준다. 한번 코드를 보자.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringBootTestApplicationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @MockBean
    public UserService userService;

    @Test
    public void userRestTemplateTest() throws Exception {
        User user = new User(1L, "wonwoo", "wonwoo@test.com", "123123");
        given(userService.findOne(1L)).willReturn(user);
        ResponseEntity<User> userEntity = this.restTemplate.getForEntity("/findOne/{id}", User.class, 1);
        User body = userEntity.getBody();
        assertThat(body.getName()).isEqualTo("wonwoo");
        assertThat(body.getEmail()).isEqualTo("wonwoo@test.com");
        assertThat(body.getPassword()).isEqualTo("123123");
        assertThat(body.getId()).isEqualTo(1);
    }
}

@Autowired 와 비슷하게 사용하면 된다. @MockBean 어노테이션만 붙어주면 된다. 그럼 실제 객체가 아닌 가짜 객체가 주입된다.
그러고나서 호출할 때와 리턴값을 가짜 객체를 만들어서 넣어주고 호출해주면 된다. 그러면 실제 UserService 호출하는 어느곳에서 가짜객체가 만들어져서 위와 같은 리턴값을 받게된다.
위의 예제는 TestRestTemplate을 이용했는데 MockMvc를 이용해도 된다.

@RunWith(SpringRunner.class)
@WebMvcTest(UserController.class)
public class UserControllerTest {

  @Autowired
  private MockMvc mvc;

  @MockBean
  public UserService userService;

  @Test
  public void userTest() throws Exception {
    given(userService.findOne(1L)).willReturn(new User(1L, "wonwoo", "wonwoo@test.com", "123123"));
    mvc.perform(get("/findOne/{id}", 1L).accept(MediaType.APPLICATION_JSON))
      .andDo(print())
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.id", is(1)))
      .andExpect(jsonPath("$.name", is("wonwoo")))
      .andExpect(jsonPath("$.email", is("wonwoo@test.com")))
      .andExpect(jsonPath("$.password", is("123123")))
      .andExpect(content().json("{'id':1,'name':'wonwoo','email':'wonwoo@test.com','password':'123123'}"));
  }
}

아까의 예제와 비슷하게 UserService에 가짜객체를 주입해서 테스트를 했다. 뭐 나머지는 예전에 테스트 포스팅을 할때 했던거라 굳이 설명하지는 않겠다.

다음은 @DataJpaTest 인데 Jpa를 테스트 할 때 사용하면 될듯하다. 바로 예제를 보자.

@RunWith(SpringRunner.class)
@DataJpaTest
public class UserRepositoryTest {

  @Autowired
  private TestEntityManager entityManager;

  @Autowired
  private UserRepository repository;

  @Test
  public void findByNameTest() {
    this.entityManager.persist(new User("wonwoo", "wonwoo@test.com","123123"));
    User user = this.repository.findByname("wonwoo");
    assertThat(user.getName()).isEqualTo("wonwoo");
    assertThat(user.getEmail()).isEqualTo("wonwoo@test.com");
    assertThat(user.getPassword()).isEqualTo("123123");
    assertThat(user.getId()).isEqualTo(1);
  }
}

TestEntityManager 라는 클래스는 Spring 1.4부터 추가 되었다. 이것은 주입받아서 테스트를 할 수 있다. persist를 해도 실제 디비에는 저장 되지 않는듯 하다. 정말 Test를 위해서 만들어진듯하다. 필자는 @DataJpaTest를 잘 사용하지 않겠지만 나머지 두개는 많이 사용할 듯하다.
이외에도 Json을 테스트 할 수 있는 것들도 존재 한다. 딱히 필자는 필요 없어서 하지는 않았다.
@JsonTest 어노테이션과 JacksonTester클래스를 한번 살펴보면 되겠다. 관심있는 분들은 한번 살펴보면 좋을 것 같다.
필자는 @MockBean 어노테이션이 젤 맘에 든다. 좀 더 쉽게 테스트를 할 수 있어 좋다.
간단한 소스도 여기에 올려놨으니 한번 살펴보면 도움이 많이 될듯하다.

Spring Jpa java8 date (LocalDateTime) 와 Jackson

제목이 거창하지만 별거 없다.
Spring data jpa와 java8에 추가된 LocalDateTime 설정과 그에 맞게 json으로 보낼 Jackson 설정을 알아볼 예정이다.
현재 JPA2.1은 java8의 date(LocalDateTime) 를 지원하지 않는다. 아마도 java8이 릴리즈 되기 전에 JPA2.1이 먼저 나와서 일것이다.
java8 이전의 Date API는 엉망진창이다.(물론 내가 잘만드는건 아니지만..) thread-safe 하지도 않고 API가 직관적이지도 않다. 그래서 우리는 Third-party 라이브러리인 Joda-time을 많이 쓰곤했다. 하지만 java8에 추가된 date는 Joda-time의 저자와 오라클 주도하에 JSR310 표준에 맞춰 개발 하였다.
아무튼 이걸 말하고자 하는건 아닌데.. 그럼 한번 살펴보자.

일단 우리는 java8의 추가된 LocalDateTime과 DB와의 연결고리를 만들어야 한다.
직접적으로 컨버터를 만들어도 되지만 Spring에서 만든 컨버터가 있다. 필자는 거의 최신이라 버전이 낮으면 없을 수도 있으니 여기를 참고해서 만들자. 아마도 직접적으로 만들면 딱히 설정할 필요가 없는데 만약 Spring에서 만든 컨버터를 사용하려면 약간의 설정이 필요하다.

@SpringBootApplication
@EntityScan(basePackageClasses = {Application.class, Jsr310JpaConverters.class} )
public class Application {

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

EntityScan에 위와 같이 넣어주면 끝난다. 그럼 일단 DB와는 컨버터가 되었다.
한번 데이터베이스에 잘 들어가나 확인해보자. 아마도 문제 없이 잘 들어갈 것이다. 왜냐면 필자가 잘 되었기 때문이다. ㅎㅎ
컨버터는 되었으니 View에 뿌려줄 json 데이터가 문제다. 처음엔 그냥 있는 그대로 출력해보자.
출력한 결과는 다음과 같다.

{  
  "id":1,
  "name":"tt",
  "password":"111",
  "email":"123",
  "localDateTime":{  
    "hour":22,
    "minute":47,
    "second":34,
    "nano":158000000,
    "dayOfYear":194,
    "dayOfWeek":"TUESDAY",
    "month":"JULY",
    "dayOfMonth":12,
    "year":2016,
    "monthValue":7,
    "chronology":{  
      "id":"ISO",
      "calendarType":"iso8601"
    }
  }
}

조금 그렇다. 물론 이렇게 써도 되겠지만(?) 필자는 컨버터가 하고 싶었다. 컨버터 방법역시 간단하다.
일단 메이븐에 아래와 같이 추가하자.

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

필자는 Spring Boot를 쓰는데 저것 역시 Version을 Boot에서 관리해준다. 그래서 명시 하지 않았다. Boot를 쓰지 않는다면 버전을 명시해야 한다. 버전은 인터넷에서.. 현재 필자의 버전은 2.8.0이다.
Spring boot의 경우에는 일단 메이븐에만 추가해도 내용이 바뀌어 출력된다. Boot가 아닌경우에는..잘… 따로 설정을 해야 하나?..

메이븐을 추가하고 다시 테스트를 해보면 아래와 같이 출력된다.

{
  "id": 1,
  "name": "tt",
  "password": "111",
  "email": "123",
  "localDateTime": [
    2016,
    7,
    12,
    22,
    47,
    34,
    158000000
  ]
}

아까보다는 나아졌다고 할 수 있지만 그래도 영 시원찮다. 좀 더 깔끔한 방법으로 출력하고 싶다. 그럼 아래와 같이 조금 설정을 해줘야한다.

@Bean
public ObjectMapper objectMapper() {
  return Jackson2ObjectMapperBuilder
    .json()
    .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    .modules(new JavaTimeModule())
    .build();
}

Spring boot를 쓰면 위와 같이 하지 않고 properties나 yaml 파일에 아래와 같이 넣자.

spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false

그럼 아래와 같이 ISO 8601 포멧으로 바뀐다. 요즘 Rest API에 많이 쓴다고 한다.

{
  "id": 1,
  "name": "tt",
  "password": "111",
  "email": "123",
  "localDateTime": "2016-07-12T22:47:34.158"
}

이제 깔끔하게 되었다. LocalDateTime도 알아봤으니 나머지 Date(LocalDate, LocalTime) 등들도 어떻게 출력 되는지 한번 살펴 보자.


private ZonedDateTime zonedDateTime; private LocalDate localDate; private LocalTime localTime; private OffsetDateTime offsetDateTime;

이 외에도 좀 더 존재하는거 같지만 위에 4개만 더 테스트 해보자.

메이븐에 jackson에 추가 히지 않았을 경우 (아무 설정 하지 않았을경우)

{
  zonedDateTime: {
    offset: {
      totalSeconds: 32400,
      id: "+09:00",
      rules: {
        fixedOffset: true,
        transitions: [

        ],
        transitionRules: [

        ]
      }
    },
    zone: {
      id: "Asia/Seoul",
      rules: {
        fixedOffset: false
      }
     //.. zonedDateTime long
     //..
     //..
    }
  },
  localDate: {
    year: 2016,
    month: "JULY",
    dayOfYear: 194,
    dayOfWeek: "TUESDAY",
    leapYear: true,
    dayOfMonth: 12,
    monthValue: 7,
    chronology: {
      id: "ISO",
      calendarType: "iso8601"
    },
    era: "CE"
  },
  localTime: {
    hour: 23,
    minute: 35,
    second: 30,
    nano: 847000000
  },
  offsetDateTime: {
    offset: {
      totalSeconds: 32400,
      id: "+09:00",
      rules: {
        fixedOffset: true,
        transitions: [

        ],
        transitionRules: [

        ]
      }
    },
    dayOfYear: 194,
    dayOfWeek: "TUESDAY",
    month: "JULY",
    dayOfMonth: 12,
    year: 2016,
    monthValue: 7,
    hour: 23,
    minute: 35,
    second: 30,
    nano: 847000000
  }
}

zonedDateTime 은 너무 길어서 일부만 가져와서 보여줬다.

spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS 설정을 제거 했을 경우

{
  id: 1,
  name: "tt",
  password: "111",
  email: "123",
  localDateTime: [
    2016,
    7,
    12,
    23,
    35,
    30,
    847000000
  ],
  zonedDateTime: 1468334130.847,
  localDate: [
    2016,
    7,
    12
  ],
  localTime: [
    23,
    35,
    30,
    847000000
  ],
  offsetDateTime: 1468334130.847
}

spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS 설정을 사용 했을 경우

{
  id: 1,
  name: "tt",
  password: "111",
  email: "123",
  localDateTime: "2016-07-12T23:35:30.847",
  zonedDateTime: "2016-07-12T23:35:30.847+09:00",
  localDate: "2016-07-12",
  localTime: "23:35:30.847",
  offsetDateTime: "2016-07-12T23:35:30.847+09:00"
}

참고로 아무 설정 하지 않았을 경우 zonedDateTime, offsetDateTime 은 DB에 BLOB로 매핑된다.
원래는 금방 포스팅했는데 컴터가 맛탱이가서 좀 오래 걸렸다. 컴터를 바꿀때가 된듯하다…