[ 네트워크 - HTTP ] 요청을 보냈는데 서버에서 반응이 없다? HttpMessage 구조를 확인해보자!
정말 헷갈렸던 시간,,,
부트캠프에서 마지막 프로젝트를 할 때, 클라이언트에서 이미지를 전송했지만 서버에서는 이미지 데이터를 받지 못했다.
당시의 나는 HTTP 메세지 구조에 대한 정확한 이해가 없었기에 도저히 답을 찾을 수 없었다...
HTTP 메세지 구조
HTTP 메세지 구조에 대한 이해가 반드시 필요하다.
HTTP 프로토콜을 통한 통신은 반드시 HTTP 메세지 구조에 맞춰서 통신한다.
요청할 때나 응답할 때 메세지 구조는 동일하고 내용만 다르다.
그렇다면 클라이언트가 요청으로 보낼 데이터가 어떻게 HTTP 메세지 구조에 맞춰서 만들어질까?
그건 바로 브라우저가 만들어준다.
아래의 그림과 같은 흐름이다.
HTTP 프로토콜로 데이터 송수신시 HTTP 메세지 구조에 맞춰서 해야한다.
브라우저가 데이터를 HTTP 메세지 구조에 맞춰 HTTP Request를 만들어서 서버로 보낸다.
서버는 HTTP Request의 데이터를 HttpServletReqeust에 담는다.
(여기서 데이터가 어떻게 byte단위로 보내지는지 알려면 socket을 공부하면 된다.)
클라이언의 요청에 따라
서버는 요청한 데이터를 등록하거나 응답 데이터를 클라이언트에게 전송한다.
이 때 클라이언트가 서버로 데이터를 전송하는 방법은 크게 4가지가 있다.
1. 정적 데이터 조회
2. 동적 데이터 조회
3. HTML Form을 통한 데이터 전송
4. HTTP API를 통한 데이터 전송
하나씩 알아보기 위해 간단한 코드를 작성했다.
구조는 다음과 같다.
스프링부트로 진행하였으면,
Spring Web과 Lombok 추가하였다.
- 예제 코드
https://github.com/doit-zero/httpTest.git
구조 순서대로 코드
controller
@Slf4j
@RestController
public class TestController {
private final TestRepository testRepository;
public TestController(TestRepository testRepository) {
this.testRepository = testRepository;
}
// 정적 데이터 조회
@GetMapping("/static-search")
public String staticSearch(){
return testRepository.findAll();
}
// 동적 데이터 조회
@GetMapping("/dynamic-search/{name}")
public String dynamicSearch(@PathVariable("name") String name){
return testRepository.findByName(name);
}
// html form
@PostMapping("/html-form")
public String htmlForm(@ModelAttribute UserDto userDto){
testRepository.put(userDto);
return userDto.getName() + " 등록 완료";
}
// multipart/form-data
@PostMapping("/multipart-form-data")
public String multipartForm(@ModelAttribute UserDto userDto) throws IOException {
testRepository.put(userDto);
return userDto.getName() + " 등록 완료";
}
// HTTP API
@PostMapping("/http-api")
public UserDto httpApi(@RequestBody UserDto userDto){
testRepository.put(userDto);
return userDto;
}
}
dto
// 테스트를 하기 위해 @Data를 씀
@Data
public class UserDto {
private String name;
private MultipartFile file;
}
repository
- @PostConstruct를 써서 IoC 컨테이너에 testRepository 빈이 등록되고 의존 관계 설정 후 초기화 함.
@Repository
public class TestRepository {
private final HashMap<String,UserDto> userDtoList = new HashMap<>();
@PostConstruct
public void init(){
for(int i = 0; i < 10; i++){
UserDto userDto = new UserDto();
userDto.setName("name" + i);
userDtoList.put("name"+i,userDto);
}
}
public String findAll(){
return "정적 데이터 조회 " + userDtoList;
}
public String findByName(String name) {
UserDto userDto = userDtoList.get(name);
if(userDto == null){
return name + " 는 등록되어 있지 않음";
}
return userDto.getName() + " 는 등록되어 있음 : " + userDto;
}
public void put(UserDto userDto) {
userDtoList.put(userDto.getName(),userDto);
}
}
static
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Upload Form</title>
</head>
<body>
<h1>Upload Form</h1>
<form id="uploadForm" enctype="multipart/form-data" action="/multipart-form-data" method="POST">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required><br><br>
<label for="file">Choose a file:</label>
<input type="file" id="file" name="file" accept="*/*" required><br><br>
<button type="submit">Submit</button>
</form>
<form id="uploadForm2" action="/html-form" method="POST">
<label for="name">Name:</label>
<input type="text" id="userName" name="name" required><br><br>
<button type="submit">Submit</button>
</form>
</body>
</html>
1. 정적 데이터 조회
1) 클라이언트가 http://localhost:8080/static-search 요청을 보낸다.
2) 브라우저는 http://localhost:8080/static-search에 해당하는 httpRequest Message를 만들어 서버에 전송한다.
다음과 같이 만들어진다. 브라우져의 네트워크 탭에서 확인 가능하다.
GET /static-search HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Cookie: Idea-38ab3bf5=a680304e-f014-4f20-bf3d-ef1ec60173ed
Host: localhost:8080
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
sec-ch-ua: "Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
3) 이 요청은 서블릿 컨테이너에 의해 스프링의 DispatcherServlet으로 전달되어 요청에 맞는 컨트롤러를 찾아 로직 실행 후
응답값을 만들어서 클라이언트에게 전송해준다.
또한 @RestController로 만들었기 때문에 응답값은 HttpResponse Message의 message body에 담겨 클라이언트에게 보내진다.
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 404
Date: Sat, 02 Nov 2024 05:33:11 GMT
Keep-Alive: timeout=60
Connection: keep-alive
정적 데이터 조회 {name6=UserDto(name=name6, file=null), name5=UserDto(name=name5, file=null), name4=UserDto(name=name4, file=null), name3=UserDto(name=name3, file=null), name9=UserDto(name=name9, file=null), name8=UserDto(name=name8, file=null), name7=UserDto(name=name7, file=null), name2=UserDto(name=name2, file=null), name1=UserDto(name=name1, file=null), name0=UserDto(name=name0, file=null)}
2. 동적 데이터 조회
이번에는 동적 데이터를 조회해보자.
서버에서 동적 데이터 조회 요청은 다음과 같은 URL 요청으로 받는다.
// 동적 데이터 조회
@GetMapping("/dynamic-search/{name}")
public String dynamicSearch(@PathVariable("name") String name){
return testRepository.findByName(name);
}
클라이언트는 다음과 같은 요청을 한다.
http://localhost:8080/dynamic-search/name1
그렇다면 HttpRequest Message는 어떻게 될까?
GET /dynamic-search/name1 HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Cookie: Idea-38ab3bf5=a680304e-f014-4f20-bf3d-ef1ec60173ed
Host: localhost:8080
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
sec-ch-ua: "Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
start line을 잘 봐보자!
name1이 되어 있는 것을 알 수 있다.
이는 queryParameter 방식도 마찬가지이다.
브라우저는 HttpRequest Message를 서버에 전송한다.
서버는 요청에 대한 응답값을 만들어 클라이언트에게 전송한다.
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 62
Date: Sat, 02 Nov 2024 05:33:58 GMT
Keep-Alive: timeout=60
Connection: keep-alive
name1 는 등록되어 있음 : UserDto(name=name1, file=null)
여기서 잠깐 왜 동적 데이터 조회라고 하는걸까?
URL에 포함된 변수 값에 따라 특정 리소스를 조회하거나 작업을 수행하기 때문에, 요청할 때마다 조회되는 데이터가 달라진다.
다음과 같이 name123 바꾼다면 조회되는 데이터도 달라진다.
3. HTML Form을 통한 데이터 전송
내가 가장 헷갈렸던 HTML Form을 통한 데이터 전송이다.
1) 먼저 HTML Form으로 name만 전송해본다.
Form 데이터를 통해 만들어진 HttpRequest Message 는 다음과 같다.
POST /html-form HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 10
Content-Type: application/x-www-form-urlencoded
Cookie: Idea-38ab3bf5=a680304e-f014-4f20-bf3d-ef1ec60173ed
Host: localhost:8080
Origin: http://localhost:8080
Referer: http://localhost:8080/
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
sec-ch-ua: "Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
name: uesrA
위의 HttpRequest Message의 message body 에 있는 데이터를 서버는 어떻게 받을까??
바로 @ModelAttribute를 통해서 받는다. UserDto의 name에 데이터가 바인딩 된다.
여기서 정말 중요한 것이 있는데!!
HttpRequest Message의 Content-Type이 application/x-www-form-urlencoded 또는 multipart/form-data일 때 @ModelAttribute가 동작할 수 있는 것이다.
즉,HttpRequest Message의 Content-Type이 어떤 것이냐의 따라서 서버의 컨트롤러에서는 어노테이션은 달라야한다.
// html form
@PostMapping("/html-form")
public String htmlForm(@ModelAttribute UserDto userDto){
testRepository.put(userDto);
return userDto.getName() + " 등록 완료";
}
@Data
public class UserDto {
private String name;
private MultipartFile file;
}
서버에서는 다음과 같은 HttpResponse Message를 만들어 클라이언트에게 보낸다.
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 19
Date: Sat, 02 Nov 2024 05:44:22 GMT
Keep-Alive: timeout=60
Connection: keep-alive
uesrA 등록 완료
이번에는 사진도 추가해서 HTML Form으로 보내보자.
이를 MutilPart Form Data라고 한다.
내가 가장 헷갈렸던 부분이다.
2) multipart-form-data
이름과 파일을 선택해서 전송하면 브라우저는 다음과 같은 HttpRequest Message를 만들어서 서버에 전송한다.
POST /multipart-form-data HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 131253
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXUZppeRwiQfeUVGk
Cookie: Idea-38ab3bf5=a680304e-f014-4f20-bf3d-ef1ec60173ed
Host: localhost:8080
Origin: http://localhost:8080
Referer: http://localhost:8080/
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
sec-ch-ua: "Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
------WebKitFormBoundaryXUZppeRwiQfeUVGk
Content-Disposition: form-data; name="name"
userB
------WebKitFormBoundaryXUZppeRwiQfeUVGk
Content-Disposition: form-data; name="file"; filename="스크린샷 2023-06-08 오전 11.19.25.png"
Content-Type: image/png
------WebKitFormBoundaryXUZppeRwiQfeUVGk--
시작 라인에 multipart-form-data 라고 되어 있고 message body에 데이터 담겨있다.
Content-Type 도 multipart/form-data; boundary=----WebKitFormBoundaryXUZppeRwiQfeUVGk
이다
HttpReqeust Message 의 Content-Type이 multipart/form-data라면 서버에서는 @ModelAttribute 써서 UserDto 객체에 데이터를 바인딩 할 수 있다.
userB는 String 객체에 바인딩 된다.
그렇다면 이미지 파일은 어떻게 바인딩할까?
이미지 파일은 자바의 MultiPartFile 객체에 바인딩하면 된다.
// multipart/form-data
@PostMapping("/multipart-form-data")
public String multipartForm(@ModelAttribute UserDto userDto) throws IOException {
testRepository.put(userDto);
return userDto.getName() + " 등록 완료";
}
@Data
public class UserDto {
private String name;
private MultipartFile file;
}
서버는 다음과 같은 HttpResponse Message를 만들어서 클라이언트에게 전송한다.
POST /multipart-form-data HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 131253
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXUZppeRwiQfeUVGk
Cookie: Idea-38ab3bf5=a680304e-f014-4f20-bf3d-ef1ec60173ed
Host: localhost:8080
Origin: http://localhost:8080
Referer: http://localhost:8080/
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
sec-ch-ua: "Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
userB 등록 완료
4. HTTP API를 통한 데이터 전송
HTTP API 또는 REST API 방식이란
JSON 형식의 데이터를 사용하여 HTTP 요청과 응답을 주고받는 것이다.
위와 같이 데이터를 전송하면 아래와 같은 HttpRequest Message가 만들어진다.
POST /http-api HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 22
{
"name":"userC"
}
여기서 Content-Type: application/json 임을 확인할 수 있다.
그렇다면 Content-Type: application/json일 경우 서버는 어떻게 해야할까?
서버는 @RequestBody를 써서 HttpRequest Message의 message body에 있는 Json 데이터를 UserDto 객체의 필드에 바인딩한다.
@PostMapping("/http-api")
public UserDto httpApi(@RequestBody UserDto userDto){
testRepository.put(userDto);
return userDto;
}
서버는 다음과 같은 응답값을 만들어서 클라이언트에게 전송한다.
정리
이전에 프로젝트의 문제는 사진을 함께로 HttpRequest Message를 보낼 때, Content-Type이 multipart/form-data 되어 있지 않아서 발생한 문제였다. 당시 HttpMessage 구조에 대한 이해가 부족했기에 문제의 원인을 찾지 못하였다...
구조와 계층을 이해하는게 중요하다. 왜냐하면 각 계층의 역할과 책임이 존재하기 때문이다.
오늘 공부했던 HttpMessage 구조도 각 계층을 가지고 있는 것과 마찬가지라고 생각한다.
각 계층에는 역할과 책임이 있다. 그 구조에 맞게 데이터를 송수신할 수 있는 규칙이 생긴다.
요청시 header 부분에 content-type을 정의함으로써 서버에서는 어떻게 데이터를 받아야할지 정할 수 있는 것이다.
즉, HttpMessage 구조를 알고 규칙에 맞춰서 데이터를 송수신 할 수 있는 것이다.