클라이언트-서버 구조에서 클라이언트가 전송한 데이터는 응용 계층부터 물리 계층을 타고 전송된다. 이때, 물리 계층까지 간 데이터는 바이너리 데이터 형태이므로, 서버 측(IntelliJ와 같은 프레임워크)은바이너리를 읽을 수 있는 InputStream과 같은 객체가 필요하다. 반대로서버에서 데이터를 적어서 클라이언트로 내려줄 때는 OutputStream을 사용할 수 있다.
InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회 📍 @RequestBody DTO dto ➡ 스프링이 내부적으로 InputStream을 열어서, JSON 데이터를 읽고, Jackson 라이브러리를 사용해 객체로 대신 만듦 OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력 📍 return dto; (@ResponseBody) ➡ 스프링이 dto 객체를 Jackson을 사용해 JSON 바이트로 변환한 뒤, 내부적으로 OutputStream을 열어 대신 write
✔ OutputStream.write를 호출할 때 일어나는 일
Application Layer: OutputStream이 8bit(1byte) 단위의 Binary 데이터를 OS에 전달
OS (Layer 4 ~ 2)
Transport Layer: OS는 데이터를 TCP 세그먼트로 자름
Network Layer: IP 주소를 붙여 IP 패킷으로 만듦
Data Link Layer: MAC 주소를 붙여 Ethernet Frame으로 만듦
Physical Layer(HW): 네트워크 카드(LAN 카드)가 OS로부터 받은 프레임(0과1의 집합)을 실제 전기 신호나 광 신호로 변환하여 케이블로 전송
🔎 InputStream과 OutputStream을 직접 사용하는 경우
가장 대표적인 사례는 '대용량 파일 처리'다.
1. InputStream: 대용량 파일 업로드 처리
예를 들어 5GB 동영상을 업로드하는데 @RequestBody byte[] fileData로 받는자면, 스프링은 5GB를 전부 메모리에 올리려고 시도하다가 OutOfMemoryError(메모리 초과)로 서버가 즉시 다운될 것이다. 이때, InputStream으로 직접 받아서, 메모리에 올리지 않고 조금씩 읽어내어(chunk) 바로 파일 시스템이나 S3 같은 저장소로 전달(stream)해야 한다.
@PostMapping("/upload-video")
public ResponseEntity<String> uploadVideo(InputStream requestStream) {
try {
// 파일을 저장할 경로 (예: S3 또는 로컬 디스크)
OutputStream fileSaveStream = new FileOutputStream("my-large-video.mp4");
// 메모리를 쓰지 않고, 요청 스트림에서 8KB씩 읽어서
// 파일 스트림으로 바로 쓴다.
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = requestStream.read(buffer)) != -1) {
fileSaveStream.write(buffer, 0, bytesRead);
}
fileSaveStream.close();
requestStream.close();
return ResponseEntity.ok("Upload Complete");
} catch (IOException e) {
return ResponseEntity.status(500).body("Upload Failed");
}
}
2. OutputStream: 대용량 파일 다운로드 처리
마찬가지로 5GB 파일을 다운로드하기 위해 byte[]로 읽어 반환하면 서버가 다운된다. OutputStream을 통해 조금씩 읽어 바로 응답으로 쏴줘야 한다.
@GetMapping("/download-video")
public void downloadVideo(OutputStream responseStream) {
try {
// 서버에 저장된 대용량 파일
InputStream fileReadStream = new FileInputStream("my-large-video.mp4");
// 8KB씩 읽어서 HTTP 응답 스트림으로 바로 쓴다.
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fileReadStream.read(buffer)) != -1) {
responseStream.write(buffer, 0, bytesRead);
}
fileReadStream.close();
responseStream.close(); // 응답이 완료됨
} catch (IOException e) {
// 예외 처리
}
}
청크(chunk): 여러 데이터를 묶어 하나의 덩어리나 블록으로 만든 것 (스트림의 가장 큰 목적) => Buffer는 chunk의 실제 구현체다.
스프링 MVC가 지원하는 스트림들은 대용량 바이너리 데이터를 메모리 과부하 없이 효율적으로 '스트리밍' 처리하기 위해 사용하는 Java의 원시 I/O 스트림과 동일하다.
🔎 HttpEntity
HttpEntity를 통해 HTTP header, body 정보를 편리하게 조회할 수 있다.
HttpEntity를 통해 HTTP body 읽기
응답에도 사용 가능하며 헤더 정보도 포함할 수 있다. 응답에 사용할 경우, 뷰를 조회하지 않고 데이터 그대로 클라이언트로 내려간다.
참고로, 요청 파라미터를 조회하는 기능과 관계없다.
💡 Stream 없이 바로 변환이 가능한 이유
Spring MVC 내부에서 HTTP 메시지 바디를 읽어서 문자나 객체로 변환해서 전달해 주기 때문이다. 이때 HTTP 메시지 컨버터(HttpMessageConverter)라는 기능을 사용한다.
💡 RequestEntity, ResponseEntity
RequestEntity : HttpMethod, url 정보를 추가할 수 있으며, 요청에서 사용한다. ResponseEntity : HTTP 상태 코드를 설정할 수 있고, 응답에서 사용한다.
🔎 @RequestBody
HTTP 메시지 바디 정보를 편리하게 조회할 수 있다.
뷰 리졸버를 통해 뷰를 조회하지 않으며, HttpMessageConverter가 동작하여 요청 바디의 내용을 그대로 가져온다.
바디를 조회하는 기능은 요청 파라미터를 조회하는 @RequestParam, @ModelAttribute와 관계없다.
@RequestBody를 통해 HTTP body 읽기
HttpEntity, @RequestBody, @ResponseBody는 HTTP 메시지 컨버터가 동작한다.
📌 HTTP API - JSON 처리
🔎 서블릿
HttpServletRequest를 사용해서 직접 HTTP body에서 데이터를 읽어올 수 있다.
이때, 요청 데이터가 JSON이므로 객체에 파싱 하길 원한다면 ObjectMapper를 사용해야 한다.
서블릿을 통해 HTTP body 읽기
🔎 @RequestBody
@RequestBody는 서블릿 사용 없이 단순 텍스트뿐만 아니라 JSON 데이터도 처리할 수 있다.
마찬가지로 JSON 데이터이지만 HTTP 메시지 컨버터는 HTTP body의 JSON 데이터도 문자나 객체로 변환해 준다.
따라서, ObjectMapper 사용 없이 body를 읽어 와서 HelloData라는 객체에 파싱 할 수 있다.
주의할 점은 @RequestBody를 생략할 수 없다는 것이다. 왜냐하면 HelloData는 클래스 타입이므로 생략할 경우, @ModelAttribute가 붙을 수 있기 때문이다.
@RequestBody를 통해 HTTP body 읽기
@ResponseBody가 붙었으므로 ViewResolver가 동작하지 않고 HTTP 메시지 컨버터가 동작하여 클라이언트로 내려줄 데이터를 HTTP body에 직접 지정할 수 있다. 리턴 값으로 이 데이터가 결정되는데, 아래처럼 HelloData의 data라는 객체를 그대로 내리면 해당 정보가 JSON으로 보인다.
요청/응답(Postman)
🔎 HttpEntity
단순 텍스트를 읽었을 때처럼 JSON도 HttpEntity를 통해 HTTP body를 읽을 수 있다.