본문 바로가기

Backend/Architecture

DSP 서버로 간단히 비교해 본 Node.js 와 Go (2)

변변찮은 포스팅 시작하겠습니다.

이번 글 내용은 저번 포스팅에서 진행했던 Node.JS 와 Go 비교에서 이어집니다.

 

- 테스트 조건

 

지난번 글에서 적은 것처럼 테스트에 사용중인 프레임워크는 Node.js 에선 NestJS / Go 에선 Gin 입니다.

언어 및 프레임워크 버전은 Node.js v24 + NestJS v11 / Go v1.24 + Gin v1.11 입니다.

 

scenarios: {
    warm_up: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '5s',
      preAllocatedVUs: 50,
    },
    r1300: {
      executor: 'constant-arrival-rate',
      rate: 1300,
      timeUnit: '1s',
      duration: '3m',
      startTime: '5s',
      preAllocatedVUs: 200,
      maxVUs: 500,
    },
};

 

이전엔 초당 500개 요청으로 테스트를 진행했었는데 할당하는 2cpu 기준 이 시나리오에선 cpu 사용량이 높아봐야 50 정도로 비율로 보면 25% 수준밖에 안되기 때문에, 요청수를 초당 1300개로 늘려서 cpu 사용량을 100 정도는 사용하도록 수정했습니다.

 

- 첫번째 테스트

 

이전 테스트를 위해 작성한 서버 로직은 OpenRTB 스펙의 요청을 받아 요청 파싱 및 광고 필터, 압축 등의 작업을 수행했었습니다.

하지만 좀 더 프로덕션 서버 환경을 가정한다면 OpenRTB 스펙에 맞게 응답 구성이 필요합니다. 따라서 이에 따른 로직을 좀 더 추가했습니다.

 

import { randomUUID } from 'crypto'

...

const nativeResponse: NativeResponseObject = {
	ver: '1.2',
	link: {
		url: clickUrl,
		clicktrackers: [],
	},
	assets: [
		{ id: 1, required: 1, title: { text: title, len: title.length } },
		...(imageUrl ? [{ id: 2, required: 0, img: { url: imageUrl } } satisfies NativeAsset] : []),
		{ id: 3, required: 0, data: { value: sponsoredBy, len: sponsoredBy.length } },
    ],
	eventtrackers: [{ event: 1, method: 1, url: impUrl }],
};
const bidId = randomUUID();
const admObj = { native: nativeResponse };
const admStr = JSON.stringify(admObj);

...

 

import "github.com/google/uuid"

...

nativeResponse := openrtb.NativeResponseObject{
	Ver: "1.2",
    Link: openrtb.NativeLink{
    	URL:           bidResult.ClickUrl,
        ClickTrackers: &[]string{},
    },
    Assets: &assets,
    EventTrackers: &[]openrtb.NativeEventTracker{
    	{Event: 1, Method: 1, URL: &bidResult.ImpUrl},
    },
}
admObj := struct {
	Native openrtb.NativeResponseObject `json:"native"`
}{
	Native: nativeResponse,
}
bidId := uuid.NewString()
byteList, err := json.Marshal(admObj)

...

 

각각 TS / Go 코드인데요. OpenRTB 스펙에 맞게 응답을 구성하면서 추가된 로직은 bidId 생성에 uuid 를 사용하고, 구성한 assets 을 응답의 adm 에 넣기위해 JSON.stringify / json.Marshal 을 사용한 일부 코드입니다. 이러한 부분들에서 CPU 자원을 좀 더 사용할 것으로 예상했습니다.

 

그리고 따로 코드를 넣진 않았지만 응답 중 nurl, eventtrackers, link.url 을 채우기 위해 요청당 한번 하던 url 압축 과정을 요청당 3번으로 늘려주었습니다. url 압축에서도 lz-string 을 사용하므로, 마찬가지로 여기서도 CPU 자원을 더 사용할 것 같습니다.

 

Node.js CPU
Go CPU

 

각각 docker stats 상의 Node.js CPU / Go CPU 사용량입니다. 생각보다 좀 차이가 더 벌어진 모양새인데, 똑같은 일을 처리하는데 Go 가 훨씬 CPU 를 많이 필요로 하는 것 같아 보이네요.

 

  Avg P70 P80 P95 P99
Node.js 2.59ms 1.79ms 2.01ms 5ms 18.63ms
Go 2.35ms 1.98ms 2.16ms 4.17ms 15.07ms

 

혹시 k6 결과상으로는 Go 가 그만큼 더 빠르지 않았을까? 해서 k6 결과도 살펴봤습니다. Go 가 좀 더 괜찮은 것 같아 보이지만, 뭔가 유의미한 차이가 있다고 보기 어려운 것 같다.. 싶네요.

 

- 두번째 테스트

 

지금까지 진행한 테스트의 로직은 전부 동기식 처리였습니다. 하지만 DSP 서버는 이러한 로직 외에도 요청/응답 로그를 따로 적재하기 위한 비동기 로직도 필요로 합니다. 요청/응답 을 저장해두어야 비딩 결과도 보고 데이터 분석을 하는데도 활용할 수 있기에 이 로직은 필수는 아니지만, 제가 맡았던 서버들에선 늘 있었습니다.

따라서 비동기 로직을 추가하고 동일한 시나리오로 테스트해서, CPU 사용량에 대한 추이를 모니터링하고자 합니다.

 

import zlib from 'zlib';

...

onModuleInit() {
	this.start();
}

log(rawLog: any) {
	const record = {
      ts: Date.now(),
      name: this.publisherName,
      ...rawLog,
    };
    const buf = Buffer.from(JSON.stringify(record) + "\n", 'utf-8')
    this.queue.push(buf);
}

start() {
	setInterval(() => {
    	void this.flush();
    }, this.interval * 1000);
}

private async flush() {
	try {
    	...
		const compressedData = this.compress(batch);
		await this.redisClient.rpush(this.key, compressedData);
    } catch (e) {
		...
    } finally {
		...
    }
}

private compress(buf: Buffer[]) {
    return zlib.zstdCompressSync(Buffer.concat(buf)); 
}

 

전부 다 가져오면 너무 길어질 것 같아 작성한 비동기 작업 구현체 중 일부 TS 코드만 가져왔습니다.

보이는 것처럼 요청 -> 응답 과정에서 발생하는 BidRequest, BidResponse 는 log 메서드를 호출해서 queue(인메모리)에 우선 들고있습니다.

그리고 interval (전 10초로 세팅했습니다) 에 따라 인메모리에 들고있던 데이터를 전부 가져와 zstd 로 압축하고, 압축한 데이터를 레디스에 밀어넣는 로직입니다. zstd 는 기본으로 제공하는 zlib 을 활용했습니다.

본래라면 이벤트스트림이나 메세지큐에 넣어야하지만 세팅하기 번거롭기도하고.. redisClient.rpush 로 비슷한 효과를 낼 수 있을거라 생각했습니다. 

 

Go 에서는 setInterval 과 같이 주기적으로 특정 로직을 수행하는 문법은 없기때문에, Ticker 를 만들고 데이터 flush 신호를 받을 수 있는 채널을 만들어 Ticker 에 의한 주기적인 tick 마다 채널로 신호가 전달되면 로직을 수행해 데이터를 zstd 로 압축해서 rpush 하도록 했습니다. zstd 라이브러리는 "github.com/klauspost/compress/zstd" 를 사용했고, 이 로직은 별도의 고루틴으로 돌게 구현했습니다.

 

추가로 Go 에서 TS 보다 약간 불리할 수 있는 로직이 추가되긴 했는데요. 위 코드를 보면 record 를 만들 때 전개 연산자를 사용해서 any 로 받은 rawLog 를 편하게 복사하고 있는데, Go 에는 이런 연산자가 없으므로 rawLog 를 any 타입으로 받은 후
json.Marshal + json.Unmarshal 을 거쳐서 map[string]any 타입으로 만드는 라인이 추가되었습니다. (rawLog 인자 타입을 map[string]any 로 바꾸는 방법은 일이 더 커져서 선택하지 않았습니다..)

 

Node.js CPU

 

Go CPU

 

여기서 결과가 꽤 이상하다고 느껴졌습니다. 앞서 작성한 로직에서 원래는 없던 비동기 로직이 추가되었고, 해당 로직에선 zstd 에 의한 압축도 수행하므로 물론 추가한 워크로드 자체가 큰 비중을 차지하지 않는다면 CPU 에 거의 변화가 없을 순 있는데, Go 의 경우엔 120~130 이던 CPU 사용량이 100 정도로 줄어든 것입니다. 반면에 Node.js 의 경우엔 CPU 사용량이 92 -> 98 정도로 약간 증가한 것을 확인할 수 있었습니다.

 

  Avg P70 P80 P95 P99
Node.js 2.88ms 1.99ms 2.45ms 7.44ms 19.63ms
Go 2.17ms 2ms 2.14ms 3.41ms 11.4ms

 

k6 결과를 봐도 Go 는 같은 상황에서 Node.js 뿐만 아니라 이전 테스트 환경보다도 더 나아진 모습입니다.

혹시나 주기적으로 도는 비동기 로직이 제대로 동작하지 못했고, 그로 인해 redis rpush 결과에 일부 누락이 생기진 않았을까 체크해봤지만 그런 문제는 없었습니다.

 

- Go 의 GC

 

위 내용으로는 Node.js 와 Go 의 비교에서 어떠한 결과도 얻을 수 없다고 생각했습니다.

저는 이 결과가 "Go 는 기존 로직에 비동기 로직이 추가되어도 서버 성능이 좋아질 수도 있고, 나빠질 수도 있다" 로 해석되었거든요.
따라서 이 문제를 GPT 랑 논의하다보니, Go 의 GC 및 메모리 할당 동작에 따른 영향이 가장 클 것 같았습니다.

즉 추가된 비동기 로직이 오히려 GC 가 도는 주기를 더 늦추게 되었고 그로 인해 CPU 사용량이 저렇게 보였을 거란 추측이죠.

 

그래서 Go 서버를 띄울 때 GODEBUG=gctrace=1 환경변수를 넣어 GC 로그를 확인해봤습니다.

 

gc 5956 @54.974s 4%: 0.004+1.2+0.014 ms clock, 0.008+0.18/0.73/0.22+0.028 ms cpu, 9->10->5 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 5957 @54.978s 4%: 0.009+1.2+0.001 ms clock, 0.018+0.079/0.66/0.67+0.002 ms cpu, 9->10->4 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 5958 @54.983s 4%: 0.004+1.3+0.013 ms clock, 0.009+0.014/0.72/0.54+0.027 ms cpu, 8->9->5 MB, 10 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 5959 @54.987s 4%: 0.022+1.0+0.013 ms clock, 0.045+0.094/0.48/0.52+0.027 ms cpu, 9->10->4 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 5960 @54.992s 4%: 0.008+1.2+0.001 ms clock, 0.016+0.040/0.64/0.56+0.002 ms cpu, 9->9->5 MB, 10 MB goal, 0 MB stacks, 0 MB globals, 2 P

 

먼저 비동기로직을 추가하지 않았을 때의 GC 로그 일부입니다. 일단 4~5ms 간격으로 GC 가 도는게 보이네요. 5ms 로 따져도 1초에 200번이나 GC 가 돌고있습니다.

그리고 힙 변화를 보면 첫 줄에 9 -> 10 -> 5 MB 는 GC 시작시에 9MB, 마킹 중 10MB, GC 가 끝나고나서 5MB 가 되었다 정도로 해석하면 되는데요. 할당은 많이 하는데 금방 죽는 객체가 많이 있기도하고, 목표치가 10~11MB 수준으로 굉장히 낮게 잡혀있어 GC 가 더 많이 돌고 있다고 보면 될 것 같습니다.

gctrace 옵션을 켜고 30초간 요청을 보냈을 때, GC 로그 라인이 5975 정도 쌓였습니다.

 

gc 375 @9.341s 1%: 0.006+1.3+0.011 ms clock, 0.012+0.16/0.72/0.16+0.023 ms cpu, 18->19->9 MB, 20 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 376 @9.347s 1%: 0.016+0.83+0.005 ms clock, 0.032+0.67/0.19/0+0.011 ms cpu, 18->35->27 MB, 20 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 377 @9.350s 1%: 0.005+2.0+0.004 ms clock, 0.011+0.055/0.78/0+0.008 ms cpu, 51->52->46 MB, 56 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 378 @9.385s 1%: 0.045+1.4+0.007 ms clock, 0.090+0.021/0.68/0.33+0.014 ms cpu, 78->80->43 MB, 92 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 379 @9.419s 1%: 0.017+1.6+0.007 ms clock, 0.035+0.037/0.80/0.23+0.014 ms cpu, 74->75->43 MB, 87 MB goal, 0 MB stacks, 0 MB globals, 2 P

 

반면 비동기로직을 추가한 후 GC 로그 일부는 약간 패턴이 달라져있습니다.

가장 눈에 띄는 힙 변화를 보면 GC 돌기 전, 돌고난 후 의 힙 사이즈가 커져있고, 그에 따라 로그에 찍혀있는 목표 MB 도 늘어나있는 것을 볼 수 있습니다. 확실히 GC 가 목표 하는 힙 사이즈가 달라지다 보니 그만큼 GC 가 도는 주기도 달라져있네요.

동일하게 30초간 요청을 보냈을 때, 이번에는 GC 로그 라인이 738 정도 쌓였습니다.

비동기로직에 의해 인메모리에 쌓이는 데이터가 생기다보니 그만큼 힙이 증가하게 되었고, 그에 따라 목표 힙 사이즈도 변경되면서 GC 주기가 늘어났고, 그만큼 CPU 가 여유로워졌다고 해석할 수 있을 것 같습니다.

 

- 런타임 파라미터 GOGC

 

GOGC 는 Go 런타임의 GC 가 얼마나 공격적으로 자주 돌지를 조절하는 환경변수로써, 이 수치를 조절함으로써 메모리를 더 쓰고 GC 빈도를 줄이거나, 혹은 그 반대로 동작하게 할 수 있습니다.

 

GO 는 매번 GC 가 끝날 때 살아남은 힙 사이즈를 보고 다음에 돌 GC 의 목표 힙 사이즈를 정합니다.

여기서 GOGC 는 뭔 역할이냐 라고하면.. 대략 수식은 "다음 목표 = 살아남은 힙 사이즈 X (1 + GOGC / 100)" 라고 할 수 있습니다.

GOGC 의 기본값은 100 이고, 위 수식대로면 목표 힙 사이즈는 살아남은 힙 사이즈의 대략 2배 정도로 세팅된다고 볼 수 있습니다.

따라서 위 GC 로그들을 보면 "9 -> 10 -> 5 MB, 11MB goal" / "78 -> 80 -> 43 MB, 87MB" 와 같이 약 2배 근처로 goal 이 세팅되고 있음을 알 수 있습니다. (중간중간 GC 이후와 goal 의 수치가 역전되어있는 로그들은 사이클 진행중에 런타임이 상태를 갱신하면서 찍힌 것이라고 봐도 될 것 같습니다)

 

이러면 이전까지의 테스트들에서 Go 서버가 메모리는 압도적으로 적게 쓰면서 CPU 는 오히려 더 많이 사용하고 있던 패턴들도 이해가 되네요. 따라서 전 Node.js 쪽은 특별히 튜닝할게 없으니 이 결과는 고정으로 두고, GOGC 설정을 500 으로 변경해 비동기 추가 전 로직 / 추가 후 로직에서의 Go CPU 사용량 패턴을 확인해봤습니다.

지금까지 메모리 사용량에 대한 부분은 거의 올리지 않았지만 메모리를 5~6 배 더 써도 충분히 여유가 있어 GOGC 를 500 으로 설정했습니다.

 

Go CPU

 

비동기로직을 넣지 않은 버전에서 Go CPU 사용량입니다. 이전 테스트에선 120~130 이던 수치가 100 정도로 내려온 것을 볼 수 있습니다. 확실히 GC 의 주기가 CPU 사용량에 상당히 큰 영향을 미치네요.

 

  Avg P70 P80 P95 P99
Node.js 2.59ms 1.79ms 2.01ms 5ms 18.63ms
Go (GOGC 100) 2.35ms 1.98ms 2.16ms 4.17ms 15.07ms
Go (GOGC 500) 1.96ms 1.89ms 2.03ms 2.92ms 7.84ms

 

k6 의 결과에서도 기존 테스트보다 확실히 더 나은 수치를 보여주네요.

 

Go CPU

 

이번엔 비동기로직을 넣은 버전에서 Go CPU 사용량입니다. 비동기 로직을 넣음으로써 약간은 CPU 가 상승한것을 볼 수 있네요.

 

  Avg P70 P80 P95 P99
Node.js 2.88ms 1.99ms 2.45ms 7.44ms 19.63ms
Go (GOGC 100) 2.17ms 2ms 2.14ms 3.41ms 11.4ms
Go (GOGC 500) 3.23ms 2.14ms 2.29ms 7.14ms 28.18ms

 

k6 결과상으로도 이 단계에선 GOGC 설정이 별다른 이득은 없음을 확인할 수 있습니다.

이 결과에 대한 해석으로 보자면, 비동기로직으로 쌓이는 데이터에 의해 GC 가 도는 주기는 충분히 낮았고 여기서 GOGC 를 500 으로 설정한 것은 GC 횟수를 더 줄일수는 있었지만 한번 GC 를 할 때 처리해야 할 힙이 더 커져서 비용이 더 발생했다고 볼 수 있을 것 같습니다.

위 그래프에도 보이는 것 처럼 오히려 GC 가 처리할 힙이 더 커짐으로써 마킹/스캔 비용으로 인해 CPU 가 중간중간 튀어 latency 에도 악영향을 준 것 같네요.

 

- 중간 결론

 

지금까지의 테스트를 바탕으로 나름의 중간 결론을 우선 정리하고 가고자 합니다.

 

1. 아직은 Node.js vs Go 간에 "명확한 우위" 를 단정할 수 없다

 

현재 구성한 워크로드(OpenRTB 요청/응답, 후보군 200개 조회와 필터링 및 후보군 선정, lz-string, zstd, 비동기 처리 등) 기준으로는, 동일한 RPS 에서 한쪽이 일관되게 더 낮은 CPU 로 처리한다고 보기 어려운 것 같습니다.

즉 결과만 놓고 보면 언어 변경만으로 운영 비용을 유의미하게 절감할 수 있다는 근거를 마련하기엔 아직 부족했습니다.

 

2. 비동기(백그라운드) 작업이 추가될 경우 Go 가 유리해질 여지가 있다

 

동일한 요청 처리 로직에 비동기 로직 (인메모리 버퍼링 -> zstd 압축 -> redis 적재) 을 추가했을 때, 추가 작업으로 인해 증가하는 CPU 사용량 양상이 Go 에서 상대적으로 더 안정적으로 보이는 지점이 있습니다.

 

물론 저는 비동기작업이 CPU 집약적인 작업 일 때를 좀 더 고려해서 적은 표현입니다. 언어 특성 관점에서 보면 런타임 처리 방식 차이가 성능에 영향을 줄 여지가 있습니다.

Node.js 는 setInterval 로 분리했고 Go 는 고루틴으로 분리했다고하더라도, CPU 집약적인 작업은 CPU time 을 사용할 수 밖에 없고, 여기서 Node.js 는 단일 이벤트 루프 기반이므로 메인 스레드에서 수행될 경우 블로킹되어 tail latency 가 악화될 수 있습니다.

반면 Go 의 경우 고루틴으로 분리했다면 런타임 스케줄러가 이를 여러 OS 스레드로 분산할 수 있기에 (GOMAXPROCS 범위 내에서) 구조적으로 조금 더 유연하다고 볼 수 있습니다.

 

하지만 고루틴이 만능은 아니고 비용이 사라지는 것은 아니기에, CPU 집약적인 작업이 존재할 때 메인 요청 처리 경로에 줄 수 있는 영향을 완화하거나 스케줄링으로 분산시키기 용이하다는 표현이었으며, Node.js 에서도 라이브러리 구현 방식에 따라 (native libuv threadpool 을 사용하는 방식 등의) 블로킹은 크게 줄어들 것이므로, Go 가 좀 더 유리해질 여지가 있다 정도로 적는게 적절할 것 같습니다.

운영해야 할 서버가 어느정도 비동기 작업을 수행하는지에 따라 달라질수도 있구요.

 

3. Go 는 튜닝 포인트가 더 많고, 그 자체가 강점이면서 리스크가 될 수 있다

 

이번 테스트에서 확인했던 것 처럼 Go 는 GOGC 와 같은 런타임 파라미터로 GC 빈도와 CPU <-> 메모리 간의 트레이드오프를 비교적 직접적으로 조절할 수 있습니다. 이는 분명 장점이지만, 반대로 말하면 기본 설정 그대로 운용할 경우 워크로드에 따라 불필요하게 잦은 GC 가 발생해 성능이 떨어질 수 있음도 확인했습니다. 저도 테스트니까 여러 케이스를 비교해가면서 왜 CPU 사용량이 이렇지? 라는 생각을 가졌지만, 이런 목적없이 그냥 Go 로 로직을 작성해서 서버를 띄웠다면 알기 어려운 포인트였을 것 같습니다.

 

따라서 실제 라이브에서 Go 를 운영한다면, 언어 자체의 성능보다도 Heap/GC/latency 를 지속적으로 관측하면서 환경에 맞는 튜닝 포인트를 찾아가는 과정이 좀 더 필요할 것 같습니다.

 

4. 언어 차이보다 "워크로드 특성 + 라이브러리 구현" 이 성능을 크게 좌우하는 것 같다

 

이번 워크로드는 JSON 파싱, 문자열/압축 처리, 응답 구성 등 라이브러리 구현과 내부 최적화의 영향을 크게 받는 작업들이 포함되어 있습니다. Node.js 는 V8 및 네이티브로 최적화된 경로가 존재하고, Go 또한 표준/서드파티 라이브러리 구현에 따라 성능이 달라질 수 있습니다.

따라서 Go 라서 무조건 더 유리하다/불리하다 라기 보다는, 현재 워크로드에서 CPU 를 많이 쓰는 병목이 무엇이고 그 병목이 어떤 구현체로 처리되는지가 더 중요할 것 같았습니다. 어찌보면 당연한 소리 같다는걸 적으면서 느꼈네요.

 

이번 테스트에서의 중간 결론은 이정도로 정리하고, 아마도 마지막이 될 다음 단계에서는 부하를 점진적으로 높여 CPU 를 극한까지 밀어붙이면서 각 서버 (Node.js/Go) 의 최대 처리량과 한계 구간의 특성을 확인해 볼 예정입니다.

'Backend > Architecture' 카테고리의 다른 글

DSP 서버로 간단히 비교해 본 Node.js 와 Go (1)  (0) 2026.01.28