티스토리 뷰
최근에 Multipart/form-data 형태의 데이터를 다루어야 했는데, PATCH나 PUT는 요청이 정상적으로 처리되지 않는 것을 확인했습니다. 그래서 어째서인지 이유를 찾아 보게 되었고, 이번에는 관련 내용에 대해 정리해보고자 합니다.
관련된 내용은 StackExchange에서 참고하였으며, 편하게 읽어주세요ㅎㅎ
1. HTML 폼(form) 요청이 PATCH와 PUT, DELETE를 지원하지 않는다.
[ Multipart PATCH/PUT API 개발 과정 ]
Multipart PATCH/PUT API 개발
개발을 진행하다가 데이터와 이미지를 함께 보내기 위해 Multipart/form-data 타입의 데이터를 다루어야 했습니다. 부서 특성상 평소에 폼 데이터를 다룰 일이 거의 없었는데, 평소에 Application/json 데이터를 처리하듯이 POST와 PUT 및 PATCH API를 작성했습니다.
해당 API를 개발할 때에는 TDD를 하지 않았는데, POST 요청과 PUT/PATCH 요청이 상당히 동일하여 POST를 복사하여 수정하는게 생산성이 높을 것 같아서 이 경우에는 TDD를 하지 않았습니다. 그래서 개발 후에 테스트 코드를 작성하였는데, 문제가 발생했습니다.
Multipart PATCH/PUT 테스트 코드 작성
빠르게 복붙해서 PATCH/PUT API를 개발한 후에 Multipart PATCH/PUT 요청에 대한 API 테스트를 MockMVC를 사용해서 작성하는 순간 1차 관문을 마주하였습니다.
@Test
public void 데이터수정PUT성공() {
...
// when
final ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.multipart(url)
.part(new MockPart("myTestMultipart", "myTestMultipart".getBytes()))
);
// then
...
}
MockMVC에서 제공하는 MockMvcRequestBuilders에서 multipart 요청은 HTTP 메소드를 지정해주는 것이 불가능했고, 내부 코드를 들여다보니 MockMvc에서 Multipart 요청은 HTTP 메소드가 POST로 강제되었기 때문입니다. (참고로 이 부분은 SpringBoot 2.6.9, Spring 5.3.21에서 개선되었습니다. HttpMethod를 설정하는 방법도 공식적으로 지원하게 되었습니다.)
public static MockMultipartHttpServletRequestBuilder multipart(String urlTemplate, Object... uriVars) {
return new MockMultipartHttpServletRequestBuilder(urlTemplate, uriVars);
}
MockMultipartHttpServletRequestBuilder(String urlTemplate, Object... uriVariables) {
super(HttpMethod.POST, urlTemplate, uriVariables);
super.contentType(MediaType.MULTIPART_FORM_DATA);
}
// SpringBoot 2.6.9 이상만 사용 가능
public static MockMultipartHttpServletRequestBuilder multipart(HttpMethod httpMethod, URI uri) {
return new MockMultipartHttpServletRequestBuilder(httpMethod, uri);
}
Spring 프레임워크에 HTTP 스펙 관련해서 미완성된 코드는 없을 터인데, 뭔가 미심쩍었지만 찾아보니 우회하여 HTTP 메소드를 강제로 지정하는 방법이 있어서 다음과 같이 테스트 코드를 수정하였습니다.
@Test
public void 데이터수정PUT성공() {
...
// when
final ResultActions resultActions = mockMvc.perform(
multipartPutBuilder(url)
.part(new MockPart("myTestMultipart", "myTestMultipart".getBytes()))
);
// then
...
}
private MockMultipartHttpServletRequestBuilder multipartPutBuilder(final String url) {
final MockMultipartHttpServletRequestBuilder builder = MockMvcRequestBuilders.multipart(url);
builder.with(request1 -> {
request1.setMethod(HttpMethod.PUT.name());
return request1;
});
return builder;
}
이렇게 테스트 코드를 수정하니 테스트는 정상적으로 통과했습니다.
하지만 테스트 코드를 강제로 처리한 것이였기 때문에 통합 테스트가 필요했습니다. 그래서 서버를 띄우고 통합테스트를 실행한 결과 요청이 정상적으로 처리되지 않았습니다.
운영 코드에서 스프링이 스펙을 누락했을 확률은 전혀 없었을 것이기에, HTML Form은 PUT과 PATCH를 지원하지 않는 것이 HTTP 스펙이라고 짐작하게 되었고, 찾아보니 GET, POST만 지원할 뿐 DELETE도 지원하지 않았습니다. 그래서 왜 폼 요청은 GET과 POST만 지원하고 PUT, PATCH, DELETE 등은 지원하지 않는 것인지, 특별한 이유가 있는지 찾아보게 되었습니다.
2. HTML 폼(form) 요청이 GET/POST만 가능하고 PATCH, PUT, DELETE는 불가능한 이유
[ HTML 폼(form) 요청이 GET과 POST 만을 지원하는 이유 ]
HTML 폼(form) 요청이 GET과 POST 만을 지원하는 이유에는 나름의 스토리가 있는데, 관련 내용을 정리하면 다음과 같습니다.
- Form 메소드의 PUT/DELETE 미지원에 대한 이슈와 PR 등록
- HTML5 초기 디자인에 포함된 Form 메소드의 PUT/DELETE 지원
- Ian 'Hixie' Hickson에 의해 반려된 Form 메소드의 PUT/DELETE 지원
- Ian 'Hixie' Hickson 의견에 대한 반박
- 마무리되지 않고 닫혀버린 Form 메소드의 PUT/DELETE 지원
1. Form 메소드의 PUT/DELETE 미지원에 대한 이슈와 PR 등록
2010년도에 누군가가 HTML Form에서 PUT과 DELETE를 지원하지 않는 것은 버그이며, 관련 내용을 이슈로 등록하고 PR을 올렸습니다.
2. HTML5 초기 디자인에 포함된 Form 메소드의 PUT/DELETE 지원
그리고 이러한 주장은 실로 합리적이였기에 HTML5의 초기 디자인을 보면 Form 메소드에 PUT과 DELETE도 추가되려고 하였습니다. 그래서 실제로 파이어 폭스(FireFox) 베타 버전에는 관련 스펙이 추가되기도 하였습니다.
위의 그림에서 보면 PATCH는 관련 내용이 없는데, PATCH가 등장한 시점과 HTML5의 초안이 작성된 시점이 겹쳐서 그렇습니다.
과거 HTTP 표준에는 PATCH 메소드가 존재하지 않았고, 2010년도에 Ruby on Rails가 부분 수정을 필요로 하여 2010년도에 공식 HTTP 프로토콜로 추가되었습니다. 그런데 저 HTML5 초안이 논의된 것도 2010년이라 관련 스펙에 추가되지 않았습니다.
(참고로 Spring 프레임워크가 PATCH 메소드를 공식적으로 지원한 것은 2012년도입니다.)
3. Ian 'Hixie' Hickson에 의해 반려된 Form 메소드의 PUT/DELETE 지원
하지만 결국 공식적으로 PUT과 DELETE는 Form 메소드에 추가되지 않았습니다. 이러한 결정을 내린 것은 HTML5의 메인테이너인 Ian 'Hixie' Hickson인데, 그는 이와 관련해서 다음과 같이 코멘트를 남겼습니다.
이를 한국어로 번역하면 다음과 같이 이해할 수 있습니다.
"PUT에는 Form의 Payload를 넣는 것을 기대하지 않으므로 Form 메소드로 추가되는 것이 이치에 맞지 않는다. DELETE 역시 Payload가 있는게 맞지 못하므로 마찬가지다."
4. Ian 'Hixie' Hickson 의견에 대한 반박
DELETE는 Payload가 없어야 한다는게 납득이 가능합니다. (그렇다고 Form 메소드로 추가되지 않는게 납득가능하다는 것은 아닙니다.)
하지만 "전체 수정"을 위한 PUT 메소드에 Payload가 없어야 한다는 것은 이상하고, PUT 관련 RFC 스펙을 보아도 payload가 없어야 한다는 것은 부자연스럽습니다. RFC 스펙을 보면 PUT은 payload에 포함된 내용된 것으로 대체되도록 요청하는 것이라고 명시되어 있기도 합니다.
그래서 컨트리뷰터들이 "왜 안되는지" 설명을 요구하였고, 그리고 "왜 포함되어야 하는지" 반박 의견을 남기게 되었습니다.
5. 마무리되지 않고 닫혀버린 Form 메소드의 PUT/DELETE 관련 이슈
하지만 이후에 Ian 'Hixie' Hickson는 추가적인 코멘트를 남기지 않았고, 관련 이슈는 수정이 반영되지 않은 채로 닫혀지게 됩니다.
그리고 결국 HTML5에서 Form 메소드는 GET과 POST만 지원되게 되었으며, 그 이후에 누구도 관련 내용에 대해 진행을 하지 않으면서 이와 관련해 아직도 사람들이 의문을 품고 납득을 하지 못하는 상황이 발생하게 됩니다.
3. 서로 다른 리소스인 데이터와 파일은 별도의 API로 구성하기
[ 서로 다른 리소스인 데이터와 파일은 별도의 API로 구성하기 ]
앞서 설명한대로 저는 개발을 진행하다가 데이터와 파일을 함께 보내는 API를 설계하였습니다. 그런데 문제는 데이터와 파일을 함께 보내기는 API가 바람직하지 않다는 점입니다.
HTTP 스펙의 주요 저자이자 REST 아키텍처 스타일의 창시자인 Roy T. Fielding은 이러한 상황을 두고 다음과 같이 코멘트하였습니다.
PUT means the sent representation is the replacement value for the target resource. A server could certainly support that functionality using any container format, it wouldn't be "normal" to use a MIME multipart, nor is it expected to be supported by the file upload functionality defined for browsers in RFC1867.
If you want to PUT a package, I suggest defining a resource that can be represented by an efficient packaging format (like ZIP) and then using PUT on that resource to have the side-effect of updating the values of its subsidiary resources.
- Roy T. Fielding (Originator of the REST architectural style) -
위에 Roy T. Fielding이 작성한 내용을 번역하면 다음과 같습니다.
"PUT은 타겟 리소스를 보내는 값으로 교체하는 것이다. 서버는 어떠한 타입으로든 해당 기능을 지원해야 하는데 Multipart 타입은 일반적이지 못하며, RFC1867에 따라 브라우저에서 지원하지도 않는다. 만약 PUT으로 패키지를 올리고 싶다면 Zip처럼 명확한 패키지 포맷을 사용하고, 보조 리소스를 업데이트하는 것이 좋다."
Roy T. Fielding이 하고자하는 얘기는 데이터와 파일(서로 다른 포맷의 리소스)이 혼재된 패키지 데이터는 데이터 형식이 명확하지 못한데, 그러한 이유는 서로 다른 리소스를 한번에 처리하기 때문이라는 것입니다. 그렇기 때문에 명확한 Zip과 같은 형태로 데이터를 묶는 것이 더 좋다는 것인데, 그것보다는 아무래도 서로 다른 리소스인 데이터와 이미지의 API를 각각 따로 만드는 것이 더욱 좋은 설계일 것입니다. 그러므로 우리도 이러한 작업을 해야하는 경우에 서로 다른 포맷의 리소스가 혼재된 경우에는 분리해서 API를 개발하는 것이 좋을 것 같습니다.
다소 허무한 결말이지만 이러한 이유로 Spring 역시 스펙에 따라 HTML 폼(form) 요청은 GET과 POST만 가능하도록 강제하였고, 실제 운영 코드에서도 처리가 되지 않았던 것이였습니다. 해당 이슈와 관련해서 언젠간 다시 논의가 될 것 같은데, 그 때에는 form 요청이 다른 메소드들도 지원하게 되지 않을까 생각됩니다. 감사합니다:)
'네트워크' 카테고리의 다른 글
[HTTP] HTTP 메소드의 멱등성(Idempotence)과 Delete 메소드가 멱등한 이유 (10) | 2023.01.03 |
---|---|
[HTTP] HTTP 상태 401(Unauthorized) vs 403(Forbidden) 차이 (3) | 2021.04.22 |
[Web] HTTP와 HTTPS의 개념 및 차이점 (34) | 2020.10.17 |
[Web] Forward와 Redirect 차이 (30) | 2019.09.10 |
[네트워크 프로그래밍] Http 프로그래밍과 Socket 프로그래밍 차이 (36) | 2019.02.17 |