[토킹테일] WebFlux 파일 서버 예제와 k6로 MVC와 비교하여 성능 테스트하기 (1-2)

Choi Wontak·2025년 4월 28일

비바이빙

목록 보기
8/8
post-thumbnail

비바이빙 - 빠르게 개발하기

AI 시대에 맞추어 2주에 한 프로덕트를 만들어내는, 작지만 빠른 개발을 지향하는 프로젝트입니다.


아직도 WebFlux 이해가 부족하다 (문제 인식 단계)

이전에 채팅 예제로 공부했는데, 뭔가 실무에 바로 적용하기에 WebFlux의 장점을 완전히 흡수하진 못한 느낌이라 GPT한테 주제 하나만 알려달라고 부탁했다.

"리액티브 파일 업로드/다운로드 서버" 만들기
클라이언트가 파일을 업로드하면, 서버가 스트리밍 방식으로 "조각"조각 받아서 저장해.
서버는 업로드 상태를 실시간으로 응답해줄 수도 있어.
다운로드할 때는, 서버가 파일 전체를 한 번에 읽지 않고, "조각" 조각 스트림으로 흘려보내.

이러한 주제를 추천해줘서 한 번 만들어보면서 공부하기로 결심했다.

아..응..그래..


공부 내용 정리

파일 서버 만들기

지난번엔 채팅 예제로 연습했는데, 이번에는 파일을 업로드하고, 다운로드 할 수 있는 서버를 만들어보겠다.

@Configuration
public class FileRouter {

    @Bean
    public RouterFunction<ServerResponse> fileRoutes(FileHandler fileHandler) {
        return route(POST("/upload"), fileHandler::upload)
                .andRoute(GET("/download/{filename}"), fileHandler::download);
    }
}

이번엔 Controller 대신 완전한 비동기 처리를 지원하는 RouterFunction를 사용하였다.
어떤 HTTP Method와 URI로 오냐에 따라 실행될 함수를 매핑한다.

public Mono<ServerResponse> upload(ServerRequest request) {
        return request.body(BodyExtractors.toMultipartData())
                .flatMap(parts -> {
                    var fileParts = parts.toSingleValueMap().values().stream()
                            .filter(part -> part instanceof FilePart)
                            .map(part -> (FilePart) part)
                            .toList();

                    if (fileParts.isEmpty()) {
                        return ServerResponse.badRequest().bodyValue("No file uploaded");
                    }

                    FilePart filePart = fileParts.get(0); // 하나만 저장
                    Path destination = Paths.get(UPLOAD_DIR).resolve(filePart.filename());

                    // 파일 스트리밍 저장
                    return DataBufferUtils.write(
                            filePart.content(), // Flux<DataBuffer>
                            destination,
                            StandardOpenOption.CREATE
                    ).then(
                            ServerResponse.ok().bodyValue("File uploaded: " + filePart.filename())
                    );
                });
    }

업로드를 위한 함수. 하나씩 파헤쳐보자.

map이 아닌 flatMap을 쓴 이유는,
파일 스트리밍 저장과 같은 또다른 비동기 (Mono) 작업이 필요하기 때문이다.
map도 비동기 처리가 가능하지만, map 안에서 새로운 Mono 스트림을 만들게 되면, Mono<Mono< T>> 이런 형식으로 반환되기 때문에 평탄화 작업이 필요하다.
그래서 flatMap을 쓰는 것

var fileParts = parts.toSingleValueMap().values().stream()
                     .filter(part -> part instanceof FilePart)
                     .map(part -> (FilePart) part)
                     .toList();

toSingleValueMap으로 첫 번째 값만 가진 맵에서 values로 스트림을 생성한다.
FilePart에 해당하는 값만 형 변환해 리스트로 만든다.

return DataBufferUtils.write(
                     filePart.content(), // Flux<DataBuffer>
                     destination,
                     StandardOpenOption.CREATE
             ).then(
                     ServerResponse.ok().bodyValue("File uploaded: " + filePart.filename())
             );

filePart.content()는 Flux< DataBuffer >를 반환한다.
파일 저장 버튼을 누르면 DataBuffer 단위로 나뉘어서 메모리에 잠시 저장되는데, filePart.content()가 이를 Flux로 연결한다.
전달된 Flux는 DataBufferUtils.write가 논블로킹으로 디스크에 전달.
.then은 앞 스트림이 성공하면 실행된다.

이런 식으로 논블로킹 업로드 완료

public Mono<ServerResponse> download(ServerRequest request) {
        String filename = request.pathVariable("filename");
        Path path = Paths.get(UPLOAD_DIR).resolve(filename);

        if (!path.toFile().exists()) {
            return ServerResponse.notFound().build();
        }

        Path file = path.resolve(filename);

        Flux<DataBuffer> fileStream = DataBufferUtils.read(
                file,
                request.exchange().getResponse().bufferFactory(),
                4096
        );


        return ServerResponse.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(fileStream, DataBuffer.class);
    }

다운로드 시 실행하는 메소드이다. 마찬가지로 하나씩 뜯어보자.

Flux<DataBuffer> fileStream = DataBufferUtils.read(
                file,
                request.exchange().getResponse().bufferFactory(),
                4096
        );

마찬가지로 file에서 데이터를 읽어와 Flux< DataBuffer > 형태로 변환한다.
읽은 데이터를 저장할 버퍼를 생성하고,
한 번에 읽어올 데이터의 크기를 4096으로 지정

return ServerResponse.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(fileStream, DataBuffer.class);

바로 다운로드 할 수 있도록 헤더를 지정해주고,
바이너리 파일을 전달한다고 명시 (APPLICATION_OCTET_STREAM)
WebFlux가 내부적으로 fileStream 안에 있는 DataBuffer들을 하나씩 꺼내
클라이언트로 스트리밍 전송하게 만들기 위해서 DataBuffer.class로 전송한다.

하면서 알게된 건데,
서버가 Mono를 리턴하면 HTTP 응답을 1개 빠르게 만들어 클라이언트로 전달한다.
하지만 Flux를 리턴하면 HTTP 응답이 채워지지 않은 채로 보내지고, Response Body를 천천히 채워나간다 (스트리밍)

이 코드에선 ServerResponse를 통해 모노로 전달하지만,
body가 Flux이기 때문에 body를 채우기 위해 클라이언트는 기다린다.

요런 식으로 만들어서 업로드 / 다운로드 성공


MVC 버전으로 만들어보기

진짜 리액티브 프로그래밍은 좋을까..?
궁금하니까 비교해보기로 결심했다.

간단하게 파일 업로드/다운로드 로직을 작성했다.

@Controller
public class TestController {

    private static final String UPLOAD_DIR = "tmp";

    @PostMapping("/upload")
    public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body("No file uploaded");
        }
        Path uploadDir = Paths.get(System.getProperty("user.dir"), "tmp");

        try {
            if (!Files.exists(uploadDir)) {
                Files.createDirectories(uploadDir); // tmp 디렉토리 없으면 생성
            }
            Path destination = uploadDir.resolve(file.getOriginalFilename());
            Files.createDirectories(destination.getParent());
            file.transferTo(destination.toFile());
            return ResponseEntity.ok("File uploaded: " + file.getOriginalFilename());
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.internalServerError().body("Upload failed");
        }
    }

    @GetMapping("/download/{filename}")
    public ResponseEntity<byte[]> download(@PathVariable String filename) {
        Path filePath = Paths.get(UPLOAD_DIR).resolve(filename);
        if (!Files.exists(filePath)) {
            return ResponseEntity.notFound().build();
        }

        try {
            byte[] fileBytes = Files.readAllBytes(filePath);
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .body(fileBytes);
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.internalServerError().build();
        }
    }
}

일단은 논블로킹의 장점을 확인하기 위해 블로킹 방식의 코드를 작성했다.

마찬가지로 잘 동작한다.
이제 비교를 해보자.

Grafana k6로 성능 테스트

Grafana k6는 오픈소스 부하 테스팅 툴이다.
찾아보니 성능 테스트 할 때 어렵지 않게 쓸 수 있는 것 같아
처음 사용해보는 툴이지만 사용해보기로 결심했다!

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
    vus: 50, // 50명 동시 접속자
    duration: '20s', // 20초 동안 지속
};

export default function () {
    const res = http.get('http://localhost:8080/download/testfile.jpg'); 
    if (res.status !== 200) {
        console.error(`Request failed with status ${res.status}`);
    }
    sleep(0.1);
}

k6 코드는 위와 같다.

참고로 테스트 용 파일은 랜덤 바이너리 데이터로 만든 jpg 파일이다.

dd if=/dev/urandom of=testfile.jpg bs=500K count=1

k6를 이용한 성능 부하 테스트 1

시나리오1
50명이 500KB 파일을 20초 동안
/download 엔드포인트에 연속으로 던진다.

WebFlux

MVC

테스트 대 실패
WebFlux의 avg가 훨씬 길게 나왔다.
이유는 너무 적은 요청을 보내서 그런 것 같다.
컨텍스트 스위칭 비용 때문이 아닐까
로컬로 진행하다 보니 무리갈까봐 겁먹어서 너무 적은 요청을 보낸 것 같다.

k6를 이용한 성능 부하 테스트 2

시나리오2
200명이 20.5MB 파일을 20초 동안
/download 엔드포인트에 연속으로 던진다.

WebFlux

MVC

이번에는 유의미한 결과가 나왔다.
평균 응답 시간은 비슷했다. (사실 MVC가 살짝 빠르다)
하지만 원하는 응답에 대해서만 비교했을 때 (expected_response:true)
WebFlux 8.94, MVC 10.5로 조금 더 빨랐다.

더 확실히 비교 가능한 점은, 실패율 (http_req_failed)이었다.
WebFlux 9.68%, MVC 52.70%로 MVC의 실패율이 훨씬 높다.

로컬 노트북으로 하다 보니 더 많이는 못 해봤고 일단 성능은 비슷하게 나왔지만,
200명 동시 접속으로 엄청나게 많은 요청을 보내 보니

확실히 더 안정적인 서비스를 유지할 수 있는 것은 WebFlux인 것 같다.


후기

WebFlux는 높은 동시성과 안정성에서 강점을 보인다. 특히 에러율이 낮고, 부하가 증가해도 응답 시간이 비교적 일정하게 유지된다.

비동기 논블로킹 방식의 장점을 알아볼 수 있었던 좋은 기회였다!

이제 얼추 리액티브 프로그래밍에 살짝 발 담근 정도는 된 것 같다.
이제 진짜 적용해보자..!!

profile
백엔드 주니어 주니어 개발자

0개의 댓글