RESTful API 03. 예제를 더욱더 RESTful하게! - ETag + URI
해당 글은
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
docs.spring.io/spring-framework/docs/3.0.0.RC1/reference/html/ch15s11.html