변변찮은 포스팅 시작하겠습니다.
이번 글에서는 제가 원래 써왔던 Node.JS 와 최근 프로젝트에서 많이 쓰고있는 Go 를 비교해보려고 합니다.
- 왜?
저는 주로 Node.js 기반으로 서버를 개발해왔고, 실제로 운영하던 서버 대부분도 Node.js 로 구성되어 있었습니다.
Node.js 는 비동기 I/O 모델을 기반으로 높은 동시성을 쉽게 다룰 수 있고, 생태계도 풍부해 개발 생산성은 높다는 장점이 분명히 있습니다.
시간이 지나면서 Kotlin, Go, Rust 같은 언어들이 주목받기 시작했고 "언어 성능" 에 대한 이야기들도 자주 등장했습니다. 개인적으로도 Go 언어에 관심이 있었고, 성능때문인지 자연스레 이 언어들의 점유율이 올라가기 시작했습니다.
이 과정에서 Node.js 의 단점으로 지적되는 부분들 - CPU 활용, 싱글 스레드 모델, 컨테이너 환경에서의 자원 활용 효율 - 에 대한 이야기도 같이 따라왔구요.
이런 흐름 속에서 사내에서도 Node.js 가 컨테이너 자원을 충분히 활용하지 못하는 것이 아닌가, 오히려 서버 비용이 더 들어가는 구조는 아닐까 라는 질문들이 나오기 시작했습니다.
언어 성능을 비교한 벤치마크 자료는 쉽게 찾을 수 있었지만, 운영하고 있는 서버의 성격을 기준으로 한 비교는 직접 해봐야 알 것 같았습니다. 그래서 운영중인 서버 중 메인이라고 볼 수 있는 DSP 서버의 역할과 요청 흐름을 기준으로, 이를 의도적으로 축소한 미니어처 서버를 NestJS 와 Gin 으로 각각 구현해 Node.js 와 Go 를 비교해보고자 합니다.
- 축소한 DSP 서버
DSP 라는건 광고 업계에서 쓰이는 용어인데, DSP 서버는 다음과 같은 일을 합니다.
OpenRTB 스펙의 광고 요청을 받으면, 내부 저장소에 있는 캠페인/광고 후보군을 조회한 후, 각 후보군을 조건에 따라 필터링합니다.
이후 남은 후보군들 중 eCPM/우선순위 등을 기준으로 최종 광고를 선택해 응답합니다.
이 과정에서 요청/응답 로그나 트래킹 이벤트를 이벤트 스트림(또는 메시지 큐) 로 적재하는 작업도 옵셔널하게 수행합니다.
저는 이를 축소해서, OpenRTB 스펙 광고 요청 -> 로컬레디스 후보군 조회 -> 간단한 필터링 -> 최종 1개 선택 -> 응답 생성 의 과정만 구현한 미니어처 서버를 만들었습니다.
async execute(option: OpenRTBRequest): Promise<ServeAd> {
const domainAdRequest = this.convertToDomainAdRequest(option);
let survivors = await this.adRepository.fetchAdList();
for (const filter of this.filters) {
survivors = await filter.pass(survivors, domainAdRequest);
}
const highestAd = selectHighestAd(survivors);
if (!highestAd) {
throw new Error('empty ads');
}
const bidContext = { bidTimestamp: this.clock.now(), bidCur: domainAdRequest.cur, impId: domainAdRequest.imp[0].id, siteDomain: domainAdRequest.site?.domain };
const encoded = this.adEncoder.encodeAd(highestAd, bidContext);
const adUrl = `https://redirect.com?d=${encoded}`;
return { id: highestAd.adId, url: adUrl };
}
이 코드는 위에 적은대로 작성했던 유즈케이스 코드(TS)를 일부 가져온 것입니다.
native 요청이라고 가정했다보니 OpenRTB 요청 중 string 으로 들어오는 imp > native > request 를 한번 파싱할 필요가 있어서 변경하는 과정이 들어가있고, 후보군을 조회한 후 필터링을 하고 가장 높은 cpm 을 가진 광고를 하나 선택해 응답으로 구성하는 로직입니다.
- 코드 세부 사항들
1. 먼저 convertToDomainAdRequest 로직은 Node.js 에선 JSON.parse / Go 에선 json.Unmarshal 이 필요합니다.
2. 후보군 조회 로직에선 미리 100개 이상씩 만들어놓는게 귀찮아서 레디스에는 10개만 만들어두고, 10개를 100개 또는 200개로 늘려서 사용했습니다. 나중에 적겠지만 이게 꽤 영향을 주는 부분이 있었는데.. 우선 여기선 로직만 보겠습니다.
private repeatAdIds(adIds: string[]): string[] {
const adsLengths = [100, 200];
const randomLength = adsLengths[Math.floor(Math.random() * adsLengths.length)];
const repeatCount = randomLength / adIds.length;
return Array.from({ length: repeatCount }, () => adIds).flat();
}
Node.js 로직입니다. 요청 1번당 위 로직을 한번 타게되는데, Math.random 을 사용해서 길이를 고르고 Array.from + flat 을 사용해서 광고 후보군 길이를 늘렸습니다.
func (adRepository *adRepository) repeatAdIds(adIds []string) []string {
adsLengths := []int{100, 200}
randomLength := adsLengths[rand.Intn(len(adsLengths))]
repeatCount := randomLength / len(adIds)
result := make([]string, 0, len(adIds)*repeatCount)
for range repeatCount {
result = append(result, adIds...)
}
return result
}
이건 Go 로직입니다. Node.js 로직과 거의 동일하게 작성했습니다.
3. 이후 필터 로직은 후보군을 순회하면서 조건을 비교하는데, adv / bcat / budget / country / native_creative / os 필터들을 넣었습니다. 간단히 구현을 보자면..
async pass(ads: AdMeta[], option: AdRequest) {
if (!option.badv || !option.badv.length) {
return ads;
}
return ads.filter((ad) => {
const { advertiserId } = ad;
return !option.badv!.includes(advertiserId);
});
}
이것은 adv filter 구현체입니다. 이름 그대로 광고주를 필터링하는 것인데, OpenRTB 요청에는 badv 에 블락할 광고주가 들어올 수 있고, 이를 사용해 필터링하는 로직입니다. 상당히 간단한 로직인데, budget filter 를 제외한 필터들은 전부 이런 로직이라고 보면 될 것 같습니다.
async pass(ads: AdMeta[], __option: AdRequest) {
const ids = ads.map((ad) => ad.adId);
const budgetList = await this.budgetRepository.getBudgets(ids);
const result = ads.filter((ad, i) => {
return budgetList[i] > ad.cpm;
});
return result;
}
이것은 budget filter 의 구현체입니다. 조회되었던 광고 후보군의 id 를 사용해 레디스로부터 해당 광고의 잔액을 조회하고, 이를 cpm 과 비교해서 잔액에 여유가 있는 광고 후보군만 남기는 로직입니다. 다른 필터들과 다르게 budget filter 에만 레디스 조회가 추가로 들어갔습니다.
4. 그리고 가장 높은 cpm 을 선택하는 로직이 필요하고,
5. 응답 구성 과정 중 encodeAd 로직은 Node.js 에선 JSON.stringify + lz-string compressToEncodedURIComponent / Go 에선 json.Marshal + lz-string CompressToEncodedURIComponent 이 필요합니다.
이런 세부사항들을 적은 이유는, 이런 작업들은 모두 I/O 보다 CPU 및 메모리 할당/GC 영향을 더 많이 받는 편이라, 위의 "왜?" 부분에 적진 않았지만 저도 직관적으로는 Go 가 더 우수한 성능을 내지 않을까 기대했습니다.
- 테스트 조건
사용한 프레임워크는 Node.js 에선 NestJS / Go 에선 Gin 을 사용했습니다.
Go 는 처음에 Fiber 로 작성하다가, Gin 이 성능상에서 조금이나마 앞선다는 글을 보고 Gin 으로 갈아탔습니다.
Node.js v24 + NestJS v11 / Go v1.24 + Gin v1.11 을 사용했습니다
실제 운영중인 DSP 서버는 k8s 환경에서 앱 컨테이너 기준 2vcpu 를 할당하고, Node.js 어플리케이션은 pm2(또는 cluster) 를 사용해 2개의 프로세스로 운영하고 있습니다.
따라서 이번 실험에서도 이 운영 형태를 그대로 가정해서, NestJS 어플리케이션은 pm2 를 사용해서 2개의 프로세스를 실행했고, Gin 어플리케이션은 GOMAXPROCS=2 로 설정해 프로세스가 사용할 수 있는 최대 코어수를 2개로 제한했습니다.
첫 테스트 단계에선 Docker 를 사용하지 않고 로컬(Mac) 환경에서 서버를 직접 띄웠고, Node.js 는 pm2 를 사용해 띄운 2개 프로세스의 cpu 와 memory 합산 지표를, Go 는 1개 프로세스의 cpu 와 memory 지표를 모니터링하는 스크립트를 작성해 실험했습니다. 이후 Docker 를 사용한 버전에서도 결과는 거의 다르지 않게 나왔기때문에, 테스트 결과를 그냥 보셔도 상관없을 것 같습니다.
부하 생성 도구는 두 서버 모두 동일하게 k6 를 사용했고, 완전히 같은 시나리오로 테스트했습니다.
scenarios: {
warm_up: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '5s',
preAllocatedVUs: 50,
},
r500: {
executor: 'constant-arrival-rate',
rate: 500,
timeUnit: '1s',
duration: '5m',
startTime: '5s',
preAllocatedVUs: 200,
maxVUs: 500,
},
};
자세히 적진 않지만 웜업 이후 초당 500개 요청을 5분동안 일정하게 서버로 보내는 시나리오입니다.
- 첫번째 결과

먼저 메모리 사용량입니다. NestJS 어플리케이션은 부팅될 때부터 250MB 가까이 메모리를 사용하고, 웜업단계를 제외하면 450MB 이상의 메모리를 계속 사용하는걸 볼 수 있습니다.
그에 반해 Gin 어플리케이션은 부팅시 20MB 정도의 메모리만 사용하고, 요청을 처리하는 중에도 30MB 정도의 메모리만 사용했습니다.
메모리 사용률에선 Go 가 압도적으로 좋은 성능을 보였습니다.

CPU 사용량입니다. 사실 저는 Go 가 직관적으로 더 좋은 성능을 보일거라고 생각했기에, 굉장히 ??? 가 드는 결과였습니다. 여러번 테스트했지만 매번 이와 비슷한 수치가 나왔습니다.
현재 운영 환경 및 이 서버 특성상 메모리 사용량은 크게 중요치 않다고 생각했고, CPU 사용량에서 Go 가 더 좋은 효율을 보인다면, k8s 클러스터의 동일한 HPA 조건에서 Go 가 같은 수의 요청을 처리할 때 EC2 를 더 적게 쓸 수 있고, 그러면 이는 서버비 에도 영향을 줄 수 있다고 생각했지만, 완전 반대로 나온 결과였습니다.
- Go 프로파일링
Go 코드를 작성할 때 뭔가 굉장히 비효율적으로 작성한게 있는건 아닌가 하는 생각에 pprof 로 프로파일링을 진행했습니다. 어찌보면 Go 에 상당한 특혜를 실어주는 것 이지만..

1. 저는 광고 후보군을 레디스에 넣어둘 때 기존 운영 레벨에서도 사용하던 것처럼 단순 string 으로 말아넣어두었고, 이를 MGet 으로 조회하는 전략을 취했습니다. 이러한 영향때문인지, 프로파일링 결과를 보면 FetchAdlist 하위에 json.Unmarshal, json.(*decodeState)... 와 같이 Go 에서 기본으로 제공하는 "encoding/json" 에서 크게 잡혀있는걸 확인할 수 있었습니다.
따라서 단순 string 으로 사용하던 포맷을 msgpack 으로 변경했습니다. 당연히 Node.js 에도 성능 향상을 기대할 수 있는 부분이긴 하지만요.
2. 그리고 중간에 gin.LoggerWithConfig.func1 이 보이는데요. 테스트시 Gin 어플리케이션 생성에서 gin.Default() 를 사용했고, 이로인해 매 요청마다 발생하는 로깅이 꽤 비용을 차지하는것으로 분석되었습니다. 따라서 gin.New() 로 변경했습니다.
3. 프로파일링에 잡힌건 아니지만 위에 적은 로직 중 레디스에 광고 후보군을 10개만 넣어두고 매 요청마다 100개 또는 200개로 복사해서 사용했다고 한 부분이 있는데, 이 부분이 Go 에선 좀 더 불리하게 작용할 여지가 있다고 하여 해당 로직을 제거했습니다.
private getRandomKey(): string {
const randomIdx = Math.floor(Math.random() * 2);
return randomIdx === 0 ? 'active_msg_ads1' : 'active-msg_ads2';
}
따라서 변경한 로직은 위처럼 100개 후보군을 갖는 키와 200개 후보군을 갖는 키 를 각각 세팅해두고, 매 요청마다 둘 중 하나의 키를 사용하도록 했습니다.
- 두번째 결과
여기서부턴 메모리 사용률 비교는 생략하겠습니다. 이어지는 테스트에선 첫번째 결과와 다른점이 거의 없었기 때문에, CPU 만 보도록 하겠습니다.

웜업을 지난 구간에서 초반 Go 의 CPU 사용률이 50~55 로 튀는건 왜인지 모르겠지만.. 해당 구간을 지나면, 조금은 그럴듯해졌네요.
하지만 여전히 Go 가 더 좋다고 볼수는 없는 것 같습니다.
여기까지 왔음에도 저는 왜 Go 가 더 좋지 않은지 납득이 잘 안되었기에.. 한번 더 테스트를 해봤습니다.
- 세번째 결과
두번째 테스트시 코드에선 각 요청당 사용할 후보군 키를 고를 때 랜덤이 필요하고, 이를 위해 Node.js 에선 Math.random / Go 에선 rand.Intn 을 사용합니다. 실제 DSP 서버에선 이렇게 구현될 이유가 없기도하고 이 부분이 Go 에 더 불리하게 작용하지 않았을까 하는 추측에 매 요청마다 무조건 200개의 후보군만 사용하도록 고정하고 테스트를 진행했습니다.
그리고 이때부터 각 앱을 docker 컨테이너에 띄우고 docker stats 로 CPU 를 모니터링 했습니다.


Node.js CPU / Go CPU 사용량입니다. 워크로드에 의한 v8 GC 동작때문인지 Node.js 의 CPU 는 이따금씩 튀는 모양새가 있긴한데 눈대중으로 보면 거진 비슷하지 않나 싶긴하네요.
각 테스트에서 적진 않았지만 모든 테스트에서 k6 의 min, max, avg, p99 는 Node.js 와 Go 가 거의 동일하게 나오긴 했습니다.
여기까지보면 제가 가정한 서버 환경에서의 Node.js 와 Go 의 CPU 성능은 별 차이없고, 메모리 사용은 Go 가 압도적으로 좋다 정도인 것 같습니다.
다만 저는 테스트 서버를 구현할 때 응답을 만드는 부분을 OpenRTB 스펙에 제대로 안맞춘것도있고, 요청/응답 로그에 대한 비동기 작업도 넣진 않았었는데요. 이런 작업들이 들어갔을 땐 양상이 어떻게 달라졌는지 다음 글에서 이어가겠습니다.