Spring @PathVariable에 대한 에러 처리는 어떻게 할까

@RequestMapping(method = RequestMethod.GET, value = "/shuttle/{number}")
public ResponseEntity shuttle(@PathVariable int number) {
    //
}

뭐 이런 API를 만든다고 했을 때 스프링으로 number에 해당하는 부분을 손쉽게 타입 변환까지 거쳐서 int로 받아 사용할 수 있다. 그런데, 만약 입력한 값이 int 타입이 아니라면 무슨 일이 생길까. 스프링은 무슨 값이 오던 int 타입으로 타입 변환을 시도할 것이고 에러가 발생한다. 그리고 응답은 400으로 나가며 본문의 메시지는 비어있다. 깔끔한 API라고 보긴 어렵다. 뭔가 에러 상황에서는 왜 그런 에러가 발생했는지 클라이언트가 알기 쉽도록 안내해 주는게 좋은 API일 것이다. 그렇다면 무슨 에러가 발생하며 어떻게 처리할 수 있나 알아보자.

@Test
public void testMethodArgumentTypeMismatchException() throws Exception {
    mockMvc.perform(get("/shuttle/aaa"))
            .andDo(print())
            .andExpect(status().isBadRequest());
}

테스트 코드다. 실행하면 콘솔에서 이런 내용을 확인할 수 있다.

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /shuttle/aaa
       Parameters = {}
          Headers = {}

Handler:
             Type = me.whiteship.web.ShuttleController
           Method = public org.springframework.http.ResponseEntity me.whiteship.web.ShuttleController.shuttle(int)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = org.springframework.web.method.annotation.MethodArgumentTypeMismatchException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = {}
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

뭐 이미 답이 다 이 안에 있다. 우선 응답이 썩 사용자에 친화적이지 않다는 것을 알 수 있고, 어떤 에러가 발생했는지도 알 수 있다. 그걸 잡아서 처리하면 되겠다.

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
    return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}

이런식으로 MATME를 잡아서 처리하는 핸들러 코드를 추가하면 원하는 메시지를 응답으로 담을 수 있다. 그냥 getMessage()를 꺼내서 보내는 것보다는 좀 더 손을 대야겠지만 그건 뭐 취향으로.. 그리고 이걸 해보며 드는 생각은 이렇다.

  • 이 예외 처리기는 공용으로 사용하기엔 뭔가 너무 일반화되어 있다는 느낌이 든다. 아무래도 컨트롤러 안에 두는게 좋겠다.
  • 저 예외 클래스가 제공하는 메서드중 getName()과 getParameter()를 잘 사용한다면 어쩌면 @ControllerAdvice에 둘 수도 있을지 모르겠다.
  • 타입이 String인 변수에 @PathVariable을 사용했다면 이 에러를 잡을 일이 없을 것이다.

DateTimeFormatter 사용할 때 파싱하려는 문자열의 Locale 정보도 꼭 주는게 좋다.

다음과 같이 DateTimeFormatter를 사용해서 “6:55 AM”이라는 문자열을 LocalTime으로 파싱하려고 한다.

@Test
public void testParsingTime() {
    String time = "6:55 AM";
    LocalTime localTime = LocalTime.parse(time, DateTimeFormatter.ofPattern("h:mm a"));
    assertThat(localTime.getHour(), is(6));
    assertThat(localTime.getMinute(), is(55));
}

코드에는 별로 크게 이상한 점이 보이지 않는다. 파싱하는 패턴이 올바른지 궁금하다면 샘플 데이터를 몇가지 더 살펴볼 수 있는데, 지금 내가 파싱하려는 데이터는 이런식이다.

7:15 AM, 8:00 AM, 11:05 AM, 1:00 PM, 1:35 PM

시간은 두자리를 꼭 채우지는 않기 때문에 h 하나만 사용하면 되고 시간과 분은 콜론(:)으로 구분하고 다음에 분은 두자리를 꼭 지키고 있기 때문에 mm을 사용했다.
마지막에 스페이스를 하나 띄고나서 a로 AM, PM을 파싱하고자 한다. 딱히 틀린건 없다고 생각했는데 막상 실행하면 에러가 난다.

java.time.format.DateTimeParseException: Text '6:55 AM' could not be parsed at index 5
    at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1947)
    at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1849)
    at java.time.LocalTime.parse(LocalTime.java:441)
    at me.whiteship.domain.ShuttleTest.testParsingTime(ShuttleTest.java:20)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
    at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
    at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:78)
    at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:212)
    at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:68)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:140)

이 에러를 해결하려면 Locale 정보를 추가해야 한다.

@Test
public void testParsingTime() {
    String time = "6:55 AM";
    LocalTime localTime = LocalTime.parse(time, DateTimeFormatter.ofPattern("h:mm a")
            .withLocale(Locale.ENGLISH)); // without Locale an exception may occur.
    assertThat(localTime.getHour(), is(6));
    assertThat(localTime.getMinute(), is(55));
}

현재 내가 작업중인 개발 환경의 JVM 환결 설정에 Locale 정보다 아마도 KOREA로 되어있는것 같다. 그래서 그 기본값에 따라 AM, PM 정보를 파싱하려는데, 한국어로는 그게 “오전”, “오후”로 표기하니까 AM, PM 정보를 파싱할 수 없어서 에러가 발생한 것이고, 데이터가 영어니까 영문 Locale로 파싱하도록 수정한 코드는 잘 실행된다.

봄싹이 갔습니다.

2008년 초인가 말(더 일찍 일지도 모르겠습니다. 기억이 안나요.)인가 봄싹 스터디라고 KSUG에서 갈라져 나온 모임이 있었습니다.

처음엔 스프링 스터디 모임이었는데 어찌저찌 하다보니 자바스크립트, 디자인패턴, 테스트 주도 개발 등 여러가지 스터디도 하고 봄싹 홈페이지 개발 모임도 하고 여러번 세미나도 했었습니다. 그러다 핵심 멤버였던 윤군이 미국으로 떠나고 저는 결혼하고 애가 둘이 되었고 다른 분들은 KSUG 회장을 역임하시며 봄싹은 그렇게 흐지부지 되었습니다.

안그래도 깔끔하게 정리하려고 했는데 이렇게 갑작스럽게 도메인 비용을 지불하지 않아 접속이 막히는 상황은 예측하지 못했는데…
머 나름대로 고맙기도 하고…  

암튼 이만 바이바이  

봄싹에서 여러 사람 만나 즐겁게 잘 놀았습니다! 모두 행복하시고 즐거우시길 빕니다.

Geb과 Spock을 사용한 테스트 작성

브라우저를 사용한 일종의 인수 테스트(Acceptance Test)를 작성할 때 꽤 유용해 보인다. 기존에 많이 알려져있던 Selenium 2.0에 포함된 WebDriver의 개념을 그대로 이어받지만 API 사용성을 훨씬 좋게 개선한 것이 Geb(젭)이다.

젭을 스팍과 같이 사용해서 요비의 이슈 본문에서 @를 사용한 사용자 아이디 자동완성 기능을 이런식으로 테스트 할 수 있다.

한때 디아블로 골드 앵벌이 매크로를 만들던 시절이 떠오르는건 그냥 기분탓이겠지…

geb이 괜찮아 보이는군.

사용자 입장에서의 시나리오별 테스트를 만들어 보려고 이것 저것 찾아봤더니 대안이 많지가 않네.

네이버에서 만든 기타(Guitar)라는거에 많은 기대를 했지만 윈도우 기반 플랫폼(AutoIt) 위에 얹어 만든거라서 윈도우에서만 동작한다. 내 개발 환경이 OSX이라서 그건 좀 곤란하다. 게다가 화면 구성 요소를 일일히 스샷 찍어서 특정 텍스트랑 맵핑 시키던데.. 신기한 기술이긴 하지만 손이 너무 많이 갈거 같다. 개발자 입장에선 css selection이 제일 편하지 않을라나.

결국엔 그냥 셀레니움 뿐인가 하고 다시 들여다 봤으나.. 2.0 이후로 뭐 크게 나아진게 없어보이고 예전에 손댔을 때 지져분하고 어려웠던 코딩이 떠올라서 손대기 싫어지고… 문제는 셀레늄 플러그인이 파폭 플러그인 뿐이라는건데 요비는 IE도 테스트 해야하고 가능한 여러 브라우저 여러 OS에서 확인이 필요하다. 셀레늄 플러그인 말고 Webdriver API로 직접 코딩하고 그 코드를 여러 OS에서 돌리면 다양한 환경에서의 테스트가 가능하긴 하지만 그 API가 그렇게 깔끔하지도 않고 쉽지도 않았다. Page Object Pattern이라는거부터 해서 공부할것도 제법된다. 어느세월에… @_@ 

뭐 다른 대안이 있겠지 싶어서 browser automation으로 검색(당연히 구글)했을때 셀레늄 다음으로 보이는 geb. 이건 webdriver(seleniun 2.0의 일부) 기반으로 만든 그루비 라이브러리인데 그나마 이게 좀 쓸만해 보인다. 웹드라이버 기반이니까 여러 브라우저 테스트를 하는것도 가능할테고 무엇보다 코드가 깔끔해 보인다. 게다가 문서까지 풍부하게 해놨네.. 대체 이렇게 열과성을 다해서 이런걸 공짜로 뿌리는 애들은 왜 그러는지 궁금하지만 내 입장에서는 땡큐지.

http://www.gebish.org/manual/current/

geb으로 요비 테스트나 만들어 보자. 후딱 만들어보고 안드로이도도 좀 보고 자바8도 봐야지. 룰루 랄라~