Programming

RESTful API 03. 예제를 더욱더 RESTful하게! - ETag + URI

Junior Lob! 2021. 3. 7. 23:00

 

해당 글은

02-RESTful-API-예제-Spring-Framework

에서 작성된 프로젝트를 좀 더 REST 하게 변경하는 과정을 간략하게 담은 글입니다.

 

이러한 과정을 통해 리처드슨 성숙도 모델의 3 level에 가까운 혹은 만족하는 API를 구현할 수 있습니다.

 

 

ETag란 무엇인가요? (Entity Tag)


HTTP Spec의 일부로써 웹 캐시 유효성 검사를 위해 제공하는 메커니즘 중 하나입니다.

이 방식의 특징으로는 클라이언트가 조건에 따른 요청을 할 수 있게끔 한다는 것입니다.

 

리소스에 대한 특정 버전 혹은 결과에 대해 ETag를 생성하여 요청을 검증하고, 서버 리소스에서 변경이 발생한다면 ETag를 변경하여 검증을 실패하게 합니다.

 

이는 새로운 리소스 결과를 클라이언트에 전달하며 ETag 값은 요청, 응답 헤더로 제공되게 됩니다.

 

 

이를 통한 장점

  • 불필요한 요청 트래픽 ( 네트워크 트래픽 )을 감소시키고, 요청에 대한 빠른 응답을 지원하게 됩니다. 서버에 영향을 주지 않는 HTTP 메서드 혹은 로직을 지원합니다. get, head..
  • 이러한 요청 트래픽 감소는 부가적으로 DB의 트랜잭션을 감소시킬 수 있습니다.

 

ETag를 사용하지 말아야할 때

  • 결과 응답 방식 포맷 등에 따라서 ETag 값이 효용성을 잃거나 계속 생성될 수 있습니다. gzip의 Timestamp..
  • 서버의 리소스를 변경시키는 로직, 자주 결과가 바뀌는 API에 대해 ETag를 적용한 경우 이는 오히려 서버의 부하를 제공할 수 있습니다.
  • ETag Spec에 맞게 구현되었더라도 다른 구현 방식, 구형 브라우저에 의해 해당 기능이 사용되지 않을 수 있습니다.

 

 

자바 진영은 ETag를 어떻게 제공하나요?


자바 진영에서는 ETag 기능을 Serlvet Filter를 통해 지원합니다. ShallowEtagHeaderFilter

 

그렇기에 다른 프레임워크에서도 재사용 가능합니다. Spring에서는 기본적으로 MD5 알고리즘을 이용해 응답의 Body를 byteStream으로 가져와 해싱하여, ETag 값으로 제공합니다.

protected String generateETagHeaderValue(InputStream inputStream, boolean isWeak) throws IOException {
        // length of W/ + " + 0 + 32bits md5 hash + "
        StringBuilder builder = new StringBuilder(37);
        if (isWeak) {
            builder.append("W/");
        }
        builder.append("\"0");
        DigestUtils.appendMd5DigestAsHex(inputStream, builder);
        builder.append('"');
        return builder.toString();
    }

 

 

 

Etag를 위한 Filter 추가하기


@Configuration
public class ETagHeaderFilter {

    @Bean
    public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
        FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean    = new FilterRegistrationBean<>( new ShallowEtagHeaderFilter());
        filterRegistrationBean.addUrlPatterns("/api/notices");
        return filterRegistrationBean;
    }
}

@Bean 메서드는 스프링 컨테이너에 의해 관리되는 Configuration Proxy Bean을 통해 호출되는 일종의 펙토리 메서드입니다. 일반적으로 위와 같이 FilterRegistrationBean과 같은 특정 객체에 값과 설정을 제공하고, 그 결과 해당 Bean 혹은 빌더의 결과를 전달하게 됩니다.

 

 

 

ETag가 설정 되었는지 확인해보기


이후 사용될 MockMvc를 언급하자면, 이 객체는 MVC 테스트에 필요한 객체로 실제 웹 애플리케이션을 구동시키지 않고 클라이언트의 역할을 하여 MockHttpServletRequest, Response를 보내고 받아 검증할 수 있습니다.

 

이를 통해 작성된 테스트 코드를 통해 동작 여부를 확인할 수 있습니다.

 

 

기본적인 작성 방식은

mockMvc.perform({MockHttpServletRequestBuilder.get, delete, post...}({URI 정보})
                .content({요청 바디에 담을 정보})
                .contentType({요청 바디의 타입}))
                .andDo(print()) // 요청, 응답 결과를 콘솔에 출력
                .andExpect(status().isCreated()) // Http 상태, 바디 값, 헤더 등 존재 여부 등 응답 결과를 검증
        ; //.andExpect(jsonPath()); JSON 데이터 검증

이렇게 됩니다.

 

 

ETag FIlter가 적용된 곳에 요청 보내기

String etag = mockMvc.perform(get("/api/notices")
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(header().exists("ETag"))
                .andReturn().getResponse().getHeader("Etag");
MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json", ETag:""0e3344bf1e63fc52fbe7ce711a2f5014f"", Content-Length:"295"]
     Content type = application/json
             Body = [{"id":2,"author":"author","title":"title","content":"content","createDate":"2021-03-07T17:27:19.504702","modifyDate":"2021-03-07T17:27:19.504702"},{"id":1,"author":"author","title":"title","content":"content","createDate":"2021-03-07T17:27:19.423703","modifyDate":"2021-03-07T17:27:19.423703"}]
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

 

최초 다음 요청부터 ETag 정보를 추가하여 전송하기

mockMvc.perform(get("/api/notices")
                .header("If-None-Match", etag)
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isNotModified());
MockHttpServletResponse:
           Status = 304
    Error message = null
          Headers = [Content-Type:"application/json", ETag:""0e3344bf1e63fc52fbe7ce711a2f5014f""]
     Content type = application/json
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

 

서버의 리소스를 업데이트 한 뒤 요청을 한다면?

mockMvc.perform(post("/api/notices")
              .content(objectMapper.writeValueAsString(createNoticeDto))
              .contentType(MediaType.APPLICATION_JSON))
              .andDo(print())
              .andExpect(status().isCreated());

// 이전 요청에서 보낸 ETag 요청을 전송
mockMvc.perform(get("/api/notices")
              .header("If-None-Match", etag)
              .contentType(MediaType.APPLICATION_JSON))
              .andDo(print())
              .andExpect(status().isOk());
MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json", ETag:""0649d2b6caf906a380376fe9258f0a7ae"", Content-Length:"442"]
     Content type = application/json
             Body = [{"id":3,"author":"author","title":"title","content":"content","createDate":"2021-03-07T17:27:19.557702","modifyDate":"2021-03-07T17:27:19.557702"},{"id":2,"author":"author","title":"title","content":"content","createDate":"2021-03-07T17:27:19.504702","modifyDate":"2021-03-07T17:27:19.504702"},{"id":1,"author":"author","title":"title","content":"content","createDate":"2021-03-07T17:27:19.423703","modifyDate":"2021-03-07T17:27:19.423703"}]
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

 

 

응답 정보에 URI를 포함하기


build.gradle

implementation 'org.springframework.boot:spring-boot-starter-hateoas'

 

 

Notice Controller의 create 변경하기

@PostMapping("/notices")
public ResponseEntity<NoticeInfo> createNotice(@Valid @RequestBody CreateNoticeDto noticeDto) {
        int result = noticeService.save(noticeDto);
        return ResponseEntity.created(linkTo(NoticeController.class).slash("notices").slash(result).toUri())
                .body(new NoticeInfo(result, "notice created"));
}

----------

mockMvc.perform(post("/api/notices")
                .content(objectMapper.writeValueAsString(createNoticeDto))
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isCreated());
MockHttpServletResponse:
           Status = 201
    Error message = null
          Headers = [Location:"http://localhost/api/notices/1", Content-Type:"application/json", Content-Length:"41"]
     Content type = application/json
             Body = {"message":"notice created","noticeId":1}
    Forwarded URL = null
   Redirected URL = http://localhost/api/notices/1
          Cookies = []

 

 

Notice Controller의 update 변경하기

@PutMapping("/notices/{noticeId}")
public ResponseEntity updateById(@Valid @RequestBody UpdateNoticeDto noticeDto,
                                             @PathVariable Long noticeId) {

        noticeService.updateById(noticeDto, noticeId);
        return ResponseEntity.ok(linkTo(NoticeController.class).slash("notices").slash(noticeId).toUri());
}

----------

mockMvc.perform(put("/api/notices/{noticeId}", 1L)
                .content(objectMapper.writeValueAsString(updateNoticeDto))
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk());
MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = "http://localhost/api/notices/1"
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

 

Notice Controller의 delete 변경하기

@DeleteMapping("/notices/{noticeId}")
public ResponseEntity deleteById(@PathVariable Long noticeId) {

        noticeService.deleteById(noticeId);
        return ResponseEntity.ok(linkTo(NoticeController.class).slash("notices").toUri());
}

----------

mockMvc.perform(delete("/api/notices/{noticeId}", 1L)
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk());
MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = "http://localhost/api/notices"
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

 

참고할 링크입니다.

 

www.baeldung.com/etags-for-rest-with-spring

 

ETags for REST with Spring | Baeldung

ETags with the Spring - ShallowEtagHeaderFilter, integration testing of the REST API, and consumption scenarios with curl.

www.baeldung.com

docs.spring.io/spring-framework/docs/3.0.0.RC1/reference/html/ch15s11.html

 

15.11 ETag support

An ETag (entity tag) is an HTTP response header returned by an HTTP/1.1 compliant web server used to determine change in content at a given URL. It can be considered to be the more sophisticated successor to the Last-Modified header. When a server returns

docs.spring.io