정말 오랜만에 (아마도 장문이 될) 주제로 블로그에 글을 쓰는 것 같습니다. 먼저 서두에 밝혀두고 싶은 점은, 이번 벤치마크는 완전히 변수들을 통제한, 정말 제대로 각잡고 한 벤치마크는 아니라는 점입니다. 어디까지나 리얼 월드에서 과연 어느 정도의 차이가 벌어지는지 실제 프로젝트로 비교해본 것이라는 점을 밝혀둡니다.
먼저 제 글을 시작하기 전에, 한 번쯤 봐두면 좋을 다른 분의 블로그 글을 소개해 드립니다.
https://medium.com/deno-the-complete-reference/bun-vs-go-native-hello-world-performance-006791174df2
위 블로그의 글을 요약하면, “생각보다 Go가 그렇게 빠르진 않네?” 라는 결론이 나옵니다.
그리고 본 글에서도 결론은 동일합니다.
결론 먼저, Go 언어는 때때로 빠르지 않습니다.
Go언어는 단순하면서도 꽤나 빠른 속도로 동작한다고 알려져 있습니다. 그리고 실제로 여러 벤치마크를 통해 알려진 것처럼, Rust나 C++에 비할 정도는 아니어도 꽤나 빠릅니다. 특히 자바스크립트 런타임들이 가지고 있는 태생적인 한계인 CPU 집약적인 연산이나, 여러 병렬 처리 및 동시성 제어 등에서 정말 큰 장점들을 가지고 있습니다. 저도 TSBOARD의 새로운 백엔드를 Go언어로 재작성 하면서, 이런 점들을 확실히 알게 되었고 이 언어가 지향하는 곳이 클라우드라는 점도 명확히 알게 되었습니다.
우선 아래의 표를 먼저 살펴보시죠.
M1 Max | (Mac Studio) |
| Go (1.23.3) |
| Bun (1.1.37) |
|
---|
Test Path | Number of Requests | Number of Workers | Requests per second | Memory (MB, peak) | Requests per second | Memory (MB, peak) |
/home/tsboard
| 100,000 | 10 | 66923.28 | 21.3 | 61038.01 | 99.5 |
(No DB conn.) | 100,000 | 50 | 118127.24 | 21.3 | 64482.78 | 99.5 |
| 100,000 | 100 | 125999.24 | 21.2 | 64933.38 | 99.3 |
(참고: hey
라는 Go 언어로 작성된 도구를 이용하여 측정한 결과입니다.)
{
"success": true,
"error": "",
"result": {
"success": true,
"officialWebsite": "tsboard.dev",
"version": "1.0.0-beta1",
"license": "MIT",
"github": "github.com/sirini/goapi"
}
}
실제로는 서두에 언급한 것처럼 완벽하게 동일한 비교가 아니고, Go언어에서 출력해줘야 하는 데이터가 좀 더 많습니다. (Go의 요청 별 응답 크기: 160 bytes / Bun 응답 크기: 114 bytes) 그럼에도 불구하고, Go는 Bun과 비교했을 때 더 높은 초당 요청 처리횟수를 보여주면서도 메모리는 Bun 대비 20% 수준만 사용하고 있습니다. 물론 CPU 사용량은 Bun 대비 2~3배 가량 높았지만, 그럼에도 이 결과만 두고 봤을때는 정말 놀라운 수준입니다.
아니, 그럼 결론을 제가 잘못 말한 게 아닌가 싶겠지만…! 이제 아래의 표도 같이 살펴보시죠.
M1 Max | (Mac Studio) |
| Go (1.23.3) |
| Bun (1.1.37) |
|
---|
Test Path | Number of Requests | Number of Workers | Requests per second | Memory (MB, peak) | Requests per second | Memory (MB, peak) |
/home/latest | 1,000 | 10 | 70.98 | 35 | 101.45 | 142.5 |
100 posts | 1,000 | 50 | 67.99 | 87.9 | 104.6 | 194.9 |
per request | 1,000 | 100 | 67.97 | 159.5 | 102.57 | 250.5 |
(참고: 이번에도 Go의 요청 당 응답 크기가 더 높은 패널티가 있습니다 ▸ Go: 621,343 bytes / Bun: 526,198 bytes)
{
"success": true,
"error": "",
"result": [
{
"uid": 1234,
"title": "제목 예시 (실제로는 더 긴 데이터가 출력 되었습니다)",
"content": "내용 예시",
"submitted": 1732253323985,
"modified": 1732276827209,
"hit": 1,
"status": 0,
"category": {
"uid": 1,
"name": "일반"
},
"cover": "/upload/thumbnails/2024/11/22/this_is_sample_output.avif",
"comment": 4,
"like": 0,
"liked": false,
"writer": {
"uid": 1,
"name": "홍길동",
"profile": "/upload/profile/2024/11/16/hong_gil_dong.avif",
"signature": "어디까지나 예시용 데이터입니다"
},
"id": "photo",
"type": 1,
"useCategory": false
},
]
}
거듭 말씀드리지만, 완벽하게 공정한 비교는 아닙니다. Go언어로 재작성 하면서 출력해야 할 데이터가 약간 늘어나긴 했거든요. 그걸 감안하고 데이터를 보더라도, 처음에 봤던 테이블과는 사뭇 다른 결과를 보실 수 있습니다. 메모리 사용량은 여전히 Bun 런타임 대비 현저히 낮지만, 초당 요청 처리 건수는 확연히 떨어지는 걸 보실 수 있습니다.
혹시 Go언어에 익숙하지 않은 저의 잘못으로 인해 이런 일이 발생한 걸까요? 저도 제가 뭔가 잘못된 코드를 작성해서 이런 사태가 벌어진 거면 좋겠지만… 수많은 여러 테스트와 코드 수정을 통해서 다다른 결론은, Go언어가 생각만큼 늘 빠르지는 않다는 점입니다. 오히려 DB 입출력이 잦은 리얼 월드에서는 Bun 런타임과 Elysia 웹프레임워크, 그리고 mysql2 라이브러리의 조합이 예상 외로 훌륭한 결과를 보여주었습니다. 동시 요청이 많아도 Go가 보여주는 것만큼의 안정적인 동작을 보여주었고, 미약해 보였던 싱글 스레드 기반의 이벤트 루프는 비동기 I/O와 함께 의외의 슈퍼 파워를 보여주었습니다.
Go의 표준 HTTP 라우터는 쓰지 마세요, 느립니다
TSBOARD의 새 백엔드를 작성할 때, 처음에는 Go 1.23에서 소개된 표준 HTTP 라우터만 사용했습니다. 동시 접속자가 적은 실제적인 상황에서는 꽤나 훌륭하게 동작했고, 별 문제가 없어보여서 그대로 진행하려고 했죠. 하지만 이미 Bun과 Elysia 조합으로 작성된 백엔드가 있었기 때문에, 정확히 어떤 부분들이 개선되었는지 확인이 필요했습니다. 그래서 hey 도구를 이용해서 부하 테스트를 진행했는데, 세상에… 앞서 보여드린 결과보다 더 열악한 결과만 나왔습니다. 동시 요청이 많아질수록 고루틴 간에 자원을 대기하는 기간이 늘어지고, DB 풀을 대기하면서 CPU 클럭만 차지하는 경우가 많아졌습니다.
응답 속도를 Bun보다 높게 하기 위해서 몇가지 도전적인 작업을 해보았지만, 빠른 응답을 위해 유실되는 응답이 많아져 이 길은 아니라고 생각하여 포기하였습니다. 어쩔 수 없이 웹 프레임워크를 쓰기로 결정하고 Fiber가 가장 마음에 들어서 선택하였습니다. 그 후 테스트를 해보니 이제서야 Bun(Elysia) 조합에 크게 떨어지지 않는 응답 속도를 회복할 수 있었습니다.
func LoadAllPostsHandler(s *services.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionUserUid := utils.FindUserUidFromHeader(r)
sinceUid64, err := strconv.ParseUint(r.FormValue("sinceUid"), 10, 32)
if err != nil {
utils.Error(w, "Invalid since uid, not a valid number")
return
}
utils.Success(w, results)
}
}
Go언어의 새로운 기능이라서 유튜브 등에서 이 표준 HTTP 라우터를 써보라고 권하는 분들이 계실 수도 있지만, 저는 권장하고 싶지 않습니다. 기능 자체는 동작하지만 프로덕션 레벨에서 빠릿하게 동작하는 수준은 결코 아닙니다. 제가 선택한 Fiber 혹은 다른 종류의 웹 프레임워크를 선택하시길 바랍니다.
func (h *TsboardHomeHandler) LoadAllPostsHandler(c fiber.Ctx) error {
actionUserUid := utils.ExtractUserUid(c.Get("Authorization"))
sinceUid64, err := strconv.ParseUint(c.FormValue("sinceUid"), 10, 32)
if err != nil {
return utils.Err(c, "Invalid since uid, not a valid number")
}
result, err := h.service.Home.GetLatestPosts(parameter)
if err != nil {
return utils.Err(c, "Failed to get latest posts")
}
return utils.Ok(c, result)
}
물론, TSBOARD가 처음에 선택했던 Bun과 Elysia의 조합은 여러분들이 상상하시는 것보다 더 바르고 빠르게 동작합니다. 높은 성능과 개발 생산성을 생각한다면 굳이 Go 언어를 고민하실 필요가 없습니다.
무엇이 잘못된걸까?
여러 가지 원인이 있을 수 있지만 (그 중에서 가장 좋은 이유라면 제가 뭔가를 잘못한 것이겠죠?!), 제가 생각한 문제는 데이터베이스 드라이버입니다. Go 언어에서 MySQL/MariaDB에 연결해서 작업을 하려면 가장 많이 쓰는 go-mysql-driver를 사용해야 합니다. 이 드라이버는 여러 기능들을 갖추고 제대로만 사용하면 자원 할당/해제는 기대한 대로 동작합니다. 실제로 이 글에서 언급하지 않은 수많은 다른 테스트에서도 안정성 만큼은 문제가 없었습니다.
import mysql, { ResultSetHeader, RowDataPacket } from "mysql2/promise"
const pool = mysql.createPool({
host: process.env.DB_HOST,user: process.env.DB_USER,password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
maxIdle: 10,
idleTimeout: 60000,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
socketPath: process.env.DB_SOCK_PATH,
})
export async function select(query: string, values: string[] = []): Promise<RowDataPacket[]> {
let result: RowDataPacket[] = []const db = await pool.getConnection()
try {
const [rows] = await db.execute<RowDataPacket[]>(query, values)
if (!rows[0]) {
return result
}
result = rows
} catch (e: any) {
console.log(`[error/select] ${query} (${e})`)
} finally {
db.release()
}
return result
}
그러나, 이 드라이버는 제가 생각했던 것보다, 그리고 다른 언어에서 구현한 MySQL / MariaDB 드라이버보다 느리게 동작합니다. 자바스크립트 생태계에서 주로 사용하는 mysql2과 같은 드라이버는 go-mysql-driver만큼이나 다양한 기능들을 제공하지만, 결과적으로 더 빠르게 동작했습니다. 물론 이것만으로 범인을 특정(?!)하는 것은 문제가 있습니다만, 자바스크립트 생태계에 있을 땐 단 한 번도 고민하지 않았던 DB I/O 성능 이슈를 Go언어에서 겪게 되고, DB 연결이 필요 없는 작업과 비교해서 결과를 보니 의심을 지우긴 어렵습니다.
func Connect(cfg *configs.Config) *sql.DB {
addr := fmt.Sprintf("tcp(%s:%s)", cfg.DBHost, cfg.DBPort)
if len(cfg.DBSocket) > 0 {
addr = fmt.Sprintf("unix(%s)", cfg.DBSocket)
}
dsn := fmt.Sprintf("%s:%s@%s/%s?charset=utf8mb4&loc=Local",
cfg.DBUser, cfg.DBPass, addr, cfg.DBName)
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("❌ Failed to connect to database: ", err)
}
if err = db.Ping(); err != nil {
log.Fatal("❌ Database ping failed: ", err)
}
maxIdle, err := strconv.ParseInt(cfg.DBMaxIdle, 10, 32)
if err != nil {
maxIdle = 20
}
maxOpen, err := strconv.ParseInt(cfg.DBMaxOpen, 10, 32)
if err != nil {
maxOpen = 20
}
db.SetMaxIdleConns(int(maxIdle))
db.SetMaxOpenConns(int(maxOpen))
db.SetConnMaxLifetime(3 * time.Minute)
return db
}
func (r *TsboardCommentRepository) GetLikedCount(commentUid uint) uint {
query := fmt.Sprintf("SELECT COUNT(*) FROM %s%s WHERE comment_uid = ? AND liked = ?",
configs.Env.Prefix, models.TABLE_COMMENT_LIKE)
var count uint
r.db.QueryRow(query, commentUid, 1).Scan(&count)
return count
}
물론 제가 뭔가 잘못한 걸 수도 있습니다. go-mysql-driver
의 권장 설정을 제대로 따르긴 했지만, 아직 스스로를 고퍼라고 생각하진 않기 때문에 만약 제가 저지른 실수가 있다면 참회하는 마음으로 다시 글을 업데이트 할 생각입니다. (지금 이 글을 쓰면서 부디 제가 뭔가 잘못한 것이길 바라고 있습니다…!)
Go 언어는 느리다, 가 아니라 빠르지는 않다
모든 언어가 마찬가지지만, Go 언어도 빠르게 동작할 수 있는 부분이 분명히 있고, 기대했던 것보다는 느리게 동작하는 부분도 있습니다. CPU 집약적인 연산(예를 들면 JWT encode/decode)이나 병렬 처리해도 동시성 관리가 거의 필요없는 부분에서는 발군의 성능을 뽐낼 수 있지만, 실제 DB를 사용해야하고, DB가 주요 병목일 수 밖에 없는 백엔드에서는 기대했던 것만큼 성능이 나오질 않습니다.
Node.js가 세상에 등장하고 Deno가 나오면서 점점 더 많은 사람들이 자바스크립트/타입스크립트 런타임에 의존하고 있습니다. 이 덕분에 Bun과 같은 고성능 런타임의 개발까지 이어졌다고 생각합니다. 정말 놀라운 결과이지만, Bun 런타임과 Elysia의 조합은 정말 인상적인 성능을 보여줍니다. TSBOARD 프로젝트를 처음 시작할 때 제가 선택한 이 스택을 보다 많은 개발자분들이 직접 써보시고, 실제 현업에서도 많이 활용해 보셨으면 좋겠습니다. 정말 좋거든요!
그래서, Go 언어로 만든 백엔드는 포기하나요?
지금까지 글을 읽어주신 분들 이라면 응당 제가 Go 언어에서 다시 Bun(Elysia) 조합으로 돌아 가겠구나, 하고 생각 하셨을 겁니다. 실제로 저도 진지하게 Go 재작성 프로젝트를 중단할까 고민도 했었습니다. 고성능 백엔드 개발에 쓸 만한 언어는 다양하게 있고, 저의 최애(?) 언어인 PHP도 건재하니 말이죠. (건재한 거 맞지 PHP…? 잘 지내니…?)
그러나, 아래의 이유로 Go 언어로 백엔드 재작성을 마무리 짓고 진정한 Gopher로 거듭나고자 합니다!

![]()
사람마다 선호하는 언어가 다르고, 자신이 생각하는 정답이 다른 사람과 다를 수도 있습니다. 저는 다른 언어들 만큼 Go 언어도 좋아하게 되었고, 비록 기대했던 것만큼 비약적인 성능 향상은 없었지만 그럼에도 Go 언어의 여러 장점들이 마음에 들어서 좀 더 사용해 보려고 합니다. 그리고 부디 언젠가 성능 향상의 비급서를 찾을 그 날을 꿈꿔봅니다.
이 긴 글을 읽어주신 분들께 감사드립니다. 혹시 기회가 된다면, 여러분들께서는 Go 언어에 대한 이 글의 비교 결과를 어떻게 생각하시는지 의견도 나눠보고 싶습니다!