FastAPI (Python) vs Fiber (Go) vs Spring Webflux (Java)

FastAPI (Python), Fiber (Go), Spring Webflux (Java) 각 언어별로 동일한 기능을 구현하고 각 언어의 스레드 관리와 이벤트처리에 대한 성능을 비교하고 싶었다. 그러기에 초당 몇건의 요청을 얼마나 안정적이고 빠르게 응답 하는지를 벤치마킹 해보았다.

FastAPI (Python) vs Fiber (Go) vs Spring Webflux (Java)
Photo by Kolleen Gladden / Unsplash

테스트를 하면서 제일 크게 고민 했던 점이 이 테스트가 각 언어별 DB 드라이버에 대한 성능 테스트로 이어지지는 않을까 하는 생각이 들어서 고심을 했지만 결론적으로는 언어별 스레드 관리와 이벤트 처리에 대한 성능을 기준으로 본다면, 언어별 DB 드라이버만의 성능을 테스트 하는 것은 아니라는 생각이 들었다.

A. 언어별 서버구현

1. Python (FastAPI) 서버 구성

1) 주요 라이브러리 버전
python 3.9
fastapi 0.103.2
aiomysql 0.2.0
gunicorn 21.0.1
2) 서버 소스
from fastapi import FastAPI, Request
from apps.memo.router import memo_router
import aiomysql.sa

app = FastAPI(
    title="Memo API",
    description="Memo CRUD API project",
    version="0.0.1"
)

@app.on_event("startup")
async def startup():
    app.state.db_pool = await aiomysql.sa.create_engine(
        user="root", password="root", host="127.0.0.1", db="test",
        maxsize         = 50,
        autocommit      = True
    )
    
@app.on_event("shutdown")
async def shutdown():
    app.state.db_pool.close()

@app.middleware("http")
async def state_insert(request: Request, call_next):
    request.state.db_pool   = app.state.db_pool
    request.state.db_conn   = await app.state.db_pool.acquire()
    response                = await call_next(request)
    return response

app.include_router(memo_router)
main.py
from typing import List

from aiomysql.sa import SAConnection
from fastapi.param_functions import Depends
from fastapi.requests import Request
from fastapi.routing import APIRouter

from apps.memo.model import memo
from apps.memo.schema import MemoSelect
from apps.memo.schema import MemoCreate

memo_router = APIRouter()


# 디비 쿼리를 위한 종속성 주입을 위한 함수
def get_db_conn(request: Request):
    pool = request.state.db_pool
    db = request.state.db_conn
    try:
        yield db
    finally:
        pool.release(db)


@memo_router.get("/memo/list/{page}", response_model=List[MemoSelect])
async def memo_list(
        page: int = 1,
        limit: int = 10,
        db: SAConnection = Depends(get_db_conn)
):
    offset = (page - 1) * limit
    query = memo.select().offset(offset).limit(limit)
    res = await db.execute(query)
    return await res.fetchall()


@memo_router.post("/memo", response_model=None)
async def memo_write(
    item: MemoCreate,
    db: SAConnection = Depends(get_db_conn)
):
    query = memo.insert().values(
        title=item.title,
        body=item.body
    )
    res = await db.execute(query)
    return {
        "idx": res.lastrowid,
        "title": item.title,
        "body": item.body
    }
router.py
from datetime import datetime
from pydantic import BaseModel

# Pydantic을 이용한 Type Hinting
class MemoCreate(BaseModel):
    title : str
    body : str

class MemoSelect(BaseModel):
    idx : int
    regdate : datetime
    title : str
    body : str
schema.py

2. Go (Fiber) 서버 구성

1) 주요 라이브러리 버전
Go 1.21.1
github.com/go-sql-driver/mysql v1.6.0
github.com/gofiber/fiber/v2 v2.49.2
2) 서버 소스
package main

import (
	"fmt"
	"log"
	"strconv"
	"time"

	"database/sql"

	_ "github.com/go-sql-driver/mysql"

	"github.com/gofiber/fiber/v2"
)

type (
	App struct {
		Idx     int    `json:"idx"`
		Regdate string `json:"regdate"`
		Title   string `json:"title"`
		Body    string `json:"body"`
	}
)

func main() {
	dsn := "root:root@tcp(127.0.0.1:3306)/test"

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		fmt.Println(err.Error())
	} else {
		fmt.Println("db is connected")
	}
	defer db.Close()

	db.SetConnMaxLifetime(time.Second * 1)
	db.SetMaxIdleConns(50)
	db.SetMaxOpenConns(50)

	//
	app := fiber.New(fiber.Config{})

	app.Post("/memo", func(c *fiber.Ctx) error {
		var d App
		c.BodyParser(&d)

		res, err := db.Exec("INSERT INTO memo(title, body) VALUES(?, ?)", d.Title, d.Body)
		if err != nil {
			panic(err.Error())
		}

		id, err := res.LastInsertId()
		if err != nil {
			panic(err.Error())
		}
		d.Idx = int(id)
		return c.Status(200).JSON(d)
	})

	app.Get("/memo/list/:page", func(c *fiber.Ctx) error {

		page := c.Params("page")

		limit := 10
		pint, _ := strconv.Atoi(page)
		offset := (pint - 1) * limit

		results, err := db.Query("SELECT * FROM memo LIMIT ?, ?", offset, limit)
		if err != nil {
			panic(err.Error())
		}

		defer results.Close()

		var list = []App{}

		for results.Next() {
			var app App

			err = results.Scan(
				&app.Idx,
				&app.Title,
				&app.Body,
				&app.Regdate,
			)
			if err != nil {
				panic(err.Error())
			}
			list = append(list, app)
		}
		return c.Status(200).JSON(list)
	})

	log.Fatal(app.Listen(":8000"))
}

3. Java (Spring Boot, Webflux) 서버 구성

1) 주요 라이브러리 버전
java 17
spring framework boot 3.1.4
2) 서버 소스
package com.example.webfluxapi.controller;

import com.example.webfluxapi.entity.Memo;
import com.example.webfluxapi.service.MemoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("")
@Slf4j
public class MemoController {

    private final MemoService memoService;

    @Autowired
    public MemoController(MemoService memoService) {
        this.memoService = memoService;
    }

    @GetMapping("/memo/list/{page}")
    public Flux<Memo> list(
            @PathVariable("page")
            Integer page,
            @RequestParam(value = "limit", defaultValue = "10")
            Integer limit
    ) {
        return memoService.list(page, limit);
    }

    @PostMapping("/memo")
    public Mono<Memo> create(@RequestBody Memo memo) {
        return memoService.create(memo);
    }
}
controller/MemoController.java
package com.example.webfluxapi.entity;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import java.time.LocalDateTime;

@Table("memo")
@Builder
@Getter
@Setter
public class Memo {
    @Id
    @Column("idx")
    private Integer idx;
    @Column("title")
    private String title;
    @Column("body")
    private String body;
    @Column("regdate")
    private LocalDateTime regdate;
}
entity/Memo.java
package com.example.webfluxapi.repository;

import com.example.webfluxapi.entity.Memo;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;

@Repository
public interface MemoRepository extends ReactiveCrudRepository<Memo, Integer> {
    @Query("SELECT * FROM memo LIMIT :page, :size")
    Flux<Memo> findAllBy(Integer page, Integer size);
}
repository/MemoRepository.java
package com.example.webfluxapi.service;

import com.example.webfluxapi.entity.Memo;
import com.example.webfluxapi.repository.MemoRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class MemoService {

    private final MemoRepository memoRepository;

    @Autowired
    public MemoService(MemoRepository memoRepository) {
        this.memoRepository = memoRepository;
    }

    public Flux<Memo> list(Integer page, Integer size) {
        return memoRepository.findAllBy((page - 1) * size, size);
    }

    public Mono<Memo> create(Memo memo) {
        return memoRepository.save(memo);
    }
}
service/MemoService.java

B. 스트레스 테스트 방법

스트레스 테스트는 Locust 라는 도구를 사용하기로 했고,
10만건의 데이터를 미리 DB에 적재하고 서버를 띄워 Select 를 하는 테스트 하나,
초기화된 데이터 테이블에 Insert 를 하는 테스트 로 나누어 스트레스 테스트를 진행했다.

1. Locust, 테스트 소스 작성

1) Insert 소스
from locust import FastHttpUser, task, between
import random
from faker import Faker

# 임의의 데이터를 생성하기 위한 라이브러리
fake = Faker('ko_kr')

class MyTest(FastHttpUser):
	# 아래 task 를 진행한 후에 대기 시간없이 바로 실행하도록 한다
    wait_time = between(0, 0)
    @task
    def index(self):
	    # 임의의 값을 post api 에 요청한다
        self.client.post("/memo", json={
            "title": fake.name(),
            "body": fake.sentence()
        })
2) Select 소스
from locust import FastHttpUser, task, between
import random

class MyTest(FastHttpUser):
    wait_time = between(0, 0)
    @task
    def index(self):
    	# 10 만 건이라 10개씩 1페이지를 구성하는 페이지를 1페이지 부터 10000페이지까지 임의로 호출
        r = random.randint(1,10000)
        self.client.get(f"/memo/list/{r}")

2. Locust, 테스트 실행

테스트는 아래와 같은 명령어로 실행 하며, 20명씩 증가 시키며 사용자 100명이 동시에 호출 한다. 그리고 시간은 60초간 실행하고 멈추도록 한다.

1) 실행 명령어
locust -H http://127.0.0.1:8000 -u 100 -r 20 -t 60s --autostart -f tests/locust_select.py
실행화면
2) chart 확인 사항
sample

로컬호스트의 8089 포트로 접근하면 위와 같은 웹페이지로 성능 및 진행사항을 확인 할 수 있는데 크게 아래 두 지표로 성능을 확인하도록 한다.

  • RPS : 초당 요청수 (Failure가 있는지 확인)
  • 95th Percentile : 대부분(95%)의 응답 시간 (노란색 그래프 확인)

C. 언어별 서버 성능

1. Python (FastAPI) 서버 성능

1) Select
Select : RPS 약 500-600, Res Time 약 150-250 ms
2) Insert
Insert : RPS 약 1600-2300, Res Time 약 60-100 ms

2. Go (Fiber) 서버 성능

1) Select
Select : RPS 약 660-800, Res Time 약 100-300 ms
2) Insert
Insert : RPS 약 4500-6000, Res Time 약 5-15 ms

3. Java (Spring Boot, Webflux) 서버 성능

1) Select
Select : RPS 약 600-700, Res Time 약 180-250 ms
2) Insert
Insert : RPS 약 4500-5500, Res Time 약 13-20 ms

C. 스트레스 테스트 결과

비동기 방식의 서버들을 가지고 테스트를 했고 성능상 큰 차이는 없다고 본다. 특히 Go 와 Reactor 기반의 Java의 Spring Webflux 는 유사한 성능을 보여주고 있다. (insert 가 select 보다 빠른 이유는 select 의 경우 페이징을 겸비한 아이템 목록을 던져주기 때문이다)

특정 언어가 더 빠르고 좋다 라는 논쟁은 의미가 없어 보인다. 어떤 언어를 다루든 비즈니스를 더 효율적으로 빨리 만들수 있는가 유지보수는 어떠한가의 기준으로 접근해야 할 것이다.