실무에서 이미지 동영상의 binary 파일로 인해 문제가 되었던 CPU, Memory 사용량을 줄이기 위해 개인적으로 문제를 분석해보고 개선점을 찾아내기 위한 작업들의 기록입니다.
코드 내역 : https://github.com/goathoon/playground/commit/291dd72bc814530974eb4d2717f3b5d932262ced
특정 이벤트가 발생시, 단말에서 이미지 메타데이터와 동영상을 서버쪽으로 전송하는 API가 있다.
하지만 이러한 이벤트가 주변 환경으로 인해 갑작스럽게 많이 발생하는 경우가 있는데 이 때, CPU가 100%까지 요동을 치고, 이로 인해 Memory도 꽤나 많이 잡아먹는 현상(60%) 이 발생했다.
서버쪽 로직에서 문제가 되는 부분은 두가지라고 생각했다.
운영환경에서 가장 크게 문제가 되었던 부분은 CPU가 100%까지 쳐서 container의 down up이 반복되었던 부분이어서 개인적으로 용량이 꽤나 큰 byte를 한꺼번에 암호화하는 작업때문에 문제가 생겼다고 확신했었다.
팀장님께서는 메모리 문제 위주로 문제점을 지적하셨다. 지적하신 부분은 9TPS 트래픽임에도 불구하고 committed memory가 max 6GB까지 할당된다는 사실이었다. 그래서 메모리를 효율화 하는 방식으로 개선하자고 말씀하셨다.
JVM의 Heap size는 pod별로 할당된 메모리의 75%만 사용할 수 있게 설정해서 6GB가 max heap memory로 잡혀있었다.
used는 최대 4GB정도로 사용되었었다. used가 4GB정도로 사용되는데, committed memory는 MAX값인 6GB정도까지 늘어날 수 있는게 정상 아닐까? 라고 생각했지만, 나도 잘 모르는 부분이기 때문에.. 다음에 기회가 된다면 여쭤봐야겠다.
어쨌거나, 일단 메모리도 4GB정도로 사용하고 있는 부분은 추후에도 문제가 될 수 있기 때문에 CPU뿐만 아니라 메모리 측면에서도 효율화하는 작업을 개인 환경에서 먼저 테스트해보고자 한다.
처음에는 단순 POJO환경에서 동영상/이미지 파일의 binary 파일을 byte 배열로 바로 받아서 부하를 통해 효율화를 진행하는 방식으로 진행하려고 했다.
하지만 생각해보니, 이러한 방식을 사용하게 된다면 결국 단일스레드에서 테스트하는 것이고, 행여 멀티스레드환경에서 이를 구현한다고 하더라도 Spring 서버에서 테스트하는 것보다 정확도나 편의성 측면에서 많이 떨어진다고 생각해서 Spring서버로 테스트하기로 했다.
테스트를 위해 다음과 같은 툴을 사용하였다.
공통적으로 처리하는 로직은 다음과 같다.
단말에서 올리는 영상이 application/octet-stream type 이라는 것은 변하지 않는다.
이러한 로직을 처리하는 방식을 두가지 상황으로 나눈다.
실제 비즈니스로직에서는 영상 업로드시 기존에 존재하는 영상을 찾아서 삭제하는 로직이 존재하여 overhead가 무조건 생기겠지만, 이는 고려하지 않기로 한다.
이후, Jemter로 5000개의 동시 요청을 10초동안 나누어 부하를 준다.
HTTP Request 정보 (mp4파일의 MIME Type 지정 = application/octet-stream)
HTTP Header Manager 설정 (Content-Type 지정 = application/octet-stream)
먼저 전체 코드를 살펴보자.
@RestController
@RequestMapping("/upload")
@Slf4j
public class BinaryUploadController {
@PostMapping(value = "/bytes-only", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<String> bytesOnly(@RequestBody byte[] data) {
File encryptedFile = new File("tmp/encrypted-byte.text");
try {
byte[] encrypted = AesEncryptor.encrypt(data);
log.info("암호화된 바이트 크기: {}", encrypted.length);
try (FileOutputStream fos = new FileOutputStream(encryptedFile)) {
fos.write(encrypted);
}
log.info("저장된 파일 경로: {}", encryptedFile.getAbsolutePath());
log.info("저장된 파일 크기: {} bytes", encryptedFile.length());
return ResponseEntity.ok("Encrypted and saved: " + encryptedFile.length() + " bytes");
} catch (Exception e) {
log.error("암호화 또는 저장 실패", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("암호화 실패: " + e.getMessage());
}
}
@PostMapping(value = "/bytes-to-stream")
public ResponseEntity<String> bytesToStream (HttpServletRequest request) throws IOException {
File encryptedFile = new File("tmp/encrypted-stream.text");
try (
ServletInputStream inputStream = request.getInputStream();
FileOutputStream fos = new FileOutputStream(encryptedFile);
CipherOutputStream cos = new CipherOutputStream(fos, AesEncryptor.initEncryptCipher())
) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
// 암호화된 조각을 바로 출력 스트림에 씀
cos.write(buffer, 0, bytesRead);
totalRead += bytesRead;
}
cos.flush(); // 마지막 flush
log.info("저장된 파일 경로: {}", encryptedFile.getAbsolutePath());
log.info("저장된 파일 크기: {} bytes", encryptedFile.length());
return ResponseEntity.ok("Encrypted and saved: " + encryptedFile.length() + " bytes");
} catch (Exception e) {
log.error("암호화 또는 저장 실패", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("암호화 실패: " + e.getMessage());
}
}
}
public class AesEncryptor {
private static final String ALGORITHM = "AES";
private static final String KEY = "MySecretKey12345";
public static byte[] encrypt(byte[] input) throws Exception {
SecretKeySpec secretKey = new SecretKeySpec(KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
return cipher.doFinal(input);
}
public static Cipher initEncryptCipher() throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher;
}
}
기존의 로직은 Binary데이터를 그대로 Byte 배열에 할당하여 암호화하여 DB에 저장하는 방식이다.
여기서 DB저장은 어차피 I/O 작업이므로 이를 File I/O로 대체하기로 했다. (굳이 I/O상황을 만들지 않아도 되지만, 기존 로직과 개선 로직이 실제로 같은 암호화된 바이너리 값으로 저장되는지 확인하기 위해 추가하였다.)
@PostMapping(value = "/bytes-only", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<String> bytesOnly(@RequestBody byte[] data) {
File encryptedFile = new File("tmp/encrypted-byte.text");
try {
byte[] encrypted = AesEncryptor.encrypt(data);
log.info("암호화된 바이트 크기: {}", encrypted.length);
try (FileOutputStream fos = new FileOutputStream(encryptedFile)) {
fos.write(encrypted);
}
log.info("저장된 파일 경로: {}", encryptedFile.getAbsolutePath());
log.info("저장된 파일 크기: {} bytes", encryptedFile.length());
return ResponseEntity.ok("Encrypted and saved: " + encryptedFile.length() + " bytes");
} catch (Exception e) {
log.error("암호화 또는 저장 실패", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("암호화 실패: " + e.getMessage());
}
}
로직 단계
1. File을 생성한다.
2. octet-stream Binary로 넘어온 파일을 그대로 encrypt한다
3. encrypt한 byte데이터 전체를 File에 한꺼번에 write
처음에 이를 개선하려고 했을때 두가지 생각을 했었다.
1번의 생각은 유효할 수도 있겠지만, 데이터 전체가 byte배열로 한번 올라가고, 이후 암호화를 블록 단위로 하게 되므로, 어림 잡아 데이터 전체 크기 * 1.1배
정도가 메모리가 할당될 것이라고 생각했다.
기존에는
데이터 전체 크기 * 2배
(인입된 byte 전체 + 이를 암호화한 byte 배열) 의 메모리가 할당 되었기 때문에 50% 메모리 리소스 사용률을 줄일 수 있다.
2번의 생각은 처음 넘어올 때부터 stream으로 받게되면 전체 데이터를 메모리에 올리지 않아도 되므로 어림 잡아 데이터 전체 크기 * 0.31배
정도로 메모리가 할당될 것이라고 생각했다.
정확히는 buffer block의 크기에 따라서 메모리 효율성은 달라질 것이다. 8192 (8KB)를 buffer 크기로 잡으면 2.6MB를 0.008로 나누어서 메모리에 올리므로, (8192 / 2,600,000) * 100 ≈ 0.31% 0.31%의 메모리만 사용하므로 322배의 메모리 효율을 볼 수 있을 것이라고 예상했다.
CPU또한 복잡한 암호화를 buffer (block) 단위로 나눠서 진행하므로 어느정도 개선이 있지 않을까? 짐작했다.
그렇다면 처음으로 든 생각은 @RequestBody
로 Stream형태로 지원할까? 였다.
@RequestBody
를 통해 받는 binary데이터를 stream으로 받고, 비즈니스로 직에서 buffer단위로 읽고 싶었지만, HttpMessageConverter에는 기본적으로 stream을 지원하지 않았다.
그렇다면 HttpServletReqeust
를 이용해보자는 생각을 했다. 이전에 얼핏 개발했을때, HttpServletReqeust.getInputStream(), ServletInputStream
으로 요청 정보를 받아왔던 기억이 있어서, 이를 활용했다.
@PostMapping(value = "/bytes-to-stream")
public ResponseEntity<String> bytesToStream (HttpServletRequest request) throws IOException {
File encryptedFile = new File("tmp/encrypted-stream.text");
try (
ServletInputStream inputStream = request.getInputStream();
FileOutputStream fos = new FileOutputStream(encryptedFile);
CipherOutputStream cos = new CipherOutputStream(fos, AesEncryptor.initEncryptCipher())
) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
// 암호화된 조각을 바로 출력 스트림에 씀
cos.write(buffer, 0, bytesRead);
totalRead += bytesRead;
}
cos.flush(); // 마지막 flush
log.info("저장된 파일 경로: {}", encryptedFile.getAbsolutePath());
log.info("저장된 파일 크기: {} bytes", encryptedFile.length());
return ResponseEntity.ok("Encrypted and saved: " + encryptedFile.length() + " bytes");
} catch (Exception e) {
log.error("암호화 또는 저장 실패", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("암호화 실패: " + e.getMessage());
}
}
로직 단계
오전 1시41분에 trigger 했던 상황은 byte배열 전체를 암호화 했을 때의 상황이다.
CPU사용률은 최대 60%, 메모리는 Full Heap Size인 6GB까지 사용되어있음을 볼 수 있다.
오전 1시43분, 1시 45분경 trigger했던 상황이 stream을 사용했던 상황이다.
CPU사용률은 오히려 80%정도까지 치솟았고, 메모리의 효율화는 확실했다. 해당 순간의 HeapDump까진 살펴보진 않았는데 대략 0.4GB정도 사용하지 않았을까 싶다.
메모리 효율화의 측면에서 거의 15배의 효율화를 볼 수 있었다. (6GB -> 0.4GB)
하지만 오히려 CPU는 늘어났다.
이부분에 대해선 조금 더 살펴봐야겠지만, 다음과 같은 이유로 추측하고는 있다.
CPU-BOUND한 암호화 작업이 block단위로 쪼개졌을뿐 Asis나 Tobe나 같을 뿐이다.
Buffer(블록) 단위로 잘라서 암호화하면, 한꺼번에 암호화 하진 않아서 부하가 덜할 줄 알았다. 하지만 생각해보면 부하가 심한 상황에서 N개의 요청 각각을 블록단위로 암호화하나 전체 배열을 암호화하나 CPU부하는 비슷할 것임을 생각하지 못했던 점이 아쉽다.
하지만 Stream으로 메모리 효율화는 성공적으로 진행했으니, 실제 실무에는 적용하지 못하더라도, 뿌듯한 작업이었다!
그렇다면 CPU의 효율화는 어떻게 진행해야할지.. 또 고민해야할 것이다!