개발자로 후회없는 삶 살기

Naver PART.팀 프로젝트 파일 업로드 최적화 본문

[대외활동]/[네이버 BoostCamp]

Naver PART.팀 프로젝트 파일 업로드 최적화

몽이장쥰 2024. 2. 10. 21:40

서론

저희 서비스에서 파일 업로드가 레이턴시의 큰 비중을 차지하고 사용자 요청이 전부 파일 업로드로 시작하기 때문에 반드시 해결해야 하는 문제입니다. 이를 해결한 방법을 작성합니다.

 

본론

- 기본 업로드 방식

private String sendFileToStorage(final RawFileData fileData) {
    try (final InputStream inputStream = fileData.getContent()) {
        String fileName = fileData.getStoreFileName();

        BlobInfo blobInfo = BlobInfo.newBuilder(bucket, fileName)
                .setContentType(fileData.getContentType())
                .build();

        return storage.create(blobInfo, inputStream)
                .getMediaLink();
    } catch (IOException e) {
        throw new RuntimeException("파일 업로드에 실패했습니다.");
    }
}

mp4 단일 파일 전체를 업로드하는 방식입니다. 위는 Google Cloud Storage에 파일 업로드를 담당하는 sendFileToStorage() 코드입니다. 동영상 파일 하나를 storage에 업로드 요청을 보내고, 업로드에 성공한 파일의 storage 경로를 반환합니다.

 

890MB 동영상을 업로드하는데 최대 4m 16s가 소요되었고

 

평균 126초가 소요되었습니다. 로컬 pc에서 클라우드로 업로드 할 때 걸리는 시간이 다른 이유는 다양한데, 네트워크 상태(대역폭 등)와 로컬 컴퓨터의 CPU 사용량, 메모리 사용량 때문입니다.

 

- 하나의 파일을 청크로 나눈 업로드 방식

대용량 단일을 한번에 업로드하는 것은 패킷 누락에 대한 Retransmission과 위험 부담이 클 거라고 생각되어 적절한 청크 사이즈를 찾고 분할해서 업로드해보기로 했습니다.

 

try (final InputStream inputStream = fileData.getContent()) {
    String fileName = fileData.getStoreFileName();
    int CHUNK_SIZE = 1024 * 1024 * 400;

    BlobInfo blobInfo = BlobInfo.newBuilder(bucket, fileName)
            .setContentType(fileData.getContentType())
            .build();

    byte[] fileBytes = inputStream.readAllBytes();
    int numChunks = (int) Math.ceil((double) fileBytes.length / CHUNK_SIZE);

    try (WriteChannel writer = storage.writer(blobInfo)) {
        for (int i = 0; i < numChunks; i++) {
            long start2 = System.currentTimeMillis();
            int start = i * CHUNK_SIZE;
            int end = Math.min(start + CHUNK_SIZE, fileBytes.length);
            byte[] chunkBytes = new byte[end - start];
            System.arraycopy(fileBytes, start, chunkBytes, 0, end - start);
            writer.write(ByteBuffer.wrap(chunkBytes));
            log.info("File Upload 완료");
            log.info("{} 초 소요됨", (System.currentTimeMillis() - start2) / 1000);
        }
    }

    return String.format("%s/%s/%s", "https://storage.googleapis.com", bucket, fileName);
}

try 문에서 input stream을 가져와 byte 청크 단위로 쪼개고 writer로 gcs에 업로드하는 코드입니다. 적절한 청크 사이즈를 찾기 위해 여러 시도를 해보았습니다.

 

-> 청크 사이즈 1MB

1MB 단위로 나누었을 땐 오히려 평균 227초가 소요되었습니다. 청크 단위로 분할 하더라도 순차적으로 업로드하는 것이기 때문에 시간 단축은 없을 거라 예상했습니다. 더 오래 걸린 이유는, 기본 업로드 방식은 네트워크 I/O가 한 번만 발생하는데 반해 파일을 나눌 경우 청크 개수만큼 일어나서 더 오래 걸리는 것으로 판단 할 수 있었습니다.

 

실제로 네트워크 I/O가 더 많이 일어나나 보겠습니다. 청크 단위로 나누지 않은 기본 업로드는 네트워크를 23% 잡지만

 

청크 단위로 쪼갠 것이 확실히 더 많이 먹는 모습이 보입니다. 앞으로 다양한 청크 사이즈로 테스트를 해보겠습니다.

 

-> 청크 사이즈 100MB

100MB로 쪼갠 경우 역시 더 오래 걸리는 경우도 있고, 아닌 경우도 있었습니다.

 

-> 청크 사이즈 200MB

가장 빠른 건 200MB로 쪼갠 것으로 기본 파일 업로드보다 10초 정도 개선되었습니다.

 

 

🚨 왜 빨라진 거지?

순차적으로 업로드하는 것이기 때문에 시간이 단축될 것이라는 기대는 못했는데, 200MB 청크 사이즈가 기본 파일 업로드보다 더 빠른 속도를 보였습니다. 제가 생각하는 이유를 나열해 보겠습니다.

 

1) 서버 측 처리 속도

GPT : 서버 측에서도 파일을 한 번에 처리하는 것보다 작은 청크 단위로 처리하는 것이 더 빠를 수 있습니다. 서버는 동시에 많은 양의 데이터를 처리하는 것보다 작은 양의 데이터를 빠르게 처리하는 데 더 효율적일 수 있습니다. GPT의 말에는 신뢰성이 부족했습니다.

 

2) 네트워크 I/O

네트워크 연결 품질이 불안정하거나 대역폭이 낮은 경우 한 번에 보낼 수 있는 데이터 단위가 작기 때문에 작은 단위로 보내는 게 더 빨리질 수 있습니다. 즉 네트워크 I/O로 인한 속도가 빨라진 거라고 생각한다.

 

3) 네트워크 단편화

단편화 : 패킷을 MTU 크기로 자르는 것
MTU : 인터넷상에서 전달할 수 있는 패킷의 최대 크기
이더넷 환경에서 기본 MTU: 1500byte
기본 L4 헤더 : 20byte
기본 L3 헤더 : 20byte

동영상과 영상 구분 없이 어떤 데이터를 흘려보내면 캡슐화, 역캡슐화를 통해 데이터를 받아들입니다. 여시서 데이터 스트림을 MSS 기반으로 자르고 각각 자른 세그먼트들을 인터넷상으로 전달하게 되는데, 각 네트워크는 MTU라는 최대 패킷 전송 단위를 가지고 있어서 MTU보다 큰 데이터는 단편화라는 과정이 이루어지고 이 과정 자체가 네트워크 적인 오버헤드를 유발할 수 있습니다.

 

위 사진처럼 기본 헤더를 제외한 1460byte의 MTU에서 발생하는 단편화 오버헤드를 제거하여 속도 개선이 있을 수 있다고 예상할 수 있습니다.

 

- data prefetch 업로드 방식

이 방식은 순서가 유지되어야 하는 동영상 특성에 따라 병렬로 업로드를 했을 경우 파일이 손상되었고 따라서 메모리에는 병렬로 로드하고 스토리지에는 순차적으로 업로드하는 아이디어를 떠오렸습니다.

 

이는 데이터를 미리 로드하는 방식으로 청크 단위로 로드할 때 메모리 로드와 스토리지 업로드가 반복되는데 

 

메모리에 로드하는 것을 미리 하여 스토리지 업로드는 막힘없이 진행될 수 있어 속도가 빨라질 거란 기대를 했습니다.

 

try (final InputStream inputStream = fileData.getContent()) {
    int CHUNK_SIZE = 1024 * 1024 * 200;
    String fileName = fileData.getStoreFileName();
    byte[] fileBytes = inputStream.readAllBytes();
    int numChunks = (int) Math.ceil((double) fileBytes.length / CHUNK_SIZE);

    List<CompletableFuture<byte[]>> futures = new ArrayList<>();

    for (int i = 0; i < numChunks; i++) {
        int start = i * CHUNK_SIZE;
        int end = Math.min(start + CHUNK_SIZE, fileBytes.length);

        futures.add(CompletableFuture.supplyAsync(() -> {
            log.info("청크 단위 메모리 업로드 중");
            byte[] chunkBytes = new byte[end - start];
            System.arraycopy(fileBytes, start, chunkBytes, 0, end - start);
            return chunkBytes;
        }));
    }

    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

    BlobInfo blobInfo = BlobInfo.newBuilder(bucket, fileName)
            .setContentType(fileData.getContentType())
            .build();

    try (WriteChannel writer = storage.writer(blobInfo)) {

        for (CompletableFuture<byte[]> future : futures) {
            long start2 = System.currentTimeMillis();
            writer.write(ByteBuffer.wrap(future.join()));

            log.info("중간 File Upload 완료");
            log.info("{} 초 소요됨", (System.currentTimeMillis() - start2) / 1000);
        }
    }

    return String.format("%s/%s/%s", "https://storage.googleapis.com", bucket, fileName);
}

첫 번째 for문에서 CompletableFuture를 사용하여 메모리 로드가 병렬적으로 일어나고 메모리 로드가 끝난 시점부터 두 번째 for문에서 순차적으로 스토리지 업로드를 수행합니다.

 

-> 청크 사이즈 100MB

메모리 로드는 멀티 쓰레드로 비동기적으로 수행하고 storage 업로드는 순차적으로 일어납니다. 청크로 나누기만 했을 때보다 평균 청크 업로드 속도가 7초 정도 빨라졌습니다.

 

-> 청크 사이즈 200MB

결과적으로 청크 사이즈만 쪼갰을 때 가장 빨랐던 200MB 청크에서 병렬 로드를 추가했을 때 가장 많은 업로드 시간 단축을 이뤘습니다.

 

✅ 빨라진 이유

갓천성 멘토님 : 파일 순서대로 메모리에 로드하고 업로드하는 것보다 파일을 업로드하는 동안 다른 파일을 미리 읽고 있으니 바로 network I/O를 탈 수 있어서 차이가 생기는 것 같습니다. data loader의 num worker가 동일한 원리로 gpu batch를 학습하는 동안 cpu는 미리 데이터를 읽어서 끝나자마자 다음 batch로 밀어 넣어주는 것입니다.

 

- 이외에 파일 업로드에서 고려한 것

1) 트랜잭션 묶음 : 어플리케이션 로직이 network I/O가 발생하는 로직과 하나의 트랜잭션에 묶여버리면 응답 시간이 너무 오래 걸려서 시스템 성능이 저하되고 부정적인 사용자 경험을 유발합니다. 이를 예방하기 위해 컨트롤러에서 별도의 업로더하고 비동기적으로 동작하게 합니다.

2) 병렬 쓰레드 : 보통 하나의 트랜잭션에서는 하나의 쓰레드를 사용하지만, 업로드 영상을 병렬적으로 메모리에 올리기 위해 별도의 트랜잭션에서 쓰레드 개수에 유의하며 병렬적으로 처리하도록 하였습니다.
Comments