고처리량 IoT 로그 집계기
Source: Dev.to

수천 개의 센서가 매초 텔레메트리 패킷을 전송하는 산업 모니터링 시스템을 상상해 보세요. 시스템은 다음을 수행해야 합니다:
- 원시 데이터 패킷 배치를 수집한다.
- 비활성 센서를 필터링한다.
- 디바이스 ID별로 온도 값을 집계한다.
- 대시보드를 위한 텍스트 요약 로그를 생성한다.
문제는 이 프로세스가 지속적으로 실행된다는 점이며, 작은 비효율(예: 불필요한 메모리 할당)도 가비지 컬렉션 일시 중지와 데이터 손실을 초래할 수 있습니다. 해결책은 Go에서 메모리 효율적인 패턴을 사용하는 것입니다.
시스템 흐름도
flowchart LR
A[Raw Data Ingestion] -->|Slice Pre‑allocation| B[Batch Processing]
B -->|Value Semantics| C{Filter Inactive}
C -->|Map Pre‑allocation| D[Aggregation]
D -->|strings.Builder| E[Log Generation]
E --> F[Final Report]
Source: …
최적화 기법
구조체 정렬
type SensorPacket struct {
Timestamp int64 // 8 bytes
Value float64 // 8 bytes
DeviceID int32 // 4 bytes
Active bool // 1 byte
// 3 bytes padding added automatically
}
패킷당 8바이트를 절약하면 백만 레코드당 약 8 MB의 RAM을 절감할 수 있습니다.
슬라이스 사전 할당
packets := make([]SensorPacket, 0, n) // n = expected batch size
사전 할당 없이 100 000개의 항목을 로드하면 약 18번의 리사이즈와 많은 메모리 복사가 발생합니다.
맵 크기 힌트
agg := make(map[int32]float64, 100) // anticipate ~100 devices
버킷을 미리 할당하면 맵이 성장할 때 발생하는 비용이 큰 재해싱을 방지할 수 있습니다.
strings.Builder
var sb strings.Builder
sb.WriteString("Device ")
sb.WriteString(strconv.Itoa(id))
sb.WriteString(": Avg Temp ")
sb.WriteString(fmt.Sprintf("%.2f", avg))
빌더를 사용하면 수백 개의 임시 문자열 생성을 피할 수 있습니다.
값 vs 포인터
func processBatch(cfg Config, data []SensorPacket) *Report {
// cfg passed by value (fast stack access)
// Report returned as a pointer to avoid copying the large map
}
메모리 레이아웃 비교
최적화된 (현재 코드)
[ Timestamp (8) ] [ Value (8) ] [ DeviceID (4) | Active (1) | Pad (3) ]
Total: 24 Bytes / Block
비최적화된 (혼합)
[ Active (1) | Pad (7) ] [ Timestamp (8) ] [ DeviceID (4) | Pad (4) ] [ Value (8) ]
Total: 32 Bytes / Block (33% wasted memory!)
예시 결과
--- Processing Complete in 6.5627ms ---
--- BATCH REPORT ---
Batch ID: 1766689634
Device 79: Avg Temp 44.52
Device 46: Avg Temp 46.42
Device 57: Avg Temp 45.37
Device 11: Avg Temp 44.54
Device 15: Avg Temp 46.43
... (truncated)
벤치마크 결과
| 연산 | 구현 | 시간 (ns/op) | 메모리 (B/op) | 할당 (op) | 성능 향상 |
|---|---|---|---|---|---|
| Slice Append | 비효율적 | 66,035 | 357,626 | 19 | – |
| 효율적 (Pre‑alloc) | 15,873 | 81,920 | 1 | 약 4.1× 빠름 | |
| String Build | 비효율적 (+) | 8,727 | 21,080 | 99 | – |
| 효율적 (Builder) | 244.7 | 416 | 1 | 약 35.6× 빠름 | |
| Map Insert | 비효율적 | 571,279 | 591,485 | 79 | – |
| 효율적 (Size hint) | 206,910 | 295,554 | 33 | 약 2.7× 빠름 | |
| Struct Pass | 값 전달 (복사) | 0.26 | 0 | 0 | – |
| 포인터 전달 (참조) | 0.25 | 0 | 0 | 비슷함 |
구조체에 대한 참고: 마이크로 벤치마크에서는 Go 컴파일러가 호출을 인라인화하기 때문에 값 전달과 포인터 전달 사이의 차이가 거의 없습니다. 실제 코드에서 호출 스택이 깊어질 경우, 큰 구조체를 포인터로 전달하면 CPU 사용량을 눈에 띄게 줄일 수 있습니다.
주요 내용
- 문자열 연결: 루프에서
+사용을 피하세요.strings.Builder는 중간에 생성되는 가비지 문자열을 없애서 35배 이상 빠르고 메모리를 98 % 절감합니다. - 메모리 사전 할당: 슬라이스와 맵에 미리 용량을 지정하면 반복적인 크기 조정 및 재해시 비용을 없앨 수 있습니다.
- 할당 횟수 중요: 할당(
allocs/op)이 적을수록 가비지 컬렉터의 작업량이 줄어들어 애플리케이션이 더 안정적이고 반응성이 높아집니다. - 슬라이스: 할당 수가 19 → 1로 감소했습니다.
- 맵: 할당 수가 79 → 33으로 감소했습니다.
이러한 패턴들을 종합하면 IoT 로그 집계기가 높은 처리량 상황에서도 성능을 유지할 수 있습니다.