기본 콘텐츠로 건너뛰기

냉장고 가계부 프로젝트 7

FoodControllerTests 클래스에서 자주 사용되는 테스트 객체를 @Before 애너테이션으로 테스트 전에 생성되도록 리팩토링하고 findAllFoods 메서드도 수정한다.
    private Food milk;
    private static final Long ID = 1L;
    
    @Before
    public void setUp() {
        milk = new Food("파스퇴르 우유 1.8L", 1, "2018-09-28");
        milk.setId(ID);
    }

FoodController.findAllFoods 메서드를 통해서 목록표를 리턴받으면 그중에서 특정 Food의 상세정보를 요청할 수 있다. 다음과 같이 테스트 메서드를 작성한다.
    @Test
    public void findById() throws Exception {
        given(jpaFoodRepository.findOne(ID)).willReturn(milk);
        mvc.perform(get("/foods/" + ID))
            .andExpect(status().isOk())
            .andExpect(content().json(mapper.writeValueAsString(milk)));
    }

findById 메서드는 jpaFoodRepository의 findOne 메서드를 호출해서 단일 행 결과를 리턴한다. 그리고 /foods/1 과 같은 url을 get 방식으로 호출하여 결과가 milk객체의 json 타입으로 돌아오는것을 테스트한다.
다음은 FoodController 의 구현부분이다.
    @GetMapping("/{id}")
    public Food findById(@PathVariable final long id) {
        return jpaFoodRepository.findOne(id);
    }

@GetMapping 은 @RequestMapping(method=RequestMethod.GET) 과 동일한 애너테이션이다. @GetMapping 내의 URL 매핑은 "/{id}"로 되어있다. 이는 URI 템플릿 변수로 RESTful 웹서비스 표준의 일부이다. 동적으로 변하는 id값은 @PathVariable 애너테이션으로 선언된 파라미터 long id 변수에 할당할 수 있다. 컨트롤러 내부는 간단하게 repository의 findOne 결과를 반환한다.

식품을 새로 등록하려면 Post 방식을 사용한다. RESTful 웹 서비스는 URL, HTTP 방식, HTTP Status 세가지로 작업을 구분한다. 요청에는 HTTP 방식과 URL로 매핑할 컨트롤러를 식별하고, 결과를 돌려줄 때 HttpStatus 를 보내줌으로 요청한 작업에 대한 결과를 판단한다.
HTTP방식은 다음과 같다.

  • HEAD/GET: URL은 대표URL(/foods) 이다. GET은 서버에서 데이터를 얻어오는것이다. HEAD는 Response Body가 없다. 상태(OK: 200)
  • POST: URL은 대표URL(/foods) 이다. POST는 서버에 새로운 데이터를 등록한다. Response Body 에 새롭게 등록된 데이터에 접근할 수 있는 Redirect URI가 전달된다. 상태(Created: 201)
  • PUT: URL은 특정 데이터 접근URL(/foods/1) 이다. PUT은 서버에 기존 데이터를 업데이트한다. Response Body가 없다. 상태는(NoContent: 204)
  • PATCH: PUT과 비슷하다, 차이점은 부분 업데이트라는 점이다. 상태(Ok: 200, NoContent: 204)
  • DELETE: URL은 특정 데이터 접근URL(/foods/1) 이다. ResponseBody는 삭제된걸 보내거나 없거나 이다. 상태(Ok: 200/NoContent: 204)
Post 로 식품을 등록하는 테스트 메서드를 작성한다.
    @MockBean
    private JpaFoodService jpaFoodService;

    @Test
    public void postSave() throws Exception {
        given(jpaFoodService.save(any(Food.class))).willReturn(milk);
        mvc.perform(post("/foods")
                .content(mapper.writeValueAsString(milk))
                .contentType(MediaType.APPLICATION_JSON_UTF8))
            .andExpect(status().isCreated())
            .andExpect(content().json(mapper.writeValueAsString(milk)))
            .andExpect(redirectedUrlPattern("**/foods/{id:\\d+}"));
    }

JpaFoodService 클래스를 MockBean 객체로 등록한다. postSave 테스트 메서드를 보면, 컨트롤러에서 jpaFoodService.save 메서드를 사용할 것으로 조건을 지정한다.
이때, save메서드에 파라미터로는 Food.class 타입인 어떠한 객체라도 들어온다는 조건이 주어졌을때, 리턴될 값은 milk 인스턴스로 선언한다.
mockMvc가 post 방식으로 데이터를 전송하는데, content는 milk인스턴스를 json형태로 변환한 값을 전달하고, contentType은 application/json; utf8 형태라고 지정한다.
응답결과로는 상태코드가 isCreated: 201로 예상되며, body에 milk인스턴스가 json형태로 파싱되서 전달받게 된다.
마지막으로 새롭게 등록된 자원(Resource)에 대한 접근할 수 있는 경로(redirectUrl) 의 urlPattern은 Path(**)에 foods/숫자 형식의 AntPattern 으로 예측한다.

컨트롤러 메서드를 확인하면 무슨 말인지 이해가 빠르다.
    @Autowired
    private JpaFoodService jpaFoodService;

    @PostMapping
    public ResponseEntity<Food> postFood(@RequestBody final Food food) {
        jpaFoodService.save(food);
        URI location = MvcUriComponentsBuilder.fromController(getClass())
                .path("/{id}")    
                .buildAndExpand(food.getId())
                .toUri();
        return ResponseEntity.created(location).body(food);
    }

우선 비지니스 로직(jpaFoodService.save) 을 수행한다.
MvcUriComponentsBuilder는 응답 본문에 전송될 새로운 자원의 URI를 만들어준다. fromController는  매핑할 컨트롤러를 정하고, path는 경로를, buildAndExpand는 URI 템플릿 변수를 정하고 build한다. toUri로 uri 형태로 리턴해준다.
ResponseEntity 클래스는 Response Body Entity를 만들어서 리턴할 수 있도록 해주는데, 응답 body와 Http Status, redirectUrl등의 정보를 전달할 수 있다.
postFood 메서드에서는 created 정적 메서드를 통해 HttpStatus(Created: 201)을 전달하면서 새로운 자원에 대한 접근 URI도 파라미터로 받고있다. 체인링으로 body메서드는 Response Body 부분이며, 새롭게 생성된 객체(food)를 파라미터로 받는다. 테스트를 실행해보면 정상적으로 수행한다.

앞서 작성했던 findById 메서드는 응답 본문에 바로 Food객체를 반환했었다. 테스트도 성공한 상태이니 ResponseEntity를 반환해서 좀더 명확한 정보를 리턴하도록 리팩토링을 한다.
    @GetMapping("/{id}")
    public ResponseEntity<Food> findById(@PathVariable final long id) {
        Food food = jpaFoodRepository.findOne(id);
        if(food == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(food);
    }

ResponseEntity 타입을 리턴한다. food 를 불러오는데 food가 null이면 notFound를 호출하고 아닌경우 ok를 응답하면서 응답 본문에 food객체를 파라미터로 전달한다.

Put 기능은 Post와 비슷하므로 바로 테스트와 구현을 한다.
    @Test
    public void put() throws Exception {
        given(jpaFoodRepository.findOne(anyLong())).willReturn(milk);
        given(jpaFoodService.save(any(Food.class))).willReturn(milk);
        URI uri = UriComponentsBuilder.fromUriString("/foods/{id}").buildAndExpand(ID).toUri();
        mvc.perform(MockMvcRequestBuilders.put(uri)
                .content(mapper.writeValueAsString(milk))
                .contentType(MediaType.APPLICATION_JSON_UTF8))
            .andExpect(status().isNoContent())
            .andExpect(content().string(""));
    }

컨트롤러에서 수정할 데이터가 있는지 먼저 조회하는 findOne메서드가 주어지고, Service로 save한다.
put을 수행할 Uri 를 빌더로 만들어주고, 응답으로 NoContent, 응답 본문은 "" 공백으로 돌아온다.
다음은 컨트롤러 메서드 구현이다.
    @PutMapping("/{id}")
    public ResponseEntity<?> updateFood(@PathVariable final long id, @RequestBody final Food food) {
        if(jpaFoodRepository.findOne(id) == null) {
            return ResponseEntity.notFound().build();
        }
        jpaFoodService.save(food);
        return ResponseEntity.noContent().build();
    }

컨트롤러는 URI 템플릿을 위한 PathVariable이 메서드 파라미터로 받고, 두번째 파라미터로 요청 바디를 food 타입으로 받는다. 데이터가 있는지 조회하고 없으면 notFound 상태로 반환하고 아닐경우 수정하고, noContent로 응답결과를 리턴한다.

Delete 를 테스트하기 전에 먼저 FoodService 인터페이스에 remove(Long id) 추상메서드를 추가로 오버로딩한다. 그러면 JdbcFoodService, MemoryFoodService, JpaFoodService 세개의 구현클래스에 전부 메서드 오버라이딩을 해주어야한다.
먼저 MemoryFoodService 부터 테스트를 작성하고 메서드를 구현한다.
    @Test
    public void removeById() {
        Long id = 1L;
        foods = Arrays.asList(milk, cola);
        for(Food food : foods) {
            memoryFoodService.save(food);
        }
        memoryFoodService.remove(id);
        foods = memoryFoodService.findAll();
        assertThat(foods.size(), equalTo(1));
    }

milk, cola 두개의 데이터를 저장한 후에 첫번째 milk 객체의 ID(1L)를 삭제한 후 전체 Food 리스트의 개수를 검증하고 있다. 다음은 구현 클래스이다.
    @Override
    public void remove(Long id) {
        Iterator<Food> itr = foods.iterator();
        while(itr.hasNext()) {
            Food food = itr.next();
            if(food.getId() == id) {
                itr.remove();
                break;
            }
        }
    }

Iterator로 food 리스트를 반복하면서 파라미터로 전달받은 ID와 동일한 객체를 찾아서 삭제하고 반복문을 중단한다.

다음은 JdbcFoodService 테스트 메서드이다.
    @Test
    public void removeById() {
        Long lastId = 0L;
        List<Food> foods = Arrays.asList(new Food("코카콜라 500mL", 2, "2018-10-30"), 
                new Food("파스퇴르 우유 1.8L", 1, "2018-09-28"));
        for(Food food : foods) {
            jdbcFoodService.save(food);
            lastId = food.getId();
        }
        jdbcFoodService.remove(lastId);
        assertThat(jdbcFoodRepository.findAll().size(), equalTo(1));
    }

lastId 변수를 선언해서 초기화한다. 두개의 Food 데이터를 저장하고 마지막 저장한 Food의 Key값 ID를 lastId 변수에 대입한다.
해당 Id를 remove메서드의 파라미터로 전달하고 전체 Food 리스트의 개수를 검증한다.
다음은 JdbcFoodService 구현 메서드이다.
    @Override
    public void remove(Long id) {
        Food food = new Food();
        food.setId(id);
        jdbcFoodRepository.remove(food);
    }

jdbcFoodRepository에 remove 메서드를 호출하는데 Food 객체를 생성해서 Id를 set해준다.

다음은 JpaFoodRepository 테스트 메서드와 구현이다.
    @Test
    public void remove() {
        Food cola = new Food("코카콜라 500mL", 2, "2018-10-30");
        jpaFoodRepository.save(cola);
        assertThat(jpaFoodRepository.findAll().size()).isEqualTo(1);
        
        jpaFoodRepository.delete(cola.getId());
        assertThat(jpaFoodRepository.findAll().size()).isEqualTo(0);
    }

    @Override
    public void remove(Long id) {
        jpaFoodRepository.delete(id);
    }

JpaFoodService 에서는 remove메서드는 JpaFoodRepository에 단순히 위임해버린다.

이제 정리가 끝났으니 Delete 테스트 메서드와 구현을 작성한다.
    @Test
    public void delete() throws Exception {
        given(jpaFoodRepository.findOne(anyLong())).willReturn(milk);
        URI uri = UriComponentsBuilder.fromUriString("/foods/{id}").buildAndExpand(ID).toUri();
        mvc.perform(MockMvcRequestBuilders.delete(uri)
                .contentType(MediaType.APPLICATION_JSON_UTF8))
            .andExpect(status().isNoContent())
            .andExpect(content().string(""));
    }

Put 테스트 메서드와 거의 동일한데 차이점은 요청에 내용이 없고 URL 호출만 있다는 점이다.
컨트롤러 메서드를 구현한다.
    @DeleteMapping("/{id}")
    public ResponseEntity<?> deleteFood(@PathVariable final long id) {
        if(jpaFoodRepository.findOne(id) == null) {
            return ResponseEntity.notFound().build();
        }
        jpaFoodService.remove(id);
        return ResponseEntity.noContent().build();
    }

@DeleteMapping 애너테이션을 메서드에 선언함으로 deleteFood 메서드는 Delete 방식으로 매핑됨을 확인할 수 있다. URI 템플릿을 위한 PathVariable로 long id를 받고있다. 데이터가 존재하지 않을 경우 notFound 상태를 반환하고 아닐 경우 삭제 로직을 실행한 뒤에 noContent 상태를 반환한다.

이전글: 냉장고 가계부 프로젝트 6
다음글: 냉장고 가계부 프로젝트 8

이 블로그의 인기 게시물

냉장고 가계부 프로젝트 14

fridge-web 프로젝트에서 api 서버와 통신할때 URL을 매번 중복해서 입력하는 부분을 제거하기 위해 RestTemplate 빈 등록메서드를 수정한다. @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { return builder.rootUri("http://localhost:8081").build(); } builder에 rootUri 메서드를 호출해서 api 서버 url을 미리 설정하고 build해서 RestTemplate 객체를 반환하면 RestTemplate을 사용하는 부분에서는 root 다음 경로만 넘겨주면 된다. WebFoodController 클래스의 restTemplate 사용부분을 전부 수정한다. @GetMapping public String foods(Model model) { ResponseEntity<Resources<FoodCommand>> response = restTemplate.exchange("/foods", HttpMethod.GET, null, new ParameterizedTypeReference<Resources<FoodCommand>>() {}, Collections.emptyMap()); .... 다른 메서드들도 동일하게 수정한다. Food 클래스에서도 이제 더이상 사용하지 않는 Cloneable과 hashCode, equals 메서드를 정리한다. Guava 의존라이브러리도 다른곳에서는 사용하지 않으므로 정리한다. 식품에 대한 최소한의 기능은 구현하였다. 이번에는 냉장고 라는 개념을 모델링한다. 식품들이 들어가고 나가는 곳은 냉장고이기 때문에 냉장고라는...

냉장고 가계부 프로젝트 27

스프링 Data 에서 제공하는 @CreatedDate, @LastModifedDate 애너테이션을 이용해서 작성일자, 수정일자를 도메인객체가 생성되고, 수정될 때 관리될 수 있도록 한다. Spring Data를 쓰기 전에는 DB컬럼에 Date 타입의 등록일 컬럼을 추가하고 DEFAULT 값으로 SYSDATE 를 줘서 DB에 Insert 되는 시간에 등록일자 컬럼이 채워지는 방식을 사용했었고, Update 쿼리가 실행될때 수정일자 컬럼에 SYSDATE를 직접 Update 해주는 방식을 자주 썼다. 그 뿐만아니라, 등록일자와 더불어 등록한 사용자(ex: registerUser)를 식별하는 식별키(ex: userId, username ...) 를 Insert 쿼리가 실행될때 등록해주고, 수정한 사용자(ex: modifedUser)를 Update 쿼리에 설정하는 방식은 차후에 있을지도 모를 일에 대비해서 늘 반복해서 작업했다. 스프링 Data 에서 제공하는 JPA Audit 기능은 이런 코드의 반복을 줄여준다. 사용자 같은 경우 객체로 넘기면 객체의 식별자가 담긴다. 등록시간은 날짜형 타입이다. public class Blog { @CreatedBy private User user; @CreatedDate private LocalDateTime creadtedDate; } Blog 클래스의 User 객체는 @CreatedBy 애너테이션으로 선언되서 등록한 사용자를 나타내는 컬럼에 값을 입력할것이다. createdDate 필드는 도메인객체가 영속성 저장소에 반영되는 시간을 나타낸다. Fridge, Food 클래스에 등록일, 수정일만 먼저 적용한다. 두 클래스에 createdDate, lastModifedDate 멤버변수를 선언한다. @Data @NoArgsConstructor @Entity @EntityListeners(AuditingEntityListener.class) public class Fridge { ...

냉장고 가계부 프로젝트 10

API 가 어느정도 준비되었으므로, UI를 만든다. 별도의 프로젝트로 구성해서 API를 호출하는 방식으로 구성한다. 프로젝트명은 fridge-web이라고 정한다. 냉장고 가계부 프로젝트 1 을 참고한다. 새 프로젝트에서는 Dependencies를 Web, Thymeleaf, DevTools 세개를 체크한다. 프로젝트가 준비되면, pom.xml 파일을 연다. <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <gro...