대용량 파일 서버 성능 개선기 (1) : 작은 청크로 분할 전송

주싱·2023년 4월 18일
14

Network Programming

목록 보기
18/21

시작하며

네티(Netty) 프레임워크를 사용해 대용량 파일을 처리할 수 있는 서버를 구현했습니다. 그리고 클라이언트에서 대용량 파일(1GB) 패치를 요청하고 패치된 파일이 로컬에 저장되기 까지의 시간을 측정함으로 서버의 성능을 측정해 보았습니다. 초기 구현에서는 서버가 파일 패치(Fetch) 요청을 받으면 클라이언트가 한 번에 파일 전체를 수신하도록 헤더를 구성해 전달했습니다. 헤더에는 전체 파일 길이가 포함되고 클라이언트는 TCP의 스트림 지향적(Stream-oriented) 특성으로 인해 헤더의 길이 필드를 보고 네트워크로부터 전체 파일을 모두 수신한 후 로컬에 저장하는 구조를 가지고 있습니다. 몇몇 문제가 예상되지만 실제로 이렇게 하면 어떤 일이 일어나는지 살펴보고 개선해 보도록 하겠습니다.

테스트 환경

우선 테스트는 동일한 컴퓨터 동일한 프로세스에서 서버와 클라이언트를 모두 띄워서 루프백 주소로 통신을 합니다. 아래와 같이 간단한 JUnit 테스트 코드를 작성해서 테스트를 수행합니다. 전체 코드는 GitHub에서 확인하실 수 있습니다.

@Test
void fileFetch() throws Exception {
    // Given: 서버 측, 서비스 파일 생성
    int megaBytes = 1024;
    File remoteFile = AdvancedFileUtils.newRandomContentsFile(remoteFilePath, megaToByte(megaBytes));

    // When: 클라이언트 측, 파일 패치 요청
    client.remoteFileAccessor()
            .remote(remoteFilePath)
            .local(localFilePath)
            .printSpentTime("File(%,d MB)fetch time(sec): ".formatted(megaBytes))
            .fetch().sync();

    // Then: 클라이언트에 패치된 파일이 서버 측 파일과 일치하는지 확인
    File localFile = new File(localFilePath);
    assertTrue(FileUtils.contentEquals(remoteFile, localFile));
}

OutOfMemoryError

크기가 작은 파일에서 시작해서 1GB 파일까지 크기를 키워가며 패치(Fetch) 요청을 서버에 보내 보았습니다. 1GB 파일을 요청했을 때 한참의 시간이 흐른 후 OutOfMemoryError가 발생합니다.

클라이언트 수신부에서 전체 파일을 누적해서 쌓는 LengthFieldBasedFrameDecoder 에서 1GB 크기의 다이렉트 버퍼를 할당하면서 문제가 생겼습니다.

@Override
protected void configHandlers(List<Handler> handlers) {
    // 길이 필드를 보고 전체 파일을 모으는 핸들러 ↓ 
    handlers.add(Handler.of(new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 0, 4, 0, 4)));
    handlers.add(Handler.of(new FileServiceDecoder(messageSpecProvider, channelSpecProvider.header())));
    handlers.add(Handler.of(new InboundMessageValidator()));
		... 
}

이 문제 자체는 JVM 옵션에서 다이렉트 메모리 제한(-XX:MaxDirectMemorySize)을 조정해서 해결할 수 있습니다. 임시적인 해결책임을 직감할 수 있지만 계속 나아가 보겠습니다.

1GB 패치, 1분 이상의 시간

JVM 옵션을 조정(-XX:MaxDirectMemorySize=2g)해서 문제를 제거한 후 1GB 파일 패치 시간을 측정해 보면 1분이 넘는 시간이 소요됩니다. 이제 어느 구간에서 병목이 발생하는지 서버의 전송부와 클라이언트의 수신부를 나누어 분석해 보겠습니다.

전송부의 병목

서버에서 파일 전송을 수행하는 네티 코드들에 로그를 찍어 보았습니다. 그 중에서 파일 전송을 실제 시작하는 API 호출(FileChannel::transferTo 메서드) 결과에서 의미있는 로그를 수집할 수 있었습니다.

로그를 분석해 보면 애플리케이션에서 요청한 1GB 파일이 실제로는 8MB 이하 (아래 주석 참조)의 조각으로 N번 나누어 전송되고 있는 것을 확인할 수 있습니다. 한 번의 쓰기 수행(8MB 이하) 시간도 0.8초까지 소요되며 느리게 진행되는 것을 확인할 수 있습니다. 또한 쓰기 메서드는 실제로 쓰기를 수행한 바이트 수를 반환하는데 간헐적으로 0 값을 반환하고 있습니다. 이것은 TCP 전송 버퍼가 가득찼고 더 이상 데이터를 쓸 수 없는 상태임을 의미합니다. TCP 전송 버퍼는 통신 상대의 TCP 수신 버퍼가 가득차게 되면 데이터를 더이상 전송하지 못하고 점점 쌓이게 됩니다. 따라서 이 문제는 클라이언트 측에서 TCP 수신 버퍼의 데이터를 빠르게 읽어가지 못하는 진짜 병목이 발생하고 있는 상황으로 예상해 볼 수 있습니다.

전송 단위가 되는 '8MB 이하'라는 기준은 어디서 오나요?

1GB 파일이 내부적으로 8MB 보다 작은 조각으로 쪼개어져 처리되고 있었는데, 이 '8MB 이하'라는 기준은 어디서 나온 건지 궁금해서 네티 코드를 뒤져보았습니다. 결과는 네티가 내부적으로 파일(FileRegion) 전송에 사용하는 mapped transfer 라는 방법에서 전송 단위를 최대 8MB로 고정해 두고 있었습니다. 네티가 FileRegion 객체를 사용해 파일을 전송하는 방법은 운영체제나 여러 환경에 따라 달라지는 것으로 보입니다. 따라서 각자의 환경에서 확인해 볼 필요가 있겠습니다. (저는 Windows 11에서 테스트를 진행했습니다)

수신부의 병목

그럼 이제 전송측에 병목을 유발한 진짜 이유인 수신측에서 왜 TCP 수신 버퍼의 데이터를 빨리 읽어 가지 못하는지 분석해 봅니다. 역시 네티 코드에 로그를 찍어 어떤 일들이 일어나는지 확인해 봅니다.

로그를 분석해 보니 대용량 파일 전체를 수신하기 위해 데이터를 누적하고 있는 LengthFieldBasedFrameDecoder 핸들러 처리 중 병목이 발생합니다. LengthFieldBasedFrameDecoder 핸들러에서는 처음에 작은 버퍼에서 시작해서 1GB 크기가 될 때 까지 점진적으로 다이렉트 메모리 버퍼 재할당을 수행하고 있었습니다. 아래 로그는 1GB를 거의 다 수신해 갈 때 쯤의 상황인데, 8MB 이하의 새롭게 수신된 파일 조각들을 저장하기 위해 계속해서 새로운 대용량의 버퍼 재할당이 일어나는 것을 확인할 수 있습니다. 1GB 다이렉트 메모리 버퍼를 할당하는데 0.5초 가량의 시간이 걸리고 있고, 8MB 이하의 파일 조각이 수신될 때 마다 대용량 버퍼 재할당이 일어남으로 불필요한 지연 시간이 누적되고 있었습니다.

원인 종합

정리해보면 클라이언트의 수신부에서 발생한 불필요한 대용량 버퍼 재할당 시간으로 인해 I/O 쓰레드가 TCP 수신 버퍼를 느리게 읽게 만들었고, 이로 인해 서버의 TCP 전송부 버퍼 역시 느리게 비워지면서 서버의 전체적인 전송 성능을 함께 떨어뜨리고 있었습니다.

작은 청크로 분할 전송하기

문제를 해결하기 위해 1GB 파일을 한 번에 모아서 처리하는 기존 구조를 개선해야 합니다. 우선 파일을 5MB 크기 청크(Chunk)로 분할해서 전송하기로 하고, 메시지 스펙에도 현재 전송하는 청크가 파일의 끝인지 여부를 알리기 위한 필드(endOfFile)를 추가합니다. 그래서 5MB 청크를 수신하며 파일의 끝이 아니면 계속해서 기존 파일에 데이터를 추가하는 방식으로 동작하도록 합니다.

이렇게 하면 작은 데이터를 읽고 파일에 쓰고 빨리 해제하는 동작을 반복해서 한 번에 큰 메모리가 필요하지 않게 되고 불필요한 대용량 버퍼 할당 비용을 제거할 수 있습니다. 서버 입장에서도 동시에 여러 요청이 들어와도 1GB를 한 번에 처리하는 대신 여러 요청을 5MB 단위로 나누어 처리할 수 있어 응답성이 더 좋아지게 됩니다.

// 요청에 대한 응답을 수행하는 구조가 되는 핸들러
@RequiredArgsConstructor
public class RequestProcessHandler extends SimpleChannelInboundHandler<Message> {
    private final RequestProcessorProvider requestProcessorProvider;

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Message message) throws Exception {
        var requestProcessor = requestProcessorProvider.getRequestProcessor(message.getClass());
        var responses = requestProcessor.process(message);
        responses.forEach(ctx::writeAndFlush);
    }
}

// 파일 패치 요청을 처리하는 구현체
@Builder
public class FileFetchRequestProcessor implements RequestProcessor {
    private final int chunkSize;
    private final String rootPath;

    @Override
    public List<Message> process(Message message) {
        var fileFetchRequest = (FileFetchRequest) message;
        var file = new File(rootPath + fileFetchRequest.getRemoteFilePath());
        var responses = new ArrayList<Message>();

        // 파일을 청크로 분할 전송
        long start = 0;
        long remainBytes = file.length();
        while (remainBytes > 0) {
            var readBytes = (int) Math.min(remainBytes, chunkSize);
            var chunk = new FileChunkTxResponse(false, start, readBytes, file.getPath());
            responses.add(chunk);
            remainBytes -= readBytes;
            start += readBytes;
        }

        // 파일 끝을 나타내는 비어있는 청크 전송
        var lastChunk = new FileChunkTxResponse(true, start, 0, file.getPath());
        responses.add(lastChunk);
        return responses;
    }
}

결과 정리

테스트 결과는 편차가 조금 있지만 5~10초 사이에 1GB 파일 패치가 완료됩니다. 처음에 성능이 너무 안좋아 결과를 비교하는게 무색해 보이지만, 결과적으로 성능이 10배 넘게 향상되었습니다.

Windows FTP 서버와 성능 비교

그럼에도 사실 이 결과가 최선의 결과인지 확신할 수는 없습니다. 제가 아직 알지 못하는 개선 포인트들이 더 있을 수 있기 때문입니다. 개선한 코드가 어느 정도 수준에 도달했음을 확신하기 위해서는 상용 제품 중에 비교군이 필요하다고 판단했습니다. 그래서 쉽게 접근할 수 있고 믿을 만한 Windows 운영체제에서 제공하는 FTP 서버를 선택했습니다. 그리고 윈도우에서 FTP 서버를 설정해서 똑같이 로프백 주소로 1GB 파일을 패치해 보았습니다. 거의 동일하게 5~10초 사이에 파일이 처리됩니다. 일단 평균은 했다고 생각하고 다음을 기약합니다.

다음 작업

다음은 서버에 동시에 많은 클라이언트가 파일 패치를 요청하면 어떤 일이 일어나는지 분석해 보고 개선이 필요한 점이 식별되면 개선해 보도록 하겠습니다.

profile
소프트웨어 엔지니어, 일상

1개의 댓글

comment-user-thumbnail
2023년 4월 28일

netty와 관련된 건 아니지만 브라우저에서 사용자가 파일을 spring 서버로 업로드할 때 속도 개선할 수있는 방법이 있을까요? 포스팅과 비슷하게 클라이언트 단에서 분할해서 보낸 다음 서버에서 합치는 방식으로도 해봤는데 포스팅에서 처럼 눈에 띄는 개선은 안보여서요,,,

답글 달기